From 8b31625e90ab952f1b4cde6ac3e5f02f781577c3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 17:00:13 -0700 Subject: [PATCH 001/304] build: keep Winsock eagerly imported on Windows The MSVC target rustflags delay-load ws2_32.dll together with the optional platform libraries. Winsock is reached both through raw-dylib imports and through its native import library, and delay-loading a DLL that is imported both ways can make the linker emit invalid delay-import thunks for it. Winsock ships with every supported Windows host, so delaying its load buys nothing. Remove ws2_32.dll from the /delayload list and record why next to the flags, keeping virtdisk, user32, winhvplatform, bcrypt, and crypt32 delay-loaded as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .cargo/config.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.cargo/config.toml b/.cargo/config.toml index e6c7c6447f0..0711f9868de 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -90,8 +90,9 @@ rustc-link-search = [] [target.'cfg(all(windows, target_env = "msvc"))'] rustflags = [ - # Set up delayloads. - "-C", "link-args=dloadhelper.lib /delayload:ws2_32.dll /delayload:virtdisk.dll /delayload:user32.dll /delayload:winhvplatform.dll /delayload:bcrypt.dll /delayload:crypt32.dll", + # Keep Winsock eager: mixed raw-dylib and native import libraries can produce + # invalid delay-import thunks. Optional platform libraries remain delay-loaded. + "-C", "link-args=dloadhelper.lib /delayload:virtdisk.dll /delayload:user32.dll /delayload:winhvplatform.dll /delayload:bcrypt.dll /delayload:crypt32.dll", # Set up the CRT: use ucrtbase.dll but statically link the VC runtime. "-C", "link-args=/nodefaultlib onecore_apiset.lib ucrt.lib libvcruntime.lib libcmt.lib", # Enable CFG From 607e6c6a2ce712c61e63ce8305fb26fcdb3a1167 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 17:02:50 -0700 Subject: [PATCH 002/304] nextest: scope a longer budget to the exhaustive ring-buffer loom test atomic_ringbuf's loom_multi_writer_wraparound test exhaustively models two writers racing a reader across the wraparound point. On loaded bare-metal Windows hosts it can run past the 60-second termination threshold of the generic loom override (30-second slow period, terminated after two periods) and fail spuriously. Add CI and agent profile overrides that match only this test by package and exact name and give it a 60-second slow period, still terminated after two periods. nextest applies the first matching override, so the new entries precede the generic loom override, and every other loom and workspace test keeps its current budget. The agent profile had no loom override, so exhaustive loom tests hit its generic 30-second kill even though they pass under CI. Give it the same generic loom override as CI. Also terminate the file with a newline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .config/nextest.toml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 5c2e0ec8398..1877fc4a433 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -57,11 +57,26 @@ threads-required = 2 # Use the same slow timeout as ci, but never terminate slow-timeout = { period = "10m" } +[[profile.ci.overrides]] +# This test exhaustively models two writers racing a reader across wraparound +# and takes over a minute on Windows. Keep the larger budget scoped to it. +filter = 'package(atomic_ringbuf) and test(/^loom_tests::loom_multi_writer_wraparound$/)' +slow-timeout = { period = "60s", terminate-after = 2 } + +[[profile.agent.overrides]] +filter = 'package(atomic_ringbuf) and test(/^loom_tests::loom_multi_writer_wraparound$/)' +slow-timeout = { period = "60s", terminate-after = 2 } + [[profile.ci.overrides]] # allow loom based tests more time, as they take a while filter = 'test(loom)' slow-timeout = { period = "30s", terminate-after = 2 } +[[profile.agent.overrides]] +# Keep the same exhaustive loom test budget as CI. +filter = 'test(loom)' +slow-timeout = { period = "30s", terminate-after = 2 } + # Profile for AI agent runs. Minimizes output noise: only prints slow, failing, # and flaky tests. Agents should use: cargo nextest run -p --profile agent [profile.agent] @@ -88,4 +103,4 @@ fail-fast = false [profile.ci.junit] path = "junit.xml" -store-success-output = "true" \ No newline at end of file +store-success-output = "true" From 53594392ceaaf5464e898aaf8e27982c83328817 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 17:07:25 -0700 Subject: [PATCH 003/304] flowey: distinguish native Windows and WSL nextest paths When generating a portable nextest invocation, make_portable_path rewrote paths relative to the Windows form of the working directory whenever the test target was Windows. That Windows working directory is only computed when flowey runs inside WSL2 and drives a Windows test binary, so on a native Windows host the conversion unwrapped a missing value and panicked. Key the conversion on windows_via_wsl2, the same condition that computes the Windows working directory. Native Windows runs now take the regular strip-prefix path, like every other native host. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- flowey/flowey_lib_common/src/gen_cargo_nextest_run_cmd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flowey/flowey_lib_common/src/gen_cargo_nextest_run_cmd.rs b/flowey/flowey_lib_common/src/gen_cargo_nextest_run_cmd.rs index 87ddcb6a38c..28663674991 100644 --- a/flowey/flowey_lib_common/src/gen_cargo_nextest_run_cmd.rs +++ b/flowey/flowey_lib_common/src/gen_cargo_nextest_run_cmd.rs @@ -239,7 +239,7 @@ impl FlowNode for Node { // Make a converted path relative/portable if requested. let make_portable_path = |path: PathBuf| -> anyhow::Result { let path = if portable { - if windows_target { + if windows_via_wsl2 { let working_dir_trimmed = working_dir_win.as_ref().unwrap().trim_end_matches('\\'); let path_win = path.display().to_string(); From ff17461103d956089d4a3c0e235540c558375c9d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 17:12:38 -0700 Subject: [PATCH 004/304] flowey: skip reinstalling an identical, possibly running cargo-nextest binary install_cargo_nextest unconditionally copies the downloaded cargo-nextest binary into the cargo home bin directory. On Windows an executable cannot be overwritten while it is running, so a second local run (or a run started from a shell that is already using cargo-nextest) fails even though the installed binary is byte-for-byte the one being installed. Compare the installed binary with the download first. When the lengths and contents match, leave the file in place and only restore the source permissions if they differ, which also repairs a lost executable bit on Unix. Otherwise copy as before, so a genuinely changed binary still fails loudly when it is in use. Add unit tests for missing and changed binaries, a missing source, an identical binary held open with read-only sharing on Windows, and permission recovery on Unix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + flowey/flowey_lib_common/Cargo.toml | 3 + .../src/install_cargo_nextest.rs | 132 +++++++++++++++++- 3 files changed, 131 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a0255eb2fe..4b48ed0fdd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2362,6 +2362,7 @@ dependencies = [ "sysinfo", "target-lexicon", "tempfile", + "test_with_tracing", "toml_edit", "which 8.0.0", "xshell", diff --git a/flowey/flowey_lib_common/Cargo.toml b/flowey/flowey_lib_common/Cargo.toml index 36619527901..97c8d8c1be9 100644 --- a/flowey/flowey_lib_common/Cargo.toml +++ b/flowey/flowey_lib_common/Cargo.toml @@ -25,5 +25,8 @@ xshell.workspace = true [target.'cfg(unix)'.dependencies] rlimit.workspace = true +[dev-dependencies] +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/flowey/flowey_lib_common/src/install_cargo_nextest.rs b/flowey/flowey_lib_common/src/install_cargo_nextest.rs index b8fef4b067a..6855ef8f32f 100644 --- a/flowey/flowey_lib_common/src/install_cargo_nextest.rs +++ b/flowey/flowey_lib_common/src/install_cargo_nextest.rs @@ -4,6 +4,8 @@ //! Install `cargo-nextest`. use flowey::node::prelude::*; +use std::io; +use std::io::Read; flowey_request! { pub struct Request(pub WriteVar); @@ -52,12 +54,11 @@ impl FlowNode for Node { let nextest_path = rt.read(nextest_path); let cargo_home = rt.read(cargo_home); - // copy to cargo home bin folder so that nextest - // is accessible via `cargo nextest`` - fs_err::copy( + install_nextest( &nextest_path, - cargo_home.join("bin").join(&cargo_nextest_bin), - )?; + &cargo_home.join("bin").join(&cargo_nextest_bin), + ) + .context("failed to install cargo-nextest")?; Ok(()) } @@ -66,3 +67,124 @@ impl FlowNode for Node { Ok(()) } } + +fn install_nextest(source: &Path, destination: &Path) -> io::Result<()> { + if files_match(source, destination)? { + let permissions = fs_err::metadata(source)?.permissions(); + if fs_err::metadata(destination)?.permissions() != permissions { + fs_err::set_permissions(destination, permissions)?; + } + } else { + fs_err::copy(source, destination)?; + } + Ok(()) +} + +fn files_match(source: &Path, destination: &Path) -> io::Result { + let mut source = fs_err::File::open(source)?; + let mut destination = match fs_err::File::open(destination) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(false), + Err(error) => return Err(error), + }; + let mut remaining = source.metadata()?.len(); + if destination.metadata()?.len() != remaining { + return Ok(false); + } + + // Do not overwrite an identical executable: it may be running on Windows. + let mut source_bytes = [0; 16 * 1024]; + let mut destination_bytes = [0; 16 * 1024]; + while remaining != 0 { + let len = remaining.min(source_bytes.len() as u64) as usize; + source.read_exact(&mut source_bytes[..len])?; + destination.read_exact(&mut destination_bytes[..len])?; + if source_bytes[..len] != destination_bytes[..len] { + return Ok(false); + } + remaining -= len as u64; + } + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn installs_missing_and_changed_binaries() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("download"); + let destination = dir.path().join("installed"); + let mut contents = vec![0x5a; 32 * 1024 + 1]; + fs_err::write(&source, &contents).unwrap(); + install_nextest(&source, &destination).unwrap(); + assert_eq!(fs_err::read(&destination).unwrap(), contents); + + contents[32 * 1024] = 0xa5; + fs_err::write(&source, &contents).unwrap(); + install_nextest(&source, &destination).unwrap(); + assert_eq!(fs_err::read(&destination).unwrap(), contents); + + fs_err::write(&source, b"shorter").unwrap(); + install_nextest(&source, &destination).unwrap(); + assert_eq!(fs_err::read(&destination).unwrap(), b"shorter"); + } + + #[test] + fn missing_source_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let destination = dir.path().join("installed"); + fs_err::write(&destination, b"installed").unwrap(); + assert_eq!( + install_nextest(&dir.path().join("missing"), &destination) + .unwrap_err() + .kind(), + io::ErrorKind::NotFound + ); + assert_eq!(fs_err::read(destination).unwrap(), b"installed"); + } + + #[cfg(windows)] + #[test] + fn identical_binary_is_not_overwritten_while_in_use() { + use fs_err::os::windows::fs::OpenOptionsExt; + + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("download.exe"); + let destination = dir.path().join("installed.exe"); + fs_err::write(&source, b"nextest").unwrap(); + install_nextest(&source, &destination).unwrap(); + + let _in_use = fs_err::OpenOptions::new() + .read(true) + .share_mode(1) // FILE_SHARE_READ: deny overwrites as a running image does. + .open(&destination) + .unwrap(); + install_nextest(&source, &destination).unwrap(); + + fs_err::write(&source, b"updated").unwrap(); + assert!(install_nextest(&source, &destination).is_err()); + assert_eq!(fs_err::read(&destination).unwrap(), b"nextest"); + } + + #[cfg(unix)] + #[test] + fn identical_binary_recovers_executable_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("download"); + let destination = dir.path().join("installed"); + fs_err::write(&source, b"nextest").unwrap(); + fs_err::set_permissions(&source, std::fs::Permissions::from_mode(0o755)).unwrap(); + install_nextest(&source, &destination).unwrap(); + fs_err::set_permissions(&destination, std::fs::Permissions::from_mode(0o644)).unwrap(); + install_nextest(&source, &destination).unwrap(); + assert_eq!( + fs_err::metadata(destination).unwrap().permissions().mode() & 0o777, + 0o755 + ); + } +} From fe6bd7773b3d728439a7a597a2fdc1e8d5d495bc Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 17:19:38 -0700 Subject: [PATCH 005/304] flowey: keep caller temp directories for native local VMM-test runs init_vmm_tests_env always recreated a temporary directory under the test content directory and pointed TEMP/TMP/SystemTemp (Windows) or TMPDIR (Unix) at it. For native local runs that directory sits below the checkout, and a long checkout path pushes the temporary Unix socket paths that tests create past the platform's small socket-name limit. Only redirect temporary directories where it is needed: CI runs keep using the test-content disk for scratch space, and Windows-from-WSL2 runs still need Windows-visible paths. Native local runs now inherit the caller's temporary-directory settings. The choice lives in a small helper with unit tests, and the VMM test guide documents the behavior next to --dir. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + Guide/src/dev_guide/tests/vmm.md | 7 ++ flowey/flowey_lib_hvlite/Cargo.toml | 1 + .../src/init_vmm_tests_env.rs | 68 ++++++++++++++++--- 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4b48ed0fdd6..9f08414dda1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2389,6 +2389,7 @@ dependencies = [ "sha2", "target-lexicon", "tempfile", + "test_with_tracing", "toml_edit", "which 8.0.0", ] diff --git a/Guide/src/dev_guide/tests/vmm.md b/Guide/src/dev_guide/tests/vmm.md index c05961022cf..6377213409f 100644 --- a/Guide/src/dev_guide/tests/vmm.md +++ b/Guide/src/dev_guide/tests/vmm.md @@ -134,6 +134,13 @@ To run a **specific test** (or set of tests), use `--filter` with a cargo xflowey vmm-tests-run --filter "test(my_test_name)" --dir /tmp/vmm-tests-run ``` +`--dir` selects the artifact and log directory. Native local runs preserve +your existing temporary-directory settings instead of placing temporary +sockets under a potentially long checkout path. Keep any custom temporary +directory short enough for Unix socket names. CI and Windows-from-WSL2 runs +still place temporary files on the test-content disk, preserving scratch-disk +placement and Windows filesystem access. + ### Targeting a Platform By default, `vmm-tests-run` builds for the current host. Use `--target` to diff --git a/flowey/flowey_lib_hvlite/Cargo.toml b/flowey/flowey_lib_hvlite/Cargo.toml index c915de38204..2127d8c6a94 100644 --- a/flowey/flowey_lib_hvlite/Cargo.toml +++ b/flowey/flowey_lib_hvlite/Cargo.toml @@ -30,6 +30,7 @@ which.workspace = true [dev-dependencies] tempfile.workspace = true +test_with_tracing.workspace = true [target.'cfg(windows)'.dependencies] crossterm = { workspace = true, features = ["windows"] } diff --git a/flowey/flowey_lib_hvlite/src/init_vmm_tests_env.rs b/flowey/flowey_lib_hvlite/src/init_vmm_tests_env.rs index cd640b9e1c7..f94f645148e 100644 --- a/flowey/flowey_lib_hvlite/src/init_vmm_tests_env.rs +++ b/flowey/flowey_lib_hvlite/src/init_vmm_tests_env.rs @@ -124,17 +124,16 @@ impl SimpleFlowNode for Node { fs_err::create_dir(&test_log_dir)?; env.insert("TEST_OUTPUT_PATH".into(), converted_log_dir); - if temp_dir.exists() { - fs_err::remove_dir_all(&temp_dir)?; - }; - fs_err::create_dir(&temp_dir)?; - - if matches!(rt.platform().kind(), FlowPlatformKind::Windows) || windows_via_wsl2 { - env.insert("TEMP".into(), converted_temp_dir.clone()); - env.insert("TMP".into(), converted_temp_dir.clone()); - env.insert("SystemTemp".into(), converted_temp_dir); - } else { - env.insert("TMPDIR".into(), converted_temp_dir); + let temp_env_vars = + temp_env_vars(rt.backend(), rt.platform().kind(), windows_via_wsl2); + if !temp_env_vars.is_empty() { + if temp_dir.exists() { + fs_err::remove_dir_all(&temp_dir)?; + }; + fs_err::create_dir(&temp_dir)?; + for name in temp_env_vars { + env.insert((*name).into(), converted_temp_dir.clone()); + } } if let Some(disk_image_dir) = converted_disk_image_dir { @@ -170,3 +169,50 @@ impl SimpleFlowNode for Node { Ok(()) } } + +fn temp_env_vars( + backend: FlowBackend, + platform: FlowPlatformKind, + windows_via_wsl2: bool, +) -> &'static [&'static str] { + // Native local runs must not inherit the checkout's potentially long path: + // temporary Unix socket names have a much smaller limit than file paths. + // CI still needs its scratch disk, and Windows-via-WSL needs Windows paths. + if matches!(backend, FlowBackend::Local) && !windows_via_wsl2 { + &[] + } else if matches!(platform, FlowPlatformKind::Windows) || windows_via_wsl2 { + &["TEMP", "TMP", "SystemTemp"] + } else { + &["TMPDIR"] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn native_local_runs_preserve_caller_temp_directories() { + for platform in [FlowPlatformKind::Windows, FlowPlatformKind::Unix] { + assert!(temp_env_vars(FlowBackend::Local, platform, false).is_empty()); + } + } + + #[test] + fn ci_and_cross_windows_runs_keep_content_disk_temp_directories() { + let windows = &["TEMP", "TMP", "SystemTemp"]; + assert_eq!( + temp_env_vars(FlowBackend::Ado, FlowPlatformKind::Windows, false), + windows + ); + assert_eq!( + temp_env_vars(FlowBackend::Local, FlowPlatformKind::Unix, true), + windows + ); + assert_eq!( + temp_env_vars(FlowBackend::Ado, FlowPlatformKind::Unix, false), + &["TMPDIR"] + ); + } +} From fea2e8992a6d5eba6cb193f4bf99130a9aa2dbe8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 20:53:36 -0700 Subject: [PATCH 006/304] lxutil: tolerate inherited extended attributes in tests The xattr unit test assumed that a newly created file carries no extended attributes. On hosts that label files, for example SELinux systems (Azure Linux included, even in permissive mode), every new file already carries security.selinux, so the initial list size is nonzero and the test fails before exercising any lxutil code. Record whatever attributes the new file inherits, then check the added, replaced, and removed user attributes relative to that baseline. The list size and content checks stay exact, names are compared order-independently, and the read buffers grow to fit the inherited attributes. Host security settings are not modified. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/support/fs/lxutil/src/lib.rs | 32 ++++++++++++++++++------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/vm/devices/support/fs/lxutil/src/lib.rs b/vm/devices/support/fs/lxutil/src/lib.rs index 0b9f6805400..0244a9f394f 100644 --- a/vm/devices/support/fs/lxutil/src/lib.rs +++ b/vm/devices/support/fs/lxutil/src/lib.rs @@ -2017,10 +2017,16 @@ mod tests { #[test] fn xattr() { + fn names(bytes: &[u8]) -> Vec<&[u8]> { + let mut names: Vec<_> = bytes.split_inclusive(|&byte| byte == 0).collect(); + names.sort_unstable(); + names + } + let env = TestEnv::new(); env.create_file("testfile", "test"); - // No attributes to start with. + // A new file may already carry host-managed attributes such as security.selinux. let err = env .volume .get_xattr("testfile", "user.test", None) @@ -2029,7 +2035,13 @@ mod tests { assert_eq!(err.value(), lx::ENODATA); let size = env.volume.list_xattr("testfile", None).unwrap(); - assert_eq!(size, 0); + let mut inherited = vec![0u8; size]; + assert_eq!( + env.volume + .list_xattr("testfile", Some(&mut inherited)) + .unwrap(), + size + ); let err = env .volume @@ -2046,7 +2058,7 @@ mod tests { let size = env.volume.get_xattr("testfile", "user.test", None).unwrap(); assert_eq!(size, 3); - let mut buffer = [0u8; 1024]; + let mut buffer = vec![0u8; inherited.len() + 1024]; let size = env .volume .get_xattr("testfile", "user.test", Some(&mut buffer)) @@ -2068,15 +2080,17 @@ mod tests { assert_eq!(size, 0); // List the attributes. + let mut expected = inherited.clone(); + expected.extend_from_slice(b"user.test\0user.empty\0"); let size = env.volume.list_xattr("testfile", None).unwrap(); - assert_eq!(size, 21); + assert_eq!(size, expected.len()); let size = env .volume .list_xattr("testfile", Some(&mut buffer)) .unwrap(); - assert_eq!(size, 21); - assert_eq!(&buffer[..21], b"user.test\0user.empty\0"); + assert_eq!(size, expected.len()); + assert_eq!(names(&buffer[..size]), names(&expected)); // Remove an attribute. env.volume.remove_xattr("testfile", "user.empty").unwrap(); @@ -2092,8 +2106,10 @@ mod tests { .list_xattr("testfile", Some(&mut buffer)) .unwrap(); - assert_eq!(size, 10); - assert_eq!(&buffer[..10], b"user.test\0"); + let mut expected = inherited; + expected.extend_from_slice(b"user.test\0"); + assert_eq!(size, expected.len()); + assert_eq!(names(&buffer[..size]), names(&expected)); // Test flags. let err = env From b74d4e6c90e22174333b98871f7f3440cb116e0a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:27:57 -0700 Subject: [PATCH 007/304] consomme: make TCP window-reopen validation deterministic test_tcp_zero_window_reopen_sends_update floods a real host socket until consomme's receive window closes, then drains the socket and waits for the window update. Whether and when that happens depends on the host's socket buffer sizes, autotuning, and persist timers, so the test fails intermittently on loaded hosts. Let TCP connections write to, and shut down, the host side through AsyncWrite instead of the concrete socket type. PolledSocket keeps its behavior: poll_close shuts down the write half, a close that is still pending is retried on the next poll, and a failed shutdown still resets the connection. With that seam, rewrite the test to drive an established connection against an in-memory writer whose write allowance the test controls. For window scales 0, 2, and 7 it checks that: - a full buffer advertises a zero window; - draining nothing, or one byte less than the MSS rounded up to the window-scale quantum, sends no update; - the next byte reopens the window with a single pure ACK that advertises the reopened size; - draining further does not send another update. The helpers that only served the old flood-and-drain test are removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../net/net_consomme/consomme/src/tcp.rs | 28 +- .../net_consomme/consomme/src/tcp/tests.rs | 240 +++++++----------- 2 files changed, 112 insertions(+), 156 deletions(-) diff --git a/vm/devices/net/net_consomme/consomme/src/tcp.rs b/vm/devices/net/net_consomme/consomme/src/tcp.rs index 2d02c107d69..41cc72718e9 100644 --- a/vm/devices/net/net_consomme/consomme/src/tcp.rs +++ b/vm/devices/net/net_consomme/consomme/src/tcp.rs @@ -56,7 +56,6 @@ use std::io::ErrorKind; use std::io::IoSlice; use std::io::IoSliceMut; use std::net::IpAddr; -use std::net::Shutdown; use std::net::SocketAddr; use std::net::SocketAddrV4; use std::net::SocketAddrV6; @@ -1770,7 +1769,7 @@ impl TcpConnectionInner { &mut self, cx: &mut Context<'_>, sender: &mut Sender<'_, impl Client>, - socket: &mut PolledSocket, + socket: &mut (impl AsyncWrite + Unpin), static_dns: &mut Option, dns: &DnsResolver, ) -> bool { @@ -1866,19 +1865,22 @@ impl TcpConnectionInner { .is_none_or(StaticDnsTcpInspection::is_empty); if self.rx_buffer.is_empty() && static_dns_empty && self.state.rx_fin() && !self.is_shutdown { - if let Err(err) = socket.get().shutdown(Shutdown::Write) { - tracelimit::warn_ratelimited!( - error = &err as &dyn std::error::Error, - src = %sender.ft.src, - dst = %sender.ft.dst, - "shutdown error" - ); - if sender.try_rst(self.tx_send, Some(self.rx_seq)) { - self.stats.rsts_tx.increment(); + match Pin::new(socket).poll_close(cx) { + Poll::Ready(Ok(())) => self.is_shutdown = true, + Poll::Pending => {} + Poll::Ready(Err(err)) => { + tracelimit::warn_ratelimited!( + error = &err as &dyn std::error::Error, + src = %sender.ft.src, + dst = %sender.ft.dst, + "shutdown error" + ); + if sender.try_rst(self.tx_send, Some(self.rx_seq)) { + self.stats.rsts_tx.increment(); + } + return false; } - return false; } - self.is_shutdown = true; } true diff --git a/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs b/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs index 0e93827f4d8..bc83659fa81 100644 --- a/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs +++ b/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs @@ -21,10 +21,10 @@ use smoltcp::wire::DnsQueryType; use smoltcp::wire::EthernetAddress; use smoltcp::wire::Ipv4Address; use smoltcp::wire::Ipv4Repr; -use socket2::SockRef; use std::io::ErrorKind; use std::net::Ipv4Addr; use std::net::Ipv6Addr; +use std::net::Shutdown; use std::net::SocketAddrV4; use std::net::SocketAddrV6; use std::sync::Arc; @@ -791,115 +791,6 @@ impl TcpTestHarness { }) .await; } - - /// Flood guest→host data (without reading the host socket) until the - /// available receive buffer (`rx_window_avail`) reaches zero, which drives - /// the advertised window to zero. Only sends segments that fit the current - /// window, so nothing is dropped. Returns bytes accepted. - async fn flood_guest_until_window_closed(&mut self) -> usize { - let guest_mac = self.guest_mac; - let gateway_mac = self.gateway_mac; - let guest_ip = self.guest_ip; - let dst_ip = self.dst_ip; - let guest_port = self.guest_port; - let dst_port = self.dst_port; - let server_ack = self.server_ack; - let ft = self.four_tuple(); - - let consomme = &mut self.consomme; - let client = &mut self.client; - // Deliberately do NOT read `host_stream`: the path backs up so the - // receive window fills. - let guest_seq = &mut self.guest_seq; - let mut buf = vec![0u8; 1514]; - let payload = [0x5Au8; 1400]; - let mut total = 0usize; - // Safety cap so a regression can't loop forever. - let mut budget = 64 << 20; - std::future::poll_fn(move |cx| { - consomme.access(client).poll(cx); - let avail = consomme - .tcp - .connections - .get(&ft) - .expect("connection should exist") - .inner - .rx_window_avail(); - if avail == 0 || budget == 0 { - return Poll::Ready(total); - } - let n = avail.min(payload.len()).min(budget); - let tcp = TcpRepr { - src_port: guest_port, - dst_port, - control: TcpControl::None, - seq_number: *guest_seq, - ack_number: Some(server_ack), - window_len: 64240, - window_scale: None, - max_seg_size: None, - sack_permitted: false, - sack_ranges: [None, None, None], - timestamp: None, - payload: &payload[..n], - }; - let len = build_tcp_packet(&mut buf, guest_mac, gateway_mac, guest_ip, dst_ip, &tcp); - consomme - .access(client) - .send(&buf[..len], &ChecksumState::NONE) - .unwrap(); - *guest_seq += n; - total += n; - budget -= n; - // Re-poll promptly to keep pushing; once the host socket buffers - // fill, consomme stops draining and `avail` reaches zero. - cx.waker().wake_by_ref(); - Poll::Pending - }) - .await - } - - /// Drain the host socket while polling consomme, waiting for a guest-bound - /// ACK that advertises a non-zero receive window. Returns the advertised - /// window length, or `None` if no such packet appeared within a few - /// seconds. - async fn drain_host_until_window_update(&mut self) -> Option { - let mut timer = pal_async::timer::PolledTimer::new(self.client.driver()); - let received = self.client.received_packets.clone(); - let consomme = &mut self.consomme; - let client = &mut self.client; - let host_stream = &mut self.host_stream; - let poll = std::future::poll_fn(move |cx| { - consomme.access(client).poll(cx); - // Drain the host socket so the receive window reopens. Registers a - // read-readiness waker on Pending so we are re-polled as more data - // arrives. - let mut rb = [0u8; 4096]; - loop { - match Pin::new(&mut *host_stream).poll_read(cx, &mut rb) { - Poll::Ready(Ok(0)) => break, - Poll::Ready(Ok(_)) => {} - Poll::Ready(Err(e)) => panic!("host read error: {e}"), - Poll::Pending => break, - } - } - let found = received.lock().iter().rev().find_map(|p| { - let t = TcpTestHarness::is_tcp_packet(p)?; - (t.ack_number.is_some() && t.window_len > 0).then_some(t.window_len) - }); - match found { - Some(w) => Poll::Ready(w), - None => Poll::Pending, - } - }); - let timeout = timer.sleep(std::time::Duration::from_secs(5)); - let poll = std::pin::pin!(poll); - let timeout = std::pin::pin!(timeout); - match futures::future::select(poll, timeout).await { - futures::future::Either::Left((w, _)) => Some(w), - futures::future::Either::Right(_) => None, - } - } } // ── Tests ────────────────────────────────────────────────────────── @@ -2503,42 +2394,105 @@ async fn test_tcp_tx_buffer_autotune_caps_at_max(driver: DefaultDriver) { /// ACK rather than waiting for the guest's zero-window probe. #[pal_async::async_test] async fn test_tcp_zero_window_reopen_sends_update(driver: DefaultDriver) { - let mut params = ConsommeParams::new().unwrap(); - // Small, fixed receive window so it closes quickly and autotune can't grow - // it (which would mask the reopen path via its own `needs_ack`). - params.tcp_rx_buffer = crate::TcpBufferBounds { - initial: 16 << 10, - max: 16 << 10, - }; - let mut h = TcpTestHarness::connect_with_params(driver, params).await; + struct HostWriter { + allowance: usize, + received: Vec, + } + + impl AsyncWrite for HostWriter { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if self.allowance == 0 { + return Poll::Pending; + } + let n = self.allowance.min(buf.len()); + self.received.extend_from_slice(&buf[..n]); + self.allowance -= n; + Poll::Ready(Ok(n)) + } - // Shrink the host receive buffer so the egress path backs up after only a - // modest amount of data, keeping the flood bounded. - SockRef::from(h.host_stream.get()) - .set_recv_buffer_size(8 << 10) - .unwrap(); + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } - // Flood until consomme's advertised receive window closes to zero. - let sent = h.flood_guest_until_window_closed().await; - assert_eq!( - h.connection_inner().rx_window_avail(), - 0, - "receive window should have closed after flooding {sent} bytes" - ); - assert!( - h.connection_inner().rx_window_last_adv < h.connection_inner().tx_mss, - "consomme should have advertised a (near) zero window to the guest" - ); + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + panic!("established connection must not shut down"); + } + } - // Discard the zero-window ACKs, then drain the host socket. Consomme must - // emit a window-update ACK on its own, without the guest probing. - h.clear_guest_packets(); - let window_update = h.drain_host_until_window_update().await; - assert!( - window_update.is_some_and(|w| w > 0), - "consomme must proactively re-advertise a non-zero window after the \ - host drains the backlog; got {window_update:?}" - ); + // Control host backpressure directly: real TCP receive/send buffers and + // their persist timers make a bounded flood/drain test depend on OS timing. + for window_scale in [0, 2, 7] { + let params = ConnectionParams { + rx_buffer: NormalizedBufferBounds { + initial: 16 << 10, + max: 16 << 10, + }, + tx_buffer: NormalizedBufferBounds { + initial: 16 << 10, + max: 16 << 10, + }, + }; + let mut connection = TcpConnection::new_base(¶ms); + connection.state = TcpState::Established; + connection.rx_window_scale = window_scale; + connection.rx_buffer = ring::Ring::new(params.rx_buffer.initial); + let data = vec![0x5a; params.rx_buffer.initial]; + connection.rx_buffer.write_at(0, &data); + connection.rx_buffer.extend_by(data.len()); + connection.needs_ack = true; + + let mut consomme = Consomme::new(ConsommeParams::new().unwrap()); + let mut client = TestClient::new(driver.clone()); + let packets = client.received_packets.clone(); + let ft = FourTuple { + src: SocketAddr::from(([10, 0, 0, 2], 44444)), + dst: SocketAddr::from(([10, 0, 0, 1], 80)), + }; + let mut sender = Sender { + ft: &ft, + client: &mut client, + state: &mut consomme.state, + }; + let dns = DnsResolver::without_backend(dns_resolver::DEFAULT_MAX_PENDING_DNS_REQUESTS); + let mut host = HostWriter { + allowance: 0, + received: Vec::new(), + }; + let mut cx = Context::from_waker(std::task::Waker::noop()); + + assert!(connection.poll_socket_write(&mut cx, &mut sender, &mut host, &mut None, &dns)); + connection.send_next(&mut sender, AckPolicy::Flush); + let closed = packets.lock().pop().expect("zero-window ACK"); + assert_eq!(parse_tcp_packet(&closed).2.window_len, 0); + assert_eq!(connection.rx_window_last_adv, 0); + assert!(!connection.needs_ack); + + let quantum = 1 << window_scale; + let reopen = connection.tx_mss.div_ceil(quantum) * quantum; + for (drain, expect_update) in [(0, false), (reopen - 1, false), (1, true), (1, false)] { + packets.lock().clear(); + host.allowance = drain; + assert!(connection.poll_socket_write(&mut cx, &mut sender, &mut host, &mut None, &dns)); + assert_eq!(connection.needs_ack, expect_update); + connection.send_next(&mut sender, AckPolicy::Flush); + let packets = packets.lock(); + assert_eq!(packets.len(), usize::from(expect_update)); + if expect_update { + let (_, _, update) = parse_tcp_packet(&packets[0]); + assert_eq!(update.control, TcpControl::None); + assert_eq!(update.ack_number, Some(connection.rx_seq)); + assert_eq!(usize::from(update.window_len) << window_scale, reopen); + assert!(update.payload.is_empty()); + } + assert!(!connection.needs_ack); + } + assert_eq!(host.received, data[..reopen + 1]); + assert_eq!(connection.rx_window_cap, params.rx_buffer.initial); + } } /// Verifies that the zero-copy TCP checksum computed over the header and the From 003d56c1f44ebf47902e367c6d3b0b23a5c82c33 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:19:31 -0700 Subject: [PATCH 008/304] virt_whp, petri: enable native PCIe NVMe boot on WHP Booting UEFI from an NVMe disk on emulated PCIe needs two changes on WHP. Petri enabled the UEFI vPCI boot option only for boot devices that live on vPCI. The firmware also uses that option to boot NVMe from emulated PCIe, so it never considered a PCIe NVMe boot disk. Enable the option for PCIe NVMe boot disks as well, without classifying them as vPCI devices. With enlightenments offloaded, WHP can pass the L0 UseGpaPinningHypercall enlightenment through to the guest. The firmware treats that bit as a requirement for an IOMMU protocol built on the GPA pin and unpin hypercalls, which OpenVMM does not implement. Mask only that enlightenment so UEFI uses its bounce-buffer IOMMU path instead. Add tests for the Petri boot classification and for masking the pinning bit while keeping the other enlightenment bits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- petri/src/vm/mod.rs | 19 ++++++++- vmm_core/virt_whp/src/cpu_contract.rs | 55 +++++++++++++++++++++++++++ vmm_core/virt_whp/src/lib.rs | 4 ++ 3 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 vmm_core/virt_whp/src/cpu_contract.rs diff --git a/petri/src/vm/mod.rs b/petri/src/vm/mod.rs index e448b7cacf8..8ef378bb691 100644 --- a/petri/src/vm/mod.rs +++ b/petri/src/vm/mod.rs @@ -994,11 +994,11 @@ impl PetriVmBuilder { ); } - if self.boot_device_type.requires_vpci_boot() { + if self.boot_device_type.requires_uefi_vpci_boot() { self.config .firmware .uefi_config_mut() - .expect("vpci boot requires uefi") + .expect("UEFI vPCI boot support requires UEFI firmware") .enable_vpci_boot = true; } @@ -2894,6 +2894,13 @@ impl BootDeviceType { ) } + /// Whether UEFI must enable its vPCI boot support. + /// + /// The firmware also uses this flag to enable NVMe boot from emulated PCIe. + fn requires_uefi_vpci_boot(&self) -> bool { + self.requires_vpci_boot() || matches!(self, BootDeviceType::PcieNvme) + } + fn requires_vmbus(&self) -> bool { match self { BootDeviceType::None @@ -3824,6 +3831,7 @@ pub(crate) fn petri_disk_cache_dir() -> String { #[cfg(test)] mod tests { use super::*; + use crate::BootDeviceType; use crate::Drive; use crate::VmbusStorageController; use crate::VmbusStorageType; @@ -4074,6 +4082,13 @@ mod tests { assert_ne!(&result1[96..], &result2[96..]); } + #[test] + fn pcie_nvme_requires_uefi_vpci_boot_without_using_vpci() { + assert!(BootDeviceType::PcieNvme.requires_uefi_vpci_boot()); + assert!(!BootDeviceType::PcieNvme.requires_vpci_boot()); + assert!(!BootDeviceType::PcieVirtioBlk.requires_uefi_vpci_boot()); + } + #[test] fn test_vmbus_storage_controller() { let mut controller = VmbusStorageController::new(Vtl::Vtl0, VmbusStorageType::Scsi); diff --git a/vmm_core/virt_whp/src/cpu_contract.rs b/vmm_core/virt_whp/src/cpu_contract.rs new file mode 100644 index 00000000000..197b93246e9 --- /dev/null +++ b/vmm_core/virt_whp/src/cpu_contract.rs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPUID policy that keeps unsupported host enlightenments from reaching the +//! guest. + +/// The L0 pinning requirement describes memory owned by OpenVMM, not memory +/// owned by OpenVMM's guest. OpenVMM does not expose the GPA pin/unpin +/// hypercalls, so this leaf keeps the enlightenment from being passed through +/// to the guest. +pub(crate) fn mask_gpa_pinning_enlightenment() -> virt::CpuidLeaf { + let mask = hvdef::HvEnlightenmentInformation::new() + .with_use_gpa_pinning_hypercall(true) + .into_bits(); + virt::CpuidLeaf::new( + hvdef::HV_CPUID_FUNCTION_MS_HV_ENLIGHTENMENT_INFORMATION, + [0; 4], + ) + .masked([ + mask as u32, + (mask >> 32) as u32, + (mask >> 64) as u32, + (mask >> 96) as u32, + ]) +} + +#[cfg(test)] +mod tests { + use super::mask_gpa_pinning_enlightenment; + + #[test] + fn l0_gpa_pinning_enlightenment_is_not_exposed() { + let enlightenment = hvdef::HvEnlightenmentInformation::new() + .with_use_gpa_pinning_hypercall(true) + .with_nested(true); + let bits = enlightenment.into_bits(); + let mut result = [ + bits as u32, + (bits >> 32) as u32, + (bits >> 64) as u32, + (bits >> 96) as u32, + ]; + + mask_gpa_pinning_enlightenment().apply(&mut result); + + let result = hvdef::HvEnlightenmentInformation::from( + result[0] as u128 + | (result[1] as u128) << 32 + | (result[2] as u128) << 64 + | (result[3] as u128) << 96, + ); + assert!(!result.use_gpa_pinning_hypercall()); + assert!(result.nested()); + } +} diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index defd621bce1..f662d15d096 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -10,6 +10,8 @@ #![expect(clippy::undocumented_unsafe_blocks, clippy::missing_safety_doc)] mod apic; +#[cfg(guest_arch = "x86_64")] +mod cpu_contract; pub mod device; mod emu; mod hypercalls; @@ -1148,6 +1150,8 @@ impl WhpPartitionInner { .map(|(f, v)| virt::CpuidLeaf::new(f.0, [v.eax, v.ebx, v.ecx, v.edx])), ); hv1_emulator::cpuid::process_hv_cpuid_leaves(&mut cpuid, false, [0; 4]); + } else { + cpuid.push(cpu_contract::mask_gpa_pinning_enlightenment()); } } From f07aa5f3cfbb18717f9851719c452d8a5e608239 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 19:32:01 -0700 Subject: [PATCH 009/304] mesh_protobuf: clone type-erased messages and expose encoded length ProtobufMessage and ProtobufAny carry already-encoded payloads, but neither type implements Clone. Code that holds type-erased saved state, such as a device-private state blob that must be both retained and handed to a consumer, therefore has to re-encode or give up ownership. Derive Clone for both types. Cloning copies the owned encoded bytes and type URL and does not re-encode anything. Also add ProtobufAny::value_len, which returns the encoded payload length without decoding it. Callers that bound saved-state sizes can reject oversized blobs before any parsing work. Extend the round-trip test to parse a clone of the encoded message. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/mesh/mesh_protobuf/src/message.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/support/mesh/mesh_protobuf/src/message.rs b/support/mesh/mesh_protobuf/src/message.rs index 096513dd4f5..88ea275f24f 100644 --- a/support/mesh/mesh_protobuf/src/message.rs +++ b/support/mesh/mesh_protobuf/src/message.rs @@ -29,7 +29,7 @@ use thiserror::Error; // // TODO: delay encoding like in mesh::Message. This requires splitting some of // the encoding traits up to remove the resource type. -#[derive(Debug)] +#[derive(Clone, Debug)] pub struct ProtobufMessage(Vec); impl ProtobufMessage { @@ -81,7 +81,7 @@ impl MessageDecode<'_, ProtobufMessage, R> for ProtobufMessageEncoding { /// A protobuf message and the associated protobuf type URL. /// /// This has the encoding of `google.protobuf.Any`. -#[derive(Protobuf)] +#[derive(Clone, Protobuf)] pub struct ProtobufAny { #[mesh(1)] type_url: String, // FUTURE: avoid allocation here @@ -143,6 +143,11 @@ impl ProtobufAny { self.value.parse() } + /// Returns the encoded message payload length without decoding it. + pub fn value_len(&self) -> usize { + self.value.0.len() + } + /// Returns `true` if this message is an encoding of `T`. pub fn is_message(&self) -> bool { &T::TYPE_URL == self.type_url.as_str() @@ -204,6 +209,7 @@ mod tests { assert!(any.is_message::()); assert!(!any.is_message::()); assert_eq!(any.parse::().unwrap(), msg); + assert_eq!(any.clone().parse::().unwrap(), msg); println!("{:?}", any.parse::().unwrap_err()); } } From 26f8f7c992313229cbc37e2f620fa896827bb0b6 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 19:36:07 -0700 Subject: [PATCH 010/304] sparse_mmap: add private copy-on-write file views SparseMapping can map files only as shared views, so guest writes to a file-backed range always reach the file. Restoring guest RAM from an immutable snapshot artifact needs the opposite: a view that starts with the file's contents, keeps every guest write private to the process, and leaves the backing file untouched. Add new_mappable_from_file_copy_on_write and SparseMapping::map_file_copy_on_write: - On Unix, the mappable is a duplicate of the file descriptor, and the view is mapped with MAP_PRIVATE. - On Windows, the mappable is a section created with PAGE_WRITECOPY (PAGE_EXECUTE_WRITECOPY when executable), and writable views are mapped with PAGE_WRITECOPY. Existing shared mappings are unchanged. Add a cross-platform test that fills a writable private view, then checks that the file bytes are unchanged and that a fresh view sees the original contents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + support/sparse_mmap/Cargo.toml | 3 + support/sparse_mmap/src/lib.rs | 3 + .../sparse_mmap/src/tests/copy_on_write.rs | 49 ++++++++++++++ support/sparse_mmap/src/unix.rs | 2 + support/sparse_mmap/src/unix/copy_on_write.rs | 50 +++++++++++++++ support/sparse_mmap/src/windows.rs | 2 + .../sparse_mmap/src/windows/copy_on_write.rs | 64 +++++++++++++++++++ 8 files changed, 174 insertions(+) create mode 100644 support/sparse_mmap/src/tests/copy_on_write.rs create mode 100644 support/sparse_mmap/src/unix/copy_on_write.rs create mode 100644 support/sparse_mmap/src/windows/copy_on_write.rs diff --git a/Cargo.lock b/Cargo.lock index 9f08414dda1..a45e5aa60ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8018,6 +8018,7 @@ dependencies = [ "libc", "pal", "parking_lot", + "tempfile", "thiserror 2.0.16", "trycopy", "windows-sys 0.61.0", diff --git a/support/sparse_mmap/Cargo.toml b/support/sparse_mmap/Cargo.toml index de64db84664..d2393f348ad 100644 --- a/support/sparse_mmap/Cargo.toml +++ b/support/sparse_mmap/Cargo.toml @@ -32,5 +32,8 @@ windows-sys = { workspace = true, features = [ "Win32_System_Threading", ]} +[dev-dependencies] +tempfile.workspace = true + [lints] workspace = true diff --git a/support/sparse_mmap/src/lib.rs b/support/sparse_mmap/src/lib.rs index d77ebbba135..1caab6f2bf8 100644 --- a/support/sparse_mmap/src/lib.rs +++ b/support/sparse_mmap/src/lib.rs @@ -18,6 +18,7 @@ pub use sys::MappableRef; pub use sys::SparseMapping; pub use sys::alloc_shared_memory; pub use sys::alloc_shared_memory_hugetlb; +pub use sys::copy_on_write::new_mappable_from_file_copy_on_write; pub use sys::new_mappable_from_file; use std::mem::MaybeUninit; @@ -188,6 +189,8 @@ impl SparseMapping { #[cfg(test)] mod tests { + mod copy_on_write; + use super::*; static BUF: [u8; 65536] = [0xcc; 65536]; diff --git a/support/sparse_mmap/src/tests/copy_on_write.rs b/support/sparse_mmap/src/tests/copy_on_write.rs new file mode 100644 index 00000000000..058ecfff2d7 --- /dev/null +++ b/support/sparse_mmap/src/tests/copy_on_write.rs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for copy-on-write file mappings. + +use crate::SparseMapping; +use crate::new_mappable_from_file_copy_on_write; +use std::io::Read; +use std::io::Seek; +use std::io::Write; + +#[test] +fn copy_on_write_file_mapping_does_not_modify_file() { + let page_size = SparseMapping::page_size(); + let mapping_size = page_size * 16; + let original = (0..mapping_size) + .map(|offset| (offset / page_size) as u8) + .collect::>(); + let mut artifact = tempfile::NamedTempFile::new().unwrap(); + artifact.write_all(&original).unwrap(); + artifact.as_file().sync_all().unwrap(); + let mut file = std::fs::File::open(artifact.path()).unwrap(); + + let mappable = new_mappable_from_file_copy_on_write(&file, false).unwrap(); + let mapping = SparseMapping::new(mapping_size).unwrap(); + mapping + .map_file_copy_on_write(0, mapping_size, &mappable, 0, true) + .unwrap(); + mapping.fill_at(0, 0xa5, mapping_size).unwrap(); + + let mut mapped_bytes = vec![0; mapping_size]; + mapping.read_at(0, &mut mapped_bytes).unwrap(); + assert_eq!(mapped_bytes, vec![0xa5; mapping_size]); + drop(mapping); + + file.rewind().unwrap(); + let mut file_bytes = Vec::new(); + file.read_to_end(&mut file_bytes).unwrap(); + assert_eq!(file_bytes, original); + + let mappable = new_mappable_from_file_copy_on_write(&file, false).unwrap(); + let mapping = SparseMapping::new(mapping_size).unwrap(); + mapping + .map_file_copy_on_write(0, mapping_size, &mappable, 0, true) + .unwrap(); + let mut remapped_bytes = vec![0; mapping_size]; + mapping.read_at(0, &mut remapped_bytes).unwrap(); + assert_eq!(remapped_bytes, original); +} diff --git a/support/sparse_mmap/src/unix.rs b/support/sparse_mmap/src/unix.rs index 67480de3fbc..8badff88e00 100644 --- a/support/sparse_mmap/src/unix.rs +++ b/support/sparse_mmap/src/unix.rs @@ -5,6 +5,8 @@ #![cfg(unix)] +pub(crate) mod copy_on_write; + use pal::unix::SyscallResult; use std::ffi::c_void; use std::fs::File; diff --git a/support/sparse_mmap/src/unix/copy_on_write.rs b/support/sparse_mmap/src/unix/copy_on_write.rs new file mode 100644 index 00000000000..998e2264c13 --- /dev/null +++ b/support/sparse_mmap/src/unix/copy_on_write.rs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Copy-on-write file mappings: private mappings whose writes are not +//! carried through to the file. + +use super::Mappable; +use super::SparseMapping; +use std::fs::File; +use std::io; +use std::io::Error; +use std::os::unix::prelude::*; + +/// Creates a mappable whose writable views are private to the mapping. +pub fn new_mappable_from_file_copy_on_write( + file: &File, + _executable: bool, +) -> io::Result { + file.as_fd().try_clone_to_owned() +} + +impl SparseMapping { + /// Maps a portion of a file privately at `offset`. + pub fn map_file_copy_on_write( + &self, + offset: usize, + len: usize, + file_mapping: impl AsFd, + file_offset: u64, + writable: bool, + ) -> Result<(), Error> { + let prot = if writable { + libc::PROT_READ | libc::PROT_WRITE + } else { + libc::PROT_READ + }; + + // SAFETY: The flags passed in are guaranteed to be valid. + unsafe { + self.mmap( + offset, + len, + prot, + libc::MAP_PRIVATE, + file_mapping.as_fd(), + file_offset as i64, + ) + } + } +} diff --git a/support/sparse_mmap/src/windows.rs b/support/sparse_mmap/src/windows.rs index 74ed2237aa7..7a8f2d04dba 100644 --- a/support/sparse_mmap/src/windows.rs +++ b/support/sparse_mmap/src/windows.rs @@ -5,6 +5,8 @@ #![cfg(windows)] +pub(crate) mod copy_on_write; + use Memory::CreateFileMappingNumaW; use Memory::CreateFileMappingW; use Memory::GetLargePageMinimum; diff --git a/support/sparse_mmap/src/windows/copy_on_write.rs b/support/sparse_mmap/src/windows/copy_on_write.rs new file mode 100644 index 00000000000..3eb6e9c3bdb --- /dev/null +++ b/support/sparse_mmap/src/windows/copy_on_write.rs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Copy-on-write file mappings: sections whose writable views are private +//! copies of the file pages. + +use super::Mappable; +use super::SparseMapping; +use std::io; +use std::io::Error; +use std::os::windows::prelude::*; +use std::ptr::null; +use std::ptr::null_mut; +use windows_sys::Win32::System::Memory::CreateFileMappingW; +use windows_sys::Win32::System::Memory::PAGE_EXECUTE_WRITECOPY; +use windows_sys::Win32::System::Memory::PAGE_READONLY; +use windows_sys::Win32::System::Memory::PAGE_WRITECOPY; + +/// Creates a section whose writable views are private copies of file pages. +pub fn new_mappable_from_file_copy_on_write( + file: &std::fs::File, + executable: bool, +) -> io::Result { + let protection = if executable { + PAGE_EXECUTE_WRITECOPY + } else { + PAGE_WRITECOPY + }; + + unsafe { + let section = CreateFileMappingW(file.as_raw_handle(), null_mut(), protection, 0, 0, null()) + as RawHandle; + if section.is_null() { + return Err(Error::last_os_error()); + } + Ok(OwnedHandle::from_raw_handle(section)) + } +} + +impl SparseMapping { + /// Maps a portion of a file as a private copy-on-write view. + pub fn map_file_copy_on_write( + &self, + offset: usize, + len: usize, + file_mapping: impl AsHandle, + file_offset: u64, + writable: bool, + ) -> Result<(), Error> { + let protect = if writable { + PAGE_WRITECOPY + } else { + PAGE_READONLY + }; + self.map_view_of_file( + offset, + len, + file_mapping.as_handle(), + file_offset, + protect, + None, + ) + } +} From e6c6c0dfe5544e4bd621bcf76cf4979318e35870 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 19:41:04 -0700 Subject: [PATCH 011/304] sparse_mmap: flush file-backed ranges with bounded concurrency Publishing file-backed guest RAM as a snapshot artifact requires the dirty pages of a shared file view to reach the file before the artifact is declared durable. SparseMapping has no way to request that writeback. Add SparseMapping::flush(offset, len) for populated shared file views: - On Unix, the range is written back synchronously with msync(MS_SYNC). - On Windows, FlushViewOfFile issues writeback synchronously per call, so a large range with fragmented dirty pages is dominated by I/O latency. Split ranges of at least 32 MiB into disjoint, page-aligned chunks, one worker per 16 MiB, capped at eight flushes in flight including the caller. Smaller ranges are flushed inline and empty ranges are a no-op. Every scoped worker is joined before returning, so no flush is still in progress when the call fails, whether a worker, the caller, or thread creation failed. Native I/O errors are propagated. Flushing a view mapped into another process is rejected as unsupported. Callers remain responsible for any file and directory durability barriers that follow; flushing only writes back the mapped pages. Add a cross-platform test that a flushed shared view reaches the file, and Windows tests covering exactly-once page coverage, bounded parallel progress, joining workers before propagating an error from either side, and byte integrity for a fragmented file. An ignored test compares serial and parallel flush timings for manual profiling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + support/sparse_mmap/Cargo.toml | 1 + support/sparse_mmap/src/lib.rs | 1 + support/sparse_mmap/src/tests/flush.rs | 27 +++ support/sparse_mmap/src/unix.rs | 1 + support/sparse_mmap/src/unix/flush.rs | 20 ++ support/sparse_mmap/src/windows.rs | 1 + support/sparse_mmap/src/windows/flush.rs | 271 +++++++++++++++++++++++ 8 files changed, 323 insertions(+) create mode 100644 support/sparse_mmap/src/tests/flush.rs create mode 100644 support/sparse_mmap/src/unix/flush.rs create mode 100644 support/sparse_mmap/src/windows/flush.rs diff --git a/Cargo.lock b/Cargo.lock index a45e5aa60ab..f3dc49c69ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8019,6 +8019,7 @@ dependencies = [ "pal", "parking_lot", "tempfile", + "test_with_tracing", "thiserror 2.0.16", "trycopy", "windows-sys 0.61.0", diff --git a/support/sparse_mmap/Cargo.toml b/support/sparse_mmap/Cargo.toml index d2393f348ad..e9d4bba2ead 100644 --- a/support/sparse_mmap/Cargo.toml +++ b/support/sparse_mmap/Cargo.toml @@ -34,6 +34,7 @@ windows-sys = { workspace = true, features = [ [dev-dependencies] tempfile.workspace = true +test_with_tracing.workspace = true [lints] workspace = true diff --git a/support/sparse_mmap/src/lib.rs b/support/sparse_mmap/src/lib.rs index 1caab6f2bf8..3071624f547 100644 --- a/support/sparse_mmap/src/lib.rs +++ b/support/sparse_mmap/src/lib.rs @@ -190,6 +190,7 @@ impl SparseMapping { #[cfg(test)] mod tests { mod copy_on_write; + mod flush; use super::*; diff --git a/support/sparse_mmap/src/tests/flush.rs b/support/sparse_mmap/src/tests/flush.rs new file mode 100644 index 00000000000..8b230e9b0b2 --- /dev/null +++ b/support/sparse_mmap/src/tests/flush.rs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for flushing shared file mappings. + +use crate::SparseMapping; +use crate::new_mappable_from_file; +use std::io::Read; +use std::io::Seek; + +#[test] +fn test_flush_shared_file_mapping() { + let page_size = SparseMapping::page_size(); + let mut file = tempfile::tempfile().unwrap(); + file.set_len(page_size as u64).unwrap(); + let mappable = new_mappable_from_file(&file, true, false).unwrap(); + let mapping = SparseMapping::new(page_size).unwrap(); + mapping.map_file(0, page_size, &mappable, 0, true).unwrap(); + + mapping.write_at(0, b"flushed").unwrap(); + mapping.flush(0, page_size).unwrap(); + + let mut bytes = [0_u8; 7]; + file.seek(std::io::SeekFrom::Start(0)).unwrap(); + file.read_exact(&mut bytes).unwrap(); + assert_eq!(&bytes, b"flushed"); +} diff --git a/support/sparse_mmap/src/unix.rs b/support/sparse_mmap/src/unix.rs index 8badff88e00..feb05fb810b 100644 --- a/support/sparse_mmap/src/unix.rs +++ b/support/sparse_mmap/src/unix.rs @@ -6,6 +6,7 @@ #![cfg(unix)] pub(crate) mod copy_on_write; +mod flush; use pal::unix::SyscallResult; use std::ffi::c_void; diff --git a/support/sparse_mmap/src/unix/flush.rs b/support/sparse_mmap/src/unix/flush.rs new file mode 100644 index 00000000000..8f0bfc547f8 --- /dev/null +++ b/support/sparse_mmap/src/unix/flush.rs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Flushing modified pages of shared file mappings to their files. + +use super::SparseMapping; +use std::io::Error; + +impl SparseMapping { + /// Flushes modified shared file pages in a populated range. + pub fn flush(&self, offset: usize, len: usize) -> Result<(), Error> { + let _ = self.validate_offset_len(offset, len)?; + // SAFETY: `validate_offset_len` proves the range is within this + // reservation. Callers use this only for populated shared mappings. + if unsafe { libc::msync(self.address.add(offset), len, libc::MS_SYNC) } < 0 { + return Err(Error::last_os_error()); + } + Ok(()) + } +} diff --git a/support/sparse_mmap/src/windows.rs b/support/sparse_mmap/src/windows.rs index 7a8f2d04dba..e9b5a1b1f36 100644 --- a/support/sparse_mmap/src/windows.rs +++ b/support/sparse_mmap/src/windows.rs @@ -6,6 +6,7 @@ #![cfg(windows)] pub(crate) mod copy_on_write; +mod flush; use Memory::CreateFileMappingNumaW; use Memory::CreateFileMappingW; diff --git a/support/sparse_mmap/src/windows/flush.rs b/support/sparse_mmap/src/windows/flush.rs new file mode 100644 index 00000000000..d0688ec2f34 --- /dev/null +++ b/support/sparse_mmap/src/windows/flush.rs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Flushing modified pages of shared file views to their files. + +use super::PAGE_SIZE; +use super::SparseMapping; +use std::io; +use std::io::Error; +use windows_sys::Win32::System::Memory::FlushViewOfFile; + +const MIN_FLUSH_BYTES_PER_WORKER: usize = 16 * 1024 * 1024; +const MAX_FLUSH_WORKERS: usize = 8; + +fn flush_file_range( + offset: usize, + len: usize, + flush: impl Fn(usize, usize) -> io::Result<()> + Sync, +) -> io::Result<()> { + if len == 0 { + return Ok(()); + } + let workers = (len / MIN_FLUSH_BYTES_PER_WORKER).clamp(1, MAX_FLUSH_WORKERS); + if workers == 1 { + return flush(offset, len); + } + + // Fragmented dirty pages make one synchronous flush I/O-latency bound. + // Use bounded, page-aligned chunks and finish all of them before returning. + let chunk_size = (len / PAGE_SIZE).div_ceil(workers) * PAGE_SIZE; + std::thread::scope(|scope| { + let flush = &flush; + let mut threads = Vec::with_capacity(workers - 1); + for start in (chunk_size..len).step_by(chunk_size) { + let chunk_len = (len - start).min(chunk_size); + threads.push( + std::thread::Builder::new() + .name("mapped-flush".to_owned()) + .spawn_scoped(scope, move || flush(offset + start, chunk_len))?, + ); + } + let mut result = flush(offset, chunk_size); + for thread in threads { + let worker_result = thread.join().expect("mapped-memory flush worker panicked"); + result = result.and(worker_result); + } + result + }) +} + +impl SparseMapping { + /// Flushes modified shared file pages in a populated local view. + pub fn flush(&self, offset: usize, len: usize) -> Result<(), Error> { + let _ = self.validate_offset_len(offset, len)?; + if self.process.is_some() { + return Err(Error::new( + io::ErrorKind::Unsupported, + "flushing a remote mapped view is unsupported", + )); + } + flush_file_range(offset, len, |offset, len| { + // SAFETY: `validate_offset_len` proves the range is within this + // reservation. Callers use this only for populated shared views. + if unsafe { FlushViewOfFile(self.as_ptr().add(offset), len) } == 0 { + return Err(Error::last_os_error()); + } + Ok(()) + }) + } +} + +#[cfg(test)] +mod tests { + use super::MAX_FLUSH_WORKERS; + use super::MIN_FLUSH_BYTES_PER_WORKER; + use super::PAGE_SIZE; + use super::SparseMapping; + use super::flush_file_range; + use parking_lot::Condvar; + use parking_lot::Mutex; + use std::io; + use std::io::Read; + use std::io::Seek; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use std::sync::mpsc; + use std::time::Duration; + use std::time::Instant; + use test_with_tracing::test; + + const SNAPSHOT_RAM_BYTES: usize = 128 * 1024 * 1024; + + #[test] + fn flush_file_range_covers_each_page_once() { + for len in [ + 0, + PAGE_SIZE, + MIN_FLUSH_BYTES_PER_WORKER - PAGE_SIZE, + MIN_FLUSH_BYTES_PER_WORKER, + MIN_FLUSH_BYTES_PER_WORKER + PAGE_SIZE, + MIN_FLUSH_BYTES_PER_WORKER * 2 + PAGE_SIZE, + SNAPSHOT_RAM_BYTES, + MIN_FLUSH_BYTES_PER_WORKER * 20 + PAGE_SIZE * 3, + ] { + let ranges = Mutex::new(Vec::new()); + flush_file_range(PAGE_SIZE, len, |offset, len| { + assert!(offset.is_multiple_of(PAGE_SIZE)); + assert!(len.is_multiple_of(PAGE_SIZE)); + assert_ne!(len, 0); + ranges.lock().push(offset..offset + len); + Ok(()) + }) + .unwrap(); + let mut ranges = ranges.into_inner(); + ranges.sort_by_key(|range| range.start); + let expected_workers = if len == 0 { + 0 + } else { + (len / MIN_FLUSH_BYTES_PER_WORKER).clamp(1, MAX_FLUSH_WORKERS) + }; + assert_eq!(ranges.len(), expected_workers, "length {len}"); + let mut end = PAGE_SIZE; + for range in ranges { + assert_eq!(range.start, end); + end = range.end; + } + assert_eq!(end, PAGE_SIZE + len); + } + } + + #[test] + fn fragmented_flush_is_bounded_parallel_and_joins_on_error() { + let chunk_len = SNAPSHOT_RAM_BYTES / MAX_FLUSH_WORKERS; + for failing_chunk in [None, Some(0), Some(1)] { + let (started, receiver) = mpsc::channel(); + let released = Mutex::new(false); + let release = Condvar::new(); + let completed = AtomicUsize::new(0); + std::thread::scope(|scope| { + let released = &released; + let release = &release; + let coordinator = scope.spawn(move || { + let all_started = (0..MAX_FLUSH_WORKERS) + .all(|_| receiver.recv_timeout(Duration::from_secs(10)).is_ok()); + *released.lock() = true; + release.notify_all(); + all_started + }); + let result = flush_file_range(0, SNAPSHOT_RAM_BYTES, |offset, len| { + if len != chunk_len { + return Err(io::Error::other( + "fragmented writeback was submitted as one serial flush", + )); + } + started.send(()).unwrap(); + let mut released = released.lock(); + while !*released { + release.wait(&mut released); + } + completed.fetch_add(1, Ordering::SeqCst); + if failing_chunk == Some(offset / chunk_len) { + Err(io::Error::from_raw_os_error(5)) + } else { + Ok(()) + } + }); + drop(started); + let all_started = coordinator.join().unwrap(); + if failing_chunk.is_some() { + assert_eq!(result.unwrap_err().raw_os_error(), Some(5)); + } else { + result.unwrap(); + } + assert!(all_started, "flush workers did not overlap"); + let completed = completed.load(Ordering::SeqCst); + assert!(completed > 1, "large flushes must overlap"); + assert_eq!(completed, MAX_FLUSH_WORKERS); + }); + } + } + + fn flush_fragmented_file( + len: usize, + flush: impl FnOnce(&SparseMapping) -> io::Result<()>, + ) -> (Duration, Duration) { + let mut file = tempfile::tempfile().unwrap(); + file.set_len(len as u64).unwrap(); + file.sync_all().unwrap(); + let mappable = crate::windows::new_mappable_from_file(&file, true, false).unwrap(); + let mapping = SparseMapping::new(len).unwrap(); + mapping.map_file(0, len, &mappable, 0, true).unwrap(); + for offset in (0..len).step_by(PAGE_SIZE * 2) { + mapping.fill_at(offset, 0x5a, PAGE_SIZE).unwrap(); + } + + let start = Instant::now(); + flush(&mapping).unwrap(); + let mapped_flush = start.elapsed(); + let start = Instant::now(); + file.sync_all().unwrap(); + let file_flush = start.elapsed(); + drop(mapping); + drop(mappable); + + file.rewind().unwrap(); + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes.len(), len); + for (index, page) in bytes.as_chunks::().0.iter().enumerate() { + let expected = if index.is_multiple_of(2) { 0x5a } else { 0 }; + assert!(page.iter().all(|&byte| byte == expected), "page {index}"); + } + (mapped_flush, file_flush) + } + + #[test] + fn flush_fragmented_file_preserves_bytes() { + flush_fragmented_file(MIN_FLUSH_BYTES_PER_WORKER * 2 + PAGE_SIZE * 3, |mapping| { + mapping.flush(0, mapping.len()) + }); + } + + #[test] + #[ignore = "manual storage timing comparison; run without concurrent workloads"] + fn profile_fragmented_file_flush() { + let mut serial = Vec::new(); + let mut parallel = Vec::new(); + for iteration in 0_usize..5 { + let order = if iteration.is_multiple_of(2) { + [false, true] + } else { + [true, false] + }; + for parallel_flush in order { + let (mapped, file) = flush_fragmented_file(SNAPSHOT_RAM_BYTES, |mapping| { + if parallel_flush { + mapping.flush(0, mapping.len()) + } else { + // SAFETY: the complete local shared view is populated + // and remains mapped until the synchronous call returns. + if unsafe { super::FlushViewOfFile(mapping.as_ptr(), mapping.len()) } == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + }); + eprintln!( + "parallel={parallel_flush} mapped_flush_ms={:.3} file_sync_ms={:.3}", + mapped.as_secs_f64() * 1000.0, + file.as_secs_f64() * 1000.0, + ); + if parallel_flush { + parallel.push(mapped + file); + } else { + serial.push(mapped + file); + } + } + } + serial.sort_unstable(); + parallel.sort_unstable(); + let serial = serial[serial.len() / 2].as_secs_f64(); + let parallel = parallel[parallel.len() / 2].as_secs_f64(); + eprintln!( + "fragmented_flush serial_p50_ms={:.3} parallel_p50_ms={:.3} speedup={:.2}", + serial * 1000.0, + parallel * 1000.0, + serial / parallel, + ); + } +} From 568b2b3482cfbf1f8100c016b4eb895227d3f8cd Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 19:53:27 -0700 Subject: [PATCH 012/304] pal: bound Linux descriptor-table pre-expansion Linux stores the first BITS_PER_LONG file descriptors of a process inline and allocates a larger descriptor table the first time a higher descriptor is needed. Once the table is shared by several threads, growing it waits for an RCU grace period. A process that opens many files after its worker threads have started, for example while restoring a VM, can stall on that wait. Add pal::unix::fd_table::expand_fd_table, meant to be called while the process is still single-threaded. When RLIMIT_NOFILE allows descriptors beyond the inline set, it duplicates /dev/null to the first dynamic descriptor with F_DUPFD_CLOEXEC and closes the duplicate again, which makes the kernel allocate the first dynamic table up front. The expansion is deliberately bounded: - It targets only the first dynamic table rather than RLIMIT_NOFILE. Later growth is what caused the observed stall, and a table sized toward the limit only adds process-exit work. - If a descriptor already occupies the target, the table is already large enough and nothing is touched. - F_DUPFD_CLOEXEC, unlike dup2, never replaces a descriptor that becomes occupied concurrently, so inherited descriptors are preserved. Add a test that an inherited descriptor at the target remains intact and usable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/pal/src/unix.rs | 2 + support/pal/src/unix/fd_table.rs | 93 ++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 support/pal/src/unix/fd_table.rs diff --git a/support/pal/src/unix.rs b/support/pal/src/unix.rs index 4de090fb2a5..7eb3bb3365f 100644 --- a/support/pal/src/unix.rs +++ b/support/pal/src/unix.rs @@ -6,6 +6,8 @@ #![expect(unsafe_code)] pub mod affinity; +#[cfg(target_os = "linux")] +pub mod fd_table; pub mod pipe; pub mod process; pub mod pthread; diff --git a/support/pal/src/unix/fd_table.rs b/support/pal/src/unix/fd_table.rs new file mode 100644 index 00000000000..91600a47ad5 --- /dev/null +++ b/support/pal/src/unix/fd_table.rs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Early expansion of the Linux file descriptor table. + +use crate::unix::SyscallResult; +use std::fs::File; +use std::io; +use std::io::Error; +use std::os::unix::prelude::*; + +const FIRST_DYNAMIC_FD: RawFd = usize::BITS as RawFd; + +/// Expands the Linux file descriptor table before worker threads are started. +/// +/// Growing a full descriptor table after other threads exist may wait for an +/// RCU grace period. Touching a high descriptor while startup is still +/// single-threaded avoids that restore-time latency. +pub fn expand_fd_table() -> io::Result<()> { + let mut limits = libc::rlimit { + rlim_cur: 0, + rlim_max: 0, + }; + + // SAFETY: `limits` is valid writable memory for `getrlimit`. + unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limits) }.syscall_result()?; + + if limits.rlim_cur <= FIRST_DYNAMIC_FD as libc::rlim_t { + return Ok(()); + } + + // Linux keeps BITS_PER_LONG descriptors inline. Allocate only the first + // dynamic table: later growth caused the observed stall, while expanding + // all the way toward RLIMIT_NOFILE adds unnecessary process-exit work. + expand_fd_table_to(FIRST_DYNAMIC_FD) +} + +fn expand_fd_table_to(target_fd: RawFd) -> io::Result<()> { + // An existing descriptor proves that the table already reaches the target. + // SAFETY: `F_GETFD` only inspects the descriptor number. + if unsafe { libc::fcntl(target_fd, libc::F_GETFD) } >= 0 { + return Ok(()); + } + let error = Error::last_os_error(); + if error.raw_os_error() != Some(libc::EBADF) { + return Err(error); + } + + let source = File::open("/dev/null")?; + if source.as_raw_fd() >= target_fd { + return Ok(()); + } + + // Unlike dup2, F_DUPFD_CLOEXEC never replaces an inherited descriptor if + // the target becomes occupied. + // SAFETY: `source` is valid and `target_fd` is a nonnegative lower bound. + let duplicate = unsafe { libc::fcntl(source.as_raw_fd(), libc::F_DUPFD_CLOEXEC, target_fd) } + .syscall_result()?; + // SAFETY: `duplicate` was returned as a new owned descriptor above. + unsafe { libc::close(duplicate) }.syscall_result()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::FIRST_DYNAMIC_FD; + use super::SyscallResult; + use super::expand_fd_table_to; + use std::io::Read; + use std::io::Write; + use std::os::fd::AsRawFd; + use std::os::fd::FromRawFd; + use std::os::unix::net::UnixStream; + + #[test] + fn expand_fd_table_preserves_occupied_target() { + let (source, mut peer) = UnixStream::pair().unwrap(); + // SAFETY: `source` is valid and the returned descriptor is checked. + let inherited_fd = + unsafe { libc::fcntl(source.as_raw_fd(), libc::F_DUPFD_CLOEXEC, FIRST_DYNAMIC_FD) } + .syscall_result() + .unwrap(); + // SAFETY: `inherited_fd` is a new owned descriptor. + let mut inherited = unsafe { UnixStream::from_raw_fd(inherited_fd) }; + + expand_fd_table_to(inherited.as_raw_fd()).unwrap(); + + peer.write_all(b"x").unwrap(); + let mut byte = [0]; + inherited.read_exact(&mut byte).unwrap(); + assert_eq!(byte, [b'x']); + } +} From 7b173b65370cef79800125479bf379b3b594cf91 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 19:55:25 -0700 Subject: [PATCH 013/304] pal: add Unix extent cloning and sparse-extent seeking Snapshot publication copies guest RAM backing files that are usually large and sparse. A byte-for-byte copy wastes time and turns holes into allocated zeroes. Where the filesystem supports it, the copy can instead share the source extents, and otherwise it should visit only the allocated ranges. Add pal::unix::fs with three primitives: - reflink clones all extents of a source file into a destination with the FICLONE ioctl on Linux, giving an independently writable copy without copying data. Other Unix platforms report Unsupported so callers can fall back to copying. - seek_data and seek_hole wrap lseek with SEEK_DATA and SEEK_HOLE and return None when no further data or hole exists (ENXIO), so callers can iterate the allocated extents of a file. Offsets above i64::MAX are rejected rather than truncated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/pal/src/unix.rs | 1 + support/pal/src/unix/fs.rs | 61 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 support/pal/src/unix/fs.rs diff --git a/support/pal/src/unix.rs b/support/pal/src/unix.rs index 7eb3bb3365f..e828d24b867 100644 --- a/support/pal/src/unix.rs +++ b/support/pal/src/unix.rs @@ -8,6 +8,7 @@ pub mod affinity; #[cfg(target_os = "linux")] pub mod fd_table; +pub mod fs; pub mod pipe; pub mod process; pub mod pthread; diff --git a/support/pal/src/unix/fs.rs b/support/pal/src/unix/fs.rs new file mode 100644 index 00000000000..ec25fbf48c4 --- /dev/null +++ b/support/pal/src/unix/fs.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use std::fs::File; +use std::io; + +/// Clones all file extents into an independently writable destination. +pub fn reflink(source: &File, destination: &File) -> io::Result<()> { + #[cfg(target_os = "linux")] + { + use std::os::fd::AsRawFd; + + // SAFETY: Both arguments are live regular-file descriptors and FICLONE + // does not retain either descriptor after the call. + let result = + unsafe { libc::ioctl(destination.as_raw_fd(), libc::FICLONE, source.as_raw_fd()) }; + if result == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } + #[cfg(not(target_os = "linux"))] + { + let _ = (source, destination); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "file extent cloning is unsupported on this platform", + )) + } +} + +/// Finds the next data extent at or after `offset`. +pub fn seek_data(file: &File, offset: u64) -> io::Result> { + seek_sparse(file, offset, libc::SEEK_DATA) +} + +/// Finds the next hole at or after `offset`. +pub fn seek_hole(file: &File, offset: u64) -> io::Result> { + seek_sparse(file, offset, libc::SEEK_HOLE) +} + +fn seek_sparse(file: &File, offset: u64, whence: i32) -> io::Result> { + use std::os::fd::AsRawFd; + + let offset = i64::try_from(offset) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file offset exceeds i64"))?; + // SAFETY: The descriptor is live and `whence` is selected internally from + // the platform's SEEK_DATA and SEEK_HOLE constants. + let result = unsafe { libc::lseek(file.as_raw_fd(), offset, whence) }; + if result >= 0 { + Ok(Some(result as u64)) + } else { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ENXIO) { + Ok(None) + } else { + Err(error) + } + } +} From 0de137158197a5ef69f6971166cf9374250c6e1d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 19:58:47 -0700 Subject: [PATCH 014/304] pal: query Unix socket peer credentials A local endpoint that should serve only the user running the VMM has to learn who is connected to it. On Unix that requires the effective user ID of the current process and the credentials of the connected socket peer. Add pal::unix::credentials: - effective_user_id returns the effective user ID of the current process and is re-exported as pal::unix::effective_user_id for ownership checks. - unix_socket_peer_user_id (Linux only) returns the user ID of the peer of a connected Unix socket via SO_PEERCRED. A result whose length does not match struct ucred is rejected as invalid data rather than trusted. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/pal/src/unix.rs | 2 ++ support/pal/src/unix/credentials.rs | 48 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 support/pal/src/unix/credentials.rs diff --git a/support/pal/src/unix.rs b/support/pal/src/unix.rs index e828d24b867..0667308f600 100644 --- a/support/pal/src/unix.rs +++ b/support/pal/src/unix.rs @@ -6,6 +6,7 @@ #![expect(unsafe_code)] pub mod affinity; +pub mod credentials; #[cfg(target_os = "linux")] pub mod fd_table; pub mod fs; @@ -13,6 +14,7 @@ pub mod pipe; pub mod process; pub mod pthread; +pub use credentials::effective_user_id; use std::fs::File; use std::io; use std::io::Error; diff --git a/support/pal/src/unix/credentials.rs b/support/pal/src/unix/credentials.rs new file mode 100644 index 00000000000..e88a8d77573 --- /dev/null +++ b/support/pal/src/unix/credentials.rs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Credentials of the current process and of Unix-socket peers. + +#[cfg(target_os = "linux")] +use std::io; +#[cfg(target_os = "linux")] +use std::io::Error; +#[cfg(target_os = "linux")] +use std::os::unix::prelude::*; + +/// Returns the effective user ID of the current process. +pub fn effective_user_id() -> u32 { + // SAFETY: geteuid has no preconditions. + unsafe { libc::geteuid() } +} + +#[cfg(target_os = "linux")] +pub fn unix_socket_peer_user_id(socket: BorrowedFd<'_>) -> io::Result { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = size_of::() as libc::socklen_t; + // SAFETY: `socket` is valid for the call and the output pointers refer to + // writable objects with the supplied sizes. + let result = unsafe { + libc::getsockopt( + socket.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + std::ptr::from_mut(&mut credentials).cast(), + &mut length, + ) + }; + if result != 0 { + return Err(Error::last_os_error()); + } + if length as usize != size_of::() { + return Err(Error::new( + io::ErrorKind::InvalidData, + "SO_PEERCRED returned an invalid credential length", + )); + } + Ok(credentials.uid) +} From 501d5ddbb1b9de61fa5dcab83a0a163721692436 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 20:09:16 -0700 Subject: [PATCH 015/304] pal: add handle-relative file access that does not follow reparse points Opening snapshot artifacts by path lets a concurrent rename, junction, or symbolic link redirect the open to a different file than the one that was validated. Code that works on an already opened directory needs to open, create, and link its entries relative to that exact directory handle, and to identify the file generation it opened. Add pal::windows::fs::relative: - open_relative_read_only opens an existing regular file relative to an opened directory with read-only sharing, so writers and delete or rename attempts fail while the handle is held. - open_relative_for_identity opens an entry for attribute access without denying other handles, for identity checks. - create_relative_new creates a new file relative to the directory. - hard_link_relative links an opened file into an opened directory under a single, non-special path component and never replaces an existing entry. - directory_entry_names enumerates entry names of an opened directory. - file_identity returns the volume serial number, the 128-bit file ID, and the current end of file of an opened file. All relative opens go through NtCreateFile with the directory as the root object, request a non-directory file, and pass FILE_OPEN_REPARSE_POINT, so a reparse point is opened as itself instead of being followed. Names and buffer sizes from the caller or from the directory query are validated before use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/pal/src/windows/fs.rs | 2 + support/pal/src/windows/fs/relative.rs | 302 +++++++++++++++++++++++++ 2 files changed, 304 insertions(+) create mode 100644 support/pal/src/windows/fs/relative.rs diff --git a/support/pal/src/windows/fs.rs b/support/pal/src/windows/fs.rs index dc5d937f326..a4f37b271f1 100644 --- a/support/pal/src/windows/fs.rs +++ b/support/pal/src/windows/fs.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub mod relative; + use super::chk_status; use super::dos_to_nt_path; use std::ffi::c_void; diff --git a/support/pal/src/windows/fs/relative.rs b/support/pal/src/windows/fs/relative.rs new file mode 100644 index 00000000000..314f6a70cee --- /dev/null +++ b/support/pal/src/windows/fs/relative.rs @@ -0,0 +1,302 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Directory-relative operations on opened handles: opening, creating, +//! hard-linking, and enumerating files relative to an opened directory, and +//! querying the identity of an opened file. + +use crate::windows::ObjectAttributes; +use crate::windows::UnicodeString; +use crate::windows::chk_status; +use std::fs; +use std::io; +use std::os::windows::ffi::OsStrExt; +use std::os::windows::ffi::OsStringExt; +use std::os::windows::io::AsHandle; +use std::os::windows::io::AsRawHandle; +use std::os::windows::io::FromRawHandle; +use std::ptr; +use std::ptr::null_mut; +use windows_sys::Wdk::Storage::FileSystem as ntioapi; +use windows_sys::Win32::Foundation::OBJ_CASE_INSENSITIVE; +use windows_sys::Win32::Foundation::STATUS_NO_MORE_FILES; +use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; +use windows_sys::Win32::Storage::FileSystem::FILE_ID_INFO; +use windows_sys::Win32::Storage::FileSystem::FILE_READ_ATTRIBUTES; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; +use windows_sys::Win32::Storage::FileSystem::FILE_STANDARD_INFO; +use windows_sys::Win32::Storage::FileSystem::FileIdInfo; +use windows_sys::Win32::Storage::FileSystem::FileStandardInfo; +use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandleEx; +use windows_sys::Win32::Storage::FileSystem::SYNCHRONIZE; +use windows_sys::Win32::System::IO::IO_STATUS_BLOCK; + +/// Stable identity and EOF for one opened file generation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct FileIdentity { + pub volume_serial_number: u64, + pub file_id: [u8; 16], + pub end_of_file: u64, +} + +/// Opens an existing regular file relative to an opened directory. +/// +/// The resulting handle allows read sharing only, so writers and +/// delete/rename attempts are rejected while the handle remains open. +pub fn open_relative_read_only( + directory: &fs::File, + name: &std::ffi::OsStr, +) -> io::Result { + open_relative_file( + directory, + name, + FILE_GENERIC_READ, + FILE_SHARE_READ, + ntioapi::FILE_OPEN, + "open", + ) +} + +/// Opens an existing file relative to a directory without denying access to +/// other handles. This is intended for identity checks during publication, +/// not for retaining an immutable restore generation. +pub fn open_relative_for_identity( + directory: &fs::File, + name: &std::ffi::OsStr, +) -> io::Result { + open_relative_file( + directory, + name, + FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + ntioapi::FILE_OPEN, + "open for identity check", + ) +} + +/// Creates a new file relative to an opened directory. +pub fn create_relative_new(directory: &fs::File, name: &std::ffi::OsStr) -> io::Result { + open_relative_file( + directory, + name, + FILE_GENERIC_WRITE, + FILE_SHARE_READ, + ntioapi::FILE_CREATE, + "create", + ) +} + +fn open_relative_file( + directory: &fs::File, + name: &std::ffi::OsStr, + desired_access: u32, + share_access: u32, + create_disposition: u32, + operation: &str, +) -> io::Result { + let name_string = name.to_string_lossy(); + let name = UnicodeString::try_from(name) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file name is too long"))?; + let mut attributes = ObjectAttributes::new(); + attributes + .name(&name) + .root(directory.as_handle()) + .attributes(OBJ_CASE_INSENSITIVE); + let mut handle = null_mut(); + let mut io_status = IO_STATUS_BLOCK::default(); + let status = unsafe { + ntioapi::NtCreateFile( + &mut handle, + desired_access | SYNCHRONIZE, + attributes.as_ptr(), + &mut io_status, + ptr::null(), + FILE_ATTRIBUTE_NORMAL, + share_access, + create_disposition, + ntioapi::FILE_NON_DIRECTORY_FILE + | ntioapi::FILE_OPEN_REPARSE_POINT + | ntioapi::FILE_SYNCHRONOUS_IO_NONALERT, + ptr::null(), + 0, + ) + }; + chk_status(status).map_err(|error| { + io::Error::new( + error.kind(), + format!("failed to {operation} {name_string}: {error}"), + ) + })?; + Ok(unsafe { fs::File::from_raw_handle(handle) }) +} + +/// Creates a hard link to `source` relative to an exact opened directory. +pub fn hard_link_relative( + source: &fs::File, + directory: &fs::File, + name: &std::ffi::OsStr, +) -> io::Result<()> { + let name: Vec = name.encode_wide().collect(); + if name.is_empty() + || name.contains(&0) + || name.as_slice() == ['.' as u16] + || name.as_slice() == ['.' as u16, '.' as u16] + || name + .iter() + .any(|character| *character == b'/' as u16 || *character == b'\\' as u16) + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "hard-link name must be one non-special path component", + )); + } + + let name_bytes = name + .len() + .checked_mul(size_of::()) + .and_then(|length| u32::try_from(length).ok()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "hard-link name is too long"))?; + let header_bytes = std::mem::offset_of!(ntioapi::FILE_LINK_INFORMATION, FileName); + let information_bytes = header_bytes + .checked_add(name_bytes as usize) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "hard-link name is too long"))? + .max(size_of::()); + let information_length = u32::try_from(information_bytes) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "hard-link name is too long"))?; + let mut information = vec![0_u64; information_bytes.div_ceil(size_of::())]; + let information = information + .as_mut_ptr() + .cast::(); + let mut io_status = IO_STATUS_BLOCK::default(); + + // SAFETY: `information` is aligned and sized for FILE_LINK_INFORMATION + // followed by the exact UTF-16 name. Both handles remain live for the + // duration of the synchronous call, which does not retain the buffer. + unsafe { + (*information).Anonymous.ReplaceIfExists = false; + (*information).RootDirectory = directory.as_raw_handle(); + (*information).FileNameLength = name_bytes; + ptr::copy_nonoverlapping( + name.as_ptr(), + (&raw mut (*information).FileName).cast::(), + name.len(), + ); + chk_status(ntioapi::NtSetInformationFile( + source.as_raw_handle(), + &mut io_status, + information.cast(), + information_length, + ntioapi::FileLinkInformation, + ))?; + } + Ok(()) +} + +/// Enumerates names relative to an opened directory handle. +pub fn directory_entry_names(directory: &fs::File) -> io::Result> { + const BUFFER_SIZE: usize = 4096; + const BUFFER_WORDS: usize = BUFFER_SIZE / size_of::(); + + let mut names = Vec::new(); + let mut restart_scan = true; + loop { + let mut buffer = [0_u64; BUFFER_WORDS]; + let mut io_status = IO_STATUS_BLOCK::default(); + let status = unsafe { + ntioapi::NtQueryDirectoryFile( + directory.as_raw_handle(), + null_mut(), + None, + ptr::null(), + &mut io_status, + buffer.as_mut_ptr().cast(), + BUFFER_SIZE as u32, + ntioapi::FileNamesInformation, + true, + ptr::null(), + restart_scan, + ) + }; + restart_scan = false; + if status == STATUS_NO_MORE_FILES { + break; + } + chk_status(status)?; + + let returned = io_status.Information; + let header_size = std::mem::offset_of!(ntioapi::FILE_NAMES_INFORMATION, FileName); + if returned < header_size { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "directory query returned a truncated entry", + )); + } + let entry = unsafe { &*buffer.as_ptr().cast::() }; + let name_length = usize::try_from(entry.FileNameLength).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "directory entry name length exceeds usize", + ) + })?; + if !name_length.is_multiple_of(2) || header_size + name_length > returned { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "directory query returned an invalid entry name", + )); + } + let name = unsafe { + std::slice::from_raw_parts(entry.FileName.as_ptr(), name_length / size_of::()) + }; + let name = std::ffi::OsString::from_wide(name); + if name != "." && name != ".." { + names.push(name); + } + } + Ok(names) +} + +/// Queries `FILE_ID_INFO` and the current EOF for an opened file. +pub fn file_identity(file: &fs::File) -> io::Result { + let mut identity = FILE_ID_INFO::default(); + let result = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileIdInfo, + (&raw mut identity).cast(), + size_of::() as u32, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + + let mut standard = FILE_STANDARD_INFO::default(); + let result = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileStandardInfo, + (&raw mut standard).cast(), + size_of::() as u32, + ) + }; + if result == 0 { + return Err(io::Error::last_os_error()); + } + if standard.Directory || standard.DeletePending { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "handle is not an available regular file", + )); + } + let end_of_file = u64::try_from(standard.EndOfFile) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "file has a negative EOF"))?; + Ok(FileIdentity { + volume_serial_number: identity.VolumeSerialNumber, + file_id: identity.FileId.Identifier, + end_of_file, + }) +} From b36a4755e84ae42fef1a1d326a74f5a80650e3c2 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 20:16:34 -0700 Subject: [PATCH 016/304] pal: add sparse-file range queries, zeroing, and block cloning Publishing and restoring large, mostly empty guest RAM files on Windows should neither read nor allocate the unallocated ranges, and should share extents with the source when the filesystem supports block cloning. Add pal::windows::fs::sparse: - set_sparse marks a file sparse (FSCTL_SET_SPARSE). - allocation_size returns the physical allocation charged to a file. - allocated_ranges enumerates the allocated ranges clipped to a length (FSCTL_QUERY_ALLOCATED_RANGES), continuing while the query reports ERROR_MORE_DATA. - zero_range deallocates a range of a sparse file so it reads back as zeroes (FSCTL_SET_ZERO_DATA). - duplicate_extents block-clones the source extents into an independently writable destination (FSCTL_DUPLICATE_EXTENTS_TO_FILE), for filesystems such as ReFS. Offsets and lengths that do not fit the Windows signed 64-bit file offsets are rejected rather than truncated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/pal/src/windows/fs.rs | 1 + support/pal/src/windows/fs/sparse.rs | 215 +++++++++++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 support/pal/src/windows/fs/sparse.rs diff --git a/support/pal/src/windows/fs.rs b/support/pal/src/windows/fs.rs index a4f37b271f1..6ed3d32cfc1 100644 --- a/support/pal/src/windows/fs.rs +++ b/support/pal/src/windows/fs.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. pub mod relative; +pub mod sparse; use super::chk_status; use super::dos_to_nt_path; diff --git a/support/pal/src/windows/fs/sparse.rs b/support/pal/src/windows/fs/sparse.rs new file mode 100644 index 00000000000..d9d76b4c04a --- /dev/null +++ b/support/pal/src/windows/fs/sparse.rs @@ -0,0 +1,215 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Sparse-file helpers: marking a file sparse, querying its allocation, +//! block-cloning its extents, enumerating its allocated ranges, and +//! deallocating zeroed ranges. + +use std::fs; +use std::io; +use std::os::windows::io::AsRawHandle; +use std::ptr; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::ERROR_MORE_DATA; +use windows_sys::Win32::Storage::FileSystem::FILE_STANDARD_INFO; +use windows_sys::Win32::Storage::FileSystem::FileStandardInfo; +use windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandleEx; +use windows_sys::Win32::System::IO::DeviceIoControl; +use windows_sys::Win32::System::Ioctl::DUPLICATE_EXTENTS_DATA; +use windows_sys::Win32::System::Ioctl::FILE_ALLOCATED_RANGE_BUFFER; +use windows_sys::Win32::System::Ioctl::FILE_ZERO_DATA_INFORMATION; +use windows_sys::Win32::System::Ioctl::FSCTL_DUPLICATE_EXTENTS_TO_FILE; +use windows_sys::Win32::System::Ioctl::FSCTL_QUERY_ALLOCATED_RANGES; +use windows_sys::Win32::System::Ioctl::FSCTL_SET_SPARSE; +use windows_sys::Win32::System::Ioctl::FSCTL_SET_ZERO_DATA; + +/// Marks a file as sparse. +pub fn set_sparse(file: &fs::File) -> io::Result<()> { + let mut returned = 0; + let result = unsafe { + DeviceIoControl( + file.as_raw_handle(), + FSCTL_SET_SPARSE, + ptr::null(), + 0, + null_mut(), + 0, + &mut returned, + null_mut(), + ) + }; + if result == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +/// Returns the physical allocation charged to a file. +pub fn allocation_size(file: &fs::File) -> io::Result { + let mut info = FILE_STANDARD_INFO::default(); + let result = unsafe { + GetFileInformationByHandleEx( + file.as_raw_handle(), + FileStandardInfo, + (&raw mut info).cast(), + size_of::() as u32, + ) + }; + if result == 0 { + Err(io::Error::last_os_error()) + } else { + u64::try_from(info.AllocationSize).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "negative file allocation size") + }) + } +} + +/// Block-clones all source extents into an independently writable destination. +pub fn duplicate_extents(source: &fs::File, destination: &fs::File, length: u64) -> io::Result<()> { + let byte_count = i64::try_from(length) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "file is too large"))?; + let input = DUPLICATE_EXTENTS_DATA { + FileHandle: source.as_raw_handle(), + SourceFileOffset: 0, + TargetFileOffset: 0, + ByteCount: byte_count, + }; + let mut returned = 0; + let result = unsafe { + DeviceIoControl( + destination.as_raw_handle(), + FSCTL_DUPLICATE_EXTENTS_TO_FILE, + (&raw const input).cast(), + size_of::() as u32, + null_mut(), + 0, + &mut returned, + null_mut(), + ) + }; + if result == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +/// Returns allocated file ranges clipped to `[0, length)`. +pub fn allocated_ranges(file: &fs::File, length: u64) -> io::Result> { + const RANGE_CAPACITY: usize = 64; + + let mut ranges = Vec::new(); + let mut cursor = 0_u64; + while cursor < length { + let input = FILE_ALLOCATED_RANGE_BUFFER { + FileOffset: i64::try_from(cursor).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "file offset exceeds i64") + })?, + Length: i64::try_from(length - cursor).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "file length exceeds i64") + })?, + }; + let mut output = [FILE_ALLOCATED_RANGE_BUFFER::default(); RANGE_CAPACITY]; + let mut returned = 0_u32; + let result = unsafe { + DeviceIoControl( + file.as_raw_handle(), + FSCTL_QUERY_ALLOCATED_RANGES, + (&raw const input).cast(), + size_of::() as u32, + output.as_mut_ptr().cast(), + size_of_val(&output) as u32, + &mut returned, + null_mut(), + ) + }; + if result == 0 { + let error = io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_MORE_DATA as i32) { + return Err(error); + } + } + if !(returned as usize).is_multiple_of(size_of::()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allocated-range query returned a partial record", + )); + } + let count = returned as usize / size_of::(); + if count > output.len() { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allocated-range query overflowed its buffer", + )); + } + for range in &output[..count] { + let offset = u64::try_from(range.FileOffset).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "negative allocated range offset", + ) + })?; + let range_length = u64::try_from(range.Length).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "negative allocated range length", + ) + })?; + let end = offset.checked_add(range_length).ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "allocated range overflowed u64") + })?; + if offset < cursor || range_length == 0 || end > length { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allocated-range query returned an invalid range", + )); + } + ranges.push((offset, range_length)); + cursor = end; + } + if result != 0 { + break; + } + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "allocated-range query made no progress", + )); + } + } + Ok(ranges) +} + +/// Deallocates a range in a sparse file and makes reads return zeroes. +pub fn zero_range(file: &fs::File, start: u64, end: u64) -> io::Result<()> { + if start >= end { + return Ok(()); + } + let input = FILE_ZERO_DATA_INFORMATION { + FileOffset: i64::try_from(start).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "zero range offset exceeds i64") + })?, + BeyondFinalZero: i64::try_from(end).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "zero range end exceeds i64") + })?, + }; + let mut returned = 0; + let result = unsafe { + DeviceIoControl( + file.as_raw_handle(), + FSCTL_SET_ZERO_DATA, + (&raw const input).cast(), + size_of::() as u32, + null_mut(), + 0, + &mut returned, + null_mut(), + ) + }; + if result == 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} From c61d776444610662f955144592f7ba26e91a9aa7 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 20:18:07 -0700 Subject: [PATCH 017/304] pal: create files and directories with explicit security descriptors Creating a file or directory and then restricting its DACL leaves a window in which the new object carries the inherited default access, and anyone with access in that window can keep a handle open. Snapshot directories and artifacts must be created with their intended access from the start. Add pal::windows::security::create: - create_directory_with_security creates a directory with the supplied security descriptor applied at creation. - create_file_with_security exclusively creates (CREATE_NEW) a regular file for read and write with the supplied security descriptor, and fails if the file already exists. Paths containing interior NUL characters are rejected as invalid input. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/pal/src/windows/security.rs | 2 + support/pal/src/windows/security/create.rs | 75 ++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 support/pal/src/windows/security/create.rs diff --git a/support/pal/src/windows/security.rs b/support/pal/src/windows/security.rs index 7eb48d41381..2f4a583ec46 100644 --- a/support/pal/src/windows/security.rs +++ b/support/pal/src/windows/security.rs @@ -3,6 +3,8 @@ //! Windows security API wrappers. +pub mod create; + use std::ffi::c_void; use std::fmt::Debug; use std::io::ErrorKind; diff --git a/support/pal/src/windows/security/create.rs b/support/pal/src/windows/security/create.rs new file mode 100644 index 00000000000..934d3435779 --- /dev/null +++ b/support/pal/src/windows/security/create.rs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Atomic creation of files and directories with an explicit security +//! descriptor. + +use super::SecurityDescriptor; +use std::io::ErrorKind; +use std::os::windows::prelude::*; +use std::path::Path; +use std::ptr::null_mut; +use widestring::U16CString; +use windows_sys::Win32::Foundation::GENERIC_READ; +use windows_sys::Win32::Foundation::GENERIC_WRITE; +use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; +use windows_sys::Win32::Storage::FileSystem::CREATE_NEW; +use windows_sys::Win32::Storage::FileSystem::CreateDirectoryW; +use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; + +/// Creates a directory with `security_descriptor` applied atomically. +pub fn create_directory_with_security( + path: &Path, + security_descriptor: &SecurityDescriptor, +) -> std::io::Result<()> { + let path = U16CString::from_os_str(path.as_os_str()) + .map_err(|error| std::io::Error::new(ErrorKind::InvalidInput, error))?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: security_descriptor.as_ptr(), + bInheritHandle: 0, + }; + // SAFETY: the NUL-terminated path and security descriptor remain valid for + // the duration of the documented Win32 call. + if unsafe { CreateDirectoryW(path.as_ptr(), &attributes) } == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +/// Exclusively creates a regular file with `security_descriptor` applied atomically. +pub fn create_file_with_security( + path: &Path, + security_descriptor: &SecurityDescriptor, +) -> std::io::Result { + let path = U16CString::from_os_str(path.as_os_str()) + .map_err(|error| std::io::Error::new(ErrorKind::InvalidInput, error))?; + let attributes = SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: security_descriptor.as_ptr(), + bInheritHandle: 0, + }; + // SAFETY: arguments satisfy CreateFileW's contracts. Ownership of a valid + // returned handle is transferred exactly once into `File`. + let handle = unsafe { + CreateFileW( + path.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + &attributes, + CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, + null_mut(), + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: `handle` is valid and uniquely owned after successful CreateFileW. + Ok(unsafe { std::fs::File::from_raw_handle(handle) }) +} From a3f124a59f2d899566190665ca8cda0e4235156f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 20:24:45 -0700 Subject: [PATCH 018/304] pal: resolve the process token user SID Authenticating a local named-pipe client on Windows means comparing the user of the connecting process with the user running the VMM. pal can parse and format SIDs but cannot obtain the user SID of a process. Add pal::windows::security::user_sid: - current_process_user_sid returns the token user SID of the current process. - process_user_sid returns the token user SID of another local process, opened with PROCESS_QUERY_LIMITED_INFORMATION. - MaximumSid::byte_len and MaximumSid::to_fixed_bytes give a bounded, zero-padded 68-byte binary encoding plus its meaningful length, so an identity can be compared or carried without heap allocation. SID lengths returned by the token query are validated against the fixed SID storage before they are copied. Add a test that the current process SID has a bounded binary identity. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/pal/src/windows/security.rs | 1 + support/pal/src/windows/security/user_sid.rs | 125 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 support/pal/src/windows/security/user_sid.rs diff --git a/support/pal/src/windows/security.rs b/support/pal/src/windows/security.rs index 2f4a583ec46..8ca106909f7 100644 --- a/support/pal/src/windows/security.rs +++ b/support/pal/src/windows/security.rs @@ -4,6 +4,7 @@ //! Windows security API wrappers. pub mod create; +pub mod user_sid; use std::ffi::c_void; use std::fmt::Debug; diff --git a/support/pal/src/windows/security/user_sid.rs b/support/pal/src/windows/security/user_sid.rs new file mode 100644 index 00000000000..b21b682c6e4 --- /dev/null +++ b/support/pal/src/windows/security/user_sid.rs @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! User SIDs of local processes, and a fixed-size binary encoding of SIDs. + +use super::MAX_SUBAUTHORITY_COUNT; +use super::MaximumSid; +use std::io::ErrorKind; +use std::os::windows::prelude::*; +use std::ptr::null_mut; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::Security::GetLengthSid; +use windows_sys::Win32::Security::GetTokenInformation; +use windows_sys::Win32::Security::TOKEN_QUERY; +use windows_sys::Win32::Security::TOKEN_USER; +use windows_sys::Win32::Security::TokenUser; +use windows_sys::Win32::System::Threading::GetCurrentProcess; +use windows_sys::Win32::System::Threading::OpenProcess; +use windows_sys::Win32::System::Threading::OpenProcessToken; +use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; + +impl MaximumSid { + /// Returns the binary SID length encoded by this value. + pub fn byte_len(&self) -> usize { + 8 + usize::from(self.sub_authority_count) * size_of::() + } + + /// Returns a zero-padded fixed representation and its meaningful length. + pub fn to_fixed_bytes(self) -> ([u8; 68], u8) { + let length = self.byte_len(); + assert!(length <= 68); + let mut bytes = [0; 68]; + // SAFETY: `MaximumSid` is a 68-byte repr(C) value and `length` was + // derived from its validated subauthority count. + unsafe { + bytes[..length].copy_from_slice(std::slice::from_raw_parts( + std::ptr::from_ref(&self).cast::(), + length, + )); + } + (bytes, length as u8) + } +} + +fn process_user_sid_from_handle(process: HANDLE) -> std::io::Result { + // SAFETY: the process handle is valid for TOKEN_QUERY and all output + // buffers remain live for the documented calls. + unsafe { + let mut token = null_mut(); + if OpenProcessToken(process, TOKEN_QUERY, &mut token) == 0 { + return Err(std::io::Error::last_os_error()); + } + let token = OwnedHandle::from_raw_handle(token); + + let mut required = 0; + let _ = GetTokenInformation( + token.as_raw_handle(), + TokenUser, + null_mut(), + 0, + &mut required, + ); + if required < size_of::() as u32 { + return Err(std::io::Error::last_os_error()); + } + let mut buffer = vec![0u8; required as usize]; + if GetTokenInformation( + token.as_raw_handle(), + TokenUser, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) == 0 + { + return Err(std::io::Error::last_os_error()); + } + let token_user = &*buffer.as_ptr().cast::(); + let length = GetLengthSid(token_user.User.Sid) as usize; + if !(8..=size_of::()).contains(&length) { + return Err(std::io::Error::new( + ErrorKind::InvalidData, + "process token contains an invalid user SID", + )); + } + let mut sid = MaximumSid::new([0; 6], [0; MAX_SUBAUTHORITY_COUNT]); + std::ptr::copy_nonoverlapping( + token_user.User.Sid.cast::(), + std::ptr::from_mut(&mut sid).cast::(), + length, + ); + Ok(sid) + } +} + +/// Returns the current process user SID. +pub fn current_process_user_sid() -> std::io::Result { + process_user_sid_from_handle(unsafe { GetCurrentProcess() }) +} + +/// Returns the user SID for a local process. +pub fn process_user_sid(process_id: u32) -> std::io::Result { + // SAFETY: OpenProcess is called with a query-only access mask and a + // concrete process identifier supplied by the named-pipe subsystem. + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, process_id) }; + if process.is_null() { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: the successful OpenProcess result is uniquely owned here. + let process = unsafe { OwnedHandle::from_raw_handle(process) }; + process_user_sid_from_handle(process.as_raw_handle()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_process_sid_has_a_bounded_binary_identity() { + let sid = current_process_user_sid().unwrap(); + let (bytes, length) = sid.to_fixed_bytes(); + assert!((8..=68).contains(&length)); + assert_eq!(bytes[0], 1); + assert!(sid.to_string_sid().starts_with("S-1-")); + } +} From ea77a73e8187006d6806c1b819f43ce1c3b6dad5 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 20:50:15 -0700 Subject: [PATCH 019/304] pal: query named-pipe peers and create protected pipes A named-pipe endpoint that should serve only one user needs to be created with a restrictive security descriptor, to identify the connected client process, and to notice when that client has gone away. pal's named-pipe helpers offer none of this. Add pal::windows::pipe::peer: - new_named_pipe_with_security creates or opens a named pipe with an explicit security descriptor. The internal create_named_pipe helper now takes an optional descriptor, and the existing constructors pass None. - client_process_id returns the process ID of the connected client. - is_pipe reports whether a handle refers to a pipe. Add PipeExt::is_pipe_peer_closed, which queries the local pipe state and reports whether the pipe is disconnected or closing. Unlike a cached disconnect event, this reflects the pipe's current state. Co-authored-by: Enrique Saurez Co-authored-by: Huzaifa Danish Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/pal/src/windows/pipe.rs | 13 +++- support/pal/src/windows/pipe/peer.rs | 100 +++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 support/pal/src/windows/pipe/peer.rs diff --git a/support/pal/src/windows/pipe.rs b/support/pal/src/windows/pipe.rs index 0296e66f2d9..0e5de5a7cba 100644 --- a/support/pal/src/windows/pipe.rs +++ b/support/pal/src/windows/pipe.rs @@ -1,9 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub mod peer; + use super::UnicodeString; use super::chk_status; use super::dos_to_nt_path; +use super::security::SecurityDescriptor; use super::status_to_error; // TODO: Revert this ntapi fallback once windows/windows-sys expose // NtCreateNamedPipeFile directly. @@ -151,6 +154,7 @@ pub fn new_named_pipe( }, true, mode == PipeMode::Message, + None, ) } @@ -161,6 +165,7 @@ fn create_named_pipe( disposition: u32, overlapped: bool, message_mode: bool, + security_descriptor: Option<&SecurityDescriptor>, ) -> Result { unsafe { let mut pathu = if root.is_null() { @@ -174,7 +179,7 @@ fn create_named_pipe( RootDirectory: root.cast::(), ObjectName: pathu.as_mut_ptr(), Attributes: OBJ_CASE_INSENSITIVE, - SecurityDescriptor: null_mut(), + SecurityDescriptor: peer::security_descriptor_ptr(security_descriptor), SecurityQualityOfService: null_mut(), }; @@ -222,6 +227,7 @@ pub fn bidirectional_pair(message_mode: bool) -> io::Result<(File, File)> { FILE_CREATE, false, message_mode, + None, )?; let mut empty_name = zeroed(); @@ -255,6 +261,7 @@ pub trait PipeExt { fn set_pipe_select_event(&self, event: &Event, event_types: u32) -> io::Result<()>; fn get_pipe_select_events(&self) -> io::Result; fn is_pipe_connected(&self) -> io::Result; + fn is_pipe_peer_closed(&self) -> io::Result; fn disconnect_pipe(&self) -> io::Result<()>; } @@ -389,6 +396,10 @@ impl PipeExt for File { Ok(connected) } + fn is_pipe_peer_closed(&self) -> io::Result { + peer::is_peer_closed(self) + } + fn disconnect_pipe(&self) -> io::Result<()> { // SAFETY: calling on a known valid handle. if unsafe { DisconnectNamedPipe(self.as_raw_handle()) } == 0 { diff --git a/support/pal/src/windows/pipe/peer.rs b/support/pal/src/windows/pipe/peer.rs new file mode 100644 index 00000000000..2f6e95334db --- /dev/null +++ b/support/pal/src/windows/pipe/peer.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Named-pipe helpers for local peer endpoints: creating a pipe with an +//! explicit security descriptor, identifying the client process, checking +//! whether a file is a pipe, and detecting a closed peer. + +use super::Disposition; +use super::PipeMode; +use super::create_named_pipe; +use crate::windows::chk_status; +use crate::windows::security::SecurityDescriptor; +use std::ffi::c_void; +use std::fs::File; +use std::io; +use std::mem::zeroed; +use std::os::windows::prelude::*; +use std::path::Path; +use std::ptr::null_mut; +use windows_sys::Wdk::Storage::FileSystem::FILE_CREATE; +use windows_sys::Wdk::Storage::FileSystem::FILE_OPEN; +use windows_sys::Wdk::Storage::FileSystem::FILE_PIPE_CLOSING_STATE; +use windows_sys::Wdk::Storage::FileSystem::FILE_PIPE_DISCONNECTED_STATE; +use windows_sys::Wdk::Storage::FileSystem::FILE_PIPE_LOCAL_INFORMATION; +use windows_sys::Wdk::Storage::FileSystem::FilePipeLocalInformation; +use windows_sys::Wdk::Storage::FileSystem::NtQueryInformationFile; +use windows_sys::Win32::Storage::FileSystem::FILE_TYPE_PIPE; +use windows_sys::Win32::Storage::FileSystem::GetFileType; +use windows_sys::Win32::System::Pipes::GetNamedPipeClientProcessId; + +pub fn new_named_pipe_with_security( + path: impl AsRef, + access: u32, + disposition: Disposition, + mode: PipeMode, + security_descriptor: &SecurityDescriptor, +) -> io::Result { + create_named_pipe( + null_mut(), + path.as_ref(), + access, + match disposition { + Disposition::Create => FILE_CREATE, + Disposition::Open => FILE_OPEN, + }, + true, + mode == PipeMode::Message, + Some(security_descriptor), + ) +} + +/// Returns the `OBJECT_ATTRIBUTES` security descriptor pointer that +/// `create_named_pipe` uses for an optional security descriptor. +pub(super) fn security_descriptor_ptr( + security_descriptor: Option<&SecurityDescriptor>, +) -> *const windows_sys::Win32::Security::SECURITY_DESCRIPTOR { + security_descriptor.map_or(null_mut(), |descriptor| { + descriptor + .as_ptr() + .cast::() + .cast_const() + }) +} + +pub fn client_process_id(pipe: &File) -> io::Result { + let mut process_id = 0; + // SAFETY: `pipe` is a live named-pipe handle and the output pointer is + // valid for the documented call. + if unsafe { GetNamedPipeClientProcessId(pipe.as_raw_handle(), &mut process_id) } == 0 { + return Err(io::Error::last_os_error()); + } + Ok(process_id) +} + +pub fn is_pipe(file: &File) -> bool { + // SAFETY: `file` owns a valid Windows handle. + unsafe { GetFileType(file.as_raw_handle()) == FILE_TYPE_PIPE } +} + +/// Returns whether the peer of a named pipe is gone, that is, whether the pipe +/// is in the disconnected or closing state. +pub(super) fn is_peer_closed(pipe: &File) -> io::Result { + // SAFETY: calling with an appropriately sized output buffer. + let state = unsafe { + let mut iosb = zeroed(); + let mut info: FILE_PIPE_LOCAL_INFORMATION = zeroed(); + chk_status(NtQueryInformationFile( + pipe.as_raw_handle().cast::(), + &mut iosb, + std::ptr::from_mut(&mut info).cast(), + size_of_val(&info) as u32, + FilePipeLocalInformation, + ))?; + info.NamedPipeState + }; + Ok(matches!( + state, + FILE_PIPE_DISCONNECTED_STATE | FILE_PIPE_CLOSING_STATE + )) +} From 01b7adbf699d062db8d0bdcdd6870822ff611f50 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 20:51:47 -0700 Subject: [PATCH 020/304] pal_async: fix named-pipe reconnect and already-connected listen Reusing a server pipe for a new client exposed two hangs in the Windows named-pipe support. PolledPipe::poll_closing waited for FILE_PIPE_DISCONNECTED in the cached pipe events. After a client closes its handle on a reused server pipe, that event is not always signaled again, so the wait could last forever. Check the current pipe state with PipeExt::is_pipe_peer_closed first and complete immediately when the peer is gone. Otherwise refresh the events and wait as before. ListeningPipe treats ConnectNamedPipe returning ERROR_PIPE_CONNECTED, a client that connected before the listen, as success. In that case no overlapped I/O was issued, so its event is never signaled and polling the listener never completed. Return the connected pipe immediately when the connect succeeded synchronously. Add Windows tests that reuse a server pipe after the client closes, complete a listen for an already-connected client, and detect a closed client without a disconnect event. Co-authored-by: Huzaifa Danish Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- support/pal/pal_async/src/windows/pipe.rs | 11 +- .../src/windows/pipe/tests/reconnect.rs | 183 ++++++++++++++++++ 2 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 support/pal/pal_async/src/windows/pipe/tests/reconnect.rs diff --git a/support/pal/pal_async/src/windows/pipe.rs b/support/pal/pal_async/src/windows/pipe.rs index ff4f2ee7234..8ba2b96763f 100644 --- a/support/pal/pal_async/src/windows/pipe.rs +++ b/support/pal/pal_async/src/windows/pipe.rs @@ -139,10 +139,10 @@ impl PolledPipe { /// Polls the pipe for entering the closing state, where the client has /// closed its handle. pub fn poll_closing(&mut self, cx: &mut Context<'_>) -> Poll> { - if self.events & FILE_PIPE_DISCONNECTED != 0 { - // Make sure the pipe is still disconnected. - self.refresh_events()?; + if self.file.is_pipe_peer_closed()? { + return Poll::Ready(Ok(())); } + self.refresh_events()?; while self.events & FILE_PIPE_DISCONNECTED == 0 { ready!( self.wakers @@ -410,6 +410,9 @@ impl Future for ListeningPipe { fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let this = self.get_mut(); let inner = this.inner.as_mut().expect("polled after completion"); + if inner.sync_success { + return Poll::Ready(Ok(this.inner.take().unwrap().file)); + } ready!(inner.event.poll_wait(cx))?; let (status, _) = inner.overlapped.io_status().expect("io should be complete"); chk_status(status)?; @@ -419,6 +422,8 @@ impl Future for ListeningPipe { #[cfg(test)] mod tests { + mod reconnect; + use super::PolledPipe; use crate::DefaultDriver; use crate::sys::pipe::NamedPipeServer; diff --git a/support/pal/pal_async/src/windows/pipe/tests/reconnect.rs b/support/pal/pal_async/src/windows/pipe/tests/reconnect.rs new file mode 100644 index 00000000000..bac629e7876 --- /dev/null +++ b/support/pal/pal_async/src/windows/pipe/tests/reconnect.rs @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for reusing server pipes: detecting a closed client without a +//! disconnect event, and completing a listen for an already-connected +//! client. + +use crate::DefaultDriver; +use crate::interest::InterestSlot; +use crate::sys::pipe::ListeningPipe; +use crate::sys::pipe::PolledPipe; +use futures::FutureExt; +use futures::future::poll_fn; +use pal::windows::chk_status; +use pal::windows::pipe::Disposition; +use pal::windows::pipe::FILE_PIPE_DISCONNECTED; +use pal::windows::pipe::FILE_PIPE_READ_READY; +use pal::windows::pipe::FILE_PIPE_WRITE_READY; +use pal::windows::pipe::PipeExt; +use pal::windows::pipe::PipeMode; +use pal::windows::pipe::new_named_pipe; +use pal_async_test::async_test; +use std::fs::OpenOptions; +use std::io; +use std::task::Context; +use std::task::Poll; +use std::task::ready; +use std::time::Duration; +use windows_sys::Win32::Foundation::GENERIC_READ; +use windows_sys::Win32::Foundation::GENERIC_WRITE; + +impl PolledPipe { + fn test_set_cached_events(&mut self, events: u32) { + self.events = events; + } + + fn test_set_select_events(&self, event_types: u32) -> io::Result<()> { + self.file.set_pipe_select_event(&self._event, event_types) + } + + fn test_is_pipe_peer_closed(&self) -> io::Result { + self.file.is_pipe_peer_closed() + } + + fn test_poll_closing_event_only(&mut self, cx: &mut Context<'_>) -> Poll> { + self.refresh_events()?; + while self.events & FILE_PIPE_DISCONNECTED == 0 { + ready!( + self.wakers + .poll_wrapped(cx, InterestSlot::Read as usize, |cx| self + .wait + .poll_wait(cx)) + )?; + + self.refresh_events()?; + } + Poll::Ready(Ok(())) + } +} + +impl ListeningPipe { + fn test_poll_without_sync_fastpath(&mut self, cx: &mut Context<'_>) -> Poll> { + let inner = self.inner.as_mut().expect("polled after completion"); + ready!(inner.event.poll_wait(cx))?; + let (status, _) = inner.overlapped.io_status().expect("io should be complete"); + chk_status(status)?; + Poll::Ready(Ok(())) + } +} + +#[async_test] +async fn reuse_server_pipe_after_client_close(driver: DefaultDriver) { + let mut id = [0; 16]; + getrandom::fill(&mut id).unwrap(); + let path = format!(r#"\\.\pipe\{:0x}"#, u128::from_ne_bytes(id)); + let server = new_named_pipe( + &path, + GENERIC_READ | GENERIC_WRITE, + Disposition::Create, + PipeMode::Byte, + ) + .unwrap(); + + let mut listener = ListeningPipe::new(&driver, server).unwrap(); + let client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + let mut connected = PolledPipe::new(&driver, listener.await.unwrap()).unwrap(); + + drop(client); + poll_fn(|cx| connected.poll_closing(cx)).await.unwrap(); + let server = connected.into_inner(); + server.disconnect_pipe().unwrap(); + + listener = ListeningPipe::new(&driver, server).unwrap(); + let _client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + listener.await.unwrap(); +} + +#[async_test] +async fn listening_pipe_ready_when_client_already_connected(driver: DefaultDriver) { + let mut id = [0; 16]; + getrandom::fill(&mut id).unwrap(); + let path = format!(r#"\\.\pipe\{:0x}"#, u128::from_ne_bytes(id)); + let server = new_named_pipe( + &path, + GENERIC_READ | GENERIC_WRITE, + Disposition::Create, + PipeMode::Byte, + ) + .unwrap(); + + let _client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + + let mut listener = ListeningPipe::new(&driver, server).unwrap(); + let old_poll = poll_fn(|cx| listener.test_poll_without_sync_fastpath(cx)); + assert!( + old_poll.now_or_never().is_none(), + "event-only listen polling should stay pending in this setup" + ); + let server = listener + .now_or_never() + .expect("listen future should complete immediately") + .unwrap(); + assert!(server.is_pipe_connected().unwrap()); +} + +#[async_test] +async fn poll_closing_checks_pipe_state_without_disconnect_event(driver: DefaultDriver) { + let mut id = [0; 16]; + getrandom::fill(&mut id).unwrap(); + let path = format!(r#"\\.\pipe\{:0x}"#, u128::from_ne_bytes(id)); + let server = new_named_pipe( + &path, + GENERIC_READ | GENERIC_WRITE, + Disposition::Create, + PipeMode::Byte, + ) + .unwrap(); + + let listener = ListeningPipe::new(&driver, server).unwrap(); + let client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + let mut connected = PolledPipe::new(&driver, listener.await.unwrap()).unwrap(); + + connected + .test_set_select_events(FILE_PIPE_READ_READY | FILE_PIPE_WRITE_READY) + .unwrap(); + drop(client); + connected.test_set_cached_events(FILE_PIPE_READ_READY | FILE_PIPE_WRITE_READY); + for _ in 0..100 { + if connected.test_is_pipe_peer_closed().unwrap() { + break; + } + std::thread::sleep(Duration::from_millis(1)); + } + assert!(connected.test_is_pipe_peer_closed().unwrap()); + let old_poll = poll_fn(|cx| connected.test_poll_closing_event_only(cx)); + assert!( + old_poll.now_or_never().is_none(), + "event-only closing polling should stay pending when disconnected events are not selected" + ); + + let poll = poll_fn(|cx| connected.poll_closing(cx)); + let result = poll.now_or_never(); + assert!( + matches!(result, Some(Ok(()))), + "expected poll_closing to finish immediately when peer is closed" + ); +} From 88865bf90ec81e55b5c4ba7f2a89405fbda943c3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:19:12 -0700 Subject: [PATCH 021/304] virt_mshv: describe SNP guest workarounds without ACI-specific names The comments explaining the SNP CPUID overrides, the withheld reference TSC page, and the SNP launch-context handling, as well as one unit test name, refer to the Azure Container Instances (ACI) guest kernel. The workarounds apply to any SNP guest kernel that behaves this way on MSHV, so describe them in terms of the guest kernel and rename the test to parses_snp_start_vp_context. No functional change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vmm_core/virt_mshv/src/x86_64/mod.rs | 8 ++++---- vmm_core/virt_mshv/src/x86_64/snp.rs | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index fae75f93332..2a261fa0770 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -277,13 +277,13 @@ fn snp_synthetic_features() -> hvdef::HvPartitionSyntheticProcessorFeatures { .with_access_partition_reference_counter(true) .with_access_synic_regs(true) .with_access_synthetic_timer_regs(true) - // HACK: Do not advertise AccessPartitionReferenceTsc to the ACI SNP - // kernel yet. Physical Hyper-V and Cloud Hypervisor both advertise + // HACK: Do not advertise AccessPartitionReferenceTsc to SNP guests yet. + // Physical Hyper-V and Cloud Hypervisor both advertise // it, relying on the hypervisor/MSHV rather than userspace to service // HV_X64_MSR_REFERENCE_TSC and its page. OpenVMM launch-imports GPA - // 0x3402000 normally; the ACI kernel then shares it as its retained + // 0x3402000 normally; the guest kernel then shares it as its retained // Hyper-V `tsc_pg`. MSHV accepts the initial import but later reports - // that GPA as unaccepted when device I/O begins. Keeping ACI on the + // that GPA as unaccepted when device I/O begins. Keeping the guest on the // reference counter MSR makes virtio-blk write/readback work. // Re-enable this after tracing and fixing MSHV's SNP PSC/host-access // handling for the shared reference-TSC page. diff --git a/vmm_core/virt_mshv/src/x86_64/snp.rs b/vmm_core/virt_mshv/src/x86_64/snp.rs index 6986af272c1..7891790a720 100644 --- a/vmm_core/virt_mshv/src/x86_64/snp.rs +++ b/vmm_core/virt_mshv/src/x86_64/snp.rs @@ -592,10 +592,10 @@ pub(super) fn snp_hv_cpuid_overrides(native_max_leaf: u32) -> [virt::CpuidLeaf; // isolation CPUID contract from MSHV_PT_ISOLATION_SNP. Cloud Hypervisor // does not install an equivalent override, but we have not confirmed // whether its environment receives the isolation leaves correctly from - // MSHV. Without these overrides, ACI Linux does not recognize a + // MSHV. Without these overrides, the guest kernel does not recognize a // non-paravisor Hyper-V SNP guest and uses the hypercall-page overlay // instead of direct VMMCALL hypercalls. Correctly describing isolation - // also keeps ACI's restricted-injection doorbell EOI path active, making + // also keeps the restricted-injection doorbell EOI path active, making // the previous APIC-access recommendation mask unnecessary. [ // Make the isolation configuration leaf discoverable. @@ -628,8 +628,8 @@ pub(super) fn snp_start_vp_vmsa_gpa( context: &hvdef::hypercall::InitialVpContextX64, ) -> Option { let encoded = context.rip; - // TODO: Confirm this ACI-specific overload is the intended Microsoft - // Hypervisor SNP contract. ACI zeroes the nominal register context and + // TODO: Confirm this launch-context overload is the intended Microsoft + // Hypervisor SNP contract. The guest zeroes the nominal register context and // stores `vmsa_gpa | 1` in its first eight bytes for HvCallStartVP. if encoded & 1 == 0 || context.as_bytes()[size_of::()..] @@ -2075,7 +2075,7 @@ mod tests { } #[test] - fn parses_aci_snp_start_vp_context() { + fn parses_snp_start_vp_context() { let mut context = hvdef::hypercall::InitialVpContextX64::new_zeroed(); context.rip = 0x517001; assert_eq!(snp_start_vp_vmsa_gpa(&context), Some(0x517000)); From 9b3ce676027ac2065f12268194afcf2ee78dbcc2 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:21:52 -0700 Subject: [PATCH 022/304] kvm: preserve pending external interrupts across VP-state capture The legacy PIC delivers an ExtINT by queuing its vector with KVM_INTERRUPT once the interrupt window is open. KVM injects the queued vector on the next KVM_RUN but offers no way to read it back, so a VP state capture taken between the two reported no pending event, and the interrupt was lost when that state was restored. Track the vector in VpRunner from inject_extint_interrupt until KVM accepts the next run, at which point it has been injected, and expose it through VpRunner::pending_extint. virt_kvm now reports it as a pending ExtInt event when capturing the activity state. Restoring a pending ExtInt goes through VpRunner::restore_pending_extint, which queues the vector with KVM_INTERRUPT and tracks it again, so a capture taken right after a restore still sees it. An interrupt that KVM has already injected is no longer reported, so it cannot be delivered twice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/kvm/src/lib.rs | 18 ++++++++++++++++++ vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs | 10 +++++----- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/vm/kvm/src/lib.rs b/vm/kvm/src/lib.rs index af2d0dfeab5..3941dd99348 100644 --- a/vm/kvm/src/lib.rs +++ b/vm/kvm/src/lib.rs @@ -1765,6 +1765,7 @@ impl<'a> Processor<'a> { VpRunner { partition: self.0, idx: self.1, + pending_extint: None, _not_send_sync: PhantomData, } } @@ -1773,6 +1774,7 @@ impl<'a> Processor<'a> { pub struct VpRunner<'a> { partition: &'a Partition, idx: u32, + pending_extint: Option, // This type stores the current thread in `partition` and removes it in // `drop`, so don't allow sending or sharing this. _not_send_sync: PhantomData<*const u8>, @@ -1878,6 +1880,9 @@ impl<'a> VpRunner<'a> { if !self.run_vp_once()? { return Ok(Exit::Interrupted); } + // KVM accepted the run, so an extint queued while the interrupt window + // was open has now been injected before guest execution. + self.pending_extint = None; let exit = match self.run_data().exit_reason { #[cfg(target_arch = "x86_64")] @@ -2065,11 +2070,24 @@ impl<'a> VpRunner<'a> { /// [`Self::check_or_request_interrupt_window`] has returned `true`. pub fn inject_extint_interrupt(&mut self, vector: u8) -> Result<()> { self.partition.vp(self.idx).interrupt(vector.into())?; + self.pending_extint = Some(vector); // Remember that there is a pending extint interrupt. KVM will update // this field again after the VP runs. self.run_data().ready_for_interrupt_injection = 0; Ok(()) } + + /// Returns an extint queued in KVM but not yet consumed by a successful run. + pub fn pending_extint(&self) -> Option { + self.pending_extint + } + + /// Restores an extint that was queued but not injected at capture time. + pub fn restore_pending_extint(&mut self, vector: u8) -> Result<()> { + self.partition.vp(self.idx).interrupt(vector.into())?; + self.pending_extint = Some(vector); + Ok(()) + } } #[derive(Debug)] diff --git a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs index 743d04cdceb..019327b328d 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs @@ -275,7 +275,6 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { }; let events = self.kvm().get_vcpu_events()?; - // N.B. KVM has no way to get back the pending extint vector. let event = if events.exception.pending != 0 { Some(vp::PendingEvent::Exception { vector: events.exception.nr, @@ -288,7 +287,10 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { }, }) } else { - None + self.vp + .runner + .pending_extint() + .map(|vector| vp::PendingEvent::ExtInt { vector }) }; let interruption = if events.exception.injected != 0 { @@ -386,9 +388,7 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { let _ = parameter; } Some(vp::PendingEvent::ExtInt { vector }) => { - // N.B. KVM has no way to clear a pending (but non-injected) - // extint interrupt. - self.kvm().interrupt(vector.into())?; + self.vp.runner.restore_pending_extint(vector)?; } None => {} } From 072a7841f23ad3eb212516ff93e1d2568f682780 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:24:44 -0700 Subject: [PATCH 023/304] virt_mshv: make RUN_VP cancellation race-free Cancelling a running MSHV VP sends a signal to its thread so that the blocking MSHV_RUN_VP ioctl returns EINTR. The signal handler did nothing, so a signal that arrived after the VP thread last checked for pending work but before it entered the ioctl was lost. The thread then blocked in the hypervisor while SynIC work or a VP inspection waited for it. A cancellation requested by the VP thread itself had no effect at all. Add a run_vp module that owns the cancellation protocol: - A thread-local flag latches every cancellation, whether it is a signal that arrives before the ioctl or a cancellation from the VP thread itself. The next run then returns EINTR without entering the hypervisor. - The ioctl is issued from a small naked-assembly stub that checks the flag immediately before the syscall instruction. If the signal lands between that check and the syscall, the handler moves the instruction pointer to the stub's return label with EINTR as the result. - Once the syscall has returned, its result is kept, so a signal racing an intercept exit cannot discard the intercepted guest instruction. The handler is installed once with sigaction on SIGRTMIN+1, because KVM owns SIGRTMIN with a different handler contract. It uses SA_RESTART so unrelated syscalls keep restarting. The x86_64 and aarch64 VPs cancel through the shared helper, and the signal-hook dependency is dropped. Add tests for a signal before entry, same-thread cancellation, preserved syscall errors, a redirect that keeps a completed syscall result, and a signal interrupting a blocking syscall. Document how to run them, and the native MSHV boot check, in the VMM test guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 - Guide/src/dev_guide/tests/vmm.md | 24 ++ vmm_core/virt_mshv/Cargo.toml | 1 - vmm_core/virt_mshv/src/aarch64/mod.rs | 7 +- vmm_core/virt_mshv/src/lib.rs | 31 +-- vmm_core/virt_mshv/src/run_vp.rs | 371 ++++++++++++++++++++++++++ vmm_core/virt_mshv/src/x86_64/mod.rs | 7 +- 7 files changed, 406 insertions(+), 36 deletions(-) create mode 100644 vmm_core/virt_mshv/src/run_vp.rs diff --git a/Cargo.lock b/Cargo.lock index f3dc49c69ab..fba137f33b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9817,7 +9817,6 @@ dependencies = [ "pal_event", "parking_lot", "pci_core", - "signal-hook", "test_with_tracing", "thiserror 2.0.16", "tracelimit", diff --git a/Guide/src/dev_guide/tests/vmm.md b/Guide/src/dev_guide/tests/vmm.md index 6377213409f..c91a8a0031b 100644 --- a/Guide/src/dev_guide/tests/vmm.md +++ b/Guide/src/dev_guide/tests/vmm.md @@ -141,6 +141,30 @@ directory short enough for Unix socket names. CI and Windows-from-WSL2 runs still place temporary files on the test-content disk, preserving scratch-disk placement and Windows filesystem access. +### MSHV run-entry cancellation + +On Linux, run the MSHV cancellation regression tests without requiring +`/dev/mshv`: + +```bash +cargo nextest run --profile agent -p virt_mshv -E 'test(run_vp::tests)' +``` + +These tests check early and same-thread cancellation, interruption of a +blocking syscall, and the boundary that preserves an already completed syscall. +MSHV must retain a wake delivered before entering `MSHV_RUN_VP`; an interrupt +signal alone can otherwise be consumed before the blocking ioctl begins. + +On an MSHV host, also exercise the Alpine boot and guest-agent handshake: + +```bash +test='multiarch::openvmm_uefi_x64_alpine_3_23_x64_boot_small' +cargo xflowey vmm-tests-run --release --filter "test(/^${test}$/)" +``` + +Keep the normal test watchdog enabled. A timeout or an unresponsive VP +inspection must not be hidden by retries or a longer deadline. + ### Targeting a Platform By default, `vmm-tests-run` builds for the current host. Use `--target` to diff --git a/vmm_core/virt_mshv/Cargo.toml b/vmm_core/virt_mshv/Cargo.toml index 723f24bd8c2..c7349c7d672 100644 --- a/vmm_core/virt_mshv/Cargo.toml +++ b/vmm_core/virt_mshv/Cargo.toml @@ -33,7 +33,6 @@ mshv-bindings = { workspace = true, features = ["with-serde", "fam-wrappers"] } mshv-ioctls.workspace = true libc.workspace = true parking_lot.workspace = true -signal-hook.workspace = true thiserror.workspace = true tracing.workspace = true zerocopy.workspace = true diff --git a/vmm_core/virt_mshv/src/aarch64/mod.rs b/vmm_core/virt_mshv/src/aarch64/mod.rs index 73027449aa0..2c5c89d33f2 100644 --- a/vmm_core/virt_mshv/src/aarch64/mod.rs +++ b/vmm_core/virt_mshv/src/aarch64/mod.rs @@ -31,7 +31,6 @@ use hvdef::HvMessageType; use hvdef::HvPartitionPropertyCode; use hvdef::Vtl; use hvdef::hypercall::HvRegisterAssoc; -use pal::unix::pthread::Pthread; use pci_core::msi::SignalMsi; use std::sync::Arc; use virt::Hv1; @@ -260,11 +259,7 @@ impl virt::Partition for MshvPartition { if vp.needs_yield.request_yield() { let thread = vp.thread.read(); if let Some(thread) = *thread { - if thread != Pthread::current() { - thread - .signal(libc::SIGRTMIN()) - .expect("thread cancel signal failed"); - } + crate::run_vp::cancel(thread).expect("thread cancel signal failed"); } } } diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 542b2a256ee..7698f5ed8ae 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -9,6 +9,7 @@ #[cfg(guest_arch = "aarch64")] mod aarch64; +mod run_vp; #[cfg(guest_arch = "x86_64")] mod x86_64; @@ -55,7 +56,6 @@ use std::os::fd::AsFd; use std::os::fd::AsRawFd; use std::os::fd::IntoRawFd as _; use std::sync::Arc; -use std::sync::Once; use std::sync::Weak; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; @@ -189,18 +189,7 @@ impl<'a> MshvProtoPartition<'a> { }) .map_err(|e| ErrorInner::InstallIntercept(e.into()))?; - // Set up a signal for forcing vcpufd.run() to exit with EINTR. - static SIGNAL_HANDLER_INIT: Once = Once::new(); - // SAFETY: The signal handler does not perform any actions that are - // forbidden for signal handlers to perform, as it performs nothing. - SIGNAL_HANDLER_INIT.call_once(|| unsafe { - signal_hook::low_level::register(libc::SIGRTMIN(), || { - // Signal handler does nothing other than enabling run_fd() - // ioctl to return with EINTR, when the associated signal is - // sent to run_fd() thread. - }) - .unwrap(); - }); + run_vp::init().map_err(ErrorInner::RunVpSignal)?; if let Some(hv_config) = &config.hv_config { if hv_config.vtl2.is_some() { @@ -495,13 +484,8 @@ struct MshvVpRunner<'a> { } impl MshvVpRunner<'_> { - fn run(&mut self) -> Result { - self.vcpufd.run().map(|msg| { - // SAFETY: hv_message and HvMessage have the same size - // (256 bytes) and compatible layout (header + 240-byte - // payload). - unsafe { std::mem::transmute::(msg) } - }) + fn run(&mut self) -> io::Result { + run_vp::run(self.vcpufd) } #[cfg(guest_arch = "x86_64")] @@ -622,6 +606,7 @@ impl virt::Processor for MshvProcessor<'_> { dev: &impl CpuIo, ) -> Result { let vpinner = self.inner; + run_vp::prepare_thread(); let _cleaner = MshvVpInnerCleaner { vpinner }; assert!(vpinner.thread.write().replace(Pthread::current()).is_none()); @@ -669,8 +654,8 @@ impl virt::Processor for MshvProcessor<'_> { Ok(exit) => { self.handle_exit(&exit, dev).await?; } - Err(e) => match e.errno() { - libc::EAGAIN | libc::EINTR => {} + Err(e) => match e.raw_os_error() { + Some(libc::EAGAIN) | Some(libc::EINTR) => {} _ => tracing::error!( error = &e as &dyn std::error::Error, "vcpufd.run returned error" @@ -731,6 +716,8 @@ impl> From for Error { // TODO: Chunk this up into smaller types. #[derive(Error, Debug)] enum ErrorInner { + #[error("failed to initialize MSHV VP cancellation signal")] + RunVpSignal(#[source] io::Error), #[error("operation not supported")] NotSupported, #[error("create_vm failed")] diff --git a/vmm_core/virt_mshv/src/run_vp.rs b/vmm_core/virt_mshv/src/run_vp.rs new file mode 100644 index 00000000000..1b9cb590e51 --- /dev/null +++ b/vmm_core/virt_mshv/src/run_vp.rs @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Race-free cancellation of the blocking MSHV_RUN_VP ioctl. +//! +//! A signal delivered before ioctl entry must not be lost. Latch early signals +//! in thread-local storage, and redirect signals in the final check-to-syscall +//! interval to an EINTR return. Once the syscall returns, preserve its result: +//! an intercepted guest instruction must not be discarded. + +use hvdef::HvMessage; +use mshv_ioctls::VcpuFd; +use pal::unix::pthread::Pthread; +use std::io; +use std::os::fd::AsRawFd; +use std::sync::OnceLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use zerocopy::FromZeros; + +std::thread_local! { + static CANCELLED: AtomicBool = const { AtomicBool::new(false) }; +} + +fn signal() -> i32 { + // KVM owns SIGRTMIN and has a different signal-handler contract. + libc::SIGRTMIN() + 1 +} + +pub(super) fn init() -> io::Result<()> { + static INIT: OnceLock> = OnceLock::new(); + (*INIT.get_or_init(|| { + // SAFETY: zero is valid for sigaction's C fields. The handler receives + // a kernel-provided ucontext because SA_SIGINFO is set. + let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; + action.sa_sigaction = handle_signal as *const () as usize; + // Preserve restart behavior for unrelated syscalls. A restarted guarded + // syscall resumes inside the protected interval and is redirected too. + action.sa_flags = libc::SA_SIGINFO | libc::SA_RESTART; + // SAFETY: the mask and action are valid for these synchronous C calls. + let failed = unsafe { + libc::sigemptyset(&mut action.sa_mask) != 0 + || libc::sigaction(signal(), &action, std::ptr::null_mut()) != 0 + }; + if failed { + Err(io::Error::last_os_error() + .raw_os_error() + .expect("signal setup returned an OS error")) + } else { + Ok(()) + } + })) + .map_err(io::Error::from_raw_os_error) +} + +pub(super) fn prepare_thread() { + // Initialize TLS before publishing the thread as a cancellation target. + CANCELLED.with(|_| {}); +} + +pub(super) fn cancel(thread: Pthread) -> io::Result<()> { + if thread == Pthread::current() { + CANCELLED.with(|cancelled| cancelled.store(true, Ordering::Relaxed)); + Ok(()) + } else { + thread.signal(signal()) + } +} + +/// # Safety +/// Must be invoked as a SA_SIGINFO handler with a valid signal context. +unsafe extern "C" fn handle_signal( + _signal: i32, + _info: *mut libc::siginfo_t, + context: *mut libc::c_void, +) { + CANCELLED.with(|cancelled| cancelled.store(true, Ordering::Relaxed)); + // SAFETY: SA_SIGINFO supplies a valid, exclusively borrowed signal context. + let context = unsafe { &mut *context.cast::() }; + interrupt_entry(context); +} + +fn interrupt_entry(context: &mut libc::ucontext_t) { + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "x86_64")] + let ip = context.uc_mcontext.gregs[libc::REG_RIP as usize] as usize; + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "aarch64")] + let ip = context.uc_mcontext.pc as usize; + + let start = guarded_syscall as *const () as usize; + let end = openvmm_mshv_syscall_return as *const () as usize; + if (start..end).contains(&ip) { + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "x86_64")] + { + context.uc_mcontext.gregs[libc::REG_RIP as usize] = end as _; + context.uc_mcontext.gregs[libc::REG_RAX as usize] = -i64::from(libc::EINTR); + } + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "aarch64")] + { + context.uc_mcontext.pc = end as _; + context.uc_mcontext.regs[0] = (-i64::from(libc::EINTR)) as _; + } + } +} + +unsafe extern "C" { + fn openvmm_mshv_syscall_return(); +} + +// These are host syscall ABIs, not guest register layouts. The naked functions +// never change the stack or callee-saved registers, so the signal handler can +// return directly from any instruction before the syscall's return boundary. +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(target_arch = "x86_64")] +#[unsafe(naked)] +/// # Safety +/// Arguments must satisfy the syscall ABI. The cancellation pointer must refer +/// to a live AtomicBool initialized on the calling thread. +unsafe extern "C" fn guarded_syscall( + _number: libc::c_long, + _arg0: usize, + _arg1: usize, + _arg2: usize, + _cancelled: *const bool, +) -> libc::c_long { + core::arch::naked_asm!( + "mov rax, rdi", + "mov rdi, rsi", + "mov rsi, rdx", + "mov rdx, rcx", + "cmp byte ptr [r8], 0", + "jne 2f", + "syscall", + ".global openvmm_mshv_syscall_return", + ".hidden openvmm_mshv_syscall_return", + "openvmm_mshv_syscall_return:", + "ret", + "2:", + "mov rax, -{eintr}", + "ret", + eintr = const libc::EINTR, + ); +} + +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(target_arch = "aarch64")] +#[unsafe(naked)] +/// # Safety +/// Arguments must satisfy the syscall ABI. The cancellation pointer must refer +/// to a live AtomicBool initialized on the calling thread. +unsafe extern "C" fn guarded_syscall( + _number: libc::c_long, + _arg0: usize, + _arg1: usize, + _arg2: usize, + _cancelled: *const bool, +) -> libc::c_long { + core::arch::naked_asm!( + "mov x8, x0", + "mov x0, x1", + "mov x1, x2", + "mov x2, x3", + "ldarb w9, [x4]", + "cbnz w9, 2f", + "svc #0", + ".global openvmm_mshv_syscall_return", + ".hidden openvmm_mshv_syscall_return", + "openvmm_mshv_syscall_return:", + "ret", + "2:", + "mov x0, #-{eintr}", + "ret", + eintr = const libc::EINTR, + ); +} + +/// # Safety +/// The caller must provide valid arguments and pointers for the syscall. +unsafe fn syscall(number: libc::c_long, args: [usize; 3]) -> io::Result { + CANCELLED.with(|cancelled| { + if cancelled.swap(false, Ordering::Relaxed) { + return Err(io::Error::from_raw_os_error(libc::EINTR)); + } + // SAFETY: the caller provides the syscall arguments; the cancellation + // flag is live and is read atomically by the guarded assembly. + let result = + unsafe { guarded_syscall(number, args[0], args[1], args[2], cancelled.as_ptr()) }; + if result < 0 { + Err(io::Error::from_raw_os_error(-result as i32)) + } else { + Ok(result) + } + }) +} + +pub(super) fn run(vcpu: &VcpuFd) -> io::Result { + const { + assert!(size_of::() == size_of::()); + } + let mut message = HvMessage::new_zeroed(); + // SAFETY: the borrowed VP fd remains open, and the kernel may write one + // complete intercept message to this live, correctly sized output buffer. + let result = unsafe { + syscall( + libc::SYS_ioctl, + [ + vcpu.as_raw_fd() as usize, + mshv_ioctls::MSHV_RUN_VP() as usize, + std::ptr::from_mut(&mut message) as usize, + ], + )? + }; + if result != 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("MSHV_RUN_VP returned unexpected result {result}"), + )); + } + Ok(message) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use std::os::unix::net::UnixStream; + use std::sync::mpsc; + use std::time::Duration; + use test_with_tracing::test; + + fn initialize() { + init().unwrap(); + prepare_thread(); + CANCELLED.with(|cancelled| cancelled.store(false, Ordering::Relaxed)); + } + + fn getpid() -> io::Result { + // SAFETY: getpid has no pointer arguments or memory side effects. + unsafe { syscall(libc::SYS_getpid, [0; 3]) } + } + + #[test] + fn signal_before_entry_is_latched() { + initialize(); + Pthread::current().signal(signal()).unwrap(); + assert_eq!(getpid().unwrap_err().raw_os_error(), Some(libc::EINTR)); + assert_eq!(getpid().unwrap(), i64::from(std::process::id())); + } + + #[test] + fn same_thread_cancellation_is_latched() { + initialize(); + cancel(Pthread::current()).unwrap(); + assert_eq!(getpid().unwrap_err().raw_os_error(), Some(libc::EINTR)); + assert_eq!(getpid().unwrap(), i64::from(std::process::id())); + } + + #[test] + fn syscall_errors_are_preserved() { + initialize(); + // SAFETY: -1 is an invalid fd; close cannot release a live descriptor. + let result = unsafe { syscall(libc::SYS_close, [usize::MAX, 0, 0]) }; + assert_eq!(result.unwrap_err().raw_os_error(), Some(libc::EBADF)); + } + + #[test] + fn entry_redirect_preserves_completed_syscalls() { + let start = guarded_syscall as *const () as usize; + let end = openvmm_mshv_syscall_return as *const () as usize; + assert!(start < end); + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + let restarted_syscall_ip = end - 2; + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "aarch64")] + let restarted_syscall_ip = end - 4; + for (ip, interrupted) in [ + (start - 1, false), + (start, true), + (restarted_syscall_ip, true), + (end - 1, true), + (end, false), + (end + 1, false), + ] { + // SAFETY: zero is valid for this C structure; the test accesses + // only its integer instruction-pointer and return-value fields. + let mut context: libc::ucontext_t = unsafe { std::mem::zeroed() }; + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "x86_64")] + { + context.uc_mcontext.gregs[libc::REG_RIP as usize] = ip as _; + context.uc_mcontext.gregs[libc::REG_RAX as usize] = 123; + } + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "aarch64")] + { + context.uc_mcontext.pc = ip as _; + context.uc_mcontext.regs[0] = 123; + } + interrupt_entry(&mut context); + let expected_ip = if interrupted { end } else { ip }; + let expected_result = if interrupted { + -i64::from(libc::EINTR) + } else { + 123 + }; + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "x86_64")] + { + assert_eq!( + context.uc_mcontext.gregs[libc::REG_RIP as usize] as usize, + expected_ip + ); + assert_eq!( + context.uc_mcontext.gregs[libc::REG_RAX as usize], + expected_result + ); + } + // xtask-fmt allow-target-arch sys-crate + #[cfg(target_arch = "aarch64")] + { + assert_eq!(context.uc_mcontext.pc as usize, expected_ip); + assert_eq!(context.uc_mcontext.regs[0] as i64, expected_result); + } + } + } + + #[test] + fn signal_interrupts_blocking_syscall() { + init().unwrap(); + let (reader, mut writer) = UnixStream::pair().unwrap(); + let (ready_send, ready_recv) = mpsc::channel(); + let (done_send, done_recv) = mpsc::channel(); + let task = std::thread::spawn(move || { + initialize(); + ready_send.send(Pthread::current()).unwrap(); + let mut byte = 0u8; + // SAFETY: the socket stays open and the byte is a live output + // buffer for a one-byte read throughout the blocking syscall. + let result = unsafe { + syscall( + libc::SYS_read, + [ + reader.as_raw_fd() as usize, + std::ptr::from_mut(&mut byte) as usize, + 1, + ], + ) + }; + done_send.send(result).unwrap(); + }); + cancel(ready_recv.recv().unwrap()).unwrap(); + let result = done_recv.recv_timeout(Duration::from_secs(2)); + if matches!(result, Err(mpsc::RecvTimeoutError::Timeout)) { + // Release a regressed read before failing instead of leaking a + // blocked test thread. + writer.write_all(&[0]).unwrap(); + } + task.join().unwrap(); + assert_eq!( + result + .expect("cancellation did not interrupt read") + .unwrap_err() + .raw_os_error(), + Some(libc::EINTR) + ); + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 2a261fa0770..d4c00db5324 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -36,7 +36,6 @@ use hvdef::hypercall::HvRegisterAssoc; use memory_range::MemoryRange; use mshv_ioctls::InterruptRequest; use mshv_ioctls::VcpuFd; -use pal::unix::pthread::Pthread; use parking_lot::Mutex; use pci_core::msi::SignalMsi; use std::os::fd::AsRawFd; @@ -657,11 +656,7 @@ impl virt::Partition for MshvPartition { if vp.needs_yield.request_yield() { let thread = vp.thread.read(); if let Some(thread) = *thread { - if thread != Pthread::current() { - thread - .signal(libc::SIGRTMIN()) - .expect("thread cancel signal failed"); - } + crate::run_vp::cancel(thread).expect("thread cancel signal failed"); } } } From 752ff3ab570c6b6c7a38017316efc1a5a96e335b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:39:58 -0700 Subject: [PATCH 024/304] virt: add Partition::finalize_memory; create the MSHV BSP after finalization On x86_64, the MSHV backend creates the BSP vCPU while building the partition, before any guest memory is registered. The MSHV driver makes vCPU creation much more expensive in that state: on a 128 MiB guest, MSHV_CREATE_VCPU takes about 10 ms when it precedes RAM registration and about 0.3 ms when it follows it. That cost is paid on every launch and every restore. Add Partition::finalize_memory, a hook the VMM calls once all guest memory is attached. It defaults to doing nothing. openvmm_core calls it right after attaching VTL0 (and VTL2) memory, and tmk_vmm does the same. MSHV on x86_64 now creates the BSP in finalize_memory and discovers the partition capabilities from it there. The creation settings needed for that step (the CPUID leaves, the processor topology, and whether Hyper-V enlightenments are configured) are kept in a CreationConfig. The BSP and the capabilities live in a finalized state that is set exactly once: - Finalizing without attached memory, or finalizing twice, is an error. - Paths that need the BSP or the capabilities (VM and VP state access, SNP launch, APIC reset values) go through the finalized state. - The SNP hypervisor CPUID override reads the native maximum leaf from the configured CPUID leaves, since no vCPU can be queried yet. aarch64 keeps creating its BSP up front. Also log how long MSHV_SET_GUEST_MEMORY and MSHV_CREATE_VCPU take, so the effect of the ordering stays visible in traces. Add unit tests for the once-only finalization rules and early access to the finalized state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/partition.rs | 8 + openvmm/openvmm_core/src/worker/dispatch.rs | 13 ++ tmk/tmk_vmm/src/host_vmm.rs | 4 + vmm_core/virt/src/generic.rs | 5 + vmm_core/virt_mshv/src/lib.rs | 24 ++- vmm_core/virt_mshv/src/timing.rs | 27 +++ vmm_core/virt_mshv/src/x86_64/finalize.rs | 222 ++++++++++++++++++++ vmm_core/virt_mshv/src/x86_64/mod.rs | 82 +++----- vmm_core/virt_mshv/src/x86_64/snp.rs | 14 +- vmm_core/virt_mshv/src/x86_64/vm_state.rs | 4 +- vmm_core/virt_mshv/src/x86_64/vp_state.rs | 6 +- 11 files changed, 341 insertions(+), 68 deletions(-) create mode 100644 vmm_core/virt_mshv/src/timing.rs create mode 100644 vmm_core/virt_mshv/src/x86_64/finalize.rs diff --git a/openvmm/openvmm_core/src/partition.rs b/openvmm/openvmm_core/src/partition.rs index e289fdacd85..3fcd9eba7c0 100644 --- a/openvmm/openvmm_core/src/partition.rs +++ b/openvmm/openvmm_core/src/partition.rs @@ -55,6 +55,9 @@ use vmm_core::partition_unit::VpRunner; /// A base partition, with methods needed at rutnime along with methods to initialize the vm. pub trait HvlitePartition: Inspect + Send + Sync + RequestYield { + /// Completes backend partition initialization after guest memory is attached. + fn finalize_memory(&self) -> anyhow::Result<()>; + /// Gets a line set target to trigger local APIC LINTs. /// /// The line number is the VP index times 2, plus the LINT number (0 or 1). @@ -187,6 +190,11 @@ impl HvlitePartition for T where T: BasicPartitionStateAccess + ArchPartition + PartitionMemoryMapper + PartitionAccessState, { + fn finalize_memory(&self) -> anyhow::Result<()> { + Partition::finalize_memory(self)?; + Ok(()) + } + #[cfg(guest_arch = "x86_64")] fn into_lint_target(self: Arc, vtl: Vtl) -> Arc { Arc::new(vmm_core::emuplat::apic::ApicLintLineTarget::new(self, vtl)) diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 4299a26ab46..59e799e7d7e 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1559,6 +1559,19 @@ impl InitializedVm { .context("failed to attach memory to VTL2")?; } + let finalize_result = { + let _span = tracing::info_span!("post-memory partition finalization").entered(); + let started = std::time::Instant::now(); + let result = partition.finalize_memory(); + tracing::info!( + elapsed_us = started.elapsed().as_micros() as u64, + success = result.is_ok(), + "post-memory partition finalization completed" + ); + result + }; + finalize_result.context("failed to finalize partition memory")?; + Ok(Self { partition, vps, diff --git a/tmk/tmk_vmm/src/host_vmm.rs b/tmk/tmk_vmm/src/host_vmm.rs index dec2de4bb41..6ee52ed4622 100644 --- a/tmk/tmk_vmm/src/host_vmm.rs +++ b/tmk/tmk_vmm/src/host_vmm.rs @@ -87,6 +87,10 @@ impl RunContext<'_> { }?; } + partition + .finalize_memory() + .context("failed to finalize partition memory")?; + let mut threads = Vec::new(); let r = self .run( diff --git a/vmm_core/virt/src/generic.rs b/vmm_core/virt/src/generic.rs index 114b3f713ae..a6ffa457220 100644 --- a/vmm_core/virt/src/generic.rs +++ b/vmm_core/virt/src/generic.rs @@ -526,6 +526,11 @@ pub trait Partition: 'static + Hv1 + Inspect + Send + Sync { /// Returns the source of the initial virtual processor state. fn initial_vp_state_source(&self) -> InitialVpStateSource; + /// Completes partition initialization after all guest memory is attached. + fn finalize_memory(&self) -> Result<(), Self::Error> { + Ok(()) + } + /// Returns a trait object for initial page imports during the initial start /// flow. fn supports_initial_page_acceptance( diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 7698f5ed8ae..3868b4f6b37 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -10,6 +10,7 @@ #[cfg(guest_arch = "aarch64")] mod aarch64; mod run_vp; +mod timing; #[cfg(guest_arch = "x86_64")] mod x86_64; @@ -143,6 +144,7 @@ impl<'a> MshvProtoPartition<'a> { /// Performs the post-init partition setup common to both architectures: /// creates VPs, BSP, installs intercepts, sets up the signal handler, /// and checks for unsupported VTL2 configuration. + /// On x86_64, the BSP is instead created when partition memory is finalized. fn new(config: ProtoPartitionConfig<'a>, vmfd: VmFd) -> Result { if config.processor_topology.vp_count() > u8::MAX as u32 { return Err(ErrorInner::TooManyVps(config.processor_topology.vp_count()).into()); @@ -161,6 +163,7 @@ impl<'a> MshvProtoPartition<'a> { }) .collect(); + #[cfg(guest_arch = "aarch64")] let bsp = vmfd .create_vcpu(0) .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; @@ -203,6 +206,7 @@ impl<'a> MshvProtoPartition<'a> { isolation: arch::MshvProtoPartitionIsolation::None, vmfd, vps, + #[cfg(guest_arch = "aarch64")] bsp, }) } @@ -224,6 +228,7 @@ pub struct MshvProtoPartition<'a> { isolation: arch::MshvProtoPartitionIsolation, vmfd: VmFd, vps: Vec, + #[cfg(guest_arch = "aarch64")] bsp: VcpuFd, } @@ -296,8 +301,12 @@ struct MshvPartitionInner { vmfd: VmFd, /// The BSP's VcpuFd, retained for partition-level register access /// (VM state get/set). Only used while VPs are stopped. + #[cfg(guest_arch = "aarch64")] #[inspect(skip)] bsp_vcpufd: VcpuFd, + #[cfg(guest_arch = "x86_64")] + #[inspect(rename = "memory_finalized", with = "|x| x.get().is_some()")] + finalized: std::sync::OnceLock, #[inspect(skip)] memory: Mutex, gm: GuestMemory, @@ -308,9 +317,13 @@ struct MshvPartitionInner { irq_routes: virt::irqcon::IrqRoutes, #[inspect(skip)] gsi_states: Mutex>, + #[cfg(guest_arch = "aarch64")] caps: virt::PartitionCapabilities, synic_ports: virt::synic::SynicPortMap, #[cfg(guest_arch = "x86_64")] + #[inspect(flatten)] + config: arch::finalize::CreationConfig, + #[cfg(guest_arch = "x86_64")] software_devices: virt::x86::apic_software_device::ApicSoftwareDevices, #[inspect(skip)] isolation: MshvIsolationState, @@ -732,6 +745,15 @@ enum ErrorInner { #[cfg(guest_arch = "x86_64")] #[error(transparent)] Snp(#[from] arch::SnpError), + #[cfg(guest_arch = "x86_64")] + #[error("guest memory is not attached")] + GuestMemoryNotAttached, + #[cfg(guest_arch = "x86_64")] + #[error("partition memory has not been finalized")] + PartitionNotFinalized, + #[cfg(guest_arch = "x86_64")] + #[error("partition memory was already finalized")] + PartitionAlreadyFinalized, #[error("vtl2 not supported")] Vtl2NotSupported, #[error("isolation not supported")] @@ -993,7 +1015,7 @@ impl virt::PartitionMemoryMap for MshvPartitionInner { exec, ) .entered(); - let mapped = self.isolation.map_user_memory(&self.vmfd, mem_region)?; + let mapped = self.isolation.map_memory_timed(&self.vmfd, mem_region)?; state.ranges[slot_to_use] = Some(MshvMemoryRange { region: mem_region, mapped, diff --git a/vmm_core/virt_mshv/src/timing.rs b/vmm_core/virt_mshv/src/timing.rs new file mode 100644 index 00000000000..9d719928a15 --- /dev/null +++ b/vmm_core/virt_mshv/src/timing.rs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Timing diagnostics for MSHV guest memory registration. + +use crate::MshvIsolationState; +use mshv_bindings::mshv_user_mem_region; +use mshv_ioctls::VmFd; + +impl MshvIsolationState { + /// Maps user memory like `map_user_memory` and emits an + /// `MSHV_SET_GUEST_MEMORY completed` event with the elapsed time. + pub(crate) fn map_memory_timed( + &self, + vmfd: &VmFd, + region: mshv_user_mem_region, + ) -> anyhow::Result { + let started = std::time::Instant::now(); + let result = self.map_user_memory(vmfd, region); + tracing::info!( + elapsed_us = started.elapsed().as_micros() as u64, + success = result.is_ok(), + "MSHV_SET_GUEST_MEMORY completed" + ); + result + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/finalize.rs b/vmm_core/virt_mshv/src/x86_64/finalize.rs new file mode 100644 index 00000000000..2d186611a53 --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/finalize.rs @@ -0,0 +1,222 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Partition finalization after guest memory is attached. +//! +//! On x86_64 the BSP is created only after all guest memory is attached. The +//! partition capabilities are then discovered from the BSP, so they are not +//! available before [`MshvPartitionInner::finalize_memory`] completes. + +use super::hv1_reference_tsc_page_supported; +use crate::Error; +use crate::ErrorInner; +use crate::MshvPartitionInner; +use inspect::Inspect; +use mshv_ioctls::VcpuFd; +use std::sync::OnceLock; +use virt::ProtoPartitionConfig; + +/// Partition state that exists only after memory finalization. +pub(crate) struct MshvFinalizedPartition { + pub(super) bsp_vcpufd: VcpuFd, + pub(super) caps: virt::PartitionCapabilities, +} + +/// Partition creation settings that are needed after the partition is built. +#[derive(Inspect)] +pub(crate) struct CreationConfig { + pub(super) cpuid: virt::CpuidLeafSet, + #[inspect(skip)] + pub(super) processor_topology: vm_topology::processor::ProcessorTopology, + pub(super) hv_configured: bool, +} + +impl CreationConfig { + pub(super) fn new(cpuid: virt::CpuidLeafSet, config: &ProtoPartitionConfig<'_>) -> Self { + Self { + cpuid, + processor_topology: (*config.processor_topology).clone(), + hv_configured: config.hv_config.is_some(), + } + } +} + +fn finalized_partition(finalized: &OnceLock) -> Result<&T, Error> { + finalized + .get() + .ok_or_else(|| ErrorInner::PartitionNotFinalized.into()) +} + +fn finalize_memory_once( + finalized: &OnceLock, + memory_attached: bool, + initialize: impl FnOnce() -> Result, +) -> Result<(), Error> { + if finalized.get().is_some() { + return Err(ErrorInner::PartitionAlreadyFinalized.into()); + } + if !memory_attached { + return Err(ErrorInner::GuestMemoryNotAttached.into()); + } + + finalized + .set(initialize()?) + .map_err(|_| ErrorInner::PartitionAlreadyFinalized.into()) +} + +/// Returns the hypervisor maximum CPUID leaf for the SNP hypervisor CPUID +/// overrides. +/// +/// This reads the configured CPUID results, since the BSP that could be +/// queried does not exist until memory is finalized. +pub(super) fn snp_native_hv_max_leaf(cpuid: &[virt::CpuidLeaf]) -> u32 { + cpuid + .iter() + .find(|leaf| { + leaf.function == hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION + && leaf.index.unwrap_or(0) == 0 + }) + .map(|leaf| leaf.result[0]) + .unwrap_or(hvdef::HV_CPUID_FUNCTION_MS_HV_ISOLATION_CONFIGURATION) +} + +impl MshvPartitionInner { + pub(super) fn finalized(&self) -> Result<&MshvFinalizedPartition, Error> { + finalized_partition(&self.finalized) + } + + pub(super) fn caps(&self) -> &virt::PartitionCapabilities { + &self + .finalized + .get() + .expect("partition memory must be finalized before capability access") + .caps + } + + pub(super) fn isolation_type(&self) -> virt::IsolationType { + if self.isolation.snp().is_some() { + virt::IsolationType::Snp + } else { + virt::IsolationType::None + } + } + + /// Creates the BSP and discovers the partition capabilities, once guest + /// memory is attached. + pub(super) fn finalize_memory(&self) -> Result<(), Error> { + let memory_attached = self.memory.lock().ranges.iter().any(Option::is_some); + + finalize_memory_once(&self.finalized, memory_attached, || { + let _span = tracing::info_span!("mshv create BSP", vp_index = 0).entered(); + let started = std::time::Instant::now(); + let result = self.vmfd.create_vcpu(0); + tracing::info!( + elapsed_us = started.elapsed().as_micros() as u64, + success = result.is_ok(), + "MSHV_CREATE_VCPU completed" + ); + let bsp_vcpufd = result.map_err(|e| ErrorInner::CreateVcpu(e.into()))?; + let caps = self.build_caps(&bsp_vcpufd)?; + Ok(MshvFinalizedPartition { bsp_vcpufd, caps }) + }) + } + + fn build_caps(&self, bsp: &VcpuFd) -> Result { + let mut cpuid_error = None; + let cpuid_caps = virt::PartitionCapabilities::from_cpuid( + &self.config.processor_topology, + &mut |function, index| { + bsp.get_cpuid_values(function, index, 0, 0) + .unwrap_or_else(|error| { + cpuid_error.get_or_insert(error); + [0; 4] + }) + }, + ); + let mut caps = match (cpuid_caps, cpuid_error) { + (Ok(caps), None) => caps, + (result, error) => { + tracing::warn!( + error = error.as_ref().map(|error| error as &dyn std::error::Error), + capabilities_error = result + .err() + .as_ref() + .map(|error| error as &dyn std::error::Error), + "failed to query CPUID capabilities, falling back to partition properties; some features may be unavailable" + ); + self.caps_from_properties(bsp)? + } + }; + caps.hv1 = self.config.hv_configured; + caps.hv1_reference_tsc_page = hv1_reference_tsc_page_supported( + caps.hv1, + self.isolation_type(), + caps.hv1_reference_tsc_page, + ); + caps.xsaves_state_bv_broken = true; + caps.can_freeze_time = true; + Ok(caps) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + #[test] + fn finalization_requires_attached_memory_and_runs_once() { + let finalized = OnceLock::new(); + let events = RefCell::new(Vec::new()); + + let result = finalize_memory_once(&finalized, false, || { + events.borrow_mut().push("backend initialized"); + Ok(42) + }); + assert!(matches!( + result, + Err(Error(ErrorInner::GuestMemoryNotAttached)) + )); + assert!(events.borrow().is_empty()); + + events.borrow_mut().push("memory attached"); + finalize_memory_once(&finalized, true, || { + events.borrow_mut().push("backend initialized"); + Ok(42) + }) + .unwrap(); + assert_eq!( + events.into_inner(), + ["memory attached", "backend initialized"] + ); + assert_eq!(finalized_partition(&finalized).unwrap(), &42); + + let result = finalize_memory_once(&finalized, true, || Ok(43)); + assert!(matches!( + result, + Err(Error(ErrorInner::PartitionAlreadyFinalized)) + )); + assert_eq!(finalized_partition(&finalized).unwrap(), &42); + } + + #[test] + fn finalization_propagates_backend_failure() { + let finalized = OnceLock::<()>::new(); + + let result = + finalize_memory_once(&finalized, true, || Err(ErrorInner::NotSupported.into())); + + assert!(matches!(result, Err(Error(ErrorInner::NotSupported)))); + assert!(finalized.get().is_none()); + } + + #[test] + fn finalized_state_rejects_early_access() { + let finalized = OnceLock::<()>::new(); + + assert!(matches!( + finalized_partition(&finalized), + Err(Error(ErrorInner::PartitionNotFinalized)) + )); + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index d4c00db5324..eb55557786b 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -3,6 +3,7 @@ //! x86_64-specific implementation of the mshv hypervisor backend. +pub(crate) mod finalize; mod vm_state; mod vp_state; @@ -333,10 +334,13 @@ fn validate_snp_cpuid_offload_config( Ok(()) } -impl MshvProtoPartition<'_> { +impl MshvPartitionInner { /// Build partition capabilities from partition properties instead of /// CPUID. - fn caps_from_properties(&self) -> Result { + fn caps_from_properties( + &self, + bsp: &VcpuFd, + ) -> Result { use virt::x86::X86PartitionCapabilities; use virt::x86::XsaveCapabilities; use x86defs::cpuid::Vendor; @@ -364,12 +368,11 @@ impl MshvProtoPartition<'_> { .get_partition_property(HvPartitionPropertyCode::MaxXsaveDataSize.0) .map_err(|e| ErrorInner::GetPartitionProperty(e.into()))?; - let reset_rdx = if self.config.isolation.isolation_type() == virt::IsolationType::Snp { + let reset_rdx = if self.isolation_type() == virt::IsolationType::Snp { 0 } else { let mut assoc = [HvRegisterAssoc::from((HvX64RegisterName::Rdx, 0u64))]; - self.bsp - .get_hvdef_regs(&mut assoc) + bsp.get_hvdef_regs(&mut assoc) .map_err(ErrorInner::Register)?; assoc[0].value.as_u64() }; @@ -384,13 +387,13 @@ impl MshvProtoPartition<'_> { vm_topology::processor::x86::ApicMode::X2ApicEnabled ); - let hv1 = self.config.hv_config.is_some(); + let hv1 = self.config.hv_configured; Ok(X86PartitionCapabilities { vendor, hv1, hv1_reference_tsc_page: hv1_reference_tsc_page_supported( hv1, - self.config.isolation.isolation_type(), + self.isolation_type(), true, ), xsave: XsaveCapabilities { @@ -421,7 +424,9 @@ impl MshvProtoPartition<'_> { nested_virt: false, }) } +} +impl MshvProtoPartition<'_> { fn max_physical_address_size(&self) -> u8 { self.vmfd .get_partition_property(HvPartitionPropertyCode::PhysicalAddressWidth.0) @@ -466,11 +471,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { let expose_hypervisor = self.config.hv_config.is_some(); cpuid.extend(snp_cpuid_overrides(expose_hypervisor)); if expose_hypervisor { - let native_max_leaf = self - .bsp - .get_cpuid_values(hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION, 0, 0, 0) - .map(|values| values[0]) - .unwrap_or(hvdef::HV_CPUID_FUNCTION_MS_HV_ISOLATION_CONFIGURATION); + let native_max_leaf = finalize::snp_native_hv_max_leaf(&cpuid); cpuid.extend(snp_hv_cpuid_overrides(native_max_leaf)); } } @@ -514,44 +515,6 @@ impl ProtoPartition for MshvProtoPartition<'_> { .map_err(|e| ErrorInner::RegisterCpuid(e.into()))?; } - let caps = { - let mut cpuid_error = None; - let cpuid_caps = virt::PartitionCapabilities::from_cpuid( - self.config.processor_topology, - &mut |function, index| { - self.bsp - .get_cpuid_values(function, index, 0, 0) - .unwrap_or_else(|err| { - cpuid_error.get_or_insert(err); - [0; 4] - }) - }, - ); - let mut caps = match (cpuid_caps, cpuid_error) { - (Ok(caps), None) => caps, - (result, error) => { - tracing::warn!( - error = error.as_ref().map(|err| err as &dyn std::error::Error), - capabilities_error = result - .err() - .as_ref() - .map(|err| err as &dyn std::error::Error), - "failed to query CPUID capabilities, falling back to partition properties; some features may be unavailable" - ); - self.caps_from_properties()? - } - }; - caps.hv1 = self.config.hv_config.is_some(); - caps.hv1_reference_tsc_page = hv1_reference_tsc_page_supported( - caps.hv1, - self.config.isolation.isolation_type(), - caps.hv1_reference_tsc_page, - ); - caps.xsaves_state_bv_broken = true; - caps.can_freeze_time = true; - caps - }; - let apic_id_map = self .config .processor_topology @@ -572,7 +535,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { let time_frozen = isolation.is_isolated(); let inner = Arc::new(MshvPartitionInner { vmfd: self.vmfd, - bsp_vcpufd: self.bsp, + finalized: Default::default(), memory: Default::default(), gm: config.guest_memory.clone(), mem_layout: config.mem_layout.clone(), @@ -581,8 +544,8 @@ impl ProtoPartition for MshvProtoPartition<'_> { gsi_states: Mutex::new(Box::new( [crate::irqfd::GsiState::Unallocated; crate::irqfd::NUM_GSIS], )), - caps, synic_ports: Default::default(), + config: finalize::CreationConfig::new(cpuid, &self.config), software_devices: ApicSoftwareDevices::new(apic_id_map), isolation, // SNP partition creation set TimeFreeze=1 before this object was built. @@ -616,6 +579,10 @@ impl virt::Partition for MshvPartition { self.inner.isolation.initial_vp_state_source() } + fn finalize_memory(&self) -> Result<(), Self::Error> { + self.inner.finalize_memory() + } + fn supports_initial_page_acceptance( &self, ) -> Option<&dyn virt::AcceptInitialPages> { @@ -636,7 +603,7 @@ impl virt::Partition for MshvPartition { } fn caps(&self) -> &virt::PartitionCapabilities { - &self.inner.caps + self.inner.caps() } fn request_msi(&self, _vtl: Vtl, request: MsiRequest) { @@ -782,9 +749,10 @@ impl virt::BindProcessor for MshvProcessorBinder { fn bind(&mut self) -> Result, Self::Error> { let inner = &self.partition.vps[self.vpindex.index() as usize]; + let finalized = self.partition.finalized()?; let vcpufd = if self.vpindex.is_bsp() { - &self.partition.bsp_vcpufd + &finalized.bsp_vcpufd } else { if self.vcpufd.is_none() { let vcpufd = self @@ -833,7 +801,7 @@ impl virt::BindProcessor for MshvProcessorBinder { if this.partition.isolation.snp().is_none() { // Set the APIC state. let apic_base = - virt::vp::Apic::at_reset(&this.partition.caps, &this.inner.vp_info).apic_base; + virt::vp::Apic::at_reset(&finalized.caps, &this.inner.vp_info).apic_base; let regs = &[ HvRegisterAssoc::from(( @@ -847,7 +815,7 @@ impl virt::BindProcessor for MshvProcessorBinder { )), ]; - let reg_count = if this.partition.caps.x2apic { 2 } else { 3 }; + let reg_count = if finalized.caps.x2apic { 2 } else { 3 }; vcpufd .set_hvdef_regs(®s[..reg_count]) @@ -1006,7 +974,7 @@ impl EmulatorSupport for MshvEmulationState<'_> { } fn vendor(&self) -> x86defs::cpuid::Vendor { - self.partition.caps.vendor + self.partition.caps().vendor } fn gp(&mut self, reg: x86emu::Gp) -> u64 { diff --git a/vmm_core/virt_mshv/src/x86_64/snp.rs b/vmm_core/virt_mshv/src/x86_64/snp.rs index 7891790a720..f184f4c6e92 100644 --- a/vmm_core/virt_mshv/src/x86_64/snp.rs +++ b/vmm_core/virt_mshv/src/x86_64/snp.rs @@ -773,7 +773,8 @@ impl MshvPartitionInner { let sev_control = mshv_bindings::snp::get_sev_control_register(vmsa_gpa / hvdef::HV_PAGE_SIZE); - self.bsp_vcpufd + self.finalized()? + .bsp_vcpufd .set_hvdef_regs(&[HvRegisterAssoc::from(( HvX64RegisterName::SevControl, sev_control, @@ -791,7 +792,8 @@ impl MshvPartitionInner { if count > x86defs::snp::HV_PSP_CPUID_LEAF_COUNT_MAX { return Err(SnpError::TooManyCpuidEntries(count).into()); } - if self.caps.hv1 { + let finalized = self.finalized()?; + if finalized.caps.hv1 { // TODO: Determine the correct long-term strategy for exposing // synthetic Hyper-V CPUID leaves to direct-boot SNP guests: include // them in the measured CPUID page, rely on GHCB CPUID fallback, or @@ -826,12 +828,12 @@ impl MshvPartitionInner { for leaf in &mut page.cpuid_leaf_info[..count] { let values = get_snp_cpuid_values( - &self.bsp_vcpufd, + &finalized.bsp_vcpufd, leaf.eax_in, leaf.ecx_in, leaf.xfem_in, leaf.xss_in, - self.caps.hv1, + finalized.caps.hv1, ) .map_err(|e| SnpError::Cpuid(e.into()))?; leaf.eax_out = values[0]; @@ -1346,7 +1348,7 @@ impl MshvProcessor<'_> { 0, 0, 0, - self.partition.caps.hv1, + self.partition.caps().hv1, ) .map_err(|err| { tracelimit::error_ratelimited!( @@ -1525,7 +1527,7 @@ impl MshvProcessor<'_> { index, xfem, xss, - self.partition.caps.hv1, + self.partition.caps().hv1, ) .map_err(|err| { tracelimit::error_ratelimited!( diff --git a/vmm_core/virt_mshv/src/x86_64/vm_state.rs b/vmm_core/virt_mshv/src/x86_64/vm_state.rs index a956e7cccbb..588c9e283f7 100644 --- a/vmm_core/virt_mshv/src/x86_64/vm_state.rs +++ b/vmm_core/virt_mshv/src/x86_64/vm_state.rs @@ -33,6 +33,7 @@ impl MshvPartition { }); self.inner + .finalized()? .bsp_vcpufd .get_hvdef_regs(&mut assoc[..]) .map_err(ErrorInner::Register)?; @@ -54,6 +55,7 @@ impl MshvPartition { regs.get_values(assoc.iter_mut().map(|assoc| &mut assoc.value)); self.inner + .finalized()? .bsp_vcpufd .set_hvdef_regs(&assoc[..]) .map_err(ErrorInner::Register)?; @@ -65,7 +67,7 @@ impl AccessVmState for &'_ MshvPartition { type Error = Error; fn caps(&self) -> &virt::PartitionCapabilities { - &self.inner.caps + self.inner.caps() } fn commit(&mut self) -> Result<(), Self::Error> { diff --git a/vmm_core/virt_mshv/src/x86_64/vp_state.rs b/vmm_core/virt_mshv/src/x86_64/vp_state.rs index 50c4527632d..90d44bc737c 100644 --- a/vmm_core/virt_mshv/src/x86_64/vp_state.rs +++ b/vmm_core/virt_mshv/src/x86_64/vp_state.rs @@ -181,7 +181,7 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { type Error = Error; fn caps(&self) -> &virt::PartitionCapabilities { - &self.partition.caps + self.partition.caps() } fn commit(&mut self) -> Result<(), Self::Error> { @@ -219,11 +219,11 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { fn xsave(&mut self) -> Result { let xsave = self.get_state( mshv_bindings::MSHV_VP_STATE_XSAVE, - self.partition.caps.xsave.compact_len as usize, + self.partition.caps().xsave.compact_len as usize, )?; Ok(vp::Xsave::from_compact( xsave.as_bytes(), - &self.partition.caps, + self.partition.caps(), )) } From a8f5a03aac3ca4f3c575617772b3ae2cf331625c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:42:59 -0700 Subject: [PATCH 025/304] virt_mshv: persist and redeliver pending external interrupts Non-isolated MSHV partitions ignored LINT0 pulses from the userspace PIC, so a guest that relies on the legacy PIC for ExtINT delivery never received those interrupts. Deliver them through the interrupt deliverability notification instead: - A LINT0 pulse marks the VP as having a pending ExtINT and kicks its run loop. - Before running, the VP requests an interrupt deliverability notification while an ExtINT is pending. - When the hypervisor reports that the VP can accept an interrupt, the notification is cleared, the PIC is acknowledged, and its vector is written as the pending ExtINT event of the VP. VP state capture and restore already carry that register, so a pending ExtINT survives a snapshot. Isolated (SNP) VPs still cannot receive PIC ExtINT through LINT0 and keep ignoring the pulse with a rate-limited warning, as do pulses for other VTLs or LINT1 and pulses for an invalid VP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vmm_core/virt_mshv/src/lib.rs | 7 ++ vmm_core/virt_mshv/src/x86_64/extint.rs | 107 ++++++++++++++++++++++++ vmm_core/virt_mshv/src/x86_64/mod.rs | 12 ++- 3 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 vmm_core/virt_mshv/src/x86_64/extint.rs diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 3868b4f6b37..27173269233 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -159,6 +159,7 @@ impl<'a> MshvProtoPartition<'a> { needs_yield: NeedsYield::new(), message_queues: MessageQueues::new(), message_queues_pending: AtomicBool::new(false), + extint_pending: AtomicBool::new(false), waker: RwLock::new(None), }) .collect(); @@ -345,6 +346,9 @@ struct MshvVpInner { /// Set by device threads after enqueuing a message to signal the VP /// thread to flush its message queues. message_queues_pending: AtomicBool, + /// Set when the userspace PIC pulses LINT0 and cleared after ExtINT delivery. + #[cfg(guest_arch = "x86_64")] + extint_pending: AtomicBool, /// Waker for the VP run loop task. Set by the VP thread, used by device /// threads to re-poll the run loop when new messages are enqueued. waker: RwLock>, @@ -663,6 +667,9 @@ impl virt::Processor for MshvProcessor<'_> { } } + #[cfg(guest_arch = "x86_64")] + self.request_extint_notification(); + match self.runner.run() { Ok(exit) => { self.handle_exit(&exit, dev).await?; diff --git a/vmm_core/virt_mshv/src/x86_64/extint.rs b/vmm_core/virt_mshv/src/x86_64/extint.rs new file mode 100644 index 00000000000..568576d2339 --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/extint.rs @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! PIC ExtINT delivery through LINT0 for non-isolated partitions. +//! +//! A LINT0 pulse from the userspace PIC marks the VP and kicks its run loop, +//! which requests an interrupt deliverability notification. Once the VP can +//! accept an interrupt, the PIC vector is injected as a pending ExtINT event. + +use crate::MshvPartitionInner; +use crate::MshvProcessor; +use crate::VcpuFdExt; +use hvdef::HvMessage; +use hvdef::HvX64RegisterName; +use hvdef::Vtl; +use hvdef::hypercall::HvRegisterAssoc; +use virt::VpIndex; +use virt::io::CpuIo; + +impl MshvPartitionInner { + pub(super) fn pulse_lint(&self, vp_index: VpIndex, vtl: Vtl, lint: u8) { + if self.isolation.snp().is_some() { + // MSHV isolated VPs cannot receive PIC ExtINT through LINT0. + tracelimit::warn_ratelimited!(?vp_index, ?vtl, lint, "ignored isolated lint pulse"); + return; + } + if vtl != Vtl::Vtl0 || lint != 0 { + tracelimit::warn_ratelimited!(?vp_index, ?vtl, lint, "unsupported lint pulse"); + return; + } + let Some(vp) = self.vps.get(vp_index.index() as usize) else { + tracelimit::warn_ratelimited!(?vp_index, "lint pulse for invalid VP"); + return; + }; + vp.extint_pending + .store(true, std::sync::atomic::Ordering::Release); + if let Some(waker) = &*vp.waker.read() { + waker.wake_by_ref(); + } + if let Some(thread) = *vp.thread.read() { + crate::run_vp::cancel(thread).expect("thread interrupt signal failed"); + } + } +} + +impl MshvProcessor<'_> { + /// Requests an interrupt deliverability notification while a LINT0 + /// ExtINT is pending. + pub(crate) fn request_extint_notification(&mut self) { + use std::sync::atomic::Ordering; + + let vpinner = self.inner; + if vpinner.extint_pending.load(Ordering::Acquire) + && !self.deliverability_notifications.interrupt_notification() + { + let notifications = self + .deliverability_notifications + .with_interrupt_notification(true); + self.partition + .vmfd + .register_deliverabilty_notifications( + self.vpindex.index(), + u64::from(notifications), + ) + .expect("requesting deliverability is not a fallible operation"); + self.deliverability_notifications = notifications; + } + } + + pub(super) fn handle_interrupt_deliverable(&mut self, message: &HvMessage, dev: &impl CpuIo) { + let message = message.as_message::(); + if message.deliverable_type != hvdef::HvX64PendingInterruptionType::HV_X64_PENDING_INTERRUPT + { + tracelimit::warn_ratelimited!( + deliverable_type = ?message.deliverable_type, + "unsupported interruption deliverability notification" + ); + return; + } + + self.inner + .extint_pending + .store(false, std::sync::atomic::Ordering::Release); + let notifications = self + .deliverability_notifications + .with_interrupt_notification(false); + self.partition + .vmfd + .register_deliverabilty_notifications(self.vpindex.index(), u64::from(notifications)) + .expect("requesting deliverability is not a fallible operation"); + self.deliverability_notifications = notifications; + + if let Some(vector) = dev.acknowledge_pic_interrupt() { + let event = hvdef::HvX64PendingExtIntEvent::new() + .with_event_pending(true) + .with_event_type(hvdef::HV_X64_PENDING_EVENT_EXT_INT) + .with_vector(vector); + self.runner + .vcpufd + .set_hvdef_regs(&[HvRegisterAssoc::from(( + HvX64RegisterName::PendingEvent0, + u128::from(event), + ))]) + .expect("setting a pending ExtINT event is not a fallible operation"); + } + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index eb55557786b..65f5bd510e7 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -3,6 +3,7 @@ //! x86_64-specific implementation of the mshv hypervisor backend. +mod extint; pub(crate) mod finalize; mod vm_state; mod vp_state; @@ -635,13 +636,7 @@ impl virt::X86Partition for MshvPartition { } fn pulse_lint(&self, vp_index: VpIndex, vtl: Vtl, lint: u8) { - // TODO: Implement LINT injection for non-isolated MSHV partitions. - // - // The legacy PIC/PIT are temporarily attached for direct-boot TSC - // calibration, but MSHV isolated VPs cannot receive PIC ExtINT through - // LINT0. The guest must route runtime interrupts through the IOAPIC/MSI - // path; PIC-dependent isolated guests are not supported. - tracelimit::warn_ratelimited!(?vp_index, ?vtl, lint, "ignored lint pulse"); + self.inner.pulse_lint(vp_index, vtl, lint) } } @@ -882,6 +877,9 @@ impl MshvProcessor<'_> { let msg = exit.as_message::(); dev.handle_eoi(msg.interrupt_vector); } + HvMessageType::HvMessageTypeX64InterruptionDeliverable => { + self.handle_interrupt_deliverable(exit, dev); + } exit_type => { panic!("Unhandled vcpu exit code {exit_type:?}"); } From 4e396f9df29465f93f3eaedc4b659d098e4f8685 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:48:15 -0700 Subject: [PATCH 026/304] chipset: preserve IOAPIC line levels across restore The IOAPIC saved state records each redirection entry but not the current level of its input line. After a restore every line therefore reads as deasserted: a level-triggered interrupt that was asserted when the state was saved is not delivered again until its device toggles the line, and a guest waiting for that interrupt can stall. Save the line levels next to the redirection entries and restore them. Saved state without line levels restores with every line deasserted, as before, and a line-level count that does not match the number of redirection entries is rejected. After an entry's route is restored, its line is evaluated, so an asserted level-triggered interrupt is delivered according to the restored routing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/chipset/src/ioapic/mod.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/vm/devices/chipset/src/ioapic/mod.rs b/vm/devices/chipset/src/ioapic/mod.rs index b0ed8007ed6..6c6adb6da4e 100644 --- a/vm/devices/chipset/src/ioapic/mod.rs +++ b/vm/devices/chipset/src/ioapic/mod.rs @@ -424,6 +424,8 @@ mod save_restore { pub(super) index: u8, #[mesh(3)] pub(super) redirection_entries: Vec, + #[mesh(4)] + pub(super) line_levels: Vec, } } @@ -446,6 +448,7 @@ mod save_restore { Ok(state::SavedState { redirection_entries: irqs.iter().map(|irq| irq.redirection.into()).collect(), + line_levels: irqs.iter().map(|irq| irq.line_level).collect(), index: index.0, id: *id, }) @@ -454,21 +457,32 @@ mod save_restore { fn restore(&mut self, state: state::SavedState) -> Result<(), RestoreError> { let state::SavedState { redirection_entries, + line_levels, id, index, } = state; if redirection_entries.len() != self.irqs.len() { return Err(RestoreError::Other(WrongNumberOfRedirectionEntries.into())); } - for (n, (state, irq)) in redirection_entries + let line_levels = if line_levels.is_empty() { + vec![false; self.irqs.len()] + } else if line_levels.len() == self.irqs.len() { + line_levels + } else { + return Err(RestoreError::Other(WrongNumberOfRedirectionEntries.into())); + }; + for (n, ((state, line_level), irq)) in redirection_entries .into_iter() + .zip(line_levels) .zip(self.irqs.iter_mut()) .enumerate() { irq.redirection = RedirectionEntry::from(state); + irq.line_level = line_level; let request = irq.redirection.as_msi(); self.routing.set_route(n as u8, request); irq.registered_request = request; + irq.evaluate(self.routing.as_ref(), &mut self.stats, n as u8, false); } self.id = id; self.index = IndexRegister(index); From e9946b27959ee25407f492055cc22a7b46525d3e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:48:57 -0700 Subject: [PATCH 027/304] chipset: fast-forward periodic PIT counting over long intervals Timer::evaluate advances a periodic (rate generator or square wave) counter one period at a time. After a long interval without evaluation, such as a host stall or a stopped VM, the loop runs once per elapsed period and toggles the output line each time. A long enough interval turns this into an effectively unbounded loop. When an active periodic counter is at the start of a period, skip every whole period that elapsed in a single step: keep the remainder so the counter stays in phase, and pulse the output once so IRQ0 sees a single edge instead of one per skipped period. Derive PartialEq and Eq for the timer state so tests can compare timers. Add tests that a single long evaluation matches tick-by-tick evaluation in every counting mode, keeps the periodic phase, fast-forwards an interval of u64::MAX ticks, and coalesces the IRQ0 edges. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + vm/devices/chipset/Cargo.toml | 1 + vm/devices/chipset/src/pit/mod.rs | 114 +++++++++++++++++++++++++++++- 3 files changed, 115 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index fba137f33b8..1b607fc7b47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -593,6 +593,7 @@ dependencies = [ "mesh", "open_enum", "pal_async", + "parking_lot", "power_resources", "test_with_tracing", "thiserror 2.0.16", diff --git a/vm/devices/chipset/Cargo.toml b/vm/devices/chipset/Cargo.toml index e49939a4c8a..82d032bab89 100644 --- a/vm/devices/chipset/Cargo.toml +++ b/vm/devices/chipset/Cargo.toml @@ -32,6 +32,7 @@ tracing.workspace = true [dev-dependencies] pal_async.workspace = true +parking_lot.workspace = true test_with_tracing.workspace = true [lints] diff --git a/vm/devices/chipset/src/pit/mod.rs b/vm/devices/chipset/src/pit/mod.rs index 7df675e9810..7eefffd5f02 100644 --- a/vm/devices/chipset/src/pit/mod.rs +++ b/vm/devices/chipset/src/pit/mod.rs @@ -35,6 +35,14 @@ struct ControlWord { #[bits(2)] select: u8, } +impl PartialEq for ControlWord { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for ControlWord {} + #[rustfmt::skip] #[bitfield(u8)] struct StatusWord { @@ -75,7 +83,7 @@ struct Timer { state: TimerState, } -#[derive(Copy, Clone, Debug, Inspect)] +#[derive(Copy, Clone, Debug, Inspect, PartialEq, Eq)] struct TimerState { ce: u16, // "counting element", i.e. the counter cr: u16, // count register, the new value @@ -216,6 +224,30 @@ impl Timer { let mode = self.state.op_mode(); let bcd = self.state.control.bcd(); while ticks > 0 { + if self.state.state == CountState::Active + && self.state.ce == self.state.cr + && matches!(mode, Mode::RateGenerator | Mode::SquareWave) + { + let reload = if bcd { + u64::from(from_bcd(self.state.cr)) + } else { + u64::from(self.state.cr) + }; + let period = if reload == 0 { + if bcd { 10_000 } else { 0x1_0000 } + } else { + reload + }; + if ticks >= period { + let out = self.state.out; + self.set_out(!out); + self.set_out(out); + ticks %= period; + if ticks == 0 { + break; + } + } + } match self.state.state { CountState::Inactive | CountState::WaitingForGate => break, CountState::Reloading => { @@ -870,6 +902,20 @@ mod tests { use super::Timer; use super::to_bcd; use crate::pit::from_bcd; + use parking_lot::Mutex; + use std::sync::Arc; + use test_with_tracing::test; + use vmcore::line_interrupt::LineInterrupt; + use vmcore::line_interrupt::LineSetTarget; + + #[derive(Default)] + struct RecordingTarget(Mutex>); + + impl LineSetTarget for RecordingTarget { + fn set_irq(&self, _vector: u32, high: bool) { + self.0.lock().push(high); + } + } #[test] fn test_bcd_comp() { @@ -972,4 +1018,70 @@ mod tests { fn test_bcd() { test_output(true); } + + #[test] + fn elapsed_downtime_matches_incremental_pit_evaluation() { + for mode in [ + Mode::TerminalCount, + Mode::RateGenerator, + Mode::SquareWave, + Mode::SoftwareStrobe, + ] { + let mut elapsed = Timer::new(true, None); + let mut incremental = Timer::new(true, None); + set_timer(&mut elapsed, mode, 100, false); + set_timer(&mut incremental, mode, 100, false); + + elapsed.evaluate(350); + for _ in 0..350 { + incremental.evaluate(1); + } + + assert_eq!(elapsed.state, incremental.state, "{mode:?}"); + } + } + + #[test] + fn elapsed_downtime_preserves_periodic_phase() { + let mut timer = Timer::new(true, None); + set_timer(&mut timer, Mode::RateGenerator, 100, false); + + timer.evaluate(350); + + assert_eq!(timer.state.ce, 51); + assert!(timer.state.out); + assert_eq!(timer.state.next_wakeup(), Some(50)); + } + + #[test] + fn long_elapsed_downtime_fast_forwards_periodic_pit() { + for mode in [Mode::RateGenerator, Mode::SquareWave] { + let mut elapsed = Timer::new(true, None); + let mut remainder = Timer::new(true, None); + set_timer(&mut elapsed, mode, 100, false); + set_timer(&mut remainder, mode, 100, false); + elapsed.evaluate(1); + remainder.evaluate(1); + + elapsed.evaluate(u64::MAX); + remainder.evaluate(u64::MAX % 100); + + assert_eq!(elapsed.state, remainder.state, "{mode:?}"); + } + } + + #[test] + fn long_elapsed_downtime_coalesces_periodic_irq0_edges() { + let target = Arc::new(RecordingTarget::default()); + let interrupt = LineInterrupt::new_with_target("pit", target.clone(), 0); + let mut timer = Timer::new(true, Some(interrupt)); + set_timer(&mut timer, Mode::RateGenerator, 2, false); + timer.evaluate(1); + target.0.lock().clear(); + + timer.evaluate(u64::MAX - 1); + + assert_eq!(*target.0.lock(), [false, true]); + assert!(timer.state.out); + } } From 55e4a809f5fc6015c62d72a4f8adc58dcbe63566 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:51:24 -0700 Subject: [PATCH 028/304] virtio_blk: let stop cancellation win over a ready queue and drain accepted I/O The virtio-blk queue worker ran its loop inside StopTask::until_stopped, which only looks at the stop signal when the loop returns Pending. While the guest keeps the queue busy, every poll finds a completion or new work ready, the loop never returns Pending, and a save/restore or teardown stop request waits indefinitely. On MSHV this showed up as a device stop that stalled for ten minutes. Poll the stop signal first on every iteration and return Cancelled once it is set, before looking at completions or new queue work. I/O that was already accepted stays with the worker, so the existing stop path still drains it and completes its descriptors. Add an integration test with a disk whose write blocks until the test releases it. The queue stop stays pending while the accepted write is outstanding, completes once the write is released, and the write is issued and completed exactly once. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + vm/devices/virtio/virtio_blk/Cargo.toml | 1 + .../virtio_blk/src/integration_tests.rs | 156 +++++++++++++++++- vm/devices/virtio/virtio_blk/src/lib.rs | 104 ++++++------ 4 files changed, 212 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1b607fc7b47..f4440370f5c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10022,6 +10022,7 @@ name = "virtio_blk" version = "0.0.0" dependencies = [ "anyhow", + "async-channel", "async-trait", "disk_backend", "disklayer_ram", diff --git a/vm/devices/virtio/virtio_blk/Cargo.toml b/vm/devices/virtio/virtio_blk/Cargo.toml index f9e453d8d3e..1f233a2c1dc 100644 --- a/vm/devices/virtio/virtio_blk/Cargo.toml +++ b/vm/devices/virtio/virtio_blk/Cargo.toml @@ -28,6 +28,7 @@ unicycle.workspace = true zerocopy.workspace = true [dev-dependencies] +async-channel.workspace = true disklayer_ram.workspace = true pal_async.workspace = true pal_event.workspace = true diff --git a/vm/devices/virtio/virtio_blk/src/integration_tests.rs b/vm/devices/virtio/virtio_blk/src/integration_tests.rs index 2efb6fa15f4..b9ae321b598 100644 --- a/vm/devices/virtio/virtio_blk/src/integration_tests.rs +++ b/vm/devices/virtio/virtio_blk/src/integration_tests.rs @@ -26,6 +26,11 @@ use parking_lot::Mutex; use scsi_buffers::RequestBuffers; use std::future::Future; use std::pin::pin; +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; +use std::task::Context; +use std::task::Waker; use std::time::Duration; use test_with_tracing::test; use virtio::QueueResources; @@ -218,7 +223,7 @@ impl TestHarness { /// desc 2 (writable): 1-byte status /// /// Returns the head descriptor index. - fn post_write_request(&mut self, head_desc: u16, sector: u64, data: &[u8]) { + fn post_write_request(&mut self, head_desc: u16, sector: u64, data: &[u8]) -> u64 { let header_gpa = self.alloc_data(REQ_HEADER_SIZE); let data_gpa = self.alloc_data(data.len() as u32); let status_gpa = self.alloc_data(1); @@ -281,6 +286,7 @@ impl TestHarness { &mut self.avail_idx, ); self.queue_event.signal(); + status_gpa } /// Build a flush request descriptor chain. @@ -531,6 +537,107 @@ fn ram_disk(size: u64, read_only: bool) -> Disk { disklayer_ram::ram_disk(size, read_only).unwrap() } +#[derive(Inspect)] +struct BlockingWriteDisk { + #[inspect(skip)] + storage: Mutex>, + #[inspect(skip)] + write_calls: Arc, + #[inspect(skip)] + write_started: Mutex>>, + #[inspect(skip)] + release_write: Mutex>>, +} + +impl DiskIo for BlockingWriteDisk { + fn disk_type(&self) -> &str { + "blocking-write-test" + } + + fn sector_count(&self) -> u64 { + self.storage.lock().len() as u64 / 512 + } + + fn sector_size(&self) -> u32 { + 512 + } + + fn disk_id(&self) -> Option<[u8; 16]> { + None + } + + fn physical_sector_size(&self) -> u32 { + 512 + } + + fn is_fua_respected(&self) -> bool { + false + } + + fn is_read_only(&self) -> bool { + false + } + + async fn read_vectored( + &self, + buffers: &RequestBuffers<'_>, + sector: u64, + ) -> Result<(), DiskError> { + let offset = sector as usize * 512; + let end = offset + buffers.len(); + let storage = self.storage.lock(); + if end > storage.len() { + return Err(DiskError::IllegalBlock); + } + buffers.writer().write(&storage[offset..end])?; + Ok(()) + } + + async fn write_vectored( + &self, + buffers: &RequestBuffers<'_>, + sector: u64, + _fua: bool, + ) -> Result<(), DiskError> { + self.write_calls.fetch_add(1, Ordering::SeqCst); + if let Some(started) = self.write_started.lock().take() { + started.send(()).unwrap(); + } + let release = self + .release_write + .lock() + .take() + .expect("blocking write release receiver is present"); + release.recv().await.unwrap(); + + let offset = sector as usize * 512; + let end = offset + buffers.len(); + let mut storage = self.storage.lock(); + if end > storage.len() { + return Err(DiskError::IllegalBlock); + } + buffers.reader().read(&mut storage[offset..end])?; + Ok(()) + } + + async fn sync_cache(&self) -> Result<(), DiskError> { + Ok(()) + } + + async fn unmap( + &self, + _sector: u64, + _count: u64, + _block_level_only: bool, + ) -> Result<(), DiskError> { + Ok(()) + } + + fn unmap_behavior(&self) -> disk_backend::UnmapBehavior { + disk_backend::UnmapBehavior::Ignored + } +} + /// Awaits `fut`, panicking with `msg` if it does not complete within `timeout`. async fn with_timeout( driver: &DefaultDriver, @@ -545,6 +652,53 @@ async fn with_timeout( } } +#[async_test] +async fn stop_drains_one_accepted_write_exactly_once(driver: DefaultDriver) { + let (_device_thread, device_driver) = DefaultPool::spawn_on_thread("virtio-blk-drain"); + let write_calls = Arc::new(AtomicUsize::new(0)); + let (write_started, write_started_recv) = std::sync::mpsc::channel(); + let (release_write, release_write_recv) = async_channel::bounded(1); + let disk = Disk::new(BlockingWriteDisk { + storage: Mutex::new(vec![0_u8; 64 * 1024]), + write_calls: write_calls.clone(), + write_started: Mutex::new(Some(write_started)), + release_write: Mutex::new(Some(release_write_recv)), + }) + .unwrap(); + let mut harness = TestHarness::with_device_driver(&driver, &device_driver, disk, false); + harness.enable().await; + + let status_gpa = harness.post_write_request(0, 0, &[0xa5; 512]); + write_started_recv + .recv_timeout(Duration::from_secs(5)) + .expect("virtio-blk worker did not accept the write"); + + let stopped = { + let mut stop = pin!(harness.device.stop_queue(0)); + assert!( + stop.as_mut() + .poll(&mut Context::from_waker(Waker::noop())) + .is_pending(), + "stop completed before the accepted write was released" + ); + release_write.send(()).await.unwrap(); + with_timeout( + &driver, + Duration::from_secs(5), + "virtio-blk stop did not drain its accepted write", + stop, + ) + .await + }; + assert!(stopped.is_some()); + + let (used_id, used_len) = harness.wait_for_used().await; + assert_eq!((used_id, used_len), (0, 1)); + assert_eq!(harness.read_status(status_gpa), VIRTIO_BLK_S_OK); + assert_eq!(write_calls.load(Ordering::SeqCst), 1); + assert_eq!(harness.used_idx, 1); +} + /// Write 1 sector then read it back. Verifies basic write and read roundtrip. #[async_test] async fn write_then_read_roundtrip(driver: DefaultDriver) { diff --git a/vm/devices/virtio/virtio_blk/src/lib.rs b/vm/devices/virtio/virtio_blk/src/lib.rs index 391c045080f..639b3cfefb9 100644 --- a/vm/devices/virtio/virtio_blk/src/lib.rs +++ b/vm/devices/virtio/virtio_blk/src/lib.rs @@ -168,60 +168,66 @@ impl AsyncRun for BlkWorker { stop: &mut StopTask<'_>, state: &mut BlkQueueState, ) -> Result<(), task_control::Cancelled> { - stop.until_stopped(async { - // Set once the queue can no longer produce work, because the guest - // violated the queue protocol. Fetching stops, but in-flight IOs - // keep draining so their descriptors are still completed; the - // worker exits once they are done. - let mut queue_done = false; - - while !(queue_done && self.ios.is_empty()) { - enum Event { - NewWork(Result), - Completed(IoCompletion), + // Set once the queue can no longer produce work, because the guest + // violated the queue protocol. Fetching stops, but in-flight IOs + // keep draining so their descriptors are still completed; the + // worker exits once they are done. + let mut queue_done = false; + + while !(queue_done && self.ios.is_empty()) { + enum Event { + NewWork(Result), + Completed(IoCompletion), + } + + let event = poll_fn(|cx| { + // Check cancellation first so a continuously ready queue + // cannot starve a save/restore or teardown stop request. + if Pin::new(&mut *stop).poll(cx).is_ready() { + return Poll::Ready(None); } - let event = poll_fn(|cx| { - // Poll for completed IOs first to free up slots. - if let Poll::Ready(Some(completion)) = self.ios.poll_next_unpin(cx) { - return Poll::Ready(Event::Completed(completion)); - } - // Accept new work if under the depth limit. - if !queue_done && self.ios.len() < MAX_IO_DEPTH { - if let Poll::Ready(item) = state.queue.poll_next_unpin(cx) { - let item = item.expect("virtio queue stream never ends"); - return Poll::Ready(Event::NewWork(item)); - } - } - Poll::Pending - }) - .await; - - match event { - Event::NewWork(Ok(work)) => { - let disk = self.disk.clone(); - let mem = state.memory.clone(); - let read_only = self.read_only; - self.ios.push(Box::pin(async move { - process_request(&disk, &mem, read_only, work).await - })); - } - Event::NewWork(Err(err)) => { - // The queue is retired: the rejected chain is never - // consumed, so retrying would fail identically forever. - tracelimit::error_ratelimited!( - error = &err as &dyn std::error::Error, - "error reading from virtio queue, stopping worker" - ); - queue_done = true; - } - Event::Completed(completion) => { - self.finish_io(&mut state.queue, completion); + // Poll for completed IOs first to free up slots. + if let Poll::Ready(Some(completion)) = self.ios.poll_next_unpin(cx) { + return Poll::Ready(Some(Event::Completed(completion))); + } + // Accept new work if under the depth limit. + if !queue_done && self.ios.len() < MAX_IO_DEPTH { + if let Poll::Ready(item) = state.queue.poll_next_unpin(cx) { + let item = item.expect("virtio queue stream never ends"); + return Poll::Ready(Some(Event::NewWork(item))); } } + Poll::Pending + }) + .await + .ok_or(task_control::Cancelled)?; + + match event { + Event::NewWork(Ok(work)) => { + let disk = self.disk.clone(); + let mem = state.memory.clone(); + let read_only = self.read_only; + self.ios.push(Box::pin(async move { + process_request(&disk, &mem, read_only, work).await + })); + } + Event::NewWork(Err(err)) => { + // The queue is retired: the rejected chain is never + // consumed, so retrying would fail identically forever. + tracelimit::error_ratelimited!( + error = &err as &dyn std::error::Error, + "error reading from virtio queue, stopping worker" + ); + queue_done = true; + } + Event::Completed(completion) => { + self.finish_io(&mut state.queue, completion); + } } - }) - .await + } + + Ok(()) } } From 947ae858b118bb17c0458c368faf5cc1b9361f96 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:52:55 -0700 Subject: [PATCH 029/304] virtio: make interrupt tests wait for queue completion verify_queue_simple_interrupt_control_inner checked that no interrupt was sent by waiting 100 ms and asserting that nothing arrived, and checked that an interrupt was sent by waiting up to 100 ms for it. On a loaded host the device task may not have processed the descriptors within 100 ms. The negative checks can then pass before the device ran at all, and the positive checks can time out. Have the test's work callback report every completed descriptor. The test now waits up to five seconds for the expected completions and only then checks the interrupt channel with try_recv, so each assertion observes the state after the device processed the work, regardless of host timing. The timeout-based assert_no_recv_in_timeout helper has no remaining users and is removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/virtio/virtio/src/tests.rs | 34 +++++++++++++-------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/vm/devices/virtio/virtio/src/tests.rs b/vm/devices/virtio/virtio/src/tests.rs index 78024238bc4..33d3c6b1e33 100644 --- a/vm/devices/virtio/virtio/src/tests.rs +++ b/vm/devices/virtio/virtio/src/tests.rs @@ -94,20 +94,6 @@ async fn must_recv_in_timeout( .unwrap() } -async fn assert_no_recv_in_timeout( - recv: &mut mesh::Receiver, - timeout: Duration, -) { - if mesh::CancelContext::new() - .with_timeout(timeout) - .until_cancelled(recv.next()) - .await - .is_ok() - { - panic!("Expected timeout, but received a value"); - } -} - /// Yield execution to the async executor, allowing spawned tasks to run. async fn yield_now() { let mut yielded = false; @@ -2558,16 +2544,21 @@ async fn verify_packed_queue_simple(driver: DefaultDriver) { } async fn verify_queue_simple_interrupt_control_inner(mut guest: VirtioTestGuest, with_index: bool) { + const COMPLETION_TIMEOUT: Duration = Duration::from_secs(5); + let (tx, mut rx) = mesh::mpsc_channel(); + let (completed, mut completions) = mesh::mpsc_channel(); let event = Event::new(); let mut queues = guest.create_direct_queues(|i| { let tx = tx.clone(); + let completed = completed.clone(); CreateDirectQueueParams { process_work: Box::new( move |queue: &mut VirtioQueue, work: VirtioQueueCallbackWork| { assert_eq!(work.payload.len(), 1); assert_eq!(work.payload[0].length, 0x1000); queue.complete(work, 123); + completed.send(()); }, ), notify: Interrupt::from_fn(move || { @@ -2582,10 +2573,13 @@ async fn verify_queue_simple_interrupt_control_inner(mut guest: VirtioTestGuest, guest.enable_interrupt(0, Some(1)); guest.add_to_avail_queue(0); event.signal(); - assert_no_recv_in_timeout(&mut rx, Duration::from_millis(100)).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + assert!(matches!(rx.try_recv(), Err(mesh::TryRecvError::Empty))); guest.add_to_avail_queue(0); event.signal(); - must_recv_in_timeout(&mut rx, Duration::from_millis(100)).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + assert_eq!(rx.try_recv().unwrap(), 0); + assert!(matches!(rx.try_recv(), Err(mesh::TryRecvError::Empty))); let (_, len) = guest.get_next_completed(0).unwrap(); assert_eq!(len, 123); @@ -2597,7 +2591,9 @@ async fn verify_queue_simple_interrupt_control_inner(mut guest: VirtioTestGuest, guest.enable_interrupt(0, None); guest.add_to_avail_queue(0); event.signal(); - must_recv_in_timeout(&mut rx, Duration::from_millis(100)).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + assert_eq!(rx.try_recv().unwrap(), 0); + assert!(matches!(rx.try_recv(), Err(mesh::TryRecvError::Empty))); let (_, len) = guest.get_next_completed(0).unwrap(); assert_eq!(len, 123); assert_eq!(guest.get_next_completed(0).is_none(), true); @@ -2607,7 +2603,9 @@ async fn verify_queue_simple_interrupt_control_inner(mut guest: VirtioTestGuest, guest.add_to_avail_queue(0); guest.add_to_avail_queue(0); event.signal(); - assert_no_recv_in_timeout(&mut rx, Duration::from_millis(100)).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + must_recv_in_timeout(&mut completions, COMPLETION_TIMEOUT).await; + assert!(matches!(rx.try_recv(), Err(mesh::TryRecvError::Empty))); let (_, len) = guest.get_next_completed(0).unwrap(); assert_eq!(len, 123); let (_, len) = guest.get_next_completed(0).unwrap(); From 21c2206c9a0728648f526a99ef3060100c4d553a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 21:54:17 -0700 Subject: [PATCH 030/304] fuse: rate-limit guest-triggerable diagnostics The FUSE request parser and session log an error or a warning for every malformed request, invalid or unexpected opcode, duplicate or racy FUSE_INIT, and unsupported FUSE_INTERRUPT, FUSE_POLL, or FUSE_NOTIFY_REPLY request. All of these are driven by the guest, so a misbehaving guest can flood the host log with them. Emit them through the tracelimit rate-limited macros instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + vm/devices/support/fs/fuse/Cargo.toml | 1 + vm/devices/support/fs/fuse/src/request.rs | 8 ++++---- .../support/fs/fuse/src/request/macros.rs | 2 +- vm/devices/support/fs/fuse/src/session.rs | 18 +++++++++--------- 5 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f4440370f5c..57a43b31754 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2476,6 +2476,7 @@ dependencies = [ "tempfile", "test_with_tracing", "thiserror 2.0.16", + "tracelimit", "tracing", "zerocopy", ] diff --git a/vm/devices/support/fs/fuse/Cargo.toml b/vm/devices/support/fs/fuse/Cargo.toml index 37046643001..ffb5e54c224 100644 --- a/vm/devices/support/fs/fuse/Cargo.toml +++ b/vm/devices/support/fs/fuse/Cargo.toml @@ -16,6 +16,7 @@ lx.workspace = true bitfield-struct.workspace = true parking_lot.workspace = true thiserror.workspace = true +tracelimit.workspace = true tracing.workspace = true zerocopy.workspace = true [target.'cfg(unix)'.dependencies] diff --git a/vm/devices/support/fs/fuse/src/request.rs b/vm/devices/support/fs/fuse/src/request.rs index 7e8b40d4984..99132cefd97 100644 --- a/vm/devices/support/fs/fuse/src/request.rs +++ b/vm/devices/support/fs/fuse/src/request.rs @@ -132,7 +132,7 @@ impl Request { fn read_operation(header: &fuse_in_header, mut reader: impl RequestReader) -> FuseOperation { if header.len as usize > reader.remaining_len() + size_of_val(header) { - tracing::error!( + tracelimit::error_ratelimited!( opcode = header.opcode, unique = header.unique, header_len = header.len, @@ -150,7 +150,7 @@ impl Request { let payload_len = (header.len as usize) - size_of::(); let available = payload_len.min(size_of::()); if available < FUSE_COMPAT_INIT_IN_SIZE as usize { - tracing::error!( + tracelimit::error_ratelimited!( opcode = header.opcode, unique = header.unique, len = available, @@ -160,7 +160,7 @@ impl Request { } let mut init = fuse_init_in::new_zeroed(); if let Err(e) = reader.read_exact(&mut init.as_mut_bytes()[..available]) { - tracing::error!( + tracelimit::error_ratelimited!( opcode = header.opcode, unique = header.unique, error = &e as &dyn std::error::Error, @@ -174,7 +174,7 @@ impl Request { match FuseOperation::read(header.opcode, reader) { Ok(operation) => operation, Err(e) => { - tracing::error!( + tracelimit::error_ratelimited!( opcode = header.opcode, unique = header.unique, error = &e as &dyn std::error::Error, diff --git a/vm/devices/support/fs/fuse/src/request/macros.rs b/vm/devices/support/fs/fuse/src/request/macros.rs index 7b6cf784155..cd14391c00c 100644 --- a/vm/devices/support/fs/fuse/src/request/macros.rs +++ b/vm/devices/support/fs/fuse/src/request/macros.rs @@ -35,7 +35,7 @@ macro_rules! fuse_operations { } },)* _ => { - tracing::error!(opcode, "Invalid opcode"); + tracelimit::error_ratelimited!(opcode, "Invalid opcode"); return Err(lx::Error::EINVAL) } }; diff --git a/vm/devices/support/fs/fuse/src/session.rs b/vm/devices/support/fs/fuse/src/session.rs index 9ce3e097995..621ee715c5c 100644 --- a/vm/devices/support/fs/fuse/src/session.rs +++ b/vm/devices/support/fs/fuse/src/session.rs @@ -87,7 +87,7 @@ impl Session { match result { Err(OperationError::FsError(e)) => { if let Err(e) = sender.send_error(unique, e.value()) { - tracing::error!( + tracelimit::error_ratelimited!( unique, error = &e as &dyn std::error::Error, "Failed to send reply", @@ -98,7 +98,7 @@ impl Session { if e.kind() == io::ErrorKind::NotFound { tracing::trace!(unique, "Request was interrupted."); } else { - tracing::error!( + tracelimit::error_ratelimited!( unique, error = &e as &dyn std::error::Error, "Failed to send reply", @@ -267,7 +267,7 @@ impl Session { sender.send_empty(request.unique())?; } FuseOperation::Init { arg: _ } => { - tracing::warn!("Duplicate init message."); + tracelimit::warn_ratelimited!("Duplicate init message."); return Err(lx::Error::EIO.into()); } FuseOperation::OpenDir { arg } => { @@ -316,7 +316,7 @@ impl Session { FuseOperation::Interrupt { arg: _ } => { // Interrupt is potentially complicated, and none of the sample file systems seem // to use it, so it's left as TODO for now. - tracing::warn!("FUSE_INTERRUPT not supported."); + tracelimit::warn_ratelimited!("FUSE_INTERRUPT not supported."); return Err(lx::Error::ENOSYS.into()); } FuseOperation::BMap { arg } => { @@ -352,12 +352,12 @@ impl Session { // Poll is not currently needed, and complicated to support. It appears to have some // way of registering for later notifications, but I can't figure out how that // works without libfuse source. - tracing::warn!("FUSE_POLL not supported."); + tracelimit::warn_ratelimited!("FUSE_POLL not supported."); return Err(lx::Error::ENOSYS.into()); } FuseOperation::NotifyReply { arg: _, data: _ } => { // Not sure what this is. It has something to do with poll, I think. - tracing::warn!("FUSE_NOTIFY_REPLY not supported."); + tracelimit::warn_ratelimited!("FUSE_NOTIFY_REPLY not supported."); return Err(lx::Error::ENOSYS.into()); } FuseOperation::BatchForget { arg, nodes } => { @@ -447,13 +447,13 @@ impl Session { let init: &fuse_init_in = if let FuseOperation::Init { arg } = request.operation() { arg } else { - tracing::error!(opcode = request.opcode(), "Expected FUSE_INIT"); + tracelimit::error_ratelimited!(opcode = request.opcode(), "Expected FUSE_INIT"); return Err(lx::Error::EIO.into()); }; let mut info = self.info.write(); if self.is_initialized() { - tracing::error!("Racy FUSE_INIT requests."); + tracelimit::error_ratelimited!("Racy FUSE_INIT requests."); return Err(lx::Error::EIO.into()); } @@ -471,7 +471,7 @@ impl Session { // Don't bother supporting old versions. Version 7.27 is what kernel 4.19 uses, and can // be supported without needing to change the daemon's behavior for compatibility. if init.major < FUSE_KERNEL_VERSION || init.minor < 27 { - tracing::error!( + tracelimit::error_ratelimited!( major = init.major, minor = init.minor, "Got unsupported kernel version", From cb0b3cde1b1e7cedddbde557b1a18c1016e25537 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:07:41 -0700 Subject: [PATCH 031/304] virtiofs: make handle and inode ID allocation fallible HandleMap hands out file handles and FUSE node IDs from an incrementing counter. It panicked if the next ID was still in use, and silently wrapped the counter after u64::MAX. The inode map and the virtio-fs root setup asserted on the IDs they got back, and InodeMap::remove and InodeMap::clear unwrapped lookups of IDs that the guest controls. All of these can be reached by guest requests. Make HandleMap::insert return None instead of panicking once the next ID is in use or the counter is exhausted; it no longer wraps. InodeMap::insert now returns a Result, and every caller (open, create, mkdir, mknod, symlink, lookup, the aggregate volume, and the Windows section volume) reports exhaustion to the guest as ENOSPC. Removing an unknown node ID is now a no-op. Clearing the map with a missing or unexpected root leaves it empty instead of asserting. Creating a volume whose root does not receive the FUSE root ID fails with EINVAL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/virtio/virtiofs/src/aggregate.rs | 2 +- vm/devices/virtio/virtiofs/src/lib.rs | 72 ++++++++++++++------- vm/devices/virtio/virtiofs/src/section.rs | 4 +- 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/vm/devices/virtio/virtiofs/src/aggregate.rs b/vm/devices/virtio/virtiofs/src/aggregate.rs index 79a5fadbace..e20535fd3e6 100644 --- a/vm/devices/virtio/virtiofs/src/aggregate.rs +++ b/vm/devices/virtio/virtiofs/src/aggregate.rs @@ -242,7 +242,7 @@ impl VirtioFs { fn insert_child_root_entry(&self, volume: Arc) -> lx::Result { let (inode, stat) = VirtioFsInode::new(volume, PathBuf::new())?; let attr = inode.attr_from_stat(&stat); - let (_, node_id) = self.insert_inode(inode); + let (_, node_id) = self.insert_inode(inode)?; Ok(fuse_entry_out::new( node_id, ENTRY_TIMEOUT, diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index b2dda91f832..c0e1e13bac1 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -214,7 +214,7 @@ impl Fuse for VirtioFs { let inode = self.get_inode(request.node_id())?; self.check_open_readonly(&inode, flags)?; let file = inode.open(flags)?; - let fh = self.insert_file(file); + let fh = self.insert_file(file)?; // TODO: Optionally allow caching. Ok(fuse_open_out::new(fh, FOPEN_DIRECT_IO)) @@ -237,9 +237,9 @@ impl Fuse for VirtioFs { // Insert the newly created inode; this can return an existing inode if it found a match // on the inode number (if this is a non-exclusive create), so make sure to associate the // file with the returned inode. - let (new_inode, node_id) = self.insert_inode(new_inode); + let (new_inode, node_id) = self.insert_inode(new_inode)?; let file = VirtioFsFile::new(file, new_inode); - let fh = self.insert_file(file); + let fh = self.insert_file(file)?; Ok(CreateOut { entry: fuse_entry_out::new(node_id, ENTRY_TIMEOUT, ATTRIBUTE_TIMEOUT, attr), open: fuse_open_out::new(fh, FOPEN_DIRECT_IO), @@ -258,7 +258,7 @@ impl Fuse for VirtioFs { let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; let (new_inode, attr) = inode.mkdir(name, arg.mode, request.uid(), request.gid())?; - let (_, node_id) = self.insert_inode(new_inode); + let (_, node_id) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( node_id, ENTRY_TIMEOUT, @@ -281,7 +281,7 @@ impl Fuse for VirtioFs { let (new_inode, attr) = inode.mknod(name, arg.mode, request.uid(), request.gid(), arg.rdev)?; - let (_, node_id) = self.insert_inode(new_inode); + let (_, node_id) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( node_id, ENTRY_TIMEOUT, @@ -303,7 +303,7 @@ impl Fuse for VirtioFs { self.check_writable(&inode)?; let (new_inode, attr) = inode.symlink(name, target, request.uid(), request.gid())?; - let (_, node_id) = self.insert_inode(new_inode); + let (_, node_id) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( node_id, ENTRY_TIMEOUT, @@ -572,7 +572,9 @@ impl VirtioFs { let mut inodes = InodeMap::new(false); let volume = Arc::new(VirtioFsVolume::new(volume, 0, readonly)); let (root_inode, _) = VirtioFsInode::new(volume, PathBuf::new())?; - assert!(inodes.insert(root_inode).1 == FUSE_ROOT_ID); + if inodes.insert(root_inode)?.1 != FUSE_ROOT_ID { + return Err(lx::Error::EINVAL); + } Ok(Self { inner: Arc::new(VirtioFsInner { inodes: RwLock::new(inodes), @@ -601,7 +603,7 @@ impl VirtioFs { fn lookup_helper(&self, inode: &VirtioFsInode, name: &lx::LxStr) -> lx::Result { let (new_inode, attr) = inode.lookup_child(name)?; - let (_, new_inode_nr) = self.insert_inode(new_inode); + let (_, new_inode_nr) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( new_inode_nr, ENTRY_TIMEOUT, @@ -638,8 +640,9 @@ impl VirtioFs { /// /// If the file system supports stable inode numbers and an inode already existed with this /// number, the existing inode is returned, not the passed in one. - fn insert_inode(&self, inode: VirtioFsInode) -> (Arc, u64) { - self.inner.inodes.write().insert(inode) + fn insert_inode(&self, inode: VirtioFsInode) -> lx::Result<(Arc, u64)> { + let mut inodes = self.inner.inodes.write(); + inodes.insert(inode) } /// Retrieve the file object with the specified file handle. @@ -654,8 +657,9 @@ impl VirtioFs { } /// Insert a new file object, and return the assigned file handle. - fn insert_file(&self, file: VirtioFsFile) -> u64 { - self.inner.files.write().insert(Arc::new(file)) + fn insert_file(&self, file: VirtioFsFile) -> lx::Result { + let mut files = self.inner.files.write(); + files.insert(Arc::new(file)).ok_or(lx::Error::ENOSPC) } /// Remove the file with the specified node ID. @@ -687,14 +691,18 @@ impl HandleMap { } /// Inserts an item into the map, and returns the assigned handle. - pub fn insert(&mut self, value: T) -> u64 { - let handle = self.next_handle; - if self.values.insert(handle, value).is_some() { - panic!("Inode number reused."); + pub fn insert(&mut self, value: T) -> Option { + if !self.can_insert() { + return None; } + let handle = self.next_handle; + self.values.insert(handle, value); + self.next_handle = handle.checked_add(1).unwrap_or(0); + Some(handle) + } - self.next_handle += 1; - handle + pub fn can_insert(&self) -> bool { + self.next_handle != 0 && !self.values.contains_key(&self.next_handle) } /// Retrieves a value from the map. @@ -758,7 +766,7 @@ impl InodeMap { } /// Insert an inode into the map, returning its node ID. - pub fn insert(&mut self, inode: VirtioFsInode) -> (Arc, u64) { + pub fn insert(&mut self, inode: VirtioFsInode) -> lx::Result<(Arc, u64)> { // Reuse an existing node id for the same host file; see `DedupKey` // for how each volume type is keyed. match self.inodes_by_key.entry(inode.dedup_key()) { @@ -767,21 +775,26 @@ impl InodeMap { let new_path = inode.clone_path(); let (existing, node_id) = entry.get(); existing.lookup(new_path); - (Arc::clone(existing), *node_id) + Ok((Arc::clone(existing), *node_id)) } Entry::Vacant(entry) => { // Inode not found, so insert it into both maps. let inode = Arc::new(inode); - let node_id = self.inodes_by_node_id.insert(Arc::clone(&inode)); + let node_id = self + .inodes_by_node_id + .insert(Arc::clone(&inode)) + .ok_or(lx::Error::ENOSPC)?; entry.insert((Arc::clone(&inode), node_id)); - (inode, node_id) + Ok((inode, node_id)) } } } /// Remove an inode with the specified FUSE node ID from the map. pub fn remove(&mut self, node_id: u64) { - let inode = self.inodes_by_node_id.remove(node_id).unwrap(); + let Some(inode) = self.inodes_by_node_id.remove(node_id) else { + return; + }; // Only drop the by-key entry if it still points at THIS node: on // path-keyed volumes the path may have been repointed to a newer inode // (via delete+recreate or `evict_dedup_key`), which must not be lost. @@ -812,11 +825,20 @@ impl InodeMap { return; } - let root_inode = Arc::clone(self.inodes_by_node_id.get(FUSE_ROOT_ID).unwrap()); + let Some(root_inode) = self.inodes_by_node_id.get(FUSE_ROOT_ID).cloned() else { + self.inodes_by_node_id.clear(); + self.inodes_by_key.clear(); + return; + }; self.inodes_by_node_id.clear(); // Re-insert the root inode. - assert!(self.inodes_by_node_id.insert(Arc::clone(&root_inode)) == FUSE_ROOT_ID); + let inserted = self.inodes_by_node_id.insert(Arc::clone(&root_inode)); + if inserted != Some(FUSE_ROOT_ID) { + self.inodes_by_node_id.clear(); + self.inodes_by_key.clear(); + return; + } // Rebuild the dedup map with just the root. self.inodes_by_key.clear(); diff --git a/vm/devices/virtio/virtiofs/src/section.rs b/vm/devices/virtio/virtiofs/src/section.rs index 8999689b447..af8a3fd8f1d 100644 --- a/vm/devices/virtio/virtiofs/src/section.rs +++ b/vm/devices/virtio/virtiofs/src/section.rs @@ -253,7 +253,7 @@ impl fuse::Fuse for SectionFs { state: InodeState::Open(section), }; let attr = inode.attr(0, &self.attr); - let node_id = self.inodes.lock().insert(inode); + let node_id = self.inodes.lock().insert(inode).ok_or(lx::Error::ENOSPC)?; tracing::trace!(node_id, name = name.to_str().unwrap(), "node_id"); Ok(fuse::protocol::fuse_entry_out::new( node_id, @@ -359,7 +359,7 @@ impl fuse::Fuse for SectionFs { }; let inode = Inode { size, state }; let mut attr = inode.attr(0, &self.attr); - let node_id = self.inodes.lock().insert(inode); + let node_id = self.inodes.lock().insert(inode).ok_or(lx::Error::ENOSPC)?; attr.ino = node_id; tracing::trace!(node_id, name = name.to_str().unwrap(), "node_id"); Ok(fuse::CreateOut { From 2400875dbcc80b7baecf5ac2d363a7684657bc4b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:09:52 -0700 Subject: [PATCH 032/304] virtiofs: bound guest payload allocations and rate-limit diagnostics Several virtio-fs paths trust sizes and offsets supplied by the guest: - read, getxattr, and listxattr allocated a buffer of whatever size the guest requested, and readdir reserved that much capacity, so one request could ask for up to 4 GiB of host memory; - write and setxattr accepted payloads of any size; - the payload reader and writer computed remaining lengths and guest addresses with unchecked arithmetic and asserted that a read returned the expected length, and read_until did not rewind the reader position after finding its terminator, so a malformed descriptor chain could underflow or panic; - the Windows section volume unwrapped the UTF-8 conversion of a guest-provided name while tracing it. Cap guest-directed buffers at 1 MiB and fail larger requests with E2BIG. Reserve the memory fallibly and fail with ENOMEM when that is not possible. Use checked arithmetic for payload offsets, lengths, guest addresses, and the written byte count, and report inconsistent chains as invalid data or EIO. Keep the reader position consistent after read_until, and trace names with their Debug representation. Also rate-limit the warnings and errors that the guest can trigger at will: unknown inodes and file handles, too many forgets, unknown config writes, and request processing failures. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + vm/devices/virtio/virtiofs/Cargo.toml | 1 + vm/devices/virtio/virtiofs/src/file.rs | 8 ++++- vm/devices/virtio/virtiofs/src/inode.rs | 2 +- vm/devices/virtio/virtiofs/src/lib.rs | 31 +++++++++++++--- vm/devices/virtio/virtiofs/src/section.rs | 4 +-- vm/devices/virtio/virtiofs/src/virtio.rs | 16 +++++---- vm/devices/virtio/virtiofs/src/virtio_util.rs | 36 ++++++++++++++----- 8 files changed, 76 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 57a43b31754..06bd7c7bb7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10235,6 +10235,7 @@ dependencies = [ "task_control", "tempfile", "test_with_tracing", + "tracelimit", "tracing", "virtio", "virtio_resources", diff --git a/vm/devices/virtio/virtiofs/Cargo.toml b/vm/devices/virtio/virtiofs/Cargo.toml index 2599b4293cb..321945fb1f0 100644 --- a/vm/devices/virtio/virtiofs/Cargo.toml +++ b/vm/devices/virtio/virtiofs/Cargo.toml @@ -24,6 +24,7 @@ anyhow.workspace = true futures.workspace = true pal_async.workspace = true parking_lot.workspace = true +tracelimit.workspace = true tracing.workspace = true zerocopy.workspace = true [target.'cfg(windows)'.dependencies] diff --git a/vm/devices/virtio/virtiofs/src/file.rs b/vm/devices/virtio/virtiofs/src/file.rs index 93753e66453..0c97512ff77 100644 --- a/vm/devices/virtio/virtiofs/src/file.rs +++ b/vm/devices/virtio/virtiofs/src/file.rs @@ -76,7 +76,13 @@ impl VirtioFsFile { size: u32, plus: bool, ) -> lx::Result> { - let mut buffer = Vec::with_capacity(size as usize); + if size as usize > crate::MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(size as usize) + .map_err(|_| lx::Error::ENOMEM)?; let mut entry_count: u32 = 0; // Report the directory's guest-visible inode number so `.`/`..` agree // with the number reported by lookup/getattr. diff --git a/vm/devices/virtio/virtiofs/src/inode.rs b/vm/devices/virtio/virtiofs/src/inode.rs index bdb35e3ee88..ed3dc51f572 100644 --- a/vm/devices/virtio/virtiofs/src/inode.rs +++ b/vm/devices/virtio/virtiofs/src/inode.rs @@ -189,7 +189,7 @@ impl VirtioFsInode { let mut old_count = self.lookup_count.load(Ordering::Acquire); loop { let new_count = if lookup_count > old_count { - tracing::warn!(node_id, "Too many forgets for inode"); + tracelimit::warn_ratelimited!(node_id, "Too many forgets for inode"); 0 } else { old_count - lookup_count diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index c0e1e13bac1..f6e2500cf5b 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -47,6 +47,8 @@ const ATTRIBUTE_TIMEOUT: Duration = Duration::from_millis(1); // update the path. const ENTRY_TIMEOUT: Duration = Duration::from_secs(0); +const MAX_GUEST_BUFFER_SIZE: usize = 1024 * 1024; + /// Shared mutable state behind a [`VirtioFs`] handle. struct VirtioFsInner { inodes: RwLock, @@ -341,13 +343,16 @@ impl Fuse for VirtioFs { fn read(&self, _request: &Request, arg: &fuse_read_in) -> lx::Result> { let file = self.get_file(arg.fh)?; - let mut buffer = vec![0u8; arg.size as usize]; + let mut buffer = guest_buffer(arg.size)?; let size = file.read(&mut buffer, arg.offset)?; buffer.truncate(size); Ok(buffer) } fn write(&self, request: &Request, arg: &fuse_write_in, data: &[u8]) -> lx::Result { + if data.len() > MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } let file = self.get_file(arg.fh)?; self.check_writable(file.inode())?; file.write(data, arg.offset, request.uid()) @@ -454,7 +459,7 @@ impl Fuse for VirtioFs { return Err(lx::Error::ENODATA); } let inode = self.get_inode(request.node_id())?; - let mut value = vec![0u8; size as usize]; + let mut value = guest_buffer(size)?; let size = inode.get_xattr(name, Some(&mut value))?; value.truncate(size); Ok(value) @@ -480,6 +485,9 @@ impl Fuse for VirtioFs { if self.is_synthetic_root(request.node_id()) { return Err(lx::Error::EROFS); } + if value.len() > MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; inode.set_xattr(name, value, flags) @@ -490,7 +498,7 @@ impl Fuse for VirtioFs { return Ok(Vec::new()); } let inode = self.get_inode(request.node_id())?; - let mut list = vec![0u8; size as usize]; + let mut list = guest_buffer(size)?; let size = inode.list_xattr(Some(&mut list))?; list.truncate(size); Ok(list) @@ -631,7 +639,7 @@ impl VirtioFs { /// Retrieve the inode with the specified node ID. fn get_inode(&self, node_id: u64) -> lx::Result> { self.inner.inodes.read().get(node_id).ok_or_else(|| { - tracing::warn!(node_id, "request for unknown inode"); + tracelimit::warn_ratelimited!(node_id, "request for unknown inode"); lx::Error::EINVAL }) } @@ -649,7 +657,7 @@ impl VirtioFs { fn get_file(&self, fh: u64) -> lx::Result> { let files = self.inner.files.read(); let file = files.get(fh).ok_or_else(|| { - tracing::warn!(fh, "Request for unknown file"); + tracelimit::warn_ratelimited!(fh, "Request for unknown file"); lx::Error::EBADF })?; @@ -668,6 +676,19 @@ impl VirtioFs { } } +fn guest_buffer(size: u32) -> lx::Result> { + let size = size as usize; + if size > MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(size) + .map_err(|_| lx::Error::ENOMEM)?; + buffer.resize(size, 0); + Ok(buffer) +} + /// A key/value map where the keys are automatically incremented identifiers. struct HandleMap { values: HashMap, diff --git a/vm/devices/virtio/virtiofs/src/section.rs b/vm/devices/virtio/virtiofs/src/section.rs index af8a3fd8f1d..5d8140ff010 100644 --- a/vm/devices/virtio/virtiofs/src/section.rs +++ b/vm/devices/virtio/virtiofs/src/section.rs @@ -254,7 +254,7 @@ impl fuse::Fuse for SectionFs { }; let attr = inode.attr(0, &self.attr); let node_id = self.inodes.lock().insert(inode).ok_or(lx::Error::ENOSPC)?; - tracing::trace!(node_id, name = name.to_str().unwrap(), "node_id"); + tracing::trace!(node_id, name = ?name, "node_id"); Ok(fuse::protocol::fuse_entry_out::new( node_id, Duration::from_secs(0), @@ -361,7 +361,7 @@ impl fuse::Fuse for SectionFs { let mut attr = inode.attr(0, &self.attr); let node_id = self.inodes.lock().insert(inode).ok_or(lx::Error::ENOSPC)?; attr.ino = node_id; - tracing::trace!(node_id, name = name.to_str().unwrap(), "node_id"); + tracing::trace!(node_id, name = ?name, "node_id"); Ok(fuse::CreateOut { entry: fuse::protocol::fuse_entry_out::new( node_id, diff --git a/vm/devices/virtio/virtiofs/src/virtio.rs b/vm/devices/virtio/virtiofs/src/virtio.rs index a66fbe4efff..508fa288f33 100644 --- a/vm/devices/virtio/virtiofs/src/virtio.rs +++ b/vm/devices/virtio/virtiofs/src/virtio.rs @@ -178,7 +178,7 @@ impl VirtioDevice for VirtioFsDevice { } async fn write_registers_u32(&mut self, offset: u16, val: u32) { - tracing::warn!(offset, val, "[virtiofs] Unknown write",); + tracelimit::warn_ratelimited!(offset, val, "[virtiofs] Unknown write",); } fn set_shared_memory_region( @@ -292,7 +292,7 @@ impl AsyncRun for VirtioFsWorker { state.queue.complete(work, bytes); } Err(err) => { - tracing::error!( + tracelimit::error_ratelimited!( error = &err as &dyn std::error::Error, "Failed processing queue" ); @@ -314,7 +314,7 @@ fn process_virtiofs_request( let request = match fuse::Request::new(reader) { Ok(request) => request, Err(e) => { - tracing::error!( + tracelimit::error_ratelimited!( error = &e as &dyn std::error::Error, "[virtiofs] Invalid FUSE message, error" ); @@ -363,16 +363,20 @@ struct VirtioReplySender<'a> { impl fuse::ReplySender for VirtioReplySender<'_> { fn send(&mut self, bufs: &[io::IoSlice<'_>]) -> io::Result<()> { let mut writer = VirtioPayloadWriter::new(self.mem, self.work); - let mut size = 0; + let mut size: usize = 0; // Write all the slices to the payload buffers. // N.B. write_vectored isn't used because it isn't guaranteed to write all the data. for buf in bufs { writer.write_all(buf)?; - size += buf.len(); + size = size + .checked_add(buf.len()) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; } - self.bytes_written = size as u32; + self.bytes_written = size + .try_into() + .map_err(|_| io::Error::from(io::ErrorKind::InvalidData))?; Ok(()) } } diff --git a/vm/devices/virtio/virtiofs/src/virtio_util.rs b/vm/devices/virtio/virtiofs/src/virtio_util.rs index e175bff9582..0e8e5e9c9b3 100644 --- a/vm/devices/virtio/virtiofs/src/virtio_util.rs +++ b/vm/devices/virtio/virtiofs/src/virtio_util.rs @@ -61,7 +61,7 @@ impl<'payload, 'mem> VirtioPayloadReader<'payload, 'mem> { /// Gets the remaining length of the current payload buffer only. fn get_current_remaining_len(&mut self) -> usize { if let Some(payload) = self.get_payload() { - payload.length as usize - self.offset + (payload.length as usize).saturating_sub(self.offset) } else { 0 } @@ -74,10 +74,16 @@ impl Read for VirtioPayloadReader<'_, '_> { // Determine how much space is left in the current buffer, and read at most that much. // A single call to read won't cross payload buffers, so to read more you must call // it repeatedly or e.g. use read_exact. - let remaining = payload.length as usize - self.offset; + let remaining = (payload.length as usize) + .checked_sub(self.offset) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; let size = cmp::min(remaining, buf.len()); + let address = payload + .address + .checked_add(self.offset as u64) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; self.guest_memory - .read_at(payload.address + self.offset as u64, &mut buf[..size]) + .read_at(address, &mut buf[..size]) .map_err(io::Error::other)?; self.offset += size; self.position += size; @@ -103,9 +109,16 @@ impl fuse::RequestReader for VirtioPayloadReader<'_, '_> { return Err(lx::Error::EINVAL); } - buffer.resize_with(buffer.len() + len, Default::default); + let new_len = buffer.len().checked_add(len).ok_or(lx::Error::E2BIG)?; + buffer + .try_reserve_exact(len) + .map_err(|_| lx::Error::ENOMEM)?; + buffer.resize(new_len, 0); let start_offset = self.offset; - assert!(self.read(&mut buffer[buffer_offset..])? == len); + let start_position = self.position; + if self.read(&mut buffer[buffer_offset..])? != len { + return Err(lx::Error::EIO); + } // Search for a matching byte in the portion of the buffer we just read. if let Some(length) = buffer[buffer_offset..].iter().position(|&c| c == byte) { @@ -114,6 +127,7 @@ impl fuse::RequestReader for VirtioPayloadReader<'_, '_> { // Rewind the offset to be just after the matching byte. self.offset = start_offset + length + 1; + self.position = start_position + length + 1; return Ok(buffer); } else { buffer_offset += len; @@ -122,7 +136,7 @@ impl fuse::RequestReader for VirtioPayloadReader<'_, '_> { } fn remaining_len(&self) -> usize { - self.len - self.position + self.len.saturating_sub(self.position) } } @@ -175,10 +189,16 @@ impl Write for VirtioPayloadWriter<'_, '_> { // Find the remaining size of the current payload buffer, and write at most that much. // This method never writes data spanning multiple buffers, so to write more you must // call it repeatedly or use write_all. - let remaining = payload.length as usize - self.offset; + let remaining = (payload.length as usize) + .checked_sub(self.offset) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; let size = cmp::min(remaining, buf.len()); + let address = payload + .address + .checked_add(self.offset as u64) + .ok_or_else(|| io::Error::from(io::ErrorKind::InvalidData))?; self.guest_memory - .write_at(payload.address + self.offset as u64, &buf[..size]) + .write_at(address, &buf[..size]) .map_err(io::Error::other)?; self.offset += size; Ok(size) From 6d64c181dcc32d1df0be18a0c39ed9a9e366584a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:13:21 -0700 Subject: [PATCH 033/304] serial_socket: drop the connection on any write failure When a write to the connected socket failed with BrokenPipe, the socket backend reported the write as successful and kept the dead socket. Any other write error was returned, but the socket also stayed attached. Either way the backend kept writing to a connection that could no longer carry data, and it never reported the disconnect or accepted a new client. Drop the current connection on any write error and return the error. Writes are then discarded, as they are while no client is connected, and the backend reports itself disconnected so the next connect accepts a new client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/serial/serial_socket/src/net.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vm/devices/serial/serial_socket/src/net.rs b/vm/devices/serial/serial_socket/src/net.rs index 10cdce09e7d..095d2aca717 100644 --- a/vm/devices/serial/serial_socket/src/net.rs +++ b/vm/devices/serial/serial_socket/src/net.rs @@ -200,11 +200,11 @@ impl AsyncWrite for SocketSerialBackend { let Some(current) = &mut self.current else { return Poll::Ready(Ok(buf.len())); }; - let r = ready!(Pin::new(current).poll_write(cx, buf)); - if matches!(&r, Err(err) if err.kind() == io::ErrorKind::BrokenPipe) { - return Poll::Ready(Ok(buf.len())); + let result = ready!(Pin::new(current).poll_write(cx, buf)); + if result.is_err() { + self.current = None; } - Poll::Ready(r) + Poll::Ready(result) } fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { From a60e1210b57fe549e04b3912a82448d7e7ef1d61 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:14:03 -0700 Subject: [PATCH 034/304] serial_socket: keep Windows named-pipe listeners reconnectable A Windows named-pipe serial backend is meant to return to listening for a new client after its client goes away. Three paths broke that: - disconnect_pipe on a pipe whose client already closed can fail with NotConnected or BrokenPipe. That error aborted the disconnect after the backend had already entered its terminal state, so no client could connect again. - Calling disconnect while listening replaced the listener with the terminal state as well. - A write to a client that went away was reported as successful when it failed with BrokenPipe, returned as an error otherwise, and in both cases the dead connection was kept. Treat NotConnected and BrokenPipe from disconnect_pipe as an already disconnected pipe, make disconnect do nothing unless a client is connected, and on any write failure return the error and go back to listening. Add a Windows test that connects a replacement client after the first client closes its handle. Co-authored-by: Huzaifa Danish Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 3 ++ vm/devices/serial/serial_socket/Cargo.toml | 7 +++ vm/devices/serial/serial_socket/src/lib.rs | 2 + .../serial/serial_socket/src/windows.rs | 26 +++++++-- .../serial/serial_socket/src/windows_tests.rs | 53 +++++++++++++++++++ 5 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 vm/devices/serial/serial_socket/src/windows_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 06bd7c7bb7f..aac2a5b3536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7755,15 +7755,18 @@ name = "serial_socket" version = "0.0.0" dependencies = [ "futures", + "getrandom 0.4.2", "inspect", "mesh", "pal", "pal_async", + "pal_async_test", "serial_core", "socket2", "tracing", "unix_socket", "vm_resource", + "windows-sys 0.61.0", ] [[package]] diff --git a/vm/devices/serial/serial_socket/Cargo.toml b/vm/devices/serial/serial_socket/Cargo.toml index c9663125c07..1b24cd191ea 100644 --- a/vm/devices/serial/serial_socket/Cargo.toml +++ b/vm/devices/serial/serial_socket/Cargo.toml @@ -21,5 +21,12 @@ socket2.workspace = true pal.workspace = true tracing.workspace = true +[target.'cfg(windows)'.dev-dependencies] +windows-sys.workspace = true + +[dev-dependencies] +getrandom.workspace = true +pal_async_test.workspace = true + [lints] workspace = true diff --git a/vm/devices/serial/serial_socket/src/lib.rs b/vm/devices/serial/serial_socket/src/lib.rs index 247d4a8729c..3b300544b87 100644 --- a/vm/devices/serial/serial_socket/src/lib.rs +++ b/vm/devices/serial/serial_socket/src/lib.rs @@ -9,3 +9,5 @@ pub mod net; #[cfg(windows)] pub mod windows; +#[cfg(all(test, windows))] +mod windows_tests; diff --git a/vm/devices/serial/serial_socket/src/windows.rs b/vm/devices/serial/serial_socket/src/windows.rs index 9a179a97a31..22c2cb1f89e 100644 --- a/vm/devices/serial/serial_socket/src/windows.rs +++ b/vm/devices/serial/serial_socket/src/windows.rs @@ -112,9 +112,20 @@ impl WindowsPipeSerialBackend { } fn disconnect(&mut self) -> io::Result<()> { + if !matches!(self.state, PipeState::Connected(_)) { + return Ok(()); + } if let PipeState::Connected(pipe) = std::mem::replace(&mut self.state, PipeState::Done) { let pipe = pipe.into_inner(); - pipe.disconnect_pipe()?; + match pipe.disconnect_pipe() { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotConnected | io::ErrorKind::BrokenPipe + ) => {} + Err(error) => return Err(error), + } self.state = PipeState::Listening(ListeningPipe::new(&self.driver, pipe)?); } Ok(()) @@ -195,11 +206,16 @@ impl AsyncWrite for WindowsPipeSerialBackend { match &mut self.state { PipeState::Done | PipeState::Listening(_) => Poll::Ready(Ok(buf.len())), PipeState::Connected(pipe) => { - let r = ready!(Pin::new(pipe).poll_write(cx, buf)); - if matches!(&r, Err(err) if err.kind() == io::ErrorKind::BrokenPipe) { - return Poll::Ready(Ok(buf.len())); + let result = ready!(Pin::new(pipe).poll_write(cx, buf)); + if result.is_err() + && let Err(error) = self.disconnect() + { + tracing::error!( + error = &error as &dyn std::error::Error, + "failed to prepare named pipe after a write failure" + ); } - Poll::Ready(r) + Poll::Ready(result) } } } diff --git a/vm/devices/serial/serial_socket/src/windows_tests.rs b/vm/devices/serial/serial_socket/src/windows_tests.rs new file mode 100644 index 00000000000..90691e214bf --- /dev/null +++ b/vm/devices/serial/serial_socket/src/windows_tests.rs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +use crate::windows::OpenWindowsPipeSerialConfig; +use crate::windows::WindowsPipeSerialBackend; +use futures::future::poll_fn; +use pal::windows::pipe::Disposition; +use pal::windows::pipe::PipeExt; +use pal::windows::pipe::PipeMode; +use pal::windows::pipe::new_named_pipe; +use pal_async::DefaultDriver; +use pal_async_test::async_test; +use serial_core::SerialIo; +use std::fs::OpenOptions; +use windows_sys::Win32::Foundation::GENERIC_READ; +use windows_sys::Win32::Foundation::GENERIC_WRITE; +use windows_sys::Win32::System::Pipes::PIPE_NOWAIT; + +#[async_test] +async fn reconnects_after_client_close(driver: DefaultDriver) { + let mut id = [0; 16]; + getrandom::fill(&mut id).unwrap(); + let path = format!(r#"\\.\pipe\{:0x}"#, u128::from_ne_bytes(id)); + let server = new_named_pipe( + &path, + GENERIC_READ | GENERIC_WRITE, + Disposition::Create, + PipeMode::Byte, + ) + .unwrap(); + let mut backend = WindowsPipeSerialBackend::new( + Box::new(driver.clone()), + OpenWindowsPipeSerialConfig::from(server), + ) + .unwrap(); + + let client = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + client.set_pipe_mode(PIPE_NOWAIT).unwrap(); + poll_fn(|cx| backend.poll_connect(cx)).await.unwrap(); + drop(client); + poll_fn(|cx| backend.poll_disconnect(cx)).await.unwrap(); + + let _replacement = OpenOptions::new() + .read(true) + .write(true) + .open(&path) + .unwrap(); + poll_fn(|cx| backend.poll_connect(cx)).await.unwrap(); +} From 54c5c34cdcbda084adf5fd8a12fb43da7af5c7fe Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:21:10 -0700 Subject: [PATCH 035/304] virtio_console: rate-limit worker errors When the console worker loop fails, it logs the error and stops. The loop fails on malformed guest descriptors, so a guest that keeps resetting the device and feeding it bad queues can trigger the error repeatedly and flood the host log. Log the failure through the tracelimit rate-limited macro. That was the crate's only direct use of tracing, so drop the dependency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 - vm/devices/virtio/virtio_console/Cargo.toml | 1 - vm/devices/virtio/virtio_console/src/lib.rs | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index aac2a5b3536..3cd44bd5f41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10067,7 +10067,6 @@ dependencies = [ "test_with_tracing", "thiserror 2.0.16", "tracelimit", - "tracing", "virtio", "virtio_resources", "vm_resource", diff --git a/vm/devices/virtio/virtio_console/Cargo.toml b/vm/devices/virtio/virtio_console/Cargo.toml index 03f8ccdf36d..33eea8ad7e8 100644 --- a/vm/devices/virtio/virtio_console/Cargo.toml +++ b/vm/devices/virtio/virtio_console/Cargo.toml @@ -25,7 +25,6 @@ futures-concurrency.workspace = true parking_lot.workspace = true thiserror.workspace = true tracelimit.workspace = true -tracing.workspace = true [dev-dependencies] futures.workspace = true diff --git a/vm/devices/virtio/virtio_console/src/lib.rs b/vm/devices/virtio/virtio_console/src/lib.rs index b740db5dc0e..908d83bd8f7 100644 --- a/vm/devices/virtio/virtio_console/src/lib.rs +++ b/vm/devices/virtio/virtio_console/src/lib.rs @@ -220,7 +220,7 @@ impl AsyncRun for ConsoleWorker { ) -> Result<(), Cancelled> { stop.until_stopped(self.run_loop(state)).await.map(|r| { if let Err(err) = r { - tracing::error!( + tracelimit::error_ratelimited!( error = &err as &dyn std::error::Error, "virtio-console worker loop failed" ); From c463c89575eeab848d8bcaa94aafbaf7715cba2f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:25:51 -0700 Subject: [PATCH 036/304] consomme: bound active TCP, UDP, and ICMP flows Consomme creates a host socket for every new guest flow and keeps it until the flow closes or times out. A guest can open flows faster than they expire and exhaust host sockets and per-flow TCP buffers. Limit the number of simultaneously active guest flows per protocol: by default 128 TCP flows, 256 UDP flows, and 16 ICMP source flows. A new flow beyond the limit is rejected with a dedicated DropReason before any host socket is created or bound. An active flow is never evicted to make room. Inbound TCP connections from port forwarding are also dropped while the TCP limit is reached. Rejections are logged with rate limiting, and the limits and active flow counts are visible through inspect. Add tests that pin the defaults, and that check that the TCP and UDP limits reject a new flow without creating a host socket and admit it again once an existing flow is gone. Document the limits in the Consomme guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/backends/consomme.md | 4 ++ .../net/net_consomme/consomme/src/icmp.rs | 8 +++ .../net_consomme/consomme/src/icmp/limits.rs | 26 +++++++++ .../net/net_consomme/consomme/src/lib.rs | 10 ++++ .../net/net_consomme/consomme/src/limits.rs | 21 +++++++ .../net/net_consomme/consomme/src/tcp.rs | 14 +++++ .../net_consomme/consomme/src/tcp/limits.rs | 25 +++++++++ .../net_consomme/consomme/src/tcp/tests.rs | 3 + .../consomme/src/tcp/tests/limits.rs | 56 +++++++++++++++++++ .../net/net_consomme/consomme/src/tests.rs | 2 + .../net_consomme/consomme/src/tests/limits.rs | 33 +++++++++++ .../net/net_consomme/consomme/src/udp.rs | 9 +++ .../net_consomme/consomme/src/udp/limits.rs | 26 +++++++++ .../consomme/src/udp/tests/limits.rs | 51 +++++++++++++++++ vm/devices/net/net_consomme/src/lib.rs | 5 +- 15 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 vm/devices/net/net_consomme/consomme/src/icmp/limits.rs create mode 100644 vm/devices/net/net_consomme/consomme/src/limits.rs create mode 100644 vm/devices/net/net_consomme/consomme/src/tcp/limits.rs create mode 100644 vm/devices/net/net_consomme/consomme/src/tcp/tests/limits.rs create mode 100644 vm/devices/net/net_consomme/consomme/src/tests/limits.rs create mode 100644 vm/devices/net/net_consomme/consomme/src/udp/limits.rs create mode 100644 vm/devices/net/net_consomme/consomme/src/udp/tests/limits.rs diff --git a/Guide/src/reference/backends/consomme.md b/Guide/src/reference/backends/consomme.md index 33f78844462..9501ac2c9fa 100644 --- a/Guide/src/reference/backends/consomme.md +++ b/Guide/src/reference/backends/consomme.md @@ -161,6 +161,10 @@ host-side UDP socket bound to an ephemeral port. Datagrams are forwarded in both directions. Idle bindings are cleaned up after a configurable timeout (5 minutes by default, per RFC 4787). +Consomme admits at most 256 active guest UDP flows, 128 active guest TCP flows, +and 16 guest ICMP source flows by default. It rejects an excess flow before +creating its host socket; it never evicts an active flow to make room. + UDP packets to the gateway on well-known ports are intercepted: - **Port 67** — handled by the built-in DHCP server diff --git a/vm/devices/net/net_consomme/consomme/src/icmp.rs b/vm/devices/net/net_consomme/consomme/src/icmp.rs index 24dbedb38a0..abc85b89b1a 100644 --- a/vm/devices/net/net_consomme/consomme/src/icmp.rs +++ b/vm/devices/net/net_consomme/consomme/src/icmp.rs @@ -4,6 +4,8 @@ // UNSAFETY: needed to cast the socket buffer to `MaybeUninit`. #![expect(unsafe_code)] +mod limits; + use super::Access; use super::Client; use super::ConsommeState; @@ -49,12 +51,14 @@ const ICMPV4_HEADER_LEN: usize = 8; pub(crate) struct Icmp { connections: HashMap, + max_connections: usize, } impl Icmp { pub fn new() -> Self { Self { connections: HashMap::new(), + max_connections: crate::limits::DEFAULT_MAX_ACTIVE_ICMP_FLOWS, } } } @@ -62,6 +66,8 @@ impl Icmp { impl Inspect for Icmp { fn inspect(&self, req: inspect::Request<'_>) { let mut resp = req.respond(); + resp.field("max_connections", self.max_connections) + .field("active_connections", self.connections.len()); for (addr, conn) in &self.connections { resp.field(&format!("{}:{}", addr.ip(), addr.port()), conn); } @@ -242,6 +248,8 @@ impl Access<'_, T> { let icmp_packet = Icmpv4Packet::new_unchecked(payload); let guest_addr = SocketAddrV4::new(addresses.src_addr, 0); + self.inner.icmp.check_flow_limit(&guest_addr)?; + let entry = self.inner.icmp.connections.entry(guest_addr); let conn = match entry { hash_map::Entry::Occupied(conn) => conn.into_mut(), diff --git a/vm/devices/net/net_consomme/consomme/src/icmp/limits.rs b/vm/devices/net/net_consomme/consomme/src/icmp/limits.rs new file mode 100644 index 00000000000..70dd54091e1 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/icmp/limits.rs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Active-flow limit of ICMP source flows. + +use super::Icmp; +use crate::DropReason; +use std::net::SocketAddrV4; + +impl Icmp { + /// Rejects a new guest source flow when the active-flow limit is reached, + /// before a host socket is opened for it. + pub(super) fn check_flow_limit(&self, guest_addr: &SocketAddrV4) -> Result<(), DropReason> { + if !self.connections.contains_key(guest_addr) + && self.connections.len() >= self.max_connections + { + tracelimit::warn_ratelimited!( + max_connections = self.max_connections, + guest = %guest_addr, + "rejecting ICMP flow before host socket creation because the active-flow limit was reached" + ); + return Err(DropReason::IcmpConnectionLimit); + } + Ok(()) + } +} diff --git a/vm/devices/net/net_consomme/consomme/src/lib.rs b/vm/devices/net/net_consomme/consomme/src/lib.rs index 76b5b50dff5..0fc9ded96a0 100644 --- a/vm/devices/net/net_consomme/consomme/src/lib.rs +++ b/vm/devices/net/net_consomme/consomme/src/lib.rs @@ -22,6 +22,7 @@ mod dhcpv6; mod dns; mod dns_resolver; mod icmp; +pub mod limits; mod local_addr_map; mod ndp; mod tcp; @@ -669,6 +670,15 @@ pub enum DropReason { /// The send buffer is invalid. #[error("send buffer full")] SendBufferFull, + /// The active TCP flow limit was reached before a host socket was created. + #[error("active TCP flow limit reached")] + TcpConnectionLimit, + /// The active UDP flow limit was reached before a host socket was bound. + #[error("active UDP flow limit reached")] + UdpConnectionLimit, + /// The active ICMP flow limit was reached before a host socket was opened. + #[error("active ICMP flow limit reached")] + IcmpConnectionLimit, /// There was an IO error. #[error("io error")] Io(#[source] std::io::Error), diff --git a/vm/devices/net/net_consomme/consomme/src/limits.rs b/vm/devices/net/net_consomme/consomme/src/limits.rs new file mode 100644 index 00000000000..aadf1d19d9a --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/limits.rs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Limits on the number of simultaneously active guest flows. +//! +//! Each protocol rejects a new guest flow once it has this many active flows, +//! before creating any host socket for it. + +/// Default maximum number of simultaneously active guest TCP flows. +/// +/// The limit is checked before creating a host TCP socket or allocating +/// per-flow TCP buffers. +pub const DEFAULT_MAX_ACTIVE_TCP_FLOWS: usize = 128; +/// Default maximum number of simultaneously active guest UDP flows. +/// +/// The limit is checked before binding a host UDP socket. +pub const DEFAULT_MAX_ACTIVE_UDP_FLOWS: usize = 256; +/// Default maximum number of simultaneously active guest ICMP source flows. +/// +/// The limit is checked before opening a host ICMP socket. +pub const DEFAULT_MAX_ACTIVE_ICMP_FLOWS: usize = 16; diff --git a/vm/devices/net/net_consomme/consomme/src/tcp.rs b/vm/devices/net/net_consomme/consomme/src/tcp.rs index 41cc72718e9..a511abfda74 100644 --- a/vm/devices/net/net_consomme/consomme/src/tcp.rs +++ b/vm/devices/net/net_consomme/consomme/src/tcp.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. mod assembler; +mod limits; mod ring; use super::Access; @@ -73,6 +74,7 @@ pub(crate) struct Tcp { listeners: HashMap, #[inspect(skip)] timer: Option, + max_connections: usize, connection_params: ConnectionParams, aggregate_stats: TcpAggregateStats, } @@ -163,6 +165,7 @@ impl Tcp { connections: HashMap::new(), listeners: HashMap::new(), timer: None, + max_connections: crate::limits::DEFAULT_MAX_ACTIVE_TCP_FLOWS, connection_params: ConnectionParams { rx_buffer: NormalizedBufferBounds::from_bounds(rx_buffer), tx_buffer: NormalizedBufferBounds::from_bounds(tx_buffer), @@ -813,8 +816,17 @@ impl Access<'_, T> { dst: ft.src, }; + let at_capacity = self.inner.tcp.connections.len() + >= self.inner.tcp.max_connections; match self.inner.tcp.connections.entry(ft) { hash_map::Entry::Vacant(e) => { + if at_capacity { + tracelimit::warn_ratelimited!( + max_connections = self.inner.tcp.max_connections, + "dropping inbound TCP flow because the active-flow limit was reached" + ); + return true; + } let mut sender = Sender { ft: &ft, client: self.client, @@ -1049,6 +1061,8 @@ impl Access<'_, T> { state: &mut self.inner.state, }; + self.inner.tcp.check_flow_limit(&ft)?; + match self.inner.tcp.connections.entry(ft) { hash_map::Entry::Occupied(mut e) => { let keep = e.get_mut().inner.handle_packet(&mut sender, &tcp)?; diff --git a/vm/devices/net/net_consomme/consomme/src/tcp/limits.rs b/vm/devices/net/net_consomme/consomme/src/tcp/limits.rs new file mode 100644 index 00000000000..68a0f6b0f0f --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tcp/limits.rs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Active-flow limit of TCP connections. + +use super::Tcp; +use crate::DropReason; +use crate::FourTuple; + +impl Tcp { + /// Rejects a new guest flow when the active-flow limit is reached, before + /// a host socket is created for it. + pub(super) fn check_flow_limit(&self, ft: &FourTuple) -> Result<(), DropReason> { + if !self.connections.contains_key(ft) && self.connections.len() >= self.max_connections { + tracelimit::warn_ratelimited!( + max_connections = self.max_connections, + src = %ft.src, + dst = %ft.dst, + "rejecting TCP flow before host socket creation because the active-flow limit was reached" + ); + return Err(DropReason::TcpConnectionLimit); + } + Ok(()) + } +} diff --git a/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs b/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs index bc83659fa81..be4cc84310f 100644 --- a/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs +++ b/vm/devices/net/net_consomme/consomme/src/tcp/tests.rs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(unix)] +mod limits; + use super::*; use crate::BindError; use crate::ChecksumState; diff --git a/vm/devices/net/net_consomme/consomme/src/tcp/tests/limits.rs b/vm/devices/net/net_consomme/consomme/src/tcp/tests/limits.rs new file mode 100644 index 00000000000..4f63635982f --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tcp/tests/limits.rs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the TCP active-flow limit. + +use super::*; + +#[cfg(unix)] +#[pal_async::async_test] +async fn test_tcp_connection_limit_rejects_and_recovers(driver: DefaultDriver) { + let mut harness = TcpTestHarness::connect(driver).await; + harness.consomme.tcp.max_connections = 1; + assert_eq!(harness.consomme.tcp.connections.len(), 1); + + let syn = TcpRepr { + src_port: harness.guest_port + 1, + dst_port: harness.dst_port, + control: TcpControl::Syn, + seq_number: TcpSeqNumber(2000), + ack_number: None, + window_len: 64240, + window_scale: Some(7), + max_seg_size: Some(1460), + sack_permitted: false, + sack_ranges: [None, None, None], + timestamp: None, + payload: &[], + }; + let len = build_tcp_packet( + &mut harness.buf, + harness.guest_mac, + harness.gateway_mac, + harness.guest_ip, + harness.dst_ip, + &syn, + ); + let rejected = harness + .consomme + .access(&mut harness.client) + .send(&harness.buf[..len], &ChecksumState::NONE); + assert!(matches!(rejected, Err(DropReason::TcpConnectionLimit))); + assert_eq!( + harness.consomme.tcp.connections.len(), + 1, + "a rejected flow must not create a host TCP socket or buffer" + ); + + // Simulate the normal poll-time removal after a connection closes. + harness.consomme.tcp.connections.clear(); + harness + .consomme + .access(&mut harness.client) + .send(&harness.buf[..len], &ChecksumState::NONE) + .unwrap(); + assert_eq!(harness.consomme.tcp.connections.len(), 1); +} diff --git a/vm/devices/net/net_consomme/consomme/src/tests.rs b/vm/devices/net/net_consomme/consomme/src/tests.rs index 2d6cc0ee5be..192ff218ca6 100644 --- a/vm/devices/net/net_consomme/consomme/src/tests.rs +++ b/vm/devices/net/net_consomme/consomme/src/tests.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod limits; + use super::*; use pal_async::DefaultDriver; use smoltcp::phy::ChecksumCapabilities; diff --git a/vm/devices/net/net_consomme/consomme/src/tests/limits.rs b/vm/devices/net/net_consomme/consomme/src/tests/limits.rs new file mode 100644 index 00000000000..c0c653d49c1 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tests/limits.rs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the default resource limits. + +use super::*; +use crate::limits::DEFAULT_MAX_ACTIVE_ICMP_FLOWS; +use crate::limits::DEFAULT_MAX_ACTIVE_TCP_FLOWS; +use crate::limits::DEFAULT_MAX_ACTIVE_UDP_FLOWS; + +#[test] +fn default_resource_limits_are_pinned() { + let params = ConsommeParams::new().unwrap(); + assert_eq!(params.udp_timeout, Duration::from_secs(300)); + assert_eq!( + params.tcp_rx_buffer, + TcpBufferBounds { + initial: 16 << 10, + max: 4 << 20, + } + ); + assert_eq!( + params.tcp_tx_buffer, + TcpBufferBounds { + initial: 16 << 10, + max: 4 << 20, + } + ); + assert_eq!(DEFAULT_MAX_ACTIVE_TCP_FLOWS, 128); + assert_eq!(DEFAULT_MAX_ACTIVE_UDP_FLOWS, 256); + assert_eq!(dns_resolver::DEFAULT_MAX_PENDING_DNS_REQUESTS, 256); + assert_eq!(DEFAULT_MAX_ACTIVE_ICMP_FLOWS, 16); +} diff --git a/vm/devices/net/net_consomme/consomme/src/udp.rs b/vm/devices/net/net_consomme/consomme/src/udp.rs index 1a416025cce..03fc34ec7aa 100644 --- a/vm/devices/net/net_consomme/consomme/src/udp.rs +++ b/vm/devices/net/net_consomme/consomme/src/udp.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod limits; + use super::Access; use super::BindError; use super::Client; @@ -69,6 +71,7 @@ pub(crate) struct Udp { connections: HashMap, listeners: HashMap, timeout: Duration, + max_connections: usize, } impl Udp { @@ -77,6 +80,7 @@ impl Udp { connections: HashMap::new(), listeners: HashMap::new(), timeout, + max_connections: crate::limits::DEFAULT_MAX_ACTIVE_UDP_FLOWS, } } } @@ -84,6 +88,8 @@ impl Udp { impl InspectMut for Udp { fn inspect_mut(&mut self, req: inspect::Request<'_>) { let mut resp = req.respond(); + resp.field("max_connections", self.max_connections) + .field("active_connections", self.connections.len()); for (addr, conn) in &mut self.connections { let key = addr.to_string(); resp.field_mut(&key, conn); @@ -494,6 +500,7 @@ impl Access<'_, T> { guest_addr: SocketAddr, guest_mac: Option, ) -> Result<&mut UdpConnection, DropReason> { + self.inner.udp.check_flow_limit(&guest_addr)?; let entry = self.inner.udp.connections.entry(guest_addr); match entry { hash_map::Entry::Occupied(conn) => Ok(conn.into_mut()), @@ -803,6 +810,8 @@ fn build_udp_packet + AsMut<[u8]> + ?Sized>( #[cfg(all(unix, test))] mod tests { + mod limits; + use super::*; use crate::Consomme; use crate::ConsommeParams; diff --git a/vm/devices/net/net_consomme/consomme/src/udp/limits.rs b/vm/devices/net/net_consomme/consomme/src/udp/limits.rs new file mode 100644 index 00000000000..7763b6a81fc --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/udp/limits.rs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Active-flow limit of UDP flows. + +use super::Udp; +use crate::DropReason; +use std::net::SocketAddr; + +impl Udp { + /// Rejects a new guest flow when the active-flow limit is reached, before + /// a host socket is bound for it. + pub(super) fn check_flow_limit(&self, guest_addr: &SocketAddr) -> Result<(), DropReason> { + if !self.connections.contains_key(guest_addr) + && self.connections.len() >= self.max_connections + { + tracelimit::warn_ratelimited!( + max_connections = self.max_connections, + guest = %guest_addr, + "rejecting UDP flow before host socket binding because the active-flow limit was reached" + ); + return Err(DropReason::UdpConnectionLimit); + } + Ok(()) + } +} diff --git a/vm/devices/net/net_consomme/consomme/src/udp/tests/limits.rs b/vm/devices/net/net_consomme/consomme/src/udp/tests/limits.rs new file mode 100644 index 00000000000..4dd63587f36 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/udp/tests/limits.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the UDP active-flow limit. + +use super::*; + +#[pal_async::async_test] +async fn test_udp_connection_limit_rejects_and_recovers(driver: DefaultDriver) { + let driver = Arc::new(driver); + let mut consomme = create_consomme_with_timeout(Duration::from_millis(1)); + consomme.udp.max_connections = 2; + let mut client = TestClient::new(driver); + let mut access = consomme.access(&mut client); + + for port in [10001, 10002] { + access + .get_or_insert( + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 2), port)), + None, + ) + .unwrap(); + } + assert_eq!(access.udp_connection_count(), 2); + + let rejected = access.get_or_insert( + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 2), 10003)), + None, + ); + assert!(matches!(rejected, Err(DropReason::UdpConnectionLimit))); + assert_eq!( + access.udp_connection_count(), + 2, + "a rejected flow must not create a host UDP socket" + ); + + for connection in access.inner.udp.connections.values_mut() { + connection.last_activity = Instant::now() - Duration::from_millis(2); + } + let mut cx = Context::from_waker(std::task::Waker::noop()); + access.poll_udp(&mut cx); + assert_eq!(access.udp_connection_count(), 0); + + access + .get_or_insert( + SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::new(10, 0, 0, 2), 10003)), + None, + ) + .unwrap(); + assert_eq!(access.udp_connection_count(), 1); +} diff --git a/vm/devices/net/net_consomme/src/lib.rs b/vm/devices/net/net_consomme/src/lib.rs index 8290df7caad..d2a11d0e07e 100644 --- a/vm/devices/net/net_consomme/src/lib.rs +++ b/vm/devices/net/net_consomme/src/lib.rs @@ -730,7 +730,10 @@ impl net_backend::Queue for ConsommeQueue { tracing::debug!(error = &err as &dyn std::error::Error, "tx packet ignored"); match err { consomme::DropReason::SendBufferFull - | consomme::DropReason::DestinationNotAllowed => { + | consomme::DropReason::DestinationNotAllowed + | consomme::DropReason::TcpConnectionLimit + | consomme::DropReason::UdpConnectionLimit + | consomme::DropReason::IcmpConnectionLimit => { self.stats.tx_dropped.increment() } consomme::DropReason::UnsupportedEthertype(_) From 4f543e1dece4ce1d7aaeaac8c966923ed4846ab3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:27:05 -0700 Subject: [PATCH 037/304] consomme: cover the pending DNS request limit with a test The DNS resolver answers with SERVFAIL once DEFAULT_MAX_PENDING_DNS_REQUESTS queries are outstanding, but no test covered that limit. Add one that fills the resolver with queries to a backend that never answers and checks that the next query gets SERVFAIL without growing the pending count. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../consomme/src/dns_resolver/mod.rs | 2 + .../consomme/src/dns_resolver/tests.rs | 48 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 vm/devices/net/net_consomme/consomme/src/dns_resolver/tests.rs diff --git a/vm/devices/net/net_consomme/consomme/src/dns_resolver/mod.rs b/vm/devices/net/net_consomme/consomme/src/dns_resolver/mod.rs index 4ea63108fe3..283779f915b 100644 --- a/vm/devices/net/net_consomme/consomme/src/dns_resolver/mod.rs +++ b/vm/devices/net/net_consomme/consomme/src/dns_resolver/mod.rs @@ -14,6 +14,8 @@ use crate::DropReason; pub mod dns_tcp; mod static_records; +#[cfg(test)] +mod tests; pub(crate) use static_records::MAX_DNS_UDP_RESPONSE_LEN; pub use static_records::StaticDnsRecord; diff --git a/vm/devices/net/net_consomme/consomme/src/dns_resolver/tests.rs b/vm/devices/net/net_consomme/consomme/src/dns_resolver/tests.rs new file mode 100644 index 00000000000..6cc0885cfc3 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/dns_resolver/tests.rs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the DNS resolver's pending-request limit. + +use super::*; + +struct HoldingBackend; + +impl DnsBackend for HoldingBackend { + fn query( + &self, + _request: &DnsRequest<'_>, + _response_sender: Sender, + _query_id: u64, + ) { + } +} + +fn request() -> DnsRequest<'static> { + static QUERY: [u8; DNS_HEADER_SIZE + 1] = [0; DNS_HEADER_SIZE + 1]; + DnsRequest { + flow: DnsFlow { + src: "10.0.0.2:10000".parse().unwrap(), + dst: "10.0.0.1:53".parse().unwrap(), + gateway_mac: EthernetAddress([0x52, 0x54, 0, 0, 0, 1]), + client_mac: EthernetAddress([0x52, 0x54, 0, 0, 0, 2]), + transport: DnsTransport::Udp, + }, + dns_query: &QUERY, + } +} + +#[test] +fn default_pending_request_limit_returns_servfail() { + let mut resolver = DnsResolver::new_for_test(Arc::new(HoldingBackend)); + let request = request(); + for _ in 0..DEFAULT_MAX_PENDING_DNS_REQUESTS { + assert!(resolver.submit_udp_query(&request).unwrap().is_none()); + } + + let response = resolver + .submit_udp_query(&request) + .unwrap() + .expect("the pending-request limit must return SERVFAIL"); + assert_eq!(resolver.pending_requests, DEFAULT_MAX_PENDING_DNS_REQUESTS); + assert_eq!(response.response_data[3] & 0x0f, 2); +} From 99c6fec260fbbab11077324958699c15348ffc0d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:28:18 -0700 Subject: [PATCH 038/304] consomme: cover IPv4 fragment rejection with a test Consomme does not reassemble IPv4 fragments and drops them before checksum validation or any host socket work, but no test covered that. Add one that sends a TCP SYN with the more-fragments flag set and expects DropReason::FragmentedPacket. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../net/net_consomme/consomme/src/tests.rs | 1 + .../consomme/src/tests/fragments.rs | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 vm/devices/net/net_consomme/consomme/src/tests/fragments.rs diff --git a/vm/devices/net/net_consomme/consomme/src/tests.rs b/vm/devices/net/net_consomme/consomme/src/tests.rs index 192ff218ca6..c7039d497c6 100644 --- a/vm/devices/net/net_consomme/consomme/src/tests.rs +++ b/vm/devices/net/net_consomme/consomme/src/tests.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod fragments; mod limits; use super::*; diff --git a/vm/devices/net/net_consomme/consomme/src/tests/fragments.rs b/vm/devices/net/net_consomme/consomme/src/tests/fragments.rs new file mode 100644 index 00000000000..a26fbc6adba --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tests/fragments.rs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of IPv4 fragment rejection. + +use super::*; + +/// IPv4 fragments are dropped before checksum validation or host socket work. +#[pal_async::async_test] +async fn ipv4_fragments_are_rejected(driver: DefaultDriver) { + let mut consomme = Consomme::new(ConsommeParams::new().unwrap()); + let mut client = TestClient::new(driver); + let mut buf = vec![0u8; 1514]; + + let guest_mac = consomme.params_mut().client_mac; + let gateway_mac = consomme.params_mut().gateway_mac; + let guest_ip = consomme.params_mut().client_ip; + let len = build_ipv4_syn( + &mut buf, + guest_mac, + gateway_mac, + guest_ip, + Ipv4Address::new(192, 0, 2, 1), + ); + Ipv4Packet::new_unchecked(&mut buf[ETHERNET_HEADER_LEN..len]).set_more_frags(true); + + let result = consomme + .access(&mut client) + .send(&buf[..len], &ChecksumState::NONE); + assert!( + matches!(result, Err(DropReason::FragmentedPacket)), + "fragmented IPv4 traffic should be rejected, got {result:?}" + ); +} From dd349021e2a8d27f16c3ca930f454ce459b0306e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:42:50 -0700 Subject: [PATCH 039/304] openvmm_entry: accept `delay::` disk wrappers DiskCliKind has a DelayDiskWrapper variant, documented as `delay::`, and the VM builder already wraps such a disk in a DelayDiskHandle, but the disk argument parser never produced it. Parse the `delay:` prefix so that a disk given on the command line can be wrapped with a fixed per-I/O delay, for example to reproduce timing-sensitive storage behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_entry/src/cli_args.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index 545954d0655..ea694659f00 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -2246,6 +2246,13 @@ impl FromStr for DiskCliKind { Self::parse_autocache(arg, std::env::var("OPENVMM_AUTO_CACHE_PATH"))? } "prwrap" => DiskCliKind::PersistentReservationsWrapper(Box::new(arg.parse()?)), + "delay" => { + let (delay_ms, kind) = arg.split_once(':').context("expected delay_ms:kind")?; + DiskCliKind::DelayDiskWrapper { + delay_ms: delay_ms.parse().context("invalid disk delay")?, + disk: Box::new(kind.parse()?), + } + } "file" => { let FileOpts { path, From 67fda13c78effeb828d4b00a43a7499a22aef0d8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 22:48:50 -0700 Subject: [PATCH 040/304] openvmm_helpers: split the snapshot module without behavior changes The snapshot module keeps the manifest type, the manifest validation, and the functions that write and read a snapshot directory in one file. The publication and restore paths are about to grow substantially and independently, so give each its own module first. Move write_snapshot into snapshot::publish and read_snapshot into snapshot::restore, verbatim, and re-export both from snapshot so existing callers are unchanged. The manifest type, MANIFEST_VERSION, validate_manifest, and the tests stay in snapshot. No functional change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_helpers/src/snapshot.rs | 88 ++----------------- .../openvmm_helpers/src/snapshot/publish.rs | 73 +++++++++++++++ .../openvmm_helpers/src/snapshot/restore.rs | 23 +++++ 3 files changed, 102 insertions(+), 82 deletions(-) create mode 100644 openvmm/openvmm_helpers/src/snapshot/publish.rs create mode 100644 openvmm/openvmm_helpers/src/snapshot/restore.rs diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index cabd96f0fbe..2d7f51e764f 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -3,10 +3,14 @@ //! Snapshot manifest types and I/O functions for saving/restoring VM snapshots. -use anyhow::Context; use mesh::payload::Protobuf; use mesh::payload::Timestamp; -use std::path::Path; + +pub mod publish; +pub mod restore; + +pub use publish::write_snapshot; +pub use restore::read_snapshot; /// Current manifest format version. Bump when making incompatible changes. pub const MANIFEST_VERSION: u32 = 1; @@ -38,86 +42,6 @@ pub struct SnapshotManifest { pub architecture: String, } -/// Write a snapshot to the given directory. -/// -/// The directory is created if it does not exist. The snapshot consists of: -/// - `manifest.bin` — protobuf-encoded [`SnapshotManifest`] -/// - `state.bin` — raw device saved-state bytes -/// - `memory.bin` — hard link to the memory backing file -pub fn write_snapshot( - dir: &Path, - manifest: &SnapshotManifest, - saved_state_bytes: &[u8], - memory_file_path: &Path, -) -> anyhow::Result<()> { - fs_err::create_dir_all(dir)?; - - // Write manifest. - let manifest_bytes = mesh::payload::encode(manifest.clone()); - fs_err::write(dir.join("manifest.bin"), &manifest_bytes)?; - - // Write device state. - fs_err::write(dir.join("state.bin"), saved_state_bytes)?; - - // Handle memory.bin: hard-link from the backing file. - let memory_bin_path = dir.join("memory.bin"); - let canonical_source = fs_err::canonicalize(memory_file_path)?; - - // Check whether source and target are already the same file (e.g., - // the user pointed --memory-backing-file at /memory.bin directly). - let needs_link = if memory_bin_path.exists() { - let canonical_target = fs_err::canonicalize(&memory_bin_path)?; - if canonical_source == canonical_target { - false - } else { - // Different file at the target path — remove it so the hard - // link can be created. - fs_err::remove_file(&memory_bin_path)?; - true - } - } else { - true - }; - - if needs_link { - if let Err(err) = std::fs::hard_link(&canonical_source, &memory_bin_path) { - if err.kind() == std::io::ErrorKind::CrossesDevices { - anyhow::bail!( - "memory backing file ({}) must be on the same filesystem as the snapshot \ - directory ({}); consider placing the backing file inside the snapshot \ - directory", - memory_file_path.display(), - dir.display(), - ); - } - return Err(err).with_context(|| { - format!( - "failed to hard-link {} -> {}", - canonical_source.display(), - memory_bin_path.display() - ) - }); - } - } - - Ok(()) -} - -/// Read a snapshot from the given directory. -/// -/// Returns the decoded manifest and the raw saved-state bytes. -/// The caller is responsible for opening `memory.bin` separately. -pub fn read_snapshot(dir: &Path) -> anyhow::Result<(SnapshotManifest, Vec)> { - let manifest_bytes = - fs_err::read(dir.join("manifest.bin")).context("failed to read manifest.bin")?; - let manifest: SnapshotManifest = - mesh::payload::decode(&manifest_bytes).context("failed to decode snapshot manifest")?; - - let state_bytes = fs_err::read(dir.join("state.bin")).context("failed to read state.bin")?; - - Ok((manifest, state_bytes)) -} - /// Validate that a snapshot manifest is compatible with the running VM config. /// /// Checks version, architecture, memory size, VP count, and page size. diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs new file mode 100644 index 00000000000..693db4acdfa --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Writing VM snapshots to a directory. + +use super::SnapshotManifest; +use anyhow::Context; +use std::path::Path; + +/// Write a snapshot to the given directory. +/// +/// The directory is created if it does not exist. The snapshot consists of: +/// - `manifest.bin` — protobuf-encoded [`SnapshotManifest`] +/// - `state.bin` — raw device saved-state bytes +/// - `memory.bin` — hard link to the memory backing file +pub fn write_snapshot( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file_path: &Path, +) -> anyhow::Result<()> { + fs_err::create_dir_all(dir)?; + + // Write manifest. + let manifest_bytes = mesh::payload::encode(manifest.clone()); + fs_err::write(dir.join("manifest.bin"), &manifest_bytes)?; + + // Write device state. + fs_err::write(dir.join("state.bin"), saved_state_bytes)?; + + // Handle memory.bin: hard-link from the backing file. + let memory_bin_path = dir.join("memory.bin"); + let canonical_source = fs_err::canonicalize(memory_file_path)?; + + // Check whether source and target are already the same file (e.g., + // the user pointed --memory-backing-file at /memory.bin directly). + let needs_link = if memory_bin_path.exists() { + let canonical_target = fs_err::canonicalize(&memory_bin_path)?; + if canonical_source == canonical_target { + false + } else { + // Different file at the target path — remove it so the hard + // link can be created. + fs_err::remove_file(&memory_bin_path)?; + true + } + } else { + true + }; + + if needs_link { + if let Err(err) = std::fs::hard_link(&canonical_source, &memory_bin_path) { + if err.kind() == std::io::ErrorKind::CrossesDevices { + anyhow::bail!( + "memory backing file ({}) must be on the same filesystem as the snapshot \ + directory ({}); consider placing the backing file inside the snapshot \ + directory", + memory_file_path.display(), + dir.display(), + ); + } + return Err(err).with_context(|| { + format!( + "failed to hard-link {} -> {}", + canonical_source.display(), + memory_bin_path.display() + ) + }); + } + } + + Ok(()) +} diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs new file mode 100644 index 00000000000..cf99dd13f29 --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Reading VM snapshots from a directory. + +use super::SnapshotManifest; +use anyhow::Context; +use std::path::Path; + +/// Read a snapshot from the given directory. +/// +/// Returns the decoded manifest and the raw saved-state bytes. +/// The caller is responsible for opening `memory.bin` separately. +pub fn read_snapshot(dir: &Path) -> anyhow::Result<(SnapshotManifest, Vec)> { + let manifest_bytes = + fs_err::read(dir.join("manifest.bin")).context("failed to read manifest.bin")?; + let manifest: SnapshotManifest = + mesh::payload::decode(&manifest_bytes).context("failed to decode snapshot manifest")?; + + let state_bytes = fs_err::read(dir.join("state.bin")).context("failed to read state.bin")?; + + Ok((manifest, state_bytes)) +} From dea3b95d3dc9cec87c1525d89c108a451776c9d1 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 23:08:26 -0700 Subject: [PATCH 041/304] net_tap: move the transmit path into its own module Move the synchronous transmit loop of `TapQueue::tx_avail` and the trivial `tx_poll` into a new `tx` module as `TapQueue::transmit` and `TapQueue::poll_tx_done`, and make the `Queue` implementation delegate to them. The imports that only the transmit path used move with it. This is pure code motion. It prepares the transmit path for queue quiescing and egress-policy enforcement so that those changes do not have to be reviewed together with the move. Behavior is unchanged: packets are still written synchronously, packets that the TAP interface cannot accept are still dropped, and every packet is still completed synchronously. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/net/net_tap/src/lib.rs | 75 ++-------------------- vm/devices/net/net_tap/src/tx.rs | 100 ++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 71 deletions(-) create mode 100644 vm/devices/net/net_tap/src/tx.rs diff --git a/vm/devices/net/net_tap/src/lib.rs b/vm/devices/net/net_tap/src/lib.rs index c1b0da9b2da..f1b0832ce9d 100644 --- a/vm/devices/net/net_tap/src/lib.rs +++ b/vm/devices/net/net_tap/src/lib.rs @@ -8,6 +8,7 @@ pub mod resolver; pub mod tap; +mod tx; use async_trait::async_trait; use futures::io::AsyncRead; @@ -26,19 +27,14 @@ use net_backend::TxId; use net_backend::TxMetadata; use net_backend::TxOffloadSupport; use net_backend::TxSegment; -use net_backend::linearize; -use net_backend::next_packet; use pal_async::driver::Driver; use parking_lot::Mutex; use std::collections::VecDeque; -use std::io::ErrorKind; -use std::io::Write; use std::pin::Pin; use std::sync::Arc; use std::task::Context; use std::task::Poll; use zerocopy::FromBytes; -use zerocopy::IntoBytes; // TODO: These virtio net header types duplicate definitions in virtio_net. // Consider extracting a shared `virtio_net_header` crate if more consumers @@ -308,78 +304,15 @@ impl Queue for TapQueue { pool: &mut dyn BufferAccess, mut segments: &[TxSegment], ) -> anyhow::Result<(bool, usize)> { - let n = segments.len(); - // Synchronously send packets received from the guest to host's network. - if let Some(tap) = self.tap.as_mut() { - while !segments.is_empty() { - let (meta, _segs, _rest) = next_packet(segments); - let hdr = build_vnet_hdr(meta); - let hdr_bytes = hdr.as_bytes(); - let mut packet = linearize(pool, &mut segments)?; - - // Fix up the IPv4 header checksum when the frontend - // requested IPv4 header checksum offload. - // - // The virtio vnet header has no mechanism for IPv4 header - // checksum offload, so we compute it in software. This - // also covers NDIS/netvsp LSO packets, where the guest - // driver zeroes ip_check (NDIS convention); the kernel's - // TAP GSO engine requires a valid checksum to segment - // the packet correctly. - // Same NDIS/LSO convention for IPv6: the guest zeroes the IPv6 - // payload-length field on segmentation-offload frames. IPv6 has - // no header checksum (so the IPv4 fixup above never runs for it); - // fix the length here so the kernel TAP GSO engine can segment. - if meta.flags.offload_ip_header_checksum() && meta.flags.is_ipv4() { - fixup_ipv4_header_checksum(&mut packet, meta.l2_len as usize); - } - if meta.flags.offload_tcp_segmentation() && meta.flags.is_ipv6() { - fixup_ipv6_payload_length(&mut packet, meta.l2_len as usize); - } - - let bufs = [ - std::io::IoSlice::new(hdr_bytes), - std::io::IoSlice::new(&packet), - ]; - match tap.write_vectored(&bufs) { - Ok(bytes_written) => { - assert_eq!( - bytes_written, - hdr_bytes.len() + packet.len(), - "TAP should never partial write" - ); - } - Err(err) if err.kind() == ErrorKind::WouldBlock => { - // dropped packet: buffer is full - - // TODO: return partial transmit here. This relies on - // remembering this condition and polling for POLLOUT in - // poll_ready(). - } - Err(err) if err.raw_os_error() == Some(libc::EIO) => { - // dropped packet: interface is not up - } - Err(err) => { - tracing::warn!( - error = &err as &dyn std::error::Error, - "write to TAP interface failed" - ); - } - } - } - } - let completed_synchronously = true; - Ok((completed_synchronously, n)) + self.transmit(pool, &mut segments) } fn tx_poll( &mut self, _pool: &mut dyn BufferAccess, - _done: &mut [TxId], + done: &mut [TxId], ) -> Result { - // Packets are sent synchronously so there is no no need to check here if - // sending has been completed. - Ok(0) + self.poll_tx_done(done) } } diff --git a/vm/devices/net/net_tap/src/tx.rs b/vm/devices/net/net_tap/src/tx.rs new file mode 100644 index 00000000000..8154686534d --- /dev/null +++ b/vm/devices/net/net_tap/src/tx.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Transmit path of a TAP queue. +//! +//! `tx_avail` writes the guest packets to the TAP interface synchronously and +//! completes all of them, including the packets that the interface drops. + +use crate::TapQueue; +use crate::build_vnet_hdr; +use crate::fixup_ipv4_header_checksum; +use crate::fixup_ipv6_payload_length; +use net_backend::BufferAccess; +use net_backend::TxError; +use net_backend::TxId; +use net_backend::TxSegment; +use net_backend::linearize; +use net_backend::next_packet; +use std::io::ErrorKind; +use std::io::Write; +use zerocopy::IntoBytes; + +impl TapQueue { + /// Implements `tx_avail`: writes the packets of `segments`. + pub(crate) fn transmit( + &mut self, + pool: &mut dyn BufferAccess, + segments: &mut &[TxSegment], + ) -> anyhow::Result<(bool, usize)> { + let n = segments.len(); + // Synchronously send packets received from the guest to host's network. + if let Some(tap) = self.tap.as_mut() { + while !segments.is_empty() { + let (meta, _segs, _rest) = next_packet(segments); + let hdr = build_vnet_hdr(meta); + let hdr_bytes = hdr.as_bytes(); + let mut packet = linearize(pool, segments)?; + + // Fix up the IPv4 header checksum when the frontend + // requested IPv4 header checksum offload. + // + // The virtio vnet header has no mechanism for IPv4 header + // checksum offload, so we compute it in software. This + // also covers NDIS/netvsp LSO packets, where the guest + // driver zeroes ip_check (NDIS convention); the kernel's + // TAP GSO engine requires a valid checksum to segment + // the packet correctly. + // Same NDIS/LSO convention for IPv6: the guest zeroes the IPv6 + // payload-length field on segmentation-offload frames. IPv6 has + // no header checksum (so the IPv4 fixup above never runs for it); + // fix the length here so the kernel TAP GSO engine can segment. + if meta.flags.offload_ip_header_checksum() && meta.flags.is_ipv4() { + fixup_ipv4_header_checksum(&mut packet, meta.l2_len as usize); + } + if meta.flags.offload_tcp_segmentation() && meta.flags.is_ipv6() { + fixup_ipv6_payload_length(&mut packet, meta.l2_len as usize); + } + + let bufs = [ + std::io::IoSlice::new(hdr_bytes), + std::io::IoSlice::new(&packet), + ]; + match tap.write_vectored(&bufs) { + Ok(bytes_written) => { + assert_eq!( + bytes_written, + hdr_bytes.len() + packet.len(), + "TAP should never partial write" + ); + } + Err(err) if err.kind() == ErrorKind::WouldBlock => { + // dropped packet: buffer is full + + // TODO: return partial transmit here. This relies on + // remembering this condition and polling for POLLOUT in + // poll_ready(). + } + Err(err) if err.raw_os_error() == Some(libc::EIO) => { + // dropped packet: interface is not up + } + Err(err) => { + tracing::warn!( + error = &err as &dyn std::error::Error, + "write to TAP interface failed" + ); + } + } + } + } + let completed_synchronously = true; + Ok((completed_synchronously, n)) + } + + /// Implements `tx_poll`. + pub(crate) fn poll_tx_done(&mut self, _done: &mut [TxId]) -> Result { + // Packets are sent synchronously so there is no no need to check here if + // sending has been completed. + Ok(0) + } +} From 57a52c8fa93b60e056c7540961e8d2a7b9bbd757 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 23:14:35 -0700 Subject: [PATCH 042/304] net_consomme: move queue transmit processing into a module Move the loop that `ConsommeQueue::poll_ready` uses to assemble queued guest frames from guest memory, send them to the Consomme stack, account for dropped frames, and complete them into `ConsommeQueue::process_tx` in a new `egress` module. `poll_ready` now calls it before it processes control messages and polls the stack, as before. This is pure code motion that gives the transmit path its own module ahead of egress-policy enforcement and queue quiescing. Behavior and statistics accounting are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/net/net_consomme/src/egress.rs | 94 +++++++++++++++++++++++ vm/devices/net/net_consomme/src/lib.rs | 78 +------------------ 2 files changed, 96 insertions(+), 76 deletions(-) create mode 100644 vm/devices/net/net_consomme/src/egress.rs diff --git a/vm/devices/net/net_consomme/src/egress.rs b/vm/devices/net/net_consomme/src/egress.rs new file mode 100644 index 00000000000..67ae820ac0b --- /dev/null +++ b/vm/devices/net/net_consomme/src/egress.rs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Egress processing of the Consomme queue. +//! +//! The queue's transmit path assembles each guest frame from guest memory and +//! sends it to the Consomme stack. + +use crate::ConsommeQueue; +use consomme::ChecksumState; +use net_backend::BufferAccess; +use net_backend::TxSegmentType; + +impl ConsommeQueue { + /// Sends the queued guest frames to the Consomme stack and completes all of + /// them. + pub(crate) fn process_tx(&mut self, pool: &mut dyn BufferAccess) { + while let Some(head) = self.state.tx_avail.front() { + let TxSegmentType::Head(meta) = &head.ty else { + unreachable!() + }; + let tx_id = meta.id; + let checksum = ChecksumState { + ipv4: meta.flags.offload_ip_header_checksum(), + tcp: meta.flags.offload_tcp_checksum(), + udp: meta.flags.offload_udp_checksum(), + tso: meta + .flags + .offload_tcp_segmentation() + .then_some(meta.max_segment_size), + gso: meta + .flags + .offload_udp_segmentation() + .then_some(meta.max_segment_size), + }; + + // Reuse the scratch buffer to avoid per-packet heap allocation. + // TSO caps the assembled packet at 64 KiB; assert so a buggy + // upstream caller can't permanently inflate the scratch buffer + // (and thus the queue's steady-state memory) by feeding an + // oversized `meta.len`. + debug_assert!( + meta.len as usize <= 64 * 1024, + "tx packet len {} exceeds 64 KiB TSO bound", + meta.len + ); + let mut buf = std::mem::take(&mut self.state.tx_scratch); + buf.clear(); + buf.resize(meta.len as usize, 0); + let gm = pool.guest_memory(); + let mut offset = 0; + for segment in self.state.tx_avail.drain(..meta.segment_count as usize) { + let dest = &mut buf[offset..offset + segment.len as usize]; + if let Err(err) = gm.read_at(segment.gpa, dest) { + tracing::error!( + error = &err as &dyn std::error::Error, + "memory write failure" + ); + } + offset += segment.len as usize; + } + + if let Err(err) = self.with_consomme(pool, |c| c.send(&buf, &checksum)) { + tracing::debug!(error = &err as &dyn std::error::Error, "tx packet ignored"); + match err { + consomme::DropReason::SendBufferFull + | consomme::DropReason::DestinationNotAllowed + | consomme::DropReason::TcpConnectionLimit + | consomme::DropReason::UdpConnectionLimit + | consomme::DropReason::IcmpConnectionLimit => { + self.stats.tx_dropped.increment() + } + consomme::DropReason::UnsupportedEthertype(_) + | consomme::DropReason::UnsupportedIpProtocol(_) + | consomme::DropReason::UnsupportedIcmpv6(_) + | consomme::DropReason::UnsupportedDhcp(_) + | consomme::DropReason::UnsupportedArp + | consomme::DropReason::UnsupportedDhcpv6(_) + | consomme::DropReason::UnsupportedNdp(_) => self.stats.tx_unknown.increment(), + consomme::DropReason::Packet(_) + | consomme::DropReason::Ipv4Checksum + | consomme::DropReason::Io(_) + | consomme::DropReason::BadTcpState(_) + | consomme::DropReason::FragmentedPacket + | consomme::DropReason::IpLengthMismatch + | consomme::DropReason::MalformedPacket => self.stats.tx_errors.increment(), + } + } + self.state.tx_scratch = buf; + + self.state.tx_ready.push_back(tx_id); + } + } +} diff --git a/vm/devices/net/net_consomme/src/lib.rs b/vm/devices/net/net_consomme/src/lib.rs index d2a11d0e07e..93f1992706d 100644 --- a/vm/devices/net/net_consomme/src/lib.rs +++ b/vm/devices/net/net_consomme/src/lib.rs @@ -4,6 +4,7 @@ #![expect(missing_docs)] #![forbid(unsafe_code)] +mod egress; pub mod resolver; use anyhow::Context as _; @@ -31,7 +32,6 @@ use net_backend::TxError; use net_backend::TxId; use net_backend::TxOffloadSupport; use net_backend::TxSegment; -use net_backend::TxSegmentType; use net_backend_resources::consomme::ConsommeRequest; use net_backend_resources::consomme::HostPortConfig; use net_backend_resources::consomme::HostPortProtocol; @@ -681,81 +681,7 @@ fn process_message( impl net_backend::Queue for ConsommeQueue { fn poll_ready(&mut self, cx: &mut Context<'_>, pool: &mut dyn BufferAccess) -> Poll<()> { - while let Some(head) = self.state.tx_avail.front() { - let TxSegmentType::Head(meta) = &head.ty else { - unreachable!() - }; - let tx_id = meta.id; - let checksum = ChecksumState { - ipv4: meta.flags.offload_ip_header_checksum(), - tcp: meta.flags.offload_tcp_checksum(), - udp: meta.flags.offload_udp_checksum(), - tso: meta - .flags - .offload_tcp_segmentation() - .then_some(meta.max_segment_size), - gso: meta - .flags - .offload_udp_segmentation() - .then_some(meta.max_segment_size), - }; - - // Reuse the scratch buffer to avoid per-packet heap allocation. - // TSO caps the assembled packet at 64 KiB; assert so a buggy - // upstream caller can't permanently inflate the scratch buffer - // (and thus the queue's steady-state memory) by feeding an - // oversized `meta.len`. - debug_assert!( - meta.len as usize <= 64 * 1024, - "tx packet len {} exceeds 64 KiB TSO bound", - meta.len - ); - let mut buf = std::mem::take(&mut self.state.tx_scratch); - buf.clear(); - buf.resize(meta.len as usize, 0); - let gm = pool.guest_memory(); - let mut offset = 0; - for segment in self.state.tx_avail.drain(..meta.segment_count as usize) { - let dest = &mut buf[offset..offset + segment.len as usize]; - if let Err(err) = gm.read_at(segment.gpa, dest) { - tracing::error!( - error = &err as &dyn std::error::Error, - "memory write failure" - ); - } - offset += segment.len as usize; - } - - if let Err(err) = self.with_consomme(pool, |c| c.send(&buf, &checksum)) { - tracing::debug!(error = &err as &dyn std::error::Error, "tx packet ignored"); - match err { - consomme::DropReason::SendBufferFull - | consomme::DropReason::DestinationNotAllowed - | consomme::DropReason::TcpConnectionLimit - | consomme::DropReason::UdpConnectionLimit - | consomme::DropReason::IcmpConnectionLimit => { - self.stats.tx_dropped.increment() - } - consomme::DropReason::UnsupportedEthertype(_) - | consomme::DropReason::UnsupportedIpProtocol(_) - | consomme::DropReason::UnsupportedIcmpv6(_) - | consomme::DropReason::UnsupportedDhcp(_) - | consomme::DropReason::UnsupportedArp - | consomme::DropReason::UnsupportedDhcpv6(_) - | consomme::DropReason::UnsupportedNdp(_) => self.stats.tx_unknown.increment(), - consomme::DropReason::Packet(_) - | consomme::DropReason::Ipv4Checksum - | consomme::DropReason::Io(_) - | consomme::DropReason::BadTcpState(_) - | consomme::DropReason::FragmentedPacket - | consomme::DropReason::IpLengthMismatch - | consomme::DropReason::MalformedPacket => self.stats.tx_errors.increment(), - } - } - self.state.tx_scratch = buf; - - self.state.tx_ready.push_back(tx_id); - } + self.process_tx(pool); // TODO: handle messages asynchronously from any queue processing, since // there is no guarantee the queue will be processed at all (e.g., if From e63353b3d68a1612a7dc7d5b9e706d571df8de17 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 23:24:59 -0700 Subject: [PATCH 043/304] virtio_console: move the direct receive loop into its own module Move the receive half of the console worker's direct forwarding loop, which copies host input from the serial backend into guest receive buffers, out of `ConsoleWorker::run_loop` and into `direct::receive`. The worker still races it against the transmit half and returns to the disconnected state when either half reports a disconnect. This is pure code motion. It gives the direct forwarding mode its own module so that later changes to it, such as disconnect policies, input staging that survives worker restarts, and the control-session broker mode, do not have to be reviewed together with the move. Behavior is unchanged. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../virtio/virtio_console/src/direct.rs | 71 +++++++++++++++++++ vm/devices/virtio/virtio_console/src/lib.rs | 51 +------------ 2 files changed, 73 insertions(+), 49 deletions(-) create mode 100644 vm/devices/virtio/virtio_console/src/direct.rs diff --git a/vm/devices/virtio/virtio_console/src/direct.rs b/vm/devices/virtio/virtio_console/src/direct.rs new file mode 100644 index 00000000000..3d3a55b2832 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/direct.rs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The direct forwarding mode of the console worker. +//! +//! In direct mode the worker forwards data between the virtio queues and a +//! [`SerialIo`] backend. + +use crate::BUF_SIZE; +use crate::WorkerError; +use futures::AsyncRead; +use guestmem::GuestMemory; +use serial_core::SerialIo; +use std::future::poll_fn; +use std::pin::Pin; +use virtio::VirtioQueue; + +/// Receive half of the direct forwarding loop. +/// +/// Copies host input from the backend to guest receive buffers. Returns +/// `Ok(false)` when the backend disconnects. +pub(crate) async fn receive( + receiveq: &mut Option, + io: &parking_lot::Mutex<&mut Box>, + mem: &GuestMemory, +) -> Result { + let Some(receiveq) = receiveq.as_mut() else { + std::future::pending().await + }; + 'rx: loop { + let work = receiveq.peek().await.map_err(WorkerError::Virtio)?; + let writeable_len = work + .payload() + .iter() + .filter(|p| p.writeable) + .map(|p| p.length as usize) + .sum::(); + if writeable_len == 0 { + // Guest posted a zero-length buffer; complete it + // immediately without calling poll_read (which + // would return Ok(0) and look like a disconnect). + let work = work.consume(); + receiveq.complete(work, 0); + continue 'rx; + } + let n = BUF_SIZE.min(writeable_len); + let mut buf = [0u8; BUF_SIZE]; + match poll_fn(|cx| Pin::new(&mut **io.lock()).poll_read(cx, &mut buf[..n])).await { + Ok(0) => { + // Backend disconnected. + break 'rx Ok(false); + } + Ok(n) => { + let work = work.consume(); + if let Err(err) = work.write(mem, &buf[..n]) { + tracelimit::error_ratelimited!( + error = &err as &dyn std::error::Error, + "failed to write to guest receive buffer" + ); + receiveq.complete(work, 0); + } else { + receiveq.complete(work, n as u32); + } + } + Err(_) => { + // Disconnect on error, like other serial impls. + break 'rx Ok(false); + } + } + } +} diff --git a/vm/devices/virtio/virtio_console/src/lib.rs b/vm/devices/virtio/virtio_console/src/lib.rs index 908d83bd8f7..49245360a08 100644 --- a/vm/devices/virtio/virtio_console/src/lib.rs +++ b/vm/devices/virtio/virtio_console/src/lib.rs @@ -32,12 +32,12 @@ #![forbid(unsafe_code)] +mod direct; pub mod resolver; mod spec; #[cfg(test)] mod tests; -use futures::AsyncRead; use futures::AsyncWrite; use futures_concurrency::future::Race as _; use guestmem::GuestMemory; @@ -290,54 +290,7 @@ impl ConsoleWorker { | futures::future::Either::Right((result, _)) => result?, }; } else { - let rx = async { - let Some(receiveq) = receiveq.as_mut() else { - std::future::pending().await - }; - 'rx: loop { - let work = receiveq.peek().await.map_err(WorkerError::Virtio)?; - let writeable_len = work - .payload() - .iter() - .filter(|p| p.writeable) - .map(|p| p.length as usize) - .sum::(); - if writeable_len == 0 { - // Guest posted a zero-length buffer; complete it - // immediately without calling poll_read (which - // would return Ok(0) and look like a disconnect). - let work = work.consume(); - receiveq.complete(work, 0); - continue 'rx; - } - let n = BUF_SIZE.min(writeable_len); - let mut buf = [0u8; BUF_SIZE]; - match poll_fn(|cx| Pin::new(&mut **io.lock()).poll_read(cx, &mut buf[..n])) - .await - { - Ok(0) => { - // Backend disconnected. - break 'rx Ok(false); - } - Ok(n) => { - let work = work.consume(); - if let Err(err) = work.write(mem, &buf[..n]) { - tracelimit::error_ratelimited!( - error = &err as &dyn std::error::Error, - "failed to write to guest receive buffer" - ); - receiveq.complete(work, 0); - } else { - receiveq.complete(work, n as u32); - } - } - Err(_) => { - // Disconnect on error, like other serial impls. - break 'rx Ok(false); - } - } - } - }; + let rx = direct::receive(receiveq, &io, mem); let tx = async { let Some(transmitq) = transmitq.as_mut() else { std::future::pending().await From bd2158d7d65970d3a4d8095add31f7fa82ea2da3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 23:32:49 -0700 Subject: [PATCH 044/304] openvmm_entry: skip raw console mode when stdin is not a terminal The REPL's stdio thread switches the console to raw mode while it forwards console input, and panics if that fails. crossterm changes the mode of the process's terminal rather than of stdin itself. When another process runs OpenVMM with stdin redirected, for example from a pipe, this either fails because there is no terminal, which makes the REPL panic, or needlessly puts the terminal of the parent process into raw mode. Add a `headless` module to the REPL whose helpers enable raw mode only when stdin is a terminal and disable it only if they enabled it, so a redirected stdin is read as-is. Interactive runs behave as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_entry/src/repl.rs | 8 +++--- openvmm/openvmm_entry/src/repl/headless.rs | 29 ++++++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 openvmm/openvmm_entry/src/repl/headless.rs diff --git a/openvmm/openvmm_entry/src/repl.rs b/openvmm/openvmm_entry/src/repl.rs index 3e6aca91776..85e54482544 100644 --- a/openvmm/openvmm_entry/src/repl.rs +++ b/openvmm/openvmm_entry/src/repl.rs @@ -68,6 +68,8 @@ use tracing_helpers::AnyhowValueExt; use vm_resource::IntoResource; use vm_resource::Resource; +mod headless; + fn maybe_with_radix_u64(s: &str) -> Result { let (radix, prefix_len) = if s.starts_with("0x") || s.starts_with("0X") { (16, 2) @@ -526,7 +528,8 @@ pub(crate) async fn run_repl( let mut stdin = io::stdin(); loop { // Raw console text until Ctrl-Q. - crossterm::terminal::enable_raw_mode().expect("failed to enable raw console mode"); + let terminal = + headless::enable_raw_mode(&stdin).expect("failed to enable raw console mode"); if let Some(input) = console_in.as_mut() { let mut buf = [0; 32]; @@ -546,8 +549,7 @@ pub(crate) async fn run_repl( } } - crossterm::terminal::disable_raw_mode() - .expect("failed to disable raw console mode"); + headless::disable_raw_mode(terminal).expect("failed to disable raw console mode"); loop { let line = rl.readline("openvmm> "); diff --git a/openvmm/openvmm_entry/src/repl/headless.rs b/openvmm/openvmm_entry/src/repl/headless.rs new file mode 100644 index 00000000000..8d45e1ba982 --- /dev/null +++ b/openvmm/openvmm_entry/src/repl/headless.rs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Headless operation of the REPL, for runs driven by another process instead +//! of an interactive console. +//! +//! With a stdin that is not a terminal, such as a pipe, the stdio thread reads +//! it without switching the console to raw mode. + +use std::io; +use std::io::IsTerminal; + +/// Enables raw console mode for the console input of the stdio thread, unless +/// stdin is not a terminal. Returns whether raw mode was enabled. +pub(super) fn enable_raw_mode(stdin: &io::Stdin) -> io::Result { + let terminal = stdin.is_terminal(); + if terminal { + crossterm::terminal::enable_raw_mode()?; + } + Ok(terminal) +} + +/// Disables raw console mode if [`enable_raw_mode`] enabled it. +pub(super) fn disable_raw_mode(enabled: bool) -> io::Result<()> { + if enabled { + crossterm::terminal::disable_raw_mode()?; + } + Ok(()) +} From ac994e467cebce9c7f697c876abeb68d2cbc896e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 23:50:18 -0700 Subject: [PATCH 045/304] state_unit: make unit start fallible and roll back partial starts `StateUnit::start` could not fail, so a unit had no way to report that it could not start, and `StateUnits::start` always considered every unit running. Units that restore process-local state when they start need to report such a failure before their dependents start. Make `StateUnit::start` return `anyhow::Result<()>` and carry the result in `StateRequest::Start`. `StateUnits::start` still starts the units in dependency order, but it no longer starts a unit one of whose dependencies failed to start. If any unit fails, the units that did start are stopped again and `StateUnits::start` returns the failures. A unit whose start failed, or whose start request could not be delivered, is left in a terminal state, so that a later start fails instead of running a partially started VM. `start_stopped_units` propagates the error. Every existing `StateUnit` implementation starts infallibly and now returns `Ok(())`. The callers of `StateUnits::start` and `start_stopped_units` handle the error: - VMBus, PCIe, VF, and relayed vPCI hot-add return it, and the dynamic vPCI device builder also removes the units it added. - After a failed worker restart, a failure to start the VM again is reported to the requester. - PCI resource assignment returns it. - OpenHCL logs it and does not proceed with the start. - VM resume cannot report a failure to its callers yet, so it treats a unit that fails to start as an unrecoverable unit failure, like the other state changes do. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openhcl/underhill_core/src/dispatch/mod.rs | 11 +- .../underhill_core/src/vmbus_relay_unit.rs | 3 +- openvmm/openvmm_core/src/worker/dispatch.rs | 23 ++- vm/devices/pci/vpci_relay/src/lib.rs | 2 +- vmm_core/src/device_builder.rs | 6 +- vmm_core/src/input_distributor.rs | 3 +- vmm_core/src/partition_unit.rs | 3 +- vmm_core/src/vmbus_unit.rs | 9 +- vmm_core/src/vmtime_unit.rs | 3 +- vmm_core/state_unit/src/lib.rs | 45 +++--- vmm_core/state_unit/src/start.rs | 136 ++++++++++++++++++ vmm_core/state_unit/src/tests/start.rs | 111 ++++++++++++++ .../chipset/backing/arc_mutex/state_unit.rs | 3 +- .../vmotherboard/src/chipset/builder/mod.rs | 4 +- .../vmotherboard/src/chipset/line_sets.rs | 4 +- 15 files changed, 324 insertions(+), 42 deletions(-) create mode 100644 vmm_core/state_unit/src/start.rs create mode 100644 vmm_core/state_unit/src/tests/start.rs diff --git a/openhcl/underhill_core/src/dispatch/mod.rs b/openhcl/underhill_core/src/dispatch/mod.rs index 5196d2e4d3a..e144f66ced0 100644 --- a/openhcl/underhill_core/src/dispatch/mod.rs +++ b/openhcl/underhill_core/src/dispatch/mod.rs @@ -840,7 +840,14 @@ impl LoadedVm { } async fn start(&mut self, correlation_id: Option) { - self.state_units.start().await; + if let Err(error) = self.state_units.start().await { + tracing::error!( + CVM_ALLOWED, + error = error.as_ref() as &dyn std::error::Error, + "VM state units failed to start" + ); + return; + } // Log the boot/blackout time. let reference_time = ReferenceTime::new(self.partition.reference_time()); @@ -1067,7 +1074,7 @@ impl LoadedVm { ) .await?; - self.state_units.start_stopped_units().await; + self.state_units.start_stopped_units().await?; self.emuplat_servicing.netvsp_state.push(save_state); Ok(()) diff --git a/openhcl/underhill_core/src/vmbus_relay_unit.rs b/openhcl/underhill_core/src/vmbus_relay_unit.rs index bce72bc9b17..21a172e45d9 100644 --- a/openhcl/underhill_core/src/vmbus_relay_unit.rs +++ b/openhcl/underhill_core/src/vmbus_relay_unit.rs @@ -43,8 +43,9 @@ impl VmbusRelayHandle { struct VmbusRelayUnit(HostVmbusTransport); impl StateUnit for &'_ VmbusRelayUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start(); + Ok(()) } async fn stop(&mut self) { diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 59e799e7d7e..e6bb6f366f5 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -3683,7 +3683,12 @@ impl LoadedVm { if self.running { return false; } - self.state_units.start().await; + // Resume cannot report a failure to its callers, so treat a unit that + // fails to start like any other unrecoverable state unit failure. + self.state_units + .start() + .await + .expect("state units failed to start"); self.running = true; true } @@ -3724,7 +3729,10 @@ impl LoadedVm { let stop_guard = self.inner.partition_unit.temporarily_stop_vps().await; // Start state units so device config space is accessible. - self.state_units.start().await; + self.state_units + .start() + .await + .context("failed to start devices for PCI resource assignment")?; let result = ecam_config_access::assign_pci_resources_for_root_complexes( &self.inner.chipset, @@ -3810,7 +3818,12 @@ impl LoadedVm { } Err(err) => { if stopped { - self.state_units.start().await; + if let Err(start_error) = self.state_units.start().await { + rpc.complete(Err(RemoteError::new(start_error.context( + "worker restart failed and the VM could not resume", + )))); + continue; + } } rpc.complete(Err(RemoteError::new(err))); } @@ -3883,7 +3896,7 @@ impl LoadedVm { ) .await?; self.inner.vmbus_devices.push(device); - self.state_units.start_stopped_units().await; + self.state_units.start_stopped_units().await?; anyhow::Ok(()) }) .await @@ -4029,7 +4042,7 @@ impl LoadedVm { // MSI. The guest may begin probing config space // immediately after receiving the interrupt, so // the device must be ready first. - self.state_units.start_stopped_units().await; + self.state_units.start_stopped_units().await?; // Now attach the device and notify the guest. if let Err(e) = rc.lock().hotplug_add_device( diff --git a/vm/devices/pci/vpci_relay/src/lib.rs b/vm/devices/pci/vpci_relay/src/lib.rs index dbd2bba8784..3ab5413aab9 100644 --- a/vm/devices/pci/vpci_relay/src/lib.rs +++ b/vm/devices/pci/vpci_relay/src/lib.rs @@ -370,7 +370,7 @@ impl VpciRelay { ready_to_remove: false, }); - state_units.start_stopped_units().await; + state_units.start_stopped_units().await?; Ok(()) } diff --git a/vmm_core/src/device_builder.rs b/vmm_core/src/device_builder.rs index 089ac1444c9..440d98a9dc4 100644 --- a/vmm_core/src/device_builder.rs +++ b/vmm_core/src/device_builder.rs @@ -144,7 +144,11 @@ pub async fn build_dynamic_vpci_device( }; let pending_offer = pending_offer.context("missing deferred VPCI channel offer")?; - state_units.start_stopped_units().await; + if let Err(error) = state_units.start_stopped_units().await { + vpci_unit.remove().await; + pci_unit.remove().await; + return Err(error); + } if let Err(error) = pending_offer .offer_registered(&vpci_bus, driver_source, vmbus, state_units.is_running()) .await diff --git a/vmm_core/src/input_distributor.rs b/vmm_core/src/input_distributor.rs index 6aafd50d85d..7d6aef61e1c 100644 --- a/vmm_core/src/input_distributor.rs +++ b/vmm_core/src/input_distributor.rs @@ -195,8 +195,9 @@ struct Inner { } impl StateUnit for Inner { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.running = true; + Ok(()) } async fn stop(&mut self) { diff --git a/vmm_core/src/partition_unit.rs b/vmm_core/src/partition_unit.rs index 59e7e4d4814..dffee436f5d 100644 --- a/vmm_core/src/partition_unit.rs +++ b/vmm_core/src/partition_unit.rs @@ -582,9 +582,10 @@ impl Drop for StopGuard { } impl StateUnit for PartitionUnitRunner { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.unit_started = true; self.try_start(); + Ok(()) } async fn stop(&mut self) { diff --git a/vmm_core/src/vmbus_unit.rs b/vmm_core/src/vmbus_unit.rs index 5d6d9b0f398..76024683b0d 100644 --- a/vmm_core/src/vmbus_unit.rs +++ b/vmm_core/src/vmbus_unit.rs @@ -78,8 +78,9 @@ impl VmbusServerHandle { struct VmbusServerUnit(VmbusServer); impl StateUnit for &'_ VmbusServerUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start(); + Ok(()) } async fn stop(&mut self) { @@ -134,8 +135,9 @@ impl ChannelUnit { } impl StateUnit for &'_ ChannelUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start(); + Ok(()) } async fn stop(&mut self) { @@ -205,8 +207,9 @@ impl Inspect for SimpleChannelUnit { } impl StateUnit for &'_ SimpleChannelUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start(); + Ok(()) } async fn stop(&mut self) { diff --git a/vmm_core/src/vmtime_unit.rs b/vmm_core/src/vmtime_unit.rs index a53688cb527..9fa55a3c138 100644 --- a/vmm_core/src/vmtime_unit.rs +++ b/vmm_core/src/vmtime_unit.rs @@ -17,8 +17,9 @@ use vmcore::vmtime::VmTimeKeeper; struct KeeperUnit<'a>(#[inspect(mut)] &'a mut VmTimeKeeper); impl StateUnit for KeeperUnit<'_> { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.0.start().await; + Ok(()) } async fn stop(&mut self) { diff --git a/vmm_core/state_unit/src/lib.rs b/vmm_core/state_unit/src/lib.rs index 1cfd09a304e..fafb489ed41 100644 --- a/vmm_core/state_unit/src/lib.rs +++ b/vmm_core/state_unit/src/lib.rs @@ -30,6 +30,8 @@ #![forbid(unsafe_code)] +mod start; + use futures::FutureExt; use futures::StreamExt; use futures::future::join_all; @@ -69,7 +71,7 @@ use vmcore::save_restore::SavedStateBlob; #[derive(Debug, MeshPayload)] pub enum StateRequest { /// Start asynchronous operations. - Start(Rpc<(), ()>), + Start(FailableRpc<(), ()>), /// Stop asynchronous operations. Stop(Rpc<(), ()>), @@ -95,7 +97,7 @@ pub enum StateRequest { #[expect(async_fn_in_trait)] // Don't need Send bounds pub trait StateUnit: InspectMut { /// Start asynchronous processing. - async fn start(&mut self); + async fn start(&mut self) -> anyhow::Result<()>; /// Stop asynchronous processing. async fn stop(&mut self); @@ -189,7 +191,7 @@ impl StateRequest { /// Runs this state request against `unit`. pub async fn apply(self, unit: &mut impl StateUnit) { match self { - StateRequest::Start(rpc) => rpc.handle(async |()| unit.start().await).await, + StateRequest::Start(rpc) => rpc.handle_failable(async |()| unit.start().await).await, StateRequest::Stop(rpc) => rpc.handle(async |()| unit.stop().await).await, StateRequest::Reset(rpc) => rpc.handle_failable(async |()| unit.reset().await).await, StateRequest::Save(rpc) => rpc.handle_failable(async |()| unit.save().await).await, @@ -218,6 +220,7 @@ enum State { Resetting, Saving, Restoring, + StartUncertain, } #[derive(Debug)] @@ -462,26 +465,16 @@ impl StateUnits { /// via [`StateUnits::add`] while the VM was running. /// /// Does nothing if all units are stopped, via [`StateUnits::stop`]. - pub async fn start_stopped_units(&mut self) { + pub async fn start_stopped_units(&mut self) -> anyhow::Result<()> { if self.is_running() { - self.start().await; + self.start().await?; } + Ok(()) } /// Starts all the state units. - pub async fn start(&mut self) { - self.run_op( - "start", - None, - State::Stopped, - State::Starting, - State::Running, - StateRequest::Start, - |_, _| Some(()), - |unit| &unit.dependencies, - ) - .await; - self.running = true; + pub async fn start(&mut self) -> anyhow::Result<()> { + self.start_with_rollback().await } /// Stops all the state units. @@ -1007,6 +1000,8 @@ impl Ready { #[cfg(test)] mod tests { + mod start; + use super::StateUnit; use super::StateUnits; use crate::run_unit; @@ -1037,7 +1032,9 @@ mod tests { struct SavedState(bool); impl StateUnit for TestUnit { - async fn start(&mut self) {} + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } async fn stop(&mut self) {} @@ -1079,7 +1076,9 @@ mod tests { } impl StateUnit for TestUnitSetDep { - async fn start(&mut self) {} + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } async fn stop(&mut self) {} @@ -1131,13 +1130,13 @@ mod tests { .add("b") .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)) .unwrap(); - units.start().await; + units.start().await.unwrap(); let _c = units .add("c") .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)); units.stop().await; - units.start().await; + units.start().await.unwrap(); units.stop().await; @@ -1184,7 +1183,7 @@ mod tests { ) }) .unwrap(); - units.start().await; + units.start().await.unwrap(); units.stop().await; let state = units.save().await.unwrap(); diff --git a/vmm_core/state_unit/src/start.rs b/vmm_core/state_unit/src/start.rs new file mode 100644 index 00000000000..d6667037da7 --- /dev/null +++ b/vmm_core/state_unit/src/start.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Fallible start with rollback. If a unit fails to start, its dependents do +//! not start, and the units that did start are stopped again. + +use super::State; +use super::StateRequest; +use super::StateUnits; +use super::state_change; +use futures::future::join_all; +use std::time::Instant; + +impl StateUnits { + /// Starts all stopped units in dependency order. + /// + /// A unit does not start if one of its dependencies fails to start. On any + /// failure, the units that did start are stopped again, and a unit whose + /// start failed or is uncertain is left in a terminal state that fails + /// later starts. + pub(super) async fn start_with_rollback(&mut self) -> anyhow::Result<()> { + enum StartResult { + Started, + DependencyFailed, + Failed(anyhow::Error), + Uncertain(anyhow::Error), + } + + let was_running = self.running; + let mut operations = Vec::new(); + let ready_set; + { + let mut inner = self.inner.lock(); + if let Some(unit) = inner + .units + .values() + .find(|unit| !matches!(unit.state, State::Stopped | State::Running)) + { + anyhow::bail!( + "state unit '{}' cannot be started from terminal state {:?}", + unit.name, + unit.state + ); + } + ready_set = inner.ready_set(None); + for (&id, unit) in &mut inner.units { + if unit.state == State::Running { + ready_set.done(id, true); + continue; + } + assert_eq!( + unit.state, + State::Stopped, + "unit {} is not stopped before start", + unit.name, + ); + let name = unit.name.clone(); + let dependencies = unit.dependencies.clone(); + let ready_set = ready_set.clone(); + let start = state_change(name.clone(), unit, StateRequest::Start, Some(())); + operations.push(async move { + if !ready_set.wait("start", id, &dependencies).await { + ready_set.done(id, false); + return (name, id, StartResult::DependencyFailed); + } + + let result = match start.await { + Ok(Some(Ok(()))) => StartResult::Started, + Ok(Some(Err(error))) => StartResult::Failed(error.into()), + Ok(None) => { + StartResult::Uncertain(anyhow::anyhow!("start request was not sent")) + } + Err(error) => StartResult::Uncertain(error.into()), + }; + ready_set.done(id, matches!(result, StartResult::Started)); + (name, id, result) + }); + unit.state = State::Starting; + } + } + + let start = Instant::now(); + let results = join_all(operations).await; + tracing::info!(duration = ?Instant::now() - start, "state-unit start complete"); + + let mut failures = Vec::new(); + let mut started_ids = Vec::new(); + { + let mut inner = self.inner.lock(); + for (name, id, result) in results { + let Some(unit) = inner.units.get_mut(&id) else { + continue; + }; + match result { + StartResult::Started => { + unit.state = State::Running; + started_ids.push(id); + } + StartResult::DependencyFailed => { + unit.state = State::Stopped; + failures.push(format!("{name}: a dependency did not start")); + } + StartResult::Failed(error) => { + unit.state = State::StartUncertain; + failures.push(format!("{name}: {error:#}")); + } + StartResult::Uncertain(error) => { + unit.state = State::StartUncertain; + failures.push(format!("{name}: {error:#}")); + } + } + } + } + + if failures.is_empty() { + self.running = true; + Ok(()) + } else { + if !started_ids.is_empty() { + self.run_op( + "failed_start_rollback", + Some(&started_ids), + State::Running, + State::Stopping, + State::Stopped, + StateRequest::Stop, + |_, _| Some(()), + |unit| &unit.dependents, + ) + .await; + } + self.running = was_running; + anyhow::bail!("state units could not start: {}", failures.join("; ")) + } + } +} diff --git a/vmm_core/state_unit/src/tests/start.rs b/vmm_core/state_unit/src/tests/start.rs new file mode 100644 index 00000000000..f08063f4cae --- /dev/null +++ b/vmm_core/state_unit/src/tests/start.rs @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for fallible start. + +use crate::StateUnit; +use crate::StateUnits; +use crate::run_unit; +use inspect::InspectMut; +use pal_async::DefaultDriver; +use pal_async::async_test; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use test_with_tracing::test; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +struct StartResultUnit { + started: Arc, + stopped: Arc, + fail: bool, +} + +impl StateUnit for StartResultUnit { + async fn start(&mut self) -> anyhow::Result<()> { + anyhow::ensure!(!self.fail, "intentional start failure"); + self.started.store(true, Ordering::Relaxed); + Ok(()) + } + + async fn stop(&mut self) { + self.stopped.store(true, Ordering::Relaxed); + } + + async fn reset(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(None) + } + + async fn restore(&mut self, _state: SavedStateBlob) -> Result<(), RestoreError> { + Err(RestoreError::SavedStateNotSupported) + } +} + +impl InspectMut for StartResultUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } +} + +#[async_test] +async fn test_start_failure_blocks_dependent(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let independent_started = Arc::new(AtomicBool::new(false)); + let independent_stopped = Arc::new(AtomicBool::new(false)); + let _independent = units + .add("independent") + .spawn(&driver, |recv| { + run_unit( + StartResultUnit { + started: independent_started.clone(), + stopped: independent_stopped.clone(), + fail: false, + }, + recv, + ) + }) + .unwrap(); + let failed = units + .add("failed") + .spawn(&driver, |recv| { + run_unit( + StartResultUnit { + started: Arc::new(AtomicBool::new(false)), + stopped: Arc::new(AtomicBool::new(false)), + fail: true, + }, + recv, + ) + }) + .unwrap(); + let dependent_started = Arc::new(AtomicBool::new(false)); + let _dependent = units + .add("dependent") + .depends_on(failed.handle()) + .spawn(&driver, |recv| { + run_unit( + StartResultUnit { + started: dependent_started.clone(), + stopped: Arc::new(AtomicBool::new(false)), + fail: false, + }, + recv, + ) + }) + .unwrap(); + + let error = units.start().await.unwrap_err(); + assert!(error.to_string().contains("intentional start failure")); + assert!(independent_started.load(Ordering::Relaxed)); + assert!(independent_stopped.load(Ordering::Relaxed)); + assert!(!dependent_started.load(Ordering::Relaxed)); + assert!(!units.is_running()); + let retry_error = units.start().await.unwrap_err(); + assert!(retry_error.to_string().contains("terminal state")); +} diff --git a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs index 5b0ebb0772c..438140f73d6 100644 --- a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs +++ b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs @@ -139,13 +139,14 @@ impl ArcMutexChipsetDeviceUnit { } impl StateUnit for ArcMutexChipsetDeviceUnit { - async fn start(&mut self) { + async fn start(&mut self) -> anyhow::Result<()> { self.running = true; // Poll the device at least once. let mut device = self.device.lock(); device.start(); device.poll_device(&mut Context::from_waker(&waker_ref(&self.poll_event))); + Ok(()) } async fn stop(&mut self) { diff --git a/vmm_core/vmotherboard/src/chipset/builder/mod.rs b/vmm_core/vmotherboard/src/chipset/builder/mod.rs index 788e80f98b1..a8e47c0fae0 100644 --- a/vmm_core/vmotherboard/src/chipset/builder/mod.rs +++ b/vmm_core/vmotherboard/src/chipset/builder/mod.rs @@ -379,7 +379,9 @@ mod chipset_unit { pub struct ChipsetUnit<'a>(pub &'a Chipset); impl StateUnit for ChipsetUnit<'_> { - async fn start(&mut self) {} + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } async fn stop(&mut self) {} diff --git a/vmm_core/vmotherboard/src/chipset/line_sets.rs b/vmm_core/vmotherboard/src/chipset/line_sets.rs index dd6d73f7d7b..9d8517a8176 100644 --- a/vmm_core/vmotherboard/src/chipset/line_sets.rs +++ b/vmm_core/vmotherboard/src/chipset/line_sets.rs @@ -60,7 +60,9 @@ impl LineSets { struct LineSetUnit<'a>(&'a LineSet); impl StateUnit for LineSetUnit<'_> { - async fn start(&mut self) {} + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } async fn stop(&mut self) {} From 16529618a1e2cfac4226808fa66d70c44e7e1f95 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 24 Sep 2026 23:56:20 -0700 Subject: [PATCH 046/304] vmcore: add fallible device start through chipset device workers A chipset device learns that it should start only through a notification, so a device whose start can fail, such as a device that runs in a chipset device worker and restores state there, cannot report that failure to its state unit before the dependent units start. Add `ChangeDeviceState::start_fallible`, which starts a device and waits until the start has completed or failed. Its default calls `start` and succeeds, so existing devices keep their behavior. The state unit of a chipset device now starts the device through it, and fails with its error, before it polls the device for the first time. The start request of the chipset device worker protocol now carries a result. The worker's device proxy overrides `start_fallible` to wait for it, while `start` still sends the request without waiting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + vm/chipset_device_resources/Cargo.toml | 1 + vm/chipset_device_resources/src/lib.rs | 10 +++++++++- vm/vmcore/src/device_state.rs | 10 ++++++++++ .../src/chipset/backing/arc_mutex/state_unit.rs | 10 +++++----- workers/chipset_device_worker/src/protocol.rs | 2 +- workers/chipset_device_worker/src/proxy.rs | 10 +++++++++- workers/chipset_device_worker/src/worker.rs | 5 ++++- 8 files changed, 40 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3cd44bd5f41..27c9ab43b50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -645,6 +645,7 @@ dependencies = [ name = "chipset_device_resources" version = "0.0.0" dependencies = [ + "anyhow", "async-trait", "chipset_device", "guestmem", diff --git a/vm/chipset_device_resources/Cargo.toml b/vm/chipset_device_resources/Cargo.toml index 4421fb5cc72..f8b19413036 100644 --- a/vm/chipset_device_resources/Cargo.toml +++ b/vm/chipset_device_resources/Cargo.toml @@ -16,6 +16,7 @@ vm_resource.workspace = true inspect.workspace = true async-trait.workspace = true +anyhow.workspace = true [lints] workspace = true diff --git a/vm/chipset_device_resources/src/lib.rs b/vm/chipset_device_resources/src/lib.rs index f8ba3fa07a0..75501af146e 100644 --- a/vm/chipset_device_resources/src/lib.rs +++ b/vm/chipset_device_resources/src/lib.rs @@ -92,6 +92,7 @@ pub trait ConfigureChipsetDevice: Send { #[async_trait] trait DynChipsetDevice: ChipsetDevice + ProtobufSaveRestore + InspectMut { fn start(&mut self); + async fn start_fallible(&mut self) -> anyhow::Result<()>; async fn stop(&mut self); async fn reset(&mut self); } @@ -101,7 +102,10 @@ impl Dy for T { fn start(&mut self) { - self.start() + ChangeDeviceState::start(self) + } + async fn start_fallible(&mut self) -> anyhow::Result<()> { + ChangeDeviceState::start_fallible(self).await } async fn stop(&mut self) { self.stop().await @@ -132,6 +136,10 @@ impl ChangeDeviceState for ErasedChipsetDevice { self.0.start() } + async fn start_fallible(&mut self) -> anyhow::Result<()> { + self.0.start_fallible().await + } + async fn stop(&mut self) { self.0.stop().await } diff --git a/vm/vmcore/src/device_state.rs b/vm/vmcore/src/device_state.rs index bffa5515366..15d949f481c 100644 --- a/vm/vmcore/src/device_state.rs +++ b/vm/vmcore/src/device_state.rs @@ -27,6 +27,16 @@ pub trait ChangeDeviceState { // to finish, which may require a bunch of other changes. fn start(&mut self); + /// Starts a device and waits until startup has either completed or failed. + /// + /// The default preserves the notification-only behavior of [`Self::start`]. + /// Devices that restore process-local workers should override this so a + /// restore failure is reported before dependent units start. + fn start_fallible(&mut self) -> impl Send + Future> { + self.start(); + async { Ok(()) } + } + /// Stops a device's asynchronous work. /// /// After this returns, the device must not process any additional work. It diff --git a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs index 438140f73d6..a61b60885cd 100644 --- a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs +++ b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs @@ -35,7 +35,7 @@ impl InspectMut for ArcMutexChipsetDeviceUnit { /// Object-safe trait for the subset of [`VmmChipsetDevice`] that we use here. #[async_trait] trait DynDevice: InspectMut + Send { - fn start(&mut self); + async fn start(&mut self) -> anyhow::Result<()>; async fn stop(&mut self); async fn reset(&mut self); fn poll_device(&mut self, cx: &mut Context<'_>); @@ -45,8 +45,8 @@ trait DynDevice: InspectMut + Send { #[async_trait] impl DynDevice for T { - fn start(&mut self) { - self.start() + async fn start(&mut self) -> anyhow::Result<()> { + self.start_fallible().await } async fn stop(&mut self) { @@ -140,11 +140,11 @@ impl ArcMutexChipsetDeviceUnit { impl StateUnit for ArcMutexChipsetDeviceUnit { async fn start(&mut self) -> anyhow::Result<()> { + self.device.clone().close().start().await?; self.running = true; - // Poll the device at least once. + // Poll the device at least once after startup completes. let mut device = self.device.lock(); - device.start(); device.poll_device(&mut Context::from_waker(&waker_ref(&self.poll_event))); Ok(()) } diff --git a/workers/chipset_device_worker/src/protocol.rs b/workers/chipset_device_worker/src/protocol.rs index 1039257b60a..1e200c0c823 100644 --- a/workers/chipset_device_worker/src/protocol.rs +++ b/workers/chipset_device_worker/src/protocol.rs @@ -60,7 +60,7 @@ pub(crate) enum DeviceRequest { /// Perform a PCI config space write. PciConfigWrite(WriteRequest, PciConfigByteEnable), /// Start the device - Start, + Start(mesh::rpc::FailableRpc<(), ()>), /// Stop the device Stop(Rpc<(), ()>), /// Reset the device diff --git a/workers/chipset_device_worker/src/proxy.rs b/workers/chipset_device_worker/src/proxy.rs index fbec1f63ce0..69b669ca6a6 100644 --- a/workers/chipset_device_worker/src/proxy.rs +++ b/workers/chipset_device_worker/src/proxy.rs @@ -267,7 +267,15 @@ impl PollDevice for ChipsetDeviceProxy { impl ChangeDeviceState for ChipsetDeviceProxy { fn start(&mut self) { - self.req_send.send(DeviceRequest::Start) + self.req_send + .send(DeviceRequest::Start(mesh::rpc::Rpc::detached(()))) + } + + async fn start_fallible(&mut self) -> anyhow::Result<()> { + self.req_send + .call_failable(DeviceRequest::Start, ()) + .await?; + Ok(()) } async fn stop(&mut self) { diff --git a/workers/chipset_device_worker/src/worker.rs b/workers/chipset_device_worker/src/worker.rs index 49d445bffae..4799aa03c24 100644 --- a/workers/chipset_device_worker/src/worker.rs +++ b/workers/chipset_device_worker/src/worker.rs @@ -252,7 +252,10 @@ impl Worker for RemoteChipsetDeviceWorker { } }, WorkerEvent::DeviceRequest(req) => match req { - DeviceRequest::Start => self.device.start(), + DeviceRequest::Start(rpc) => { + rpc.handle_failable(async |()| self.device.start_fallible().await) + .await + } DeviceRequest::Stop(rpc) => { rpc.handle(async |()| self.device.stop().await).await } From d5f89e064abf56e5e079f52e92b2f1496cc787c3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:01:32 -0700 Subject: [PATCH 047/304] openvmm: make VM resume fallible Resuming a VM starts its state units, which can now fail to start, but `VmRpc::Resume` could only report whether the VM was resumed, so a start failure could not reach the requester. Make `VmRpc::Resume` a failable RPC, and make `LoadedVm::resume` return the error of starting the state units. The worker propagates it when it resumes the VM after a restart, a pulse save/restore, a reset, or a state dump. The REPL logs a failed resume, the ttrpc `ResumeVm` handler and Petri return the error, and launching a VM that is not paused fails if it does not resume. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/worker/dispatch.rs | 20 +++++++++---------- .../openvmm_core/src/worker/dispatch/dump.rs | 4 +++- openvmm/openvmm_defs/src/rpc.rs | 2 +- openvmm/openvmm_entry/src/lib.rs | 5 ++++- openvmm/openvmm_entry/src/repl.rs | 7 +++++-- openvmm/openvmm_entry/src/ttrpc/mod.rs | 2 +- petri/src/worker.rs | 4 ++-- 7 files changed, 26 insertions(+), 18 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index e6bb6f366f5..4d5497faa42 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -400,7 +400,7 @@ impl Worker for VmWorker { LOADED_VM.store(&vm); if running { - vm.resume().await; + vm.resume().await?; } Ok(Self { vm, @@ -3679,18 +3679,16 @@ impl LoadedVmInner { } impl LoadedVm { - async fn resume(&mut self) -> bool { + async fn resume(&mut self) -> anyhow::Result { if self.running { - return false; + return Ok(false); } - // Resume cannot report a failure to its callers, so treat a unit that - // fails to start like any other unrecoverable state unit failure. self.state_units .start() .await - .expect("state units failed to start"); + .context("VM state units failed to start")?; self.running = true; - true + Ok(true) } async fn pause(&mut self) -> bool { @@ -3848,7 +3846,9 @@ impl LoadedVm { rpc.handle(async |()| self.inner.partition_unit.clear_halt().await) .await } - VmRpc::Resume(rpc) => rpc.handle(async |()| self.resume().await).await, + VmRpc::Resume(rpc) => { + rpc.handle_failable(async |()| self.resume().await).await + } VmRpc::Pause(rpc) => rpc.handle(async |()| self.pause().await).await, VmRpc::Save(rpc) => { rpc.handle_failable(async |()| self.save().await.map(ProtobufMessage::new)) @@ -3925,7 +3925,7 @@ impl LoadedVm { self.save_reset_restore().await?; if paused { - self.resume().await; + self.resume().await?; } Ok(()) }) @@ -4405,7 +4405,7 @@ impl LoadedVm { } if resume { - self.resume().await; + self.resume().await?; } Ok(()) } diff --git a/openvmm/openvmm_core/src/worker/dispatch/dump.rs b/openvmm/openvmm_core/src/worker/dispatch/dump.rs index ec47b71126d..a0108c686da 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/dump.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/dump.rs @@ -19,7 +19,9 @@ impl LoadedVm { let was_running = self.pause().await; let result = self.dump_state_inner(file).await; if was_running { - self.resume().await; + self.resume() + .await + .context("failed to resume VM after dump")?; } result } diff --git a/openvmm/openvmm_defs/src/rpc.rs b/openvmm/openvmm_defs/src/rpc.rs index 58af188f5ef..e6d8e702320 100644 --- a/openvmm/openvmm_defs/src/rpc.rs +++ b/openvmm/openvmm_defs/src/rpc.rs @@ -20,7 +20,7 @@ use vm_resource::kind::VmbusDeviceHandleKind; #[derive(MeshPayload)] pub enum VmRpc { Save(FailableRpc<(), ProtobufMessage>), - Resume(Rpc<(), bool>), + Resume(FailableRpc<(), bool>), Pause(Rpc<(), bool>), ClearHalt(Rpc<(), bool>), Reset(FailableRpc<(), ()>), diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 40d178dd792..b5adc095ca6 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2965,7 +2965,10 @@ async fn run_control_inner( } if !opt.paused { - vm_rpc.call(VmRpc::Resume, ()).await?; + anyhow::ensure!( + vm_rpc.call_failable(VmRpc::Resume, ()).await?, + "VM failed to start; inspect the worker log for the device startup error" + ); } let paravisor_diag = Arc::new(diag_client::DiagClient::from_dialer( diff --git a/openvmm/openvmm_entry/src/repl.rs b/openvmm/openvmm_entry/src/repl.rs index 85e54482544..6624c53ed6f 100644 --- a/openvmm/openvmm_entry/src/repl.rs +++ b/openvmm/openvmm_entry/src/repl.rs @@ -606,7 +606,7 @@ pub(crate) async fn run_repl( enum StateChange { Pause(bool), - Resume(bool), + Resume(Result), Reset(Result<(), RemoteError>), PulseSaveRestore(Result<(), PulseSaveRestoreError>), ServiceVtl2(anyhow::Result), @@ -708,13 +708,16 @@ pub(crate) async fn run_repl( tracing::warn!("already paused"); } } - StateChange::Resume(success) => { + StateChange::Resume(Ok(success)) => { if success { tracing::info!("resumed complete"); } else { tracing::warn!("already running"); } } + StateChange::Resume(Err(err)) => { + tracing::error!(error = &err as &dyn std::error::Error, "resume failed") + } StateChange::Reset(r) => match r { Ok(()) => tracing::info!("reset complete"), Err(err) => tracing::error!( diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index ba14b0397f6..ea332286029 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1434,7 +1434,7 @@ impl VmService { async fn resume_vm(&mut self) -> anyhow::Result<()> { let vm = self.vm.clone().context("VM not created yet")?; vm.worker_rpc - .call(VmRpc::Resume, ()) + .call_failable(VmRpc::Resume, ()) .await .map(drop) .context("resume failed")?; diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 5071503305d..5b5ca3440d6 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -52,8 +52,8 @@ impl Worker { self.rpc.call(VmRpc::Pause, ()).await } - pub(crate) async fn resume(&self) -> Result { - self.rpc.call(VmRpc::Resume, ()).await + pub(crate) async fn resume(&self) -> anyhow::Result { + Ok(self.rpc.call_failable(VmRpc::Resume, ()).await?) } pub(crate) async fn save(&self) -> anyhow::Result { From 7d578ecb7296d9ca895371af678b81d9f3ea9c12 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:07:51 -0700 Subject: [PATCH 048/304] state_unit: record the exact unit inventory in saved state A VM's saved state contains only the state units that have state to save. Restoring it fails for a saved unit that the destination VM lacks, but it cannot detect a destination that has additional units: those just keep their initial state. Add `StateUnits::inventory`, which returns the names of all registered units in registration order, and `StateUnits::validate_inventory`, which requires a saved inventory to match it exactly. Move the VM worker's saved-state envelope from `openvmm_core` to `openvmm_defs`, keeping its protobuf name, and record the inventory in a new field. Restore validates the inventory when the saved state has one; saved state from before this change has none and restores as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 2 ++ openvmm/openvmm_core/src/worker/dispatch.rs | 16 ++++----- openvmm/openvmm_defs/Cargo.toml | 2 ++ openvmm/openvmm_defs/src/worker.rs | 14 ++++++++ vmm_core/state_unit/src/inventory.rs | 29 +++++++++++++++ vmm_core/state_unit/src/lib.rs | 2 ++ vmm_core/state_unit/src/tests/inventory.rs | 40 +++++++++++++++++++++ 7 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 vmm_core/state_unit/src/inventory.rs create mode 100644 vmm_core/state_unit/src/tests/inventory.rs diff --git a/Cargo.lock b/Cargo.lock index 27c9ab43b50..b7195f01eef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5968,6 +5968,7 @@ dependencies = [ "net_backend_resources", "openvmm_pcat_locator", "smbios_defs", + "state_unit", "thiserror 2.0.16", "tpm_resources", "unix_socket", @@ -5975,6 +5976,7 @@ dependencies = [ "virt_whp", "vm_resource", "vmbus_proxy", + "vmcore", "vmgs_resources", "vmm_core_defs", "vmotherboard", diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 4d5497faa42..d0a06c283dd 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -56,7 +56,6 @@ use membacking::SharedMemoryBacking; use memory_range::MemoryRange; use mesh::MeshPayload; use mesh::error::RemoteError; -use mesh::payload::Protobuf; use mesh::payload::message::ProtobufMessage; use mesh_worker::Worker; use mesh_worker::WorkerId; @@ -85,6 +84,7 @@ use openvmm_defs::config::X2ApicConfig; use openvmm_defs::config::X86TopologyConfig; use openvmm_defs::rpc::PulseSaveRestoreError; use openvmm_defs::rpc::VmRpc; +use openvmm_defs::worker::SavedState; use openvmm_defs::worker::VM_WORKER; use openvmm_defs::worker::VmWorkerParameters; use openvmm_pcat_locator::RomFileLocation; @@ -100,7 +100,6 @@ use pcie::switch::GenericPcieSwitch; use scsi_core::ResolveScsiDeviceHandleParams; use scsidisk::atapi_scsi::AtapiScsiDisk; use serial_16550_resources::ComPort; -use state_unit::SavedStateUnit; use state_unit::SpawnedUnit; use state_unit::StateUnits; use std::fs::File; @@ -139,7 +138,6 @@ use vmbus_channel::channel::VmbusDevice; use vmbus_server::HvsockRelayChannel; use vmbus_server::VmbusServer; use vmbus_server::hvsock::HvsockRelay; -use vmcore::save_restore::SavedStateRoot; use vmcore::vm_task::VmTaskDriverSource; use vmcore::vm_task::thread::ThreadDriverBackend; use vmcore::vmtime::VmTime; @@ -286,13 +284,6 @@ pub struct Manifest { rtc_delta_milliseconds: i64, } -#[derive(Protobuf, SavedStateRoot)] -#[mesh(package = "openvmm")] -pub struct SavedState { - #[mesh(1)] - pub units: Vec, -} - async fn open_simple_disk( resolver: &ResourceResolver, disk_type: Resource, @@ -4306,11 +4297,16 @@ impl LoadedVm { async fn save(&mut self) -> anyhow::Result { Ok(SavedState { units: self.state_units.save().await?, + inventory: self.state_units.inventory(), }) } /// Restore state on the VM. async fn restore(&mut self, state: SavedState) -> anyhow::Result<()> { + // Saved state without an inventory is restored without the check. + if !state.inventory.is_empty() { + self.state_units.validate_inventory(&state.inventory)?; + } self.state_units.restore(state.units).await?; Ok(()) } diff --git a/openvmm/openvmm_defs/Cargo.toml b/openvmm/openvmm_defs/Cargo.toml index ba75221295b..cf6fb6b4f20 100644 --- a/openvmm/openvmm_defs/Cargo.toml +++ b/openvmm/openvmm_defs/Cargo.toml @@ -12,8 +12,10 @@ openvmm_pcat_locator.workspace = true # vmcore memory_range = { workspace = true, features = ["mesh"] } +state_unit.workspace = true vm_resource.workspace = true vmgs_resources.workspace = true +vmcore.workspace = true vmotherboard.workspace = true floppy_resources.workspace = true diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index c380398b497..3d721740863 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -7,9 +7,12 @@ use crate::config::Config; use crate::rpc::VmRpc; use hypervisor_resources::HypervisorKind; use mesh::MeshPayload; +use mesh::payload::Protobuf; use mesh::payload::message::ProtobufMessage; use mesh_worker::WorkerId; +use state_unit::SavedStateUnit; use vm_resource::Resource; +use vmcore::save_restore::SavedStateRoot; use vmm_core_defs::HaltReason; /// File descriptor (Unix) or handle (Windows) for file-backed guest RAM. @@ -21,6 +24,17 @@ pub type SharedMemoryFd = std::os::windows::io::OwnedHandle; pub const VM_WORKER: WorkerId = WorkerId::new("VmWorker"); +/// Complete saved state consumed by the VM worker. +#[derive(Protobuf, SavedStateRoot)] +#[mesh(package = "openvmm")] +pub struct SavedState { + #[mesh(1)] + pub units: Vec, + /// Complete state-unit inventory, including units with no mutable state. + #[mesh(2)] + pub inventory: Vec, +} + /// Launch parameters for the VM worker. #[derive(MeshPayload)] pub struct VmWorkerParameters { diff --git a/vmm_core/state_unit/src/inventory.rs b/vmm_core/state_unit/src/inventory.rs new file mode 100644 index 00000000000..ff3bbb650e2 --- /dev/null +++ b/vmm_core/state_unit/src/inventory.rs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The state-unit inventory: the registered unit names, which a snapshot +//! records and restore requires to match exactly. + +use super::StateUnits; + +impl StateUnits { + /// Returns every registered state-unit name in stable registration order. + pub fn inventory(&self) -> Vec { + self.inner + .lock() + .units + .values() + .map(|unit| unit.name.to_string()) + .collect() + } + + /// Verifies that a saved inventory exactly matches the registered units. + pub fn validate_inventory(&self, saved_inventory: &[String]) -> anyhow::Result<()> { + let current_inventory = self.inventory(); + anyhow::ensure!( + saved_inventory == current_inventory, + "saved state-unit inventory does not match the current machine: saved={saved_inventory:?}, current={current_inventory:?}" + ); + Ok(()) + } +} diff --git a/vmm_core/state_unit/src/lib.rs b/vmm_core/state_unit/src/lib.rs index fafb489ed41..22d6493a7e2 100644 --- a/vmm_core/state_unit/src/lib.rs +++ b/vmm_core/state_unit/src/lib.rs @@ -30,6 +30,7 @@ #![forbid(unsafe_code)] +mod inventory; mod start; use futures::FutureExt; @@ -1000,6 +1001,7 @@ impl Ready { #[cfg(test)] mod tests { + mod inventory; mod start; use super::StateUnit; diff --git a/vmm_core/state_unit/src/tests/inventory.rs b/vmm_core/state_unit/src/tests/inventory.rs new file mode 100644 index 00000000000..a700ab9a832 --- /dev/null +++ b/vmm_core/state_unit/src/tests/inventory.rs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the state-unit inventory. + +use super::TestUnit; +use crate::StateUnits; +use crate::run_unit; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; + +#[async_test] +async fn test_exact_inventory_validation(driver: DefaultDriver) { + let units = StateUnits::new(); + let _first = units + .add("first") + .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)) + .unwrap(); + let _second = units + .add("second") + .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)) + .unwrap(); + + assert_eq!(units.inventory(), ["first", "second"]); + units + .validate_inventory(&["first".to_owned(), "second".to_owned()]) + .unwrap(); + assert!(units.validate_inventory(&["first".to_owned()]).is_err()); + assert!( + units + .validate_inventory(&["second".to_owned(), "first".to_owned()]) + .is_err() + ); + assert!( + units + .validate_inventory(&["first".to_owned(), "second".to_owned(), "extra".to_owned(),]) + .is_err() + ); +} From dcf7542509235228f4f7084c1425447d3eb680c4 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:10:44 -0700 Subject: [PATCH 049/304] state_unit: add bounded quiesce transactions with rollback classification Saving a running VM for a snapshot first stops all of its state units. `StateUnits::stop` waits indefinitely and cannot tell its caller whether a failure left the VM in a state that it can resume from. Add `StateUnits::quiesce_for_save`, which stops all units in reverse dependency order within a timeout. A unit one of whose dependents did not stop is not stopped and keeps running. A unit whose stop timed out or could not be delivered may be partially stopped: it is left in a terminal state, and the returned `QuiesceForSaveError` reports that the VM must be terminated instead of resumed. Add `StateUnits::resume_after_failed_save`, which restarts all units within a timeout when saving fails after a fully successful quiesce. It refuses to run unless every unit is cleanly stopped, and it leaves units that fail to restart in the terminal state. Rename that terminal state, which failed starts already use, from `StartUncertain` to `QuiesceUncertain`, since it now also records uncertain quiesce outcomes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vmm_core/state_unit/src/lib.rs | 4 +- vmm_core/state_unit/src/quiesce.rs | 216 +++++++++++++++++++++++ vmm_core/state_unit/src/start.rs | 4 +- vmm_core/state_unit/src/tests/quiesce.rs | 172 ++++++++++++++++++ 4 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 vmm_core/state_unit/src/quiesce.rs create mode 100644 vmm_core/state_unit/src/tests/quiesce.rs diff --git a/vmm_core/state_unit/src/lib.rs b/vmm_core/state_unit/src/lib.rs index 22d6493a7e2..f5cbb255ba2 100644 --- a/vmm_core/state_unit/src/lib.rs +++ b/vmm_core/state_unit/src/lib.rs @@ -31,6 +31,7 @@ #![forbid(unsafe_code)] mod inventory; +pub mod quiesce; mod start; use futures::FutureExt; @@ -221,7 +222,7 @@ enum State { Resetting, Saving, Restoring, - StartUncertain, + QuiesceUncertain, } #[derive(Debug)] @@ -1002,6 +1003,7 @@ impl Ready { #[cfg(test)] mod tests { mod inventory; + mod quiesce; mod start; use super::StateUnit; diff --git a/vmm_core/state_unit/src/quiesce.rs b/vmm_core/state_unit/src/quiesce.rs new file mode 100644 index 00000000000..fc66a7cff8b --- /dev/null +++ b/vmm_core/state_unit/src/quiesce.rs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot quiesce transactions: bounded quiesce of every unit for save, and +//! restart after a failed save. + +use super::State; +use super::StateRequest; +use super::StateUnits; +use super::state_change; +use futures::future::join_all; +use mesh::CancelContext; +use std::time::Duration; +use std::time::Instant; +use thiserror::Error; + +/// A bounded quiesce failed and the VM must not be saved. +#[derive(Debug, Error)] +#[error("failed to quiesce state units for save: {failures:?}")] +pub struct QuiesceForSaveError { + failures: Vec<(String, String)>, + uncertain: bool, +} + +impl QuiesceForSaveError { + /// Returns whether any unit may have partially changed ownership or state. + /// + /// When true, the VM cannot be resumed safely and must be terminated. + pub fn has_uncertain_state(&self) -> bool { + self.uncertain + } +} + +impl StateUnits { + /// Stops all units in reverse dependency order within `timeout`. + /// + /// A timeout or communication failure after a stop request is sent leaves + /// ownership uncertain. The returned error reports this via + /// [`QuiesceForSaveError::has_uncertain_state`]; callers must terminate the + /// VM rather than attempt rollback in that case. + pub async fn quiesce_for_save(&mut self, timeout: Duration) -> Result<(), QuiesceForSaveError> { + assert!(self.running); + + enum UnitResult { + Stopped, + DependencyFailed, + Uncertain(anyhow::Error), + } + + let context = CancelContext::new().with_timeout(timeout); + let mut operations = Vec::new(); + let ready_set; + { + let mut inner = self.inner.lock(); + ready_set = inner.ready_set(None); + for (&id, unit) in &mut inner.units { + assert_eq!( + unit.state, + State::Running, + "unit {} is not running before save quiesce", + unit.name, + ); + let name = unit.name.clone(); + let dependencies = unit.dependents.clone(); + let ready_set = ready_set.clone(); + let mut context = context.clone(); + let stop = state_change(name.clone(), unit, StateRequest::Stop, Some(())); + operations.push(async move { + if !ready_set.wait("quiesce_for_save", id, &dependencies).await { + ready_set.done(id, false); + return (name, id, UnitResult::DependencyFailed); + } + + let result = match context.until_cancelled(stop).await { + Ok(Ok(Some(()))) => UnitResult::Stopped, + Ok(Ok(None)) => { + UnitResult::Uncertain(anyhow::anyhow!("stop request was not sent")) + } + Ok(Err(error)) => UnitResult::Uncertain(error.into()), + Err(reason) => UnitResult::Uncertain(reason.into()), + }; + ready_set.done(id, matches!(result, UnitResult::Stopped)); + (name, id, result) + }); + unit.state = State::Stopping; + } + } + + let start = Instant::now(); + let results = join_all(operations).await; + tracing::info!(duration = ?Instant::now() - start, "save quiesce complete"); + + let mut failures = Vec::new(); + let mut uncertain = false; + let mut inner = self.inner.lock(); + for (name, id, result) in results { + let Some(unit) = inner.units.get_mut(&id) else { + continue; + }; + match result { + UnitResult::Stopped => unit.state = State::Stopped, + UnitResult::DependencyFailed => { + unit.state = State::Running; + failures.push(( + name.to_string(), + "a dependent unit did not quiesce".to_owned(), + )); + } + UnitResult::Uncertain(error) => { + unit.state = State::QuiesceUncertain; + uncertain = true; + failures.push((name.to_string(), format!("{error:#}"))); + } + } + } + drop(inner); + + if failures.is_empty() { + self.running = false; + Ok(()) + } else { + Err(QuiesceForSaveError { + failures, + uncertain, + }) + } + } + + /// Restarts every unit after a save failure that occurred after a fully + /// successful quiesce but before publication committed. + pub async fn resume_after_failed_save(&mut self, timeout: Duration) -> anyhow::Result<()> { + anyhow::ensure!(!self.running, "VM was not successfully quiesced for save"); + anyhow::ensure!( + self.inner + .lock() + .units + .values() + .all(|unit| unit.state == State::Stopped), + "state-unit rollback is unsafe because not every unit is cleanly stopped" + ); + + enum StartResult { + Started, + DependencyFailed, + Failed(anyhow::Error), + Uncertain(anyhow::Error), + } + + let context = CancelContext::new().with_timeout(timeout); + let mut operations = Vec::new(); + let ready_set; + { + let mut inner = self.inner.lock(); + ready_set = inner.ready_set(None); + for (&id, unit) in &mut inner.units { + let name = unit.name.clone(); + let dependencies = unit.dependencies.clone(); + let ready_set = ready_set.clone(); + let mut context = context.clone(); + let start = state_change(name.clone(), unit, StateRequest::Start, Some(())); + operations.push(async move { + if !ready_set.wait("snapshot_rollback", id, &dependencies).await { + ready_set.done(id, false); + return (name, id, StartResult::DependencyFailed); + } + + let result = match context.until_cancelled(start).await { + Ok(Ok(Some(Ok(())))) => StartResult::Started, + Ok(Ok(Some(Err(error)))) => StartResult::Failed(error.into()), + Ok(Ok(None)) => { + StartResult::Uncertain(anyhow::anyhow!("start request was not sent")) + } + Ok(Err(error)) => StartResult::Uncertain(error.into()), + Err(reason) => StartResult::Uncertain(reason.into()), + }; + ready_set.done(id, matches!(result, StartResult::Started)); + (name, id, result) + }); + unit.state = State::Starting; + } + } + + let results = join_all(operations).await; + let mut failures = Vec::new(); + let mut inner = self.inner.lock(); + for (name, id, result) in results { + let Some(unit) = inner.units.get_mut(&id) else { + continue; + }; + match result { + StartResult::Started => unit.state = State::Running, + StartResult::DependencyFailed => { + unit.state = State::Stopped; + failures.push(format!("{name}: a dependency did not restart")); + } + StartResult::Failed(error) => { + unit.state = State::QuiesceUncertain; + failures.push(format!("{name}: {error:#}")); + } + StartResult::Uncertain(error) => { + unit.state = State::QuiesceUncertain; + failures.push(format!("{name}: {error:#}")); + } + } + } + drop(inner); + + anyhow::ensure!( + failures.is_empty(), + "snapshot rollback could not safely restart all state units: {}", + failures.join("; ") + ); + self.running = true; + Ok(()) + } +} diff --git a/vmm_core/state_unit/src/start.rs b/vmm_core/state_unit/src/start.rs index d6667037da7..a2e098c071a 100644 --- a/vmm_core/state_unit/src/start.rs +++ b/vmm_core/state_unit/src/start.rs @@ -101,11 +101,11 @@ impl StateUnits { failures.push(format!("{name}: a dependency did not start")); } StartResult::Failed(error) => { - unit.state = State::StartUncertain; + unit.state = State::QuiesceUncertain; failures.push(format!("{name}: {error:#}")); } StartResult::Uncertain(error) => { - unit.state = State::StartUncertain; + unit.state = State::QuiesceUncertain; failures.push(format!("{name}: {error:#}")); } } diff --git a/vmm_core/state_unit/src/tests/quiesce.rs b/vmm_core/state_unit/src/tests/quiesce.rs new file mode 100644 index 00000000000..59fd25643c0 --- /dev/null +++ b/vmm_core/state_unit/src/tests/quiesce.rs @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for snapshot quiesce and for restart after a failed save. + +use super::TestUnit; +use crate::StateUnit; +use crate::StateUnits; +use crate::run_unit; +use inspect::InspectMut; +use pal_async::DefaultDriver; +use pal_async::async_test; +use std::time::Duration; +use test_with_tracing::test; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +struct SlowStopUnit { + driver: DefaultDriver, + delay: Duration, +} + +impl StateUnit for SlowStopUnit { + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn stop(&mut self) { + pal_async::timer::PolledTimer::new(&self.driver) + .sleep(self.delay) + .await; + } + + async fn reset(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(None) + } + + async fn restore(&mut self, _state: SavedStateBlob) -> Result<(), RestoreError> { + Err(RestoreError::SavedStateNotSupported) + } +} + +impl InspectMut for SlowStopUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } +} + +struct SlowRollbackStartUnit { + driver: DefaultDriver, + starts: u32, +} + +impl StateUnit for SlowRollbackStartUnit { + async fn start(&mut self) -> anyhow::Result<()> { + if self.starts != 0 { + pal_async::timer::PolledTimer::new(&self.driver) + .sleep(Duration::from_secs(1)) + .await; + } + self.starts += 1; + Ok(()) + } + + async fn stop(&mut self) {} + + async fn reset(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(None) + } + + async fn restore(&mut self, _state: SavedStateBlob) -> Result<(), RestoreError> { + Err(RestoreError::SavedStateNotSupported) + } +} + +impl InspectMut for SlowRollbackStartUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } +} + +#[async_test] +async fn test_quiesce_and_resume_after_failed_save(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let _unit = units + .add("unit") + .spawn(&driver, |recv| run_unit(TestUnit::default(), recv)) + .unwrap(); + units.start().await.unwrap(); + + units + .quiesce_for_save(Duration::from_secs(1)) + .await + .unwrap(); + assert!(!units.is_running()); + + units + .resume_after_failed_save(Duration::from_secs(1)) + .await + .unwrap(); + assert!(units.is_running()); +} + +#[async_test] +async fn test_quiesce_timeout_is_uncertain(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let _unit = units + .add("slow") + .spawn(&driver, { + let driver = driver.clone(); + |recv| { + run_unit( + SlowStopUnit { + driver, + delay: Duration::from_secs(1), + }, + recv, + ) + } + }) + .unwrap(); + units.start().await.unwrap(); + + let started = std::time::Instant::now(); + let error = units + .quiesce_for_save(Duration::from_millis(10)) + .await + .unwrap_err(); + assert!(error.has_uncertain_state()); + assert!(started.elapsed() < Duration::from_millis(500)); + assert!( + units + .resume_after_failed_save(Duration::from_secs(1)) + .await + .is_err() + ); +} + +#[async_test] +async fn test_rollback_start_timeout_is_bounded(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let _unit = units + .add("slow-rollback") + .spawn(&driver, { + let driver = driver.clone(); + |recv| run_unit(SlowRollbackStartUnit { driver, starts: 0 }, recv) + }) + .unwrap(); + units.start().await.unwrap(); + units + .quiesce_for_save(Duration::from_secs(1)) + .await + .unwrap(); + + let started = std::time::Instant::now(); + let error = units + .resume_after_failed_save(Duration::from_millis(10)) + .await + .unwrap_err(); + assert!(error.to_string().contains("snapshot rollback")); + assert!(started.elapsed() < Duration::from_millis(500)); + assert!(!units.is_running()); +} From a40676941c16b6053d6da8558876d329e54686b3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:14:33 -0700 Subject: [PATCH 050/304] vmcore: add device input quiesce and resume A snapshot has to be taken at a vCPU boundary where no host input, such as console or network input, is still being delivered to the guest. Nothing lets the VMM tell a device to stop accepting host input while the VM keeps running. Add `quiesce_input` and `resume_input` to `ChangeDeviceState` and to `StateUnit`. A device or unit that implements them stops accepting new host input while keeping its runtime state, and resumes it after a failed snapshot transaction. Both default to doing nothing. Add the corresponding `QuiesceInput` and `ResumeInput` state requests, and `StateUnits::quiesce_input_for_save` and `resume_input_after_save`, which send them to every unit within a timeout and fail if any unit fails or does not respond. The chipset device state unit, the erased chipset device, and the chipset device worker and its proxy forward the requests to the device. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/chipset_device_resources/src/lib.rs | 16 +++++ vm/vmcore/src/device_state.rs | 10 ++++ vmm_core/state_unit/src/lib.rs | 26 ++++++++ vmm_core/state_unit/src/quiesce.rs | 59 ++++++++++++++++++- .../chipset/backing/arc_mutex/state_unit.rs | 16 +++++ workers/chipset_device_worker/src/protocol.rs | 4 ++ workers/chipset_device_worker/src/proxy.rs | 14 +++++ workers/chipset_device_worker/src/worker.rs | 8 +++ 8 files changed, 151 insertions(+), 2 deletions(-) diff --git a/vm/chipset_device_resources/src/lib.rs b/vm/chipset_device_resources/src/lib.rs index 75501af146e..f61ac784b69 100644 --- a/vm/chipset_device_resources/src/lib.rs +++ b/vm/chipset_device_resources/src/lib.rs @@ -93,6 +93,8 @@ pub trait ConfigureChipsetDevice: Send { trait DynChipsetDevice: ChipsetDevice + ProtobufSaveRestore + InspectMut { fn start(&mut self); async fn start_fallible(&mut self) -> anyhow::Result<()>; + async fn quiesce_input(&mut self) -> anyhow::Result<()>; + async fn resume_input(&mut self) -> anyhow::Result<()>; async fn stop(&mut self); async fn reset(&mut self); } @@ -107,6 +109,12 @@ impl Dy async fn start_fallible(&mut self) -> anyhow::Result<()> { ChangeDeviceState::start_fallible(self).await } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + ChangeDeviceState::quiesce_input(self).await + } + async fn resume_input(&mut self) -> anyhow::Result<()> { + ChangeDeviceState::resume_input(self).await + } async fn stop(&mut self) { self.stop().await } @@ -140,6 +148,14 @@ impl ChangeDeviceState for ErasedChipsetDevice { self.0.start_fallible().await } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.0.quiesce_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.0.resume_input().await + } + async fn stop(&mut self) { self.0.stop().await } diff --git a/vm/vmcore/src/device_state.rs b/vm/vmcore/src/device_state.rs index 15d949f481c..58d678d636a 100644 --- a/vm/vmcore/src/device_state.rs +++ b/vm/vmcore/src/device_state.rs @@ -37,6 +37,16 @@ pub trait ChangeDeviceState { async { Ok(()) } } + /// Stops accepting new host input before a snapshot vCPU boundary. + fn quiesce_input(&mut self) -> impl Send + Future> { + async { Ok(()) } + } + + /// Resumes host input after a failed snapshot transaction. + fn resume_input(&mut self) -> impl Send + Future> { + async { Ok(()) } + } + /// Stops a device's asynchronous work. /// /// After this returns, the device must not process any additional work. It diff --git a/vmm_core/state_unit/src/lib.rs b/vmm_core/state_unit/src/lib.rs index f5cbb255ba2..59831fc4954 100644 --- a/vmm_core/state_unit/src/lib.rs +++ b/vmm_core/state_unit/src/lib.rs @@ -78,6 +78,12 @@ pub enum StateRequest { /// Stop asynchronous operations. Stop(Rpc<(), ()>), + /// Stop accepting new host input before a snapshot vCPU boundary. + QuiesceInput(FailableRpc<(), ()>), + + /// Resume host input after a failed snapshot transaction. + ResumeInput(FailableRpc<(), ()>), + /// Reset a stopped unit to initial state. Reset(FailableRpc<(), ()>), @@ -104,6 +110,16 @@ pub trait StateUnit: InspectMut { /// Stop asynchronous processing. async fn stop(&mut self); + /// Stops accepting new host input while preserving runtime state. + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + /// Resumes host input after a failed snapshot transaction. + async fn resume_input(&mut self) -> anyhow::Result<()> { + Ok(()) + } + /// Reset to initial state. /// /// Must only be called while stopped. @@ -167,6 +183,8 @@ impl StateRequest { StateRequest::Start(_) | StateRequest::Stop(_) + | StateRequest::QuiesceInput(_) + | StateRequest::ResumeInput(_) | StateRequest::Reset(_) | StateRequest::Save(_) | StateRequest::Restore(_) => { @@ -195,6 +213,14 @@ impl StateRequest { match self { StateRequest::Start(rpc) => rpc.handle_failable(async |()| unit.start().await).await, StateRequest::Stop(rpc) => rpc.handle(async |()| unit.stop().await).await, + StateRequest::QuiesceInput(rpc) => { + rpc.handle_failable(async |()| unit.quiesce_input().await) + .await + } + StateRequest::ResumeInput(rpc) => { + rpc.handle_failable(async |()| unit.resume_input().await) + .await + } StateRequest::Reset(rpc) => rpc.handle_failable(async |()| unit.reset().await).await, StateRequest::Save(rpc) => rpc.handle_failable(async |()| unit.save().await).await, StateRequest::Restore(rpc) => { diff --git a/vmm_core/state_unit/src/quiesce.rs b/vmm_core/state_unit/src/quiesce.rs index fc66a7cff8b..70e8a455ac3 100644 --- a/vmm_core/state_unit/src/quiesce.rs +++ b/vmm_core/state_unit/src/quiesce.rs @@ -1,15 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Snapshot quiesce transactions: bounded quiesce of every unit for save, and -//! restart after a failed save. +//! Snapshot quiesce transactions: gating host input around the snapshot +//! vCPU boundary, bounded quiesce of every unit for save, and restart after a +//! failed save. use super::State; use super::StateRequest; use super::StateUnits; use super::state_change; +use anyhow::Context as _; use futures::future::join_all; use mesh::CancelContext; +use mesh::rpc::FailableRpc; use std::time::Duration; use std::time::Instant; use thiserror::Error; @@ -32,6 +35,58 @@ impl QuiesceForSaveError { } impl StateUnits { + /// Stops host-input producers before establishing a snapshot vCPU boundary. + pub async fn quiesce_input_for_save(&self, timeout: Duration) -> anyhow::Result<()> { + self.run_input_op("quiesce_input", timeout, StateRequest::QuiesceInput) + .await + } + + /// Resumes host-input producers before releasing a failed snapshot boundary. + pub async fn resume_input_after_save(&self, timeout: Duration) -> anyhow::Result<()> { + self.run_input_op("resume_input", timeout, StateRequest::ResumeInput) + .await + } + + async fn run_input_op( + &self, + operation: &'static str, + timeout: Duration, + request: impl Copy + FnOnce(FailableRpc<(), ()>) -> StateRequest, + ) -> anyhow::Result<()> { + let operations = { + let inner = self.inner.lock(); + inner + .units + .values() + .map(|unit| { + let name = unit.name.clone(); + let operation = state_change(name.clone(), unit, request, Some(())); + async move { (name, operation.await) } + }) + .collect::>() + }; + let mut context = CancelContext::new().with_timeout(timeout); + let results = context + .until_cancelled(join_all(operations)) + .await + .with_context(|| format!("{operation} timed out"))?; + let mut failures = Vec::new(); + for (name, result) in results { + match result { + Ok(Some(Ok(()))) => {} + Ok(Some(Err(error))) => failures.push(format!("{name}: {error}")), + Ok(None) => failures.push(format!("{name}: request was not sent")), + Err(error) => failures.push(format!("{name}: {error}")), + } + } + anyhow::ensure!( + failures.is_empty(), + "{operation} failed: {}", + failures.join("; ") + ); + Ok(()) + } + /// Stops all units in reverse dependency order within `timeout`. /// /// A timeout or communication failure after a stop request is sent leaves diff --git a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs index a61b60885cd..b71f781f933 100644 --- a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs +++ b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs @@ -37,6 +37,8 @@ impl InspectMut for ArcMutexChipsetDeviceUnit { trait DynDevice: InspectMut + Send { async fn start(&mut self) -> anyhow::Result<()>; async fn stop(&mut self); + async fn quiesce_input(&mut self) -> anyhow::Result<()>; + async fn resume_input(&mut self) -> anyhow::Result<()>; async fn reset(&mut self); fn poll_device(&mut self, cx: &mut Context<'_>); fn save(&mut self) -> Result; @@ -52,6 +54,12 @@ impl DynDevice for T { async fn stop(&mut self) { self.stop().await } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + vmcore::device_state::ChangeDeviceState::quiesce_input(self).await + } + async fn resume_input(&mut self) -> anyhow::Result<()> { + vmcore::device_state::ChangeDeviceState::resume_input(self).await + } async fn reset(&mut self) { self.reset().await @@ -163,6 +171,14 @@ impl StateUnit for ArcMutexChipsetDeviceUnit { // These are currently not true. } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.device.clone().close().quiesce_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.device.clone().close().resume_input().await + } + async fn reset(&mut self) -> anyhow::Result<()> { self.device.clone().close().reset().await; Ok(()) diff --git a/workers/chipset_device_worker/src/protocol.rs b/workers/chipset_device_worker/src/protocol.rs index 1e200c0c823..241a2294d66 100644 --- a/workers/chipset_device_worker/src/protocol.rs +++ b/workers/chipset_device_worker/src/protocol.rs @@ -61,6 +61,10 @@ pub(crate) enum DeviceRequest { PciConfigWrite(WriteRequest, PciConfigByteEnable), /// Start the device Start(mesh::rpc::FailableRpc<(), ()>), + /// Stop accepting new host input before snapshot. + QuiesceInput(mesh::rpc::FailableRpc<(), ()>), + /// Resume host input after a failed snapshot. + ResumeInput(mesh::rpc::FailableRpc<(), ()>), /// Stop the device Stop(Rpc<(), ()>), /// Reset the device diff --git a/workers/chipset_device_worker/src/proxy.rs b/workers/chipset_device_worker/src/proxy.rs index 69b669ca6a6..0bdad4819dd 100644 --- a/workers/chipset_device_worker/src/proxy.rs +++ b/workers/chipset_device_worker/src/proxy.rs @@ -278,6 +278,20 @@ impl ChangeDeviceState for ChipsetDeviceProxy { Ok(()) } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.req_send + .call_failable(DeviceRequest::QuiesceInput, ()) + .await?; + Ok(()) + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.req_send + .call_failable(DeviceRequest::ResumeInput, ()) + .await?; + Ok(()) + } + async fn stop(&mut self) { self.req_send .call(DeviceRequest::Stop, ()) diff --git a/workers/chipset_device_worker/src/worker.rs b/workers/chipset_device_worker/src/worker.rs index 4799aa03c24..fdc7ec8d7fc 100644 --- a/workers/chipset_device_worker/src/worker.rs +++ b/workers/chipset_device_worker/src/worker.rs @@ -256,6 +256,14 @@ impl Worker for RemoteChipsetDeviceWorker { rpc.handle_failable(async |()| self.device.start_fallible().await) .await } + DeviceRequest::QuiesceInput(rpc) => { + rpc.handle_failable(async |()| self.device.quiesce_input().await) + .await + } + DeviceRequest::ResumeInput(rpc) => { + rpc.handle_failable(async |()| self.device.resume_input().await) + .await + } DeviceRequest::Stop(rpc) => { rpc.handle(async |()| self.device.stop().await).await } From c1be51ed4fc19954d17ac79021c86ac0d0959fca Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:22:17 -0700 Subject: [PATCH 051/304] vmcore, state_unit: advance guest time across stopped downtime When a stopped VM is restored from a snapshot, the time that passed while it was not running should become visible to the guest, but VM time resumes from the saved value and nothing lets devices account for the gap. Add `VmTimeKeeper::advance`, which moves stopped VM time forward, and an `advance_time` hook to `StateUnit` and `ChangeDeviceState`, which lets a stopped unit or device advance its guest-visible time by a given duration. Both default to doing nothing, since most units derive their deadlines from VM time. The VM time keeper unit advances VM time, and the chipset device state unit and the erased chipset device forward the hook to their device. Add the `AdvanceTime` state request and `StateUnits::advance_time`, which applies it to all stopped units in dependency order. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/chipset_device_resources/src/lib.rs | 8 ++++ vm/vmcore/src/device_state.rs | 9 +++++ vm/vmcore/src/vmtime.rs | 10 +++++ vmm_core/src/vmtime_unit.rs | 5 +++ vmm_core/state_unit/src/advance_time.rs | 37 +++++++++++++++++++ vmm_core/state_unit/src/lib.rs | 16 ++++++++ .../chipset/backing/arc_mutex/state_unit.rs | 9 +++++ 7 files changed, 94 insertions(+) create mode 100644 vmm_core/state_unit/src/advance_time.rs diff --git a/vm/chipset_device_resources/src/lib.rs b/vm/chipset_device_resources/src/lib.rs index f61ac784b69..d34a9d9328f 100644 --- a/vm/chipset_device_resources/src/lib.rs +++ b/vm/chipset_device_resources/src/lib.rs @@ -97,6 +97,7 @@ trait DynChipsetDevice: ChipsetDevice + ProtobufSaveRestore + InspectMut { async fn resume_input(&mut self) -> anyhow::Result<()>; async fn stop(&mut self); async fn reset(&mut self); + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()>; } #[async_trait] @@ -121,6 +122,9 @@ impl Dy async fn reset(&mut self) { self.reset().await } + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.advance_time(duration).await + } } /// A resolved chipset device resource. @@ -163,6 +167,10 @@ impl ChangeDeviceState for ErasedChipsetDevice { async fn reset(&mut self) { self.0.reset().await } + + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.0.advance_time(duration).await + } } impl ChipsetDevice for ErasedChipsetDevice { diff --git a/vm/vmcore/src/device_state.rs b/vm/vmcore/src/device_state.rs index 58d678d636a..088d900f576 100644 --- a/vm/vmcore/src/device_state.rs +++ b/vm/vmcore/src/device_state.rs @@ -6,6 +6,7 @@ #![forbid(unsafe_code)] use std::future::Future; +use std::time::Duration; /// Trait for transitioning device state. pub trait ChangeDeviceState { @@ -62,4 +63,12 @@ pub trait ChangeDeviceState { /// Callers must ensure that the device is in a stopped state before calling /// this method. fn reset(&mut self) -> impl Send + Future; + + /// Advances guest-visible device time after snapshot restore. + fn advance_time( + &mut self, + _duration: Duration, + ) -> impl Send + Future> { + async { Ok(()) } + } } diff --git a/vm/vmcore/src/vmtime.rs b/vm/vmcore/src/vmtime.rs index 0991be23693..e3f30659663 100644 --- a/vm/vmcore/src/vmtime.rs +++ b/vm/vmcore/src/vmtime.rs @@ -481,6 +481,16 @@ impl VmTimeKeeper { self.reset_to(vmtime).await } + /// Advances stopped VM time by `duration`. + pub async fn advance(&mut self, duration: Duration) { + let vmtime = self + .time + .stop_time() + .expect("VM time must be stopped before advancing") + .wrapping_add(duration); + self.reset_to(vmtime).await; + } + async fn reset_to(&mut self, vmtime: VmTime) { assert!(!self.time.is_started(), "should be stopped"); self.time = TimeState::Stopped(vmtime); diff --git a/vmm_core/src/vmtime_unit.rs b/vmm_core/src/vmtime_unit.rs index 9fa55a3c138..e044524e437 100644 --- a/vmm_core/src/vmtime_unit.rs +++ b/vmm_core/src/vmtime_unit.rs @@ -39,6 +39,11 @@ impl StateUnit for KeeperUnit<'_> { self.0.restore(state.parse()?).await; Ok(()) } + + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.0.advance(duration).await; + Ok(()) + } } /// Runs the VM time keeper, responding to state changes from `recv`, until diff --git a/vmm_core/state_unit/src/advance_time.rs b/vmm_core/state_unit/src/advance_time.rs new file mode 100644 index 00000000000..e06a8382471 --- /dev/null +++ b/vmm_core/state_unit/src/advance_time.rs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Advancing guest-visible time by the snapshot downtime after restore. + +use super::State; +use super::StateRequest; +use super::StateTransitionError; +use super::StateUnits; +use super::check; +use std::time::Duration; + +impl StateUnits { + /// Advances guest-visible time on all stopped units in dependency order. + pub async fn advance_time(&mut self, duration: Duration) -> Result<(), StateTransitionError> { + assert!(!self.running); + let results = self + .run_op( + "advance_time", + None, + State::Stopped, + State::AdvancingTime, + State::Stopped, + StateRequest::AdvanceTime, + |_, _| Some(duration), + |unit| &unit.dependencies, + ) + .await; + check( + "advance_time", + results + .into_iter() + .map(|(name, result)| (name, result.map_err(anyhow::Error::from))), + )?; + Ok(()) + } +} diff --git a/vmm_core/state_unit/src/lib.rs b/vmm_core/state_unit/src/lib.rs index 59831fc4954..5f0e86eb89b 100644 --- a/vmm_core/state_unit/src/lib.rs +++ b/vmm_core/state_unit/src/lib.rs @@ -30,6 +30,7 @@ #![forbid(unsafe_code)] +mod advance_time; mod inventory; pub mod quiesce; mod start; @@ -93,6 +94,9 @@ pub enum StateRequest { /// Restore state of a stopped unit. Restore(FailableRpc), + /// Advance guest-visible time while stopped after snapshot restore. + AdvanceTime(FailableRpc), + /// Inspect state. Inspect(inspect::Deferred), } @@ -134,6 +138,12 @@ pub trait StateUnit: InspectMut { /// /// Must only be called while stopped. async fn restore(&mut self, buffer: SavedStateBlob) -> Result<(), RestoreError>; + + /// Advance guest-visible time after restore. Most units derive their + /// deadlines from VM time and need no direct adjustment. + async fn advance_time(&mut self, _duration: std::time::Duration) -> anyhow::Result<()> { + Ok(()) + } } /// Runs a simple unit that only needs to respond to state requests. @@ -185,6 +195,7 @@ impl StateRequest { | StateRequest::Stop(_) | StateRequest::QuiesceInput(_) | StateRequest::ResumeInput(_) + | StateRequest::AdvanceTime(_) | StateRequest::Reset(_) | StateRequest::Save(_) | StateRequest::Restore(_) => { @@ -227,6 +238,10 @@ impl StateRequest { rpc.handle_failable(async |buffer| unit.restore(buffer).await) .await } + StateRequest::AdvanceTime(rpc) => { + rpc.handle_failable(async |duration| unit.advance_time(duration).await) + .await + } StateRequest::Inspect(req) => req.inspect(unit), } } @@ -248,6 +263,7 @@ enum State { Resetting, Saving, Restoring, + AdvancingTime, QuiesceUncertain, } diff --git a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs index b71f781f933..a25df16ddff 100644 --- a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs +++ b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/state_unit.rs @@ -40,6 +40,7 @@ trait DynDevice: InspectMut + Send { async fn quiesce_input(&mut self) -> anyhow::Result<()>; async fn resume_input(&mut self) -> anyhow::Result<()>; async fn reset(&mut self); + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()>; fn poll_device(&mut self, cx: &mut Context<'_>); fn save(&mut self) -> Result; fn restore(&mut self, state: SavedStateBlob) -> Result<(), RestoreError>; @@ -65,6 +66,10 @@ impl DynDevice for T { self.reset().await } + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.advance_time(duration).await + } + fn poll_device(&mut self, cx: &mut Context<'_>) { if let Some(poll) = self.supports_poll_device() { poll.poll_device(cx); @@ -184,6 +189,10 @@ impl StateUnit for ArcMutexChipsetDeviceUnit { Ok(()) } + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.device.clone().close().advance_time(duration).await + } + async fn save(&mut self) -> Result, SaveError> { if self.omit_saved_state { return Ok(None); From 0831fcbdf4d10d1a35859a930d361328f7dfb7c3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:28:40 -0700 Subject: [PATCH 052/304] chipset: persist and advance the RTC clock The saved state of the CMOS RTC holds only its registers. The guest-visible time comes from the clock source, so a restored VM sees the time of the restore rather than the time at which it was saved, and the time that the VM spent stopped cannot be applied deliberately. Save the guest-visible UTC time with the RTC state, restore the clock source to it, and implement `advance_time` by moving the clock forward by the downtime. The PIIX4 RTC forwards the hook. Access the time through a fallible `UtcClockSource`. When no sample is available, or its year cannot be represented, the RTC invalidates the calendar registers and clears the valid RAM and time bit of status register D until the next successful update, and the saved state records whether the time is valid. The local clock remains the only source, and it always provides a sample. The saved state also reserves the field that an earlier snapshot format of the microVM RTC used for an in-progress coherent calendar read, and restoring a saved state that has one fails. A saved state without the time validity restores with the time marked valid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 2 + vm/devices/chipset/Cargo.toml | 1 + vm/devices/chipset/src/cmos_rtc.rs | 57 +++++++-- vm/devices/chipset/src/cmos_rtc/clock.rs | 115 ++++++++++++++++++ .../src/cmos_rtc/save_restore/snapshot.rs | 56 +++++++++ .../chipset/src/cmos_rtc/tests/snapshot.rs | 55 +++++++++ vm/devices/chipset_legacy/Cargo.toml | 1 + .../chipset_legacy/src/piix4_cmos_rtc.rs | 4 + 8 files changed, 284 insertions(+), 7 deletions(-) create mode 100644 vm/devices/chipset/src/cmos_rtc/clock.rs create mode 100644 vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs create mode 100644 vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index b7195f01eef..bae6f6dd705 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -579,6 +579,7 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" name = "chipset" version = "0.0.0" dependencies = [ + "anyhow", "async-trait", "bitfield-struct 0.11.0", "chipset_device", @@ -690,6 +691,7 @@ dependencies = [ name = "chipset_legacy" version = "0.0.0" dependencies = [ + "anyhow", "async-trait", "chipset", "chipset_device", diff --git a/vm/devices/chipset/Cargo.toml b/vm/devices/chipset/Cargo.toml index 82d032bab89..fa14f4e4df1 100644 --- a/vm/devices/chipset/Cargo.toml +++ b/vm/devices/chipset/Cargo.toml @@ -18,6 +18,7 @@ vmcore.workspace = true x86defs.workspace = true async-trait.workspace = true +anyhow.workspace = true bitfield-struct.workspace = true futures.workspace = true inspect_counters.workspace = true diff --git a/vm/devices/chipset/src/cmos_rtc.rs b/vm/devices/chipset/src/cmos_rtc.rs index d0dc8c1a016..b9ffde05b02 100644 --- a/vm/devices/chipset/src/cmos_rtc.rs +++ b/vm/devices/chipset/src/cmos_rtc.rs @@ -6,6 +6,8 @@ #![warn(missing_docs)] +pub mod clock; + use self::spec::CmosReg; use self::spec::ENABLE_OSCILLATOR_CONTROL; use self::spec::StatusRegA; @@ -176,7 +178,7 @@ pub struct Rtc { enlightened_interrupts: bool, // Runtime deps - real_time_source: Box, + real_time_source: Box, interrupt: LineInterrupt, vmtime_alarm: VmTimeAccess, vmtimer_periodic: VmTimerPeriodic, @@ -194,6 +196,8 @@ pub struct Rtc { struct RtcState { addr: u8, cmos: CmosData, + time_valid: bool, + transaction_read_mask: Option, } impl RtcState { @@ -220,6 +224,8 @@ impl RtcState { // default Hyper-V used, so we'll stick with it addr: 0x80, cmos, + time_valid: true, + transaction_read_mask: None, } } } @@ -235,6 +241,10 @@ impl ChangeDeviceState for Rtc { self.update_timers(); self.update_interrupt_line_level(); } + + async fn advance_time(&mut self, duration: Duration) -> anyhow::Result<()> { + self.advance_clock(duration) + } } impl ChipsetDevice for Rtc { @@ -312,7 +322,7 @@ impl Rtc { initial_cmos, enlightened_interrupts, - real_time_source, + real_time_source: Box::new(clock::LocalClockUtcSource(real_time_source)), interrupt, vmtime_alarm: vmtime_source.access("rtc-alarm"), vmtimer_periodic: VmTimerPeriodic::new(vmtime_source.access("rtc-periodic")), @@ -674,7 +684,9 @@ impl Rtc { // wait for a rising or falling transition of the bit (typically rising edge), // and then the guest OS will wait for another of the same transition. if !StatusRegB::from(self.state.cmos[CmosReg::STATUS_B]).set() { - let now = self.real_time_source.get_time(); + let Ok(now) = self.real_time_source.get_time() else { + return self.status_a_without_clock(data); + }; let elapsed = now - self.last_update_bit_blip; // check if the programmed time jumped backwards @@ -718,6 +730,7 @@ impl Rtc { data.into() } + CmosReg::STATUS_D if !self.state.time_valid => StatusRegD::new().with_vrt(false).into(), CmosReg::STATUS_D => { // always report valid ram time StatusRegD::new().with_vrt(true).into() @@ -774,16 +787,22 @@ impl Rtc { return; } - let real_time = self.real_time_source.get_time(); + let Some(real_time) = self.sample_utc_clock() else { + return; + }; let Ok(clock_time): Result = real_time.try_into() else { tracelimit::warn_ratelimited!( ?real_time, "invalid date/time in real_time_source, skipping sync" ); + self.invalidate_calendar(); return; }; let clock_time = clock_time.to_zoned(jiff::tz::TimeZone::UTC).datetime(); + if !self.validate_calendar_year(&clock_time) { + return; + } let status_b = StatusRegB::from(self.state.cmos[CmosReg::STATUS_B]); @@ -830,6 +849,7 @@ impl Rtc { self.state.cmos[CmosReg::HOUR] |= 0x80; } } + self.state.time_valid = true; tracing::trace!( cmos_reg_status_b = self.state.cmos[CmosReg::STATUS_B], @@ -918,6 +938,8 @@ impl PollDevice for Rtc { } mod save_restore { + mod snapshot; + use super::*; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; @@ -935,6 +957,15 @@ mod save_restore { pub addr: u8, #[mesh(2)] pub cmos: [u8; 256], + /// Guest-visible RTC time at the stopped snapshot boundary. + #[mesh(3)] + pub clock_time_millis: i64, + /// Calendar registers already read from an active coherent sample. + #[mesh(4)] + pub transaction_read_mask: Option, + /// Whether the latched calendar sample is valid. + #[mesh(5)] + pub time_valid: Option, } } @@ -942,20 +973,30 @@ mod save_restore { type SavedState = state::SavedState; fn save(&mut self) -> Result { - let RtcState { addr, ref cmos } = self.state; + let RtcState { addr, ref cmos, .. } = self.state; - let saved_state = state::SavedState { addr, cmos: cmos.0 }; + let saved_state = state::SavedState { + addr, + cmos: cmos.0, + clock_time_millis: self.saved_clock_time()?, + transaction_read_mask: self.state.transaction_read_mask, + time_valid: Some(self.state.time_valid), + }; Ok(saved_state) } fn restore(&mut self, state: Self::SavedState) -> Result<(), RestoreError> { - let state::SavedState { addr, cmos } = state; + let fields = snapshot::Fields::validate(&state)?; + let state::SavedState { addr, cmos, .. } = state; self.state = RtcState { addr, cmos: CmosData(cmos), + time_valid: fields.time_valid, + transaction_read_mask: fields.transaction_read_mask, }; + self.restore_clock(fields.clock_time_millis); self.update_timers(); self.update_interrupt_line_level(); @@ -967,6 +1008,8 @@ mod save_restore { #[cfg(test)] mod tests { + mod snapshot; + use super::*; use local_clock::MockLocalClock; use local_clock::MockLocalClockAccessor; diff --git a/vm/devices/chipset/src/cmos_rtc/clock.rs b/vm/devices/chipset/src/cmos_rtc/clock.rs new file mode 100644 index 00000000000..6684f47906f --- /dev/null +++ b/vm/devices/chipset/src/cmos_rtc/clock.rs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The UTC clock source of the RTC: a fallible clock abstraction, calendar +//! invalidation when no representable time is available, and advancing the +//! guest-visible time. + +use super::Rtc; +use super::spec::CmosReg; +use super::spec::StatusRegA; +use anyhow::Context; +use inspect::Inspect; +use local_clock::InspectableLocalClock; +use local_clock::LocalClockTime; +use std::time::Duration; + +/// A fallible UTC clock source for the RTC. +pub trait UtcClockSource: Inspect + Send { + /// Samples UTC as milliseconds from the Unix epoch. + fn get_time(&mut self) -> Result; + + /// Updates the guest-visible UTC epoch. + fn set_time(&mut self, new_time: LocalClockTime); +} + +/// Indicates that an RTC UTC clock source could not provide a sample. +#[derive(Debug, thiserror::Error)] +#[error("UTC clock sample is unavailable")] +pub struct UtcClockSourceError; + +#[derive(Inspect)] +#[inspect(transparent)] +pub(super) struct LocalClockUtcSource(pub(super) Box); + +impl UtcClockSource for LocalClockUtcSource { + fn get_time(&mut self) -> Result { + Ok(self.0.get_time()) + } + + fn set_time(&mut self, new_time: LocalClockTime) { + self.0.set_time(new_time) + } +} + +impl Rtc { + /// Advances the guest-visible UTC time by `duration`. + pub(super) fn advance_clock(&mut self, duration: Duration) -> anyhow::Result<()> { + let delta = i64::try_from(duration.as_millis()) + .context("RTC downtime does not fit in milliseconds")?; + let current = self + .real_time_source + .get_time() + .context("failed to sample RTC UTC clock")?; + let advanced = current + .as_millis_since_unix_epoch() + .checked_add(delta) + .context("RTC time overflow while applying snapshot downtime")?; + self.real_time_source + .set_time(LocalClockTime::from_millis_since_unix_epoch(advanced)); + self.update_timers(); + self.update_interrupt_line_level(); + Ok(()) + } + + /// Samples the UTC clock, invalidating the calendar if no sample is + /// available. + pub(super) fn sample_utc_clock(&mut self) -> Option { + match self.real_time_source.get_time() { + Ok(real_time) => Some(real_time), + Err(error) => { + tracelimit::warn_ratelimited!(?error, "failed to sample RTC UTC clock"); + self.invalidate_calendar(); + None + } + } + } + + /// Returns whether the calendar registers can represent the year of + /// `clock_time`, invalidating the calendar if they cannot. + pub(super) fn validate_calendar_year(&mut self, clock_time: &jiff::civil::DateTime) -> bool { + if !(0..=9999).contains(&clock_time.year()) { + tracelimit::warn_ratelimited!( + year = clock_time.year(), + "RTC UTC year is not representable" + ); + self.invalidate_calendar(); + return false; + } + true + } + + /// Returns status register A when the UTC clock cannot be sampled: an + /// update is reported in progress and the calendar is invalidated. + pub(super) fn status_a_without_clock(&mut self, mut data: StatusRegA) -> u8 { + data.set_update(true); + self.invalidate_calendar(); + data.into() + } + + pub(super) fn invalidate_calendar(&mut self) { + for reg in [ + CmosReg::SECOND, + CmosReg::MINUTE, + CmosReg::HOUR, + CmosReg::DAY_OF_WEEK, + CmosReg::DAY_OF_MONTH, + CmosReg::MONTH, + CmosReg::YEAR, + self.century_reg, + ] { + self.state.cmos[reg] = 0; + } + self.state.time_valid = false; + } +} diff --git a/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs b/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs new file mode 100644 index 00000000000..07bd8af071e --- /dev/null +++ b/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The snapshot fields of the RTC saved state: the guest-visible UTC time, +//! the calendar validity, and the retired coherent-transaction state. + +use super::state::SavedState; +use crate::cmos_rtc::Rtc; +use local_clock::LocalClockTime; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; + +/// The validated snapshot fields of a saved state. +pub(super) struct Fields { + pub(super) time_valid: bool, + pub(super) transaction_read_mask: Option, + pub(super) clock_time_millis: i64, +} + +impl Fields { + /// Validates the snapshot fields of `state` before any RTC state changes. + pub(super) fn validate(state: &SavedState) -> Result { + let time_valid = state.time_valid.unwrap_or(true); + + if state.transaction_read_mask.is_some() { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "invalid RTC coherent transaction state" + ))); + } + + Ok(Self { + time_valid, + transaction_read_mask: state.transaction_read_mask, + clock_time_millis: state.clock_time_millis, + }) + } +} + +impl Rtc { + /// Samples the guest-visible UTC time for the saved state. + pub(super) fn saved_clock_time(&mut self) -> Result { + Ok(self + .real_time_source + .get_time() + .map_err(|error| SaveError::Other(error.into()))? + .as_millis_since_unix_epoch()) + } + + /// Restores the guest-visible UTC time of the saved state. + pub(super) fn restore_clock(&mut self, clock_time_millis: i64) { + self.real_time_source + .set_time(LocalClockTime::from_millis_since_unix_epoch( + clock_time_millis, + )); + } +} diff --git a/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs b/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs new file mode 100644 index 00000000000..7e372e57dd2 --- /dev/null +++ b/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the snapshot fields of the RTC saved state and for advancing +//! the guest-visible time. + +use super::get_cmos_data; +use super::new_test_rtc; +use crate::cmos_rtc::spec::CmosReg; +use std::time::Duration; +use test_with_tracing::test; +use vmcore::device_state::ChangeDeviceState; +use vmcore::save_restore::SaveRestore; + +#[test] +fn restore_preserves_guest_epoch_and_advances_downtime() { + let (mut pool, _vm_time_keeper, clock, mut rtc) = new_test_rtc(); + clock.tick(Duration::from_secs(10)); + let captured_time = clock.get_time(); + let saved = rtc.save().unwrap(); + + clock.tick(Duration::from_secs(60)); + rtc.restore(saved).unwrap(); + assert_eq!(clock.get_time(), captured_time); + + pool.run_until(rtc.advance_time(Duration::from_secs(3))) + .unwrap(); + assert_eq!( + clock.get_time() - captured_time, + Duration::from_secs(3).into() + ); +} + +#[test] +fn rtc_rejects_retired_coherent_transaction_state() { + let (_, _, _, mut rtc) = new_test_rtc(); + let mut saved = rtc.save().unwrap(); + saved.transaction_read_mask = Some(1 << 15); + + assert!(matches!( + rtc.restore(saved), + Err(vmcore::save_restore::RestoreError::InvalidSavedState(_)) + )); +} + +#[test] +fn rtc_restores_legacy_valid_time_semantics() { + let (_, _, _, mut rtc) = new_test_rtc(); + let mut saved = rtc.save().unwrap(); + saved.time_valid = None; + saved.transaction_read_mask = None; + + rtc.restore(saved).unwrap(); + assert_eq!(get_cmos_data(&mut rtc, CmosReg::STATUS_D), 0x80); +} diff --git a/vm/devices/chipset_legacy/Cargo.toml b/vm/devices/chipset_legacy/Cargo.toml index 8999be9ec5a..cb37972717b 100644 --- a/vm/devices/chipset_legacy/Cargo.toml +++ b/vm/devices/chipset_legacy/Cargo.toml @@ -22,6 +22,7 @@ power_resources.workspace = true vmcore.workspace = true async-trait.workspace = true +anyhow.workspace = true inspect.workspace = true mesh.workspace = true open_enum.workspace = true diff --git a/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs b/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs index d32668c78ce..af8a786f477 100644 --- a/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs +++ b/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs @@ -76,6 +76,10 @@ impl ChangeDeviceState for Piix4CmosRtc { async fn reset(&mut self) { self.inner.reset().await; } + + async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { + self.inner.advance_time(duration).await + } } impl ChipsetDevice for Piix4CmosRtc { From 76b157b0ec24157c19a550538d3b367101f24947 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:29:59 -0700 Subject: [PATCH 053/304] chipset: reject inconsistent RTC interrupt state on restore The IRQF bit of status register C must be set exactly when one of the update, periodic, or alarm flags is set. The RTC restored status register C from the saved registers as they were, so a corrupted or tampered saved state could restore an interrupt state that the device can never reach and leave the interrupt line out of sync with it. Reject a saved state with an inconsistent status register C before any RTC state changes, and test that a failed restore leaves the RTC unchanged. Co-authored-by: Ruize Tang <1466040111@qq.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../src/cmos_rtc/save_restore/snapshot.rs | 11 +++++++ .../chipset/src/cmos_rtc/tests/snapshot.rs | 31 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs b/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs index 07bd8af071e..f53f5d2ce03 100644 --- a/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs +++ b/vm/devices/chipset/src/cmos_rtc/save_restore/snapshot.rs @@ -6,6 +6,8 @@ use super::state::SavedState; use crate::cmos_rtc::Rtc; +use crate::cmos_rtc::spec::CmosReg; +use crate::cmos_rtc::spec::StatusRegC; use local_clock::LocalClockTime; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; @@ -28,6 +30,15 @@ impl Fields { ))); } + let status_c = StatusRegC::from(state.cmos[CmosReg::STATUS_C.0 as usize]); + if status_c.irq_combined() + != (status_c.irq_update() || status_c.irq_periodic() || status_c.irq_alarm()) + { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "inconsistent RTC status C interrupt flags" + ))); + } + Ok(Self { time_valid, transaction_read_mask: state.transaction_read_mask, diff --git a/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs b/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs index 7e372e57dd2..ca59bd4cea2 100644 --- a/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs +++ b/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs @@ -12,6 +12,37 @@ use test_with_tracing::test; use vmcore::device_state::ChangeDeviceState; use vmcore::save_restore::SaveRestore; +#[test] +fn restore_rejects_inconsistent_status_c_before_mutating_state() { + use vmcore::save_restore::{ProtobufSaveRestore, SavedStateBlob}; + + for status_c in [0x10, 0x80] { + let (_pool, _keeper, _, mut rtc) = new_test_rtc(); + let before = SaveRestore::save(&mut rtc).unwrap(); + let mut invalid = SaveRestore::save(&mut rtc).unwrap(); + invalid.cmos[CmosReg::STATUS_C.0 as usize] = status_c; + + let error = + ProtobufSaveRestore::restore(&mut rtc, SavedStateBlob::new(invalid)).unwrap_err(); + match error { + vmcore::save_restore::RestoreError::InvalidSavedState(error) => { + assert_eq!( + error.to_string(), + "inconsistent RTC status C interrupt flags" + ) + } + error => panic!("unexpected restore error: {error}"), + } + + let after = SaveRestore::save(&mut rtc).unwrap(); + assert_eq!(after.addr, before.addr); + assert_eq!(after.cmos, before.cmos); + assert_eq!(after.clock_time_millis, before.clock_time_millis); + assert_eq!(after.transaction_read_mask, before.transaction_read_mask); + assert_eq!(after.time_valid, before.time_valid); + } +} + #[test] fn restore_preserves_guest_epoch_and_advances_downtime() { let (mut pool, _vm_time_keeper, clock, mut rtc) = new_test_rtc(); From 8f7e3f94f791a803a1bf903d753fb2d6fe8e8922 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:31:42 -0700 Subject: [PATCH 054/304] chipset: catch up the PIT after downtime The PIT evaluates its counters when the guest accesses them or when its armed VM-time wakeup fires. Restoring it does not arm a wakeup, so after a restore, and in particular after VM time was advanced by the time the VM spent stopped, timer 0 raised no interrupts until the guest next accessed the PIT. When the PIT starts, evaluate its counters at the current VM time and arm the wakeup for the next output transition of timer 0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/chipset/src/pit/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/vm/devices/chipset/src/pit/mod.rs b/vm/devices/chipset/src/pit/mod.rs index 7eefffd5f02..a8e8d7b378e 100644 --- a/vm/devices/chipset/src/pit/mod.rs +++ b/vm/devices/chipset/src/pit/mod.rs @@ -595,7 +595,10 @@ impl PitDevice { } impl ChangeDeviceState for PitDevice { - fn start(&mut self) {} + fn start(&mut self) { + self.evaluate(self.vmtime.now()); + self.arm_wakeup(); + } async fn stop(&mut self) {} From 9dc92d33441cb68e3368c2a4c165d067785e42c7 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:37:10 -0700 Subject: [PATCH 055/304] virt: define a canonical x86 CPU compatibility contract Restoring an x86 snapshot is only safe on a partition that presents the guest with the CPU that it was saved on: the same vendor and CPUID results, XSAVE layout, and processor features. There is no canonical representation of that effective CPU that a snapshot could record and a restore could compare. Add `CpuCompatibilityContract`, a protobuf description of the effective CPU: the vendor, every CPUID leaf with its mask, the supported XCR0 and XSS features, the standard and compacted XSAVE sizes and component layout, the x2APIC, CET, SGX, and TSC_AUX features, and the physical address width. `CpuCompatibilityContract::new` builds it from the partition capabilities and the effective CPUID leaves. Add `Partition::cpu_compatibility_contract` for x86 guests, and implement it in the KVM, MSHV, WHP, and OpenHCL partitions from their capabilities and CPUID leaf sets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openhcl/virt_mshv_vtl/src/lib.rs | 5 + vmm_core/virt/src/generic.rs | 4 + vmm_core/virt/src/x86/cpu_contract.rs | 112 +++++++++++++++++++++++ vmm_core/virt/src/x86/mod.rs | 2 + vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 4 + vmm_core/virt_mshv/src/x86_64/mod.rs | 4 + vmm_core/virt_whp/src/lib.rs | 5 + 7 files changed, 136 insertions(+) create mode 100644 vmm_core/virt/src/x86/cpu_contract.rs diff --git a/openhcl/virt_mshv_vtl/src/lib.rs b/openhcl/virt_mshv_vtl/src/lib.rs index 47c82cc9631..0617cae3537 100755 --- a/openhcl/virt_mshv_vtl/src/lib.rs +++ b/openhcl/virt_mshv_vtl/src/lib.rs @@ -923,6 +923,11 @@ impl virt::Partition for UhPartition { virt::InitialVpStateSource::Registers } + #[cfg(guest_arch = "x86_64")] + fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { + virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) + } + fn supports_reset(&self) -> Option<&dyn virt::ResetPartition> { None } diff --git a/vmm_core/virt/src/generic.rs b/vmm_core/virt/src/generic.rs index a6ffa457220..7b34b1ee548 100644 --- a/vmm_core/virt/src/generic.rs +++ b/vmm_core/virt/src/generic.rs @@ -531,6 +531,10 @@ pub trait Partition: 'static + Hv1 + Inspect + Send + Sync { Ok(()) } + /// Returns the effective x86 CPU compatibility contract. + #[cfg(guest_arch = "x86_64")] + fn cpu_compatibility_contract(&self) -> crate::x86::CpuCompatibilityContract; + /// Returns a trait object for initial page imports during the initial start /// flow. fn supports_initial_page_acceptance( diff --git a/vmm_core/virt/src/x86/cpu_contract.rs b/vmm_core/virt/src/x86/cpu_contract.rs new file mode 100644 index 00000000000..cb6e17cbab4 --- /dev/null +++ b/vmm_core/virt/src/x86/cpu_contract.rs @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The effective x86 CPU compatibility contract that a snapshot restore +//! destination must reproduce exactly. + +use super::X86PartitionCapabilities; + +/// Canonical CPUID leaf in a saved CPU compatibility contract. +#[derive(Debug, Clone, PartialEq, Eq, mesh_protobuf::Protobuf)] +#[mesh(package = "virt.x86")] +pub struct CpuContractCpuidLeaf { + #[mesh(1)] + pub function: u32, + #[mesh(2)] + pub index: Option, + #[mesh(3)] + pub result: [u32; 4], + #[mesh(4)] + pub mask: [u32; 4], +} + +/// XSAVE component layout in a saved CPU compatibility contract. +#[derive(Debug, Clone, PartialEq, Eq, mesh_protobuf::Protobuf)] +#[mesh(package = "virt.x86")] +pub struct CpuContractXsaveComponent { + #[mesh(1)] + pub index: u32, + #[mesh(2)] + pub offset: u32, + #[mesh(3)] + pub length: u32, + #[mesh(4)] + pub align: bool, +} + +/// Effective CPU contract that a destination must reproduce exactly. +#[derive(Debug, Clone, PartialEq, Eq, mesh_protobuf::Protobuf)] +#[mesh(package = "virt.x86")] +pub struct CpuCompatibilityContract { + #[mesh(1)] + pub vendor: [u8; 12], + #[mesh(2)] + pub cpuid: Vec, + #[mesh(3)] + pub xcr0_supported: u64, + #[mesh(4)] + pub xss_supported: u64, + #[mesh(5)] + pub xsave_standard_len: u32, + #[mesh(6)] + pub xsave_compact_len: u32, + #[mesh(7)] + pub xsave_components: Vec, + #[mesh(8)] + pub x2apic: bool, + #[mesh(9)] + pub x2apic_enabled: bool, + #[mesh(10)] + pub cet: bool, + #[mesh(11)] + pub cet_ss: bool, + #[mesh(12)] + pub sgx: bool, + #[mesh(13)] + pub tsc_aux: bool, + #[mesh(14)] + pub physical_address_width: u32, +} + +impl CpuCompatibilityContract { + /// Builds a canonical contract from effective partition state. + pub fn new(caps: &X86PartitionCapabilities, cpuid: &crate::CpuidLeafSet) -> Self { + Self { + vendor: caps.vendor.0, + cpuid: cpuid + .leaves() + .iter() + .map(|leaf| CpuContractCpuidLeaf { + function: leaf.function, + index: leaf.index, + result: leaf.result, + mask: leaf.mask, + }) + .collect(), + xcr0_supported: caps.xsave.features, + xss_supported: caps.xsave.supervisor_features, + xsave_standard_len: caps.xsave.standard_len, + xsave_compact_len: caps.xsave.compact_len, + xsave_components: caps + .xsave + .feature_info + .iter() + .enumerate() + .filter(|(_, feature)| feature.len != 0) + .map(|(index, feature)| CpuContractXsaveComponent { + index: index as u32, + offset: feature.offset, + length: feature.len, + align: feature.align, + }) + .collect(), + x2apic: caps.x2apic, + x2apic_enabled: caps.x2apic_enabled, + cet: caps.cet, + cet_ss: caps.cet_ss, + sgx: caps.sgx, + tsc_aux: caps.tsc_aux, + physical_address_width: u32::from(caps.physical_address_width), + } + } +} diff --git a/vmm_core/virt/src/x86/mod.rs b/vmm_core/virt/src/x86/mod.rs index e476b15d819..90d05b76c00 100644 --- a/vmm_core/virt/src/x86/mod.rs +++ b/vmm_core/virt/src/x86/mod.rs @@ -4,12 +4,14 @@ //! x86-specific state. pub mod apic_software_device; +pub mod cpu_contract; pub mod snp; pub mod topology; pub mod vm; pub mod vp; use crate::state::StateElement; +pub use cpu_contract::CpuCompatibilityContract; use inspect::Inspect; use mesh_protobuf::Protobuf; use std::fmt::Debug; diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index d53399b15af..a9372f7f21e 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -768,6 +768,10 @@ impl Partition for KvmPartition { virt::InitialVpStateSource::Registers } + fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { + virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) + } + fn supports_reset(&self) -> Option<&dyn ResetPartition> { // TODO: Support resetting SNP launch state and rebuilding the protected // guest before advertising reset support. diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 65f5bd510e7..cb688d102cd 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -584,6 +584,10 @@ impl virt::Partition for MshvPartition { self.inner.finalize_memory() } + fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { + virt::x86::CpuCompatibilityContract::new(self.inner.caps(), &self.inner.config.cpuid) + } + fn supports_initial_page_acceptance( &self, ) -> Option<&dyn virt::AcceptInitialPages> { diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index f662d15d096..546e3115b1a 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -609,6 +609,11 @@ impl virt::Partition for WhpPartition { virt::InitialVpStateSource::Registers } + #[cfg(guest_arch = "x86_64")] + fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { + virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) + } + fn supports_reset(&self) -> Option<&dyn virt::ResetPartition> { if whp::capabilities::reset_partition() { Some(self) From 14b985ea5f64c9823ac04eb15f7420ca447ffcd8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:43:51 -0700 Subject: [PATCH 056/304] virt: add portable TSC-deadline VP state and APIC-timer advance The saved state of a VP did not include IA32_TSC_DEADLINE, so a guest that arms its local APIC timer in TSC-deadline mode loses the pending deadline across a save and restore. Nothing could move a saved APIC timer forward by the time that the VM spent stopped either. Add a `TscDeadline` VP state element, which is present when the partition supports TSC-deadline mode, and record that support, as reported by CPUID, in the partition capabilities and the CPU compatibility contract. The deadline is not compared after restore, since it can expire between restoring it and reading it back. KVM, WHP, and the hypervisor-backed OpenHCL VPs access the IA32_TSC_DEADLINE register; the OpenHCL SNP and TDX VPs do not implement it. MSHV keeps reporting no TSC-deadline support, since it does not reliably deliver TSC-deadline events to direct-boot guests. Add `Apic::advance_timer`, which advances a one-shot or periodic LAPIC timer by a downtime at a given timer frequency and queues the timer interrupt if the timer expired, and `Apic::advance_tsc_deadline`, which queues the interrupt of an armed TSC deadline that an advanced TSC passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + .../virt_mshv_vtl/src/processor/mshv/x64.rs | 8 + .../virt_mshv_vtl/src/processor/snp/mod.rs | 8 + .../virt_mshv_vtl/src/processor/tdx/mod.rs | 8 + vm/x86/x86defs/src/lib.rs | 1 + vmm_core/virt/Cargo.toml | 1 + vmm_core/virt/src/x86/cpu_contract.rs | 3 + vmm_core/virt/src/x86/mod.rs | 4 + vmm_core/virt/src/x86/vp.rs | 4 + vmm_core/virt/src/x86/vp/snapshot.rs | 291 ++++++++++++++++++ vmm_core/virt_kvm/src/arch/x86_64/regs.rs | 1 + vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs | 8 + vmm_core/virt_mshv/src/x86_64/finalize.rs | 1 + vmm_core/virt_mshv/src/x86_64/mod.rs | 3 + vmm_core/virt_mshv/src/x86_64/vp_state.rs | 8 + vmm_core/virt_whp/src/regs.rs | 1 + vmm_core/virt_whp/src/vp_state.rs | 8 + 17 files changed, 359 insertions(+) create mode 100644 vmm_core/virt/src/x86/vp/snapshot.rs diff --git a/Cargo.lock b/Cargo.lock index bae6f6dd705..f0a873fed9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9738,6 +9738,7 @@ dependencies = [ "parking_lot", "pci_core", "slab", + "test_with_tracing", "thiserror 2.0.16", "tracelimit", "tracing", diff --git a/openhcl/virt_mshv_vtl/src/processor/mshv/x64.rs b/openhcl/virt_mshv_vtl/src/processor/mshv/x64.rs index e083fd78a58..aa48cbc5f9b 100644 --- a/openhcl/virt_mshv_vtl/src/processor/mshv/x64.rs +++ b/openhcl/virt_mshv_vtl/src/processor/mshv/x64.rs @@ -1634,6 +1634,14 @@ impl AccessVpState for UhVpStateAccess<'_, '_, HypervisorBackedX86> { self.set_register_state(value) } + fn tsc_deadline(&mut self) -> Result { + self.get_register_state() + } + + fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { + self.set_register_state(value) + } + fn cet(&mut self) -> Result { self.get_register_state() } diff --git a/openhcl/virt_mshv_vtl/src/processor/snp/mod.rs b/openhcl/virt_mshv_vtl/src/processor/snp/mod.rs index e7b86a3ebdc..7b3c12a1dce 100755 --- a/openhcl/virt_mshv_vtl/src/processor/snp/mod.rs +++ b/openhcl/virt_mshv_vtl/src/processor/snp/mod.rs @@ -2927,6 +2927,14 @@ impl AccessVpState for UhVpStateAccess<'_, '_, SnpBacked> { Err(vp_state::Error::Unimplemented("tsc")) } + fn tsc_deadline(&mut self) -> Result { + Err(vp_state::Error::Unimplemented("tsc_deadline")) + } + + fn set_tsc_deadline(&mut self, _value: &vp::TscDeadline) -> Result<(), Self::Error> { + Err(vp_state::Error::Unimplemented("tsc_deadline")) + } + fn tsc_aux(&mut self) -> Result { let vmsa = self.vp.runner.vmsa(self.vtl); Ok(vp::TscAux { diff --git a/openhcl/virt_mshv_vtl/src/processor/tdx/mod.rs b/openhcl/virt_mshv_vtl/src/processor/tdx/mod.rs index ff271123714..0584040c5ab 100644 --- a/openhcl/virt_mshv_vtl/src/processor/tdx/mod.rs +++ b/openhcl/virt_mshv_vtl/src/processor/tdx/mod.rs @@ -4192,6 +4192,14 @@ impl AccessVpState for UhVpStateAccess<'_, '_, TdxBacked> { Err(vp_state::Error::Unimplemented("tsc")) } + fn tsc_deadline(&mut self) -> Result { + Err(vp_state::Error::Unimplemented("tsc_deadline")) + } + + fn set_tsc_deadline(&mut self, _value: &vp::TscDeadline) -> Result<(), Self::Error> { + Err(vp_state::Error::Unimplemented("tsc_deadline")) + } + fn tsc_aux(&mut self) -> Result { Ok(vp::TscAux { value: self.vp.backing.vtls[self.vtl].private_regs.msr_tsc_aux, diff --git a/vm/x86/x86defs/src/lib.rs b/vm/x86/x86defs/src/lib.rs index 5f8b79383c3..0edd5d61cc0 100644 --- a/vm/x86/x86defs/src/lib.rs +++ b/vm/x86/x86defs/src/lib.rs @@ -209,6 +209,7 @@ pub struct MiscEnable { } pub const X86X_MSR_TSC: u32 = 0x10; +pub const X86X_MSR_TSC_DEADLINE: u32 = 0x6e0; pub const X86X_IA32_MSR_PLATFORM_ID: u32 = 0x17; pub const X86X_MSR_APIC_BASE: u32 = 0x1b; pub const X86X_MSR_EBL_CR_POWERON: u32 = 0x2a; diff --git a/vmm_core/virt/Cargo.toml b/vmm_core/virt/Cargo.toml index d63a25a3096..7368544fb6d 100644 --- a/vmm_core/virt/Cargo.toml +++ b/vmm_core/virt/Cargo.toml @@ -28,6 +28,7 @@ thiserror.workspace = true tracing.workspace = true zerocopy.workspace = true [dev-dependencies] +test_with_tracing.workspace = true [build-dependencies] build_rs_guest_arch.workspace = true diff --git a/vmm_core/virt/src/x86/cpu_contract.rs b/vmm_core/virt/src/x86/cpu_contract.rs index cb6e17cbab4..bccbe4c314c 100644 --- a/vmm_core/virt/src/x86/cpu_contract.rs +++ b/vmm_core/virt/src/x86/cpu_contract.rs @@ -66,6 +66,8 @@ pub struct CpuCompatibilityContract { pub tsc_aux: bool, #[mesh(14)] pub physical_address_width: u32, + #[mesh(15)] + pub tsc_deadline: bool, } impl CpuCompatibilityContract { @@ -107,6 +109,7 @@ impl CpuCompatibilityContract { sgx: caps.sgx, tsc_aux: caps.tsc_aux, physical_address_width: u32::from(caps.physical_address_width), + tsc_deadline: caps.tsc_deadline, } } } diff --git a/vmm_core/virt/src/x86/mod.rs b/vmm_core/virt/src/x86/mod.rs index 90d05b76c00..268c81bd249 100644 --- a/vmm_core/virt/src/x86/mod.rs +++ b/vmm_core/virt/src/x86/mod.rs @@ -76,6 +76,8 @@ pub struct X86PartitionCapabilities { pub sgx: bool, /// TSC_AUX is supported pub tsc_aux: bool, + /// IA32_TSC_DEADLINE is supported. + pub tsc_deadline: bool, /// The address of the virtual top of memory, for encrypted VMs. /// /// This is computed from the Hyper-V isolation leaf. It is guaranteed to be @@ -135,6 +137,7 @@ impl X86PartitionCapabilities { cet_ss: false, sgx: false, tsc_aux: false, + tsc_deadline: false, vtom: None, physical_address_width: max_physical_address_size_from_cpuid(&mut *f), snp_c_bit: snp_c_bit_from_cpuid(&mut *f), @@ -158,6 +161,7 @@ impl X86PartitionCapabilities { this.reset_rdx = result[0].into(); let features = result[2] as u64 | ((result[3] as u64) << 32); this.x2apic = features & (1 << 21) != 0; + this.tsc_deadline = features & (1 << 24) != 0; xsave = features & (1 << 26) != 0; hypervisor = features & (1 << 31) != 0; } diff --git a/vmm_core/virt/src/x86/vp.rs b/vmm_core/virt/src/x86/vp.rs index be25442b921..13f2c6b7ffa 100644 --- a/vmm_core/virt/src/x86/vp.rs +++ b/vmm_core/virt/src/x86/vp.rs @@ -3,6 +3,8 @@ //! Per-VP state. +mod snapshot; + use super::SegmentRegister; use super::TableRegister; use super::X86PartitionCapabilities; @@ -24,6 +26,7 @@ use hvdef::HvX64SegmentRegister; use hvdef::HvX64TableRegister; use inspect::Inspect; use mesh_protobuf::Protobuf; +pub use snapshot::TscDeadline; use std::fmt::Debug; use vm_topology::processor::x86::X86VpInfo; use x86defs::RFlags; @@ -1962,6 +1965,7 @@ state_trait! { (12, "cet", cet, set_cet, Cet), (13, "cet_ss", cet_ss, set_cet_ss, CetSs), (14, "tsc_aux", tsc_aux, set_tsc_aux, TscAux), + (15, "tsc_deadline", tsc_deadline, set_tsc_deadline, TscDeadline), // Synic state (100, "synic", synic_msrs, set_synic_msrs, SyntheticMsrs), diff --git a/vmm_core/virt/src/x86/vp/snapshot.rs b/vmm_core/virt/src/x86/vp/snapshot.rs new file mode 100644 index 00000000000..3724841cfdb --- /dev/null +++ b/vmm_core/virt/src/x86/vp/snapshot.rs @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! VP state for snapshot restore: the IA32_TSC_DEADLINE state element and +//! LAPIC timer advancement across snapshot downtime. + +use super::Apic; +use super::ApicRegisters; +use crate::state::HvRegisterState; +use crate::state::StateElement; +use crate::x86::X86PartitionCapabilities; +use anyhow::Context as _; +use hvdef::HvRegisterValue; +use hvdef::HvX64RegisterName; +use inspect::Inspect; +use mesh_protobuf::Protobuf; +use vm_topology::processor::x86::X86VpInfo; + +impl Apic { + fn queue_timer_interrupt(&mut self, registers: &mut ApicRegisters) { + if registers.lvt_timer & (1 << 16) != 0 { + return; + } + let vector = (registers.lvt_timer & 0xff) as usize; + if vector < 16 { + return; + } + let bank = vector / 32; + let mask = 1 << (vector % 32); + registers.irr[bank] |= mask; + registers.tmr[bank] &= !mask; + self.auto_eoi[bank] &= !mask; + } + + /// Advances an active TSC deadline and returns the restored deadline value. + pub fn advance_tsc_deadline( + &mut self, + previous_tsc: u64, + advanced_tsc: u64, + deadline: u64, + ) -> u64 { + let mut registers = ApicRegisters::from_array(self.registers); + let timer_mode = (registers.lvt_timer >> 17) & 0x3; + let deadline_distance = deadline.wrapping_sub(previous_tsc) as i64; + let elapsed_ticks = advanced_tsc - previous_tsc; + if deadline != 0 + && timer_mode == 2 + && (deadline_distance <= 0 || deadline_distance as u64 <= elapsed_ticks) + { + self.queue_timer_interrupt(&mut registers); + self.registers = *registers.as_array(); + 0 + } else { + deadline + } + } + + /// Advances the LAPIC timer by host downtime using the interrupt clock. + pub fn advance_timer( + &mut self, + duration: std::time::Duration, + frequency_hz: u64, + ) -> anyhow::Result<()> { + let registers = ApicRegisters::from_array(self.registers); + let mut registers = registers; + if registers.timer_ccr == 0 { + return Ok(()); + } + + let timer_mode = (registers.lvt_timer >> 17) & 0x3; + if timer_mode == 2 { + // TSC-deadline mode is handled through IA32_TSC_DEADLINE. + return Ok(()); + } + let divider_shift = match registers.timer_dcr & 0xb { + 0xb => 0, + 0 => 1, + 1 => 2, + 2 => 3, + 3 => 4, + 8 => 5, + 9 => 6, + 0xa => 7, + _ => unreachable!(), + }; + let elapsed_ticks = duration + .as_nanos() + .checked_mul(u128::from(frequency_hz)) + .context("LAPIC timer downtime adjustment overflows")? + / 1_000_000_000 + / (1_u128 << divider_shift); + + if elapsed_ticks >= u128::from(registers.timer_ccr) { + self.queue_timer_interrupt(&mut registers); + if timer_mode == 1 && registers.timer_icr != 0 { + let remaining = elapsed_ticks - u128::from(registers.timer_ccr); + let period = u128::from(registers.timer_icr); + registers.timer_ccr = (period - remaining % period) as u32; + } else { + registers.timer_ccr = 0; + } + } else { + registers.timer_ccr -= elapsed_ticks as u32; + } + self.registers = *registers.as_array(); + Ok(()) + } +} + +/// IA32_TSC_DEADLINE state. +#[repr(C)] +#[derive(Default, Debug, PartialEq, Eq, Protobuf, Inspect)] +#[mesh(package = "virt.x86")] +pub struct TscDeadline { + #[mesh(1)] + #[inspect(hex)] + pub value: u64, +} + +impl HvRegisterState for TscDeadline { + fn names(&self) -> &'static [HvX64RegisterName; 1] { + &[HvX64RegisterName::TscDeadline] + } + + fn get_values<'a>(&self, it: impl Iterator) { + for (dest, src) in it.zip([self.value]) { + *dest = src.into(); + } + } + + fn set_values(&mut self, it: impl Iterator) { + for (src, dest) in it.zip([&mut self.value]) { + *dest = src.as_u64(); + } + } +} + +impl StateElement for TscDeadline { + fn is_present(caps: &X86PartitionCapabilities) -> bool { + caps.tsc_deadline + } + + fn at_reset(_caps: &X86PartitionCapabilities, _vp_info: &X86VpInfo) -> Self { + Self { value: 0 } + } + + fn can_compare(_caps: &X86PartitionCapabilities) -> bool { + // A deadline can expire between restoring it and reading it back. + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use test_with_tracing::test; + use zerocopy::FromZeros; + + const TIMER_VECTOR: u32 = 0x40; + const TIMER_MASKED: u32 = 1 << 16; + const TIMER_PERIODIC: u32 = 1 << 17; + const TIMER_TSC_DEADLINE: u32 = 2 << 17; + + fn apic(lvt_timer: u32, initial_count: u32, current_count: u32) -> Apic { + let registers = ApicRegisters { + lvt_timer, + timer_icr: initial_count, + timer_ccr: current_count, + timer_dcr: 0xb, + ..FromZeros::new_zeroed() + }; + Apic { + apic_base: 0, + registers: *registers.as_array(), + auto_eoi: [0; 8], + } + } + + fn timer_pending(apic: &Apic) -> bool { + let registers = apic.registers(); + registers.irr[TIMER_VECTOR as usize / 32] & (1 << (TIMER_VECTOR as usize % 32)) != 0 + } + + #[test] + fn snapshot_downtime_expires_one_shot_once() { + let mut apic = apic(TIMER_VECTOR, 100, 50); + + apic.advance_timer(Duration::from_nanos(50), 1_000_000_000) + .unwrap(); + + assert_eq!(apic.registers().timer_ccr, 0); + assert!(timer_pending(&apic)); + + apic.advance_timer(Duration::from_secs(1), 1_000_000_000) + .unwrap(); + assert!(timer_pending(&apic)); + } + + #[test] + fn snapshot_downtime_does_not_queue_masked_one_shot() { + let mut apic = apic(TIMER_VECTOR | TIMER_MASKED, 100, 50); + + apic.advance_timer(Duration::from_nanos(50), 1_000_000_000) + .unwrap(); + + assert_eq!(apic.registers().timer_ccr, 0); + assert!(!timer_pending(&apic)); + } + + #[test] + fn snapshot_downtime_coalesces_periods_and_preserves_phase() { + let mut apic = apic(TIMER_VECTOR | TIMER_PERIODIC, 100, 25); + + apic.advance_timer(Duration::from_nanos(250), 1_000_000_000) + .unwrap(); + + assert_eq!(apic.registers().timer_ccr, 75); + assert!(timer_pending(&apic)); + } + + #[test] + fn snapshot_downtime_preserves_near_expiry_timer() { + let mut apic = apic(TIMER_VECTOR, 100, 50); + + apic.advance_timer(Duration::from_nanos(49), 1_000_000_000) + .unwrap(); + + assert_eq!(apic.registers().timer_ccr, 1); + assert!(!timer_pending(&apic)); + } + + #[test] + fn snapshot_downtime_rejects_lapic_tick_overflow() { + let mut apic = apic(TIMER_VECTOR, 100, 50); + + let error = apic.advance_timer(Duration::MAX, u64::MAX).unwrap_err(); + + assert!(error.to_string().contains("overflows")); + } + + #[test] + fn snapshot_downtime_expires_tsc_deadline_once() { + let mut apic = apic(TIMER_VECTOR | TIMER_TSC_DEADLINE, 0, 0); + + let deadline = apic.advance_tsc_deadline(1_000, 2_000, 2_000); + + assert_eq!(deadline, 0); + assert!(timer_pending(&apic)); + } + + #[test] + fn snapshot_downtime_expires_already_overdue_tsc_deadline() { + let mut apic = apic(TIMER_VECTOR | TIMER_TSC_DEADLINE, 0, 0); + + let deadline = apic.advance_tsc_deadline(1_000, 2_000, 500); + + assert_eq!(deadline, 0); + assert!(timer_pending(&apic)); + } + + #[test] + fn snapshot_downtime_preserves_wrapped_future_tsc_deadline() { + let mut apic = apic(TIMER_VECTOR | TIMER_TSC_DEADLINE, 0, 0); + + let deadline = apic.advance_tsc_deadline(u64::MAX - 1_000, u64::MAX - 900, 500); + + assert_eq!(deadline, 500); + assert!(!timer_pending(&apic)); + } + + #[test] + fn snapshot_downtime_drops_masked_tsc_deadline() { + let mut apic = apic(TIMER_VECTOR | TIMER_TSC_DEADLINE | TIMER_MASKED, 0, 0); + + let deadline = apic.advance_tsc_deadline(1_000, 2_000, 2_000); + + assert_eq!(deadline, 0); + assert!(!timer_pending(&apic)); + } + + #[test] + fn snapshot_downtime_ignores_deadline_outside_deadline_mode() { + let mut apic = apic(TIMER_VECTOR, 0, 0); + + let deadline = apic.advance_tsc_deadline(1_000, 2_000, 1_500); + + assert_eq!(deadline, 1_500); + assert!(!timer_pending(&apic)); + } +} diff --git a/vmm_core/virt_kvm/src/arch/x86_64/regs.rs b/vmm_core/virt_kvm/src/arch/x86_64/regs.rs index e4138dd0248..b5bbfa48fec 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/regs.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/regs.rs @@ -52,6 +52,7 @@ pub const fn register_to_msr(name: HvX64RegisterName) -> Result x86defs::X86X_MSR_MTRR_PHYSBASE0 + 15, HvX64RegisterName::Tsc => x86defs::X86X_MSR_TSC, + HvX64RegisterName::TscDeadline => x86defs::X86X_MSR_TSC_DEADLINE, HvX64RegisterName::TscAux => x86defs::X86X_MSR_TSC_AUX, HvX64RegisterName::Xss => x86defs::X86X_MSR_XSS, diff --git a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs index 019327b328d..7fe4c3f538f 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs @@ -527,6 +527,14 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { self.set_register_state(tsc) } + fn tsc_deadline(&mut self) -> Result { + self.get_register_state() + } + + fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { + self.set_register_state(value) + } + fn cet(&mut self) -> Result { self.get_register_state() } diff --git a/vmm_core/virt_mshv/src/x86_64/finalize.rs b/vmm_core/virt_mshv/src/x86_64/finalize.rs index 2d186611a53..1b8b59f490a 100644 --- a/vmm_core/virt_mshv/src/x86_64/finalize.rs +++ b/vmm_core/virt_mshv/src/x86_64/finalize.rs @@ -153,6 +153,7 @@ impl MshvPartitionInner { self.isolation_type(), caps.hv1_reference_tsc_page, ); + caps.tsc_deadline = false; caps.xsaves_state_bv_broken = true; caps.can_freeze_time = true; Ok(caps) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index cb688d102cd..0b588fd137b 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -411,6 +411,9 @@ impl MshvPartitionInner { cet_ss: false, sgx: false, tsc_aux: false, + // MSHV does not reliably deliver TSC deadline events to direct-boot + // guests. Fall back to the LAPIC count-mode clockevent. + tsc_deadline: false, vtom: None, physical_address_width: self .vmfd diff --git a/vmm_core/virt_mshv/src/x86_64/vp_state.rs b/vmm_core/virt_mshv/src/x86_64/vp_state.rs index 90d44bc737c..027f01206fe 100644 --- a/vmm_core/virt_mshv/src/x86_64/vp_state.rs +++ b/vmm_core/virt_mshv/src/x86_64/vp_state.rs @@ -330,6 +330,14 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { self.set_register_state(value) } + fn tsc_deadline(&mut self) -> Result { + self.get_register_state() + } + + fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { + self.set_register_state(value) + } + fn cet(&mut self) -> Result { self.get_register_state() } diff --git a/vmm_core/virt_whp/src/regs.rs b/vmm_core/virt_whp/src/regs.rs index 957782f4812..9e95e713282 100644 --- a/vmm_core/virt_whp/src/regs.rs +++ b/vmm_core/virt_whp/src/regs.rs @@ -75,6 +75,7 @@ mod x86_64 { HvX64RegisterName::Cr8 => whp::abi::WHvX64RegisterCr8, HvX64RegisterName::Xfem => whp::abi::WHvX64RegisterXCr0, HvX64RegisterName::Tsc => whp::abi::WHvX64RegisterTsc, + HvX64RegisterName::TscDeadline => whp::abi::WHvX64RegisterTscDeadline, HvX64RegisterName::Efer => whp::abi::WHvX64RegisterEfer, HvX64RegisterName::ApicBase => whp::abi::WHvX64RegisterApicBase, diff --git a/vmm_core/virt_whp/src/vp_state.rs b/vmm_core/virt_whp/src/vp_state.rs index 418747d7abe..fb143214f53 100644 --- a/vmm_core/virt_whp/src/vp_state.rs +++ b/vmm_core/virt_whp/src/vp_state.rs @@ -211,6 +211,14 @@ mod x86 { self.run.vp.set_register_state(self.vtl, value) } + fn tsc_deadline(&mut self) -> Result { + self.run.vp.get_register_state(self.vtl) + } + + fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { + self.run.vp.set_register_state(self.vtl, value) + } + fn synic_msrs(&mut self) -> Result { match self.run.state.vtls[self.vtl].hv { Some(_) => { From d7e676ecec2597181ba19788349c5f5011be0af1 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:47:40 -0700 Subject: [PATCH 057/304] virt: add exact TSC-frequency helpers and snapshot clock hooks Restoring a snapshot, possibly on another host, requires the guest TSC to keep its saved frequency and to account for the time that the VM spent stopped. Partitions cannot report or request a TSC frequency, though, and processors cannot move their TSC forward. Add defaulted `Partition` hooks that return the effective guest TSC and local APIC timer frequencies, request a TSC frequency, and advance any backend-specific guest clock by a snapshot downtime, and a defaulted `Processor::advance_tsc`, which advances the stopped VTL0 TSC by a number of guest cycles through the VP state access. The defaults report no frequency and ignore frequency requests, so that backends opt in individually. Add `tsc_frequency_cpuid_leaves`, which builds a CPUID leaf 0x15 that exposes an exact TSC frequency, including frequencies that do not fit the 32-bit crystal frequency, without overflowing the frequency computation of the Linux kernel. Detect KVM's paravirtual clock from the hypervisor CPUID leaves, and record it in the partition capabilities and the CPU compatibility contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vmm_core/virt/src/generic.rs | 28 ++++++ vmm_core/virt/src/x86/cpu_contract.rs | 3 + vmm_core/virt/src/x86/mod.rs | 5 + vmm_core/virt/src/x86/tsc.rs | 138 ++++++++++++++++++++++++++ vmm_core/virt_mshv/src/x86_64/mod.rs | 1 + 5 files changed, 175 insertions(+) create mode 100644 vmm_core/virt/src/x86/tsc.rs diff --git a/vmm_core/virt/src/generic.rs b/vmm_core/virt/src/generic.rs index 7b34b1ee548..ee24f6444ad 100644 --- a/vmm_core/virt/src/generic.rs +++ b/vmm_core/virt/src/generic.rs @@ -535,6 +535,26 @@ pub trait Partition: 'static + Hv1 + Inspect + Send + Sync { #[cfg(guest_arch = "x86_64")] fn cpu_compatibility_contract(&self) -> crate::x86::CpuCompatibilityContract; + /// Returns the effective guest TSC frequency when applicable. + fn tsc_frequency_hz(&self) -> Result, Self::Error> { + Ok(None) + } + + /// Requests the effective guest TSC frequency when supported. + fn set_tsc_frequency_hz(&self, _frequency_hz: u64) -> Result<(), Self::Error> { + Ok(()) + } + + /// Advances any backend-specific guest clock by snapshot downtime. + fn advance_snapshot_time(&self, _duration: std::time::Duration) -> Result<(), Self::Error> { + Ok(()) + } + + /// Returns the effective LAPIC interrupt clock frequency when available. + fn apic_frequency_hz(&self) -> Result, Self::Error> { + Ok(None) + } + /// Returns a trait object for initial page imports during the initial start /// flow. fn supports_initial_page_acceptance( @@ -762,6 +782,14 @@ pub trait Processor: InspectMut { } fn access_state(&mut self, vtl: Vtl) -> Self::StateAccess<'_>; + + /// Advances the stopped VTL0 TSC by the specified number of guest cycles. + /// + /// Backends may override this to adjust a running counter's offset directly. + #[cfg(guest_arch = "x86_64")] + fn advance_tsc(&mut self, cycles: u64) -> anyhow::Result<()> { + crate::x86::tsc::advance_tsc(self, cycles) + } } /// A source for [`StopVp`]. diff --git a/vmm_core/virt/src/x86/cpu_contract.rs b/vmm_core/virt/src/x86/cpu_contract.rs index bccbe4c314c..16960326324 100644 --- a/vmm_core/virt/src/x86/cpu_contract.rs +++ b/vmm_core/virt/src/x86/cpu_contract.rs @@ -68,6 +68,8 @@ pub struct CpuCompatibilityContract { pub physical_address_width: u32, #[mesh(15)] pub tsc_deadline: bool, + #[mesh(16)] + pub kvm_clock: bool, } impl CpuCompatibilityContract { @@ -110,6 +112,7 @@ impl CpuCompatibilityContract { tsc_aux: caps.tsc_aux, physical_address_width: u32::from(caps.physical_address_width), tsc_deadline: caps.tsc_deadline, + kvm_clock: caps.kvm_clock, } } } diff --git a/vmm_core/virt/src/x86/mod.rs b/vmm_core/virt/src/x86/mod.rs index 268c81bd249..1f1152c2223 100644 --- a/vmm_core/virt/src/x86/mod.rs +++ b/vmm_core/virt/src/x86/mod.rs @@ -7,6 +7,7 @@ pub mod apic_software_device; pub mod cpu_contract; pub mod snp; pub mod topology; +pub mod tsc; pub mod vm; pub mod vp; @@ -78,6 +79,8 @@ pub struct X86PartitionCapabilities { pub tsc_aux: bool, /// IA32_TSC_DEADLINE is supported. pub tsc_deadline: bool, + /// KVM's paravirtual clock and its configuration MSRs are exposed. + pub kvm_clock: bool, /// The address of the virtual top of memory, for encrypted VMs. /// /// This is computed from the Hyper-V isolation leaf. It is guaranteed to be @@ -138,6 +141,7 @@ impl X86PartitionCapabilities { sgx: false, tsc_aux: false, tsc_deadline: false, + kvm_clock: false, vtom: None, physical_address_width: max_physical_address_size_from_cpuid(&mut *f), snp_c_bit: snp_c_bit_from_cpuid(&mut *f), @@ -223,6 +227,7 @@ impl X86PartitionCapabilities { // Hypervisor info. if hypervisor { + this.kvm_clock = tsc::kvm_clock_from_cpuid(&mut *f); let hv_max = f(hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION, 0)[0]; if hv_max >= hvdef::HV_CPUID_FUNCTION_MS_HV_ENLIGHTENMENT_INFORMATION && f(hvdef::HV_CPUID_FUNCTION_HV_INTERFACE, 0)[0] == u32::from_le_bytes(*b"Hv#1") diff --git a/vmm_core/virt/src/x86/tsc.rs b/vmm_core/virt/src/x86/tsc.rs new file mode 100644 index 00000000000..e903cbfe554 --- /dev/null +++ b/vmm_core/virt/src/x86/tsc.rs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TSC and paravirtual clock support: CPUID leaves that expose an exact TSC +//! frequency, KVM clock detection, and the default snapshot-downtime TSC +//! adjustment. + +use thiserror::Error; +use x86defs::cpuid::CpuidFunction; + +/// The backend TSC frequency cannot be represented by CPUID leaf `0x15`. +#[derive(Debug, Error)] +pub enum TscFrequencyCpuidError { + #[error("TSC frequency must be nonzero")] + Zero, + #[error("TSC frequency {0} Hz cannot be represented by CPUID leaf 0x15")] + Unrepresentable(u64), +} + +/// Returns CPUID overrides that expose an exact virtual TSC frequency. +pub fn tsc_frequency_cpuid_leaves( + frequency_hz: u64, + current_max_basic_leaf: u32, +) -> Result<[crate::CpuidLeaf; 2], TscFrequencyCpuidError> { + fn gcd(mut left: u64, mut right: u64) -> u64 { + while right != 0 { + (left, right) = (right, left % right); + } + left + } + + if frequency_hz == 0 { + return Err(TscFrequencyCpuidError::Zero); + } + const FALLBACK_CRYSTAL_FREQUENCY_HZ: u64 = 1_000_000_000; + let (denominator, numerator, crystal_frequency_hz) = + if let Ok(frequency_hz) = u32::try_from(frequency_hz) { + (1, 1, frequency_hz) + } else { + let divisor = gcd(frequency_hz, FALLBACK_CRYSTAL_FREQUENCY_HZ); + let numerator = u32::try_from(frequency_hz / divisor) + .map_err(|_| TscFrequencyCpuidError::Unrepresentable(frequency_hz))?; + let denominator = u32::try_from(FALLBACK_CRYSTAL_FREQUENCY_HZ / divisor) + .map_err(|_| TscFrequencyCpuidError::Unrepresentable(frequency_hz))?; + (denominator, numerator, FALLBACK_CRYSTAL_FREQUENCY_HZ as u32) + }; + + Ok([ + crate::CpuidLeaf::new( + CpuidFunction::VendorAndMaxFunction.0, + [ + current_max_basic_leaf.max(CpuidFunction::CoreCrystalClockInformation.0), + 0, + 0, + 0, + ], + ) + .masked([u32::MAX, 0, 0, 0]), + crate::CpuidLeaf::new( + CpuidFunction::CoreCrystalClockInformation.0, + [denominator, numerator, crystal_frequency_hz, 0], + ), + ]) +} + +/// Returns whether the hypervisor CPUID leaves report KVM with its +/// paravirtual clock (`KVM_FEATURE_CLOCKSOURCE2`). +pub(super) fn kvm_clock_from_cpuid(f: &mut dyn FnMut(u32, u32) -> [u32; 4]) -> bool { + let [_, ebx, ecx, edx] = f(hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION, 0); + let mut vendor = [0_u8; 12]; + vendor[0..4].copy_from_slice(&ebx.to_le_bytes()); + vendor[4..8].copy_from_slice(&ecx.to_le_bytes()); + vendor[8..12].copy_from_slice(&edx.to_le_bytes()); + if vendor.starts_with(b"KVMKVMKVM") { + const KVM_FEATURE_CLOCKSOURCE2: u32 = 1 << 3; + f(hvdef::HV_CPUID_FUNCTION_HV_INTERFACE, 0)[0] & KVM_FEATURE_CLOCKSOURCE2 != 0 + } else { + false + } +} + +/// Advances the stopped VTL0 TSC of `processor` by `cycles` guest cycles +/// through its state access interface. +/// +/// This is the default implementation of `Processor::advance_tsc`. +#[cfg(guest_arch = "x86_64")] +pub(crate) fn advance_tsc( + processor: &mut P, + cycles: u64, +) -> anyhow::Result<()> { + use crate::x86::vp::AccessVpState; + use anyhow::Context as _; + + let mut access = processor.access_state(hvdef::Vtl::Vtl0); + let mut tsc = access.tsc()?; + tsc.value = tsc + .value + .checked_add(cycles) + .context("TSC downtime adjustment exceeds the counter range")?; + access.set_tsc(&tsc)?; + access.commit()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::tsc_frequency_cpuid_leaves; + use test_with_tracing::test; + + #[test] + fn tsc_frequency_cpuid_avoids_kernel_overflow() { + let frequency_hz = 2_194_843_733; + let leaves = tsc_frequency_cpuid_leaves(frequency_hz, 0).unwrap(); + let [denominator, numerator, crystal_frequency_hz, _] = leaves[1].result; + + assert_eq!( + u64::from(crystal_frequency_hz) * u64::from(numerator) / u64::from(denominator), + frequency_hz + ); + assert!( + (crystal_frequency_hz / 1000) + .checked_mul(numerator) + .is_some() + ); + } + + #[test] + fn tsc_frequency_cpuid_supports_large_round_frequency() { + let frequency_hz = 5_000_000_000; + let leaves = tsc_frequency_cpuid_leaves(frequency_hz, 0).unwrap(); + let [denominator, numerator, crystal_frequency_hz, _] = leaves[1].result; + + assert_eq!( + u64::from(crystal_frequency_hz) * u64::from(numerator) / u64::from(denominator), + frequency_hz + ); + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 0b588fd137b..41c93abdd6a 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -414,6 +414,7 @@ impl MshvPartitionInner { // MSHV does not reliably deliver TSC deadline events to direct-boot // guests. Fall back to the LAPIC count-mode clockevent. tsc_deadline: false, + kvm_clock: false, vtom: None, physical_address_width: self .vmfd From fd20fd85b2fcc8c95f1b7ace3912fddc116e34e4 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:51:02 -0700 Subject: [PATCH 058/304] virt_kvm: save and restore exact TSC and kvm-clock state KVM partitions did not report their TSC frequency, so guests had to calibrate it, and a restored VM could not be given the TSC frequency that it was saved with. The kvm-clock MSRs of a guest that uses KVM's paravirtual clock were not saved either, so the guest lost its clock configuration across a save and restore. Expose the exact TSC frequency of the BSP in CPUID leaf 0x15, and implement the partition clock hooks: report the TSC frequency and the 1 GHz frequency of the in-kernel local APIC timer, set the TSC frequency of every vCPU, and advance the kvm clock by a snapshot downtime. Advance the TSC of a vCPU by adjusting its TSC offset, since KVM can discard a sub-second write to IA32_TSC as a synchronization attempt. Save and restore the kvm-clock wall-clock and system-time MSRs with the synthetic MSRs when the partition exposes the kvm clock, and make the synthetic MSR and reference time state present for such partitions even without Hyper-V enlightenments. Add the KVM ioctls that get and set the TSC frequency of a vCPU and its TSC offset device attribute. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/kvm/src/lib.rs | 68 ++++++++ vmm_core/virt/src/x86/vm.rs | 2 +- vmm_core/virt/src/x86/vp.rs | 13 +- vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 41 +++-- vmm_core/virt_kvm/src/arch/x86_64/tsc.rs | 151 ++++++++++++++++++ vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs | 8 +- .../src/arch/x86_64/vp_state/kvm_clock.rs | 55 +++++++ vmm_core/virt_kvm/src/lib.rs | 5 + 8 files changed, 330 insertions(+), 13 deletions(-) create mode 100644 vmm_core/virt_kvm/src/arch/x86_64/tsc.rs create mode 100644 vmm_core/virt_kvm/src/arch/x86_64/vp_state/kvm_clock.rs diff --git a/vm/kvm/src/lib.rs b/vm/kvm/src/lib.rs index 3941dd99348..1230dc5553e 100644 --- a/vm/kvm/src/lib.rs +++ b/vm/kvm/src/lib.rs @@ -97,6 +97,10 @@ mod ioctl { ioctl_read!(kvm_get_debugregs, KVMIO, 0xa1, kvm_debugregs); #[cfg(target_arch = "x86_64")] ioctl_write_ptr!(kvm_set_debugregs, KVMIO, 0xa2, kvm_debugregs); + #[cfg(target_arch = "x86_64")] + ioctl_write_int_bad!(kvm_set_tsc_khz, request_code_none!(KVMIO, 0xa2)); + #[cfg(target_arch = "x86_64")] + ioctl_write_int_bad!(kvm_get_tsc_khz, request_code_none!(KVMIO, 0xa3)); ioctl_write_ptr!(kvm_enable_cap, KVMIO, 0xa3, kvm_enable_cap); #[cfg(target_arch = "x86_64")] ioctl_read!(kvm_get_xsave, KVMIO, 0xa4, kvm_xsave); @@ -283,6 +287,18 @@ pub enum Error { SetRegs(#[source] nix::Error), #[error("SetSRegs")] SetSRegs(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetTscFrequency")] + GetTscFrequency(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("SetTscFrequency")] + SetTscFrequency(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetTscOffset")] + GetTscOffset(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("SetTscOffset")] + SetTscOffset(#[source] nix::Error), #[error("Run")] Run(#[source] nix::Error), #[error("RunMemoryFault(flags={flags:#x}, gpa={gpa:#x}, size={size:#x})")] @@ -1276,6 +1292,58 @@ pub enum RoutingEntry { pub struct Processor<'a>(&'a Partition, u32); impl<'a> Processor<'a> { + #[cfg(target_arch = "x86_64")] + pub fn tsc_frequency_hz(&self) -> Result { + // SAFETY: Calling the documented vCPU ioctl with no pointer argument. + let khz = unsafe { ioctl::kvm_get_tsc_khz(self.get().vcpu.as_raw_fd(), 0) } + .map_err(Error::GetTscFrequency)?; + if khz <= 0 { + return Err(Error::GetTscFrequency(nix::errno::Errno::EINVAL)); + } + Ok(khz as u64 * 1000) + } + + #[cfg(target_arch = "x86_64")] + pub fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<()> { + let khz = frequency_hz + .checked_div(1000) + .and_then(|khz| libc::c_int::try_from(khz).ok()) + .ok_or(Error::SetTscFrequency(nix::errno::Errno::EINVAL))?; + // SAFETY: Calling the documented vCPU ioctl with its integer value. + unsafe { ioctl::kvm_set_tsc_khz(self.get().vcpu.as_raw_fd(), khz) } + .map_err(Error::SetTscFrequency)?; + Ok(()) + } + + /// Gets the guest TSC offset, in guest TSC cycles. + #[cfg(target_arch = "x86_64")] + pub fn tsc_offset(&self) -> Result { + let mut offset = 0u64; + let attr = kvm_device_attr { + group: KVM_VCPU_TSC_CTRL, + attr: u64::from(KVM_VCPU_TSC_OFFSET), + addr: std::ptr::from_mut(&mut offset) as u64, + flags: 0, + }; + // SAFETY: `attr.addr` points to `offset` for the duration of the ioctl. + unsafe { + ioctl::kvm_get_device_attr(self.get().vcpu.as_raw_fd(), &attr) + .map_err(Error::GetTscOffset)?; + } + Ok(offset) + } + + /// Sets the guest TSC offset without KVM's TSC-write synchronization heuristic. + #[cfg(target_arch = "x86_64")] + pub fn set_tsc_offset(&self, offset: u64) -> Result<()> { + // SAFETY: `offset` is the u64 input required by KVM_VCPU_TSC_OFFSET. + unsafe { + self.set_device_attr(KVM_VCPU_TSC_CTRL, KVM_VCPU_TSC_OFFSET, &offset, 0) + .map_err(Error::SetTscOffset)?; + } + Ok(()) + } + pub fn enable_synic(&self) -> Result<()> { // TODO: We are not checking KVM_CAP_ENABLE_CAP_VM first. // TODO: We are not calling KVM_CHECK_EXTENSION first. diff --git a/vmm_core/virt/src/x86/vm.rs b/vmm_core/virt/src/x86/vm.rs index 1dda3a92aeb..df99c18fd9c 100644 --- a/vmm_core/virt/src/x86/vm.rs +++ b/vmm_core/virt/src/x86/vm.rs @@ -119,7 +119,7 @@ impl HvRegisterState for ReferenceTime { impl StateElement for ReferenceTime { fn is_present(caps: &X86PartitionCapabilities) -> bool { - caps.hv1 + caps.hv1 || caps.kvm_clock } fn at_reset(_caps: &X86PartitionCapabilities, _vp_info: &X86VpInfo) -> Self { diff --git a/vmm_core/virt/src/x86/vp.rs b/vmm_core/virt/src/x86/vp.rs index 13f2c6b7ffa..79968ba184f 100644 --- a/vmm_core/virt/src/x86/vp.rs +++ b/vmm_core/virt/src/x86/vp.rs @@ -1710,6 +1710,12 @@ pub struct SyntheticMsrs { #[mesh(5)] #[inspect(iter_by_index)] pub sint: [u64; 16], + /// KVM wall-clock GPA/configuration MSR. + #[mesh(6)] + pub kvm_wall_clock: u64, + /// KVM system-time GPA/configuration MSR. + #[mesh(7)] + pub kvm_system_time: u64, } impl HvRegisterState for SyntheticMsrs { @@ -1765,16 +1771,21 @@ impl HvRegisterState for SyntheticMsrs { impl StateElement for SyntheticMsrs { fn is_present(caps: &X86PartitionCapabilities) -> bool { - caps.hv1 + caps.hv1 || caps.kvm_clock } fn at_reset(_caps: &X86PartitionCapabilities, _vp_info: &X86VpInfo) -> Self { + if !_caps.hv1 { + return Self::default(); + } Self { vp_assist_page: 0, scontrol: 1, siefp: 0, simp: 0, sint: [0x10000; 16], + kvm_wall_clock: 0, + kvm_system_time: 0, } } } diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index a9372f7f21e..2722e990b11 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -7,6 +7,7 @@ mod regs; pub(crate) mod snp; +mod tsc; mod vm_state; mod vp_state; @@ -506,15 +507,6 @@ impl ProtoPartition for KvmProtoPartition<'_> { self.vm.set_bsp(bsp_apic_id)?; } - let mut caps = virt::PartitionCapabilities::from_cpuid( - self.config.processor_topology, - &mut |function, index| cpuid.result(function, index, &[0; 4]), - ) - .map_err(KvmError::Capabilities)?; - - caps.can_freeze_time = false; - caps.nested_virt = self.nested_virt; - // Create all VCPUs now so that they are assigned dense, sequential // vcpu_idx values (KVM assigns vcpu_idx in creation order). KVM's // Hyper-V enlightenment code has a fast O(1) VP-index-to-vcpu lookup @@ -527,6 +519,16 @@ impl ProtoPartition for KvmProtoPartition<'_> { self.vm.add_vp(vp_info.apic_id)?; } + let cpuid = tsc::add_frequency_leaves(&self.vm, bsp_apic_id, cpuid)?; + let mut caps = virt::PartitionCapabilities::from_cpuid( + self.config.processor_topology, + &mut |function, index| cpuid.result(function, index, &[0; 4]), + ) + .map_err(KvmError::Capabilities)?; + + caps.can_freeze_time = false; + caps.nested_virt = self.nested_virt; + let mut gsi_routing = GsiRouting::new(); // Claim the IOAPIC routes. @@ -772,6 +774,22 @@ impl Partition for KvmPartition { virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) } + fn tsc_frequency_hz(&self) -> Result, Self::Error> { + self.inner.tsc_frequency_hz() + } + + fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), Self::Error> { + self.inner.set_tsc_frequency_hz(frequency_hz) + } + + fn apic_frequency_hz(&self) -> Result, Self::Error> { + Ok(Some(tsc::APIC_FREQUENCY_HZ)) + } + + fn advance_snapshot_time(&self, duration: Duration) -> Result<(), Self::Error> { + self.inner.advance_snapshot_time(duration) + } + fn supports_reset(&self) -> Option<&dyn ResetPartition> { // TODO: Support resetting SNP launch state and rebuilding the protected // guest before advertising reset support. @@ -1866,6 +1884,11 @@ impl<'p> Processor for KvmProcessor<'p> { assert_eq!(vtl, Vtl::Vtl0); KvmVpStateAccess::new(self) } + + fn advance_tsc(&mut self, cycles: u64) -> anyhow::Result<()> { + tsc::advance_tsc(&self.partition.kvm.vp(self.inner.vp_info.apic_id), cycles)?; + Ok(()) + } } impl virt::synic::Synic for KvmPartitionInner { diff --git a/vmm_core/virt_kvm/src/arch/x86_64/tsc.rs b/vmm_core/virt_kvm/src/arch/x86_64/tsc.rs new file mode 100644 index 00000000000..162cc899c75 --- /dev/null +++ b/vmm_core/virt_kvm/src/arch/x86_64/tsc.rs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TSC and clock support: the snapshot-downtime TSC offset adjustment, the +//! CPUID leaves that expose the exact TSC frequency, and the partition clock +//! controls used by snapshot restore. + +use crate::KvmError; +use crate::KvmPartitionInner; +use std::time::Duration; +use virt::CpuidLeafSet; +use vm_topology::processor::x86::X86VpInfo; +use x86defs::cpuid::CpuidFunction; + +/// KVM's in-kernel local APIC uses a fixed one-nanosecond bus cycle. +pub(super) const APIC_FREQUENCY_HZ: u64 = 1_000_000_000; + +fn kvm_vcpu_id(vp_info: &X86VpInfo) -> u32 { + vp_info.apic_id +} + +/// Adds the CPUID leaves that expose the BSP's exact TSC frequency. +pub(super) fn add_frequency_leaves( + vm: &kvm::Partition, + bsp_vcpu_id: u32, + cpuid: CpuidLeafSet, +) -> Result { + let tsc_frequency_hz = vm.vp(bsp_vcpu_id).tsc_frequency_hz()?; + let current_max_basic_leaf = cpuid.result(CpuidFunction::VendorAndMaxFunction.0, 0, &[0; 4])[0]; + let mut cpuid = cpuid.into_leaves(); + cpuid.extend(virt::x86::tsc::tsc_frequency_cpuid_leaves( + tsc_frequency_hz, + current_max_basic_leaf, + )?); + Ok(CpuidLeafSet::new(cpuid)) +} + +impl KvmPartitionInner { + pub(super) fn tsc_frequency_hz(&self) -> Result, KvmError> { + let bsp_vcpu_id = kvm_vcpu_id(&self.bsp().vp_info); + Ok(Some(self.kvm.vp(bsp_vcpu_id).tsc_frequency_hz()?)) + } + + pub(super) fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), KvmError> { + for vp in &self.vps { + self.kvm + .vp(kvm_vcpu_id(&vp.vp_info)) + .set_tsc_frequency_hz(frequency_hz)?; + } + Ok(()) + } + + pub(super) fn advance_snapshot_time(&self, duration: Duration) -> Result<(), KvmError> { + let clock = self.kvm.get_clock_ns()?; + let delta = + u64::try_from(duration.as_nanos()).map_err(|_| KvmError::SnapshotClockOverflow)?; + let requested_clock = clock + .clock + .checked_add(delta) + .ok_or(KvmError::SnapshotClockOverflow)?; + self.kvm.set_clock_ns(requested_clock)?; + Ok(()) + } +} + +pub(crate) fn advance_tsc(vp: &kvm::Processor<'_>, cycles: u64) -> Result<(), KvmError> { + // KVM can discard sub-second IA32_TSC writes as synchronization attempts. + // Adjusting the offset also avoids introducing read/write latency skew. + let offset = vp.tsc_offset()?; + vp.set_tsc_offset(offset.wrapping_add(cycles))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::advance_tsc; + use super::kvm_vcpu_id; + use test_with_tracing::test; + use virt::VpIndex; + use vm_topology::processor::VpInfo; + use vm_topology::processor::x86::X86VpInfo; + + #[test] + fn kvm_vcpu_ids_follow_sparse_apic_ids() { + let vp0 = X86VpInfo { + base: VpInfo { + vp_index: VpIndex::new(0), + vnode: 0, + }, + apic_id: 253, + }; + let vp1 = X86VpInfo { + base: VpInfo { + vp_index: VpIndex::new(1), + vnode: 1, + }, + apic_id: 254, + }; + + assert_eq!(kvm_vcpu_id(&vp0), 253); + assert_eq!(kvm_vcpu_id(&vp1), 254); + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn snapshot_tsc_downtime_advances_exact_cycles() { + let kvm = kvm::Kvm::new().unwrap(); + for vp_count in [1, 2, 4, 8] { + let mut partition = kvm.new_vm(kvm::VmType::Default).unwrap(); + for index in 0..vp_count { + partition.add_vp(index).unwrap(); + } + let frequency = partition.vp(0).tsc_frequency_hz().unwrap(); + for index in 0..vp_count { + partition + .vp(index) + .set_msrs(&[(x86defs::X86X_MSR_TSC, frequency * 10)]) + .unwrap(); + } + for cycles in [frequency / 4, frequency * 2, 0, 1] { + for index in 0..vp_count { + let vp = partition.vp(index); + let before = vp.tsc_offset().unwrap(); + advance_tsc(&vp, cycles).unwrap(); + assert_eq!( + vp.tsc_offset().unwrap(), + before.wrapping_add(cycles), + "VP {index} of {vp_count}, delta {cycles} cycles" + ); + } + } + } + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn snapshot_tsc_downtime_preserves_wrapping_per_vp_offsets() { + let kvm = kvm::Kvm::new().unwrap(); + let mut partition = kvm.new_vm(kvm::VmType::Default).unwrap(); + let offsets = [u64::MAX - 10, 0, 1_000, 10_000]; + for (index, offset) in offsets.into_iter().enumerate() { + partition.add_vp(index as u32).unwrap(); + partition.vp(index as u32).set_tsc_offset(offset).unwrap(); + } + for (index, offset) in offsets.into_iter().enumerate() { + let vp = partition.vp(index as u32); + advance_tsc(&vp, 100).unwrap(); + assert_eq!(vp.tsc_offset().unwrap(), offset.wrapping_add(100)); + } + } +} diff --git a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs index 7fe4c3f538f..4937a7f1647 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod kvm_clock; + use super::KvmProcessor; use super::regs::register_to_msr; use crate::KvmError; @@ -564,11 +566,13 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { } fn synic_msrs(&mut self) -> Result { - self.get_register_state() + self.synthetic_msrs() } fn set_synic_msrs(&mut self, value: &vp::SyntheticMsrs) -> Result<(), Self::Error> { - self.set_register_state(value)?; + if !self.set_synthetic_msrs(value)? { + return Ok(()); + } // Mirror the restored synic MSRs into the processor's tracked state and // overlay pages. Runs before set_synic_{message,event_flags}_page (per diff --git a/vmm_core/virt_kvm/src/arch/x86_64/vp_state/kvm_clock.rs b/vmm_core/virt_kvm/src/arch/x86_64/vp_state/kvm_clock.rs new file mode 100644 index 00000000000..d6c64517826 --- /dev/null +++ b/vmm_core/virt_kvm/src/arch/x86_64/vp_state/kvm_clock.rs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! KVM paravirtual clock MSR state, which is saved and restored with the +//! synthetic MSRs. + +use super::KvmVpStateAccess; +use crate::KvmError; +use virt::x86::vp; +use virt::x86::vp::AccessVpState; + +const MSR_KVM_WALL_CLOCK_NEW: u32 = 0x4b56_4d00; +const MSR_KVM_SYSTEM_TIME_NEW: u32 = 0x4b56_4d01; + +impl KvmVpStateAccess<'_, '_> { + /// Reads the Hyper-V synthetic MSRs and the KVM clock MSRs that the + /// partition exposes. + pub(super) fn synthetic_msrs(&mut self) -> Result { + let mut value = if self.caps().hv1 { + self.get_register_state()? + } else { + vp::SyntheticMsrs::default() + }; + if self.caps().kvm_clock { + let mut msrs = [0; 2]; + self.kvm().get_msrs( + &[MSR_KVM_WALL_CLOCK_NEW, MSR_KVM_SYSTEM_TIME_NEW], + &mut msrs, + )?; + [value.kvm_wall_clock, value.kvm_system_time] = msrs; + } + Ok(value) + } + + /// Writes the Hyper-V synthetic MSRs and the KVM clock MSRs that the + /// partition exposes. + /// + /// Returns whether the Hyper-V synthetic MSRs were written, in which case + /// the caller must mirror them into the processor state. + pub(super) fn set_synthetic_msrs( + &mut self, + value: &vp::SyntheticMsrs, + ) -> Result { + if self.caps().hv1 { + self.set_register_state(value)?; + } + if self.caps().kvm_clock { + self.kvm().set_msrs(&[ + (MSR_KVM_WALL_CLOCK_NEW, value.kvm_wall_clock), + (MSR_KVM_SYSTEM_TIME_NEW, value.kvm_system_time), + ])?; + } + Ok(self.caps().hv1) + } +} diff --git a/vmm_core/virt_kvm/src/lib.rs b/vmm_core/virt_kvm/src/lib.rs index 4900032d49c..88f09728950 100644 --- a/vmm_core/virt_kvm/src/lib.rs +++ b/vmm_core/virt_kvm/src/lib.rs @@ -67,6 +67,8 @@ pub enum KvmError { State(#[from] Box>), #[error("invalid state while restoring: {0}")] InvalidState(&'static str), + #[error("snapshot clock downtime adjustment overflows")] + SnapshotClockOverflow, #[error("unsupported isolation configuration: {0}")] UnsupportedIsolationConfiguration(&'static str), #[error("misaligned gic base address")] @@ -91,6 +93,9 @@ pub enum KvmError { #[cfg(guest_arch = "x86_64")] #[error("failed to compute topology cpuid")] TopologyCpuid(#[source] virt::x86::topology::UnknownVendor), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TscFrequencyCpuid(#[from] virt::x86::tsc::TscFrequencyCpuidError), } #[derive(Inspect)] From b7bc2586b78b6657d570640e23b0e41d039f6bd8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 00:53:48 -0700 Subject: [PATCH 059/304] virt_mshv: synchronize restored vCPU clocks and expose the exact TSC frequency MSHV partitions did not report their TSC frequency, so guests had to calibrate it, and nothing verified that a snapshot is restored on a host with the TSC frequency that it was saved with. The TSC of each VP is also restored at a different host time, so the counters of a restored SMP guest drift apart. Expose the exact TSC frequency of the partition in CPUID leaf 0x15, and implement the partition clock hooks: report the TSC and local APIC timer frequencies, reject a requested TSC frequency that differs from that of the partition, since MSHV cannot change it, and, after a snapshot downtime, freeze partition time and align the counters of all VPs to the advanced BSP counter. Hide the TSC-deadline timer from the guest, in both the processor features of the partition and CPUID, since MSHV does not reliably deliver its events to direct-boot guests, and report that ordinary state access does not freeze the partition clock. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vmm_core/virt_mshv/src/lib.rs | 15 ++ vmm_core/virt_mshv/src/x86_64/finalize.rs | 3 +- vmm_core/virt_mshv/src/x86_64/mod.rs | 19 ++ vmm_core/virt_mshv/src/x86_64/tsc.rs | 216 ++++++++++++++++++++++ 4 files changed, 252 insertions(+), 1 deletion(-) create mode 100644 vmm_core/virt_mshv/src/x86_64/tsc.rs diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 27173269233..7898b36395f 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -810,6 +810,13 @@ enum ErrorInner { #[error("register access error")] Register(#[source] KernelError), #[cfg(guest_arch = "x86_64")] + #[error("failed to synchronize restored VP {vp_index} TSC")] + SynchronizeTsc { + vp_index: u32, + #[source] + error: KernelError, + }, + #[cfg(guest_arch = "x86_64")] #[error("failed to get VP state {ty}")] GetVpState { #[source] @@ -836,6 +843,14 @@ enum ErrorInner { #[error("unsupported processor vendor: {0:?}")] UnsupportedProcessorVendor(hvdef::HvProcessorVendor), #[cfg(guest_arch = "x86_64")] + #[error( + "TSC frequency mismatch between snapshot ({saved} Hz) and destination ({destination} Hz)" + )] + TscFrequencyMismatch { saved: u64, destination: u64 }, + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TscFrequencyCpuid(#[from] virt::x86::tsc::TscFrequencyCpuidError), + #[cfg(guest_arch = "x86_64")] #[error("failed to create virtual device")] NewDevice(#[source] virt::x86::apic_software_device::DeviceIdInUse), } diff --git a/vmm_core/virt_mshv/src/x86_64/finalize.rs b/vmm_core/virt_mshv/src/x86_64/finalize.rs index 1b8b59f490a..4efda0b1954 100644 --- a/vmm_core/virt_mshv/src/x86_64/finalize.rs +++ b/vmm_core/virt_mshv/src/x86_64/finalize.rs @@ -155,7 +155,8 @@ impl MshvPartitionInner { ); caps.tsc_deadline = false; caps.xsaves_state_bv_broken = true; - caps.can_freeze_time = true; + // Ordinary state access does not freeze the partition clock. + caps.can_freeze_time = false; Ok(caps) } } diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 41c93abdd6a..616bafda5fb 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -5,6 +5,7 @@ mod extint; pub(crate) mod finalize; +mod tsc; mod vm_state; mod vp_state; @@ -129,6 +130,7 @@ impl virt::Hypervisor for LinuxMshv { x2apic, config.processor_topology.smt_enabled(), )?; + let create_args = tsc::with_features1(create_args, false); let vmfd = create_vm_with_retry(&self.mshv, &create_args)?; @@ -480,6 +482,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { cpuid.extend(snp_hv_cpuid_overrides(native_max_leaf)); } } + let cpuid = tsc::add_cpuid_leaves(&self.vmfd, cpuid)?; let cpuid = virt::CpuidLeafSet::new(cpuid); // Apply CPUID overrides partition-wide. @@ -592,6 +595,22 @@ impl virt::Partition for MshvPartition { virt::x86::CpuCompatibilityContract::new(self.inner.caps(), &self.inner.config.cpuid) } + fn tsc_frequency_hz(&self) -> Result, Self::Error> { + self.inner.tsc_frequency_hz() + } + + fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), Self::Error> { + self.inner.set_tsc_frequency_hz(frequency_hz) + } + + fn advance_snapshot_time(&self, _duration: std::time::Duration) -> Result<(), Self::Error> { + self.inner.advance_snapshot_time() + } + + fn apic_frequency_hz(&self) -> Result, Self::Error> { + self.inner.apic_frequency_hz() + } + fn supports_initial_page_acceptance( &self, ) -> Option<&dyn virt::AcceptInitialPages> { diff --git a/vmm_core/virt_mshv/src/x86_64/tsc.rs b/vmm_core/virt_mshv/src/x86_64/tsc.rs new file mode 100644 index 00000000000..179700b96c6 --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/tsc.rs @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TSC support: the TSC deadline timer and TSC_ADJUST feature policy, the +//! CPUID leaves that expose the exact TSC frequency, the clock frequencies +//! used by snapshot restore, and restored-TSC synchronization. + +use crate::Error; +use crate::ErrorInner; +use crate::KernelError; +use crate::MshvPartitionInner; +use crate::VcpuFdExt; +use hvdef::HvPartitionPropertyCode; +use hvdef::HvX64RegisterName; +use hvdef::hypercall::HvRegisterAssoc; +use mshv_ioctls::VcpuFd; +use mshv_ioctls::VmFd; +use x86defs::cpuid::CpuidFunction; + +/// Returns the processor features (bank 1) to expose: the supported features +/// without the TSC deadline timer, and without TSC_ADJUST for a versioned CPU +/// contract. +fn supported_features1(versioned_cpu_contract: bool) -> hvdef::HvX64PartitionProcessorFeatures1 { + super::supported_processor_features1() + .with_tsc_deadline_tmr_support(false) + .with_tsc_adjust_support(!versioned_cpu_contract) +} + +/// Applies [`supported_features1`] to the partition creation arguments. +pub(super) fn with_features1( + mut args: mshv_bindings::mshv_create_partition_v2, + versioned_cpu_contract: bool, +) -> mshv_bindings::mshv_create_partition_v2 { + let mut banks = args.pt_cpu_fbanks; + banks[1] = !u64::from(supported_features1(versioned_cpu_contract)); + args.pt_cpu_fbanks = banks; + args +} + +/// Adds the CPUID leaves that expose the exact TSC frequency and that hide the +/// TSC deadline timer. +pub(super) fn add_cpuid_leaves( + vmfd: &VmFd, + cpuid: Vec, +) -> Result, Error> { + let cpuid = virt::CpuidLeafSet::new(cpuid); + let tsc_frequency_hz = vmfd + .get_partition_property(HvPartitionPropertyCode::ProcessorClockFrequency.0) + .map_err(|error| ErrorInner::GetPartitionProperty(error.into()))?; + let current_max_basic_leaf = cpuid.result(CpuidFunction::VendorAndMaxFunction.0, 0, &[0; 4])[0]; + let mut cpuid = cpuid.into_leaves(); + cpuid.extend(virt::x86::tsc::tsc_frequency_cpuid_leaves( + tsc_frequency_hz, + current_max_basic_leaf, + )?); + cpuid.push( + virt::CpuidLeaf::new(CpuidFunction::VersionAndFeatures.0, [0; 4]).masked([ + 0, + 0, + 1 << 24, + 0, + ]), + ); + Ok(cpuid) +} + +impl MshvPartitionInner { + pub(super) fn tsc_frequency_hz(&self) -> Result, Error> { + Ok(Some( + self.vmfd + .get_partition_property(HvPartitionPropertyCode::ProcessorClockFrequency.0) + .map_err(|error| ErrorInner::GetPartitionProperty(error.into()))?, + )) + } + + pub(super) fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), Error> { + let destination = self + .vmfd + .get_partition_property(HvPartitionPropertyCode::ProcessorClockFrequency.0) + .map_err(|error| ErrorInner::GetPartitionProperty(error.into()))?; + if frequency_hz != destination { + return Err(ErrorInner::TscFrequencyMismatch { + saved: frequency_hz, + destination, + } + .into()); + } + Ok(()) + } + + /// Aligns the restored VP counters to the advanced BSP counter. + pub(super) fn advance_snapshot_time(&self) -> Result<(), Error> { + if self.vps.len() <= 1 { + return Ok(()); + } + + // Per-VP counter writes run at different host times. Freeze before + // aligning them to the advanced BSP counter; the first VP run thaws time. + self.freeze_time()?; + synchronize_restored_tscs(&self.vmfd, &self.finalized()?.bsp_vcpufd, self.vps.len()) + } + + pub(super) fn apic_frequency_hz(&self) -> Result, Error> { + Ok(Some( + self.vmfd + .get_partition_property(HvPartitionPropertyCode::ApicFrequency.0) + .map_err(|error| ErrorInner::GetPartitionProperty(error.into()))?, + )) + } +} + +fn synchronize_restored_tscs(vmfd: &VmFd, bsp: &VcpuFd, vp_count: usize) -> Result<(), Error> { + let mut registers = [HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0_u64))]; + bsp.get_hvdef_regs(&mut registers) + .map_err(ErrorInner::Register)?; + + #[repr(C)] + struct SetTsc { + header: hvdef::hypercall::GetSetVpRegisters, + register: HvRegisterAssoc, + } + + let mut input = SetTsc { + header: hvdef::hypercall::GetSetVpRegisters { + partition_id: 0, + vp_index: 0, + target_vtl: hvdef::hypercall::HvInputVtl::CURRENT_VTL, + rsvd: [0; 3], + }, + register: registers[0], + }; + for vp_index in 1..vp_count as u32 { + input.header.vp_index = vp_index; + let mut args = mshv_bindings::mshv_root_hvcall { + code: hvdef::HypercallCode::HvCallSetVpRegisters.0, + in_sz: size_of::() as u16, + in_ptr: std::ptr::addr_of!(input) as u64, + reps: 1, + ..Default::default() + }; + vmfd.hvcall(&mut args) + .map_err(|error| ErrorInner::SynchronizeTsc { + vp_index, + error: error.into(), + })?; + if args.reps != 1 { + return Err(ErrorInner::SynchronizeTsc { + vp_index, + error: KernelError::Kernel(std::io::Error::from_raw_os_error(libc::EINTR)), + } + .into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::x86_64::partition_create_args; + use mshv_ioctls::Mshv; + use test_with_tracing::test; + + #[test] + fn versioned_cpu_contract_does_not_expose_tsc_adjust() { + for versioned_cpu_contract in [false, true] { + let processor_features1 = supported_features1(versioned_cpu_contract); + assert_eq!( + processor_features1.tsc_adjust_support(), + !versioned_cpu_contract + ); + let args = with_features1( + partition_create_args(&virt::ProtoPartitionIsolation::None, false, false).unwrap(), + versioned_cpu_contract, + ); + let pt_cpu_fbanks = args.pt_cpu_fbanks; + assert_eq!(pt_cpu_fbanks[1], !u64::from(processor_features1)); + } + } + + #[test] + #[ignore = "requires /dev/mshv"] + fn restored_tscs_are_identical_while_partition_time_is_frozen() { + for vp_count in [1, 2, 4, 8] { + let mshv = Mshv::new().unwrap(); + let vmfd = mshv.create_vm().unwrap(); + vmfd.initialize().unwrap(); + let vps: Vec<_> = (0..vp_count) + .map(|index| vmfd.create_vcpu(index).unwrap()) + .collect(); + vmfd.set_partition_property(HvPartitionPropertyCode::TimeFreeze.0, 1) + .unwrap(); + let expected_tsc = 1_000_000_u64; + for (index, vp) in vps.iter().enumerate() { + vp.set_hvdef_regs(&[HvRegisterAssoc::from(( + HvX64RegisterName::Tsc, + expected_tsc + index as u64 * 100_000, + ))]) + .unwrap(); + } + + synchronize_restored_tscs(&vmfd, &vps[0], vps.len()).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + for (index, vp) in vps.iter().enumerate() { + let mut registers = [HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0_u64))]; + vp.get_hvdef_regs(&mut registers).unwrap(); + assert_eq!( + registers[0].value.as_u64(), + expected_tsc, + "VP {index} TSC differs with {vp_count} VPs" + ); + } + } + } +} From 00bced019c5956f666608620e87cc45534a22339 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 01:00:04 -0700 Subject: [PATCH 060/304] virt_whp: synchronize restored vCPU clocks and keep SMP TSC reads coherent WHP partitions did not report their TSC frequency, so guests had to calibrate it, and nothing verified that a snapshot is restored on a host with the TSC frequency that it was saved with. The TSC of each VP is also restored at a different host time, so the counters of a restored SMP guest disagree with each other. Expose the exact TSC frequency of the partition in CPUID leaf 0x15, and implement the partition clock hooks: report the TSC frequency and the local APIC timer frequency, reject a requested TSC frequency that differs from that of the partition, and, after a snapshot downtime, align the counters of all VPs of an SMP partition with partition time suspended. To keep TSC reads coherent across the VPs, a restored SMP partition that does not use nested virtualization then derives the TSC of each VP from the partition-wide reference time. While this restored-TSC clock is active, WHP exits on RDTSC and on accesses to IA32_TSC, TSC_ADJUST, and IA32_TSC_DEADLINE, and the VP state accessors read and restore the TSC through the clock. A VP whose guest writes its TSC or changes its TSC_ADJUST leaves the clock and gets the native WHP semantics again, and resetting the partition disables the clock. Read IA32_TSC_DEADLINE before the APIC state when saving a VP, so that the deadline cannot expire between the two reads. Add the WHP properties and registers that this needs, and report a failure to inject a general protection fault from an MSR exit instead of panicking. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + vm/whp/src/lib.rs | 9 + vm/whp/src/x64.rs | 2 + vmm_core/virt_support_apic/src/lib.rs | 3 +- vmm_core/virt_whp/Cargo.toml | 3 + vmm_core/virt_whp/src/lib.rs | 42 ++- vmm_core/virt_whp/src/tsc.rs | 400 ++++++++++++++++++++++++++ vmm_core/virt_whp/src/vp.rs | 21 +- vmm_core/virt_whp/src/vp/tsc.rs | 233 +++++++++++++++ vmm_core/virt_whp/src/vp_state.rs | 33 ++- vmm_core/virt_whp/src/vp_state/tsc.rs | 80 ++++++ 11 files changed, 813 insertions(+), 14 deletions(-) create mode 100644 vmm_core/virt_whp/src/tsc.rs create mode 100644 vmm_core/virt_whp/src/vp/tsc.rs create mode 100644 vmm_core/virt_whp/src/vp_state/tsc.rs diff --git a/Cargo.lock b/Cargo.lock index f0a873fed9d..a1a239312db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9981,6 +9981,7 @@ dependencies = [ "pci_core", "range_map_vec", "sparse_mmap", + "test_with_tracing", "thiserror 2.0.16", "tracelimit", "tracing", diff --git a/vm/whp/src/lib.rs b/vm/whp/src/lib.rs index 2662a514bc0..9b5c03a6b3b 100644 --- a/vm/whp/src/lib.rs +++ b/vm/whp/src/lib.rs @@ -737,6 +737,15 @@ impl Partition { self.get_property(partition_prop::ReferenceTime) } + pub fn extended_vm_exits(&self) -> Result { + self.get_property(partition_prop::ExtendedVmExits) + } + + #[cfg(target_arch = "x86_64")] + pub fn x64_msr_exit_bitmap(&self) -> Result { + self.get_property(partition_prop::X64MsrExitBitmap) + } + pub fn physical_address_width(&self) -> Result { self.get_property(partition_prop::PhysicalAddressWidth) } diff --git a/vm/whp/src/x64.rs b/vm/whp/src/x64.rs index 53b89e2b3ce..10e328d1434 100644 --- a/vm/whp/src/x64.rs +++ b/vm/whp/src/x64.rs @@ -43,6 +43,8 @@ pub enum Register64 { XCr0 = abi::WHvX64RegisterXCr0.0, Tsc = abi::WHvX64RegisterTsc.0, + TscAdjust = abi::WHvX64RegisterTscAdjust.0, + TscDeadline = abi::WHvX64RegisterTscDeadline.0, Efer = abi::WHvX64RegisterEfer.0, KernelGsBase = abi::WHvX64RegisterKernelGsBase.0, ApicBase = abi::WHvX64RegisterApicBase.0, diff --git a/vmm_core/virt_support_apic/src/lib.rs b/vmm_core/virt_support_apic/src/lib.rs index 3d593c50939..77e7b48e50d 100644 --- a/vmm_core/virt_support_apic/src/lib.rs +++ b/vmm_core/virt_support_apic/src/lib.rs @@ -43,7 +43,8 @@ use x86defs::msi::MsiAddress; use x86defs::msi::MsiData; const NANOS_PER_TICK: u64 = 5; // 200Mhz -const TIMER_FREQUENCY: u64 = 1_000_000_000 / NANOS_PER_TICK; +/// The emulated LAPIC timer bus frequency in Hz, before its clock divider. +pub const TIMER_FREQUENCY: u64 = 1_000_000_000 / NANOS_PER_TICK; const APIC_VERSION: u32 = 0x50014; const ICR_LOW_MASK: Icr = Icr::new() diff --git a/vmm_core/virt_whp/Cargo.toml b/vmm_core/virt_whp/Cargo.toml index f110a5f90f0..912cc6568ee 100644 --- a/vmm_core/virt_whp/Cargo.toml +++ b/vmm_core/virt_whp/Cargo.toml @@ -51,5 +51,8 @@ x86emu.workspace = true [build-dependencies] build_rs_guest_arch.workspace = true +[dev-dependencies] +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 546e3115b1a..685e2b183b8 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -18,6 +18,8 @@ mod hypercalls; mod memory; mod regs; mod synic; +#[cfg(guest_arch = "x86_64")] +mod tsc; mod vm_state; mod vp; mod vp_state; @@ -124,6 +126,9 @@ struct WhpPartitionInner { caps: virt::PartitionCapabilities, #[cfg(guest_arch = "x86_64")] cpuid: virt::CpuidLeafSet, + #[cfg(guest_arch = "x86_64")] + #[inspect(flatten)] + clock: tsc::PartitionClock, vtl0_alias_map_offset: Option, monitor_page: MonitorPage, hvstate: Hv1State, @@ -531,6 +536,8 @@ impl virt::ResetPartition for WhpPartition { type Error = Error; fn reset(&self) -> Result<(), Error> { + #[cfg(guest_arch = "x86_64")] + self.inner.reset_restored_tsc()?; self.inner.vtl0.reset()?; self.validate_is_reset(Vtl::Vtl0); @@ -614,6 +621,26 @@ impl virt::Partition for WhpPartition { virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) } + #[cfg(guest_arch = "x86_64")] + fn tsc_frequency_hz(&self) -> Result, Self::Error> { + Ok(Some(self.inner.clock.tsc_frequency_hz)) + } + + #[cfg(guest_arch = "x86_64")] + fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), Self::Error> { + self.inner.clock.check_frequency(frequency_hz) + } + + #[cfg(guest_arch = "x86_64")] + fn advance_snapshot_time(&self, _duration: std::time::Duration) -> Result<(), Self::Error> { + self.inner.advance_snapshot_time() + } + + #[cfg(guest_arch = "x86_64")] + fn apic_frequency_hz(&self) -> Result, Self::Error> { + self.inner.apic_frequency_hz() + } + fn supports_reset(&self) -> Option<&dyn virt::ResetPartition> { if whp::capabilities::reset_partition() { Some(self) @@ -832,6 +859,9 @@ pub enum Error { GicV2NotSupported, #[error("failed to compute topology cpuid")] TopologyCpuid(#[source] virt::x86::topology::UnknownVendor), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TscFrequencyCpuid(#[from] virt::x86::tsc::TscFrequencyCpuidError), #[error("{0} is not supported on this architecture")] UnsupportedParameter(&'static str), #[error("WHP does not support nested virtualization on this host")] @@ -844,6 +874,11 @@ pub enum Error { NestedVirtIncompatibleWithIsolation, #[error("WHP does not support {0:?} isolation")] IsolationNotSupported(IsolationType), + #[error("saved TSC frequency {saved} Hz does not match destination frequency {destination} Hz")] + TscFrequencyMismatch { saved: u64, destination: u64 }, + #[cfg(guest_arch = "x86_64")] + #[error("timestamp intercept arrived without a restored TSC clock")] + UnexpectedTimestampExit, } trait WhpResultExt { @@ -1114,6 +1149,9 @@ impl WhpPartitionInner { // These are validated by VtlPartition::new and only consumed on x86_64. let _ = (user_mode_apic, offload_enlightenments, nested_virt); + #[cfg(guest_arch = "x86_64")] + let tsc_frequency = vtl0.whp.tsc_frequency().for_op("get tsc frequency")?; + // FUTURE: register cpuid results with the hypervisor, and register // appropriate per-VP results where necessary (or tell the hypervisor // the AMD topology information so that it can provide per-VP results @@ -1194,6 +1232,7 @@ impl WhpPartitionInner { ) .map_err(Error::TopologyCpuid)?; + let cpuid = tsc::add_frequency_leaves(cpuid, tsc_frequency, &vtl0)?; virt::CpuidLeafSet::new(cpuid) }; @@ -1297,7 +1336,6 @@ impl WhpPartitionInner { if vtl0.hypervisor_enlightened { Hv1State::Offloaded } else { - let tsc_frequency = vtl0.whp.tsc_frequency().for_op("get tsc frequency")?; let ref_time = ReferenceTimeSource::new(VmTimeReferenceTimeSource::new( proto_config.vmtime.clone(), )); @@ -1326,6 +1364,8 @@ impl WhpPartitionInner { caps, #[cfg(guest_arch = "x86_64")] cpuid, + #[cfg(guest_arch = "x86_64")] + clock: tsc::PartitionClock::new(tsc_frequency), vtl0_alias_map_offset, monitor_page: MonitorPage::new(), hvstate, diff --git a/vmm_core/virt_whp/src/tsc.rs b/vmm_core/virt_whp/src/tsc.rs new file mode 100644 index 00000000000..1319463fdba --- /dev/null +++ b/vmm_core/virt_whp/src/tsc.rs @@ -0,0 +1,400 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Restored-TSC support: the partition clock and its TSC model for restored +//! SMP partitions, TSC synchronization on restore, the CPUID leaves that +//! expose the exact TSC frequency, and the clock frequencies used by snapshot +//! restore. + +use crate::Error; +use crate::LocalApicKind; +use crate::VtlPartition; +use crate::WhpPartitionInner; +use crate::WhpResultExt; +use inspect::Inspect; +use parking_lot::Mutex; +use x86defs::cpuid::CpuidFunction; + +pub(crate) const IA32_TSC_ADJUST: u32 = 0x3b; +const REFERENCE_TIME_FREQUENCY: u128 = 10_000_000; + +/// The TSC frequency and the restored-TSC clock of a partition. +#[derive(Inspect)] +pub(crate) struct PartitionClock { + pub(crate) tsc_frequency_hz: u64, + #[inspect(skip)] + pub(crate) restored_tsc: Mutex>, +} + +impl PartitionClock { + pub(crate) fn new(tsc_frequency_hz: u64) -> Self { + Self { + tsc_frequency_hz, + restored_tsc: Mutex::new(None), + } + } + + /// Checks that a saved TSC frequency matches this partition's. + pub(crate) fn check_frequency(&self, frequency_hz: u64) -> Result<(), Error> { + if frequency_hz != self.tsc_frequency_hz { + return Err(Error::TscFrequencyMismatch { + saved: frequency_hz, + destination: self.tsc_frequency_hz, + }); + } + Ok(()) + } +} + +/// Adds the CPUID leaves that expose the exact TSC frequency. +pub(crate) fn add_frequency_leaves( + cpuid: Vec, + tsc_frequency_hz: u64, + vtl0: &VtlPartition, +) -> Result, Error> { + let cpuid = virt::CpuidLeafSet::new(cpuid); + let current_max_basic_leaf = + cpuid.result(CpuidFunction::VendorAndMaxFunction.0, 0, &vtl0.cpuid(0, 0))[0]; + let mut cpuid = cpuid.into_leaves(); + cpuid.extend(virt::x86::tsc::tsc_frequency_cpuid_leaves( + tsc_frequency_hz, + current_max_basic_leaf, + )?); + Ok(cpuid) +} + +impl WhpPartitionInner { + /// Disables the restored-TSC clock before a partition reset. + pub(crate) fn reset_restored_tsc(&self) -> Result<(), Error> { + let mut restored_tsc = self.clock.restored_tsc.lock(); + if let Some(clock) = &*restored_tsc { + clock.disable(&self.vtl0.whp)?; + } + *restored_tsc = None; + Ok(()) + } + + /// Aligns the restored VP counters with partition time suspended, and + /// enables the restored-TSC clock. + pub(crate) fn advance_snapshot_time(&self) -> Result<(), Error> { + if self.vps.len() <= 1 { + return Ok(()); + } + + let partition = &self.vtl0.whp; + partition + .suspend_time() + .for_op("suspend restored partition time")?; + let tsc = synchronize_restored_tscs(partition, self.vps.len())?; + if self.caps.nested_virt { + return Ok(()); + } + let leaf = CpuidFunction::ExtendedFeatures.0; + let features = self.cpuid.result(leaf, 0, &self.vtl0.cpuid(leaf, 0)); + let tsc_adjust_supported = + x86defs::cpuid::ExtendedFeatureSubleaf0Ebx::from(features[1]).tsc_adjust(); + let mut clock = self.clock.restored_tsc.lock(); + *clock = Some(RestoredTsc::enable( + partition, + tsc, + self.clock.tsc_frequency_hz, + self.vps.len(), + tsc_adjust_supported, + clock.as_ref(), + )?); + Ok(()) + } + + pub(crate) fn apic_frequency_hz(&self) -> Result, Error> { + let frequency = match &self.vtl0.lapic { + LocalApicKind::Emulated(_) => virt_support_apic::TIMER_FREQUENCY, + LocalApicKind::Offloaded => self + .vtl0 + .whp + .apic_frequency() + .for_op("get APIC clock frequency")?, + }; + Ok(Some(frequency)) + } +} + +fn synchronize_restored_tscs(partition: &whp::Partition, vp_count: usize) -> Result { + let tsc = partition + .vp(0) + .get_register(whp::Register64::Tsc) + .for_op("read restored BSP TSC")?; + for vp_index in 1..vp_count as u32 { + partition + .vp(vp_index) + .set_register(whp::Register64::Tsc, tsc) + .for_op("synchronize restored VP TSC")?; + } + Ok(tsc) +} + +pub(crate) struct RestoredTsc { + clock: ReferenceTsc, + original_exits: whp::abi::WHV_EXTENDED_VM_EXITS, + original_msr_exits: whp::abi::WHV_X64_MSR_EXIT_BITMAP, + pub(crate) tsc_adjust_supported: bool, +} + +impl RestoredTsc { + pub(crate) fn enable( + partition: &whp::Partition, + tsc: u64, + frequency_hz: u64, + vp_count: usize, + tsc_adjust_supported: bool, + previous: Option<&Self>, + ) -> Result { + let exits = partition + .extended_vm_exits() + .for_op("get restored VM exits")?; + let msr_exits = partition + .x64_msr_exit_bitmap() + .for_op("get restored MSR exits")?; + let reference_time = partition + .reference_time() + .for_op("get restored TSC epoch")?; + let (original_exits, original_msr_exits) = previous + .map(|previous| (previous.original_exits, previous.original_msr_exits)) + .unwrap_or((exits, msr_exits)); + + partition + .set_property(whp::PartitionProperty::X64MsrExitBitmap( + msr_exits + | whp::abi::WHV_X64_MSR_EXIT_BITMAP::TscMsrRead + | whp::abi::WHV_X64_MSR_EXIT_BITMAP::TscMsrWrite, + )) + .for_op("intercept restored TSC MSRs")?; + partition + .set_property(whp::PartitionProperty::ExtendedVmExits( + exits + | whp::abi::WHV_EXTENDED_VM_EXITS::X64RdtscExit + | whp::abi::WHV_EXTENDED_VM_EXITS::X64MsrExit, + )) + .for_op("intercept restored TSC reads")?; + + Ok(Self { + clock: ReferenceTsc::new(tsc, reference_time, frequency_hz, vp_count), + original_exits, + original_msr_exits, + tsc_adjust_supported, + }) + } + + pub(crate) fn disable(&self, partition: &whp::Partition) -> Result<(), Error> { + partition + .set_property(whp::PartitionProperty::ExtendedVmExits(self.original_exits)) + .for_op("reset restored TSC instruction exits")?; + partition + .set_property(whp::PartitionProperty::X64MsrExitBitmap( + self.original_msr_exits, + )) + .for_op("reset restored TSC MSR exits")?; + Ok(()) + } + + pub(crate) fn read(&self, vp_index: u32, reference_time: u64) -> Option { + self.clock.read(vp_index, reference_time) + } + + pub(crate) fn restore(&mut self, vp_index: u32, tsc: u64, reference_time: u64) { + self.clock.restore(vp_index, tsc, reference_time); + } + + pub(crate) fn guest_write(&mut self, vp_index: u32) { + self.clock.guest_write(vp_index); + } +} + +pub(crate) fn msr_index(exit_msr: u32, write: bool, rcx: u64) -> u32 { + // WHP reports TSC_ADJUST and TSC_DEADLINE writes as TSC writes. + // ECX retains the original selector. + if write && exit_msr == x86defs::X86X_MSR_TSC { + rcx as u32 + } else { + exit_msr + } +} + +struct ReferenceTsc { + frequency_hz: u64, + offsets: Vec>, +} + +impl ReferenceTsc { + fn new(tsc: u64, reference_time: u64, frequency_hz: u64, vp_count: usize) -> Self { + let mut clock = Self { + frequency_hz, + offsets: Vec::new(), + }; + let offset = tsc.wrapping_sub(clock.ticks(reference_time)); + clock.offsets.resize(vp_count, Some(offset)); + clock + } + + fn ticks(&self, reference_time: u64) -> u64 { + // Reference time is partition-wide, in 100ns units. The architectural + // TSC wraps at 64 bits, including at frequencies requiring a wide product. + (u128::from(reference_time) * u128::from(self.frequency_hz) / REFERENCE_TIME_FREQUENCY) + as u64 + } + + fn read(&self, vp_index: u32, reference_time: u64) -> Option { + self.offsets[vp_index as usize] + .map(|offset| self.ticks(reference_time).wrapping_add(offset)) + } + + fn restore(&mut self, vp_index: u32, tsc: u64, reference_time: u64) { + self.offsets[vp_index as usize] = Some(tsc.wrapping_sub(self.ticks(reference_time))); + } + + fn guest_write(&mut self, vp_index: u32) { + // A guest explicitly programming one counter owns its new timeline. + // Preserve WHP's native TSC/TSC_ADJUST and deadline-timer semantics. + self.offsets[vp_index as usize] = None; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn tsc_adjust_write_uses_the_original_guest_msr_selector() { + assert_eq!(msr_index(0x10, true, 0x10), 0x10); + assert_eq!(msr_index(0x10, true, 0x3b), 0x3b); + assert_eq!(msr_index(0x10, true, 0x6e0), 0x6e0); + assert_eq!(msr_index(0x10, true, 0xffff_ffff_0000_003b), 0x3b); + assert_eq!(msr_index(0x3b, false, 0x3b), 0x3b); + assert_eq!(msr_index(0x6e0, false, 0x6e0), 0x6e0); + assert_eq!(msr_index(0x10, false, 0x10), 0x10); + } + + #[test] + fn restored_counters_share_one_advancing_clock() { + for frequency in [1_000_000_000, 2_793_439_000] { + for count in [1, 2, 4, 8] { + let clock = ReferenceTsc::new(123_456_789, 1234, frequency, count); + let mut previous = 123_456_789; + for reference in [1234, 1234, 1235, 1236, 10_001_234] { + for vp in 0..count as u32 { + let value = clock.read(vp, reference).unwrap(); + assert!(value >= previous); + assert_eq!(clock.read(0, reference), Some(value)); + previous = value; + } + } + assert_eq!(clock.read(0, 10_001_234), Some(123_456_789 + frequency)); + } + } + } + + #[test] + fn counter_and_offset_arithmetic_wraps() { + let clock = ReferenceTsc::new(u64::MAX - 100, 0, 1_000_000_000, 2); + assert_eq!(clock.read(1, 2), Some(99)); + let clock = ReferenceTsc::new(1, 1000, 1_000_000_000, 2); + assert_eq!(clock.read(0, 1000), Some(1)); + assert_eq!(clock.read(1, 1001), Some(101)); + let clock = ReferenceTsc::new(0, 0, 100_000_000, 1); + assert_eq!(clock.read(0, u64::MAX), Some(u64::MAX.wrapping_mul(10))); + } + + #[test] + fn explicit_guest_writes_affect_only_the_target_counter() { + let mut clock = ReferenceTsc::new(1000, 0, 1_000_000_000, 4); + clock.guest_write(1); + assert_eq!(clock.read(1, 10), None); + for vp in [0, 2, 3] { + assert_eq!(clock.read(vp, 10), Some(2000)); + } + clock.restore(1, 7, 10); + assert_eq!(clock.read(1, 10), Some(7)); + assert_eq!(clock.read(1, 11), Some(107)); + assert_eq!(clock.read(0, 11), Some(2100)); + } + + #[test] + fn restored_reference_time_can_move_backwards() { + let clock = ReferenceTsc::new(10_000, 100, 1_000_000_000, 2); + assert_eq!(clock.read(0, 50), Some(5000)); + assert_eq!(clock.read(1, 100), Some(10_000)); + } + + #[test] + #[ignore = "requires WHP"] + fn restored_tscs_are_identical_while_partition_time_is_suspended() { + for vp_count in [1, 2, 4, 8] { + let mut config = whp::PartitionConfig::new().unwrap(); + config + .set_property(whp::PartitionProperty::ProcessorCount(vp_count)) + .unwrap(); + let partition = config.create().unwrap(); + for vp_index in 0..vp_count { + partition.create_vp(vp_index).create().unwrap(); + } + + partition.suspend_time().unwrap(); + let expected_tsc = 1_000_000_u64; + for vp_index in 0..vp_count { + partition + .vp(vp_index) + .set_register( + whp::Register64::Tsc, + expected_tsc + u64::from(vp_index) * 100_000, + ) + .unwrap(); + } + + synchronize_restored_tscs(&partition, vp_count as usize).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + for vp_index in 0..vp_count { + let actual_tsc = partition + .vp(vp_index) + .get_register(whp::Register64::Tsc) + .unwrap(); + assert_eq!(actual_tsc, expected_tsc, "VP {vp_index} TSC differs"); + } + } + } + + #[test] + #[ignore = "requires WHP"] + fn restored_tsc_intercepts_are_reversible() { + let mut config = whp::PartitionConfig::new().unwrap(); + config + .set_property(whp::PartitionProperty::ProcessorCount(2)) + .unwrap(); + let partition = config.create().unwrap(); + for vp in 0..2 { + partition.create_vp(vp).create().unwrap(); + } + partition.suspend_time().unwrap(); + let original_exits = partition.extended_vm_exits().unwrap(); + let original_msrs = partition.x64_msr_exit_bitmap().unwrap(); + let frequency = partition.tsc_frequency().unwrap(); + let clock = RestoredTsc::enable(&partition, 1_000_000, frequency, 2, false, None).unwrap(); + assert!( + partition + .extended_vm_exits() + .unwrap() + .is_set(whp::abi::WHV_EXTENDED_VM_EXITS::X64RdtscExit) + ); + assert!( + partition + .x64_msr_exit_bitmap() + .unwrap() + .is_set(whp::abi::WHV_X64_MSR_EXIT_BITMAP::TscMsrRead) + ); + let clock = + RestoredTsc::enable(&partition, 2_000_000, frequency, 2, false, Some(&clock)).unwrap(); + clock.disable(&partition).unwrap(); + assert_eq!(partition.extended_vm_exits().unwrap().0, original_exits.0); + assert_eq!(partition.x64_msr_exit_bitmap().unwrap().0, original_msrs.0); + } +} diff --git a/vmm_core/virt_whp/src/vp.rs b/vmm_core/virt_whp/src/vp.rs index d713c5ee86a..91456759ad3 100644 --- a/vmm_core/virt_whp/src/vp.rs +++ b/vmm_core/virt_whp/src/vp.rs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(guest_arch = "x86_64")] +mod tsc; + use super::Vplc; use super::VtlPartition; use super::vtl2::Vtl2InterceptState; @@ -39,6 +42,8 @@ pub(crate) struct ExitStats { #[cfg(guest_arch = "x86_64")] cpuid: Counter, #[cfg(guest_arch = "x86_64")] + rdtsc: Counter, + #[cfg(guest_arch = "x86_64")] apic_eoi: Counter, cancel: Counter, halt: Counter, @@ -502,6 +507,7 @@ impl<'a> WhpProcessor<'a> { mod x86 { use crate::Hv1State; use crate::WhpProcessor; + use crate::WhpResultExt; use crate::emu; use crate::emu::WhpVpRefEmulation; use crate::memory::x86::GpaBackingType; @@ -561,12 +567,16 @@ mod x86 { self.handle_cpuid(info, exit); &mut self.state.exits.cpuid } + ExitReason::Rdtsc(info) => { + self.handle_rdtsc_exit(dev, info, exit)?; + &mut self.state.exits.rdtsc + } ExitReason::ApicEoi(info) => { self.handle_apic_eoi(info, dev); &mut self.state.exits.apic_eoi } ExitReason::MsrAccess(info) => { - self.handle_msr(dev, info, exit); + self.handle_msr(dev, info, exit)?; &mut self.state.exits.msr } ExitReason::InterruptWindow(info) => { @@ -1222,7 +1232,10 @@ mod x86 { dev: &impl CpuIo, info: &whp::abi::WHV_X64_MSR_ACCESS_CONTEXT, exit: whp::Exit<'_>, - ) { + ) -> Result<(), VpHaltReason> { + if self.handle_restored_tsc_msr_exit(dev, info, exit)? { + return Ok(()); + } let handled = if info.AccessInfo.IsWrite() { self.msr_write(dev, exit, info.MsrNumber, info.Rax, info.Rdx) } else { @@ -1238,8 +1251,10 @@ mod x86 { self.current_whp() .set_register(whp::Register128::PendingEvent, event.into()) - .unwrap(); + .for_op("inject general protection fault") + .map_err(|error| dev.fatal_error(error.into()))?; } + Ok(()) } fn msr_write( diff --git a/vmm_core/virt_whp/src/vp/tsc.rs b/vmm_core/virt_whp/src/vp/tsc.rs new file mode 100644 index 00000000000..56d95acd7b0 --- /dev/null +++ b/vmm_core/virt_whp/src/vp/tsc.rs @@ -0,0 +1,233 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! RDTSC and TSC MSR exits of a restored SMP partition. While the partition's +//! restored-TSC clock (see [`crate::tsc`]) is active, guest timestamp reads +//! use its partition-wide TSC model. + +use crate::WhpProcessor; +use crate::WhpResultExt; +use hvdef::Vtl; +use virt::VpHaltReason; +use virt::io::CpuIo; +use whp::set_registers; + +impl WhpProcessor<'_> { + /// Handles an RDTSC or RDTSCP exit. + pub(super) fn handle_rdtsc_exit( + &self, + dev: &impl CpuIo, + info: &whp::abi::WHV_X64_RDTSC_CONTEXT, + exit: whp::Exit<'_>, + ) -> Result<(), VpHaltReason> { + self.handle_rdtsc(info, exit) + .map_err(|error| dev.fatal_error(error.into())) + } + + /// Handles a TSC, TSC_ADJUST, or TSC deadline MSR exit while the + /// restored-TSC clock is active. Returns whether the access was handled. + pub(super) fn handle_restored_tsc_msr_exit( + &self, + dev: &impl CpuIo, + info: &whp::abi::WHV_X64_MSR_ACCESS_CONTEXT, + exit: whp::Exit<'_>, + ) -> Result { + self.handle_restored_tsc_msr(info, exit) + .map_err(|error| dev.fatal_error(error.into())) + } + + fn inject_general_protection_fault(&self) -> Result<(), crate::Error> { + let event = hvdef::HvX64PendingExceptionEvent::new() + .with_event_pending(true) + .with_event_type(hvdef::HV_X64_PENDING_EVENT_EXCEPTION) + .with_deliver_error_code(true) + .with_vector(0xd); + self.current_whp() + .set_register(whp::Register128::PendingEvent, event.into()) + .for_op("inject general protection fault") + } + + fn read_restored_tsc(&self) -> Result { + let reference = self + .current_vtlp() + .whp + .reference_time() + .for_op("read guest TSC reference time")?; + let value = { + let clock = self.vp.partition.clock.restored_tsc.lock(); + clock + .as_ref() + .ok_or(crate::Error::UnexpectedTimestampExit)? + .read(self.vp.index.index(), reference) + }; + match value { + Some(value) => Ok(value), + None => self + .current_whp() + .get_register(whp::Register64::Tsc) + .for_op("read guest-programmed TSC"), + } + } + + fn complete_tsc_read( + &self, + value: u64, + rip: u64, + tsc_aux: Option, + ) -> Result<(), crate::Error> { + let rax = value & 0xffff_ffff; + let rdx = value >> 32; + match tsc_aux { + Some(aux) => { + set_registers!( + self.current_whp(), + [ + (whp::Register64::Rax, rax), + (whp::Register64::Rdx, rdx), + (whp::Register64::Rip, rip), + (whp::Register64::Rcx, aux & 0xffff_ffff), + ], + ) + .for_op("complete guest timestamp read")?; + } + None => { + set_registers!( + self.current_whp(), + [ + (whp::Register64::Rax, rax), + (whp::Register64::Rdx, rdx), + (whp::Register64::Rip, rip), + ], + ) + .for_op("complete guest timestamp read")?; + } + } + Ok(()) + } + + fn handle_rdtsc( + &self, + info: &whp::abi::WHV_X64_RDTSC_CONTEXT, + exit: whp::Exit<'_>, + ) -> Result<(), crate::Error> { + if exit.vp_context.ExecutionState.Cpl() != 0 { + let cr4 = self + .current_whp() + .get_register(whp::Register64::Cr4) + .for_op("check timestamp access permission")?; + if cr4 & x86defs::X64_CR4_TSD != 0 { + return self.inject_general_protection_fault(); + } + } + let value = self.read_restored_tsc()?; + if self.state.exits.rdtsc.get() < 4 { + tracing::debug!( + vp = self.vp.index.index(), + value, + whp_tsc = info.Tsc, + whp_reference_time = info.ReferenceTime, + "restored timestamp read" + ); + } + let rip = exit + .vp_context + .Rip + .wrapping_add(u64::from(exit.vp_context.InstructionLength())); + let tsc_aux = (info.RdtscInfo & 1 != 0).then_some(info.TscAux); + self.complete_tsc_read(value, rip, tsc_aux) + } + + fn handle_restored_tsc_msr( + &self, + info: &whp::abi::WHV_X64_MSR_ACCESS_CONTEXT, + exit: whp::Exit<'_>, + ) -> Result { + if self.state.active_vtl != Vtl::Vtl0 + || !matches!( + info.MsrNumber, + x86defs::X86X_MSR_TSC + | crate::tsc::IA32_TSC_ADJUST + | x86defs::X86X_MSR_TSC_DEADLINE + ) + { + return Ok(false); + } + let tsc_adjust_supported = { + let clock = self.vp.partition.clock.restored_tsc.lock(); + let Some(clock) = clock.as_ref() else { + return Ok(false); + }; + clock.tsc_adjust_supported + }; + if exit.vp_context.ExecutionState.Cpl() != 0 { + self.inject_general_protection_fault()?; + return Ok(true); + } + let rcx = self + .current_whp() + .get_register(whp::Register64::Rcx) + .for_op("read timestamp MSR selector")?; + let msr = crate::tsc::msr_index(info.MsrNumber, info.AccessInfo.IsWrite(), rcx); + if !matches!( + msr, + x86defs::X86X_MSR_TSC | crate::tsc::IA32_TSC_ADJUST | x86defs::X86X_MSR_TSC_DEADLINE + ) || (msr == crate::tsc::IA32_TSC_ADJUST && !tsc_adjust_supported) + || (msr == x86defs::X86X_MSR_TSC_DEADLINE && !self.vp.partition.caps.tsc_deadline) + { + self.inject_general_protection_fault()?; + return Ok(true); + } + + let rip = exit.vp_context.Rip.wrapping_add(2); + if info.AccessInfo.IsWrite() { + let value = (info.Rax & 0xffff_ffff) | (info.Rdx << 32); + let changed = if msr == x86defs::X86X_MSR_TSC_DEADLINE { + self.current_whp() + .set_register(whp::Register64::TscDeadline, value) + .for_op("write guest TSC deadline")?; + false + } else if msr == crate::tsc::IA32_TSC_ADJUST { + let previous = self + .current_whp() + .get_register(whp::Register64::TscAdjust) + .for_op("read guest TSC adjustment")?; + self.current_whp() + .set_register(whp::Register64::TscAdjust, value) + .for_op("write guest TSC adjustment")?; + value != previous + } else { + self.current_whp() + .set_register(whp::Register64::Tsc, value) + .for_op("write guest TSC")?; + true + }; + if changed { + self.vp + .partition + .clock + .restored_tsc + .lock() + .as_mut() + .ok_or(crate::Error::UnexpectedTimestampExit)? + .guest_write(self.vp.index.index()); + } + self.current_whp() + .set_register(whp::Register64::Rip, rip) + .for_op("complete guest TSC write")?; + } else { + let value = if msr == x86defs::X86X_MSR_TSC_DEADLINE { + self.current_whp() + .get_register(whp::Register64::TscDeadline) + .for_op("read guest TSC deadline")? + } else if msr == crate::tsc::IA32_TSC_ADJUST { + self.current_whp() + .get_register(whp::Register64::TscAdjust) + .for_op("read guest TSC adjustment")? + } else { + self.read_restored_tsc()? + }; + self.complete_tsc_read(value, rip, None)?; + } + Ok(true) + } +} diff --git a/vmm_core/virt_whp/src/vp_state.rs b/vmm_core/virt_whp/src/vp_state.rs index fb143214f53..854d5cb7abc 100644 --- a/vmm_core/virt_whp/src/vp_state.rs +++ b/vmm_core/virt_whp/src/vp_state.rs @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +#[cfg(guest_arch = "x86_64")] +mod tsc; + use super::Error; use crate::WhpProcessor; use crate::WhpResultExt; @@ -16,11 +19,18 @@ use zerocopy::FromZeros; pub struct WhpVpStateAccess<'a, 'b> { run: &'a mut WhpProcessor<'b>, vtl: Vtl, + #[cfg(guest_arch = "x86_64")] + tsc_deadline: tsc::TscDeadlineRead, } impl<'a> WhpProcessor<'a> { pub(crate) fn access_state(&mut self, vtl: Vtl) -> WhpVpStateAccess<'_, 'a> { - WhpVpStateAccess { run: self, vtl } + WhpVpStateAccess { + run: self, + vtl, + #[cfg(guest_arch = "x86_64")] + tsc_deadline: Default::default(), + } } } @@ -124,6 +134,7 @@ mod x86 { } fn apic(&mut self) -> Result { + self.read_tsc_deadline_before_apic()?; self.run.save_apic(self.vtl) } @@ -180,10 +191,22 @@ mod x86 { } fn tsc(&mut self) -> Result { + if let Some(tsc) = self.restored_tsc()? { + return Ok(tsc); + } self.run.vp.get_register_state(self.vtl) } fn set_tsc(&mut self, value: &vp::Tsc) -> Result<(), Self::Error> { + self.run.vp.set_register_state(self.vtl, value)?; + self.restore_tsc(value) + } + + fn tsc_deadline(&mut self) -> Result { + self.saved_tsc_deadline() + } + + fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { self.run.vp.set_register_state(self.vtl, value) } @@ -211,14 +234,6 @@ mod x86 { self.run.vp.set_register_state(self.vtl, value) } - fn tsc_deadline(&mut self) -> Result { - self.run.vp.get_register_state(self.vtl) - } - - fn set_tsc_deadline(&mut self, value: &vp::TscDeadline) -> Result<(), Self::Error> { - self.run.vp.set_register_state(self.vtl, value) - } - fn synic_msrs(&mut self) -> Result { match self.run.state.vtls[self.vtl].hv { Some(_) => { diff --git a/vmm_core/virt_whp/src/vp_state/tsc.rs b/vmm_core/virt_whp/src/vp_state/tsc.rs new file mode 100644 index 00000000000..a6505792642 --- /dev/null +++ b/vmm_core/virt_whp/src/vp_state/tsc.rs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! IA32_TSC and IA32_TSC_DEADLINE state access with the restored-TSC clock. + +use super::WhpVpStateAccess; +use crate::Error; +use crate::WhpResultExt; +use virt::x86::vp; +use virt::x86::vp::AccessVpState; + +/// IA32_TSC_DEADLINE read ordering for one state access. +#[derive(Default)] +pub(super) struct TscDeadlineRead { + before_apic: Option, + read: bool, +} + +impl WhpVpStateAccess<'_, '_> { + /// State serialization reads APIC before IA32_TSC_DEADLINE. Read the + /// deadline first so an expiry cannot fall between those snapshots. + pub(super) fn read_tsc_deadline_before_apic(&mut self) -> Result<(), Error> { + if self.caps().tsc_deadline && !self.tsc_deadline.read { + self.tsc_deadline.before_apic = Some(self.run.vp.get_register_state(self.vtl)?); + } + Ok(()) + } + + /// Returns the IA32_TSC_DEADLINE value read before the APIC state, or + /// reads it now. + pub(super) fn saved_tsc_deadline(&mut self) -> Result { + self.tsc_deadline.read = true; + if let Some(value) = self.tsc_deadline.before_apic.take() { + Ok(value) + } else { + self.run.vp.get_register_state(self.vtl) + } + } + + /// Returns the VTL0 TSC of the restored-TSC clock, unless the guest has + /// programmed this VP's counter. + pub(super) fn restored_tsc(&self) -> Result, Error> { + if self.vtl == hvdef::Vtl::Vtl0 { + let clock = self.run.vp.partition.clock.restored_tsc.lock(); + if let Some(clock) = &*clock { + let reference = self + .run + .vp + .partition + .vtl0 + .whp + .reference_time() + .for_op("read restored TSC reference time")?; + if let Some(value) = clock.read(self.run.vp.index.index(), reference) { + return Ok(Some(vp::Tsc { value })); + } + } + } + Ok(None) + } + + /// Restores this VP's VTL0 counter in the restored-TSC clock. + pub(super) fn restore_tsc(&self, value: &vp::Tsc) -> Result<(), Error> { + if self.vtl == hvdef::Vtl::Vtl0 { + let mut clock = self.run.vp.partition.clock.restored_tsc.lock(); + if let Some(clock) = &mut *clock { + let reference = self + .run + .vp + .partition + .vtl0 + .whp + .reference_time() + .for_op("restore TSC reference time")?; + clock.restore(self.run.vp.index.index(), value.value, reference); + } + } + Ok(()) + } +} From b3070731e403c6ced2fd8dd4640fa394afca4b90 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 01:03:38 -0700 Subject: [PATCH 061/304] vmm_core: advance VP TSC and APIC timers across downtime When a VM is restored after it was stopped for some time, the TSC of each VP and its local APIC timer continue from their saved values, so the guest does not observe the elapsed time and its timers fire late. Add `PartitionUnit::advance_tsc`, which advances the TSC of every stopped VP by the downtime at the guest TSC frequency. It also advances the local APIC timer of each VP at the APIC timer frequency, when the backend reports one, and fires an armed TSC deadline that the advanced TSC passed. Each VP adjusts its TSC through `Processor::advance_tsc`, reprograms its APIC and TSC deadline, and then verifies that its counter advanced by at least the requested amount, since a backend could discard the adjustment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + vmm_core/Cargo.toml | 3 + vmm_core/src/partition_unit.rs | 6 + vmm_core/src/partition_unit/snapshot.rs | 49 ++++++ vmm_core/src/partition_unit/vp_set.rs | 16 ++ vmm_core/src/partition_unit/vp_set/tsc.rs | 194 ++++++++++++++++++++++ 6 files changed, 269 insertions(+) create mode 100644 vmm_core/src/partition_unit/snapshot.rs create mode 100644 vmm_core/src/partition_unit/vp_set/tsc.rs diff --git a/Cargo.lock b/Cargo.lock index a1a239312db..5d38f7b75e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10853,6 +10853,7 @@ dependencies = [ "power_resources", "slab", "state_unit", + "test_with_tracing", "thiserror 2.0.16", "tracing", "virt", diff --git a/vmm_core/Cargo.toml b/vmm_core/Cargo.toml index fff9330f44f..8690b6783a2 100644 --- a/vmm_core/Cargo.toml +++ b/vmm_core/Cargo.toml @@ -65,5 +65,8 @@ iced-x86 = { optional = true, workspace = true, features = ["std", "fast_fmt"] } [build-dependencies] build_rs_guest_arch.workspace = true +[dev-dependencies] +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/vmm_core/src/partition_unit.rs b/vmm_core/src/partition_unit.rs index dffee436f5d..b1320ef2c16 100644 --- a/vmm_core/src/partition_unit.rs +++ b/vmm_core/src/partition_unit.rs @@ -4,6 +4,8 @@ //! State unit for managing the VM partition and associated virtual processors. mod debug; +#[cfg(guest_arch = "x86_64")] +mod snapshot; mod vp_set; pub use vp_set::Halt; @@ -130,6 +132,8 @@ enum PartitionRequest { SetInitialRegs(Rpc<(Vtl, Arc), Result<(), InitialRegError>>), AcceptInitialPages(Rpc, Result<(), AcceptInitialPagesError>>), StopVps(Rpc<(), ()>), + #[cfg(guest_arch = "x86_64")] + Snapshot(snapshot::SnapshotRequest), StartVps, /// Build the partition state blob for a dump file. #[cfg(feature = "dump")] @@ -379,6 +383,8 @@ impl PartitionUnitRunner { PartitionRequest::StopVps(rpc) => { rpc.handle(async |()| self.stop_vps().await).await } + #[cfg(guest_arch = "x86_64")] + PartitionRequest::Snapshot(req) => self.handle_snapshot(req).await, PartitionRequest::StartVps => { self.resume_vps(); } diff --git a/vmm_core/src/partition_unit/snapshot.rs b/vmm_core/src/partition_unit/snapshot.rs new file mode 100644 index 00000000000..b4087127086 --- /dev/null +++ b/vmm_core/src/partition_unit/snapshot.rs @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Partition unit support for snapshots: advancing TSC after restore downtime. + +use super::PartitionRequest; +use super::PartitionUnit; +use super::PartitionUnitRunner; +use mesh::rpc::FailableRpc; +use mesh::rpc::RpcSend; + +/// Snapshot requests handled by the partition unit runner. +pub(super) enum SnapshotRequest { + AdvanceTsc(FailableRpc<(std::time::Duration, u64, Option), ()>), +} + +impl PartitionUnit { + /// Advances TSC state on all stopped vCPUs. + pub async fn advance_tsc( + &mut self, + duration: std::time::Duration, + frequency_hz: u64, + apic_frequency_hz: Option, + ) -> anyhow::Result<()> { + self.req_send + .call_failable( + |rpc| PartitionRequest::Snapshot(SnapshotRequest::AdvanceTsc(rpc)), + (duration, frequency_hz, apic_frequency_hz), + ) + .await?; + Ok(()) + } +} + +impl PartitionUnitRunner { + /// Handles a snapshot request from [`PartitionUnit`]. + pub(super) async fn handle_snapshot(&mut self, request: SnapshotRequest) { + match request { + SnapshotRequest::AdvanceTsc(rpc) => { + rpc.handle_failable(async |(duration, frequency_hz, apic_frequency_hz)| { + self.vp_set + .advance_tsc(duration, frequency_hz, apic_frequency_hz) + .await + }) + .await + } + } + } +} diff --git a/vmm_core/src/partition_unit/vp_set.rs b/vmm_core/src/partition_unit/vp_set.rs index 535ce869d6c..e753036d7bb 100644 --- a/vmm_core/src/partition_unit/vp_set.rs +++ b/vmm_core/src/partition_unit/vp_set.rs @@ -3,6 +3,9 @@ //! Virtual processor state management. +#[cfg(guest_arch = "x86_64")] +mod tsc; + use super::HaltReason; use super::HaltReasonReceiver; use super::InternalHaltReason; @@ -78,6 +81,10 @@ trait ControlVp: ProtobufSaveRestore { /// Scrub per-VP state for a VTL. fn scrub(&mut self, vtl: Vtl) -> anyhow::Result<()>; + /// Advances the stopped vCPU TSC after snapshot downtime. + #[cfg(guest_arch = "x86_64")] + fn advance_tsc(&mut self, advance: tsc::TscAdvance) -> anyhow::Result<()>; + #[cfg(feature = "gdb")] fn debug(&mut self) -> &mut dyn DebugVp; @@ -175,6 +182,11 @@ where } } + #[cfg(guest_arch = "x86_64")] + fn advance_tsc(&mut self, advance: tsc::TscAdvance) -> anyhow::Result<()> { + advance.apply(self.vp) + } + fn inspect_vp( &mut self, vtl_guest_memory: &[Option; NUM_VTLS], @@ -1101,6 +1113,8 @@ enum StateEvent { Restore(Rpc>), Reset(mesh::rpc::FailableRpc<(), ()>), Scrub(mesh::rpc::FailableRpc), + #[cfg(guest_arch = "x86_64")] + AdvanceTsc(mesh::rpc::FailableRpc), #[cfg(feature = "dump")] GetDumpVpState(Rpc>), #[cfg(feature = "gdb")] @@ -1347,6 +1361,8 @@ impl RunnerInner { StateEvent::Restore(rpc) => rpc.handle_sync(|data| vp.restore(data)), StateEvent::Reset(rpc) => rpc.handle_failable_sync(|()| vp.reset()), StateEvent::Scrub(rpc) => rpc.handle_failable_sync(|vtl| vp.scrub(vtl)), + #[cfg(guest_arch = "x86_64")] + StateEvent::AdvanceTsc(rpc) => rpc.handle_failable_sync(|tsc| vp.advance_tsc(tsc)), #[cfg(feature = "dump")] StateEvent::GetDumpVpState(rpc) => rpc.handle_sync(|vtl| vp.get_dump_vp_state(vtl)), #[cfg(feature = "gdb")] diff --git a/vmm_core/src/partition_unit/vp_set/tsc.rs b/vmm_core/src/partition_unit/vp_set/tsc.rs new file mode 100644 index 00000000000..107535a69dd --- /dev/null +++ b/vmm_core/src/partition_unit/vp_set/tsc.rs @@ -0,0 +1,194 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! TSC advancement for snapshot restore downtime. + +use super::StateEvent; +use super::VpEvent; +use super::VpSet; +use anyhow::Context as _; +use futures::future::TryJoinAll; +use hvdef::Vtl; +use mesh::rpc::RpcSend; +use std::time::Duration; +use virt::Processor; +use virt::vp::AccessVpState; + +/// A request to advance the TSC of a stopped VP by the restore downtime. +#[derive(Debug, Copy, Clone)] +pub(super) struct TscAdvance { + duration: Duration, + frequency_hz: u64, + apic_frequency_hz: Option, +} + +struct AdvancedTsc { + value: u64, + cycles: u64, +} + +impl AdvancedTsc { + fn validate(&self, observed_tsc: u64) -> anyhow::Result<()> { + anyhow::ensure!( + observed_tsc >= self.value, + "restored TSC did not advance by the required downtime: requested {:#x}, observed {observed_tsc:#x}, adjustment {} cycles", + self.value, + self.cycles, + ); + Ok(()) + } +} + +fn advance_tsc_state( + previous_tsc: u64, + duration: Duration, + frequency_hz: u64, +) -> anyhow::Result { + let cycles = duration + .as_nanos() + .checked_mul(u128::from(frequency_hz)) + .context("TSC downtime adjustment overflows")? + / 1_000_000_000; + let cycles = u64::try_from(cycles).context("TSC downtime adjustment exceeds u64")?; + let value = previous_tsc + .checked_add(cycles) + .context("TSC downtime adjustment exceeds the counter range")?; + Ok(AdvancedTsc { value, cycles }) +} + +impl TscAdvance { + /// Advances the TSC of the stopped VP `vp`, together with its LAPIC timer + /// and TSC deadline when present. + pub(super) fn apply(self, vp: &mut impl Processor) -> anyhow::Result<()> { + let Self { + duration, + frequency_hz, + apic_frequency_hz, + } = self; + let mut access = vp.access_state(Vtl::Vtl0); + let tsc = access.tsc().context("failed to read stopped vCPU TSC")?; + let mut tsc_deadline = access + .caps() + .tsc_deadline + .then(|| access.tsc_deadline()) + .transpose() + .context("failed to read stopped vCPU TSC deadline")?; + let mut apic = apic_frequency_hz + .map(|frequency| { + let mut apic = access + .apic() + .context("failed to read stopped LAPIC timer")?; + apic.advance_timer(duration, frequency)?; + anyhow::Ok(apic) + }) + .transpose()?; + let previous_tsc = tsc.value; + let advanced = advance_tsc_state(previous_tsc, duration, frequency_hz)?; + if let (Some(apic), Some(tsc_deadline)) = (apic.as_mut(), tsc_deadline.as_mut()) { + tsc_deadline.value = + apic.advance_tsc_deadline(previous_tsc, advanced.value, tsc_deadline.value); + } + drop(access); + vp.advance_tsc(advanced.cycles) + .context("failed to adjust stopped vCPU TSC")?; + let mut access = vp.access_state(Vtl::Vtl0); + if let Some(apic) = apic.take() { + access + .set_apic(&apic) + .context("failed to reprogram stopped LAPIC timer")?; + } + if let Some(tsc_deadline) = tsc_deadline { + access + .set_tsc_deadline(&tsc_deadline) + .context("failed to reprogram stopped vCPU TSC deadline")?; + } + access + .commit() + .context("failed to commit adjusted vCPU TSC")?; + let observed_tsc = access + .tsc() + .context("failed to read back adjusted vCPU TSC")? + .value; + tracing::debug!( + previous_tsc, + requested_tsc = advanced.value, + observed_tsc, + cycles = advanced.cycles, + frequency_hz, + ?duration, + "adjusted restored vCPU TSC" + ); + advanced.validate(observed_tsc)?; + Ok(()) + } +} + +impl VpSet { + /// Advances TSC state on every stopped vCPU. + pub async fn advance_tsc( + &mut self, + duration: Duration, + frequency_hz: u64, + apic_frequency_hz: Option, + ) -> anyhow::Result<()> { + anyhow::ensure!(!self.started, "vCPUs must be stopped before adjusting TSC"); + self.vps + .iter() + .enumerate() + .map(|(index, vp)| async move { + vp.send + .call_failable( + |rpc| VpEvent::State(StateEvent::AdvanceTsc(rpc)), + TscAdvance { + duration, + frequency_hz, + apic_frequency_hz, + }, + ) + .await + .with_context(|| format!("vp{index} TSC adjustment")) + }) + .collect::>() + .await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use test_with_tracing::test; + + #[test] + fn downtime_advances_tsc_by_elapsed_cycles() { + let advanced = advance_tsc_state(1_000, Duration::from_secs(1), 1_000).unwrap(); + + assert_eq!(advanced.value, 2_000); + assert_eq!(advanced.cycles, 1_000); + } + + #[test] + fn downtime_rejects_tsc_overflow() { + assert!(advance_tsc_state(u64::MAX, Duration::from_nanos(1), 1_000_000_000).is_err()); + assert!(advance_tsc_state(0, Duration::MAX, u64::MAX).is_err()); + } + + #[test] + fn downtime_rejects_discarded_or_partial_tsc_advancement() { + let advanced = advance_tsc_state(1_000, Duration::from_millis(250), 1_000_000_000).unwrap(); + + assert!(advanced.validate(1_000).is_err()); + assert!(advanced.validate(advanced.value - 1).is_err()); + } + + #[test] + fn downtime_allows_tsc_progress_during_adjustment() { + for duration in [Duration::ZERO, Duration::from_millis(250)] { + let advanced = advance_tsc_state(1_000, duration, 1_000_000_000).unwrap(); + + advanced.validate(advanced.value).unwrap(); + advanced.validate(advanced.value + 1_000).unwrap(); + } + } +} From eea6dff56d302d2aca98b5099fb75d594d955175 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 01:10:18 -0700 Subject: [PATCH 062/304] openvmm_defs: add opt-in snapshot lifecycle profiling Measuring where snapshot capture and restore spend their time requires timing individual lifecycle phases in several OpenVMM components and processes, and correlating the results, without changing the behavior or the output of ordinary runs. Add a `profile` module to `openvmm_defs`. When the `OPENVMM_STARTUP_PROFILE` environment variable is set to a nonempty value other than `0`, a `ProfileSpan` measures an exclusive phase or a cumulative milestone and writes one machine-readable `OPENVMM_SNAPSHOT_PROFILE_V1` record to stderr, with its duration, the time since process start, the process ID, and optional byte and fault counters. Otherwise spans are inert. OpenVMM initializes the process-relative clock at startup; the lifecycle phases add their spans as they are implemented. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_defs/src/lib.rs | 1 + openvmm/openvmm_defs/src/profile.rs | 159 ++++++++++++++++++++++++++++ openvmm/openvmm_entry/src/lib.rs | 1 + 3 files changed, 161 insertions(+) create mode 100644 openvmm/openvmm_defs/src/profile.rs diff --git a/openvmm/openvmm_defs/src/lib.rs b/openvmm/openvmm_defs/src/lib.rs index 158f5cff881..face50c39c0 100644 --- a/openvmm/openvmm_defs/src/lib.rs +++ b/openvmm/openvmm_defs/src/lib.rs @@ -8,5 +8,6 @@ pub mod config; pub mod entrypoint; +pub mod profile; pub mod rpc; pub mod worker; diff --git a/openvmm/openvmm_defs/src/profile.rs b/openvmm/openvmm_defs/src/profile.rs new file mode 100644 index 00000000000..9bdc618b0d0 --- /dev/null +++ b/openvmm/openvmm_defs/src/profile.rs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Opt-in snapshot lifecycle profiling shared by OpenVMM components. + +use std::fmt::Write as _; +use std::io::Write as _; +use std::sync::OnceLock; +use std::time::Duration; +use std::time::Instant; + +/// Environment variable that enables snapshot lifecycle profiling. +pub const SNAPSHOT_PROFILE_ENV: &str = "OPENVMM_STARTUP_PROFILE"; + +/// Prefix for machine-readable snapshot lifecycle profile records. +pub const SNAPSHOT_PROFILE_PREFIX: &str = "OPENVMM_SNAPSHOT_PROFILE_V1"; + +static ENABLED: OnceLock = OnceLock::new(); +static PROCESS_STARTED: OnceLock = OnceLock::new(); + +/// Optional counters associated with a completed lifecycle phase. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct ProfileCounters { + /// Logical artifact bytes handled by the phase. + pub logical_bytes: Option, + /// Physically allocated artifact bytes handled by the phase. + pub allocated_bytes: Option, + /// Guest-physical memory faults observed through the VMM resolver. + pub gpa_faults: Option, + /// Bytes populated while resolving guest-physical memory faults. + pub populated_bytes: Option, +} + +/// An optional monotonic timer that is inert unless profiling is enabled. +#[derive(Debug)] +pub struct ProfileSpan { + started: Option, +} + +/// Initializes the process-relative profile clock when profiling is enabled. +/// +/// Call this as early as possible during process startup. Repeated calls are +/// harmless. +pub fn initialize() { + if enabled() { + PROCESS_STARTED.get_or_init(Instant::now); + } +} + +/// Returns whether snapshot lifecycle profiling is enabled. +pub fn enabled() -> bool { + *ENABLED.get_or_init(|| { + std::env::var_os(SNAPSHOT_PROFILE_ENV) + .is_some_and(|value| !value.is_empty() && value != "0") + }) +} + +impl ProfileSpan { + /// Starts an interval only when profiling is enabled. + pub fn start() -> Self { + let started = enabled().then(|| { + let now = Instant::now(); + PROCESS_STARTED.get_or_init(|| now); + now + }); + Self { started } + } + + /// Emits one exclusive phase interval. + pub fn complete(self, operation: &str, phase: &str, counters: ProfileCounters) { + self.emit(operation, phase, true, counters); + } + + /// Emits one cumulative milestone that may contain nested phase intervals. + pub fn complete_milestone(self, operation: &str, phase: &str, counters: ProfileCounters) { + self.emit(operation, phase, false, counters); + } + + fn emit(self, operation: &str, phase: &str, exclusive: bool, counters: ProfileCounters) { + let Some(started) = self.started else { + return; + }; + let ended = Instant::now(); + let process_started = PROCESS_STARTED.get().copied().unwrap_or(started); + let line = format_record( + operation, + phase, + exclusive, + ended.duration_since(started), + ended.duration_since(process_started), + std::process::id(), + counters, + ); + // Profiling must never change VM behavior. In particular, a closed + // diagnostic stream is not a lifecycle failure. + let _ = writeln!(std::io::stderr().lock(), "{line}"); + } +} + +fn format_record( + operation: &str, + phase: &str, + exclusive: bool, + duration: Duration, + process_elapsed: Duration, + pid: u32, + counters: ProfileCounters, +) -> String { + let mut line = format!( + "{SNAPSHOT_PROFILE_PREFIX} operation={operation} phase={phase} exclusive={} \ + duration_ns={} process_elapsed_ns={} pid={pid}", + u8::from(exclusive), + duration.as_nanos(), + process_elapsed.as_nanos(), + ); + if let Some(value) = counters.logical_bytes { + let _ = write!(line, " logical_bytes={value}"); + } + if let Some(value) = counters.allocated_bytes { + let _ = write!(line, " allocated_bytes={value}"); + } + if let Some(value) = counters.gpa_faults { + let _ = write!(line, " gpa_faults={value}"); + } + if let Some(value) = counters.populated_bytes { + let _ = write!(line, " populated_bytes={value}"); + } + line +} + +#[cfg(test)] +mod tests { + use super::ProfileCounters; + use super::format_record; + use std::time::Duration; + + #[test] + fn profile_record_has_stable_machine_readable_fields() { + assert_eq!( + format_record( + "restore", + "artifact_open", + true, + Duration::from_nanos(12), + Duration::from_nanos(34), + 56, + ProfileCounters { + logical_bytes: Some(78), + allocated_bytes: Some(90), + gpa_faults: None, + populated_bytes: None, + }, + ), + "OPENVMM_SNAPSHOT_PROFILE_V1 operation=restore phase=artifact_open \ + exclusive=1 duration_ns=12 process_elapsed_ns=34 pid=56 \ + logical_bytes=78 allocated_bytes=90" + ); + } +} diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index b5adc095ca6..8b865951810 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2696,6 +2696,7 @@ fn prepare_snapshot_restore( } fn do_main(pidfile_guard: &mut Option) -> anyhow::Result { + openvmm_defs::profile::initialize(); #[cfg(windows)] pal::windows::disable_hard_error_dialog(); From 388d52800538ba23f0551222a63fbb12fbaaa122 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 01:20:16 -0700 Subject: [PATCH 063/304] openvmm_helpers: version the snapshot manifest format and bound its decoding The snapshot manifest identified its format only by a version number: nothing marked the file as an OpenVMM snapshot, or recorded which saved-state schema `state.bin` holds and how long it is. Reading a snapshot also decoded `manifest.bin` and read `state.bin` without any size limit. Introduce manifest version 3, which adds a format magic, the saved-state schema version and protobuf root type, and the length of `state.bin`, and move the format definitions and their validation into a `format` module. `SnapshotManifest` implements `Default` with the current format identifiers, and the callers that build manifests take the fields that they do not set from it. Manifests of the earlier version 2 format, which records SHA-256 digests of `state.bin` and `memory.bin` instead, are accepted as well; version 1 manifests are no longer accepted. Writing a snapshot records the length of `state.bin` and rejects saved state larger than 256 MiB. Reading a snapshot limits `manifest.bin` to 1 MiB, validates the manifest format before it reads `state.bin`, and requires `state.bin` to have the recorded length. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/dev_guide/snapshot_format.md | 10 ++ openvmm/openvmm_entry/src/vm_controller.rs | 1 + openvmm/openvmm_helpers/src/snapshot.rs | 36 +++- .../openvmm_helpers/src/snapshot/format.rs | 160 ++++++++++++++++++ .../openvmm_helpers/src/snapshot/publish.rs | 9 +- .../openvmm_helpers/src/snapshot/restore.rs | 48 +++++- vmm_tests/vmm_tests/tests/tests/x86_64.rs | 1 + 7 files changed, 250 insertions(+), 15 deletions(-) create mode 100644 openvmm/openvmm_helpers/src/snapshot/format.rs diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 809822ce089..6b0d5e4ed38 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -21,6 +21,16 @@ The manifest is a protobuf message defined as in `openvmm/openvmm_helpers/src/snapshot.rs`, encoded using the `mesh` crate's protobuf encoding. +New snapshots use manifest version 3, which records a format magic, the +saved-state schema version and protobuf root type, and the exact length of +`state.bin`. The legacy `state_sha256` and `memory_sha256` protobuf tags remain +reserved so version 2 manifests can be decoded; version 3 requires both fields +to be absent. Restore accepts versions 2 and 3. + +Reading a snapshot bounds `manifest.bin` to 1 MiB and `state.bin` to 256 MiB, +validates the manifest format before it reads `state.bin`, and requires +`state.bin` to have the length that the manifest records. + ## Device state (`state.bin`) The device state contains every device's saved state, collected via the diff --git a/openvmm/openvmm_entry/src/vm_controller.rs b/openvmm/openvmm_entry/src/vm_controller.rs index 2e87654b9aa..f51d00f01af 100644 --- a/openvmm/openvmm_entry/src/vm_controller.rs +++ b/openvmm/openvmm_entry/src/vm_controller.rs @@ -496,6 +496,7 @@ impl VmController { vp_count: self.processors, page_size: crate::system_page_size(), architecture: crate::GUEST_ARCH.to_string(), + ..Default::default() }; // Write snapshot directory. diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index 2d7f51e764f..7e25cb65644 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -6,6 +6,7 @@ use mesh::payload::Protobuf; use mesh::payload::Timestamp; +pub mod format; pub mod publish; pub mod restore; @@ -13,7 +14,7 @@ pub use publish::write_snapshot; pub use restore::read_snapshot; /// Current manifest format version. Bump when making incompatible changes. -pub const MANIFEST_VERSION: u32 = 1; +pub const MANIFEST_VERSION: u32 = 3; /// Manifest describing a VM snapshot. #[derive(Clone, Protobuf)] @@ -40,6 +41,24 @@ pub struct SnapshotManifest { /// Architecture string ("x86_64" or "aarch64"). #[mesh(7)] pub architecture: String, + /// Length of `state.bin` in bytes. + #[mesh(8)] + pub state_size_bytes: u64, + /// Legacy v2 SHA-256 digest of `state.bin`; empty in v3. + #[mesh(9)] + pub state_sha256: Vec, + /// Legacy v2 SHA-256 digest of `memory.bin`; empty in v3. + #[mesh(10)] + pub memory_sha256: Vec, + /// Snapshot format magic. + #[mesh(12)] + pub format_magic: Vec, + /// Version of the serialized VM saved-state schema. + #[mesh(13)] + pub saved_state_schema_version: u32, + /// Fully qualified protobuf root type stored in `state.bin`. + #[mesh(14)] + pub saved_state_root_type: String, } /// Validate that a snapshot manifest is compatible with the running VM config. @@ -54,13 +73,8 @@ pub fn validate_manifest( expected_vp_count: u32, expected_page_size: u32, ) -> anyhow::Result<()> { - if manifest.version != MANIFEST_VERSION { - anyhow::bail!( - "snapshot manifest version {} is not supported (expected {})", - manifest.version, - MANIFEST_VERSION, - ); - } + format::validate_manifest_header(manifest)?; + format::validate_manifest_version(manifest)?; if manifest.architecture != expected_arch { anyhow::bail!( @@ -102,7 +116,7 @@ mod tests { use super::*; /// Helper: build a test manifest with sensible defaults. - fn test_manifest() -> SnapshotManifest { + pub(super) fn test_manifest() -> SnapshotManifest { SnapshotManifest { version: MANIFEST_VERSION, created_at: Timestamp { @@ -114,6 +128,7 @@ mod tests { vp_count: 2, page_size: 4096, architecture: "x86_64".to_string(), + ..Default::default() } } @@ -136,6 +151,9 @@ mod tests { assert_eq!(read_manifest.memory_size_bytes, manifest.memory_size_bytes); assert_eq!(read_manifest.vp_count, manifest.vp_count); assert_eq!(read_manifest.architecture, manifest.architecture); + assert_eq!(read_manifest.state_size_bytes, state.len() as u64); + assert!(read_manifest.state_sha256.is_empty()); + assert!(read_manifest.memory_sha256.is_empty()); assert_eq!(read_state, state); // memory.bin should exist in the snapshot directory. diff --git a/openvmm/openvmm_helpers/src/snapshot/format.rs b/openvmm/openvmm_helpers/src/snapshot/format.rs new file mode 100644 index 00000000000..639fc8e9fbf --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/format.rs @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot format identifiers and manifest format validation: format magic +//! and supported versions, saved-state schema, and size limits. + +use super::MANIFEST_VERSION; +use super::SnapshotManifest; +use mesh::payload::Timestamp; + +/// Magic identifying the OpenVMM snapshot manifest format. +pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V3\0"; +pub(super) const LEGACY_MANIFEST_VERSION: u32 = 2; +pub(super) const LEGACY_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V2\0"; +/// Saved-state schema version used by the VM worker envelope. +pub const SAVED_STATE_SCHEMA_VERSION: u32 = 1; +/// Protobuf root type stored in `state.bin`. +pub const SAVED_STATE_ROOT_TYPE: &str = "openvmm.SavedState"; + +pub(super) const MAX_MANIFEST_SIZE_BYTES: u64 = 1024 * 1024; +pub(super) const MAX_SAVED_STATE_SIZE_BYTES: u64 = 256 * 1024 * 1024; +pub(super) const SHA256_SIZE: usize = 32; + +/// An empty manifest in the current snapshot format. +/// +/// Capture code sets the identity fields (version, creation time, OpenVMM +/// version, and VM shape) explicitly and takes the rest from this default: +/// the current format magic, saved-state schema, and root type, with every +/// other field empty. +impl Default for SnapshotManifest { + fn default() -> Self { + Self { + version: MANIFEST_VERSION, + created_at: Timestamp { + seconds: 0, + nanos: 0, + }, + openvmm_version: String::new(), + memory_size_bytes: 0, + vp_count: 0, + page_size: 0, + architecture: String::new(), + state_size_bytes: 0, + state_sha256: Vec::new(), + memory_sha256: Vec::new(), + format_magic: SNAPSHOT_FORMAT_MAGIC.to_vec(), + saved_state_schema_version: SAVED_STATE_SCHEMA_VERSION, + saved_state_root_type: SAVED_STATE_ROOT_TYPE.to_owned(), + } + } +} + +pub(super) fn validate_manifest_header(manifest: &SnapshotManifest) -> anyhow::Result<()> { + let expected_magic = match manifest.version { + LEGACY_MANIFEST_VERSION => LEGACY_SNAPSHOT_FORMAT_MAGIC, + MANIFEST_VERSION => SNAPSHOT_FORMAT_MAGIC, + version => anyhow::bail!( + "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {MANIFEST_VERSION})" + ), + }; + anyhow::ensure!( + manifest.format_magic == expected_magic, + "snapshot format magic is invalid" + ); + anyhow::ensure!( + manifest.saved_state_schema_version == SAVED_STATE_SCHEMA_VERSION, + "snapshot saved-state schema version {} is unsupported", + manifest.saved_state_schema_version + ); + anyhow::ensure!( + manifest.saved_state_root_type == SAVED_STATE_ROOT_TYPE, + "snapshot saved-state root type '{}' is unsupported", + manifest.saved_state_root_type + ); + Ok(()) +} + +pub(super) fn validate_manifest_version(manifest: &SnapshotManifest) -> anyhow::Result<()> { + match manifest.version { + LEGACY_MANIFEST_VERSION => { + anyhow::ensure!( + manifest.state_sha256.len() == SHA256_SIZE, + "legacy state.bin SHA-256 digest has invalid length {}", + manifest.state_sha256.len(), + ); + anyhow::ensure!( + manifest.memory_sha256.len() == SHA256_SIZE, + "legacy memory.bin SHA-256 digest has invalid length {}", + manifest.memory_sha256.len(), + ); + } + MANIFEST_VERSION => { + anyhow::ensure!( + manifest.state_sha256.is_empty() && manifest.memory_sha256.is_empty(), + "snapshot manifest version {} must not contain legacy artifact digests", + manifest.version, + ); + } + version => anyhow::bail!( + "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {MANIFEST_VERSION})" + ), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::tests::test_manifest; + use super::super::validate_manifest; + use super::*; + + #[test] + fn current_manifest_rejects_legacy_payload_digests() { + let mut manifest = test_manifest(); + manifest.state_sha256 = vec![0; SHA256_SIZE]; + let error = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!(error.to_string().contains("legacy artifact digests")); + } + + #[test] + fn legacy_v2_manifest_requires_payload_digests() { + let mut manifest = test_manifest(); + manifest.version = LEGACY_MANIFEST_VERSION; + manifest.format_magic = LEGACY_SNAPSHOT_FORMAT_MAGIC.to_vec(); + let error = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!( + error + .to_string() + .contains("legacy state.bin SHA-256 digest") + ); + + manifest.state_sha256 = vec![0; SHA256_SIZE]; + manifest.memory_sha256 = vec![0; SHA256_SIZE]; + validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap(); + } + + #[test] + fn validate_manifest_wrong_magic() { + let mut manifest = test_manifest(); + manifest.format_magic = b"NOT_OPENVMM".to_vec(); + let err = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!(err.to_string().contains("format magic")); + } + + #[test] + fn validate_manifest_wrong_saved_state_schema() { + let mut manifest = test_manifest(); + manifest.saved_state_schema_version += 1; + let err = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!(err.to_string().contains("schema version")); + } + + #[test] + fn validate_manifest_wrong_saved_state_root() { + let mut manifest = test_manifest(); + manifest.saved_state_root_type = "other.SavedState".to_owned(); + let err = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!(err.to_string().contains("root type")); + } +} diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs index 693db4acdfa..35def60b583 100644 --- a/openvmm/openvmm_helpers/src/snapshot/publish.rs +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -4,6 +4,7 @@ //! Writing VM snapshots to a directory. use super::SnapshotManifest; +use super::format::MAX_SAVED_STATE_SIZE_BYTES; use anyhow::Context; use std::path::Path; @@ -19,10 +20,16 @@ pub fn write_snapshot( saved_state_bytes: &[u8], memory_file_path: &Path, ) -> anyhow::Result<()> { + anyhow::ensure!( + u64::try_from(saved_state_bytes.len()).unwrap_or(u64::MAX) <= MAX_SAVED_STATE_SIZE_BYTES, + "saved state exceeds the maximum size of {MAX_SAVED_STATE_SIZE_BYTES} bytes" + ); fs_err::create_dir_all(dir)?; // Write manifest. - let manifest_bytes = mesh::payload::encode(manifest.clone()); + let mut manifest = manifest.clone(); + manifest.state_size_bytes = saved_state_bytes.len() as u64; + let manifest_bytes = mesh::payload::encode(manifest); fs_err::write(dir.join("manifest.bin"), &manifest_bytes)?; // Write device state. diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs index cf99dd13f29..28eb7f953da 100644 --- a/openvmm/openvmm_helpers/src/snapshot/restore.rs +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -4,20 +4,58 @@ //! Reading VM snapshots from a directory. use super::SnapshotManifest; +use super::format::MAX_MANIFEST_SIZE_BYTES; +use super::format::MAX_SAVED_STATE_SIZE_BYTES; +use super::format::validate_manifest_header; use anyhow::Context; +use std::io::Read; use std::path::Path; /// Read a snapshot from the given directory. /// -/// Returns the decoded manifest and the raw saved-state bytes. -/// The caller is responsible for opening `memory.bin` separately. +/// Returns the decoded manifest and the raw saved-state bytes. The manifest +/// and `state.bin` are read with bounded sizes, and `state.bin` must have the +/// length that the manifest records. The caller is responsible for validating +/// the manifest against the VM and for opening `memory.bin` separately. pub fn read_snapshot(dir: &Path) -> anyhow::Result<(SnapshotManifest, Vec)> { - let manifest_bytes = - fs_err::read(dir.join("manifest.bin")).context("failed to read manifest.bin")?; + let manifest_bytes = read_bounded( + &dir.join("manifest.bin"), + MAX_MANIFEST_SIZE_BYTES, + "manifest.bin", + )?; let manifest: SnapshotManifest = mesh::payload::decode(&manifest_bytes).context("failed to decode snapshot manifest")?; + validate_manifest_header(&manifest)?; + anyhow::ensure!( + manifest.state_size_bytes <= MAX_SAVED_STATE_SIZE_BYTES, + "state.bin exceeds the maximum size of {MAX_SAVED_STATE_SIZE_BYTES} bytes" + ); - let state_bytes = fs_err::read(dir.join("state.bin")).context("failed to read state.bin")?; + let state_bytes = read_bounded( + &dir.join("state.bin"), + manifest.state_size_bytes, + "state.bin", + )?; + anyhow::ensure!( + state_bytes.len() as u64 == manifest.state_size_bytes, + "state.bin length {} does not match the manifest length {}", + state_bytes.len(), + manifest.state_size_bytes, + ); Ok((manifest, state_bytes)) } + +/// Reads at most `limit` bytes of the file at `path`, failing if it is longer. +fn read_bounded(path: &Path, limit: u64, description: &str) -> anyhow::Result> { + let file = fs_err::File::open(path).with_context(|| format!("failed to open {description}"))?; + let mut bytes = Vec::new(); + file.take(limit.saturating_add(1)) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read {description}"))?; + anyhow::ensure!( + bytes.len() as u64 <= limit, + "{description} exceeds the maximum size of {limit} bytes" + ); + Ok(bytes) +} diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64.rs b/vmm_tests/vmm_tests/tests/tests/x86_64.rs index 6a1af29d6c1..50f7c561131 100644 --- a/vmm_tests/vmm_tests/tests/tests/x86_64.rs +++ b/vmm_tests/vmm_tests/tests/tests/x86_64.rs @@ -608,6 +608,7 @@ async fn snapshot_save_to_disk( vp_count: 2, page_size: 4096, architecture: "x86_64".to_string(), + ..Default::default() }; openvmm_helpers::snapshot::write_snapshot(&snap_dir, &manifest, &saved_state_bytes, &mem_path)?; From 9f680d5b3ad67ceb08bc587c93ff531cab59e907 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 01:32:38 -0700 Subject: [PATCH 064/304] openvmm_helpers: publish snapshots atomically through durable staging Saving a snapshot created the destination directory, wrote the manifest and the saved state into it, and hard-linked the memory backing file as `memory.bin`. A failure could leave a partial snapshot at the destination, nothing was flushed to stable storage, an existing directory was silently reused, and the hard link made the snapshot an alias of live guest RAM that any later write to the backing file corrupted. The backing file also had to be on the same filesystem as the snapshot. Publish snapshots through a private, uniquely named staging directory next to the destination. `state.bin`, `memory.bin`, and `manifest.bin` are written and flushed there, the staging directory is flushed, and it is then renamed to the destination without replacing an existing path. The rename is the commit point, after which the parent directory is flushed. A failure before the commit removes the staging directory and leaves the destination absent; `SnapshotWriteError` distinguishes it from a committed snapshot whose parent directory could not be flushed. `memory.bin` is now an independent copy of guest RAM, made from an open handle so that replacing the path of the backing file cannot substitute other bytes, and `write_snapshot_from_memory_file` accepts that handle directly. The copy clones the file when the filesystem supports it, and otherwise copies only allocated ranges or nonzero data on Linux and copies densely on Windows. The backing file must have the length that the manifest records. Add the snapshot file-system helpers that this needs: private directories, no-replace renames, directory flushes, regular-file access that rejects Windows reparse points, and sparse-aware copies. Update the Guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 4 + Guide/src/dev_guide/snapshot_format.md | 32 +- Guide/src/user_guide/openvmm/snapshots.md | 19 +- openvmm/openvmm_helpers/Cargo.toml | 11 +- openvmm/openvmm_helpers/src/snapshot.rs | 36 +- .../openvmm_helpers/src/snapshot/format.rs | 5 +- openvmm/openvmm_helpers/src/snapshot/fs.rs | 600 ++++++++++++++++++ .../openvmm_helpers/src/snapshot/publish.rs | 475 ++++++++++++-- 8 files changed, 1067 insertions(+), 115 deletions(-) create mode 100644 openvmm/openvmm_helpers/src/snapshot/fs.rs diff --git a/Cargo.lock b/Cargo.lock index 5d38f7b75e8..14a5b907cfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6142,11 +6142,15 @@ dependencies = [ "hypervisor_resources", "libc", "mesh", + "nix 0.30.1", "openvmm_defs", + "pal", "sparse_mmap", "tempfile", + "thiserror 2.0.16", "tracing", "vm_resource", + "windows-sys 0.61.0", ] [[package]] diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 6b0d5e4ed38..9ad65501744 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -11,7 +11,7 @@ A snapshot is stored as a directory containing three files: snapshot-dir/ ├── manifest.bin # Protobuf-encoded SnapshotManifest ├── state.bin # Protobuf-encoded device saved state -└── memory.bin # Hard link to the guest memory backing file +└── memory.bin # Independent copy of the guest memory backing file ``` ## Manifest format @@ -40,26 +40,28 @@ default values, forward/backward compatibility) apply. ## Memory (`memory.bin`) -`memory.bin` is a hard link to the file-backed guest RAM file. During a save, -`write_snapshot()` creates this hard link using `std::fs::hard_link`. +`memory.bin` is an independent copy of the file-backed guest RAM, so resuming +or reusing the source VM cannot modify a published snapshot. Clone support is +used when available, with allocated-range or zero-scan copying as a fallback on +Linux; Windows uses a dense copy. The copy reads through an already-open handle +rather than reopening the backing file's path, so replacing the source path +cannot substitute different bytes. -```admonish note -The hard-link approach means the memory backing file and snapshot directory -must reside on the same filesystem. If they are on different filesystems, -`write_snapshot` returns an error with a suggestion to place the backing -file inside the snapshot directory. -``` - -### Same-file detection +## Publication -If the user passes `--memory file=/memory.bin`, the source and -target of the hard link are the same file. The code detects this by -canonicalizing both paths and comparing them. When they match, the hard-link -step is skipped. +`write_snapshot()` writes and flushes `state.bin`, `memory.bin`, and +`manifest.bin` in a uniquely named, private staging directory next to the +destination, flushes the staging directory, and then renames it to the +destination in one operation that never replaces an existing path. The rename +is the commit point; the parent directory is flushed afterwards. A failure +before the commit removes the staging directory and leaves the destination +absent. The destination must not already exist. ## Code references - Manifest type and I/O: `openvmm/openvmm_helpers/src/snapshot.rs` +- Format validation, publication, and file-system helpers: + `openvmm/openvmm_helpers/src/snapshot/` - Restore entry point: `prepare_snapshot_restore()` in `openvmm/openvmm_entry/src/lib.rs` - File-backed memory: `SharedMemoryFd` type alias in diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 65fbe83d8fd..956b0f1dc7d 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -26,11 +26,10 @@ Snapshots require **file-backed guest memory**. Pass `file=` in the `--memory` option when launching the VM so that guest RAM is written to a file on disk rather than held in anonymous memory. -```admonish warning -The memory backing file and the snapshot directory must be on the **same -filesystem**. OpenVMM creates a hard link from the backing file to -`memory.bin` inside the snapshot directory, which does not work across -filesystem boundaries. +```admonish note +OpenVMM copies the memory backing file into `memory.bin`, using file cloning +when the filesystem supports it, so the backing file and the snapshot directory +can be on different filesystems. ``` ## Saving a snapshot @@ -52,12 +51,14 @@ specifying the output directory: save-snapshot path/to/snapshot-dir ``` -OpenVMM writes `manifest.bin`, `state.bin`, and a hard link to `memory.bin` -into the specified directory. +OpenVMM writes and flushes `manifest.bin`, `state.bin`, and a copy of guest +RAM in `memory.bin` in a uniquely named sibling staging directory, and then +atomically renames the completed directory to the specified path. The +destination must not already exist. The rename is the commit point: if saving +fails before it, nothing is published at the destination. ```admonish warning -After saving, the VM remains **paused** and resume is blocked. Resuming -would mutate guest RAM through `memory.bin`, corrupting the snapshot. +After saving, the VM remains **paused** and the REPL blocks resume. Use `shutdown` to exit OpenVMM after saving. ``` diff --git a/openvmm/openvmm_helpers/Cargo.toml b/openvmm/openvmm_helpers/Cargo.toml index 60c3a6e4c93..95631168712 100644 --- a/openvmm/openvmm_helpers/Cargo.toml +++ b/openvmm/openvmm_helpers/Cargo.toml @@ -13,21 +13,30 @@ disklayer_vhdx.workspace = true get_resources.workspace = true hypervisor_resources.workspace = true openvmm_defs.workspace = true +pal.workspace = true vm_resource.workspace = true mesh.workspace = true anyhow.workspace = true fs-err.workspace = true +thiserror.workspace = true tracing.workspace = true -[target.'cfg(target_os = "linux")'.dependencies] +[target.'cfg(unix)'.dependencies] libc.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +nix = { workspace = true, features = ["dir", "fs"] } + [target.'cfg(windows)'.dependencies] disk_vhdmp.workspace = true sparse_mmap.workspace = true +[target.'cfg(windows)'.dependencies.windows-sys] +workspace = true +features = ["Win32_Storage_FileSystem"] + [dev-dependencies] tempfile.workspace = true diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index 7e25cb65644..188e6e1f7d4 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -7,6 +7,7 @@ use mesh::payload::Protobuf; use mesh::payload::Timestamp; pub mod format; +pub mod fs; pub mod publish; pub mod restore; @@ -139,7 +140,7 @@ mod tests { // Create a fake memory backing file in the same directory (same fs). let mem_path = dir.path().join("memory.bin"); - std::fs::write(&mem_path, b"FAKEMEM").unwrap(); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); let manifest = test_manifest(); let state = b"saved-state-data"; @@ -160,39 +161,6 @@ mod tests { assert!(snap_dir.join("memory.bin").exists()); } - #[test] - fn write_snapshot_creates_dir() { - let dir = tempfile::tempdir().unwrap(); - let snap_dir = dir.path().join("a").join("b").join("c"); - - let mem_path = dir.path().join("memory.bin"); - std::fs::write(&mem_path, b"MEM").unwrap(); - - write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); - - assert!(snap_dir.join("manifest.bin").exists()); - assert!(snap_dir.join("state.bin").exists()); - assert!(snap_dir.join("memory.bin").exists()); - } - - #[test] - fn write_snapshot_same_memory_path() { - // When the memory backing file IS /memory.bin, the function - // should detect the collision and skip the hard-link. - let dir = tempfile::tempdir().unwrap(); - let snap_dir = dir.path().join("snap"); - std::fs::create_dir_all(&snap_dir).unwrap(); - - let mem_path = snap_dir.join("memory.bin"); - std::fs::write(&mem_path, b"SAMEFILE").unwrap(); - - // Should succeed without error. - write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); - - // The file content should be unchanged. - assert_eq!(std::fs::read(&mem_path).unwrap(), b"SAMEFILE"); - } - #[test] fn read_snapshot_missing_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/openvmm/openvmm_helpers/src/snapshot/format.rs b/openvmm/openvmm_helpers/src/snapshot/format.rs index 639fc8e9fbf..3bf041df843 100644 --- a/openvmm/openvmm_helpers/src/snapshot/format.rs +++ b/openvmm/openvmm_helpers/src/snapshot/format.rs @@ -2,7 +2,7 @@ // Licensed under the MIT License. //! Snapshot format identifiers and manifest format validation: format magic -//! and supported versions, saved-state schema, and size limits. +//! and supported versions, saved-state schema, artifact names, and size limits. use super::MANIFEST_VERSION; use super::SnapshotManifest; @@ -17,6 +17,9 @@ pub const SAVED_STATE_SCHEMA_VERSION: u32 = 1; /// Protobuf root type stored in `state.bin`. pub const SAVED_STATE_ROOT_TYPE: &str = "openvmm.SavedState"; +pub(super) const MANIFEST_FILE_NAME: &str = "manifest.bin"; +pub(super) const STATE_FILE_NAME: &str = "state.bin"; +pub(super) const MEMORY_FILE_NAME: &str = "memory.bin"; pub(super) const MAX_MANIFEST_SIZE_BYTES: u64 = 1024 * 1024; pub(super) const MAX_SAVED_STATE_SIZE_BYTES: u64 = 256 * 1024 * 1024; pub(super) const SHA256_SIZE: usize = 32; diff --git a/openvmm/openvmm_helpers/src/snapshot/fs.rs b/openvmm/openvmm_helpers/src/snapshot/fs.rs new file mode 100644 index 00000000000..852dcf1e315 --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/fs.rs @@ -0,0 +1,600 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! File-system helpers for snapshot artifacts, including their +//! platform-specific implementations: private files and directories, +//! no-replace renames and directory flushes, and sparse-aware clone and copy +//! with allocation accounting. + +use anyhow::Context; +#[cfg(windows)] +use std::io::Read; +#[cfg(windows)] +use std::io::Seek; +#[cfg(windows)] +use std::io::SeekFrom; +use std::io::Write; +use std::path::Path; + +const COPY_BUFFER_SIZE: usize = 1024 * 1024; + +pub(super) fn snapshot_parent(dir: &Path) -> &Path { + match dir.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + } +} + +pub(super) fn path_exists(path: &Path) -> anyhow::Result { + match fs_err::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err).with_context(|| format!("failed to inspect {}", path.display())), + } +} + +pub(super) fn ensure_path_absent(path: &Path, description: &str) -> anyhow::Result<()> { + anyhow::ensure!( + !path_exists(path)?, + "{description} already exists: {}", + path.display(), + ); + Ok(()) +} + +pub(super) fn validate_directory(path: &Path, description: &str) -> anyhow::Result<()> { + let metadata = fs_err::symlink_metadata(path) + .with_context(|| format!("failed to inspect {description} {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_dir(), + "{description} is not a directory: {}", + path.display(), + ); + Ok(()) +} + +pub(super) fn create_private_directory(path: &Path) -> std::io::Result<()> { + #[cfg(windows)] + { + let descriptor: pal::windows::security::LocalSecurityDescriptor = + "D:P(A;;FA;;;SY)(A;;FA;;;OW)".parse()?; + pal::windows::security::create::create_directory_with_security(path, &descriptor) + } + #[cfg(not(windows))] + let mut builder = std::fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + #[cfg(not(windows))] + return builder.create(path); +} + +#[cfg(all(target_os = "linux", target_env = "gnu"))] +pub(super) fn rename_no_replace(source: &Path, destination: &Path) -> anyhow::Result<()> { + let source_parent = snapshot_parent(source); + let destination_parent = snapshot_parent(destination); + anyhow::ensure!( + source_parent == destination_parent, + "snapshot staging and destination directories have different parents", + ); + let source_name = source + .file_name() + .context("snapshot staging path must name a directory")?; + let destination_name = destination + .file_name() + .context("snapshot destination must name a directory")?; + let parent = std::fs::File::open(destination_parent).with_context(|| { + format!( + "failed to open snapshot parent directory {}", + destination_parent.display() + ) + })?; + nix::fcntl::renameat2( + &parent, + source_name, + &parent, + destination_name, + nix::fcntl::RenameFlags::RENAME_NOREPLACE, + )?; + Ok(()) +} + +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +pub(super) fn rename_no_replace(source: &Path, destination: &Path) -> anyhow::Result<()> { + fs_err::rename(source, destination)?; + Ok(()) +} + +fn create_file(path: &Path, description: &str) -> anyhow::Result { + #[cfg(windows)] + { + let descriptor: pal::windows::security::LocalSecurityDescriptor = + "D:P(A;;FA;;;SY)(A;;FA;;;OW)".parse()?; + pal::windows::security::create::create_file_with_security(path, &descriptor) + .with_context(|| format!("failed to create {description} at {}", path.display())) + } + #[cfg(not(windows))] + let mut options = fs_err::OpenOptions::new(); + #[cfg(not(windows))] + options.write(true).create_new(true); + #[cfg(unix)] + { + use fs_err::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(not(windows))] + return options + .open(path) + .map(Into::into) + .with_context(|| format!("failed to create {description} at {}", path.display())); +} + +pub(super) fn write_bytes(path: &Path, bytes: &[u8], description: &str) -> anyhow::Result<()> { + let mut file = create_file(path, description)?; + file.write_all(bytes) + .with_context(|| format!("failed to write {description}"))?; + file.sync_all() + .with_context(|| format!("failed to flush {description}"))?; + Ok(()) +} + +pub(super) fn copy_exact( + source_file: &std::fs::File, + destination_path: &Path, + expected_length: u64, + source_description: &str, + destination_description: &str, +) -> anyhow::Result<()> { + let source_metadata = source_file + .metadata() + .with_context(|| format!("failed to inspect {source_description}"))?; + anyhow::ensure!( + source_metadata.file_type().is_file(), + "{source_description} handle is not a regular file" + ); + let source_length = source_metadata.len(); + anyhow::ensure!( + source_length == expected_length, + "{source_description} size ({source_length} bytes) doesn't match manifest ({expected_length} bytes)", + ); + let destination = create_file(destination_path, destination_description)?; + let method = clone_or_copy(source_file, &destination, expected_length) + .with_context(|| format!("failed to copy {source_description}"))?; + + anyhow::ensure!( + source_file + .metadata() + .with_context(|| format!("failed to re-inspect {source_description}"))? + .len() + == expected_length, + "{source_description} changed length while it was being copied", + ); + anyhow::ensure!( + destination + .metadata() + .with_context(|| format!("failed to inspect {destination_description}"))? + .len() + == expected_length, + "{destination_description} length does not match {source_description}", + ); + + destination + .sync_all() + .with_context(|| format!("failed to flush {destination_description}"))?; + let source_allocated_bytes = allocated_file_bytes(source_file, expected_length).ok(); + let allocated_bytes = allocated_file_bytes(&destination, expected_length).ok(); + tracing::info!( + method, + logical_bytes = expected_length, + ?source_allocated_bytes, + ?allocated_bytes, + "published independent snapshot artifact" + ); + Ok(()) +} + +#[cfg(not(windows))] +fn size_empty_file(file: &std::fs::File, length: u64, description: &str) -> anyhow::Result<()> { + file.set_len(0) + .with_context(|| format!("failed to reset {description}"))?; + file.set_len(length) + .with_context(|| format!("failed to size {description}"))?; + anyhow::ensure!( + file.metadata() + .with_context(|| format!("failed to inspect {description}"))? + .len() + == length, + "{description} has the wrong logical length after sizing" + ); + Ok(()) +} + +#[cfg(target_os = "linux")] +fn clone_or_copy( + source: &std::fs::File, + destination: &std::fs::File, + length: u64, +) -> anyhow::Result<&'static str> { + let clone_error = match pal::unix::fs::reflink(source, destination) { + Ok(()) => return Ok("ficlone"), + Err(error) => error, + }; + tracing::debug!( + error = &clone_error as &dyn std::error::Error, + "FICLONE unavailable" + ); + size_empty_file(destination, length, "snapshot clone destination")?; + match linux_allocated_ranges(source, length) { + Ok(ranges) => { + copy_allocated_ranges(source, destination, length, &ranges)?; + Ok("seek-data-hole") + } + Err(error) => { + tracing::debug!( + error = &error as &dyn std::error::Error, + "SEEK_DATA/SEEK_HOLE unavailable" + ); + copy_nonzero_data(source, destination, 0, length)?; + Ok("zero-scan") + } + } +} + +#[cfg(windows)] +fn clone_or_copy( + source: &std::fs::File, + destination: &std::fs::File, + length: u64, +) -> anyhow::Result<&'static str> { + let mut source = source + .try_clone() + .context("failed to duplicate snapshot memory source handle")?; + source + .seek(SeekFrom::Start(0)) + .context("failed to rewind snapshot memory source")?; + let mut destination = destination + .try_clone() + .context("failed to duplicate snapshot memory destination handle")?; + destination + .seek(SeekFrom::Start(0)) + .context("failed to rewind snapshot memory destination")?; + + let limit = length + .checked_add(1) + .context("snapshot memory length cannot be bounded")?; + let mut source = Read::by_ref(&mut source).take(limit); + let mut total = 0_u64; + let mut buffer = vec![0_u8; COPY_BUFFER_SIZE]; + loop { + let count = source + .read(&mut buffer) + .context("failed to read snapshot memory source")?; + if count == 0 { + break; + } + destination + .write_all(&buffer[..count]) + .context("failed to write dense snapshot memory destination")?; + total = total + .checked_add(count as u64) + .context("snapshot memory length overflowed u64")?; + } + anyhow::ensure!( + total == length, + "snapshot memory changed while it was copied (expected {length} bytes, copied {total} bytes)", + ); + Ok("dense-copy") +} + +#[cfg(not(any(target_os = "linux", windows)))] +fn clone_or_copy( + source: &std::fs::File, + destination: &std::fs::File, + length: u64, +) -> anyhow::Result<&'static str> { + size_empty_file(destination, length, "snapshot clone destination")?; + copy_nonzero_data(source, destination, 0, length)?; + Ok("zero-scan") +} + +#[cfg(target_os = "linux")] +fn copy_allocated_ranges( + source: &std::fs::File, + destination: &std::fs::File, + length: u64, + ranges: &[(u64, u64)], +) -> anyhow::Result<()> { + let mut cursor = 0_u64; + for &(offset, range_length) in ranges { + let end = offset + .checked_add(range_length) + .context("allocated range overflowed u64")?; + anyhow::ensure!( + offset >= cursor && range_length != 0 && end <= length, + "invalid allocated range for sparse copy" + ); + copy_nonzero_data(source, destination, offset, range_length)?; + cursor = end; + } + Ok(()) +} + +#[cfg(not(windows))] +fn copy_nonzero_data( + source: &std::fs::File, + destination: &std::fs::File, + offset: u64, + length: u64, +) -> anyhow::Result<()> { + let end = offset + .checked_add(length) + .context("snapshot copy range overflowed u64")?; + let mut cursor = offset; + let mut buffer = vec![0_u8; COPY_BUFFER_SIZE]; + while cursor < end { + let count = usize::try_from((end - cursor).min(buffer.len() as u64)).unwrap(); + let read = read_file_at(source, &mut buffer[..count], cursor) + .context("failed to read snapshot source")?; + anyhow::ensure!( + read != 0, + "snapshot source changed while it was being copied" + ); + if buffer[..read].iter().any(|byte| *byte != 0) { + write_file_all_at(destination, &buffer[..read], cursor) + .context("failed to write snapshot destination")?; + } + cursor = cursor + .checked_add(read as u64) + .context("snapshot copy offset overflowed u64")?; + } + Ok(()) +} + +#[cfg(not(windows))] +fn read_file_at(file: &std::fs::File, buffer: &mut [u8], offset: u64) -> std::io::Result { + #[cfg(unix)] + { + std::os::unix::fs::FileExt::read_at(file, buffer, offset) + } + #[cfg(windows)] + { + std::os::windows::fs::FileExt::seek_read(file, buffer, offset) + } +} + +#[cfg(not(windows))] +fn write_file_all_at( + file: &std::fs::File, + mut bytes: &[u8], + mut offset: u64, +) -> std::io::Result<()> { + while !bytes.is_empty() { + #[cfg(unix)] + let written = std::os::unix::fs::FileExt::write_at(file, bytes, offset)?; + #[cfg(windows)] + let written = std::os::windows::fs::FileExt::seek_write(file, bytes, offset)?; + if written == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "failed to write snapshot destination", + )); + } + bytes = &bytes[written..]; + offset = offset + .checked_add(written as u64) + .ok_or_else(|| std::io::Error::other("snapshot write offset overflowed u64"))?; + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn linux_allocated_ranges(file: &std::fs::File, length: u64) -> std::io::Result> { + use std::os::fd::AsRawFd; + + let extent_file = std::fs::File::open(format!("/proc/self/fd/{}", file.as_raw_fd()))?; + let mut ranges = Vec::new(); + let mut cursor = 0_u64; + while cursor < length { + let Some(data) = pal::unix::fs::seek_data(&extent_file, cursor)? else { + break; + }; + if data >= length { + break; + } + let hole = pal::unix::fs::seek_hole(&extent_file, data)?.unwrap_or(length); + if hole <= data || hole > length { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "filesystem returned an invalid sparse extent", + )); + } + ranges.push((data, hole - data)); + cursor = hole; + } + Ok(ranges) +} + +#[cfg(target_os = "linux")] +pub(super) fn allocated_file_bytes(file: &std::fs::File, _length: u64) -> anyhow::Result { + use std::os::unix::fs::MetadataExt; + + file.metadata()? + .blocks() + .checked_mul(512) + .context("allocated byte count overflowed u64") +} + +#[cfg(windows)] +pub(super) fn allocated_file_bytes(file: &std::fs::File, length: u64) -> anyhow::Result { + match pal::windows::fs::sparse::allocated_ranges(file, length) { + Ok(ranges) => ranges + .into_iter() + .try_fold(0_u64, |total, (_, range_length)| { + total + .checked_add(range_length) + .context("allocated byte count overflowed u64") + }), + Err(error) => { + tracing::debug!( + error = &error as &dyn std::error::Error, + "allocated-range accounting is unavailable" + ); + pal::windows::fs::sparse::allocation_size(file).map_err(anyhow::Error::new) + } + } +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub(super) fn allocated_file_bytes(file: &std::fs::File, _length: u64) -> anyhow::Result { + Ok(file.metadata()?.len()) +} + +pub(super) fn open_regular_file(path: &Path, description: &str) -> anyhow::Result { + open_regular_file_impl(path, description) +} + +fn open_regular_file_impl(path: &Path, description: &str) -> anyhow::Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + + options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); + } + + let file = options + .open(path) + .with_context(|| format!("failed to open {description} at {}", path.display()))?; + validate_opened_regular_file(&file, description, path)?; + Ok(file) +} + +fn validate_opened_regular_file( + file: &std::fs::File, + description: &str, + path: &Path, +) -> anyhow::Result<()> { + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect opened {description}"))?; + anyhow::ensure!( + metadata.file_type().is_file(), + "{description} is not a regular file: {}", + path.display(), + ); + reject_windows_reparse_point(&metadata, description, path) +} + +#[cfg(windows)] +fn reject_windows_reparse_point( + metadata: &std::fs::Metadata, + description: &str, + path: &Path, +) -> anyhow::Result<()> { + use std::os::windows::fs::MetadataExt; + use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT; + + anyhow::ensure!( + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT == 0, + "{description} is a reparse point: {}", + path.display(), + ); + Ok(()) +} + +#[cfg(not(windows))] +fn reject_windows_reparse_point( + _metadata: &std::fs::Metadata, + _description: &str, + _path: &Path, +) -> anyhow::Result<()> { + Ok(()) +} + +#[cfg(unix)] +pub(super) fn sync_directory(path: &Path) -> anyhow::Result<()> { + fs_err::File::open(path) + .with_context(|| format!("failed to open directory {} for flush", path.display()))? + .sync_all() + .with_context(|| format!("failed to flush directory {}", path.display())) +} + +#[cfg(not(unix))] +pub(super) fn sync_directory(_path: &Path) -> anyhow::Result<()> { + // Windows has no portable directory-flush equivalent. Artifact handles are + // flushed before the same-volume directory rename, which remains the + // publication boundary; crash durability of that rename is filesystem-defined. + Ok(()) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use std::io::Seek; + use std::io::SeekFrom; + + #[test] + fn sparse_copy_fallbacks_preserve_extents_and_clone_independence() { + const MEMORY_SIZE: u64 = 16 * 1024 * 1024; + const EXTENT_SIZE: u64 = 4096; + let dir = tempfile::tempdir().unwrap(); + let source_path = dir.path().join("source.bin"); + let mut source = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(source_path) + .unwrap(); + source.set_len(MEMORY_SIZE).unwrap(); + source.write_all(b"head").unwrap(); + source.seek(SeekFrom::End(-4)).unwrap(); + source.write_all(b"tail").unwrap(); + source.sync_all().unwrap(); + + let mut expected = vec![0_u8; MEMORY_SIZE as usize]; + let tail_offset = expected.len() - 4; + expected[..4].copy_from_slice(b"head"); + expected[tail_offset..].copy_from_slice(b"tail"); + + let allocated_ranges = [(0, EXTENT_SIZE), (MEMORY_SIZE - EXTENT_SIZE, EXTENT_SIZE)]; + for fallback in ["allocated-ranges", "zero-scan"] { + let path = dir.path().join(format!("{fallback}.bin")); + let destination = create_file(&path, fallback).unwrap(); + size_empty_file(&destination, MEMORY_SIZE, fallback).unwrap(); + match fallback { + "allocated-ranges" => { + copy_allocated_ranges(&source, &destination, MEMORY_SIZE, &allocated_ranges) + .unwrap() + } + "zero-scan" => copy_nonzero_data(&source, &destination, 0, MEMORY_SIZE).unwrap(), + _ => unreachable!(), + } + destination.sync_all().unwrap(); + let allocated = allocated_file_bytes(&destination, MEMORY_SIZE).unwrap(); + assert!( + allocated < MEMORY_SIZE / 4, + "{fallback} allocated {allocated} bytes for a {MEMORY_SIZE}-byte file", + ); + } + + source.seek(SeekFrom::Start(0)).unwrap(); + source.write_all(b"xxxx").unwrap(); + source.sync_all().unwrap(); + for fallback in ["allocated-ranges", "zero-scan"] { + assert_eq!( + std::fs::read(dir.path().join(format!("{fallback}.bin"))).unwrap(), + expected, + "{fallback}" + ); + } + } +} diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs index 35def60b583..0e219496cb3 100644 --- a/openvmm/openvmm_helpers/src/snapshot/publish.rs +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -1,80 +1,445 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Writing VM snapshots to a directory. +//! Snapshot publication. Artifacts are written and flushed in a private +//! staging directory next to the destination, which is then renamed into +//! place in one operation; failures roll back and clean up the staging +//! directory. +use super::MANIFEST_VERSION; use super::SnapshotManifest; +use super::format::MANIFEST_FILE_NAME; +use super::format::MAX_MANIFEST_SIZE_BYTES; use super::format::MAX_SAVED_STATE_SIZE_BYTES; +use super::format::MEMORY_FILE_NAME; +use super::format::STATE_FILE_NAME; +use super::format::validate_manifest_header; +use super::format::validate_manifest_version; +use super::fs::allocated_file_bytes; +use super::fs::copy_exact; +use super::fs::create_private_directory; +use super::fs::ensure_path_absent; +use super::fs::open_regular_file; +use super::fs::path_exists; +use super::fs::rename_no_replace; +use super::fs::snapshot_parent; +use super::fs::sync_directory; +use super::fs::validate_directory; +use super::fs::write_bytes; use anyhow::Context; use std::path::Path; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +static STAGING_SEQUENCE: AtomicU64 = AtomicU64::new(0); + +/// Error publishing a snapshot. +#[derive(Debug, thiserror::Error)] +pub enum SnapshotWriteError { + /// Nothing was published at the final destination. + #[error(transparent)] + BeforeCommit(#[from] anyhow::Error), + /// The directory rename committed, but its parent could not be flushed. + #[error("snapshot committed to {path:?}, but flushing its parent directory failed: {error:#}")] + Committed { + /// Final committed snapshot path. + path: PathBuf, + /// Parent-directory flush failure. + #[source] + error: anyhow::Error, + }, +} + +impl SnapshotWriteError { + /// Returns whether the final snapshot directory has been published. + pub fn is_committed(&self) -> bool { + matches!(self, Self::Committed { .. }) + } + + /// Returns whether a quiesced source may safely roll back and resume. + pub fn is_rollback_safe(&self) -> bool { + matches!(self, Self::BeforeCommit(_)) + } +} /// Write a snapshot to the given directory. /// -/// The directory is created if it does not exist. The snapshot consists of: +/// The final directory must not already exist. The snapshot consists of: /// - `manifest.bin` — protobuf-encoded [`SnapshotManifest`] /// - `state.bin` — raw device saved-state bytes -/// - `memory.bin` — hard link to the memory backing file +/// - `memory.bin` — private copy of the memory backing file +/// +/// The files are written and flushed in a unique sibling staging directory. +/// The completed staging directory is then renamed to `dir` in one operation. pub fn write_snapshot( dir: &Path, manifest: &SnapshotManifest, saved_state_bytes: &[u8], memory_file_path: &Path, -) -> anyhow::Result<()> { - anyhow::ensure!( - u64::try_from(saved_state_bytes.len()).unwrap_or(u64::MAX) <= MAX_SAVED_STATE_SIZE_BYTES, - "saved state exceeds the maximum size of {MAX_SAVED_STATE_SIZE_BYTES} bytes" - ); - fs_err::create_dir_all(dir)?; - - // Write manifest. - let mut manifest = manifest.clone(); - manifest.state_size_bytes = saved_state_bytes.len() as u64; - let manifest_bytes = mesh::payload::encode(manifest); - fs_err::write(dir.join("manifest.bin"), &manifest_bytes)?; - - // Write device state. - fs_err::write(dir.join("state.bin"), saved_state_bytes)?; - - // Handle memory.bin: hard-link from the backing file. - let memory_bin_path = dir.join("memory.bin"); - let canonical_source = fs_err::canonicalize(memory_file_path)?; - - // Check whether source and target are already the same file (e.g., - // the user pointed --memory-backing-file at /memory.bin directly). - let needs_link = if memory_bin_path.exists() { - let canonical_target = fs_err::canonicalize(&memory_bin_path)?; - if canonical_source == canonical_target { - false - } else { - // Different file at the target path — remove it so the hard - // link can be created. - fs_err::remove_file(&memory_bin_path)?; - true +) -> Result<(), SnapshotWriteError> { + let memory_file = open_regular_file(memory_file_path, "memory backing file")?; + write_snapshot_from_memory_file(dir, manifest, saved_state_bytes, &memory_file) +} + +/// Writes a snapshot from the exact open file handle backing guest RAM. +/// +/// Capture callers should prefer this over [`write_snapshot`] so replacing the +/// backing pathname cannot substitute different bytes after VM construction. +pub fn write_snapshot_from_memory_file( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, +) -> Result<(), SnapshotWriteError> { + write_snapshot_with_memory_publication(dir, manifest, saved_state_bytes, memory_file) +} + +fn write_snapshot_with_memory_publication( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, +) -> Result<(), SnapshotWriteError> { + let mut staging = stage_snapshot(dir, manifest, saved_state_bytes, memory_file)?; + let commit = openvmm_defs::profile::ProfileSpan::start(); + if let Err(error) = + ensure_path_absent(dir, "snapshot destination").and_then(|()| staging.publish(dir)) + { + return Err(staging.rollback(error)); + } + commit.complete("capture", "publication_commit", Default::default()); + + let parent = snapshot_parent(dir); + let parent_sync = openvmm_defs::profile::ProfileSpan::start(); + sync_directory(parent).map_err(|error| SnapshotWriteError::Committed { + path: dir.to_owned(), + error, + })?; + parent_sync.complete("capture", "publication_parent_sync", Default::default()); + Ok(()) +} + +fn stage_snapshot( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, +) -> Result { + validate_manifest_header(manifest)?; + validate_manifest_version(manifest)?; + if manifest.version != MANIFEST_VERSION { + return Err(anyhow::anyhow!( + "snapshot manifest version {} is not supported for writing (expected {})", + manifest.version, + MANIFEST_VERSION, + ) + .into()); + } + if u64::try_from(saved_state_bytes.len()).unwrap_or(u64::MAX) > MAX_SAVED_STATE_SIZE_BYTES { + return Err(anyhow::anyhow!( + "saved state exceeds the maximum size of {MAX_SAVED_STATE_SIZE_BYTES} bytes" + ) + .into()); + } + + let parent = snapshot_parent(dir); + validate_directory(parent, "snapshot parent directory")?; + ensure_path_absent(dir, "snapshot destination")?; + + let staging = StagingDirectory::create(parent, dir)?; + let state_path = staging.path().join(STATE_FILE_NAME); + let memory_path = staging.path().join(MEMORY_FILE_NAME); + let manifest_path = staging.path().join(MANIFEST_FILE_NAME); + + let result = (|| -> anyhow::Result<()> { + let state_write = openvmm_defs::profile::ProfileSpan::start(); + write_bytes(&state_path, saved_state_bytes, "saved state")?; + state_write.complete( + "capture", + "publication_state", + profile_path_counters(&state_path, saved_state_bytes.len() as u64), + ); + let memory_publish = openvmm_defs::profile::ProfileSpan::start(); + copy_exact( + memory_file, + &memory_path, + manifest.memory_size_bytes, + "memory backing file", + "snapshot memory", + )?; + memory_publish.complete( + "capture", + "publication_memory", + profile_path_counters(&memory_path, manifest.memory_size_bytes), + ); + let mut published_manifest = manifest.clone(); + published_manifest.state_size_bytes = saved_state_bytes.len() as u64; + // Current local snapshots use strict structure and length checks + // without RAM-sized in-band hashing. + published_manifest.state_sha256.clear(); + published_manifest.memory_sha256.clear(); + + let manifest_bytes = mesh::payload::encode(published_manifest); + anyhow::ensure!( + manifest_bytes.len() as u64 <= MAX_MANIFEST_SIZE_BYTES, + "snapshot manifest exceeds the maximum size of {MAX_MANIFEST_SIZE_BYTES} bytes", + ); + let manifest_write = openvmm_defs::profile::ProfileSpan::start(); + write_bytes(&manifest_path, &manifest_bytes, "snapshot manifest")?; + manifest_write.complete( + "capture", + "publication_manifest", + profile_path_counters(&manifest_path, manifest_bytes.len() as u64), + ); + + let staging_sync = openvmm_defs::profile::ProfileSpan::start(); + sync_directory(staging.path())?; + staging_sync.complete("capture", "publication_staging_sync", Default::default()); + Ok(()) + })(); + match result { + Ok(()) => Ok(staging), + Err(error) => Err(staging.rollback(error)), + } +} + +struct StagingDirectory { + path: Option, +} + +impl StagingDirectory { + fn create(parent: &Path, destination: &Path) -> anyhow::Result { + let destination_name = destination + .file_name() + .context("snapshot destination must name a directory")? + .to_string_lossy(); + let sequence = STAGING_SEQUENCE.fetch_add(1, Ordering::Relaxed); + + for attempt in 0..100_u32 { + let path = parent.join(format!( + ".{destination_name}.staging-{}-{sequence}-{attempt}", + std::process::id() + )); + match create_private_directory(&path) { + Ok(()) => { + return Ok(Self { path: Some(path) }); + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(err) => { + return Err(err).with_context(|| { + format!("failed to create staging directory {}", path.display()) + }); + } + } } - } else { - true - }; - - if needs_link { - if let Err(err) = std::fs::hard_link(&canonical_source, &memory_bin_path) { - if err.kind() == std::io::ErrorKind::CrossesDevices { - anyhow::bail!( - "memory backing file ({}) must be on the same filesystem as the snapshot \ - directory ({}); consider placing the backing file inside the snapshot \ - directory", - memory_file_path.display(), - dir.display(), + + anyhow::bail!( + "failed to allocate a unique snapshot staging directory in {}", + parent.display() + ) + } + + fn path(&self) -> &Path { + self.path.as_deref().expect("staging path is present") + } + + fn publish(&mut self, destination: &Path) -> anyhow::Result<()> { + let staging_path = self.path(); + rename_no_replace(staging_path, destination).with_context(|| { + format!( + "failed to publish snapshot {} to {}", + staging_path.display(), + destination.display() + ) + })?; + self.path = None; + Ok(()) + } + + fn rollback(mut self, error: anyhow::Error) -> SnapshotWriteError { + if let Err(cleanup_error) = self.remove_staging_directory() { + tracing::warn!( + error = cleanup_error.as_ref() as &dyn std::error::Error, + "failed to remove independent snapshot staging artifacts" + ); + } + SnapshotWriteError::BeforeCommit(error) + } + + fn remove_staging_directory(&mut self) -> anyhow::Result<()> { + let Some(path) = self.path.as_ref() else { + return Ok(()); + }; + let parent = snapshot_parent(path).to_owned(); + match fs_err::remove_dir_all(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!("failed to remove staging directory {}", path.display()) + }); + } + } + anyhow::ensure!( + !path_exists(path)?, + "snapshot staging directory still exists after removal: {}", + path.display() + ); + sync_directory(&parent).context("failed to flush snapshot staging cleanup")?; + self.path = None; + Ok(()) + } +} + +impl Drop for StagingDirectory { + fn drop(&mut self) { + if let Some(path) = &self.path { + if let Err(error) = fs_err::remove_dir_all(path) { + tracing::error!( + error = &error as &dyn std::error::Error, + path = %path.display(), + "failed to remove dropped snapshot staging directory" ); } - return Err(err).with_context(|| { - format!( - "failed to hard-link {} -> {}", - canonical_source.display(), - memory_bin_path.display() - ) - }); } } +} - Ok(()) +fn profile_path_counters( + path: &Path, + logical_bytes: u64, +) -> openvmm_defs::profile::ProfileCounters { + if !openvmm_defs::profile::enabled() { + return Default::default(); + } + let allocated_bytes = std::fs::File::open(path) + .ok() + .and_then(|file| allocated_file_bytes(&file, logical_bytes).ok()); + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(logical_bytes), + allocated_bytes, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::super::tests::test_manifest; + use super::*; + + #[test] + fn write_snapshot_rejects_missing_parent() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("a").join("b").join("c"); + + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + + let err = write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap_err(); + assert!(!err.is_committed()); + assert!(err.to_string().contains("snapshot parent directory")); + assert!(!snap_dir.exists()); + } + + #[test] + fn write_snapshot_copies_memory() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, b"SAMEFILE").unwrap(); + + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 8; + write_snapshot(&snap_dir, &manifest, b"state", &mem_path).unwrap(); + std::fs::write(&mem_path, b"MODIFIED").unwrap(); + + assert_eq!( + std::fs::read(snap_dir.join(MEMORY_FILE_NAME)).unwrap(), + b"SAMEFILE" + ); + } + + #[test] + fn exact_memory_handle_survives_path_replacement_before_publish() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + let moved_path = dir.path().join("mapped-memory.bin"); + std::fs::write(&mem_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&mem_path) + .unwrap(); + std::fs::rename(&mem_path, &moved_path).unwrap(); + std::fs::write(&mem_path, vec![0xa5_u8; 1024]).unwrap(); + + write_snapshot_from_memory_file(&snap_dir, &test_manifest(), b"state", &memory_file) + .unwrap(); + + assert_eq!( + std::fs::read(snap_dir.join(MEMORY_FILE_NAME)).unwrap(), + vec![0x5a_u8; 1024] + ); + } + + #[test] + fn write_snapshot_rejects_existing_destination() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + std::fs::create_dir(&snap_dir).unwrap(); + std::fs::write(snap_dir.join("sentinel"), b"keep").unwrap(); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + + let err = write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap_err(); + assert!(err.to_string().contains("already exists")); + assert_eq!(std::fs::read(snap_dir.join("sentinel")).unwrap(), b"keep"); + } + + #[test] + fn publish_does_not_replace_destination_created_after_staging() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mut staging = StagingDirectory::create(dir.path(), &snap_dir).unwrap(); + std::fs::create_dir(&snap_dir).unwrap(); + std::fs::write(snap_dir.join("sentinel"), b"keep").unwrap(); + + assert!(staging.publish(&snap_dir).is_err()); + assert_eq!(std::fs::read(snap_dir.join("sentinel")).unwrap(), b"keep"); + } + + #[test] + fn write_snapshot_rejects_oversized_memory_without_publishing() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1025]).unwrap(); + + let err = write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap_err(); + assert!(err.to_string().contains("doesn't match manifest")); + assert!(!snap_dir.exists()); + assert!(std::fs::read_dir(dir.path()).unwrap().all(|entry| { + !entry + .unwrap() + .file_name() + .to_string_lossy() + .contains("staging") + })); + } + + #[test] + fn write_snapshot_rejects_oversized_manifest_without_publishing() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + let mut manifest = test_manifest(); + manifest.openvmm_version = "x".repeat(MAX_MANIFEST_SIZE_BYTES as usize); + + let err = write_snapshot(&snap_dir, &manifest, b"state", &mem_path).unwrap_err(); + assert!(err.to_string().contains("manifest exceeds")); + assert!(!snap_dir.exists()); + } } From f6a4471fcd72c709033a891354738ec432889b09 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 01:37:12 -0700 Subject: [PATCH 065/304] openvmm: publish the exact file-backed RAM handle Copying guest RAM into `memory.bin` keeps a snapshot independent of the source VM, but it doubles the storage and the capture time for a VM whose backing file OpenVMM itself creates and owns, and whose source is terminated after capture anyway. Add `write_snapshot_from_owned_memory_file`, which publishes such RAM by hard-linking the exact open backing handle into the staging directory as `memory.bin`, after the state and the manifest are written and the handle is flushed. Linux links the descriptor with `linkat(AT_EMPTY_PATH)` or through `/proc/self/fd`, and Windows uses a handle-relative link; the link is then opened relative to the staging directory and must have the device and inode or file ID and the length of the source. Filesystems that cannot link fall back to the independent copy. Once the link may exist, the staging directory aliases live RAM: a failure removes it and flushes its parent before it reports a rollback-safe error, and a failed cleanup is reported as `SnapshotWriteError::CleanupUncertain`, which requires the source to terminate instead of resuming. Size new memory backing files through `initialize_snapshot_memory_backing_file`, which keeps them sparse except on Windows, where a dense file avoids slow copy-on-write faults, and add `open_memory_backing_file_handle`, which returns the sized backing file itself so that a caller can keep the exact handle for publication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_helpers/src/shared_memory.rs | 22 +- openvmm/openvmm_helpers/src/snapshot/fs.rs | 342 ++++++++++++++- .../openvmm_helpers/src/snapshot/publish.rs | 407 +++++++++++++++++- 3 files changed, 745 insertions(+), 26 deletions(-) diff --git a/openvmm/openvmm_helpers/src/shared_memory.rs b/openvmm/openvmm_helpers/src/shared_memory.rs index e85043fa5db..46fb4e06189 100644 --- a/openvmm/openvmm_helpers/src/shared_memory.rs +++ b/openvmm/openvmm_helpers/src/shared_memory.rs @@ -15,17 +15,29 @@ pub fn open_memory_backing_file( path: &std::path::Path, size: u64, ) -> anyhow::Result { - let file = fs_err::OpenOptions::new() + file_to_shared_memory_fd(open_memory_backing_file_handle(path, size)?) +} + +/// Opens and sizes a file backing guest RAM without converting its handle. +/// +/// Snapshot capture uses this to give the worker a duplicate while retaining +/// the exact same underlying file for publication. +pub fn open_memory_backing_file_handle( + path: &std::path::Path, + size: u64, +) -> anyhow::Result { + let file = std::fs::OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) - .open(path)?; + .open(path) + .with_context(|| format!("failed to open memory backing file {}", path.display()))?; let existing_len = file.metadata()?.len(); if existing_len == 0 { - file.set_len(size) - .context("failed to set memory backing file size")?; + crate::snapshot::fs::initialize_snapshot_memory_backing_file(&file, size) + .context("failed to initialize snapshot memory backing file")?; } else if existing_len != size { anyhow::bail!( "memory backing file {} has size {} bytes, expected {} bytes", @@ -35,7 +47,7 @@ pub fn open_memory_backing_file( ); } - file_to_shared_memory_fd(file.into()) + Ok(file) } /// Convert a `std::fs::File` to the platform-appropriate shared memory handle. diff --git a/openvmm/openvmm_helpers/src/snapshot/fs.rs b/openvmm/openvmm_helpers/src/snapshot/fs.rs index 852dcf1e315..1c65b9b0193 100644 --- a/openvmm/openvmm_helpers/src/snapshot/fs.rs +++ b/openvmm/openvmm_helpers/src/snapshot/fs.rs @@ -3,8 +3,9 @@ //! File-system helpers for snapshot artifacts, including their //! platform-specific implementations: private files and directories, -//! no-replace renames and directory flushes, and sparse-aware clone and copy -//! with allocation accounting. +//! no-replace renames and directory flushes, directory-relative artifact +//! access, exact-file hard links, and sparse-aware clone and copy with +//! allocation accounting. use anyhow::Context; #[cfg(windows)] @@ -15,6 +16,7 @@ use std::io::Seek; use std::io::SeekFrom; use std::io::Write; use std::path::Path; +use std::path::PathBuf; const COPY_BUFFER_SIZE: usize = 1024 * 1024; @@ -140,6 +142,207 @@ pub(super) fn write_bytes(path: &Path, bytes: &[u8], description: &str) -> anyho Ok(()) } +#[cfg(target_os = "linux")] +pub(super) fn create_hard_link_from_handle( + source: &std::fs::File, + directory: &std::fs::File, + name: &str, +) -> std::io::Result<&'static str> { + use nix::fcntl::AT_FDCWD; + use nix::fcntl::AtFlags; + use std::os::fd::AsRawFd; + + match nix::unistd::linkat( + source, + Path::new(""), + directory, + name, + AtFlags::AT_EMPTY_PATH, + ) { + Ok(()) => return Ok("linkat-empty-path"), + Err(error) + if matches!( + error, + nix::errno::Errno::EPERM | nix::errno::Errno::EINVAL | nix::errno::Errno::ENOENT + ) => + { + tracing::debug!( + error = &nix_error(error) as &dyn std::error::Error, + "AT_EMPTY_PATH hard link is unavailable" + ); + } + Err(error) => return Err(nix_error(error)), + } + + let source_path = PathBuf::from(format!("/proc/self/fd/{}", source.as_raw_fd())); + // Following is required to link the descriptor's target rather than the + // procfs symlink itself. The descriptor remains live, and the caller proves + // the resulting device/inode against `source` before publication. + nix::unistd::linkat( + AT_FDCWD, + &source_path, + directory, + name, + AtFlags::AT_SYMLINK_FOLLOW, + ) + .map(|()| "linkat-proc-fd") + .map_err(nix_error) +} + +#[cfg(windows)] +pub(super) fn create_hard_link_from_handle( + source: &std::fs::File, + directory: &std::fs::File, + name: &str, +) -> std::io::Result<&'static str> { + pal::windows::fs::relative::hard_link_relative(source, directory, std::ffi::OsStr::new(name))?; + Ok("file-link-information") +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub(super) fn create_hard_link_from_handle( + _source: &std::fs::File, + _directory: &std::fs::File, + _name: &str, +) -> std::io::Result<&'static str> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "exact-file hard links are unsupported on this platform", + )) +} + +#[cfg(target_os = "linux")] +pub(super) fn hard_link_is_unsupported(error: &std::io::Error) -> bool { + matches!( + error.raw_os_error(), + Some( + libc::EACCES + | libc::EMLINK + | libc::EINVAL + | libc::ELOOP + | libc::ENOENT + | libc::ENOSYS + | libc::EOPNOTSUPP + | libc::EPERM + | libc::EXDEV + ) + ) +} + +#[cfg(windows)] +pub(super) fn hard_link_is_unsupported(error: &std::io::Error) -> bool { + use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; + use windows_sys::Win32::Foundation::ERROR_FILE_SYSTEM_LIMITATION; + use windows_sys::Win32::Foundation::ERROR_INVALID_FUNCTION; + use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER; + use windows_sys::Win32::Foundation::ERROR_NOT_SAME_DEVICE; + use windows_sys::Win32::Foundation::ERROR_NOT_SUPPORTED; + use windows_sys::Win32::Foundation::ERROR_PRIVILEGE_NOT_HELD; + use windows_sys::Win32::Foundation::ERROR_TOO_MANY_LINKS; + + matches!( + error.raw_os_error(), + Some(raw) if matches!( + raw as u32, + ERROR_ACCESS_DENIED + | ERROR_FILE_SYSTEM_LIMITATION + | ERROR_INVALID_FUNCTION + | ERROR_INVALID_PARAMETER + | ERROR_NOT_SAME_DEVICE + | ERROR_NOT_SUPPORTED + | ERROR_PRIVILEGE_NOT_HELD + | ERROR_TOO_MANY_LINKS + ) + ) +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub(super) fn hard_link_is_unsupported(_error: &std::io::Error) -> bool { + true +} + +pub(super) fn verify_hard_link_identity( + source: &std::fs::File, + linked: &std::fs::File, + expected_length: u64, +) -> anyhow::Result<()> { + let source_metadata = source + .metadata() + .context("failed to re-inspect automatic snapshot RAM handle")?; + let linked_metadata = linked + .metadata() + .context("failed to inspect linked snapshot memory")?; + anyhow::ensure!( + source_metadata.file_type().is_file() && linked_metadata.file_type().is_file(), + "automatic snapshot RAM hard link does not resolve to regular files" + ); + anyhow::ensure!( + source_metadata.len() == expected_length && linked_metadata.len() == expected_length, + "automatic snapshot RAM hard-link EOF does not match manifest ({expected_length} bytes)" + ); + + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::MetadataExt; + anyhow::ensure!( + source_metadata.dev() == linked_metadata.dev() + && source_metadata.ino() == linked_metadata.ino(), + "automatic snapshot RAM hard link has the wrong device or inode" + ); + } + #[cfg(windows)] + { + let source_identity = pal::windows::fs::relative::file_identity(source) + .context("failed to query automatic snapshot RAM identity")?; + let linked_identity = pal::windows::fs::relative::file_identity(linked) + .context("failed to query linked snapshot RAM identity")?; + anyhow::ensure!( + source_identity == linked_identity && source_identity.end_of_file == expected_length, + "automatic snapshot RAM hard link has the wrong file identity or EOF" + ); + } + #[cfg(not(any(target_os = "linux", windows)))] + anyhow::bail!("exact-file hard-link identity checks are unsupported on this platform"); + + Ok(()) +} + +/// Sizes a newly created snapshot RAM backing. +/// +/// Windows leaves the file non-sparse to avoid slow copy-on-write faults from +/// sparse files. Other supported platforms retain sparse allocation. +pub fn initialize_snapshot_memory_backing_file( + file: &std::fs::File, + size: u64, +) -> anyhow::Result { + let metadata = file + .metadata() + .context("failed to inspect new snapshot memory backing")?; + anyhow::ensure!( + metadata.file_type().is_file(), + "snapshot memory backing handle is not a regular file" + ); + anyhow::ensure!( + metadata.len() == 0, + "new snapshot memory backing is not empty" + ); + + size_empty_file(file, size, "snapshot memory backing")?; + let allocated_bytes = allocated_file_bytes(file, size) + .context("failed to inspect snapshot memory backing allocation")?; + #[cfg(not(windows))] + anyhow::ensure!( + allocated_bytes == 0, + "snapshot memory backing allocated {allocated_bytes} bytes while sizing to {size} bytes" + ); + tracing::info!( + logical_bytes = size, + allocated_bytes, + "initialized snapshot RAM backing" + ); + Ok(allocated_bytes) +} + pub(super) fn copy_exact( source_file: &std::fs::File, destination_path: &Path, @@ -195,7 +398,6 @@ pub(super) fn copy_exact( Ok(()) } -#[cfg(not(windows))] fn size_empty_file(file: &std::fs::File, length: u64, description: &str) -> anyhow::Result<()> { file.set_len(0) .with_context(|| format!("failed to reset {description}"))?; @@ -451,6 +653,138 @@ pub(super) fn allocated_file_bytes(file: &std::fs::File, _length: u64) -> anyhow Ok(file.metadata()?.len()) } +pub(super) struct OpenedSnapshotDirectory { + pub(super) file: std::fs::File, + path: PathBuf, +} + +impl OpenedSnapshotDirectory { + pub(super) fn open_for_publication(path: &Path) -> anyhow::Result { + Self::open_impl(path, true) + } + + fn open_impl(path: &Path, allow_changes: bool) -> anyhow::Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; + use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; + + options + .share_mode(if allow_changes { + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE + } else { + FILE_SHARE_READ + }) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); + } + #[cfg(not(windows))] + let _ = allow_changes; + let file = options + .open(path) + .with_context(|| format!("failed to open snapshot directory {}", path.display()))?; + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect snapshot directory {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_dir(), + "snapshot path is not a directory: {}", + path.display(), + ); + reject_windows_reparse_point(&metadata, "snapshot directory", path)?; + Ok(Self { + file, + path: path.to_owned(), + }) + } + + #[cfg(not(windows))] + pub(super) fn open_regular_file( + &self, + name: &str, + description: &str, + ) -> anyhow::Result { + #[cfg(target_os = "linux")] + let file = { + use nix::fcntl::OFlag; + use nix::sys::stat::Mode; + + let fd = nix::fcntl::openat( + &self.file, + name, + OFlag::O_RDONLY | OFlag::O_CLOEXEC | OFlag::O_NOFOLLOW, + Mode::empty(), + ) + .map_err(nix_error) + .with_context(|| { + format!( + "failed to open {description} at {}", + self.display_path(name).display() + ) + })?; + std::fs::File::from(fd) + }; + #[cfg(windows)] + let file = pal::windows::fs::relative::open_relative_read_only( + &self.file, + std::ffi::OsStr::new(name), + ) + .with_context(|| { + format!( + "failed to open {description} at {}", + self.display_path(name).display() + ) + })?; + #[cfg(not(any(target_os = "linux", windows)))] + let file = open_regular_file_impl(&self.path.join(name), description)?; + + validate_opened_regular_file(&file, description, &self.display_path(name))?; + Ok(file) + } + + pub(super) fn open_regular_file_for_identity( + &self, + name: &str, + description: &str, + ) -> anyhow::Result { + #[cfg(windows)] + let file = pal::windows::fs::relative::open_relative_for_identity( + &self.file, + std::ffi::OsStr::new(name), + ) + .with_context(|| { + format!( + "failed to open {description} at {}", + self.display_path(name).display() + ) + })?; + #[cfg(not(windows))] + let file = self.open_regular_file(name, description)?; + + validate_opened_regular_file(&file, description, &self.display_path(name))?; + Ok(file) + } + + pub(super) fn display_path(&self, name: impl AsRef) -> PathBuf { + self.path.join(name) + } +} + +#[cfg(target_os = "linux")] +fn nix_error(error: nix::errno::Errno) -> std::io::Error { + std::io::Error::from_raw_os_error(error as i32) +} + pub(super) fn open_regular_file(path: &Path, description: &str) -> anyhow::Result { open_regular_file_impl(path, description) } @@ -554,7 +888,7 @@ mod tests { .create_new(true) .open(source_path) .unwrap(); - source.set_len(MEMORY_SIZE).unwrap(); + initialize_snapshot_memory_backing_file(&source, MEMORY_SIZE).unwrap(); source.write_all(b"head").unwrap(); source.seek(SeekFrom::End(-4)).unwrap(); source.write_all(b"tail").unwrap(); diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs index 0e219496cb3..24fbc5f52db 100644 --- a/openvmm/openvmm_helpers/src/snapshot/publish.rs +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -15,16 +15,20 @@ use super::format::MEMORY_FILE_NAME; use super::format::STATE_FILE_NAME; use super::format::validate_manifest_header; use super::format::validate_manifest_version; +use super::fs::OpenedSnapshotDirectory; use super::fs::allocated_file_bytes; use super::fs::copy_exact; +use super::fs::create_hard_link_from_handle; use super::fs::create_private_directory; use super::fs::ensure_path_absent; +use super::fs::hard_link_is_unsupported; use super::fs::open_regular_file; use super::fs::path_exists; use super::fs::rename_no_replace; use super::fs::snapshot_parent; use super::fs::sync_directory; use super::fs::validate_directory; +use super::fs::verify_hard_link_identity; use super::fs::write_bytes; use anyhow::Context; use std::path::Path; @@ -40,6 +44,22 @@ pub enum SnapshotWriteError { /// Nothing was published at the final destination. #[error(transparent)] BeforeCommit(#[from] anyhow::Error), + /// Publication did not commit, but an alias to live source RAM may remain. + /// + /// Callers must terminate rather than resume the source. The reported + /// private staging path may require operator cleanup after termination. + #[error( + "snapshot failed before commit at {path:?}, and automatic RAM staging alias cleanup is uncertain: publication error: {error:#}; cleanup error: {cleanup_error:#}" + )] + CleanupUncertain { + /// Staging directory that may retain an alias to live source RAM. + path: PathBuf, + /// Original publication failure. + error: anyhow::Error, + /// Failure removing or proving absence of the staging alias. + #[source] + cleanup_error: anyhow::Error, + }, /// The directory rename committed, but its parent could not be flushed. #[error("snapshot committed to {path:?}, but flushing its parent directory failed: {error:#}")] Committed { @@ -92,7 +112,43 @@ pub fn write_snapshot_from_memory_file( saved_state_bytes: &[u8], memory_file: &std::fs::File, ) -> Result<(), SnapshotWriteError> { - write_snapshot_with_memory_publication(dir, manifest, saved_state_bytes, memory_file) + write_snapshot_with_memory_publication( + dir, + manifest, + saved_state_bytes, + memory_file, + MemoryPublication::IndependentCopy, + ) +} + +/// Writes a snapshot by promoting OpenVMM-owned guest RAM as an exact file. +/// +/// The caller must use this only for automatic backing whose lifetime it owns, +/// and must stop all writers before calling. It must terminate the source after +/// success or [`SnapshotWriteError::Committed`]. A +/// [`SnapshotWriteError::BeforeCommit`] proves that staging was removed and the +/// source may resume. [`SnapshotWriteError::CleanupUncertain`] requires source +/// termination because a private staging alias may remain. Unsupported hard +/// links fall back to an independent sparse-aware copy. +pub fn write_snapshot_from_owned_memory_file( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, +) -> Result<(), SnapshotWriteError> { + write_snapshot_with_memory_publication( + dir, + manifest, + saved_state_bytes, + memory_file, + MemoryPublication::OwnedExactFile, + ) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MemoryPublication { + IndependentCopy, + OwnedExactFile, } fn write_snapshot_with_memory_publication( @@ -100,8 +156,15 @@ fn write_snapshot_with_memory_publication( manifest: &SnapshotManifest, saved_state_bytes: &[u8], memory_file: &std::fs::File, + memory_publication: MemoryPublication, ) -> Result<(), SnapshotWriteError> { - let mut staging = stage_snapshot(dir, manifest, saved_state_bytes, memory_file)?; + let mut staging = stage_snapshot( + dir, + manifest, + saved_state_bytes, + memory_file, + memory_publication, + )?; let commit = openvmm_defs::profile::ProfileSpan::start(); if let Err(error) = ensure_path_absent(dir, "snapshot destination").and_then(|()| staging.publish(dir)) @@ -125,6 +188,7 @@ fn stage_snapshot( manifest: &SnapshotManifest, saved_state_bytes: &[u8], memory_file: &std::fs::File, + memory_publication: MemoryPublication, ) -> Result { validate_manifest_header(manifest)?; validate_manifest_version(manifest)?; @@ -147,7 +211,7 @@ fn stage_snapshot( validate_directory(parent, "snapshot parent directory")?; ensure_path_absent(dir, "snapshot destination")?; - let staging = StagingDirectory::create(parent, dir)?; + let mut staging = StagingDirectory::create(parent, dir)?; let state_path = staging.path().join(STATE_FILE_NAME); let memory_path = staging.path().join(MEMORY_FILE_NAME); let manifest_path = staging.path().join(MANIFEST_FILE_NAME); @@ -160,19 +224,21 @@ fn stage_snapshot( "publication_state", profile_path_counters(&state_path, saved_state_bytes.len() as u64), ); - let memory_publish = openvmm_defs::profile::ProfileSpan::start(); - copy_exact( - memory_file, - &memory_path, - manifest.memory_size_bytes, - "memory backing file", - "snapshot memory", - )?; - memory_publish.complete( - "capture", - "publication_memory", - profile_path_counters(&memory_path, manifest.memory_size_bytes), - ); + if memory_publication == MemoryPublication::IndependentCopy { + let memory_publish = openvmm_defs::profile::ProfileSpan::start(); + copy_exact( + memory_file, + &memory_path, + manifest.memory_size_bytes, + "memory backing file", + "snapshot memory", + )?; + memory_publish.complete( + "capture", + "publication_memory", + profile_path_counters(&memory_path, manifest.memory_size_bytes), + ); + } let mut published_manifest = manifest.clone(); published_manifest.state_size_bytes = saved_state_bytes.len() as u64; // Current local snapshots use strict structure and length checks @@ -193,6 +259,16 @@ fn stage_snapshot( profile_path_counters(&manifest_path, manifest_bytes.len() as u64), ); + if memory_publication == MemoryPublication::OwnedExactFile { + let memory_publish = openvmm_defs::profile::ProfileSpan::start(); + publish_owned_memory_file(&mut staging, memory_file, manifest.memory_size_bytes)?; + memory_publish.complete( + "capture", + "publication_memory", + profile_path_counters(&memory_path, manifest.memory_size_bytes), + ); + } + let staging_sync = openvmm_defs::profile::ProfileSpan::start(); sync_directory(staging.path())?; staging_sync.complete("capture", "publication_staging_sync", Default::default()); @@ -206,6 +282,9 @@ fn stage_snapshot( struct StagingDirectory { path: Option, + source_memory_alias: bool, + #[cfg(test)] + inject_cleanup_failure: bool, } impl StagingDirectory { @@ -223,7 +302,12 @@ impl StagingDirectory { )); match create_private_directory(&path) { Ok(()) => { - return Ok(Self { path: Some(path) }); + return Ok(Self { + path: Some(path), + source_memory_alias: false, + #[cfg(test)] + inject_cleanup_failure: false, + }); } Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue, Err(err) => { @@ -257,7 +341,24 @@ impl StagingDirectory { Ok(()) } + fn mark_source_memory_alias(&mut self) { + self.source_memory_alias = true; + } + fn rollback(mut self, error: anyhow::Error) -> SnapshotWriteError { + if self.source_memory_alias + && let Err(cleanup_error) = self.remove_staging_directory() + { + return SnapshotWriteError::CleanupUncertain { + path: self + .path + .clone() + .expect("unpublished staging path is present"), + error, + cleanup_error, + }; + } + if let Err(cleanup_error) = self.remove_staging_directory() { tracing::warn!( error = cleanup_error.as_ref() as &dyn std::error::Error, @@ -268,6 +369,10 @@ impl StagingDirectory { } fn remove_staging_directory(&mut self) -> anyhow::Result<()> { + #[cfg(test)] + if self.inject_cleanup_failure { + anyhow::bail!("injected snapshot staging cleanup failure"); + } let Some(path) = self.path.as_ref() else { return Ok(()); }; @@ -288,6 +393,7 @@ impl StagingDirectory { ); sync_directory(&parent).context("failed to flush snapshot staging cleanup")?; self.path = None; + self.source_memory_alias = false; Ok(()) } } @@ -298,6 +404,7 @@ impl Drop for StagingDirectory { if let Err(error) = fs_err::remove_dir_all(path) { tracing::error!( error = &error as &dyn std::error::Error, + source_memory_alias = self.source_memory_alias, path = %path.display(), "failed to remove dropped snapshot staging directory" ); @@ -323,10 +430,74 @@ fn profile_path_counters( } } +fn publish_owned_memory_file( + staging: &mut StagingDirectory, + source: &std::fs::File, + expected_length: u64, +) -> anyhow::Result<()> { + let source_metadata = source + .metadata() + .context("failed to inspect automatic snapshot RAM handle")?; + anyhow::ensure!( + source_metadata.file_type().is_file(), + "automatic snapshot RAM handle is not a regular file" + ); + anyhow::ensure!( + source_metadata.len() == expected_length, + "automatic snapshot RAM size ({} bytes) doesn't match manifest ({expected_length} bytes)", + source_metadata.len() + ); + + // The VM worker has stopped all writers before this call. Flush through + // the exact handle immediately before linking the same file generation. + source + .sync_all() + .context("failed to flush automatic snapshot RAM handle")?; + + let memory_path = staging.path().join(MEMORY_FILE_NAME); + let directory = OpenedSnapshotDirectory::open_for_publication(staging.path())?; + // From this point, any failure is treated as though the link may have been + // installed until the complete staging directory is proven absent. + staging.mark_source_memory_alias(); + let method = match create_hard_link_from_handle(source, &directory.file, MEMORY_FILE_NAME) { + Ok(method) => method, + Err(error) if hard_link_is_unsupported(&error) => { + tracing::info!( + error = &error as &dyn std::error::Error, + "exact-file snapshot RAM publication is unavailable; using independent copy" + ); + drop(directory); + return copy_exact( + source, + &memory_path, + expected_length, + "automatic snapshot RAM handle", + "snapshot memory", + ); + } + Err(error) => { + return Err(error).context("failed to create automatic RAM staging hard link"); + } + }; + let linked = + directory.open_regular_file_for_identity(MEMORY_FILE_NAME, "linked snapshot memory")?; + verify_hard_link_identity(source, &linked, expected_length)?; + tracing::info!( + method, + logical_bytes = expected_length, + "published exact snapshot memory artifact" + ); + Ok(()) +} + #[cfg(test)] mod tests { + use super::super::fs::initialize_snapshot_memory_backing_file; use super::super::tests::test_manifest; use super::*; + use std::io::Seek; + use std::io::SeekFrom; + use std::io::Write; #[test] fn write_snapshot_rejects_missing_parent() { @@ -360,6 +531,208 @@ mod tests { ); } + #[cfg(any(target_os = "linux", windows))] + #[test] + fn owned_memory_snapshot_publishes_exact_file() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("automatic-memory.bin"); + std::fs::write(&memory_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&memory_path) + .unwrap(); + + write_snapshot_from_owned_memory_file(&snap_dir, &test_manifest(), b"state", &memory_file) + .unwrap(); + + let published = std::fs::File::open(snap_dir.join(MEMORY_FILE_NAME)).unwrap(); + verify_hard_link_identity(&memory_file, &published, 1024).unwrap(); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn owned_memory_link_uses_exact_handle_after_path_replacement() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("automatic-memory.bin"); + let moved_path = dir.path().join("mapped-memory.bin"); + std::fs::write(&memory_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&memory_path) + .unwrap(); + std::fs::rename(&memory_path, &moved_path).unwrap(); + std::fs::write(&memory_path, vec![0xa5_u8; 1024]).unwrap(); + + write_snapshot_from_owned_memory_file(&snap_dir, &test_manifest(), b"state", &memory_file) + .unwrap(); + + let published = std::fs::File::open(snap_dir.join(MEMORY_FILE_NAME)).unwrap(); + verify_hard_link_identity(&memory_file, &published, 1024).unwrap(); + assert_eq!( + std::fs::read(snap_dir.join(MEMORY_FILE_NAME)).unwrap(), + vec![0x5a_u8; 1024] + ); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn injected_failure_removes_live_memory_staging_alias() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("automatic-memory.bin"); + std::fs::write(&memory_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(&memory_path) + .unwrap(); + let staging = stage_snapshot( + &snap_dir, + &test_manifest(), + b"state", + &memory_file, + MemoryPublication::OwnedExactFile, + ) + .unwrap(); + assert!(staging.source_memory_alias); + let staging_path = staging.path().to_owned(); + + let error = staging.rollback(anyhow::anyhow!("injected failure after memory link")); + + assert!(error.is_rollback_safe()); + assert!(!staging_path.exists()); + assert!(!snap_dir.exists()); + std::fs::write(&memory_path, vec![0xa5_u8; 1024]).unwrap(); + } + + #[test] + fn uncertain_live_alias_cleanup_is_not_rollback_safe() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mut staging = StagingDirectory::create(dir.path(), &snap_dir).unwrap(); + staging.mark_source_memory_alias(); + staging.inject_cleanup_failure = true; + + let error = staging.rollback(anyhow::anyhow!("injected publication failure")); + + assert!(!error.is_committed()); + assert!(!error.is_rollback_safe()); + assert!(matches!(error, SnapshotWriteError::CleanupUncertain { .. })); + } + + #[cfg(target_os = "linux")] + #[test] + fn write_snapshot_preserves_sparse_memory_and_clone_independence() { + const MEMORY_SIZE: u64 = 16 * 1024 * 1024; + let dir = tempfile::tempdir().unwrap(); + for (case, sparse) in [("zero", true), ("mixed", true), ("dense", false)] { + let case_dir = dir.path().join(case); + std::fs::create_dir(&case_dir).unwrap(); + let snap_dir = case_dir.join("snap"); + let mem_path = case_dir.join("memory.bin"); + let mut memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(&mem_path) + .unwrap(); + assert_eq!( + initialize_snapshot_memory_backing_file(&memory_file, MEMORY_SIZE).unwrap(), + 0 + ); + + let mut expected = vec![0_u8; MEMORY_SIZE as usize]; + match case { + "zero" => {} + "mixed" => { + let tail_offset = expected.len() - 4; + expected[..4].copy_from_slice(b"head"); + expected[tail_offset..].copy_from_slice(b"tail"); + memory_file.write_all(b"head").unwrap(); + memory_file.seek(SeekFrom::End(-4)).unwrap(); + memory_file.write_all(b"tail").unwrap(); + } + "dense" => { + expected.fill(0x5a); + memory_file.seek(SeekFrom::Start(0)).unwrap(); + memory_file.write_all(&expected).unwrap(); + } + _ => unreachable!(), + } + memory_file.sync_all().unwrap(); + + let mut manifest = test_manifest(); + manifest.memory_size_bytes = MEMORY_SIZE; + write_snapshot_from_memory_file(&snap_dir, &manifest, b"state", &memory_file).unwrap(); + + memory_file.seek(SeekFrom::Start(0)).unwrap(); + memory_file.write_all(b"xxxx").unwrap(); + memory_file.sync_all().unwrap(); + + let published_path = snap_dir.join(MEMORY_FILE_NAME); + let published = std::fs::File::open(&published_path).unwrap(); + assert_eq!(published.metadata().unwrap().len(), MEMORY_SIZE); + if sparse { + let allocated = allocated_file_bytes(&published, MEMORY_SIZE).unwrap(); + assert!( + allocated < MEMORY_SIZE / 4, + "{case} snapshot allocated {allocated} bytes for a {MEMORY_SIZE}-byte file", + ); + } + assert_eq!(std::fs::read(published_path).unwrap(), expected, "{case}"); + } + } + + #[cfg(windows)] + #[test] + fn write_snapshot_uses_dense_memory_files_on_windows() { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_SPARSE_FILE: u32 = 0x200; + const MEMORY_SIZE: u64 = 4 * 1024 * 1024; + + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + let mut memory_file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create_new(true) + .open(mem_path) + .unwrap(); + initialize_snapshot_memory_backing_file(&memory_file, MEMORY_SIZE).unwrap(); + assert_eq!( + memory_file.metadata().unwrap().file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0 + ); + + memory_file.write_all(b"head").unwrap(); + memory_file.seek(SeekFrom::End(-4)).unwrap(); + memory_file.write_all(b"tail").unwrap(); + memory_file.sync_all().unwrap(); + + let mut manifest = test_manifest(); + manifest.memory_size_bytes = MEMORY_SIZE; + write_snapshot_from_memory_file(&snap_dir, &manifest, b"state", &memory_file).unwrap(); + + let published_path = snap_dir.join(MEMORY_FILE_NAME); + let published = std::fs::File::open(&published_path).unwrap(); + assert_eq!( + published.metadata().unwrap().file_attributes() & FILE_ATTRIBUTE_SPARSE_FILE, + 0 + ); + + let mut expected = vec![0_u8; MEMORY_SIZE as usize]; + expected[..4].copy_from_slice(b"head"); + let tail_offset = expected.len() - 4; + expected[tail_offset..].copy_from_slice(b"tail"); + assert_eq!(std::fs::read(published_path).unwrap(), expected); + } + #[test] fn exact_memory_handle_survives_path_replacement_before_publish() { let dir = tempfile::tempdir().unwrap(); From 16a340484f3d92dfa9fa6c1b40d42e2d01648b25 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 01:41:23 -0700 Subject: [PATCH 066/304] membacking: map guest RAM privately copy-on-write with fault accounting Restoring a snapshot maps `memory.bin` as guest RAM, but a file backing could only be mapped shared, so every guest write would go into the snapshot artifact, and the snapshot could be used only once. Add a file mapping mode to the memory manager. With `FileMappingMode::CopyOnWrite`, an existing backing file is mapped with private copy-on-write views, so the guest reads the contents of the file while its writes stay private to the process and never reach the file. Copy-on-write RAM requires an existing file backing, and it is not offered as a shared-memory backing to other processes. The default mode is shared, as before. Also let the memory manager count guest-memory faults and the bytes populated while resolving them, for diagnosing restore performance, and report the totals through `MemoryManager::fault_counters`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + openvmm/membacking/Cargo.toml | 3 + openvmm/membacking/src/lib.rs | 1 + .../membacking/src/mapping_manager/manager.rs | 53 +++++- openvmm/membacking/src/mapping_manager/mod.rs | 1 + .../src/mapping_manager/va_mapper.rs | 154 ++++++++++++----- openvmm/membacking/src/memory_manager/mod.rs | 158 +++++++++++++++++- 7 files changed, 319 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 14a5b907cfd..5da9e260331 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4600,6 +4600,7 @@ dependencies = [ "range_map_vec", "slab", "sparse_mmap", + "tempfile", "thiserror 2.0.16", "tracing", "virt", diff --git a/openvmm/membacking/Cargo.toml b/openvmm/membacking/Cargo.toml index 7e78eb001eb..590f20c3b90 100644 --- a/openvmm/membacking/Cargo.toml +++ b/openvmm/membacking/Cargo.toml @@ -33,5 +33,8 @@ windows-sys = { workspace = true, features = [ "Win32_System_Memory", ]} +[dev-dependencies] +tempfile.workspace = true + [lints] workspace = true diff --git a/openvmm/membacking/src/lib.rs b/openvmm/membacking/src/lib.rs index 8aa86856293..c0aa05aefc2 100644 --- a/openvmm/membacking/src/lib.rs +++ b/openvmm/membacking/src/lib.rs @@ -88,6 +88,7 @@ mod sys { /// On Unix, this is an empty (uninhabitable) enum. pub type RemoteProcess = sys::RemoteProcess; +pub use mapping_manager::FileMappingMode; pub use mapping_manager::Mappable; pub use memory_manager::DeviceMemoryMapper; pub use memory_manager::GuestMemoryBuilder; diff --git a/openvmm/membacking/src/mapping_manager/manager.rs b/openvmm/membacking/src/mapping_manager/manager.rs index dfd509c923e..eb92376d944 100644 --- a/openvmm/membacking/src/mapping_manager/manager.rs +++ b/openvmm/membacking/src/mapping_manager/manager.rs @@ -61,6 +61,7 @@ impl MappingManager { max_addr: u64, minimum_va_alignment: Option, supports_memory_fault_resolution: bool, + track_memory_faults: bool, ) -> Result<(Self, Arc), VaMapperError> { let this = Self::new_bare(spawn, max_addr, minimum_va_alignment); // Create the primary mapper as part of construction. Being first, it is @@ -72,6 +73,7 @@ impl MappingManager { true, MapperRole::Primary { supports_memory_fault_resolution, + track_memory_faults, }, ) .await?; @@ -302,7 +304,7 @@ fn inspect_mappings(mappings: &Vec) -> impl '_ + Inspect { req.respond() .field("writable", mapping.params.writable) .field("mapping_type", mapping.params.mapping_type) - .field("backed_by_fd", mapping.params.backing.mappable().is_some()) + .field("backed_by_fd", mapping.params.backing.is_file_backed()) .hex("file_offset", mapping.params.backing.file_offset()); }), ); @@ -315,6 +317,15 @@ struct Mapping { active_mappers: Vec, } +/// Controls whether writes to a file-backed mapping update the backing object. +#[derive(Debug, Copy, Clone, Eq, PartialEq, MeshPayload)] +pub enum FileMappingMode { + /// Writes are visible through the backing object and all of its mappings. + Shared, + /// Writes are private to this mapper and do not modify the backing object. + CopyOnWrite, +} + /// How a guest memory mapping is backed by host memory. #[derive(Debug, MeshPayload, Clone)] pub enum MappingBacking { @@ -328,6 +339,17 @@ pub enum MappingBacking { /// The file offset into `mappable`. file_offset: u64, }, + /// Backed by a file whose writable pages are private to this mapper. + /// + /// Although this retains a mappable handle internally, it cannot be + /// exported to DMA targets or additional VA mappers because each mapping + /// would otherwise receive an independent private copy. + CopyOnWriteFile { + /// The OS object to map. + mappable: Mappable, + /// The file offset into `mappable`. + file_offset: u64, + }, /// Backed by private anonymous memory committed directly by the mapping /// manager. There is no backing fd, so the memory cannot be shared with /// other processes or programmed into DMA targets that require an fd; it is @@ -354,17 +376,34 @@ impl MappingBacking { pub fn mappable(&self) -> Option<&Mappable> { match self { MappingBacking::File { mappable, .. } => Some(mappable), - MappingBacking::Private => None, + MappingBacking::CopyOnWriteFile { .. } | MappingBacking::Private => None, } } /// Returns the offset within the backing object, or 0 if there is none. pub fn file_offset(&self) -> u64 { match self { - MappingBacking::File { file_offset, .. } => *file_offset, + MappingBacking::File { file_offset, .. } + | MappingBacking::CopyOnWriteFile { file_offset, .. } => *file_offset, MappingBacking::Private => 0, } } + + /// Returns whether the backing has process-private write state. + pub fn is_private(&self) -> bool { + matches!( + self, + MappingBacking::CopyOnWriteFile { .. } | MappingBacking::Private + ) + } + + /// Returns whether the backing uses a file or section internally. + pub fn is_file_backed(&self) -> bool { + matches!( + self, + MappingBacking::File { .. } | MappingBacking::CopyOnWriteFile { .. } + ) + } } /// Host-memory policy for a mapping, applied to the mapped VA range after the @@ -710,7 +749,7 @@ impl MappingManagerTask { // Private memory is anonymous and lives in a single mapper's address // space, so it requires at most one mapper. Reject adding private RAM // (e.g. via hotplug) while more than one mapper is present. - if matches!(params.backing, MappingBacking::Private) && self.mappers.mappers.len() > 1 { + if params.backing.is_private() && self.mappers.mappers.len() > 1 { anyhow::bail!("cannot add private memory while multiple mappers are present"); } @@ -765,9 +804,7 @@ impl MappingManagerTask { /// Returns true if any current mapping is backed by private memory. fn has_private_mapping(&self) -> bool { - self.mappings - .iter() - .any(|m| matches!(m.params.backing, MappingBacking::Private)) + self.mappings.iter().any(|m| m.params.backing.is_private()) } fn get_dma_target_mappings(&self) -> Vec { @@ -1490,6 +1527,7 @@ mod tests { true, // eager MapperRole::Primary { supports_memory_fault_resolution: false, + track_memory_faults: false, }, ); let (mapper, _) = futures::join!(mapper_future, async { @@ -1530,6 +1568,7 @@ mod tests { true, // eager MapperRole::Primary { supports_memory_fault_resolution: false, + track_memory_faults: false, }, ); let (mapper, mapper_req_send) = futures::join!(mapper_future, async { diff --git a/openvmm/membacking/src/mapping_manager/mod.rs b/openvmm/membacking/src/mapping_manager/mod.rs index 0f6ff059099..261f0fe02d4 100644 --- a/openvmm/membacking/src/mapping_manager/mod.rs +++ b/openvmm/membacking/src/mapping_manager/mod.rs @@ -8,6 +8,7 @@ mod mappable; mod object_cache; mod va_mapper; +pub use manager::FileMappingMode; pub use manager::MappingBacking; pub use manager::MappingManager; pub use manager::MappingManagerClient; diff --git a/openvmm/membacking/src/mapping_manager/va_mapper.rs b/openvmm/membacking/src/mapping_manager/va_mapper.rs index b969bfe8efc..56e1eb10fd7 100644 --- a/openvmm/membacking/src/mapping_manager/va_mapper.rs +++ b/openvmm/membacking/src/mapping_manager/va_mapper.rs @@ -87,6 +87,8 @@ use windows_sys::Win32::System::Memory::PAGE_READONLY; #[cfg(windows)] use windows_sys::Win32::System::Memory::PAGE_READWRITE; #[cfg(windows)] +use windows_sys::Win32::System::Memory::PAGE_WRITECOPY; +#[cfg(windows)] use windows_sys::Win32::System::Memory::SECTION_MAP_READ; #[cfg(windows)] use windows_sys::Win32::System::Memory::SECTION_MAP_WRITE; @@ -118,6 +120,8 @@ pub(crate) enum MapperRole { /// enabled when this is set. Carried on the `Primary` variant because it /// is meaningless for a secondary mapper. supports_memory_fault_resolution: bool, + /// Whether to retain fault counters for diagnostics. + track_memory_faults: bool, }, /// Any other mapper; plain read-write 4 KB pages. Secondary, @@ -131,8 +135,8 @@ struct MappingProps { /// Backed by private anonymous memory (committed up front) rather than a /// shared file/section mapping. private: bool, - /// General per-mapping fault counters, always present. See [`FaultStats`]. - stats: FaultStats, + /// Optional per-mapping fault counters. See [`FaultStats`]. + stats: Option, /// Soft-large-page (Windows THP) state, or `None` when the scheme does not /// apply (non-primary/device mappers, read-only or non-THP ranges, and every /// non-Windows host). See the [`soft_lp`] module. @@ -141,16 +145,28 @@ struct MappingProps { /// Per-mapping fault counters, exposed via `Inspect`. /// -/// Recorded for every mapping regardless of host OS, role, or backing, so -/// general fault accounting is available even on mappings that never use soft -/// large pages. Kept per mapping — one set of counters per backing, and thus -/// per NUMA node — so they scale for large multi-NUMA-node VMs rather than -/// contending on a single global counter. The counters are plain atomics -/// (`SharedCounter`), bumped in place under the mapping-index read lock. +/// When diagnostics request accounting, counters are recorded for every mapping +/// regardless of host OS or backing, including mappings that never use soft +/// large pages. They remain absent on the default path. Counters are kept per +/// mapping — one set per backing, and thus per NUMA node — so they scale for +/// large multi-NUMA-node VMs rather than contending on a single global counter. +/// The counters are plain atomics (`SharedCounter`), bumped in place under the +/// mapping-index read lock. #[derive(Debug, Default, Inspect)] struct FaultStats { /// Guest memory faults resolved for this mapping. guest_faults: SharedCounter, + /// Bytes returned to the backend while resolving guest memory faults. + populated_bytes: SharedCounter, +} + +/// Aggregate fault counters for all mappings owned by a VA mapper. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct MemoryFaultCounters { + /// Guest memory faults resolved through this mapper. + pub guest_faults: u64, + /// Bytes returned to the backend while resolving those faults. + pub populated_bytes: u64, } /// A virtual address space mapper for guest memory. @@ -173,6 +189,23 @@ impl std::fmt::Debug for VaMapper { } } +impl VaMapper { + pub(crate) fn fault_counters(&self) -> MemoryFaultCounters { + self.inner.mappings.read().iter().fold( + MemoryFaultCounters::default(), + |mut total, (_, props)| { + if let Some(stats) = &props.stats { + total.guest_faults = total.guest_faults.wrapping_add(stats.guest_faults.get()); + total.populated_bytes = total + .populated_bytes + .wrapping_add(stats.populated_bytes.get()); + } + total + }, + ) + } +} + impl Drop for VaMapper { fn drop(&mut self) { // Do not join the mapper thread here. The mapping manager must process @@ -189,10 +222,10 @@ impl Drop for VaMapper { impl Inspect for VaMapper { /// Contributes each mapping's counters to the shared `mappings` node, keyed /// by GPA range, so the stats sit alongside the mapping they describe (the - /// mapping-manager entry with the same range merges with this one). Every - /// mapping contributes a `faults` child (general fault accounting); only - /// soft-large-page mappings (the primary mapper's writable THP ranges on - /// Windows) additionally contribute a `soft_large_pages` child. + /// mapping-manager entry with the same range merges with this one). Mappings + /// contribute a `faults` child when accounting is enabled; soft-large-page + /// mappings (the primary mapper's writable THP ranges on Windows) + /// additionally contribute a `soft_large_pages` child. fn inspect(&self, req: inspect::Request<'_>) { req.respond().field( "mappings", @@ -205,7 +238,9 @@ impl Inspect for VaMapper { &range.to_string(), inspect::adhoc(|req| { let mut resp = req.respond(); - resp.field("faults", &props.stats); + if let Some(stats) = &props.stats { + resp.field("faults", stats); + } if let Some(sl) = &props.soft_lp { resp.field("soft_large_pages", sl); } @@ -248,6 +283,8 @@ struct MapperInner { /// wedge on the first guest write; the primary mapper only enables them when /// this is set. supports_memory_fault_resolution: bool, + /// Whether mappings retain guest-memory fault counters for diagnostics. + track_memory_faults: bool, req_send: mesh::Sender, /// Maintains a weak reference to avoid a reference cycle with the partition. host_access: OnceLock, @@ -354,7 +391,8 @@ impl MapperTask { // `soft_lp` module. They also depend on the partition delivering write // faults to raise deferred-protect windows, so don't even build one when // the partition can't resolve faults. - let soft_lp = if self.inner.supports_memory_fault_resolution { + let copy_on_write = matches!(params.backing, MappingBacking::CopyOnWriteFile { .. }); + let soft_lp = if self.inner.supports_memory_fault_resolution && !copy_on_write { SoftLp::new( params.range, ¶ms.policy, @@ -375,9 +413,16 @@ impl MapperTask { mappable, file_offset, } => { - self.map_file(¶ms, mappable, *file_offset, deferred_protect)?; + self.map_file(¶ms, mappable, *file_offset, deferred_protect, false)?; false } + MappingBacking::CopyOnWriteFile { + mappable, + file_offset, + } => { + self.map_file(¶ms, mappable, *file_offset, false, true)?; + true + } MappingBacking::Private => { self.map_private(¶ms, deferred_protect)?; true @@ -387,7 +432,7 @@ impl MapperTask { params.range, MappingProps { private, - stats: FaultStats::default(), + stats: self.inner.track_memory_faults.then(FaultStats::default), soft_lp, }, ); @@ -402,6 +447,7 @@ impl MapperTask { mappable: &super::mappable::Mappable, file_offset: u64, deferred_protect: bool, + copy_on_write: bool, ) -> Result<(), MappingError> { let &MappingParams { range, @@ -423,18 +469,29 @@ impl MapperTask { // fails with ERROR_INVALID_PARAMETER). Deferred protect implies // `writable`. #[cfg(windows)] - let (protect, access) = ( - if writable { - PAGE_READWRITE - } else { - PAGE_READONLY - }, - if writable { - SECTION_MAP_READ | SECTION_MAP_WRITE - } else { - SECTION_MAP_READ - }, - ); + let (protect, access) = if copy_on_write { + ( + if writable { + PAGE_WRITECOPY + } else { + PAGE_READONLY + }, + SECTION_MAP_READ, + ) + } else { + ( + if writable { + PAGE_READWRITE + } else { + PAGE_READONLY + }, + if writable { + SECTION_MAP_READ | SECTION_MAP_WRITE + } else { + SECTION_MAP_READ + }, + ) + }; // `deferred_protect` is only consulted on Windows below; keep it live on // other targets so the shared parameter doesn't warn. let _ = deferred_protect; @@ -451,13 +508,23 @@ impl MapperTask { ) } _ => { - self.inner.mapping.map_file( - range.start() as usize, - range.len() as usize, - mappable, - file_offset, - writable, - ) + if copy_on_write { + self.inner.mapping.map_file_copy_on_write( + range.start() as usize, + range.len() as usize, + mappable, + file_offset, + writable, + ) + } else { + self.inner.mapping.map_file( + range.start() as usize, + range.len() as usize, + mappable, + file_offset, + writable, + ) + } } }; @@ -732,11 +799,12 @@ impl VaMapper { // Soft large pages apply only to the primary mapper, and only when the // partition resolves faults; `supports_memory_fault_resolution` rides on // the `Primary` variant. - let (primary, supports_memory_fault_resolution) = match role { + let (primary, supports_memory_fault_resolution, track_memory_faults) = match role { MapperRole::Primary { supports_memory_fault_resolution, - } => (true, supports_memory_fault_resolution), - MapperRole::Secondary => (false, false), + track_memory_faults, + } => (true, supports_memory_fault_resolution, track_memory_faults), + MapperRole::Secondary => (false, false, false), }; let mapping = match &remote_process { None => SparseMapping::new_with_minimum_alignment( @@ -769,6 +837,7 @@ impl VaMapper { eager: AtomicBool::new(eager), primary, supports_memory_fault_resolution, + track_memory_faults, req_send, host_access: OnceLock::new(), }); @@ -1010,19 +1079,22 @@ impl ResolveMemoryFault for VaMapper { UnexpectedPageFault, )); } - props.stats.guest_faults.increment(); - // Soft large pages (Windows) raise the covering 2 MB window on the first // write and may resolve to the whole window; every other mapping (and // every non-Windows host) resolves to the single faulting page. - match &props.soft_lp { + let populated = match &props.soft_lp { Some(sl) => sl .resolve(&self.inner.mapping, fault, write, start, end) .map_err(|err| { GuestMemoryBackingError::new(GuestMemoryErrorKind::Other, fault.start(), err) }), None => Ok(fault), + }?; + if let Some(stats) = &props.stats { + stats.guest_faults.increment(); + stats.populated_bytes.add(populated.len()); } + Ok(populated) } } diff --git a/openvmm/membacking/src/memory_manager/mod.rs b/openvmm/membacking/src/memory_manager/mod.rs index bc2f97bc8e6..46b840de264 100644 --- a/openvmm/membacking/src/memory_manager/mod.rs +++ b/openvmm/membacking/src/memory_manager/mod.rs @@ -8,6 +8,7 @@ mod device_memory; pub use device_memory::DeviceMemoryMapper; use crate::RemoteProcess; +use crate::mapping_manager::FileMappingMode; use crate::mapping_manager::Mappable; use crate::mapping_manager::MappingBacking; use crate::mapping_manager::MappingManager; @@ -62,11 +63,22 @@ pub struct GuestMemoryManager { supports_memory_fault_resolution: bool, } +/// Aggregate guest-physical fault counters for a memory manager. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct MemoryFaultCounters { + /// Guest memory faults resolved through the VMM. + pub guest_faults: u64, + /// Bytes returned to the backend while resolving those faults. + pub populated_bytes: u64, +} + /// A single RAM backing allocation — one memfd or anonymous region. #[derive(Debug)] struct RamBacking { /// The file-backed memory handle. `None` for private (anonymous) backings. mappable: Option, + /// How writes to a file-backed mapping are handled. + file_mapping_mode: FileMappingMode, /// GPA ranges covered by this backing. ranges: Vec, /// Prefetch pages at build time. @@ -169,6 +181,9 @@ pub enum MemoryBuildError { /// Hugepages are incompatible with x86 legacy RAM splitting. #[error("hugepages are incompatible with x86 legacy RAM splitting")] HugepagesWithLegacy, + /// Copy-on-write mappings require an existing file backing. + #[error("copy-on-write file mapping requires an existing memory backing")] + CopyOnWriteWithoutExistingBacking, /// Invalid hugepage size. #[error("hugepage size {0} must be a power of two and at least the host page size")] InvalidHugepageSize(MemorySize), @@ -212,6 +227,7 @@ pub struct RamBackingRequest { hugepages: bool, hugepage_size: Option, existing_mappable: Option, + file_mapping_mode: FileMappingMode, host_numa_node: Option, } @@ -229,6 +245,7 @@ impl RamBackingRequest { hugepages: false, hugepage_size: None, existing_mappable: None, + file_mapping_mode: FileMappingMode::Shared, host_numa_node: None, } } @@ -272,6 +289,12 @@ impl RamBackingRequest { self } + /// Controls whether writes update an existing file backing. + pub fn file_mapping_mode(mut self, mode: FileMappingMode) -> Self { + self.file_mapping_mode = mode; + self + } + /// Bind this backing's memory to a specific host NUMA node /// (Linux: `mbind(MPOL_BIND)`, Windows: `CreateFileMappingNuma` for /// large-page sections and `MemExtendedParameterNumaNode` otherwise). @@ -348,6 +371,7 @@ pub struct GuestMemoryBuilder { pin_mappings: bool, x86_legacy_support: bool, supports_memory_fault_resolution: bool, + track_memory_faults: bool, backing_requests: Vec, } @@ -359,6 +383,7 @@ impl GuestMemoryBuilder { pin_mappings: false, x86_legacy_support: false, supports_memory_fault_resolution: false, + track_memory_faults: false, backing_requests: Vec::new(), } } @@ -404,6 +429,12 @@ impl GuestMemoryBuilder { self } + /// Enables guest-memory fault accounting for diagnostics. + pub fn track_memory_faults(mut self, enable: bool) -> Self { + self.track_memory_faults = enable; + self + } + /// Adds a RAM backing request. Call once per backing (one per NUMA node, /// or once for a non-NUMA VM). pub fn add_backing(mut self, request: RamBackingRequest) -> Self { @@ -428,6 +459,11 @@ impl GuestMemoryBuilder { if req.private_memory && req.existing_mappable.is_some() { return Err(MemoryBuildError::PrivateMemoryWithExistingBacking); } + if req.file_mapping_mode == FileMappingMode::CopyOnWrite + && req.existing_mappable.is_none() + { + return Err(MemoryBuildError::CopyOnWriteWithoutExistingBacking); + } if req.host_numa_node.is_some() && cfg!(not(any(target_os = "linux", target_os = "windows"))) { @@ -485,6 +521,7 @@ impl GuestMemoryBuilder { if req.private_memory { backings.push(RamBacking { mappable: None, + file_mapping_mode: FileMappingMode::Shared, ranges: req.ranges, prefetch: req.prefetch, transparent_hugepages: req.transparent_hugepages, @@ -538,6 +575,7 @@ impl GuestMemoryBuilder { backings.push(RamBacking { mappable: Some(mappable), + file_mapping_mode: req.file_mapping_mode, ranges: req.ranges, // On Windows, hugepage (SEC_LARGE_PAGES) backing only yields 2 MB // SLAT entries when the SLAT is populated in >= 512-page batches; @@ -575,6 +613,7 @@ impl GuestMemoryBuilder { max_addr, max_hugepage_size, self.supports_memory_fault_resolution, + self.track_memory_faults, ) .await .map_err(MemoryBuildError::VaMapper)?; @@ -624,12 +663,18 @@ impl GuestMemoryBuilder { // region-driven DMA machinery (mapped by host VA). Without // this, an assigned device DMAing to private RAM would take // IOMMU faults (silent DMA failure). - let backing_kind = match &backing.mappable { - Some(mappable) => MappingBacking::File { + let backing_kind = match (&backing.mappable, backing.file_mapping_mode) { + (Some(mappable), FileMappingMode::Shared) => MappingBacking::File { mappable: mappable.clone(), file_offset, }, - None => MappingBacking::Private, + (Some(mappable), FileMappingMode::CopyOnWrite) => { + MappingBacking::CopyOnWriteFile { + mappable: mappable.clone(), + file_offset, + } + } + (None, _) => MappingBacking::Private, }; region .add_mapping( @@ -688,18 +733,43 @@ impl GuestMemoryBuilder { #[derive(Debug, MeshPayload)] pub struct SharedMemoryBacking { guest_ram: Mappable, + file_mapping_mode: FileMappingMode, } impl SharedMemoryBacking { /// Create a SharedMemoryBacking from a mappable handle/fd. pub fn from_mappable(guest_ram: Mappable) -> Self { - Self { guest_ram } + Self { + guest_ram, + file_mapping_mode: FileMappingMode::Shared, + } + } + + /// Create a SharedMemoryBacking with an explicit file mapping mode. + pub fn from_mappable_with_mode( + guest_ram: Mappable, + file_mapping_mode: FileMappingMode, + ) -> Self { + Self { + guest_ram, + file_mapping_mode, + } } /// Returns the mappable, consuming this backing. pub fn into_mappable(self) -> Mappable { self.guest_ram } + + /// Returns whether writes to this backing are private to the current VM. + pub fn is_copy_on_write(&self) -> bool { + self.file_mapping_mode == FileMappingMode::CopyOnWrite + } + + /// Returns the mappable and mapping mode, consuming this backing. + pub fn into_parts(self) -> (Mappable, FileMappingMode) { + (self.guest_ram, self.file_mapping_mode) + } } /// A mesh-serializable object for providing access to guest memory. @@ -737,6 +807,15 @@ impl GuestMemoryManager { } } + /// Returns aggregate guest-physical fault and population counters. + pub fn fault_counters(&self) -> MemoryFaultCounters { + let counters = self.va_mapper.fault_counters(); + MemoryFaultCounters { + guest_faults: counters.guest_faults, + populated_bytes: counters.populated_bytes, + } + } + /// Returns a resolver that prepares guest-memory backing on demand in /// response to partition memory-access faults (soft large pages, lazy /// commit). @@ -783,8 +862,12 @@ impl GuestMemoryManager { if self.guest_ram.len() != 1 { return None; } + if self.guest_ram[0].file_mapping_mode != FileMappingMode::Shared { + return None; + } Some(SharedMemoryBacking { guest_ram: self.guest_ram[0].mappable.clone()?, + file_mapping_mode: FileMappingMode::Shared, }) } @@ -927,6 +1010,9 @@ mod tests { use super::*; use pal_async::async_test; use std::error::Error as _; + use std::io::Read; + use std::io::Seek; + use std::io::Write; /// Build a GuestMemoryManager with the given backing range groups, /// and return a GuestMemory handle for read/write testing. @@ -967,6 +1053,70 @@ mod tests { )); } + #[async_test] + async fn test_copy_on_write_existing_backing_is_private() { + let size = SparseMapping::page_size() as u64; + let original = vec![0x5a_u8; size as usize]; + let mut file = tempfile::tempfile().unwrap(); + file.write_all(&original).unwrap(); + file.sync_all().unwrap(); + let mappable = sparse_mmap::new_mappable_from_file_copy_on_write(&file, false).unwrap(); + let backing = RamBackingRequest::new(vec![MemoryRange::new(0..size)]) + .existing_mappable(mappable.into()) + .file_mapping_mode(FileMappingMode::CopyOnWrite); + let manager = GuestMemoryBuilder::new() + .add_backing(backing) + .build(size) + .await + .unwrap(); + + assert!(manager.shared_memory_backing().is_none()); + let memory = manager.client().guest_memory().await.unwrap(); + memory.write_at(0, &[0xa5]).unwrap(); + assert_eq!(memory.read_plain::(0).unwrap(), 0xa5); + + file.rewind().unwrap(); + let mut file_bytes = Vec::new(); + file.read_to_end(&mut file_bytes).unwrap(); + assert_eq!(file_bytes, original); + } + + #[async_test] + async fn test_snapshot_cow_and_fresh_private_expansion_are_independent() { + let page = SparseMapping::page_size() as u64; + let original = vec![0x5a_u8; page as usize]; + let mut file = tempfile::tempfile().unwrap(); + file.write_all(&original).unwrap(); + file.sync_all().unwrap(); + let mappable = sparse_mmap::new_mappable_from_file_copy_on_write(&file, false).unwrap(); + + let manager = GuestMemoryBuilder::new() + .add_backing( + RamBackingRequest::new(vec![MemoryRange::new(0..page)]) + .existing_mappable(mappable.into()) + .file_mapping_mode(FileMappingMode::CopyOnWrite), + ) + .add_backing( + RamBackingRequest::new(vec![MemoryRange::new(page..2 * page)]).private_memory(true), + ) + .build(2 * page) + .await + .unwrap(); + let memory = manager.client().guest_memory().await.unwrap(); + + assert_eq!(memory.read_plain::(0).unwrap(), 0x5a); + assert_eq!(memory.read_plain::(page).unwrap(), 0); + memory.write_at(0, &[0xa5]).unwrap(); + memory.write_at(page, &[0xcc]).unwrap(); + assert_eq!(memory.read_plain::(0).unwrap(), 0xa5); + assert_eq!(memory.read_plain::(page).unwrap(), 0xcc); + + file.rewind().unwrap(); + let mut file_bytes = Vec::new(); + file.read_to_end(&mut file_bytes).unwrap(); + assert_eq!(file_bytes, original); + } + #[test] fn test_validate_hugepage_size() { let page_size = SparseMapping::page_size() as u64; From 6fa7e4cba0b36886194e814d22b203ae079215e6 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 01:52:49 -0700 Subject: [PATCH 067/304] openvmm_helpers: open snapshots once and read artifacts relative to the directory Restore read `manifest.bin` and `state.bin` by joining each name to the snapshot path, and the caller reopened `memory.bin` the same way. Every open resolved the pathname again, following symbolic links, so renaming or replacing the directory, or one of its files, between two opens could combine artifacts of different generations. Unexpected files in the directory were ignored, and the length of `memory.bin` was checked only by the caller. Add `OpenedSnapshot`, which opens the snapshot directory once and then opens each artifact relative to that directory handle. Linux uses `openat` with `O_NOFOLLOW`; Windows uses handle-relative, read-only opens that share only reads and reject reparse points. `OpenedSnapshot::open` reads the bounded manifest and saved state through the opened handles, requires the directory to contain exactly `manifest.bin`, `state.bin`, and `memory.bin` as regular files, checks the exact artifact lengths, and records the generation of the opened memory file: its identity, length, and, on Linux, its modification and change times. Accessors return the manifest and state, check that the memory generation is unchanged, duplicate the exact memory handle for a later mapping, and report logical and allocated artifact sizes for profiling. `into_parts` moves the open directory and artifact handles into the new `SnapshotRestoreGuards`, so a later caller can keep the generation open for the lifetime of a restored VM. `restore::read_snapshot` now takes the expected guest memory size and validates all three artifacts through an `OpenedSnapshot`, and `read_snapshot_manifest`, `read_snapshot_with_memory`, and `read_snapshot_artifacts_with_memory` cover manifest-only reads and reads that also return the exact memory handle. The top-level `read_snapshot` re-export is removed; its callers use `restore::read_snapshot`. The command-line restore path still reopens `memory.bin` as shared, writable guest RAM, until restores map it privately copy-on-write. Document restore validation and the limits of the local format: it does not authenticate same-length payload changes, and Linux file descriptors do not provide mandatory write exclusion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/dev_guide/snapshot_format.md | 24 +- Guide/src/user_guide/openvmm/snapshots.md | 15 +- openvmm/openvmm_defs/src/worker.rs | 13 + openvmm/openvmm_entry/src/lib.rs | 3 +- openvmm/openvmm_helpers/src/snapshot.rs | 6 +- openvmm/openvmm_helpers/src/snapshot/fs.rs | 188 ++++- .../openvmm_helpers/src/snapshot/restore.rs | 645 +++++++++++++++++- vmm_tests/vmm_tests/tests/tests/x86_64.rs | 3 +- 8 files changed, 848 insertions(+), 49 deletions(-) diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 9ad65501744..d679dd373fc 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -29,7 +29,13 @@ to be absent. Restore accepts versions 2 and 3. Reading a snapshot bounds `manifest.bin` to 1 MiB and `state.bin` to 256 MiB, validates the manifest format before it reads `state.bin`, and requires -`state.bin` to have the length that the manifest records. +`state.bin` and `memory.bin` to have the lengths that the manifest records. + +The default format is a local machine-state contract, not an authenticated +container. All versions receive the same regular-file, no-follow/no-reparse, +bounded decoding, exact-length, and inventory validation, but the on-disk +format does not authenticate same-length payload changes. Export or transport +layers must provide broader integrity and authentication outside this format. ## Device state (`state.bin`) @@ -57,10 +63,24 @@ is the commit point; the parent directory is flushed afterwards. A failure before the commit removes the staging directory and leaves the destination absent. The destination must not already exist. +## Restore + +Restore opens the snapshot directory once and resolves its artifacts relative +to that handle. The directory must contain exactly `manifest.bin`, `state.bin`, +and `memory.bin`, each a regular file. Windows uses read-only handles with +`FILE_SHARE_READ` only, rejects reparse points, and records the memory file's +`FILE_ID_INFO` and EOF. Linux uses `O_NOFOLLOW` directory and regular-file +descriptors and records the memory file's device, inode, length, and +modification and change times, so renaming or replacing the original path +cannot substitute another generation. Linux file descriptors do not provide +mandatory write exclusion; deployments that need authenticated or write-proof +local artifacts must add a stronger mode such as a lease, fs-verity, or a +verified artifact broker. + ## Code references - Manifest type and I/O: `openvmm/openvmm_helpers/src/snapshot.rs` -- Format validation, publication, and file-system helpers: +- Format validation, publication, restore-side access, and file-system helpers: `openvmm/openvmm_helpers/src/snapshot/` - Restore entry point: `prepare_snapshot_restore()` in `openvmm/openvmm_entry/src/lib.rs` diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 956b0f1dc7d..40489d6fe0c 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -76,9 +76,18 @@ cargo run -- \ --restore-snapshot path/to/snapshot-dir ``` -`--restore-snapshot` automatically opens `memory.bin` from the snapshot -directory, so `file=...` should not be specified in `--memory` (the two -options are mutually exclusive). +`--restore-snapshot` verifies and opens `memory.bin` from the snapshot +directory, so `file=...` should not be specified in `--memory` (the two options +are mutually exclusive). + +```admonish warning +Version 3 does not contain or validate embedded checksums for `state.bin` or +`memory.bin`. Restore still requires regular files, bounded manifest and state +decoding, and exact artifact lengths, but same-length payload changes are not +detected. Protect snapshot directories with host access controls. Integrity or +authentication for export and transport must be supplied outside the default +snapshot format. +``` ```admonish note The `--memory` and `--processors` values must match the values recorded in diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index 3d721740863..feab96c323e 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -24,6 +24,19 @@ pub type SharedMemoryFd = std::os::windows::io::OwnedHandle; pub const VM_WORKER: WorkerId = WorkerId::new("VmWorker"); +/// Exact snapshot-generation handles retained for a restored VM's lifetime. +#[derive(MeshPayload)] +pub struct SnapshotRestoreGuards { + /// Open snapshot directory used for relative artifact access. + pub directory: std::fs::File, + /// Open manifest artifact. + pub manifest: std::fs::File, + /// Open saved-state artifact. + pub state: std::fs::File, + /// Open memory artifact backing the copy-on-write section. + pub memory: std::fs::File, +} + /// Complete saved state consumed by the VM worker. #[derive(Protobuf, SavedStateRoot)] #[mesh(package = "openvmm")] diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 8b865951810..945c39479c2 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2657,7 +2657,8 @@ fn prepare_snapshot_restore( openvmm_defs::worker::SharedMemoryFd, mesh::payload::message::ProtobufMessage, )> { - let (manifest, state_bytes) = openvmm_helpers::snapshot::read_snapshot(snapshot_dir)?; + let (manifest, state_bytes) = + openvmm_helpers::snapshot::restore::read_snapshot(snapshot_dir, opt.memory_size())?; // Validate manifest against current VM config. openvmm_helpers::snapshot::validate_manifest( diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index 188e6e1f7d4..301c4e14821 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -12,7 +12,6 @@ pub mod publish; pub mod restore; pub use publish::write_snapshot; -pub use restore::read_snapshot; /// Current manifest format version. Bump when making incompatible changes. pub const MANIFEST_VERSION: u32 = 3; @@ -114,6 +113,7 @@ pub fn validate_manifest( #[cfg(test)] mod tests { + use super::restore::read_snapshot; use super::*; /// Helper: build a test manifest with sensible defaults. @@ -147,7 +147,7 @@ mod tests { write_snapshot(&snap_dir, &manifest, state, &mem_path).unwrap(); - let (read_manifest, read_state) = read_snapshot(&snap_dir).unwrap(); + let (read_manifest, read_state) = read_snapshot(&snap_dir, 1024).unwrap(); assert_eq!(read_manifest.version, manifest.version); assert_eq!(read_manifest.memory_size_bytes, manifest.memory_size_bytes); assert_eq!(read_manifest.vp_count, manifest.vp_count); @@ -165,7 +165,7 @@ mod tests { fn read_snapshot_missing_file() { let dir = tempfile::tempdir().unwrap(); // No files written — read should fail. - let result = read_snapshot(dir.path()); + let result = read_snapshot(dir.path(), 1024); assert!(result.is_err()); } diff --git a/openvmm/openvmm_helpers/src/snapshot/fs.rs b/openvmm/openvmm_helpers/src/snapshot/fs.rs index 1c65b9b0193..9a5304d7047 100644 --- a/openvmm/openvmm_helpers/src/snapshot/fs.rs +++ b/openvmm/openvmm_helpers/src/snapshot/fs.rs @@ -4,15 +4,12 @@ //! File-system helpers for snapshot artifacts, including their //! platform-specific implementations: private files and directories, //! no-replace renames and directory flushes, directory-relative artifact -//! access, exact-file hard links, and sparse-aware clone and copy with -//! allocation accounting. +//! access and file generations, exact-file hard links, and sparse-aware clone +//! and copy with allocation accounting. use anyhow::Context; -#[cfg(windows)] use std::io::Read; -#[cfg(windows)] use std::io::Seek; -#[cfg(windows)] use std::io::SeekFrom; use std::io::Write; use std::path::Path; @@ -659,6 +656,10 @@ pub(super) struct OpenedSnapshotDirectory { } impl OpenedSnapshotDirectory { + pub(super) fn open(path: &Path) -> anyhow::Result { + Self::open_impl(path, false) + } + pub(super) fn open_for_publication(path: &Path) -> anyhow::Result { Self::open_impl(path, true) } @@ -708,7 +709,6 @@ impl OpenedSnapshotDirectory { }) } - #[cfg(not(windows))] pub(super) fn open_regular_file( &self, name: &str, @@ -775,9 +775,73 @@ impl OpenedSnapshotDirectory { Ok(file) } + pub(super) fn open_file_with_length( + &self, + name: &str, + expected_length: u64, + artifact_name: &str, + ) -> anyhow::Result { + let file = self.open_regular_file(name, artifact_name)?; + let length = opened_file_generation(&file, artifact_name)?.length(); + anyhow::ensure!( + length == expected_length, + "{artifact_name} size ({length} bytes) doesn't match manifest ({expected_length} bytes)", + ); + Ok(file) + } + + pub(super) fn entry_names(&self) -> anyhow::Result> { + #[cfg(target_os = "linux")] + { + use std::os::fd::OwnedFd; + use std::os::unix::ffi::OsStrExt; + + let directory: OwnedFd = self + .file + .try_clone() + .context("failed to duplicate snapshot directory handle")? + .into(); + let mut directory = nix::dir::Dir::from_fd(directory).map_err(nix_error)?; + let mut names = Vec::new(); + for entry in directory.iter() { + let entry = entry.map_err(nix_error)?; + let name = std::ffi::OsStr::from_bytes(entry.file_name().to_bytes()); + if name != "." && name != ".." { + names.push(name.to_owned()); + } + } + Ok(names) + } + #[cfg(windows)] + { + pal::windows::fs::relative::directory_entry_names(&self.file) + .context("failed to enumerate opened snapshot directory") + } + #[cfg(not(any(target_os = "linux", windows)))] + { + fs_err::read_dir(&self.path) + .with_context(|| { + format!( + "failed to enumerate snapshot directory {}", + self.path.display() + ) + })? + .map(|entry| { + entry + .context("failed to inspect snapshot directory entry") + .map(|entry| entry.file_name()) + }) + .collect() + } + } + pub(super) fn display_path(&self, name: impl AsRef) -> PathBuf { self.path.join(name) } + + pub(super) fn into_file(self) -> std::fs::File { + self.file + } } #[cfg(target_os = "linux")] @@ -854,6 +918,116 @@ fn reject_windows_reparse_point( Ok(()) } +#[cfg(target_os = "linux")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct OpenedFileGeneration { + device: u64, + inode: u64, + length: u64, + modified_seconds: i64, + modified_nanoseconds: i64, + changed_seconds: i64, + changed_nanoseconds: i64, +} + +#[cfg(windows)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct OpenedFileGeneration { + volume_serial_number: u64, + file_id: [u8; 16], + length: u64, +} + +#[cfg(not(any(target_os = "linux", windows)))] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct OpenedFileGeneration { + length: u64, +} + +impl OpenedFileGeneration { + pub(super) fn length(self) -> u64 { + self.length + } +} + +#[cfg(target_os = "linux")] +pub(super) fn opened_file_generation( + file: &std::fs::File, + description: &str, +) -> anyhow::Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect opened {description}"))?; + Ok(OpenedFileGeneration { + device: metadata.dev(), + inode: metadata.ino(), + length: metadata.len(), + modified_seconds: metadata.mtime(), + modified_nanoseconds: metadata.mtime_nsec(), + changed_seconds: metadata.ctime(), + changed_nanoseconds: metadata.ctime_nsec(), + }) +} + +#[cfg(windows)] +pub(super) fn opened_file_generation( + file: &std::fs::File, + description: &str, +) -> anyhow::Result { + let identity = pal::windows::fs::relative::file_identity(file) + .with_context(|| format!("failed to query {description} FILE_ID_INFO and EOF"))?; + Ok(OpenedFileGeneration { + volume_serial_number: identity.volume_serial_number, + file_id: identity.file_id, + length: identity.end_of_file, + }) +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub(super) fn opened_file_generation( + file: &std::fs::File, + description: &str, +) -> anyhow::Result { + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect opened {description}"))?; + Ok(OpenedFileGeneration { + length: metadata.len(), + }) +} + +pub(super) fn read_bounded_open_file( + file: &std::fs::File, + maximum_size: u64, + description: &str, +) -> anyhow::Result> { + let generation = opened_file_generation(file, description)?; + let length = generation.length(); + anyhow::ensure!( + length <= maximum_size, + "{description} is {length} bytes, exceeding the maximum of {maximum_size} bytes", + ); + let capacity = usize::try_from(length).context("artifact length does not fit in usize")?; + let mut bytes = Vec::with_capacity(capacity); + let mut reader = file + .try_clone() + .with_context(|| format!("failed to duplicate {description} handle"))?; + reader + .seek(SeekFrom::Start(0)) + .with_context(|| format!("failed to rewind {description}"))?; + reader + .take(maximum_size + 1) + .read_to_end(&mut bytes) + .with_context(|| format!("failed to read {description}"))?; + anyhow::ensure!( + bytes.len() as u64 == length && opened_file_generation(file, description)? == generation, + "{description} changed while it was being read", + ); + Ok(bytes) +} + #[cfg(unix)] pub(super) fn sync_directory(path: &Path) -> anyhow::Result<()> { fs_err::File::open(path) @@ -873,8 +1047,6 @@ pub(super) fn sync_directory(_path: &Path) -> anyhow::Result<()> { #[cfg(all(test, target_os = "linux"))] mod tests { use super::*; - use std::io::Seek; - use std::io::SeekFrom; #[test] fn sparse_copy_fallbacks_preserve_extents_and_clone_independence() { diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs index 28eb7f953da..5d8f168d4f5 100644 --- a/openvmm/openvmm_helpers/src/snapshot/restore.rs +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -1,61 +1,644 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Reading VM snapshots from a directory. +//! Restore-side snapshot access: opening one published generation through a +//! retained directory handle, and structural validation against its manifest. use super::SnapshotManifest; +use super::format::MANIFEST_FILE_NAME; use super::format::MAX_MANIFEST_SIZE_BYTES; use super::format::MAX_SAVED_STATE_SIZE_BYTES; +use super::format::MEMORY_FILE_NAME; +use super::format::STATE_FILE_NAME; use super::format::validate_manifest_header; +use super::format::validate_manifest_version; +use super::fs::OpenedFileGeneration; +use super::fs::OpenedSnapshotDirectory; +use super::fs::allocated_file_bytes; +use super::fs::opened_file_generation; +use super::fs::read_bounded_open_file; use anyhow::Context; -use std::io::Read; +use std::collections::HashSet; use std::path::Path; -/// Read a snapshot from the given directory. +/// One structurally validated snapshot generation opened for restore. /// -/// Returns the decoded manifest and the raw saved-state bytes. The manifest -/// and `state.bin` are read with bounded sizes, and `state.bin` must have the -/// length that the manifest records. The caller is responsible for validating -/// the manifest against the VM and for opening `memory.bin` separately. -pub fn read_snapshot(dir: &Path) -> anyhow::Result<(SnapshotManifest, Vec)> { - let manifest_bytes = read_bounded( - &dir.join("manifest.bin"), - MAX_MANIFEST_SIZE_BYTES, - "manifest.bin", - )?; +/// Artifact access is relative to the retained directory handle. The open +/// artifact handles keep pathname replacement from substituting another +/// generation after validation. +pub struct OpenedSnapshot { + directory: OpenedSnapshotDirectory, + manifest_file: std::fs::File, + state_file: std::fs::File, + memory_file: std::fs::File, + memory_generation: OpenedFileGeneration, + manifest: SnapshotManifest, + state_bytes: Vec, +} + +impl OpenedSnapshot { + /// Opens and structurally validates one exact snapshot generation. + pub fn open(dir: &Path) -> anyhow::Result { + let directory = OpenedSnapshotDirectory::open(dir)?; + let manifest_file = directory.open_regular_file(MANIFEST_FILE_NAME, "snapshot manifest")?; + let state_file = directory.open_regular_file(STATE_FILE_NAME, "saved state")?; + let memory_file = directory.open_regular_file(MEMORY_FILE_NAME, "snapshot memory")?; + let manifest = decode_snapshot_manifest(&manifest_file)?; + validate_snapshot_directory(&directory)?; + anyhow::ensure!( + manifest.state_size_bytes <= MAX_SAVED_STATE_SIZE_BYTES, + "state.bin length in the manifest exceeds the maximum size of \ + {MAX_SAVED_STATE_SIZE_BYTES} bytes", + ); + + let state_bytes = + read_bounded_open_file(&state_file, MAX_SAVED_STATE_SIZE_BYTES, "saved state")?; + anyhow::ensure!( + state_bytes.len() as u64 == manifest.state_size_bytes, + "state.bin size ({} bytes) doesn't match manifest ({} bytes)", + state_bytes.len(), + manifest.state_size_bytes, + ); + + let memory_generation = opened_file_generation(&memory_file, MEMORY_FILE_NAME)?; + anyhow::ensure!( + memory_generation.length() == manifest.memory_size_bytes, + "memory.bin size ({} bytes) doesn't match manifest ({} bytes)", + memory_generation.length(), + manifest.memory_size_bytes, + ); + + Ok(Self { + directory, + manifest_file, + state_file, + memory_file, + memory_generation, + manifest, + state_bytes, + }) + } + + /// Returns the authoritative manifest read from this opened generation. + pub fn manifest(&self) -> &SnapshotManifest { + &self.manifest + } + + /// Returns the saved-state bytes read from this opened generation. + pub fn state_bytes(&self) -> &[u8] { + &self.state_bytes + } + + /// Returns total logical and allocated bytes for the opened artifacts. + /// + /// This is intended for opt-in profiling. Restore validation does not + /// depend on allocation accounting being available. + pub fn artifact_size_counters(&self) -> anyhow::Result<(u64, u64)> { + [&self.manifest_file, &self.state_file, &self.memory_file] + .into_iter() + .try_fold((0_u64, 0_u64), |(logical, allocated), file| { + let length = file.metadata()?.len(); + Ok(( + logical + .checked_add(length) + .context("logical byte count overflow")?, + allocated + .checked_add(allocated_file_bytes(file, length)?) + .context("allocated byte count overflow")?, + )) + }) + } + + /// Duplicates the exact memory handle used to create a private mapping. + pub fn duplicate_memory_file_for_mapping( + &self, + expected_memory_size: u64, + ) -> anyhow::Result { + self.validate_memory_generation(expected_memory_size)?; + let duplicate = self + .memory_file + .try_clone() + .context("failed to duplicate snapshot memory handle")?; + anyhow::ensure!( + opened_file_generation(&duplicate, MEMORY_FILE_NAME)? == self.memory_generation, + "snapshot memory duplicate does not refer to the opened generation", + ); + Ok(duplicate) + } + + /// Verifies that the opened memory generation and EOF are unchanged. + pub fn validate_memory_generation(&self, expected_memory_size: u64) -> anyhow::Result<()> { + anyhow::ensure!( + self.manifest.memory_size_bytes == expected_memory_size, + "memory.bin size in the manifest ({} bytes) doesn't match expected ({expected_memory_size} bytes)", + self.manifest.memory_size_bytes, + ); + anyhow::ensure!( + opened_file_generation(&self.memory_file, MEMORY_FILE_NAME)? == self.memory_generation, + "snapshot memory generation changed after it was opened", + ); + Ok(()) + } + + /// Consumes this snapshot into decoded data and lifetime guard handles. + pub fn into_parts( + self, + ) -> ( + SnapshotManifest, + Vec, + openvmm_defs::worker::SnapshotRestoreGuards, + ) { + ( + self.manifest, + self.state_bytes, + openvmm_defs::worker::SnapshotRestoreGuards { + directory: self.directory.into_file(), + manifest: self.manifest_file, + state: self.state_file, + memory: self.memory_file, + }, + ) + } +} + +fn read_snapshot_manifest_from_directory( + directory: &OpenedSnapshotDirectory, +) -> anyhow::Result<(SnapshotManifest, std::fs::File)> { + let manifest_file = directory.open_regular_file(MANIFEST_FILE_NAME, "snapshot manifest")?; + let manifest = decode_snapshot_manifest(&manifest_file)?; + Ok((manifest, manifest_file)) +} + +fn decode_snapshot_manifest(manifest_file: &std::fs::File) -> anyhow::Result { + let manifest_bytes = + read_bounded_open_file(manifest_file, MAX_MANIFEST_SIZE_BYTES, "snapshot manifest")?; let manifest: SnapshotManifest = mesh::payload::decode(&manifest_bytes).context("failed to decode snapshot manifest")?; validate_manifest_header(&manifest)?; + validate_manifest_version(&manifest)?; + Ok(manifest) +} + +/// Read a snapshot from the given directory. +/// +/// Returns the decoded manifest and raw saved-state bytes after structurally +/// validating all three artifacts. `expected_memory_size` bounds memory. +pub fn read_snapshot( + dir: &Path, + expected_memory_size: u64, +) -> anyhow::Result<(SnapshotManifest, Vec)> { + let snapshot = OpenedSnapshot::open(dir)?; + snapshot.validate_memory_generation(expected_memory_size)?; + let (manifest, state_bytes, _) = snapshot.into_parts(); + Ok((manifest, state_bytes)) +} + +/// Reads and structurally validates only `manifest.bin`. +/// +/// Production restore should use [`OpenedSnapshot`] so later artifact access +/// remains anchored to the same opened directory generation. +pub fn read_snapshot_manifest(dir: &Path) -> anyhow::Result { + let directory = OpenedSnapshotDirectory::open(dir)?; + let (manifest, _) = read_snapshot_manifest_from_directory(&directory)?; + validate_snapshot_directory(&directory)?; + Ok(manifest) +} + +/// Read and structurally validate a snapshot, returning its exact memory handle. +/// +/// The returned file is positioned at offset zero and must be used directly +/// for restore so a path replacement cannot substitute different memory. +pub fn read_snapshot_with_memory( + dir: &Path, + expected_memory_size: u64, +) -> anyhow::Result<(SnapshotManifest, Vec, std::fs::File)> { + let snapshot = OpenedSnapshot::open(dir)?; + snapshot.validate_memory_generation(expected_memory_size)?; + let (manifest, state_bytes, guards) = snapshot.into_parts(); + let openvmm_defs::worker::SnapshotRestoreGuards { memory, .. } = guards; + Ok((manifest, state_bytes, memory)) +} + +/// Opens snapshot artifacts against an already validated manifest. +/// +/// This compatibility helper reopens the directory. Production restore should +/// keep an [`OpenedSnapshot`] alive through worker construction instead. +pub fn read_snapshot_artifacts_with_memory( + dir: &Path, + manifest: &SnapshotManifest, + expected_memory_size: u64, +) -> anyhow::Result<(Vec, std::fs::File)> { + let directory = OpenedSnapshotDirectory::open(dir)?; + validate_manifest_header(manifest)?; + validate_manifest_version(manifest)?; + validate_snapshot_directory(&directory)?; anyhow::ensure!( manifest.state_size_bytes <= MAX_SAVED_STATE_SIZE_BYTES, - "state.bin exceeds the maximum size of {MAX_SAVED_STATE_SIZE_BYTES} bytes" + "state.bin length in the manifest exceeds the maximum size of \ + {MAX_SAVED_STATE_SIZE_BYTES} bytes", ); - let state_bytes = read_bounded( - &dir.join("state.bin"), - manifest.state_size_bytes, - "state.bin", - )?; + let state_file = directory.open_regular_file(STATE_FILE_NAME, "saved state")?; + let state_bytes = + read_bounded_open_file(&state_file, MAX_SAVED_STATE_SIZE_BYTES, "saved state")?; anyhow::ensure!( state_bytes.len() as u64 == manifest.state_size_bytes, - "state.bin length {} does not match the manifest length {}", + "state.bin size ({} bytes) doesn't match manifest ({} bytes)", state_bytes.len(), manifest.state_size_bytes, ); + anyhow::ensure!( + manifest.memory_size_bytes == expected_memory_size, + "memory.bin size in the manifest ({} bytes) doesn't match expected ({expected_memory_size} bytes)", + manifest.memory_size_bytes, + ); + let memory_file = directory.open_file_with_length( + MEMORY_FILE_NAME, + expected_memory_size, + MEMORY_FILE_NAME, + )?; - Ok((manifest, state_bytes)) + Ok((state_bytes, memory_file)) } -/// Reads at most `limit` bytes of the file at `path`, failing if it is longer. -fn read_bounded(path: &Path, limit: u64, description: &str) -> anyhow::Result> { - let file = fs_err::File::open(path).with_context(|| format!("failed to open {description}"))?; - let mut bytes = Vec::new(); - file.take(limit.saturating_add(1)) - .read_to_end(&mut bytes) - .with_context(|| format!("failed to read {description}"))?; +fn validate_snapshot_directory(directory: &OpenedSnapshotDirectory) -> anyhow::Result<()> { + let mut entries = HashSet::new(); + for name in directory.entry_names()? { + anyhow::ensure!( + name == MANIFEST_FILE_NAME || name == STATE_FILE_NAME || name == MEMORY_FILE_NAME, + "unexpected artifact in snapshot directory: {}", + directory.display_path(&name).display(), + ); + entries.insert(name); + } anyhow::ensure!( - bytes.len() as u64 <= limit, - "{description} exceeds the maximum size of {limit} bytes" + entries.len() == 3 + && entries.contains(std::ffi::OsStr::new(MANIFEST_FILE_NAME)) + && entries.contains(std::ffi::OsStr::new(STATE_FILE_NAME)) + && entries.contains(std::ffi::OsStr::new(MEMORY_FILE_NAME)), + "snapshot directory is incomplete" ); - Ok(bytes) + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::format::LEGACY_MANIFEST_VERSION; + use super::super::format::LEGACY_SNAPSHOT_FORMAT_MAGIC; + use super::super::format::SHA256_SIZE; + use super::super::publish::write_snapshot; + use super::super::tests::test_manifest; + use super::*; + use std::io::Read; + #[cfg(target_os = "linux")] + use std::io::Write; + + #[test] + fn read_snapshot_accepts_same_length_state_change_without_legacy_checksum_validation() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(STATE_FILE_NAME), b"other").unwrap(); + + let (_, state) = read_snapshot(&snap_dir, 1024).unwrap(); + assert_eq!(state, b"other"); + } + + #[test] + fn read_snapshot_accepts_same_length_memory_change_without_legacy_checksum_validation() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(MEMORY_FILE_NAME), vec![1_u8; 1024]).unwrap(); + + let (_, _, mut memory) = read_snapshot_with_memory(&snap_dir, 1024).unwrap(); + let mut bytes = Vec::new(); + memory.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, vec![1_u8; 1024]); + } + + #[test] + fn supplied_manifest_remains_authoritative_during_artifact_open() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + let manifest = read_snapshot_manifest(&snap_dir).unwrap(); + + std::fs::write(snap_dir.join(MANIFEST_FILE_NAME), b"replacement").unwrap(); + let (state, mut memory) = + read_snapshot_artifacts_with_memory(&snap_dir, &manifest, 1024).unwrap(); + let mut bytes = Vec::new(); + memory.read_to_end(&mut bytes).unwrap(); + + assert_eq!(state, b"state"); + assert_eq!(bytes, vec![0_u8; 1024]); + } + + #[test] + fn read_snapshot_accepts_legacy_v2_manifest_without_verifying_digests() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + let mut manifest = read_snapshot_manifest(&snap_dir).unwrap(); + manifest.version = LEGACY_MANIFEST_VERSION; + manifest.format_magic = LEGACY_SNAPSHOT_FORMAT_MAGIC.to_vec(); + manifest.state_sha256 = vec![0xa5; SHA256_SIZE]; + manifest.memory_sha256 = vec![0x5a; SHA256_SIZE]; + std::fs::write( + snap_dir.join(MANIFEST_FILE_NAME), + mesh::payload::encode(manifest), + ) + .unwrap(); + std::fs::write(snap_dir.join(STATE_FILE_NAME), b"other").unwrap(); + std::fs::write(snap_dir.join(MEMORY_FILE_NAME), vec![1_u8; 1024]).unwrap(); + + let (manifest, state) = read_snapshot(&snap_dir, 1024).unwrap(); + assert_eq!(manifest.version, LEGACY_MANIFEST_VERSION); + assert_eq!(state, b"other"); + } + + #[test] + fn read_snapshot_rejects_malformed_legacy_digest_lengths() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + let mut manifest = read_snapshot_manifest(&snap_dir).unwrap(); + manifest.version = LEGACY_MANIFEST_VERSION; + manifest.format_magic = LEGACY_SNAPSHOT_FORMAT_MAGIC.to_vec(); + manifest.state_sha256 = vec![0; SHA256_SIZE - 1]; + manifest.memory_sha256 = vec![0; SHA256_SIZE]; + std::fs::write( + snap_dir.join(MANIFEST_FILE_NAME), + mesh::payload::encode(manifest.clone()), + ) + .unwrap(); + let error = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(error.to_string().contains("state.bin SHA-256 digest")); + + manifest.state_sha256 = vec![0; SHA256_SIZE]; + manifest.memory_sha256 = vec![0; SHA256_SIZE + 1]; + std::fs::write( + snap_dir.join(MANIFEST_FILE_NAME), + mesh::payload::encode(manifest), + ) + .unwrap(); + let error = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(error.to_string().contains("memory.bin SHA-256 digest")); + } + + #[test] + fn read_snapshot_still_requires_exact_memory_length() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(MEMORY_FILE_NAME), vec![1_u8; 1023]).unwrap(); + + let error = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(error.to_string().contains("memory.bin size")); + } + + #[test] + fn read_snapshot_rejects_corrupt_manifest() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(MANIFEST_FILE_NAME), b"not-a-manifest").unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("decode snapshot manifest")); + } + + #[test] + fn read_snapshot_rejects_truncated_state() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join(STATE_FILE_NAME), b"sta").unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("state.bin size")); + } + + #[test] + fn read_snapshot_rejects_truncated_memory() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(snap_dir.join(MEMORY_FILE_NAME)) + .unwrap() + .set_len(512) + .unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("memory.bin size")); + } + + #[test] + fn read_snapshot_rejects_oversized_state() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(snap_dir.join(STATE_FILE_NAME)) + .unwrap() + .set_len(MAX_SAVED_STATE_SIZE_BYTES + 1) + .unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("saved state is")); + assert!(err.to_string().contains("exceeding the maximum")); + } + + #[test] + fn read_snapshot_rejects_unexpected_artifact() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + std::fs::write(snap_dir.join("extra.bin"), b"unexpected").unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!(err.to_string().contains("unexpected artifact")); + } + + #[cfg(unix)] + #[test] + fn read_snapshot_rejects_symlinked_artifact() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + let state_path = snap_dir.join(STATE_FILE_NAME); + let moved_state_path = snap_dir.join("state-target.bin"); + std::fs::rename(&state_path, &moved_state_path).unwrap(); + symlink(&moved_state_path, &state_path).unwrap(); + + let err = read_snapshot(&snap_dir, 1024).err().unwrap(); + assert!( + err.to_string().contains("unexpected artifact") + || err.to_string().contains("not a regular file") + || err.to_string().contains("failed to open saved state") + ); + } + + #[cfg(unix)] + #[test] + fn opened_memory_handle_survives_path_replacement() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let mem_path = dir.path().join("memory.bin"); + std::fs::write(&mem_path, vec![0x5a_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); + + let (_, _, mut opened_memory) = read_snapshot_with_memory(&snap_dir, 1024).unwrap(); + let original_path = snap_dir.join(MEMORY_FILE_NAME); + let moved_path = snap_dir.join("opened-memory.bin"); + std::fs::rename(&original_path, &moved_path).unwrap(); + std::fs::write(&original_path, vec![0xa5_u8; 1024]).unwrap(); + + let mut bytes = Vec::new(); + opened_memory.read_to_end(&mut bytes).unwrap(); + assert_eq!(bytes, vec![0x5a_u8; 1024]); + } + + #[cfg(target_os = "linux")] + #[test] + fn opened_snapshot_survives_directory_path_replacement() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let moved_dir = dir.path().join("opened-snapshot"); + let memory_source = dir.path().join("memory-source.bin"); + std::fs::write(&memory_source, vec![0x5a_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &memory_source).unwrap(); + + let snapshot = OpenedSnapshot::open(&snap_dir).unwrap(); + std::fs::rename(&snap_dir, &moved_dir).unwrap(); + std::fs::write(&memory_source, vec![0xa5_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"other", &memory_source).unwrap(); + + snapshot.validate_memory_generation(1024).unwrap(); + let (_, state, guards) = snapshot.into_parts(); + let mut memory = guards.memory; + let mut bytes = Vec::new(); + memory.read_to_end(&mut bytes).unwrap(); + assert_eq!(state, b"state"); + assert_eq!(bytes, vec![0x5a_u8; 1024]); + } + + #[cfg(target_os = "linux")] + #[test] + fn opened_snapshot_detects_same_length_memory_write_before_mapping() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_source = dir.path().join("memory-source.bin"); + std::fs::write(&memory_source, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &memory_source).unwrap(); + + let snapshot = OpenedSnapshot::open(&snap_dir).unwrap(); + std::thread::sleep(std::time::Duration::from_secs(1)); + let mut writer = std::fs::OpenOptions::new() + .write(true) + .open(snap_dir.join(MEMORY_FILE_NAME)) + .unwrap(); + writer.write_all(&[1]).unwrap(); + writer.sync_all().unwrap(); + + let error = snapshot + .duplicate_memory_file_for_mapping(1024) + .unwrap_err(); + assert!( + error + .to_string() + .contains("memory generation changed after it was opened") + ); + } + + #[cfg(windows)] + #[test] + fn opened_snapshot_denies_mutation_until_guards_drop() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let moved_dir = dir.path().join("moved"); + let memory_source = dir.path().join("memory-source.bin"); + std::fs::write(&memory_source, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &memory_source).unwrap(); + + let snapshot = OpenedSnapshot::open(&snap_dir).unwrap(); + let memory_path = snap_dir.join(MEMORY_FILE_NAME); + assert!( + std::fs::OpenOptions::new() + .write(true) + .open(&memory_path) + .is_err() + ); + assert!( + std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&memory_path) + .is_err() + ); + assert!(std::fs::remove_file(&memory_path).is_err()); + assert!(std::fs::rename(&snap_dir, &moved_dir).is_err()); + + drop(snapshot); + std::fs::rename(&snap_dir, &moved_dir).unwrap(); + } + + #[cfg(windows)] + #[test] + fn opened_snapshot_rejects_reparse_artifact() { + use std::os::windows::fs::symlink_file; + + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_source = dir.path().join("memory-source.bin"); + let replacement = dir.path().join("replacement.bin"); + std::fs::write(&memory_source, vec![0_u8; 1024]).unwrap(); + write_snapshot(&snap_dir, &test_manifest(), b"state", &memory_source).unwrap(); + std::fs::rename(snap_dir.join(MEMORY_FILE_NAME), &replacement).unwrap(); + match symlink_file(&replacement, snap_dir.join(MEMORY_FILE_NAME)) { + Ok(()) => {} + Err(error) + if error.kind() == std::io::ErrorKind::PermissionDenied + || error.raw_os_error() == Some(1314) => + { + return; + } + Err(error) => panic!("failed to create test symlink: {error}"), + } + + let error = OpenedSnapshot::open(&snap_dir).err().unwrap(); + assert!( + error.to_string().contains("reparse point") + || error.to_string().contains("not a regular file") + ); + } } diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64.rs b/vmm_tests/vmm_tests/tests/tests/x86_64.rs index 50f7c561131..57472e41818 100644 --- a/vmm_tests/vmm_tests/tests/tests/x86_64.rs +++ b/vmm_tests/vmm_tests/tests/tests/x86_64.rs @@ -616,7 +616,8 @@ async fn snapshot_save_to_disk( assert!(snap_dir.join("manifest.bin").exists()); assert!(snap_dir.join("state.bin").exists()); assert!(snap_dir.join("memory.bin").exists()); - let (read_manifest, read_state) = openvmm_helpers::snapshot::read_snapshot(&snap_dir)?; + let (read_manifest, read_state) = + openvmm_helpers::snapshot::restore::read_snapshot(&snap_dir, mem_size)?; assert_eq!( read_state, saved_state_bytes, "state roundtrip through disk should match" From a8c616b102ef470ea227365b99856b291002eba1 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 02:04:11 -0700 Subject: [PATCH 068/304] openvmm: restore RAM through a private CoW mapping and retain generation handles `--restore-snapshot` reopened `memory.bin` by path for reading and writing and mapped it as shared guest RAM. Every guest write after restore went into the snapshot artifact, so a snapshot could be restored only once, and copying the directory first was the only way to reuse it. The restored VM also held no handle to the generation it had validated, so the artifacts could be replaced while the VM ran. Restore now opens the snapshot through an `OpenedSnapshot` before the VM configuration is built, in the new `snapshot_restore` module of `openvmm_entry`. Just before the VM worker launches, the opened generation is validated against the VM configuration, a duplicate of the exact opened memory handle becomes a private copy-on-write memory handle (`file_to_copy_on_write_memory_fd`), and the memory generation is checked again after the mapping handle is created. The worker receives the handle with the new `shared_memory_copy_on_write` flag and maps guest RAM privately copy-on-write, so guest writes stay in the process and never reach `memory.bin`, and one snapshot can be restored any number of times. The open directory and artifact handles move to the worker as the new `snapshot_restore_guards` parameter, and the worker keeps them until it exits, which keeps the generation open for the lifetime of the restored VM; on Windows the read-only handles also deny writes to it. The worker takes its restore inputs from the parameters in the new `dispatch/restore` module, which also takes over the saved-state inventory check. The management and petri launches pass the defaults: a shared mapping and no guards. With `OPENVMM_STARTUP_PROFILE` set, restore records the `restore` phases `artifact_open`, `artifact_prepare`, and `cow_section_create`, and a launch records the `startup` milestones `worker_launch` and `worker_construct` and the worker phases `partition_prototype`, `cow_map_view`, `partition_build`, `gpa_registration`, and `partition_finalize`. Document that restores are private and repeatable, and describe the handles that restore retains. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/dev_guide/snapshot_format.md | 15 ++- Guide/src/user_guide/openvmm/snapshots.md | 9 +- openvmm/openvmm_core/src/worker/dispatch.rs | 39 +++++-- .../src/worker/dispatch/restore.rs | 54 +++++++++ openvmm/openvmm_defs/src/worker.rs | 4 + openvmm/openvmm_entry/src/lib.rs | 61 ++-------- .../openvmm_entry/src/snapshot_restore/mod.rs | 108 ++++++++++++++++++ .../src/snapshot_restore/prepare.rs | 79 +++++++++++++ openvmm/openvmm_entry/src/ttrpc/mod.rs | 2 + openvmm/openvmm_helpers/src/shared_memory.rs | 15 +++ petri/src/worker.rs | 2 + 11 files changed, 317 insertions(+), 71 deletions(-) create mode 100644 openvmm/openvmm_core/src/worker/dispatch/restore.rs create mode 100644 openvmm/openvmm_entry/src/snapshot_restore/mod.rs create mode 100644 openvmm/openvmm_entry/src/snapshot_restore/prepare.rs diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index d679dd373fc..c0242b5e328 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -67,11 +67,14 @@ absent. The destination must not already exist. Restore opens the snapshot directory once and resolves its artifacts relative to that handle. The directory must contain exactly `manifest.bin`, `state.bin`, -and `memory.bin`, each a regular file. Windows uses read-only handles with -`FILE_SHARE_READ` only, rejects reparse points, and records the memory file's -`FILE_ID_INFO` and EOF. Linux uses `O_NOFOLLOW` directory and regular-file -descriptors and records the memory file's device, inode, length, and -modification and change times, so renaming or replacing the original path +and `memory.bin`, each a regular file. Guest RAM is a private copy-on-write +mapping of the opened `memory.bin` handle, so guest writes never reach the +snapshot and it can be restored repeatedly. Windows uses read-only handles with +`FILE_SHARE_READ` only, rejects reparse points, compares `FILE_ID_INFO` and EOF +before and after creating the private COW section, and keeps the directory and +artifact guards in the VM worker until teardown. Linux keeps the exact +`O_NOFOLLOW` directory and regular-file descriptors and rejects observable +metadata changes before handoff, so renaming or replacing the original path cannot substitute another generation. Linux file descriptors do not provide mandatory write exclusion; deployments that need authenticated or write-proof local artifacts must add a stronger mode such as a lease, fs-verity, or a @@ -83,7 +86,7 @@ verified artifact broker. - Format validation, publication, restore-side access, and file-system helpers: `openvmm/openvmm_helpers/src/snapshot/` - Restore entry point: `prepare_snapshot_restore()` in - `openvmm/openvmm_entry/src/lib.rs` + `openvmm/openvmm_entry/src/snapshot_restore/prepare.rs` - File-backed memory: `SharedMemoryFd` type alias in `openvmm/openvmm_defs/src/worker.rs` diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 40489d6fe0c..28dfe989b8a 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -78,7 +78,8 @@ cargo run -- \ `--restore-snapshot` verifies and opens `memory.bin` from the snapshot directory, so `file=...` should not be specified in `--memory` (the two options -are mutually exclusive). +are mutually exclusive). Guest writes use a private copy-on-write mapping and +do not modify the snapshot artifact. ```admonish warning Version 3 does not contain or validate embedded checksums for `state.bin` or @@ -190,10 +191,8 @@ immediately with a clear error if any active device does not support it. - Snapshots are **not portable** across architectures (e.g., you cannot restore an x86_64 snapshot on aarch64) -- After restoring, `memory.bin` in the snapshot directory becomes the live - guest RAM backing file and will be modified as the VM runs. To restore - from the same snapshot multiple times, copy the snapshot directory before - each restore. +- Restores use private copy-on-write RAM, so a snapshot can be restored + repeatedly without copying it or modifying `memory.bin`. - VMs using VPCI or PCIe devices do not currently support save/restore - OpenHCL-based VMs do not currently support this snapshot mechanism - VMs using PCAT firmware do not support save/restore diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index d0a06c283dd..50a4ab4b553 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -8,6 +8,7 @@ mod intel_vtd_wiring; mod ioapic_iommu_wiring; mod pcie_topology; mod pcie_wiring; +mod restore; mod smmu_wiring; use crate::emuplat; @@ -322,6 +323,8 @@ pub struct VmWorker { vm: LoadedVm, rpc: mesh::Receiver, device_thread: JoinHandle<()>, + /// Restored snapshot handles, dropped when `run` returns after the device thread exits. + _snapshot_restore_guards: Option, } impl Worker for VmWorker { @@ -329,7 +332,9 @@ impl Worker for VmWorker { type State = RestartState; const ID: WorkerId = VM_WORKER; - fn new(parameters: Self::Parameters) -> anyhow::Result { + fn new(mut parameters: Self::Parameters) -> anyhow::Result { + let worker_construct = openvmm_defs::profile::ProfileSpan::start(); + let restore_params = restore::RestoreParameters::take(&mut parameters)?; let (device_thread, device_driver) = new_device_thread(); let manifest = Manifest::from_config(parameters.cfg); @@ -339,7 +344,7 @@ impl Worker for VmWorker { let shared_memory = parameters .shared_memory - .map(|fd| SharedMemoryBacking::from_mappable(fd.into())); + .map(|fd| restore_params.shared_memory_backing(fd)); let vm = block_on(InitializedVm::new( VmTaskDriverSource::new(ThreadDriverBackend::new(device_driver)), @@ -357,10 +362,12 @@ impl Worker for VmWorker { LOADED_VM.store(&vm); + worker_construct.complete_milestone("startup", "worker_construct", Default::default()); Ok(Self { vm, rpc: parameters.rpc, device_thread, + _snapshot_restore_guards: restore_params.guards, }) } @@ -397,6 +404,7 @@ impl Worker for VmWorker { vm, rpc, device_thread, + _snapshot_restore_guards: None, }) }) } @@ -1223,6 +1231,7 @@ impl InitializedVm { let device_assignment_msi_iova_range = resolve_device_assignment_msi_iova_range(platform_info.device_assignment_msi_iova); + let partition_prototype = openvmm_defs::profile::ProfileSpan::start(); let proto = hypervisor .new_partition(virt::ProtoPartitionConfig { processor_topology: &processor_topology, @@ -1234,6 +1243,7 @@ impl InitializedVm { device_assignment_msi_iova_range, }) .context("failed to create the prototype partition")?; + partition_prototype.complete("startup", "partition_prototype", Default::default()); let physical_address_size = proto.max_physical_address_size(); @@ -1314,6 +1324,10 @@ impl InitializedVm { }) .context("invalid memory configuration")?; let mem_layout = resolved_layout.memory_layout; + let guest_memory_counters = openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(mem_layout.ram().iter().map(|range| range.range.len()).sum()), + ..Default::default() + }; let resolved_pcie_root_complex_ranges = resolved_layout.pcie_root_complex_ranges; let virtio_mmio_region = resolved_layout.virtio_mmio_region; let chipset_mmio = resolved_layout.chipset_mmio; @@ -1422,7 +1436,7 @@ impl InitializedVm { "shared memory restore not supported with {nodes_with_ranges} memory nodes" ); } - Some(smb.into_mappable()) + Some(smb.into_parts()) } else { None }; @@ -1459,8 +1473,10 @@ impl InitializedVm { if mem.hugepages { backing = backing.hugepages(mem.hugepage_size); } - if let Some(mappable) = existing_mappable.take() { - backing = backing.existing_mappable(mappable); + if let Some((mappable, file_mapping_mode)) = existing_mappable.take() { + backing = backing + .existing_mappable(mappable) + .file_mapping_mode(file_mapping_mode); } memory_builder = memory_builder.add_backing(backing); @@ -1490,10 +1506,12 @@ impl InitializedVm { .end_of_layout() .max(mem_layout.vtl2_range().map_or(0, |r| r.end())); + let cow_map_view = openvmm_defs::profile::ProfileSpan::start(); let mut memory_manager = memory_builder .build(max_addr) .await .context("failed to build guest memory")?; + cow_map_view.complete("startup", "cow_map_view", guest_memory_counters); let gm = memory_manager .client() @@ -1513,6 +1531,7 @@ impl InitializedVm { )); } + let partition_build = openvmm_defs::profile::ProfileSpan::start(); let (partition, vps) = proto .build(virt::PartitionConfig { mem_layout: &mem_layout, @@ -1523,11 +1542,13 @@ impl InitializedVm { .then(|| memory_manager.memory_fault_resolver()), }) .context("failed to create the partition")?; + partition_build.complete("startup", "partition_build", Default::default()); let vps = vps.into_iter().map(|vp| Box::new(vp) as _).collect(); let partition = Arc::new(partition); + let gpa_registration = openvmm_defs::profile::ProfileSpan::start(); memory_manager .attach_partition( Vtl::Vtl0, @@ -1549,7 +1570,9 @@ impl InitializedVm { .await .context("failed to attach memory to VTL2")?; } + gpa_registration.complete("startup", "gpa_registration", guest_memory_counters); + let partition_finalize = openvmm_defs::profile::ProfileSpan::start(); let finalize_result = { let _span = tracing::info_span!("post-memory partition finalization").entered(); let started = std::time::Instant::now(); @@ -1562,6 +1585,7 @@ impl InitializedVm { result }; finalize_result.context("failed to finalize partition memory")?; + partition_finalize.complete("startup", "partition_finalize", Default::default()); Ok(Self { partition, @@ -4303,10 +4327,7 @@ impl LoadedVm { /// Restore state on the VM. async fn restore(&mut self, state: SavedState) -> anyhow::Result<()> { - // Saved state without an inventory is restored without the check. - if !state.inventory.is_empty() { - self.state_units.validate_inventory(&state.inventory)?; - } + restore::validate_inventory(&self.state_units, &state)?; self.state_units.restore(state.units).await?; Ok(()) } diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs new file mode 100644 index 00000000000..5354f64daf5 --- /dev/null +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot restore support for the VM worker: the restore inputs taken from +//! the worker parameters and the checks applied to the saved state. + +use membacking::FileMappingMode; +use membacking::SharedMemoryBacking; +use openvmm_defs::worker::SavedState; +use openvmm_defs::worker::SharedMemoryFd; +use openvmm_defs::worker::SnapshotRestoreGuards; +use openvmm_defs::worker::VmWorkerParameters; +use state_unit::StateUnits; + +/// Snapshot-restore inputs taken from the [`VmWorkerParameters`]. +pub(super) struct RestoreParameters { + /// Mapping mode of the file-backed guest RAM handle. + file_mapping_mode: FileMappingMode, + /// Snapshot generation handles that must outlive the restored VM. + pub(super) guards: Option, +} + +impl RestoreParameters { + /// Takes the restore inputs out of the worker parameters. + pub(super) fn take(parameters: &mut VmWorkerParameters) -> anyhow::Result { + let guards = parameters.snapshot_restore_guards.take(); + let file_mapping_mode = if parameters.shared_memory_copy_on_write { + FileMappingMode::CopyOnWrite + } else { + FileMappingMode::Shared + }; + Ok(Self { + file_mapping_mode, + guards, + }) + } + + /// Wraps the file-backed guest RAM handle with the restore's mapping mode. + pub(super) fn shared_memory_backing(&self, fd: SharedMemoryFd) -> SharedMemoryBacking { + SharedMemoryBacking::from_mappable_with_mode(fd.into(), self.file_mapping_mode) + } +} + +/// Validates the state-unit inventory of a saved-state envelope. Saved state +/// without an inventory is restored without the check. +pub(super) fn validate_inventory( + state_units: &StateUnits, + state: &SavedState, +) -> anyhow::Result<()> { + if !state.inventory.is_empty() { + state_units.validate_inventory(&state.inventory)?; + } + Ok(()) +} diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index feab96c323e..21ed7c46fa0 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -60,6 +60,10 @@ pub struct VmWorkerParameters { /// File-backed guest RAM handle. When set, guest memory uses this /// fd/handle instead of allocating anonymous memory. pub shared_memory: Option, + /// Whether writes to `shared_memory` must remain private to this VM. + pub shared_memory_copy_on_write: bool, + /// Snapshot generation handles that must outlive the restored VM. + pub snapshot_restore_guards: Option, /// The VM RPC channel. pub rpc: mesh::Receiver, /// The notification channel. diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 945c39479c2..d93337dbd8b 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -14,6 +14,7 @@ mod meshworker; mod pidfile; mod repl; mod serial_io; +mod snapshot_restore; mod storage_builder; mod tracing_init; mod ttrpc; @@ -2648,54 +2649,6 @@ pub(crate) const GUEST_ARCH: &str = if cfg!(guest_arch = "x86_64") { "aarch64" }; -/// Open a snapshot directory and validate it against the current VM config. -/// Returns the shared memory fd (from memory.bin) and the saved device state. -fn prepare_snapshot_restore( - snapshot_dir: &Path, - opt: &Options, -) -> anyhow::Result<( - openvmm_defs::worker::SharedMemoryFd, - mesh::payload::message::ProtobufMessage, -)> { - let (manifest, state_bytes) = - openvmm_helpers::snapshot::restore::read_snapshot(snapshot_dir, opt.memory_size())?; - - // Validate manifest against current VM config. - openvmm_helpers::snapshot::validate_manifest( - &manifest, - GUEST_ARCH, - opt.memory_size(), - opt.processors, - system_page_size(), - )?; - - // Open memory.bin (existing file, no create, no resize). - let memory_file = fs_err::OpenOptions::new() - .read(true) - .write(true) - .open(snapshot_dir.join("memory.bin"))?; - - // Validate file size matches expected memory size. - let file_size = memory_file.metadata()?.len(); - if file_size != manifest.memory_size_bytes { - anyhow::bail!( - "memory.bin size ({file_size} bytes) doesn't match manifest ({} bytes)", - manifest.memory_size_bytes, - ); - } - - let shared_memory_fd = - openvmm_helpers::shared_memory::file_to_shared_memory_fd(memory_file.into())?; - - // Reconstruct ProtobufMessage from the saved state bytes. - // The save side wrote mesh::payload::encode(ProtobufMessage), so we decode - // back to ProtobufMessage. - let state_msg: mesh::payload::message::ProtobufMessage = mesh::payload::decode(&state_bytes) - .context("failed to decode saved state from snapshot")?; - - Ok((shared_memory_fd, state_msg)) -} - fn do_main(pidfile_guard: &mut Option) -> anyhow::Result { openvmm_defs::profile::initialize(); #[cfg(windows)] @@ -2805,6 +2758,7 @@ async fn run_control_inner( opt: Options, ) -> anyhow::Result { let mesh = mesh_slot.as_ref().unwrap(); + let mut restore = snapshot_restore::SnapshotRestore::open(&opt)?; let (mut vm_config, mut resources) = vm_config_from_command_line(driver, mesh, &opt).await?; let mut vnc_worker = None; @@ -2929,8 +2883,8 @@ async fn run_control_inner( let vm_worker = { let vm_host = mesh.make_host("vm", opt.log_file.clone()).await?; - let (shared_memory, saved_state) = if let Some(snapshot_dir) = &opt.restore_snapshot { - let (fd, state_msg) = prepare_snapshot_restore(snapshot_dir, &opt)?; + let (shared_memory, saved_state) = if opt.restore_snapshot.is_some() { + let (fd, state_msg) = restore.prepare(&opt)?; (Some(fd), Some(state_msg)) } else { let shared_memory = opt @@ -2944,6 +2898,7 @@ async fn run_control_inner( .transpose()?; (shared_memory, None) }; + let restore = restore.into_worker(); let params = VmWorkerParameters { hypervisor: match &opt.hypervisor { @@ -2953,13 +2908,17 @@ async fn run_control_inner( cfg: vm_config, saved_state, shared_memory, + shared_memory_copy_on_write: restore.shared_memory_copy_on_write, + snapshot_restore_guards: restore.guards, rpc: rpc_recv, notify: notify_send, }; + let worker_launch = openvmm_defs::profile::ProfileSpan::start(); vm_host .launch_worker(VM_WORKER, params) .await - .context("failed to launch vm worker")? + .context("failed to launch vm worker") + .inspect(|_| snapshot_restore::worker_launched(worker_launch))? }; if opt.restore_snapshot.is_some() { diff --git a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs new file mode 100644 index 00000000000..81f5e97c790 --- /dev/null +++ b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot restore of a VM launched from the command line. +//! +//! A `--restore-snapshot` launch opens one exact snapshot generation first +//! ([`SnapshotRestore::open`]). Just before the VM worker launches, the opened +//! generation is validated against the VM configuration and its RAM is mapped +//! copy-on-write ([`SnapshotRestore::prepare`]). Its open artifact handles then +//! move to the worker, which keeps the generation pinned until VM teardown. +//! +//! With `OPENVMM_STARTUP_PROFILE` set, these steps record the `restore` phases +//! `artifact_open`, `artifact_prepare`, and `cow_section_create`, and the +//! launch records the `startup` milestone `worker_launch` +//! ([`worker_launched`]). + +mod prepare; + +use crate::Options; +use anyhow::Context; +use mesh::payload::message::ProtobufMessage; +use openvmm_defs::profile::ProfileSpan; +use openvmm_defs::worker::SharedMemoryFd; +use openvmm_defs::worker::SnapshotRestoreGuards; +use openvmm_helpers::snapshot::restore::OpenedSnapshot; + +/// The snapshot restore of a VM launched from the command line. +pub(crate) struct SnapshotRestore { + /// The opened snapshot generation, until [`Self::prepare`] takes it. + snapshot: Option, + /// The restore inputs of the VM worker, recorded by [`Self::prepare`]. + worker: WorkerRestore, +} + +/// Restore inputs of the VM worker beyond its guest RAM and saved state. +#[derive(Default)] +pub(crate) struct WorkerRestore { + /// Whether writes to the worker's guest RAM must remain private to this VM. + pub(crate) shared_memory_copy_on_write: bool, + /// Snapshot generation handles that must outlive the restored VM. + pub(crate) guards: Option, +} + +impl SnapshotRestore { + /// Opens the `--restore-snapshot` generation, if any. + pub(crate) fn open(opt: &Options) -> anyhow::Result { + let artifact_open = ProfileSpan::start(); + let snapshot = opt + .restore_snapshot + .as_deref() + .map(OpenedSnapshot::open) + .transpose()?; + if opt.restore_snapshot.is_some() { + let counters = if openvmm_defs::profile::enabled() { + snapshot + .as_ref() + .and_then(|snapshot| snapshot.artifact_size_counters().ok()) + .map(|(logical_bytes, allocated_bytes)| { + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(logical_bytes), + allocated_bytes: Some(allocated_bytes), + ..Default::default() + } + }) + .unwrap_or_default() + } else { + Default::default() + }; + artifact_open.complete("restore", "artifact_open", counters); + } + Ok(Self { + snapshot, + worker: WorkerRestore::default(), + }) + } + + /// Validates the opened snapshot generation against the VM configuration + /// and prepares it for the VM worker. + /// + /// Returns the worker's copy-on-write guest RAM and saved state, and + /// records the worker's other restore inputs for [`Self::into_worker`]. + pub(crate) fn prepare( + &mut self, + opt: &Options, + ) -> anyhow::Result<(SharedMemoryFd, ProtobufMessage)> { + let prepared = prepare::prepare_snapshot_restore( + self.snapshot + .take() + .context("snapshot restore is missing its opened generation")?, + opt, + )?; + self.worker.shared_memory_copy_on_write = true; + self.worker.guards = Some(prepared.guards); + Ok((prepared.shared_memory, prepared.saved_state)) + } + + /// Returns the restore inputs of the VM worker: the defaults of a VM that + /// is not restored, or those recorded by [`Self::prepare`]. + pub(crate) fn into_worker(self) -> WorkerRestore { + self.worker + } +} + +/// Records the `startup/worker_launch` profile milestone once the VM worker +/// has launched. +pub(crate) fn worker_launched(worker_launch: ProfileSpan) { + worker_launch.complete_milestone("startup", "worker_launch", Default::default()); +} diff --git a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs new file mode 100644 index 00000000000..3329bdcf2e5 --- /dev/null +++ b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Validation of an opened snapshot generation against the VM configuration, +//! and preparation of the copy-on-write guest RAM and saved state it restores. + +use crate::Options; +use anyhow::Context; + +/// An opened snapshot generation, validated against the VM configuration and +/// prepared for the VM worker. +pub(crate) struct PreparedSnapshotRestore { + pub(crate) shared_memory: openvmm_defs::worker::SharedMemoryFd, + pub(crate) guards: openvmm_defs::worker::SnapshotRestoreGuards, + pub(crate) saved_state: mesh::payload::message::ProtobufMessage, +} + +/// Validate an opened snapshot generation against the current VM config. +/// Returns the shared memory handle, lifetime guards, and saved device state. +pub(super) fn prepare_snapshot_restore( + snapshot: openvmm_helpers::snapshot::restore::OpenedSnapshot, + opt: &Options, +) -> anyhow::Result { + prepare_snapshot_restore_for_config(snapshot, opt.memory_size(), opt.processors) +} + +pub(crate) fn prepare_snapshot_restore_for_config( + snapshot: openvmm_helpers::snapshot::restore::OpenedSnapshot, + expected_memory_size: u64, + expected_vp_count: u32, +) -> anyhow::Result { + let artifact_prepare = openvmm_defs::profile::ProfileSpan::start(); + let manifest = snapshot.manifest(); + // Validate manifest against current VM config. + openvmm_helpers::snapshot::validate_manifest( + manifest, + crate::GUEST_ARCH, + expected_memory_size, + expected_vp_count, + crate::system_page_size(), + )?; + + let state_msg: mesh::payload::message::ProtobufMessage = + mesh::payload::decode(snapshot.state_bytes()) + .context("failed to decode saved state from snapshot")?; + + artifact_prepare.complete( + "restore", + "artifact_prepare", + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(expected_memory_size), + ..Default::default() + }, + ); + + // Create the private mapping from a duplicate of the exact opened handle. + // The original file and directory handles move to the worker and keep this + // generation pinned until VM teardown. + let cow_section_create = openvmm_defs::profile::ProfileSpan::start(); + let memory_file = snapshot.duplicate_memory_file_for_mapping(expected_memory_size)?; + let shared_memory = + openvmm_helpers::shared_memory::file_to_copy_on_write_memory_fd(memory_file)?; + cow_section_create.complete( + "restore", + "cow_section_create", + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(expected_memory_size), + ..Default::default() + }, + ); + snapshot.validate_memory_generation(expected_memory_size)?; + let (_, _, guards) = snapshot.into_parts(); + + Ok(PreparedSnapshotRestore { + shared_memory, + guards, + saved_state: state_msg, + }) +} diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index ea332286029..8404cad2b28 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1302,6 +1302,8 @@ impl VmService { cfg: config, saved_state: None, shared_memory: None, + shared_memory_copy_on_write: false, + snapshot_restore_guards: None, rpc: recv, notify: notify_send, }, diff --git a/openvmm/openvmm_helpers/src/shared_memory.rs b/openvmm/openvmm_helpers/src/shared_memory.rs index 46fb4e06189..fdf79315a5f 100644 --- a/openvmm/openvmm_helpers/src/shared_memory.rs +++ b/openvmm/openvmm_helpers/src/shared_memory.rs @@ -64,3 +64,18 @@ pub fn file_to_shared_memory_fd(file: std::fs::File) -> anyhow::Result anyhow::Result { + #[cfg(unix)] + { + use std::os::unix::io::OwnedFd; + Ok(OwnedFd::from(file)) + } + #[cfg(windows)] + { + Ok(sparse_mmap::new_mappable_from_file_copy_on_write( + &file, false, + )?) + } +} diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 5b5ca3440d6..2b4f12da2e3 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -34,6 +34,8 @@ impl Worker { cfg, saved_state: None, shared_memory, + shared_memory_copy_on_write: false, + snapshot_restore_guards: None, rpc: rpc_recv, notify: notify_send, }; From 8765361d49c3815b68a74a303fea7381e7db87e8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 02:11:16 -0700 Subject: [PATCH 069/304] openvmm: signal restore readiness to a local socket or pipe An orchestrator that restores a VM from a snapshot has no reliable way to learn when the restore has finished: OpenVMM reports failures only in its log, and a restored vCPU may already be running before the orchestrator can tell whether devices started. Add `--restore-ready-path `, which requires `--restore-snapshot`. Before the VM worker launches, OpenVMM connects to the named endpoint, an existing Unix domain socket on Linux or a `//./pipe/...` named pipe on Windows, and passes the connection to the worker as the new `restore_ready_sink` parameter. After the saved state is applied, the worker holds the restored VPs stopped. When the VM first starts, the worker starts its state units, writes exactly `RESTORE_READY_EVENT_V1` (`OPENVMM_RESTORE_READY_V1\n`) to the endpoint and flushes it, and only then releases the VPs, so the event always precedes guest execution. A failure to connect, write, or flush fails the launch, and a failure to write or flush also stops the started units, leaving the VPs stopped. The endpoint is used once and is never saved in a snapshot. The worker keeps this restore state in `dispatch/restore`, which now sequences the start of a restored VM. With `--paused`, the first successful `resume` in the REPL publishes the event. A resume that fails before then ends the REPL with an error and tears the VM down, because the single-use endpoint may already be consumed and a later resume would start the guest without the event. With `OPENVMM_STARTUP_PROFILE` set, restore also records the `restore` phases `saved_state_restore` and `device_start`, the latter with the guest-memory faults and populated bytes counted while the devices start. The management and petri launches pass no readiness endpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/user_guide/openvmm/snapshots.md | 12 +++ openvmm/openvmm_core/src/worker/dispatch.rs | 17 ++-- .../src/worker/dispatch/restore.rs | 87 ++++++++++++++++++- openvmm/openvmm_defs/src/worker.rs | 5 ++ openvmm/openvmm_entry/src/cli_args.rs | 5 ++ openvmm/openvmm_entry/src/lib.rs | 3 + openvmm/openvmm_entry/src/repl.rs | 7 +- openvmm/openvmm_entry/src/repl/launch.rs | 24 +++++ .../openvmm_entry/src/repl/restore_ready.rs | 32 +++++++ .../openvmm_entry/src/snapshot_restore/mod.rs | 6 ++ .../src/snapshot_restore/ready.rs | 47 ++++++++++ openvmm/openvmm_entry/src/ttrpc/mod.rs | 1 + petri/src/worker.rs | 1 + 13 files changed, 239 insertions(+), 8 deletions(-) create mode 100644 openvmm/openvmm_entry/src/repl/launch.rs create mode 100644 openvmm/openvmm_entry/src/repl/restore_ready.rs create mode 100644 openvmm/openvmm_entry/src/snapshot_restore/ready.rs diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 28dfe989b8a..e20c0f4c6d8 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -81,6 +81,18 @@ directory, so `file=...` should not be specified in `--memory` (the two options are mutually exclusive). Guest writes use a private copy-on-write mapping and do not modify the snapshot artifact. +Orchestrators can add `--restore-ready-path `, which requires +`--restore-snapshot`. OpenVMM connects to an existing Unix domain socket on +Linux or a `//./pipe/...` named pipe on Windows and writes +`OPENVMM_RESTORE_READY_V1\n` after restore validation and state-unit startup, +before releasing a restored vCPU. The event is single-use and is not +serialized. A connection, write, or flush failure aborts startup and stops the +started units. With `--paused`, the first successful `resume` publishes the +event; if that resume fails, OpenVMM exits with an error rather than allowing +a later resume to start the guest without the event. The peer must accept and +read while resume is in progress; on Windows, flush completion waits until the +named-pipe peer consumes the complete frame. + ```admonish warning Version 3 does not contain or validate embedded checksums for `state.bin` or `memory.bin`. Restore still requires regular files, bounded manifest and state diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 50a4ab4b553..bc946750d5f 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -358,7 +358,7 @@ impl Worker for VmWorker { .transpose() .context("failed to decode saved state")?; - let vm = block_with_io(|_| vm.load(saved_state, parameters.notify))?; + let vm = block_with_io(|_| vm.load(saved_state, parameters.notify, restore_params.state))?; LOADED_VM.store(&vm); @@ -393,7 +393,9 @@ impl Worker for VmWorker { shared_memory, ))?; pal_async::local::block_on(async { - let mut vm = vm.load(Some(saved_state), notify).await?; + let mut vm = vm + .load(Some(saved_state), notify, Default::default()) + .await?; LOADED_VM.store(&vm); @@ -877,6 +879,7 @@ pub(crate) struct LoadedVm { state_units: StateUnits, inner: LoadedVmInner, running: bool, + snapshot_restore: restore::SnapshotRestore, } struct DynamicVpciDeviceEntry { @@ -1445,6 +1448,7 @@ impl InitializedVm { memory_builder = memory_builder .vtl0_alias_map(vtl0_alias_map) .supports_memory_fault_resolution(supports_memory_fault_resolution) + .track_memory_faults(openvmm_defs::profile::enabled()) .x86_legacy_support( matches!(cfg.load_mode, LoadMode::Pcat { .. }) || cfg.chipset.with_hyperv_vga, ); @@ -1615,6 +1619,7 @@ impl InitializedVm { self, saved_state: Option, client_notify_send: mesh::Sender, + snapshot_restore: restore::SnapshotRestore, ) -> Result { use vmotherboard::options::dev; @@ -3197,6 +3202,7 @@ impl InitializedVm { let mut this = LoadedVm { state_units, running: false, + snapshot_restore, inner: LoadedVmInner { driver_source, resolver, @@ -3253,9 +3259,11 @@ impl InitializedVm { }; if let Some(saved_state) = saved_state { + let saved_state_restore = this.begin_snapshot_restore(); this.restore(saved_state) .await .context("loadedvm restore failed")?; + this.finish_snapshot_restore(saved_state_restore).await; } else { // Assign PCI bus numbers/BARs before building firmware so that the // ACPI tables (specifically the SRAT generic-initiator entries) can @@ -3698,10 +3706,7 @@ impl LoadedVm { if self.running { return Ok(false); } - self.state_units - .start() - .await - .context("VM state units failed to start")?; + self.start_state_units().await?; self.running = true; Ok(true) } diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index 5354f64daf5..5ae42621e70 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -2,18 +2,29 @@ // Licensed under the MIT License. //! Snapshot restore support for the VM worker: the restore inputs taken from -//! the worker parameters and the checks applied to the saved state. +//! the worker parameters, the restore state kept by [`LoadedVm`], the steps +//! around applying the saved state, and the post-restore start sequence +//! (readiness event and VP release). +use super::LoadedVm; +use anyhow::Context; use membacking::FileMappingMode; use membacking::SharedMemoryBacking; +use openvmm_defs::profile::ProfileSpan; +use openvmm_defs::worker::RESTORE_READY_EVENT_V1; use openvmm_defs::worker::SavedState; use openvmm_defs::worker::SharedMemoryFd; use openvmm_defs::worker::SnapshotRestoreGuards; use openvmm_defs::worker::VmWorkerParameters; use state_unit::StateUnits; +use std::fs::File; +use std::io::Write as _; +use vmm_core::partition_unit::StopGuard; /// Snapshot-restore inputs taken from the [`VmWorkerParameters`]. pub(super) struct RestoreParameters { + /// Restore state handed to the loaded VM. + pub(super) state: SnapshotRestore, /// Mapping mode of the file-backed guest RAM handle. file_mapping_mode: FileMappingMode, /// Snapshot generation handles that must outlive the restored VM. @@ -24,12 +35,17 @@ impl RestoreParameters { /// Takes the restore inputs out of the worker parameters. pub(super) fn take(parameters: &mut VmWorkerParameters) -> anyhow::Result { let guards = parameters.snapshot_restore_guards.take(); + let ready_sink = parameters.restore_ready_sink.take(); let file_mapping_mode = if parameters.shared_memory_copy_on_write { FileMappingMode::CopyOnWrite } else { FileMappingMode::Shared }; Ok(Self { + state: SnapshotRestore { + ready_sink, + ..Default::default() + }, file_mapping_mode, guards, }) @@ -41,6 +57,17 @@ impl RestoreParameters { } } +/// Snapshot-restore state of a [`LoadedVm`]. +#[derive(Default)] +pub(super) struct SnapshotRestore { + /// Whether the VM was loaded from saved state. + restored_from_snapshot: bool, + /// Holds the VPs stopped after a restore until the VM first starts. + start_guard: Option, + /// Single-use sink for the restore readiness event. + ready_sink: Option, +} + /// Validates the state-unit inventory of a saved-state envelope. Saved state /// without an inventory is restored without the check. pub(super) fn validate_inventory( @@ -52,3 +79,61 @@ pub(super) fn validate_inventory( } Ok(()) } + +impl LoadedVm { + /// Prepares to restore saved state into the loaded VM: records that the + /// VM is restored, and starts the profile span of the restore. + pub(super) fn begin_snapshot_restore(&mut self) -> ProfileSpan { + self.snapshot_restore.restored_from_snapshot = true; + ProfileSpan::start() + } + + /// Completes a restore after the saved state is applied: holds the VPs + /// stopped until the VM first starts. + pub(super) async fn finish_snapshot_restore(&mut self, saved_state_restore: ProfileSpan) { + saved_state_restore.complete("restore", "saved_state_restore", Default::default()); + self.snapshot_restore.start_guard = + Some(self.inner.partition_unit.temporarily_stop_vps().await); + } + + /// Starts the state units for [`LoadedVm::resume`], sequencing the + /// restore readiness event and the release of the restored VPs around the + /// start. + pub(super) async fn start_state_units(&mut self) -> anyhow::Result<()> { + let device_start = ProfileSpan::start(); + self.state_units + .start() + .await + .context("VM state units failed to start")?; + if self.snapshot_restore.restored_from_snapshot && openvmm_defs::profile::enabled() { + let faults = self.inner.memory_manager.fault_counters(); + device_start.complete( + "restore", + "device_start", + openvmm_defs::profile::ProfileCounters { + gpa_faults: Some(faults.guest_faults), + populated_bytes: Some(faults.populated_bytes), + ..Default::default() + }, + ); + } + if let Some(mut sink) = self.snapshot_restore.ready_sink.take() { + let signal_result = sink.write_all(RESTORE_READY_EVENT_V1).and_then(|()| { + #[cfg(windows)] + { + sink.sync_all() + } + #[cfg(not(windows))] + { + sink.flush() + } + }); + if let Err(error) = signal_result { + self.state_units.stop().await; + return Err(error).context("failed to publish restore readiness event"); + } + } + self.snapshot_restore.start_guard.take(); + Ok(()) + } +} diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index 21ed7c46fa0..8dac1527f08 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -24,6 +24,9 @@ pub type SharedMemoryFd = std::os::windows::io::OwnedHandle; pub const VM_WORKER: WorkerId = WorkerId::new("VmWorker"); +/// Complete event written before a restored VM can execute. +pub const RESTORE_READY_EVENT_V1: &[u8] = b"OPENVMM_RESTORE_READY_V1\n"; + /// Exact snapshot-generation handles retained for a restored VM's lifetime. #[derive(MeshPayload)] pub struct SnapshotRestoreGuards { @@ -64,6 +67,8 @@ pub struct VmWorkerParameters { pub shared_memory_copy_on_write: bool, /// Snapshot generation handles that must outlive the restored VM. pub snapshot_restore_guards: Option, + /// Single-use process-local sink for the restore readiness event. + pub restore_ready_sink: Option, /// The VM RPC channel. pub rpc: mesh::Receiver, /// The notification channel. diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index ea694659f00..1e069854d4d 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -256,6 +256,11 @@ Examples: )] pub restore_snapshot: Option, + /// Write OPENVMM_RESTORE_READY_V1 to this Unix socket or Windows named pipe + /// after restore startup completes and before guest execution begins. + #[clap(long, value_name = "PATH", requires = "restore_snapshot")] + pub restore_ready_path: Option, + /// use private anonymous memory for guest RAM #[clap(long = "private-memory", hide = true, conflicts_with_all = ["deprecated_memory_backing_file", "restore_snapshot", "numa"])] pub deprecated_private_memory: bool, diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index d93337dbd8b..f2f615bdfa8 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2899,6 +2899,7 @@ async fn run_control_inner( (shared_memory, None) }; let restore = restore.into_worker(); + let restore_ready_sink = snapshot_restore::restore_ready_sink(&opt)?; let params = VmWorkerParameters { hypervisor: match &opt.hypervisor { @@ -2910,6 +2911,7 @@ async fn run_control_inner( shared_memory, shared_memory_copy_on_write: restore.shared_memory_copy_on_write, snapshot_restore_guards: restore.guards, + restore_ready_sink, rpc: rpc_recv, notify: notify_send, }; @@ -3002,6 +3004,7 @@ async fn run_control_inner( kvp_ic: resources.kvp_ic, console_in: resources.console_in, has_vtl2, + launch: repl::launch::ReplLaunch::from_options(&opt), }, ) .await; diff --git a/openvmm/openvmm_entry/src/repl.rs b/openvmm/openvmm_entry/src/repl.rs index 6624c53ed6f..0f6feefaefa 100644 --- a/openvmm/openvmm_entry/src/repl.rs +++ b/openvmm/openvmm_entry/src/repl.rs @@ -69,6 +69,8 @@ use vm_resource::IntoResource; use vm_resource::Resource; mod headless; +pub(crate) mod launch; +mod restore_ready; fn maybe_with_radix_u64(s: &str) -> Result { let (radix, prefix_len) = if s.starts_with("0x") || s.starts_with("0X") { @@ -440,6 +442,7 @@ pub(crate) struct ReplResources { pub kvp_ic: Option>, pub console_in: Option>, pub has_vtl2: bool, + pub launch: launch::ReplLaunch, } /// Run the interactive REPL. @@ -458,6 +461,7 @@ pub(crate) async fn run_repl( kvp_ic, console_in, has_vtl2, + mut launch, } = resources; let (console_command_send, console_command_recv) = mesh::channel(); @@ -710,13 +714,14 @@ pub(crate) async fn run_repl( } StateChange::Resume(Ok(success)) => { if success { + launch.restore_ready_pending = false; tracing::info!("resumed complete"); } else { tracing::warn!("already running"); } } StateChange::Resume(Err(err)) => { - tracing::error!(error = &err as &dyn std::error::Error, "resume failed") + restore_ready::resume_failed(err, launch.restore_ready_pending)? } StateChange::Reset(r) => match r { Ok(()) => tracing::info!("reset complete"), diff --git a/openvmm/openvmm_entry/src/repl/launch.rs b/openvmm/openvmm_entry/src/repl/launch.rs new file mode 100644 index 00000000000..8fa7f13d75e --- /dev/null +++ b/openvmm/openvmm_entry/src/repl/launch.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! How a VM launch configures the REPL beyond the resources it shares with it: +//! whether a paused snapshot restore awaits its first resume +//! ([`super::restore_ready`]). + +use crate::Options; + +/// How a VM launch configures the REPL. +pub(crate) struct ReplLaunch { + /// Whether a paused snapshot restore waits for its first successful + /// `resume`, which publishes its restore readiness event. + pub(crate) restore_ready_pending: bool, +} + +impl ReplLaunch { + /// Returns how a VM launched from the command line configures the REPL. + pub(crate) fn from_options(opt: &Options) -> Self { + Self { + restore_ready_pending: opt.paused && opt.restore_ready_path.is_some(), + } + } +} diff --git a/openvmm/openvmm_entry/src/repl/restore_ready.rs b/openvmm/openvmm_entry/src/repl/restore_ready.rs new file mode 100644 index 00000000000..11d990ed017 --- /dev/null +++ b/openvmm/openvmm_entry/src/repl/restore_ready.rs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Snapshot restore readiness in the REPL. +//! +//! A VM restored with `--restore-snapshot --restore-ready-path` publishes its +//! restore readiness event, once, when it first starts. With `--paused`, that +//! first start is the REPL's first successful `resume`; until then, the +//! restore readiness is pending (`ReplLaunch::restore_ready_pending`). A +//! resume that fails while it is pending ends the REPL with an error, which +//! tears the restored VM down: the single-use readiness endpoint may already +//! be consumed, and a later resume would start the guest without the event. + +use mesh::error::RemoteError; + +/// Target of the event logged here: the REPL's own, next to its other resume +/// events. +const REPL_TARGET: &str = "openvmm_entry::repl"; + +/// Handles a failed `resume` command. The failure is logged; while the +/// restore readiness is `pending`, it also ends the REPL with an error. +pub(super) fn resume_failed(err: RemoteError, pending: bool) -> anyhow::Result<()> { + tracing::error!( + target: REPL_TARGET, + error = &err as &dyn std::error::Error, + "resume failed" + ); + if pending { + return Err(anyhow::Error::new(err).context("restore readiness resume failed")); + } + Ok(()) +} diff --git a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs index 81f5e97c790..4a4a7805b32 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs @@ -8,6 +8,9 @@ //! generation is validated against the VM configuration and its RAM is mapped //! copy-on-write ([`SnapshotRestore::prepare`]). Its open artifact handles then //! move to the worker, which keeps the generation pinned until VM teardown. +//! The `--restore-ready-path` endpoint is connected next +//! ([`restore_ready_sink`]); the worker writes the restore readiness event to +//! it when the restored VM first starts. //! //! With `OPENVMM_STARTUP_PROFILE` set, these steps record the `restore` phases //! `artifact_open`, `artifact_prepare`, and `cow_section_create`, and the @@ -15,6 +18,9 @@ //! ([`worker_launched`]). mod prepare; +mod ready; + +pub(crate) use ready::restore_ready_sink; use crate::Options; use anyhow::Context; diff --git a/openvmm/openvmm_entry/src/snapshot_restore/ready.rs b/openvmm/openvmm_entry/src/snapshot_restore/ready.rs new file mode 100644 index 00000000000..41cad3e2518 --- /dev/null +++ b/openvmm/openvmm_entry/src/snapshot_restore/ready.rs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The restore readiness endpoint: a single-use, process-local sink to which +//! the VM worker writes its restore readiness event when the restored VM first +//! starts. + +use crate::Options; +use anyhow::Context; +use std::fs::File; +use std::io; +use std::path::Path; + +/// Connects the `--restore-ready-path` endpoint, if one is configured. +pub(crate) fn restore_ready_sink(opt: &Options) -> anyhow::Result> { + opt.restore_ready_path + .as_deref() + .map(connect_restore_ready_sink) + .transpose() + .context("failed to connect restore readiness endpoint") +} + +/// Connects a single-use restore-readiness event sink. +pub(crate) fn connect_restore_ready_sink(path: &Path) -> io::Result { + #[cfg(unix)] + { + use std::os::fd::OwnedFd; + + let socket = unix_socket::UnixStream::connect(path)?; + Ok(File::from(OwnedFd::from(socket))) + } + + #[cfg(windows)] + { + const NAMED_PIPE_PREFIX: &str = "//./pipe/"; + + let normalized = path.to_string_lossy().replace('\\', "/"); + if !normalized.starts_with(NAMED_PIPE_PREFIX) || normalized.len() == NAMED_PIPE_PREFIX.len() + { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "restore readiness path must name a Windows //./pipe/... endpoint", + )); + } + std::fs::OpenOptions::new().write(true).open(path) + } +} diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 8404cad2b28..6327f931fa0 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1304,6 +1304,7 @@ impl VmService { shared_memory: None, shared_memory_copy_on_write: false, snapshot_restore_guards: None, + restore_ready_sink: None, rpc: recv, notify: notify_send, }, diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 2b4f12da2e3..26f3fc67203 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -36,6 +36,7 @@ impl Worker { shared_memory, shared_memory_copy_on_write: false, snapshot_restore_guards: None, + restore_ready_sink: None, rpc: rpc_recv, notify: notify_send, }; From 91ca89d0c804165e343788f59850c0074eaf0b12 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 02:14:56 -0700 Subject: [PATCH 070/304] virt_whp: resolve unmapped-GPA faults and register restored RAM lazily A WHP partition registers every guest RAM range with WHP when the range is mapped, and asks WHP to deliver GPA access faults on mapped memory to the VMM so that it can populate pages itself. For a VM restored from a private copy-on-write snapshot mapping, both are wasted work: registering a large memory range up front makes startup scale with guest memory, and every first touch of a page costs a VP exit and a populate call even though WHP can resolve the copy-on-write fault itself. Add two partition-creation options to `ProtoPartitionConfig`. `user_mode_memory_faults` keeps the current behavior when set; when it is clear, WHP no longer requests `GpaAccessFaultExit`, and the partition reports that it cannot resolve memory faults, so the memory backing does not defer commits or protections to them. `lazy_memory_registration` makes the WHP memory mapper register only the first 64 MiB of each mapping when it is mapped. A guest access to a range that is not yet registered reaches the VP as an unmapped-GPA access; the mapper then registers the 2-MiB chunk containing it, and the VP retries the access. The mapper tracks the registered ranges, so an overlay page removed from an unregistered range is unmapped rather than remapped, and unmapping a range unregisters only what was registered. Mapping failures in the mapper are now returned instead of panicking. The VM worker and the TMK VMM keep the current behavior: user-mode fault resolution and eager registration. Backends other than WHP ignore both options. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/worker/dispatch.rs | 2 + tmk/tmk_vmm/src/host_vmm.rs | 2 + vmm_core/virt/src/generic.rs | 6 + vmm_core/virt_whp/src/lib.rs | 12 +- vmm_core/virt_whp/src/memory.rs | 90 ++++- .../virt_whp/src/memory/lazy_registration.rs | 317 ++++++++++++++++++ vmm_core/virt_whp/src/vp.rs | 4 + 7 files changed, 417 insertions(+), 16 deletions(-) create mode 100644 vmm_core/virt_whp/src/memory/lazy_registration.rs diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index bc946750d5f..572267ec3fb 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1244,6 +1244,8 @@ impl InitializedVm { nested_virt: cfg.hypervisor.nested_virt, #[cfg(guest_arch = "aarch64")] device_assignment_msi_iova_range, + user_mode_memory_faults: true, + lazy_memory_registration: false, }) .context("failed to create the prototype partition")?; partition_prototype.complete("startup", "partition_prototype", Default::default()); diff --git a/tmk/tmk_vmm/src/host_vmm.rs b/tmk/tmk_vmm/src/host_vmm.rs index 6ee52ed4622..7cdf922f282 100644 --- a/tmk/tmk_vmm/src/host_vmm.rs +++ b/tmk/tmk_vmm/src/host_vmm.rs @@ -47,6 +47,8 @@ impl RunContext<'_> { nested_virt: false, #[cfg(guest_arch = "aarch64")] device_assignment_msi_iova_range: None, + user_mode_memory_faults: true, + lazy_memory_registration: false, }) .context("failed to create proto partition")?; diff --git a/vmm_core/virt/src/generic.rs b/vmm_core/virt/src/generic.rs index ee24f6444ad..dfcbb0a9179 100644 --- a/vmm_core/virt/src/generic.rs +++ b/vmm_core/virt/src/generic.rs @@ -356,6 +356,12 @@ pub struct ProtoPartitionConfig<'a> { /// Device-assignment MSI IOVA reservation selected for this partition. #[cfg(guest_arch = "aarch64")] pub device_assignment_msi_iova_range: Option, + /// Whether supported backends should return mapped guest-memory faults to + /// the VMM for user-mode resolution. + pub user_mode_memory_faults: bool, + /// Whether supported backends should register large guest-memory ranges + /// incrementally as the guest accesses them. + pub lazy_memory_registration: bool, } /// Partition creation configuration. diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 685e2b183b8..43e3ee6a01a 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -1029,7 +1029,8 @@ impl ProtoPartition for WhpProtoPartition<'_> { // the memory backing can resolve them on demand (soft large pages, lazy // commit). WHP on aarch64 does not deliver these faults, so the backing // must not defer any commit or protection to a fault. - cfg!(guest_arch = "x86_64") + // Pure COW restores intentionally leave these faults to WHP. + cfg!(guest_arch = "x86_64") && self.config.user_mode_memory_faults } fn build( @@ -1610,8 +1611,10 @@ impl VtlPartition { // for ROM regions, resulting in an extra syscall and C++ exception for // each such exit. We know locally whether memory is supposed to be // mapped writable, so we can avoid this. + // Pure writable-COW restores leave these faults to WHP instead, + // avoiding one VP exit and populate call per first-touch page. // TODO-aarch64 - if cfg!(guest_arch = "x86_64") { + if cfg!(guest_arch = "x86_64") && config.user_mode_memory_faults { extended_exits |= whp::abi::WHV_EXTENDED_VM_EXITS::GpaAccessFaultExit; } @@ -1803,7 +1806,10 @@ impl VtlPartition { Box::new(memory::vtl2_mapper::VtlMemoryMapper::new(mapping_state)) } else { - Box::new(memory::WhpMemoryMapper::new(with_overlays)) + Box::new(memory::WhpMemoryMapper::with_lazy_registration( + with_overlays, + config.lazy_memory_registration, + )) }; Ok(Self { diff --git a/vmm_core/virt_whp/src/memory.rs b/vmm_core/virt_whp/src/memory.rs index 3e78f0cdc5a..2d34d11b754 100644 --- a/vmm_core/virt_whp/src/memory.rs +++ b/vmm_core/virt_whp/src/memory.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod lazy_registration; pub mod vtl2_mapper; use super::VtlPartition; @@ -132,6 +133,15 @@ pub(crate) trait MemoryMapper: Inspect + Send + Sync { #[cfg_attr(guest_arch = "aarch64", expect(dead_code))] fn in_deferred_range(&self, gpa: u64) -> bool; + /// Registers the deferred range containing `gpa`, if any. + fn map_deferred_on_fault( + &self, + _partition: &dyn SimpleMemoryMap, + _gpa: u64, + ) -> anyhow::Result { + Ok(false) + } + /// Map all deferred ranges and put the mapper into a mapped state, where /// future map calls are no longer deferred. fn map_deferred(&self, partition: &dyn SimpleMemoryMap) -> anyhow::Result<()>; @@ -191,10 +201,12 @@ pub(crate) trait MemoryMapper: Inspect + Send + Sync { } /// Memory mapper implementation that does not support VTLs, but does support -/// overlays. +/// overlays and optional on-demand GPA registration. #[derive(Debug, Inspect)] pub(crate) struct WhpMemoryMapper { + #[inspect(rename = "state")] overlays: Option>, + overlays_supported: bool, } impl WhpMemoryMapper { @@ -205,6 +217,7 @@ impl WhpMemoryMapper { } else { None }, + overlays_supported: with_overlays, } } } @@ -230,7 +243,7 @@ impl MemoryMapper for WhpMemoryMapper { writable, executable: exec, }; - overlays.lock().map_range(partition, mapping); + overlays.lock().map_range(partition, mapping)?; } else { // SAFETY: caller guarantees `data` is a valid pointer // describing `size` bytes until this range is unmapped. @@ -255,7 +268,7 @@ impl MemoryMapper for WhpMemoryMapper { } fn overlays_supported(&self) -> bool { - self.overlays.is_some() + self.overlays_supported } fn add_overlay_page( @@ -268,6 +281,7 @@ impl MemoryMapper for WhpMemoryMapper { ) -> bool { self.overlays .as_ref() + .filter(|_| self.overlays_supported) .expect("cannot add overlays if not supported") .lock() .add_overlay_page( @@ -284,6 +298,7 @@ impl MemoryMapper for WhpMemoryMapper { fn remove_overlay_page(&self, partition: &dyn SimpleMemoryMap, gpa: u64) { self.overlays .as_ref() + .filter(|_| self.overlays_supported) .expect("cannot remove overlays if not supported") .lock() .remove_overlay_page(partition, gpa) @@ -295,6 +310,17 @@ impl MemoryMapper for WhpMemoryMapper { false } + fn map_deferred_on_fault( + &self, + partition: &dyn SimpleMemoryMap, + gpa: u64, + ) -> anyhow::Result { + match self.overlays.as_ref() { + Some(state) => state.lock().map_deferred_on_fault(partition, gpa), + None => Ok(false), + } + } + fn map_deferred(&self, _partition: &dyn SimpleMemoryMap) -> anyhow::Result<()> { Ok(()) } @@ -586,21 +612,25 @@ impl Overlay { } } -/// Tracks active mappings and overlay pages. Only used when the Hv1 emulator is -/// enabled, since otherwise there are no overlay pages and mapping requests can -/// be passed straight through to WHP. +/// Tracks active mappings, overlay pages, and optional on-demand GPA +/// registration. Without overlays or lazy registration, mapping requests pass +/// straight through to WHP. #[derive(Debug, Default, Inspect)] pub(crate) struct EmulatedOverlayState { + lazy_registration: bool, /// Active memory mappings. Non-overlapping, sorted by GPA. #[inspect(with = "|x| inspect::iter_by_key(x.iter().map(Mapping::as_inspect_kv))")] mappings: Vec, /// Active overlay pages. Non-overlapping, sorted by GPA. #[inspect(with = "|x| inspect::iter_by_key(x.iter().map(Overlay::as_inspect_kv))")] overlays: Vec, + /// Underlying GPA ranges already registered with WHP. + #[inspect(iter_by_index)] + registered_ranges: Vec, } impl EmulatedOverlayState { - fn map_range(&mut self, p: &dyn SimpleMemoryMap, mapping: Mapping) { + fn map_range(&mut self, p: &dyn SimpleMemoryMap, mapping: Mapping) -> anyhow::Result<()> { let index = self .mappings .binary_search_by_key(&mapping.range.start(), |m| m.range.end() - 1) @@ -609,8 +639,19 @@ impl EmulatedOverlayState { assert!(old_mapping.range.start() >= mapping.range.end()); } - let mut gpa = mapping.range.start(); - let end = mapping.range.end(); + self.map_initial_range(p, &mapping)?; + self.mappings.insert(index, mapping); + Ok(()) + } + + fn map_underlay_range( + &self, + p: &dyn SimpleMemoryMap, + mapping: &Mapping, + range: MemoryRange, + ) -> anyhow::Result<()> { + let mut gpa = range.start(); + let end = range.end(); while gpa < end { let o_index = self .overlays @@ -621,6 +662,13 @@ impl EmulatedOverlayState { .get(o_index) .map(|o| std::cmp::min(o.gpa, end)) .unwrap_or(end); + if this_end == gpa { + // An overlay page starts here; skip it without mapping. + gpa += HV_PAGE_SIZE; + continue; + } + // SAFETY: `range` is contained by `mapping.range`, whose data + // pointer remains valid until the matching unmap. unsafe { p.map_range( None, @@ -631,15 +679,16 @@ impl EmulatedOverlayState { gpa, mapping.writable, mapping.executable, - ) - .expect("cannot handle mapping failure"); + )?; } + if this_end == end { + break; + } // Skip the overlay page. gpa = this_end + HV_PAGE_SIZE; } - - self.mappings.insert(index, mapping); + Ok(()) } fn unmap_range(&mut self, p: &dyn SimpleMemoryMap, gpa: u64, len: u64) { @@ -661,6 +710,13 @@ impl EmulatedOverlayState { assert!(range.contains(&mapping.range)); } + if self.lazy_registration { + return self.unmap_registered_ranges(p, range); + } + self.unmap_underlay_range(p, range); + } + + fn unmap_underlay_range(&self, p: &dyn SimpleMemoryMap, range: MemoryRange) { let mut gpa = range.start(); let end = range.end(); while gpa < end { @@ -679,6 +735,9 @@ impl EmulatedOverlayState { .expect("cannot handle unmap failure"); } + if this_end == end { + break; + } // Skip the overlay page. gpa = this_end + HV_PAGE_SIZE; } @@ -716,6 +775,11 @@ impl EmulatedOverlayState { .unwrap(); let _overlay = self.overlays.remove(index); + if !self.underlay_registered(gpa) { + p.unmap_range(gpa, HV_PAGE_SIZE) + .expect("cannot handle unmap failure"); + return; + } // Remap the old page. let mut remapped = false; diff --git a/vmm_core/virt_whp/src/memory/lazy_registration.rs b/vmm_core/virt_whp/src/memory/lazy_registration.rs new file mode 100644 index 00000000000..61750ee48c4 --- /dev/null +++ b/vmm_core/virt_whp/src/memory/lazy_registration.rs @@ -0,0 +1,317 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! On-demand GPA registration for large guest-memory ranges. +//! +//! With lazy registration, [`WhpMemoryMapper`] registers only a bounded +//! prefix of each mapping with WHP when it is mapped, and registers the rest +//! in fixed-size chunks when the guest first accesses them. + +use super::EmulatedOverlayState; +use super::Mapping; +use super::SimpleMemoryMap; +use super::WhpMemoryMapper; +use crate::VtlPartition; +use memory_range::MemoryRange; +use parking_lot::Mutex; + +impl WhpMemoryMapper { + /// Creates a mapper that optionally registers large ranges with WHP as the + /// guest accesses them. + pub(crate) fn with_lazy_registration(with_overlays: bool, lazy_registration: bool) -> Self { + if !lazy_registration { + return Self::new(with_overlays); + } + Self { + overlays: Some(Mutex::new(EmulatedOverlayState::new(lazy_registration))), + overlays_supported: with_overlays, + } + } +} + +impl VtlPartition { + pub(crate) fn map_deferred_on_fault(&self, gpa: u64) -> anyhow::Result { + self.mapper.map_deferred_on_fault(&self.whp, gpa) + } +} + +impl EmulatedOverlayState { + // Bound synchronous restore registration to the smallest canonical guest + // size, then amortize later registration in large-page-sized chunks. + const INITIAL_REGISTRATION_BYTES: u64 = 64 * 1024 * 1024; + const REGISTRATION_CHUNK_BYTES: u64 = 2 * 1024 * 1024; + + fn new(lazy_registration: bool) -> Self { + Self { + lazy_registration, + mappings: Vec::new(), + overlays: Vec::new(), + registered_ranges: Vec::new(), + } + } + + /// Registers the initial part of a new `mapping` with WHP: all of it, or a + /// bounded prefix with lazy registration. + pub(super) fn map_initial_range( + &mut self, + p: &dyn SimpleMemoryMap, + mapping: &Mapping, + ) -> anyhow::Result<()> { + let registered_end = if self.lazy_registration { + mapping + .range + .start() + .saturating_add(Self::INITIAL_REGISTRATION_BYTES) + .min(mapping.range.end()) + } else { + mapping.range.end() + }; + let registered = MemoryRange::new(mapping.range.start()..registered_end); + self.map_underlay_range(p, mapping, registered)?; + if self.lazy_registration { + self.record_registered_range(registered); + } + Ok(()) + } + + /// Unregisters the registered ranges within the unmapped `range`. + pub(super) fn unmap_registered_ranges(&mut self, p: &dyn SimpleMemoryMap, range: MemoryRange) { + let registered_ranges = std::mem::take(&mut self.registered_ranges); + for registered in registered_ranges { + if range.contains(®istered) { + self.unmap_underlay_range(p, registered); + } else { + assert!(!range.overlaps(®istered)); + self.registered_ranges.push(registered); + } + } + } + + /// Returns whether the mapping page under `gpa` is registered with WHP. + pub(super) fn underlay_registered(&self, gpa: u64) -> bool { + !self.lazy_registration + || self + .registered_ranges + .iter() + .any(|range| range.contains_addr(gpa)) + } + + pub(super) fn map_deferred_on_fault( + &mut self, + p: &dyn SimpleMemoryMap, + gpa: u64, + ) -> anyhow::Result { + if !self.lazy_registration { + return Ok(false); + } + let Some(mapping) = self + .mappings + .iter() + .find(|mapping| mapping.range.contains_addr(gpa)) + else { + return Ok(false); + }; + if self + .registered_ranges + .iter() + .any(|range| range.contains_addr(gpa)) + { + return Ok(true); + } + + let chunk_start = mapping.range.start() + + (gpa - mapping.range.start()) / Self::REGISTRATION_CHUNK_BYTES + * Self::REGISTRATION_CHUNK_BYTES; + let chunk_end = chunk_start + .saturating_add(Self::REGISTRATION_CHUNK_BYTES) + .min(mapping.range.end()); + let range = MemoryRange::new(chunk_start..chunk_end); + self.map_underlay_range(p, mapping, range)?; + self.record_registered_range(range); + Ok(true) + } + + fn record_registered_range(&mut self, range: MemoryRange) { + assert!(!range.is_empty()); + let index = self + .registered_ranges + .partition_point(|registered| registered.start() < range.start()); + if let Some(previous) = index + .checked_sub(1) + .and_then(|previous| self.registered_ranges.get(previous)) + { + assert!(previous.end() <= range.start()); + } + if let Some(next) = self.registered_ranges.get(index) { + assert!(range.end() <= next.start()); + } + self.registered_ranges.insert(index, range); + } +} + +#[cfg(guest_arch = "x86_64")] +impl crate::WhpProcessor<'_> { + /// Registers the deferred RAM range under an unmapped-GPA access. Returns + /// whether the range was registered, so the access can be retried. + pub(crate) fn map_deferred_ram( + &self, + access: &whp::abi::WHV_MEMORY_ACCESS_CONTEXT, + backing_type: &super::x86::GpaBackingType, + ) -> bool { + if access.AccessInfo.GpaUnmapped() + && matches!(backing_type, super::x86::GpaBackingType::Ram { .. }) + { + match self.current_vtlp().map_deferred_on_fault(access.Gpa) { + Ok(true) => return true, + Ok(false) => {} + Err(err) => { + tracelimit::warn_ratelimited!( + gpa = access.Gpa, + error = ?err, + "failed to register deferred gpa range" + ); + } + } + } + false + } +} + +#[cfg(test)] +mod tests { + use crate::memory::*; + use sparse_mmap::alloc::Allocation; + use test_with_tracing::test; + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + struct MapCall { + range: MemoryRange, + data: usize, + } + + #[derive(Debug, Default)] + struct TestPartition { + maps: Mutex>, + unmaps: Mutex>, + } + + impl SimpleMemoryMap for TestPartition { + unsafe fn map_range( + &self, + _process: Option>, + data: *mut u8, + size: usize, + addr: u64, + _writable: bool, + _exec: bool, + ) -> anyhow::Result<()> { + self.maps.lock().push(MapCall { + range: MemoryRange::new(addr..addr + size as u64), + data: data.addr(), + }); + Ok(()) + } + + fn unmap_range(&self, addr: u64, size: u64) -> anyhow::Result<()> { + self.unmaps.lock().push(MemoryRange::new(addr..addr + size)); + Ok(()) + } + } + + #[test] + fn lazy_registration_maps_a_bounded_prefix_and_fault_chunks() { + const MIB: u64 = 1024 * 1024; + let partition = TestPartition::default(); + let mapper = WhpMemoryMapper::with_lazy_registration(false, true); + let data = std::ptr::without_provenance_mut::(0x10000); + + // SAFETY: TestPartition only records the pointer and never dereferences it. + unsafe { + mapper + .map_range(&partition, None, data, (512 * MIB) as usize, 0, true, true) + .unwrap(); + } + assert_eq!( + *partition.maps.lock(), + [MapCall { + range: MemoryRange::new(0..64 * MIB), + data: data.addr(), + }] + ); + + assert!(mapper.map_deferred_on_fault(&partition, 257 * MIB).unwrap()); + assert!( + mapper + .map_deferred_on_fault(&partition, 257 * MIB + 4096) + .unwrap() + ); + assert_eq!( + *partition.maps.lock(), + [ + MapCall { + range: MemoryRange::new(0..64 * MIB), + data: data.addr(), + }, + MapCall { + range: MemoryRange::new(256 * MIB..258 * MIB), + data: data.wrapping_add((256 * MIB) as usize).addr(), + }, + ] + ); + + mapper.unmap_range(&partition, 0, 512 * MIB).unwrap(); + assert_eq!( + *partition.unmaps.lock(), + [ + MemoryRange::new(0..64 * MIB), + MemoryRange::new(256 * MIB..258 * MIB), + ] + ); + } + + #[test] + fn lazy_registration_restores_only_registered_overlay_underlays() { + const MIB: u64 = 1024 * 1024; + let partition = TestPartition::default(); + let mapper = WhpMemoryMapper::with_lazy_registration(true, true); + let data = std::ptr::without_provenance_mut::(0x10000); + + // SAFETY: TestPartition only records the pointer and never dereferences it. + unsafe { + mapper + .map_range(&partition, None, data, (128 * MIB) as usize, 0, true, true) + .unwrap(); + } + + let registered_gpa = MIB; + let registered_overlay = Arc::new(SharedMem::new( + Allocation::new(HV_PAGE_SIZE as usize).unwrap(), + )); + assert!(mapper.add_overlay_page( + &partition, + registered_gpa, + registered_overlay, + false, + false, + )); + mapper.remove_overlay_page(&partition, registered_gpa); + assert_eq!( + partition.maps.lock().last().copied(), + Some(MapCall { + range: MemoryRange::new(registered_gpa..registered_gpa + HV_PAGE_SIZE), + data: data.wrapping_add(registered_gpa as usize).addr(), + }) + ); + + let deferred_gpa = 100 * MIB; + let deferred_overlay = Arc::new(SharedMem::new( + Allocation::new(HV_PAGE_SIZE as usize).unwrap(), + )); + assert!(mapper.add_overlay_page(&partition, deferred_gpa, deferred_overlay, false, false,)); + mapper.remove_overlay_page(&partition, deferred_gpa); + assert_eq!( + partition.unmaps.lock().last().copied(), + Some(MemoryRange::new(deferred_gpa..deferred_gpa + HV_PAGE_SIZE)) + ); + } +} diff --git a/vmm_core/virt_whp/src/vp.rs b/vmm_core/virt_whp/src/vp.rs index 91456759ad3..8403fe046fd 100644 --- a/vmm_core/virt_whp/src/vp.rs +++ b/vmm_core/virt_whp/src/vp.rs @@ -721,6 +721,10 @@ mod x86 { | GpaBackingType::Unaccepted => false, }; + if self.map_deferred_ram(access, &backing_type) { + return Ok(()); + } + if !access.AccessInfo.GpaUnmapped() && should_populate { // This is a mapped GPA that wasn't mapped in the SLAT. Tell the // kernel to populate the SLAT. From 6bf6fe22db75f627bf7030e3063bb30f18bc0c55 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 02:26:39 -0700 Subject: [PATCH 071/304] virtio: add device-private saved state with deferred activation and serialized kicks The virtio transports save and restore only transport state: feature negotiation, queue configuration, and queue progress. A device whose state lives outside its queues, such as buffered console or network data, has nowhere to save it, so such devices cannot support save/restore. Restore also trusted the saved queue layout and progress, and a queue notification signaled the queue event directly from the transport, so a kick that raced with queue startup could be lost; this is why the virtio-blk over virtio-mmio boot test was marked unstable. Add device-private saved state to `VirtioDevice`. After all queues stop, `save_device` returns an optional typed `SavedStateBlob`, which the PCI and MMIO transports save as a new `device_state` field; `restore_device` receives it, and `device_state_validator` returns an immutable validator that the transport keeps after the device moves into its task. The defaults preserve the current behavior: devices that support save/restore have no private state, and other devices cannot be saved. The new `device::saved_state` module holds the validator type and a helper that reads the front restored descriptor without consuming it. Restore now validates the saved state before it changes the transport: exactly two feature banks, queue sizes within each queue's device maximum, enabled rings aligned, inside guest RAM, and not overlapping, available and used progress consistent with the rings, progress present for every enabled queue of a DRIVER_OK device, and private state accepted by the device's validator. The restored private state is then staged, and the device task applies it on the first activation: the start of an active device, DRIVER_OK, a device-configuration access, or a queue kick. A configuration access whose activation fails completes with an error, and the staged state is kept. `start_fallible` waits for the activation and the queue starts, so a failure fails the VM start. A save before activation returns the staged state unchanged, and queues that were never started keep their restored progress. A guest reset of a device that is not DRIVER_OK discards the staged state. Queue notifications now reach the device task as serialized `Kick` commands, ordered with private-state activation. Kicks coalesce until the task dequeues them, and a kick for a queue that has not started is staged and delivered once the queue starts, so no notification is lost. The virtio-blk over virtio-mmio boot test is therefore no longer marked unstable. The device task traces the restore lifecycle under the `virtio_restore` target. `SavedStateBlob` becomes `Clone`, so the staged state can be applied and saved again. The virtio-blk persistence test now shuts the guest down with an enlightened shutdown after its save/restore pulse, avoiding the pipette shutdown delay, which depends on the guest timer state that the test just restored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + vm/devices/virtio/virtio/Cargo.toml | 1 + vm/devices/virtio/virtio/src/device.rs | 46 + .../virtio/virtio/src/device/saved_state.rs | 102 ++ vm/devices/virtio/virtio/src/tests.rs | 6 +- .../virtio/virtio/src/tests/saved_state.rs | 1176 +++++++++++++++++ .../virtio/virtio/src/transport/core.rs | 39 +- .../virtio/src/transport/core/restore.rs | 347 +++++ .../virtio/virtio/src/transport/mmio.rs | 9 + vm/devices/virtio/virtio/src/transport/pci.rs | 11 +- .../virtio/virtio/src/transport/task.rs | 51 +- .../virtio/virtio/src/transport/task/kick.rs | 203 +++ .../virtio/src/transport/task/restore.rs | 217 +++ vm/vmcore/src/save_restore.rs | 15 +- vmm_tests/vmm_tests/tests/tests/x86_64.rs | 12 +- 15 files changed, 2208 insertions(+), 28 deletions(-) create mode 100644 vm/devices/virtio/virtio/src/device/saved_state.rs create mode 100644 vm/devices/virtio/virtio/src/tests/saved_state.rs create mode 100644 vm/devices/virtio/virtio/src/transport/core/restore.rs create mode 100644 vm/devices/virtio/virtio/src/transport/task/kick.rs create mode 100644 vm/devices/virtio/virtio/src/transport/task/restore.rs diff --git a/Cargo.lock b/Cargo.lock index 5da9e260331..9d5bec67a5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10026,6 +10026,7 @@ dependencies = [ "test_with_tracing", "thiserror 2.0.16", "tracelimit", + "tracing", "virtio_resources", "virtio_spec", "vm_resource", diff --git a/vm/devices/virtio/virtio/Cargo.toml b/vm/devices/virtio/virtio/Cargo.toml index 058f45cd1d9..d017a88ce27 100644 --- a/vm/devices/virtio/virtio/Cargo.toml +++ b/vm/devices/virtio/virtio/Cargo.toml @@ -29,6 +29,7 @@ async-trait.workspace = true futures.workspace = true parking_lot.workspace = true thiserror.workspace = true +tracing.workspace = true zerocopy.workspace = true [dev-dependencies] mesh.workspace = true diff --git a/vm/devices/virtio/virtio/src/device.rs b/vm/devices/virtio/virtio/src/device.rs index 598e513204f..1a0eb55a509 100644 --- a/vm/devices/virtio/virtio/src/device.rs +++ b/vm/devices/virtio/virtio/src/device.rs @@ -4,6 +4,8 @@ //! Per-queue virtio device trait (`VirtioDevice`) and object-safe wrapper //! (`DynVirtioDevice`). +pub mod saved_state; + use crate::DEFAULT_QUEUE_SIZE; use crate::DeviceTraits; use crate::QueueResources; @@ -11,9 +13,13 @@ use crate::queue::QueueState; use crate::spec::VirtioDeviceFeatures; use guestmem::MappedMemoryRegion; use inspect::InspectMut; +use saved_state::DeviceStateValidator; use std::future::Future; use std::pin::Pin; use std::sync::Arc; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; /// Per-queue virtio device trait. Ergonomic async fn — not object-safe. /// @@ -114,6 +120,25 @@ pub trait VirtioDevice: InspectMut + Send { fn supports_save_restore(&self) -> bool { false } + + /// Save device-private state after all queues have stopped. + /// + /// The transport saves queue and feature-negotiation state separately. + /// Devices with additional state should return a typed [`SavedStateBlob`]. + fn save_device(&mut self) -> Result, SaveError> { + saved_state::default_save_device(self.supports_save_restore()) + } + + /// Restore device-private state before any queues are restarted. + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + saved_state::default_restore_device(self.supports_save_restore(), state) + } + + /// Return an immutable validator that the transport can retain after the + /// device moves into its async task. + fn device_state_validator(&self) -> DeviceStateValidator { + saved_state::default_device_state_validator(self.supports_save_restore()) + } } /// Object-safe wrapper for [`VirtioDevice`]. @@ -166,6 +191,15 @@ pub trait DynVirtioDevice: InspectMut + Send { /// Whether the device supports save/restore. fn supports_save_restore(&self) -> bool; + + /// Save device-private state after all queues have stopped. + fn save_device(&mut self) -> Result, SaveError>; + + /// Restore device-private state before any queues are restarted. + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError>; + + /// Return immutable validation for device-private saved state. + fn device_state_validator(&self) -> DeviceStateValidator; } impl DynVirtioDevice for T { @@ -229,4 +263,16 @@ impl DynVirtioDevice for T { fn supports_save_restore(&self) -> bool { VirtioDevice::supports_save_restore(self) } + + fn save_device(&mut self) -> Result, SaveError> { + VirtioDevice::save_device(self) + } + + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + VirtioDevice::restore_device(self, state) + } + + fn device_state_validator(&self) -> DeviceStateValidator { + VirtioDevice::device_state_validator(self) + } } diff --git a/vm/devices/virtio/virtio/src/device/saved_state.rs b/vm/devices/virtio/virtio/src/device/saved_state.rs new file mode 100644 index 00000000000..308a18d624f --- /dev/null +++ b/vm/devices/virtio/virtio/src/device/saved_state.rs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Device-private saved state for [`VirtioDevice`](super::VirtioDevice) +//! implementations. +//! +//! The transport saves queue and feature-negotiation state itself. Devices with +//! additional state implement `save_device`, `restore_device`, and +//! `device_state_validator`, using the types and helpers in this module. + +use crate::queue::QueueError; +use crate::queue::QueueParams; +use crate::queue::QueueState; +use crate::queue::new_queue; +use crate::spec::VirtioDeviceFeatures; +use guestmem::GuestMemory; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +/// Restored queue metadata available to device-private state validation. +#[derive(Clone, Copy, Debug)] +pub struct DeviceQueueState { + pub params: QueueParams, + pub queue_state: Option, +} + +/// Synchronous validation for opaque device-private saved state and its queue +/// dependencies. +pub type DeviceStateValidator = Box< + dyn Fn( + Option<&SavedStateBlob>, + &VirtioDeviceFeatures, + &[DeviceQueueState], + &GuestMemory, + ) -> Result<(), RestoreError> + + Send + + Sync, +>; + +/// Inspect the readable length of the front restored descriptor without +/// advancing the queue. +pub fn restored_queue_front_readable_length( + features: VirtioDeviceFeatures, + params: QueueParams, + mem: GuestMemory, + initial_state: Option, +) -> Result, QueueError> { + let (mut queue, _) = new_queue(features, mem, params, initial_state)?; + Ok(queue.try_peek_work()?.map(|work| { + work.payload + .iter() + .filter(|payload| !payload.writeable) + .map(|payload| u64::from(payload.length)) + .sum() + })) +} + +/// The default `VirtioDevice::save_device`: devices that support save/restore +/// have no private state, and other devices cannot be saved. +pub(super) fn default_save_device( + supports_save_restore: bool, +) -> Result, SaveError> { + if supports_save_restore { + Ok(None) + } else { + Err(SaveError::NotSupported) + } +} + +/// The default `VirtioDevice::restore_device`: devices that support +/// save/restore accept only an absent private state. +pub(super) fn default_restore_device( + supports_save_restore: bool, + state: Option, +) -> Result<(), RestoreError> { + if !supports_save_restore { + return Err(RestoreError::SavedStateNotSupported); + } + if state.is_some() { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "device does not accept private saved state" + ))); + } + Ok(()) +} + +/// The default `VirtioDevice::device_state_validator`, which applies the same +/// rules as [`default_restore_device`]. +pub(super) fn default_device_state_validator(supports_save_restore: bool) -> DeviceStateValidator { + Box::new(move |state, _features, _queues, _guest_memory| { + if !supports_save_restore { + return Err(RestoreError::SavedStateNotSupported); + } + if state.is_some() { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "device does not accept private saved state" + ))); + } + Ok(()) + }) +} diff --git a/vm/devices/virtio/virtio/src/tests.rs b/vm/devices/virtio/virtio/src/tests.rs index 33d3c6b1e33..6290a5010c0 100644 --- a/vm/devices/virtio/virtio/src/tests.rs +++ b/vm/devices/virtio/virtio/src/tests.rs @@ -6,6 +6,8 @@ #![expect(unsafe_code)] #![cfg(test)] +mod saved_state; + use crate::DeviceTraits; use crate::DynVirtioDevice; use crate::PciInterruptModel; @@ -4785,9 +4787,11 @@ async fn mmio_save_restore_round_trip(driver: DefaultDriver) { dev.stop().await; // Save state. - let saved = dev.save().expect("save should succeed"); + let mut saved = dev.save().expect("save should succeed"); assert_eq!(saved.queues.len(), 1); assert!(saved.queues[0].common.enable); + saved_state::check_restored_queue_validation(&mut saved, guest.queue_features(), &mem); + drop(dev); // Create a new device and restore into it. let interrupt2 = LineInterrupt::detached(); diff --git a/vm/devices/virtio/virtio/src/tests/saved_state.rs b/vm/devices/virtio/virtio/src/tests/saved_state.rs new file mode 100644 index 00000000000..5c975145e1e --- /dev/null +++ b/vm/devices/virtio/virtio/src/tests/saved_state.rs @@ -0,0 +1,1176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for device-private saved state and restore: private-state round trips, +//! restore validation, the activation of staged private state, and staged queue +//! kicks. + +use super::*; +use std::sync::atomic::AtomicBool; +use test_with_tracing::test; +mod private_state { + use mesh::payload::Protobuf; + use vmcore::save_restore::SavedStateRoot; + + #[derive(Protobuf, SavedStateRoot)] + #[mesh(package = "virtio.test_private_state")] + pub struct SavedState { + #[mesh(1)] + pub value: u64, + } +} + +#[derive(InspectMut)] +#[inspect(skip)] +struct PrivateStateTestDevice { + probe: PrivateStateProbe, + max_queues: u16, +} + +#[derive(Clone)] +struct PrivateStateProbe { + value: Arc, + restore_count: Arc, + start_count: Arc, + queue_event: Arc>>, + initial_avail: Arc, + initial_used: Arc, + value_at_start: Arc, + fail_restore: Arc, +} + +impl PrivateStateTestDevice { + fn new(value: usize, max_queues: u16, fail_restore: bool) -> (Self, PrivateStateProbe) { + let probe = PrivateStateProbe { + value: Arc::new(AtomicUsize::new(value)), + restore_count: Arc::new(AtomicUsize::new(0)), + start_count: Arc::new(AtomicUsize::new(0)), + queue_event: Arc::new(Mutex::new(None)), + initial_avail: Arc::new(AtomicUsize::new(usize::MAX)), + initial_used: Arc::new(AtomicUsize::new(usize::MAX)), + value_at_start: Arc::new(AtomicUsize::new(usize::MAX)), + fail_restore: Arc::new(AtomicBool::new(fail_restore)), + }; + ( + Self { + probe: probe.clone(), + max_queues, + }, + probe, + ) + } +} + +impl PrivateStateProbe { + fn take_queue_kick(&self) -> bool { + self.queue_event + .lock() + .as_ref() + .is_some_and(Event::try_wait) + } +} + +impl VirtioDevice for PrivateStateTestDevice { + fn traits(&self) -> DeviceTraits { + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new(), + max_queues: self.max_queues, + device_register_length: 4, + ..Default::default() + } + } + + async fn read_registers_u32(&mut self, _offset: u16) -> u32 { + self.probe.value.load(Ordering::Relaxed) as u32 + } + + async fn write_registers_u32(&mut self, _offset: u16, val: u32) { + self.probe.value.store(val as usize, Ordering::Relaxed); + } + + async fn start_queue( + &mut self, + _idx: u16, + resources: QueueResources, + _features: &VirtioDeviceFeatures, + initial_state: Option, + ) -> anyhow::Result<()> { + self.probe.start_count.fetch_add(1, Ordering::Relaxed); + *self.probe.queue_event.lock() = Some(resources.event.clone()); + self.probe + .value_at_start + .store(self.probe.value.load(Ordering::Relaxed), Ordering::Relaxed); + if let Some(initial_state) = initial_state { + self.probe + .initial_avail + .store(initial_state.avail_index as usize, Ordering::Relaxed); + self.probe + .initial_used + .store(initial_state.used_index as usize, Ordering::Relaxed); + } + Ok(()) + } + + async fn stop_queue(&mut self, _idx: u16) -> Option { + (self.probe.start_count.load(Ordering::Relaxed) != 0).then_some(QueueState { + avail_index: 3, + used_index: 1, + }) + } + + fn supports_save_restore(&self) -> bool { + true + } + + fn save_device( + &mut self, + ) -> Result, vmcore::save_restore::SaveError> { + Ok(Some(vmcore::save_restore::SavedStateBlob::new( + private_state::SavedState { + value: self.probe.value.load(Ordering::Relaxed) as u64, + }, + ))) + } + + fn restore_device( + &mut self, + state: Option, + ) -> Result<(), vmcore::save_restore::RestoreError> { + self.probe.restore_count.fetch_add(1, Ordering::Relaxed); + if self.probe.fail_restore.load(Ordering::Relaxed) { + return Err(vmcore::save_restore::RestoreError::Other(anyhow::anyhow!( + "intentional private-state restore failure" + ))); + } + let state = state.ok_or_else(|| { + vmcore::save_restore::RestoreError::InvalidSavedState(anyhow::anyhow!( + "missing private test state" + )) + })?; + let state: private_state::SavedState = state.parse()?; + let value = usize::try_from(state.value).map_err(|_| { + vmcore::save_restore::RestoreError::InvalidSavedState(anyhow::anyhow!( + "private test state is out of range" + )) + })?; + self.probe.value.store(value, Ordering::Relaxed); + Ok(()) + } + + fn device_state_validator(&self) -> crate::device::saved_state::DeviceStateValidator { + Box::new(|state, _features, _queues, _guest_memory| { + let state = state.ok_or_else(|| { + vmcore::save_restore::RestoreError::InvalidSavedState(anyhow::anyhow!( + "missing private test state" + )) + })?; + let _: private_state::SavedState = state.parse()?; + Ok(()) + }) + } +} + +impl VirtioPciTestDevice { + /// Like `new_with_register_length`, for a caller-provided device. + fn new_with_device( + driver: &DefaultDriver, + test_mem: &Arc, + device: Box, + ) -> Self { + let doorbell_registration: Arc = test_mem.clone(); + let mem = GuestMemory::new("test", test_mem.clone()); + let msi_conn = MsiConnection::new(); + + let dev = VirtioPciDevice::new( + device, + driver, + mem.clone(), + PciInterruptModel::Msix(&msi_conn.target()), + Some(doorbell_registration), + &mut ExternallyManagedMmioIntercepts, + None, + ) + .unwrap(); + + let test_intc = Arc::new(TestPciInterruptController::new()); + msi_conn.connect(test_intc.signal_msi()); + + Self { + pci_device: dev, + test_intc, + } + } +} + +/// Checks that restore validation accepts the saved queue and rejects +/// corrupted copies of it, leaving the saved state unchanged. +pub(super) fn check_restored_queue_validation( + saved: &mut ::SavedState, + features: VirtioDeviceFeatures, + mem: &GuestMemory, +) { + use crate::transport::core::restore::validate_restored_queue; + + let queue = &mut saved.queues[0].common; + validate_restored_queue(0, queue, features, mem, &mut Vec::new()) + .expect("valid queue was rejected"); + + let original_size = queue.size; + queue.size = 3; + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.size = original_size; + + let original_avail = queue.avail_addr; + queue.avail_addr = queue.desc_addr; + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.avail_addr = original_avail; + + let original_desc = queue.desc_addr; + queue.desc_addr = u64::MAX - 15; + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.desc_addr = 1 << 60; + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.desc_addr = original_desc; + + let original_progress = queue.queue_state; + queue.queue_state = Some(QueueState { + avail_index: original_size + 1, + used_index: 0, + }); + assert!(validate_restored_queue(0, queue, features, mem, &mut Vec::new(),).is_err()); + queue.queue_state = original_progress; +} +#[async_test] +async fn mmio_restore_rejects_missing_active_queue_progress(driver: DefaultDriver) { + use vmcore::device_state::ChangeDeviceState; + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mem = GuestMemory::new("test", test_mem.clone()); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let traits = DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, VIRTIO_F_RING_INDIRECT_DESC | VIRTIO_F_RING_EVENT_IDX), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }; + let mut source = VirtioMmioDevice::new( + Box::new(TestDevice::new(&driver_source, traits.clone(), None)), + &driver, + mem.clone(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + guest + .setup_chipset_device(&mut source, guest.queue_features()) + .await; + source.stop().await; + let mut saved = source.save().unwrap(); + assert!(saved.queues[0].common.enable); + assert!(saved.queues[0].common.queue_state.take().is_some()); + + let mut destination = VirtioMmioDevice::new( + Box::new(TestDevice::new(&driver_source, traits, None)), + &driver, + mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + assert!(destination.restore(saved).is_err()); +} + +#[async_test] +async fn mmio_private_state_round_trip(driver: DefaultDriver) { + use crate::spec::mmio::VirtioMmioRegister; + use vmcore::device_state::ChangeDeviceState; + use vmcore::save_restore::SaveRestore; + + let (source_device, _source_probe) = PrivateStateTestDevice::new(42, 0, false); + let mut source = VirtioMmioDevice::new( + Box::new(source_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + source.stop().await; + let saved = source.save().expect("private state save should succeed"); + assert!(saved.device_state.is_some()); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = VirtioMmioDevice::new( + Box::new(destination_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination + .restore(saved) + .expect("private state restore should validate"); + destination.start_fallible().await.unwrap(); + assert_eq!(probe.value.load(Ordering::Relaxed), 0); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + + destination.stop().await; + let resaved = destination.save().unwrap(); + assert_eq!( + resaved + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); + destination.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + + for expected_count in [1, 1] { + let mut data = [0; 4]; + match destination.mmio_read(VirtioMmioRegister::CONFIG.0 as u64, &mut data) { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.value.load(Ordering::Relaxed), 42); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), expected_count); + } + + destination.stop().await; + let mut invalid = destination.save().unwrap(); + invalid.device_state = Some(vmcore::save_restore::SavedStateBlob::new( + vmcore::save_restore::NoSavedState, + )); + assert!(destination.restore(invalid).is_err()); +} + +#[async_test] +async fn mmio_private_state_config_write_triggers_restore(driver: DefaultDriver) { + use crate::spec::mmio::VirtioMmioRegister; + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = MmioTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = MmioTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + + let value = 7u32; + match destination + .dev + .mmio_write(VirtioMmioRegister::CONFIG.0 as u64, &value.to_ne_bytes()) + { + IoResult::Defer(token) => token.write_future().await.unwrap(), + other => panic!("expected deferred config write, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value.load(Ordering::Relaxed), value as usize); +} + +#[async_test] +async fn pci_private_state_config_triggers_restore(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + const DEVICE_CONFIG_OFFSET: u64 = 64; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + + destination.stop().await; + let resaved = destination.dev.save().unwrap(); + assert_eq!( + resaved + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); + destination.dev.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + + let mut data = [0; 4]; + match destination + .dev + .mmio_read(BAR0_BASE + DEVICE_CONFIG_OFFSET, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(u32::from_ne_bytes(data), 42); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn pci_private_state_config_write_triggers_restore(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + const DEVICE_CONFIG_OFFSET: u64 = 64; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + + let value = 7u32; + match destination + .dev + .mmio_write(BAR0_BASE + DEVICE_CONFIG_OFFSET, &value.to_ne_bytes()) + { + IoResult::Defer(token) => token.write_future().await.unwrap(), + other => panic!("expected deferred config write, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value.load(Ordering::Relaxed), value as usize); +} + +async fn save_active_private_mmio( + driver: &DefaultDriver, + test_mem: &Arc, +) -> ::SavedState { + use vmcore::save_restore::SaveRestore; + + let guest = VirtioTestGuest::new_split(driver, test_mem, 1, 4, true); + let (device, probe) = PrivateStateTestDevice::new(42, 1, false); + let mut source = VirtioMmioDevice::new( + Box::new(device), + driver, + GuestMemory::new("test", test_mem.clone()), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + guest + .setup_chipset_device(&mut source, guest.queue_features()) + .await; + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + source.stop().await; + let saved = source.save().unwrap(); + assert_eq!( + saved.queues[0].common.queue_state, + Some(QueueState { + avail_index: 3, + used_index: 1, + }) + ); + saved +} + +async fn save_active_private_pci( + driver: &DefaultDriver, + test_mem: &Arc, +) -> ::SavedState { + use vmcore::save_restore::SaveRestore; + + let guest = VirtioTestGuest::new_split(driver, test_mem, 1, 4, true); + let (device, probe) = PrivateStateTestDevice::new(42, 1, false); + let mut source = VirtioPciTestDevice::new_with_device(driver, test_mem, Box::new(device)); + guest + .setup_pci_device(&mut source, guest.queue_features()) + .await; + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + source.pci_device.stop().await; + let saved = source.pci_device.save().unwrap(); + assert_eq!( + saved.queues[0].common.queue_state, + Some(QueueState { + avail_index: 3, + used_index: 1, + }) + ); + saved +} + +#[async_test] +async fn mmio_active_private_state_restores_before_queue(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let saved = save_active_private_mmio(&driver, &test_mem).await; + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value_at_start.load(Ordering::Relaxed), 42); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), 3); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn pci_active_private_state_restores_before_queue(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let saved = save_active_private_pci(&driver, &test_mem).await; + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value_at_start.load(Ordering::Relaxed), 42); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), 3); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn mmio_active_zero_queue_device_restores_private_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = MmioTestTransport::new(Box::new(source_device), &driver, 0); + source.write_driver_ok(); + yield_and_poll(&mut source).await; + assert_ne!(source.read_status() & VIRTIO_DRIVER_OK, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = MmioTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value.load(Ordering::Relaxed), 42); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); +} + +#[async_test] +async fn pci_active_zero_queue_device_restores_private_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.write_driver_ok(); + yield_and_poll(&mut source).await; + assert_ne!(source.read_status() & VIRTIO_DRIVER_OK, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.value.load(Ordering::Relaxed), 42); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); +} + +#[async_test] +async fn mmio_staged_kick_starts_after_driver_ok(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.write_u32(80, 0); + destination.write_u32(80, 0); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + assert!(!probe.take_queue_kick()); + + destination.write_u32(112, VIRTIO_DRIVER_OK); + yield_and_poll_device(&mut destination).await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), 3); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), 1); + assert!(probe.take_queue_kick()); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn pci_staged_kick_starts_after_driver_ok(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + destination + .pci_device + .mmio_write( + BAR0_BASE + VIRTIO_PCI_COMMON_CFG_SIZE as u64, + &0u16.to_ne_bytes(), + ) + .unwrap(); + destination + .pci_device + .mmio_write( + BAR0_BASE + VIRTIO_PCI_COMMON_CFG_SIZE as u64, + &0u16.to_ne_bytes(), + ) + .unwrap(); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + assert!(!probe.take_queue_kick()); + + let current = destination.pci_device.read_u32(20); + destination + .pci_device + .write_u32(20, (current & !0xff) | VIRTIO_DRIVER_OK); + yield_and_poll_device(&mut destination.pci_device).await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), 3); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), 1); + assert!(probe.take_queue_kick()); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn mmio_reset_discards_pending_private_state(driver: DefaultDriver) { + use crate::spec::mmio::VirtioMmioRegister; + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = MmioTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = MmioTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + destination.write_status_zero(); + + let mut data = [0; 4]; + match destination + .dev + .mmio_read(VirtioMmioRegister::CONFIG.0 as u64, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + assert_eq!(u32::from_ne_bytes(data), 0); +} + +#[async_test] +async fn pci_reset_discards_pending_private_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + const DEVICE_CONFIG_OFFSET: u64 = 64; + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + destination.write_status_zero(); + + let mut data = [0; 4]; + match destination + .dev + .mmio_read(BAR0_BASE + DEVICE_CONFIG_OFFSET, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + assert_eq!(u32::from_ne_bytes(data), 0); +} + +#[async_test] +async fn mmio_config_restore_failure_completes_and_retains_state(driver: DefaultDriver) { + use crate::spec::mmio::VirtioMmioRegister; + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = MmioTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, true); + let mut destination = MmioTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + + let mut data = [0; 4]; + let read_result = match destination + .dev + .mmio_read(VirtioMmioRegister::CONFIG.0 as u64, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await, + other => panic!("expected deferred config read, got {other:?}"), + }; + assert!(matches!(read_result, Err(IoError::NoResponse))); + + let write_result = match destination + .dev + .mmio_write(VirtioMmioRegister::CONFIG.0 as u64, &7u32.to_ne_bytes()) + { + IoResult::Defer(token) => token.write_future().await, + other => panic!("expected deferred config write, got {other:?}"), + }; + assert!(matches!(write_result, Err(IoError::NoResponse))); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 2); + + destination.stop().await; + let resaved = destination.dev.save().unwrap(); + assert_eq!( + resaved + .device_state + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); +} + +#[async_test] +async fn pci_config_restore_failure_completes_and_retains_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + const DEVICE_CONFIG_OFFSET: u64 = 64; + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let saved = source.dev.save().unwrap(); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, true); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + destination.dev.restore(saved).unwrap(); + destination.dev.start_fallible().await.unwrap(); + + let mut data = [0; 4]; + let read_result = match destination + .dev + .mmio_read(BAR0_BASE + DEVICE_CONFIG_OFFSET, &mut data) + { + IoResult::Defer(token) => token.read_future(&mut data).await, + other => panic!("expected deferred config read, got {other:?}"), + }; + assert!(matches!(read_result, Err(IoError::NoResponse))); + + let write_result = match destination + .dev + .mmio_write(BAR0_BASE + DEVICE_CONFIG_OFFSET, &7u32.to_ne_bytes()) + { + IoResult::Defer(token) => token.write_future().await, + other => panic!("expected deferred config write, got {other:?}"), + }; + assert!(matches!(write_result, Err(IoError::NoResponse))); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 2); + + destination.stop().await; + let resaved = destination.dev.save().unwrap(); + assert_eq!( + resaved + .device_state + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); +} + +#[async_test] +async fn mmio_active_start_failure_preserves_saved_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let saved = save_active_private_mmio(&driver, &test_mem).await; + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + + destination.restore(saved).unwrap(); + assert!(destination.start_fallible().await.is_err()); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + destination.stop().await; + let resaved = destination.save().unwrap(); + assert_eq!( + resaved.queues[0].common.queue_state, + Some(QueueState { + avail_index: 3, + used_index: 1, + }) + ); + assert_eq!( + resaved + .device_state + .unwrap() + .parse::() + .unwrap() + .value, + 42 + ); +} + +#[async_test] +async fn mmio_failed_kick_does_not_signal_restored_queue(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.write_u32(80, 0); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + probe.fail_restore.store(false, Ordering::Relaxed); + destination.write_u32(112, VIRTIO_DRIVER_OK); + yield_and_poll_device(&mut destination).await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 2); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn pci_failed_kick_does_not_signal_restored_queue(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + destination + .pci_device + .mmio_write( + BAR0_BASE + VIRTIO_PCI_COMMON_CFG_SIZE as u64, + &0u16.to_ne_bytes(), + ) + .unwrap(); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + probe.fail_restore.store(false, Ordering::Relaxed); + let current = destination.pci_device.read_u32(20); + destination + .pci_device + .write_u32(20, (current & !0xff) | VIRTIO_DRIVER_OK); + yield_and_poll_device(&mut destination.pci_device).await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 2); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn mmio_driver_ok_restore_failure_releases_write(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + + let token = match destination.mmio_write(112, &VIRTIO_DRIVER_OK.to_ne_bytes()) { + IoResult::Defer(token) => token, + other => panic!("expected deferred status write, got {other:?}"), + }; + yield_and_poll_device(&mut destination).await; + token.write_future().await.unwrap(); + assert_eq!(destination.read_u32(112), 0); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + let mut data = [0; 4]; + match destination.mmio_read(0x100, &mut data) { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn pci_driver_ok_restore_failure_releases_write(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, true); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + + let token = match destination + .pci_device + .mmio_write(BAR0_BASE + 20, &[VIRTIO_DRIVER_OK as u8]) + { + IoResult::Defer(token) => token, + other => panic!("expected deferred status write, got {other:?}"), + }; + yield_and_poll_device(&mut destination.pci_device).await; + token.write_future().await.unwrap(); + assert_eq!(destination.pci_device.read_u32(20) & 0xff, 0); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + let mut data = [0; 4]; + match destination.pci_device.mmio_read(BAR0_BASE + 64, &mut data) { + IoResult::Defer(token) => token.read_future(&mut data).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); +} + +#[async_test] +async fn mmio_reset_discards_staged_kick(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.write_u32(80, 0); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + destination.write_u32(112, 0); + guest + .setup_chipset_device(&mut destination, guest.queue_features()) + .await; + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), usize::MAX); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), usize::MAX); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn pci_reset_discards_staged_kick(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const BAR0_BASE: u64 = 0x10000000000; + let test_mem = VirtioTestMemoryAccess::new(); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + destination + .pci_device + .mmio_write( + BAR0_BASE + VIRTIO_PCI_COMMON_CFG_SIZE as u64, + &0u16.to_ne_bytes(), + ) + .unwrap(); + yield_now().await; + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); + + let current = destination.pci_device.read_u32(20); + destination.pci_device.write_u32(20, current & !0xff); + guest + .setup_pci_device(&mut destination, guest.queue_features()) + .await; + assert_eq!(probe.start_count.load(Ordering::Relaxed), 1); + assert_eq!(probe.initial_avail.load(Ordering::Relaxed), usize::MAX); + assert_eq!(probe.initial_used.load(Ordering::Relaxed), usize::MAX); + assert!(!probe.take_queue_kick()); +} + +#[async_test] +async fn mmio_restore_skips_disabled_zero_address_ring(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_mmio(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let queue = &mut saved.queues[0].common; + queue.enable = false; + queue.desc_addr = 0; + queue.avail_addr = 0; + queue.used_addr = 0; + queue.queue_state = None; + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = VirtioMmioDevice::new( + Box::new(device), + &driver, + GuestMemory::new("test", test_mem), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); +} + +#[async_test] +async fn pci_restore_skips_disabled_zero_address_ring(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut saved = save_active_private_pci(&driver, &test_mem).await; + saved.common.device_status &= !(VIRTIO_DRIVER_OK as u8); + let queue = &mut saved.queues[0].common; + queue.enable = false; + queue.desc_addr = 0; + queue.avail_addr = 0; + queue.used_addr = 0; + queue.queue_state = None; + let (device, probe) = PrivateStateTestDevice::new(0, 1, false); + let mut destination = + VirtioPciTestDevice::new_with_device(&driver, &test_mem, Box::new(device)); + destination.pci_device.restore(saved).unwrap(); + destination.pci_device.start_fallible().await.unwrap(); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); + assert_eq!(probe.start_count.load(Ordering::Relaxed), 0); +} + +#[async_test] +async fn pci_restore_rejects_invalid_private_state(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + let (source_device, _) = PrivateStateTestDevice::new(42, 0, false); + let mut source = PciTestTransport::new(Box::new(source_device), &driver, 0); + source.stop().await; + let mut saved = source.dev.save().unwrap(); + saved.device_state = Some(vmcore::save_restore::SavedStateBlob::new( + vmcore::save_restore::NoSavedState, + )); + + let (destination_device, probe) = PrivateStateTestDevice::new(0, 0, false); + let mut destination = PciTestTransport::new(Box::new(destination_device), &driver, 0); + assert!(destination.dev.restore(saved).is_err()); + assert_eq!(probe.restore_count.load(Ordering::Relaxed), 0); +} diff --git a/vm/devices/virtio/virtio/src/transport/core.rs b/vm/devices/virtio/virtio/src/transport/core.rs index 0ef24ccc726..b2bb974b333 100644 --- a/vm/devices/virtio/virtio/src/transport/core.rs +++ b/vm/devices/virtio/virtio/src/transport/core.rs @@ -3,6 +3,8 @@ //! Shared virtio transport core — state and logic common to PCI and MMIO. +pub(crate) mod restore; + use super::StalledIo; use super::task::DeviceCommand; use super::task::StartParams; @@ -88,6 +90,8 @@ pub(crate) struct VirtioTransportCore { pub doorbells: VirtioDoorbells, pub supports_save_restore: bool, #[inspect(skip)] + pub restore: restore::TransportRestore, + #[inspect(skip)] pub guest_memory: GuestMemory, #[inspect(with = "Option::is_some")] pub pending_status_deferred: Option, @@ -129,6 +133,7 @@ impl VirtioTransportCore { .with_version_1(true) .with_access_platform(true); let supports_save_restore = device.supports_save_restore(); + let restore = restore::TransportRestore::new(&*device, traits.max_queues); let (sender, receiver) = mesh::channel(); let _device_task = driver.spawn("virtio-device-task", async move { @@ -150,6 +155,7 @@ impl VirtioTransportCore { config_generation: 0, doorbells: VirtioDoorbells::new(doorbell_registration), supports_save_restore, + restore, guest_memory, pending_status_deferred: None, stalled_io: Vec::new(), @@ -208,10 +214,12 @@ impl VirtioTransportCore { driver_feature_select, queue_select, queues, + restore, } = self; drop(pending_status_deferred.take()); stalled_io.clear(); + restore.reset(); doorbells.clear(); *device_status = VirtioDeviceStatus::new(); @@ -277,6 +285,7 @@ impl VirtioTransportCore { ); if val == 0 { + self.reset_inactive_device(); if self.device_status.as_u32() == 0 { return; } @@ -327,6 +336,7 @@ impl VirtioTransportCore { event: qd.event.clone(), guest_memory: self.guest_memory.clone(), }, + qd.saved_state, ) }) .collect(); @@ -358,6 +368,15 @@ impl VirtioTransportCore { /// `ChangeDeviceState::start()` implementation. pub fn start(&mut self, ops: &mut dyn TransportOps) { + if let Some(params) = self.start_params(ops) { + // Fire and forget — start() is sync, can't await. + self.device_sender + .send(DeviceCommand::Start(Rpc::detached(params))); + } + } + + fn start_params(&mut self, ops: &mut dyn TransportOps) -> Option { + let device_state = self.restore.take_start_state(); if self.device_status.driver_ok() { let features = self.driver_feature; let mut queues = Vec::new(); @@ -365,7 +384,7 @@ impl VirtioTransportCore { if !qd.params.enable { continue; } - let initial_state = qd.saved_state.take(); + let initial_state = qd.saved_state; queues.push(( i, qd.params, @@ -391,12 +410,14 @@ impl VirtioTransportCore { }) .collect(); - let params = StartParams { queues, features }; - - // Fire and forget — start() is sync, can't await. - self.device_sender - .send(DeviceCommand::Start(Rpc::detached(params))); + return Some(StartParams { + queues, + features, + device_state, + active: true, + }); } + restore::inactive_start_params(self.driver_feature, device_state) } /// `ChangeDeviceState::stop()` implementation. @@ -412,6 +433,7 @@ impl VirtioTransportCore { .call(DeviceCommand::Stop, ()) .await .expect("device task is gone"); + let states = self.restore.capture_stop(states, &self.queues); for (i, state) in states.into_iter().enumerate() { self.queues[i].saved_state = state; } @@ -427,7 +449,7 @@ impl VirtioTransportCore { /// Signal a queue notification by index. pub fn notify_queue(&self, queue_index: u32) { if let Some(qd) = self.queues.get(queue_index as usize) { - qd.event.signal(); + self.kick_queue(queue_index, &qd.event); } } @@ -469,6 +491,7 @@ impl VirtioTransportCore { &mut self, ops: &mut dyn TransportOps, common: &super::saved_state::state::CommonSavedState, + device_state: Option, queue_states: impl Iterator, saved_queue_count: usize, ) -> Result<(), RestoreError> { @@ -477,6 +500,7 @@ impl VirtioTransportCore { } let queue_items: Vec<_> = queue_states.collect(); + self.validate_restored_state(common, &device_state, &queue_items, saved_queue_count)?; super::saved_state::validate_restore( common, @@ -515,6 +539,7 @@ impl VirtioTransportCore { } self.device_status = VirtioDeviceStatus::from(common.device_status); + self.restore.stage(device_state); // Verify ephemeral runtime state. assert!(!self.state.is_busy()); diff --git a/vm/devices/virtio/virtio/src/transport/core/restore.rs b/vm/devices/virtio/virtio/src/transport/core/restore.rs new file mode 100644 index 00000000000..87ebf8b75c1 --- /dev/null +++ b/vm/devices/virtio/virtio/src/transport/core/restore.rs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Device-private state, restore validation, and queue kicks for the shared +//! transport core. +//! +//! `stop()` captures the device-private state next to the queue states, and +//! `save()` takes it. `restore_common()` validates the restored rings and the +//! private state before it changes any transport state, then stages the +//! private state, which the next start hands to the device task. + +use super::QueueData; +use super::TransportOps; +use super::VirtioTransportCore; +use crate::DynVirtioDevice; +use crate::device::saved_state::DeviceQueueState; +use crate::device::saved_state::DeviceStateValidator; +use crate::queue::QueueCoreCompleteWork; +use crate::queue::QueueCoreGetWork; +use crate::queue::QueueParams; +use crate::queue::QueueState; +use crate::spec::VirtioDeviceFeatures; +use crate::spec::VirtioDeviceStatus; +use crate::transport::saved_state::state::CommonQueueState; +use crate::transport::saved_state::state::CommonSavedState; +use crate::transport::task::DeviceCommand; +use crate::transport::task::StartParams; +use crate::transport::task::kick::Kick; +use crate::transport::task::kick::mark_kick_queued; +use crate::transport::task::restore::DeviceRestoreState; +use crate::transport::task::restore::StopResult; +use guestmem::GuestMemory; +use mesh::rpc::Rpc; +use mesh::rpc::RpcSend; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +/// Device-private state and queue kick state of the transport core. +pub(crate) struct TransportRestore { + /// Validates restored private state; see VirtioDevice::device_state_validator. + validator: DeviceStateValidator, + /// Private state captured by the last `stop()`, taken by `save()`. + captured: Option, SaveError>>, + /// Private state restored by `restore_common()`, handed to the next start. + restored: DeviceRestoreState, + /// Per queue, whether a kick is queued to the device task. + kick_queued: Vec>, +} + +impl TransportRestore { + pub(super) fn new(device: &dyn DynVirtioDevice, max_queues: u16) -> Self { + Self { + validator: device.device_state_validator(), + captured: None, + restored: DeviceRestoreState::NotRestored, + kick_queued: (0..max_queues) + .map(|_| Arc::new(AtomicBool::new(false))) + .collect(), + } + } + + /// Resets the state for a transport reset. + pub(super) fn reset(&mut self) { + self.captured = None; + self.restored = DeviceRestoreState::NotRestored; + for kick_queued in &self.kick_queued { + kick_queued.store(false, Ordering::Release); + } + } + + /// Drops the captured private state and takes the restored private state + /// for a start. + pub(super) fn take_start_state(&mut self) -> DeviceRestoreState { + self.captured = None; + std::mem::replace(&mut self.restored, DeviceRestoreState::NotRestored) + } + + /// Stages restored private state for the next start. + pub(super) fn stage(&mut self, device_state: Option) { + self.restored = DeviceRestoreState::Restored(device_state); + } + + /// Captures the device-private state of a stop result and returns the + /// queue states to save. Queues that were not started keep their saved + /// state. + pub(super) fn capture_stop( + &mut self, + result: StopResult, + queues: &[QueueData], + ) -> Vec> { + self.captured = Some(result.device_state); + result + .queues + .into_iter() + .zip(queues) + .map(|((was_started, state), qd)| if was_started { state } else { qd.saved_state }) + .collect() + } +} + +/// Start parameters that only stage restored private state, for a device +/// that is not DRIVER_OK. +pub(super) fn inactive_start_params( + features: VirtioDeviceFeatures, + device_state: DeviceRestoreState, +) -> Option { + device_state.is_restored().then(|| StartParams { + queues: Vec::new(), + features, + device_state, + active: false, + }) +} + +impl VirtioTransportCore { + /// Starts the device and waits for private-state restore and queue startup. + pub async fn start_fallible(&mut self, ops: &mut dyn TransportOps) -> anyhow::Result<()> { + if let Some(params) = self.start_params(ops) { + self.device_sender + .call_failable(DeviceCommand::Start, params) + .await?; + } + Ok(()) + } + + /// Take the device-private state captured when the queues stopped. + pub fn take_device_state(&mut self) -> Result, SaveError> { + self.restore + .captured + .take() + .ok_or_else(|| SaveError::Other(anyhow::anyhow!("device state was not captured")))? + } + + /// Sends a queue kick to the device task, unless one is already queued. + pub(super) fn kick_queue(&self, queue_index: u32, event: &pal_event::Event) { + if let Some(queued) = self.restore.kick_queued.get(queue_index as usize) { + if mark_kick_queued(queued) { + self.device_sender.send(DeviceCommand::Kick(Kick { + idx: queue_index as u16, + event: event.clone(), + queued: queued.clone(), + })); + } + } + } + + /// Resets the device task when the guest resets a device that is not + /// DRIVER_OK, which discards staged private state and kicks. + pub(super) fn reset_inactive_device(&self) { + if !self.device_status.driver_ok() { + self.device_sender + .send(DeviceCommand::Reset(Rpc::detached(()))); + } + } + + /// Validates restored state before `restore_common()` applies it. + /// + /// This is stricter than `saved_state::validate_restore()`, which + /// `restore_common()` runs afterwards: there must be exactly two feature + /// banks, queue sizes are bounded by each queue's device size, enabled + /// rings must be valid in guest memory, and the device must accept its + /// private state. + pub(super) fn validate_restored_state( + &self, + common: &CommonSavedState, + device_state: &Option, + queue_items: &[(CommonQueueState, u16)], + saved_queue_count: usize, + ) -> Result<(), RestoreError> { + let saved_banks = &common.driver_feature_banks; + if saved_banks.len() != 2 { + return Err(RestoreError::InvalidSavedState( + RestoreValidationError::FeatureBankCountMismatch { + saved: saved_banks.len(), + expected: 2, + } + .into(), + )); + } + // Check the feature banks and the queue count. The queue sizes are + // checked below, against each queue's device size. + crate::transport::saved_state::validate_restore( + common, + &self.device_feature, + std::iter::empty(), + self.queues.len(), + saved_queue_count, + crate::MAX_QUEUE_SIZE, + )?; + for (i, (q, _)) in queue_items.iter().enumerate() { + let max = self.queues[i].initial_size; + if q.size > max { + return Err(RestoreError::InvalidSavedState( + RestoreValidationError::QueueSizeTooLarge { + index: i, + size: q.size, + max, + } + .into(), + )); + } + } + + let mut restored_features = VirtioDeviceFeatures::new(); + for (index, &bank) in common.driver_feature_banks.iter().enumerate() { + restored_features.set_bank(index, bank); + } + let mut ring_ranges = Vec::new(); + for (index, (queue, _)) in queue_items.iter().enumerate() { + if !queue.enable { + continue; + } + if VirtioDeviceStatus::from(common.device_status).driver_ok() + && queue.queue_state.is_none() + { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "queue {index}: enabled DRIVER_OK queue is missing progress state" + ))); + } + validate_restored_queue( + index, + queue, + restored_features, + &self.guest_memory, + &mut ring_ranges, + )?; + } + let device_queues = queue_items + .iter() + .map(|(queue, _)| DeviceQueueState { + params: QueueParams { + size: queue.size, + enable: queue.enable, + desc_addr: queue.desc_addr, + avail_addr: queue.avail_addr, + used_addr: queue.used_addr, + }, + queue_state: queue.queue_state, + }) + .collect::>(); + (self.restore.validator)( + device_state.as_ref(), + &restored_features, + &device_queues, + &self.guest_memory, + ) + } +} + +#[derive(Debug, thiserror::Error)] +enum RestoreValidationError { + #[error("saved state has {saved} feature banks, expected {expected}")] + FeatureBankCountMismatch { saved: usize, expected: usize }, + #[error("queue {index}: saved size {size} exceeds device maximum {max}")] + QueueSizeTooLarge { index: usize, size: u16, max: u16 }, +} + +/// Validates the layout and progress of an enabled restored queue. Its rings +/// must not overlap previous_ranges, to which they are added. +pub(crate) fn validate_restored_queue( + index: usize, + queue: &CommonQueueState, + features: VirtioDeviceFeatures, + guest_memory: &GuestMemory, + previous_ranges: &mut Vec<(usize, &'static str, std::ops::Range)>, +) -> Result<(), RestoreError> { + let invalid = |message: String| { + RestoreError::InvalidSavedState(anyhow::anyhow!("queue {index}: {message}")) + }; + if queue.size == 0 || (!features.ring_packed() && !queue.size.is_power_of_two()) { + return Err(invalid(format!( + "invalid {} ring size {}", + if features.ring_packed() { + "packed" + } else { + "split" + }, + queue.size + ))); + } + + let descriptor_length = u64::from(queue.size) + .checked_mul(16) + .ok_or_else(|| invalid("descriptor length overflow".to_owned()))?; + let (available_length, used_length) = if features.ring_packed() { + (4, 4) + } else { + ( + u64::from(queue.size) + .checked_mul(2) + .and_then(|length| length.checked_add(6)) + .ok_or_else(|| invalid("available ring length overflow".to_owned()))?, + u64::from(queue.size) + .checked_mul(8) + .and_then(|length| length.checked_add(6)) + .ok_or_else(|| invalid("used ring length overflow".to_owned()))?, + ) + }; + let specifications = [ + ("descriptor", queue.desc_addr, descriptor_length, 16), + ("available", queue.avail_addr, available_length, 2), + ("used", queue.used_addr, used_length, 4), + ]; + let mut queue_ranges = Vec::new(); + for (name, address, length, alignment) in specifications { + if address % alignment != 0 { + return Err(invalid(format!( + "{name} ring address {address:#x} is not {alignment}-byte aligned" + ))); + } + let end = address + .checked_add(length) + .ok_or_else(|| invalid(format!("{name} ring range overflows")))?; + guest_memory + .subrange(address, length, true) + .map_err(|error| invalid(format!("{name} ring is outside guest RAM: {error}")))?; + let range = address..end; + for (other_index, other_name, other_range) in + previous_ranges.iter().chain(queue_ranges.iter()) + { + if range.start < other_range.end && other_range.start < range.end { + return Err(invalid(format!( + "{name} ring overlaps queue {other_index} {other_name} ring" + ))); + } + } + queue_ranges.push((index, name, range)); + } + + let params = QueueParams { + size: queue.size, + enable: true, + desc_addr: queue.desc_addr, + avail_addr: queue.avail_addr, + used_addr: queue.used_addr, + }; + QueueCoreGetWork::new(features, guest_memory.clone(), params, queue.queue_state) + .map_err(|error| invalid(format!("invalid available progress: {error}")))?; + QueueCoreCompleteWork::new(features, guest_memory.clone(), params, queue.queue_state) + .map_err(|error| invalid(format!("invalid used progress: {error}")))?; + previous_ranges.extend(queue_ranges); + Ok(()) +} diff --git a/vm/devices/virtio/virtio/src/transport/mmio.rs b/vm/devices/virtio/virtio/src/transport/mmio.rs index b418e8209e7..e6c1213bb6a 100644 --- a/vm/devices/virtio/virtio/src/transport/mmio.rs +++ b/vm/devices/virtio/virtio/src/transport/mmio.rs @@ -359,6 +359,10 @@ impl ChangeDeviceState for VirtioMmioDevice { self.core.start(&mut self.mmio); } + async fn start_fallible(&mut self) -> anyhow::Result<()> { + self.core.start_fallible(&mut self.mmio).await + } + async fn stop(&mut self) { self.core.stop(&mut self.mmio).await; } @@ -392,6 +396,7 @@ mod saved_state { use crate::transport::saved_state::state::CommonQueueState; use crate::transport::saved_state::state::CommonSavedState; use mesh::payload::Protobuf; + use vmcore::save_restore::SavedStateBlob; use vmcore::save_restore::SavedStateRoot; #[derive(Protobuf)] @@ -410,6 +415,8 @@ mod saved_state { pub queues: Vec, #[mesh(3)] pub interrupt_status: u32, + #[mesh(4)] + pub device_state: Option, } } @@ -427,6 +434,7 @@ mod saved_state { common: self.core.save_queue_common(i), }) .collect(), + device_state: self.core.take_device_state()?, interrupt_status: self.mmio.interrupt_state.lock().status, }) } @@ -439,6 +447,7 @@ mod saved_state { self.core.restore_common( &mut self.mmio, &state.common, + state.device_state, state.queues.into_iter().map(|sq| (sq.common, 0)), saved_queue_count, )?; diff --git a/vm/devices/virtio/virtio/src/transport/pci.rs b/vm/devices/virtio/virtio/src/transport/pci.rs index 112d129e15f..94f71d568ae 100644 --- a/vm/devices/virtio/virtio/src/transport/pci.rs +++ b/vm/devices/virtio/virtio/src/transport/pci.rs @@ -772,6 +772,10 @@ impl ChangeDeviceState for VirtioPciDevice { self.core.start(&mut self.pci); } + async fn start_fallible(&mut self) -> anyhow::Result<()> { + self.core.start_fallible(&mut self.pci).await + } + async fn stop(&mut self) { self.core.stop(&mut self.pci).await; } @@ -813,6 +817,7 @@ mod saved_state { use mesh::payload::Protobuf; use pci_core::cfg_space_emu::ConfigSpaceType0Emulator; use vmcore::save_restore::SaveRestore; + use vmcore::save_restore::SavedStateBlob; use vmcore::save_restore::SavedStateRoot; #[derive(Protobuf)] @@ -840,6 +845,8 @@ mod saved_state { /// the guest firmware runs) survive a save/restore cycle. #[mesh(5)] pub cfg_space: ::SavedState, + #[mesh(6)] + pub device_state: Option, } #[derive(Protobuf, SavedStateRoot)] @@ -874,8 +881,9 @@ mod saved_state { msix_vector: qd.msix_vector, }) .collect(), - interrupt_status: *self.pci.interrupt_status.lock(), cfg_space: self.pci.config_space.save()?, + device_state: self.core.take_device_state()?, + interrupt_status: *self.pci.interrupt_status.lock(), }) } @@ -900,6 +908,7 @@ mod saved_state { self.core.restore_common( &mut self.pci, &state.common, + state.device_state, state .queues .into_iter() diff --git a/vm/devices/virtio/virtio/src/transport/task.rs b/vm/devices/virtio/virtio/src/transport/task.rs index 79cb453b018..7d9c36efcd3 100644 --- a/vm/devices/virtio/virtio/src/transport/task.rs +++ b/vm/devices/virtio/virtio/src/transport/task.rs @@ -8,6 +8,9 @@ //! The transports become thin MMIO/PCI forwarders that send RPCs to //! the task. +pub(super) mod kick; +pub(super) mod restore; + use crate::DynVirtioDevice; use crate::QueueResources; use crate::queue::QueueState; @@ -36,7 +39,7 @@ pub enum DeviceCommand { /// Guest writes status=0 — stop all queues, reset device. Disable(Rpc<(), ()>), /// ChangeDeviceState::stop() — stop queues, return states for resume. - Stop(Rpc<(), Vec>>), + Stop(Rpc<(), restore::StopResult>), /// ChangeDeviceState::start() — restart queues with saved states. Start(FailableRpc), /// ChangeDeviceState::reset() — stop queues, reset device. @@ -55,6 +58,8 @@ pub enum DeviceCommand { data: [u8; 8], deferred: DeferredWrite, }, + /// Queue notification, serialized with private-state activation. + Kick(kick::Kick), /// Inspect the device state. Inspect(inspect::Deferred), } @@ -74,7 +79,7 @@ pub enum ConfigReadCompletion { /// Parameters for the Enable command. pub struct EnableParams { - pub queues: Vec<(u16, QueueResources)>, + pub queues: Vec<(u16, QueueResources, Option)>, pub features: VirtioDeviceFeatures, } @@ -82,6 +87,8 @@ pub struct EnableParams { pub struct StartParams { pub queues: Vec<(u16, QueueResources, Option)>, pub features: VirtioDeviceFeatures, + pub device_state: restore::DeviceRestoreState, + pub active: bool, } /// Transport-side state machine tracking in-flight device operations. @@ -129,7 +136,7 @@ impl TransportState { pub fn start_enable( &mut self, sender: &mesh::Sender, - queues: Vec<(u16, QueueResources)>, + queues: Vec<(u16, QueueResources, Option)>, features: VirtioDeviceFeatures, ) { assert!(!self.is_busy()); @@ -186,14 +193,17 @@ impl TransportState { struct DeviceTask { device: Box, max_queues: u16, + restore: restore::RestoreStaging, + kicks: kick::KickState, } impl DeviceTask { async fn enable(&mut self, params: EnableParams) -> bool { - for (idx, resources) in params.queues { + let mut started = + kick::StartedQueues::new("driver-ok", ¶ms.features, params.queues.len()); + for (idx, resources, initial_state) in params.queues { if let Err(err) = self - .device - .start_queue(idx, resources, ¶ms.features, None) + .start_queue(idx, resources, initial_state, &mut started) .await { tracelimit::error_ratelimited!( @@ -202,30 +212,34 @@ impl DeviceTask { "virtio device start_queue failed" ); self.stop_all_queues().await; + self.discard_staged(); self.device.reset().await; return false; } } + self.dispatch_pending_kicks(started); true } async fn disable(&mut self) { self.stop_all_queues().await; + self.discard_staged(); self.device.reset().await; } - async fn stop(&mut self) -> Vec> { + async fn stop(&mut self) -> restore::StopResult { let mut states = vec![None; self.max_queues as usize]; for idx in 0..self.max_queues { states[idx as usize] = self.device.stop_queue(idx).await; } - states + self.stop_result(states) } async fn start(&mut self, params: StartParams) -> anyhow::Result<()> { + let mut started = + kick::StartedQueues::new("active-start", ¶ms.features, params.queues.len()); for (idx, resources, initial_state) in params.queues { - self.device - .start_queue(idx, resources, ¶ms.features, initial_state) + self.start_queue(idx, resources, initial_state, &mut started) .await .map_err(|err| { tracelimit::error_ratelimited!( @@ -236,17 +250,20 @@ impl DeviceTask { err })?; } + self.dispatch_pending_kicks(started); Ok(()) } async fn reset(&mut self) { self.stop_all_queues().await; + self.discard_staged(); self.device.reset().await; } async fn stop_all_queues(&mut self) { for idx in 0..self.max_queues { self.device.stop_queue(idx).await; + self.kicks.started[idx as usize] = false; } } } @@ -256,15 +273,22 @@ pub async fn run_device_task( device: Box, mut recv: mesh::Receiver, ) { + let traits = device.traits(); let mut task = DeviceTask { - max_queues: device.traits().max_queues, + max_queues: traits.max_queues, + restore: restore::RestoreStaging::new(traits.device_id.0), + kicks: kick::KickState::new(traits.max_queues), device, }; while let Some(cmd) = recv.next().await { + let Some(cmd) = task.restore_before_config(cmd) else { + continue; + }; match cmd { DeviceCommand::Enable(rpc) => { - rpc.handle(async |params| task.enable(params).await).await; + rpc.handle(async |params| task.enable_with_restore(params).await) + .await; } DeviceCommand::Disable(rpc) => { rpc.handle(async |()| task.disable().await).await; @@ -278,7 +302,7 @@ pub async fn run_device_task( // but not propagated to the transport. // TODO: update ChangeDeviceState to allow async start() // so failures can be handled by the transport. - rpc.handle_failable(async |params| task.start(params).await) + rpc.handle_failable(async |params| task.start_with_restore(params).await) .await; } DeviceCommand::Reset(rpc) => { @@ -341,6 +365,7 @@ pub async fn run_device_task( } deferred.complete(); } + DeviceCommand::Kick(kick) => task.kick(kick), DeviceCommand::Inspect(deferred) => { deferred.inspect(&mut *task.device); } diff --git a/vm/devices/virtio/virtio/src/transport/task/kick.rs b/vm/devices/virtio/virtio/src/transport/task/kick.rs new file mode 100644 index 00000000000..2b1cff43332 --- /dev/null +++ b/vm/devices/virtio/virtio/src/transport/task/kick.rs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Queue kicks serialized through the device task. +//! +//! Emulated queue notifications reach the device task as +//! [`DeviceCommand::Kick`](super::DeviceCommand::Kick), so that they are +//! ordered with device-private state activation. Kicks coalesce until the +//! device task dequeues them. A kick for a queue that has not started is staged +//! and dispatched once the queue starts. + +use super::DeviceTask; +use crate::QueueResources; +use crate::queue::QueueState; +use crate::spec::VirtioDeviceFeatures; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +/// Queue notification, serialized with private-state activation. +pub struct Kick { + pub idx: u16, + pub event: pal_event::Event, + pub queued: Arc, +} + +/// Marks a queue kick as queued. Returns `true` if the caller must send a +/// [`Kick`], or `false` if one is already queued. +pub(crate) fn mark_kick_queued(queued: &AtomicBool) -> bool { + !queued.swap(true, Ordering::AcqRel) +} + +/// Per-queue kick state of the device task. +pub(super) struct KickState { + /// The notification event of each queue that has been kicked. + events: Vec>, + /// Kicks staged for queues that have not started. + pending: Vec, + /// Queues that are started. + pub(super) started: Vec, +} + +impl KickState { + pub(super) fn new(max_queues: u16) -> Self { + Self { + events: vec![None; max_queues as usize], + pending: vec![false; max_queues as usize], + started: vec![false; max_queues as usize], + } + } +} + +/// Queues started by one `enable` or `start`, whose staged kicks are +/// dispatched once all of the queues have started. +pub(super) struct StartedQueues<'a> { + /// The trigger reported by the `queue_start` restore lifecycle trace. + trigger: &'static str, + features: &'a VirtioDeviceFeatures, + events: Vec<(u16, pal_event::Event)>, +} + +impl<'a> StartedQueues<'a> { + pub(super) fn new( + trigger: &'static str, + features: &'a VirtioDeviceFeatures, + queue_count: usize, + ) -> Self { + Self { + trigger, + features, + events: Vec::with_capacity(queue_count), + } + } +} + +impl DeviceTask { + /// Starts one queue and records it in `started`. + pub(super) async fn start_queue( + &mut self, + idx: u16, + resources: QueueResources, + initial_state: Option, + started: &mut StartedQueues<'_>, + ) -> anyhow::Result<()> { + let event = resources.event.clone(); + let restored_progress = initial_state.is_some(); + let result = self + .device + .start_queue(idx, resources, started.features, initial_state) + .await; + tracing::debug!( + target: "virtio_restore", + event = "queue_start", + device_type = self.restore.device_type, + trigger = started.trigger, + queue_index = i32::from(idx), + restored_progress, + success = result.is_ok(), + "virtio restore lifecycle" + ); + if result.is_ok() { + self.kicks.started[idx as usize] = true; + started.events.push((idx, event)); + } + result + } + + /// Handles `DeviceCommand::Kick`. + pub(super) fn kick(&mut self, kick: Kick) { + let Kick { idx, event, queued } = kick; + queued.store(false, Ordering::Release); + if let Some(slot) = self.kicks.events.get_mut(idx as usize) { + *slot = Some(event.clone()); + } + if let Err(error) = self.apply_pending_restore("kick", Some(idx)) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + idx, + "virtio device restore failed before queue kick" + ); + return; + } + if self.kicks.started.get(idx as usize) == Some(&true) { + event.signal(); + tracing::debug!( + target: "virtio_restore", + event = "kick_dispatch", + device_type = self.restore.device_type, + trigger = "kick", + queue_index = i32::from(idx), + restored_progress = false, + success = true, + "virtio restore lifecycle" + ); + } else if let Some(pending) = self.kicks.pending.get_mut(idx as usize) { + let newly_staged = !*pending; + *pending = true; + if newly_staged { + tracing::debug!( + target: "virtio_restore", + event = "kick_staged", + device_type = self.restore.device_type, + trigger = "kick", + queue_index = i32::from(idx), + restored_progress = false, + success = true, + "virtio restore lifecycle" + ); + } + } + } + + /// Drains stale queue notifications and discards staged kicks. + pub(super) fn clear_queue_events(&mut self) { + for event in self.kicks.events.iter().flatten() { + while event.try_wait() {} + } + self.kicks.pending.fill(false); + } + + /// Dispatches the kicks staged for the queues in `started`. + pub(super) fn dispatch_pending_kicks(&mut self, started: StartedQueues<'_>) { + for (idx, event) in started.events { + self.dispatch_pending_kick(idx, &event); + } + } + + fn dispatch_pending_kick(&mut self, idx: u16, event: &pal_event::Event) { + if !std::mem::take(&mut self.kicks.pending[idx as usize]) { + return; + } + event.signal(); + tracing::debug!( + target: "virtio_restore", + event = "kick_dispatch", + device_type = self.restore.device_type, + trigger = "driver-ok", + queue_index = i32::from(idx), + restored_progress = false, + success = true, + "virtio restore lifecycle" + ); + } +} + +#[cfg(test)] +mod tests { + use super::mark_kick_queued; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + use test_with_tracing::test; + + #[test] + fn queue_kicks_coalesce_until_the_command_is_dequeued() { + let queued = AtomicBool::new(false); + + assert!(mark_kick_queued(&queued)); + assert!(!mark_kick_queued(&queued)); + + queued.store(false, Ordering::Release); + assert!(mark_kick_queued(&queued)); + } +} diff --git a/vm/devices/virtio/virtio/src/transport/task/restore.rs b/vm/devices/virtio/virtio/src/transport/task/restore.rs new file mode 100644 index 00000000000..b1c59df89eb --- /dev/null +++ b/vm/devices/virtio/virtio/src/transport/task/restore.rs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Device-private state capture and restore staging in the device task. +//! +//! `ChangeDeviceState::stop()` captures the device-private state together +//! with the queue states. After a restore, `start()` stages the restored +//! private state, and the device task applies it on the first activation: the +//! start of an active device, DRIVER_OK, a device-config access, or a queue +//! kick. + +use super::DeviceCommand; +use super::DeviceTask; +use super::EnableParams; +use super::StartParams; +use crate::queue::QueueState; +use chipset_device::io::IoError; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +/// Whether this start follows restore, and its optional private payload. +#[derive(Clone)] +pub enum DeviceRestoreState { + NotRestored, + Restored(Option), +} + +impl DeviceRestoreState { + pub fn is_restored(&self) -> bool { + matches!(self, Self::Restored(_)) + } +} + +/// Queue and device-private state captured after a device has stopped. +pub struct StopResult { + pub queues: Vec<(bool, Option)>, + pub device_state: Result, SaveError>, +} + +/// Restore staging state of the device task. +pub(super) struct RestoreStaging { + /// The device type reported by restore lifecycle traces. + pub(super) device_type: u16, + /// Restored private state that has not been applied to the device yet. + pending: DeviceRestoreState, +} + +impl RestoreStaging { + pub(super) fn new(device_type: u16) -> Self { + Self { + device_type, + pending: DeviceRestoreState::NotRestored, + } + } +} + +impl DeviceTask { + /// Handles `DeviceCommand::Enable`: applies staged private state before + /// the queues start. + pub(super) async fn enable_with_restore(&mut self, params: EnableParams) -> bool { + if let Err(error) = self.apply_pending_restore("driver-ok", None) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "virtio device restore failed during enable" + ); + self.stop_all_queues().await; + self.clear_queue_events(); + self.device.reset().await; + self.restore.pending = DeviceRestoreState::NotRestored; + return false; + } + self.enable(params).await + } + + /// Handles `DeviceCommand::Start`: stages restored private state and, for + /// an active device, applies it before the queues start. Stops the queues + /// again if one of them fails to start. + pub(super) async fn start_with_restore( + &mut self, + mut params: StartParams, + ) -> anyhow::Result<()> { + let device_state = + std::mem::replace(&mut params.device_state, DeviceRestoreState::NotRestored); + self.stage_restore(device_state, params.active); + if !params.active { + return Ok(()); + } + self.apply_pending_restore("active-start", None)?; + let result = self.start(params).await; + if result.is_err() { + self.stop_all_queues().await; + } + result + } + + fn stage_restore(&mut self, state: DeviceRestoreState, active: bool) { + if state.is_restored() { + tracing::debug!( + target: "virtio_restore", + event = "restore_staged", + device_type = self.restore.device_type, + trigger = if active { "active-start" } else { "inactive-start" }, + queue_index = -1, + restored_progress = false, + success = true, + "virtio restore lifecycle" + ); + self.restore.pending = state; + } + } + + pub(super) fn apply_pending_restore( + &mut self, + trigger: &'static str, + queue_index: Option, + ) -> Result<(), RestoreError> { + let DeviceRestoreState::Restored(state) = &self.restore.pending else { + return Ok(()); + }; + let result = self.device.restore_device(state.clone()); + tracing::debug!( + target: "virtio_restore", + event = "private_state_apply", + device_type = self.restore.device_type, + trigger, + queue_index = queue_index.map(i32::from).unwrap_or(-1), + restored_progress = false, + success = result.is_ok(), + "virtio restore lifecycle" + ); + if result.is_ok() { + self.restore.pending = DeviceRestoreState::NotRestored; + } + result + } + + /// Discards staged kicks and staged private state before the device is + /// reset. + pub(super) fn discard_staged(&mut self) { + self.clear_queue_events(); + self.restore.pending = DeviceRestoreState::NotRestored; + } + + /// Builds the `DeviceCommand::Stop` result from the stopped queue states. + /// + /// Only queues that were started report their state. The device-private + /// state is saved from the device, or is the staged restored state if it + /// has not been applied yet. + pub(super) fn stop_result(&mut self, states: Vec>) -> StopResult { + StopResult { + queues: self + .kicks + .started + .iter_mut() + .map(std::mem::take) + .zip(states) + .collect(), + device_state: match &self.restore.pending { + DeviceRestoreState::NotRestored => self.device.save_device(), + DeviceRestoreState::Restored(state) => Ok(state.clone()), + }, + } + } + + /// Applies staged private state before a device-config access. + /// + /// Returns the command to process, or `None` if the restore failed and the + /// access was completed with an error. + pub(super) fn restore_before_config(&mut self, cmd: DeviceCommand) -> Option { + match cmd { + DeviceCommand::ReadConfig { + offset, + len, + completion, + deferred, + } => { + if let Err(error) = self.apply_pending_restore("config-read", None) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "virtio device restore failed before config read" + ); + deferred.complete_error(IoError::NoResponse); + return None; + } + Some(DeviceCommand::ReadConfig { + offset, + len, + completion, + deferred, + }) + } + DeviceCommand::WriteConfig { + offset, + len, + data, + deferred, + } => { + if let Err(error) = self.apply_pending_restore("config-write", None) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "virtio device restore failed before config write" + ); + deferred.complete_error(IoError::NoResponse); + return None; + } + Some(DeviceCommand::WriteConfig { + offset, + len, + data, + deferred, + }) + } + cmd => Some(cmd), + } + } +} diff --git a/vm/vmcore/src/save_restore.rs b/vm/vmcore/src/save_restore.rs index 895215fe015..5e48d6058c7 100644 --- a/vm/vmcore/src/save_restore.rs +++ b/vm/vmcore/src/save_restore.rs @@ -115,7 +115,7 @@ pub trait ProtobufSaveRestore { } /// An opaque saved state blob, encoded as a protobuf message. -#[derive(Debug, Protobuf)] +#[derive(Clone, Debug, Protobuf)] #[mesh(transparent)] pub struct SavedStateBlob(ProtobufAny); @@ -244,3 +244,16 @@ pub mod private { }; } } + +#[cfg(test)] +mod tests { + use super::NoSavedState; + use super::SavedStateBlob; + + #[test] + fn cloned_saved_state_blob_parses() { + let state = SavedStateBlob::new(NoSavedState); + state.clone().parse::().unwrap(); + state.parse::().unwrap(); + } +} diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64.rs b/vmm_tests/vmm_tests/tests/tests/x86_64.rs index 57472e41818..666d07214f2 100644 --- a/vmm_tests/vmm_tests/tests/tests/x86_64.rs +++ b/vmm_tests/vmm_tests/tests/tests/x86_64.rs @@ -335,10 +335,7 @@ async fn vpci_relay_tdisp_device( } /// Boot with a virtio-blk disk via virtio-mmio and verify the device appears in the guest. -#[openvmm_test(unstable( - reason = "virtio-blk over virtio-mmio boot test fails frequently in CI; root cause unknown", - linux_direct_x64 -))] +#[openvmm_test(linux_direct_x64)] async fn virtio_blk_device(config: PetriVmBuilder) -> anyhow::Result<()> { use disk_backend_resources::LayeredDiskHandle; use disk_backend_resources::layer::RamDiskLayerHandle; @@ -445,7 +442,12 @@ async fn virtio_blk_device(config: PetriVmBuilder) -> anyho "post-restore write/read mismatch: {readback}" ); - agent.power_off().await?; + drop(sh); + drop(agent); + // Avoid the pipette shutdown delay after the pulse, since that delay + // depends on the guest timer state the test just restored. + vm.send_enlightened_shutdown(petri::ShutdownKind::Shutdown) + .await?; vm.wait_for_clean_teardown().await?; Ok(()) } From 415eed04b9b6f846cf24f136fccb568c8998453e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 02:29:18 -0700 Subject: [PATCH 072/304] virtio: forward input quiesce and resume through transports A snapshot of a running VM first gates host input, so that no new external input reaches a device while its state is captured, and resumes the input if the save transaction fails. `ChangeDeviceState` provides `quiesce_input` and `resume_input` for this, but the virtio transports did not implement them, so a virtio device could not take part. Add `quiesce_input` and `resume_input` to `VirtioDevice`, defaulting to success for devices without host input. The PCI and MMIO transports forward both through the device task as the new `QuiesceInput` and `ResumeInput` commands, so they are ordered with the device's other commands, and return the device's error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/virtio/virtio/src/device.rs | 24 +++++++++++++++++++ .../virtio/src/transport/core/restore.rs | 16 +++++++++++++ .../virtio/virtio/src/transport/mmio.rs | 8 +++++++ vm/devices/virtio/virtio/src/transport/pci.rs | 8 +++++++ .../virtio/virtio/src/transport/task.rs | 6 +++++ .../virtio/src/transport/task/restore.rs | 13 ++++++++++ 6 files changed, 75 insertions(+) diff --git a/vm/devices/virtio/virtio/src/device.rs b/vm/devices/virtio/virtio/src/device.rs index 1a0eb55a509..cd5860e5374 100644 --- a/vm/devices/virtio/virtio/src/device.rs +++ b/vm/devices/virtio/virtio/src/device.rs @@ -111,6 +111,16 @@ pub trait VirtioDevice: InspectMut + Send { async {} } + /// Stops accepting new host input while preserving device and queue state. + fn quiesce_input(&mut self) -> impl Future> + Send { + async { Ok(()) } + } + + /// Resumes host input after a failed save transaction. + fn resume_input(&mut self) -> impl Future> + Send { + async { Ok(()) } + } + /// Whether the device supports save/restore. /// /// Devices that return `false` will cause the transport's `save()` to @@ -189,6 +199,12 @@ pub trait DynVirtioDevice: InspectMut + Send { /// Reset device-internal state. fn reset(&mut self) -> Pin + Send + '_>>; + /// Stops accepting new host input while preserving device state. + fn quiesce_input(&mut self) -> Pin> + Send + '_>>; + + /// Resumes host input after a failed save transaction. + fn resume_input(&mut self) -> Pin> + Send + '_>>; + /// Whether the device supports save/restore. fn supports_save_restore(&self) -> bool; @@ -260,6 +276,14 @@ impl DynVirtioDevice for T { Box::pin(VirtioDevice::reset(self)) } + fn quiesce_input(&mut self) -> Pin> + Send + '_>> { + Box::pin(VirtioDevice::quiesce_input(self)) + } + + fn resume_input(&mut self) -> Pin> + Send + '_>> { + Box::pin(VirtioDevice::resume_input(self)) + } + fn supports_save_restore(&self) -> bool { VirtioDevice::supports_save_restore(self) } diff --git a/vm/devices/virtio/virtio/src/transport/core/restore.rs b/vm/devices/virtio/virtio/src/transport/core/restore.rs index 87ebf8b75c1..0e19c67669a 100644 --- a/vm/devices/virtio/virtio/src/transport/core/restore.rs +++ b/vm/devices/virtio/virtio/src/transport/core/restore.rs @@ -127,6 +127,22 @@ impl VirtioTransportCore { Ok(()) } + /// Stops device-specific host input before a snapshot boundary. + pub async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.device_sender + .call_failable(DeviceCommand::QuiesceInput, ()) + .await?; + Ok(()) + } + + /// Resumes device-specific host input after a failed snapshot. + pub async fn resume_input(&mut self) -> anyhow::Result<()> { + self.device_sender + .call_failable(DeviceCommand::ResumeInput, ()) + .await?; + Ok(()) + } + /// Take the device-private state captured when the queues stopped. pub fn take_device_state(&mut self) -> Result, SaveError> { self.restore diff --git a/vm/devices/virtio/virtio/src/transport/mmio.rs b/vm/devices/virtio/virtio/src/transport/mmio.rs index e6c1213bb6a..18fb58a3a9b 100644 --- a/vm/devices/virtio/virtio/src/transport/mmio.rs +++ b/vm/devices/virtio/virtio/src/transport/mmio.rs @@ -363,6 +363,14 @@ impl ChangeDeviceState for VirtioMmioDevice { self.core.start_fallible(&mut self.mmio).await } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.core.quiesce_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.core.resume_input().await + } + async fn stop(&mut self) { self.core.stop(&mut self.mmio).await; } diff --git a/vm/devices/virtio/virtio/src/transport/pci.rs b/vm/devices/virtio/virtio/src/transport/pci.rs index 94f71d568ae..da14ba8187b 100644 --- a/vm/devices/virtio/virtio/src/transport/pci.rs +++ b/vm/devices/virtio/virtio/src/transport/pci.rs @@ -776,6 +776,14 @@ impl ChangeDeviceState for VirtioPciDevice { self.core.start_fallible(&mut self.pci).await } + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.core.quiesce_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.core.resume_input().await + } + async fn stop(&mut self) { self.core.stop(&mut self.pci).await; } diff --git a/vm/devices/virtio/virtio/src/transport/task.rs b/vm/devices/virtio/virtio/src/transport/task.rs index 7d9c36efcd3..516a705294e 100644 --- a/vm/devices/virtio/virtio/src/transport/task.rs +++ b/vm/devices/virtio/virtio/src/transport/task.rs @@ -44,6 +44,10 @@ pub enum DeviceCommand { Start(FailableRpc), /// ChangeDeviceState::reset() — stop queues, reset device. Reset(Rpc<(), ()>), + /// Gate host input before establishing a snapshot vCPU boundary. + QuiesceInput(FailableRpc<(), ()>), + /// Resume host input after a failed snapshot transaction. + ResumeInput(FailableRpc<(), ()>), /// Config register read at byte offset with byte length. ReadConfig { offset: u16, @@ -308,6 +312,8 @@ pub async fn run_device_task( DeviceCommand::Reset(rpc) => { rpc.handle(async |()| task.reset().await).await; } + DeviceCommand::QuiesceInput(rpc) => task.quiesce_input(rpc).await, + DeviceCommand::ResumeInput(rpc) => task.resume_input(rpc).await, DeviceCommand::ReadConfig { offset, len, diff --git a/vm/devices/virtio/virtio/src/transport/task/restore.rs b/vm/devices/virtio/virtio/src/transport/task/restore.rs index b1c59df89eb..214bd46b3e1 100644 --- a/vm/devices/virtio/virtio/src/transport/task/restore.rs +++ b/vm/devices/virtio/virtio/src/transport/task/restore.rs @@ -15,6 +15,7 @@ use super::EnableParams; use super::StartParams; use crate::queue::QueueState; use chipset_device::io::IoError; +use mesh::rpc::FailableRpc; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; use vmcore::save_restore::SavedStateBlob; @@ -214,4 +215,16 @@ impl DeviceTask { cmd => Some(cmd), } } + + /// Handles `DeviceCommand::QuiesceInput`. + pub(super) async fn quiesce_input(&mut self, rpc: FailableRpc<(), ()>) { + rpc.handle_failable(async |()| self.device.quiesce_input().await) + .await; + } + + /// Handles `DeviceCommand::ResumeInput`. + pub(super) async fn resume_input(&mut self, rpc: FailableRpc<(), ()>) { + rpc.handle_failable(async |()| self.device.resume_input().await) + .await; + } } From fd4ab02c589a1d011444d50a4634387f5f64a761 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 02:37:25 -0700 Subject: [PATCH 073/304] virtio_console: add attachment identities and reconnect and disconnect policies A virtio-console always discarded guest output while its host backend was disconnected, and a console resource could not describe how its host endpoint is attached or reconnected. A host that must not lose console output, or that reattaches endpoints after a restore, has no way to ask for that, and the resolver cannot reject an inconsistent endpoint description. Add `VirtioConsoleDisconnectPolicy` and an optional `VirtioConsoleAttachment` to `VirtioConsoleHandle`. With the `Retain` policy, the console keeps pending guest transmit descriptors while the backend is disconnected and forwards them after it reconnects; `Discard` keeps the current behavior and remains the default of `VirtioConsoleDevice::new`. The new `new_with_policy` constructor selects the policy, and the worker now holds its backend in a `ConsoleWorkerMode`. The attachment records a stable ID, the backend kind, the attachment mode, the endpoint identity, and a reconnect policy with its requirement and timeout. The resolver rejects an attachment whose identifiers are empty or oversized, whose mode and reconnect policy conflict, or whose requirement and timeout do not match the policy: a recreated listener or a discarding attachment is optional without a timeout, a reconnecting client is required with a bounded timeout, and an inherited attachment is required without a timeout. Disconnects are also handled consistently: after a read error or end-of-file, the worker completes the backend's disconnect before it waits for a new connection, and a backend write that accepts no bytes is treated as a disconnect instead of a panic. All existing console resources use `Discard` without an attachment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_entry/src/lib.rs | 8 ++- openvmm/openvmm_entry/src/ttrpc/mod.rs | 15 ++++- .../virtio/virtio_console/src/direct.rs | 49 ++++++++++++++- vm/devices/virtio/virtio_console/src/lib.rs | 32 ++++++---- .../virtio/virtio_console/src/resolver.rs | 9 ++- .../virtio_console/src/resolver/attachment.rs | 61 +++++++++++++++++++ .../src/console/attachment.rs | 53 ++++++++++++++++ vm/devices/virtio/virtio_resources/src/lib.rs | 4 ++ 8 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 vm/devices/virtio/virtio_console/src/resolver/attachment.rs create mode 100644 vm/devices/virtio/virtio_resources/src/console/attachment.rs diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index f2f615bdfa8..58c90382347 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -1968,7 +1968,13 @@ async fn vm_config_from_command_line( if let Some(backend) = virtio_console_backend { let resource: Resource = - virtio_resources::console::VirtioConsoleHandle { backend }.into_resource(); + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy: + virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy::Discard, + attachment: None, + } + .into_resource(); if let Some(pcie_port) = &opt.virtio_console_pcie_port { pcie_devices.push(PcieDeviceConfig { port_name: pcie_port.clone(), diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 6327f931fa0..6375eab9609 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -98,6 +98,7 @@ use storvsp_resources::ScsiControllerRequest; use storvsp_resources::ScsiDeviceAndPath; use unix_socket::UnixListener; use virtio_resources::VirtioPciDeviceHandle; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; use vm_manifest_builder::VmManifestBuilder; use vm_resource::IntoResource; use vm_resource::Resource; @@ -1257,7 +1258,12 @@ impl VmService { ) })?; let resource: Resource = - virtio_resources::console::VirtioConsoleHandle { backend }.into_resource(); + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy: VirtioConsoleDisconnectPolicy::Discard, + attachment: None, + } + .into_resource(); if cfg!(windows) || cfg!(target_os = "macos") { config.vpci_devices.push(VpciDeviceConfig { vtl: DeviceVtl::Vtl0, @@ -2351,7 +2357,12 @@ async fn build_virtio_device( } Kind::Console(vmservice::VirtioConsole { backend }) => { let backend = build_serial_backend(backend.context("missing console backend")?)?; - virtio_resources::console::VirtioConsoleHandle { backend }.into_resource() + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy: VirtioConsoleDisconnectPolicy::Discard, + attachment: None, + } + .into_resource() } Kind::VhostUser(vhost_user) => build_vhost_user_device(vhost_user)?, Kind::Fs(config) => build_virtio_fs(config)?.into_resource(), diff --git a/vm/devices/virtio/virtio_console/src/direct.rs b/vm/devices/virtio/virtio_console/src/direct.rs index 3d3a55b2832..d3725f925ec 100644 --- a/vm/devices/virtio/virtio_console/src/direct.rs +++ b/vm/devices/virtio/virtio_console/src/direct.rs @@ -1,19 +1,62 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! The direct forwarding mode of the console worker. +//! Console worker modes and the direct forwarding mode. //! -//! In direct mode the worker forwards data between the virtio queues and a -//! [`SerialIo`] backend. +//! The worker runs in a [`ConsoleWorkerMode`]: direct forwarding between the +//! virtio queues and a [`SerialIo`] backend. In direct mode a +//! [`VirtioConsoleDisconnectPolicy`] selects whether guest output is discarded +//! or retained while the backend is disconnected. use crate::BUF_SIZE; +use crate::ConsoleWorker; +use crate::VirtioConsoleDevice; use crate::WorkerError; +use crate::spec::VirtioConsoleConfig; use futures::AsyncRead; use guestmem::GuestMemory; +use inspect::InspectMut; use serial_core::SerialIo; use std::future::poll_fn; use std::pin::Pin; +use task_control::TaskControl; use virtio::VirtioQueue; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vmcore::vm_task::VmTaskDriverSource; + +pub(crate) enum ConsoleWorkerMode { + Direct { + io: Box, + disconnect_policy: VirtioConsoleDisconnectPolicy, + }, +} + +impl InspectMut for ConsoleWorker { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + let ConsoleWorkerMode::Direct { io, .. } = &mut self.mode; + req.respond().field("mode", "direct").field_mut("io", io); + } +} + +impl VirtioConsoleDevice { + /// Create a console with explicit behavior while its backend is disconnected. + pub fn new_with_policy( + driver_source: &VmTaskDriverSource, + io: Box, + disconnect_policy: VirtioConsoleDisconnectPolicy, + ) -> Self { + Self { + driver: driver_source.simple(), + config: VirtioConsoleConfig::default(), + worker: TaskControl::new(ConsoleWorker { + mode: ConsoleWorkerMode::Direct { + io, + disconnect_policy, + }, + }), + } + } +} /// Receive half of the direct forwarding loop. /// diff --git a/vm/devices/virtio/virtio_console/src/lib.rs b/vm/devices/virtio/virtio_console/src/lib.rs index 49245360a08..42a85a54142 100644 --- a/vm/devices/virtio/virtio_console/src/lib.rs +++ b/vm/devices/virtio/virtio_console/src/lib.rs @@ -29,6 +29,8 @@ //! `Ok(0)`), the worker drains any pending guest TX descriptors without //! forwarding them. Once `poll_connect` resolves, normal bidirectional //! forwarding resumes. +//! [`VirtioConsoleDevice::new_with_policy`] can instead retain pending guest +//! TX descriptors until the backend reconnects. #![forbid(unsafe_code)] @@ -59,6 +61,7 @@ use virtio::VirtioDevice; use virtio::VirtioQueue; use virtio::queue::QueueState; use virtio::spec::VirtioDeviceFeatures; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; use vmcore::vm_task::VmTaskDriver; use vmcore::vm_task::VmTaskDriverSource; @@ -74,11 +77,7 @@ pub struct VirtioConsoleDevice { impl VirtioConsoleDevice { /// Create a new virtio console device backed by the given serial I/O. pub fn new(driver_source: &VmTaskDriverSource, io: Box) -> Self { - Self { - driver: driver_source.simple(), - config: VirtioConsoleConfig::default(), - worker: TaskControl::new(ConsoleWorker { io }), - } + Self::new_with_policy(driver_source, io, VirtioConsoleDisconnectPolicy::Discard) } } @@ -190,10 +189,8 @@ impl VirtioDevice for VirtioConsoleDevice { async fn reset(&mut self) {} } -#[derive(InspectMut)] struct ConsoleWorker { - #[inspect(mut)] - io: Box, + mode: direct::ConsoleWorkerMode, } #[derive(InspectMut)] @@ -249,10 +246,15 @@ impl ConsoleWorker { /// So, be careful not to leave any state in a weird intermediate state across /// an await point. async fn run_loop(&mut self, state: &mut ConsoleWorkerState) -> Result<(), WorkerError> { - let mut connected: bool = self.io.is_connected(); + let direct::ConsoleWorkerMode::Direct { + io: serial_io, + disconnect_policy, + } = &mut self.mode; + let disconnect_policy = *disconnect_policy; + let mut connected: bool = serial_io.is_connected(); let receiveq = &mut state.receiveq; let transmitq = &mut state.transmitq; - let mut io = parking_lot::Mutex::new(&mut self.io); + let mut io = parking_lot::Mutex::new(serial_io); let mem = &state.mem; let partial_transmit = &mut state.partial_transmit; @@ -262,6 +264,9 @@ impl ConsoleWorker { } loop { if !connected { + poll_fn(|cx| io.get_mut().poll_disconnect(cx)) + .await + .map_err(WorkerError::Serial)?; // Wait for the backend to connect, discarding any guest tx data // in the meantime. let wait_connect = async { @@ -271,6 +276,9 @@ impl ConsoleWorker { Ok::<_, WorkerError>(true) }; let drain_tx = async { + if disconnect_policy == VirtioConsoleDisconnectPolicy::Retain { + return std::future::pending().await; + } let Some(transmitq) = transmitq.as_mut() else { std::future::pending().await }; @@ -311,8 +319,10 @@ impl ConsoleWorker { }) .await { + Ok(0) => { + break 'tx Ok(false); + } Ok(written) => { - assert!(written > 0); written_this_chunk += written; *partial_transmit += written; } diff --git a/vm/devices/virtio/virtio_console/src/resolver.rs b/vm/devices/virtio/virtio_console/src/resolver.rs index 69531965a50..9218edfdb40 100644 --- a/vm/devices/virtio/virtio_console/src/resolver.rs +++ b/vm/devices/virtio/virtio_console/src/resolver.rs @@ -3,6 +3,8 @@ //! Resource resolver for virtio-console devices. +mod attachment; + use crate::VirtioConsoleDevice; use async_trait::async_trait; use serial_core::resources::ResolveSerialBackendParams; @@ -33,6 +35,7 @@ impl AsyncResolveResource for VirtioCon resource: VirtioConsoleHandle, input: VirtioResolveInput<'_>, ) -> Result { + attachment::validate_attachment(resource.attachment.as_ref())?; let io = resolver .resolve( resource.backend, @@ -43,7 +46,11 @@ impl AsyncResolveResource for VirtioCon ) .await?; - let device = VirtioConsoleDevice::new(input.driver_source, io.0.into_io()); + let device = VirtioConsoleDevice::new_with_policy( + input.driver_source, + io.0.into_io(), + resource.disconnect_policy, + ); Ok(device.into()) } diff --git a/vm/devices/virtio/virtio_console/src/resolver/attachment.rs b/vm/devices/virtio/virtio_console/src/resolver/attachment.rs new file mode 100644 index 00000000000..7e0c725dbb8 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/resolver/attachment.rs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Validation of the host endpoint attachment carried by console resources. + +use virtio_resources::console::attachment::VirtioConsoleAttachment; +use virtio_resources::console::attachment::VirtioConsoleAttachmentMode; +use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; + +pub(super) fn validate_attachment( + attachment: Option<&VirtioConsoleAttachment>, +) -> anyhow::Result<()> { + if let Some(attachment) = attachment { + anyhow::ensure!( + !attachment.stable_id.is_empty() && attachment.stable_id.len() <= 128, + "virtio-console attachment has an invalid stable ID" + ); + anyhow::ensure!( + !attachment.endpoint_identity.is_empty() && attachment.endpoint_identity.len() <= 4096, + "virtio-console attachment has an invalid endpoint identity" + ); + anyhow::ensure!( + matches!( + (attachment.mode, attachment.reconnect_policy), + ( + VirtioConsoleAttachmentMode::Listen, + VirtioConsoleReconnectPolicy::RecreateListener + ) | ( + VirtioConsoleAttachmentMode::Connect, + VirtioConsoleReconnectPolicy::ReconnectClient + ) | ( + VirtioConsoleAttachmentMode::Inherited, + VirtioConsoleReconnectPolicy::RequireInheritedAttachment + ) | ( + VirtioConsoleAttachmentMode::Inherited, + VirtioConsoleReconnectPolicy::DiscardWhileDisconnected + ) + ), + "virtio-console attachment mode and reconnect policy conflict" + ); + match attachment.reconnect_policy { + VirtioConsoleReconnectPolicy::RecreateListener => anyhow::ensure!( + !attachment.required && attachment.reconnect_timeout_ms == 0, + "listener console attachments must be optional and have no reconnect timeout" + ), + VirtioConsoleReconnectPolicy::ReconnectClient => anyhow::ensure!( + attachment.required && attachment.reconnect_timeout_ms != 0, + "client console attachments must be required with a bounded timeout" + ), + VirtioConsoleReconnectPolicy::RequireInheritedAttachment => anyhow::ensure!( + attachment.required && attachment.reconnect_timeout_ms == 0, + "inherited console attachments must be required with no reconnect timeout" + ), + VirtioConsoleReconnectPolicy::DiscardWhileDisconnected => anyhow::ensure!( + !attachment.required && attachment.reconnect_timeout_ms == 0, + "discarding console attachments must be optional with no reconnect timeout" + ), + } + } + Ok(()) +} diff --git a/vm/devices/virtio/virtio_resources/src/console/attachment.rs b/vm/devices/virtio/virtio_resources/src/console/attachment.rs new file mode 100644 index 00000000000..a21452734e1 --- /dev/null +++ b/vm/devices/virtio/virtio_resources/src/console/attachment.rs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host endpoint attachment and disconnect policy of virtio-console resources. +//! +//! [`VirtioConsoleAttachment`] describes how a console's host endpoint is +//! attached and reconnected, and [`VirtioConsoleDisconnectPolicy`] selects what +//! the device does with guest output while that endpoint is disconnected. + +use mesh::MeshPayload; + +#[derive(Copy, Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum VirtioConsoleDisconnectPolicy { + /// Complete and discard guest transmit descriptors while disconnected. + Discard, + /// Retain guest transmit descriptors until the backend reconnects. + Retain, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum VirtioConsoleBackendKind { + UnixSocket, + NamedPipe, + Tcp, + Inherited, + Disconnected, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum VirtioConsoleAttachmentMode { + Listen, + Connect, + Inherited, +} + +#[derive(Copy, Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum VirtioConsoleReconnectPolicy { + RecreateListener, + ReconnectClient, + RequireInheritedAttachment, + DiscardWhileDisconnected, +} + +#[derive(Clone, Debug, Eq, PartialEq, MeshPayload)] +pub struct VirtioConsoleAttachment { + pub stable_id: String, + pub backend_kind: VirtioConsoleBackendKind, + pub mode: VirtioConsoleAttachmentMode, + pub endpoint_identity: String, + pub reconnect_policy: VirtioConsoleReconnectPolicy, + pub required: bool, + pub reconnect_timeout_ms: u64, +} diff --git a/vm/devices/virtio/virtio_resources/src/lib.rs b/vm/devices/virtio/virtio_resources/src/lib.rs index 689c1b57f1f..93eb6b7ac6c 100644 --- a/vm/devices/virtio/virtio_resources/src/lib.rs +++ b/vm/devices/virtio/virtio_resources/src/lib.rs @@ -151,6 +151,8 @@ pub mod net { } pub mod console { + pub mod attachment; + use mesh::MeshPayload; use vm_resource::Resource; use vm_resource::ResourceId; @@ -160,6 +162,8 @@ pub mod console { #[derive(MeshPayload)] pub struct VirtioConsoleHandle { pub backend: Resource, + pub disconnect_policy: attachment::VirtioConsoleDisconnectPolicy, + pub attachment: Option, } impl ResourceId for VirtioConsoleHandle { From 65442cd4ef1f35dd5ce8c82203f0eb872bd6f55f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 02:43:52 -0700 Subject: [PATCH 074/304] virtio_console: persist buffered direct-console I/O A virtio-console could not be saved: host input already read from the backend lived only in a local buffer of the receive loop, the worker state was removed once both queues stopped, and the progress within the current transmit descriptor was lost with it. Restoring a console would therefore drop accepted input or replay output. Implement device-private save and restore for the direct console mode. The worker state is now created with the device and kept while the queues are stopped, so a stopped device still holds its private state. Host input is staged in the worker state before it is copied to a guest receive buffer, so input accepted from the backend survives worker restarts and snapshots; the receive loop also reads the backend before the guest posts a buffer, completing zero-length buffers as it goes. The saved state, schema version 1, records the console size, the bytes already written from the current transmit descriptor, the staged input, and the disconnect policy. Restore validates the encoded size, the schema version, the policy, and the bounds of every field before decoding further, and requires a restored transmit offset to lie within the front descriptor of the enabled transmit queue, both before the queues restart and when the transmit queue starts. Reset clears the private state. The console also implements `quiesce_input` and `resume_input`: while input is gated, the worker reads no host input, and output continues. `SavedStateBlob::encoded_len` returns the encoded payload length without decoding it, which the console uses to bound its private state. Document that virtio-console now supports save and restore through the transport's device-private payload. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 2 + Guide/src/dev_guide/snapshot_format.md | 7 +- Guide/src/user_guide/openvmm/snapshots.md | 2 +- vm/devices/virtio/virtio_console/Cargo.toml | 2 + .../virtio/virtio_console/src/direct.rs | 142 +++++--- vm/devices/virtio/virtio_console/src/lib.rs | 93 +++--- .../virtio/virtio_console/src/saved_state.rs | 265 +++++++++++++++ vm/devices/virtio/virtio_console/src/tests.rs | 21 +- .../virtio/virtio_console/src/tests/direct.rs | 54 ++++ .../virtio_console/src/tests/harness.rs | 185 +++++++++++ .../virtio_console/src/tests/saved_state.rs | 304 ++++++++++++++++++ vm/vmcore/src/save_restore.rs | 5 + 12 files changed, 998 insertions(+), 84 deletions(-) create mode 100644 vm/devices/virtio/virtio_console/src/saved_state.rs create mode 100644 vm/devices/virtio/virtio_console/src/tests/direct.rs create mode 100644 vm/devices/virtio/virtio_console/src/tests/harness.rs create mode 100644 vm/devices/virtio/virtio_console/src/tests/saved_state.rs diff --git a/Cargo.lock b/Cargo.lock index 9d5bec67a5d..4d004e46c6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10068,10 +10068,12 @@ version = "0.0.0" dependencies = [ "anyhow", "async-trait", + "chipset_device", "futures", "futures-concurrency", "guestmem", "inspect", + "mesh", "pal_async", "pal_event", "parking_lot", diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index c0242b5e328..77381aafa9d 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -145,10 +145,11 @@ Key unsupported categories: - **Pass-through PCI** — `AssignedPciDevice`, `RelayedVpciDevice`. - **VGA / GDMA** — marked `todo!()` (will panic on save). - **Virtio devices** — the `VirtioDevice` trait defaults - `supports_save_restore()` to `false`. Only `virtio-blk`, + `supports_save_restore()` to `false`. `virtio-blk`, `virtio-console`, `virtio-net`, `virtio-pmem`, and `virtio-rng` override it to `true`. - Devices with host-side session state (`virtio-9p`, `virtiofs`, - `virtio-console`) intentionally leave it `false`. + The transport stores an opaque typed device-private payload in addition to + common queue state. Devices with unsupported host-side session state + (`virtio-9p`, `virtiofs`) leave save/restore disabled. - **Some VMBus devices** — `GuestCrashDevice`, `GuestEmulationDevice`, `VmbusSerialHost`, `Vmbfs` return `None` from `supports_save_restore()`. diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index e20c0f4c6d8..29cf031483e 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -179,6 +179,7 @@ OpenVMM snapshots: | virtio-net | Virtio (PCI/MMIO) | Yes | | virtio-pmem | Virtio (PCI/MMIO) | Yes | | virtio-rng | Virtio (PCI/MMIO) | Yes | +| virtio-console | Virtio (PCI/MMIO) | Yes | | NVMe | PCI | **No** | | VGA | PCI | **No** (`todo!()`) | | GDMA (MANA network) | PCI | **No** (`todo!()`) | @@ -187,7 +188,6 @@ OpenVMM snapshots: | Relayed vPCI | PCI | **No** | | PCAT BIOS firmware | Chipset (ISA) | **No** (see limitations) | | virtio-9p, virtiofs | Virtio (PCI/MMIO) | **No** | -| virtio-console | Virtio (PCI/MMIO) | **No** | | Guest Crash Device | VMBus | **No** | | Guest Emulation Device (GED) | VMBus | **No** | | VMBus serial (host) | VMBus | **No** | diff --git a/vm/devices/virtio/virtio_console/Cargo.toml b/vm/devices/virtio/virtio_console/Cargo.toml index 33eea8ad7e8..5797763a2aa 100644 --- a/vm/devices/virtio/virtio_console/Cargo.toml +++ b/vm/devices/virtio/virtio_console/Cargo.toml @@ -12,6 +12,7 @@ virtio_resources.workspace = true inspect.workspace = true guestmem.workspace = true +mesh.workspace = true vmcore.workspace = true vm_resource.workspace = true serial_core.workspace = true @@ -27,6 +28,7 @@ thiserror.workspace = true tracelimit.workspace = true [dev-dependencies] +chipset_device.workspace = true futures.workspace = true pal_async.workspace = true pal_event.workspace = true diff --git a/vm/devices/virtio/virtio_console/src/direct.rs b/vm/devices/virtio/virtio_console/src/direct.rs index d3725f925ec..c116efa7874 100644 --- a/vm/devices/virtio/virtio_console/src/direct.rs +++ b/vm/devices/virtio/virtio_console/src/direct.rs @@ -6,10 +6,13 @@ //! The worker runs in a [`ConsoleWorkerMode`]: direct forwarding between the //! virtio queues and a [`SerialIo`] backend. In direct mode a //! [`VirtioConsoleDisconnectPolicy`] selects whether guest output is discarded -//! or retained while the backend is disconnected. +//! or retained while the backend is disconnected, and host input is staged in +//! the worker state before it is copied to a guest buffer, so input already +//! accepted from the backend survives worker restarts and snapshots. use crate::BUF_SIZE; use crate::ConsoleWorker; +use crate::ConsoleWorkerState; use crate::VirtioConsoleDevice; use crate::WorkerError; use crate::spec::VirtioConsoleConfig; @@ -17,8 +20,10 @@ use futures::AsyncRead; use guestmem::GuestMemory; use inspect::InspectMut; use serial_core::SerialIo; +use std::collections::VecDeque; use std::future::poll_fn; use std::pin::Pin; +use std::task::Poll; use task_control::TaskControl; use virtio::VirtioQueue; use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; @@ -45,32 +50,109 @@ impl VirtioConsoleDevice { io: Box, disconnect_policy: VirtioConsoleDisconnectPolicy, ) -> Self { + Self::new_with_name_and_policy(driver_source, io, "virtio-console", disconnect_policy) + } + + /// Create a named console with explicit disconnected-backend behavior. + pub fn new_with_name_and_policy( + driver_source: &VmTaskDriverSource, + io: Box, + worker_name: &'static str, + disconnect_policy: VirtioConsoleDisconnectPolicy, + ) -> Self { + let driver = driver_source.simple(); + let mut worker = TaskControl::new(ConsoleWorker { + mode: ConsoleWorkerMode::Direct { + io, + disconnect_policy, + }, + }); + worker.insert( + &driver, + worker_name, + ConsoleWorkerState { + receiveq: None, + transmitq: None, + mem: GuestMemory::empty(), + partial_transmit: 0, + staged_rx: VecDeque::new(), + input_gated: false, + }, + ); Self { - driver: driver_source.simple(), + driver, config: VirtioConsoleConfig::default(), - worker: TaskControl::new(ConsoleWorker { - mode: ConsoleWorkerMode::Direct { - io, - disconnect_policy, - }, - }), + worker, } } } /// Receive half of the direct forwarding loop. /// -/// Copies host input from the backend to guest receive buffers. Returns -/// `Ok(false)` when the backend disconnects. +/// Reads host input into `staged_rx` before copying it to guest receive +/// buffers, so that accepted input survives a cancel and restart of the worker. +/// Returns `Ok(false)` when the backend disconnects. While `input_gated` is +/// set, no host input is read. pub(crate) async fn receive( receiveq: &mut Option, io: &parking_lot::Mutex<&mut Box>, mem: &GuestMemory, + staged_rx: &mut VecDeque, + input_gated: bool, ) -> Result { - let Some(receiveq) = receiveq.as_mut() else { - std::future::pending().await - }; + if input_gated { + return std::future::pending().await; + } 'rx: loop { + if staged_rx.is_empty() { + let mut buf = [0u8; BUF_SIZE]; + let read = poll_fn(|cx| { + if let Some(receiveq) = receiveq.as_mut() { + loop { + match receiveq.try_peek() { + Ok(Some(work)) => { + let writeable_len = work + .payload() + .iter() + .filter(|payload| payload.writeable) + .map(|payload| payload.length as usize) + .sum::(); + if writeable_len != 0 { + break; + } + let work = work.consume(); + receiveq.complete(work, 0); + } + Ok(None) => { + let _ = receiveq.poll_kick(cx); + break; + } + Err(error) => { + return Poll::Ready(Err(WorkerError::Virtio(error))); + } + } + } + } + + Pin::new(&mut **io.lock()) + .poll_read(cx, &mut buf) + .map(|result| result.map_err(WorkerError::Serial)) + }) + .await; + let read = match read { + Ok(read) => read, + Err(WorkerError::Serial(_)) => break 'rx Ok(false), + Err(error) => return Err(error), + }; + if read == 0 { + break 'rx Ok(false); + } + staged_rx.extend(&buf[..read]); + } + + let Some(receiveq) = receiveq.as_mut() else { + std::future::pending().await + }; let work = receiveq.peek().await.map_err(WorkerError::Virtio)?; let writeable_len = work .payload() @@ -86,29 +168,17 @@ pub(crate) async fn receive( receiveq.complete(work, 0); continue 'rx; } - let n = BUF_SIZE.min(writeable_len); - let mut buf = [0u8; BUF_SIZE]; - match poll_fn(|cx| Pin::new(&mut **io.lock()).poll_read(cx, &mut buf[..n])).await { - Ok(0) => { - // Backend disconnected. - break 'rx Ok(false); - } - Ok(n) => { - let work = work.consume(); - if let Err(err) = work.write(mem, &buf[..n]) { - tracelimit::error_ratelimited!( - error = &err as &dyn std::error::Error, - "failed to write to guest receive buffer" - ); - receiveq.complete(work, 0); - } else { - receiveq.complete(work, n as u32); - } - } - Err(_) => { - // Disconnect on error, like other serial impls. - break 'rx Ok(false); - } + let n = staged_rx.len().min(writeable_len); + let work = work.consume(); + if let Err(err) = work.write(mem, &staged_rx.make_contiguous()[..n]) { + tracelimit::error_ratelimited!( + error = &err as &dyn std::error::Error, + "failed to write to guest receive buffer" + ); + receiveq.complete(work, 0); + } else { + staged_rx.drain(..n); + receiveq.complete(work, n as u32); } } } diff --git a/vm/devices/virtio/virtio_console/src/lib.rs b/vm/devices/virtio/virtio_console/src/lib.rs index 42a85a54142..59c330372ab 100644 --- a/vm/devices/virtio/virtio_console/src/lib.rs +++ b/vm/devices/virtio/virtio_console/src/lib.rs @@ -36,6 +36,7 @@ mod direct; pub mod resolver; +mod saved_state; mod spec; #[cfg(test)] mod tests; @@ -62,6 +63,9 @@ use virtio::VirtioQueue; use virtio::queue::QueueState; use virtio::spec::VirtioDeviceFeatures; use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; use vmcore::vm_task::VmTaskDriver; use vmcore::vm_task::VmTaskDriverSource; @@ -113,7 +117,7 @@ impl VirtioDevice for VirtioConsoleDevice { initial_state: Option, ) -> anyhow::Result<()> { let guest_memory = resources.guest_memory.clone(); - let queue = VirtioQueue::new( + let mut queue = VirtioQueue::new( *features, resources.params, resources.guest_memory, @@ -122,40 +126,18 @@ impl VirtioDevice for VirtioConsoleDevice { initial_state, )?; - assert!(idx < 2); - - if self.worker.has_state() { - // Worker is already running with the other queue — inject this one. - // update_with cancels the current run iteration, applies the - // closure, then the worker restarts. - self.worker.update_with(move |_worker, state| { - if let Some(state) = state { - if idx == 0 { - state.receiveq = Some(queue); - } else { - state.transmitq = Some(queue); - } - } - }); + anyhow::ensure!(idx < 2, "invalid virtio-console queue index {idx}"); + + self.worker.stop().await; + let state = self.worker.state_mut().unwrap(); + saved_state::check_restored_tx_offset(idx, state.partial_transmit, &mut queue)?; + state.mem = guest_memory; + if idx == 0 { + state.receiveq = Some(queue); } else { - // First queue to start — create the worker state. - let (receiveq, transmitq) = if idx == 0 { - (Some(queue), None) - } else { - (None, Some(queue)) - }; - self.worker.insert( - &self.driver, - "virtio-console", - ConsoleWorkerState { - receiveq, - transmitq, - mem: guest_memory, - partial_transmit: 0, - }, - ); - self.worker.start(); + state.transmitq = Some(queue); } + self.worker.start(); Ok(()) } @@ -172,21 +154,45 @@ impl VirtioDevice for VirtioConsoleDevice { let queue = match idx { 0 => state.receiveq.take(), 1 => state.transmitq.take(), - _ => unreachable!(), + _ => return None, }; - // If both queues have been taken, remove the worker state entirely. - // Otherwise, restart the worker so the remaining queue stays active. - if state.receiveq.is_none() && state.transmitq.is_none() { - self.worker.remove(); - } else { + // Keep the stopped worker state when both queues are gone so private + // TX/RX progress remains available to snapshot capture. + if state.receiveq.is_some() || state.transmitq.is_some() { self.worker.start(); } queue.map(|q| q.queue_state()) } - async fn reset(&mut self) {} + async fn reset(&mut self) { + self.reset_private_state(); + } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.set_input_gated(true).await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.set_input_gated(false).await + } + + fn supports_save_restore(&self) -> bool { + true + } + + fn save_device(&mut self) -> Result, SaveError> { + self.save_private_state() + } + + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + self.restore_private_state(state) + } + + fn device_state_validator(&self) -> virtio::device::saved_state::DeviceStateValidator { + self.private_state_validator() + } } struct ConsoleWorker { @@ -201,6 +207,9 @@ struct ConsoleWorkerState { /// Bytes already written for the current transmitq descriptor. /// Must survive cancel/restart to avoid re-sending data. partial_transmit: usize, + #[inspect(with = "std::collections::VecDeque::len")] + staged_rx: std::collections::VecDeque, + input_gated: bool, } impl InspectTaskMut for ConsoleWorker { @@ -257,6 +266,8 @@ impl ConsoleWorker { let mut io = parking_lot::Mutex::new(serial_io); let mem = &state.mem; let partial_transmit = &mut state.partial_transmit; + let staged_rx = &mut state.staged_rx; + let input_gated = state.input_gated; // If neither queue is present, there's nothing to do. if receiveq.is_none() && transmitq.is_none() { @@ -298,7 +309,7 @@ impl ConsoleWorker { | futures::future::Either::Right((result, _)) => result?, }; } else { - let rx = direct::receive(receiveq, &io, mem); + let rx = direct::receive(receiveq, &io, mem, staged_rx, input_gated); let tx = async { let Some(transmitq) = transmitq.as_mut() else { std::future::pending().await diff --git a/vm/devices/virtio/virtio_console/src/saved_state.rs b/vm/devices/virtio/virtio_console/src/saved_state.rs new file mode 100644 index 00000000000..b93a1596e42 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/saved_state.rs @@ -0,0 +1,265 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Save and restore of the device-private virtio-console state. +//! +//! The worker state outlives queue restarts so that the TX progress of the +//! current descriptor and staged host input can be captured once both queues +//! are stopped. This module defines the versioned +//! saved-state format ([`SavedState`]), validates it before restore, and +//! implements save, restore, input quiescing, and reset of that state. + +use crate::BUF_SIZE; +use crate::VirtioConsoleDevice; +use crate::direct::ConsoleWorkerMode; +use crate::spec::VirtioConsoleConfig; +use guestmem::GuestMemory; +use mesh::payload::Protobuf; +use virtio::VirtioQueue; +use virtio::device::saved_state::DeviceQueueState; +use virtio::device::saved_state::DeviceStateValidator; +use virtio::device::saved_state::restored_queue_front_readable_length; +use virtio::spec::VirtioDeviceFeatures; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; +use vmcore::save_restore::SavedStateRoot; + +/// Maximum accepted, not-yet-delivered host input. The direct worker stages at +/// most one [`BUF_SIZE`] read. +pub(crate) const MAX_STAGED_RX_BYTES: usize = BUF_SIZE; +pub(crate) const MAX_SAVED_STATE_BYTES: usize = 512 * 1024; +pub(crate) const DIRECT_SAVED_STATE_VERSION: u32 = 1; +#[cfg(test)] +pub(crate) const SAVED_STATE_VERSION: u32 = DIRECT_SAVED_STATE_VERSION; + +impl VirtioConsoleDevice { + /// Resets the private state that persists across queue restarts. + pub(crate) fn reset_private_state(&mut self) { + self.config = VirtioConsoleConfig::default(); + let (_, mut state) = self.worker.get_mut(); + let state = state.as_mut().unwrap(); + state.partial_transmit = 0; + state.staged_rx.clear(); + state.input_gated = false; + state.mem = GuestMemory::empty(); + } + + /// Stops or resumes accepting new host input, preserving device and queue + /// state. + pub(crate) async fn set_input_gated(&mut self, input_gated: bool) -> anyhow::Result<()> { + self.worker.stop().await; + let state = self.worker.state_mut().unwrap(); + state.input_gated = input_gated; + if state.receiveq.is_some() || state.transmitq.is_some() { + self.worker.start(); + } + Ok(()) + } + + pub(crate) fn save_private_state(&mut self) -> Result, SaveError> { + let (worker, state) = self.worker.get(); + let state = state.as_ref().unwrap(); + if state.receiveq.is_some() || state.transmitq.is_some() { + return Err(SaveError::Other(anyhow::anyhow!( + "virtio-console queues are still running" + ))); + } + if state.staged_rx.len() > MAX_STAGED_RX_BYTES { + return Err(SaveError::InvalidChildSavedState(anyhow::anyhow!( + "virtio-console staged RX exceeds its ABI bound" + ))); + } + let ConsoleWorkerMode::Direct { + disconnect_policy, .. + } = &worker.mode; + Ok(Some(SavedStateBlob::new(SavedState { + schema_version: DIRECT_SAVED_STATE_VERSION, + columns: self.config.cols.into(), + rows: self.config.rows.into(), + partial_transmit: state.partial_transmit as u64, + staged_rx: state.staged_rx.iter().copied().collect(), + disconnect_policy_id: disconnect_policy_id(*disconnect_policy), + }))) + } + + pub(crate) fn restore_private_state( + &mut self, + state: Option, + ) -> Result<(), RestoreError> { + let (worker, runtime) = self.worker.get_mut(); + let saved = validate_saved_state(state.as_ref(), worker.mode.validation_mode())?; + let runtime = runtime.ok_or_else(|| { + RestoreError::Other(anyhow::anyhow!( + "virtio-console worker state is unavailable" + )) + })?; + if runtime.receiveq.is_some() || runtime.transmitq.is_some() { + return Err(RestoreError::Other(anyhow::anyhow!( + "cannot restore a running virtio-console" + ))); + } + let columns = u16::try_from(saved.columns) + .map_err(|_| invalid_saved_state("console column count is out of range"))?; + let rows = u16::try_from(saved.rows) + .map_err(|_| invalid_saved_state("console row count is out of range"))?; + let partial_transmit = usize::try_from(saved.partial_transmit) + .map_err(|_| invalid_saved_state("console TX offset is out of range"))?; + + self.config = VirtioConsoleConfig { + cols: columns, + rows, + }; + runtime.partial_transmit = partial_transmit; + runtime.staged_rx = saved.staged_rx.into(); + Ok(()) + } + + pub(crate) fn private_state_validator(&self) -> DeviceStateValidator { + let mode = self.worker.get().0.mode.validation_mode(); + Box::new(move |state, features, queues, guest_memory| { + let saved = validate_saved_state(state, mode)?; + validate_saved_tx_offset(saved.partial_transmit, *features, queues, guest_memory)?; + Ok(()) + }) + } +} + +/// Checks a restored TX offset against the current descriptor when the +/// transmit queue (`idx` 1) is started. +pub(crate) fn check_restored_tx_offset( + idx: u16, + partial_transmit: usize, + queue: &mut VirtioQueue, +) -> anyhow::Result<()> { + if idx == 1 && partial_transmit != 0 { + let work = queue + .try_peek() + .map_err(|error| anyhow::anyhow!(error).context("invalid restored TX queue"))? + .ok_or_else(|| anyhow::anyhow!("restored TX offset has no current descriptor"))?; + anyhow::ensure!( + partial_transmit <= work.readable_length() as usize, + "restored TX offset {} exceeds descriptor length {}", + partial_transmit, + work.readable_length() + ); + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum SavedStateMode { + Direct(VirtioConsoleDisconnectPolicy), +} + +impl ConsoleWorkerMode { + fn validation_mode(&self) -> SavedStateMode { + let Self::Direct { + disconnect_policy, .. + } = self; + SavedStateMode::Direct(*disconnect_policy) + } +} + +fn disconnect_policy_id(policy: VirtioConsoleDisconnectPolicy) -> u32 { + match policy { + VirtioConsoleDisconnectPolicy::Discard => 0, + VirtioConsoleDisconnectPolicy::Retain => 1, + } +} + +fn invalid_saved_state(message: impl Into) -> RestoreError { + RestoreError::InvalidSavedState(anyhow::anyhow!(message.into())) +} + +fn validate_saved_state( + state: Option<&SavedStateBlob>, + mode: SavedStateMode, +) -> Result { + let state = state.ok_or_else(|| invalid_saved_state("missing console private state"))?; + if state.encoded_len() > MAX_SAVED_STATE_BYTES { + return Err(invalid_saved_state( + "console private state exceeds its encoded size bound", + )); + } + let saved: SavedState = state.parse()?; + let SavedStateMode::Direct(disconnect_policy) = mode; + if saved.schema_version != DIRECT_SAVED_STATE_VERSION { + return Err(invalid_saved_state(format!( + "direct console requires schema version {DIRECT_SAVED_STATE_VERSION}, got {}", + saved.schema_version + ))); + } + if saved.disconnect_policy_id != disconnect_policy_id(disconnect_policy) { + return Err(invalid_saved_state( + "console disconnect policy does not match the saved policy", + )); + } + u16::try_from(saved.columns) + .map_err(|_| invalid_saved_state("console column count is out of range"))?; + u16::try_from(saved.rows) + .map_err(|_| invalid_saved_state("console row count is out of range"))?; + usize::try_from(saved.partial_transmit) + .map_err(|_| invalid_saved_state("console TX offset is out of range"))?; + if saved.staged_rx.len() > MAX_STAGED_RX_BYTES { + return Err(invalid_saved_state( + "console staged RX exceeds its ABI bound", + )); + } + Ok(saved) +} + +fn validate_saved_tx_offset( + partial_transmit: u64, + features: VirtioDeviceFeatures, + queues: &[DeviceQueueState], + guest_memory: &GuestMemory, +) -> Result<(), RestoreError> { + if partial_transmit == 0 { + return Ok(()); + } + let transmitq = queues + .get(1) + .ok_or_else(|| invalid_saved_state("console saved state has no transmit queue"))?; + if !transmitq.params.enable { + return Err(invalid_saved_state( + "console TX offset requires an enabled transmit queue", + )); + } + let readable_length = restored_queue_front_readable_length( + features, + transmitq.params, + guest_memory.clone(), + transmitq.queue_state, + ) + .map_err(|error| { + RestoreError::InvalidSavedState( + anyhow::Error::new(error).context("console transmit queue is invalid"), + ) + })? + .ok_or_else(|| invalid_saved_state("console TX offset has no current descriptor"))?; + if partial_transmit > readable_length { + return Err(invalid_saved_state(format!( + "console TX offset {partial_transmit} exceeds descriptor length {readable_length}" + ))); + } + Ok(()) +} + +#[derive(Protobuf, SavedStateRoot)] +#[mesh(package = "virtio.console")] +pub struct SavedState { + #[mesh(1)] + pub schema_version: u32, + #[mesh(2)] + pub columns: u32, + #[mesh(3)] + pub rows: u32, + #[mesh(4)] + pub partial_transmit: u64, + #[mesh(5)] + pub staged_rx: Vec, + #[mesh(6)] + pub disconnect_policy_id: u32, +} diff --git a/vm/devices/virtio/virtio_console/src/tests.rs b/vm/devices/virtio/virtio_console/src/tests.rs index fd4db573a20..74a0b0468ff 100644 --- a/vm/devices/virtio/virtio_console/src/tests.rs +++ b/vm/devices/virtio/virtio_console/src/tests.rs @@ -7,6 +7,10 @@ //! serial IO backend, and real virtio queues — then drive requests through the //! descriptor rings just as a guest driver would. +mod direct; +mod harness; +mod saved_state; + use crate::VirtioConsoleDevice; use futures::AsyncRead; use futures::AsyncWrite; @@ -55,7 +59,7 @@ const TX_USED_ADDR: u64 = 0x12000; // Data area for payloads const DATA_BASE: u64 = 0x20000; -const TOTAL_MEM_SIZE: usize = 0x30000; +const TOTAL_MEM_SIZE: usize = 0x200000; // --- MockSerialIo --- @@ -77,6 +81,8 @@ struct MockShared { write_limit_then_disconnect: Option, /// If set, each poll_write accepts at most this many bytes (persistent). max_write_size: Option, + /// Extra state used by `harness::ControlledMockSerialIo`. + controls: harness::MockControls, } /// A mock `SerialIo` implementation backed by shared state. @@ -245,6 +251,7 @@ fn new_mock_serial() -> (MockSerialIo, MockSerialHandle) { disconnect_waker: None, write_limit_then_disconnect: None, max_write_size: None, + controls: Default::default(), })); ( MockSerialIo { @@ -310,6 +317,14 @@ impl TestHarness { /// Enable the device with both queues. async fn enable(&mut self) { + self.enable_with_state(None, None).await; + } + + async fn enable_with_state( + &mut self, + receive_state: Option, + transmit_state: Option, + ) { let features = VirtioDeviceFeatures::new(); // Queue 0: receiveq (host→guest) @@ -329,7 +344,7 @@ impl TestHarness { guest_memory: self.mem.clone(), }, &features, - None, + receive_state, ) .await .unwrap(); @@ -351,7 +366,7 @@ impl TestHarness { guest_memory: self.mem.clone(), }, &features, - None, + transmit_state, ) .await .unwrap(); diff --git a/vm/devices/virtio/virtio_console/src/tests/direct.rs b/vm/devices/virtio/virtio_console/src/tests/direct.rs new file mode 100644 index 00000000000..ba99ff85dc3 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/tests/direct.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for input gating and read-error handling in the direct worker mode. + +use super::TestHarness; +use super::harness::yield_now; +use super::harness::yield_until; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; +use virtio::VirtioDevice; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; + +#[async_test] +async fn input_gate_cancels_rx_but_keeps_tx_running(driver: DefaultDriver) { + let mut harness = TestHarness::new(&driver); + harness.enable().await; + harness.device.quiesce_input().await.unwrap(); + + harness.handle.inject_rx_data(b"gated-input"); + for _ in 0..10 { + yield_now().await; + } + assert_eq!(harness.handle.pending_rx_len(), b"gated-input".len()); + + harness.post_tx_and_signal(0, b"tx-while-gated"); + let (used_id, _) = harness.wait_for_tx_used().await; + assert_eq!(used_id, 0); + assert_eq!(harness.handle.take_tx_data(), b"tx-while-gated"); + + harness.device.resume_input().await.unwrap(); + let gpa = harness.post_rx_buffer_and_signal(0, 64); + let (_, used_len) = harness.wait_for_rx_used().await; + let mut received = vec![0; used_len as usize]; + harness.mem.read_at(gpa, &mut received).unwrap(); + assert_eq!(received, b"gated-input"); +} + +#[async_test] +async fn read_error_drives_disconnect_before_reconnect(driver: DefaultDriver) { + let mut harness = TestHarness::new_with_policy(&driver, VirtioConsoleDisconnectPolicy::Discard); + harness.enable().await; + harness.handle.set_read_error_then_disconnect(); + yield_until(|| harness.handle.disconnect_poll_count() != 0).await; + + harness.handle.reconnect(); + let gpa = harness.post_rx_buffer_and_signal(0, 64); + harness.handle.inject_rx_data(b"after-read-error"); + let (_, used_len) = harness.wait_for_rx_used().await; + let mut received = vec![0; used_len as usize]; + harness.mem.read_at(gpa, &mut received).unwrap(); + assert_eq!(received, b"after-read-error"); +} diff --git a/vm/devices/virtio/virtio_console/src/tests/harness.rs b/vm/devices/virtio/virtio_console/src/tests/harness.rs new file mode 100644 index 00000000000..5d25dce6d4a --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/tests/harness.rs @@ -0,0 +1,185 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Test harness extensions for the direct-mode and save/restore tests. +//! +//! [`ControlledMockSerialIo`] wraps the mock serial backend and applies the +//! fault injection configured in [`MockControls`]. [`TestHarness`] gains policy +//! device variants backed by it and device replacement for restore tests. The +//! remaining helpers poll the executor. + +use super::MockSerialHandle; +use super::MockSerialIo; +use super::TestHarness; +use super::new_mock_serial; +use crate::VirtioConsoleDevice; +use futures::AsyncRead; +use futures::AsyncWrite; +use inspect::InspectMut; +use pal_async::DefaultDriver; +use serial_core::SerialIo; +use std::io; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vmcore::vm_task::SingleDriverBackend; +use vmcore::vm_task::VmTaskDriverSource; + +/// Additional mock backend state, applied by [`ControlledMockSerialIo`]. +#[derive(Default)] +pub(super) struct MockControls { + read_error_then_disconnect: bool, + disconnect_poll_count: usize, +} + +/// The mock serial backend with [`MockControls`] applied before each call is +/// forwarded to [`MockSerialIo`]. +struct ControlledMockSerialIo(MockSerialIo); + +fn new_controlled_mock_serial() -> (ControlledMockSerialIo, MockSerialHandle) { + let (io, handle) = new_mock_serial(); + (ControlledMockSerialIo(io), handle) +} + +impl InspectMut for ControlledMockSerialIo { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + self.0.inspect_mut(req); + } +} + +impl SerialIo for ControlledMockSerialIo { + fn is_connected(&self) -> bool { + self.0.is_connected() + } + + fn poll_connect(&mut self, cx: &mut Context<'_>) -> Poll> { + self.0.poll_connect(cx) + } + + fn poll_disconnect(&mut self, cx: &mut Context<'_>) -> Poll> { + { + let mut shared = self.0.shared.lock(); + shared.controls.disconnect_poll_count += 1; + if shared.controls.read_error_then_disconnect { + shared.controls.read_error_then_disconnect = false; + shared.connected = false; + return Poll::Ready(Ok(())); + } + } + self.0.poll_disconnect(cx) + } +} + +impl AsyncRead for ControlledMockSerialIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut [u8], + ) -> Poll> { + { + let shared = self.0.shared.lock(); + if shared.connected && shared.controls.read_error_then_disconnect { + return Poll::Ready(Err(io::ErrorKind::ConnectionReset.into())); + } + } + Pin::new(&mut self.0).poll_read(cx, buf) + } +} + +impl AsyncWrite for ControlledMockSerialIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.0).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.0).poll_close(cx) + } +} + +impl MockSerialHandle { + pub(super) fn tx_data(&self) -> Vec { + self.shared.lock().tx_buf.clone() + } + + pub(super) fn pending_rx_len(&self) -> usize { + self.shared.lock().rx_buf.len() + } + + pub(super) fn set_read_error_then_disconnect(&self) { + let mut shared = self.shared.lock(); + shared.controls.read_error_then_disconnect = true; + if let Some(waker) = shared.rx_waker.take() { + waker.wake(); + } + } + + pub(super) fn disconnect_poll_count(&self) -> usize { + self.shared.lock().controls.disconnect_poll_count + } +} + +impl TestHarness { + pub(super) fn new_with_policy( + driver: &DefaultDriver, + disconnect_policy: VirtioConsoleDisconnectPolicy, + ) -> Self { + Self::new_with_device(driver, |driver_source, io| { + VirtioConsoleDevice::new_with_policy(driver_source, io, disconnect_policy) + }) + } + + /// Builds the standard harness, then swaps in the device returned by + /// `make_device` together with a fresh controlled mock backend. + fn new_with_device( + driver: &DefaultDriver, + make_device: impl FnOnce(&VmTaskDriverSource, Box) -> VirtioConsoleDevice, + ) -> Self { + let mut harness = Self::new(driver); + let (io, handle) = new_controlled_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + harness.device = make_device(&driver_source, Box::new(io)); + harness.handle = handle; + harness + } + + pub(super) fn replace_device(&mut self, disconnect_policy: VirtioConsoleDisconnectPolicy) { + let (io, handle) = new_controlled_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(self.driver.clone())); + self.device = + VirtioConsoleDevice::new_with_policy(&driver_source, Box::new(io), disconnect_policy); + self.handle = handle; + } +} + +pub(super) async fn yield_now() { + let mut yielded = false; + std::future::poll_fn(|cx| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }) + .await +} + +pub(super) async fn yield_until(mut condition: impl FnMut() -> bool) { + for _ in 0..1000 { + if condition() { + return; + } + yield_now().await; + } + assert!(condition(), "condition did not become true"); +} diff --git a/vm/devices/virtio/virtio_console/src/tests/saved_state.rs b/vm/devices/virtio/virtio_console/src/tests/saved_state.rs new file mode 100644 index 00000000000..38d3aac97b0 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/tests/saved_state.rs @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for saving, validating, and restoring the device-private console state. + +use super::DATA_BASE; +use super::QUEUE_SIZE; +use super::TOTAL_MEM_SIZE; +use super::TX_AVAIL_ADDR; +use super::TX_DESC_ADDR; +use super::TX_USED_ADDR; +use super::TestHarness; +use super::harness::yield_until; +use super::new_mock_serial; +use crate::VirtioConsoleDevice; +use chipset_device::io::IoResult; +use chipset_device::mmio::MmioIntercept; +use guestmem::GuestMemory; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; +use virtio::VirtioDevice; +use virtio::device::saved_state::DeviceQueueState; +use virtio::queue::QueueParams; +use virtio::queue::QueueState; +use virtio::spec::VirtioDeviceFeatures; +use virtio::spec::queue::DescriptorFlags; +use virtio::test_helpers::init_avail_ring; +use virtio::test_helpers::init_used_ring; +use virtio::test_helpers::make_available; +use virtio::test_helpers::write_descriptor; +use virtio::transport::VirtioMmioDevice; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vmcore::device_state::ChangeDeviceState; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::save_restore::SaveRestore; +use vmcore::save_restore::SavedStateBlob; +use vmcore::vm_task::SingleDriverBackend; +use vmcore::vm_task::VmTaskDriverSource; + +#[async_test] +async fn staged_rx_restores_before_new_endpoint_bytes(driver: DefaultDriver) { + let mut harness = TestHarness::new(&driver); + harness.enable().await; + + harness.handle.inject_rx_data(b"before-snapshot"); + yield_until(|| harness.handle.pending_rx_len() == 0).await; + + let receive_state = harness.device.stop_queue(0).await.unwrap(); + let transmit_state = harness.device.stop_queue(1).await.unwrap(); + let saved = harness.device.save_device().unwrap().unwrap(); + + harness.replace_device(VirtioConsoleDisconnectPolicy::Discard); + harness.device.restore_device(Some(saved)).unwrap(); + harness + .enable_with_state(Some(receive_state), Some(transmit_state)) + .await; + harness.handle.inject_rx_data(b"after-restore"); + + let first_gpa = harness.post_rx_buffer_and_signal(0, 64); + let (_, first_len) = harness.wait_for_rx_used().await; + let mut first = vec![0; first_len as usize]; + harness.mem.read_at(first_gpa, &mut first).unwrap(); + assert_eq!(first, b"before-snapshot"); + + let second_gpa = harness.post_rx_buffer_and_signal(1, 64); + let (_, second_len) = harness.wait_for_rx_used().await; + let mut second = vec![0; second_len as usize]; + harness.mem.read_at(second_gpa, &mut second).unwrap(); + assert_eq!(second, b"after-restore"); +} + +#[async_test] +async fn partial_tx_restores_without_replay(driver: DefaultDriver) { + let mut harness = TestHarness::new_with_policy(&driver, VirtioConsoleDisconnectPolicy::Retain); + harness.enable().await; + harness.handle.set_write_limit_then_disconnect(3); + harness.post_tx_and_signal(0, b"abcdef"); + yield_until(|| harness.handle.tx_data() == b"abc").await; + + let receive_state = harness.device.stop_queue(0).await.unwrap(); + let transmit_state = harness.device.stop_queue(1).await.unwrap(); + let saved = harness.device.save_device().unwrap().unwrap(); + + harness.replace_device(VirtioConsoleDisconnectPolicy::Retain); + harness.device.restore_device(Some(saved)).unwrap(); + harness + .enable_with_state(Some(receive_state), Some(transmit_state)) + .await; + + let (used_id, _) = harness.wait_for_tx_used().await; + assert_eq!(used_id, 0); + assert_eq!(harness.handle.take_tx_data(), b"def"); +} + +#[async_test] +async fn saved_state_validator_rejects_wrong_schema(driver: DefaultDriver) { + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + let validator = device.device_state_validator(); + let state = SavedStateBlob::new(crate::saved_state::SavedState { + schema_version: crate::saved_state::SAVED_STATE_VERSION + 1, + columns: 0, + rows: 0, + partial_transmit: 0, + staged_rx: Vec::new(), + disconnect_policy_id: 0, + }); + assert!( + validator( + Some(&state), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_err() + ); +} + +#[async_test] +async fn saved_state_validator_rejects_tx_offset_past_descriptor(driver: DefaultDriver) { + let mem = GuestMemory::allocate(TOTAL_MEM_SIZE); + init_avail_ring(&mem, TX_AVAIL_ADDR); + init_used_ring(&mem, TX_USED_ADDR); + let payload_gpa = DATA_BASE; + mem.write_at(payload_gpa, b"short").unwrap(); + write_descriptor( + &mem, + TX_DESC_ADDR, + 0, + payload_gpa, + 5, + DescriptorFlags::new(), + 0, + ); + let mut avail_index = 0; + make_available(&mem, TX_AVAIL_ADDR, QUEUE_SIZE, 0, &mut avail_index); + + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + let validator = device.device_state_validator(); + let state = SavedStateBlob::new(crate::saved_state::SavedState { + schema_version: crate::saved_state::SAVED_STATE_VERSION, + columns: 0, + rows: 0, + partial_transmit: 6, + staged_rx: Vec::new(), + disconnect_policy_id: 0, + }); + let queues = [ + DeviceQueueState { + params: QueueParams::default(), + queue_state: None, + }, + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: true, + desc_addr: TX_DESC_ADDR, + avail_addr: TX_AVAIL_ADDR, + used_addr: TX_USED_ADDR, + }, + queue_state: Some(QueueState::default()), + }, + ]; + assert!(validator(Some(&state), &VirtioDeviceFeatures::new(), &queues, &mem,).is_err()); +} + +#[async_test] +async fn inactive_transport_restore_defers_console_private_state(driver: DefaultDriver) { + let source_harness = TestHarness::new(&driver); + let mut source = VirtioMmioDevice::new( + Box::new(source_harness.device), + &driver, + source_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + source.stop().await; + let mut saved = source.save().unwrap(); + let staged_rx = b"deferred console input".to_vec(); + saved.device_state = Some(SavedStateBlob::new(crate::saved_state::SavedState { + schema_version: crate::saved_state::SAVED_STATE_VERSION, + columns: 123, + rows: 45, + partial_transmit: 0, + staged_rx: staged_rx.clone(), + disconnect_policy_id: 0, + })); + + let destination_harness = TestHarness::new(&driver); + let mut destination = VirtioMmioDevice::new( + Box::new(destination_harness.device), + &driver, + destination_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.stop().await; + let staged = destination.save().unwrap(); + let staged_private = staged + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap(); + assert_eq!(staged_private.columns, 123); + assert_eq!(staged_private.rows, 45); + assert_eq!(staged_private.staged_rx, staged_rx); + + destination.start_fallible().await.unwrap(); + let mut config = [0; 4]; + match destination.mmio_read(0x100, &mut config) { + IoResult::Defer(token) => token.read_future(&mut config).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + assert_eq!(u16::from_ne_bytes(config[..2].try_into().unwrap()), 123); + assert_eq!(u16::from_ne_bytes(config[2..].try_into().unwrap()), 45); + + let mut invalid = staged; + invalid.device_state = Some(SavedStateBlob::new(crate::saved_state::SavedState { + schema_version: crate::saved_state::SAVED_STATE_VERSION, + columns: 80, + rows: 25, + partial_transmit: 0, + staged_rx: vec![0; crate::saved_state::MAX_STAGED_RX_BYTES + 1], + disconnect_policy_id: 0, + })); + let invalid_harness = TestHarness::new(&driver); + let mut invalid_destination = VirtioMmioDevice::new( + Box::new(invalid_harness.device), + &driver, + invalid_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + assert!(invalid_destination.restore(invalid).is_err()); +} + +#[async_test] +async fn direct_schema_v1_allows_staged_rx_without_receive_queue(driver: DefaultDriver) { + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + let state = crate::saved_state::SavedState { + schema_version: crate::saved_state::DIRECT_SAVED_STATE_VERSION, + columns: 0, + rows: 0, + partial_transmit: 0, + staged_rx: vec![1], + disconnect_policy_id: 0, + }; + let saved = SavedStateBlob::new(state); + let validator = device.device_state_validator(); + assert!( + validator( + Some(&saved), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_ok() + ); +} + +#[async_test] +async fn oversized_saved_state_is_rejected_before_nested_decode(driver: DefaultDriver) { + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + let state = crate::saved_state::SavedState { + schema_version: crate::saved_state::DIRECT_SAVED_STATE_VERSION, + columns: 0, + rows: 0, + partial_transmit: 0, + staged_rx: vec![0; crate::saved_state::MAX_SAVED_STATE_BYTES], + disconnect_policy_id: 0, + }; + let saved = SavedStateBlob::new(state); + assert!(saved.encoded_len() > crate::saved_state::MAX_SAVED_STATE_BYTES); + let validator = device.device_state_validator(); + assert!( + validator( + Some(&saved), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_err() + ); +} diff --git a/vm/vmcore/src/save_restore.rs b/vm/vmcore/src/save_restore.rs index 5e48d6058c7..e3644fd6ee7 100644 --- a/vm/vmcore/src/save_restore.rs +++ b/vm/vmcore/src/save_restore.rs @@ -141,6 +141,11 @@ impl SavedStateBlob { pub fn parse(&self) -> Result { self.0.parse() } + + /// Returns the encoded state payload length without decoding it. + pub fn encoded_len(&self) -> usize { + self.0.value_len() + } } impl ProtobufSaveRestore for T From df730819b551e8cab5790824bddcd67a6b2fdaae Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 02:47:37 -0700 Subject: [PATCH 075/304] net_backend: add an endpoint queue quiesce contract To save a network device, its frontend must stop exchanging packets with the endpoint at a well-defined point: every receive and transmit already accepted by the endpoint must complete, and the endpoint must not keep any guest buffer. The `Queue` trait had no operation for this, so a frontend could only drop its queues, losing work in flight. Add `Queue::quiesce`, which finishes the work that a queue already accepted without accepting new work. On success, all resulting receive and transmit completions are available through `rx_poll` and `tx_poll`, the queue retains no buffer IDs or guest-memory segments, and the returned `QueueQuiesceResult` reports how many completions of each kind remain to be polled. `Queue::resume` resumes host receive admission after a rolled-back quiesce. Queues do not support quiesce by default, and resume succeeds by default. `BufferAccess` now requires `Send`, so a quiesce future can hold the frontend's buffer pool across await points; every existing implementation already is. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/net/net_backend/src/lib.rs | 20 +++++++++++++++++++- vm/devices/net/net_backend/src/quiesce.rs | 19 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 vm/devices/net/net_backend/src/quiesce.rs diff --git a/vm/devices/net/net_backend/src/lib.rs b/vm/devices/net/net_backend/src/lib.rs index 16d4b67cd17..66085cbd1df 100644 --- a/vm/devices/net/net_backend/src/lib.rs +++ b/vm/devices/net/net_backend/src/lib.rs @@ -37,6 +37,7 @@ pub mod loopback; pub mod null; +pub mod quiesce; pub mod resolve; pub mod tests; @@ -215,6 +216,23 @@ pub trait Queue: Send + InspectMut { fn tx_poll(&mut self, pool: &mut dyn BufferAccess, done: &mut [TxId]) -> Result; + /// Finishes work already accepted by this queue without accepting new work. + /// + /// On success, all resulting RX and TX completions must be available through + /// [`Queue::rx_poll`] and [`Queue::tx_poll`]. Implementations must not retain + /// any buffer IDs or guest-memory segments after returning. + async fn quiesce( + &mut self, + _pool: &mut dyn BufferAccess, + ) -> anyhow::Result { + anyhow::bail!("network queue does not support quiesce") + } + + /// Resumes host RX admission after a rolled-back quiesce transaction. + fn resume(&mut self) -> anyhow::Result<()> { + Ok(()) + } + /// Get queue statistics fn queue_stats(&self) -> Option<&dyn BackendQueueStats> { None // Default implementation - not all queues implement stats @@ -231,7 +249,7 @@ pub trait Queue: Send + InspectMut { /// to [`Queue`] methods. This means no `Arc`/`Mutex` is needed /// between the frontend and backend for buffer access—the borrow /// checker enforces exclusive access statically. -pub trait BufferAccess { +pub trait BufferAccess: Send { /// The associated guest memory accessor. fn guest_memory(&self) -> &GuestMemory; diff --git a/vm/devices/net/net_backend/src/quiesce.rs b/vm/devices/net/net_backend/src/quiesce.rs new file mode 100644 index 00000000000..130c2f7db11 --- /dev/null +++ b/vm/devices/net/net_backend/src/quiesce.rs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Queue quiesce. +//! +//! [`Queue::quiesce`](crate::Queue::quiesce) finishes the work that a queue +//! already accepted without accepting new work, and reports the completions +//! that the endpoint still holds in a [`QueueQuiesceResult`]. +//! [`Queue::resume`](crate::Queue::resume) resumes host RX admission after a +//! rolled-back quiesce. + +/// Completion ownership retained by an endpoint after quiesce. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct QueueQuiesceResult { + /// RX completions available through [`Queue::rx_poll`](crate::Queue::rx_poll). + pub rx_ready: usize, + /// TX completions available through [`Queue::tx_poll`](crate::Queue::tx_poll). + pub tx_ready: usize, +} From ebc1ef345f34255b69f1f628859e327c142526cd Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 02:49:08 -0700 Subject: [PATCH 076/304] net_tap: quiesce queues and retain pending transmit ownership A TAP queue wrote guest packets synchronously and completed every packet, including the ones that the TAP interface dropped because its buffer was full or the interface was down. A packet dropped under backpressure was therefore lost, and the queue could not implement the endpoint quiesce contract, because it could not report which transmits were still in flight. Each `tx_avail` call now writes at most one guest packet. When the TAP interface applies backpressure, the queue keeps the packet, and with it the ownership of its descriptors, until `poll_ready` writes it through the new `AsyncWrite` implementation of `PolledTap`; `tx_poll` then completes it. A partial write or a write error is reported as a fatal transmit error instead of being ignored. Implement `Queue::quiesce` for TAP queues: a quiesced queue drops its free receive buffers, stops reading from the interface and admitting receive buffers, finishes writing its pending packet, and reports the receive and transmit completions that it still holds. `Queue::resume` resumes receiving after a rolled-back quiesce. The TAP backpressure test now checks that every packet is completed exactly once instead of being dropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/net/net_tap/src/lib.rs | 27 +++ vm/devices/net/net_tap/src/quiesce.rs | 38 ++++ vm/devices/net/net_tap/src/tap.rs | 2 + vm/devices/net/net_tap/src/tap/async_write.rs | 38 ++++ vm/devices/net/net_tap/src/tx.rs | 187 +++++++++++------- vm/devices/net/net_tap/tests/tap_tests.rs | 21 +- 6 files changed, 241 insertions(+), 72 deletions(-) create mode 100644 vm/devices/net/net_tap/src/quiesce.rs create mode 100644 vm/devices/net/net_tap/src/tap/async_write.rs diff --git a/vm/devices/net/net_tap/src/lib.rs b/vm/devices/net/net_tap/src/lib.rs index f1b0832ce9d..988404288bb 100644 --- a/vm/devices/net/net_tap/src/lib.rs +++ b/vm/devices/net/net_tap/src/lib.rs @@ -6,6 +6,7 @@ #![cfg(target_os = "linux")] #![expect(missing_docs)] +mod quiesce; pub mod resolver; pub mod tap; mod tx; @@ -193,6 +194,8 @@ struct TapQueue { tap: Option, inner: Inner, buffer: Box<[u8]>, + tx: tx::TxState, + input_quiesced: bool, } struct Inner { @@ -226,16 +229,25 @@ impl TapQueue { rx_ready: VecDeque::new(), }, buffer: vec![0; 65535 + size_of::()].into_boxed_slice(), + tx: tx::TxState::new(), + input_quiesced: false, }) } } +#[async_trait] impl Queue for TapQueue { fn poll_ready(&mut self, cx: &mut Context<'_>, pool: &mut dyn BufferAccess) -> Poll<()> { + if self.poll_tx(cx).is_ready() { + return Poll::Ready(()); + } if !self.inner.rx_ready.is_empty() { return Poll::Ready(()); } + if self.input_quiesced { + return Poll::Pending; + } let tap = if let Some(tap) = self.tap.as_mut() { tap } else { @@ -283,6 +295,9 @@ impl Queue for TapQueue { } fn rx_avail(&mut self, _pool: &mut dyn BufferAccess, done: &[RxId]) { + if self.input_quiesced { + return; + } self.inner.rx_free.extend(done); } @@ -314,6 +329,18 @@ impl Queue for TapQueue { ) -> Result { self.poll_tx_done(done) } + + async fn quiesce( + &mut self, + _pool: &mut dyn BufferAccess, + ) -> anyhow::Result { + self.quiesce_queue().await + } + + fn resume(&mut self) -> anyhow::Result<()> { + self.input_quiesced = false; + Ok(()) + } } /// Compute and write the IPv4 header checksum in place. diff --git a/vm/devices/net/net_tap/src/quiesce.rs b/vm/devices/net/net_tap/src/quiesce.rs new file mode 100644 index 00000000000..22c86a8453b --- /dev/null +++ b/vm/devices/net/net_tap/src/quiesce.rs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Quiesce of a TAP queue. +//! +//! A quiesced queue drops its free RX buffers and stops reading from the TAP +//! interface, finishes writing its pending packet, and reports the +//! completions that it still holds. + +use crate::TapQueue; +use anyhow::Context as _; +use net_backend::quiesce::QueueQuiesceResult; +use std::task::Poll; + +impl TapQueue { + /// Implements `quiesce`. + pub(crate) async fn quiesce_queue(&mut self) -> anyhow::Result { + self.input_quiesced = true; + self.inner.rx_free.clear(); + std::future::poll_fn(|cx| { + self.poll_pending_tx(cx); + if self.tx.pending.is_none() || self.tx.error.is_some() { + Poll::Ready(()) + } else { + Poll::Pending + } + }) + .await; + if let Some(error) = self.tx.error.take() { + self.inner.rx_ready.clear(); + return Err(error).context("failed to quiesce TAP transmit queue"); + } + Ok(QueueQuiesceResult { + rx_ready: self.inner.rx_ready.len(), + tx_ready: self.tx.ready.len(), + }) + } +} diff --git a/vm/devices/net/net_tap/src/tap.rs b/vm/devices/net/net_tap/src/tap.rs index ba15e64b536..cac0082105f 100644 --- a/vm/devices/net/net_tap/src/tap.rs +++ b/vm/devices/net/net_tap/src/tap.rs @@ -6,6 +6,8 @@ // UNSAFETY: Interacting with a union in bindgen-generated code and calling an ioctl. #![expect(unsafe_code)] +mod async_write; + use crate::VirtioNetHdr; use futures::AsyncRead; use linux_net_bindings::gen_if; diff --git a/vm/devices/net/net_tap/src/tap/async_write.rs b/vm/devices/net/net_tap/src/tap/async_write.rs new file mode 100644 index 00000000000..76a1c5666a2 --- /dev/null +++ b/vm/devices/net/net_tap/src/tap/async_write.rs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Asynchronous writes to a TAP interface, used by the queue transmit path to +//! wait for the interface to accept a packet. + +use super::PolledTap; +use futures::AsyncWrite; +use std::io; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; + +impl AsyncWrite for PolledTap { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.tap).poll_write(cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.tap).poll_flush(cx) + } + + fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.tap).poll_close(cx) + } + + fn poll_write_vectored( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bufs: &[io::IoSlice<'_>], + ) -> Poll> { + Pin::new(&mut self.tap).poll_write_vectored(cx, bufs) + } +} diff --git a/vm/devices/net/net_tap/src/tx.rs b/vm/devices/net/net_tap/src/tx.rs index 8154686534d..eb62c632da0 100644 --- a/vm/devices/net/net_tap/src/tx.rs +++ b/vm/devices/net/net_tap/src/tx.rs @@ -3,98 +3,153 @@ //! Transmit path of a TAP queue. //! -//! `tx_avail` writes the guest packets to the TAP interface synchronously and -//! completes all of them, including the packets that the interface drops. +//! Each `tx_avail` call writes at most one guest packet. When the TAP +//! interface applies backpressure, the queue keeps the packet, and with it +//! the ownership of its descriptors, until `poll_ready` writes it; `tx_poll` +//! then completes it. use crate::TapQueue; +use crate::VirtioNetHdr; use crate::build_vnet_hdr; use crate::fixup_ipv4_header_checksum; use crate::fixup_ipv6_payload_length; +use anyhow::Context as _; +use futures::io::AsyncWrite; use net_backend::BufferAccess; use net_backend::TxError; use net_backend::TxId; use net_backend::TxSegment; use net_backend::linearize; use net_backend::next_packet; +use std::collections::VecDeque; use std::io::ErrorKind; use std::io::Write; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; use zerocopy::IntoBytes; +/// Transmit state of a [`TapQueue`]. +pub(crate) struct TxState { + pub(crate) pending: Option, + pub(crate) ready: VecDeque, + pub(crate) error: Option, +} + +/// A packet accepted from the guest and not yet written to the TAP interface. +pub(crate) struct PendingTx { + id: TxId, + header: VirtioNetHdr, + packet: Vec, +} + +impl TxState { + pub(crate) fn new() -> Self { + Self { + pending: None, + ready: VecDeque::new(), + error: None, + } + } +} + impl TapQueue { - /// Implements `tx_avail`: writes the packets of `segments`. + /// Writes the pending packet, if any, and reports whether TX completions or a + /// TX error are ready. + pub(crate) fn poll_tx(&mut self, cx: &mut Context<'_>) -> Poll<()> { + self.poll_pending_tx(cx); + if !self.tx.ready.is_empty() || self.tx.error.is_some() { + Poll::Ready(()) + } else { + Poll::Pending + } + } + + pub(crate) fn poll_pending_tx(&mut self, cx: &mut Context<'_>) { + let Some(pending) = self.tx.pending.as_ref() else { + return; + }; + let Some(tap) = self.tap.as_mut() else { + return; + }; + let header = pending.header.as_bytes(); + let bufs = [ + std::io::IoSlice::new(header), + std::io::IoSlice::new(&pending.packet), + ]; + match Pin::new(tap).poll_write_vectored(cx, &bufs) { + Poll::Ready(Ok(bytes_written)) + if bytes_written == header.len() + pending.packet.len() => + { + let pending = self.tx.pending.take().unwrap(); + self.tx.ready.push_back(pending.id); + } + Poll::Ready(Ok(bytes_written)) => { + self.tx.error = Some(std::io::Error::new( + ErrorKind::WriteZero, + format!( + "partial TAP packet write: wrote {bytes_written} of {} bytes", + header.len() + pending.packet.len() + ), + )); + } + Poll::Ready(Err(error)) => self.tx.error = Some(error), + Poll::Pending => {} + } + } + + /// Implements `tx_avail`: writes the next packet of `segments`. pub(crate) fn transmit( &mut self, pool: &mut dyn BufferAccess, segments: &mut &[TxSegment], ) -> anyhow::Result<(bool, usize)> { - let n = segments.len(); - // Synchronously send packets received from the guest to host's network. - if let Some(tap) = self.tap.as_mut() { - while !segments.is_empty() { - let (meta, _segs, _rest) = next_packet(segments); - let hdr = build_vnet_hdr(meta); - let hdr_bytes = hdr.as_bytes(); - let mut packet = linearize(pool, segments)?; - - // Fix up the IPv4 header checksum when the frontend - // requested IPv4 header checksum offload. - // - // The virtio vnet header has no mechanism for IPv4 header - // checksum offload, so we compute it in software. This - // also covers NDIS/netvsp LSO packets, where the guest - // driver zeroes ip_check (NDIS convention); the kernel's - // TAP GSO engine requires a valid checksum to segment - // the packet correctly. - // Same NDIS/LSO convention for IPv6: the guest zeroes the IPv6 - // payload-length field on segmentation-offload frames. IPv6 has - // no header checksum (so the IPv4 fixup above never runs for it); - // fix the length here so the kernel TAP GSO engine can segment. - if meta.flags.offload_ip_header_checksum() && meta.flags.is_ipv4() { - fixup_ipv4_header_checksum(&mut packet, meta.l2_len as usize); - } - if meta.flags.offload_tcp_segmentation() && meta.flags.is_ipv6() { - fixup_ipv6_payload_length(&mut packet, meta.l2_len as usize); - } - - let bufs = [ - std::io::IoSlice::new(hdr_bytes), - std::io::IoSlice::new(&packet), - ]; - match tap.write_vectored(&bufs) { - Ok(bytes_written) => { - assert_eq!( - bytes_written, - hdr_bytes.len() + packet.len(), - "TAP should never partial write" - ); - } - Err(err) if err.kind() == ErrorKind::WouldBlock => { - // dropped packet: buffer is full + if segments.is_empty() || self.tx.pending.is_some() { + return Ok((false, 0)); + } - // TODO: return partial transmit here. This relies on - // remembering this condition and polling for POLLOUT in - // poll_ready(). - } - Err(err) if err.raw_os_error() == Some(libc::EIO) => { - // dropped packet: interface is not up - } - Err(err) => { - tracing::warn!( - error = &err as &dyn std::error::Error, - "write to TAP interface failed" - ); - } - } + let (metadata, packet_segments, _) = next_packet(segments); + let segment_count = packet_segments.len(); + let id = metadata.id; + let header = build_vnet_hdr(metadata); + let mut packet = linearize(pool, segments)?; + if metadata.flags.offload_ip_header_checksum() && metadata.flags.is_ipv4() { + fixup_ipv4_header_checksum(&mut packet, metadata.l2_len as usize); + } + if metadata.flags.offload_tcp_segmentation() && metadata.flags.is_ipv6() { + fixup_ipv6_payload_length(&mut packet, metadata.l2_len as usize); + } + let tap = self.tap.as_mut().context("TAP queue is unavailable")?; + let header_bytes = header.as_bytes(); + let bufs = [ + std::io::IoSlice::new(header_bytes), + std::io::IoSlice::new(&packet), + ]; + match tap.write_vectored(&bufs) { + Ok(bytes_written) if bytes_written == header_bytes.len() + packet.len() => { + Ok((true, segment_count)) } + Ok(bytes_written) => anyhow::bail!( + "partial TAP packet write: wrote {bytes_written} of {} bytes", + header_bytes.len() + packet.len() + ), + Err(error) if error.kind() == ErrorKind::WouldBlock => { + self.tx.pending = Some(PendingTx { id, header, packet }); + Ok((false, segment_count)) + } + Err(error) => Err(error).context("failed to write TAP packet"), } - let completed_synchronously = true; - Ok((completed_synchronously, n)) } /// Implements `tx_poll`. - pub(crate) fn poll_tx_done(&mut self, _done: &mut [TxId]) -> Result { - // Packets are sent synchronously so there is no no need to check here if - // sending has been completed. - Ok(0) + pub(crate) fn poll_tx_done(&mut self, done: &mut [TxId]) -> Result { + if let Some(error) = self.tx.error.take() { + return Err(TxError::Fatal(error.into())); + } + let count = done.len().min(self.tx.ready.len()); + for (destination, id) in done.iter_mut().zip(self.tx.ready.drain(..count)) { + *destination = id; + } + Ok(count) } } diff --git a/vm/devices/net/net_tap/tests/tap_tests.rs b/vm/devices/net/net_tap/tests/tap_tests.rs index 424b39c88df..9e4cb0b843e 100644 --- a/vm/devices/net/net_tap/tests/tap_tests.rs +++ b/vm/devices/net/net_tap/tests/tap_tests.rs @@ -318,9 +318,9 @@ mod tap_tests { ); } - /// Validates that flooding tx_avail doesn't panic or error — packets are - /// silently dropped when the kernel buffer fills up. - async fn test_tap_tx_wouldblock_drops(driver: DefaultDriver) { + /// Validates that flooding tx_avail preserves ownership until the TAP + /// accepts each packet, including when the kernel applies backpressure. + async fn test_tap_tx_backpressure_preserves_ownership(driver: DefaultDriver) { let mut endpoint = new_endpoint("tap0").unwrap(); configure_tap("tap0", "10.0.0.1/24"); @@ -357,11 +357,17 @@ mod tap_tests { len: frame_len, }]; - // Flood with packets — should never error. + // Flood with packets. A packet accepted asynchronously must be + // returned through tx_poll before the next packet is submitted. for _ in 0..10000 { let (completed, count) = queue.tx_avail(&mut pool, &segments).unwrap(); - assert!(completed, "tx should always complete synchronously"); assert_eq!(count, 1); + if !completed { + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + let mut done = [TxId(0)]; + assert_eq!(queue.tx_poll(&mut pool, &mut done).unwrap(), 1); + assert_eq!(done[0].0, 0); + } } } @@ -633,7 +639,10 @@ mod tap_tests { async_trial("tap_get_queues", test_tap_get_queues), async_trial("tap_tx_sends_frame", test_tap_tx_sends_frame), async_trial("tap_rx_receives_packet", test_tap_rx_receives_packet), - async_trial("tap_tx_wouldblock_drops", test_tap_tx_wouldblock_drops), + async_trial( + "tap_tx_backpressure_preserves_ownership", + test_tap_tx_backpressure_preserves_ownership, + ), async_trial("tap_tx_with_offloads", test_tap_tx_with_offloads), async_trial("tap_tso_ipv4_checksum", test_tap_tso_ipv4_checksum), ] From 6614bc41eb0f53b74cfd683af0e15d7125c03a50 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:02:05 -0700 Subject: [PATCH 077/304] net_consomme: quiesce endpoint queues A Consomme queue could not stop exchanging packets at a well-defined point for a save, because it always polled the Consomme stack and its control channels and kept accepting receive buffers. Implement `Queue::quiesce` for Consomme queues. A quiesced queue processes the frames that the guest already queued for transmit, drops its posted receive buffers, ignores new ones, and stops polling the Consomme stack and the control channels; `poll_ready` then only reports the completions still pending. Quiesce reports the receive and transmit completions that the queue still holds, and `Queue::resume` restores normal polling after a rolled-back quiesce. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/net/net_consomme/src/lib.rs | 22 +++++++++++++ vm/devices/net/net_consomme/src/quiesce.rs | 38 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) create mode 100644 vm/devices/net/net_consomme/src/quiesce.rs diff --git a/vm/devices/net/net_consomme/src/lib.rs b/vm/devices/net/net_consomme/src/lib.rs index 93f1992706d..f7f7435e20c 100644 --- a/vm/devices/net/net_consomme/src/lib.rs +++ b/vm/devices/net/net_consomme/src/lib.rs @@ -5,6 +5,7 @@ #![forbid(unsafe_code)] mod egress; +mod quiesce; pub mod resolver; use anyhow::Context as _; @@ -378,6 +379,7 @@ impl net_backend::Endpoint for ConsommeEndpoint { }, stats: Default::default(), driver: config.driver, + input_quiesced: false, }); let port_forwards = std::mem::take(&mut queue.endpoint_state.as_mut().unwrap().port_forwards); @@ -443,6 +445,7 @@ pub struct ConsommeQueue { state: QueueState, stats: Stats, driver: Box, + input_quiesced: bool, } impl InspectMut for ConsommeQueue { @@ -679,9 +682,13 @@ fn process_message( } } +#[async_trait] impl net_backend::Queue for ConsommeQueue { fn poll_ready(&mut self, cx: &mut Context<'_>, pool: &mut dyn BufferAccess) -> Poll<()> { self.process_tx(pool); + if self.input_quiesced { + return self.poll_ready_quiesced(); + } // TODO: handle messages asynchronously from any queue processing, since // there is no guarantee the queue will be processed at all (e.g., if @@ -699,6 +706,9 @@ impl net_backend::Queue for ConsommeQueue { } fn rx_avail(&mut self, _pool: &mut dyn BufferAccess, done: &[RxId]) { + if self.input_quiesced { + return; + } self.state.rx_avail.extend(done); } @@ -734,6 +744,18 @@ impl net_backend::Queue for ConsommeQueue { } Ok(n) } + + async fn quiesce( + &mut self, + pool: &mut dyn BufferAccess, + ) -> anyhow::Result { + self.quiesce_queue(pool) + } + + fn resume(&mut self) -> anyhow::Result<()> { + self.input_quiesced = false; + Ok(()) + } } struct QueueState { diff --git a/vm/devices/net/net_consomme/src/quiesce.rs b/vm/devices/net/net_consomme/src/quiesce.rs new file mode 100644 index 00000000000..40bba9ad56f --- /dev/null +++ b/vm/devices/net/net_consomme/src/quiesce.rs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Quiesce of a Consomme queue. +//! +//! A quiesced queue keeps transmitting guest frames, but it drops its posted +//! RX buffers, ignores new ones, and stops polling the Consomme stack and the +//! control channels until it is resumed. + +use crate::ConsommeQueue; +use net_backend::BufferAccess; +use std::task::Poll; + +impl ConsommeQueue { + /// Implements `quiesce`. + pub(crate) fn quiesce_queue( + &mut self, + pool: &mut dyn BufferAccess, + ) -> anyhow::Result { + self.input_quiesced = true; + self.process_tx(pool); + self.state.rx_avail.clear(); + Ok(net_backend::quiesce::QueueQuiesceResult { + rx_ready: self.state.rx_ready.len(), + tx_ready: self.state.tx_ready.len(), + }) + } + + /// Implements `poll_ready` while the queue is quiesced: reports the + /// pending completions without polling the stack or the control channels. + pub(crate) fn poll_ready_quiesced(&self) -> Poll<()> { + if !self.state.tx_ready.is_empty() || !self.state.rx_ready.is_empty() { + Poll::Ready(()) + } else { + Poll::Pending + } + } +} From bb0544114e94bd139fb9358aaab2daaeffcb580f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:05:32 -0700 Subject: [PATCH 078/304] consomme: add an exact static IPv4 and gateway identity Consomme derives the guest and gateway addresses from a CIDR, so a host cannot give a guest an exact, preconfigured IPv4 identity: a specific guest address and prefix, and a gateway whose address and MAC address stay fixed across snapshots and restores. Consomme also always treats traffic to the gateway as ordinary egress, with no way to map it onto host loopback, which a guest with a static identity uses to reach host services. Add `ConsommeParams::set_static_ipv4`, which sets an exact guest address, prefix length, gateway address, and gateway MAC address. The identity must be consistent: a prefix of 1 to 30 bits, a guest address that is neither the network nor the broadcast address, and a gateway at the first host address of the network, distinct from the guest. A static identity stops advertising a routable IPv6 prefix and enables the new `map_gateway_to_host_loopback` parameter, which translates guest TCP and UDP flows addressed to the gateway onto host loopback. With the mapping disabled, such flows are dropped, except TCP to the single port named by the new `gateway_loopback_proxy_port` parameter. Expose the identity to resources: `ConsommeHandle` gains an optional `StaticIpv4Config`, which is mutually exclusive with `cidr`, and optional overrides of host-local access, the gateway mapping, and the gateway proxy port. The resolver rejects a handle with both a CIDR and a static identity, or with an inconsistent identity. Existing handles set none of the new fields. This changes one behavior of every Consomme endpoint: a guest TCP or UDP flow addressed to the gateway, other than the gateway's own DNS and DHCP services, used to be sent to the gateway's address on the host network, where no gateway exists. Such flows are now dropped unless the gateway mapping or the proxy port allows them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_entry/src/lib.rs | 4 + openvmm/openvmm_entry/src/ttrpc/mod.rs | 8 + petri/src/vm/openvmm/modify.rs | 16 ++ .../src/consomme/static_ipv4.rs | 24 +++ .../net/net_backend_resources/src/lib.rs | 11 ++ .../net/net_consomme/consomme/src/lib.rs | 8 + .../net_consomme/consomme/src/static_ipv4.rs | 80 ++++++++++ .../net/net_consomme/consomme/src/tcp.rs | 7 +- .../net/net_consomme/consomme/src/tests.rs | 1 + .../consomme/src/tests/static_ipv4.rs | 144 ++++++++++++++++++ .../net/net_consomme/consomme/src/udp.rs | 6 +- vm/devices/net/net_consomme/src/resolver.rs | 7 + .../net_consomme/src/resolver/addressing.rs | 37 +++++ 13 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 vm/devices/net/net_backend_resources/src/consomme/static_ipv4.rs create mode 100644 vm/devices/net/net_consomme/consomme/src/static_ipv4.rs create mode 100644 vm/devices/net/net_consomme/consomme/src/tests/static_ipv4.rs create mode 100644 vm/devices/net/net_consomme/src/resolver/addressing.rs diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 58c90382347..fc510ff8031 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2357,8 +2357,12 @@ fn parse_endpoint( }; net_backend_resources::consomme::ConsommeHandle { cidr: cidr.clone(), + static_ipv4: None, ports, recv, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource() } diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 6375eab9609..1e5125acdc6 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1828,12 +1828,16 @@ fn parse_nic_config( } else { Some(consomme.cidr) }, + static_ipv4: None, ports: consomme .ports .into_iter() .map(parse_port_config) .collect::>()?, recv, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(), _ => anyhow::bail!("unsupported backend"), @@ -2436,11 +2440,15 @@ fn build_nic_backend( Kind::Consomme(vmservice::ConsommeBackend { cidr, ports }) => { net_backend_resources::consomme::ConsommeHandle { cidr: (!cidr.is_empty()).then_some(cidr), + static_ipv4: None, ports: ports .into_iter() .map(parse_port_config) .collect::>()?, recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource() } diff --git a/petri/src/vm/openvmm/modify.rs b/petri/src/vm/openvmm/modify.rs index 32a4bc611c1..d8a4f845ac8 100644 --- a/petri/src/vm/openvmm/modify.rs +++ b/petri/src/vm/openvmm/modify.rs @@ -116,8 +116,12 @@ impl PetriVmConfigOpenVmm { pub fn with_nic(mut self) -> Self { let endpoint = net_backend_resources::consomme::ConsommeHandle { cidr: None, + static_ipv4: None, ports: Vec::new(), recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(); if let Some(vtl2_settings) = self.runtime_config.vtl2_settings.as_mut() { @@ -162,8 +166,12 @@ impl PetriVmConfigOpenVmm { pub fn with_pcie_nic(mut self, port_name: &str, mac_address: MacAddress) -> Self { let endpoint = net_backend_resources::consomme::ConsommeHandle { cidr: None, + static_ipv4: None, ports: Vec::new(), recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(); self.config.pcie_devices.push(PcieDeviceConfig { @@ -212,8 +220,12 @@ impl PetriVmConfigOpenVmm { pub fn with_virtio_nic(mut self, port_name: &str, mac_address: MacAddress) -> Self { let endpoint = net_backend_resources::consomme::ConsommeHandle { cidr: None, + static_ipv4: None, ports: Vec::new(), recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(); @@ -244,6 +256,7 @@ impl PetriVmConfigOpenVmm { let (port_send, port_recv) = mesh::oneshot(); let endpoint = net_backend_resources::consomme::ConsommeHandle { cidr: None, + static_ipv4: None, ports: vec![net_backend_resources::consomme::HostPortConfig { protocol: net_backend_resources::consomme::HostPortProtocol::Tcp, host_address: Some(net_backend_resources::consomme::HostIpAddress::Ipv4( @@ -253,6 +266,9 @@ impl PetriVmConfigOpenVmm { guest_port: pipette_client::PIPETTE_PORT as u16, }], recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, } .into_resource(); self.config.pcie_devices.push(PcieDeviceConfig { diff --git a/vm/devices/net/net_backend_resources/src/consomme/static_ipv4.rs b/vm/devices/net/net_backend_resources/src/consomme/static_ipv4.rs new file mode 100644 index 00000000000..86ebe311695 --- /dev/null +++ b/vm/devices/net/net_backend_resources/src/consomme/static_ipv4.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Exact static IPv4 identity of a Consomme endpoint. +//! +//! A [`StaticIpv4Config`] in a [`ConsommeHandle`](super::ConsommeHandle) +//! replaces the CIDR-derived guest and gateway addresses with an exact +//! identity. + +use crate::mac_address::MacAddress; +use mesh::MeshPayload; + +/// Exact static IPv4 identity used by the microVM network profile. +#[derive(Clone, Debug, MeshPayload)] +pub struct StaticIpv4Config { + /// Guest IPv4 address. + pub guest_ipv4: std::net::Ipv4Addr, + /// IPv4 subnet prefix length. + pub prefix_length: u8, + /// Host gateway IPv4 address. + pub gateway_ipv4: std::net::Ipv4Addr, + /// Host gateway Ethernet address. + pub gateway_mac: MacAddress, +} diff --git a/vm/devices/net/net_backend_resources/src/lib.rs b/vm/devices/net/net_backend_resources/src/lib.rs index e86d4b0c90c..6eb030799f0 100644 --- a/vm/devices/net/net_backend_resources/src/lib.rs +++ b/vm/devices/net/net_backend_resources/src/lib.rs @@ -27,6 +27,8 @@ pub mod null { /// Consomme backend. pub mod consomme { + pub mod static_ipv4; + use mesh::MeshPayload; use vm_resource::ResourceId; use vm_resource::kind::NetEndpointHandleKind; @@ -108,6 +110,15 @@ pub mod consomme { pub ports: Vec, /// Optional channel for runtime port bind/unbind after the endpoint starts. pub recv: Option>, + /// Optional exact static identity, mutually exclusive with `cidr`. + pub static_ipv4: Option, + /// Override guest access to host-local destination addresses. + pub allow_host_local_access: Option, + /// Override translation of the guest gateway onto host loopback. + pub map_gateway_to_host_loopback: Option, + /// Exact gateway TCP port translated to host loopback even when the + /// general gateway mapping is disabled. + pub gateway_loopback_proxy_port: Option, } impl ResourceId for ConsommeHandle { diff --git a/vm/devices/net/net_consomme/consomme/src/lib.rs b/vm/devices/net/net_consomme/consomme/src/lib.rs index 0fc9ded96a0..11e12d4d22d 100644 --- a/vm/devices/net/net_consomme/consomme/src/lib.rs +++ b/vm/devices/net/net_consomme/consomme/src/lib.rs @@ -25,6 +25,7 @@ mod icmp; pub mod limits; mod local_addr_map; mod ndp; +pub mod static_ipv4; mod tcp; mod udp; @@ -191,6 +192,11 @@ pub struct ConsommeParams { /// If true, allow guest traffic destined for host-local addresses /// (loopback, unspecified, link-local). pub allow_host_local_access: bool, + /// If true, translate guest traffic to the IPv4 gateway onto host loopback. + pub map_gateway_to_host_loopback: bool, + /// Exact gateway TCP port translated to host loopback independently of the + /// general gateway mapping. + pub gateway_loopback_proxy_port: Option, /// Per-connection TCP receive ring buffer bounds (guest-to-host). pub tcp_rx_buffer: TcpBufferBounds, /// Per-connection TCP transmit ring buffer bounds (host-to-guest). @@ -258,6 +264,8 @@ impl ConsommeParams { tcp_close_timeout: Duration::from_secs(60), skip_ipv6_checks: false, allow_host_local_access: false, + map_gateway_to_host_loopback: false, + gateway_loopback_proxy_port: None, tcp_rx_buffer: DEFAULT_TCP_BUFFER_BOUNDS, tcp_tx_buffer: DEFAULT_TCP_BUFFER_BOUNDS, }) diff --git a/vm/devices/net/net_consomme/consomme/src/static_ipv4.rs b/vm/devices/net/net_consomme/consomme/src/static_ipv4.rs new file mode 100644 index 00000000000..52669b58db7 --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/static_ipv4.rs @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Exact static IPv4 identity and gateway loopback translation. +//! +//! [`ConsommeParams::set_static_ipv4`] replaces the default or CIDR-derived +//! addresses with an exact guest and gateway identity and enables the +//! translation of guest traffic to the gateway onto host loopback, which +//! [`ConsommeParams::map_gateway_to_host_loopback`] and +//! [`ConsommeParams::gateway_loopback_proxy_port`] control. + +use crate::ConsommeParams; +use crate::ConsommeState; +use smoltcp::wire::EthernetAddress; +use smoltcp::wire::IpProtocol; +use smoltcp::wire::Ipv4Address; +use std::net::Ipv4Addr; +use std::net::SocketAddr; +use std::net::SocketAddrV4; +use thiserror::Error; + +/// An error indicating that a static IPv4 identity is internally inconsistent. +#[derive(Debug, Error)] +#[error("invalid static IPv4 identity")] +pub struct InvalidStaticIpv4; + +impl ConsommeParams { + /// Sets an exact static IPv4 client and gateway identity. + pub fn set_static_ipv4( + &mut self, + guest_ipv4: Ipv4Addr, + prefix_length: u8, + gateway_ipv4: Ipv4Addr, + gateway_mac: [u8; 6], + ) -> Result<(), InvalidStaticIpv4> { + if !(1..=30).contains(&prefix_length) { + return Err(InvalidStaticIpv4); + } + let mask = u32::MAX << (32 - prefix_length); + let guest = u32::from(guest_ipv4); + let gateway = u32::from(gateway_ipv4); + let network = guest & mask; + let broadcast = network | !mask; + if guest == network || guest == broadcast || gateway != network + 1 || guest == gateway { + return Err(InvalidStaticIpv4); + } + + self.client_ip = Ipv4Address::from(guest_ipv4.octets()); + self.gateway_ip = Ipv4Address::from(gateway_ipv4.octets()); + self.net_mask = Ipv4Address::from(Ipv4Addr::from(mask).octets()); + self.gateway_mac = EthernetAddress(gateway_mac); + self.advertise_routable_ipv6 = false; + self.map_gateway_to_host_loopback = true; + Ok(()) + } +} + +impl ConsommeState { + /// Resolves the host destination of a guest flow of `protocol`. + /// + /// Guest traffic to the IPv4 gateway is translated onto host loopback when + /// the gateway mapping or the gateway proxy port allows it and is rejected + /// (`None`) otherwise. Other destinations are resolved as virtual mapped + /// addresses. + pub(crate) fn resolve_flow_destination( + &self, + addr: &SocketAddr, + protocol: IpProtocol, + ) -> Option { + if let SocketAddr::V4(address) = addr + && *address.ip() == self.params.gateway_ip + { + return (self.params.map_gateway_to_host_loopback + || (protocol == IpProtocol::Tcp + && self.params.gateway_loopback_proxy_port == Some(address.port()))) + .then(|| SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, address.port()))); + } + Some(self.resolve_destination(addr)) + } +} diff --git a/vm/devices/net/net_consomme/consomme/src/tcp.rs b/vm/devices/net/net_consomme/consomme/src/tcp.rs index a511abfda74..5a5df21d642 100644 --- a/vm/devices/net/net_consomme/consomme/src/tcp.rs +++ b/vm/devices/net/net_consomme/consomme/src/tcp.rs @@ -1110,7 +1110,12 @@ impl Access<'_, T> { } else { // Resolve virtual mapped addresses back to real host // addresses before establishing the connection. - let resolved_dst = sender.state.resolve_destination(&sender.ft.dst); + let Some(resolved_dst) = sender + .state + .resolve_flow_destination(&sender.ft.dst, IpProtocol::Tcp) + else { + return Err(DropReason::DestinationNotAllowed); + }; // If this is directed to a local port owned by the guest, use the // appropriate host port substitution. let is_local_address = sender.state.params.is_local_address(&resolved_dst); diff --git a/vm/devices/net/net_consomme/consomme/src/tests.rs b/vm/devices/net/net_consomme/consomme/src/tests.rs index c7039d497c6..579e20e9654 100644 --- a/vm/devices/net/net_consomme/consomme/src/tests.rs +++ b/vm/devices/net/net_consomme/consomme/src/tests.rs @@ -3,6 +3,7 @@ mod fragments; mod limits; +mod static_ipv4; use super::*; use pal_async::DefaultDriver; diff --git a/vm/devices/net/net_consomme/consomme/src/tests/static_ipv4.rs b/vm/devices/net/net_consomme/consomme/src/tests/static_ipv4.rs new file mode 100644 index 00000000000..7f8ac0f27ba --- /dev/null +++ b/vm/devices/net/net_consomme/consomme/src/tests/static_ipv4.rs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the exact static IPv4 identity and the gateway loopback +//! translation. + +use super::*; + +#[test] +fn static_ipv4_identity_is_exact_and_disables_ipv6_advertisement() { + let mut params = ConsommeParams::new().unwrap(); + params + .set_static_ipv4( + Ipv4Addr::new(192, 168, 5, 37), + 28, + Ipv4Addr::new(192, 168, 5, 33), + [0x52, 0x54, 0, 168, 5, 33], + ) + .unwrap(); + + assert_eq!(params.client_ip, Ipv4Address::new(192, 168, 5, 37)); + assert_eq!(params.gateway_ip, Ipv4Address::new(192, 168, 5, 33)); + assert_eq!(params.net_mask, Ipv4Address::new(255, 255, 255, 240)); + assert_eq!( + params.gateway_mac, + EthernetAddress([0x52, 0x54, 0, 168, 5, 33]) + ); + assert!(!params.advertise_routable_ipv6); + assert!(params.map_gateway_to_host_loopback); + + let consomme = Consomme::new(params); + for protocol in [IpProtocol::Tcp, IpProtocol::Udp] { + assert_eq!( + consomme + .state + .resolve_flow_destination(&"192.168.5.33:8080".parse().unwrap(), protocol), + Some("127.0.0.1:8080".parse().unwrap()) + ); + } +} + +#[test] +fn gateway_loopback_can_be_denied_with_one_proxy_exception() { + let mut params = ConsommeParams::new().unwrap(); + params + .set_static_ipv4( + Ipv4Addr::new(192, 168, 5, 37), + 28, + Ipv4Addr::new(192, 168, 5, 33), + [0x52, 0x54, 0, 168, 5, 33], + ) + .unwrap(); + params.map_gateway_to_host_loopback = false; + params.gateway_loopback_proxy_port = Some(8443); + let consomme = Consomme::new(params); + + for protocol in [IpProtocol::Tcp, IpProtocol::Udp] { + for port in [8442, 8443, 8444] { + let address = SocketAddr::from((Ipv4Addr::new(192, 168, 5, 33), port)); + let expected = (protocol == IpProtocol::Tcp && port == 8443) + .then_some(SocketAddr::from((Ipv4Addr::LOCALHOST, 8443))); + assert_eq!( + consomme.state.resolve_flow_destination(&address, protocol), + expected, + "{protocol:?} port {port}" + ); + } + } + assert_eq!( + consomme + .state + .resolve_flow_destination(&"192.0.2.1:8443".parse().unwrap(), IpProtocol::Udp), + Some("192.0.2.1:8443".parse().unwrap()) + ); +} + +#[pal_async::async_test] +async fn gateway_proxy_exception_rejects_udp(driver: DefaultDriver) { + let listener = std::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let proxy_port = listener.local_addr().unwrap().port(); + let mut params = ConsommeParams::new().unwrap(); + params.map_gateway_to_host_loopback = false; + params.gateway_loopback_proxy_port = Some(proxy_port); + let guest_mac = params.client_mac; + let gateway_mac = params.gateway_mac; + let guest_ip = params.client_ip; + let gateway_ip = params.gateway_ip; + let mut consomme = Consomme::new(params); + let mut client = TestClient::new(driver); + + for destination in [gateway_ip, Ipv4Addr::LOCALHOST] { + let mut buf = [0u8; 1514]; + let len = build_ipv4_dns_query( + &mut buf, + guest_mac, + gateway_mac, + guest_ip, + destination, + 44444, + b"proxy-exception-must-be-tcp", + ); + let mut ipv4 = Ipv4Packet::new_unchecked(&mut buf[ETHERNET_HEADER_LEN..len]); + let mut udp = UdpPacket::new_unchecked(ipv4.payload_mut()); + udp.set_dst_port(proxy_port); + udp.fill_checksum(&guest_ip.into(), &destination.into()); + let result = consomme + .access(&mut client) + .send(&buf[..len], &ChecksumState::NONE); + assert!( + matches!(result, Err(DropReason::DestinationNotAllowed)), + "UDP proxy exception must be rejected, got {result:?}" + ); + } + assert_eq!( + listener.recv(&mut [0; 64]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); +} + +#[test] +fn static_ipv4_identity_rejects_inconsistent_values() { + let mut params = ConsommeParams::new().unwrap(); + assert!( + params + .set_static_ipv4( + Ipv4Addr::new(10, 0, 0, 2), + 31, + Ipv4Addr::new(10, 0, 0, 1), + [0x52, 0x54, 0, 0, 0, 1], + ) + .is_err() + ); + assert!( + params + .set_static_ipv4( + Ipv4Addr::new(10, 0, 0, 2), + 24, + Ipv4Addr::new(10, 0, 0, 9), + [0x52, 0x54, 0, 0, 0, 9], + ) + .is_err() + ); +} diff --git a/vm/devices/net/net_consomme/consomme/src/udp.rs b/vm/devices/net/net_consomme/consomme/src/udp.rs index 03fc34ec7aa..7ad183ceed5 100644 --- a/vm/devices/net/net_consomme/consomme/src/udp.rs +++ b/vm/devices/net/net_consomme/consomme/src/udp.rs @@ -460,7 +460,11 @@ impl Access<'_, T> { }; // Resolve virtual mapped addresses back to the real host address. - let mut dst_sock_addr = self.inner.state.resolve_destination(&dst_sock_addr); + let mut dst_sock_addr = self + .inner + .state + .resolve_flow_destination(&dst_sock_addr, IpProtocol::Udp) + .ok_or(DropReason::DestinationNotAllowed)?; if self.inner.state.params.is_local_address(&dst_sock_addr) { // This packet is destined for a local address. If the port matches a listener, // translate it so that the connection loops back to the expected destination. diff --git a/vm/devices/net/net_consomme/src/resolver.rs b/vm/devices/net/net_consomme/src/resolver.rs index f41e7b60f3c..dd55f08c550 100644 --- a/vm/devices/net/net_consomme/src/resolver.rs +++ b/vm/devices/net/net_consomme/src/resolver.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod addressing; + use crate::ConsommeEndpoint; use crate::IpProtocol; use crate::PortForwardConfig; @@ -29,6 +31,10 @@ pub enum ResolveConsommeError { Consomme(consomme::Error), #[error(transparent)] InvalidCidr(consomme::InvalidCidr), + #[error(transparent)] + InvalidStaticIpv4(consomme::static_ipv4::InvalidStaticIpv4), + #[error("Consomme CIDR and exact static IPv4 configuration are mutually exclusive")] + ConflictingIpv4Configuration, #[error("failed to create socket for port forward ({details})")] SocketCreation { #[source] @@ -48,6 +54,7 @@ impl ResolveResource for ConsommeResolver ) -> Result { let mut state = ConsommeParams::new().map_err(ResolveConsommeError::Consomme)?; state.client_mac.0 = input.mac_address.to_bytes(); + addressing::configure(&mut state, &resource)?; if let Some(cidr) = &resource.cidr { state .set_cidr(cidr) diff --git a/vm/devices/net/net_consomme/src/resolver/addressing.rs b/vm/devices/net/net_consomme/src/resolver/addressing.rs new file mode 100644 index 00000000000..b86ab5ea8a5 --- /dev/null +++ b/vm/devices/net/net_consomme/src/resolver/addressing.rs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Static IPv4 addressing and host access overrides of Consomme resources. + +use super::ResolveConsommeError; +use consomme::ConsommeParams; +use net_backend_resources::consomme::ConsommeHandle; + +/// Applies the exact static IPv4 identity, which is mutually exclusive with a +/// CIDR, and the host access overrides of `resource` to `state`. +pub(super) fn configure( + state: &mut ConsommeParams, + resource: &ConsommeHandle, +) -> Result<(), ResolveConsommeError> { + if resource.cidr.is_some() && resource.static_ipv4.is_some() { + return Err(ResolveConsommeError::ConflictingIpv4Configuration); + } + if let Some(config) = &resource.static_ipv4 { + state + .set_static_ipv4( + config.guest_ipv4, + config.prefix_length, + config.gateway_ipv4, + config.gateway_mac.to_bytes(), + ) + .map_err(ResolveConsommeError::InvalidStaticIpv4)?; + } + if let Some(allow) = resource.allow_host_local_access { + state.allow_host_local_access = allow; + } + if let Some(map) = resource.map_gateway_to_host_loopback { + state.map_gateway_to_host_loopback = map; + } + state.gateway_loopback_proxy_port = resource.gateway_loopback_proxy_port; + Ok(()) +} From 5bed17bdcf86e84fea3a1c5a656b7fe6d1a5c0f2 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:13:04 -0700 Subject: [PATCH 079/304] virtio_net: persist drained queue state for static-identity NICs virtio-net claimed to support save and restore unconditionally, but the transport saved only queue progress: packets that the endpoint had accepted, or that the device held for the guest, were lost, and nothing tied a restored guest's view of its network identity to the endpoint. Saving a VM with virtio-net therefore silently corrupted the guest's network state. Save and restore are now opt-in. `NicBuilder::save_restore` binds a device to an exact static IPv4 identity and a fixed effective feature contract, and `VirtioNetHandle` gains `save_restore`, `static_ipv4`, and `effective_features` fields for resources; a resource that opts in must supply both. Other devices report that they do not support save and restore. When the transport stops an active queue pair of such a device, the device quiesces the endpoint queues, drains every completion the endpoint still holds so that no descriptor stays in flight, and captures the progress of both queues, which the transport then collects queue by queue. The device-private saved state, schema version 1, records the identity, the guest MAC address, the link state, the effective and negotiated feature banks, the endpoint offloads, the queue-pair lifecycle, and an endpoint generation that each restore advances. Restore validates it against the destination device: the identity must match and use MAC addresses derived from its IPv4 addresses, the effective features and endpoint offloads must be unchanged, the negotiated features must stay within the contract and match the saved banks, and the device must have a single queue pair whose restored queues own no descriptors. Restarting a stopped pair requires the same negotiated features. The device also implements `quiesce_input` and `resume_input`: while input is gated, the endpoint queues finish the work they already accepted but admit no new host receive traffic, and the gate is re-established whenever the queues restart. Workers are now started only by the coordinator, after it establishes the gate. Petri records the PCIe ports of virtio-net devices added without a save and restore contract and skips its startup save/restore test while any of them is configured. Every existing virtio-net resource leaves `save_restore` unset. Document that virtio-net supports save and restore only with a static identity and feature contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + Guide/src/dev_guide/snapshot_format.md | 4 +- Guide/src/user_guide/openvmm/snapshots.md | 2 +- openvmm/openvmm_entry/src/lib.rs | 3 + openvmm/openvmm_entry/src/ttrpc/mod.rs | 3 + petri/burette/src/tests/network.rs | 3 + petri/src/vm/openvmm/construct.rs | 1 + petri/src/vm/openvmm/mod.rs | 5 + petri/src/vm/openvmm/modify.rs | 67 ++- petri/src/vm/openvmm/start.rs | 12 +- vm/devices/virtio/virtio_net/Cargo.toml | 3 +- vm/devices/virtio/virtio_net/src/lib.rs | 54 +- vm/devices/virtio/virtio_net/src/quiesce.rs | 195 +++++++ vm/devices/virtio/virtio_net/src/resolver.rs | 1 + .../virtio/virtio_net/src/saved_state.rs | 543 ++++++++++++++++++ vm/devices/virtio/virtio_net/src/tests.rs | 6 + .../virtio/virtio_net/src/tests/harness.rs | 240 ++++++++ .../virtio/virtio_net/src/tests/quiesce.rs | 42 ++ .../virtio_net/src/tests/saved_state.rs | 210 +++++++ vm/devices/virtio/virtio_resources/src/lib.rs | 3 + vmm_tests/vmm_tests/tests/tests/x86_64.rs | 3 + 21 files changed, 1368 insertions(+), 33 deletions(-) create mode 100644 vm/devices/virtio/virtio_net/src/quiesce.rs create mode 100644 vm/devices/virtio/virtio_net/src/saved_state.rs create mode 100644 vm/devices/virtio/virtio_net/src/tests/harness.rs create mode 100644 vm/devices/virtio/virtio_net/src/tests/quiesce.rs create mode 100644 vm/devices/virtio/virtio_net/src/tests/saved_state.rs diff --git a/Cargo.lock b/Cargo.lock index 4d004e46c6b..2d36e602181 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10095,6 +10095,7 @@ dependencies = [ "anyhow", "async-trait", "bitfield-struct 0.11.0", + "chipset_device", "futures", "guestmem", "inspect", diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 77381aafa9d..edf8ad78a0b 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -146,7 +146,9 @@ Key unsupported categories: - **VGA / GDMA** — marked `todo!()` (will panic on save). - **Virtio devices** — the `VirtioDevice` trait defaults `supports_save_restore()` to `false`. `virtio-blk`, `virtio-console`, - `virtio-net`, `virtio-pmem`, and `virtio-rng` override it to `true`. + `virtio-pmem`, and `virtio-rng` override it to `true`. `virtio-net` enables + it only for resources with an explicit static identity and feature contract; + ordinary virtio-net resources remain disabled. The transport stores an opaque typed device-private payload in addition to common queue state. Devices with unsupported host-side session state (`virtio-9p`, `virtiofs`) leave save/restore disabled. diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 29cf031483e..6aefd617ad7 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -176,7 +176,7 @@ OpenVMM snapshots: | VMBus Keyboard / Mouse / Video | VMBus | Yes | | Guest Emulation Log | VMBus | Yes | | virtio-blk | Virtio (PCI/MMIO) | Yes | -| virtio-net | Virtio (PCI/MMIO) | Yes | +| virtio-net | Virtio (PCI/MMIO) | Static identity only | | virtio-pmem | Virtio (PCI/MMIO) | Yes | | virtio-rng | Virtio (PCI/MMIO) | Yes | | virtio-console | Virtio (PCI/MMIO) | Yes | diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index fc510ff8031..5b833296dde 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -1872,6 +1872,9 @@ async fn vm_config_from_command_line( max_queues: vport.max_queues, mac_address: vport.mac_address, endpoint: vport.endpoint, + save_restore: false, + static_ipv4: None, + effective_features: None, } .into_resource(); if let Some(pcie_port) = &cli_cfg.pcie_port { diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 1e5125acdc6..d23ce0390da 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -2341,6 +2341,9 @@ async fn build_virtio_device( .parse::() .context("invalid mac address")?, endpoint, + save_restore: false, + static_ipv4: None, + effective_features: None, } .into_resource() } diff --git a/petri/burette/src/tests/network.rs b/petri/burette/src/tests/network.rs index 2d281ee0171..d6714de584b 100644 --- a/petri/burette/src/tests/network.rs +++ b/petri/burette/src/tests/network.rs @@ -510,6 +510,9 @@ mod tap { max_queues: None, mac_address: TAP_MAC_ADDRESS, endpoint, + save_restore: false, + static_ipv4: None, + effective_features: None, } .into_resource(), ) diff --git a/petri/src/vm/openvmm/construct.rs b/petri/src/vm/openvmm/construct.rs index 1d9d231f4fc..6ee3d184b22 100644 --- a/petri/src/vm/openvmm/construct.rs +++ b/petri/src/vm/openvmm/construct.rs @@ -779,6 +779,7 @@ impl PetriVmConfigOpenVmm { framebuffer_view, pending_iommu: Vec::new(), + pcie_ports_without_save_restore: Vec::new(), }) } } diff --git a/petri/src/vm/openvmm/mod.rs b/petri/src/vm/openvmm/mod.rs index 06901959402..8125153c5d8 100644 --- a/petri/src/vm/openvmm/mod.rs +++ b/petri/src/vm/openvmm/mod.rs @@ -190,6 +190,11 @@ pub struct PetriVmConfigOpenVmm { // Deferred IOMMU configuration: (rc_name, iommu_config) pairs resolved // against pcie_root_complexes at VM start time. pending_iommu: Vec<(String, openvmm_defs::config::PcieIommuConfig)>, + + // PCIe ports of devices that were configured without save/restore + // support. The startup save/restore test is skipped while any of these + // ports has a device. + pcie_ports_without_save_restore: Vec, } /// Various channels and resources used to interact with the VM while it is running. struct PetriVmResourcesOpenVmm { diff --git a/petri/src/vm/openvmm/modify.rs b/petri/src/vm/openvmm/modify.rs index d8a4f845ac8..117d288a70b 100644 --- a/petri/src/vm/openvmm/modify.rs +++ b/petri/src/vm/openvmm/modify.rs @@ -217,6 +217,9 @@ impl PetriVmConfigOpenVmm { /// /// This exposes a virtio-net device on a PCIe root port, suitable for /// guests running virtio drivers (e.g. Linux with UEFI boot). + /// + /// The NIC does not support save/restore, so the VM skips its startup + /// save/restore test. pub fn with_virtio_nic(mut self, port_name: &str, mac_address: MacAddress) -> Self { let endpoint = net_backend_resources::consomme::ConsommeHandle { cidr: None, @@ -229,18 +232,17 @@ impl PetriVmConfigOpenVmm { } .into_resource(); - self.config.pcie_devices.push(PcieDeviceConfig { - port_name: port_name.to_string(), - resource: virtio_resources::VirtioPciDeviceHandle( - virtio_resources::net::VirtioNetHandle { - max_queues: None, - mac_address, - endpoint, - } - .into_resource(), - ) - .into_resource(), - }); + self.push_virtio_nic( + port_name, + virtio_resources::net::VirtioNetHandle { + max_queues: None, + mac_address, + endpoint, + save_restore: false, + static_ipv4: None, + effective_features: None, + }, + ); self } @@ -252,6 +254,9 @@ impl PetriVmConfigOpenVmm { /// This configures consomme to forward the pipette TCP port from the /// host into the guest, so the petri framework can connect to the /// pipette agent over TCP. + /// + /// The NIC does not support save/restore, so the VM skips its startup + /// save/restore test. pub fn with_tcp_pipette_nic(mut self, port_name: &str, mac_address: MacAddress) -> Self { let (port_send, port_recv) = mesh::oneshot(); let endpoint = net_backend_resources::consomme::ConsommeHandle { @@ -271,20 +276,36 @@ impl PetriVmConfigOpenVmm { gateway_loopback_proxy_port: None, } .into_resource(); + self.push_virtio_nic( + port_name, + virtio_resources::net::VirtioNetHandle { + max_queues: None, + mac_address, + endpoint, + save_restore: false, + static_ipv4: None, + effective_features: None, + }, + ); + self.resources.tcp_pipette_port = Some(port_recv); + self + } + + /// Add a virtio-net device on a PCIe root port. + /// + /// A virtio-net device supports save/restore only when its handle opts in + /// with a static IPv4 identity and a fixed feature contract. Saving a VM + /// with any other virtio-net device fails, so record the port to make the + /// VM skip its startup save/restore test while that device is configured. + fn push_virtio_nic(&mut self, port_name: &str, nic: virtio_resources::net::VirtioNetHandle) { + if !nic.save_restore { + self.pcie_ports_without_save_restore + .push(port_name.to_string()); + } self.config.pcie_devices.push(PcieDeviceConfig { port_name: port_name.to_string(), - resource: virtio_resources::VirtioPciDeviceHandle( - virtio_resources::net::VirtioNetHandle { - max_queues: None, - mac_address, - endpoint, - } - .into_resource(), - ) - .into_resource(), + resource: virtio_resources::VirtioPciDeviceHandle(nic.into_resource()).into_resource(), }); - self.resources.tcp_pipette_port = Some(port_recv); - self } /// Request nested virtualization support from the host hypervisor. diff --git a/petri/src/vm/openvmm/start.rs b/petri/src/vm/openvmm/start.rs index b6b4044e20a..4f02ed9629f 100644 --- a/petri/src/vm/openvmm/start.rs +++ b/petri/src/vm/openvmm/start.rs @@ -46,6 +46,7 @@ impl PetriVmConfigOpenVmm { framebuffer_view, pending_iommu, + pcie_ports_without_save_restore, } = self; // Resolve deferred IOMMU assignments. @@ -64,10 +65,13 @@ impl PetriVmConfigOpenVmm { // TODO: VPCI and some PCIe endpoints (NVMe/GDMA) don't support // TODO: virtio vsock doesn't support save/restore yet // save/restore yet. - let has_unsupported_pcie_save_restore_device = config - .pcie_devices - .iter() - .any(|device| matches!(device.resource.id(), "nvme" | "gdma")); + // Other PCIe devices that do not support save/restore, such as + // virtio-net NICs without a save/restore contract, are recorded by + // port when they are added. + let has_unsupported_pcie_save_restore_device = config.pcie_devices.iter().any(|device| { + matches!(device.resource.id(), "nvme" | "gdma") + || pcie_ports_without_save_restore.contains(&device.port_name) + }); let supports_save_restore = !resources.properties.is_openhcl && !resources.properties.is_pcat && !matches!(arch, MachineArch::Aarch64) diff --git a/vm/devices/virtio/virtio_net/Cargo.toml b/vm/devices/virtio/virtio_net/Cargo.toml index f954aac4ff1..7fb13c7c762 100644 --- a/vm/devices/virtio/virtio_net/Cargo.toml +++ b/vm/devices/virtio/virtio_net/Cargo.toml @@ -11,6 +11,7 @@ vm_resource.workspace = true inspect.workspace = true inspect_counters.workspace = true +mesh.workspace = true net_backend.workspace = true net_backend_resources.workspace = true pal_async.workspace = true @@ -30,8 +31,8 @@ tracing.workspace = true zerocopy.workspace = true [dev-dependencies] +chipset_device.workspace = true futures.workspace = true -mesh.workspace = true pal_event.workspace = true parking_lot.workspace = true test_with_tracing.workspace = true diff --git a/vm/devices/virtio/virtio_net/src/lib.rs b/vm/devices/virtio/virtio_net/src/lib.rs index e632711ac85..a018b2a8a4d 100644 --- a/vm/devices/virtio/virtio_net/src/lib.rs +++ b/vm/devices/virtio/virtio_net/src/lib.rs @@ -12,7 +12,9 @@ #![forbid(unsafe_code)] mod buffers; +mod quiesce; pub mod resolver; +mod saved_state; #[cfg(test)] mod tests; @@ -40,6 +42,7 @@ use net_backend_resources::mac_address::MacAddress; use pal_async::wait::PolledWait; use std::future::pending; use std::mem::offset_of; +use std::ops::ControlFlow; use std::sync::Arc; use std::task::Poll; use task_control::AsyncRun; @@ -53,10 +56,14 @@ use virtio::QueueResources; use virtio::VirtioDevice; use virtio::VirtioQueue; use virtio::VirtioQueueCallbackWork; +use virtio::device::saved_state::DeviceStateValidator; use virtio::in_order::InOrderCompletion; use virtio::queue::QueueCompletion; use virtio::queue::QueueState; use virtio::spec::VirtioDeviceFeatures; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; use vmcore::vm_task::VmTaskDriver; use vmcore::vm_task::VmTaskDriverSource; use zerocopy::FromBytes; @@ -226,6 +233,7 @@ struct Adapter { tx_fast_completions: bool, mac_address: MacAddress, tx_offload_support: TxOffloadSupport, + save_restore: Option, } pub struct Device { @@ -235,6 +243,7 @@ pub struct Device { driver_source: VmTaskDriverSource, /// Per-pair state tracking. pairs: Vec, + lifecycle: saved_state::Lifecycle, } /// Tracks the state of a queue pair through the start_queue lifecycle. @@ -248,9 +257,15 @@ enum QueuePairState { queue_size: u16, /// true if this is the RX queue (even index), false if TX (odd). is_rx: bool, + feature_banks: [u32; 2], }, /// Both queues started, worker running. Active, + /// Both queues stopped; each transport call takes its corresponding state. + Stopped { + rx: Option, + tx: Option, + }, } impl VirtioDevice for Device { @@ -342,13 +357,15 @@ impl VirtioDevice for Device { let pair_idx = (idx / 2) as usize; let is_rx = idx.is_multiple_of(2); + self.prepare_queue_start(pair_idx, is_rx, features)?; match &self.pairs[pair_idx] { - QueuePairState::Empty => { + QueuePairState::Empty | QueuePairState::Stopped { .. } => { // First queue of the pair — buffer it. self.pairs[pair_idx] = QueuePairState::HalfOpen { queue, queue_size, is_rx, + feature_banks: [features.bank(0), features.bank(1)], }; } QueuePairState::HalfOpen { @@ -373,6 +390,7 @@ impl VirtioDevice for Device { queue: pending_queue, queue_size: pending_queue_size, is_rx: pending_is_rx, + feature_banks: _, } = prev else { unreachable!() @@ -416,6 +434,9 @@ impl VirtioDevice for Device { } async fn stop_queue(&mut self, idx: u16) -> Option { + if let ControlFlow::Break(state) = self.stop_saved_queue(idx).await { + return state; + } let pair_idx = (idx / 2) as usize; if pair_idx < self.pairs.len() { @@ -447,10 +468,31 @@ impl VirtioDevice for Device { async fn reset(&mut self) { self.pairs.fill_with(|| QueuePairState::Empty); + self.lifecycle.reset(); + } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.quiesce_endpoint_input().await + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.resume_endpoint_input().await } fn supports_save_restore(&self) -> bool { - true + self.adapter.save_restore.is_some() + } + + fn save_device(&mut self) -> Result, SaveError> { + self.save_private_state() + } + + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + self.restore_private_state(state) + } + + fn device_state_validator(&self) -> DeviceStateValidator { + self.private_state_validator() } } @@ -532,6 +574,7 @@ struct PendingTxPacket { pub struct NicBuilder { max_queue_pairs: u16, + save_restore: Option, } impl NicBuilder { @@ -576,6 +619,7 @@ impl NicBuilder { tx_fast_completions: endpoint.tx_fast_completions(), mac_address, tx_offload_support, + save_restore: self.save_restore, }); let coordinator = TaskControl::new(CoordinatorState { @@ -603,6 +647,7 @@ impl NicBuilder { pairs: (0..max_queue_pairs) .map(|_| QueuePairState::Empty) .collect(), + lifecycle: Default::default(), }) } } @@ -611,6 +656,7 @@ impl Device { pub fn builder() -> NicBuilder { NicBuilder { max_queue_pairs: !0, + save_restore: None, } } } @@ -632,6 +678,7 @@ impl Device { .collect(), num_queues, restart: true, + input_quiesced: self.lifecycle.input_quiesced, }, ); } @@ -671,7 +718,6 @@ impl Device { let coordinator = self.coordinator.state_mut().unwrap(); let worker_task = &mut coordinator.workers[idx]; worker_task.insert(&driver, "virtio-net".to_string(), worker); - worker_task.start(); } } @@ -679,6 +725,7 @@ struct Coordinator { workers: Vec>, num_queues: u16, restart: bool, + input_quiesced: bool, } struct CoordinatorState { @@ -739,6 +786,7 @@ impl Coordinator { "failed to restart queues" ); } + self.establish_input_gate(stop).await?; self.restart = false; } self.start_workers(); diff --git a/vm/devices/virtio/virtio_net/src/quiesce.rs b/vm/devices/virtio/virtio_net/src/quiesce.rs new file mode 100644 index 00000000000..ccec542718f --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/quiesce.rs @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Input quiesce of the virtio-net queue workers. +//! +//! Quiescing input stops the coordinator and the workers, lets each endpoint +//! queue finish the work it already accepted while it stops admitting new host +//! RX, drains the resulting completions, and restarts the workers. The +//! coordinator latches the input gate and re-establishes it whenever it +//! restarts the endpoint queues; resuming input releases it. Only devices built +//! with [`NicBuilder::save_restore`](crate::NicBuilder::save_restore) support +//! input quiesce. + +use crate::Coordinator; +use crate::Device; +use crate::EndpointQueueState; +use crate::Worker; +use crate::WorkerError; +use anyhow::Context as _; +use std::future::pending; +use task_control::StopTask; + +impl Device { + /// Implements `quiesce_input`. + pub(crate) async fn quiesce_endpoint_input(&mut self) -> anyhow::Result<()> { + anyhow::ensure!( + self.adapter.save_restore.is_some(), + "virtio-net input quiesce is unavailable without saved-state configuration" + ); + self.lifecycle.input_quiesced = true; + self.quiesce_active_workers(true).await + } + + /// Implements `resume_input`. + pub(crate) async fn resume_endpoint_input(&mut self) -> anyhow::Result<()> { + self.resume_quiesced_input().await?; + self.lifecycle.input_quiesced = false; + Ok(()) + } + + pub(crate) async fn quiesce_active_workers(&mut self, restart: bool) -> anyhow::Result<()> { + if !self.coordinator.is_running() && self.coordinator.state().is_none() { + return Ok(()); + } + self.coordinator.stop().await; + { + let coordinator = self + .coordinator + .state_mut() + .context("virtio-net coordinator state is unavailable")?; + coordinator.input_quiesced = self.lifecycle.input_quiesced; + for worker in &mut coordinator.workers { + worker.stop().await; + } + for worker in &mut coordinator.workers { + let (queue, state) = worker.get_mut(); + let state = + state.context("virtio-net worker state is unavailable during quiesce")?; + if let Some(queue) = queue.state.as_mut() { + state + .quiesce_endpoint(queue) + .await + .map_err(anyhow::Error::new)?; + } + } + if restart { + for worker in &mut coordinator.workers { + worker.start(); + } + } + } + if restart { + self.coordinator.start(); + } + Ok(()) + } + + async fn resume_quiesced_input(&mut self) -> anyhow::Result<()> { + if !self.coordinator.is_running() && self.coordinator.state().is_none() { + return Ok(()); + } + self.coordinator.stop().await; + { + let coordinator = self + .coordinator + .state_mut() + .context("virtio-net coordinator state is unavailable")?; + coordinator.input_quiesced = false; + for worker in &mut coordinator.workers { + worker.stop().await; + } + for worker in &mut coordinator.workers { + let (queue, state) = worker.get_mut(); + let state = state.context("virtio-net worker state is unavailable")?; + if let Some(queue) = queue.state.as_mut() { + state.resume_endpoint(queue)?; + } + worker.start(); + } + } + self.coordinator.start(); + Ok(()) + } +} + +impl Coordinator { + /// Re-establishes a latched input gate after the endpoint queues restart. + /// + /// If the gate cannot be established, waits until the coordinator is + /// stopped instead of starting the workers. + pub(crate) async fn establish_input_gate( + &mut self, + stop: &mut StopTask<'_>, + ) -> Result<(), task_control::Cancelled> { + if self.input_quiesced + && let Err(err) = stop.until_stopped(self.quiesce_workers()).await? + { + tracing::error!( + error = %err, + "failed to establish virtio-net input gate" + ); + stop.until_stopped(pending::<()>()).await?; + } + Ok(()) + } + + async fn quiesce_workers(&mut self) -> anyhow::Result<()> { + for worker in &mut self.workers { + let (queue, state) = worker.get_mut(); + let state = state.context("virtio-net worker state is unavailable")?; + if let Some(queue) = queue.state.as_mut() { + state + .quiesce_endpoint(queue) + .await + .map_err(anyhow::Error::new)?; + } + } + Ok(()) + } +} + +impl Worker { + async fn quiesce_endpoint( + &mut self, + queue_state: &mut EndpointQueueState, + ) -> Result<(), WorkerError> { + for _ in 0..=usize::from(self.virtio_state.tx_queue_size) { + while self.transmit_pending_segments(queue_state)? {} + + queue_state + .queue + .quiesce(&mut self.active_state.pending_rx_packets) + .await + .map_err(WorkerError::Endpoint)?; + self.process_endpoint_rx(queue_state.queue.as_mut())?; + self.process_endpoint_tx(queue_state.queue.as_mut())?; + + if self.active_state.data.tx_segments.is_empty() + && self + .active_state + .pending_tx_packets + .iter() + .all(Option::is_none) + { + let ownership = queue_state + .queue + .quiesce(&mut self.active_state.pending_rx_packets) + .await + .map_err(WorkerError::Endpoint)?; + if ownership.rx_ready != 0 || ownership.tx_ready != 0 { + return Err(WorkerError::Endpoint(anyhow::anyhow!( + "network endpoint retained completions after quiesce drain" + ))); + } + return Ok(()); + } + } + Err(WorkerError::Endpoint(anyhow::anyhow!( + "network endpoint did not drain within the queue-size bound" + ))) + } + + fn resume_endpoint(&mut self, queue_state: &mut EndpointQueueState) -> anyhow::Result<()> { + queue_state.queue.resume()?; + let count = self + .active_state + .pending_rx_packets + .fill_ready(&mut self.active_state.data.rx_ready); + queue_state.queue.rx_avail( + &mut self.active_state.pending_rx_packets, + &self.active_state.data.rx_ready[..count], + ); + Ok(()) + } +} diff --git a/vm/devices/virtio/virtio_net/src/resolver.rs b/vm/devices/virtio/virtio_net/src/resolver.rs index c91451f7477..87fee532481 100644 --- a/vm/devices/virtio/virtio_net/src/resolver.rs +++ b/vm/devices/virtio/virtio_net/src/resolver.rs @@ -37,6 +37,7 @@ impl AsyncResolveResource for VirtioNetReso if let Some(max_queues) = resource.max_queues { builder = builder.max_queues(max_queues); } + builder = builder.save_restore_resource(&resource)?; let endpoint = resolver .resolve( diff --git a/vm/devices/virtio/virtio_net/src/saved_state.rs b/vm/devices/virtio/virtio_net/src/saved_state.rs new file mode 100644 index 00000000000..6f967f2104c --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/saved_state.rs @@ -0,0 +1,543 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Saved state of a virtio-net device built for save and restore. +//! +//! [`NicBuilder::save_restore`] binds the device to an exact static IPv4 +//! identity and an effective feature contract. When the transport stops an +//! active queue pair, the device quiesces the endpoint and captures the +//! drained queue progress, which the transport then collects queue by queue. +//! The device-private [`SavedState`] records the identity, the feature +//! contract and the queue-pair lifecycle, and is validated against the +//! destination device before it is restored. + +use crate::Adapter; +use crate::Device; +use crate::NetStatus; +use crate::NicBuilder; +use crate::QueuePairState; +use crate::Worker; +use mesh::payload::Protobuf; +use net_backend::TxOffloadSupport; +use net_backend_resources::consomme::static_ipv4::StaticIpv4Config; +use std::ops::ControlFlow; +use virtio::device::saved_state::DeviceQueueState; +use virtio::device::saved_state::DeviceStateValidator; +use virtio::queue::QueueState; +use virtio::spec::VirtioDeviceFeatures; +use virtio_resources::net::VirtioNetHandle; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; +use vmcore::save_restore::SavedStateRoot; + +const SAVED_STATE_VERSION: u32 = 1; + +/// Save and restore configuration of a device built with +/// [`NicBuilder::save_restore`]. +pub(crate) struct SaveRestoreConfig { + static_ipv4: StaticIpv4Config, + effective_features: u64, +} + +/// Queue-pair stop, quiesce and restore bookkeeping of a [`Device`]. +#[derive(Default)] +pub(crate) struct Lifecycle { + stop_error: Option, + stopped_feature_banks: Option<[u32; 2]>, + stopped_queue_count: u32, + pub(crate) endpoint_generation: u64, + pub(crate) input_quiesced: bool, +} + +impl Lifecycle { + /// Clears the queue-pair state on device reset. The endpoint generation is + /// kept. + pub(crate) fn reset(&mut self) { + self.stop_error = None; + self.stopped_feature_banks = None; + self.stopped_queue_count = 0; + self.input_quiesced = false; + } +} + +impl NicBuilder { + /// Enables save and restore for a device with the given exact static IPv4 + /// identity and effective feature contract. + pub fn save_restore(mut self, static_ipv4: StaticIpv4Config, effective_features: u64) -> Self { + self.save_restore = Some(SaveRestoreConfig { + static_ipv4, + effective_features, + }); + self + } + + /// Applies the save and restore configuration of a virtio-net resource. + pub(crate) fn save_restore_resource(self, resource: &VirtioNetHandle) -> anyhow::Result { + if !resource.save_restore { + return Ok(self); + } + Ok(self.save_restore( + resource + .static_ipv4 + .clone() + .ok_or_else(|| anyhow::anyhow!("saved virtio-net requires static IPv4 identity"))?, + resource.effective_features.ok_or_else(|| { + anyhow::anyhow!("saved virtio-net requires an effective feature contract") + })?, + )) + } +} + +impl Device { + /// Checks and resets the saved queue-pair state before `start_queue` + /// starts queue `pair_idx`/`is_rx`. + pub(crate) fn prepare_queue_start( + &mut self, + pair_idx: usize, + is_rx: bool, + features: &VirtioDeviceFeatures, + ) -> anyhow::Result<()> { + let feature_banks = [features.bank(0), features.bank(1)]; + match self.pairs.get(pair_idx) { + Some(QueuePairState::Empty) => { + self.lifecycle.stopped_queue_count = 0; + } + Some(QueuePairState::Stopped { rx, tx }) => { + anyhow::ensure!( + rx.is_none() && tx.is_none(), + "cannot restart virtio-net before all stopped queue states are collected" + ); + if let Some(previous) = self.lifecycle.stopped_feature_banks { + anyhow::ensure!( + previous == feature_banks, + "virtio-net negotiated features changed while restarting queues" + ); + } + self.lifecycle.stopped_queue_count = 0; + } + Some(QueuePairState::HalfOpen { + is_rx: pending_is_rx, + feature_banks: pending_feature_banks, + .. + }) if *pending_is_rx != is_rx => { + anyhow::ensure!( + *pending_feature_banks == feature_banks, + "virtio-net queue pair negotiated different features" + ); + } + _ => {} + } + Ok(()) + } + + /// Stops queue `idx` when its state is kept: a started half-open queue, an + /// active pair of a device built for save and restore, or a stopped pair. + /// + /// Returns [`ControlFlow::Continue`] when `stop_queue` must stop the queue + /// itself. + pub(crate) async fn stop_saved_queue(&mut self, idx: u16) -> ControlFlow> { + let pair_idx = (idx / 2) as usize; + if pair_idx >= self.pairs.len() { + return ControlFlow::Continue(()); + } + match self.pairs[pair_idx] { + QueuePairState::HalfOpen { is_rx, .. } if is_rx == idx.is_multiple_of(2) => { + let previous = std::mem::replace(&mut self.pairs[pair_idx], QueuePairState::Empty); + let QueuePairState::HalfOpen { + queue, + feature_banks, + .. + } = previous + else { + unreachable!() + }; + self.lifecycle.stopped_feature_banks = Some(feature_banks); + self.lifecycle.stopped_queue_count = 1; + ControlFlow::Break(Some(queue.queue_state())) + } + QueuePairState::Active if self.adapter.save_restore.is_some() => { + if let Err(error) = self.capture_stopped_pairs().await { + self.lifecycle.stop_error = Some(error); + return ControlFlow::Break(None); + } + ControlFlow::Break(self.take_stopped_queue(idx)) + } + QueuePairState::Stopped { .. } => ControlFlow::Break(self.take_stopped_queue(idx)), + _ => ControlFlow::Continue(()), + } + } + + fn take_stopped_queue(&mut self, idx: u16) -> Option { + let pair_idx = (idx / 2) as usize; + match self.pairs.get_mut(pair_idx) { + Some(QueuePairState::Stopped { rx, tx }) => { + if idx.is_multiple_of(2) { + rx.take() + } else { + tx.take() + } + } + _ => None, + } + } + + async fn capture_stopped_pairs(&mut self) -> anyhow::Result<()> { + self.quiesce_active_workers(false).await?; + let mut coordinator = self.coordinator.remove(); + self.lifecycle.stopped_queue_count = 0; + for (pair_index, worker) in coordinator.workers.iter_mut().enumerate() { + worker.task_mut().state.take(); + let worker = worker.remove(); + let (rx, tx, feature_banks) = worker.into_stopped_state()?; + if let Some(previous) = self.lifecycle.stopped_feature_banks { + anyhow::ensure!( + previous == feature_banks, + "virtio-net queue pairs negotiated different features" + ); + } else { + self.lifecycle.stopped_feature_banks = Some(feature_banks); + } + self.pairs[pair_index] = QueuePairState::Stopped { + rx: Some(rx), + tx: Some(tx), + }; + self.lifecycle.stopped_queue_count += 2; + } + Ok(()) + } + + /// Implements `save_device`. + pub(crate) fn save_private_state(&mut self) -> Result, SaveError> { + if let Some(error) = self.lifecycle.stop_error.take() { + return Err(SaveError::Other(error)); + } + let config = self.adapter.save_restore.as_ref().ok_or_else(|| { + SaveError::Other(anyhow::anyhow!( + "virtio-net static IPv4 identity is unavailable" + )) + })?; + let static_ipv4 = &config.static_ipv4; + let effective_features = config.effective_features; + if !self.pairs.iter().all(|pair| { + matches!( + pair, + QueuePairState::Empty | QueuePairState::Stopped { rx: None, tx: None } + ) + }) { + return Err(SaveError::Other(anyhow::anyhow!( + "virtio-net queues are still running" + ))); + } + let negotiated = match self.lifecycle.stopped_queue_count { + 0 => [0, 0], + 1 | 2 => self.lifecycle.stopped_feature_banks.ok_or_else(|| { + SaveError::Other(anyhow::anyhow!( + "virtio-net stopped queue features are unavailable" + )) + })?, + count => { + return Err(SaveError::Other(anyhow::anyhow!( + "virtio-net stopped queue count {count} is invalid" + ))); + } + }; + Ok(Some(SavedStateBlob::new(SavedState { + schema_version: SAVED_STATE_VERSION, + guest_ipv4: u32::from(static_ipv4.guest_ipv4), + prefix_length: u32::from(static_ipv4.prefix_length), + gateway_ipv4: u32::from(static_ipv4.gateway_ipv4), + guest_mac: self.adapter.mac_address.to_bytes().to_vec(), + gateway_mac: static_ipv4.gateway_mac.to_bytes().to_vec(), + link_up: NetStatus::from(self.registers.status).link_up(), + effective_feature_bank0: effective_features as u32, + effective_feature_bank1: (effective_features >> 32) as u32, + negotiated_feature_bank0: negotiated[0], + negotiated_feature_bank1: negotiated[1], + endpoint_offload_bits: endpoint_offload_bits(self.adapter.tx_offload_support), + queue_pair_lifecycle: self.lifecycle.stopped_queue_count, + rx_in_order_outstanding: 0, + tx_in_order_outstanding: 0, + pending_rx_packet_count: 0, + pending_rx_payload_bytes: 0, + pending_tx_packet_count: 0, + pending_tx_payload_bytes: 0, + endpoint_generation: self.lifecycle.endpoint_generation, + }))) + } + + /// Implements `restore_device`. + pub(crate) fn restore_private_state( + &mut self, + state: Option, + ) -> Result<(), RestoreError> { + let saved = validate_saved_state(state.as_ref(), &self.adapter, None, None)?; + self.registers.status = NetStatus::new().with_link_up(saved.link_up).into(); + self.lifecycle.endpoint_generation = saved + .endpoint_generation + .checked_add(1) + .ok_or_else(|| invalid_saved_state("virtio-net endpoint generation overflowed"))?; + self.lifecycle.stopped_feature_banks = (saved.queue_pair_lifecycle != 0).then_some([ + saved.negotiated_feature_bank0, + saved.negotiated_feature_bank1, + ]); + self.lifecycle.stopped_queue_count = saved.queue_pair_lifecycle; + Ok(()) + } + + /// Implements `device_state_validator`. + pub(crate) fn private_state_validator(&self) -> DeviceStateValidator { + let adapter = self.adapter.clone(); + Box::new(move |state, features, queues, _guest_memory| { + validate_saved_state(state, &adapter, Some(features), Some(queues))?; + Ok(()) + }) + } +} + +impl Worker { + fn into_stopped_state(mut self) -> anyhow::Result<(QueueState, QueueState, [u32; 2])> { + anyhow::ensure!( + self.active_state.data.tx_segments.is_empty() + && self + .active_state + .pending_tx_packets + .iter() + .all(Option::is_none) + && self.virtio_state.tx_in_order.outstanding() == 0, + "virtio-net TX ownership remained after endpoint quiesce" + ); + + // The number of guest RX descriptors currently owned by the device. + let pending_rx = self + .active_state + .pending_rx_packets + .fill_ready(&mut self.active_state.data.rx_ready); + anyhow::ensure!( + pending_rx == self.virtio_state.rx_in_order.outstanding(), + "virtio-net RX ownership does not match its completion cursor" + ); + let current_rx = self.virtio_state.rx_queue.queue_state(); + anyhow::ensure!( + usize::from(current_rx.avail_index.wrapping_sub(current_rx.used_index)) == pending_rx, + "virtio-net RX queue cursors do not match pending descriptor ownership" + ); + let rx = QueueState { + avail_index: current_rx.used_index, + used_index: current_rx.used_index, + }; + + let tx = self.virtio_state.tx_queue.queue_state(); + anyhow::ensure!( + tx.avail_index == tx.used_index, + "virtio-net TX queue retained an incomplete descriptor" + ); + Ok(( + rx, + tx, + [ + self.negotiated_features.into_bits(), + self.negotiated_features_bank1.into_bits(), + ], + )) + } +} + +fn endpoint_offload_bits(offloads: TxOffloadSupport) -> u32 { + u32::from(offloads.ipv4_header) + | (u32::from(offloads.tcp) << 1) + | (u32::from(offloads.udp) << 2) + | (u32::from(offloads.tso) << 3) + | (u32::from(offloads.uso) << 4) +} + +fn derived_mac(address: std::net::Ipv4Addr) -> [u8; 6] { + let [_, second, third, fourth] = address.octets(); + [0x52, 0x54, 0, second, third, fourth] +} + +fn invalid_saved_state(message: impl Into) -> RestoreError { + RestoreError::InvalidSavedState(anyhow::anyhow!(message.into())) +} + +fn validate_saved_state( + state: Option<&SavedStateBlob>, + adapter: &Adapter, + features: Option<&VirtioDeviceFeatures>, + queues: Option<&[DeviceQueueState]>, +) -> Result { + let state = state.ok_or_else(|| invalid_saved_state("missing virtio-net private state"))?; + let saved: SavedState = state.parse()?; + if saved.schema_version != SAVED_STATE_VERSION { + return Err(invalid_saved_state(format!( + "unsupported virtio-net schema version {}", + saved.schema_version + ))); + } + + let config = adapter + .save_restore + .as_ref() + .ok_or_else(|| invalid_saved_state("virtio-net static IPv4 identity is unavailable"))?; + let static_ipv4 = &config.static_ipv4; + let effective_features = config.effective_features; + if saved.guest_ipv4 != u32::from(static_ipv4.guest_ipv4) + || saved.prefix_length != u32::from(static_ipv4.prefix_length) + || saved.gateway_ipv4 != u32::from(static_ipv4.gateway_ipv4) + || saved.guest_mac != adapter.mac_address.to_bytes() + || saved.gateway_mac != static_ipv4.gateway_mac.to_bytes() + { + return Err(invalid_saved_state( + "virtio-net static identity does not match the destination", + )); + } + if saved.guest_mac != derived_mac(static_ipv4.guest_ipv4) + || saved.gateway_mac != derived_mac(static_ipv4.gateway_ipv4) + { + return Err(invalid_saved_state( + "virtio-net static identity is not canonical", + )); + } + if !(1..=30).contains(&static_ipv4.prefix_length) { + return Err(invalid_saved_state("virtio-net prefix is out of range")); + } + let mask = u32::MAX << (32 - static_ipv4.prefix_length); + let guest = u32::from(static_ipv4.guest_ipv4); + let network = guest & mask; + if u32::from(static_ipv4.gateway_ipv4) != network + 1 + || guest == network + || guest == (network | !mask) + || static_ipv4.guest_ipv4 == static_ipv4.gateway_ipv4 + { + return Err(invalid_saved_state( + "virtio-net IPv4 identity is internally inconsistent", + )); + } + + if saved.effective_feature_bank0 != effective_features as u32 + || saved.effective_feature_bank1 != (effective_features >> 32) as u32 + || saved.endpoint_offload_bits != endpoint_offload_bits(adapter.tx_offload_support) + { + return Err(invalid_saved_state( + "virtio-net feature or endpoint capability contract changed", + )); + } + if saved.queue_pair_lifecycle > 2 + || saved.rx_in_order_outstanding != 0 + || saved.tx_in_order_outstanding != 0 + || saved.pending_rx_packet_count != 0 + || saved.pending_rx_payload_bytes != 0 + || saved.pending_tx_packet_count != 0 + || saved.pending_tx_payload_bytes != 0 + { + return Err(invalid_saved_state( + "virtio-net saved packet ownership is not fully drained", + )); + } + + if let Some(features) = features { + if features.ring_packed() || (features.into_bits() & !effective_features) != 0 { + return Err(invalid_saved_state( + "virtio-net negotiated features violate the device contract", + )); + } + if saved.queue_pair_lifecycle == 0 { + if saved.negotiated_feature_bank0 != 0 || saved.negotiated_feature_bank1 != 0 { + return Err(invalid_saved_state( + "virtio-net inactive queues retain negotiated feature state", + )); + } + } else if saved.negotiated_feature_bank0 != features.bank(0) + || saved.negotiated_feature_bank1 != features.bank(1) + { + return Err(invalid_saved_state( + "virtio-net negotiated features do not match saved state", + )); + } + } + if let Some(queues) = queues { + if queues.len() != 2 { + return Err(invalid_saved_state( + "virtio-net saved state requires exactly one queue pair", + )); + } + let started_count = queues + .iter() + .filter(|queue| queue.params.enable && queue.queue_state.is_some()) + .count(); + if started_count != usize::try_from(saved.queue_pair_lifecycle).unwrap_or(usize::MAX) { + return Err(invalid_saved_state( + "virtio-net queue lifecycle does not match transport state", + )); + } + for queue in queues { + if !queue.params.enable && queue.queue_state.is_some() { + return Err(invalid_saved_state( + "virtio-net disabled queue retains progress state", + )); + } + if !queue.params.enable { + continue; + } + if queue.params.size == 0 || queue.params.size > 256 { + return Err(invalid_saved_state( + "virtio-net restored queue size is out of range", + )); + } + if queue + .queue_state + .is_some_and(|state| state.avail_index != state.used_index) + { + return Err(invalid_saved_state( + "virtio-net restored queue retains unrepresented ownership", + )); + } + } + } + Ok(saved) +} + +#[derive(Protobuf, SavedStateRoot)] +#[mesh(package = "virtio.net")] +pub struct SavedState { + #[mesh(1)] + pub schema_version: u32, + #[mesh(2)] + pub guest_ipv4: u32, + #[mesh(3)] + pub prefix_length: u32, + #[mesh(4)] + pub gateway_ipv4: u32, + #[mesh(5)] + pub guest_mac: Vec, + #[mesh(6)] + pub gateway_mac: Vec, + #[mesh(7)] + pub link_up: bool, + #[mesh(8)] + pub effective_feature_bank0: u32, + #[mesh(9)] + pub effective_feature_bank1: u32, + #[mesh(10)] + pub negotiated_feature_bank0: u32, + #[mesh(11)] + pub negotiated_feature_bank1: u32, + #[mesh(12)] + pub endpoint_offload_bits: u32, + #[mesh(13)] + pub queue_pair_lifecycle: u32, + #[mesh(14)] + pub rx_in_order_outstanding: u32, + #[mesh(15)] + pub tx_in_order_outstanding: u32, + #[mesh(16)] + pub pending_rx_packet_count: u32, + #[mesh(17)] + pub pending_rx_payload_bytes: u64, + #[mesh(18)] + pub pending_tx_packet_count: u32, + #[mesh(19)] + pub pending_tx_payload_bytes: u64, + #[mesh(20)] + pub endpoint_generation: u64, +} diff --git a/vm/devices/virtio/virtio_net/src/tests.rs b/vm/devices/virtio/virtio_net/src/tests.rs index d6120e86a99..c0b61bb7d69 100644 --- a/vm/devices/virtio/virtio_net/src/tests.rs +++ b/vm/devices/virtio/virtio_net/src/tests.rs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod harness; +mod quiesce; +mod saved_state; + use async_trait::async_trait; use guestmem::GuestMemory; use inspect::InspectMut; @@ -232,6 +236,7 @@ struct MockQueueHandle { ready_waker: Arc>>, rx_avail_notify: mesh::Receiver<()>, tx_avail_notify: mesh::Receiver<()>, + ext: harness::QueueHandleExt, } impl MockQueueHandle { @@ -353,6 +358,7 @@ fn new_mock_queue() -> (MockQueue, MockQueueHandle) { ready_waker, rx_avail_notify: rx_avail_rx, tx_avail_notify: tx_avail_rx, + ext: Default::default(), }; (queue, handle) } diff --git a/vm/devices/virtio/virtio_net/src/tests/harness.rs b/vm/devices/virtio/virtio_net/src/tests/harness.rs new file mode 100644 index 00000000000..453bef09255 --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/tests/harness.rs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Test harness extensions for the quiesce and saved-state tests. +//! +//! [`ForkMockEndpoint`] wraps the mock endpoint and wraps each mock queue in a +//! [`ForkMockQueue`], which supports quiesce. + +use super::MockEndpoint; +use super::MockQueue; +use super::MockQueueHandle; +use super::QUEUE_SIZE; +use super::RX_AVAIL_ADDR; +use super::RX_DESC_ADDR; +use super::RX_USED_ADDR; +use super::TestHarness; +use super::new_mock_queue; +use crate::Device; +use async_trait::async_trait; +use futures::StreamExt; +use inspect::InspectMut; +use net_backend::Endpoint; +use net_backend::EndpointAction; +use net_backend::MultiQueueSupport; +use net_backend::QueueConfig; +use net_backend::RssConfig; +use net_backend::RxId; +use net_backend::TxError; +use net_backend::TxId; +use net_backend::TxOffloadSupport; +use net_backend::TxSegment; +use net_backend::quiesce::QueueQuiesceResult; +use net_backend_resources::mac_address::MacAddress; +use pal_async::DefaultDriver; +use std::task::Context; +use std::task::Poll; +use std::time::Duration; +use virtio::QueueResources; +use virtio::VirtioDevice; +use virtio::queue::QueueParams; +use virtio::spec::VirtioDeviceFeatures; +use vmcore::interrupt::Interrupt; +use vmcore::vm_task::SingleDriverBackend; +use vmcore::vm_task::VmTaskDriverSource; + +/// Controls of a [`MockQueueHandle`] for queues created by +/// [`ForkMockEndpoint`]. +#[derive(Default)] +pub(super) struct QueueHandleExt { + quiesce_notify: mesh::Receiver<()>, +} + +impl MockQueueHandle { + pub(super) async fn wait_for_quiesce(&mut self) { + mesh::CancelContext::new() + .with_timeout(Duration::from_secs(5)) + .until_cancelled(self.ext.quiesce_notify.next()) + .await + .expect("timed out waiting for queue quiesce") + .expect("channel closed"); + } +} + +/// A [`MockEndpoint`] that creates [`ForkMockQueue`]s. +struct ForkMockEndpoint { + endpoint: MockEndpoint, +} + +impl InspectMut for ForkMockEndpoint { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + self.endpoint.inspect_mut(req); + } +} + +#[async_trait] +impl Endpoint for ForkMockEndpoint { + fn endpoint_type(&self) -> &'static str { + self.endpoint.endpoint_type() + } + + async fn get_queues( + &mut self, + _config: Vec, + _rss: Option<&RssConfig<'_>>, + queues: &mut Vec>, + ) -> anyhow::Result<()> { + let (queue, mut handle) = new_mock_queue(); + let queue = ForkMockQueue { + queue, + quiesce_notify: handle.ext.quiesce_notify.sender(), + }; + self.endpoint.queue_tx.send(handle); + queues.push(Box::new(queue)); + Ok(()) + } + + async fn stop(&mut self) { + self.endpoint.stop().await + } + + fn is_ordered(&self) -> bool { + self.endpoint.is_ordered() + } + + fn tx_offload_support(&self) -> TxOffloadSupport { + self.endpoint.tx_offload_support() + } + + fn multiqueue_support(&self) -> MultiQueueSupport { + self.endpoint.multiqueue_support() + } + + fn tx_fast_completions(&self) -> bool { + self.endpoint.tx_fast_completions() + } + + async fn wait_for_endpoint_action(&mut self) -> EndpointAction { + self.endpoint.wait_for_endpoint_action().await + } +} + +/// A [`MockQueue`] that supports quiesce. +struct ForkMockQueue { + queue: MockQueue, + quiesce_notify: mesh::Sender<()>, +} + +impl InspectMut for ForkMockQueue { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + self.queue.inspect_mut(req); + } +} + +#[async_trait] +impl net_backend::Queue for ForkMockQueue { + fn poll_ready( + &mut self, + cx: &mut Context<'_>, + pool: &mut dyn net_backend::BufferAccess, + ) -> Poll<()> { + self.queue.poll_ready(cx, pool) + } + + fn rx_avail(&mut self, pool: &mut dyn net_backend::BufferAccess, done: &[RxId]) { + self.queue.rx_avail(pool, done) + } + + fn rx_poll( + &mut self, + pool: &mut dyn net_backend::BufferAccess, + packets: &mut [RxId], + ) -> anyhow::Result { + self.queue.rx_poll(pool, packets) + } + + async fn quiesce( + &mut self, + _pool: &mut dyn net_backend::BufferAccess, + ) -> anyhow::Result { + self.quiesce_notify.send(()); + self.queue.rx_pending.lock().clear(); + Ok(QueueQuiesceResult { + rx_ready: self.queue.rx_ready.lock().len(), + tx_ready: self.queue.tx_completions.lock().iter().map(Vec::len).sum(), + }) + } + + fn tx_avail( + &mut self, + pool: &mut dyn net_backend::BufferAccess, + segments: &[TxSegment], + ) -> anyhow::Result<(bool, usize)> { + self.queue.tx_avail(pool, segments) + } + + fn tx_poll( + &mut self, + pool: &mut dyn net_backend::BufferAccess, + done: &mut [TxId], + ) -> Result { + self.queue.tx_poll(pool, done) + } +} + +impl TestHarness { + /// Replaces the harness device with one built for save and restore over a + /// [`ForkMockEndpoint`]. + pub(super) fn new_save_restore(driver: &DefaultDriver) -> Self { + let mut harness = Self::new(driver); + let (queue_tx, queue_handle_rx) = mesh::channel(); + let endpoint = ForkMockEndpoint { + endpoint: MockEndpoint { + queue_tx, + is_ordered: true, + }, + }; + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let mac = MacAddress::new([0x52, 0x54, 0, 0, 0, 2]); + harness.device = Device::builder() + .save_restore( + net_backend_resources::consomme::static_ipv4::StaticIpv4Config { + guest_ipv4: std::net::Ipv4Addr::new(10, 0, 0, 2), + prefix_length: 24, + gateway_ipv4: std::net::Ipv4Addr::new(10, 0, 0, 1), + gateway_mac: MacAddress::new([0x52, 0x54, 0, 0, 0, 1]), + }, + (1 << 5) | (1 << 32), + ) + .build(&driver_source, Box::new(endpoint), mac) + .unwrap(); + harness.queue_handle_rx = queue_handle_rx; + harness + } + + pub(super) async fn start_rx_queue(&mut self, features: &VirtioDeviceFeatures) { + let rx_interrupt = Interrupt::from_event(self.rx_interrupt_event.clone()); + + // Queue 0: RX + self.device + .start_queue( + 0, + QueueResources { + params: QueueParams { + size: QUEUE_SIZE, + enable: true, + desc_addr: RX_DESC_ADDR, + avail_addr: RX_AVAIL_ADDR, + used_addr: RX_USED_ADDR, + }, + notify: rx_interrupt, + event: self.rx_event.clone(), + guest_memory: self.mem.clone(), + }, + features, + None, + ) + .await + .unwrap(); + } +} diff --git a/vm/devices/virtio/virtio_net/src/tests/quiesce.rs b/vm/devices/virtio/virtio_net/src/tests/quiesce.rs new file mode 100644 index 00000000000..363bd022046 --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/tests/quiesce.rs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Input quiesce tests: a quiesce requested before the queues start is latched, +//! and quiesce drains the TX work that the endpoint already accepted. + +use super::TestHarness; +use net_backend::TxId; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; +use virtio::VirtioDevice; + +#[async_test] +async fn input_quiesce_latches_before_queue_start(driver: DefaultDriver) { + let mut harness = TestHarness::new_save_restore(&driver); + harness.device.quiesce_input().await.unwrap(); + + let mut handle = harness.enable_and_get_handle().await; + handle.wait_for_quiesce().await; + + harness.device.resume_input().await.unwrap(); +} + +#[async_test] +async fn snapshot_drains_accepted_pending_tx_exactly_once(driver: DefaultDriver) { + let mut harness = TestHarness::new_save_restore(&driver); + let mut handle = harness.enable_and_get_handle().await; + handle.tx_avail_behavior.lock().sync = false; + + harness.post_tx_and_signal(0, 64); + handle.wait_for_tx_avail().await; + handle.tx_completions.lock().push_back(vec![TxId(0)]); + + harness.device.quiesce_input().await.unwrap(); + let rx_state = harness.device.stop_queue(0).await.unwrap(); + let tx_state = harness.device.stop_queue(1).await.unwrap(); + assert_eq!(rx_state.avail_index, rx_state.used_index); + assert_eq!(tx_state.avail_index, tx_state.used_index); + harness.device.save_device().unwrap().unwrap(); + assert!(handle.tx_completions.lock().is_empty()); +} diff --git a/vm/devices/virtio/virtio_net/src/tests/saved_state.rs b/vm/devices/virtio/virtio_net/src/tests/saved_state.rs new file mode 100644 index 00000000000..ea6adb05e82 --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/tests/saved_state.rs @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Saved-state tests: stopping the queues rewinds unused RX descriptors, +//! partial queue lifecycles are saved and validated, and an inactive transport +//! defers the device-private state until the device is activated. + +use super::QUEUE_SIZE; +use super::RX_AVAIL_ADDR; +use super::RX_DESC_ADDR; +use super::RX_USED_ADDR; +use super::TX_AVAIL_ADDR; +use super::TX_DESC_ADDR; +use super::TX_USED_ADDR; +use super::TestHarness; +use chipset_device::io::IoResult; +use chipset_device::mmio::MmioIntercept; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; +use virtio::VirtioDevice; +use virtio::device::saved_state::DeviceQueueState; +use virtio::queue::QueueParams; +use virtio::spec::VirtioDeviceFeatures; +use virtio::transport::VirtioMmioDevice; +use vmcore::device_state::ChangeDeviceState; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::save_restore::SaveRestore; + +#[async_test] +async fn save_restore_rewinds_unused_rx_and_validates_private_state(driver: DefaultDriver) { + let mut harness = TestHarness::new_save_restore(&driver); + let mut handle = harness.enable_and_get_handle().await; + harness.post_rx_buffer_and_signal(0, 1500); + handle.wait_for_rx_pending().await; + + harness.device.quiesce_input().await.unwrap(); + let rx_state = harness.device.stop_queue(0).await.unwrap(); + let tx_state = harness.device.stop_queue(1).await.unwrap(); + assert_eq!(rx_state.avail_index, rx_state.used_index); + assert_eq!(tx_state.avail_index, tx_state.used_index); + let saved = harness.device.save_device().unwrap().unwrap(); + + let queues = [ + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: true, + desc_addr: RX_DESC_ADDR, + avail_addr: RX_AVAIL_ADDR, + used_addr: RX_USED_ADDR, + }, + queue_state: Some(rx_state), + }, + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: true, + desc_addr: TX_DESC_ADDR, + avail_addr: TX_AVAIL_ADDR, + used_addr: TX_USED_ADDR, + }, + queue_state: Some(tx_state), + }, + ]; + let restored = TestHarness::new_save_restore(&driver); + restored.device.device_state_validator()( + Some(&saved), + &VirtioDeviceFeatures::new(), + &queues, + &harness.mem, + ) + .unwrap(); + let mut restored_device = restored.device; + restored_device.restore_device(Some(saved)).unwrap(); + assert_eq!(restored_device.lifecycle.endpoint_generation, 1); +} + +#[async_test] +async fn save_restore_preserves_partial_queue_lifecycle(driver: DefaultDriver) { + let features = VirtioDeviceFeatures::new().with_bank(0, 1 << 5); + let disabled_queues = [ + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: false, + desc_addr: RX_DESC_ADDR, + avail_addr: RX_AVAIL_ADDR, + used_addr: RX_USED_ADDR, + }, + queue_state: None, + }, + DeviceQueueState { + params: QueueParams { + size: QUEUE_SIZE, + enable: false, + desc_addr: TX_DESC_ADDR, + avail_addr: TX_AVAIL_ADDR, + used_addr: TX_USED_ADDR, + }, + queue_state: None, + }, + ]; + + let mut unstarted = TestHarness::new_save_restore(&driver); + let unstarted_state = unstarted.device.save_device().unwrap().unwrap(); + unstarted.device.device_state_validator()( + Some(&unstarted_state), + &features, + &disabled_queues, + &unstarted.mem, + ) + .unwrap(); + let mut configured_queues = disabled_queues; + configured_queues[0].params.enable = true; + configured_queues[1].params.enable = true; + unstarted.device.device_state_validator()( + Some(&unstarted_state), + &features, + &configured_queues, + &unstarted.mem, + ) + .unwrap(); + + let mut half_open = TestHarness::new_save_restore(&driver); + half_open.start_rx_queue(&features).await; + let rx_state = half_open.device.stop_queue(0).await.unwrap(); + let half_open_state = half_open.device.save_device().unwrap().unwrap(); + let mut partial_queues = disabled_queues; + partial_queues[0].params.enable = true; + partial_queues[0].queue_state = Some(rx_state); + half_open.device.device_state_validator()( + Some(&half_open_state), + &features, + &partial_queues, + &half_open.mem, + ) + .unwrap(); +} + +#[async_test] +async fn inactive_transport_restore_defers_net_private_state(driver: DefaultDriver) { + let source_harness = TestHarness::new_save_restore(&driver); + let mut source = VirtioMmioDevice::new( + Box::new(source_harness.device), + &driver, + source_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + source.stop().await; + let saved = source.save().unwrap(); + assert_eq!( + saved + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .endpoint_generation, + 0 + ); + + let destination_harness = TestHarness::new_save_restore(&driver); + let mut destination = VirtioMmioDevice::new( + Box::new(destination_harness.device), + &driver, + destination_harness.mem, + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.stop().await; + let staged = destination.save().unwrap(); + assert_eq!( + staged + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .endpoint_generation, + 0 + ); + + destination.start_fallible().await.unwrap(); + let mut config = [0; 4]; + match destination.mmio_read(0x100, &mut config) { + IoResult::Defer(token) => token.read_future(&mut config).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + destination.stop().await; + let activated = destination.save().unwrap(); + assert_eq!( + activated + .device_state + .unwrap() + .parse::() + .unwrap() + .endpoint_generation, + 1 + ); +} diff --git a/vm/devices/virtio/virtio_resources/src/lib.rs b/vm/devices/virtio/virtio_resources/src/lib.rs index 93eb6b7ac6c..0a7a71ae483 100644 --- a/vm/devices/virtio/virtio_resources/src/lib.rs +++ b/vm/devices/virtio/virtio_resources/src/lib.rs @@ -143,6 +143,9 @@ pub mod net { pub max_queues: Option, pub mac_address: MacAddress, pub endpoint: Resource, + pub save_restore: bool, + pub static_ipv4: Option, + pub effective_features: Option, } impl ResourceId for VirtioNetHandle { diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64.rs b/vmm_tests/vmm_tests/tests/tests/x86_64.rs index 666d07214f2..59fd65e17fb 100644 --- a/vmm_tests/vmm_tests/tests/tests/x86_64.rs +++ b/vmm_tests/vmm_tests/tests/tests/x86_64.rs @@ -252,6 +252,9 @@ async fn vpci_filter(config: PetriVmBuilder) -> anyhow::Res max_queues: None, mac_address: MacAddress::new([0x00, 0x15, 0x5D, 0x12, 0x12, 0x12]), endpoint: NullHandle.into_resource(), + save_restore: false, + static_ipv4: None, + effective_features: None, } .into_resource(), ) From 5066f52d4b6e8a9fd2055aafe327fb5a6d0e6a5b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:15:27 -0700 Subject: [PATCH 080/304] fuse: save and restore negotiated session state A FUSE session negotiates its protocol version, feature flags, and limits with the guest during FUSE_INIT and keeps them only in memory. A file system device built on it could not be saved and restored, because a restored session would be uninitialized while the guest driver continued with its negotiated protocol. Add `SessionState`, a serializable snapshot of whether a session is initialized and of its negotiated values, which contains no file handle, task, or native resource. `Session::save_state` captures it, and `Session::restore_state` restores it after validating it: an uninitialized state must carry no negotiated values, and an initialized one must use a supported protocol version (7.27 through the implementation's minor version), accept only feature flags that the guest advertised, keep its extended-init flags consistent, and have nonzero write and time-granularity limits. The types are re-exported from the crate root. Two related fixes make the saved values well-defined. FUSE_INIT now records the negotiated minor version as the lower of the guest's and the implementation's, instead of the guest's value, so a session never claims a protocol newer than it implements. Destroying a session also resets its negotiated information, so a later session starts from defaults. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/support/fs/fuse/src/lib.rs | 3 + vm/devices/support/fs/fuse/src/session.rs | 9 +- .../fs/fuse/src/session/saved_state.rs | 167 ++++++++++++++++++ .../fs/fuse/src/session/tests/saved_state.rs | 104 +++++++++++ 4 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 vm/devices/support/fs/fuse/src/session/saved_state.rs create mode 100644 vm/devices/support/fs/fuse/src/session/tests/saved_state.rs diff --git a/vm/devices/support/fs/fuse/src/lib.rs b/vm/devices/support/fs/fuse/src/lib.rs index 72fb3e21504..52ddf7a5717 100644 --- a/vm/devices/support/fs/fuse/src/lib.rs +++ b/vm/devices/support/fs/fuse/src/lib.rs @@ -24,6 +24,9 @@ pub use request::RequestReader; pub use request::check_name; pub use session::Session; pub use session::SessionInfo; +pub use session::saved_state::SessionInfoState; +pub use session::saved_state::SessionState; +pub use session::saved_state::SessionStateError; use lx::LxStr; use lx::LxString; diff --git a/vm/devices/support/fs/fuse/src/session.rs b/vm/devices/support/fs/fuse/src/session.rs index 621ee715c5c..4508e79448e 100644 --- a/vm/devices/support/fs/fuse/src/session.rs +++ b/vm/devices/support/fs/fuse/src/session.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub(crate) mod saved_state; + use super::Fuse; use super::Mapper; use super::protocol::*; @@ -119,6 +121,7 @@ impl Session { if self.initialized.swap(false, atomic::Ordering::AcqRel) { self.fs.destroy(); } + *self.info.write() = SessionInfo::default(); } /// Perform the actual dispatch. This allows the caller to send an error reply if any operation @@ -481,7 +484,7 @@ impl Session { // Prepare the session info and call the file system to negotiate. info.major = init.major; - info.minor = init.minor; + info.minor = init.minor.min(FUSE_KERNEL_MINOR_VERSION); info.max_readahead = init.max_readahead; info.capable = init.flags; info.want = DEFAULT_FLAGS & init.flags; @@ -606,7 +609,7 @@ impl Session { } /// Provides information about a session. Public fields may be modified during `init`. -#[derive(Default)] +#[derive(Clone, Copy, Default)] pub struct SessionInfo { major: u32, minor: u32, @@ -650,6 +653,8 @@ enum OperationError { #[cfg(test)] mod tests { + mod saved_state; + use super::*; use crate::request::tests::*; use parking_lot::Mutex; diff --git a/vm/devices/support/fs/fuse/src/session/saved_state.rs b/vm/devices/support/fs/fuse/src/session/saved_state.rs new file mode 100644 index 00000000000..f4a1bc4dc8e --- /dev/null +++ b/vm/devices/support/fs/fuse/src/session/saved_state.rs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Serializable FUSE session state: the negotiated protocol values, their +//! validation, and saving and restoring them on a session. + +use super::Session; +use super::SessionInfo; +use crate::protocol::FUSE_INIT_EXT; +use crate::protocol::FUSE_KERNEL_MINOR_VERSION; +use crate::protocol::FUSE_KERNEL_VERSION; +use std::sync::atomic; +use thiserror::Error; + +/// Serializable FUSE session information. +/// +/// This is intentionally independent from any transport or filesystem +/// implementation. It contains only negotiated protocol values, never a file +/// handle, task, or native resource. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SessionInfoState { + /// Negotiated FUSE major version. + pub major: u32, + /// Negotiated FUSE minor version. + pub minor: u32, + /// Guest maximum readahead. + pub max_readahead: u32, + /// Feature flags advertised by the guest. + pub capable: u32, + /// Extended feature flags advertised by the guest. + pub capable2: u32, + /// Feature flags accepted by the server. + pub want: u32, + /// Extended feature flags accepted by the server. + pub want2: u32, + /// Maximum background requests. + pub max_background: u16, + /// Congestion threshold. + pub congestion_threshold: u16, + /// Maximum write size. + pub max_write: u32, + /// Timestamp granularity. + pub time_gran: u32, +} + +/// Serializable state of a FUSE session. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct SessionState { + /// Whether the session has completed FUSE_INIT. + pub initialized: bool, + /// Negotiated session information. + pub info: SessionInfoState, +} + +/// Invalid FUSE session state. +#[derive(Debug, Error)] +pub enum SessionStateError { + /// The state does not describe a supported FUSE protocol. + #[error("unsupported FUSE protocol version")] + UnsupportedVersion, + /// Accepted feature bits were not advertised by the guest. + #[error("FUSE negotiated flags are not a subset of guest capabilities")] + InvalidFlags, + /// The extended-init flags are inconsistent. + #[error("FUSE extended-init flags are inconsistent")] + InvalidExtendedFlags, + /// A negotiated protocol limit is invalid. + #[error("FUSE negotiated protocol limit is invalid")] + InvalidLimits, + /// An uninitialized session retained negotiation state. + #[error("uninitialized FUSE session retained negotiation state")] + UninitializedWithInfo, +} + +impl SessionState { + /// Validates that the state can be restored by this implementation. + pub fn validate(&self) -> Result<(), SessionStateError> { + let info = self.info; + if !self.initialized { + return if info == SessionInfoState::default() { + Ok(()) + } else { + Err(SessionStateError::UninitializedWithInfo) + }; + } + if info.major != FUSE_KERNEL_VERSION + || !(27..=FUSE_KERNEL_MINOR_VERSION).contains(&info.minor) + { + return Err(SessionStateError::UnsupportedVersion); + } + if info.want & !info.capable != 0 { + return Err(SessionStateError::InvalidFlags); + } + if info.capable & FUSE_INIT_EXT == 0 { + if info.capable2 != 0 || info.want2 != 0 { + return Err(SessionStateError::InvalidExtendedFlags); + } + } else if info.want & FUSE_INIT_EXT == 0 { + if info.want2 != 0 { + return Err(SessionStateError::InvalidExtendedFlags); + } + } else if info.want2 & !info.capable2 != 0 { + return Err(SessionStateError::InvalidExtendedFlags); + } + if info.max_write == 0 || info.time_gran == 0 { + return Err(SessionStateError::InvalidLimits); + } + Ok(()) + } +} + +impl Session { + /// Saves typed negotiated protocol state for device-private migration. + pub fn save_state(&self) -> SessionState { + let initialized = self.is_initialized(); + let info = SessionInfoState::from(*self.info.read()); + SessionState { initialized, info } + } + + /// Restores a previously validated FUSE negotiation. + /// + /// The caller must ensure no request dispatch is active while invoking + /// this method. + pub fn restore_state(&self, state: SessionState) -> Result<(), SessionStateError> { + state.validate()?; + *self.info.write() = state.info.into(); + self.initialized + .store(state.initialized, atomic::Ordering::Release); + Ok(()) + } +} + +impl From for SessionInfoState { + fn from(info: SessionInfo) -> Self { + Self { + major: info.major, + minor: info.minor, + max_readahead: info.max_readahead, + capable: info.capable, + capable2: info.capable2, + want: info.want, + want2: info.want2, + max_background: info.max_background, + congestion_threshold: info.congestion_threshold, + max_write: info.max_write, + time_gran: info.time_gran, + } + } +} + +impl From for SessionInfo { + fn from(state: SessionInfoState) -> Self { + Self { + major: state.major, + minor: state.minor, + max_readahead: state.max_readahead, + capable: state.capable, + capable2: state.capable2, + want: state.want, + want2: state.want2, + max_background: state.max_background, + congestion_threshold: state.congestion_threshold, + max_write: state.max_write, + time_gran: state.time_gran, + } + } +} diff --git a/vm/devices/support/fs/fuse/src/session/tests/saved_state.rs b/vm/devices/support/fs/fuse/src/session/tests/saved_state.rs new file mode 100644 index 00000000000..8fa32a49979 --- /dev/null +++ b/vm/devices/support/fs/fuse/src/session/tests/saved_state.rs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for saving, validating, and restoring the FUSE session state. + +use super::*; +use crate::session::saved_state::SessionInfoState; +use crate::session::saved_state::SessionState; +use crate::session::saved_state::SessionStateError; + +#[test] +fn initialized_session_state_restores_without_a_second_init() { + let mut init_sender = MockSender::default(); + let source = Session::new(TestFs::default()); + source.dispatch( + Request::new(FUSE_INIT_REQUEST).unwrap(), + &mut init_sender, + None, + ); + let saved = source.save_state(); + assert!(saved.initialized); + + let fs = TestFs::default(); + let state = Arc::clone(&fs.state); + let destination = Session::new(fs); + destination.restore_state(saved).unwrap(); + assert!(destination.is_initialized()); + + let mut sender = MockSender { state: 1 }; + destination.dispatch( + Request::new(FUSE_GETATTR_REQUEST).unwrap(), + &mut sender, + None, + ); + assert_eq!(state.lock().called, GETATTR_CALLED); +} + +#[test] +fn malformed_session_state_is_rejected() { + let state = SessionState { + initialized: true, + info: SessionInfoState { + major: FUSE_KERNEL_VERSION, + minor: 27, + capable: FUSE_ASYNC_READ, + want: FUSE_ASYNC_READ | FUSE_BIG_WRITES, + max_write: PAGE_SIZE, + time_gran: 1, + ..Default::default() + }, + }; + assert!(matches!( + state.validate(), + Err(SessionStateError::InvalidFlags) + )); + + let state = SessionState { + initialized: false, + info: SessionInfoState { + max_write: PAGE_SIZE, + ..Default::default() + }, + }; + assert!(matches!( + state.validate(), + Err(SessionStateError::UninitializedWithInfo) + )); + + let state = SessionState { + initialized: true, + info: SessionInfoState { + major: FUSE_KERNEL_VERSION, + minor: FUSE_KERNEL_MINOR_VERSION + 1, + max_write: PAGE_SIZE, + time_gran: 1, + ..Default::default() + }, + }; + assert!(matches!( + state.validate(), + Err(SessionStateError::UnsupportedVersion) + )); +} + +#[test] +fn init_newer_minor_is_capped_to_the_supported_version() { + let request_data = make_init_request( + FUSE_KERNEL_VERSION, + FUSE_KERNEL_MINOR_VERSION + 1, + 131072, + 0, + 0, + ); + let session = Session::new(InitCapturingFs::default()); + let mut sender = CapturingSender::default(); + + session.dispatch( + Request::new(request_data.as_slice()).unwrap(), + &mut sender, + None, + ); + + assert_eq!(session.save_state().info.minor, FUSE_KERNEL_MINOR_VERSION); +} From 18869b05e0b11d8de00fa1ebd39baf94170ecff9 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:17:48 -0700 Subject: [PATCH 081/304] virtiofs: track inode aliases through link, rename, and unlink A virtio-fs inode records a single host path, which it uses for every later operation on the node. That path went stale in several cases. A hard link gave the inode a second name that nothing recorded, so unlinking the original name left the inode pointing at a missing path even though it was still reachable through the link. Renaming a directory left every open inode below it pointing at the old location. The dedup map, which lets repeated lookups of a host file return one FUSE node, only evicted the renamed or removed path, without updating the inodes that referred to it. Each inode now keeps the set of host paths through which the guest knows it. Lookups and hard links add a path. After an unlink, the paths at or below the removed name are dropped from every inode of the volume, and an inode whose primary path was removed switches to one of its remaining aliases. After a rename, the paths at or below the old name are rewritten under the new name, after first dropping the aliases of any overwritten destination. The dedup map is then rebuilt from the surviving inodes, and a path-keyed inode with no remaining alias is left out of it, so a later create at a vacated path still gets a fresh node. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/virtio/virtiofs/src/inode.rs | 69 +++++++++++++++++++++---- vm/devices/virtio/virtiofs/src/lib.rs | 64 +++++++++++++++-------- 2 files changed, 101 insertions(+), 32 deletions(-) diff --git a/vm/devices/virtio/virtiofs/src/inode.rs b/vm/devices/virtio/virtiofs/src/inode.rs index ed3dc51f572..e27a8f2d135 100644 --- a/vm/devices/virtio/virtiofs/src/inode.rs +++ b/vm/devices/virtio/virtiofs/src/inode.rs @@ -10,7 +10,9 @@ use lxutil::LxCreateOptions; use lxutil::LxVolume; use lxutil::PathBufExt; use parking_lot::RwLock; +use std::collections::BTreeSet; use std::ops::Deref; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; @@ -93,6 +95,7 @@ pub(crate) enum DedupKey { pub struct VirtioFsInode { pub(crate) volume: Arc, path: RwLock, + pub(crate) aliases: RwLock>, lookup_count: AtomicU64, inode_nr: lx::ino_t, /// This inode's number as reported to the guest: its namespaced inode @@ -111,9 +114,12 @@ impl VirtioFsInode { /// Create a new inode for the specified path, with previously retrieved attributes. pub fn with_attr(volume: Arc, path: PathBuf, stat: &lx::Stat) -> Self { let guest_inode_nr = volume.map_inode(stat.inode_nr); + let mut aliases = BTreeSet::new(); + aliases.insert(path.clone()); Self { volume, path: RwLock::new(path), + aliases: RwLock::new(aliases), lookup_count: AtomicU64::new(1), inode_nr: stat.inode_nr, guest_inode_nr, @@ -172,6 +178,7 @@ impl VirtioFsInode { /// Increments the lookup count. pub fn lookup(&self, new_path: PathBuf) { self.lookup_count.fetch_add(1, Ordering::AcqRel); + self.aliases.write().insert(new_path.clone()); let mut path = self.path.write(); *path = new_path; } @@ -400,6 +407,56 @@ impl VirtioFsInode { self.get_path().clone() } + /// Returns all host-relative aliases currently known to the guest. + pub(crate) fn aliases(&self) -> Vec { + self.aliases.read().iter().cloned().collect() + } + + /// Adds an alias after a successful hard link. + pub(crate) fn add_alias(&self, path: PathBuf) { + self.aliases.write().insert(path); + } + + /// Removes all aliases at or below an unlinked directory path. + pub(crate) fn remove_alias_prefix(&self, path: &Path) { + let mut aliases = self.aliases.write(); + let removed_primary = self.path.read().starts_with(path); + aliases.retain(|alias| !alias.starts_with(path)); + if removed_primary { + if let Some(alias) = aliases.first() { + *self.path.write() = alias.clone(); + } + } + } + + /// Replaces an alias prefix after a successful rename. + pub(crate) fn rename_alias_prefix(&self, old: &Path, new: &Path) { + let mut aliases = self.aliases.write(); + let replacements: Vec<_> = aliases + .iter() + .filter_map(|alias| { + alias.strip_prefix(old).ok().map(|suffix| { + let mut replacement = new.to_path_buf(); + replacement.push(suffix); + (alias.clone(), replacement) + }) + }) + .collect(); + for (old_alias, new_alias) in &replacements { + aliases.remove(old_alias); + aliases.insert(new_alias.clone()); + } + drop(aliases); + + let mut path = self.path.write(); + let suffix = path.strip_prefix(old).ok().map(ToOwned::to_owned); + if let Some(suffix) = suffix { + let mut replacement = new.to_path_buf(); + replacement.push(&suffix); + *path = replacement; + } + } + /// The key used to deduplicate this inode in the `InodeMap`, so that /// repeated lookups of the same host file return one stable FUSE node id. /// @@ -416,18 +473,8 @@ impl VirtioFsInode { DedupKey::Path(self.volume_id(), self.get_path().clone()) } - /// The [`DedupKey::Path`] that a child named `name` of this inode would use, - /// for path-keyed (non-stable-id) volumes only. - pub(crate) fn child_path_dedup_key(&self, name: &LxStr) -> Option { - if self.volume.supports_stable_file_id() { - return None; - } - let path = self.child_path(name).ok()?; - Some(DedupKey::Path(self.volume_id(), path)) - } - /// Appends a child name to this inode's path. - fn child_path(&self, name: &LxStr) -> lx::Result { + pub(crate) fn child_path(&self, name: &LxStr) -> lx::Result { // Defense in depth: the FUSE request parser already validates names, // but assert here to catch any bypass. assert!(!name.is_empty(), "empty child name"); diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index f6e2500cf5b..676b20cb782 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -321,7 +321,9 @@ impl Fuse for VirtioFs { let inode = self.get_inode(request.node_id())?; let target_inode = self.get_inode(target)?; self.check_writable(&inode)?; + let alias = inode.child_path(name)?; let attr = inode.link(name, &target_inode)?; + target_inode.add_alias(alias); // Increment the lookup count since we're returning an entry for this inode. // The kernel will send a forget for this entry later. @@ -422,17 +424,12 @@ impl Fuse for VirtioFs { return Err(lx::Error::EXDEV); } self.check_writable(&inode)?; + let old_path = inode.child_path(name)?; + let new_path = new_inode.child_path(new_name)?; inode.rename(name, &new_inode, new_name, flags)?; - // A rename doesn't preserve inode identity on path-keyed volumes, so - // evict both the vacated source path and the overwritten destination - // path from the dedup map. let mut inodes = self.inner.inodes.write(); - if let Some(key) = inode.child_path_dedup_key(name) { - inodes.evict_dedup_key(&key); - } - if let Some(key) = new_inode.child_path_dedup_key(new_name) { - inodes.evict_dedup_key(&key); - } + inodes.remove_alias_prefix(inode.volume_id(), &new_path); + inodes.rename_alias_prefix(inode.volume_id(), &old_path, &new_path); Ok(()) } @@ -627,12 +624,12 @@ impl VirtioFs { } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + let path = inode.child_path(name)?; inode.unlink(name, flags)?; - // On path-keyed volumes the path is the inode's identity, so evict it - // now; a later create at the same path must not alias the removed inode. - if let Some(key) = inode.child_path_dedup_key(name) { - self.inner.inodes.write().evict_dedup_key(&key); - } + self.inner + .inodes + .write() + .remove_alias_prefix(inode.volume_id(), &path); Ok(()) } @@ -826,13 +823,38 @@ impl InodeMap { } } - /// Detach a [`DedupKey::Path`] entry from its current inode so a later - /// create at that path gets a fresh node id instead of aliasing the - /// removed/renamed file. The inode stays in `inodes_by_node_id` for any - /// live fd or watch. - pub fn evict_dedup_key(&mut self, key: &DedupKey) { - if matches!(key, DedupKey::Path(..)) { - self.inodes_by_key.remove(key); + /// Removes aliases at or below an unlinked path and refreshes path-keyed + /// deduplication for any surviving inodes. + pub fn remove_alias_prefix(&mut self, volume_id: u32, path: &Path) { + for inode in self.inodes_by_node_id.values.values() { + if inode.volume_id() == volume_id { + inode.remove_alias_prefix(path); + } + } + self.rebuild_dedup_keys(); + } + + /// Rewrites aliases at or below a renamed path and refreshes path-keyed + /// deduplication for any surviving inodes. + pub fn rename_alias_prefix(&mut self, volume_id: u32, old: &Path, new: &Path) { + for inode in self.inodes_by_node_id.values.values() { + if inode.volume_id() == volume_id { + inode.rename_alias_prefix(old, new); + } + } + self.rebuild_dedup_keys(); + } + + fn rebuild_dedup_keys(&mut self) { + self.inodes_by_key.clear(); + for (&node_id, inode) in &self.inodes_by_node_id.values { + let key = inode.dedup_key(); + if matches!(key, DedupKey::Path(..)) && inode.aliases().is_empty() { + continue; + } + self.inodes_by_key + .entry(key) + .or_insert_with(|| (Arc::clone(inode), node_id)); } } From 977b518d1ebb1917f65de9b428042ccc950e3bda Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:22:04 -0700 Subject: [PATCH 082/304] loader_defs: name the boot_params ACPI RSDP field Linux 5.0 added `acpi_rsdp_addr` to the zero page: a 64-bit field at offset 0x70 of `boot_params` through which a boot loader hands the kernel the physical address of the ACPI RSDP, so the kernel need not scan legacy memory for it. `boot_params` still declared those bytes as part of the 16-byte `_pad3`, so a loader could not set the field. Split `_pad3` into `acpi_rsdp_addr` and the remaining 8 bytes of padding, matching the kernel's layout. The structure's size and the offsets of all other fields are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/loader/loader_defs/src/linux.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/vm/loader/loader_defs/src/linux.rs b/vm/loader/loader_defs/src/linux.rs index 4916db0ed85..7d615965f05 100644 --- a/vm/loader/loader_defs/src/linux.rs +++ b/vm/loader/loader_defs/src/linux.rs @@ -222,7 +222,8 @@ pub struct boot_params { pub _pad2: [u8; 4], pub tboot_addr: u64, pub ist_info: ist_info, - pub _pad3: [u8; 16], + pub acpi_rsdp_addr: u64, + pub _pad3: [u8; 8], pub hd0_info: [u8; 16], pub hd1_info: [u8; 16], pub sys_desc_table: sys_desc_table, From f840a0c146e5ac35bf2c22c766b224d9cab204dc Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:24:46 -0700 Subject: [PATCH 083/304] loader: let static ELF loading reuse a caller buffer `load_static_elf` allocates a page-aligned chunk buffer for every call and uses it to import the file-backed parts of each segment. A loader that imports several images in a row, such as a kernel followed by an initial RAM disk, therefore allocates and frees that buffer once per image. Split the import into a crate-private `load_static_elf_with_buffer`, which takes the chunk buffer from its caller, and keep `load_static_elf` as a wrapper that allocates a buffer for a single call. Behavior is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/loader/src/elf.rs | 55 +++++++++++++++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/vm/loader/src/elf.rs b/vm/loader/src/elf.rs index c0a631f0b4c..a2885f42b4c 100644 --- a/vm/loader/src/elf.rs +++ b/vm/loader/src/elf.rs @@ -104,6 +104,33 @@ pub fn load_static_elf( acceptance: crate::importer::BootPageAcceptance, tag: &'static str, ) -> Result +where + F: Read + Seek, +{ + let mut buffer = ChunkBuf::new(); + load_static_elf_with_buffer( + importer, + kernel_image, + start_address, + load_offset, + assume_pic, + acceptance, + tag, + &mut buffer, + ) +} + +/// Loads a static ELF while reusing the caller's page-aligned import buffer. +pub(crate) fn load_static_elf_with_buffer( + importer: &mut dyn ImageLoad, + kernel_image: &mut F, + start_address: u64, + load_offset: u64, + assume_pic: bool, + acceptance: crate::importer::BootPageAcceptance, + tag: &'static str, + buffer: &mut ChunkBuf, +) -> Result where F: Read + Seek, { @@ -244,7 +271,6 @@ where drop(reader); // During the second pass, import each segment. - let mut buf = ChunkBuf::new(); for seg in &segments { let mem_offset = seg .p_paddr @@ -268,19 +294,20 @@ where } if seg.p_memsz > 0 { - buf.import_file_region( - importer, - ImportFileRegion { - file: kernel_image, - file_offset: seg.p_offset, - file_length: seg.p_filesz, - gpa: mem_offset, - memory_length: seg.p_memsz, - acceptance, - tag, - }, - ) - .map_err(Error::ImportFileRegion)?; + buffer + .import_file_region( + importer, + ImportFileRegion { + file: kernel_image, + file_offset: seg.p_offset, + file_length: seg.p_filesz, + gpa: mem_offset, + memory_length: seg.p_memsz, + acceptance, + tag, + }, + ) + .map_err(Error::ImportFileRegion)?; } } From 25326daf49b34348586ffd77cc84c4ac8c8de7a8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:26:24 -0700 Subject: [PATCH 084/304] loader: add an Intel MP 1.4 table builder A guest booted directly into a Linux kernel, without firmware and without ACPI, learns its processors and interrupt routing from Intel MultiProcessor Specification 1.4 tables. The loader had no way to build them. Add the `mptable` module, whose `build` function returns an MP floating pointer, placed at guest address 0, and a configuration table, placed at 0x400, for a given set of APIC IDs and level-triggered ISA IRQs. The configuration table describes each processor, with the first as the bootstrap processor, one ISA bus, one I/O APIC at 0xfec00000, and the routing of the 15 ISA IRQs other than the cascade, with IRQ 0 on pin 2; IRQs listed as level-triggered are described as active-high, and the others use bus defaults. APIC IDs must be contiguous from zero and fit the 8-bit MP processor entry, IRQs must be in the ISA range, and both structures carry valid checksums. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/loader/src/lib.rs | 1 + vm/loader/src/mptable.rs | 262 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 vm/loader/src/mptable.rs diff --git a/vm/loader/src/lib.rs b/vm/loader/src/lib.rs index 30353fc0e8c..35c236cc044 100644 --- a/vm/loader/src/lib.rs +++ b/vm/loader/src/lib.rs @@ -11,6 +11,7 @@ pub mod cpuid; pub mod elf; pub mod importer; pub mod linux; +pub mod mptable; pub mod paravisor; pub mod pcat; pub mod smbios; diff --git a/vm/loader/src/mptable.rs b/vm/loader/src/mptable.rs new file mode 100644 index 00000000000..206b23a5c28 --- /dev/null +++ b/vm/loader/src/mptable.rs @@ -0,0 +1,262 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Intel MP 1.4 table construction for x86 guests. + +use thiserror::Error; + +/// Guest-physical address of the MP floating pointer. +pub const MP_FLOATING_POINTER_ADDR: usize = 0; +/// Guest-physical address of the MP configuration table. +pub const MP_CONFIG_TABLE_ADDR: usize = 0x400; + +const MP_IRQ_FLAGS_LEVEL_HIGH: u16 = 0x000d; +const MP_CONFIG_HEADER_SIZE: usize = 44; +const MP_PROCESSOR_SIZE: usize = 20; +const MP_NON_PROCESSOR_ENTRY_COUNT: usize = 17; + +/// Guest-visible processor and interrupt data represented by MP tables. +#[derive(Debug, Clone, Copy)] +pub struct MpTableConfig<'a> { + /// APIC IDs in virtual-processor order. The first entry is the BSP. + pub apic_ids: &'a [u32], + /// ISA IRQs described as active-high, level-triggered. + pub level_triggered_irqs: &'a [u32], +} + +/// Fully constructed MP 1.4 data. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MpTables { + /// The 16-byte MP floating pointer. + pub floating_pointer: [u8; 16], + /// The MP configuration table referenced by the floating pointer. + pub configuration_table: Vec, +} + +/// MP table construction error. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum Error { + /// At least one processor must be described. + #[error("MP processor topology must contain at least one processor")] + NoProcessors, + /// MicroVM APIC IDs must be contiguous and start at zero. + #[error("MP processor {index} has APIC ID {apic_id}; expected {expected}")] + InvalidApicId { + /// Processor index. + index: usize, + /// Supplied APIC ID. + apic_id: u32, + /// Required APIC ID. + expected: u32, + }, + /// MP 1.4 processor entries contain only an 8-bit APIC ID. + #[error("MP processor {index} APIC ID {apic_id} does not fit in an MP 1.4 entry")] + ApicIdTooLarge { + /// Processor index. + index: usize, + /// Supplied APIC ID. + apic_id: u32, + }, + /// The entry count or table length cannot be represented. + #[error("MP table has too many processor or interrupt entries")] + TooManyEntries, + /// ISA IRQs are limited to 0 through 15. + #[error("MP table IRQ {0} is outside the ISA range")] + InvalidIrq(u32), +} + +/// Builds the MP floating pointer and configuration table. +pub fn build(config: &MpTableConfig<'_>) -> Result { + let configuration_table = build_config_table(config)?; + let mut floating_pointer = [0u8; 16]; + floating_pointer[..4].copy_from_slice(b"_MP_"); + floating_pointer[4..8].copy_from_slice(&(MP_CONFIG_TABLE_ADDR as u32).to_le_bytes()); + floating_pointer[8] = 1; + floating_pointer[9] = 4; + floating_pointer[10] = checksum(&floating_pointer); + Ok(MpTables { + floating_pointer, + configuration_table, + }) +} + +/// Builds an Intel MP 1.4 configuration table. +pub fn build_config_table(config: &MpTableConfig<'_>) -> Result, Error> { + if config.apic_ids.is_empty() { + return Err(Error::NoProcessors); + } + if let Some(&irq) = config.level_triggered_irqs.iter().find(|irq| **irq >= 16) { + return Err(Error::InvalidIrq(irq)); + } + let entry_count = u16::try_from( + config + .apic_ids + .len() + .checked_add(MP_NON_PROCESSOR_ENTRY_COUNT) + .ok_or(Error::TooManyEntries)?, + ) + .map_err(|_| Error::TooManyEntries)?; + + let capacity = MP_CONFIG_HEADER_SIZE + .checked_add( + MP_PROCESSOR_SIZE + .checked_mul(config.apic_ids.len()) + .ok_or(Error::TooManyEntries)?, + ) + .and_then(|size| size.checked_add(136)) + .ok_or(Error::TooManyEntries)?; + let mut table = Vec::with_capacity(capacity); + table.extend_from_slice(b"PCMP"); + table.extend_from_slice(&0u16.to_le_bytes()); + table.push(4); + table.push(0); + table.extend_from_slice(b"OPENVMM "); + table.extend_from_slice(b"MICROVM "); + table.extend_from_slice(&0u32.to_le_bytes()); + table.extend_from_slice(&0u16.to_le_bytes()); + table.extend_from_slice(&entry_count.to_le_bytes()); + table.extend_from_slice(&0xfee0_0000u32.to_le_bytes()); + table.extend_from_slice(&0u32.to_le_bytes()); + assert_eq!(table.len(), MP_CONFIG_HEADER_SIZE); + + for (index, &apic_id) in config.apic_ids.iter().enumerate() { + let expected = u32::try_from(index).map_err(|_| Error::TooManyEntries)?; + if apic_id != expected { + return Err(Error::InvalidApicId { + index, + apic_id, + expected, + }); + } + let apic_id = + u8::try_from(apic_id).map_err(|_| Error::ApicIdTooLarge { index, apic_id })?; + table.extend_from_slice(&[0, apic_id, 0x14, if index == 0 { 3 } else { 1 }]); + table.extend_from_slice(&0u32.to_le_bytes()); + table.extend_from_slice(&0u32.to_le_bytes()); + table.extend_from_slice(&[0; 8]); + } + assert_eq!( + table.len(), + MP_CONFIG_HEADER_SIZE + MP_PROCESSOR_SIZE * config.apic_ids.len() + ); + + table.extend_from_slice(&[1, 0]); + table.extend_from_slice(b"ISA "); + table.extend_from_slice(&[2, 0, 0x11, 1]); + table.extend_from_slice(&0xfec0_0000u32.to_le_bytes()); + + for irq in (0u8..16).filter(|irq| *irq != 2) { + let pin = if irq == 0 { 2 } else { irq }; + let flags = if config.level_triggered_irqs.contains(&u32::from(irq)) { + MP_IRQ_FLAGS_LEVEL_HIGH + } else { + 0 + }; + table.extend_from_slice(&[3, 0]); + table.extend_from_slice(&flags.to_le_bytes()); + table.extend_from_slice(&[0, irq, 0, pin]); + } + + let table_len = u16::try_from(table.len()).map_err(|_| Error::TooManyEntries)?; + table[4..6].copy_from_slice(&table_len.to_le_bytes()); + table[7] = checksum(&table); + Ok(table) +} + +fn checksum(bytes: &[u8]) -> u8 { + 0u8.wrapping_sub( + bytes + .iter() + .copied() + .fold(0u8, |sum, byte| sum.wrapping_add(byte)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn builds_valid_checksums_and_processor_entries() { + for processor_count in [1usize, 2, 4, 8] { + let apic_ids = (0..processor_count as u32).collect::>(); + let tables = build(&MpTableConfig { + apic_ids: &apic_ids, + level_triggered_irqs: &[4, 5, 6, 7], + }) + .unwrap(); + + assert_eq!(&tables.floating_pointer[..4], b"_MP_"); + assert_eq!( + u32::from_le_bytes(tables.floating_pointer[4..8].try_into().unwrap()), + MP_CONFIG_TABLE_ADDR as u32 + ); + assert_eq!( + tables + .floating_pointer + .iter() + .copied() + .fold(0u8, |sum, byte| sum.wrapping_add(byte)), + 0 + ); + assert_eq!( + tables + .configuration_table + .iter() + .copied() + .fold(0u8, |sum, byte| sum.wrapping_add(byte)), + 0 + ); + assert_eq!( + tables.configuration_table.len(), + 180 + MP_PROCESSOR_SIZE * processor_count + ); + for (index, entry) in tables.configuration_table + [MP_CONFIG_HEADER_SIZE..MP_CONFIG_HEADER_SIZE + MP_PROCESSOR_SIZE * processor_count] + .chunks_exact(MP_PROCESSOR_SIZE) + .enumerate() + { + assert_eq!(entry[0], 0); + assert_eq!(entry[1], index as u8); + assert_eq!(entry[3], if index == 0 { 3 } else { 1 }); + } + } + } + + #[test] + fn rejects_invalid_inputs() { + assert_eq!( + build(&MpTableConfig { + apic_ids: &[], + level_triggered_irqs: &[], + }), + Err(Error::NoProcessors) + ); + assert!(matches!( + build(&MpTableConfig { + apic_ids: &[0, 2], + level_triggered_irqs: &[], + }), + Err(Error::InvalidApicId { .. }) + )); + assert_eq!( + build(&MpTableConfig { + apic_ids: &[0], + level_triggered_irqs: &[16], + }), + Err(Error::InvalidIrq(16)) + ); + let apic_ids = (0..=256).collect::>(); + assert!(matches!( + build(&MpTableConfig { + apic_ids: &apic_ids, + level_triggered_irqs: &[], + }), + Err(Error::ApicIdTooLarge { + index: 256, + apic_id: 256 + }) + )); + } +} From 1adbd9f876b59f752bbc59f744d0317c1bfe9f42 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:28:31 -0700 Subject: [PATCH 085/304] loader: add firmware-less Linux direct boot with MP tables Linux direct boot on x86 always describes the machine to the kernel through ACPI tables, and optionally SMBIOS. A minimal virtual machine without firmware and without an ACPI platform needs a boot path that presents only what the kernel strictly requires: its memory map, its processors and interrupt routing, and its command line. Add `linux::microvm::load_x86_mptable`, which boots an uncompressed x86-64 ELF kernel with Intel MP tables and no ACPI, SMBIOS, or firmware tables; a bzImage is rejected. It loads the kernel and an optional initial RAM disk through one reusable 1-MiB import buffer, and imports: - the MP floating pointer and configuration table built from the given processor and interrupt configuration, in guest page 0, rejecting a table that would reach the GDT; - the command line, the GDT, and identity-mapped page tables; - a zero page whose e820 map lists guest RAM, reserves the legacy ISA hole from 0xa0000 to 0x100000, and reserves the caller's page-aligned, sorted, non-overlapping ranges inside RAM; and - the 64-bit entry registers. The memory layout must place RAM under the ISA hole and must leave the 3-GiB to 4-GiB range free for MMIO; any other layout, or an invalid reserved range, is rejected before anything is imported. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/loader/src/linux.rs | 13 + vm/loader/src/linux/microvm.rs | 859 +++++++++++++++++++++++++++++++++ 2 files changed, 872 insertions(+) create mode 100644 vm/loader/src/linux/microvm.rs diff --git a/vm/loader/src/linux.rs b/vm/loader/src/linux.rs index 09f5710317b..cebf08da055 100644 --- a/vm/loader/src/linux.rs +++ b/vm/loader/src/linux.rs @@ -3,6 +3,8 @@ //! Linux specific loader definitions and implementation. +pub mod microvm; + use crate::common::ChunkBuf; use crate::common::ImportFileRegion; use crate::common::ImportFileRegionError; @@ -14,6 +16,7 @@ use crate::importer::BootPageAcceptance; use crate::importer::GuestArch; use crate::importer::ImageLoad; use crate::importer::X86Register; +use crate::mptable; use aarch64defs::Cpsr64; use aarch64defs::IntermPhysAddrSize; use aarch64defs::SctlrEl1; @@ -221,6 +224,16 @@ pub enum Error { TooManyMemoryRanges(usize), #[error("acpi tables are empty")] EmptyAcpiTables, + #[error("MP-table Linux direct boot requires an uncompressed ELF kernel")] + MpTableRequiresElf, + #[error("failed to construct MP tables")] + MpTable(#[from] mptable::Error), + #[error("MP table ending at {table_end:#x} overlaps the GDT at {gdt_addr:#x}")] + MpTableOverlap { table_end: usize, gdt_addr: u64 }, + #[error("invalid MP-table reserved memory range {start:#x}..{end:#x}")] + InvalidReservedMemoryRange { start: u64, end: u64 }, + #[error("the memory layout cannot represent the fixed MP-table platform ranges")] + InvalidMpTableMemoryLayout, } /// ACPI tables to place in guest memory: a one-page RSDP plus the tables it diff --git a/vm/loader/src/linux/microvm.rs b/vm/loader/src/linux/microvm.rs new file mode 100644 index 00000000000..e468aba4840 --- /dev/null +++ b/vm/loader/src/linux/microvm.rs @@ -0,0 +1,859 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Linux direct boot for the microVM MP-table machine profile. + +use super::CR3_BASE; +use super::Error; +use super::GDT_BASE; +use super::InitrdAddressType; +use super::InitrdConfig; +use super::InitrdInfo; +use super::KERNEL_BASE; +use super::KernelInfo; +use super::LoadInfo; +use super::ZERO_PAGE_BASE; +use crate::common::ChunkBuf; +use crate::common::ImportFileRegion; +use crate::common::import_default_gdt; +use crate::elf::load_static_elf_with_buffer; +use crate::importer::BootPageAcceptance; +use crate::importer::ImageLoad; +use crate::importer::X86Register; +use crate::mptable; +use hvdef::HV_PAGE_SIZE; +use loader_defs::linux as defs; +use memory_range::MemoryRange; +use page_table::IdentityMapSize; +use page_table::x64::IdentityMapBuilder; +use page_table::x64::PageTable; +use page_table::x64::align_up_to_large_page_size; +use page_table::x64::align_up_to_page_size; +use std::ffi::CString; +use std::io::Read; +use std::io::Seek; +use vm_topology::memory::MemoryLayout; +use zerocopy::FromZeros; +use zerocopy::IntoBytes; + +const MPTABLE_CMDLINE_BASE: u64 = 0x2_0000; +const MPTABLE_CMDLINE_END: u64 = 0x3_0000; +const MPTABLE_ISA_HOLE_BASE: u64 = 0xa_0000; +const MPTABLE_ISA_HOLE_END: u64 = 0x10_0000; +const MPTABLE_MMIO_GAP_BASE: u64 = 0xc000_0000; +const MPTABLE_MMIO_GAP_END: u64 = 0x1_0000_0000; +const MPTABLE_LOAD_CHUNK_SIZE: usize = 1024 * 1024; +const MPTABLE_PAGE_TABLE_COUNT: usize = 6; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct E820Range { + start: u64, + end: u64, + entry_type: u32, +} + +fn build_setup_header(cmdline: &CString, initrd_base: u32, initrd_size: u32) -> defs::setup_header { + const LOADER_TYPE_UNREGISTERED: u8 = 0xff; + + let mut hdr = defs::setup_header { + boot_flag: 0xaa55.into(), + header: 0x53726448.into(), + kernel_alignment: 0x100000.into(), + ..FromZeros::new_zeroed() + }; + hdr.type_of_loader = LOADER_TYPE_UNREGISTERED; + hdr.cmd_line_ptr = MPTABLE_CMDLINE_BASE.try_into().expect("must fit in u32"); + hdr.cmdline_size = (cmdline.as_bytes().len() as u64) + .try_into() + .expect("must fit in u32"); + hdr.ramdisk_image = initrd_base.into(); + hdr.ramdisk_size = initrd_size.into(); + hdr +} + +fn range_is_in_ram(mem_layout: &MemoryLayout, range: MemoryRange) -> bool { + mem_layout + .ram() + .iter() + .any(|ram| range.start() >= ram.range.start() && range.end() <= ram.range.end()) +} + +fn build_mptable_e820( + mem_layout: &MemoryLayout, + reserved_memory_ranges: &[MemoryRange], +) -> Result, Error> { + let mut previous_end = 0; + for range in reserved_memory_ranges { + let valid = !range.is_empty() + && range.start().is_multiple_of(HV_PAGE_SIZE) + && range.end().is_multiple_of(HV_PAGE_SIZE) + && range.start() >= previous_end + && range_is_in_ram(mem_layout, *range); + if !valid { + return Err(Error::InvalidReservedMemoryRange { + start: range.start(), + end: range.end(), + }); + } + previous_end = range.end(); + } + + let isa_hole = MemoryRange::new(MPTABLE_ISA_HOLE_BASE..MPTABLE_ISA_HOLE_END); + let mmio_gap = MemoryRange::new(MPTABLE_MMIO_GAP_BASE..MPTABLE_MMIO_GAP_END); + if !range_is_in_ram(mem_layout, isa_hole) + || mem_layout + .ram() + .iter() + .any(|ram| ram.range.overlaps(&mmio_gap)) + || reserved_memory_ranges + .iter() + .any(|range| range.overlaps(&isa_hole)) + { + return Err(Error::InvalidMpTableMemoryLayout); + } + + let mut reservations = reserved_memory_ranges.to_vec(); + reservations.push(isa_hole); + reservations.sort(); + + let mut entries = Vec::with_capacity(mem_layout.ram().len() + reservations.len() * 2 + 1); + for ram in mem_layout.ram() { + let mut next = ram.range.start(); + for reserved in reservations.iter().filter(|reserved| { + reserved.start() >= ram.range.start() && reserved.end() <= ram.range.end() + }) { + if next < reserved.start() { + entries.push(E820Range { + start: next, + end: reserved.start(), + entry_type: defs::E820_RAM, + }); + } + entries.push(E820Range { + start: reserved.start(), + end: reserved.end(), + entry_type: defs::E820_RESERVED, + }); + next = reserved.end(); + } + if next < ram.range.end() { + entries.push(E820Range { + start: next, + end: ram.range.end(), + entry_type: defs::E820_RAM, + }); + } + } + entries.sort_by_key(|entry| (entry.start, entry.end)); + + let mut merged: Vec = Vec::with_capacity(entries.len()); + for entry in entries { + if entry.start >= entry.end { + return Err(Error::InvalidMpTableMemoryLayout); + } + if let Some(previous) = merged.last_mut() { + if entry.start < previous.end { + return Err(Error::InvalidMpTableMemoryLayout); + } + if entry.start == previous.end && entry.entry_type == previous.entry_type { + previous.end = entry.end; + continue; + } + } + merged.push(entry); + } + Ok(merged) +} + +fn build_mptable_zero_page( + mem_layout: &MemoryLayout, + reserved_memory_ranges: &[MemoryRange], + cmdline: &CString, + initrd_base: u32, + initrd_size: u32, +) -> Result { + let hdr = build_setup_header(cmdline, initrd_base, initrd_size); + let mut boot_params = defs::boot_params { + hdr, + ..FromZeros::new_zeroed() + }; + let entries = build_mptable_e820(mem_layout, reserved_memory_ranges)?; + let capacity = boot_params.e820_map.len(); + if entries.len() > capacity { + return Err(Error::TooManyMemoryRanges(capacity)); + } + for (output, entry) in boot_params.e820_map.iter_mut().zip(&entries) { + *output = defs::e820entry { + addr: entry.start.into(), + size: (entry.end - entry.start).into(), + typ: entry.entry_type.into(), + }; + } + boot_params.e820_entries = + u8::try_from(entries.len()).map_err(|_| Error::TooManyMemoryRanges(capacity))?; + Ok(boot_params) +} + +fn import_initrd_with_buffer( + initrd: Option>, + next_addr: u64, + importer: &mut dyn ImageLoad, + buffer: &mut ChunkBuf, +) -> Result, Error> { + let initrd_info = match initrd { + Some(cfg) => { + let initrd_address = match cfg.initrd_address { + InitrdAddressType::AfterKernel => align_up_to_large_page_size(next_addr), + InitrdAddressType::Address(addr) => addr, + }; + + tracing::trace!(initrd_address, "loading initrd"); + super::check_address_alignment(initrd_address)?; + + buffer + .import_file_region( + importer, + ImportFileRegion { + file: cfg.initrd, + file_offset: 0, + file_length: cfg.size, + gpa: initrd_address, + memory_length: cfg.size, + acceptance: BootPageAcceptance::Exclusive, + tag: "linux-initrd", + }, + ) + .map_err(Error::ImportInitrd)?; + + Some(InitrdInfo { + gpa: initrd_address, + size: cfg.size, + }) + } + None => None, + }; + Ok(initrd_info) +} + +fn load_uncompressed_kernel_and_initrd_x64_with_buffer( + importer: &mut dyn ImageLoad, + kernel_image: &mut F, + kernel_minimum_start_address: u64, + initrd: Option>, + buffer: &mut ChunkBuf, +) -> Result +where + F: Read + Seek, +{ + let elf_load_info = load_static_elf_with_buffer( + importer, + kernel_image, + kernel_minimum_start_address, + 0, + false, + BootPageAcceptance::Exclusive, + "linux-kernel", + buffer, + ) + .map_err(Error::ElfLoader)?; + + let crate::elf::LoadInfo { + minimum_address_used: min_addr, + next_available_address: next_addr, + entrypoint, + } = elf_load_info; + tracing::trace!(min_addr, next_addr, entrypoint, "loaded kernel"); + + let initrd_info = import_initrd_with_buffer(initrd, next_addr, importer, buffer)?; + + Ok(LoadInfo { + kernel: KernelInfo { + gpa: min_addr, + size: next_addr - min_addr, + entrypoint, + }, + initrd: initrd_info, + dtb: None, + bzimage_setup_header: None, + }) +} + +fn import_command_line( + importer: &mut impl ImageLoad, + cmdline: &CString, +) -> Result<(), Error> { + let raw_cmdline = cmdline.as_bytes_with_nul(); + let slot_size = MPTABLE_CMDLINE_END - MPTABLE_CMDLINE_BASE; + if raw_cmdline.len() as u64 > slot_size { + return Err(Error::CommandLineTooLong(raw_cmdline.len(), slot_size)); + } + if raw_cmdline.len() > 1 { + let cmdline_size_pages = align_up_to_page_size(raw_cmdline.len() as u64) / HV_PAGE_SIZE; + importer + .import_pages( + MPTABLE_CMDLINE_BASE / HV_PAGE_SIZE, + cmdline_size_pages, + "linux-commandline", + BootPageAcceptance::Exclusive, + raw_cmdline, + ) + .map_err(Error::Importer)?; + } + Ok(()) +} + +fn import_x86_boot_pages(importer: &mut impl ImageLoad) -> Result<(), Error> { + import_default_gdt(importer, GDT_BASE / HV_PAGE_SIZE).map_err(Error::Importer)?; + let mut page_table_work_buffer: Vec = + vec![PageTable::new_zeroed(); MPTABLE_PAGE_TABLE_COUNT]; + let mut page_table: Vec = vec![0; MPTABLE_PAGE_TABLE_COUNT * HV_PAGE_SIZE as usize]; + let page_table = IdentityMapBuilder::new( + CR3_BASE, + IdentityMapSize::Size4Gb, + page_table_work_buffer.as_mut_slice(), + page_table.as_mut_slice(), + )? + .build(); + assert!((page_table.len() as u64).is_multiple_of(HV_PAGE_SIZE)); + importer + .import_pages( + CR3_BASE / HV_PAGE_SIZE, + page_table.len() as u64 / HV_PAGE_SIZE, + "linux-pagetables", + BootPageAcceptance::Exclusive, + page_table, + ) + .map_err(Error::Importer)?; + Ok(()) +} + +fn import_x86_registers( + importer: &mut impl ImageLoad, + load_info: &LoadInfo, +) -> Result<(), Error> { + let mut import_reg = |register| { + importer + .import_vp_register(register) + .map_err(Error::Importer) + }; + + import_reg(X86Register::Cr0(x86defs::X64_CR0_PG | x86defs::X64_CR0_PE))?; + import_reg(X86Register::Cr3(CR3_BASE))?; + import_reg(X86Register::Cr4(x86defs::X64_CR4_PAE))?; + import_reg(X86Register::Efer( + x86defs::X64_EFER_SCE + | x86defs::X64_EFER_LME + | x86defs::X64_EFER_LMA + | x86defs::X64_EFER_NXE, + ))?; + import_reg(X86Register::Rip(load_info.kernel.entrypoint))?; + import_reg(X86Register::Rsi(ZERO_PAGE_BASE))?; + Ok(()) +} + +fn import_mptable_config( + importer: &mut impl ImageLoad, + load_info: &LoadInfo, + cmdline: &CString, + mem_layout: &MemoryLayout, + config: &mptable::MpTableConfig<'_>, + reserved_memory_ranges: &[MemoryRange], +) -> Result<(), Error> { + let raw_cmdline = cmdline.as_bytes_with_nul(); + let slot_size = MPTABLE_CMDLINE_END - MPTABLE_CMDLINE_BASE; + if raw_cmdline.len() as u64 > slot_size { + return Err(Error::CommandLineTooLong(raw_cmdline.len(), slot_size)); + } + let tables = mptable::build(config)?; + let table_end = mptable::MP_CONFIG_TABLE_ADDR + .checked_add(tables.configuration_table.len()) + .ok_or(Error::InvalidMpTableMemoryLayout)?; + if table_end > GDT_BASE as usize { + return Err(Error::MpTableOverlap { + table_end, + gdt_addr: GDT_BASE, + }); + } + let boot_params = build_mptable_zero_page( + mem_layout, + reserved_memory_ranges, + cmdline, + load_info.initrd.as_ref().map(|info| info.gpa).unwrap_or(0) as u32, + load_info.initrd.as_ref().map(|info| info.size).unwrap_or(0) as u32, + )?; + + let mut mp_page = [0u8; HV_PAGE_SIZE as usize]; + mp_page[mptable::MP_FLOATING_POINTER_ADDR + ..mptable::MP_FLOATING_POINTER_ADDR + tables.floating_pointer.len()] + .copy_from_slice(&tables.floating_pointer); + mp_page[mptable::MP_CONFIG_TABLE_ADDR..table_end].copy_from_slice(&tables.configuration_table); + importer + .import_pages( + 0, + 1, + "linux-mptable", + BootPageAcceptance::Exclusive, + &mp_page, + ) + .map_err(Error::Importer)?; + + import_command_line(importer, cmdline)?; + import_x86_boot_pages(importer)?; + importer + .import_pages( + ZERO_PAGE_BASE / HV_PAGE_SIZE, + 1, + "linux-zeropage", + BootPageAcceptance::Exclusive, + boot_params.as_bytes(), + ) + .map_err(Error::Importer)?; + import_x86_registers(importer, load_info)?; + Ok(()) +} + +/// Loads an uncompressed x86-64 ELF kernel through Linux direct boot with +/// Intel MP tables and no ACPI, SMBIOS, or firmware tables. +pub fn load_x86_mptable( + importer: &mut impl ImageLoad, + kernel_image: &mut F, + initrd: Option>, + cmdline: &CString, + mem_layout: &MemoryLayout, + config: &mptable::MpTableConfig<'_>, + reserved_memory_ranges: &[MemoryRange], +) -> Result +where + F: Read + Seek, +{ + if crate::bzimage::is_bzimage(kernel_image).map_err(Error::BzImage)? { + return Err(Error::MpTableRequiresElf); + } + let mut buffer = ChunkBuf::with_size(MPTABLE_LOAD_CHUNK_SIZE); + let load_info = load_uncompressed_kernel_and_initrd_x64_with_buffer( + importer, + kernel_image, + KERNEL_BASE, + initrd, + &mut buffer, + )?; + import_mptable_config( + importer, + &load_info, + cmdline, + mem_layout, + config, + reserved_memory_ranges, + )?; + Ok(load_info) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::importer::IgvmParameterType; + use crate::importer::IsolationConfig; + use crate::importer::ParameterAreaIndex; + use crate::importer::StartupMemoryType; + use test_with_tracing::test; + use zerocopy::FromBytes; + + const MB: u64 = 0x100000; + const GB: u64 = 0x4000_0000; + + fn make_layout(ram_size: u64) -> MemoryLayout { + MemoryLayout::new( + ram_size, + &[MemoryRange::new(4 * GB - 128 * MB..4 * GB)], + &[], + &[], + None, + ) + .unwrap() + } + + fn make_mptable_layout(ram_size: u64) -> MemoryLayout { + MemoryLayout::new( + ram_size, + &[MemoryRange::new(3 * GB..4 * GB)], + &[], + &[], + None, + ) + .unwrap() + } + + #[derive(Default)] + struct RecordingImporter { + pages: Vec<(String, u64, u64)>, + imports: Vec, + registers: Vec, + vp_context_page: Option, + } + + #[derive(Debug)] + struct ImportRecord { + page_base: u64, + page_count: u64, + tag: String, + data: Vec, + } + + impl RecordingImporter { + fn page_base(&self, tag: &str) -> Option { + self.pages + .iter() + .find(|(t, ..)| t == tag) + .map(|(_, base, _)| *base) + } + } + + impl ImageLoad for RecordingImporter { + fn isolation_config(&self) -> IsolationConfig { + IsolationConfig { + paravisor_present: false, + isolation_type: crate::importer::IsolationType::None, + shared_gpa_boundary_bits: None, + } + } + + fn create_parameter_area( + &mut self, + _page_base: u64, + _page_count: u32, + _debug_tag: &str, + ) -> anyhow::Result { + unimplemented!() + } + + fn create_parameter_area_with_data( + &mut self, + _page_base: u64, + _page_count: u32, + _debug_tag: &str, + _initial_data: &[u8], + ) -> anyhow::Result { + unimplemented!() + } + + fn import_parameter( + &mut self, + _parameter_area: ParameterAreaIndex, + _byte_offset: u32, + _parameter_type: IgvmParameterType, + ) -> anyhow::Result<()> { + unimplemented!() + } + + fn import_pages( + &mut self, + page_base: u64, + page_count: u64, + debug_tag: &'static str, + _acceptance: BootPageAcceptance, + data: &[u8], + ) -> anyhow::Result<()> { + self.pages + .push((debug_tag.to_string(), page_base, page_count)); + self.imports.push(ImportRecord { + page_base, + page_count, + tag: debug_tag.to_string(), + data: data.to_vec(), + }); + Ok(()) + } + + fn import_vp_register(&mut self, register: X86Register) -> anyhow::Result<()> { + self.registers.push(register); + Ok(()) + } + + fn verify_startup_memory_available( + &mut self, + _page_base: u64, + _page_count: u64, + _memory_type: StartupMemoryType, + ) -> anyhow::Result<()> { + Ok(()) + } + + fn set_vp_context_page(&mut self, page_base: u64) -> anyhow::Result<()> { + self.vp_context_page = Some(page_base); + Ok(()) + } + + fn relocation_region( + &mut self, + _gpa: u64, + _size_bytes: u64, + _relocation_alignment: u64, + _minimum_relocation_gpa: u64, + _maximum_relocation_gpa: u64, + _apply_rip_offset: bool, + _apply_gdtr_offset: bool, + _vp_index: u16, + ) -> anyhow::Result<()> { + unimplemented!() + } + + fn page_table_relocation( + &mut self, + _page_table_gpa: u64, + _size_pages: u64, + _used_pages: u64, + _vp_index: u16, + ) -> anyhow::Result<()> { + unimplemented!() + } + + fn set_imported_regions_config_page(&mut self, _page_base: u64) { + unimplemented!() + } + } + + fn test_load_info() -> LoadInfo { + LoadInfo { + kernel: KernelInfo { + gpa: KERNEL_BASE, + size: 0x1000, + entrypoint: KERNEL_BASE, + }, + initrd: None, + dtb: None, + bzimage_setup_header: None, + } + } + + fn mptable_config(apic_ids: &[u32]) -> mptable::MpTableConfig<'_> { + mptable::MpTableConfig { + apic_ids, + level_triggered_irqs: &[4, 5, 6, 7], + } + } + + fn imported_zero_page(importer: &RecordingImporter) -> defs::boot_params { + let data = &importer + .imports + .iter() + .find(|import| import.tag == "linux-zeropage") + .unwrap() + .data; + defs::boot_params::read_from_bytes(data).unwrap() + } + + #[test] + fn mptable_config_places_boot_data_without_firmware_tables() { + let apic_ids = [0, 1, 2, 3]; + let mut importer = RecordingImporter::default(); + import_mptable_config( + &mut importer, + &test_load_info(), + &CString::new("console=hvc0").unwrap(), + &make_layout(256 * MB), + &mptable_config(&apic_ids), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap(); + + let mp = importer + .imports + .iter() + .find(|import| import.tag == "linux-mptable") + .unwrap(); + assert_eq!(mp.page_base, 0); + assert_eq!(mp.page_count, 1); + assert_eq!( + &mp.data[mptable::MP_FLOATING_POINTER_ADDR..mptable::MP_FLOATING_POINTER_ADDR + 4], + b"_MP_" + ); + let table_length = u16::from_le_bytes( + mp.data[mptable::MP_CONFIG_TABLE_ADDR + 4..mptable::MP_CONFIG_TABLE_ADDR + 6] + .try_into() + .unwrap(), + ) as usize; + assert!(mptable::MP_CONFIG_TABLE_ADDR + table_length <= GDT_BASE as usize); + assert_eq!( + mp.data[mptable::MP_CONFIG_TABLE_ADDR..mptable::MP_CONFIG_TABLE_ADDR + table_length] + .iter() + .copied() + .fold(0u8, |sum, byte| sum.wrapping_add(byte)), + 0 + ); + assert_eq!( + importer.page_base("linux-commandline"), + Some(MPTABLE_CMDLINE_BASE / HV_PAGE_SIZE) + ); + assert!(importer.imports.iter().all(|import| { + !import.tag.contains("acpi") + && !import.tag.contains("rsdp") + && !import.tag.contains("smbios") + })); + + let boot_params = imported_zero_page(&importer); + assert_eq!(boot_params.acpi_rsdp_addr, 0); + assert_eq!( + u32::from(boot_params.hdr.cmd_line_ptr), + MPTABLE_CMDLINE_BASE as u32 + ); + assert_eq!(u32::from(boot_params.hdr.cmdline_size), 12); + assert!( + importer + .registers + .contains(&X86Register::Rsi(ZERO_PAGE_BASE)) + ); + assert!(importer.registers.contains(&X86Register::Cr3(CR3_BASE))); + assert!( + importer + .registers + .contains(&X86Register::Cr0(x86defs::X64_CR0_PG | x86defs::X64_CR0_PE)) + ); + assert!( + importer + .registers + .contains(&X86Register::Cr4(x86defs::X64_CR4_PAE)) + ); + assert!(importer.registers.contains(&X86Register::Rip(KERNEL_BASE))); + assert!( + !importer + .registers + .contains(&X86Register::Pat(x86defs::X86X_MSR_DEFAULT_PAT)) + ); + assert!( + !importer + .registers + .contains(&X86Register::MtrrDefType(0xc00)) + ); + } + + #[test] + fn mptable_config_supports_full_command_line_region() { + let cmdline = CString::new(vec![b'a'; 65_535]).unwrap(); + let mut importer = RecordingImporter::default(); + import_mptable_config( + &mut importer, + &test_load_info(), + &cmdline, + &make_layout(256 * MB), + &mptable_config(&[0]), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap(); + let imported = importer + .imports + .iter() + .find(|import| import.tag == "linux-commandline") + .unwrap(); + assert_eq!(imported.page_base, MPTABLE_CMDLINE_BASE / HV_PAGE_SIZE); + assert_eq!(imported.page_count, 16); + assert_eq!(imported.data.len(), 65_536); + assert_eq!(imported.data[65_535], 0); + + let mut importer = RecordingImporter::default(); + let cmdline = CString::new(vec![b'a'; 65_536]).unwrap(); + let error = import_mptable_config( + &mut importer, + &test_load_info(), + &cmdline, + &make_layout(256 * MB), + &mptable_config(&[0]), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap_err(); + assert!(matches!(error, Error::CommandLineTooLong(65_537, 65_536))); + assert!(importer.imports.is_empty()); + } + + #[test] + fn mptable_e820_reserves_status_and_isa_hole_and_leaves_mmio_gap() { + let entries = build_mptable_e820( + &make_mptable_layout(8 * GB), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap(); + assert_eq!( + entries.as_slice(), + &[ + E820Range { + start: 0, + end: 0x3_0000, + entry_type: defs::E820_RAM, + }, + E820Range { + start: 0x3_0000, + end: 0x3_1000, + entry_type: defs::E820_RESERVED, + }, + E820Range { + start: 0x3_1000, + end: MPTABLE_ISA_HOLE_BASE, + entry_type: defs::E820_RAM, + }, + E820Range { + start: MPTABLE_ISA_HOLE_BASE, + end: MPTABLE_ISA_HOLE_END, + entry_type: defs::E820_RESERVED, + }, + E820Range { + start: MPTABLE_ISA_HOLE_END, + end: MPTABLE_MMIO_GAP_BASE, + entry_type: defs::E820_RAM, + }, + E820Range { + start: MPTABLE_MMIO_GAP_END, + end: 9 * GB, + entry_type: defs::E820_RAM, + }, + ] + ); + } + + #[test] + fn mptable_e820_rejects_invalid_reservations_and_capacity() { + let layout = make_mptable_layout(256 * MB); + for ranges in [ + vec![MemoryRange::new(256 * MB..256 * MB + HV_PAGE_SIZE)], + vec![ + MemoryRange::new(0x4_0000..0x4_2000), + MemoryRange::new(0x4_1000..0x4_3000), + ], + vec![ + MemoryRange::new(0x5_0000..0x5_1000), + MemoryRange::new(0x4_0000..0x4_1000), + ], + ] { + assert!(matches!( + build_mptable_e820(&layout, &ranges), + Err(Error::InvalidReservedMemoryRange { .. }) + )); + } + + let reservations = (0..70) + .map(|index| { + let start = 0x1_0000 + index * 0x2000; + MemoryRange::new(start..start + HV_PAGE_SIZE) + }) + .collect::>(); + assert!(matches!( + build_mptable_zero_page(&layout, &reservations, &CString::new("").unwrap(), 0, 0,), + Err(Error::TooManyMemoryRanges(128)) + )); + } + + #[test] + fn mptable_config_rejects_table_overlap_before_import() { + let apic_ids = (0..200).collect::>(); + let mut importer = RecordingImporter::default(); + let error = import_mptable_config( + &mut importer, + &test_load_info(), + &CString::new("").unwrap(), + &make_mptable_layout(256 * MB), + &mptable_config(&apic_ids), + &[MemoryRange::new(0x3_0000..0x3_1000)], + ) + .unwrap_err(); + assert!(matches!(error, Error::MpTableOverlap { .. })); + assert!(importer.imports.is_empty()); + } +} From 0ee411e5cf09cc3bcfcc4d93b6734bdca87a09ff Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:32:33 -0700 Subject: [PATCH 086/304] vmm_core: make level-triggered legacy IRQs explicit The x86 ACPI builder describes legacy ISA IRQs with their bus defaults, edge-triggered and active-high, and a VM whose chipset devices signal a legacy IRQ by level had no way to say so in the MADT. The MP tables that the loader now builds already take such a list, so the two descriptions of a machine could not agree. Add `level_triggered_irqs` to the x86 `AcpiArchConfig`. The MADT gains an active-high, level-triggered interrupt source override for each listed IRQ. Every existing configuration, in OpenVMM, OpenHCL, and igvmfilegen, passes an empty list, so the generated tables are unchanged. A test checks that the MADT and the MP configuration table enumerate the same APIC IDs for 1, 2, 4, and 8 processors, and another checks the override for a level-triggered IRQ. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + openhcl/underhill_core/src/loader/mod.rs | 2 + openhcl/underhill_core/src/worker.rs | 1 + openvmm/openvmm_core/src/worker/dispatch.rs | 2 + vm/loader/igvmfilegen/src/snp_linux_direct.rs | 10 +++ vmm_core/Cargo.toml | 1 + vmm_core/src/acpi_builder.rs | 8 ++ vmm_core/src/acpi_builder/microvm.rs | 32 ++++++++ vmm_core/src/acpi_builder/test/microvm.rs | 76 +++++++++++++++++++ 9 files changed, 133 insertions(+) create mode 100644 vmm_core/src/acpi_builder/microvm.rs create mode 100644 vmm_core/src/acpi_builder/test/microvm.rs diff --git a/Cargo.lock b/Cargo.lock index 2d36e602181..d883787958d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10853,6 +10853,7 @@ dependencies = [ "iced-x86", "input_core", "inspect", + "loader", "memory_range", "mesh", "pal_async", diff --git a/openhcl/underhill_core/src/loader/mod.rs b/openhcl/underhill_core/src/loader/mod.rs index c04b2f1eb79..d711b7e5b48 100644 --- a/openhcl/underhill_core/src/loader/mod.rs +++ b/openhcl/underhill_core/src/loader/mod.rs @@ -279,6 +279,7 @@ fn load_linux(params: LoadLinuxParams<'_>) -> Result { with_psp: platform_config.general.psp_enabled, pm_base: chipset_resources::pm::DEFAULT_PM_PIO_BASE, acpi_irq: chipset_resources::pm::DEFAULT_ACPI_IRQ, + level_triggered_irqs: &[], iommu: None, }, }; @@ -505,6 +506,7 @@ pub fn write_uefi_config( with_psp: platform_config.general.psp_enabled, pm_base: chipset_resources::pm::DEFAULT_PM_PIO_BASE, acpi_irq: chipset_resources::pm::DEFAULT_ACPI_IRQ, + level_triggered_irqs: &[], iommu: None, }, #[cfg(guest_arch = "aarch64")] diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index 712555de8a9..26f600f4b7c 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -2734,6 +2734,7 @@ async fn new_underhill_vm( with_psp: dps.general.psp_enabled, pm_base: DEFAULT_PM_PIO_BASE, acpi_irq: DEFAULT_ACPI_IRQ, + level_triggered_irqs: &[], iommu: None, }, }; diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 572267ec3fb..7bc27ed1783 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1808,6 +1808,7 @@ impl InitializedVm { with_psp: cfg.chipset.with_generic_psp, pm_base: PM_BASE, acpi_irq: SYSTEM_IRQ_ACPI, + level_triggered_irqs: &[], iommu: None, }, }; @@ -3339,6 +3340,7 @@ impl LoadedVmInner { with_pit: self.chipset_capabilities.with_pit, pm_base: PM_BASE, acpi_irq: SYSTEM_IRQ_ACPI, + level_triggered_irqs: &[], iommu: match &self.iommu_devices { IommuDevices::AmdVi(devices) => { Some(vmm_core::acpi_builder::X86IommuAcpiConfig::AmdVi( diff --git a/vm/loader/igvmfilegen/src/snp_linux_direct.rs b/vm/loader/igvmfilegen/src/snp_linux_direct.rs index f34a01684a0..a9fe8844649 100644 --- a/vm/loader/igvmfilegen/src/snp_linux_direct.rs +++ b/vm/loader/igvmfilegen/src/snp_linux_direct.rs @@ -114,6 +114,7 @@ impl FixedGuestLayout { with_psp: false, pm_base: DEFAULT_PM_PIO_BASE, acpi_irq: DEFAULT_ACPI_IRQ, + level_triggered_irqs: &[], iommu: None, }, } @@ -581,6 +582,15 @@ mod tests { entries = rest; } assert!(entries.is_empty()); + + let AcpiArchConfig::X86 { + level_triggered_irqs, + .. + } = layout.acpi_builder().arch + else { + panic!("fixed layout must use x86 ACPI"); + }; + assert!(level_triggered_irqs.is_empty()); } } diff --git a/vmm_core/Cargo.toml b/vmm_core/Cargo.toml index 8690b6783a2..046fc3c9d37 100644 --- a/vmm_core/Cargo.toml +++ b/vmm_core/Cargo.toml @@ -66,6 +66,7 @@ iced-x86 = { optional = true, workspace = true, features = ["std", "fast_fmt"] } build_rs_guest_arch.workspace = true [dev-dependencies] +loader.workspace = true test_with_tracing.workspace = true [lints] diff --git a/vmm_core/src/acpi_builder.rs b/vmm_core/src/acpi_builder.rs index 316ba0fbd3e..c31d0bb64fd 100644 --- a/vmm_core/src/acpi_builder.rs +++ b/vmm_core/src/acpi_builder.rs @@ -3,6 +3,8 @@ //! Construct ACPI tables for a concrete VM topology +mod microvm; + // TODO: continue to remove these hardcoded deps use acpi::cedt::Cedt; use acpi::dsdt; @@ -227,6 +229,8 @@ pub enum AcpiArchConfig { pm_base: u16, /// ACPI IRQ number. acpi_irq: u32, + /// Legacy IRQs that must be described as active-high, level-triggered. + level_triggered_irqs: &'static [u32], /// x86 IOMMU ACPI table configuration. Generates an IVRS (AMD) or /// DMAR (Intel VT-d) table when set. At most one x86 IOMMU type /// is active per VM. @@ -585,6 +589,7 @@ impl AcpiTablesBuilder<'_, T> { } } + microvm::extend_madt_level_triggered_irqs(&self.arch, &mut madt_extra); T::extend_madt(self.processor_topology, &mut madt_extra); let (apic_addr, flags) = match self.arch { @@ -1473,6 +1478,8 @@ impl AcpiTablesBuilder<'_, T> { #[cfg(test)] mod test { + mod microvm; + use super::*; use acpi_spec::madt::MadtParser; use acpi_spec::mcfg::parse_mcfg; @@ -1514,6 +1521,7 @@ mod test { with_psp: false, pm_base: 1234, acpi_irq: 2, + level_triggered_irqs: &[], iommu: None, }, } diff --git a/vmm_core/src/acpi_builder/microvm.rs b/vmm_core/src/acpi_builder/microvm.rs new file mode 100644 index 00000000000..a7e1eb274ec --- /dev/null +++ b/vmm_core/src/acpi_builder/microvm.rs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM MADT additions: interrupt source overrides that describe legacy +//! IRQs as active-high and level-triggered. + +use super::AcpiArchConfig; +use acpi_spec::madt::InterruptPolarity; +use acpi_spec::madt::InterruptTriggerMode; +use zerocopy::IntoBytes; + +/// Appends an active-high, level-triggered interrupt source override for each +/// of the x86 `level_triggered_irqs` to the MADT entries in `madt_extra`. +pub(super) fn extend_madt_level_triggered_irqs(arch: &AcpiArchConfig, madt_extra: &mut Vec) { + if let AcpiArchConfig::X86 { + level_triggered_irqs, + .. + } = *arch + { + for &irq in level_triggered_irqs { + madt_extra.extend_from_slice( + acpi_spec::madt::MadtInterruptSourceOverride::new( + irq.try_into().expect("legacy IRQ should be in range"), + irq, + Some(InterruptPolarity::ActiveHigh), + Some(InterruptTriggerMode::Level), + ) + .as_bytes(), + ); + } + } +} diff --git a/vmm_core/src/acpi_builder/test/microvm.rs b/vmm_core/src/acpi_builder/test/microvm.rs new file mode 100644 index 00000000000..f8f6e901d5b --- /dev/null +++ b/vmm_core/src/acpi_builder/test/microvm.rs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM MADT tests: SMP APIC IDs match the shared MP table, and +//! level-triggered legacy IRQs get interrupt source overrides. + +use super::*; +use vm_topology::processor::x86::X2ApicState; + +#[test] +fn test_microvm_smp_madt_matches_shared_mp_table() { + for processor_count in [1, 2, 4, 8] { + let mut topology_builder = TopologyBuilder::new_x86(); + topology_builder + .vps_per_socket(processor_count) + .smt_enabled(false) + .x2apic(X2ApicState::Unsupported); + let topology = topology_builder.build(processor_count).unwrap(); + let apic_ids = topology.vps_arch().map(|vp| vp.apic_id).collect::>(); + + let mem = new_mem(); + let pcie = vec![]; + let madt = new_builder(&mem, &topology, &pcie).build_madt(); + let madt_ids = MadtParser::new(&madt) + .unwrap() + .parse_apic_ids() + .unwrap() + .into_iter() + .flatten() + .collect::>(); + + let mp_table = loader::mptable::build_config_table(&loader::mptable::MpTableConfig { + apic_ids: &apic_ids, + level_triggered_irqs: &[], + }) + .unwrap(); + let mp_ids = mp_table[44..44 + apic_ids.len() * 20] + .chunks_exact(20) + .map(|entry| { + assert_eq!(entry[0], 0); + u32::from(entry[1]) + }) + .collect::>(); + + assert_eq!(mp_ids, apic_ids); + assert_eq!(madt_ids, apic_ids); + } +} + +#[test] +fn test_madt_level_triggered_irq_override() { + let mem = new_mem(); + let topology = TopologyBuilder::new_x86().build(1).unwrap(); + let pcie = vec![]; + let mut builder = new_builder(&mem, &topology, &pcie); + let AcpiArchConfig::X86 { + level_triggered_irqs, + .. + } = &mut builder.arch + else { + unreachable!() + }; + *level_triggered_irqs = &[5]; + + let madt = builder.build_madt(); + let expected = acpi_spec::madt::MadtInterruptSourceOverride::new( + 5, + 5, + Some(InterruptPolarity::ActiveHigh), + Some(InterruptTriggerMode::Level), + ); + assert!( + madt.windows(expected.as_bytes().len()) + .any(|bytes| bytes == expected.as_bytes()) + ); +} From b05cc3ec16641d428f737097bab9ac7e8d791c30 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:39:32 -0700 Subject: [PATCH 087/304] openvmm_defs: add machine-profile selection OpenVMM builds one kind of guest-visible machine. A second, minimal machine needs a way to be selected in the VM configuration that is independent of the hypervisor backend, and a place for its own settings. Add the `microvm` module to `openvmm_defs` with `MachineProfile`, which selects the standard machine or the microVM machine, and a `MicrovmConfig` for settings specific to the microVM machine, initially empty. `Config` gains `machine_profile` and `microvm` fields. The worker keeps the profile in its manifest and in the loaded VM, and carries it into the manifest of a restarted worker. Every existing configuration selects the standard machine, and nothing acts on the profile yet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/worker/dispatch.rs | 6 ++++++ openvmm/openvmm_defs/src/config.rs | 4 ++++ openvmm/openvmm_defs/src/lib.rs | 1 + openvmm/openvmm_defs/src/microvm.rs | 22 +++++++++++++++++++++ openvmm/openvmm_entry/src/lib.rs | 2 ++ openvmm/openvmm_entry/src/ttrpc/mod.rs | 2 ++ petri/src/vm/openvmm/construct.rs | 2 ++ 7 files changed, 39 insertions(+) create mode 100644 openvmm/openvmm_defs/src/microvm.rs diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 7bc27ed1783..a106ae68336 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -83,6 +83,7 @@ use openvmm_defs::config::Vtl2BaseAddressType; use openvmm_defs::config::Vtl2Config; use openvmm_defs::config::X2ApicConfig; use openvmm_defs::config::X86TopologyConfig; +use openvmm_defs::microvm::MachineProfile; use openvmm_defs::rpc::PulseSaveRestoreError; use openvmm_defs::rpc::VmRpc; use openvmm_defs::worker::SavedState; @@ -205,6 +206,7 @@ pub fn new_device_thread() -> (JoinHandle<()>, DefaultDriver) { impl Manifest { fn from_config(config: Config) -> Self { Self { + machine_profile: config.machine_profile, load_mode: config.load_mode, floppy_disks: config.floppy_disks, ide_disks: config.ide_disks, @@ -283,6 +285,7 @@ pub struct Manifest { chipset_capabilities: VmChipsetCapabilities, layout: vmm_core_defs::LayoutConfig, rtc_delta_milliseconds: i64, + machine_profile: MachineProfile, } async fn open_simple_disk( @@ -929,6 +932,7 @@ struct LoadedVmInner { pci_legacy_interrupts: Vec<((u8, Option), u32)>, firmware_event_send: Option>, + machine_profile: MachineProfile, load_mode: LoadMode, igvm_file: Option, next_igvm_file: Option, @@ -3234,6 +3238,7 @@ impl InitializedVm { chipset_cfg: cfg.chipset, chipset_capabilities: cfg.chipset_capabilities, firmware_event_send: cfg.firmware_event_send, + machine_profile: cfg.machine_profile, load_mode: cfg.load_mode, virtio_mmio_region, virtio_mmio_irq, @@ -4364,6 +4369,7 @@ impl LoadedVm { } let manifest = Manifest { + machine_profile: self.inner.machine_profile, load_mode: self.inner.load_mode, floppy_disks: vec![], // TODO ide_disks: vec![], // TODO diff --git a/openvmm/openvmm_defs/src/config.rs b/openvmm/openvmm_defs/src/config.rs index d12c16d1a97..98859ef02cc 100644 --- a/openvmm/openvmm_defs/src/config.rs +++ b/openvmm/openvmm_defs/src/config.rs @@ -66,6 +66,10 @@ pub struct Config { pub layout: vmm_core_defs::LayoutConfig, // This is used for testing. TODO: resourcify, and also store this in VMGS. pub rtc_delta_milliseconds: i64, + /// The versioned guest-visible machine contract. + pub machine_profile: crate::microvm::MachineProfile, + /// Configuration specific to the microVM machine profile. + pub microvm: crate::microvm::MicrovmConfig, } pub const DEFAULT_GIC_DISTRIBUTOR_BASE: u64 = 0xFFFF_0000; diff --git a/openvmm/openvmm_defs/src/lib.rs b/openvmm/openvmm_defs/src/lib.rs index face50c39c0..b9f34868d33 100644 --- a/openvmm/openvmm_defs/src/lib.rs +++ b/openvmm/openvmm_defs/src/lib.rs @@ -8,6 +8,7 @@ pub mod config; pub mod entrypoint; +pub mod microvm; pub mod profile; pub mod rpc; pub mod worker; diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs new file mode 100644 index 00000000000..a4e5f37e764 --- /dev/null +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM machine profile definitions. + +use mesh::MeshPayload; + +/// The guest-visible machine contract, independent of the hypervisor backend. +#[derive(MeshPayload, Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum MachineProfile { + /// The standard OpenVMM machine. + #[default] + Standard, + /// The microVM machine. + Microvm, +} + +/// VM configuration specific to the microVM machine profile +/// ([`MachineProfile::Microvm`]), held in +/// [`Config::microvm`](crate::config::Config::microvm). +#[derive(MeshPayload, Debug, Default)] +pub struct MicrovmConfig {} diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 5b833296dde..bed17aa54ae 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2073,6 +2073,8 @@ async fn vm_config_from_command_line( pcie_devices.extend(vfio_pcie_devices); let mut cfg = Config { + machine_profile: Default::default(), + microvm: Default::default(), chipset, load_mode, floppy_disks, diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index d23ce0390da..b0298984b8c 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1121,6 +1121,8 @@ impl VmService { let mut config = Config { // TODO: devices, other stuff + machine_profile: openvmm_defs::microvm::MachineProfile::Standard, + microvm: Default::default(), load_mode, ide_disks: vec![], floppy_disks: vec![], diff --git a/petri/src/vm/openvmm/construct.rs b/petri/src/vm/openvmm/construct.rs index 6ee3d184b22..e86c6d461ac 100644 --- a/petri/src/vm/openvmm/construct.rs +++ b/petri/src/vm/openvmm/construct.rs @@ -651,6 +651,8 @@ impl PetriVmConfigOpenVmm { let config = Config { // Firmware + machine_profile: Default::default(), + microvm: Default::default(), load_mode, firmware_event_send: Some(firmware_event_send), From cf523f95ba327bdeb65363c88bf898491d9147b1 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:43:36 -0700 Subject: [PATCH 088/304] virt: describe the deterministic microVM topology The x86 processor topology of a VM is derived from the host's topology, so the same configuration can present different APIC IDs and cache topologies on different hosts. A microVM whose snapshots must restore on any host needs a topology that depends only on its configuration. The worker now builds the x86 topology through the new `dispatch::microvm::x86_topology_builder`: a microVM starts from the fixed default topology instead of the host's, and other VMs are unchanged. The partition-wide topology CPUID leaves also report the bootstrap processor's APIC identity, with the fields included in each leaf's mask: the initial APIC ID in leaf 01h, the x2APIC ID in leaves 0Bh and 1Fh, and the extended APIC, compute-unit, and node IDs in leaf 8000001Eh. Backends replace these values for each processor, so the reported identity of every processor is unchanged, but the partition-wide leaves now describe a real processor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/worker/dispatch.rs | 11 +++- .../src/worker/dispatch/microvm.rs | 19 ++++++ vmm_core/virt/src/x86/topology.rs | 4 ++ .../virt/src/x86/topology/bsp_identity.rs | 64 +++++++++++++++++++ 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 openvmm/openvmm_core/src/worker/dispatch/microvm.rs create mode 100644 vmm_core/virt/src/x86/topology/bsp_identity.rs diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index a106ae68336..9e3d4887f1e 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -6,6 +6,7 @@ mod dump; mod ecam_config_access; mod intel_vtd_wiring; mod ioapic_iommu_wiring; +mod microvm; mod pcie_topology; mod pcie_wiring; mod restore; @@ -132,6 +133,7 @@ use vm_topology::memory::MemoryLayout; use vm_topology::pcie::PcieHostBridge; use vm_topology::pcie::PcieHostBridgeCxlInfo; use vm_topology::processor::ProcessorTopology; +#[cfg(guest_arch = "aarch64")] use vm_topology::processor::TopologyBuilder; use vm_topology::processor::aarch64::Aarch64Topology; use vm_topology::processor::aarch64::GicVersion; @@ -512,7 +514,10 @@ struct X86TopologyResult { } #[cfg(guest_arch = "x86_64")] -fn build_x86_topology(config: &ProcessorTopologyConfig) -> anyhow::Result { +fn build_x86_topology( + config: &ProcessorTopologyConfig, + machine_profile: MachineProfile, +) -> anyhow::Result { use vm_topology::processor::x86::X2ApicState; let arch = match &config.arch { @@ -520,7 +525,7 @@ fn build_x86_topology(config: &ProcessorTopologyConfig) -> anyhow::Result arch.clone(), _ => anyhow::bail!("invalid architecture config"), }; - let mut builder = TopologyBuilder::from_host_topology()?; + let mut builder = microvm::x86_topology_builder(machine_profile)?; builder.apic_id_offset(arch.apic_id_offset); if let Some(smt) = config.enable_smt { builder.smt_enabled(smt); @@ -1200,7 +1205,7 @@ impl InitializedVm { }; #[cfg(not(guest_arch = "aarch64"))] let mut processor_topology = { - let result = build_x86_topology(&cfg.processor_topology)?; + let result = build_x86_topology(&cfg.processor_topology, cfg.machine_profile)?; result.processor_topology }; diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs new file mode 100644 index 00000000000..8d05bc872df --- /dev/null +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Worker construction and lifecycle support for the microVM profile. + +#[cfg(guest_arch = "x86_64")] +use openvmm_defs::microvm::MachineProfile; + +#[cfg(guest_arch = "x86_64")] +pub(super) fn x86_topology_builder( + machine_profile: MachineProfile, +) -> anyhow::Result> +{ + if machine_profile == MachineProfile::Microvm { + Ok(vm_topology::processor::TopologyBuilder::new_x86()) + } else { + Ok(vm_topology::processor::TopologyBuilder::from_host_topology()?) + } +} diff --git a/vmm_core/virt/src/x86/topology.rs b/vmm_core/virt/src/x86/topology.rs index ebe60d16b0b..0edd393e16e 100644 --- a/vmm_core/virt/src/x86/topology.rs +++ b/vmm_core/virt/src/x86/topology.rs @@ -3,6 +3,8 @@ //! Provides processor topology related cpuid leaves. +mod bsp_identity; + use crate::CpuidLeaf; use std::cmp::min; use thiserror::Error; @@ -44,6 +46,7 @@ pub fn topology_cpuid<'a>( return Err(UnknownVendor(vendor)); }; + let first_leaf = leaves.len(); // Set the number of VPs per socket in leaf 01h. leaves.push( CpuidLeaf::new( @@ -90,6 +93,7 @@ pub fn topology_cpuid<'a>( amd_processor_topology_definition_cpuid(topology, leaves); } + bsp_identity::apply(topology, &mut leaves[first_leaf..]); Ok(()) } diff --git a/vmm_core/virt/src/x86/topology/bsp_identity.rs b/vmm_core/virt/src/x86/topology/bsp_identity.rs new file mode 100644 index 00000000000..ce02a24ff6d --- /dev/null +++ b/vmm_core/virt/src/x86/topology/bsp_identity.rs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Reports the BSP's APIC identity in the partition-wide topology CPUID +//! leaves. Backends replace these values for each VP. + +use crate::CpuidLeaf; +use vm_topology::processor::ProcessorTopology; +use x86defs::cpuid::CpuidFunction; +use x86defs::cpuid::ProcessorTopologyDefinitionEax; +use x86defs::cpuid::ProcessorTopologyDefinitionEbx; +use x86defs::cpuid::ProcessorTopologyDefinitionEcx; +use x86defs::cpuid::VersionAndFeaturesEbx; + +/// Adds the BSP identity to the topology `leaves` built by +/// [`super::topology_cpuid`]: the initial APIC ID in leaf 01h, the x2APIC ID +/// in leaves 0Bh and 1Fh, and the extended APIC, compute unit, and node IDs +/// in leaf 8000001Eh, each included in the leaf's mask. +pub(super) fn apply(topology: &ProcessorTopology, leaves: &mut [CpuidLeaf]) { + let bsp_apic_id = topology.vp_arch(crate::VpIndex::BSP).apic_id; + for leaf in leaves { + match CpuidFunction(leaf.function) { + CpuidFunction::VersionAndFeatures => { + leaf.result[1] = VersionAndFeaturesEbx::from(leaf.result[1]) + .with_initial_apic_id(bsp_apic_id as u8) + .into(); + leaf.mask[1] = VersionAndFeaturesEbx::from(leaf.mask[1]) + .with_initial_apic_id(0xff) + .into(); + } + CpuidFunction::ExtendedTopologyEnumeration + | CpuidFunction::V2ExtendedTopologyEnumeration => { + leaf.result[3] = bsp_apic_id; + leaf.mask[3] = !0; + } + CpuidFunction::ProcessorTopologyDefinition => { + let threads_per_compute_unit: u8 = if topology.smt_enabled() { 1 } else { 0 }; + leaf.result[0] = ProcessorTopologyDefinitionEax::from(leaf.result[0]) + .with_extended_apic_id(bsp_apic_id) + .into(); + leaf.mask[0] = ProcessorTopologyDefinitionEax::from(leaf.mask[0]) + .with_extended_apic_id(!0) + .into(); + leaf.result[1] = ProcessorTopologyDefinitionEbx::from(leaf.result[1]) + .with_compute_unit_id( + (bsp_apic_id % topology.reserved_vps_per_socket() + / (u32::from(threads_per_compute_unit) + 1)) + as u8, + ) + .into(); + leaf.mask[1] = ProcessorTopologyDefinitionEbx::from(leaf.mask[1]) + .with_compute_unit_id(!0) + .into(); + leaf.result[2] = ProcessorTopologyDefinitionEcx::from(leaf.result[2]) + .with_node_id((bsp_apic_id / topology.reserved_vps_per_socket()) as u8) + .into(); + leaf.mask[2] = ProcessorTopologyDefinitionEcx::from(leaf.mask[2]) + .with_node_id(!0) + .into(); + } + _ => {} + } + } +} From d99e685c95f03b2457f51f7e0953dbcfe46605f2 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:46:59 -0700 Subject: [PATCH 089/304] chipset: add an RTC mode seam The CMOS RTC has a single behavior, fixed at construction, and the base chipset builder offers no way to choose another. A machine profile that needs a different guest-visible RTC behavior has nowhere to select it. Add `RtcMode` to the RTC and `GenericCmosRtcMode` to the generic CMOS RTC dependencies of the base chipset, each with a single `Standard` mode, the programmable MC146818-compatible behavior. `Rtc::new_with_mode` and `Rtc::new_with_mode_and_utc_clock`, which also takes a fallible UTC clock source, construct an RTC in a given mode, and a reset returns the RTC to its mode's initial register state. Every mode currently shares the standard state, and OpenVMM and OpenHCL select the standard mode, so the guest-visible behavior is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openhcl/underhill_core/src/worker.rs | 1 + openvmm/openvmm_core/src/worker/dispatch.rs | 1 + vm/devices/chipset/src/cmos_rtc.rs | 7 ++- vm/devices/chipset/src/cmos_rtc/clock.rs | 33 +++++++++++++ vm/devices/chipset/src/cmos_rtc/mode.rs | 51 +++++++++++++++++++++ vmm_core/vmotherboard/src/base_chipset.rs | 15 +++++- 6 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 vm/devices/chipset/src/cmos_rtc/mode.rs diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index 26f600f4b7c..f788058aff9 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -3045,6 +3045,7 @@ async fn new_underhill_vm( time_source: PlatformResource.into_resource(), century_reg_idx: 0x32, initial_cmos: None, + mode: dev::GenericCmosRtcMode::Standard, }); if dps.general.tpm_enabled { diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 9e3d4887f1e..b6a96ccb673 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1995,6 +1995,7 @@ impl InitializedVm { .into_resource(), century_reg_idx: 0x32, // TODO: automatically sync with FADT initial_cmos: initial_rtc_cmos, + mode: dev::GenericCmosRtcMode::Standard, } }); diff --git a/vm/devices/chipset/src/cmos_rtc.rs b/vm/devices/chipset/src/cmos_rtc.rs index b9ffde05b02..3f427a70be0 100644 --- a/vm/devices/chipset/src/cmos_rtc.rs +++ b/vm/devices/chipset/src/cmos_rtc.rs @@ -7,7 +7,9 @@ #![warn(missing_docs)] pub mod clock; +mod mode; +pub use self::mode::RtcMode; use self::spec::CmosReg; use self::spec::ENABLE_OSCILLATOR_CONTROL; use self::spec::StatusRegA; @@ -176,6 +178,8 @@ pub struct Rtc { century_reg: CmosReg, initial_cmos: Option<[u8; 256]>, enlightened_interrupts: bool, + #[inspect(skip)] + mode: RtcMode, // Runtime deps real_time_source: Box, @@ -236,7 +240,7 @@ impl ChangeDeviceState for Rtc { async fn stop(&mut self) {} async fn reset(&mut self) { - self.state = RtcState::new(self.initial_cmos); + self.state = RtcState::with_mode(self.initial_cmos, self.mode); self.update_timers(); self.update_interrupt_line_level(); @@ -321,6 +325,7 @@ impl Rtc { century_reg: CmosReg(century_reg_idx), initial_cmos, enlightened_interrupts, + mode: RtcMode::Standard, real_time_source: Box::new(clock::LocalClockUtcSource(real_time_source)), interrupt, diff --git a/vm/devices/chipset/src/cmos_rtc/clock.rs b/vm/devices/chipset/src/cmos_rtc/clock.rs index 6684f47906f..95a2fdd94ae 100644 --- a/vm/devices/chipset/src/cmos_rtc/clock.rs +++ b/vm/devices/chipset/src/cmos_rtc/clock.rs @@ -6,6 +6,8 @@ //! guest-visible time. use super::Rtc; +use super::RtcMode; +use super::RtcState; use super::spec::CmosReg; use super::spec::StatusRegA; use anyhow::Context; @@ -13,6 +15,9 @@ use inspect::Inspect; use local_clock::InspectableLocalClock; use local_clock::LocalClockTime; use std::time::Duration; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::vmtime::VmTimeSource; +use vmcore::vmtime::VmTimerPeriodic; /// A fallible UTC clock source for the RTC. pub trait UtcClockSource: Inspect + Send { @@ -43,6 +48,34 @@ impl UtcClockSource for LocalClockUtcSource { } impl Rtc { + /// Creates a CMOS RTC with an injectable, fallible UTC clock source. + pub fn new_with_mode_and_utc_clock( + real_time_source: Box, + interrupt: LineInterrupt, + vmtime_source: &VmTimeSource, + century_reg_idx: u8, + initial_cmos: Option<[u8; 256]>, + enlightened_interrupts: bool, + mode: RtcMode, + ) -> Self { + Rtc { + century_reg: CmosReg(century_reg_idx), + initial_cmos, + enlightened_interrupts, + mode, + + real_time_source, + interrupt, + vmtime_alarm: vmtime_source.access("rtc-alarm"), + vmtimer_periodic: VmTimerPeriodic::new(vmtime_source.access("rtc-periodic")), + vmtimer_update: VmTimerPeriodic::new(vmtime_source.access("rtc-update")), + + last_update_bit_blip: LocalClockTime::from_millis_since_unix_epoch(0), + + state: RtcState::with_mode(initial_cmos, mode), + } + } + /// Advances the guest-visible UTC time by `duration`. pub(super) fn advance_clock(&mut self, duration: Duration) -> anyhow::Result<()> { let delta = i64::try_from(duration.as_millis()) diff --git a/vm/devices/chipset/src/cmos_rtc/mode.rs b/vm/devices/chipset/src/cmos_rtc/mode.rs new file mode 100644 index 00000000000..b07e3a029d2 --- /dev/null +++ b/vm/devices/chipset/src/cmos_rtc/mode.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Guest-visible RTC behavior modes. + +use super::Rtc; +use super::RtcState; +use local_clock::InspectableLocalClock; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::vmtime::VmTimeSource; + +/// Guest-visible RTC behavior. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RtcMode { + /// Standard programmable MC146818-compatible behavior. + Standard, +} + +impl RtcState { + /// Returns the initial register state for `mode`. All modes currently + /// share the standard initial state. + pub(super) fn with_mode(initial_cmos: Option<[u8; 256]>, _mode: RtcMode) -> Self { + Self::new(initial_cmos) + } +} + +impl Rtc { + /// Creates a CMOS RTC with an explicit guest-visible behavior mode. + pub fn new_with_mode( + real_time_source: Box, + interrupt: LineInterrupt, + vmtime_source: &VmTimeSource, + century_reg_idx: u8, + initial_cmos: Option<[u8; 256]>, + enlightened_interrupts: bool, + mode: RtcMode, + ) -> Self { + Rtc { + mode, + state: RtcState::with_mode(initial_cmos, mode), + ..Self::new( + real_time_source, + interrupt, + vmtime_source, + century_reg_idx, + initial_cmos, + enlightened_interrupts, + ) + } + } +} diff --git a/vmm_core/vmotherboard/src/base_chipset.rs b/vmm_core/vmotherboard/src/base_chipset.rs index d5d7afd97b5..55f3670c158 100644 --- a/vmm_core/vmotherboard/src/base_chipset.rs +++ b/vmm_core/vmotherboard/src/base_chipset.rs @@ -438,6 +438,7 @@ impl<'a> BaseChipsetBuilder<'a> { time_source, century_reg_idx, initial_cmos, + mode, }) = deps_generic_cmos_rtc { let resolved = resolver @@ -445,13 +446,16 @@ impl<'a> BaseChipsetBuilder<'a> { .await .map_err(BaseChipsetBuilderError::ResolveResource)?; builder.arc_mutex_device("rtc").add(|services| { - cmos_rtc::Rtc::new( + cmos_rtc::Rtc::new_with_mode( resolved.0, services.new_line(IRQ_LINE_SET, "interrupt", irq), services.register_vmtime(), century_reg_idx, initial_cmos, false, + match mode { + options::dev::GenericCmosRtcMode::Standard => cmos_rtc::RtcMode::Standard, + }, ) })?; } @@ -1168,6 +1172,13 @@ pub mod options { } } + /// Guest-visible generic CMOS RTC behavior. + #[derive(Clone, Copy)] + pub enum GenericCmosRtcMode { + /// Standard programmable MC146818-compatible behavior. + Standard, + } + /// Generic MC146818A compatible RTC + CMOS device pub struct GenericCmosRtcDeps { /// IRQ line to signal RTC device events @@ -1178,6 +1189,8 @@ pub mod options { pub century_reg_idx: u8, /// Initial state of CMOS RAM pub initial_cmos: Option<[u8; 256]>, + /// Guest-visible RTC behavior. + pub mode: GenericCmosRtcMode, } /// PIIX4 "flavored" MC146818A compatible RTC + CMOS device From abd20e65268fa0b79cf15a4e4df9bff5d3bebadf Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:49:25 -0700 Subject: [PATCH 090/304] vm_manifest_builder: add the minimal microVM base chipset without ACPI Every base chipset type includes firmware-facing or bus infrastructure that a minimal machine does not want, such as VMBus, PCI, the PIIX4 devices, or ACPI power management. A microVM needs a base chipset that contains only the architectural devices its guest requires. Add `BaseChipsetType::Microvm`, which is supported only on x86-64. Its chipset contains exactly a generic CMOS RTC, a generic I/O APIC, the PIC, and the PIT, without VMBus, PCI, ISA DMA, or ACPI power management. Its layout reserves 1 GiB of chipset MMIO below 4 GiB and none above, so guest RAM ends at 3 GiB and resumes at 4 GiB; a memory-layout test checks that placement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../openvmm_core/src/worker/memory_layout.rs | 2 + .../src/worker/memory_layout/microvm.rs | 66 ++++++++++++++++ vmm_core/vm_manifest_builder/src/lib.rs | 9 +++ vmm_core/vm_manifest_builder/src/microvm.rs | 75 +++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 openvmm/openvmm_core/src/worker/memory_layout/microvm.rs create mode 100644 vmm_core/vm_manifest_builder/src/microvm.rs diff --git a/openvmm/openvmm_core/src/worker/memory_layout.rs b/openvmm/openvmm_core/src/worker/memory_layout.rs index 1db9e1a07a0..48b1cb978a9 100644 --- a/openvmm/openvmm_core/src/worker/memory_layout.rs +++ b/openvmm/openvmm_core/src/worker/memory_layout.rs @@ -16,6 +16,8 @@ //! VTL2 chipset MMIO. Callers express sizing intent; the resolver places //! everything and derives the effective MMIO gaps for [`MemoryLayout`]. +mod microvm; + use super::vm_loaders::igvm::Vtl2MemoryLayoutRequest; use anyhow::Context; use anyhow::bail; diff --git a/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs b/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs new file mode 100644 index 00000000000..37a8a3d6c5c --- /dev/null +++ b/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM memory layout tests: the fixed microVM chipset layout reserves the +//! 3-GiB to 4-GiB range for MMIO and resumes RAM above 4 GiB. + +#[cfg(test)] +mod tests { + use super::super::*; + use test_with_tracing::test; + + #[cfg(guest_arch = "x86_64")] + const DEFAULT_CHIPSET_LOW_MMIO_SIZE: u32 = 128 * 1024 * 1024; + #[cfg(guest_arch = "aarch64")] + const DEFAULT_CHIPSET_LOW_MMIO_SIZE: u32 = 512 * 1024 * 1024; + const DEFAULT_CHIPSET_HIGH_MMIO_SIZE: u64 = 512 * 1024 * 1024; + + const DEFAULT_LAYOUT: vmm_core_defs::LayoutConfig = vmm_core_defs::LayoutConfig { + chipset_low_mmio_size: DEFAULT_CHIPSET_LOW_MMIO_SIZE, + chipset_high_mmio_size: DEFAULT_CHIPSET_HIGH_MMIO_SIZE, + vtl2_chipset_mmio_size: 0, + }; + + fn input( + node_mem_sizes: &[u64], + vtl2_layout: Option, + ) -> MemoryLayoutInput<'_> { + MemoryLayoutInput { + node_mem_sizes, + layout: DEFAULT_LAYOUT, + pcie_root_complexes: &[], + virtio_mmio_count: 0, + pcie_ecam_below_4gb: false, + vtl2_layout, + ram_start_address: 0, + vtl2_framebuffer_size: 0, + physical_address_size: 46, + } + } + + fn resolve(input: MemoryLayoutInput<'_>) -> MemoryLayout { + resolve_memory_layout(input).unwrap().memory_layout + } + + #[test] + fn microvm_ram_ends_at_3gb_and_resumes_at_4gb() { + let mut config = input(&[4 * GB], None); + config.layout = vmm_core_defs::LayoutConfig { + chipset_low_mmio_size: GB as u32, + chipset_high_mmio_size: 0, + vtl2_chipset_mmio_size: 0, + }; + let layout = resolve(config); + assert_eq!( + layout + .ram() + .iter() + .map(|range| range.range) + .collect::>(), + [ + MemoryRange::new(0..3 * GB), + MemoryRange::new(4 * GB..5 * GB), + ] + ); + } +} diff --git a/vmm_core/vm_manifest_builder/src/lib.rs b/vmm_core/vm_manifest_builder/src/lib.rs index 7bb56f8042b..86b815a3f0d 100644 --- a/vmm_core/vm_manifest_builder/src/lib.rs +++ b/vmm_core/vm_manifest_builder/src/lib.rs @@ -16,6 +16,8 @@ #![forbid(unsafe_code)] +mod microvm; + use chipset_resources::LEGACY_CHIPSET_PCI_BUS_NAME; use chipset_resources::battery::BatteryDeviceHandleAArch64; use chipset_resources::battery::BatteryDeviceHandleX64; @@ -173,6 +175,8 @@ pub enum BaseChipsetType { UnenlightenedLinuxDirect, /// Enlightened Linux VM with a minimal emulated chipset for direct boot. EnlightenedLinuxDirect, + /// microVM with only its allowlisted architectural devices. + Microvm, } /// The machine architecture of the VM. @@ -282,6 +286,7 @@ impl VmManifestBuilder { /// architecture. pub fn new(ty: BaseChipsetType, arch: MachineArch) -> Self { let vmbus = !matches!(ty, BaseChipsetType::UnenlightenedLinuxDirect); + let vmbus = microvm::vmbus_enabled(&ty, vmbus); VmManifestBuilder { ty, arch, @@ -597,6 +602,9 @@ impl VmManifestBuilder { result.attach_guest_watchdog(); } } + BaseChipsetType::Microvm => { + microvm::build(self.arch, &mut result)?; + } BaseChipsetType::HypervGen2Uefi | BaseChipsetType::HyperVGen2LinuxDirect => { result.chipset = BaseChipsetManifest { with_generic_cmos_rtc: is_x86, @@ -688,6 +696,7 @@ impl VmManifestBuilder { chipset_high_mmio_size: if self.vmbus { default_high } else { 0 }, vtl2_chipset_mmio_size: 0, }, + BaseChipsetType::Microvm => microvm::layout_config(), BaseChipsetType::HclHost => LayoutConfig { chipset_low_mmio_size: default_low, chipset_high_mmio_size: if self.vmbus { default_high } else { 0 }, diff --git a/vmm_core/vm_manifest_builder/src/microvm.rs b/vmm_core/vm_manifest_builder/src/microvm.rs new file mode 100644 index 00000000000..3c22cef5042 --- /dev/null +++ b/vmm_core/vm_manifest_builder/src/microvm.rs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM chipset manifest construction. + +use super::BaseChipsetManifest; +use super::BaseChipsetType; +use super::Error; +use super::ErrorInner; +use super::LayoutConfig; +use super::MachineArch; +use super::VmChipsetResult; + +pub(super) fn vmbus_enabled(ty: &BaseChipsetType, default: bool) -> bool { + default && !matches!(ty, BaseChipsetType::Microvm) +} + +pub(super) fn build(arch: MachineArch, result: &mut VmChipsetResult) -> Result<(), Error> { + if arch != MachineArch::X86_64 { + return Err(Error(ErrorInner::UnsupportedArch)); + } + result.chipset = BaseChipsetManifest { + with_generic_cmos_rtc: true, + ..BaseChipsetManifest::empty() + }; + result.attach_generic_ioapic(); + result.attach_pic(); + result.attach_pit(); + Ok(()) +} + +pub(super) fn layout_config() -> LayoutConfig { + LayoutConfig { + chipset_low_mmio_size: 1024 * 1024 * 1024, + chipset_high_mmio_size: 0, + vtl2_chipset_mmio_size: 0, + } +} + +#[cfg(test)] +mod tests { + use super::super::BaseChipsetType; + use super::super::MachineArch; + use super::super::VmManifestBuilder; + use chipset_resources::pic::PicDeviceHandle; + use chipset_resources::pit::PitDeviceHandle; + use test_with_tracing::test; + use vm_resource::ResourceId; + + #[test] + fn microvm_microvm_has_only_allowlisted_base_devices() { + let builder = VmManifestBuilder::new(BaseChipsetType::Microvm, MachineArch::X86_64); + assert!(!builder.vmbus); + assert_eq!( + builder.layout_config().chipset_low_mmio_size, + 1024 * 1024 * 1024 + ); + + let result = builder.build().unwrap(); + assert!(result.chipset.with_generic_cmos_rtc); + assert_eq!( + result + .chipset_devices + .iter() + .map(|device| device.name.as_str()) + .collect::>(), + ["ioapic", PicDeviceHandle::ID, PitDeviceHandle::ID] + ); + assert!(result.capabilities.with_ioapic); + assert!(result.capabilities.with_pic); + assert!(result.capabilities.with_pit); + assert!(result.pci_chipset_devices.is_empty()); + assert!(result.isa_dma_controller.is_none()); + } +} From 76ce1a5ac22669019c10fa01436f2163d36b7ef7 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 03:55:17 -0700 Subject: [PATCH 091/304] vmm_core: add status-bearing guest power-off A guest can ask to be powered off, but not with a result: a guest that runs a single workload and then powers off has no way to hand its exit status to the host process that launched it. Add `PowerRequest::PowerOffWithStatus` and `HaltReason::PowerOffWithStatus`, which carry an 8-bit guest-provided status, and forward the request as that halt reason. Every consumer treats the new reason like a power-off: the debugger stop report, the OpenVMM VM controller, OpenHCL, and Petri. No device issues the request yet. A test checks that the status survives a mesh round trip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openhcl/underhill_core/src/worker.rs | 2 +- openvmm/openvmm_entry/src/vm_controller.rs | 4 +++- petri/src/vm/openvmm/runtime.rs | 4 +++- vm/power_resources/src/lib.rs | 5 +++++ vmm_core/src/partition_unit/debug.rs | 4 +++- vmm_core/src/platform_resolvers.rs | 3 +++ vmm_core/vmm_core_defs/src/lib.rs | 16 ++++++++++++++++ 7 files changed, 34 insertions(+), 4 deletions(-) diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index f788058aff9..e29358b2ddb 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -4166,7 +4166,7 @@ async fn halt_task( while let Ok(reason) = halt_notify_recv.recv().await { let halt_request = match reason { - HaltReason::PowerOff => HaltRequest::PowerOff, + HaltReason::PowerOff | HaltReason::PowerOffWithStatus { .. } => HaltRequest::PowerOff, // The paravisor's own watchdog raises Reset, so Watchdog never reaches // this arm today; it is folded into Reset to stay exhaustive. Honoring a // configurable paravisor watchdog action would mean raising Watchdog here. diff --git a/openvmm/openvmm_entry/src/vm_controller.rs b/openvmm/openvmm_entry/src/vm_controller.rs index f51d00f01af..8fdf17a2052 100644 --- a/openvmm/openvmm_entry/src/vm_controller.rs +++ b/openvmm/openvmm_entry/src/vm_controller.rs @@ -158,7 +158,9 @@ impl Default for GuestPowerActions { /// Decide what to do for a guest halt, given the per-event actions. fn action_for(reason: &HaltReason, actions: &GuestPowerActions) -> GuestPowerAction { match reason { - HaltReason::PowerOff | HaltReason::Hibernate => actions.shutdown, + HaltReason::PowerOff | HaltReason::PowerOffWithStatus { .. } | HaltReason::Hibernate => { + actions.shutdown + } HaltReason::Reset => actions.reset, HaltReason::TripleFault { .. } => actions.crash, HaltReason::Watchdog => actions.watchdog, diff --git a/petri/src/vm/openvmm/runtime.rs b/petri/src/vm/openvmm/runtime.rs index 082b75efd0c..a726002cadf 100644 --- a/petri/src/vm/openvmm/runtime.rs +++ b/petri/src/vm/openvmm/runtime.rs @@ -90,7 +90,9 @@ impl PetriVmRuntime for PetriVmOpenVmm { tracing::info!(?halt_reason, "Got halt reason"); let reason = match halt_reason { - HaltReason::PowerOff => PetriHaltReason::PowerOff, + HaltReason::PowerOff | HaltReason::PowerOffWithStatus { .. } => { + PetriHaltReason::PowerOff + } HaltReason::Reset => PetriHaltReason::Reset, HaltReason::Hibernate => PetriHaltReason::Hibernate, HaltReason::TripleFault { .. } => PetriHaltReason::TripleFault, diff --git a/vm/power_resources/src/lib.rs b/vm/power_resources/src/lib.rs index d3fbea82f30..1e83c97069a 100644 --- a/vm/power_resources/src/lib.rs +++ b/vm/power_resources/src/lib.rs @@ -52,4 +52,9 @@ pub enum PowerRequest { /// The VP that caused the triple fault. vp: u32, }, + /// Power off the VM and return the guest-provided process status. + PowerOffWithStatus { + /// Guest-provided process status. + code: u8, + }, } diff --git a/vmm_core/src/partition_unit/debug.rs b/vmm_core/src/partition_unit/debug.rs index 69dcb937213..19e0aa89c0a 100644 --- a/vmm_core/src/partition_unit/debug.rs +++ b/vmm_core/src/partition_unit/debug.rs @@ -53,7 +53,9 @@ impl DebuggerState { tracing::debug!("halt reported to debugger"); self.halt_reported = true; notify.send(match reason { - HaltReason::PowerOff | HaltReason::Hibernate => DebugStopReason::PowerOff, + HaltReason::PowerOff + | HaltReason::PowerOffWithStatus { .. } + | HaltReason::Hibernate => DebugStopReason::PowerOff, HaltReason::Reset => DebugStopReason::Reset, HaltReason::Watchdog => DebugStopReason::Watchdog, HaltReason::TripleFault { vp, .. } => DebugStopReason::TripleFault { vp: *vp }, diff --git a/vmm_core/src/platform_resolvers.rs b/vmm_core/src/platform_resolvers.rs index 0bab41f1f1c..8554543de6f 100644 --- a/vmm_core/src/platform_resolvers.rs +++ b/vmm_core/src/platform_resolvers.rs @@ -27,6 +27,9 @@ impl ResolveResource for HaltResolver let halt = self.0.clone(); Ok((move |request: PowerRequest| match request { PowerRequest::PowerOff => halt.halt(HaltReason::PowerOff), + PowerRequest::PowerOffWithStatus { code } => { + halt.halt(HaltReason::PowerOffWithStatus { code }) + } PowerRequest::Reset => halt.halt(HaltReason::Reset), PowerRequest::Hibernate => halt.halt(HaltReason::Hibernate), PowerRequest::TripleFault { vp } => halt.halt(HaltReason::TripleFault { diff --git a/vmm_core/vmm_core_defs/src/lib.rs b/vmm_core/vmm_core_defs/src/lib.rs index 87d8f5c2ec5..d4ed8ff9e5d 100644 --- a/vmm_core/vmm_core_defs/src/lib.rs +++ b/vmm_core/vmm_core_defs/src/lib.rs @@ -62,4 +62,20 @@ pub enum HaltReason { }, /// The guest watchdog timer expired without being petted. Watchdog, + PowerOffWithStatus { + code: u8, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn power_off_status_survives_mesh_round_trip() { + let encoded = mesh::payload::encode(HaltReason::PowerOffWithStatus { code: 37 }); + let decoded: HaltReason = mesh::payload::decode(&encoded).unwrap(); + assert_eq!(decoded, HaltReason::PowerOffWithStatus { code: 37 }); + assert_eq!(format!("{decoded:?}"), "PowerOffWithStatus { code: 37 }"); + } } From a39fe480683e8f04b269c597b144cd26ccdf203c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 04:01:10 -0700 Subject: [PATCH 092/304] chipset: add the microVM portb console A microVM has no UART, no VMBus, and no firmware console, so it needs a debug console that costs the guest a single port access per byte and the host no emulated serial state machine. Add `MicrovmPortb`, a raw bidirectional byte channel at I/O ports 0xe9 (data) and 0xea (status), backed by any `SerialIo` endpoint: - A guest write to the data port queues every byte of the access; the device drains the queue to the endpoint from `poll_device`. Output is binary-transparent, so a guest can stream arbitrary bytes. - A guest read from the data port pops one received byte (0 when the queue is empty) and zero-fills the rest of the access. Bit 0 of the status port reports that input is available. - Each direction buffers at most 1 MiB. Output beyond that drops the newest bytes with a rate-limited warning, and input stops being read from the endpoint until the guest consumes some of it. - An endpoint write error other than a broken pipe drops the buffered output and re-arms the output wakeup, so the next guest write still reaches a recovered endpoint. - `stop` flushes whatever the endpoint accepts immediately. Bytes still owned by the device are saved in `MicrovmPortbSavedState` and resent to the reconstructed endpoint after restore; restore rejects buffers larger than the device limit. Add `MicrovmPortbHandle` and `MicrovmPortbResolver`, and register the resolver for x86_64 guests. Nothing instantiates the device yet. Tests cover binary output and zero-filled reads, polling a newly accepted connection immediately, and preserving the output wakeup across an endpoint error. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + openvmm/openvmm_resources/src/lib.rs | 2 + vm/devices/chipset/Cargo.toml | 1 + vm/devices/chipset/src/lib.rs | 1 + vm/devices/chipset/src/microvm.rs | 451 ++++++++++++++++++++ vm/devices/chipset/src/microvm/resolver.rs | 56 +++ vm/devices/chipset_resources/src/lib.rs | 2 + vm/devices/chipset_resources/src/microvm.rs | 21 + 8 files changed, 535 insertions(+) create mode 100644 vm/devices/chipset/src/microvm.rs create mode 100644 vm/devices/chipset/src/microvm/resolver.rs create mode 100644 vm/devices/chipset_resources/src/microvm.rs diff --git a/Cargo.lock b/Cargo.lock index d883787958d..7f4d38dba89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -596,6 +596,7 @@ dependencies = [ "pal_async", "parking_lot", "power_resources", + "serial_core", "test_with_tracing", "thiserror 2.0.16", "tracelimit", diff --git a/openvmm/openvmm_resources/src/lib.rs b/openvmm/openvmm_resources/src/lib.rs index 91054d9fcf9..f0e3add8dd4 100644 --- a/openvmm/openvmm_resources/src/lib.rs +++ b/openvmm/openvmm_resources/src/lib.rs @@ -26,6 +26,8 @@ vm_resource::register_static_resolvers! { #[cfg(guest_arch = "x86_64")] chipset::pit::resolver::PitResolver, #[cfg(guest_arch = "x86_64")] + chipset::microvm::resolver::MicrovmPortbResolver, + #[cfg(guest_arch = "x86_64")] chipset::pic::resolver::PicResolver, #[cfg(guest_arch = "x86_64")] chipset::ioapic::resolver::GenericIoApicResolver, diff --git a/vm/devices/chipset/Cargo.toml b/vm/devices/chipset/Cargo.toml index fa14f4e4df1..a21c2369e39 100644 --- a/vm/devices/chipset/Cargo.toml +++ b/vm/devices/chipset/Cargo.toml @@ -11,6 +11,7 @@ chipset_device.workspace = true chipset_device_resources.workspace = true chipset_resources.workspace = true power_resources.workspace = true +serial_core.workspace = true vm_resource.workspace = true input_core.workspace = true diff --git a/vm/devices/chipset/src/lib.rs b/vm/devices/chipset/src/lib.rs index 488fbb4f43e..a0a7fd6ee32 100644 --- a/vm/devices/chipset/src/lib.rs +++ b/vm/devices/chipset/src/lib.rs @@ -21,6 +21,7 @@ pub mod cmos_rtc; pub mod dma; pub mod i8042; pub mod ioapic; +pub mod microvm; pub mod pic; pub mod pit; pub mod pm; diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs new file mode 100644 index 00000000000..03c08b9c6c3 --- /dev/null +++ b/vm/devices/chipset/src/microvm.rs @@ -0,0 +1,451 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM chipset devices. + +pub mod resolver; + +use chipset_device::ChipsetDevice; +use chipset_device::io::IoError; +use chipset_device::io::IoResult; +use chipset_device::pio::PortIoIntercept; +use chipset_device::poll_device::PollDevice; +use futures::AsyncRead; +use futures::AsyncWrite; +use inspect::InspectMut; +use serial_core::SerialIo; +use std::collections::VecDeque; +use std::io; +use std::io::ErrorKind; +use std::ops::RangeInclusive; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use std::task::Waker; +use vmcore::device_state::ChangeDeviceState; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SaveRestore; +use vmcore::save_restore::SavedStateRoot; + +const DATA_PORT: u16 = 0xe9; +const STATUS_PORT: u16 = 0xea; +const STATUS_INPUT_AVAILABLE: u8 = 1 << 0; +const BUFFER_MAX: usize = 1024 * 1024; + +/// Raw bidirectional microVM portb console. +#[derive(InspectMut)] +pub struct MicrovmPortb { + #[inspect(skip)] + io_region: (&'static str, RangeInclusive), + #[inspect(mut)] + io: Box, + #[inspect(with = "VecDeque::len")] + rx_buffer: VecDeque, + #[inspect(with = "VecDeque::len")] + tx_buffer: VecDeque, + #[inspect(skip)] + rx_waker: Option, + #[inspect(skip)] + tx_waker: Option, +} + +impl MicrovmPortb { + /// Creates a portb console using `io` as its host endpoint. + pub fn new(io: Box) -> Self { + Self { + io_region: ("microvm-portb", DATA_PORT..=STATUS_PORT), + io, + rx_buffer: VecDeque::new(), + tx_buffer: VecDeque::new(), + rx_waker: None, + tx_waker: None, + } + } + + fn poll_rx(&mut self, cx: &mut Context<'_>) { + let mut buffer = [0; 256]; + loop { + if self.rx_buffer.len() == BUFFER_MAX { + self.rx_waker = Some(cx.waker().clone()); + return; + } + + let available = BUFFER_MAX - self.rx_buffer.len(); + let read_len = available.min(buffer.len()); + match Pin::new(&mut self.io).poll_read(cx, &mut buffer[..read_len]) { + Poll::Ready(Ok(0)) | Poll::Pending => return, + Poll::Ready(Ok(count)) => self.rx_buffer.extend(&buffer[..count]), + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "microVM portb input failed" + ); + return; + } + } + } + } + + fn poll_tx(&mut self, cx: &mut Context<'_>) -> Poll> { + while !self.tx_buffer.is_empty() { + let (buffer, _) = self.tx_buffer.as_slices(); + match Pin::new(&mut self.io).poll_write(cx, buffer) { + Poll::Ready(Ok(0)) => return Poll::Ready(Err(ErrorKind::WriteZero.into())), + Poll::Ready(Ok(count)) => { + self.tx_buffer.drain(..count); + } + Poll::Ready(Err(error)) if error.kind() == ErrorKind::BrokenPipe => { + return Poll::Ready(Err(error)); + } + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + len = buffer.len(), + error = &error as &dyn std::error::Error, + "microVM portb output failed; dropping buffered bytes" + ); + self.tx_buffer.clear(); + self.tx_waker = Some(cx.waker().clone()); + return Poll::Ready(Err(error)); + } + Poll::Pending => return Poll::Pending, + } + } + self.tx_waker = Some(cx.waker().clone()); + Poll::Ready(Ok(())) + } + + fn wake_tx(&mut self) { + if let Some(waker) = self.tx_waker.take() { + waker.wake(); + } + } + + fn wake_rx(&mut self) { + if let Some(waker) = self.rx_waker.take() { + waker.wake(); + } + } +} + +impl ChangeDeviceState for MicrovmPortb { + fn start(&mut self) {} + + async fn stop(&mut self) { + // Drain every byte the endpoint accepts immediately. Any remaining + // VMM-owned bytes are serialized and retried against the reconstructed + // endpoint after restore. + let _ = self.poll_tx(&mut Context::from_waker(Waker::noop())); + } + + async fn reset(&mut self) { + self.rx_buffer.clear(); + self.tx_buffer.clear(); + } +} + +impl ChipsetDevice for MicrovmPortb { + fn supports_pio(&mut self) -> Option<&mut dyn PortIoIntercept> { + Some(self) + } + + fn supports_poll_device(&mut self) -> Option<&mut dyn PollDevice> { + Some(self) + } +} + +impl PollDevice for MicrovmPortb { + fn poll_device(&mut self, cx: &mut Context<'_>) { + if !self.io.is_connected() { + match self.io.poll_connect(cx) { + Poll::Ready(Ok(())) => {} + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "microVM portb backend connection failed" + ); + return; + } + Poll::Pending => return, + } + } + self.poll_rx(cx); + let _ = self.poll_tx(cx); + } +} + +impl PortIoIntercept for MicrovmPortb { + fn io_read(&mut self, io_port: u16, data: &mut [u8]) -> IoResult { + if data.is_empty() { + return IoResult::Err(IoError::InvalidAccessSize); + } + data.fill(0); + match io_port { + DATA_PORT => { + data[0] = self.rx_buffer.pop_front().unwrap_or(0); + self.wake_rx(); + } + STATUS_PORT => { + data[0] = if !self.rx_buffer.is_empty() { + STATUS_INPUT_AVAILABLE + } else { + 0 + }; + } + _ => return IoResult::Err(IoError::InvalidRegister), + } + IoResult::Ok + } + + fn io_write(&mut self, io_port: u16, data: &[u8]) -> IoResult { + match io_port { + DATA_PORT => { + let available = BUFFER_MAX - self.tx_buffer.len(); + self.tx_buffer.extend(data.iter().copied().take(available)); + if data.len() > available { + tracelimit::warn_ratelimited!( + dropped = data.len() - available, + "microVM portb output buffer full; dropping newest bytes" + ); + } + self.wake_tx(); + } + STATUS_PORT => {} + _ => return IoResult::Err(IoError::InvalidRegister), + } + IoResult::Ok + } + + fn get_static_regions(&mut self) -> &[(&str, RangeInclusive)] { + std::slice::from_ref(&self.io_region) + } +} + +impl SaveRestore for MicrovmPortb { + type SavedState = MicrovmPortbSavedState; + + fn save(&mut self) -> Result { + Ok(MicrovmPortbSavedState { + rx_buffer: self.rx_buffer.iter().copied().collect(), + tx_buffer: self.tx_buffer.iter().copied().collect(), + }) + } + + fn restore(&mut self, state: Self::SavedState) -> Result<(), RestoreError> { + if state.rx_buffer.len() > BUFFER_MAX || state.tx_buffer.len() > BUFFER_MAX { + return Err(RestoreError::InvalidSavedState( + io::Error::new( + ErrorKind::InvalidData, + format!("microVM portb buffer exceeds {BUFFER_MAX} bytes"), + ) + .into(), + )); + } + self.rx_buffer = state.rx_buffer.into(); + self.tx_buffer = state.tx_buffer.into(); + Ok(()) + } +} + +/// Saved guest-visible bytes owned by the microVM portb device. +#[derive(mesh::payload::Protobuf, SavedStateRoot)] +#[mesh(package = "chipset.microvm_portb")] +pub struct MicrovmPortbSavedState { + /// Host bytes accepted but not consumed by the guest. + #[mesh(1)] + pub rx_buffer: Vec, + /// Guest bytes accepted but not acknowledged by the host endpoint. + #[mesh(2)] + pub tx_buffer: Vec, +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::AsyncRead; + use futures::AsyncWrite; + use parking_lot::Mutex; + use serial_core::disconnected::Disconnected; + use serial_core::serial_io::Connected; + use std::io; + use std::pin::Pin; + use std::sync::Arc; + use std::task::Context; + use std::task::Poll; + use test_with_tracing::test; + + #[derive(Default)] + struct OutputState { + bytes: Vec, + writable: bool, + flushed: bool, + error: Option, + } + + struct BufferedOutput(Arc>); + + impl AsyncRead for BufferedOutput { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buffer: &mut [u8], + ) -> Poll> { + Poll::Pending + } + } + + impl AsyncWrite for BufferedOutput { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + let mut state = self.0.lock(); + if let Some(error) = state.error { + return Poll::Ready(Err(error.into())); + } + if !state.writable { + return Poll::Pending; + } + let count = buffer.len().min(3); + state.bytes.extend_from_slice(&buffer[..count]); + Poll::Ready(Ok(count)) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + if self.0.lock().flushed { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.poll_flush(cx) + } + } + + #[test] + fn portb_output_error_preserves_the_next_write_wakeup() { + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + use std::task::Wake; + + struct OutputWake(AtomicBool); + impl Wake for OutputWake { + fn wake(self: Arc) { + self.0.store(true, Ordering::Relaxed); + } + } + + let wake = Arc::new(OutputWake(AtomicBool::new(false))); + let waker = Waker::from(wake.clone()); + let mut cx = Context::from_waker(&waker); + let state = Arc::new(Mutex::new(OutputState { + error: Some(ErrorKind::Other), + ..Default::default() + })); + let mut portb = MicrovmPortb::new(Box::new(Connected::new(BufferedOutput(state.clone())))); + assert!(matches!(portb.io_write(DATA_PORT, b"lost"), IoResult::Ok)); + portb.poll_device(&mut cx); + state.lock().error = None; + state.lock().writable = true; + assert!(matches!( + portb.io_write(DATA_PORT, b"next write"), + IoResult::Ok + )); + assert!(wake.0.load(Ordering::Relaxed)); + portb.poll_device(&mut cx); + assert_eq!(state.lock().bytes, b"next write"); + } + + struct ConnectWithByte { + connected: bool, + byte: Option, + } + + impl InspectMut for ConnectWithByte { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } + } + + impl SerialIo for ConnectWithByte { + fn is_connected(&self) -> bool { + self.connected + } + + fn poll_connect(&mut self, _cx: &mut Context<'_>) -> Poll> { + self.connected = true; + Poll::Ready(Ok(())) + } + + fn poll_disconnect(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } + } + + impl AsyncRead for ConnectWithByte { + fn poll_read( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + data: &mut [u8], + ) -> Poll> { + let Some(byte) = self.byte.take() else { + return Poll::Pending; + }; + data[0] = byte; + Poll::Ready(Ok(1)) + } + } + + impl AsyncWrite for ConnectWithByte { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + data: &[u8], + ) -> Poll> { + Poll::Ready(Ok(data.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_close(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + } + + #[test] + fn portb_preserves_wide_binary_output_and_zero_fills_reads() { + let mut portb = MicrovmPortb::new(Box::new(Disconnected)); + assert!(matches!( + portb.io_write(DATA_PORT, b"\0\xffA\x80"), + IoResult::Ok + )); + assert_eq!(portb.tx_buffer, b"\0\xffA\x80"); + + portb.rx_buffer.extend([0x5a, 0x6b]); + let mut status = [0xff; 4]; + assert!(matches!( + portb.io_read(STATUS_PORT, &mut status), + IoResult::Ok + )); + assert_eq!(status, [STATUS_INPUT_AVAILABLE, 0, 0, 0]); + + let mut data = [0xff; 4]; + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [0x5a, 0, 0, 0]); + } + + #[test] + fn accepted_connection_is_polled_immediately() { + let mut portb = MicrovmPortb::new(Box::new(ConnectWithByte { + connected: false, + byte: Some(0x5a), + })); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + assert_eq!(portb.rx_buffer, [0x5a]); + } +} diff --git a/vm/devices/chipset/src/microvm/resolver.rs b/vm/devices/chipset/src/microvm/resolver.rs new file mode 100644 index 00000000000..3fdfee7bb77 --- /dev/null +++ b/vm/devices/chipset/src/microvm/resolver.rs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resource resolver for microVM chipset devices. + +use super::MicrovmPortb; +use async_trait::async_trait; +use chipset_device_resources::ResolveChipsetDeviceHandleParams; +use chipset_device_resources::ResolvedChipsetDevice; +use chipset_resources::microvm::MicrovmPortbHandle; +use serial_core::resources::ResolveSerialBackendParams; +use thiserror::Error; +use vm_resource::AsyncResolveResource; +use vm_resource::ResolveError; +use vm_resource::ResourceResolver; +use vm_resource::declare_static_async_resolver; +use vm_resource::kind::ChipsetDeviceHandleKind; + +/// Resolver for the microVM portb console. +pub struct MicrovmPortbResolver; + +declare_static_async_resolver! { + MicrovmPortbResolver, + (ChipsetDeviceHandleKind, MicrovmPortbHandle), +} + +#[derive(Debug, Error)] +pub enum ResolveMicrovmPortbError { + #[error("failed to resolve microVM portb backend")] + ResolveBackend(#[source] ResolveError), +} + +#[async_trait] +impl AsyncResolveResource for MicrovmPortbResolver { + type Output = ResolvedChipsetDevice; + type Error = ResolveMicrovmPortbError; + + async fn resolve( + &self, + resolver: &ResourceResolver, + resource: MicrovmPortbHandle, + input: ResolveChipsetDeviceHandleParams<'_>, + ) -> Result { + let io = resolver + .resolve( + resource.io, + ResolveSerialBackendParams { + driver: Box::new(input.task_driver_source.simple()), + _async_trait_workaround: &(), + }, + ) + .await + .map_err(ResolveMicrovmPortbError::ResolveBackend)?; + Ok(MicrovmPortb::new(io.0.into_io()).into()) + } +} diff --git a/vm/devices/chipset_resources/src/lib.rs b/vm/devices/chipset_resources/src/lib.rs index cefbe8558f9..34d7009135e 100644 --- a/vm/devices/chipset_resources/src/lib.rs +++ b/vm/devices/chipset_resources/src/lib.rs @@ -226,6 +226,8 @@ pub mod pit { } } +pub mod microvm; + pub mod battery { //! Resource definitions for the battery device diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs new file mode 100644 index 00000000000..e92fb91892f --- /dev/null +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resource definitions for microVM chipset devices. + +use mesh::MeshPayload; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::ChipsetDeviceHandleKind; +use vm_resource::kind::SerialBackendHandle; + +/// The microVM bidirectional portb console at ports `0xe9` and `0xea`. +#[derive(MeshPayload)] +pub struct MicrovmPortbHandle { + /// Host serial endpoint used for raw input and output. + pub io: Resource, +} + +impl ResourceId for MicrovmPortbHandle { + const ID: &'static str = "microvm-portb"; +} From 073018af80e6d12f8847a2594f4821e819d3e387 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 04:06:03 -0700 Subject: [PATCH 093/304] chipset: add the microVM shutdown and exit-status port A microVM guest that runs one workload needs to end the VM and report the workload's result to the host with a single port access, without ACPI, a PM1 block, or a firmware power-off path. Add `MicrovmShutdown`, a write-only port at 0x604. A write of any size requests `PowerRequest::PowerOffWithStatus` with the first byte of the access as the status (0 for an empty access). Reads return all ones. The device is stateless, so it saves `NoSavedState`. Add `MicrovmShutdownHandle` and `MicrovmShutdownResolver`, which obtains the power request client from the platform, and register the resolver for x86_64 guests. Nothing instantiates the device yet. A test checks that the first byte of a wide or single-byte write becomes the power-off status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_resources/src/lib.rs | 2 + vm/devices/chipset/src/microvm.rs | 97 +++++++++++++++++++++ vm/devices/chipset/src/microvm/resolver.rs | 34 ++++++++ vm/devices/chipset_resources/src/microvm.rs | 8 ++ 4 files changed, 141 insertions(+) diff --git a/openvmm/openvmm_resources/src/lib.rs b/openvmm/openvmm_resources/src/lib.rs index f0e3add8dd4..ab83adbab28 100644 --- a/openvmm/openvmm_resources/src/lib.rs +++ b/openvmm/openvmm_resources/src/lib.rs @@ -28,6 +28,8 @@ vm_resource::register_static_resolvers! { #[cfg(guest_arch = "x86_64")] chipset::microvm::resolver::MicrovmPortbResolver, #[cfg(guest_arch = "x86_64")] + chipset::microvm::resolver::MicrovmShutdownResolver, + #[cfg(guest_arch = "x86_64")] chipset::pic::resolver::PicResolver, #[cfg(guest_arch = "x86_64")] chipset::ioapic::resolver::GenericIoApicResolver, diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index 03c08b9c6c3..07a04c8c82b 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -13,6 +13,8 @@ use chipset_device::poll_device::PollDevice; use futures::AsyncRead; use futures::AsyncWrite; use inspect::InspectMut; +use power_resources::PowerRequest; +use power_resources::PowerRequestClient; use serial_core::SerialIo; use std::collections::VecDeque; use std::io; @@ -23,6 +25,7 @@ use std::task::Context; use std::task::Poll; use std::task::Waker; use vmcore::device_state::ChangeDeviceState; +use vmcore::save_restore::NoSavedState; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; use vmcore::save_restore::SaveRestore; @@ -30,6 +33,7 @@ use vmcore::save_restore::SavedStateRoot; const DATA_PORT: u16 = 0xe9; const STATUS_PORT: u16 = 0xea; +const SHUTDOWN_PORT: u16 = 0x604; const STATUS_INPUT_AVAILABLE: u8 = 1 << 0; const BUFFER_MAX: usize = 1024 * 1024; @@ -259,6 +263,74 @@ pub struct MicrovmPortbSavedState { pub tx_buffer: Vec, } +/// microVM process-status shutdown port. +#[derive(InspectMut)] +pub struct MicrovmShutdown { + #[inspect(skip)] + io_region: (&'static str, RangeInclusive), + #[inspect(skip)] + power_request: PowerRequestClient, +} + +impl MicrovmShutdown { + /// Creates the shutdown device. + pub fn new(power_request: PowerRequestClient) -> Self { + Self { + io_region: ("microvm-shutdown", SHUTDOWN_PORT..=SHUTDOWN_PORT), + power_request, + } + } +} + +impl ChangeDeviceState for MicrovmShutdown { + fn start(&mut self) {} + async fn stop(&mut self) {} + async fn reset(&mut self) {} +} + +impl ChipsetDevice for MicrovmShutdown { + fn supports_pio(&mut self) -> Option<&mut dyn PortIoIntercept> { + Some(self) + } +} + +impl PortIoIntercept for MicrovmShutdown { + fn io_read(&mut self, io_port: u16, data: &mut [u8]) -> IoResult { + if io_port != SHUTDOWN_PORT { + return IoResult::Err(IoError::InvalidRegister); + } + data.fill(0xff); + IoResult::Ok + } + + fn io_write(&mut self, io_port: u16, data: &[u8]) -> IoResult { + if io_port != SHUTDOWN_PORT { + return IoResult::Err(IoError::InvalidRegister); + } + self.power_request + .power_request(PowerRequest::PowerOffWithStatus { + code: data.first().copied().unwrap_or(0), + }); + IoResult::Ok + } + + fn get_static_regions(&mut self) -> &[(&str, RangeInclusive)] { + std::slice::from_ref(&self.io_region) + } +} + +impl SaveRestore for MicrovmShutdown { + type SavedState = NoSavedState; + + fn save(&mut self) -> Result { + Ok(NoSavedState) + } + + fn restore(&mut self, NoSavedState: Self::SavedState) -> Result<(), RestoreError> { + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -448,4 +520,29 @@ mod tests { portb.poll_device(&mut Context::from_waker(Waker::noop())); assert_eq!(portb.rx_buffer, [0x5a]); } + + #[test] + fn lifecycle_ports_preserve_status_and_remain_nonblocking() { + let requests = Arc::new(Mutex::new(Vec::new())); + let captured = Arc::clone(&requests); + let mut shutdown = + MicrovmShutdown::new((move |request| captured.lock().push(request)).into()); + assert!(matches!( + shutdown.io_write(SHUTDOWN_PORT, &[37, 99]), + IoResult::Ok + )); + assert_eq!( + *requests.lock(), + [PowerRequest::PowerOffWithStatus { code: 37 }] + ); + requests.lock().clear(); + assert!(matches!( + shutdown.io_write(SHUTDOWN_PORT, &[0x25]), + IoResult::Ok + )); + assert_eq!( + *requests.lock(), + [PowerRequest::PowerOffWithStatus { code: 0x25 }] + ); + } } diff --git a/vm/devices/chipset/src/microvm/resolver.rs b/vm/devices/chipset/src/microvm/resolver.rs index 3fdfee7bb77..2d6d83e8e4c 100644 --- a/vm/devices/chipset/src/microvm/resolver.rs +++ b/vm/devices/chipset/src/microvm/resolver.rs @@ -4,13 +4,18 @@ //! Resource resolver for microVM chipset devices. use super::MicrovmPortb; +use super::MicrovmShutdown; use async_trait::async_trait; use chipset_device_resources::ResolveChipsetDeviceHandleParams; use chipset_device_resources::ResolvedChipsetDevice; use chipset_resources::microvm::MicrovmPortbHandle; +use chipset_resources::microvm::MicrovmShutdownHandle; +use power_resources::PowerRequestHandleKind; use serial_core::resources::ResolveSerialBackendParams; use thiserror::Error; use vm_resource::AsyncResolveResource; +use vm_resource::IntoResource; +use vm_resource::PlatformResource; use vm_resource::ResolveError; use vm_resource::ResourceResolver; use vm_resource::declare_static_async_resolver; @@ -54,3 +59,32 @@ impl AsyncResolveResource for Micro Ok(MicrovmPortb::new(io.0.into_io()).into()) } } + +/// Resolver for the microVM shutdown port. +pub struct MicrovmShutdownResolver; + +declare_static_async_resolver! { + MicrovmShutdownResolver, + (ChipsetDeviceHandleKind, MicrovmShutdownHandle), +} + +#[async_trait] +impl AsyncResolveResource + for MicrovmShutdownResolver +{ + type Output = ResolvedChipsetDevice; + type Error = ResolveMicrovmPortbError; + + async fn resolve( + &self, + resolver: &ResourceResolver, + _resource: MicrovmShutdownHandle, + _input: ResolveChipsetDeviceHandleParams<'_>, + ) -> Result { + let power_request = resolver + .resolve::(PlatformResource.into_resource(), ()) + .await + .map_err(ResolveMicrovmPortbError::ResolveBackend)?; + Ok(MicrovmShutdown::new(power_request).into()) + } +} diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs index e92fb91892f..f03b515526a 100644 --- a/vm/devices/chipset_resources/src/microvm.rs +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -19,3 +19,11 @@ pub struct MicrovmPortbHandle { impl ResourceId for MicrovmPortbHandle { const ID: &'static str = "microvm-portb"; } + +/// microVM shutdown control port at `0x604`. +#[derive(MeshPayload)] +pub struct MicrovmShutdownHandle; + +impl ResourceId for MicrovmShutdownHandle { + const ID: &'static str = "microvm-shutdown"; +} From 9d8f017f75a19a4b800da025401ec3b977e847d3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 04:19:50 -0700 Subject: [PATCH 094/304] openvmm_core: boot microVMs through MP-table direct load The microVM profile has no firmware and no ACPI. Linux instead discovers the processors and interrupt routing from Intel MP 1.4 tables, which the loader can already build. The worker needs a boot mode that selects that loader, and a machine contract to check before it builds anything. Add `LinuxDirectBootMode::MpTable` (x86-64 only). The worker loads the kernel through `vm_loaders::microvm::load_linux_x86_mptable`, which builds MP tables from the VP APIC IDs and the profile's level-triggered IRQ list (currently empty). MP-table boot rejects isolation. An embedded NUL in a Linux command line is now a load error instead of a panic. Define the profile's command-line ABI in `openvmm_defs::microvm`: - The profile owns the leading tokens `earlycon=xe9 console=hvc0 reboot=t panic=-1`, which use the patched guest kernel's port-0xe9 console. The command line is limited to 64 KiB. - `build_microvm_command_line` appends user arguments and rejects any that override `earlycon=`, `console=`, or `nr_cpus=`. - `append_microvm_processor_limit` appends the host-owned `nr_cpus=` for a supported processor count (1, 2, 4, or 8). Add `validate_machine_config`, which checks the fixed machine contract: - Boot and backend: MP-table Linux boot without isolation, emulated serial, or SMBIOS overrides, and a KVM, MSHV, or WHP backend. - Processors and memory: 1, 2, 4, or 8 vCPUs in one socket without SMT or x2APIC, and one NUMA node with RAM. - Features: no Hyper-V enlightenments, VTL2, isolation, or nested virtualization. - Devices: exactly the microVM base chipset with the IOAPIC, PIC, PIT, portb, and shutdown devices. No floppy, IDE, PCI, PCIe, VPCI, ISA DMA, VMBus, graphics, VMGS, firmware or debugger channel, RTC offset, or virtio device. - Layout: the fixed 1-GiB low MMIO layout. For the standard profile, the validator only rejects MP-table boot. The worker validates every configuration before it constructs the VM. On a microVM cold boot (not a restore), the worker adds `lapic_timer_hz=` with the backend-reported LAPIC timer frequency, so the patched guest kernel skips timer calibration. A user-supplied value is canonicalized when it matches the backend and rejected when it does not. Parsing follows the kernel's rules: hyphens and underscores are interchangeable, quoting is honored, values may be decimal, octal, or hexadecimal, and tokens after `--` belong to init. The token is inserted before `--` and before any virtio-mmio discovery tokens. `HvlitePartition` gains a `MicrovmPartition` supertrait that exposes the backend LAPIC frequency. The microVM chipset does not trace unknown port I/O: Linux probes many legacy ports that the profile deliberately omits. Nothing configures a microVM yet. Tests cover the boot-mode matrix, the host-owned processor limit, and LAPIC frequency propagation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/partition.rs | 6 +- openvmm/openvmm_core/src/partition/microvm.rs | 25 + openvmm/openvmm_core/src/worker/dispatch.rs | 19 +- .../src/worker/dispatch/microvm.rs | 129 ++++- .../src/worker/vm_loaders/linux.rs | 6 +- .../src/worker/vm_loaders/microvm.rs | 299 ++++++++++++ .../openvmm_core/src/worker/vm_loaders/mod.rs | 1 + openvmm/openvmm_defs/src/config.rs | 2 + openvmm/openvmm_defs/src/microvm.rs | 446 +++++++++++++++++- 9 files changed, 925 insertions(+), 8 deletions(-) create mode 100644 openvmm/openvmm_core/src/partition/microvm.rs create mode 100644 openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs diff --git a/openvmm/openvmm_core/src/partition.rs b/openvmm/openvmm_core/src/partition.rs index 3fcd9eba7c0..304c2071b73 100644 --- a/openvmm/openvmm_core/src/partition.rs +++ b/openvmm/openvmm_core/src/partition.rs @@ -11,6 +11,8 @@ //! //! If this ends up not being true, then this layer should probably be removed. +pub mod microvm; + use anyhow::Context as _; use async_trait::async_trait; use guestmem::DoorbellRegistration; @@ -54,7 +56,9 @@ use vmm_core::partition_unit::VmPartition; use vmm_core::partition_unit::VpRunner; /// A base partition, with methods needed at rutnime along with methods to initialize the vm. -pub trait HvlitePartition: Inspect + Send + Sync + RequestYield { +pub trait HvlitePartition: + Inspect + Send + Sync + RequestYield + microvm::MicrovmPartition +{ /// Completes backend partition initialization after guest memory is attached. fn finalize_memory(&self) -> anyhow::Result<()>; diff --git a/openvmm/openvmm_core/src/partition/microvm.rs b/openvmm/openvmm_core/src/partition/microvm.rs new file mode 100644 index 00000000000..2b38f5d4241 --- /dev/null +++ b/openvmm/openvmm_core/src/partition/microvm.rs @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Hypervisor partition operations used by the microVM profile. + +use super::ArchPartition; +use super::BasicPartitionStateAccess; +use virt::Partition; +use virt::PartitionAccessState; +use virt::PartitionMemoryMapper; + +/// Backend partition operations required by the microVM lifecycle. +pub trait MicrovmPartition: Send + Sync { + /// Returns the LAPIC interrupt clock frequency when available. + fn apic_frequency_hz(&self) -> anyhow::Result>; +} + +impl MicrovmPartition for T +where + T: BasicPartitionStateAccess + ArchPartition + PartitionMemoryMapper + PartitionAccessState, +{ + fn apic_frequency_hz(&self) -> anyhow::Result> { + Ok(Partition::apic_frequency_hz(self)?) + } +} diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index b6a96ccb673..ba1336c9628 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -340,6 +340,7 @@ impl Worker for VmWorker { fn new(mut parameters: Self::Parameters) -> anyhow::Result { let worker_construct = openvmm_defs::profile::ProfileSpan::start(); let restore_params = restore::RestoreParameters::take(&mut parameters)?; + let microvm_params = microvm::MicrovmParameters::take(&mut parameters)?; let (device_thread, device_driver) = new_device_thread(); let manifest = Manifest::from_config(parameters.cfg); @@ -351,7 +352,7 @@ impl Worker for VmWorker { .shared_memory .map(|fd| restore_params.shared_memory_backing(fd)); - let vm = block_on(InitializedVm::new( + let mut vm = block_on(InitializedVm::new( VmTaskDriverSource::new(ThreadDriverBackend::new(device_driver)), hypervisor.0, manifest, @@ -362,6 +363,7 @@ impl Worker for VmWorker { .map(|m| m.parse()) .transpose() .context("failed to decode saved state")?; + microvm_params.prepare_cold_boot(&mut vm, saved_state.is_some())?; let vm = block_with_io(|_| vm.load(saved_state, parameters.notify, restore_params.state))?; @@ -2160,7 +2162,7 @@ impl InitializedVm { .with_device_handles(cfg.chipset_devices) .with_pci_device_handles(cfg.pci_chipset_devices) .with_isa_dma_handle(cfg.isa_dma_controller) - .with_trace_unknown_pio(true) // todo: add CLI param? + .with_trace_unknown_pio(microvm::trace_unknown_pio(cfg.machine_profile)) .build(&driver_source, &state_units, &resolver) .await?; @@ -3351,7 +3353,7 @@ impl LoadedVmInner { with_pit: self.chipset_capabilities.with_pit, pm_base: PM_BASE, acpi_irq: SYSTEM_IRQ_ACPI, - level_triggered_irqs: &[], + level_triggered_irqs: microvm::level_triggered_irqs(self.machine_profile), iommu: match &self.iommu_devices { IommuDevices::AmdVi(devices) => { Some(vmm_core::acpi_builder::X86IommuAcpiConfig::AmdVi( @@ -3401,6 +3403,16 @@ impl LoadedVmInner { } = match &self.load_mode { LoadMode::None => return Ok(()), #[cfg(guest_arch = "x86_64")] + &LoadMode::Linux { + ref kernel, + ref initrd, + ref cmdline, + isolation, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + ref smbios, + .. + } => microvm::load_linux_x86_mptable(self, kernel, initrd, cmdline, isolation, smbios)?, + #[cfg(guest_arch = "x86_64")] &LoadMode::Linux { ref kernel, ref initrd, @@ -3415,6 +3427,7 @@ impl LoadedVmInner { anyhow::bail!("device tree boot mode is not supported on x86_64"); } openvmm_defs::config::LinuxDirectBootMode::Acpi => {} + openvmm_defs::config::LinuxDirectBootMode::MpTable => unreachable!(), } let isolation = match (isolation, self.hypervisor_cfg.with_isolation) { ( diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 8d05bc872df..9b6738ef6a2 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -3,8 +3,135 @@ //! Worker construction and lifecycle support for the microVM profile. -#[cfg(guest_arch = "x86_64")] +use super::InitializedVm; +use super::LoadedVmInner; +use super::Manifest; +use crate::partition::HvlitePartition; +use anyhow::Context; use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::worker::VmWorkerParameters; +use vm_loader::InitialLoad; + +/// MicroVM inputs taken from the [`VmWorkerParameters`]. +pub(super) struct MicrovmParameters {} + +impl MicrovmParameters { + /// Takes the microVM inputs out of the worker parameters and validates the + /// machine configuration for the selected hypervisor. + pub(super) fn take(parameters: &mut VmWorkerParameters) -> anyhow::Result { + openvmm_defs::microvm::validate_machine_config( + ¶meters.cfg, + Some(parameters.hypervisor.id()), + )?; + Ok(Self {}) + } + + /// Prepares the kernel command line of a microVM cold boot, before the VM + /// is loaded. + pub(super) fn prepare_cold_boot( + &self, + vm: &mut InitializedVm, + restored_from_snapshot: bool, + ) -> anyhow::Result<()> { + prepare_cold_boot_command_line(&mut vm.cfg, vm.partition.as_ref(), restored_from_snapshot) + } +} + +#[cfg(guest_arch = "x86_64")] +pub(super) fn load_linux_x86_mptable( + vm: &LoadedVmInner, + kernel: &std::fs::File, + initrd: &Option, + cmdline: &str, + isolation: openvmm_defs::config::LinuxIsolationConfig, + smbios: &openvmm_defs::config::SmbiosConfig, +) -> anyhow::Result> { + anyhow::ensure!( + vm.machine_profile == MachineProfile::Microvm, + "Linux MP-table boot mode requires the microVM profile" + ); + anyhow::ensure!( + isolation == openvmm_defs::config::LinuxIsolationConfig::None + && vm.hypervisor_cfg.with_isolation.is_none(), + "microVM MP-table boot does not support isolation" + ); + let apic_ids = vm + .processor_topology + .vps_arch() + .map(|vp| vp.apic_id) + .collect::>(); + let kernel_config = crate::worker::vm_loaders::linux::KernelConfig { + kernel, + initrd, + cmdline, + mem_layout: &vm.mem_layout, + isolation: crate::worker::vm_loaders::linux::KernelIsolationConfig::None, + smbios, + }; + Ok(crate::worker::vm_loaders::microvm::load_linux_x86_mptable( + &kernel_config, + &vm.gm, + &apic_ids, + &openvmm_defs::microvm::MICROVM_LEVEL_TRIGGERED_IRQS, + &[], + )?) +} + +pub(super) fn level_triggered_irqs(machine_profile: MachineProfile) -> &'static [u32] { + match machine_profile { + MachineProfile::Microvm => &openvmm_defs::microvm::MICROVM_LEVEL_TRIGGERED_IRQS, + MachineProfile::Standard => &[], + } +} + +pub(super) fn trace_unknown_pio(machine_profile: MachineProfile) -> bool { + machine_profile == MachineProfile::Standard +} + +#[cfg(guest_arch = "x86_64")] +fn prepare_cold_boot_command_line( + cfg: &mut Manifest, + partition: &dyn HvlitePartition, + restored_from_snapshot: bool, +) -> anyhow::Result<()> { + if restored_from_snapshot || cfg.machine_profile != MachineProfile::Microvm { + return Ok(()); + } + + let cmdline = match &mut cfg.load_mode { + openvmm_defs::config::LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } => Some(cmdline), + _ => None, + }; + let Some(cmdline) = cmdline else { + anyhow::bail!("microVM has no supported cold-boot command line"); + }; + match partition + .apic_frequency_hz() + .context("failed to query the backend guest LAPIC frequency")? + { + Some(frequency_hz) => { + crate::worker::vm_loaders::microvm::propagate_apic_frequency(cmdline, frequency_hz) + .context("failed to propagate the guest LAPIC frequency")?; + } + None => tracing::warn!( + "backend does not expose a guest LAPIC frequency; retaining guest timer calibration" + ), + } + Ok(()) +} + +#[cfg(not(guest_arch = "x86_64"))] +fn prepare_cold_boot_command_line( + _cfg: &mut Manifest, + _partition: &dyn HvlitePartition, + _restored_from_snapshot: bool, +) -> anyhow::Result<()> { + Ok(()) +} #[cfg(guest_arch = "x86_64")] pub(super) fn x86_topology_builder( diff --git a/openvmm/openvmm_core/src/worker/vm_loaders/linux.rs b/openvmm/openvmm_core/src/worker/vm_loaders/linux.rs index 258c0a8341c..2bde66fd7e6 100644 --- a/openvmm/openvmm_core/src/worker/vm_loaders/linux.rs +++ b/openvmm/openvmm_core/src/worker/vm_loaders/linux.rs @@ -35,6 +35,10 @@ pub enum Error { Efi(#[source] guestmem::GuestMemoryError), #[error("failed to finalize SNP VMSA")] SnpVmsa(#[source] anyhow::Error), + #[error("Linux kernel command line contains an embedded NUL")] + CommandLineNul(#[source] std::ffi::NulError), + #[error("MP-table Linux direct boot does not support isolation")] + MpTableIsolation, } struct Aarch64EfiInfo { @@ -191,7 +195,7 @@ pub fn load_linux_x86( size: initrd_size, }); - let cmdline = CString::new(cfg.cmdline).unwrap(); + let cmdline = CString::new(cfg.cmdline).map_err(Error::CommandLineNul)?; let snp = match cfg.isolation { KernelIsolationConfig::None => None, KernelIsolationConfig::Snp(snp) => Some(snp), diff --git a/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs b/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs new file mode 100644 index 00000000000..abd6e1a275c --- /dev/null +++ b/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs @@ -0,0 +1,299 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM-specific Linux boot configuration. + +use super::linux::Error as LinuxError; +use super::linux::KernelConfig; +use super::linux::KernelIsolationConfig; +use guestmem::GuestMemory; +use loader::importer::X86Register; +use loader::linux::InitrdAddressType; +use loader::linux::InitrdConfig; +use memory_range::MemoryRange; +use std::ffi::CString; +use std::io::Seek; +use thiserror::Error; +use vm_loader::InitialLoad; +use vm_loader::Loader; + +const LAPIC_TIMER_HZ: &str = "lapic_timer_hz"; +const MIN_APIC_FREQUENCY_HZ: u64 = 1_000_000; +const VIRTIO_MMIO_DEVICE: &str = "virtio_mmio.device="; + +#[cfg_attr(not(guest_arch = "x86_64"), expect(dead_code))] +pub fn load_linux_x86_mptable( + cfg: &KernelConfig<'_>, + gm: &GuestMemory, + apic_ids: &[u32], + level_triggered_irqs: &[u32], + reserved_memory_ranges: &[MemoryRange], +) -> Result, LinuxError> { + if !matches!(cfg.isolation, KernelIsolationConfig::None) { + return Err(LinuxError::MpTableIsolation); + } + + let mut kernel_file = cfg.kernel; + let (mut initrd_reader, initrd_size) = if let Some(mut initrd_file) = cfg.initrd.as_ref() { + initrd_file.rewind().map_err(LinuxError::InitRd)?; + let size = initrd_file + .seek(std::io::SeekFrom::End(0)) + .map_err(LinuxError::InitRd)?; + (Some(initrd_file), size) + } else { + (None, 0) + }; + let initrd_config = initrd_reader.as_mut().map(|reader| InitrdConfig { + initrd_address: InitrdAddressType::AfterKernel, + initrd: reader, + size: initrd_size, + }); + let cmdline = CString::new(cfg.cmdline).map_err(LinuxError::CommandLineNul)?; + let mut loader = Loader::new(gm.clone(), cfg.mem_layout, hvdef::Vtl::Vtl0); + loader::linux::microvm::load_x86_mptable( + &mut loader, + &mut kernel_file, + initrd_config, + &cmdline, + cfg.mem_layout, + &loader::mptable::MpTableConfig { + apic_ids, + level_triggered_irqs, + }, + reserved_memory_ranges, + ) + .map_err(LinuxError::Loader)?; + Ok(loader.initial_regs_and_page_imports()) +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub(crate) enum FrequencyParameterError { + #[error("duplicate parameter")] + Duplicate, + #[error("malformed parameter: {0}")] + Malformed(String), + #[error("value {specified} does not match the backend-reported value {reported}")] + Mismatch { specified: u64, reported: u64 }, +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub(crate) enum ApicFrequencyError { + #[error("LAPIC frequency {0} Hz is outside the supported range of 1 MHz through 4294967295 Hz")] + OutOfRange(u64), + #[error("invalid lapic_timer_hz: {0}")] + Parameter(#[from] FrequencyParameterError), +} + +fn command_line_tokens(cmdline: &str) -> (Vec<(usize, &str)>, Option) { + let mut tokens = Vec::new(); + let mut token_start = None; + let mut in_quote = false; + for (offset, character) in cmdline.char_indices() { + if character == '"' { + in_quote = !in_quote; + } + if character.is_ascii_whitespace() && !in_quote { + if let Some(start) = token_start.take() { + tokens.push((start, &cmdline[start..offset])); + } + } else if token_start.is_none() { + token_start = Some(offset); + } + } + let unterminated_quote_offset = if in_quote { token_start } else { None }; + if let Some(start) = token_start { + tokens.push((start, &cmdline[start..])); + } + (tokens, unterminated_quote_offset) +} + +fn linux_parameter_name_matches(actual: &str, expected: &str) -> bool { + actual + .bytes() + .map(|byte| if byte == b'-' { b'_' } else { byte }) + .eq(expected + .bytes() + .map(|byte| if byte == b'-' { b'_' } else { byte })) +} + +fn parse_linux_uint(value: &str) -> Option { + let value = if let Some(value) = value.strip_prefix('"') { + value.strip_suffix('"')? + } else { + if value.ends_with('"') { + return None; + } + value + }; + let value = value.strip_prefix('+').unwrap_or(value); + if value.is_empty() { + return None; + } + + let (digits, radix) = if let Some(digits) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + (digits, 16) + } else if value.starts_with('0') { + (value, 8) + } else { + (value, 10) + }; + u32::from_str_radix(digits, radix).ok() +} + +pub(crate) fn propagate_apic_frequency( + cmdline: &mut String, + frequency_hz: u64, +) -> Result<(), ApicFrequencyError> { + if !(MIN_APIC_FREQUENCY_HZ..=u64::from(u32::MAX)).contains(&frequency_hz) { + return Err(ApicFrequencyError::OutOfRange(frequency_hz)); + } + Ok(propagate_frequency_parameter( + cmdline, + LAPIC_TIMER_HZ, + frequency_hz, + )?) +} + +fn propagate_frequency_parameter( + cmdline: &mut String, + parameter_name: &str, + frequency: u64, +) -> Result<(), FrequencyParameterError> { + let canonical_parameter = format!("{parameter_name}={frequency}"); + + let mut parameter = None; + let mut delimiter_offset = None; + let mut discovery_offset = None; + let (tokens, unterminated_quote_offset) = command_line_tokens(cmdline); + for (offset, raw_token) in tokens { + let token_length = raw_token.len(); + let token = raw_token.strip_prefix('"').map_or(raw_token, |token| { + if token + .split_once('=') + .is_some_and(|(_name, value)| value.starts_with('"')) + { + token + } else { + token.strip_suffix('"').unwrap_or(token) + } + }); + if token == "--" { + delimiter_offset = Some(offset); + break; + } + if discovery_offset.is_none() && token.starts_with(VIRTIO_MMIO_DEVICE) { + discovery_offset = Some(offset); + } + let name = token.split_once('=').map_or(token, |(name, _value)| name); + if linux_parameter_name_matches(name, parameter_name) { + if parameter.is_some() { + return Err(FrequencyParameterError::Duplicate); + } + parameter = Some((offset..offset + token_length, token.to_owned())); + } + } + + if let Some((range, parameter)) = parameter { + let Some((_name, value)) = parameter.split_once('=') else { + return Err(FrequencyParameterError::Malformed(parameter)); + }; + let specified = parse_linux_uint(value) + .map(u64::from) + .ok_or_else(|| FrequencyParameterError::Malformed(parameter.clone()))?; + if specified != frequency { + return Err(FrequencyParameterError::Mismatch { + specified, + reported: frequency, + }); + } + cmdline.replace_range(range, &canonical_parameter); + return Ok(()); + } + + if let Some(offset) = discovery_offset + .or(delimiter_offset) + .or(unterminated_quote_offset) + { + cmdline.insert_str(offset, &format!("{canonical_parameter} ")); + return Ok(()); + } + if !cmdline.is_empty() + && !cmdline + .as_bytes() + .last() + .is_some_and(|byte| byte.is_ascii_whitespace()) + { + cmdline.push(' '); + } + cmdline.push_str(&canonical_parameter); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn propagates_backend_apic_frequency() { + let mut cmdline = "console=hvc0 -- tenant".to_owned(); + propagate_apic_frequency(&mut cmdline, 200_000_000).unwrap(); + assert_eq!(cmdline, "console=hvc0 lapic_timer_hz=200000000 -- tenant"); + + let mut discovery = "console=hvc0 virtio_mmio.device=0x1000@0xd0002000:7".to_owned(); + propagate_apic_frequency(&mut discovery, 200_000_000).unwrap(); + assert_eq!( + discovery, + "console=hvc0 lapic_timer_hz=200000000 virtio_mmio.device=0x1000@0xd0002000:7" + ); + } + + #[test] + fn canonicalizes_apic_frequency_using_linux_parameter_rules() { + for parameter in [ + "lapic_timer_hz=200000000", + "lapic-timer-hz=200000000", + "lapic_timer_hz=0xbebc200", + r#"lapic_timer_hz="200000000""#, + r#""lapic_timer_hz=200000000""#, + ] { + let mut cmdline = format!("console=hvc0 {parameter} -- lapic_timer_hz=1"); + propagate_apic_frequency(&mut cmdline, 200_000_000).unwrap(); + assert_eq!( + cmdline, + "console=hvc0 lapic_timer_hz=200000000 -- lapic_timer_hz=1" + ); + } + } + + #[test] + fn rejects_invalid_apic_frequency_without_mutating_command_line() { + for parameter in [ + "lapic_timer_hz", + "lapic_timer_hz=", + "lapic_timer_hz=0", + "lapic_timer_hz=199999999", + "lapic_timer_hz=4294967296", + "lapic_timer_hz=200000000 lapic-timer-hz=200000000", + ] { + let mut cmdline = parameter.to_owned(); + let error = propagate_apic_frequency(&mut cmdline, 200_000_000).unwrap_err(); + assert!(error.to_string().contains(LAPIC_TIMER_HZ)); + assert_eq!(cmdline, parameter); + } + for frequency in [0, MIN_APIC_FREQUENCY_HZ - 1, u64::from(u32::MAX) + 1] { + let mut cmdline = "console=hvc0".to_owned(); + assert_eq!( + propagate_apic_frequency(&mut cmdline, frequency), + Err(ApicFrequencyError::OutOfRange(frequency)) + ); + assert_eq!(cmdline, "console=hvc0"); + } + for frequency in [MIN_APIC_FREQUENCY_HZ, u64::from(u32::MAX)] { + propagate_apic_frequency(&mut String::new(), frequency).unwrap(); + } + } +} diff --git a/openvmm/openvmm_core/src/worker/vm_loaders/mod.rs b/openvmm/openvmm_core/src/worker/vm_loaders/mod.rs index dcb574a2dce..603e0e026ad 100644 --- a/openvmm/openvmm_core/src/worker/vm_loaders/mod.rs +++ b/openvmm/openvmm_core/src/worker/vm_loaders/mod.rs @@ -5,5 +5,6 @@ pub mod igvm; pub mod linux; +pub mod microvm; pub mod pcat; pub mod uefi; diff --git a/openvmm/openvmm_defs/src/config.rs b/openvmm/openvmm_defs/src/config.rs index 98859ef02cc..131d4641043 100644 --- a/openvmm/openvmm_defs/src/config.rs +++ b/openvmm/openvmm_defs/src/config.rs @@ -125,6 +125,8 @@ pub enum LinuxDirectBootMode { /// an EFI system table so the kernel enters its ACPI code path. On x86, /// ACPI tables are always provided via the zero page. Acpi, + /// Intel MP 1.4 tables for ACPI-free x86-64 microVM discovery. + MpTable, } /// Isolation-specific settings for Linux direct boot. diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index a4e5f37e764..01046da22cd 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -3,7 +3,119 @@ //! MicroVM machine profile definitions. +use crate::config::ArchTopologyConfig; +use crate::config::Config; +use crate::config::LinuxDirectBootMode; +use crate::config::LinuxIsolationConfig; +use crate::config::LoadMode; +use crate::config::SmbiosBiosOverrides; +use crate::config::SmbiosConfig; +use crate::config::SmbiosSystemOverrides; +use crate::config::X2ApicConfig; +use crate::config::X86TopologyConfig; +use guid::Guid; use mesh::MeshPayload; +use std::fmt::Write as _; +use vmotherboard::options::BaseChipsetManifest; + +/// Returns whether a processor count is valid for the microVM. +pub const fn microvm_processor_count_supported(processor_count: u32) -> bool { + matches!(processor_count, 1 | 2 | 4 | 8) +} + +/// Command line owned by the microVM profile. +pub const MICROVM_BASE_COMMAND_LINE: &str = "earlycon=xe9 console=hvc0 reboot=t panic=-1"; +/// Maximum microVM command-line size, including its trailing NUL. +pub const MICROVM_COMMAND_LINE_MAX_SIZE: usize = 64 * 1024; +/// The microVM publishes no level-triggered virtio IRQs in its MP table. +pub const MICROVM_LEVEL_TRIGGERED_IRQS: [u32; 0] = []; + +fn validate_microvm_command_line(config: &Config) -> anyhow::Result<()> { + let MachineProfile::Microvm = config.machine_profile else { + unreachable!("microVM command-line validation requires the microVM profile"); + }; + let cmdline = match &config.load_mode { + LoadMode::Linux { + cmdline, + boot_mode: LinuxDirectBootMode::MpTable, + .. + } => cmdline, + _ => anyhow::bail!("microVM requires Linux MP-table load mode"), + }; + anyhow::ensure!( + !cmdline.contains('\0'), + "microVM command line contains an embedded NUL" + ); + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM command line exceeds the 64-KiB ABI limit" + ); + + let tokens = cmdline.split_ascii_whitespace().collect::>(); + let base_tokens = MICROVM_BASE_COMMAND_LINE + .split_ascii_whitespace() + .collect::>(); + anyhow::ensure!( + tokens.starts_with(&base_tokens), + "microVM command line does not begin with the ABI base tokens" + ); + for prefix in ["earlycon=", "console="] { + let count = tokens + .iter() + .filter(|token| token.starts_with(prefix)) + .count(); + anyhow::ensure!( + count == 1, + "microVM command line has an invalid number of {prefix} tokens" + ); + } + Ok(()) +} + +/// Builds the microVM command line and rejects profile-owned user tokens. +pub fn build_microvm_command_line(user_args: &[String]) -> anyhow::Result { + for arg in user_args { + if arg.contains('\0') { + anyhow::bail!("microVM kernel command line contains an embedded NUL"); + } + if arg.split_ascii_whitespace().any(|token| { + ["earlycon=", "console=", "nr_cpus="] + .iter() + .any(|reserved| token.starts_with(reserved)) + }) { + anyhow::bail!( + "microVM kernel command line cannot override profile-owned configuration" + ); + } + } + + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + for arg in user_args.iter().filter(|arg| !arg.is_empty()) { + cmdline.push(' '); + cmdline.push_str(arg); + } + if cmdline.len() >= MICROVM_COMMAND_LINE_MAX_SIZE { + anyhow::bail!("microVM kernel command line exceeds the 64-KiB ABI limit"); + } + Ok(cmdline) +} + +/// Appends the host-owned processor capacity to a microVM command line. +pub fn append_microvm_processor_limit( + cmdline: &mut String, + processor_count: u32, +) -> anyhow::Result<()> { + anyhow::ensure!( + microvm_processor_count_supported(processor_count), + "microVM does not support {processor_count} vCPUs" + ); + write!(cmdline, " nr_cpus={processor_count}")?; + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM kernel command line exceeds the 64-KiB ABI limit" + ); + Ok(()) +} /// The guest-visible machine contract, independent of the hypervisor backend. #[derive(MeshPayload, Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -16,7 +128,337 @@ pub enum MachineProfile { } /// VM configuration specific to the microVM machine profile -/// ([`MachineProfile::Microvm`]), held in -/// [`Config::microvm`](crate::config::Config::microvm). +/// ([`MachineProfile::Microvm`]), held in [`Config::microvm`]. #[derive(MeshPayload, Debug, Default)] pub struct MicrovmConfig {} + +fn validate_machine_load_mode( + machine_profile: MachineProfile, + load_mode: &LoadMode, +) -> anyhow::Result<()> { + if machine_profile == MachineProfile::Standard { + anyhow::ensure!( + !matches!( + load_mode, + LoadMode::Linux { + boot_mode: LinuxDirectBootMode::MpTable, + .. + } + ), + "Linux MP-table boot mode requires the microVM profile" + ); + return Ok(()); + } + + match load_mode { + LoadMode::Linux { + enable_serial, + isolation, + boot_mode, + smbios, + .. + } => { + anyhow::ensure!( + *boot_mode == LinuxDirectBootMode::MpTable, + "microVM Linux direct boot requires MP tables" + ); + anyhow::ensure!( + *isolation == LinuxIsolationConfig::None, + "microVM MP-table boot does not support isolation" + ); + anyhow::ensure!( + !enable_serial, + "microVM MP-table boot does not support emulated serial" + ); + let SmbiosConfig { bios, system } = &**smbios; + let SmbiosBiosOverrides { + vendor, + version: bios_version, + release_date, + release, + } = bios; + let SmbiosSystemOverrides { + manufacturer, + product_name, + version: system_version, + serial_number, + sku_number, + family, + uuid, + } = system; + anyhow::ensure!( + vendor.is_none() + && bios_version.is_none() + && release_date.is_none() + && release.is_none() + && manufacturer.is_none() + && product_name.is_none() + && system_version.is_none() + && serial_number.is_none() + && sku_number.is_none() + && family.is_none() + && *uuid == Guid::ZERO, + "microVM MP-table boot does not expose SMBIOS overrides" + ); + Ok(()) + } + _ => anyhow::bail!("microVM requires Linux MP-table load mode"), + } +} + +/// Validates the microVM machine contract. Standard-machine configurations are unchanged. +pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> anyhow::Result<()> { + let MachineProfile::Microvm = config.machine_profile else { + validate_machine_load_mode(config.machine_profile, &config.load_mode)?; + return Ok(()); + }; + + validate_microvm_command_line(config)?; + validate_machine_load_mode(config.machine_profile, &config.load_mode)?; + if let Some(hypervisor_id) = hypervisor_id { + anyhow::ensure!( + matches!(hypervisor_id, "kvm" | "mshv" | "whp"), + "microVM requires the KVM, MSHV, or WHP hypervisor" + ); + } + anyhow::ensure!( + microvm_processor_count_supported(config.processor_topology.proc_count), + "microVM does not support {} vCPUs", + config.processor_topology.proc_count + ); + let has_expected_topology = config.processor_topology.vps_per_socket + == Some(config.processor_topology.proc_count) + && config.processor_topology.enable_smt == Some(false) + && matches!( + &config.processor_topology.arch, + Some(ArchTopologyConfig::X86(X86TopologyConfig { + apic_id_offset: 0, + x2apic: X2ApicConfig::Unsupported, + })) + ); + anyhow::ensure!( + has_expected_topology, + "microVM requires its fixed x86 APIC topology" + ); + anyhow::ensure!( + config.numa.nodes.len() == 1 && config.numa.distances.is_empty(), + "microVM requires a single NUMA node" + ); + anyhow::ensure!(config.numa.nodes[0].mem.is_some(), "microVM requires RAM"); + anyhow::ensure!( + !config.hypervisor.with_hv + && config.hypervisor.with_vtl2.is_none() + && config.hypervisor.with_isolation.is_none() + && !config.hypervisor.nested_virt, + "microVM does not support Hyper-V enlightenments, VTL2, isolation, or nested virtualization" + ); + + let expected_chipset = BaseChipsetManifest { + with_generic_cmos_rtc: true, + ..BaseChipsetManifest::empty() + }; + anyhow::ensure!( + config.chipset == expected_chipset, + "microVM chipset is not the microVM allowlist" + ); + anyhow::ensure!( + config.chipset_capabilities.with_ioapic + && config.chipset_capabilities.with_pic + && config.chipset_capabilities.with_pit + && !config.chipset_capabilities.with_generic_isa_dma + && !config.chipset_capabilities.with_psp + && !config.chipset_capabilities.with_guest_watchdog + && !config.chipset_capabilities.with_i440bx_host_pci_bridge, + "microVM chipset capabilities do not match the fixed profile" + ); + + let mut chipset_ids = config + .chipset_devices + .iter() + .map(|device| (device.name.as_str(), device.resource.id())) + .collect::>(); + chipset_ids.sort_unstable(); + anyhow::ensure!( + chipset_ids + == [ + ("ioapic", "generic-ioapic"), + ("microvm-portb", "microvm-portb"), + ("microvm-shutdown", "microvm-shutdown"), + ("pic", "pic"), + ("pit", "pit"), + ], + "microVM chipset-device inventory is not exact" + ); + + anyhow::ensure!( + config.floppy_disks.is_empty() && config.ide_disks.is_empty(), + "microVM does not support floppy or IDE devices" + ); + anyhow::ensure!( + config.pcie_root_complexes.is_empty() + && config.pcie_devices.is_empty() + && config.pcie_switches.is_empty() + && config.pcie_generic_initiators.is_empty() + && config.vpci_devices.is_empty() + && config.pci_chipset_devices.is_empty() + && config.isa_dma_controller.is_none(), + "microVM does not support PCI, PCIe, VPCI, or ISA DMA" + ); + anyhow::ensure!( + config.vmbus.is_none() && config.vtl2_vmbus.is_none() && config.vmbus_devices.is_empty(), + "microVM does not support VMBus" + ); + anyhow::ensure!( + config.framebuffer.is_none() && config.vga_firmware.is_none() && !config.vtl2_gfx, + "microVM does not support graphics or VGA firmware" + ); + anyhow::ensure!(config.vmgs.is_none(), "microVM does not support VMGS"); + anyhow::ensure!( + config.firmware_event_send.is_none() && config.debugger_rpc.is_none(), + "microVM does not support firmware or debugger resources" + ); + anyhow::ensure!( + config.rtc_delta_milliseconds == 0, + "microVM RTC must be anchored directly to UTC" + ); + #[cfg(windows)] + anyhow::ensure!( + config.kernel_vmnics.is_empty() && config.vpci_resources.is_empty(), + "microVM does not support kernel NIC or VPCI resources" + ); + + anyhow::ensure!( + config.virtio_devices.is_empty(), + "microVM does not support virtio devices" + ); + anyhow::ensure!( + config.layout.chipset_low_mmio_size == 1024 * 1024 * 1024 + && config.layout.chipset_high_mmio_size == 0 + && config.layout.vtl2_chipset_mmio_size == 0, + "microVM requires the fixed 3-GiB/4-GiB RAM split" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs::File; + + fn linux_load_mode( + boot_mode: LinuxDirectBootMode, + isolation: LinuxIsolationConfig, + enable_serial: bool, + smbios: SmbiosConfig, + ) -> LoadMode { + LoadMode::Linux { + kernel: File::open(std::env::current_exe().unwrap()).unwrap(), + initrd: None, + cmdline: MICROVM_BASE_COMMAND_LINE.to_owned(), + enable_serial, + isolation, + boot_mode, + smbios: Box::new(smbios), + } + } + + #[test] + fn validates_linux_direct_boot_mode_matrix() { + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::None, + false, + SmbiosConfig::default(), + ), + ) + .unwrap(); + validate_machine_load_mode( + MachineProfile::Standard, + &linux_load_mode( + LinuxDirectBootMode::Acpi, + LinuxIsolationConfig::None, + true, + SmbiosConfig::default(), + ), + ) + .unwrap(); + + for boot_mode in [LinuxDirectBootMode::Acpi, LinuxDirectBootMode::DeviceTree] { + assert!( + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + boot_mode, + LinuxIsolationConfig::None, + false, + SmbiosConfig::default(), + ), + ) + .is_err() + ); + } + assert!( + validate_machine_load_mode( + MachineProfile::Standard, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::None, + false, + SmbiosConfig::default(), + ), + ) + .is_err() + ); + assert!( + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::Snp { + restricted_injection: false, + }, + false, + SmbiosConfig::default(), + ), + ) + .is_err() + ); + assert!( + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::None, + true, + SmbiosConfig::default(), + ), + ) + .is_err() + ); + let mut smbios = SmbiosConfig::default(); + smbios.system.product_name = Some("override".to_owned()); + assert!( + validate_machine_load_mode( + MachineProfile::Microvm, + &linux_load_mode( + LinuxDirectBootMode::MpTable, + LinuxIsolationConfig::None, + false, + smbios, + ), + ) + .is_err() + ); + } + + #[test] + fn microvm_processor_limit_is_host_owned() { + let mut cmdline = build_microvm_command_line(&[]).unwrap(); + append_microvm_processor_limit(&mut cmdline, 8).unwrap(); + assert_eq!(cmdline, format!("{MICROVM_BASE_COMMAND_LINE} nr_cpus=8")); + assert!(append_microvm_processor_limit(&mut cmdline, 3).is_err()); + assert!(build_microvm_command_line(&["nr_cpus=1".to_owned()]).is_err()); + } +} From a0a3dd45f0f5270ff3d4b927785f9991d6bb2904 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 04:34:55 -0700 Subject: [PATCH 095/304] openvmm_core: reject host save, pulse save/restore, and worker restart for microVMs The microVM profile's persistent state is defined by its own snapshot contract, which later commits establish. Until then, the generic host-driven paths would produce state that nothing can restore: a saved microVM cannot be restored, and a worker restart would rebuild the VM from a manifest that does not describe it. Filter management RPCs in the worker loop before dispatch. For a microVM: - `VmRpc::Save` fails with "save is unavailable for microVM". - `VmRpc::PulseSaveRestore` fails with the new `PulseSaveRestoreError::UnsupportedMachineProfile`. - `WorkerRpc::Restart` fails with "worker restart is unavailable for microVM". The filters complete a rejected RPC in place and return every other RPC unchanged, so the standard machine is unaffected. Petri's `verify_save_restore` treats the new error like a missing reset: it logs a warning and skips the save/restore check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/worker/dispatch.rs | 12 +++++ .../src/worker/dispatch/microvm.rs | 44 +++++++++++++++++++ openvmm/openvmm_defs/src/rpc.rs | 2 + petri/src/vm/openvmm/runtime.rs | 4 ++ 4 files changed, 62 insertions(+) diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index ba1336c9628..51e5f142efa 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -3834,6 +3834,18 @@ impl LoadedVm { (a, b, c).race().await }; + let event = match event { + Event::VmRpc(Ok(message)) => match self.filter_vm_rpc(message) { + Some(message) => Event::VmRpc(Ok(message)), + None => continue, + }, + Event::WorkerRpc(Ok(message)) => match self.filter_worker_rpc(message) { + Some(message) => Event::WorkerRpc(Ok(message)), + None => continue, + }, + event => event, + }; + match event { Event::WorkerRpc(Err(_)) => break, Event::WorkerRpc(Ok(message)) => match message { diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 9b6738ef6a2..79ce186e027 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -4,11 +4,17 @@ //! Worker construction and lifecycle support for the microVM profile. use super::InitializedVm; +use super::LoadedVm; use super::LoadedVmInner; use super::Manifest; +use super::RestartState; use crate::partition::HvlitePartition; use anyhow::Context; +use mesh::error::RemoteError; +use mesh_worker::WorkerRpc; use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::rpc::PulseSaveRestoreError; +use openvmm_defs::rpc::VmRpc; use openvmm_defs::worker::VmWorkerParameters; use vm_loader::InitialLoad; @@ -77,6 +83,44 @@ pub(super) fn load_linux_x86_mptable( )?) } +impl LoadedVm { + /// Applies the microVM restrictions to a management RPC. Rejected RPCs are + /// completed here; the others are returned for dispatch. + pub(super) fn filter_vm_rpc(&self, message: VmRpc) -> Option { + if self.inner.machine_profile != MachineProfile::Microvm { + return Some(message); + } + match message { + VmRpc::Save(rpc) => { + rpc.handle_failable_sync(|()| anyhow::bail!("save is unavailable for microVM")); + None + } + VmRpc::PulseSaveRestore(rpc) => { + rpc.complete(Err(PulseSaveRestoreError::UnsupportedMachineProfile)); + None + } + message => Some(message), + } + } + + /// Rejects worker RPCs that the microVM profile does not support. Rejected + /// RPCs are completed here; the others are returned for dispatch. + pub(super) fn filter_worker_rpc( + &self, + message: WorkerRpc, + ) -> Option> { + match message { + WorkerRpc::Restart(rpc) if self.inner.machine_profile == MachineProfile::Microvm => { + rpc.complete(Err(RemoteError::new(anyhow::anyhow!( + "worker restart is unavailable for microVM" + )))); + None + } + message => Some(message), + } + } +} + pub(super) fn level_triggered_irqs(machine_profile: MachineProfile) -> &'static [u32] { match machine_profile { MachineProfile::Microvm => &openvmm_defs::microvm::MICROVM_LEVEL_TRIGGERED_IRQS, diff --git a/openvmm/openvmm_defs/src/rpc.rs b/openvmm/openvmm_defs/src/rpc.rs index e6d8e702320..5b05698eabe 100644 --- a/openvmm/openvmm_defs/src/rpc.rs +++ b/openvmm/openvmm_defs/src/rpc.rs @@ -59,6 +59,8 @@ pub enum PulseSaveRestoreError { ResetNotSupported, #[error("pulse save+restore failed")] Other(#[source] RemoteError), + #[error("save and restore are unavailable for this machine profile")] + UnsupportedMachineProfile, } impl From for PulseSaveRestoreError { diff --git a/petri/src/vm/openvmm/runtime.rs b/petri/src/vm/openvmm/runtime.rs index a726002cadf..3820e3d6102 100644 --- a/petri/src/vm/openvmm/runtime.rs +++ b/petri/src/vm/openvmm/runtime.rs @@ -813,6 +813,10 @@ impl PetriVmInner { tracing::warn!("Reset not supported, could not test save + restore."); break; } + Err(RpcError::Call(PulseSaveRestoreError::UnsupportedMachineProfile)) => { + tracing::warn!("Save + restore is unavailable for this machine profile."); + break; + } Err(RpcError::Call(PulseSaveRestoreError::Other(err))) => { return Err(anyhow::Error::from(err)) .context(format!("Save + restore {i} failed.")); From 345a97dba2cf5c107515fa0ccd3198dcbb256820 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 04:40:11 -0700 Subject: [PATCH 096/304] openvmm_entry: expose `--machine microvm` The worker can boot the microVM profile, but nothing selects it. Add `--machine `, which defaults to `standard`. With `--machine microvm`, the entry point does the following: - Validates the options before it opens any host resource: - The guest is x86-64 with 1, 2, 4, or 8 processors and no custom NUMA, socket, SMT, APIC-offset, or x2APIC settings. - The guest boots Linux directly, without firmware options. - Hyper-V, VTL2, isolation, nested virtualization, GET, VMBus, and the debugger are disabled. - A named backend is KVM or MSHV on Linux, or WHP on Windows. - There are no UART, debugcon, storage, network, virtio, legacy, PCIe, IOMMU, or assigned devices. - Snapshot restore is rejected. - Builds the microVM base chipset and fixed layout, and adds the portb console and shutdown devices. Portb owns the interactive console (`hvc0` in the guest), so COM1 defaults to none. - Loads the kernel and the optional initrd in MP-table boot mode with the profile-owned command line: the base tokens, then the user arguments, then the host-owned `nr_cpus=`. Hyper-V enlightenments, VMGS, and SMBIOS overrides are disabled. - Fixes the topology to one socket with one core per vCPU, no SMT, and xAPIC with contiguous APIC IDs. - Auto-selects the backend with `choose_microvm_hypervisor`, which uses the usual probe order on Linux and Windows and fails on other hosts. Every configuration, for either profile, is then checked against the machine contract, including the named hypervisor. For now, a status-bearing power-off through port 0x604 follows `--guest-shutdown-action` like any power-off. A later change propagates the status to the process exit code. Document `--machine` in the CLI reference and the MP-table boot layout in the Linux direct boot reference. Tests cover option parsing, processor and topology validation, command-line ownership, and the rejection of unsupported combinations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../devices/firmware/linux_direct.md | 23 +- Guide/src/reference/openvmm/management/cli.md | 34 ++ openvmm/openvmm_entry/src/cli_args.rs | 6 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 313 ++++++++++++++++++ openvmm/openvmm_entry/src/lib.rs | 26 +- openvmm/openvmm_entry/src/microvm/config.rs | 229 +++++++++++++ openvmm/openvmm_entry/src/microvm/mod.rs | 8 + openvmm/openvmm_helpers/src/hypervisor.rs | 2 + .../openvmm_helpers/src/hypervisor/microvm.rs | 16 + 9 files changed, 649 insertions(+), 8 deletions(-) create mode 100644 openvmm/openvmm_entry/src/cli_args/microvm.rs create mode 100644 openvmm/openvmm_entry/src/microvm/config.rs create mode 100644 openvmm/openvmm_entry/src/microvm/mod.rs create mode 100644 openvmm/openvmm_helpers/src/hypervisor/microvm.rs diff --git a/Guide/src/reference/devices/firmware/linux_direct.md b/Guide/src/reference/devices/firmware/linux_direct.md index 5b6b084165d..d00133b2b4c 100644 --- a/Guide/src/reference/devices/firmware/linux_direct.md +++ b/Guide/src/reference/devices/firmware/linux_direct.md @@ -22,6 +22,14 @@ the protected-mode code directly into guest memory and relies on the kernel's built-in decompressor to run at boot time. All standard bzImage compression formats are supported since decompression is handled by the kernel itself. +The x86-64 microVM profile uses the Linux zero-page protocol with an +uncompressed ELF kernel, but selects an ACPI-free platform mode. OpenVMM places +an Intel MP 1.4 floating pointer at `0x0`, its configuration table at `0x400`, +the bootstrap GDT at `0x1000`, `boot_params` at `0x2000`, and the command line +in the 64-KiB region starting at `0x20000`. The MP table describes contiguous +APIC IDs, the ISA bus, the IOAPIC, and legacy IRQ routing. No RSDP, ACPI table, +SMBIOS anchor, or SMBIOS structure table is imported. + On AArch64, pass the uncompressed `Image` file (not `Image.gz`). ## x86_64 Boot Flow @@ -32,12 +40,12 @@ On x86_64, OpenVMM follows the standard Linux boot protocol: 2. An initrd (if provided) is placed after the kernel. 3. A **zero page** is constructed containing the memory map, command line pointer, and initrd location. -4. ACPI tables (MADT, FADT, DSDT, SRAT, etc.) are built by OpenVMM's ACPI +4. For the standard profile, ACPI tables (MADT, FADT, DSDT, SRAT, etc.) are built by OpenVMM's ACPI builder and placed in low memory just above the boot metadata. The RSDP is placed at the fixed `0xE0000` and the kernel discovers it through its legacy firmware scan of `[0xE0000, 0x100000)`; the RSDP's XSDT pointer references the tables below it. -5. **SMBIOS (DMI) tables** are synthesized. Because there is no firmware to +5. For the standard profile, **SMBIOS (DMI) tables** are synthesized. Because there is no firmware to build them, OpenVMM constructs a SMBIOS 3.0 (64-bit) entry point (`_SM3_`) plus a minimal structure table (Type 0 BIOS, Type 1 System, Type 127 end-of-table) itself. ("SMBIOS 3.0 (64-bit) Entry Point" is the spec's name @@ -56,6 +64,11 @@ On x86_64, OpenVMM follows the standard Linux boot protocol: The DSDT includes whatever x86 chipset devices are configured (serial ports, IOAPIC, PCI bus, VMBus, virtio-mmio, RTC, etc.). +For `--machine microvm`, steps 4 and 5 are replaced by the MP-table layout +described above. The zero page leaves `acpi_rsdp_addr` zero, reserves the ISA +hole in e820, and leaves the fixed 3-to-4-GiB MMIO aperture out of RAM. The loader enters the ELF kernel in long mode with +`RSI=0x2000`, `CR3=0x4000`, and paging enabled. + ## AArch64 Boot Flow On AArch64, OpenVMM supports two modes for presenting hardware descriptions to @@ -105,8 +118,8 @@ will result in an error. ## SMBIOS / DMI Identity -On both architectures the synthesized SMBIOS tables expose a default identity, -so `/sys/class/dmi/id/*` reads consistently regardless of boot path: +For standard-profile direct boot on both architectures, synthesized SMBIOS +tables expose a default identity: | sysfs file | Default value | |------------|---------------| @@ -120,6 +133,8 @@ The SMBIOS UUID defaults to the all-zero GUID, which Linux treats as not present and therefore does not expose as `product_uuid`. Pass `--smbios type=1,uuid=` (or `uuid=random`) to set it. +The microVM MP-table mode exposes no SMBIOS data and rejects SMBIOS overrides. + ## CLI Usage ```bash diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index d56a1b71e83..455f09d2c86 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -19,6 +19,40 @@ describes the source definitions. `MAJOR.MINOR.PATCH`. On Windows, the executable's `VERSIONINFO` uses the product version as `MAJOR.MINOR.PATCH.0`. * `--processors `: The number of processors. Defaults to 1. +* `--machine `: Select the guest-visible machine contract. The + default is `standard`. `microvm` selects the ACPI-free x86-64 Linux direct + microVM, which + runs on KVM, MSHV, or WHP with exactly 1, 2, 4, or 8 vCPUs. On + Linux, auto-detection prefers MSHV when `/dev/mshv` is available and falls + back to KVM: + + ```bash + openvmm --machine microvm --hypervisor kvm \ + --kernel vmlinux --initrd initramfs.cpio.gz + openvmm --machine microvm --hypervisor mshv \ + --kernel vmlinux --initrd initramfs.cpio.gz + openvmm --machine microvm --hypervisor whp \ + --kernel vmlinux --initrd initramfs.cpio.gz + openvmm --machine microvm --processors 8 --hypervisor whp \ + --kernel vmlinux --initrd initramfs.cpio.gz + ``` + + The kernel must be an uncompressed ELF64 image. The profile owns the base command line + (`earlycon=xe9 console=hvc0 reboot=t panic=-1`). It appends + `nr_cpus=` from the validated processor topology. It reserves a 1-GiB + MMIO gap from 3 to 4 GiB and exposes only PIC/IOAPIC, PIT, a CMOS RTC + anchored to UTC, + the microVM portb console, and the shutdown port. User arguments cannot + override `earlycon=`, `console=`, or `nr_cpus=`. Firmware, ACPI, SMBIOS, PCI, + VMBus, UARTs, storage, networking, virtio, graphics, isolation, nested + virtualization, and other devices are rejected. Linux discovers contiguous + APIC IDs and the IOAPIC from Intel + MP 1.4 tables at `0x0` and `0x400`; `boot_params` is at `0x2000`, the command + line starts at `0x20000`, and no ACPI or SMBIOS data is exposed. Host-driven + save/restore, pulse-save/restore, and worker restart remain unavailable. + + `microvm` uses one socket and one die, + with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. * `--memory `: Configure guest RAM. Defaults to `size=1G`. `SPEC` can be a size-only shorthand, such as `--memory 4G`, or a comma-separated key/value list: diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index 1e069854d4d..ddef5cac5c0 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -18,6 +18,8 @@ // anything else on this file though. #![warn(missing_docs)] +pub mod microvm; + use anyhow::Context; use clap::Parser; use clap::ValueEnum; @@ -149,6 +151,10 @@ pub struct NumaDistanceCli { long_version = openvmm_build_info::get().long_version(), )] pub struct Options { + /// guest machine profile + #[clap(long, value_enum, default_value = "standard")] + pub machine: microvm::MachineProfileCli, + /// processor count #[clap(short = 'p', long, value_name = "COUNT", default_value = "1")] pub processors: u32, diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs new file mode 100644 index 00000000000..43589a9d50a --- /dev/null +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -0,0 +1,313 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Command-line options of the microVM machine profile. + +use super::Options; +use super::SmtConfigCli; +use clap::ValueEnum; +use openvmm_defs::config::X2ApicConfig; +use openvmm_defs::microvm::MachineProfile; + +/// Guest-visible machine profile. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MachineProfileCli { + /// The standard OpenVMM machine. + Standard, + /// The microVM fixed-topology machine. + Microvm, +} + +impl From for MachineProfile { + fn from(value: MachineProfileCli) -> Self { + match value { + MachineProfileCli::Standard => Self::Standard, + MachineProfileCli::Microvm => Self::Microvm, + } + } +} + +impl Options { + pub(crate) fn validate_microvm_options(&self) -> anyhow::Result<()> { + if self.machine != MachineProfileCli::Microvm { + return Ok(()); + } + + anyhow::ensure!( + cfg!(guest_arch = "x86_64"), + "microVM requires an x86-64 guest" + ); + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(self.processors), + "microVM does not support {} vCPUs", + self.processors + ); + anyhow::ensure!( + self.numa.is_none() && self.numa_distance.is_none(), + "microVM does not support custom NUMA topology" + ); + anyhow::ensure!( + self.vps_per_socket.is_none() + && self.smt == SmtConfigCli::Auto + && self.apic_id_offset == 0 + && matches!(self.x2apic, X2ApicConfig::Auto), + "microVM owns CPU topology and APIC configuration" + ); + anyhow::ensure!( + self.restore_snapshot.is_none(), + "microVM does not support snapshot restore" + ); + anyhow::ensure!( + self.uefi.is_none() && !self.pcat && self.igvm.is_none() && !self.device_tree, + "microVM requires Linux direct boot" + ); + anyhow::ensure!( + self.deprecated_uefi_firmware.is_none() + && !self.deprecated_uefi_debug + && !self.deprecated_uefi_enable_memory_protections + && !self.deprecated_uefi_force_dma_bounce + && !self.deprecated_uefi_force_firmware_version + && !self.deprecated_disable_frontpage + && self.pcat_firmware.is_none() + && self.pcat_boot_order.is_none() + && self.vga_firmware.is_none() + && !self.secure_boot + && self.secure_boot_template.is_none() + && self.custom_uefi_json.is_none() + && self.deprecated_uefi_console_mode.is_none() + && self.deprecated_efi_diagnostics_log_level.is_none() + && !self.deprecated_default_boot_always_attempt, + "microVM does not support firmware options" + ); + anyhow::ensure!( + !self.hv + && !self.vtl2 + && self.isolation.is_none() + && !self.nested_virt + && !self.get + && !self.vmbus_redirect + && self.vmbus_vsock_path.is_none() + && self.vmbus_vtl2_vsock_path.is_none() + && self.openhcl_dump_path.is_none() + && self.gdb.is_none(), + "microVM does not support Hyper-V, VTL2, isolation, nested virtualization, GET, or VMBus" + ); + if let Some(hypervisor) = self.hypervisor.as_deref() { + let name = hypervisor.split(':').next().unwrap_or(hypervisor); + anyhow::ensure!( + (cfg!(target_os = "linux") && matches!(name, "kvm" | "mshv")) + || (cfg!(windows) && name == "whp"), + "microVM requires KVM or MSHV on Linux, or WHP on Windows" + ); + } + + anyhow::ensure!( + self.com1.is_none() + && self.com2.is_none() + && self.com3.is_none() + && self.com4.is_none() + && self.vmbus_com1_serial.is_none() + && self.vmbus_com2_serial.is_none() + && self.debugcon.is_none() + && !self.serial_tx_only, + "microVM exposes only the portb serial device" + ); + anyhow::ensure!( + self.disk.is_empty() + && self.nvme.is_empty() + && self.nvme_pci.is_empty() + && self.vmbus_scsi.is_empty() + && self.openhcl_controller.is_empty() + && self.ide.is_empty() + && self.floppy.is_empty() + && self.virtio_blk.is_empty(), + "microVM does not expose storage devices" + ); + anyhow::ensure!( + self.virtio_9p.is_empty() + && self.virtio_fs.is_empty() + && self.virtio_fs_shmem.is_empty() + && self.virtio_pmem.is_none() + && !self.virtio_rng + && self.virtio_vsock_path.is_none() + && self.virtio_net.is_empty() + && self.virtio_console.is_none() + && self.virtio_console_pcie_port.is_none(), + "microVM does not expose virtio devices" + ); + #[cfg(target_os = "linux")] + anyhow::ensure!( + self.vhost_user.is_empty(), + "microVM does not support vhost-user devices" + ); + #[cfg(target_os = "linux")] + anyhow::ensure!( + self.virtio_vsock_vhost_cid.is_none(), + "microVM does not support vhost-vsock" + ); + anyhow::ensure!( + !self.nic + && self.mana.is_empty() + && !self.gfx + && !self.vtl2_gfx + && !self.vnc.vnc + && self.tpm.is_none() + && !self.guest_watchdog + && self.imc.is_none() + && !self.battery + && self.vmgs.is_none(), + "microVM does not expose legacy NIC, MANA, graphics, TPM, watchdog, IMC, battery, or VMGS devices" + ); + anyhow::ensure!( + self.net.is_empty(), + "microVM does not expose a network device" + ); + anyhow::ensure!( + self.cxl_test.is_empty() + && self.pcie_root_complex.is_empty() + && self.pcie_root_port.is_empty() + && self.pcie_switch.is_empty() + && self.pcie_generic_initiator.is_empty() + && self.pcie_remote.is_empty() + && self.amd_iommu.is_empty() + && self.intel_vtd.is_empty(), + "microVM does not support PCIe or IOMMU devices" + ); + #[cfg(windows)] + anyhow::ensure!( + self.device.is_empty() && self.kernel_vmnic.is_empty(), + "microVM does not support assigned devices or kernel VM NICs" + ); + #[cfg(target_os = "linux")] + anyhow::ensure!( + self.vfio.is_empty() && self.iommu.is_empty(), + "microVM does not support VFIO or IOMMU devices" + ); + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Parser; + use openvmm_defs::microvm::MICROVM_BASE_COMMAND_LINE; + use openvmm_defs::microvm::MICROVM_COMMAND_LINE_MAX_SIZE; + use openvmm_defs::microvm::build_microvm_command_line; + use test_with_tracing::test; + + #[test] + fn test_machine_profile_option_parsed() { + let opt = Options::try_parse_from(["openvmm"]).unwrap(); + assert_eq!(opt.machine, MachineProfileCli::Standard); + assert_eq!(MachineProfile::from(opt.machine), MachineProfile::Standard); + + let opt = Options::try_parse_from(["openvmm", "--machine", "microvm"]).unwrap(); + assert_eq!(opt.machine, MachineProfileCli::Microvm); + assert_eq!(MachineProfile::from(opt.machine), MachineProfile::Microvm); + + assert!(Options::try_parse_from(["openvmm", "--machine", "microvm-v2"]).is_err()); + assert!(Options::try_parse_from(["openvmm", "--machine", "microvm-v3"]).is_err()); + assert!(Options::try_parse_from(["openvmm", "--machine", "nvx"]).is_err()); + assert!(Options::try_parse_from(["openvmm", "--machine", "unknown"]).is_err()); + } + + #[test] + fn test_microvm_processor_validation() { + for processors in [1, 2, 4, 8] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--processors", + &processors.to_string(), + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + } + + for processors in [0, 3, 5, 16] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--processors", + &processors.to_string(), + ]) + .unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + + for args in [ + vec!["openvmm", "--machine", "microvm", "--vps-per-socket", "1"], + vec!["openvmm", "--machine", "microvm", "--smt", "off"], + vec!["openvmm", "--machine", "microvm", "--apic-id-offset", "1"], + vec!["openvmm", "--machine", "microvm", "--x2apic", "on"], + vec!["openvmm", "--machine", "microvm", "--numa", "size=128M"], + ] { + let options = Options::try_parse_from(args).unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } + + #[test] + fn test_microvm_command_line_is_owned_and_bounded() { + assert_eq!( + build_microvm_command_line(&[]).unwrap(), + MICROVM_BASE_COMMAND_LINE + ); + assert_eq!( + build_microvm_command_line(&["foo=bar".into()]).unwrap(), + format!("{MICROVM_BASE_COMMAND_LINE} foo=bar") + ); + + for reserved in ["earlycon=uart", "console=ttyS0", "nr_cpus=1"] { + assert!(build_microvm_command_line(&[reserved.into()]).is_err()); + } + assert!(build_microvm_command_line(&["foo=bar\0baz".into()]).is_err()); + assert!(build_microvm_command_line(&["x".repeat(MICROVM_COMMAND_LINE_MAX_SIZE)]).is_err()); + } + + #[test] + fn test_microvm_preflight_rejects_unsupported_combinations() { + let valid = Options::try_parse_from(["openvmm", "--machine", "microvm"]).unwrap(); + valid.validate_microvm_options().unwrap(); + if cfg!(target_os = "linux") { + let valid_mshv = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--hypervisor", + "mshv", + ]) + .unwrap(); + valid_mshv.validate_microvm_options().unwrap(); + } + + for args in [ + vec!["openvmm", "--machine", "microvm", "--uefi"], + vec!["openvmm", "--machine", "microvm", "--hypervisor", "unknown"], + vec!["openvmm", "--machine", "microvm", "--virtio-rng"], + vec!["openvmm", "--machine", "microvm", "--com1", "none"], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "stderr", + ], + vec!["openvmm", "--machine", "microvm", "--net", "consomme"], + vec![ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + ], + ] { + let options = Options::try_parse_from(args).unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } +} diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index bed17aa54ae..7c9e52aaac9 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -11,6 +11,7 @@ mod cli_args; mod crash_dump; mod kvp; mod meshworker; +mod microvm; mod pidfile; mod repl; mod serial_io; @@ -40,6 +41,7 @@ use cli_args::TpmVersionCli; use cli_args::UefiConsoleModeCli; use cli_args::VirtioBusCli; use cli_args::VmgsCli; +use cli_args::microvm::MachineProfileCli; use crash_dump::spawn_dump_handler; use cxl_spec::test::CxlTestDeviceHandle; use disk_backend_resources::DelayDiskHandle; @@ -232,7 +234,9 @@ fn build_switch_list(all_switches: &[cli_args::GenericPcieSwitchCli]) -> Vec BaseChipsetType { - if opt.igvm.is_some() { + if opt.machine == MachineProfileCli::Microvm { + BaseChipsetType::Microvm + } else if opt.igvm.is_some() { match opt.igvm_personality { None => BaseChipsetType::HclHost, Some(IgvmPersonalityCli::Uefi) => BaseChipsetType::HypervGen2Uefi, @@ -322,6 +326,7 @@ async fn vm_config_from_command_line( ) -> anyhow::Result<(Config, VmResources)> { opt.validate_isolation_options()?; opt.validate_igvm_options()?; + let mut microvm = microvm::MicrovmConfigBuilder::new(opt)?; let (_, serial_driver) = DefaultPool::spawn_on_thread("serial"); let uefi = opt.effective_uefi()?; @@ -420,11 +425,13 @@ async fn vm_config_from_command_line( opt.com4.as_ref().is_some_and(|c| c.debugger_mode), ]; + microvm.setup_portb(&console_state, &serial_driver)?; + let serial0_cfg = setup_serial( "com1", opt.com1 .clone() - .map_or(SerialConfigCli::Console, |c| c.backend), + .map_or(microvm.default_com1_backend(), |c| c.backend), if cfg!(guest_arch = "x86_64") { "ttyS0" } else { @@ -1334,7 +1341,12 @@ async fn vm_config_from_command_line( TpmVersionCli::V185 => TpmVersion::V185, }); - if opt.restore_snapshot.is_some() { + microvm.add_chipset_devices(&mut chipset_devices)?; + + if let Some(microvm_load_mode) = microvm.load_mode(arch)? { + load_mode = microvm_load_mode; + with_hv = false; + } else if opt.restore_snapshot.is_some() { // Snapshot restore: skip firmware loading entirely. Device state and // memory come from the snapshot directory. load_mode = LoadMode::None; @@ -1518,6 +1530,7 @@ async fn vm_config_from_command_line( }; } + microvm.validate_vmgs()?; let mut vmgs = Some(if let Some(VmgsCli { kind, provision }) = &opt.vmgs { let disk = VmgsDisk { disk: disk_open(kind, false) @@ -1537,6 +1550,7 @@ async fn vm_config_from_command_line( } else { VmgsResource::Ephemeral }); + microvm.filter_vmgs(&mut vmgs); if with_get && with_hv { let has_vtl0_nvme = storage.has_vtl0_nvme(); @@ -2073,7 +2087,7 @@ async fn vm_config_from_command_line( pcie_devices.extend(vfio_pcie_devices); let mut cfg = Config { - machine_profile: Default::default(), + machine_profile: opt.machine.into(), microvm: Default::default(), chipset, load_mode, @@ -2223,6 +2237,7 @@ async fn vm_config_from_command_line( device.port_name ); } + microvm.finish(&mut cfg)?; resources.serial_driver = Some(serial_driver); validate_snp_config(&cfg)?; Ok((cfg, resources)) @@ -2919,6 +2934,9 @@ async fn run_control_inner( let params = VmWorkerParameters { hypervisor: match &opt.hypervisor { Some(name) => openvmm_helpers::hypervisor::hypervisor_resource(name)?, + None if opt.machine == MachineProfileCli::Microvm => { + openvmm_helpers::hypervisor::microvm::choose_microvm_hypervisor()? + } None => openvmm_helpers::hypervisor::choose_hypervisor()?, }, cfg: vm_config, diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs new file mode 100644 index 00000000000..dd4c95084d0 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM parts of the VM configuration built from the command line. + +use crate::ConsoleState; +use crate::Options; +use crate::cli_args::SerialConfigCli; +use crate::cli_args::microvm::MachineProfileCli; +use crate::serial_io; +use anyhow::Context; +use anyhow::bail; +use chipset_resources::microvm::MicrovmPortbHandle; +use chipset_resources::microvm::MicrovmShutdownHandle; +use futures::AsyncReadExt; +use futures::executor::block_on; +use futures::io::AllowStdIo; +use openvmm_defs::config::Config; +use openvmm_defs::config::LoadMode; +use openvmm_defs::microvm::build_microvm_command_line; +use pal_async::DefaultDriver; +use std::cell::RefCell; +use std::thread; +use vm_manifest_builder::MachineArch; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::SerialBackendHandle; +use vmgs_resources::VmgsResource; +use vmotherboard::ChipsetDeviceHandle; + +/// MicroVM state computed while building a VM configuration from the command +/// line. All methods are no-ops for the standard machine profile. +pub(crate) struct MicrovmConfigBuilder<'a> { + opt: &'a Options, + active: bool, + portb: Option>, +} + +impl<'a> MicrovmConfigBuilder<'a> { + /// Validates the microVM options. + pub(crate) fn new(opt: &'a Options) -> anyhow::Result { + let active = opt.machine == MachineProfileCli::Microvm; + opt.validate_microvm_options()?; + + if active + && (opt.com1.is_some() + || opt.com2.is_some() + || opt.com3.is_some() + || opt.com4.is_some() + || opt.vmbus_com1_serial.is_some() + || opt.vmbus_com2_serial.is_some() + || opt.debugcon.is_some()) + { + bail!("microVM does not expose UART, debugcon, or VMBus serial"); + } + + Ok(Self { + opt, + active, + portb: None, + }) + } + + /// Returns the backend used for `--com1` when it is not specified. + pub(crate) fn default_com1_backend(&self) -> SerialConfigCli { + if self.active { + SerialConfigCli::None + } else { + SerialConfigCli::Console + } + } + + /// Connects the host side of the portb console (`hvc0`) to the host + /// console. + /// + /// This must run before the other serial devices so that portb owns the + /// interactive console. + pub(crate) fn setup_portb( + &mut self, + console_state: &RefCell>>, + serial_driver: &DefaultDriver, + ) -> anyhow::Result<()> { + if !self.active { + return Ok(()); + } + self.portb = Some(setup_host_console( + "microvm-portb", + "hvc0", + console_state, + serial_driver, + )?); + Ok(()) + } + + /// Adds the portb and shutdown chipset devices. + pub(crate) fn add_chipset_devices( + &mut self, + chipset_devices: &mut Vec, + ) -> anyhow::Result<()> { + let Some(io) = self.portb.take() else { + return Ok(()); + }; + chipset_devices.push(ChipsetDeviceHandle { + name: MicrovmPortbHandle::ID.to_owned(), + resource: MicrovmPortbHandle { io }.into_resource(), + }); + chipset_devices.push(ChipsetDeviceHandle { + name: MicrovmShutdownHandle::ID.to_owned(), + resource: MicrovmShutdownHandle.into_resource(), + }); + Ok(()) + } + + /// Returns the MP-table Linux load mode of the microVM profile. + pub(crate) fn load_mode(&self, arch: MachineArch) -> anyhow::Result> { + if !self.active { + return Ok(None); + } + let opt = self.opt; + if arch != MachineArch::X86_64 { + bail!("the microVM profile requires an x86-64 guest"); + } + if opt.igvm.is_some() || opt.pcat || opt.uefi.is_some() { + bail!("the microVM profile requires Linux direct boot"); + } + + let kernel = fs_err::File::open( + (opt.kernel.0) + .as_ref() + .context("must provide a Linux kernel when using --machine microvm")?, + ) + .context("failed to open Linux kernel")?; + let initrd = (opt.initrd.0) + .as_ref() + .map(fs_err::File::open) + .transpose() + .context("failed to open Linux initrd")?; + + Ok(Some(LoadMode::Linux { + kernel: kernel.into(), + initrd: initrd.map(Into::into), + cmdline: build_effective_microvm_command_line(&opt.cmdline, opt.processors)?, + enable_serial: false, + isolation: openvmm_defs::config::LinuxIsolationConfig::None, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + smbios: Box::default(), + })) + } + + /// Rejects VMGS before the standard VMGS resource is opened. + pub(crate) fn validate_vmgs(&self) -> anyhow::Result<()> { + if self.active && self.opt.vmgs.is_some() { + bail!("microVM does not support VMGS"); + } + Ok(()) + } + + /// Drops the default VMGS resource, which the microVM does not expose. + pub(crate) fn filter_vmgs(&self, vmgs: &mut Option) { + if self.active { + *vmgs = None; + } + } + + /// Completes the microVM configuration after the storage devices have been + /// added, and validates the machine contract of every profile. + pub(crate) fn finish(self, cfg: &mut Config) -> anyhow::Result<()> { + let opt = self.opt; + if self.active { + cfg.processor_topology.vps_per_socket = Some(opt.processors); + cfg.processor_topology.enable_smt = Some(false); + #[cfg(guest_arch = "x86_64")] + { + cfg.processor_topology.arch = Some(openvmm_defs::config::ArchTopologyConfig::X86( + openvmm_defs::config::X86TopologyConfig { + apic_id_offset: 0, + x2apic: openvmm_defs::config::X2ApicConfig::Unsupported, + }, + )); + } + } + + let requested_hypervisor = opt + .hypervisor + .as_deref() + .and_then(|spec| spec.split(':').next()); + openvmm_defs::microvm::validate_machine_config(cfg, requested_hypervisor)?; + Ok(()) + } +} + +/// Connects the portb endpoint to the host console, relaying its output to +/// stdout, and returns its backend. +fn setup_host_console( + name: &str, + device: &'static str, + console_state: &RefCell>>, + serial_driver: &DefaultDriver, +) -> anyhow::Result> { + if let Some(console_state) = console_state.borrow().as_ref() { + bail!("console already set by {}", console_state.device); + } + let (config, serial) = serial_io::anonymous_serial_pair(serial_driver)?; + let (serial_read, serial_write) = AsyncReadExt::split(serial); + *console_state.borrow_mut() = Some(ConsoleState { + device, + input: Box::new(serial_write), + }); + thread::Builder::new() + .name(name.to_owned()) + .spawn(move || { + let _ = block_on(futures::io::copy( + serial_read, + &mut AllowStdIo::new(term::raw_stdout()), + )); + }) + .context("failed to spawn the portb output relay")?; + Ok(config) +} + +fn build_effective_microvm_command_line( + user_args: &[String], + processor_count: u32, +) -> anyhow::Result { + let mut cmdline = build_microvm_command_line(user_args)?; + openvmm_defs::microvm::append_microvm_processor_limit(&mut cmdline, processor_count)?; + Ok(cmdline) +} diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs new file mode 100644 index 00000000000..3cd46b29a76 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM machine profile support for the OpenVMM entry point. + +mod config; + +pub(crate) use config::MicrovmConfigBuilder; diff --git a/openvmm/openvmm_helpers/src/hypervisor.rs b/openvmm/openvmm_helpers/src/hypervisor.rs index 31d909eca43..0cb66f5a314 100644 --- a/openvmm/openvmm_helpers/src/hypervisor.rs +++ b/openvmm/openvmm_helpers/src/hypervisor.rs @@ -7,6 +7,8 @@ use hypervisor_resources::HypervisorKind; use vm_resource::Resource; +pub mod microvm; + /// Returns a [`Resource`] for the first available hypervisor /// backend. /// diff --git a/openvmm/openvmm_helpers/src/hypervisor/microvm.rs b/openvmm/openvmm_helpers/src/hypervisor/microvm.rs new file mode 100644 index 00000000000..f4105325df9 --- /dev/null +++ b/openvmm/openvmm_helpers/src/hypervisor/microvm.rs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Hypervisor selection for the microVM machine profile. + +use super::choose_hypervisor; +use hypervisor_resources::HypervisorKind; +use vm_resource::Resource; + +/// Returns the native backend supported by the microVM profile. +pub fn choose_microvm_hypervisor() -> anyhow::Result> { + #[cfg(any(target_os = "linux", windows))] + return choose_hypervisor(); + #[cfg(not(any(target_os = "linux", windows)))] + anyhow::bail!("the microVM profile requires a Linux KVM/MSHV or Windows WHP host"); +} From f68f2c6abf999f254651a682fa9c87c8d45c2a18 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 04:47:03 -0700 Subject: [PATCH 097/304] petri, vmm_tests: add a microVM builder, portb harness, and cold-boot test Nothing exercises a microVM end to end yet. Teach Petri to build one and add a cold-boot lifecycle test. `PetriVmBuilder::with_microvm_machine(n)` selects the microVM profile with a deterministic topology: n vCPUs in one socket, no SMT, and xAPIC. The builder also sets: - Minimal mode, with no VMBus and no Hyper-V enlightenments. - No storage controllers, NVMe or virtio-blk drives, or agent images. - Linux direct boot, with pipette not running as init. `PetriVmConfig` carries the machine profile, and the Hyper-V backend rejects the microVM profile. For a microVM, the OpenVMM backend does the following: - Builds the microVM chipset. - Switches the Linux load mode to MP-table boot with the profile-owned command line: the base tokens, the test's init arguments, and `nr_cpus=`. - Drops VMGS, the firmware event channel, VMBus, and Hyper-V enlightenments. - Attaches the portb and shutdown devices, and validates the configuration against the machine contract. - Chooses the backend with `choose_microvm_hypervisor`. Portb replaces the emulated serial ports. Its output is logged and also forwarded raw to the test through the new `log_task_with_output`, and the test can write raw input. New test APIs: - `wait_for_microvm_portb_output`, `wait_for_microvm_portb_bytes`, and `write_microvm_portb_input` for the portb console. - `pulse_save_restore`, which performs one pulse save/restore. - `with_linux_command_line`, which edits the effective Linux command line. - `prepare_initrd_with_file`, which injects one file into the Linux direct initrd. `prepare_initrd` now uses it. - `linux_direct_boot_files`, which returns the kernel and initrd paths. A builder-supplied initrd now replaces the artifact initrd for any Linux direct VM, not only with pipette as init, and Linux direct firmware may omit the initrd. `x86_64::microvm::phase_1_lifecycle` boots a 1-vCPU microVM whose init script drives portb through `/dev/port`. The test: 1. Waits for the boot marker. 2. Checks that host save and pulse save/restore are rejected. 3. Round-trips a ping and a binary-transparent echo (NUL, CR, LF, DEL, and 0xff) through portb. 4. Requests a status-37 shutdown through port 0x604, and checks that the resulting power-off halt carries the status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- petri/src/tracing.rs | 24 ++ petri/src/vm/hyperv/powershell.rs | 3 + petri/src/vm/microvm.rs | 51 ++++ petri/src/vm/mod.rs | 59 ++++- petri/src/vm/openvmm/construct.rs | 90 +++++-- petri/src/vm/openvmm/microvm.rs | 248 ++++++++++++++++++ petri/src/vm/openvmm/mod.rs | 2 + petri/src/vm/openvmm/modify.rs | 9 + petri/src/vm/openvmm/runtime.rs | 16 ++ petri/src/worker.rs | 7 +- vmm_tests/vmm_tests/tests/tests/x86_64.rs | 1 + .../vmm_tests/tests/tests/x86_64/microvm.rs | 151 +++++++++++ 12 files changed, 631 insertions(+), 30 deletions(-) create mode 100644 petri/src/vm/microvm.rs create mode 100644 petri/src/vm/openvmm/microvm.rs create mode 100644 vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs diff --git a/petri/src/tracing.rs b/petri/src/tracing.rs index 474d87aeb04..800f377f276 100644 --- a/petri/src/tracing.rs +++ b/petri/src/tracing.rs @@ -373,8 +373,28 @@ pub async fn log_task( log_file: PetriLogFile, reader: impl AsyncRead + Unpin + Send + 'static, name: &str, +) -> anyhow::Result<()> { + log_task_inner(log_file, reader, name, None).await +} + +/// Logs lines from `reader` and forwards their raw bytes to `output`. +pub async fn log_task_with_output( + log_file: PetriLogFile, + reader: impl AsyncRead + Unpin + Send + 'static, + name: &str, + output: mesh::Sender>, +) -> anyhow::Result<()> { + log_task_inner(log_file, reader, name, Some(output)).await +} + +async fn log_task_inner( + log_file: PetriLogFile, + reader: impl AsyncRead + Unpin + Send + 'static, + name: &str, + output: Option>>, ) -> anyhow::Result<()> { const MAX_LINE_LENGTH: usize = 4096; + tracing::info!("connected to {name}"); let mut buf = Vec::with_capacity(MAX_LINE_LENGTH); let mut reader = BufReader::new(reader); @@ -396,6 +416,10 @@ pub async fn log_task( _ => {} } + if let Some(output) = &output { + output.send(buf.clone()); + } + let string_buf = String::from_utf8_lossy(&buf); let string_buf_trimmed = string_buf.trim_end(); diff --git a/petri/src/vm/hyperv/powershell.rs b/petri/src/vm/hyperv/powershell.rs index 418c1ee3c76..2c450cab8a0 100644 --- a/petri/src/vm/hyperv/powershell.rs +++ b/petri/src/vm/hyperv/powershell.rs @@ -492,6 +492,7 @@ impl HyperVNewCustomVMArgs { let PetriVmConfig { name, arch, + machine_profile, firmware, memory, proc_topology, @@ -501,6 +502,8 @@ impl HyperVNewCustomVMArgs { .. } = config; + crate::vm::microvm::ensure_hyperv_compatible(*machine_profile)?; + if firmware .openhcl_config() .is_some_and(|c| c.vtl2_base_address_type.is_some()) diff --git a/petri/src/vm/microvm.rs b/petri/src/vm/microvm.rs new file mode 100644 index 00000000000..143245025dd --- /dev/null +++ b/petri/src/vm/microvm.rs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Backend-independent microVM builder support. + +use super::ApicMode; +use super::BootDeviceType; +use super::PetriVmBuilder; +use super::PetriVmmBackend; +use openvmm_defs::microvm::MachineProfile; + +impl PetriVmBuilder { + /// Select the microVM machine profile with deterministic SMP topology. + pub fn with_microvm_machine(mut self, processor_count: u32) -> Self { + assert!( + openvmm_defs::microvm::microvm_processor_count_supported(processor_count), + "microVM supports only 1, 2, 4, or 8 vCPUs" + ); + self.config.machine_profile = MachineProfile::Microvm; + self.config.proc_topology.vp_count = processor_count; + self.config.proc_topology.vps_per_socket = Some(processor_count); + self.config.proc_topology.enable_smt = Some(false); + self.config.proc_topology.apic_mode = Some(ApicMode::Xapic); + self.minimal_mode = true; + self.enable_serial = true; + self.use_virtio_vsock = false; + self.no_vmbus = true; + self.no_hv = true; + self.config.vmbus_storage_controllers.clear(); + self.config.pcie_nvme_drives.clear(); + self.config.pcie_virtio_blk_drives.clear(); + self.config.physical_nvme_devices.clear(); + self.agent_image = None; + self.openhcl_agent_image = None; + self.boot_device_type = BootDeviceType::None; + self + } +} + +pub(super) fn uses_pipette_as_init(machine_profile: MachineProfile) -> bool { + machine_profile == MachineProfile::Standard +} + +#[cfg(windows)] +pub(super) fn ensure_hyperv_compatible(machine_profile: MachineProfile) -> anyhow::Result<()> { + anyhow::ensure!( + machine_profile == MachineProfile::Standard, + "the microVM profile is only supported by OpenVMM" + ); + Ok(()) +} diff --git a/petri/src/vm/mod.rs b/petri/src/vm/mod.rs index 8ef378bb691..312e40350e3 100644 --- a/petri/src/vm/mod.rs +++ b/petri/src/vm/mod.rs @@ -4,6 +4,7 @@ /// Hyper-V VM management #[cfg(windows)] pub mod hyperv; +mod microvm; /// OpenVMM VM management pub mod openvmm; /// QEMU full machine emulation management @@ -28,6 +29,7 @@ use get_resources::ged::FirmwareEvent; use guid::Guid; use mesh::CancelContext; use openvmm_defs::config::Vtl2BaseAddressType; +use openvmm_defs::microvm::MachineProfile; use pal_async::DefaultDriver; use pal_async::task::Spawn; use pal_async::task::Task; @@ -237,6 +239,8 @@ pub struct PetriVmConfig { pub name: String, /// The architecture of the VM pub arch: MachineArch, + /// The guest-visible machine contract. + pub machine_profile: MachineProfile, /// Log levels for the host VMM process. pub host_log_levels: Option, /// Firmware and/or OS to load into the VM and associated settings @@ -605,6 +609,7 @@ impl PetriVmBuilder { config: PetriVmConfig { name: make_vm_safe_name(test_name), arch: artifacts.arch, + machine_profile: MachineProfile::Standard, host_log_levels: None, firmware: artifacts.firmware, hibernation_enabled: false, @@ -664,7 +669,7 @@ impl PetriVmBuilder { self.minimal_mode } - /// Supply a pre-built initrd with pipette already injected. + /// Supply a pre-built Linux-direct initrd. /// /// When set, the builder skips the runtime gzip decompress/inject/ /// recompress cycle, using this initrd directly. Use @@ -746,6 +751,35 @@ impl PetriVmBuilder { }) } + /// Build a copy of the Linux-direct initrd with one file injected as the + /// guest's `rdinit=` program. + pub fn prepare_initrd_with_file( + &self, + path: &str, + data: &[u8], + mode: u32, + ) -> anyhow::Result { + let initrd_path = self + .config + .firmware + .linux_direct_initrd() + .context("initrd injection requires Linux direct boot with initrd")?; + let initrd_gz = std::fs::read(initrd_path) + .with_context(|| format!("failed to read initrd at {}", initrd_path.display()))?; + let merged_gz = initrd_cpio::inject_into_initrd(&initrd_gz, path, data, mode) + .with_context(|| format!("failed to inject {path} into initrd"))?; + + let mut tmp = tempfile::NamedTempFile::new() + .context("failed to create temp file for modified initrd")?; + tmp.write_all(&merged_gz) + .context("failed to write modified initrd")?; + + Ok(PetriInitrd { + path: TempOrPersistentPath::Temp(Arc::new(tmp.into_temp_path())), + rdinit_param: format!("/{path}"), + }) + } + /// Enable serial port output even in minimal mode. /// /// Useful for diagnostics — the serial device overhead is negligible; @@ -1154,6 +1188,16 @@ impl PetriVmBuilder { } } + /// Returns the kernel and initrd paths for a direct-boot Linux VM. + pub fn linux_direct_boot_files(&self) -> Option<(&Path, &Path)> { + match &self.config.firmware { + Firmware::LinuxDirect { kernel, initrd } => { + Some((kernel.get(), initrd.as_ref()?.get())) + } + _ => None, + } + } + /// Whether pipette will run as PID 1 init in the initrd. /// /// True for non-OpenHCL Linux direct boot when a pipette binary is @@ -1162,6 +1206,7 @@ impl PetriVmBuilder { fn uses_pipette_as_init(&self) -> bool { self.config.firmware.is_linux_direct() && !self.config.firmware.is_openhcl() + && microvm::uses_pipette_as_init(self.config.machine_profile) && self.pipette_binary.is_some() } @@ -2782,8 +2827,8 @@ pub enum Firmware { LinuxDirect { /// The kernel to boot. kernel: ResolvedArtifact, - /// The initrd to use. - initrd: ResolvedArtifact, + /// The optional initrd to use. + initrd: Option, }, /// Boot Linux directly, without any firmware, with OpenHCL in VTL2. OpenhclLinuxDirect { @@ -2926,11 +2971,11 @@ impl Firmware { match arch { MachineArch::X86_64 => Firmware::LinuxDirect { kernel: resolver.require(LINUX_DIRECT_TEST_KERNEL_X64).erase(), - initrd: resolver.require(LINUX_DIRECT_TEST_INITRD_X64).erase(), + initrd: Some(resolver.require(LINUX_DIRECT_TEST_INITRD_X64).erase()), }, MachineArch::Aarch64 => Firmware::LinuxDirect { kernel: resolver.require(LINUX_DIRECT_TEST_KERNEL_AARCH64).erase(), - initrd: resolver.require(LINUX_DIRECT_TEST_INITRD_AARCH64).erase(), + initrd: Some(resolver.require(LINUX_DIRECT_TEST_INITRD_AARCH64).erase()), }, } } @@ -2943,7 +2988,7 @@ impl Firmware { use petri_artifacts_vmm_test::artifacts::loadable::*; Firmware::LinuxDirect { kernel: resolver.require(LINUX_DIRECT_TEST_BZIMAGE_X64).erase(), - initrd: resolver.require(LINUX_DIRECT_TEST_INITRD_X64).erase(), + initrd: Some(resolver.require(LINUX_DIRECT_TEST_INITRD_X64).erase()), } } @@ -3056,7 +3101,7 @@ impl Firmware { /// Get the initrd path for Linux direct boot firmware. pub fn linux_direct_initrd(&self) -> Option<&Path> { match self { - Firmware::LinuxDirect { initrd, .. } => Some(initrd.get()), + Firmware::LinuxDirect { initrd, .. } => initrd.as_ref().map(|initrd| initrd.get()), _ => None, } } diff --git a/petri/src/vm/openvmm/construct.rs b/petri/src/vm/openvmm/construct.rs index e86c6d461ac..6a232257c6c 100644 --- a/petri/src/vm/openvmm/construct.rs +++ b/petri/src/vm/openvmm/construct.rs @@ -117,6 +117,7 @@ impl PetriVmConfigOpenVmm { let PetriVmConfig { name: _, arch, + machine_profile, host_log_levels, firmware, hibernation_enabled, @@ -131,6 +132,8 @@ impl PetriVmConfigOpenVmm { physical_nvme_devices, } = petri_vm_config; + super::microvm::validate_profile(machine_profile, arch, &firmware)?; + if !physical_nvme_devices.is_empty() { anyhow::bail!("Physical NVMe devices are only supported with the Hyper-V backend"); } @@ -165,10 +168,14 @@ impl PetriVmConfigOpenVmm { use_virtio_vsock: properties.use_virtio_vsock, no_vmbus: properties.no_vmbus, no_hv: properties.no_hv, + machine_profile, }; let mut chipset = VmManifestBuilder::new( match firmware { + _ if super::microvm::is_enabled(machine_profile) => { + vm_manifest_builder::BaseChipsetType::Microvm + } Firmware::LinuxDirect { .. } => { vm_manifest_builder::BaseChipsetType::HyperVGen2LinuxDirect } @@ -188,14 +195,9 @@ impl PetriVmConfigOpenVmm { let mut load_mode = setup.load_firmware()?; - // If using pipette-as-init, replace the initrd with the pre-built - // one that has pipette injected. run_core() guarantees that - // prebuilt_initrd is set when uses_pipette_as_init is true. - if properties.uses_pipette_as_init { + // Replace the initrd when the builder supplied a modified copy. + if let Some(prebuilt) = prebuilt_initrd.as_ref() { if let LoadMode::Linux { initrd, .. } = &mut load_mode { - let prebuilt = prebuilt_initrd - .as_ref() - .expect("uses_pipette_as_init requires prebuilt_initrd"); let file = std::fs::File::open(prebuilt.path.as_ref()).with_context(|| { format!( "failed to open prebuilt initrd at {}", @@ -206,6 +208,13 @@ impl PetriVmConfigOpenVmm { } } + super::microvm::configure_load_mode( + machine_profile, + &mut load_mode, + proc_topology.vp_count, + )?; + + let mut microvm_serial = None; let (emulated_serial_config, log_stream_tasks, linux_direct_serial_agent) = if !properties.enable_serial { // No emulated serial backends (OpenHCL VMBus serial stubs may still exist) @@ -215,7 +224,9 @@ impl PetriVmConfigOpenVmm { emulated_serial_config, serial_tasks, linux_direct_serial_agent, + microvm, } = setup.configure_serial(log_source)?; + microvm_serial = microvm; ( emulated_serial_config, serial_tasks, @@ -356,7 +367,7 @@ impl PetriVmConfigOpenVmm { // Configure the serial ports now that they have been updated by the // OpenHCL configuration. - if properties.enable_serial { + if properties.enable_serial && super::microvm::configures_standard_serial(machine_profile) { chipset = chipset.with_serial(emulated_serial_config); // Set so that we don't pull serial data until the guest is // ready. Otherwise, Linux will drop the input serial data @@ -593,7 +604,7 @@ impl PetriVmConfigOpenVmm { } }; - let vmgs = if firmware.is_openhcl() { + let vmgs = if firmware.is_openhcl() || !super::microvm::has_vmgs(machine_profile) { None } else { Some(memdiff_vmgs(&vmgs).await?) @@ -607,6 +618,12 @@ impl PetriVmConfigOpenVmm { capabilities, } = chipset; + let microvm = super::microvm::attach_chipset_devices( + machine_profile, + &mut chipset_devices, + microvm_serial, + ); + // Add the TPM if let Some(tpm) = setup.config_tpm().await? { chipset_devices.push(tpm); @@ -651,10 +668,13 @@ impl PetriVmConfigOpenVmm { let config = Config { // Firmware - machine_profile: Default::default(), + machine_profile, microvm: Default::default(), load_mode, - firmware_event_send: Some(firmware_event_send), + firmware_event_send: super::microvm::firmware_event_send( + machine_profile, + firmware_event_send, + ), // CPU and RAM numa, @@ -670,7 +690,7 @@ impl PetriVmConfigOpenVmm { // Basic virtualization device support hypervisor: HypervisorConfig { - with_hv: !properties.no_hv, + with_hv: super::microvm::hypervisor_enabled(machine_profile, !properties.no_hv), with_vtl2, with_isolation: match firmware.isolation() { Some(IsolationType::Vbs) => Some(openvmm_defs::config::IsolationType::Vbs), @@ -679,7 +699,7 @@ impl PetriVmConfigOpenVmm { }, nested_virt: false, }, - vmbus: if properties.no_vmbus { + vmbus: if properties.no_vmbus || super::microvm::vmbus_disabled(machine_profile) { None } else { Some(VmbusConfig { @@ -725,6 +745,8 @@ impl PetriVmConfigOpenVmm { rtc_delta_milliseconds: 0, }; + super::microvm::validate_config(&config)?; + // Make the pipette connection listener. let path = format!("{vsock_path_string}_{PIPETTE_PORT}"); let pipette_listener = PolledSocket::new( @@ -761,6 +783,7 @@ impl PetriVmConfigOpenVmm { pipette_listener, vtl2_pipette_listener, linux_direct_serial_agent, + microvm, tcp_pipette_port: None, driver: driver.clone(), output_dir: log_source.output_dir().to_owned(), @@ -802,12 +825,14 @@ struct PetriVmConfigSetupCore<'a> { use_virtio_vsock: bool, no_vmbus: bool, no_hv: bool, + machine_profile: openvmm_defs::microvm::MachineProfile, } -struct SerialData { - emulated_serial_config: [Option>; 4], - serial_tasks: Vec>>, - linux_direct_serial_agent: Option, +pub(super) struct SerialData { + pub(super) emulated_serial_config: [Option>; 4], + pub(super) serial_tasks: Vec>>, + pub(super) linux_direct_serial_agent: Option, + pub(super) microvm: Option, } enum VideoDevice { @@ -828,6 +853,15 @@ impl PetriVmConfigSetupCore<'_> { let (serial0_host, serial0) = self .create_serial_stream() .context("failed to create serial0 stream")?; + if super::microvm::is_enabled(self.machine_profile) { + return super::microvm::configure_serial( + self.driver, + serial0_log_file, + serial0_host, + serial0, + ); + } + let (serial0_read, serial0_write) = serial0_host.split(); let serial0_task = self.driver.spawn( "serial0-console", @@ -860,12 +894,14 @@ impl PetriVmConfigSetupCore<'_> { emulated_serial_config: [serial0, serial1, serial2, None], serial_tasks, linux_direct_serial_agent: Some(linux_direct_serial_agent), + microvm: None, }) } else { Ok(SerialData { emulated_serial_config: [serial0, None, serial2, None], serial_tasks, linux_direct_serial_agent: None, + microvm: None, }) } } @@ -906,9 +942,14 @@ impl PetriVmConfigSetupCore<'_> { let kernel = File::open(kernel.clone()) .context("Failed to open kernel")? .into(); - let initrd = File::open(initrd.clone()) - .context("Failed to open initrd")? - .into(); + let initrd = initrd + .as_ref() + .map(|initrd| { + File::open(initrd.clone()) + .context("Failed to open initrd") + .map(Into::into) + }) + .transpose()?; let init = self .pipette_rdinit_param @@ -920,12 +961,17 @@ impl PetriVmConfigSetupCore<'_> { } else { String::new() }; - let cmdline = format!("{serial_args}panic=-1 rdinit={init} {vsock_blacklist}"); + let cmdline = super::microvm::linux_command_line( + self.machine_profile, + cmdline, + init, + vsock_blacklist, + ); LoadMode::Linux { kernel, - initrd: Some(initrd), + initrd, cmdline, enable_serial: self.enable_serial, isolation: openvmm_defs::config::LinuxIsolationConfig::None, diff --git a/petri/src/vm/openvmm/microvm.rs b/petri/src/vm/openvmm/microvm.rs new file mode 100644 index 00000000000..3ed325a1017 --- /dev/null +++ b/petri/src/vm/openvmm/microvm.rs @@ -0,0 +1,248 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! OpenVMM-specific microVM construction and runtime support. + +use super::construct::SerialData; +use super::runtime::PetriVmInner; +use crate::Firmware; +use crate::PetriLogFile; +use anyhow::Context; +use chipset_resources::microvm::MicrovmPortbHandle; +use chipset_resources::microvm::MicrovmShutdownHandle; +use futures::AsyncWriteExt; +use openvmm_defs::config::Config; +use openvmm_defs::config::LinuxDirectBootMode; +use openvmm_defs::config::LinuxIsolationConfig; +use openvmm_defs::config::LoadMode; +use openvmm_defs::microvm::MachineProfile; +use pal_async::DefaultDriver; +use pal_async::socket::PolledSocket; +use pal_async::socket::WriteHalf; +use pal_async::task::Spawn; +use petri_artifacts_common::tags::MachineArch; +use serial_core::resources::DisconnectedSerialBackendHandle; +use unix_socket::UnixStream; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::SerialBackendHandle; +use vmotherboard::ChipsetDeviceHandle; + +pub(super) struct RuntimeResources { + input: WriteHalf, + output: mesh::Receiver>, +} + +pub(super) struct PortbSerial { + io: Resource, + runtime: RuntimeResources, +} + +pub(super) fn is_enabled(machine_profile: MachineProfile) -> bool { + machine_profile == MachineProfile::Microvm +} + +pub(super) fn validate_profile( + machine_profile: MachineProfile, + arch: MachineArch, + firmware: &Firmware, +) -> anyhow::Result<()> { + if is_enabled(machine_profile) { + anyhow::ensure!( + matches!(arch, MachineArch::X86_64), + "microVM requires an x86-64 guest" + ); + anyhow::ensure!( + matches!(firmware, Firmware::LinuxDirect { .. }), + "microVM requires an uncompressed Linux direct artifact" + ); + } + Ok(()) +} + +pub(super) fn linux_command_line( + machine_profile: MachineProfile, + standard: String, + init: &str, + vsock_blacklist: &str, +) -> String { + if is_enabled(machine_profile) { + format!("rdinit={init} {vsock_blacklist}") + } else { + standard + } +} + +pub(super) fn configure_load_mode( + machine_profile: MachineProfile, + load_mode: &mut LoadMode, + processor_count: u32, +) -> anyhow::Result<()> { + if !is_enabled(machine_profile) { + return Ok(()); + } + + let LoadMode::Linux { + cmdline, + enable_serial, + isolation, + boot_mode, + smbios, + .. + } = load_mode + else { + unreachable!("microVM firmware was validated as LinuxDirect"); + }; + let mut configured = + openvmm_defs::microvm::build_microvm_command_line(&[std::mem::take(cmdline)])?; + openvmm_defs::microvm::append_microvm_processor_limit(&mut configured, processor_count)?; + *cmdline = configured; + *enable_serial = false; + *isolation = LinuxIsolationConfig::None; + *boot_mode = LinuxDirectBootMode::MpTable; + **smbios = Default::default(); + Ok(()) +} + +pub(super) fn configure_serial( + driver: &DefaultDriver, + log_file: PetriLogFile, + host: PolledSocket, + io: Option>, +) -> anyhow::Result { + let (read, input) = host.split(); + let (output_send, output) = mesh::channel(); + let task = driver.spawn( + "microvm-portb-console", + crate::log_task_with_output(log_file, read, "microvm-portb-console", output_send), + ); + Ok(SerialData { + emulated_serial_config: [None, None, None, None], + serial_tasks: vec![task], + linux_direct_serial_agent: None, + microvm: Some(PortbSerial { + io: io.unwrap_or_else(|| DisconnectedSerialBackendHandle.into_resource()), + runtime: RuntimeResources { input, output }, + }), + }) +} + +pub(super) fn attach_chipset_devices( + machine_profile: MachineProfile, + chipset_devices: &mut Vec, + serial: Option, +) -> Option { + if !is_enabled(machine_profile) { + assert!(serial.is_none()); + return None; + } + + let (io, runtime) = match serial { + Some(PortbSerial { io, runtime }) => (io, Some(runtime)), + None => (DisconnectedSerialBackendHandle.into_resource(), None), + }; + chipset_devices.extend([ + ChipsetDeviceHandle { + name: MicrovmPortbHandle::ID.to_owned(), + resource: MicrovmPortbHandle { io }.into_resource(), + }, + ChipsetDeviceHandle { + name: MicrovmShutdownHandle::ID.to_owned(), + resource: MicrovmShutdownHandle.into_resource(), + }, + ]); + runtime +} + +pub(super) fn configures_standard_serial(machine_profile: MachineProfile) -> bool { + !is_enabled(machine_profile) +} + +pub(super) fn has_vmgs(machine_profile: MachineProfile) -> bool { + !is_enabled(machine_profile) +} + +pub(super) fn firmware_event_send(machine_profile: MachineProfile, sender: T) -> Option { + (!is_enabled(machine_profile)).then_some(sender) +} + +pub(super) fn hypervisor_enabled(machine_profile: MachineProfile, default: bool) -> bool { + default && !is_enabled(machine_profile) +} + +pub(super) fn vmbus_disabled(machine_profile: MachineProfile) -> bool { + is_enabled(machine_profile) +} + +pub(super) fn validate_config(config: &Config) -> anyhow::Result<()> { + openvmm_defs::microvm::validate_machine_config(config, None) +} + +pub(crate) fn choose_hypervisor( + machine_profile: MachineProfile, + standard: impl FnOnce() -> anyhow::Result, + microvm: impl FnOnce() -> anyhow::Result, +) -> anyhow::Result { + if is_enabled(machine_profile) { + microvm() + } else { + standard() + } +} + +impl PetriVmInner { + pub(super) async fn wait_for_microvm_portb_output( + &mut self, + marker: &str, + ) -> anyhow::Result<()> { + self.wait_for_microvm_portb_bytes(marker.as_bytes()).await + } + + pub(super) async fn wait_for_microvm_portb_bytes( + &mut self, + marker: &[u8], + ) -> anyhow::Result<()> { + anyhow::ensure!(!marker.is_empty(), "microVM portb marker cannot be empty"); + let output = &mut self + .resources + .microvm + .as_mut() + .context("microVM portb output is not configured")? + .output; + let mut buffered = Vec::new(); + loop { + let chunk = output.recv().await.map_err(|error| { + anyhow::anyhow!( + "microVM portb output disconnected ({error}) after bytes {:?}", + String::from_utf8_lossy(&buffered[..buffered.len().min(256)]) + ) + })?; + buffered.extend_from_slice(&chunk); + if buffered + .windows(marker.len()) + .any(|window| window == marker) + { + return Ok(()); + } + } + } + + pub(super) async fn write_microvm_portb_input(&mut self, input: &[u8]) -> anyhow::Result<()> { + self.resources + .microvm + .as_mut() + .context("microVM portb input is not configured")? + .input + .write_all(input) + .await + .context("writing microVM portb input") + } + + pub(super) async fn pulse_save_restore(&self) -> anyhow::Result<()> { + self.worker + .pulse_save_restore() + .await + .map_err(anyhow::Error::from) + } +} diff --git a/petri/src/vm/openvmm/mod.rs b/petri/src/vm/openvmm/mod.rs index 8125153c5d8..5efe4e4bf4c 100644 --- a/petri/src/vm/openvmm/mod.rs +++ b/petri/src/vm/openvmm/mod.rs @@ -11,6 +11,7 @@ mod construct; #[cfg(target_os = "linux")] mod hugetlb; +pub(crate) mod microvm; mod modify; mod runtime; mod start; @@ -207,6 +208,7 @@ struct PetriVmResourcesOpenVmm { pipette_listener: PolledSocket, vtl2_pipette_listener: Option>, linux_direct_serial_agent: Option, + microvm: Option, /// When set, the host connects to pipette via TCP through consomme /// port forwarding instead of accepting on the Unix socket listener. diff --git a/petri/src/vm/openvmm/modify.rs b/petri/src/vm/openvmm/modify.rs index 117d288a70b..b5635b0a79c 100644 --- a/petri/src/vm/openvmm/modify.rs +++ b/petri/src/vm/openvmm/modify.rs @@ -36,6 +36,15 @@ use vm_resource::IntoResource; use vmotherboard::ChipsetDeviceHandle; impl PetriVmConfigOpenVmm { + /// Modify the effective Linux direct kernel command line. + pub fn with_linux_command_line(mut self, f: impl FnOnce(&mut String)) -> Self { + let LoadMode::Linux { cmdline, .. } = &mut self.config.load_mode else { + panic!("Linux command-line configuration requires Linux direct boot.") + }; + f(cmdline); + self + } + /// Enable the VTL0 alias map. // TODO: Remove once #912 is fixed. pub fn with_vtl0_alias_map(mut self) -> Self { diff --git a/petri/src/vm/openvmm/runtime.rs b/petri/src/vm/openvmm/runtime.rs index 3820e3d6102..29782c39ab0 100644 --- a/petri/src/vm/openvmm/runtime.rs +++ b/petri/src/vm/openvmm/runtime.rs @@ -370,6 +370,22 @@ impl PetriVmOpenVmm { pub async fn verify_save_restore(&mut self) -> anyhow::Result<()> ); petri_vm_fn!(pub(crate) async fn launch_linux_direct_pipette(&mut self) -> anyhow::Result<()>); + petri_vm_fn!( + /// Wait for a microVM portb console marker. + pub async fn wait_for_microvm_portb_output(&mut self, marker: &str) -> anyhow::Result<()> + ); + petri_vm_fn!( + /// Wait for an exact byte sequence from the microVM portb console. + pub async fn wait_for_microvm_portb_bytes(&mut self, marker: &[u8]) -> anyhow::Result<()> + ); + petri_vm_fn!( + /// Write raw bytes to the microVM portb console input stream. + pub async fn write_microvm_portb_input(&mut self, input: &[u8]) -> anyhow::Result<()> + ); + petri_vm_fn!( + /// Perform one pulse save/restore operation. + pub async fn pulse_save_restore(&mut self) -> anyhow::Result<()> + ); /// Wrap the provided future in a race with the worker process's halt /// notification channel. This is useful for preventing a future from diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 26f3fc67203..2e02290b6a9 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -29,8 +29,13 @@ impl Worker { let (vm_rpc, rpc_recv) = mesh::channel(); let (notify_send, notify_recv) = mesh::channel(); + let hypervisor = crate::openvmm::microvm::choose_hypervisor( + cfg.machine_profile, + openvmm_helpers::hypervisor::choose_hypervisor, + openvmm_helpers::hypervisor::microvm::choose_microvm_hypervisor, + )?; let params = VmWorkerParameters { - hypervisor: openvmm_helpers::hypervisor::choose_hypervisor()?, + hypervisor, cfg, saved_state: None, shared_memory, diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64.rs b/vmm_tests/vmm_tests/tests/tests/x86_64.rs index 59fd65e17fb..36533e9a37c 100644 --- a/vmm_tests/vmm_tests/tests/tests/x86_64.rs +++ b/vmm_tests/vmm_tests/tests/tests/x86_64.rs @@ -4,6 +4,7 @@ //! Integration tests for x86_64 guests. mod ipmi; +mod microvm; mod openhcl_linux_direct; mod openhcl_uefi; mod storage; diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs b/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs new file mode 100644 index 00000000000..56593984d5b --- /dev/null +++ b/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM lifecycle integration tests. + +use anyhow::Context; +use mesh::CancelContext; +use petri::PetriHaltReason; +use petri::PetriVmBuilder; +use petri::openvmm::OpenVmmPetriBackend; +use std::time::Duration; +use vmm_test_macros::openvmm_test_no_agent; + +fn portb_output(bytes: &[u8]) -> anyhow::Result { + use std::fmt::Write as _; + + let mut output = String::new(); + for byte in bytes { + writeln!( + output, + "printf '\\x{byte:02x}' | dd of=/dev/port bs=1 seek=233 count=1 conv=notrunc 2>/dev/null" + )?; + } + Ok(output) +} + +#[openvmm_test_no_agent(linux_direct_x64)] +async fn phase_1_lifecycle(config: PetriVmBuilder) -> anyhow::Result<()> { + const TIMEOUT: Duration = Duration::from_secs(30); + const BOOT_MARKER: &[u8] = b"OPENVMM-LINUX-DIRECT-TEST-READY\n"; + const RAW_MARKER: &[u8] = b"\0\r\n\x7f\xffLINUX-DIRECT-ECHO\n"; + const COMMAND_PING: u8 = 1; + const COMMAND_ECHO: u8 = 2; + const COMMAND_SHUTDOWN: u8 = 6; + + let script = format!( + "#!/bin/busybox sh\n\ + mount -t devtmpfs devtmpfs /dev 2>/dev/null || true\n\ + read_port() {{\n\ + dd if=/dev/port bs=1 skip=\"$1\" count=1 2>/dev/null | od -An -tu1 | tr -d ' '\n\ + }}\n\ + next_byte() {{\n\ + while :; do\n\ + status=$(read_port 234)\n\ + [ -n \"$status\" ] || status=0\n\ + if [ $((status & 1)) -ne 0 ]; then\n\ + read_port 233\n\ + return\n\ + fi\n\ + done\n\ + }}\n\ + {}\ + while :; do\n\ + command=$(next_byte)\n\ + case \"$command\" in\n\ + {COMMAND_PING})\n\ + {}\ + ;;\n\ + {COMMAND_ECHO})\n\ + {}\ + ;;\n\ + {COMMAND_SHUTDOWN})\n\ + status=$(next_byte)\n\ + octal=$(printf '%03o' \"$status\")\n\ + printf \"\\\\$octal\" | dd of=/dev/port bs=1 seek=1540 count=1 conv=notrunc 2>/dev/null\n\ + while :; do sleep 1; done\n\ + ;;\n\ + *)\n\ + {}\ + ;;\n\ + esac\n\ + done\n", + portb_output(BOOT_MARKER)?, + portb_output(b"PONG\n")?, + portb_output(RAW_MARKER)?, + portb_output(b"UNKNOWN-COMMAND\n")?, + ); + let config = config.with_microvm_machine(1); + let initrd = config.prepare_initrd_with_file("microvm-test", script.as_bytes(), 0o100755)?; + let mut vm = config + .with_prebuilt_initrd(initrd) + .modify_backend(|config| { + config.with_linux_command_line(|cmdline| { + assert!(cmdline.contains("rdinit=/microvm-test")); + }) + }) + .run_without_agent() + .await?; + + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.backend().wait_for_microvm_portb_bytes(BOOT_MARKER)) + .await + .context("timed out waiting for microVM Linux direct boot marker")??; + + let save_error = vm + .backend() + .save_state() + .await + .expect_err("microVM host save unexpectedly succeeded"); + assert!( + format!("{save_error:#}").contains("save is unavailable for microVM"), + "unexpected microVM host-save error: {save_error:#}" + ); + let pulse_error = vm + .backend() + .pulse_save_restore() + .await + .expect_err("microVM pulse save/restore unexpectedly succeeded"); + assert!( + format!("{pulse_error:#}") + .contains("save and restore are unavailable for this machine profile"), + "unexpected microVM pulse-save error: {pulse_error:#}" + ); + + vm.backend() + .write_microvm_portb_input(&[COMMAND_PING]) + .await?; + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.backend().wait_for_microvm_portb_output("PONG")) + .await + .context("microVM Linux direct guest did not answer ping")??; + + vm.backend() + .write_microvm_portb_input(&[COMMAND_ECHO]) + .await?; + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.backend().wait_for_microvm_portb_bytes(RAW_MARKER)) + .await + .context("microVM raw binary echo was not observed")??; + + vm.backend() + .write_microvm_portb_input(&[COMMAND_SHUTDOWN, 37]) + .await?; + + let halt = CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.wait_for_halt()) + .await + .context("timed out waiting for microVM status shutdown")??; + assert_eq!(halt.reason, PetriHaltReason::PowerOff); + assert!( + halt.detail.contains("code: 37"), + "expected microVM shutdown status 37, got {}", + halt.detail + ); + + vm.teardown().await +} From 354815ec8332a3afdb68ec844fb3dbec02ee0b75 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 04:56:56 -0700 Subject: [PATCH 098/304] virtio: allow feature masking for virtio-MMIO devices A machine profile with a fixed guest ABI must control which virtio features a device offers. The microVM profile, for example, supports only split rings. The MMIO transport currently offers every feature the device reports. Add `VirtioDeviceFeatures::without_bits`, which clears the features selected by a 64-bit mask, and `VirtioMmioDevice::new_with_disabled_features`, which masks the guest-visible device features before the guest reads them. `VirtioMmioDevice::new` delegates to it with an empty mask, so existing devices are unchanged. Saved-state validation already requires restored driver features to be a subset of the transport's device features, so a saved state that negotiated a masked feature fails to restore. A test checks that masking the packed-ring bit leaves the other features in place. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../virtio/virtio/src/transport/mmio.rs | 27 ++++++++++++++++++- vm/devices/virtio/virtio_spec/src/lib.rs | 22 +++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/vm/devices/virtio/virtio/src/transport/mmio.rs b/vm/devices/virtio/virtio/src/transport/mmio.rs index 18fb58a3a9b..c9cea9905fa 100644 --- a/vm/devices/virtio/virtio/src/transport/mmio.rs +++ b/vm/devices/virtio/virtio/src/transport/mmio.rs @@ -114,6 +114,29 @@ impl VirtioMmioDevice { doorbell_registration: Option>, mmio_gpa: u64, mmio_len: u64, + ) -> std::io::Result { + Self::new_with_disabled_features( + device, + driver, + guest_memory, + interrupt, + doorbell_registration, + mmio_gpa, + mmio_len, + 0, + ) + } + + /// Creates an MMIO transport after masking guest-visible device features. + pub fn new_with_disabled_features( + device: Box, + driver: &impl Spawn, + guest_memory: GuestMemory, + interrupt: LineInterrupt, + doorbell_registration: Option>, + mmio_gpa: u64, + mmio_len: u64, + disabled_features: u64, ) -> std::io::Result { let traits = device.traits(); let interrupt_state = Arc::new(Mutex::new(InterruptState { @@ -121,7 +144,9 @@ impl VirtioMmioDevice { status: 0, })); - let core = VirtioTransportCore::new(device, driver, guest_memory, doorbell_registration)?; + let mut core = + VirtioTransportCore::new(device, driver, guest_memory, doorbell_registration)?; + core.device_feature = core.device_feature.without_bits(disabled_features); Ok(Self { core, diff --git a/vm/devices/virtio/virtio_spec/src/lib.rs b/vm/devices/virtio/virtio_spec/src/lib.rs index f0797524944..e0fc0dfb044 100644 --- a/vm/devices/virtio/virtio_spec/src/lib.rs +++ b/vm/devices/virtio/virtio_spec/src/lib.rs @@ -85,6 +85,28 @@ impl VirtioDeviceFeatures { self.set_bank(index, val); self } + + /// Clears every feature selected by `disabled`. + pub fn without_bits(self, disabled: u64) -> Self { + Self::from_bits(self.into_bits() & !disabled) + } +} + +#[cfg(test)] +mod feature_tests { + use super::*; + + #[test] + fn disabled_feature_mask_removes_packed_ring_only() { + let features = VirtioDeviceFeatures::new() + .with_version_1(true) + .with_access_platform(true) + .with_ring_packed(true) + .without_bits(1 << 34); + assert!(features.version_1()); + assert!(features.access_platform()); + assert!(!features.ring_packed()); + } } #[bitfield(u8)] From b298c23dcff9c7f7aeeacb9ecd6ddcc94ded4db7 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 05:07:16 -0700 Subject: [PATCH 099/304] openvmm: place virtio devices at fixed microVM MMIO slots The standard machine assigns virtio-mmio windows from a dynamic region of the memory layout in device order, so a device's address and IRQ depend on which other devices are present. A microVM guest instead discovers its devices from a fixed map that does not change with the device set, which keeps the guest ABI stable. Define the microVM virtio-mmio map in `openvmm_defs::microvm`: eight 0x1000-byte windows from 0xd0000000 in stable device order (network, filesystem, boot console, four sandbox blocks, control console), their IRQs, and a check that the windows are ordered, disjoint, and inside the 3-to-4-GiB aperture. IRQ 8 stays with the RTC, and no slot IRQ is level triggered. Wire the first slot, the boot virtio-console: - `--virtio-console` is now accepted with `--machine microvm` using `listen=PATH`, `listen=tcp:IP:PORT`, `console`, or `none`. A PCIe port is rejected. The console appears in the guest as `hvc1` at 0xd0002000, IRQ 7, and the profile's base command line switches to `console=hvc1`. - `append_microvm_virtio_discovery` appends a host-owned `virtio_mmio.device=` token for each present slot, in slot order. `build_microvm_command_line` now rejects user `virtio_mmio.device=` tokens. The machine contract requires exactly one discovery token per device, in fixed-address order at the end of the command line. - The machine contract admits at most eight virtio-mmio devices, and at most one of them may be a virtio-console. - The worker places microVM virtio-mmio devices at their fixed slots through `VirtioMmioSlots` instead of the layout's dynamic region. The layout reserves no dynamic windows for a microVM. The fixed slots expose split rings only: the packed-ring feature is masked. - When the console uses the terminal, portb output moves to stderr. A listener retains guest output until a client connects, and `none` discards it. Tests cover the console command line, discovery tokens, reserved tokens, the console option matrix, and the IRQ assignments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 24 ++- openvmm/openvmm_core/src/worker/dispatch.rs | 12 ++ .../src/worker/dispatch/microvm.rs | 86 +++++++++ openvmm/openvmm_defs/src/microvm.rs | 172 ++++++++++++++++-- openvmm/openvmm_entry/src/cli_args/microvm.rs | 80 ++++++-- openvmm/openvmm_entry/src/lib.rs | 16 +- openvmm/openvmm_entry/src/microvm/config.rs | 160 +++++++++++++--- petri/src/vm/openvmm/microvm.rs | 2 +- 8 files changed, 491 insertions(+), 61 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 455f09d2c86..5c2ffbc84a2 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -38,13 +38,19 @@ describes the source definitions. ``` The kernel must be an uncompressed ELF64 image. The profile owns the base command line - (`earlycon=xe9 console=hvc0 reboot=t panic=-1`). It appends + (`earlycon=xe9 console=hvc0 reboot=t panic=-1`) and switches the primary + console to `hvc1` when `--virtio-console` is present. It appends `nr_cpus=` from the validated processor topology. It reserves a 1-GiB MMIO gap from 3 to 4 GiB and exposes only PIC/IOAPIC, PIT, a CMOS RTC anchored to UTC, - the microVM portb console, and the shutdown port. User arguments cannot - override `earlycon=`, `console=`, or `nr_cpus=`. Firmware, ACPI, SMBIOS, PCI, - VMBus, UARTs, storage, networking, virtio, graphics, isolation, nested + the microVM portb console, the shutdown port, and the optional fixed virtio + devices described below. User arguments cannot override `earlycon=`, + `console=`, `virtio_mmio.device=`, or `nr_cpus=`. + + One optional `--virtio-console ` is exposed at MMIO `0xd0002000`, + IRQ 7 as the boot/log console (`hvc1`) and uses split rings. Firmware, ACPI, + SMBIOS, PCI, + VMBus, UARTs, storage, networking, graphics, isolation, nested virtualization, and other devices are rejected. Linux discovers contiguous APIC IDs and the IOAPIC from Intel MP 1.4 tables at `0x0` and `0x400`; `boot_params` is at `0x2000`, the command @@ -339,8 +345,14 @@ Serial devices can be configured to appear as different devices inside the guest dropped bytes with its own retransmission. Debugger mode is chosen independently per COM port, so one port can talk to WinDbg while another behaves normally. -* `--virtio-console `: Expose a virtio console device (appears as - `/dev/hvc0` inside the guest). +* `--virtio-console `: Expose a virtio console device. It normally + appears as `/dev/hvc0`. Under `--machine microvm`, it occupies fixed MMIO + `0xd0002000`, IRQ 7, and is selected as `/dev/hvc1`; the raw portb path + remains available as `hvc0` for early output and recovery. A microVM accepts + `listen=PATH`, `listen=tcp:IP:PORT`, `console`, or `none`. A listener + retains guest output until a client connects. `console` moves portb output + to stderr while the terminal is attached to `hvc1`, and `none` discards + guest output. The `BACKEND` argument is the same for all serial devices: diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 51e5f142efa..c20917a7513 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1299,6 +1299,7 @@ impl InitializedVm { .iter() .filter(|(bus, _)| matches!(bus, VirtioBus::Mmio)) .count(); + let virtio_mmio_count = microvm::virtio_mmio_count(cfg.machine_profile, virtio_mmio_count); // On aarch64 Linux direct boot, start RAM at 1 GiB to avoid the low GPA // region (128 MiB–129 MiB) that iommufd reserves for the host MSI @@ -3073,6 +3074,7 @@ impl InitializedVm { // allocation indexed by the order of VirtioBus::Mmio devices. let mut pci_device_number = 10; let mut virtio_mmio_index = 0; + let mut microvm_virtio_slots = microvm::VirtioMmioSlots::new(chipset_mmio); // Avoid an ISA interrupt to avoid conflicts and to avoid needing to // configure the line as level-triggered in the MADT (necessary for @@ -3097,6 +3099,16 @@ impl InitializedVm { ) .await?; match bus { + VirtioBus::Mmio if cfg.machine_profile == MachineProfile::Microvm => { + microvm_virtio_slots.add_device( + &chipset_builder, + &driver_source, + &gm, + &partition, + &id, + device, + )?; + } VirtioBus::Mmio => { let mmio_start = virtio_mmio_region.start() + virtio_mmio_index as u64 * 0x1000; virtio_mmio_index += 1; diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 79ce186e027..b6343a5211a 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -9,14 +9,22 @@ use super::LoadedVmInner; use super::Manifest; use super::RestartState; use crate::partition::HvlitePartition; +use crate::worker::memory_layout::ChipsetMmioRanges; use anyhow::Context; +use chipset_device_resources::IRQ_LINE_SET; +use guestmem::GuestMemory; +use hvdef::Vtl; use mesh::error::RemoteError; use mesh_worker::WorkerRpc; use openvmm_defs::microvm::MachineProfile; use openvmm_defs::rpc::PulseSaveRestoreError; use openvmm_defs::rpc::VmRpc; use openvmm_defs::worker::VmWorkerParameters; +use std::sync::Arc; +use virtio::VirtioMmioDevice; +use virtio::resolve::ResolvedVirtioDevice; use vm_loader::InitialLoad; +use vmcore::vm_task::VmTaskDriverSource; /// MicroVM inputs taken from the [`VmWorkerParameters`]. pub(super) struct MicrovmParameters {} @@ -121,6 +129,84 @@ impl LoadedVm { } } +fn virtio_mmio_config( + id: &str, + chipset_mmio: ChipsetMmioRanges, +) -> anyhow::Result<(u64, u64, u32, u64)> { + let (start, irq) = match id { + "virtio-console" => ( + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ, + ), + _ => anyhow::bail!("unsupported microVM virtio device '{id}' reached worker construction"), + }; + let len = openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN; + anyhow::ensure!( + start >= chipset_mmio.low.start() + && start + .checked_add(len) + .is_some_and(|end| end <= chipset_mmio.low.end()), + "microVM virtio slot for '{id}' is outside the fixed low-MMIO aperture" + ); + // The fixed slots expose split rings only. + let disabled_features = 1 << 34; + Ok((start, len, irq, disabled_features)) +} + +/// Assigns the fixed virtio-mmio slots of the microVM profile to devices, in +/// device order. +pub(super) struct VirtioMmioSlots { + chipset_mmio: ChipsetMmioRanges, +} + +impl VirtioMmioSlots { + pub(super) fn new(chipset_mmio: ChipsetMmioRanges) -> Self { + Self { chipset_mmio } + } + + /// Adds a virtio-mmio device at its fixed microVM slot. + pub(super) fn add_device( + &mut self, + chipset_builder: &vmotherboard::ChipsetBuilder<'_>, + driver_source: &VmTaskDriverSource, + gm: &GuestMemory, + partition: &Arc, + id: &str, + device: ResolvedVirtioDevice, + ) -> anyhow::Result<()> { + let (mmio_start, mmio_len, irq, disabled_features) = + virtio_mmio_config(id, self.chipset_mmio)?; + let id = format!("{id}-{mmio_start}"); + let gm = gm.clone(); + chipset_builder.arc_mutex_device(id).try_add(|services| { + VirtioMmioDevice::new_with_disabled_features( + device.0, + &driver_source.simple(), + gm, + services.new_line(IRQ_LINE_SET, "interrupt", irq), + partition.clone().into_doorbell_registration(Vtl::Vtl0), + mmio_start, + mmio_len, + disabled_features, + ) + })?; + Ok(()) + } +} + +/// Returns the number of virtio-mmio slots that the memory layout allocates. +/// MicroVM devices use fixed slots in the low MMIO aperture instead. +pub(super) fn virtio_mmio_count( + machine_profile: MachineProfile, + virtio_mmio_count: usize, +) -> usize { + if machine_profile == MachineProfile::Microvm { + 0 + } else { + virtio_mmio_count + } +} + pub(super) fn level_triggered_irqs(machine_profile: MachineProfile) -> &'static [u32] { match machine_profile { MachineProfile::Microvm => &openvmm_defs::microvm::MICROVM_LEVEL_TRIGGERED_IRQS, diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 01046da22cd..815c179cf97 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -11,6 +11,7 @@ use crate::config::LoadMode; use crate::config::SmbiosBiosOverrides; use crate::config::SmbiosConfig; use crate::config::SmbiosSystemOverrides; +use crate::config::VirtioBus; use crate::config::X2ApicConfig; use crate::config::X86TopologyConfig; use guid::Guid; @@ -25,11 +26,99 @@ pub const fn microvm_processor_count_supported(processor_count: u32) -> bool { /// Command line owned by the microVM profile. pub const MICROVM_BASE_COMMAND_LINE: &str = "earlycon=xe9 console=hvc0 reboot=t panic=-1"; +/// Command line when the microVM virtio console is present. +pub const MICROVM_CONSOLE_COMMAND_LINE: &str = "earlycon=xe9 console=hvc1 reboot=t panic=-1"; /// Maximum microVM command-line size, including its trailing NUL. pub const MICROVM_COMMAND_LINE_MAX_SIZE: usize = 64 * 1024; +/// Fixed distro virtio-blk MMIO base. +pub const MICROVM_VIRTIO_BLK_MMIO_BASE: u64 = 0xd000_3000; +/// Reserved microVM virtio-net MMIO base. +pub const MICROVM_VIRTIO_NET_MMIO_BASE: u64 = 0xd000_0000; +/// Reserved microVM virtio-fs MMIO base. +pub const MICROVM_VIRTIO_FS_MMIO_BASE: u64 = 0xd000_1000; +/// Reserved microVM virtio-console MMIO base. +pub const MICROVM_VIRTIO_CONSOLE_MMIO_BASE: u64 = 0xd000_2000; +/// Fixed microVM control virtio-console MMIO base. +pub const MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE: u64 = 0xd000_7000; +/// Fixed microVM virtio transport window length. +pub const MICROVM_VIRTIO_MMIO_LEN: u64 = 0x1000; +/// Fixed distro virtio-blk interrupt. +pub const MICROVM_VIRTIO_BLK_IRQ: u32 = 4; +/// Fixed virtio-blk interrupt for the runtime lower layer. +/// +/// IRQ 8 is exclusively owned by the microVM RTC. +pub const MICROVM_VIRTIO_RUNTIME_BLK_IRQ: u32 = 12; +/// Fixed virtio-blk interrupt for the custom lower layer. +pub const MICROVM_VIRTIO_CUSTOM_BLK_IRQ: u32 = 9; +/// Fixed virtio-blk interrupt for the writable scratch layer. +pub const MICROVM_VIRTIO_SCRATCH_BLK_IRQ: u32 = 11; +/// Fixed microVM virtio-console interrupt. +pub const MICROVM_VIRTIO_CONSOLE_IRQ: u32 = 7; +/// Fixed microVM control virtio-console interrupt. +pub const MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ: u32 = 3; +/// Fixed microVM virtio-fs interrupt. +pub const MICROVM_VIRTIO_FS_IRQ: u32 = 6; +/// Fixed microVM virtio-net interrupt on KVM. +pub const MICROVM_VIRTIO_NET_KVM_IRQ: u32 = 10; +/// Fixed microVM virtio-net interrupt on WHP. +pub const MICROVM_VIRTIO_NET_WHP_IRQ: u32 = 5; +/// MicroVM virtio MMIO reservations in stable device order. +pub const MICROVM_VIRTIO_MMIO_BASES: [u64; 8] = [ + MICROVM_VIRTIO_NET_MMIO_BASE, + MICROVM_VIRTIO_FS_MMIO_BASE, + MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + MICROVM_VIRTIO_BLK_MMIO_BASE, + 0xd000_4000, + 0xd000_5000, + 0xd000_6000, + MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, +]; /// The microVM publishes no level-triggered virtio IRQs in its MP table. pub const MICROVM_LEVEL_TRIGGERED_IRQS: [u32; 0] = []; +fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { + let bases = &MICROVM_VIRTIO_MMIO_BASES; + for (index, base) in bases.iter().copied().enumerate() { + let end = base + .checked_add(MICROVM_VIRTIO_MMIO_LEN) + .ok_or_else(|| anyhow::anyhow!("microVM virtio MMIO reservation overflows"))?; + anyhow::ensure!( + base >= 0xc000_0000 && end <= 0x1_0000_0000, + "microVM virtio MMIO reservation {index} is outside the fixed aperture" + ); + if let Some(next) = bases.get(index + 1) { + anyhow::ensure!(end <= *next, "microVM virtio MMIO reservations overlap"); + } + } + Ok(()) +} + +/// Appends sandbox virtio devices in fixed-address order. +pub fn append_microvm_virtio_discovery( + cmdline: &mut String, + has_console: bool, +) -> anyhow::Result<()> { + anyhow::ensure!( + !cmdline + .split_ascii_whitespace() + .any(|token| token.starts_with("virtio_mmio.device=")), + "microVM command line already contains virtio-mmio discovery" + ); + + use std::fmt::Write as _; + if has_console { + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONSOLE_IRQ}" + )?; + } + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM kernel command line exceeds the 64-KiB ABI limit after device discovery" + ); + Ok(()) +} + fn validate_microvm_command_line(config: &Config) -> anyhow::Result<()> { let MachineProfile::Microvm = config.machine_profile else { unreachable!("microVM command-line validation requires the microVM profile"); @@ -52,34 +141,66 @@ fn validate_microvm_command_line(config: &Config) -> anyhow::Result<()> { ); let tokens = cmdline.split_ascii_whitespace().collect::>(); - let base_tokens = MICROVM_BASE_COMMAND_LINE - .split_ascii_whitespace() - .collect::>(); + let has_console = config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtio-console"); + let base_tokens = if has_console { + MICROVM_CONSOLE_COMMAND_LINE + } else { + MICROVM_BASE_COMMAND_LINE + } + .split_ascii_whitespace() + .collect::>(); anyhow::ensure!( tokens.starts_with(&base_tokens), "microVM command line does not begin with the ABI base tokens" ); - for prefix in ["earlycon=", "console="] { + for prefix in ["earlycon=", "console=", "virtio_mmio.device="] { let count = tokens .iter() .filter(|token| token.starts_with(prefix)) .count(); + let expected = match prefix { + "virtio_mmio.device=" => config.virtio_devices.len(), + _ => 1, + }; anyhow::ensure!( - count == 1, + count == expected, "microVM command line has an invalid number of {prefix} tokens" ); } + let mut expected_discovery = Vec::new(); + if has_console { + expected_discovery.push(format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONSOLE_IRQ}" + )); + } + if !expected_discovery.is_empty() { + anyhow::ensure!( + tokens.ends_with( + &expected_discovery + .iter() + .map(String::as_str) + .collect::>() + ), + "microVM virtio discovery tokens are not in fixed-address order" + ); + } Ok(()) } /// Builds the microVM command line and rejects profile-owned user tokens. -pub fn build_microvm_command_line(user_args: &[String]) -> anyhow::Result { +pub fn build_microvm_command_line( + user_args: &[String], + has_console: bool, +) -> anyhow::Result { for arg in user_args { if arg.contains('\0') { anyhow::bail!("microVM kernel command line contains an embedded NUL"); } if arg.split_ascii_whitespace().any(|token| { - ["earlycon=", "console=", "nr_cpus="] + ["earlycon=", "console=", "virtio_mmio.device=", "nr_cpus="] .iter() .any(|reserved| token.starts_with(reserved)) }) { @@ -89,7 +210,12 @@ pub fn build_microvm_command_line(user_args: &[String]) -> anyhow::Result) -> return Ok(()); }; + validate_microvm_virtio_reservations()?; validate_microvm_command_line(config)?; validate_machine_load_mode(config.machine_profile, &config.load_mode)?; if let Some(hypervisor_id) = hypervisor_id { @@ -328,9 +455,23 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> ); anyhow::ensure!( - config.virtio_devices.is_empty(), - "microVM does not support virtio devices" + config.virtio_devices.len() <= 8, + "microVM has too many virtio devices" ); + let mut has_console = false; + for (bus, device) in &config.virtio_devices { + anyhow::ensure!( + *bus == VirtioBus::Mmio, + "microVM permits only virtio-mmio devices" + ); + match device.id() { + "virtio-console" => anyhow::ensure!( + !std::mem::replace(&mut has_console, true), + "microVM permits only one virtio-console device" + ), + id => anyhow::bail!("microVM does not permit virtio device '{id}'"), + } + } anyhow::ensure!( config.layout.chipset_low_mmio_size == 1024 * 1024 * 1024 && config.layout.chipset_high_mmio_size == 0 @@ -455,10 +596,17 @@ mod tests { #[test] fn microvm_processor_limit_is_host_owned() { - let mut cmdline = build_microvm_command_line(&[]).unwrap(); + let mut cmdline = build_microvm_command_line(&[], false).unwrap(); append_microvm_processor_limit(&mut cmdline, 8).unwrap(); assert_eq!(cmdline, format!("{MICROVM_BASE_COMMAND_LINE} nr_cpus=8")); assert!(append_microvm_processor_limit(&mut cmdline, 3).is_err()); - assert!(build_microvm_command_line(&["nr_cpus=1".to_owned()]).is_err()); + assert!(build_microvm_command_line(&["nr_cpus=1".to_owned()], false).is_err()); + } + + #[test] + fn microvm_block_irqs_avoid_rtc_and_are_edge_triggered() { + assert_eq!(MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, 3); + assert_eq!(MICROVM_VIRTIO_RUNTIME_BLK_IRQ, 12); + assert!(MICROVM_LEVEL_TRIGGERED_IRQS.is_empty()); } } diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 43589a9d50a..25db08e241a 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -4,6 +4,7 @@ //! Command-line options of the microVM machine profile. use super::Options; +use super::SerialConfigCli; use super::SmtConfigCli; use clap::ValueEnum; use openvmm_defs::config::X2ApicConfig; @@ -110,7 +111,27 @@ impl Options { && self.vmbus_com2_serial.is_none() && self.debugcon.is_none() && !self.serial_tx_only, - "microVM exposes only the portb serial device" + "microVM exposes only portb and virtio-console serial devices" + ); + anyhow::ensure!( + self.virtio_console_pcie_port.is_none(), + "microVM requires virtio-console on its fixed MMIO transport" + ); + if let Some(console) = &self.virtio_console { + anyhow::ensure!( + matches!( + console, + SerialConfigCli::Pipe(_) + | SerialConfigCli::Tcp(_) + | SerialConfigCli::Console + | SerialConfigCli::None + ), + "microVM virtio-console requires listen=..., console, or none" + ); + } + anyhow::ensure!( + self.virtio_console.is_some() || self.virtio_console_pcie_port.is_none(), + "--virtio-console-pcie-port requires --virtio-console" ); anyhow::ensure!( self.disk.is_empty() @@ -130,10 +151,8 @@ impl Options { && self.virtio_pmem.is_none() && !self.virtio_rng && self.virtio_vsock_path.is_none() - && self.virtio_net.is_empty() - && self.virtio_console.is_none() - && self.virtio_console_pcie_port.is_none(), - "microVM does not expose virtio devices" + && self.virtio_net.is_empty(), + "microVM does not expose additional virtio devices" ); #[cfg(target_os = "linux")] anyhow::ensure!( @@ -194,6 +213,8 @@ mod tests { use clap::Parser; use openvmm_defs::microvm::MICROVM_BASE_COMMAND_LINE; use openvmm_defs::microvm::MICROVM_COMMAND_LINE_MAX_SIZE; + use openvmm_defs::microvm::MICROVM_CONSOLE_COMMAND_LINE; + use openvmm_defs::microvm::append_microvm_virtio_discovery; use openvmm_defs::microvm::build_microvm_command_line; use test_with_tracing::test; @@ -254,19 +275,38 @@ mod tests { #[test] fn test_microvm_command_line_is_owned_and_bounded() { assert_eq!( - build_microvm_command_line(&[]).unwrap(), + build_microvm_command_line(&[], false).unwrap(), MICROVM_BASE_COMMAND_LINE ); assert_eq!( - build_microvm_command_line(&["foo=bar".into()]).unwrap(), + build_microvm_command_line(&["foo=bar".into()], false).unwrap(), format!("{MICROVM_BASE_COMMAND_LINE} foo=bar") ); + assert_eq!( + build_microvm_command_line(&[], true).unwrap(), + MICROVM_CONSOLE_COMMAND_LINE + ); + + let mut with_devices = build_microvm_command_line(&[], true).unwrap(); + append_microvm_virtio_discovery(&mut with_devices, true).unwrap(); + assert_eq!( + with_devices, + format!("{MICROVM_CONSOLE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0002000:7") + ); - for reserved in ["earlycon=uart", "console=ttyS0", "nr_cpus=1"] { - assert!(build_microvm_command_line(&[reserved.into()]).is_err()); + for reserved in [ + "earlycon=uart", + "console=ttyS0", + "virtio_mmio.device=bad", + "nr_cpus=1", + ] { + assert!(build_microvm_command_line(&[reserved.into()], false).is_err()); } - assert!(build_microvm_command_line(&["foo=bar\0baz".into()]).is_err()); - assert!(build_microvm_command_line(&["x".repeat(MICROVM_COMMAND_LINE_MAX_SIZE)]).is_err()); + assert!(build_microvm_command_line(&["foo=bar\0baz".into()], false).is_err()); + assert!( + build_microvm_command_line(&["x".repeat(MICROVM_COMMAND_LINE_MAX_SIZE)], false) + .is_err() + ); } #[test] @@ -284,6 +324,15 @@ mod tests { .unwrap(); valid_mshv.validate_microvm_options().unwrap(); } + let valid_console = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "listen=tcp:127.0.0.1:5555", + ]) + .unwrap(); + valid_console.validate_microvm_options().unwrap(); for args in [ vec!["openvmm", "--machine", "microvm", "--uefi"], @@ -297,6 +346,15 @@ mod tests { "--virtio-console", "stderr", ], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "listen=tcp:127.0.0.1:5555", + "--virtio-console-pcie-port", + "port0", + ], vec!["openvmm", "--machine", "microvm", "--net", "consomme"], vec![ "openvmm", diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 7c9e52aaac9..58259274e1f 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -518,7 +518,9 @@ async fn vm_config_from_command_line( "debugcon", )?; - let virtio_console_backend = if let Some(serial_cfg) = opt.virtio_console.clone() { + let virtio_console_backend = if microvm.is_active() { + microvm.setup_virtio_consoles(&console_state, &serial_driver)? + } else if let Some(serial_cfg) = opt.virtio_console.clone() { setup_serial("virtio-console", serial_cfg, "hvc0")? } else { None @@ -1985,14 +1987,10 @@ async fn vm_config_from_command_line( if let Some(backend) = virtio_console_backend { let resource: Resource = - virtio_resources::console::VirtioConsoleHandle { - backend, - disconnect_policy: - virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy::Discard, - attachment: None, - } - .into_resource(); - if let Some(pcie_port) = &opt.virtio_console_pcie_port { + microvm.virtio_console_handle(backend).into_resource(); + if microvm.is_active() { + add_virtio_device(VirtioBusCli::Mmio, resource, &mut pcie_devices); + } else if let Some(pcie_port) = &opt.virtio_console_pcie_port { pcie_devices.push(PcieDeviceConfig { port_name: pcie_port.clone(), resource: VirtioPciDeviceHandle(resource).into_resource(), diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index dd4c95084d0..d8351f3f89e 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -17,10 +17,13 @@ use futures::executor::block_on; use futures::io::AllowStdIo; use openvmm_defs::config::Config; use openvmm_defs::config::LoadMode; +use openvmm_defs::microvm::MachineProfile; use openvmm_defs::microvm::build_microvm_command_line; use pal_async::DefaultDriver; +use serial_core::resources::DisconnectedSerialBackendHandle; use std::cell::RefCell; use std::thread; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; use vm_manifest_builder::MachineArch; use vm_resource::IntoResource; use vm_resource::Resource; @@ -34,6 +37,7 @@ use vmotherboard::ChipsetDeviceHandle; pub(crate) struct MicrovmConfigBuilder<'a> { opt: &'a Options, active: bool, + console: Option, portb: Option>, } @@ -55,13 +59,25 @@ impl<'a> MicrovmConfigBuilder<'a> { bail!("microVM does not expose UART, debugcon, or VMBus serial"); } + let console = if active { + opt.virtio_console.clone() + } else { + None + }; + Ok(Self { opt, active, + console, portb: None, }) } + /// Returns whether the microVM machine profile is selected. + pub(crate) fn is_active(&self) -> bool { + self.active + } + /// Returns the backend used for `--com1` when it is not specified. pub(crate) fn default_com1_backend(&self) -> SerialConfigCli { if self.active { @@ -71,11 +87,10 @@ impl<'a> MicrovmConfigBuilder<'a> { } } - /// Connects the host side of the portb console (`hvc0`) to the host - /// console. + /// Connects the host side of the portb console (`hvc0`). /// /// This must run before the other serial devices so that portb owns the - /// interactive console. + /// interactive console when the boot virtio-console does not. pub(crate) fn setup_portb( &mut self, console_state: &RefCell>>, @@ -84,8 +99,14 @@ impl<'a> MicrovmConfigBuilder<'a> { if !self.active { return Ok(()); } + let backend = if matches!(self.console, Some(SerialConfigCli::Console)) { + SerialConfigCli::Stderr + } else { + SerialConfigCli::Console + }; self.portb = Some(setup_host_console( "microvm-portb", + backend, "hvc0", console_state, serial_driver, @@ -93,6 +114,40 @@ impl<'a> MicrovmConfigBuilder<'a> { Ok(()) } + /// Connects the host side of the boot virtio-console, returning its + /// backend. + pub(crate) fn setup_virtio_consoles( + &self, + console_state: &RefCell>>, + serial_driver: &DefaultDriver, + ) -> anyhow::Result>> { + let virtio_console_backend = if let Some(serial_cfg) = self.console.clone() { + match serial_cfg { + SerialConfigCli::Pipe(path) => { + Some(serial_io::bind_serial(&path).with_context(|| { + format!( + "failed to bind microVM virtio console listener {}", + path.display() + ) + })?) + } + SerialConfigCli::Tcp(address) => Some(serial_io::bind_tcp_serial(&address)?), + SerialConfigCli::Console => Some(setup_host_console( + "virtio-console", + SerialConfigCli::Console, + "hvc1", + console_state, + serial_driver, + )?), + SerialConfigCli::None => Some(DisconnectedSerialBackendHandle.into_resource()), + _ => unreachable!("microVM console backend was validated"), + } + } else { + None + }; + Ok(virtio_console_backend) + } + /// Adds the portb and shutdown chipset devices. pub(crate) fn add_chipset_devices( &mut self, @@ -140,7 +195,11 @@ impl<'a> MicrovmConfigBuilder<'a> { Ok(Some(LoadMode::Linux { kernel: kernel.into(), initrd: initrd.map(Into::into), - cmdline: build_effective_microvm_command_line(&opt.cmdline, opt.processors)?, + cmdline: build_effective_microvm_command_line( + &opt.cmdline, + opt.processors, + self.console.is_some(), + )?, enable_serial: false, isolation: openvmm_defs::config::LinuxIsolationConfig::None, boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, @@ -163,6 +222,27 @@ impl<'a> MicrovmConfigBuilder<'a> { } } + /// Builds the boot virtio-console device handle. + /// + /// A microVM retains guest output until a listener's client connects; a + /// disconnected (`none`) console discards it. + pub(crate) fn virtio_console_handle( + &self, + backend: Resource, + ) -> virtio_resources::console::VirtioConsoleHandle { + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy: if self.active + && !matches!(self.console, Some(SerialConfigCli::None)) + { + VirtioConsoleDisconnectPolicy::Retain + } else { + VirtioConsoleDisconnectPolicy::Discard + }, + attachment: None, + } + } + /// Completes the microVM configuration after the storage devices have been /// added, and validates the machine contract of every profile. pub(crate) fn finish(self, cfg: &mut Config) -> anyhow::Result<()> { @@ -185,45 +265,81 @@ impl<'a> MicrovmConfigBuilder<'a> { .hypervisor .as_deref() .and_then(|spec| spec.split(':').next()); + if cfg.machine_profile == MachineProfile::Microvm { + let cmdline = match &mut cfg.load_mode { + LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } => cmdline, + _ => { + unreachable!("microVM configuration was constructed with a supported load mode") + } + }; + let has_console = cfg + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtio-console"); + openvmm_defs::microvm::append_microvm_virtio_discovery(cmdline, has_console)?; + } openvmm_defs::microvm::validate_machine_config(cfg, requested_hypervisor)?; Ok(()) } } -/// Connects the portb endpoint to the host console, relaying its output to -/// stdout, and returns its backend. +/// Connects a portb or boot virtio-console endpoint to the host console or +/// stderr, returning its backend. fn setup_host_console( name: &str, + backend: SerialConfigCli, device: &'static str, console_state: &RefCell>>, serial_driver: &DefaultDriver, ) -> anyhow::Result> { - if let Some(console_state) = console_state.borrow().as_ref() { - bail!("console already set by {}", console_state.device); - } - let (config, serial) = serial_io::anonymous_serial_pair(serial_driver)?; - let (serial_read, serial_write) = AsyncReadExt::split(serial); - *console_state.borrow_mut() = Some(ConsoleState { - device, - input: Box::new(serial_write), - }); + Ok(match backend { + SerialConfigCli::Console => { + if let Some(console_state) = console_state.borrow().as_ref() { + bail!("console already set by {}", console_state.device); + } + let (config, serial) = serial_io::anonymous_serial_pair(serial_driver)?; + let (serial_read, serial_write) = AsyncReadExt::split(serial); + *console_state.borrow_mut() = Some(ConsoleState { + device, + input: Box::new(serial_write), + }); + spawn_output(name, serial_read, term::raw_stdout())?; + config + } + SerialConfigCli::Stderr => { + let (config, serial) = serial_io::anonymous_serial_pair(serial_driver)?; + spawn_output(name, serial, term::raw_stderr())?; + config + } + _ => unreachable!("microVM host consoles use the console or stderr"), + }) +} + +/// Relays `input` to `output` on a dedicated thread. +fn spawn_output( + name: &str, + input: impl futures::AsyncRead + Send + Unpin + 'static, + output: impl std::io::Write + Send + 'static, +) -> anyhow::Result<()> { thread::Builder::new() .name(name.to_owned()) .spawn(move || { - let _ = block_on(futures::io::copy( - serial_read, - &mut AllowStdIo::new(term::raw_stdout()), - )); + let _ = block_on(futures::io::copy(input, &mut AllowStdIo::new(output))); }) - .context("failed to spawn the portb output relay")?; - Ok(config) + .with_context(|| format!("failed to spawn the {name} output relay"))?; + Ok(()) } fn build_effective_microvm_command_line( user_args: &[String], processor_count: u32, + has_console: bool, ) -> anyhow::Result { - let mut cmdline = build_microvm_command_line(user_args)?; + let mut cmdline = build_microvm_command_line(user_args, has_console)?; openvmm_defs::microvm::append_microvm_processor_limit(&mut cmdline, processor_count)?; Ok(cmdline) } diff --git a/petri/src/vm/openvmm/microvm.rs b/petri/src/vm/openvmm/microvm.rs index 3ed325a1017..a0a1955bcd6 100644 --- a/petri/src/vm/openvmm/microvm.rs +++ b/petri/src/vm/openvmm/microvm.rs @@ -95,7 +95,7 @@ pub(super) fn configure_load_mode( unreachable!("microVM firmware was validated as LinuxDirect"); }; let mut configured = - openvmm_defs::microvm::build_microvm_command_line(&[std::mem::take(cmdline)])?; + openvmm_defs::microvm::build_microvm_command_line(&[std::mem::take(cmdline)], false)?; openvmm_defs::microvm::append_microvm_processor_limit(&mut configured, processor_count)?; *cmdline = configured; *enable_serial = false; From 589c217c4594a00e912b2f5cb365abf02f922403 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 05:10:41 -0700 Subject: [PATCH 100/304] virtio: add shared MMIO interrupt status and ACK doorbells A virtio-mmio interrupt costs the guest two trapping register accesses: it reads INTERRUPT_STATUS to learn why the line fired, then writes INTERRUPT_ACK to clear it. Both accesses exit to the VMM. Add two ways for the MMIO transport to avoid these exits. `VirtioMmioInterruptMode::SharedStatus { status_gpa }` keeps the interrupt status in a naturally aligned 32-bit word of guest memory instead of the INTERRUPT_STATUS register. The device sets status bits with an atomic compare-exchange, and it pulses the interrupt line only when the word goes from zero to nonzero. A guest that supports the mode reads and clears the word without exiting. INTERRUPT_STATUS reads return the word. The transport rejects a misaligned or inaccessible word and zeroes it when the transport is created, reset, or dropped. In the default `Legacy` mode on Linux, the transport registers an accelerated doorbell on INTERRUPT_ACK for the used-buffer bit. A used-buffer acknowledgement then no longer exits to user mode. A used-buffer generation count keeps a deferred acknowledgement from clearing a completion that the guest has not read yet. `VirtioDevice::supports_accelerated_doorbells` (true by default) lets a device opt out of accelerated queue-notify and INTERRUPT_ACK doorbells. Its notifications are then emulated by the transport. `VirtioMmioDevice::new_with_disabled_features_and_interrupt_mode` selects the mode. `new` and `new_with_disabled_features` keep the legacy mode, so existing devices are unchanged. The MMIO constructors now require a driver that can wait on the doorbell event on Linux. Saving reads the effective status from the register or the shared word. Restore resets the interrupt state, then applies the saved status to the same place. Tests cover the following: - Used-buffer acknowledgement through the doorbell, with and without registered doorbells. - An acknowledgement that races a new completion. - Shared-status save, restore, and reset. - Bit coalescing and zero-transition pulses, and a completion that races the guest's exchange. - Teardown, and the doorbell opt-out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/virtio/virtio/src/device.rs | 14 + vm/devices/virtio/virtio/src/tests.rs | 30 +- .../virtio/virtio/src/tests/interrupt.rs | 452 ++++++++++++++ .../virtio/virtio/src/transport/core.rs | 2 + .../virtio/virtio/src/transport/mmio.rs | 37 +- .../virtio/src/transport/mmio/interrupt.rs | 564 ++++++++++++++++++ vm/devices/virtio/virtio/src/transport/mod.rs | 1 + 7 files changed, 1085 insertions(+), 15 deletions(-) create mode 100644 vm/devices/virtio/virtio/src/tests/interrupt.rs create mode 100644 vm/devices/virtio/virtio/src/transport/mmio/interrupt.rs diff --git a/vm/devices/virtio/virtio/src/device.rs b/vm/devices/virtio/virtio/src/device.rs index cd5860e5374..71a371c3bbf 100644 --- a/vm/devices/virtio/virtio/src/device.rs +++ b/vm/devices/virtio/virtio/src/device.rs @@ -29,6 +29,13 @@ pub trait VirtioDevice: InspectMut + Send { /// Device identity and capabilities. fn traits(&self) -> DeviceTraits; + /// Whether the transport should register accelerated queue doorbells. + /// Devices that opt out receive queue notifications through transport + /// emulation instead. + fn supports_accelerated_doorbells(&self) -> bool { + true + } + /// The queue size for the given queue index. /// /// This is the initial value the transport advertises to the guest @@ -161,6 +168,9 @@ pub trait DynVirtioDevice: InspectMut + Send { /// Device identity and capabilities. fn traits(&self) -> DeviceTraits; + /// Whether the transport should register accelerated queue doorbells. + fn supports_accelerated_doorbells(&self) -> bool; + /// The queue size for the given queue index. fn queue_size(&self, queue_index: u16) -> u16; @@ -223,6 +233,10 @@ impl DynVirtioDevice for T { VirtioDevice::traits(self) } + fn supports_accelerated_doorbells(&self) -> bool { + VirtioDevice::supports_accelerated_doorbells(self) + } + fn queue_size(&self, queue_index: u16) -> u16 { VirtioDevice::queue_size(self, queue_index) } diff --git a/vm/devices/virtio/virtio/src/tests.rs b/vm/devices/virtio/virtio/src/tests.rs index 6290a5010c0..2abcdc69dac 100644 --- a/vm/devices/virtio/virtio/src/tests.rs +++ b/vm/devices/virtio/virtio/src/tests.rs @@ -6,6 +6,7 @@ #![expect(unsafe_code)] #![cfg(test)] +mod interrupt; mod saved_state; use crate::DeviceTraits; @@ -133,6 +134,7 @@ struct VirtioTestMemoryAccess { /// Behind an `Arc` so each handed-out registration can remove its own entry /// when it is dropped. live_doorbells: Arc>>, + doorbell_events: Arc>>, } /// A doorbell registration as the transport asked for it. @@ -298,12 +300,22 @@ unsafe impl GuestMemoryAccess for VirtioTestMemoryAccess { }; Ok(()) } + + fn compare_exchange_fallback( + &self, + address: u64, + current: &mut [u8], + new: &[u8], + ) -> Result { + self.compare_exchange_memory(address, current, new) + } } /// The handle a doorbell registration hands back. Dropping it is what /// UNINSTALLS the doorbell, so it de-registers itself from `live_doorbells`. struct DoorbellEntry { live: Arc>>, + _event: interrupt::DoorbellEvent, spec: DoorbellSpec, } @@ -322,7 +334,7 @@ impl DoorbellRegistration for VirtioTestMemoryAccess { address: u64, value: Option, length: Option, - _: &Event, + event: &Event, ) -> io::Result> { self.doorbell_count.fetch_add(1, Ordering::Relaxed); let spec = DoorbellSpec { @@ -333,6 +345,7 @@ impl DoorbellRegistration for VirtioTestMemoryAccess { self.live_doorbells.lock().push(spec); Ok(Box::new(DoorbellEntry { live: self.live_doorbells.clone(), + _event: interrupt::DoorbellEvent::register(&self.doorbell_events, spec, event), spec, })) } @@ -4745,12 +4758,19 @@ async fn pci_save_restore_round_trip(driver: DefaultDriver) { #[async_test] async fn mmio_save_restore_round_trip(driver: DefaultDriver) { + for register_doorbells in [false, true] { + mmio_save_restore_round_trip_inner(driver.clone(), register_doorbells).await; + } +} + +async fn mmio_save_restore_round_trip_inner(driver: DefaultDriver, register_doorbells: bool) { use crate::spec::mmio::VirtioMmioRegister; use vmcore::device_state::ChangeDeviceState; use vmcore::save_restore::SaveRestore; let test_mem = VirtioTestMemoryAccess::new(); - let doorbell_registration: Arc = test_mem.clone(); + let doorbell_registration: Option> = + register_doorbells.then(|| test_mem.clone() as _); let mem = GuestMemory::new("test", test_mem.clone()); let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); @@ -4773,7 +4793,7 @@ async fn mmio_save_restore_round_trip(driver: DefaultDriver) { &driver_source.simple(), mem.clone(), interrupt, - Some(doorbell_registration.clone()), + doorbell_registration.clone(), 0, 1, ) @@ -4790,6 +4810,7 @@ async fn mmio_save_restore_round_trip(driver: DefaultDriver) { let mut saved = dev.save().expect("save should succeed"); assert_eq!(saved.queues.len(), 1); assert!(saved.queues[0].common.enable); + saved.interrupt_status = VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER; saved_state::check_restored_queue_validation(&mut saved, guest.queue_features(), &mem); drop(dev); @@ -4811,7 +4832,7 @@ async fn mmio_save_restore_round_trip(driver: DefaultDriver) { &driver_source.simple(), mem.clone(), interrupt2, - Some(doorbell_registration), + doorbell_registration, 0, 1, ) @@ -4823,6 +4844,7 @@ async fn mmio_save_restore_round_trip(driver: DefaultDriver) { dev2.read_u32(VirtioMmioRegister::STATUS.0 as u64) & VIRTIO_DRIVER_OK, 0 ); + interrupt::check_used_buffer_ack(&test_mem, &mut dev2, register_doorbells); // Stop and clean up. dev2.stop().await; diff --git a/vm/devices/virtio/virtio/src/tests/interrupt.rs b/vm/devices/virtio/virtio/src/tests/interrupt.rs new file mode 100644 index 00000000000..ee9c6884089 --- /dev/null +++ b/vm/devices/virtio/virtio/src/tests/interrupt.rs @@ -0,0 +1,452 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for virtio-mmio interrupt-status delivery: used-buffer acknowledgement +//! through the `INTERRUPT_ACK` doorbell, the shared interrupt-status mode, and +//! devices that opt out of accelerated doorbells. + +use super::*; +use crate::transport::VirtioMmioInterruptMode; +use test_with_tracing::test; + +/// The event of a doorbell registration, recorded so that tests can signal +/// the doorbell. Dropping it removes the record. +pub(super) struct DoorbellEvent { + events: Arc>>, + spec: DoorbellSpec, +} + +impl DoorbellEvent { + pub(super) fn register( + events: &Arc>>, + spec: DoorbellSpec, + event: &Event, + ) -> Self { + events.lock().push((spec, event.clone())); + Self { + events: events.clone(), + spec, + } + } +} + +impl Drop for DoorbellEvent { + fn drop(&mut self) { + let mut events = self.events.lock(); + if let Some(i) = events.iter().position(|(spec, _)| *spec == self.spec) { + events.remove(i); + } + } +} + +impl VirtioTestMemoryAccess { + fn signal_doorbell(&self, spec: DoorbellSpec) -> bool { + let event = self + .doorbell_events + .lock() + .iter() + .find_map(|(registered, event)| (*registered == spec).then(|| event.clone())); + if let Some(event) = event { + event.signal(); + true + } else { + false + } + } + + /// The `compare_exchange_fallback` of the test memory, which the shared + /// interrupt-status word uses. + pub(super) fn compare_exchange_memory( + &self, + address: u64, + current: &mut [u8], + new: &[u8], + ) -> Result { + let mut map = self.memory_map.lock(); + let Some((true, value)) = map.get(address, current.len()) else { + panic!("Unexpected compare exchange request at address {address:x}"); + }; + if value == current { + value.copy_from_slice(new); + Ok(true) + } else { + current.copy_from_slice(value); + Ok(false) + } + } +} + +/// Acknowledges the pending used-buffer interrupt of a restored device, through +/// the `INTERRUPT_ACK` doorbell when it is accelerated, and checks that the +/// status bit clears. +pub(super) fn check_used_buffer_ack( + test_mem: &VirtioTestMemoryAccess, + dev2: &mut VirtioMmioDevice, + register_doorbells: bool, +) { + use crate::spec::mmio::VirtioMmioRegister; + + let ack = DoorbellSpec { + address: VirtioMmioRegister::INTERRUPT_ACK.0 as u64, + value: Some(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER.into()), + length: Some(4), + }; + let accelerated = register_doorbells && cfg!(target_os = "linux"); + assert_eq!(test_mem.installed_doorbells().contains(&ack), accelerated); + if accelerated { + assert!(test_mem.signal_doorbell(ack)); + } else { + dev2.write_u32( + VirtioMmioRegister::INTERRUPT_ACK.0 as u64, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + ); + } + assert_eq!( + dev2.read_u32(VirtioMmioRegister::INTERRUPT_STATUS.0 as u64) + & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + 0 + ); +} + +/// A [`TestDevice`] that opts out of accelerated queue doorbells. +#[derive(InspectMut)] +#[inspect(skip)] +struct NoAcceleratedDoorbells(TestDevice); + +impl VirtioDevice for NoAcceleratedDoorbells { + fn traits(&self) -> DeviceTraits { + VirtioDevice::traits(&self.0) + } + + fn supports_accelerated_doorbells(&self) -> bool { + false + } + + async fn read_registers_u32(&mut self, offset: u16) -> u32 { + VirtioDevice::read_registers_u32(&mut self.0, offset).await + } + + async fn write_registers_u32(&mut self, offset: u16, val: u32) { + VirtioDevice::write_registers_u32(&mut self.0, offset, val).await + } + + async fn start_queue( + &mut self, + idx: u16, + resources: QueueResources, + features: &VirtioDeviceFeatures, + initial_state: Option, + ) -> anyhow::Result<()> { + VirtioDevice::start_queue(&mut self.0, idx, resources, features, initial_state).await + } + + async fn stop_queue(&mut self, idx: u16) -> Option { + VirtioDevice::stop_queue(&mut self.0, idx).await + } + + async fn reset(&mut self) { + VirtioDevice::reset(&mut self.0).await + } + + fn supports_save_restore(&self) -> bool { + VirtioDevice::supports_save_restore(&self.0) + } +} +#[async_test] +async fn mmio_used_buffer_ack_doorbell_deasserts_interrupt(driver: DefaultDriver) { + for register_doorbells in [false, true] { + mmio_used_buffer_ack_inner(driver.clone(), register_doorbells).await; + } +} + +async fn mmio_used_buffer_ack_inner(driver: DefaultDriver, register_doorbells: bool) { + const MMIO_BASE: u64 = 0x1000; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 2, true); + let target = TestLineInterruptTarget::new_arc(); + let interrupt = LineInterrupt::new_with_target("test", target.clone(), 0); + let base_addr = guest.get_queue_descriptor_backing_memory_address(0); + let queue_work = Arc::new( + move |_: u16, queue: &mut VirtioQueue, work: VirtioQueueCallbackWork| { + assert_eq!(work.payload[0].address, base_addr); + queue.complete(work, 123); + }, + ); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let mut dev = VirtioMmioDevice::new( + Box::new(TestDevice::new( + &driver_source, + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, VIRTIO_F_RING_EVENT_IDX | 2) + .with_bank(1, VIRTIO_F_VERSION_1), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }, + Some(queue_work), + )), + &driver, + guest.mem(), + interrupt, + if register_doorbells { + Some(test_mem.clone()) + } else { + None + }, + MMIO_BASE, + 0x1000, + ) + .unwrap(); + + guest + .setup_chipset_device(&mut dev, guest.queue_features()) + .await; + guest.add_to_avail_queue(0); + dev.write_u32(80, 0); + poll_fn(|cx| target.poll_high(cx, 0)).await; + assert_eq!( + dev.read_u32(96) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER + ); + + let ack = DoorbellSpec { + address: MMIO_BASE + 100, + value: Some(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER.into()), + length: Some(4), + }; + let accelerated = register_doorbells && cfg!(target_os = "linux"); + assert_eq!(test_mem.installed_doorbells().contains(&ack), accelerated); + if accelerated { + assert!(test_mem.signal_doorbell(ack)); + } else { + dev.write_u32(100, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + } + assert_eq!( + dev.read_u32(96) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + 0 + ); + assert!(!target.is_high(0)); + dev.stop().await; +} + +#[async_test] +async fn mmio_ack_before_completion_keeps_new_interrupt_asserted(driver: DefaultDriver) { + for register_doorbells in [false, true] { + mmio_ack_before_completion_inner(driver.clone(), register_doorbells).await; + } +} + +async fn mmio_ack_before_completion_inner(driver: DefaultDriver, register_doorbells: bool) { + const MMIO_BASE: u64 = 0x2000; + + let test_mem = VirtioTestMemoryAccess::new(); + let mut guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let target = TestLineInterruptTarget::new_arc(); + let interrupt = LineInterrupt::new_with_target("test", target.clone(), 0); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let (completed, mut completions) = mesh::channel(); + let mut dev = VirtioMmioDevice::new( + Box::new(TestDevice::new( + &driver_source, + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, VIRTIO_F_RING_EVENT_IDX | 2) + .with_bank(1, VIRTIO_F_VERSION_1), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }, + Some(Arc::new( + move |_: u16, queue: &mut VirtioQueue, work: VirtioQueueCallbackWork| { + queue.complete(work, 123); + completed.send(()); + }, + )), + )), + &driver, + guest.mem(), + interrupt, + if register_doorbells { + Some(test_mem.clone()) + } else { + None + }, + MMIO_BASE, + 0x1000, + ) + .unwrap(); + + guest + .setup_chipset_device(&mut dev, guest.queue_features()) + .await; + guest.add_to_avail_queue(0); + dev.write_u32(80, 0); + poll_fn(|cx| target.poll_high(cx, 0)).await; + must_recv_in_timeout(&mut completions, Duration::from_secs(5)).await; + assert_eq!(guest.get_next_completed(0), Some((0, 123))); + assert_eq!( + dev.read_u32(96) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER + ); + let ack = DoorbellSpec { + address: MMIO_BASE + 100, + value: Some(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER.into()), + length: Some(4), + }; + let accelerated = register_doorbells && cfg!(target_os = "linux"); + assert_eq!(test_mem.installed_doorbells().contains(&ack), accelerated); + if !accelerated { + dev.write_u32(100, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + assert!(!target.is_high(0)); + } + + guest.add_to_avail_queue(0); + dev.write_u32(80, 0); + must_recv_in_timeout(&mut completions, Duration::from_secs(5)).await; + assert_eq!(guest.get_next_completed(0), Some((0, 123))); + if accelerated { + // A deferred ACK must not clear a completion newer than the status read. + assert!(test_mem.signal_doorbell(ack)); + } + assert_eq!( + dev.read_u32(96) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER + ); + assert!(target.is_high(0)); + dev.stop().await; +} + +#[async_test] +async fn mmio_shared_status_save_restore_and_reset(driver: DefaultDriver) { + use vmcore::save_restore::SaveRestore; + + const STATUS_GPA: u64 = 0xfeed_0000; + + let test_mem = VirtioTestMemoryAccess::new(); + test_mem.modify_memory_map(STATUS_GPA, &0u32.to_ne_bytes(), true); + let doorbell_registration: Arc = test_mem.clone(); + let mem = GuestMemory::new("test", test_mem.clone()); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + let device = || { + Box::new(TestDevice::new( + &driver_source, + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, 2 | VIRTIO_F_RING_INDIRECT_DESC | VIRTIO_F_RING_EVENT_IDX), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }, + None, + )) + }; + + let source_target = TestLineInterruptTarget::new_arc(); + let mut source = VirtioMmioDevice::new_with_disabled_features_and_interrupt_mode( + device(), + &driver_source.simple(), + mem.clone(), + LineInterrupt::new_with_target("shared-status-source", source_target.clone(), 0), + Some(doorbell_registration.clone()), + 0, + 0x1000, + 0, + VirtioMmioInterruptMode::SharedStatus { + status_gpa: STATUS_GPA, + }, + ) + .unwrap(); + guest + .setup_chipset_device(&mut source, guest.queue_features()) + .await; + assert_eq!(test_mem.memory_map_get_u32(STATUS_GPA), 0); + test_mem.modify_memory_map( + STATUS_GPA, + &VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE.to_ne_bytes(), + true, + ); + assert_eq!( + test_mem.memory_map_get_u32(STATUS_GPA), + VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE + ); + assert!(!source_target.is_high(0)); + source.stop().await; + let saved = source.save().unwrap(); + assert_eq!( + saved.interrupt_status, + VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE + ); + drop(source); + + let destination_target = TestLineInterruptTarget::new_arc(); + let mut destination = VirtioMmioDevice::new_with_disabled_features_and_interrupt_mode( + device(), + &driver_source.simple(), + mem, + LineInterrupt::new_with_target("shared-status-destination", destination_target.clone(), 0), + Some(doorbell_registration), + 0, + 0x1000, + 0, + VirtioMmioInterruptMode::SharedStatus { + status_gpa: STATUS_GPA, + }, + ) + .unwrap(); + destination.restore(saved).unwrap(); + assert_eq!( + test_mem.memory_map_get_u32(STATUS_GPA), + VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE + ); + assert!(!destination_target.is_high(0)); + + destination.reset().await; + assert_eq!(test_mem.memory_map_get_u32(STATUS_GPA), 0); + assert!(!destination_target.is_high(0)); +} + +#[async_test] +async fn mmio_respects_accelerated_doorbell_opt_out(driver: DefaultDriver) { + let test_mem = VirtioTestMemoryAccess::new(); + let doorbell_registration: Arc = test_mem.clone(); + let mem = GuestMemory::new("test", test_mem.clone()); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let guest = VirtioTestGuest::new_split(&driver, &test_mem, 1, 4, true); + + let device = NoAcceleratedDoorbells(TestDevice::new( + &driver_source, + DeviceTraits { + device_id: VirtioDeviceType::CONSOLE, + device_features: VirtioDeviceFeatures::new() + .with_bank(0, 2 | VIRTIO_F_RING_INDIRECT_DESC | VIRTIO_F_RING_EVENT_IDX), + max_queues: 1, + device_register_length: 0, + ..Default::default() + }, + None, + )); + let mut dev = VirtioMmioDevice::new( + Box::new(device), + &driver, + mem, + LineInterrupt::detached(), + Some(doorbell_registration), + 0, + 1, + ) + .unwrap(); + + guest + .setup_chipset_device(&mut dev, guest.queue_features()) + .await; + + assert!(test_mem.installed_doorbells().is_empty()); + assert_eq!(test_mem.doorbell_count.load(Ordering::Relaxed), 0); + dev.stop().await; +} diff --git a/vm/devices/virtio/virtio/src/transport/core.rs b/vm/devices/virtio/virtio/src/transport/core.rs index b2bb974b333..937f6a8095b 100644 --- a/vm/devices/virtio/virtio/src/transport/core.rs +++ b/vm/devices/virtio/virtio/src/transport/core.rs @@ -108,6 +108,8 @@ impl VirtioTransportCore { doorbell_registration: Option>, ) -> std::io::Result { let traits = device.traits(); + let accelerated_doorbells = device.supports_accelerated_doorbells(); + let doorbell_registration = doorbell_registration.filter(|_| accelerated_doorbells); let queues: Vec = (0..traits.max_queues) .map(|i| { let size = device.queue_size(i); diff --git a/vm/devices/virtio/virtio/src/transport/mmio.rs b/vm/devices/virtio/virtio/src/transport/mmio.rs index c9cea9905fa..3dfd82d35cd 100644 --- a/vm/devices/virtio/virtio/src/transport/mmio.rs +++ b/vm/devices/virtio/virtio/src/transport/mmio.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +pub(super) mod interrupt; + use super::StalledIo; use super::core::TransportOps; use super::core::VirtioTransportCore; @@ -25,7 +27,7 @@ use guestmem::DoorbellRegistration; use guestmem::GuestMemory; use inspect::Inspect; use inspect::InspectMut; -use pal_async::task::Spawn; +use interrupt::MmioDriver; use parking_lot::Mutex; use std::fmt; use std::ops::RangeInclusive; @@ -44,16 +46,23 @@ struct MmioTransport { #[inspect(hex)] vendor_id: u32, interrupt_state: Arc>, + #[inspect(skip)] + interrupt_ack: interrupt::InterruptAck, } #[derive(Inspect)] struct InterruptState { interrupt: LineInterrupt, status: u32, + #[inspect(flatten)] + delivery: interrupt::StatusDelivery, } impl InterruptState { fn update(&mut self, is_set: bool, bits: u32) { + if self.delivery.update(&self.interrupt, is_set, bits) { + return; + } if is_set { self.status |= bits; } else { @@ -80,7 +89,7 @@ impl TransportOps for MmioTransport { } fn reset_interrupts(&mut self) { - self.interrupt_state.lock().update(false, !0); + self.reset_interrupt_state(); } fn doorbell_region(&mut self) -> Option<(u64, u32)> { @@ -108,14 +117,14 @@ impl fmt::Debug for VirtioMmioDevice { impl VirtioMmioDevice { pub fn new( device: Box, - driver: &impl Spawn, + driver: &impl MmioDriver, guest_memory: GuestMemory, interrupt: LineInterrupt, doorbell_registration: Option>, mmio_gpa: u64, mmio_len: u64, ) -> std::io::Result { - Self::new_with_disabled_features( + Self::new_with_disabled_features_and_interrupt_mode( device, driver, guest_memory, @@ -124,25 +133,30 @@ impl VirtioMmioDevice { mmio_gpa, mmio_len, 0, + interrupt::VirtioMmioInterruptMode::Legacy, ) } - /// Creates an MMIO transport after masking guest-visible device features. - pub fn new_with_disabled_features( + /// Creates an MMIO transport with explicit interrupt-status delivery. + pub fn new_with_disabled_features_and_interrupt_mode( device: Box, - driver: &impl Spawn, + driver: &impl MmioDriver, guest_memory: GuestMemory, interrupt: LineInterrupt, doorbell_registration: Option>, mmio_gpa: u64, mmio_len: u64, disabled_features: u64, + interrupt_mode: interrupt::VirtioMmioInterruptMode, ) -> std::io::Result { let traits = device.traits(); + let (delivery, ack) = interrupt::setup(&*device, &guest_memory, interrupt_mode)?; let interrupt_state = Arc::new(Mutex::new(InterruptState { interrupt, status: 0, + delivery, })); + let ack = ack.register(driver, &doorbell_registration, mmio_gpa, &interrupt_state); let mut core = VirtioTransportCore::new(device, driver, guest_memory, doorbell_registration)?; @@ -155,6 +169,7 @@ impl VirtioMmioDevice { device_id: traits.device_id.0 as u32, vendor_id: 0x1af4, interrupt_state, + interrupt_ack: ack, }, }) } @@ -208,7 +223,7 @@ impl VirtioMmioDevice { .is_some_and(|qd| qd.params.enable) as u32 } VirtioMmioRegister::QUEUE_NOTIFY => 0, - VirtioMmioRegister::INTERRUPT_STATUS => self.mmio.interrupt_state.lock().status, + VirtioMmioRegister::INTERRUPT_STATUS => self.mmio.read_interrupt_status(), VirtioMmioRegister::INTERRUPT_ACK => 0, VirtioMmioRegister::STATUS => self.core.device_status.as_u32(), VirtioMmioRegister::QUEUE_DESC_LOW => self @@ -468,7 +483,7 @@ mod saved_state { }) .collect(), device_state: self.core.take_device_state()?, - interrupt_status: self.mmio.interrupt_state.lock().status, + interrupt_status: self.mmio.read_interrupt_status(), }) } @@ -476,6 +491,7 @@ mod saved_state { &mut self, state: Self::SavedState, ) -> Result<(), vmcore::save_restore::RestoreError> { + self.mmio.reset_interrupt_state(); let saved_queue_count = state.queues.len(); self.core.restore_common( &mut self.mmio, @@ -488,8 +504,7 @@ mod saved_state { // Restore MMIO-specific interrupt state. { let mut is = self.mmio.interrupt_state.lock(); - is.status = state.interrupt_status; - is.interrupt.set_level(is.status != 0); + is.restore_status(state.interrupt_status)?; } Ok(()) diff --git a/vm/devices/virtio/virtio/src/transport/mmio/interrupt.rs b/vm/devices/virtio/virtio/src/transport/mmio/interrupt.rs new file mode 100644 index 00000000000..cc297d4681e --- /dev/null +++ b/vm/devices/virtio/virtio/src/transport/mmio/interrupt.rs @@ -0,0 +1,564 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Interrupt-status delivery of the virtio-mmio transport. +//! +//! In [`VirtioMmioInterruptMode::Legacy`] mode the guest reads +//! `INTERRUPT_STATUS` and acknowledges it through `INTERRUPT_ACK`. On Linux, +//! used-buffer acknowledgements may also arrive through an accelerated +//! doorbell on `INTERRUPT_ACK`; a used-buffer generation count keeps such a +//! deferred acknowledgement from clearing a completion that the guest has not +//! read yet. In [`VirtioMmioInterruptMode::SharedStatus`] mode the status lives +//! in a guest-memory word, and each zero-to-nonzero transition pulses the +//! interrupt line. + +use super::InterruptState; +use super::MmioTransport; +use super::VirtioMmioDevice; +use crate::DynVirtioDevice; +use crate::spec::VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER; +#[cfg(target_os = "linux")] +use crate::spec::mmio::VirtioMmioRegister; +use guestmem::DoorbellRegistration; +use guestmem::GuestMemory; +use guestmem::GuestMemoryError; +use inspect::Inspect; +#[cfg(target_os = "linux")] +use pal_async::driver::PollImpl; +#[cfg(target_os = "linux")] +use pal_async::fd::PollFdReady; +#[cfg(target_os = "linux")] +use pal_async::interest::InterestSlot; +#[cfg(target_os = "linux")] +use pal_async::interest::PollEvents; +#[cfg(target_os = "linux")] +use pal_async::task::Task; +#[cfg(target_os = "linux")] +use pal_event::Event; +use parking_lot::Mutex; +#[cfg(target_os = "linux")] +use std::future::poll_fn; +#[cfg(target_os = "linux")] +use std::os::fd::AsFd; +#[cfg(target_os = "linux")] +use std::os::fd::AsRawFd; +use std::sync::Arc; +use vmcore::line_interrupt::LineInterrupt; +use vmcore::save_restore::RestoreError; + +/// The driver bound of the MMIO transport constructors. On Linux the driver +/// must also wait for the accelerated `INTERRUPT_ACK` doorbell. +#[cfg(target_os = "linux")] +pub(super) use pal_async::driver::SpawnDriver as MmioDriver; +#[cfg(not(target_os = "linux"))] +pub(super) use pal_async::task::Spawn as MmioDriver; + +/// Interrupt-status delivery used by a virtio-mmio transport. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum VirtioMmioInterruptMode { + /// Virtio MMIO status reads, acknowledgement writes, and a level interrupt. + #[default] + Legacy, + /// A shared atomic status word and a pulse on each zero-to-nonzero transition. + SharedStatus { status_gpa: u64 }, +} + +impl VirtioMmioDevice { + /// Creates an MMIO transport after masking guest-visible device features. + pub fn new_with_disabled_features( + device: Box, + driver: &impl MmioDriver, + guest_memory: GuestMemory, + interrupt: LineInterrupt, + doorbell_registration: Option>, + mmio_gpa: u64, + mmio_len: u64, + disabled_features: u64, + ) -> std::io::Result { + Self::new_with_disabled_features_and_interrupt_mode( + device, + driver, + guest_memory, + interrupt, + doorbell_registration, + mmio_gpa, + mmio_len, + disabled_features, + VirtioMmioInterruptMode::Legacy, + ) + } +} + +/// Interrupt-status state in addition to the legacy status word. +#[derive(Inspect)] +pub(super) struct StatusDelivery { + used_buffer_generation: u64, + observed_used_buffer_generation: Option, + shared_status: Option, +} + +impl StatusDelivery { + /// Applies a status update in shared-status mode, or tracks used-buffer + /// generations in legacy mode. Returns `true` if the update is complete. + pub(super) fn update(&mut self, interrupt: &LineInterrupt, is_set: bool, bits: u32) -> bool { + if let Some(shared_status) = &self.shared_status { + if is_set { + let old_status = shared_status + .fetch_or(bits) + .expect("validated shared interrupt-status memory became inaccessible"); + if old_status == 0 { + interrupt.set_level(true); + interrupt.set_level(false); + } + } + return true; + } + + if bits & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER != 0 { + if is_set { + self.used_buffer_generation = self.used_buffer_generation.wrapping_add(1); + } else { + self.observed_used_buffer_generation = None; + } + } + false + } +} + +#[derive(Inspect)] +struct SharedInterruptStatus { + #[inspect(skip)] + guest_memory: GuestMemory, + #[inspect(hex)] + gpa: u64, +} + +impl SharedInterruptStatus { + fn update(&self, operation: impl Fn(u32) -> u32) -> Result { + let mut current = self.guest_memory.read_plain::(self.gpa)?; + loop { + let new = operation(current); + match self.guest_memory.compare_exchange(self.gpa, current, new)? { + Ok(_) => return Ok(current), + Err(actual) => current = actual, + } + } + } + + fn load(&self) -> Result { + self.update(|current| current) + } + + fn store(&self, value: u32) -> Result { + self.update(|_| value) + } + + fn fetch_or(&self, bits: u32) -> Result { + self.update(|current| current | bits) + } +} + +/// Validates the interrupt mode of a new transport and returns its +/// interrupt-status state and its `INTERRUPT_ACK` doorbell setup. +pub(super) fn setup( + device: &dyn DynVirtioDevice, + guest_memory: &GuestMemory, + interrupt_mode: VirtioMmioInterruptMode, +) -> std::io::Result<(StatusDelivery, AckDoorbellSetup)> { + #[cfg(target_os = "linux")] + let supports_accelerated_doorbells = device.supports_accelerated_doorbells(); + #[cfg(not(target_os = "linux"))] + let _ = device; + let shared_status = match interrupt_mode { + VirtioMmioInterruptMode::Legacy => None, + VirtioMmioInterruptMode::SharedStatus { status_gpa } => { + if !status_gpa.is_multiple_of(size_of::() as u64) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("shared interrupt-status GPA {status_gpa:#x} is not naturally aligned"), + )); + } + let shared_status = SharedInterruptStatus { + guest_memory: guest_memory.clone(), + gpa: status_gpa, + }; + shared_status.store(0).map_err(|error| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("shared interrupt-status GPA {status_gpa:#x} is inaccessible: {error}"), + ) + })?; + Some(shared_status) + } + }; + Ok(( + StatusDelivery { + used_buffer_generation: 0, + observed_used_buffer_generation: None, + shared_status, + }, + AckDoorbellSetup { + #[cfg(target_os = "linux")] + enabled: interrupt_mode == VirtioMmioInterruptMode::Legacy + && supports_accelerated_doorbells, + }, + )) +} + +/// Whether a new transport accelerates used-buffer acknowledgements. +pub(super) struct AckDoorbellSetup { + #[cfg(target_os = "linux")] + enabled: bool, +} + +impl AckDoorbellSetup { + /// Registers the `INTERRUPT_ACK` doorbell, if enabled and available. + #[cfg(target_os = "linux")] + pub(super) fn register( + self, + driver: &impl MmioDriver, + doorbell_registration: &Option>, + mmio_gpa: u64, + interrupt_state: &Arc>, + ) -> InterruptAck { + let (interrupt_ack_event, interrupt_ack_task, interrupt_ack_doorbell) = if self.enabled + && let Some(registration) = doorbell_registration + { + let event = Event::new(); + match registration.register_doorbell( + mmio_gpa + VirtioMmioRegister::INTERRUPT_ACK.0 as u64, + Some(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER.into()), + Some(4), + &event, + ) { + Ok(doorbell) => match driver.new_dyn_fd_ready(event.as_fd().as_raw_fd()) { + Ok(ready) => { + let interrupt_ack_event = event.clone(); + let task = driver.spawn( + "virtio-mmio-interrupt-ack", + InterruptAckWait { + ready, + event, + interrupt_state: interrupt_state.clone(), + } + .run(), + ); + (Some(interrupt_ack_event), Some(task), Some(doorbell)) + } + Err(_) => (None, None, None), + }, + Err(_) => (None, None, None), + } + } else { + (None, None, None) + }; + InterruptAck { + _task: interrupt_ack_task, + _doorbell: interrupt_ack_doorbell, + event: interrupt_ack_event, + } + } + + /// Registers the `INTERRUPT_ACK` doorbell, if enabled and available. + #[cfg(not(target_os = "linux"))] + pub(super) fn register( + self, + _driver: &impl MmioDriver, + _doorbell_registration: &Option>, + _mmio_gpa: u64, + _interrupt_state: &Arc>, + ) -> InterruptAck { + InterruptAck {} + } +} + +/// The accelerated `INTERRUPT_ACK` doorbell of a transport, if registered. +#[derive(Default)] +pub(super) struct InterruptAck { + #[cfg(target_os = "linux")] + _task: Option>, + #[cfg(target_os = "linux")] + _doorbell: Option>, + #[cfg(target_os = "linux")] + event: Option, +} + +impl InterruptAck { + /// Applies a used-buffer acknowledgement that arrived through the doorbell. + fn acknowledge(&self, state: &mut InterruptState) { + #[cfg(target_os = "linux")] + if let Some(event) = &self.event { + if event.try_wait() { + state.acknowledge_used_buffer(); + } + } + #[cfg(not(target_os = "linux"))] + let _ = state; + } +} + +#[cfg(target_os = "linux")] +struct InterruptAckWait { + ready: PollImpl, + event: Event, + interrupt_state: Arc>, +} + +#[cfg(target_os = "linux")] +impl InterruptAckWait { + async fn run(mut self) { + loop { + poll_fn(|cx| { + self.ready + .poll_fd_ready(cx, InterestSlot::Read, PollEvents::IN) + }) + .await; + self.ready.clear_fd_ready(InterestSlot::Read); + let mut state = self.interrupt_state.lock(); + if self.event.try_wait() { + state.acknowledge_used_buffer(); + } + } + } +} + +impl InterruptState { + fn read_status(&mut self) -> u32 { + if let Some(shared_status) = &self.delivery.shared_status { + return shared_status + .load() + .expect("validated shared interrupt-status memory became inaccessible"); + } + if self.status & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER != 0 { + self.delivery.observed_used_buffer_generation = + Some(self.delivery.used_buffer_generation); + } + self.status + } + + #[cfg(target_os = "linux")] + fn acknowledge_used_buffer(&mut self) { + if self.delivery.shared_status.is_some() { + return; + } + if self.delivery.observed_used_buffer_generation + == Some(self.delivery.used_buffer_generation) + { + self.update(false, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + } else { + self.delivery.observed_used_buffer_generation = None; + } + } + + /// Restores the saved interrupt status. + pub(super) fn restore_status(&mut self, status: u32) -> Result<(), RestoreError> { + if let Some(shared_status) = &self.delivery.shared_status { + shared_status + .store(status) + .map_err(|error| RestoreError::InvalidSavedState(error.into()))?; + self.interrupt.set_level(false); + } else { + self.status = status; + self.delivery.used_buffer_generation = 0; + self.delivery.observed_used_buffer_generation = + (self.status & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER != 0).then_some(0); + self.interrupt.set_level(self.status != 0); + } + Ok(()) + } +} + +impl MmioTransport { + fn lock_interrupt_state(&self) -> parking_lot::MutexGuard<'_, InterruptState> { + let mut state = self.interrupt_state.lock(); + self.interrupt_ack.acknowledge(&mut state); + state + } + + pub(super) fn read_interrupt_status(&self) -> u32 { + self.lock_interrupt_state().read_status() + } + + pub(super) fn reset_interrupt_state(&self) { + let mut state = self.lock_interrupt_state(); + if let Some(shared_status) = &state.delivery.shared_status { + shared_status + .store(0) + .expect("validated shared interrupt-status memory became inaccessible"); + } + state.status = 0; + state.delivery.used_buffer_generation = 0; + state.delivery.observed_used_buffer_generation = None; + state.interrupt.set_level(false); + } +} + +impl Drop for MmioTransport { + fn drop(&mut self) { + let state = self.interrupt_state.lock(); + if let Some(shared_status) = &state.delivery.shared_status { + let _ = shared_status.store(0); + } + state.interrupt.set_level(false); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::spec::VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE; + use std::sync::Barrier; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use vmcore::line_interrupt::LineSetTarget; + + #[derive(Default)] + struct CountingInterruptTarget { + high: AtomicBool, + pulses: AtomicUsize, + } + + impl LineSetTarget for CountingInterruptTarget { + fn set_irq(&self, _vector: u32, high: bool) { + self.high.store(high, Ordering::SeqCst); + if high { + self.pulses.fetch_add(1, Ordering::SeqCst); + } + } + } + + fn shared_interrupt_state( + guest_memory: &GuestMemory, + target: Arc, + ) -> InterruptState { + InterruptState { + interrupt: LineInterrupt::new_with_target("shared-status-test", target, 0), + status: 0, + delivery: StatusDelivery { + used_buffer_generation: 0, + observed_used_buffer_generation: None, + shared_status: Some(SharedInterruptStatus { + guest_memory: guest_memory.clone(), + gpa: 0, + }), + }, + } + } + + #[test] + fn shared_status_coalesces_bits_and_pulses_on_zero_transition() { + let guest_memory = GuestMemory::allocate(0x1000); + let target = Arc::new(CountingInterruptTarget::default()); + let mut state = shared_interrupt_state(&guest_memory, target.clone()); + + state.update(true, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + state.update(true, VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE); + state.update(true, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + + assert_eq!(state.read_status(), 3); + assert_eq!(target.pulses.load(Ordering::SeqCst), 1); + assert!(!target.high.load(Ordering::SeqCst)); + + let consumed = state + .delivery + .shared_status + .as_ref() + .unwrap() + .store(0) + .unwrap(); + assert_eq!(consumed, 3); + state.update(true, VIRTIO_MMIO_INTERRUPT_STATUS_CONFIG_CHANGE); + assert_eq!(target.pulses.load(Ordering::SeqCst), 2); + } + + #[test] + fn shared_status_completion_racing_exchange_is_not_lost() { + const ITERATIONS: usize = 1000; + + let guest_memory = GuestMemory::allocate(0x1000); + let shared_status = Arc::new(SharedInterruptStatus { + guest_memory, + gpa: 0, + }); + let barrier = Arc::new(Barrier::new(2)); + let publisher = { + let shared_status = shared_status.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + for _ in 0..ITERATIONS { + barrier.wait(); + shared_status + .fetch_or(VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER) + .unwrap(); + barrier.wait(); + } + }) + }; + + for _ in 0..ITERATIONS { + shared_status.store(0).unwrap(); + barrier.wait(); + let consumed = shared_status.store(0).unwrap(); + barrier.wait(); + let pending = shared_status.load().unwrap(); + assert_eq!( + (consumed | pending) & VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER, + VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER + ); + } + publisher.join().unwrap(); + } + + #[test] + fn shared_status_reset_clears_pending_state_without_interrupt() { + let guest_memory = GuestMemory::allocate(0x1000); + let target = Arc::new(CountingInterruptTarget::default()); + let state = MmioTransport { + fixed_mmio_region: ("test", 0..=0xfff), + device_id: 0, + vendor_id: 0, + interrupt_state: Arc::new(Mutex::new(shared_interrupt_state( + &guest_memory, + target.clone(), + ))), + interrupt_ack: InterruptAck::default(), + }; + state + .interrupt_state + .lock() + .update(true, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + let pulses = target.pulses.load(Ordering::SeqCst); + + state.reset_interrupt_state(); + + assert_eq!(state.read_interrupt_status(), 0); + assert_eq!(target.pulses.load(Ordering::SeqCst), pulses); + assert!(!target.high.load(Ordering::SeqCst)); + } + + #[test] + fn shared_status_teardown_clears_pending_state() { + let guest_memory = GuestMemory::allocate(0x1000); + let target = Arc::new(CountingInterruptTarget::default()); + let transport = MmioTransport { + fixed_mmio_region: ("test", 0..=0xfff), + device_id: 0, + vendor_id: 0, + interrupt_state: Arc::new(Mutex::new(shared_interrupt_state( + &guest_memory, + target.clone(), + ))), + interrupt_ack: InterruptAck::default(), + }; + transport + .interrupt_state + .lock() + .update(true, VIRTIO_MMIO_INTERRUPT_STATUS_USED_BUFFER); + + drop(transport); + + assert_eq!(guest_memory.read_plain::(0).unwrap(), 0); + assert!(!target.high.load(Ordering::SeqCst)); + } +} diff --git a/vm/devices/virtio/virtio/src/transport/mod.rs b/vm/devices/virtio/virtio/src/transport/mod.rs index fe444f0c03f..913f06c3d52 100644 --- a/vm/devices/virtio/virtio/src/transport/mod.rs +++ b/vm/devices/virtio/virtio/src/transport/mod.rs @@ -51,5 +51,6 @@ fn validate_queue_size(queue_index: u16, size: u16) -> io::Result<()> { } pub use mmio::VirtioMmioDevice; +pub use mmio::interrupt::VirtioMmioInterruptMode; pub use pci::PciInterruptModel; pub use pci::VirtioPciDevice; From 9686e9460082c088ce286398afcc7fd9e5ad6ab9 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 05:16:11 -0700 Subject: [PATCH 101/304] openvmm: use shared interrupt status for fixed microVM slots The fixed microVM virtio-mmio slots still deliver interrupts through the legacy INTERRUPT_STATUS and INTERRUPT_ACK registers, so every virtio interrupt costs the guest two exits. Give every fixed slot a word on a shared interrupt-status page and deliver its interrupts through `VirtioMmioInterruptMode::SharedStatus`: - The page is at GPA 0x30000 and holds one naturally aligned 32-bit word per slot, in slot order from offset 0x00 (network) to 0x1c (control console). `microvm_virtio_status_gpa` maps a slot to its word. The reservation check now also requires every slot to have a distinct, ordered word inside the page. `MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES` names the four sandbox-block slots. - The worker creates each fixed-slot device in shared-status mode, and the MP-table loader reserves the page in the guest's e820 map so the guest never allocates it as RAM. The guest must use the patched kernel's shared-status virtio-mmio support. In this mode an INTERRUPT_ACK write does not clear the word, so a stock driver would never see the zero-to-nonzero transition that raises the next interrupt. A test pins the word of every slot and rejects an unknown slot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../devices/firmware/linux_direct.md | 3 +- .../src/worker/dispatch/microvm.rs | 22 +++-- openvmm/openvmm_defs/src/microvm.rs | 83 +++++++++++++++++++ openvmm/openvmm_entry/src/cli_args/microvm.rs | 2 +- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/Guide/src/reference/devices/firmware/linux_direct.md b/Guide/src/reference/devices/firmware/linux_direct.md index d00133b2b4c..6db44351d7e 100644 --- a/Guide/src/reference/devices/firmware/linux_direct.md +++ b/Guide/src/reference/devices/firmware/linux_direct.md @@ -66,7 +66,8 @@ IOAPIC, PCI bus, VMBus, virtio-mmio, RTC, etc.). For `--machine microvm`, steps 4 and 5 are replaced by the MP-table layout described above. The zero page leaves `acpi_rsdp_addr` zero, reserves the ISA -hole in e820, and leaves the fixed 3-to-4-GiB MMIO aperture out of RAM. The loader enters the ELF kernel in long mode with +hole and the live shared-status page in e820, and leaves the fixed 3-to-4-GiB +MMIO aperture out of RAM. The loader enters the ELF kernel in long mode with `RSI=0x2000`, `CR3=0x4000`, and paging enabled. ## AArch64 Boot Flow diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index b6343a5211a..02108a07ddd 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -14,6 +14,7 @@ use anyhow::Context; use chipset_device_resources::IRQ_LINE_SET; use guestmem::GuestMemory; use hvdef::Vtl; +use memory_range::MemoryRange; use mesh::error::RemoteError; use mesh_worker::WorkerRpc; use openvmm_defs::microvm::MachineProfile; @@ -22,6 +23,7 @@ use openvmm_defs::rpc::VmRpc; use openvmm_defs::worker::VmWorkerParameters; use std::sync::Arc; use virtio::VirtioMmioDevice; +use virtio::VirtioMmioInterruptMode; use virtio::resolve::ResolvedVirtioDevice; use vm_loader::InitialLoad; use vmcore::vm_task::VmTaskDriverSource; @@ -74,6 +76,11 @@ pub(super) fn load_linux_x86_mptable( .vps_arch() .map(|vp| vp.apic_id) .collect::>(); + let reserved_memory_ranges = [MemoryRange::new( + openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA + ..openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA + + openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + )]; let kernel_config = crate::worker::vm_loaders::linux::KernelConfig { kernel, initrd, @@ -87,7 +94,7 @@ pub(super) fn load_linux_x86_mptable( &vm.gm, &apic_ids, &openvmm_defs::microvm::MICROVM_LEVEL_TRIGGERED_IRQS, - &[], + &reserved_memory_ranges, )?) } @@ -132,7 +139,7 @@ impl LoadedVm { fn virtio_mmio_config( id: &str, chipset_mmio: ChipsetMmioRanges, -) -> anyhow::Result<(u64, u64, u32, u64)> { +) -> anyhow::Result<(u64, u64, u32, u64, VirtioMmioInterruptMode)> { let (start, irq) = match id { "virtio-console" => ( openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, @@ -150,7 +157,11 @@ fn virtio_mmio_config( ); // The fixed slots expose split rings only. let disabled_features = 1 << 34; - Ok((start, len, irq, disabled_features)) + let interrupt_mode = VirtioMmioInterruptMode::SharedStatus { + status_gpa: openvmm_defs::microvm::microvm_virtio_status_gpa(start) + .context("microVM slot has no shared-status word")?, + }; + Ok((start, len, irq, disabled_features, interrupt_mode)) } /// Assigns the fixed virtio-mmio slots of the microVM profile to devices, in @@ -174,12 +185,12 @@ impl VirtioMmioSlots { id: &str, device: ResolvedVirtioDevice, ) -> anyhow::Result<()> { - let (mmio_start, mmio_len, irq, disabled_features) = + let (mmio_start, mmio_len, irq, disabled_features, interrupt_mode) = virtio_mmio_config(id, self.chipset_mmio)?; let id = format!("{id}-{mmio_start}"); let gm = gm.clone(); chipset_builder.arc_mutex_device(id).try_add(|services| { - VirtioMmioDevice::new_with_disabled_features( + VirtioMmioDevice::new_with_disabled_features_and_interrupt_mode( device.0, &driver_source.simple(), gm, @@ -188,6 +199,7 @@ impl VirtioMmioSlots { mmio_start, mmio_len, disabled_features, + interrupt_mode, ) })?; Ok(()) diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 815c179cf97..d160986fc09 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -73,6 +73,49 @@ pub const MICROVM_VIRTIO_MMIO_BASES: [u64; 8] = [ 0xd000_6000, MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, ]; +/// Fixed sandbox virtio-blk MMIO slots in layer order. +pub const MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES: [u64; 4] = [ + MICROVM_VIRTIO_BLK_MMIO_BASE, + 0xd000_4000, + 0xd000_5000, + 0xd000_6000, +]; +/// Guest-physical base of the shared virtio interrupt-status page. +pub const MICROVM_SHARED_STATUS_PAGE_GPA: u64 = 0x3_0000; +/// Size of the shared virtio interrupt-status page. +pub const MICROVM_SHARED_STATUS_PAGE_SIZE: u64 = 0x1000; +/// Shared-status offset for virtio-net. +pub const MICROVM_VIRTIO_NET_STATUS_OFFSET: u64 = 0x00; +/// Shared-status offset for virtio-fs. +pub const MICROVM_VIRTIO_FS_STATUS_OFFSET: u64 = 0x04; +/// Shared-status offset for virtio-console. +pub const MICROVM_VIRTIO_CONSOLE_STATUS_OFFSET: u64 = 0x08; +/// Shared-status offset for the distro virtio-blk slot. +pub const MICROVM_VIRTIO_DISTRO_BLK_STATUS_OFFSET: u64 = 0x0c; +/// Shared-status offset for the runtime virtio-blk slot. +pub const MICROVM_VIRTIO_RUNTIME_BLK_STATUS_OFFSET: u64 = 0x10; +/// Shared-status offset for the custom virtio-blk slot. +pub const MICROVM_VIRTIO_CUSTOM_BLK_STATUS_OFFSET: u64 = 0x14; +/// Shared-status offset for the scratch virtio-blk slot. +pub const MICROVM_VIRTIO_SCRATCH_BLK_STATUS_OFFSET: u64 = 0x18; +/// Shared-status offset for the dedicated control virtio-console. +pub const MICROVM_VIRTIO_CONTROL_CONSOLE_STATUS_OFFSET: u64 = 0x1c; + +/// Returns the shared interrupt-status word for a fixed virtio-mmio slot. +pub const fn microvm_virtio_status_gpa(mmio_base: u64) -> Option { + let offset = match mmio_base { + MICROVM_VIRTIO_NET_MMIO_BASE => MICROVM_VIRTIO_NET_STATUS_OFFSET, + MICROVM_VIRTIO_FS_MMIO_BASE => MICROVM_VIRTIO_FS_STATUS_OFFSET, + MICROVM_VIRTIO_CONSOLE_MMIO_BASE => MICROVM_VIRTIO_CONSOLE_STATUS_OFFSET, + MICROVM_VIRTIO_BLK_MMIO_BASE => MICROVM_VIRTIO_DISTRO_BLK_STATUS_OFFSET, + 0xd000_4000 => MICROVM_VIRTIO_RUNTIME_BLK_STATUS_OFFSET, + 0xd000_5000 => MICROVM_VIRTIO_CUSTOM_BLK_STATUS_OFFSET, + 0xd000_6000 => MICROVM_VIRTIO_SCRATCH_BLK_STATUS_OFFSET, + MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE => MICROVM_VIRTIO_CONTROL_CONSOLE_STATUS_OFFSET, + _ => return None, + }; + Some(MICROVM_SHARED_STATUS_PAGE_GPA + offset) +} /// The microVM publishes no level-triggered virtio IRQs in its MP table. pub const MICROVM_LEVEL_TRIGGERED_IRQS: [u32; 0] = []; @@ -90,6 +133,24 @@ fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { anyhow::ensure!(end <= *next, "microVM virtio MMIO reservations overlap"); } } + let mut previous_status_gpa = None; + for base in bases { + let status_gpa = microvm_virtio_status_gpa(*base).ok_or_else(|| { + anyhow::anyhow!("microVM virtio MMIO slot {base:#x} has no shared-status word") + })?; + anyhow::ensure!( + status_gpa.is_multiple_of(size_of::() as u64) + && status_gpa >= MICROVM_SHARED_STATUS_PAGE_GPA + && status_gpa + size_of::() as u64 + <= MICROVM_SHARED_STATUS_PAGE_GPA + MICROVM_SHARED_STATUS_PAGE_SIZE, + "microVM shared-status word for slot {base:#x} is outside the reserved page" + ); + anyhow::ensure!( + previous_status_gpa.is_none_or(|previous| previous < status_gpa), + "microVM shared-status words are duplicated or out of order" + ); + previous_status_gpa = Some(status_gpa); + } Ok(()) } @@ -609,4 +670,26 @@ mod tests { assert_eq!(MICROVM_VIRTIO_RUNTIME_BLK_IRQ, 12); assert!(MICROVM_LEVEL_TRIGGERED_IRQS.is_empty()); } + + #[test] + fn microvm_shared_status_slots_are_stable() { + let slots = [ + (MICROVM_VIRTIO_NET_MMIO_BASE, 0x3_0000), + (MICROVM_VIRTIO_FS_MMIO_BASE, 0x3_0004), + (MICROVM_VIRTIO_CONSOLE_MMIO_BASE, 0x3_0008), + (MICROVM_VIRTIO_BLK_MMIO_BASE, 0x3_000c), + (MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[1], 0x3_0010), + (MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[2], 0x3_0014), + (MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[3], 0x3_0018), + (MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, 0x3_001c), + ]; + for (mmio_base, expected_gpa) in slots { + assert_eq!(microvm_virtio_status_gpa(mmio_base), Some(expected_gpa)); + assert_eq!(expected_gpa % size_of::() as u64, 0); + assert!( + expected_gpa < MICROVM_SHARED_STATUS_PAGE_GPA + MICROVM_SHARED_STATUS_PAGE_SIZE + ); + } + assert_eq!(microvm_virtio_status_gpa(0xd000_8000), None); + } } diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 25db08e241a..9425922bbec 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -15,7 +15,7 @@ use openvmm_defs::microvm::MachineProfile; pub enum MachineProfileCli { /// The standard OpenVMM machine. Standard, - /// The microVM fixed-topology machine. + /// The microVM fixed-topology shared-status machine. Microvm, } From 2ce524409e995367584a8fd683e592881d02a19e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 05:21:56 -0700 Subject: [PATCH 102/304] vmm_core: stop VPs at a deferred-I/O boundary A guest can request a snapshot by writing to an I/O port. For the snapshot to be exact, every VP must be stopped after that write has been accepted but before the issuing VP resumes guest execution: capturing earlier loses the request, and letting the VP run past the write lets the guest change state that the snapshot claims to contain. Add `PartitionUnit::temporarily_stop_vps_at_io_boundary`. The caller passes a sender that releases the deferred I/O completion and a receiver that reports that the completion has run. The partition unit: 1. Queues a stop event for every running VP, and marks the set stopped. 2. Releases the deferred I/O. Every VP has already observed its stop event, so the VP that issued the I/O cannot re-enter the guest. 3. Waits for the I/O completion, then for every VP to stop, even when the completion channel closed or a VP runner was dropped. The returned `StopGuard` holds the VPs stopped. Failure is terminal: the stop reference is kept, so a failed boundary cannot be restarted before the partition is torn down. `VpSet::stop` now marks the set stopped as soon as it has queued the stop events. Dropping a stop mid-wait therefore no longer leaves the set marked running, and a later stop does not queue duplicate stop events. The partition unit's snapshot request is no longer x86-64 only; the TSC advance remains x86-64 only. Tests cover a successful and a failed boundary, the ordering of the I/O completion and the VP stops, a dropped VP runner, and a boundary on already-stopped VPs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vmm_core/src/partition_unit.rs | 3 - vmm_core/src/partition_unit/snapshot.rs | 37 +++- vmm_core/src/partition_unit/vp_set.rs | 8 +- .../src/partition_unit/vp_set/boundary.rs | 203 ++++++++++++++++++ 4 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 vmm_core/src/partition_unit/vp_set/boundary.rs diff --git a/vmm_core/src/partition_unit.rs b/vmm_core/src/partition_unit.rs index b1320ef2c16..3a30c1338a8 100644 --- a/vmm_core/src/partition_unit.rs +++ b/vmm_core/src/partition_unit.rs @@ -4,7 +4,6 @@ //! State unit for managing the VM partition and associated virtual processors. mod debug; -#[cfg(guest_arch = "x86_64")] mod snapshot; mod vp_set; @@ -132,7 +131,6 @@ enum PartitionRequest { SetInitialRegs(Rpc<(Vtl, Arc), Result<(), InitialRegError>>), AcceptInitialPages(Rpc, Result<(), AcceptInitialPagesError>>), StopVps(Rpc<(), ()>), - #[cfg(guest_arch = "x86_64")] Snapshot(snapshot::SnapshotRequest), StartVps, /// Build the partition state blob for a dump file. @@ -383,7 +381,6 @@ impl PartitionUnitRunner { PartitionRequest::StopVps(rpc) => { rpc.handle(async |()| self.stop_vps().await).await } - #[cfg(guest_arch = "x86_64")] PartitionRequest::Snapshot(req) => self.handle_snapshot(req).await, PartitionRequest::StartVps => { self.resume_vps(); diff --git a/vmm_core/src/partition_unit/snapshot.rs b/vmm_core/src/partition_unit/snapshot.rs index b4087127086..2620e38f5b0 100644 --- a/vmm_core/src/partition_unit/snapshot.rs +++ b/vmm_core/src/partition_unit/snapshot.rs @@ -1,21 +1,44 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Partition unit support for snapshots: advancing TSC after restore downtime. +//! Partition unit support for snapshots: stopping VPs at a deferred I/O +//! boundary for capture, and advancing TSC after restore downtime. use super::PartitionRequest; use super::PartitionUnit; use super::PartitionUnitRunner; +use super::StopGuard; use mesh::rpc::FailableRpc; use mesh::rpc::RpcSend; /// Snapshot requests handled by the partition unit runner. pub(super) enum SnapshotRequest { + StopVpsAtIoBoundary(FailableRpc<(mesh::OneshotSender<()>, mesh::OneshotReceiver<()>), ()>), + #[cfg(guest_arch = "x86_64")] AdvanceTsc(FailableRpc<(std::time::Duration, u64, Option), ()>), } impl PartitionUnit { + /// Stops VPs after queuing stop events but before completing deferred I/O. + /// + /// Failure is terminal: the caller must tear down the partition instead of + /// attempting to resume it. An I/O-completion error retains the stop reference. + pub async fn temporarily_stop_vps_at_io_boundary( + &mut self, + release_io: mesh::OneshotSender<()>, + io_completed: mesh::OneshotReceiver<()>, + ) -> anyhow::Result { + self.req_send + .call_failable( + |rpc| PartitionRequest::Snapshot(SnapshotRequest::StopVpsAtIoBoundary(rpc)), + (release_io, io_completed), + ) + .await?; + Ok(StopGuard(self.req_send.clone())) + } + /// Advances TSC state on all stopped vCPUs. + #[cfg(guest_arch = "x86_64")] pub async fn advance_tsc( &mut self, duration: std::time::Duration, @@ -36,6 +59,18 @@ impl PartitionUnitRunner { /// Handles a snapshot request from [`PartitionUnit`]. pub(super) async fn handle_snapshot(&mut self, request: SnapshotRequest) { match request { + SnapshotRequest::StopVpsAtIoBoundary(rpc) => { + rpc.handle_failable(async |(release_io, io_completed)| { + // Keep the stop reference on failure too: a failed + // boundary must not be restarted before teardown. + self.vp_stop_count += 1; + self.vp_set + .stop_at_io_boundary(release_io, io_completed) + .await + }) + .await + } + #[cfg(guest_arch = "x86_64")] SnapshotRequest::AdvanceTsc(rpc) => { rpc.handle_failable(async |(duration, frequency_hz, apic_frequency_hz)| { self.vp_set diff --git a/vmm_core/src/partition_unit/vp_set.rs b/vmm_core/src/partition_unit/vp_set.rs index e753036d7bb..829d29e50bc 100644 --- a/vmm_core/src/partition_unit/vp_set.rs +++ b/vmm_core/src/partition_unit/vp_set.rs @@ -3,6 +3,7 @@ //! Virtual processor state management. +mod boundary; #[cfg(guest_arch = "x86_64")] mod tsc; @@ -843,7 +844,8 @@ impl VpSet { /// Stops all VPs. pub async fn stop(&mut self) { if self.started { - self.vps + let stops = self + .vps .iter() .map(|vp| { let (send, recv) = mesh::oneshot(); @@ -851,9 +853,9 @@ impl VpSet { // Ignore VPs whose runners have been dropped. async { recv.await.ok() } }) - .collect::>() - .await; + .collect::>(); self.started = false; + stops.await; } } diff --git a/vmm_core/src/partition_unit/vp_set/boundary.rs b/vmm_core/src/partition_unit/vp_set/boundary.rs new file mode 100644 index 00000000000..406eb3cdc71 --- /dev/null +++ b/vmm_core/src/partition_unit/vp_set/boundary.rs @@ -0,0 +1,203 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The snapshot I/O boundary: queuing a stop for every VP before releasing a +//! deferred I/O completion, so the VP that issued the I/O cannot re-enter the +//! guest. + +use super::VpEvent; +use super::VpSet; +use anyhow::Context as _; +use futures::future::JoinAll; + +impl VpSet { + /// Stops all VPs at a deferred I/O completion boundary. + pub async fn stop_at_io_boundary( + &mut self, + release_io: mesh::OneshotSender<()>, + io_completed: mesh::OneshotReceiver<()>, + ) -> anyhow::Result<()> { + let stops = self.started.then(|| { + let stops = self + .vps + .iter() + .map(|vp| { + let (send, recv) = mesh::oneshot(); + vp.send.send(VpEvent::Stop(send)); + async { recv.await.ok() } + }) + .collect::>(); + self.started = false; + stops + }); + + // Every VP has observed a queued stop event before the deferred I/O is + // completed, so the originating VP cannot re-enter guest execution. + release_io.send(()); + let io_result = io_completed + .await + .context("deferred I/O completion channel closed"); + // `started` is already false, so a later stop cannot join these requests. + // Drain them even if the I/O peer failed before acknowledging completion. + if let Some(stops) = stops { + stops.await; + } + io_result + } +} + +#[cfg(test)] +mod tests { + use crate::partition_unit::vp_set::*; + use test_with_tracing::test; + use vm_topology::processor::VpInfo; + + fn vp_set(count: u32) -> (VpSet, Vec) { + let (halt, _halt_recv) = Halt::new(); + let mut vps = VpSet::new([None, None, None], Arc::new(halt)); + let runners = (0..count) + .map(|index| { + vps.add(TargetVpInfo { + base: VpInfo { + vp_index: VpIndex::new(index), + vnode: 0, + }, + #[cfg(guest_arch = "x86_64")] + apic_id: index, + #[cfg(guest_arch = "aarch64")] + mpidr: u64::from(index).into(), + #[cfg(guest_arch = "aarch64")] + gicr: None, + #[cfg(guest_arch = "aarch64")] + pmu_gsiv: None, + }) + }) + .collect(); + (vps, runners) + } + + fn boundary_waits_for_all_stops(io_succeeds: bool) { + let (mut vps, mut runners) = vp_set(2); + vps.start(); + for runner in &mut runners { + assert!(matches!(runner.recv.try_recv(), Ok(VpEvent::Start))); + } + + let (release, released) = mesh::oneshot(); + let (complete, completed) = mesh::oneshot(); + let mut boundary = Box::pin(vps.stop_at_io_boundary(release, completed)); + assert!(boundary.as_mut().now_or_never().is_none()); + released.now_or_never().unwrap().unwrap(); + let mut stops = runners.iter_mut().map(|runner| { + let Ok(VpEvent::Stop(stop)) = runner.recv.try_recv() else { + panic!("expected a stop request before releasing I/O"); + }; + stop + }); + let first_stop = stops.next().unwrap(); + let second_stop = stops.next().unwrap(); + if io_succeeds { + complete.send(()); + } else { + drop(complete); + } + assert!(boundary.as_mut().now_or_never().is_none()); + first_stop.send(()); + assert!(boundary.as_mut().now_or_never().is_none()); + second_stop.send(()); + let result = boundary.as_mut().now_or_never().unwrap(); + if io_succeeds { + result.unwrap(); + } else { + assert_eq!( + result.unwrap_err().to_string(), + "deferred I/O completion channel closed" + ); + } + drop(boundary); + assert!(!vps.started); + assert!(vps.stop().now_or_never().is_some()); + for runner in &mut runners { + assert!(runner.recv.try_recv().is_err()); + } + } + + #[test] + fn successful_boundary_waits_for_all_stops() { + boundary_waits_for_all_stops(true); + } + + #[test] + fn failed_boundary_waits_for_all_stops() { + boundary_waits_for_all_stops(false); + } + + #[test] + fn boundary_waits_for_io_completion_after_all_stops() { + for io_succeeds in [true, false] { + let (mut vps, mut runners) = vp_set(1); + vps.start(); + assert!(matches!(runners[0].recv.try_recv(), Ok(VpEvent::Start))); + + let (release, released) = mesh::oneshot(); + let (complete, completed) = mesh::oneshot(); + let mut boundary = Box::pin(vps.stop_at_io_boundary(release, completed)); + assert!(boundary.as_mut().now_or_never().is_none()); + released.now_or_never().unwrap().unwrap(); + let Ok(VpEvent::Stop(stop)) = runners[0].recv.try_recv() else { + panic!("expected a stop request before releasing I/O"); + }; + stop.send(()); + assert!(boundary.as_mut().now_or_never().is_none()); + + if io_succeeds { + complete.send(()); + } else { + drop(complete); + } + let result = boundary.as_mut().now_or_never().unwrap(); + assert_eq!(result.is_ok(), io_succeeds); + drop(boundary); + assert!(!vps.started); + } + } + + #[test] + fn failed_boundary_tolerates_dropped_runner() { + let (mut vps, mut runners) = vp_set(2); + vps.start(); + for runner in &mut runners { + assert!(matches!(runner.recv.try_recv(), Ok(VpEvent::Start))); + } + let (release, released) = mesh::oneshot(); + let (complete, completed) = mesh::oneshot(); + let mut boundary = Box::pin(vps.stop_at_io_boundary(release, completed)); + assert!(boundary.as_mut().now_or_never().is_none()); + released.now_or_never().unwrap().unwrap(); + drop(complete); + assert!(boundary.as_mut().now_or_never().is_none()); + drop(runners.pop().unwrap()); + assert!(boundary.as_mut().now_or_never().is_none()); + let Ok(VpEvent::Stop(stop)) = runners[0].recv.try_recv() else { + panic!("expected a stop request before releasing I/O"); + }; + stop.send(()); + assert!(boundary.as_mut().now_or_never().unwrap().is_err()); + drop(boundary); + drop(runners); + assert!(vps.teardown().now_or_never().is_some()); + } + + #[test] + fn stopped_boundary_still_checks_io_completion() { + let (mut vps, mut runners) = vp_set(1); + let (release, released) = mesh::oneshot(); + let (complete, completed) = mesh::oneshot(); + let mut boundary = Box::pin(vps.stop_at_io_boundary(release, completed)); + assert!(boundary.as_mut().now_or_never().is_none()); + released.now_or_never().unwrap().unwrap(); + assert!(runners[0].recv.try_recv().is_err()); + drop(complete); + assert!(boundary.as_mut().now_or_never().unwrap().is_err()); + } +} From c8da54eec96a9c78bb9e65ee460c06143be8972a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 05:27:28 -0700 Subject: [PATCH 103/304] chipset: add the microVM snapshot-request port A microVM guest requests a snapshot of itself by writing to port 0x605. The snapshot must reflect the instant just after that write, so the write cannot complete until the VMM has stopped every vCPU; the issuing vCPU must not execute another guest instruction first. Add `MicrovmSnapshotRequest`, a write-only port at 0x605. When it has a notification target, a write: - Defers the port write and sends a `MicrovmSnapshotBoundaryRequest` to the target. The request carries a sender that releases the write, a receiver that reports that the write has completed, the maximum time allowed to gate host input before the vCPUs stop, and an RPC that the target completes once the snapshot transaction's outcome is final. - Completes the deferred write when the target releases it, and signals that completion. - Coalesces duplicate writes while a transaction is pending, with a rate-limited warning. A completed transaction is reaped before the next write, so the guest can request another snapshot even before the device's poll routine runs. Without a notification target, writes complete immediately and are ignored. Reads return all ones. Stop and reset fail a deferred write that has not been released, and keep a released transaction pending until it completes. The device has no saved state. Add `MicrovmSnapshotRequestHandle`, its resolver, and the resolver's registration for x86_64 guests. Nothing instantiates the device yet. Tests cover coalescing until acknowledgement, reuse after a completed or closed transaction, an early completion, the poll waker across duplicate requests, stop and reset with and without a released write, and the nonblocking behavior without a target. Co-authored-by: Ruize Tang <1466040111@qq.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_resources/src/lib.rs | 2 + vm/devices/chipset/src/microvm.rs | 444 ++++++++++++++++++++ vm/devices/chipset/src/microvm/resolver.rs | 27 ++ vm/devices/chipset_resources/src/microvm.rs | 26 ++ 4 files changed, 499 insertions(+) diff --git a/openvmm/openvmm_resources/src/lib.rs b/openvmm/openvmm_resources/src/lib.rs index ab83adbab28..53c6272b869 100644 --- a/openvmm/openvmm_resources/src/lib.rs +++ b/openvmm/openvmm_resources/src/lib.rs @@ -30,6 +30,8 @@ vm_resource::register_static_resolvers! { #[cfg(guest_arch = "x86_64")] chipset::microvm::resolver::MicrovmShutdownResolver, #[cfg(guest_arch = "x86_64")] + chipset::microvm::resolver::MicrovmSnapshotRequestResolver, + #[cfg(guest_arch = "x86_64")] chipset::pic::resolver::PicResolver, #[cfg(guest_arch = "x86_64")] chipset::ioapic::resolver::GenericIoApicResolver, diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index 07a04c8c82b..f09261264cf 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -8,6 +8,8 @@ pub mod resolver; use chipset_device::ChipsetDevice; use chipset_device::io::IoError; use chipset_device::io::IoResult; +use chipset_device::io::deferred::DeferredWrite; +use chipset_device::io::deferred::defer_write; use chipset_device::pio::PortIoIntercept; use chipset_device::poll_device::PollDevice; use futures::AsyncRead; @@ -34,6 +36,7 @@ use vmcore::save_restore::SavedStateRoot; const DATA_PORT: u16 = 0xe9; const STATUS_PORT: u16 = 0xea; const SHUTDOWN_PORT: u16 = 0x604; +const SNAPSHOT_PORT: u16 = 0x605; const STATUS_INPUT_AVAILABLE: u8 = 1 << 0; const BUFFER_MAX: usize = 1024 * 1024; @@ -331,6 +334,180 @@ impl SaveRestore for MicrovmShutdown { } } +/// Snapshot-request port with at most one unacknowledged notification. +#[derive(InspectMut)] +pub struct MicrovmSnapshotRequest { + #[inspect(skip)] + io_region: (&'static str, RangeInclusive), + #[inspect(skip)] + notify: Option>, + input_gate_timeout: std::time::Duration, + #[inspect(skip)] + pending: Option, + #[inspect(skip)] + poll_waker: Option, +} + +struct PendingSnapshotWrite { + release_write: mesh::OneshotReceiver<()>, + deferred_write: Option, + write_completed: Option>, + transaction_complete: mesh::rpc::PendingRpc<()>, + write_released: bool, +} + +impl MicrovmSnapshotRequest { + /// Creates a snapshot-request device with an optional asynchronous notification target. + pub fn new( + notify: Option>, + input_gate_timeout: std::time::Duration, + ) -> Self { + Self { + io_region: ("microvm-snapshot-request", SNAPSHOT_PORT..=SNAPSHOT_PORT), + notify, + input_gate_timeout, + pending: None, + poll_waker: None, + } + } + + fn poll_pending(&mut self) { + use std::future::Future; + + let mut cx = Context::from_waker(self.poll_waker.as_ref().unwrap_or(Waker::noop())); + let Some(pending) = &mut self.pending else { + return; + }; + if !pending.write_released + && Pin::new(&mut pending.release_write) + .poll(&mut cx) + .is_ready() + { + pending.write_released = true; + if let Some(deferred_write) = pending.deferred_write.take() { + deferred_write.complete(); + } + if let Some(write_completed) = pending.write_completed.take() { + write_completed.send(()); + } + } + if Pin::new(&mut pending.transaction_complete) + .poll(&mut cx) + .is_ready() + { + self.pending = None; + } + } +} + +impl ChangeDeviceState for MicrovmSnapshotRequest { + fn start(&mut self) {} + async fn stop(&mut self) { + if self + .pending + .as_ref() + .is_some_and(|pending| pending.write_released) + { + return; + } + if let Some(mut pending) = self.pending.take() { + if let Some(deferred_write) = pending.deferred_write.take() { + deferred_write.complete_error(IoError::InvalidRegister); + } + if let Some(write_completed) = pending.write_completed.take() { + write_completed.send(()); + } + } + } + async fn reset(&mut self) { + self.stop().await; + } +} + +impl ChipsetDevice for MicrovmSnapshotRequest { + fn supports_pio(&mut self) -> Option<&mut dyn PortIoIntercept> { + Some(self) + } + + fn supports_poll_device(&mut self) -> Option<&mut dyn PollDevice> { + Some(self) + } +} + +impl PollDevice for MicrovmSnapshotRequest { + fn poll_device(&mut self, cx: &mut Context<'_>) { + self.poll_waker = Some(cx.waker().clone()); + self.poll_pending(); + } +} + +impl PortIoIntercept for MicrovmSnapshotRequest { + fn io_read(&mut self, io_port: u16, data: &mut [u8]) -> IoResult { + if io_port != SNAPSHOT_PORT { + return IoResult::Err(IoError::InvalidRegister); + } + data.fill(0xff); + IoResult::Ok + } + + fn io_write(&mut self, io_port: u16, _data: &[u8]) -> IoResult { + use mesh::rpc::RpcSend; + + if io_port != SNAPSHOT_PORT { + return IoResult::Err(IoError::InvalidRegister); + } + // A completed transaction can resume the vCPUs before the device's + // poll task runs. Reap it before deciding this write is a duplicate. + self.poll_pending(); + if self.pending.is_some() { + tracelimit::warn_ratelimited!("coalescing duplicate microVM snapshot request"); + return IoResult::Ok; + } + if let Some(notify) = &self.notify { + let (deferred_write, token) = defer_write(); + let (release_write, release_recv) = mesh::oneshot(); + let (write_completed, write_completed_recv) = mesh::oneshot(); + let transaction_complete = notify.call( + |transaction_complete| chipset_resources::microvm::MicrovmSnapshotBoundaryRequest { + release_write, + write_completed: write_completed_recv, + input_gate_timeout: self.input_gate_timeout, + transaction_complete, + }, + (), + ); + self.pending = Some(PendingSnapshotWrite { + release_write: release_recv, + deferred_write: Some(deferred_write), + write_completed: Some(write_completed), + transaction_complete, + write_released: false, + }); + if let Some(waker) = &self.poll_waker { + waker.wake_by_ref(); + } + return IoResult::Defer(token); + } + IoResult::Ok + } + + fn get_static_regions(&mut self) -> &[(&str, RangeInclusive)] { + std::slice::from_ref(&self.io_region) + } +} + +impl SaveRestore for MicrovmSnapshotRequest { + type SavedState = NoSavedState; + + fn save(&mut self) -> Result { + Ok(NoSavedState) + } + + fn restore(&mut self, NoSavedState: Self::SavedState) -> Result<(), RestoreError> { + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; @@ -544,5 +721,272 @@ mod tests { *requests.lock(), [PowerRequest::PowerOffWithStatus { code: 0x25 }] ); + + let mut snapshot = MicrovmSnapshotRequest::new(None, std::time::Duration::from_secs(1)); + let mut data = [0; 4]; + assert!(matches!( + snapshot.io_read(SNAPSHOT_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [0xff; 4]); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[1]), + IoResult::Ok + )); + } + + #[test] + fn snapshot_requests_are_coalesced_until_acknowledged() { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + snapshot.poll_device(&mut Context::from_waker(Waker::noop())); + + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[2, 3, 4, 5]), + IoResult::Ok + )); + let mut first = recv.try_recv().unwrap(); + assert!(recv.try_recv().is_err()); + assert!( + deferred_write + .poll_write(&mut Context::from_waker(Waker::noop())) + .is_pending() + ); + + first.release_write.send(()); + snapshot.poll_device(&mut Context::from_waker(Waker::noop())); + assert!( + deferred_write + .poll_write(&mut Context::from_waker(Waker::noop())) + .is_ready() + ); + assert!( + Pin::new(&mut first.write_completed) + .poll(&mut Context::from_waker(Waker::noop())) + .is_ready() + ); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Ok + )); + assert!(recv.try_recv().is_err()); + + first.transaction_complete.complete(()); + snapshot.poll_device(&mut Context::from_waker(Waker::noop())); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[]), + IoResult::Defer(_) + )); + recv.try_recv().unwrap(); + } + + #[test] + fn snapshot_request_completed_transaction_is_reusable_without_poll() { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + let mut first = recv.try_recv().unwrap(); + first.release_write.send(()); + snapshot.poll_device(&mut cx); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(matches!( + Pin::new(&mut first.write_completed).poll(&mut cx), + Poll::Ready(Ok(())) + )); + + first.transaction_complete.complete(()); + // The next write can acquire the device before its poll task runs. + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + recv.try_recv().unwrap(); + } + + #[test] + fn snapshot_request_early_completion_releases_previous_write() { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[0]) else { + panic!("snapshot write was not deferred"); + }; + let mut first = recv.try_recv().unwrap(); + first.release_write.send(()); + first.transaction_complete.complete(()); + + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[1]), + IoResult::Defer(_) + )); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(matches!( + Pin::new(&mut first.write_completed).poll(&mut cx), + Poll::Ready(Ok(())) + )); + recv.try_recv().unwrap(); + } + + #[test] + fn snapshot_request_closed_transaction_is_reusable() { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + let mut first = recv.try_recv().unwrap(); + drop(first.transaction_complete); + + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Err(IoError::NoResponse)) + )); + assert!(matches!( + Pin::new(&mut first.write_completed).poll(&mut cx), + Poll::Ready(Err(_)) + )); + recv.try_recv().unwrap(); + } + + #[test] + fn snapshot_request_duplicate_preserves_poll_waker() { + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + + #[derive(Default)] + struct WakeCount(AtomicUsize); + + impl futures::task::ArcWake for WakeCount { + fn wake_by_ref(arc_self: &Arc) { + arc_self.0.fetch_add(1, Ordering::Relaxed); + } + } + + let wakes = Arc::new(WakeCount::default()); + let waker = futures::task::waker(Arc::clone(&wakes)); + let mut cx = Context::from_waker(&waker); + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + snapshot.poll_device(&mut cx); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + let first = recv.try_recv().unwrap(); + snapshot.poll_device(&mut cx); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[1]), + IoResult::Ok + )); + wakes.0.store(0, Ordering::Relaxed); + first.release_write.send(()); + assert_ne!(wakes.0.load(Ordering::Relaxed), 0); + + snapshot.poll_device(&mut cx); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[1]), + IoResult::Ok + )); + wakes.0.store(0, Ordering::Relaxed); + first.transaction_complete.complete(()); + assert_ne!(wakes.0.load(Ordering::Relaxed), 0); + snapshot.poll_device(&mut cx); + assert!(snapshot.pending.is_none()); + } + + #[test] + fn snapshot_request_stop_and_reset_cancel_unreleased_write() { + for reset in [false, true] { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + let mut first = recv.try_recv().unwrap(); + if reset { + futures::executor::block_on(snapshot.reset()); + } else { + futures::executor::block_on(snapshot.stop()); + } + assert!(snapshot.pending.is_none()); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Err(IoError::InvalidRegister)) + )); + assert!(matches!( + Pin::new(&mut first.write_completed).poll(&mut cx), + Poll::Ready(Ok(())) + )); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + recv.try_recv().unwrap(); + } + } + + #[test] + fn snapshot_request_stop_and_reset_preserve_released_transaction() { + for reset in [false, true] { + let (send, mut recv) = mesh::channel(); + let mut snapshot = + MicrovmSnapshotRequest::new(Some(send), std::time::Duration::from_secs(1)); + let mut cx = Context::from_waker(Waker::noop()); + let IoResult::Defer(mut deferred_write) = snapshot.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("snapshot write was not deferred"); + }; + let first = recv.try_recv().unwrap(); + first.release_write.send(()); + snapshot.poll_device(&mut cx); + assert!(matches!( + deferred_write.poll_write(&mut cx), + Poll::Ready(Ok(())) + )); + if reset { + futures::executor::block_on(snapshot.reset()); + } else { + futures::executor::block_on(snapshot.stop()); + } + assert!(snapshot.pending.is_some()); + snapshot.start(); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Ok + )); + assert!(recv.try_recv().is_err()); + first.transaction_complete.complete(()); + assert!(matches!( + snapshot.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + recv.try_recv().unwrap(); + } } } diff --git a/vm/devices/chipset/src/microvm/resolver.rs b/vm/devices/chipset/src/microvm/resolver.rs index 2d6d83e8e4c..3c0642de1a6 100644 --- a/vm/devices/chipset/src/microvm/resolver.rs +++ b/vm/devices/chipset/src/microvm/resolver.rs @@ -5,11 +5,13 @@ use super::MicrovmPortb; use super::MicrovmShutdown; +use super::MicrovmSnapshotRequest; use async_trait::async_trait; use chipset_device_resources::ResolveChipsetDeviceHandleParams; use chipset_device_resources::ResolvedChipsetDevice; use chipset_resources::microvm::MicrovmPortbHandle; use chipset_resources::microvm::MicrovmShutdownHandle; +use chipset_resources::microvm::MicrovmSnapshotRequestHandle; use power_resources::PowerRequestHandleKind; use serial_core::resources::ResolveSerialBackendParams; use thiserror::Error; @@ -17,8 +19,10 @@ use vm_resource::AsyncResolveResource; use vm_resource::IntoResource; use vm_resource::PlatformResource; use vm_resource::ResolveError; +use vm_resource::ResolveResource; use vm_resource::ResourceResolver; use vm_resource::declare_static_async_resolver; +use vm_resource::declare_static_resolver; use vm_resource::kind::ChipsetDeviceHandleKind; /// Resolver for the microVM portb console. @@ -68,6 +72,14 @@ declare_static_async_resolver! { (ChipsetDeviceHandleKind, MicrovmShutdownHandle), } +/// Resolver for the microVM snapshot-request port. +pub struct MicrovmSnapshotRequestResolver; + +declare_static_resolver! { + MicrovmSnapshotRequestResolver, + (ChipsetDeviceHandleKind, MicrovmSnapshotRequestHandle), +} + #[async_trait] impl AsyncResolveResource for MicrovmShutdownResolver @@ -88,3 +100,18 @@ impl AsyncResolveResource Ok(MicrovmShutdown::new(power_request).into()) } } + +impl ResolveResource + for MicrovmSnapshotRequestResolver +{ + type Output = ResolvedChipsetDevice; + type Error = std::convert::Infallible; + + fn resolve( + &self, + resource: MicrovmSnapshotRequestHandle, + _input: ResolveChipsetDeviceHandleParams<'_>, + ) -> Result { + Ok(MicrovmSnapshotRequest::new(resource.notify, resource.input_gate_timeout).into()) + } +} diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs index f03b515526a..26fa5ced4e9 100644 --- a/vm/devices/chipset_resources/src/microvm.rs +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -9,6 +9,19 @@ use vm_resource::ResourceId; use vm_resource::kind::ChipsetDeviceHandleKind; use vm_resource::kind::SerialBackendHandle; +/// Worker-local request used to establish the exact post-OUT snapshot boundary. +#[derive(MeshPayload)] +pub struct MicrovmSnapshotBoundaryRequest { + /// Signals the device after all vCPUs have stopped. + pub release_write: mesh::OneshotSender<()>, + /// Completes after the deferred PMIO write has completed. + pub write_completed: mesh::OneshotReceiver<()>, + /// Maximum time allowed to gate host input before stopping vCPUs. + pub input_gate_timeout: std::time::Duration, + /// Completed only after the final transaction outcome is known. + pub transaction_complete: mesh::rpc::Rpc<(), ()>, +} + /// The microVM bidirectional portb console at ports `0xe9` and `0xea`. #[derive(MeshPayload)] pub struct MicrovmPortbHandle { @@ -27,3 +40,16 @@ pub struct MicrovmShutdownHandle; impl ResourceId for MicrovmShutdownHandle { const ID: &'static str = "microvm-shutdown"; } + +/// microVM snapshot-request port at `0x605`. +#[derive(MeshPayload)] +pub struct MicrovmSnapshotRequestHandle { + /// Optional worker-local boundary coordination target. + pub notify: Option>, + /// Maximum time allowed to gate host input before stopping vCPUs. + pub input_gate_timeout: std::time::Duration, +} + +impl ResourceId for MicrovmSnapshotRequestHandle { + const ID: &'static str = "microvm-snapshot-request"; +} From 637cd1034f1dab53fbc2b70c663a4503a1b027d6 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 05:33:45 -0700 Subject: [PATCH 104/304] openvmm_core: establish the post-OUT snapshot boundary in the worker The snapshot-request port defers the guest's write and asks for a boundary, but nothing answers. Teach the VM worker to establish that boundary and to capture the stopped VM's state on request. The worker takes two new channels from `VmWorkerParameters`: the boundary requests from the snapshot-request port and a ready notification for the controller. Both are `None` in every current launcher, so no behavior changes until a launcher wires them. On a boundary request the worker: 1. Ignores the request (releasing the write and completing the transaction) when a boundary is already active or no controller listens for ready notifications. 2. Gates host input with the request's timeout, and rolls the gate back and releases the write if gating fails. If the rollback also fails, the worker stops, because the input state is uncertain. 3. Stops every VP at the deferred-I/O boundary, records the host wall clock, and notifies the controller. A failed stop is terminal: the worker stops the VM and exits. Add three bounded RPCs that the controller drives while the boundary is active: - `VmRpc::QuiesceForSnapshot` quiesces every state unit within a timeout, saves the VM, and flushes the shared guest-RAM file mappings. It returns a `SnapshotSaveResponse` with the saved state, the state-unit inventory, the effective command line, the TSC and LAPIC timer frequencies, the capture wall clock, and the encoded CPU compatibility contract. `SnapshotQuiesceError` tells the caller whether the request was rejected, failed without uncertain state (rollback is safe), or left uncertain state (the VM must be terminated). It is rejected for the standard profile and for a stopped VM. - `VmRpc::ResumeAfterFailedSnapshot` resumes the state units after a rollback-safe failure, then releases the boundary. - `VmRpc::ReleaseSnapshotBoundary` ungates input, completes the guest's transaction, and restarts the VPs. Supporting changes: - `GuestMemoryManager::flush_shared_file_backing` flushes every shared file mapping of guest RAM, so a published RAM file contains the guest's last writes. - `MicrovmPartition` exposes the backend's CPU compatibility contract and guest TSC frequency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + .../src/mapping_manager/va_mapper.rs | 20 ++ openvmm/membacking/src/memory_manager/mod.rs | 5 + openvmm/openvmm_core/src/partition/microvm.rs | 16 + openvmm/openvmm_core/src/worker/dispatch.rs | 29 +- .../src/worker/dispatch/microvm.rs | 279 +++++++++++++++++- openvmm/openvmm_defs/Cargo.toml | 1 + openvmm/openvmm_defs/src/rpc.rs | 43 +++ openvmm/openvmm_defs/src/worker.rs | 5 + openvmm/openvmm_entry/src/lib.rs | 2 + openvmm/openvmm_entry/src/ttrpc/mod.rs | 2 + petri/src/worker.rs | 2 + 12 files changed, 401 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f4d38dba89..8674cfe2a50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5959,6 +5959,7 @@ name = "openvmm_defs" version = "0.0.0" dependencies = [ "anyhow", + "chipset_resources", "floppy_resources", "framebuffer", "get_resources", diff --git a/openvmm/membacking/src/mapping_manager/va_mapper.rs b/openvmm/membacking/src/mapping_manager/va_mapper.rs index 56e1eb10fd7..ecad769daab 100644 --- a/openvmm/membacking/src/mapping_manager/va_mapper.rs +++ b/openvmm/membacking/src/mapping_manager/va_mapper.rs @@ -910,6 +910,26 @@ impl VaMapper { self.inner.mapping.len() } + /// Flushes every active shared file mapping in this mapper. + pub fn flush_shared_file_mappings(&self) -> std::io::Result<()> { + let ranges: Vec = self + .inner + .mappings + .read() + .iter() + .filter_map(|(range, properties)| { + (!properties.private) + .then(|| MemoryRange::new(*range.start()..range.end().saturating_add(1))) + }) + .collect(); + for range in ranges { + self.inner + .mapping + .flush(range.start() as usize, range.len() as usize)?; + } + Ok(()) + } + /// Returns true if this mapper receives mappings eagerly. pub fn is_eager(&self) -> bool { self.inner.eager.load(Ordering::Relaxed) diff --git a/openvmm/membacking/src/memory_manager/mod.rs b/openvmm/membacking/src/memory_manager/mod.rs index 46b840de264..83f235e7669 100644 --- a/openvmm/membacking/src/memory_manager/mod.rs +++ b/openvmm/membacking/src/memory_manager/mod.rs @@ -807,6 +807,11 @@ impl GuestMemoryManager { } } + /// Flushes guest writes from the primary shared file mappings. + pub fn flush_shared_file_backing(&self) -> io::Result<()> { + self.va_mapper.flush_shared_file_mappings() + } + /// Returns aggregate guest-physical fault and population counters. pub fn fault_counters(&self) -> MemoryFaultCounters { let counters = self.va_mapper.fault_counters(); diff --git a/openvmm/openvmm_core/src/partition/microvm.rs b/openvmm/openvmm_core/src/partition/microvm.rs index 2b38f5d4241..faa17f11c1c 100644 --- a/openvmm/openvmm_core/src/partition/microvm.rs +++ b/openvmm/openvmm_core/src/partition/microvm.rs @@ -11,6 +11,13 @@ use virt::PartitionMemoryMapper; /// Backend partition operations required by the microVM lifecycle. pub trait MicrovmPartition: Send + Sync { + /// Returns the effective x86 CPU compatibility contract. + #[cfg(guest_arch = "x86_64")] + fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract; + + /// Returns the effective guest TSC frequency. + fn tsc_frequency_hz(&self) -> anyhow::Result>; + /// Returns the LAPIC interrupt clock frequency when available. fn apic_frequency_hz(&self) -> anyhow::Result>; } @@ -19,6 +26,15 @@ impl MicrovmPartition for T where T: BasicPartitionStateAccess + ArchPartition + PartitionMemoryMapper + PartitionAccessState, { + #[cfg(guest_arch = "x86_64")] + fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { + Partition::cpu_compatibility_contract(self) + } + + fn tsc_frequency_hz(&self) -> anyhow::Result> { + Ok(Partition::tsc_frequency_hz(self)?) + } + fn apic_frequency_hz(&self) -> anyhow::Result> { Ok(Partition::apic_frequency_hz(self)?) } diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index c20917a7513..81b2e24ce48 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -365,7 +365,9 @@ impl Worker for VmWorker { .context("failed to decode saved state")?; microvm_params.prepare_cold_boot(&mut vm, saved_state.is_some())?; - let vm = block_with_io(|_| vm.load(saved_state, parameters.notify, restore_params.state))?; + let mut vm = + block_with_io(|_| vm.load(saved_state, parameters.notify, restore_params.state))?; + vm.snapshot_boundary = microvm_params.snapshot_boundary; LOADED_VM.store(&vm); @@ -890,6 +892,7 @@ pub(crate) struct LoadedVm { inner: LoadedVmInner, running: bool, snapshot_restore: restore::SnapshotRestore, + snapshot_boundary: microvm::SnapshotBoundary, } struct DynamicVpciDeviceEntry { @@ -3230,6 +3233,7 @@ impl InitializedVm { state_units, running: false, snapshot_restore, + snapshot_boundary: Default::default(), inner: LoadedVmInner { driver_source, resolver, @@ -3816,6 +3820,7 @@ impl LoadedVm { WorkerRpc(Result, mesh::RecvError>), VmRpc(Result), Halt(Result), + SnapshotBoundary(microvm::SnapshotBoundaryEvent), } // Start a task to handle state unit inspections by filtering the worker @@ -3843,7 +3848,8 @@ impl LoadedVm { let a = rpc_recv.recv().map(Event::VmRpc); let b = worker_rpc.recv().map(Event::WorkerRpc); let c = self.inner.halt_recv.recv().map(Event::Halt); - (a, b, c).race().await + let d = self.snapshot_boundary.recv().map(Event::SnapshotBoundary); + (a, b, c, d).race().await }; let event = match event { @@ -3926,6 +3932,20 @@ impl LoadedVm { rpc.handle_failable(async |()| self.save().await.map(ProtobufMessage::new)) .await } + VmRpc::QuiesceForSnapshot(rpc) => { + rpc.handle(async |timeout| self.quiesce_for_snapshot(timeout).await) + .await; + } + VmRpc::ResumeAfterFailedSnapshot(rpc) => { + rpc.handle_failable(async |timeout| { + self.resume_after_failed_snapshot(timeout).await + }) + .await; + } + VmRpc::ReleaseSnapshotBoundary(rpc) => { + rpc.handle_failable(async |()| self.release_snapshot_boundary().await) + .await + } VmRpc::Nmi(rpc) => rpc.handle_sync(|vpindex| { if vpindex < self.inner.processor_topology.vp_count() { // Send an NMI MSI to the processor. We could raise @@ -4276,6 +4296,11 @@ impl LoadedVm { Event::Halt(Ok(reason)) => { self.inner.client_notify_send.send(reason); } + Event::SnapshotBoundary(event) => { + if !self.handle_snapshot_boundary(event).await { + break; + } + } } } diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 02108a07ddd..6611ec8ce6c 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -12,34 +12,46 @@ use crate::partition::HvlitePartition; use crate::worker::memory_layout::ChipsetMmioRanges; use anyhow::Context; use chipset_device_resources::IRQ_LINE_SET; +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; use guestmem::GuestMemory; use hvdef::Vtl; use memory_range::MemoryRange; use mesh::error::RemoteError; +use mesh::rpc::Rpc; use mesh_worker::WorkerRpc; use openvmm_defs::microvm::MachineProfile; use openvmm_defs::rpc::PulseSaveRestoreError; use openvmm_defs::rpc::VmRpc; use openvmm_defs::worker::VmWorkerParameters; use std::sync::Arc; +use std::time::Duration; use virtio::VirtioMmioDevice; use virtio::VirtioMmioInterruptMode; use virtio::resolve::ResolvedVirtioDevice; use vm_loader::InitialLoad; use vmcore::vm_task::VmTaskDriverSource; +use vmm_core::partition_unit::StopGuard; /// MicroVM inputs taken from the [`VmWorkerParameters`]. -pub(super) struct MicrovmParameters {} +pub(super) struct MicrovmParameters { + /// Snapshot boundary channels handed to the loaded VM. + pub(super) snapshot_boundary: SnapshotBoundary, +} impl MicrovmParameters { /// Takes the microVM inputs out of the worker parameters and validates the /// machine configuration for the selected hypervisor. pub(super) fn take(parameters: &mut VmWorkerParameters) -> anyhow::Result { + let snapshot_boundary = SnapshotBoundary { + requests: parameters.snapshot_boundary_requests.take(), + ready: parameters.snapshot_ready.take(), + ..Default::default() + }; openvmm_defs::microvm::validate_machine_config( ¶meters.cfg, Some(parameters.hypervisor.id()), )?; - Ok(Self {}) + Ok(Self { snapshot_boundary }) } /// Prepares the kernel command line of a microVM cold boot, before the VM @@ -53,6 +65,37 @@ impl MicrovmParameters { } } +/// A guest snapshot-boundary request, or the closure of the request channel. +pub(super) type SnapshotBoundaryEvent = Result; + +/// Guest-requested snapshot boundary state of a [`LoadedVm`]. +#[derive(Default)] +pub(super) struct SnapshotBoundary { + /// Deferred snapshot PMIO requests awaiting an exact post-OUT boundary. + requests: Option>, + /// Notifies the controller after the worker establishes a boundary. + ready: Option>, + /// Holds the vCPUs stopped while a boundary is active. + stop_guard: Option, + /// Completes the guest's snapshot transaction when the boundary is released. + transaction_complete: Option>, + /// Host wall time at the stopped capture boundary. + capture_wall_clock: Option, + /// Input-gate timeout of the active boundary. + input_gate_timeout: Option, +} + +impl SnapshotBoundary { + /// Receives the next boundary request. Never completes when there is no + /// request channel. + pub(super) async fn recv(&mut self) -> SnapshotBoundaryEvent { + match self.requests.as_mut() { + Some(requests) => requests.recv().await, + None => std::future::pending().await, + } + } +} + #[cfg(guest_arch = "x86_64")] pub(super) fn load_linux_x86_mptable( vm: &LoadedVmInner, @@ -99,6 +142,238 @@ pub(super) fn load_linux_x86_mptable( } impl LoadedVm { + pub(super) async fn establish_snapshot_boundary( + &mut self, + request: MicrovmSnapshotBoundaryRequest, + ) -> bool { + if self.snapshot_boundary.stop_guard.is_some() { + tracelimit::warn_ratelimited!("dropping duplicate microVM snapshot boundary request"); + request.release_write.send(()); + request.transaction_complete.complete(()); + return true; + } + let Some(snapshot_ready) = self.snapshot_boundary.ready.clone() else { + request.release_write.send(()); + request.transaction_complete.complete(()); + return true; + }; + + let input_gate = openvmm_defs::profile::ProfileSpan::start(); + if let Err(error) = self + .state_units + .quiesce_input_for_save(request.input_gate_timeout) + .await + { + tracelimit::error_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "failed to gate host input before snapshot boundary" + ); + if let Err(resume_error) = self + .state_units + .resume_input_after_save(request.input_gate_timeout) + .await + { + tracelimit::error_ratelimited!( + error = resume_error.as_ref() as &dyn std::error::Error, + "host-input gate rollback is uncertain; terminating VM worker" + ); + request.transaction_complete.complete(()); + return false; + } + request.release_write.send(()); + request.transaction_complete.complete(()); + return true; + } + input_gate.complete("capture", "input_gate", Default::default()); + + let vp_stop_at_io_boundary = openvmm_defs::profile::ProfileSpan::start(); + match self + .inner + .partition_unit + .temporarily_stop_vps_at_io_boundary(request.release_write, request.write_completed) + .await + { + Ok(stop_guard) => { + vp_stop_at_io_boundary.complete( + "capture", + "vp_stop_at_io_boundary", + Default::default(), + ); + self.snapshot_boundary.stop_guard = Some(stop_guard); + self.snapshot_boundary.transaction_complete = Some(request.transaction_complete); + self.snapshot_boundary.capture_wall_clock = + Some(std::time::SystemTime::now().into()); + self.snapshot_boundary.input_gate_timeout = Some(request.input_gate_timeout); + snapshot_ready.send(()); + true + } + Err(error) => { + tracelimit::error_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "failed to establish snapshot PMIO boundary; terminating VM worker" + ); + request.transaction_complete.complete(()); + false + } + } + } + + pub(super) async fn release_snapshot_boundary(&mut self) -> anyhow::Result<()> { + let input_gate_timeout = self + .snapshot_boundary + .input_gate_timeout + .context("snapshot boundary is missing its input-gate timeout")?; + self.state_units + .resume_input_after_save(input_gate_timeout) + .await?; + let transaction_complete = self + .snapshot_boundary + .transaction_complete + .take() + .context("no active microVM snapshot boundary")?; + let stop_guard = self + .snapshot_boundary + .stop_guard + .take() + .context("snapshot boundary is missing its vCPU stop guard")?; + self.snapshot_boundary.capture_wall_clock = None; + self.snapshot_boundary.input_gate_timeout = None; + transaction_complete.complete(()); + drop(stop_guard); + Ok(()) + } + + pub(super) async fn quiesce_for_snapshot( + &mut self, + timeout: Duration, + ) -> Result + { + use mesh::payload::message::ProtobufMessage; + + if self.inner.machine_profile != MachineProfile::Microvm { + return Err(openvmm_defs::rpc::SnapshotQuiesceError::Rejected( + RemoteError::new(anyhow::anyhow!( + "guest-requested snapshot quiesce requires the microVM profile" + )), + )); + } + if !self.running { + return Err(openvmm_defs::rpc::SnapshotQuiesceError::Rejected( + RemoteError::new(anyhow::anyhow!("VM is already stopped")), + )); + } + + let quiesce = openvmm_defs::profile::ProfileSpan::start(); + if let Err(error) = self.state_units.quiesce_for_save(timeout).await { + return Err(if error.has_uncertain_state() { + openvmm_defs::rpc::SnapshotQuiesceError::Uncertain(RemoteError::new(error)) + } else { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + }); + } + quiesce.complete("capture", "quiesce", Default::default()); + self.running = false; + + let save_state = openvmm_defs::profile::ProfileSpan::start(); + let saved_state = self.save().await.map_err(|error| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + })?; + save_state.complete("capture", "save_state", Default::default()); + let mapped_memory_flush = openvmm_defs::profile::ProfileSpan::start(); + self.inner + .memory_manager + .flush_shared_file_backing() + .context("failed to flush mapped guest RAM") + .map_err(|error| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + })?; + mapped_memory_flush.complete("capture", "mapped_memory_flush", Default::default()); + let effective_command_line = match &self.inner.load_mode { + openvmm_defs::config::LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } => cmdline.clone(), + _ => { + return Err(openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe( + RemoteError::new(anyhow::anyhow!( + "microVM snapshot has no effective command line" + )), + )); + } + }; + let tsc_frequency_hz = self + .inner + .partition + .tsc_frequency_hz() + .map_err(|error| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + })? + .ok_or_else(|| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new( + anyhow::anyhow!("backend does not expose a guest TSC frequency"), + )) + })?; + let apic_frequency_hz = self + .inner + .partition + .apic_frequency_hz() + .map_err(|error| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + })? + .ok_or_else(|| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new( + anyhow::anyhow!("backend does not expose a local APIC frequency"), + )) + })?; + let capture_wall_clock = self.snapshot_boundary.capture_wall_clock.ok_or_else(|| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new( + anyhow::anyhow!("snapshot boundary has no wall-clock timestamp"), + )) + })?; + Ok(openvmm_defs::rpc::SnapshotSaveResponse { + state_unit_names: saved_state.inventory.clone(), + saved_state: ProtobufMessage::new(saved_state), + effective_command_line, + tsc_frequency_hz, + apic_frequency_hz, + capture_wall_clock, + cpu_contract: mesh::payload::encode(self.inner.partition.cpu_compatibility_contract()), + }) + } + + /// Resumes the VM after a rollback-safe snapshot failure and releases the + /// snapshot boundary. + pub(super) async fn resume_after_failed_snapshot( + &mut self, + timeout: Duration, + ) -> anyhow::Result<()> { + self.state_units.resume_after_failed_save(timeout).await?; + self.running = true; + self.release_snapshot_boundary().await?; + Ok(()) + } + + /// Handles an event of the snapshot boundary request channel. Returns + /// `false` when the VM worker must stop. + pub(super) async fn handle_snapshot_boundary(&mut self, event: SnapshotBoundaryEvent) -> bool { + match event { + Ok(request) => { + if !self.establish_snapshot_boundary(request).await { + if self.running { + self.state_units.stop().await; + self.running = false; + } + return false; + } + } + Err(_) => { + self.snapshot_boundary.requests = None; + } + } + true + } + /// Applies the microVM restrictions to a management RPC. Rejected RPCs are /// completed here; the others are returned for dispatch. pub(super) fn filter_vm_rpc(&self, message: VmRpc) -> Option { diff --git a/openvmm/openvmm_defs/Cargo.toml b/openvmm/openvmm_defs/Cargo.toml index cf6fb6b4f20..b135f2f55ad 100644 --- a/openvmm/openvmm_defs/Cargo.toml +++ b/openvmm/openvmm_defs/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +chipset_resources.workspace = true hypervisor_resources.workspace = true openvmm_pcat_locator.workspace = true diff --git a/openvmm/openvmm_defs/src/rpc.rs b/openvmm/openvmm_defs/src/rpc.rs index 5b05698eabe..52eecf26c11 100644 --- a/openvmm/openvmm_defs/src/rpc.rs +++ b/openvmm/openvmm_defs/src/rpc.rs @@ -13,6 +13,7 @@ use mesh::rpc::FailableRpc; use mesh::rpc::Rpc; use std::fmt; use std::fs::File; +use std::time::Duration; use vm_resource::Resource; use vm_resource::kind::PciDeviceHandleKind; use vm_resource::kind::VmbusDeviceHandleKind; @@ -20,6 +21,12 @@ use vm_resource::kind::VmbusDeviceHandleKind; #[derive(MeshPayload)] pub enum VmRpc { Save(FailableRpc<(), ProtobufMessage>), + /// Boundedly quiesce the VM and return saved state while leaving it stopped. + QuiesceForSnapshot(Rpc>), + /// Resume a VM after a rollback-safe snapshot failure before commit. + ResumeAfterFailedSnapshot(FailableRpc), + /// Release a post-OUT boundary without starting a snapshot transaction. + ReleaseSnapshotBoundary(FailableRpc<(), ()>), Resume(FailableRpc<(), bool>), Pause(Rpc<(), bool>), ClearHalt(Rpc<(), bool>), @@ -53,6 +60,39 @@ pub enum VmRpc { DumpState(FailableRpc), } +/// State returned after a successful bounded snapshot quiesce. +#[derive(Debug, MeshPayload)] +pub struct SnapshotSaveResponse { + /// Encoded VM saved state. + pub saved_state: ProtobufMessage, + /// Complete state-unit inventory in stable registration order. + pub state_unit_names: Vec, + /// Effective Linux direct command line loaded into the guest. + pub effective_command_line: String, + /// Effective guest TSC frequency. + pub tsc_frequency_hz: u64, + /// Effective local APIC timer frequency. + pub apic_frequency_hz: u64, + /// Host wall time at the stopped capture boundary. + pub capture_wall_clock: mesh::payload::Timestamp, + /// Canonical effective CPU compatibility contract. + pub cpu_contract: Vec, +} + +/// Failure classification for a bounded snapshot quiesce/save operation. +#[derive(Debug, MeshPayload, thiserror::Error)] +pub enum SnapshotQuiesceError { + /// The request was rejected before any state transition began. + #[error("snapshot quiesce request was rejected")] + Rejected(#[source] RemoteError), + /// No uncertain transition occurred; the controller may request rollback. + #[error("snapshot quiesce failed without uncertain state")] + RollbackSafe(#[source] RemoteError), + /// A unit may have partially transitioned; the VM must be terminated. + #[error("snapshot quiesce left uncertain state")] + Uncertain(#[source] RemoteError), +} + #[derive(Debug, MeshPayload, thiserror::Error)] pub enum PulseSaveRestoreError { #[error("reset not supported")] @@ -74,6 +114,9 @@ impl fmt::Debug for VmRpc { let s = match self { VmRpc::Reset(_) => "Reset", VmRpc::Save(_) => "Save", + VmRpc::QuiesceForSnapshot(_) => "QuiesceForSnapshot", + VmRpc::ResumeAfterFailedSnapshot(_) => "ResumeAfterFailedSnapshot", + VmRpc::ReleaseSnapshotBoundary(_) => "ReleaseSnapshotBoundary", VmRpc::Resume(_) => "Resume", VmRpc::Pause(_) => "Pause", VmRpc::ClearHalt(_) => "ClearHalt", diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index 8dac1527f08..3bc543c9d85 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -67,6 +67,11 @@ pub struct VmWorkerParameters { pub shared_memory_copy_on_write: bool, /// Snapshot generation handles that must outlive the restored VM. pub snapshot_restore_guards: Option, + /// Deferred microVM PMIO requests awaiting an exact post-OUT boundary. + pub snapshot_boundary_requests: + Option>, + /// Notifies the controller after the worker establishes the boundary. + pub snapshot_ready: Option>, /// Single-use process-local sink for the restore readiness event. pub restore_ready_sink: Option, /// The VM RPC channel. diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 58259274e1f..72f68156b19 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2942,6 +2942,8 @@ async fn run_control_inner( shared_memory, shared_memory_copy_on_write: restore.shared_memory_copy_on_write, snapshot_restore_guards: restore.guards, + snapshot_boundary_requests: None, + snapshot_ready: None, restore_ready_sink, rpc: rpc_recv, notify: notify_send, diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index b0298984b8c..8b6dc25dfd3 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1312,6 +1312,8 @@ impl VmService { shared_memory: None, shared_memory_copy_on_write: false, snapshot_restore_guards: None, + snapshot_boundary_requests: None, + snapshot_ready: None, restore_ready_sink: None, rpc: recv, notify: notify_send, diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 2e02290b6a9..67cae5a2a77 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -41,6 +41,8 @@ impl Worker { shared_memory, shared_memory_copy_on_write: false, snapshot_restore_guards: None, + snapshot_boundary_requests: None, + snapshot_ready: None, restore_ready_sink: None, rpc: rpc_recv, notify: notify_send, From 3b5bb4ecdb42f28970662a04d709472e04a1a7bb Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 05:36:23 -0700 Subject: [PATCH 105/304] openvmm_core: fence management mutations during snapshot boundaries While a snapshot boundary holds the VPs stopped, the controller is reading a state that the snapshot claims is exact. A management RPC that mutates the VM in that window (a memory write, a reset, a resume, device hot-plug) would change the state being captured, or restart VPs that must stay stopped. Add `snapshot_rpc::filter`, which the worker applies before dispatching every management RPC while a boundary is active: - The snapshot control RPCs (quiesce, resume after failure, and release) and memory reads pass through. - Every other fallible RPC fails immediately with "VM mutation is unavailable while a microVM snapshot boundary is active". The caller does not wait for the boundary's release, and can retry after it. - Infallible RPCs (pause, clear halt, NMI) are dropped, so the caller observes a channel error. A pulse save/restore reports the same error text. - Every rejection is logged with the operation's name. Outside a boundary, every RPC passes through unchanged. Stop and reset of the snapshot-request port leave a cancelled request in the worker's queue. `snapshot_rpc::filter_boundary_request` drops a request whose release channel is closed, so a cancelled or reset VM is never captured and a cancelled predecessor does not discard the next live request. Tests cover the filter matrix, immediate failure and retry after release, dropped infallible RPCs, and cancelled, live, and orphaned boundary requests against the real snapshot-request device. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + openvmm/openvmm_core/Cargo.toml | 1 + openvmm/openvmm_core/src/worker/dispatch.rs | 1 + .../src/worker/dispatch/microvm.rs | 10 +- .../src/worker/dispatch/snapshot_rpc.rs | 240 ++++++++++++++++++ 5 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 openvmm/openvmm_core/src/worker/dispatch/snapshot_rpc.rs diff --git a/Cargo.lock b/Cargo.lock index 8674cfe2a50..f582c734809 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5867,6 +5867,7 @@ dependencies = [ "build_rs_guest_arch", "cache_topology", "cfg-if", + "chipset", "chipset_device", "chipset_device_resources", "chipset_device_worker", diff --git a/openvmm/openvmm_core/Cargo.toml b/openvmm/openvmm_core/Cargo.toml index d3a08b5c2e3..3db3916e043 100644 --- a/openvmm/openvmm_core/Cargo.toml +++ b/openvmm/openvmm_core/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true gdb = ["vmm_core/gdb"] [dev-dependencies] +chipset.workspace = true test_with_tracing.workspace = true [dependencies] diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 81b2e24ce48..a43c5131c96 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -11,6 +11,7 @@ mod pcie_topology; mod pcie_wiring; mod restore; mod smmu_wiring; +mod snapshot_rpc; use crate::emuplat; use crate::partition::BindHvliteVp; diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 6611ec8ce6c..f6afa4d9633 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -8,6 +8,7 @@ use super::LoadedVm; use super::LoadedVmInner; use super::Manifest; use super::RestartState; +use super::snapshot_rpc; use crate::partition::HvlitePartition; use crate::worker::memory_layout::ChipsetMmioRanges; use anyhow::Context; @@ -146,6 +147,9 @@ impl LoadedVm { &mut self, request: MicrovmSnapshotBoundaryRequest, ) -> bool { + let Some(request) = snapshot_rpc::filter_boundary_request(request) else { + return true; + }; if self.snapshot_boundary.stop_guard.is_some() { tracelimit::warn_ratelimited!("dropping duplicate microVM snapshot boundary request"); request.release_write.send(()); @@ -374,9 +378,11 @@ impl LoadedVm { true } - /// Applies the microVM restrictions to a management RPC. Rejected RPCs are - /// completed here; the others are returned for dispatch. + /// Applies the snapshot-boundary and microVM restrictions to a management + /// RPC. Rejected RPCs are completed here; the others are returned for + /// dispatch. pub(super) fn filter_vm_rpc(&self, message: VmRpc) -> Option { + let message = snapshot_rpc::filter(message, self.snapshot_boundary.stop_guard.is_some())?; if self.inner.machine_profile != MachineProfile::Microvm { return Some(message); } diff --git a/openvmm/openvmm_core/src/worker/dispatch/snapshot_rpc.rs b/openvmm/openvmm_core/src/worker/dispatch/snapshot_rpc.rs new file mode 100644 index 00000000000..8bf211c05c0 --- /dev/null +++ b/openvmm/openvmm_core/src/worker/dispatch/snapshot_rpc.rs @@ -0,0 +1,240 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Filter guest snapshot requests and management mutations at the snapshot boundary. + +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use mesh::error::RemoteError; +use openvmm_defs::rpc::PulseSaveRestoreError; +use openvmm_defs::rpc::VmRpc; + +#[derive(Debug, thiserror::Error)] +#[error("VM mutation is unavailable while a microVM snapshot boundary is active")] +struct SnapshotBoundaryActive; + +pub(super) fn filter_boundary_request( + request: MicrovmSnapshotBoundaryRequest, +) -> Option { + // Stop/reset drops the release receiver but leaves the request queued. + // The worker serializes reset with boundary establishment, so checking + // before gating input or stopping VPs prevents capture of a reset VM. + if request.release_write.is_closed() { + tracelimit::warn_ratelimited!("dropping cancelled microVM snapshot boundary request"); + return None; + } + Some(request) +} + +pub(super) fn filter(message: VmRpc, boundary_active: bool) -> Option { + if !boundary_active { + return Some(message); + } + + let operation = format!("{message:?}"); + match message { + message @ (VmRpc::QuiesceForSnapshot(_) + | VmRpc::ResumeAfterFailedSnapshot(_) + | VmRpc::ReleaseSnapshotBoundary(_) + | VmRpc::ReadMemory(_)) => return Some(message), + VmRpc::Save(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::Resume(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::Reset(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::AddVmbusDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::ConnectHvsock(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::StartReloadIgvm(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::CompleteReloadIgvm(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::WriteMemory(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::UpdateCliParams(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::AddPcieDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::RemovePcieDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::AddVpciDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::RemoveVpciDevice(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::DumpState(rpc) => rpc.fail(SnapshotBoundaryActive), + VmRpc::PulseSaveRestore(rpc) => rpc.complete(Err(PulseSaveRestoreError::Other( + RemoteError::new(SnapshotBoundaryActive), + ))), + VmRpc::Pause(rpc) | VmRpc::ClearHalt(rpc) => drop(rpc), + VmRpc::Nmi(rpc) => drop(rpc), + } + tracelimit::warn_ratelimited!( + rpc = operation, + "rejected management RPC during microVM snapshot boundary" + ); + None +} + +#[cfg(test)] +mod tests { + use super::filter; + use super::filter_boundary_request; + use chipset::microvm::MicrovmSnapshotRequest; + use chipset_device::io::IoError; + use chipset_device::io::IoResult; + use chipset_device::pio::PortIoIntercept; + use chipset_device::poll_device::PollDevice; + use futures::executor::block_on; + use mesh::rpc::Rpc; + use mesh::rpc::RpcSend; + use openvmm_defs::rpc::VmRpc; + use std::task::Context; + use std::task::Poll; + use std::task::Waker; + use std::time::Duration; + use test_with_tracing::test; + use vmcore::device_state::ChangeDeviceState; + + const SNAPSHOT_PORT: u16 = 0x605; + + #[test] + fn snapshot_boundary_drops_requests_cancelled_by_stop_or_reset() { + for reset in [false, true] { + let (send, mut requests) = mesh::channel(); + let mut device = MicrovmSnapshotRequest::new(Some(send), Duration::from_secs(1)); + device.start(); + let IoResult::Defer(mut cancelled_write) = device.io_write(SNAPSHOT_PORT, &[0]) else { + panic!("snapshot write was not deferred"); + }; + + if reset { + block_on(device.reset()); + } else { + block_on(device.stop()); + } + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + cancelled_write.poll_write(&mut cx), + Poll::Ready(Err(IoError::InvalidRegister)) + )); + + device.start(); + let IoResult::Defer(mut next_write) = device.io_write(SNAPSHOT_PORT, &[1]) else { + panic!("next snapshot write was not deferred"); + }; + // Cancellation leaves the old request ahead of the new one in the queue. + let old_request = requests.try_recv().unwrap(); + assert!(old_request.release_write.is_closed()); + assert!(filter_boundary_request(old_request).is_none()); + + let request = filter_boundary_request(requests.try_recv().unwrap()) + .expect("a cancelled predecessor must not discard the next request"); + request.release_write.send(()); + device.poll_device(&mut cx); + assert!(matches!( + next_write.poll_write(&mut cx), + Poll::Ready(Ok(())) + )); + block_on(request.write_completed).unwrap(); + request.transaction_complete.complete(()); + assert!(requests.try_recv().is_err()); + } + } + + #[test] + fn snapshot_boundary_accepts_live_request() { + let (send, mut requests) = mesh::channel(); + let mut device = MicrovmSnapshotRequest::new(Some(send), Duration::from_secs(1)); + device.start(); + let IoResult::Defer(mut write) = device.io_write(SNAPSHOT_PORT, &[0]) else { + panic!("snapshot write was not deferred"); + }; + let request = filter_boundary_request(requests.try_recv().unwrap()) + .expect("live snapshot request was discarded"); + request.release_write.send(()); + let mut cx = Context::from_waker(Waker::noop()); + device.poll_device(&mut cx); + assert!(matches!(write.poll_write(&mut cx), Poll::Ready(Ok(())))); + block_on(request.write_completed).unwrap(); + request.transaction_complete.complete(()); + } + + #[test] + fn snapshot_boundary_drops_request_from_dropped_device() { + let (send, mut requests) = mesh::channel(); + let mut device = MicrovmSnapshotRequest::new(Some(send), Duration::from_secs(1)); + assert!(matches!( + device.io_write(SNAPSHOT_PORT, &[0]), + IoResult::Defer(_) + )); + drop(device); + assert!(filter_boundary_request(requests.try_recv().unwrap()).is_none()); + } + + #[test] + fn mutation_outside_snapshot_reaches_dispatch() { + for message in [ + VmRpc::WriteMemory(Rpc::detached((0, vec![1]))), + VmRpc::Reset(Rpc::detached(())), + VmRpc::Resume(Rpc::detached(())), + VmRpc::Pause(Rpc::detached(())), + ] { + assert!(filter(message, false).is_some()); + } + } + + #[test] + fn snapshot_control_and_reads_reach_dispatch() { + for message in [ + VmRpc::QuiesceForSnapshot(Rpc::detached(Duration::from_secs(1))), + VmRpc::ResumeAfterFailedSnapshot(Rpc::detached(Duration::from_secs(1))), + VmRpc::ReleaseSnapshotBoundary(Rpc::detached(())), + VmRpc::ReadMemory(Rpc::detached((0, 1))), + ] { + assert!(filter(message, true).is_some()); + } + } + + #[test] + fn snapshot_mutations_fail_without_waiting_for_release() { + let (send, mut recv) = mesh::channel(); + let write = send.call(VmRpc::WriteMemory, (0, vec![1])); + let reset = send.call(VmRpc::Reset, ()); + let resume = send.call(VmRpc::Resume, ()); + let remove = send.call(VmRpc::RemovePcieDevice, "port".to_owned()); + + for _ in 0..4 { + assert!(filter(recv.try_recv().unwrap(), true).is_none()); + } + for result in [block_on(write), block_on(reset), block_on(remove)] { + let error = result.unwrap().unwrap_err(); + assert!(error.to_string().contains("snapshot boundary is active")); + } + assert!(block_on(resume).unwrap().is_err()); + + let release = send.call(VmRpc::ReleaseSnapshotBoundary, ()); + let Some(VmRpc::ReleaseSnapshotBoundary(rpc)) = filter(recv.try_recv().unwrap(), true) + else { + panic!("snapshot release was blocked"); + }; + rpc.complete(Ok(())); + block_on(release).unwrap().unwrap(); + } + + #[test] + fn infallible_mutations_report_channel_error() { + let (send, mut recv) = mesh::channel(); + let pause = send.call(VmRpc::Pause, ()); + let clear_halt = send.call(VmRpc::ClearHalt, ()); + let nmi = send.call(VmRpc::Nmi, 0); + for _ in 0..3 { + assert!(filter(recv.try_recv().unwrap(), true).is_none()); + } + assert!(block_on(pause).is_err()); + assert!(block_on(clear_halt).is_err()); + assert!(block_on(nmi).is_err()); + } + + #[test] + fn mutation_can_be_retried_after_boundary_release() { + let (send, mut recv) = mesh::channel(); + let first = send.call(VmRpc::WriteMemory, (0, vec![1])); + assert!(filter(recv.try_recv().unwrap(), true).is_none()); + assert!(block_on(first).unwrap().is_err()); + + let retry = send.call(VmRpc::WriteMemory, (0, vec![2])); + let Some(VmRpc::WriteMemory(rpc)) = filter(recv.try_recv().unwrap(), false) else { + panic!("write was blocked after boundary release"); + }; + rpc.complete(Ok(())); + block_on(retry).unwrap().unwrap(); + } +} From 5221003da2531af3ecfb0f239c16c2eaf3847006 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 05:44:53 -0700 Subject: [PATCH 106/304] openvmm_helpers: define the authoritative microVM machine contract A microVM snapshot is only restorable on a machine that reproduces the captured guest ABI exactly: the same RAM layout, processors, devices and their placement, state units, backend, kernel command line, CPU features, and clock frequencies. The generic manifest records only the RAM size, the VP count, and the architecture. Add `snapshot::microvm`, which defines `SnapshotMachineContract` and stores it in the manifest's new optional `machine_contract` field. The field is optional, so existing manifests remain valid. The contract records the following: - Identity: the machine profile, microVM ABI version 2, boot-layout version 2, and the source hypervisor. - Command line: the effective kernel command line and its SHA-256. - RAM: the canonical RAM ranges (low RAM up to 3 GiB, then high RAM from 4 GiB) and their offsets in `memory.bin`. - Processors: one socket, one die, one core per vCPU, no SMT, and contiguous APIC IDs. - Devices: the fixed device inventory in stable order, with each device's state unit, kind, PMIO/MMIO ranges, IRQ, transport, features, and queues. - State units: the complete state-unit inventory, in order. - Interrupts: the shared-status interrupt mode and its page. - Clocks: the capture wall clock, the TSC frequency (with zero tolerance), the LAPIC timer frequency (optional for older snapshots), and the "advance by host downtime" clock policy. - CPU: the canonical encoded CPU compatibility contract and its SHA-256. `microvm_machine_contract` builds the contract for a captured VM from these inputs and validates its shape. `validate_supported_microvm_contract` rejects an unsupported ABI or boot layout. `validate_microvm_machine_contract` validates a manifest's contract against the manifest and compares it field by field with the contract that the restoring machine expects. Shape validation bounds every list and string and checks both digests. It rejects overlapping, out-of-file, or incomplete RAM ranges, a noncanonical topology, duplicate device, state-unit, or APIC IDs, a noncanonical device order, a device whose state unit is unknown, and invalid device ranges or queue inventories. Tests cover a valid contract, each rejected field, the canonical topology, the shared-status page, and RAM, APIC-ID, and state-unit defects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + openvmm/openvmm_defs/src/microvm.rs | 3 + openvmm/openvmm_helpers/Cargo.toml | 1 + openvmm/openvmm_helpers/src/snapshot.rs | 4 + .../openvmm_helpers/src/snapshot/format.rs | 24 + .../openvmm_helpers/src/snapshot/microvm.rs | 964 ++++++++++++++++++ 6 files changed, 997 insertions(+) create mode 100644 openvmm/openvmm_helpers/src/snapshot/microvm.rs diff --git a/Cargo.lock b/Cargo.lock index f582c734809..c088f162599 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6149,6 +6149,7 @@ dependencies = [ "nix 0.30.1", "openvmm_defs", "pal", + "sha2", "sparse_mmap", "tempfile", "thiserror 2.0.16", diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index d160986fc09..01368f3d71f 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -19,6 +19,9 @@ use mesh::MeshPayload; use std::fmt::Write as _; use vmotherboard::options::BaseChipsetManifest; +/// The persisted microVM ABI version. +pub const MICROVM_ABI_VERSION_2: u32 = 2; + /// Returns whether a processor count is valid for the microVM. pub const fn microvm_processor_count_supported(processor_count: u32) -> bool { matches!(processor_count, 1 | 2 | 4 | 8) diff --git a/openvmm/openvmm_helpers/Cargo.toml b/openvmm/openvmm_helpers/Cargo.toml index 95631168712..6bf2ceb9c8b 100644 --- a/openvmm/openvmm_helpers/Cargo.toml +++ b/openvmm/openvmm_helpers/Cargo.toml @@ -20,6 +20,7 @@ mesh.workspace = true anyhow.workspace = true fs-err.workspace = true +sha2.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index 301c4e14821..1a529c5dbea 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -8,6 +8,7 @@ use mesh::payload::Timestamp; pub mod format; pub mod fs; +pub mod microvm; pub mod publish; pub mod restore; @@ -50,6 +51,9 @@ pub struct SnapshotManifest { /// Legacy v2 SHA-256 digest of `memory.bin`; empty in v3. #[mesh(10)] pub memory_sha256: Vec, + /// Authoritative machine composition for versioned machine profiles. + #[mesh(11)] + pub machine_contract: Option, /// Snapshot format magic. #[mesh(12)] pub format_magic: Vec, diff --git a/openvmm/openvmm_helpers/src/snapshot/format.rs b/openvmm/openvmm_helpers/src/snapshot/format.rs index 3bf041df843..ff887c8f53f 100644 --- a/openvmm/openvmm_helpers/src/snapshot/format.rs +++ b/openvmm/openvmm_helpers/src/snapshot/format.rs @@ -7,6 +7,7 @@ use super::MANIFEST_VERSION; use super::SnapshotManifest; use mesh::payload::Timestamp; +use sha2::Digest; /// Magic identifying the OpenVMM snapshot manifest format. pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V3\0"; @@ -46,6 +47,7 @@ impl Default for SnapshotManifest { state_size_bytes: 0, state_sha256: Vec::new(), memory_sha256: Vec::new(), + machine_contract: None, format_magic: SNAPSHOT_FORMAT_MAGIC.to_vec(), saved_state_schema_version: SAVED_STATE_SCHEMA_VERSION, saved_state_root_type: SAVED_STATE_ROOT_TYPE.to_owned(), @@ -53,6 +55,28 @@ impl Default for SnapshotManifest { } } +pub(super) fn validate_sha256(digest: &[u8], description: &str) -> anyhow::Result<()> { + anyhow::ensure!( + digest.len() == SHA256_SIZE, + "{description} SHA-256 digest has invalid length {}", + digest.len(), + ); + Ok(()) +} + +pub(super) fn verify_digest( + bytes: &[u8], + expected: &[u8], + description: &str, +) -> anyhow::Result<()> { + let actual: [u8; 32] = sha2::Sha256::digest(bytes).into(); + anyhow::ensure!( + actual.as_slice() == expected, + "{description} SHA-256 digest mismatch", + ); + Ok(()) +} + pub(super) fn validate_manifest_header(manifest: &SnapshotManifest) -> anyhow::Result<()> { let expected_magic = match manifest.version { LEGACY_MANIFEST_VERSION => LEGACY_SNAPSHOT_FORMAT_MAGIC, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs new file mode 100644 index 00000000000..c15b015ba7c --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -0,0 +1,964 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM snapshot machine-contract types, construction, and validation. + +use super::SnapshotManifest; +use super::format::validate_sha256; +use super::format::verify_digest; +use anyhow::Context; +use mesh::payload::Protobuf; +use mesh::payload::Timestamp; +use sha2::Digest; +use std::collections::HashSet; + +/// Linux-direct MP-table boot layout with shared interrupt status. +pub const MICROVM_BOOT_LAYOUT_VERSION: u32 = 2; +/// Snapshot contract name for shared-status edge interrupts. +pub const MICROVM_SHARED_STATUS_INTERRUPT_MODE: &str = "edge-shared-status"; +/// Clock policy applied when a snapshot is restored. +pub const ADVANCE_BY_HOST_DOWNTIME: &str = "advance_by_host_downtime"; +const MAX_MEMORY_RANGES: usize = 128; +const MAX_DEVICES: usize = 64; +const MAX_DEVICE_RANGES: usize = 16; +const MAX_STATE_UNITS: usize = 512; +const MAX_COMMAND_LINE_BYTES: usize = 64 * 1024; +const MAX_CPU_CONTRACT_BYTES: usize = 1024 * 1024; + +/// A guest RAM range and its corresponding offset in `memory.bin`. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMemoryRange { + /// Guest physical base address. + #[mesh(1)] + pub gpa_start: u64, + /// Range length in bytes. + #[mesh(2)] + pub length: u64, + /// Byte offset in `memory.bin`. + #[mesh(3)] + pub file_offset: u64, +} + +/// Processor topology that must be reproduced during restore. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotProcessorTopology { + /// Socket count. + #[mesh(1)] + pub sockets: u32, + /// Dies per socket. + #[mesh(2)] + pub dies_per_socket: u32, + /// Cores per die. + #[mesh(3)] + pub cores_per_die: u32, + /// Threads per core. + #[mesh(4)] + pub threads_per_core: u32, + /// APIC IDs in virtual-processor order. + #[mesh(5)] + pub apic_ids: Vec, +} + +/// An address range owned by a saved device. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotDeviceRange { + /// Address space (`pmio` or `mmio`). + #[mesh(1)] + pub address_space: String, + /// Range base address. + #[mesh(2)] + pub start: u64, + /// Range length in bytes. + #[mesh(3)] + pub length: u64, +} + +/// Guest-visible device identity and placement. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotDevice { + /// Stable device identity. + #[mesh(1)] + pub stable_id: String, + /// State-unit identity, including units with no mutable state. + #[mesh(2)] + pub state_unit_name: String, + /// Device kind. + #[mesh(3)] + pub kind: String, + /// Stable device order. + #[mesh(4)] + pub order: u32, + /// PMIO or MMIO ranges. + #[mesh(5)] + pub ranges: Vec, + /// Interrupt number, or `None` for devices without an interrupt. + #[mesh(6)] + pub irq: Option, + /// Transport kind, empty for non-transport devices. + #[mesh(7)] + pub transport: String, + /// Effective feature banks in bank order. + #[mesh(8)] + pub feature_banks: Vec, + /// Queue count. + #[mesh(9)] + pub queue_count: u32, + /// Maximum queue sizes in queue order. + #[mesh(10)] + pub queue_max_sizes: Vec, +} + +/// Machine composition that becomes authoritative after capture. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMachineContract { + /// Machine profile name. + #[mesh(1)] + pub machine_profile: String, + /// microVM ABI version. + #[mesh(2)] + pub microvm_abi_version: u32, + /// Source hypervisor kind. + #[mesh(3)] + pub source_hypervisor: String, + /// Effective kernel command line. + #[mesh(4)] + pub effective_command_line: String, + /// SHA-256 of the effective command line. + #[mesh(5)] + pub effective_command_line_sha256: Vec, + /// Guest RAM ranges in stable order. + #[mesh(6)] + pub memory_ranges: Vec, + /// Processor topology. + #[mesh(7)] + pub topology: SnapshotProcessorTopology, + /// Guest-visible devices in stable order. + #[mesh(8)] + pub devices: Vec, + /// Complete state-unit inventory in stable order. + #[mesh(9)] + pub state_unit_names: Vec, + /// Host wall time at the stopped capture boundary. + #[mesh(11)] + pub capture_wall_clock: Timestamp, + /// Effective guest TSC frequency. + #[mesh(12)] + pub tsc_frequency_hz: u64, + /// Accepted destination TSC frequency tolerance in parts per million. + #[mesh(13)] + pub tsc_tolerance_ppm: u32, + /// Canonical protobuf-encoded effective CPU contract. + #[mesh(14)] + pub cpu_contract: Vec, + /// SHA-256 of the canonical CPU contract. + #[mesh(15)] + pub cpu_contract_sha256: Vec, + /// Version of the fixed cold-boot and memory layout. + #[mesh(16)] + pub boot_layout_version: u32, + /// Policy used to advance clocks and deadlines over host downtime. + #[mesh(17)] + pub clock_policy: String, + /// Effective local APIC timer frequency. + #[mesh(20)] + pub apic_frequency_hz: Option, + /// Virtio interrupt-delivery mode. + #[mesh(24)] + pub virtio_interrupt_mode: String, + /// Guest-physical base of the shared interrupt-status page, or zero when absent. + #[mesh(25)] + pub virtio_shared_status_page_gpa: u64, + /// Size of the shared interrupt-status page, or zero when absent. + #[mesh(26)] + pub virtio_shared_status_page_size: u64, +} + +impl SnapshotMachineContract { + /// Sets the effective command line and its digest together. + pub fn set_effective_command_line(&mut self, command_line: String) { + self.effective_command_line_sha256 = sha2::Sha256::digest(command_line.as_bytes()).to_vec(); + self.effective_command_line = command_line; + } + + /// Sets the canonical CPU compatibility contract and its digest together. + pub fn set_cpu_compatibility_contract(&mut self, cpu_contract: Vec) { + self.cpu_contract_sha256 = sha2::Sha256::digest(&cpu_contract).to_vec(); + self.cpu_contract = cpu_contract; + } +} + +fn microvm_snapshot_topology(processor_count: u32) -> anyhow::Result { + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(processor_count), + "microVM does not support {processor_count} vCPUs" + ); + Ok(SnapshotProcessorTopology { + sockets: 1, + dies_per_socket: 1, + cores_per_die: processor_count, + threads_per_core: 1, + apic_ids: (0..processor_count).collect(), + }) +} + +fn canonical_microvm_memory_ranges(memory_size: u64) -> anyhow::Result> { + const LOW_RAM_END: u64 = 3 * 1024 * 1024 * 1024; + const HIGH_RAM_START: u64 = 4 * 1024 * 1024 * 1024; + anyhow::ensure!(memory_size != 0, "microVM RAM size must be nonzero"); + let low_length = memory_size.min(LOW_RAM_END); + let mut ranges = vec![SnapshotMemoryRange { + gpa_start: 0, + length: low_length, + file_offset: 0, + }]; + if memory_size > LOW_RAM_END { + let high_length = memory_size - LOW_RAM_END; + HIGH_RAM_START + .checked_add(high_length) + .context("microVM RAM layout overflows GPA space")?; + ranges.push(SnapshotMemoryRange { + gpa_start: HIGH_RAM_START, + length: high_length, + file_offset: LOW_RAM_END, + }); + } + Ok(ranges) +} + +/// Builds the authoritative microVM machine contract. +pub fn microvm_machine_contract( + source_hypervisor: &str, + boot_layout_version: u32, + effective_command_line: String, + processor_count: u32, + memory_size: u64, + state_unit_names: Vec, + capture_wall_clock: Timestamp, + tsc_frequency_hz: u64, + apic_frequency_hz: Option, + cpu_contract: Vec, +) -> anyhow::Result { + anyhow::ensure!( + matches!(source_hypervisor, "kvm" | "mshv" | "whp"), + "microVM snapshots require the KVM, MSHV, or WHP hypervisor" + ); + let topology = microvm_snapshot_topology(processor_count)?; + let memory_ranges = canonical_microvm_memory_ranges(memory_size)?; + + let device = |stable_id: &str, + state_unit_name: &str, + kind: &str, + ranges: Vec, + irq: Option, + order: u32| SnapshotDevice { + stable_id: stable_id.to_owned(), + state_unit_name: state_unit_name.to_owned(), + kind: kind.to_owned(), + order, + ranges, + irq, + transport: String::new(), + feature_banks: Vec::new(), + queue_count: 0, + queue_max_sizes: Vec::new(), + }; + let pmio = |start, length| SnapshotDeviceRange { + address_space: "pmio".to_owned(), + start, + length, + }; + let mmio = |start, length| SnapshotDeviceRange { + address_space: "mmio".to_owned(), + start, + length, + }; + + let devices = vec![ + device("partition", "partition", "partition", Vec::new(), None, 0), + device("vp0", "partition", "vcpu", Vec::new(), None, 1), + device("vmtime", "vmtime", "clock", Vec::new(), None, 2), + device( + "pic", + "pic", + "pic", + vec![pmio(0x20, 2), pmio(0xa0, 2)], + None, + 3, + ), + device( + "ioapic", + "ioapic", + "ioapic", + vec![mmio(0xfec0_0000, 0x1000)], + None, + 4, + ), + device( + "lapic", + "partition", + "lapic", + vec![mmio(0xfee0_0000, 0x1000)], + None, + 5, + ), + device("pit", "pit", "pit", vec![pmio(0x40, 4)], Some(0), 6), + device("rtc", "rtc", "rtc", vec![pmio(0x70, 2)], Some(8), 7), + device( + "microvm-portb", + "microvm-portb", + "portb", + vec![pmio(0xe9, 2)], + None, + 8, + ), + device( + "microvm-shutdown", + "microvm-shutdown", + "shutdown", + vec![pmio(0x604, 1)], + None, + 9, + ), + device( + "microvm-snapshot-request", + "microvm-snapshot-request", + "snapshot-request", + vec![pmio(0x605, 1)], + None, + 10, + ), + ]; + + let mut contract = SnapshotMachineContract { + machine_profile: "microvm".to_owned(), + microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + source_hypervisor: source_hypervisor.to_owned(), + effective_command_line: String::new(), + effective_command_line_sha256: Vec::new(), + memory_ranges, + topology, + devices, + state_unit_names, + capture_wall_clock, + tsc_frequency_hz, + tsc_tolerance_ppm: 0, + cpu_contract: Vec::new(), + cpu_contract_sha256: Vec::new(), + boot_layout_version, + clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), + apic_frequency_hz, + virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), + virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, + virtio_shared_status_page_size: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + }; + contract.set_effective_command_line(effective_command_line); + contract.set_cpu_compatibility_contract(cpu_contract); + validate_machine_contract_shape(&contract, memory_size, processor_count)?; + Ok(contract) +} + +/// Rejects a snapshot contract that does not use the supported persisted microVM identities. +pub fn validate_supported_microvm_contract( + contract: &SnapshotMachineContract, +) -> anyhow::Result<()> { + anyhow::ensure!( + contract.microvm_abi_version == openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + "snapshot microVM ABI version {} is unsupported; this OpenVMM supports version {}", + contract.microvm_abi_version, + openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + ); + anyhow::ensure!( + contract.boot_layout_version == MICROVM_BOOT_LAYOUT_VERSION, + "snapshot boot layout version {} is unsupported; this OpenVMM supports version {}", + contract.boot_layout_version, + MICROVM_BOOT_LAYOUT_VERSION, + ); + Ok(()) +} + +/// Validate and exactly compare an authoritative microVM machine contract. +pub fn validate_microvm_machine_contract( + manifest: &SnapshotManifest, + expected: &SnapshotMachineContract, +) -> anyhow::Result<()> { + let contract = manifest + .machine_contract + .as_ref() + .context("snapshot is missing the authoritative machine contract")?; + validate_machine_contract_shape(contract, manifest.memory_size_bytes, manifest.vp_count)?; + + anyhow::ensure!( + contract.machine_profile == "microvm", + "snapshot machine profile '{}' is not microvm", + contract.machine_profile, + ); + anyhow::ensure!( + contract.machine_profile == expected.machine_profile, + "snapshot machine profile doesn't match the requested machine" + ); + anyhow::ensure!( + contract.microvm_abi_version == expected.microvm_abi_version, + "snapshot microVM ABI version {} doesn't match expected {}", + contract.microvm_abi_version, + expected.microvm_abi_version, + ); + anyhow::ensure!( + contract.source_hypervisor == expected.source_hypervisor, + "snapshot source hypervisor '{}' doesn't match expected '{}'", + contract.source_hypervisor, + expected.source_hypervisor, + ); + anyhow::ensure!( + contract.boot_layout_version == expected.boot_layout_version, + "snapshot boot layout version doesn't match the requested machine" + ); + anyhow::ensure!( + contract.virtio_interrupt_mode == expected.virtio_interrupt_mode + && contract.virtio_shared_status_page_gpa == expected.virtio_shared_status_page_gpa + && contract.virtio_shared_status_page_size == expected.virtio_shared_status_page_size, + "snapshot virtio interrupt contract doesn't match the requested machine" + ); + anyhow::ensure!( + contract.clock_policy == expected.clock_policy, + "snapshot clock policy doesn't match the requested machine" + ); + anyhow::ensure!( + contract.effective_command_line == expected.effective_command_line, + "snapshot effective command line doesn't match the requested machine" + ); + anyhow::ensure!( + contract.memory_ranges == expected.memory_ranges, + "snapshot RAM layout doesn't match the requested machine" + ); + anyhow::ensure!( + contract.topology == expected.topology, + "snapshot processor topology doesn't match the requested machine" + ); + anyhow::ensure!( + contract.devices == expected.devices, + "snapshot device inventory, order, or configuration doesn't match the requested machine" + ); + anyhow::ensure!( + contract.state_unit_names == expected.state_unit_names, + "snapshot state-unit inventory or order doesn't match the requested machine" + ); + anyhow::ensure!( + contract.tsc_frequency_hz == expected.tsc_frequency_hz + && contract.tsc_tolerance_ppm == expected.tsc_tolerance_ppm, + "snapshot TSC frequency contract doesn't match the destination" + ); + anyhow::ensure!( + contract.apic_frequency_hz == expected.apic_frequency_hz, + "snapshot APIC frequency contract doesn't match the destination" + ); + anyhow::ensure!( + contract.cpu_contract == expected.cpu_contract, + "snapshot CPU compatibility contract doesn't match the destination" + ); + Ok(()) +} + +pub(super) fn validate_machine_contract_shape( + contract: &SnapshotMachineContract, + memory_size: u64, + vp_count: u32, +) -> anyhow::Result<()> { + validate_supported_microvm_contract(contract)?; + anyhow::ensure!( + contract.virtio_interrupt_mode == MICROVM_SHARED_STATUS_INTERRUPT_MODE + && contract.virtio_shared_status_page_gpa + == openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA + && contract.virtio_shared_status_page_size + == openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + "snapshot microVM shared-status interrupt contract is invalid" + ); + anyhow::ensure!( + contract.clock_policy == ADVANCE_BY_HOST_DOWNTIME, + "snapshot clock policy '{}' is unsupported", + contract.clock_policy + ); + anyhow::ensure!( + contract.effective_command_line.len() < MAX_COMMAND_LINE_BYTES, + "snapshot command line exceeds the 64-KiB limit" + ); + anyhow::ensure!( + !contract.effective_command_line.contains('\0'), + "snapshot command line contains an embedded NUL" + ); + validate_sha256( + &contract.effective_command_line_sha256, + "effective command line", + )?; + verify_digest( + contract.effective_command_line.as_bytes(), + &contract.effective_command_line_sha256, + "effective command line", + )?; + anyhow::ensure!( + contract.tsc_frequency_hz != 0, + "snapshot TSC frequency must be nonzero" + ); + if let Some(apic_frequency_hz) = contract.apic_frequency_hz { + anyhow::ensure!( + apic_frequency_hz != 0, + "snapshot APIC frequency must be nonzero" + ); + } + anyhow::ensure!( + !contract.cpu_contract.is_empty() && contract.cpu_contract.len() <= MAX_CPU_CONTRACT_BYTES, + "snapshot CPU contract size is invalid" + ); + validate_sha256(&contract.cpu_contract_sha256, "CPU contract")?; + verify_digest( + &contract.cpu_contract, + &contract.cpu_contract_sha256, + "CPU contract", + )?; + let _: std::time::SystemTime = contract + .capture_wall_clock + .try_into() + .context("snapshot capture wall clock is invalid")?; + + anyhow::ensure!( + !contract.memory_ranges.is_empty() && contract.memory_ranges.len() <= MAX_MEMORY_RANGES, + "snapshot RAM range count is invalid" + ); + let mut total_memory = 0_u64; + for (index, range) in contract.memory_ranges.iter().enumerate() { + anyhow::ensure!(range.length != 0, "snapshot RAM range {index} is empty"); + let gpa_end = range + .gpa_start + .checked_add(range.length) + .with_context(|| format!("snapshot RAM range {index} overflows GPA space"))?; + let file_end = range + .file_offset + .checked_add(range.length) + .with_context(|| format!("snapshot RAM range {index} overflows file space"))?; + anyhow::ensure!( + file_end <= memory_size, + "snapshot RAM range {index} exceeds memory.bin" + ); + total_memory = total_memory + .checked_add(range.length) + .context("snapshot total RAM size overflows")?; + for previous in &contract.memory_ranges[..index] { + let previous_gpa_end = previous.gpa_start + previous.length; + let previous_file_end = previous.file_offset + previous.length; + anyhow::ensure!( + gpa_end <= previous.gpa_start || range.gpa_start >= previous_gpa_end, + "snapshot RAM ranges overlap in GPA space" + ); + anyhow::ensure!( + file_end <= previous.file_offset || range.file_offset >= previous_file_end, + "snapshot RAM ranges overlap in memory.bin" + ); + } + } + anyhow::ensure!( + total_memory == memory_size, + "snapshot RAM ranges cover {total_memory} bytes, expected {memory_size}" + ); + let topology = &contract.topology; + let topology_vp_count = u64::from(topology.sockets) + .checked_mul(u64::from(topology.dies_per_socket)) + .and_then(|count| count.checked_mul(u64::from(topology.cores_per_die))) + .and_then(|count| count.checked_mul(u64::from(topology.threads_per_core))) + .context("snapshot processor topology overflows")?; + anyhow::ensure!( + topology_vp_count == u64::from(vp_count) && topology.apic_ids.len() == vp_count as usize, + "snapshot processor topology doesn't describe {vp_count} virtual processors" + ); + ensure_unique(&topology.apic_ids, "APIC ID")?; + anyhow::ensure!( + *topology == microvm_snapshot_topology(vp_count)?, + "snapshot processor topology is not canonical for the microVM" + ); + anyhow::ensure!( + contract.devices.len() <= MAX_DEVICES, + "snapshot device inventory is too large" + ); + let mut device_ids = HashSet::new(); + for (index, device) in contract.devices.iter().enumerate() { + anyhow::ensure!( + device.order == index as u32, + "snapshot device order is not canonical" + ); + anyhow::ensure!( + !device.stable_id.is_empty() && device_ids.insert(device.stable_id.as_str()), + "snapshot contains an empty or duplicate device ID" + ); + anyhow::ensure!( + !device.state_unit_name.is_empty(), + "snapshot device '{}' has no state-unit name", + device.stable_id, + ); + anyhow::ensure!( + device.ranges.len() <= MAX_DEVICE_RANGES, + "snapshot device '{}' has too many address ranges", + device.stable_id, + ); + for range in &device.ranges { + anyhow::ensure!( + matches!(range.address_space.as_str(), "pmio" | "mmio") && range.length != 0, + "snapshot device '{}' has an invalid address range", + device.stable_id, + ); + range.start.checked_add(range.length).with_context(|| { + format!("snapshot device '{}' range overflows", device.stable_id) + })?; + } + anyhow::ensure!( + device.queue_count as usize == device.queue_max_sizes.len(), + "snapshot device '{}' queue inventory is inconsistent", + device.stable_id, + ); + } + + anyhow::ensure!( + !contract.state_unit_names.is_empty() && contract.state_unit_names.len() <= MAX_STATE_UNITS, + "snapshot state-unit inventory size is invalid" + ); + ensure_unique(&contract.state_unit_names, "state-unit name")?; + let state_units = contract + .state_unit_names + .iter() + .map(String::as_str) + .collect::>(); + for device in &contract.devices { + anyhow::ensure!( + state_units.contains(device.state_unit_name.as_str()), + "snapshot device '{}' references an unknown state unit", + device.stable_id, + ); + } + + Ok(()) +} + +fn ensure_unique(values: &[T], description: &str) -> anyhow::Result<()> +where + T: Eq + std::hash::Hash, +{ + let mut unique = HashSet::with_capacity(values.len()); + anyhow::ensure!( + values.iter().all(|value| unique.insert(value)), + "snapshot contains a duplicate {description}" + ); + Ok(()) +} + +#[cfg(test)] +fn test_machine_contract() -> SnapshotMachineContract { + let mut contract = SnapshotMachineContract { + machine_profile: "microvm".to_owned(), + microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + source_hypervisor: "whp".to_owned(), + effective_command_line: String::new(), + effective_command_line_sha256: Vec::new(), + memory_ranges: vec![SnapshotMemoryRange { + gpa_start: 0, + length: 1024, + file_offset: 0, + }], + topology: SnapshotProcessorTopology { + sockets: 1, + dies_per_socket: 1, + cores_per_die: 2, + threads_per_core: 1, + apic_ids: vec![0, 1], + }, + devices: vec![ + SnapshotDevice { + stable_id: "portb".to_owned(), + state_unit_name: "portb".to_owned(), + kind: "portb".to_owned(), + order: 0, + ranges: vec![SnapshotDeviceRange { + address_space: "pmio".to_owned(), + start: 0xe9, + length: 2, + }], + irq: None, + transport: String::new(), + feature_banks: Vec::new(), + queue_count: 0, + queue_max_sizes: Vec::new(), + }, + SnapshotDevice { + stable_id: "shutdown".to_owned(), + state_unit_name: "shutdown".to_owned(), + kind: "shutdown".to_owned(), + order: 1, + ranges: vec![SnapshotDeviceRange { + address_space: "pmio".to_owned(), + start: 0x604, + length: 1, + }], + irq: None, + transport: String::new(), + feature_banks: Vec::new(), + queue_count: 0, + queue_max_sizes: Vec::new(), + }, + ], + state_unit_names: vec!["portb".to_owned(), "shutdown".to_owned()], + capture_wall_clock: std::time::SystemTime::now().into(), + tsc_frequency_hz: 1_000_000_000, + tsc_tolerance_ppm: 0, + cpu_contract: Vec::new(), + cpu_contract_sha256: Vec::new(), + boot_layout_version: MICROVM_BOOT_LAYOUT_VERSION, + clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), + apic_frequency_hz: Some(1_000_000_000), + virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), + virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, + virtio_shared_status_page_size: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + }; + contract.set_effective_command_line("console=hvc0".to_owned()); + contract.set_cpu_compatibility_contract(vec![1, 2, 3]); + contract +} + +#[cfg(test)] +mod tests { + use super::super::tests::test_manifest; + use super::*; + + #[test] + fn validate_microvm_machine_contract_ok() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + validate_microvm_machine_contract(&manifest, &contract).unwrap(); + } + + #[test] + fn validate_microvm_machine_contract_rejects_abi() { + let mut manifest = test_manifest(); + let expected = test_machine_contract(); + let mut contract = expected.clone(); + contract.microvm_abi_version = 1; + manifest.machine_contract = Some(contract); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("ABI version 1 is unsupported")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_backend() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.source_hypervisor = "kvm".to_owned(); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("source hypervisor")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_command_line() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.set_effective_command_line("console=other".to_owned()); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("command line")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_cpu_contract() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.set_cpu_compatibility_contract(vec![9, 9, 9]); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("CPU compatibility")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_tsc_frequency() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.tsc_frequency_hz += 1; + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("TSC frequency")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_apic_frequency() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.apic_frequency_hz = expected.apic_frequency_hz.map(|frequency| frequency + 1); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("APIC frequency")); + } + + #[test] + fn validate_microvm_machine_contract_accepts_legacy_apic_frequency() { + let mut manifest = test_manifest(); + let mut contract = test_machine_contract(); + contract.apic_frequency_hz = None; + manifest.machine_contract = Some(contract.clone()); + + validate_microvm_machine_contract(&manifest, &contract).unwrap(); + } + + #[test] + fn validate_microvm_machine_contract_rejects_unsupported_boot_layout() { + let mut manifest = test_manifest(); + let expected = test_machine_contract(); + let mut contract = expected.clone(); + contract.boot_layout_version = 1; + manifest.machine_contract = Some(contract); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!( + err.to_string() + .contains("boot layout version 1 is unsupported") + ); + } + + #[test] + fn microvm_snapshot_topology_is_canonical() { + for processor_count in [1, 2, 4, 8] { + let topology = microvm_snapshot_topology(processor_count).unwrap(); + assert_eq!(topology.sockets, 1); + assert_eq!(topology.dies_per_socket, 1); + assert_eq!(topology.cores_per_die, processor_count); + assert_eq!(topology.threads_per_core, 1); + assert_eq!(topology.apic_ids, (0..processor_count).collect::>()); + assert_eq!(MICROVM_BOOT_LAYOUT_VERSION, 2); + } + + for processor_count in [0, 3, 5, 16] { + assert!(microvm_snapshot_topology(processor_count).is_err()); + } + } + + #[test] + fn microvm_snapshot_identifies_shared_status_page() { + let contract = test_machine_contract(); + let mut manifest = test_manifest(); + manifest.machine_contract = Some(contract.clone()); + + validate_microvm_machine_contract(&manifest, &contract).unwrap(); + + manifest + .machine_contract + .as_mut() + .unwrap() + .virtio_shared_status_page_gpa += 0x1000; + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!( + error + .to_string() + .contains("shared-status interrupt contract") + ); + } + + #[test] + fn validate_microvm_snapshot_rejects_noncanonical_apic_ids() { + let mut manifest = test_manifest(); + let mut contract = test_machine_contract(); + contract.topology.apic_ids = vec![0, 2]; + manifest.machine_contract = Some(contract.clone()); + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("not canonical")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_clock_policy() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.clock_policy = "freeze_during_downtime".to_owned(); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("clock policy")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_topology() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.topology.apic_ids.swap(0, 1); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("processor topology")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_device_order() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.devices.reverse(); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("device inventory")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_removed_device() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.devices.pop(); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("device inventory")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_state_unit_order() { + let mut manifest = test_manifest(); + let contract = test_machine_contract(); + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.state_unit_names.reverse(); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(err.to_string().contains("state-unit inventory")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_duplicate_state_unit() { + let mut manifest = test_manifest(); + let mut contract = test_machine_contract(); + contract.state_unit_names.push("portb".to_owned()); + manifest.machine_contract = Some(contract.clone()); + let err = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(err.to_string().contains("duplicate state-unit name")); + } + + #[test] + fn validate_microvm_machine_contract_rejects_overlapping_ram() { + let mut manifest = test_manifest(); + let mut contract = test_machine_contract(); + contract.memory_ranges = vec![ + SnapshotMemoryRange { + gpa_start: 0, + length: 768, + file_offset: 0, + }, + SnapshotMemoryRange { + gpa_start: 512, + length: 256, + file_offset: 768, + }, + ]; + manifest.machine_contract = Some(contract.clone()); + let err = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(err.to_string().contains("overlap in GPA space")); + } +} From 534f688869353afdf7b964d266d68af638e7850d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 05:49:39 -0700 Subject: [PATCH 107/304] openvmm_core: propagate the guest TSC frequency for snapshot-capable cold boots The machine contract records the guest TSC frequency with zero tolerance, and a restore recreates that rate before any vCPU runs. A cold-booted Linux guest, however, calibrates its TSC against emulated timers, so the rate it uses can differ slightly from the rate that the backend reports and the contract records. A snapshot of that guest would resume with a clock that disagrees with the guest's own calibration. Let the host tell the guest its TSC frequency instead. Add `propagate_tsc_frequency` to the microVM loader. It writes the backend frequency into the kernel command line as `tsc_early_khz=`, in the same way that `propagate_apic_frequency` writes `lapic_timer_hz=`: - The frequency must be between 500 MHz and 10 GHz. It is rounded down to whole kHz. - The parameter is inserted before the fixed `virtio_mmio.device=` discovery suffix and before `--`, so device discovery and tenant arguments stay last. - A matching parameter is kept. If it is not in canonical decimal form (hexadecimal, quoted, or spelled with hyphens), it is rewritten in that form. A mismatched, malformed, or duplicate parameter is an error, and the command line is left unchanged. - Parsing follows the kernel's rules for quoting, whitespace, and parameter names, as for `lapic_timer_hz=`. `propagate_snapshot_tsc_frequency` applies this only when the cold boot can publish a snapshot, so ordinary boots keep the guest's normal TSC discovery path. Add `VmWorkerParameters::snapshot_capture_enabled` to carry that decision to the worker. `MicrovmParameters` records it, and `prepare_cold_boot_command_line` queries the backend TSC frequency and propagates it before the LAPIC frequency. If the backend reports no TSC frequency, the command line is left unchanged and a warning is logged. Every caller passes `false` for now. The CLI option that enables snapshot capture comes in a later commit, so this change does not alter any command line yet. Tests cover the range checks, the kHz conversion, placement before device discovery and `--`, matching, mismatched, malformed, duplicate, quoted, and hexadecimal values, and a platform snapshot command line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../src/worker/dispatch/microvm.rs | 33 +- .../src/worker/vm_loaders/microvm.rs | 371 +++++++++++++++++- openvmm/openvmm_defs/src/worker.rs | 2 + openvmm/openvmm_entry/src/lib.rs | 1 + openvmm/openvmm_entry/src/ttrpc/mod.rs | 1 + petri/src/worker.rs | 1 + 6 files changed, 402 insertions(+), 7 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index f6afa4d9633..1927e029669 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -37,6 +37,8 @@ use vmm_core::partition_unit::StopGuard; pub(super) struct MicrovmParameters { /// Snapshot boundary channels handed to the loaded VM. pub(super) snapshot_boundary: SnapshotBoundary, + /// Whether this cold boot can publish a microVM snapshot. + snapshot_capture_enabled: bool, } impl MicrovmParameters { @@ -48,11 +50,15 @@ impl MicrovmParameters { ready: parameters.snapshot_ready.take(), ..Default::default() }; + let snapshot_capture_enabled = parameters.snapshot_capture_enabled; openvmm_defs::microvm::validate_machine_config( ¶meters.cfg, Some(parameters.hypervisor.id()), )?; - Ok(Self { snapshot_boundary }) + Ok(Self { + snapshot_boundary, + snapshot_capture_enabled, + }) } /// Prepares the kernel command line of a microVM cold boot, before the VM @@ -62,7 +68,12 @@ impl MicrovmParameters { vm: &mut InitializedVm, restored_from_snapshot: bool, ) -> anyhow::Result<()> { - prepare_cold_boot_command_line(&mut vm.cfg, vm.partition.as_ref(), restored_from_snapshot) + prepare_cold_boot_command_line( + &mut vm.cfg, + vm.partition.as_ref(), + restored_from_snapshot, + self.snapshot_capture_enabled, + ) } } @@ -516,6 +527,7 @@ fn prepare_cold_boot_command_line( cfg: &mut Manifest, partition: &dyn HvlitePartition, restored_from_snapshot: bool, + snapshot_capture_enabled: bool, ) -> anyhow::Result<()> { if restored_from_snapshot || cfg.machine_profile != MachineProfile::Microvm { return Ok(()); @@ -532,6 +544,22 @@ fn prepare_cold_boot_command_line( let Some(cmdline) = cmdline else { anyhow::bail!("microVM has no supported cold-boot command line"); }; + match partition + .tsc_frequency_hz() + .context("failed to query the backend guest TSC frequency")? + { + Some(frequency_hz) => { + crate::worker::vm_loaders::microvm::propagate_snapshot_tsc_frequency( + cmdline, + frequency_hz, + snapshot_capture_enabled, + ) + .context("failed to propagate the guest TSC frequency")?; + } + None => tracing::warn!( + "backend does not expose a guest TSC frequency; preserving the microVM command line" + ), + } match partition .apic_frequency_hz() .context("failed to query the backend guest LAPIC frequency")? @@ -552,6 +580,7 @@ fn prepare_cold_boot_command_line( _cfg: &mut Manifest, _partition: &dyn HvlitePartition, _restored_from_snapshot: bool, + _snapshot_capture_enabled: bool, ) -> anyhow::Result<()> { Ok(()) } diff --git a/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs b/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs index abd6e1a275c..9af124ab451 100644 --- a/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs +++ b/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs @@ -17,6 +17,10 @@ use thiserror::Error; use vm_loader::InitialLoad; use vm_loader::Loader; +const MIN_TSC_FREQUENCY_HZ: u64 = 500_000_000; +const MAX_TSC_FREQUENCY_HZ: u64 = 10_000_000_000; +const HZ_PER_KHZ: u64 = 1000; +const TSC_EARLY_KHZ: &str = "tsc_early_khz"; const LAPIC_TIMER_HZ: &str = "lapic_timer_hz"; const MIN_APIC_FREQUENCY_HZ: u64 = 1_000_000; const VIRTIO_MMIO_DEVICE: &str = "virtio_mmio.device="; @@ -76,6 +80,38 @@ pub(crate) enum FrequencyParameterError { Mismatch { specified: u64, reported: u64 }, } +#[derive(Debug, Error, PartialEq, Eq)] +pub(crate) enum TscFrequencyError { + #[error("TSC frequency {0} Hz is too small to convert to kHz")] + TooSmall(u64), + #[error("TSC frequency {0} Hz is outside the supported range of 500 MHz through 10 GHz")] + OutOfRange(u64), + #[error("kernel command line contains duplicate tsc_early_khz parameters")] + Duplicate, + #[error("kernel command line contains malformed tsc_early_khz parameter: {0}")] + Malformed(String), + #[error( + "kernel command line tsc_early_khz value {specified} does not match the backend-reported value {reported}" + )] + Mismatch { specified: u64, reported: u64 }, +} + +impl From for TscFrequencyError { + fn from(error: FrequencyParameterError) -> Self { + match error { + FrequencyParameterError::Duplicate => Self::Duplicate, + FrequencyParameterError::Malformed(parameter) => Self::Malformed(parameter), + FrequencyParameterError::Mismatch { + specified, + reported, + } => Self::Mismatch { + specified, + reported, + }, + } + } +} + #[derive(Debug, Error, PartialEq, Eq)] pub(crate) enum ApicFrequencyError { #[error("LAPIC frequency {0} Hz is outside the supported range of 1 MHz through 4294967295 Hz")] @@ -143,6 +179,23 @@ fn parse_linux_uint(value: &str) -> Option { u32::from_str_radix(digits, radix).ok() } +pub(crate) fn propagate_tsc_frequency( + cmdline: &mut String, + frequency_hz: u64, +) -> Result<(), TscFrequencyError> { + if frequency_hz < HZ_PER_KHZ { + return Err(TscFrequencyError::TooSmall(frequency_hz)); + } + if !(MIN_TSC_FREQUENCY_HZ..=MAX_TSC_FREQUENCY_HZ).contains(&frequency_hz) { + return Err(TscFrequencyError::OutOfRange(frequency_hz)); + } + Ok(propagate_frequency_parameter( + cmdline, + TSC_EARLY_KHZ, + frequency_hz / HZ_PER_KHZ, + )?) +} + pub(crate) fn propagate_apic_frequency( cmdline: &mut String, frequency_hz: u64, @@ -232,22 +285,38 @@ fn propagate_frequency_parameter( Ok(()) } +pub(crate) fn propagate_snapshot_tsc_frequency( + cmdline: &mut String, + frequency_hz: u64, + snapshot_capture_enabled: bool, +) -> Result<(), TscFrequencyError> { + if !snapshot_capture_enabled { + return Ok(()); + } + propagate_tsc_frequency(cmdline, frequency_hz) +} + #[cfg(test)] mod tests { use super::*; use test_with_tracing::test; + const TSC_FREQUENCY_HZ: u64 = 2_500_000_999; + #[test] - fn propagates_backend_apic_frequency() { + fn propagates_backend_apic_frequency_without_changing_tsc_policy() { let mut cmdline = "console=hvc0 -- tenant".to_owned(); + propagate_snapshot_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ, false).unwrap(); propagate_apic_frequency(&mut cmdline, 200_000_000).unwrap(); assert_eq!(cmdline, "console=hvc0 lapic_timer_hz=200000000 -- tenant"); - let mut discovery = "console=hvc0 virtio_mmio.device=0x1000@0xd0002000:7".to_owned(); - propagate_apic_frequency(&mut discovery, 200_000_000).unwrap(); + let mut snapshot = "console=hvc0 virtio_mmio.device=0x1000@0xd0002000:7".to_owned(); + propagate_snapshot_tsc_frequency(&mut snapshot, TSC_FREQUENCY_HZ, true).unwrap(); + propagate_apic_frequency(&mut snapshot, 200_000_000).unwrap(); assert_eq!( - discovery, - "console=hvc0 lapic_timer_hz=200000000 virtio_mmio.device=0x1000@0xd0002000:7" + snapshot, + "console=hvc0 tsc_early_khz=2500000 lapic_timer_hz=200000000 \ + virtio_mmio.device=0x1000@0xd0002000:7" ); } @@ -296,4 +365,296 @@ mod tests { propagate_apic_frequency(&mut String::new(), frequency).unwrap(); } } + + #[test] + fn preserves_non_snapshot_command_line() { + let mut cmdline = "console=ttyS0 tsc_early_khz=not-a-number".to_owned(); + let original = cmdline.clone(); + + propagate_snapshot_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ, false).unwrap(); + + assert_eq!(cmdline, original); + } + + #[test] + fn appends_normalized_frequency() { + let mut cmdline = "console=ttyS0".to_owned(); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(cmdline, "console=ttyS0 tsc_early_khz=2500000"); + } + + #[test] + fn inserts_before_fixed_virtio_discovery_suffix() { + let mut cmdline = "console=hvc1 \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0003000:4" + .to_owned(); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!( + cmdline, + "console=hvc1 tsc_early_khz=2500000 \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0003000:4" + ); + } + + #[test] + fn accepts_matching_frequency_without_mutation() { + let mut cmdline = "console=ttyS0 tsc_early_khz=2500000".to_owned(); + let original = cmdline.clone(); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(cmdline, original); + } + + #[test] + fn rejects_mismatched_frequency() { + let mut cmdline = "tsc_early_khz=2499999".to_owned(); + assert_eq!( + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Mismatch { + specified: 2_499_999, + reported: 2_500_000, + }) + ); + } + + #[test] + fn rejects_malformed_frequency() { + for parameter in [ + "tsc_early_khz", + "tsc_early_khz=", + "tsc_early_khz=2.5", + "tsc_early_khz=18446744073709551616", + ] { + let mut cmdline = parameter.to_owned(); + assert!(matches!( + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Malformed(_)) + )); + } + } + + #[test] + fn rejects_duplicate_frequency() { + let mut cmdline = "tsc_early_khz=2500000 console=ttyS0 tsc_early_khz=2500000".to_owned(); + assert_eq!( + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Duplicate) + ); + } + + #[test] + fn validates_frequency_range_before_conversion() { + let mut cmdline = String::new(); + assert_eq!( + propagate_tsc_frequency(&mut cmdline, 999), + Err(TscFrequencyError::TooSmall(999)) + ); + assert_eq!( + propagate_tsc_frequency(&mut cmdline, MIN_TSC_FREQUENCY_HZ - 1), + Err(TscFrequencyError::OutOfRange(MIN_TSC_FREQUENCY_HZ - 1)) + ); + assert_eq!( + propagate_tsc_frequency(&mut cmdline, MAX_TSC_FREQUENCY_HZ + 1), + Err(TscFrequencyError::OutOfRange(MAX_TSC_FREQUENCY_HZ + 1)) + ); + + propagate_tsc_frequency(&mut String::new(), MIN_TSC_FREQUENCY_HZ).unwrap(); + propagate_tsc_frequency(&mut String::new(), MAX_TSC_FREQUENCY_HZ).unwrap(); + } + + #[test] + fn handles_ascii_whitespace_without_adding_extra_whitespace() { + let mut empty = String::new(); + propagate_tsc_frequency(&mut empty, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(empty, "tsc_early_khz=2500000"); + + let mut trailing = "console=ttyS0 \t".to_owned(); + propagate_tsc_frequency(&mut trailing, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(trailing, "console=ttyS0 \ttsc_early_khz=2500000"); + + let mut supplied = "console=ttyS0\t tsc_early_khz=2500000\n".to_owned(); + let original = supplied.clone(); + propagate_tsc_frequency(&mut supplied, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(supplied, original); + } + + #[test] + fn inserts_before_delimiter_and_ignores_tokens_after_it() { + let mut cmdline = "console=ttyS0 \t-- tsc_early_khz=1 tsc_early_khz=malformed".to_owned(); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!( + cmdline, + "console=ttyS0 \ttsc_early_khz=2500000 -- \ + tsc_early_khz=1 tsc_early_khz=malformed" + ); + + let mut supplied = "tsc_early_khz=2500000 -- tsc_early_khz=1 tsc_early_khz=2".to_owned(); + let original = supplied.clone(); + propagate_tsc_frequency(&mut supplied, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(supplied, original); + } + + #[test] + fn treats_hyphens_and_underscores_as_the_same_parameter_name() { + let mut matching = "tsc-early-khz=2500000".to_owned(); + propagate_tsc_frequency(&mut matching, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(matching, "tsc_early_khz=2500000"); + + let mut mismatched = "tsc-early-khz=2499999".to_owned(); + assert_eq!( + propagate_tsc_frequency(&mut mismatched, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Mismatch { + specified: 2_499_999, + reported: 2_500_000, + }) + ); + + let mut duplicate = "tsc-early-khz=2500000 tsc_early_khz=2500000".to_owned(); + assert_eq!( + propagate_tsc_frequency(&mut duplicate, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Duplicate) + ); + } + + #[test] + fn canonicalizes_quote_stripped_decimal_frequency() { + let mut cmdline = r#"console=ttyS0 tsc_early_khz="2500000""#.to_owned(); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(cmdline, "console=ttyS0 tsc_early_khz=2500000"); + } + + #[test] + fn ignores_parameter_text_inside_quoted_values() { + for quoted_text in [ + "--", + "tsc_early_khz=1", + "virtio_mmio.device=0x1000@0xd0002000:7", + ] { + let original = format!(r#"console=hvc1 note="left {quoted_text} right""#); + let mut cmdline = original.clone(); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(cmdline, format!("{original} tsc_early_khz=2500000")); + + let original = format!("{original} tsc_early_khz=2500000"); + let mut cmdline = original.clone(); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(cmdline, original); + } + } + + #[test] + fn inserts_before_whole_quoted_discovery_or_delimiter() { + for suffix in [ + r#""virtio_mmio.device=0x1000@0xd0002000:7""#, + r#""--" tsc_early_khz=1"#, + ] { + let mut cmdline = format!("console=hvc1 {suffix}"); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!( + cmdline, + format!("console=hvc1 tsc_early_khz=2500000 {suffix}") + ); + } + } + + #[test] + fn inserts_before_unterminated_quoted_argument() { + for suffix in [ + r#"note="left -- right"#, + r#"note="left tsc_early_khz=1"#, + r#"-- note="left"#, + ] { + let mut cmdline = format!("console=hvc1 {suffix}"); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!( + cmdline, + format!("console=hvc1 tsc_early_khz=2500000 {suffix}") + ); + } + } + + #[test] + fn canonicalizes_whole_quoted_frequency_parameters() { + let mut matching = r#""tsc_early_khz=2500000""#.to_owned(); + propagate_tsc_frequency(&mut matching, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(matching, "tsc_early_khz=2500000"); + + let mut mismatched = r#""tsc_early_khz=2499999""#.to_owned(); + assert_eq!( + propagate_tsc_frequency(&mut mismatched, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Mismatch { + specified: 2_499_999, + reported: 2_500_000, + }) + ); + + let mut duplicate = r#""tsc_early_khz=2500000" tsc_early_khz=2500000"#.to_owned(); + assert_eq!( + propagate_tsc_frequency(&mut duplicate, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Duplicate) + ); + + for parameter in [r#""tsc_early_khz""#, r#""tsc_early_khz="2500000"""#] { + let mut cmdline = parameter.to_owned(); + assert!(matches!( + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Malformed(_)) + )); + } + } + + #[test] + fn canonicalizes_hexadecimal_frequency() { + let mut matching = "tsc_early_khz=0x2625a0".to_owned(); + propagate_tsc_frequency(&mut matching, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!(matching, "tsc_early_khz=2500000"); + + let mut mismatched = "tsc_early_khz=0X26259F".to_owned(); + assert_eq!( + propagate_tsc_frequency(&mut mismatched, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Mismatch { + specified: 2_499_999, + reported: 2_500_000, + }) + ); + } + + #[test] + fn canonicalizes_platform_snapshot_command_line() { + for supplied in [ + "tsc-early-khz=2500000", + "tsc_early_khz=0x2625a0", + r#"tsc_early_khz="2500000""#, + ] { + let mut cmdline = format!( + "earlycon=xe9 console=hvc0 nvx_snapshot_tier=platform {supplied} \ + virtio_mmio.device=0x1000@0xd0002000:7" + ); + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); + assert_eq!( + cmdline, + "earlycon=xe9 console=hvc0 nvx_snapshot_tier=platform \ + tsc_early_khz=2500000 virtio_mmio.device=0x1000@0xd0002000:7" + ); + } + } + + #[test] + fn rejects_malformed_or_overflowing_linux_uint() { + for parameter in [ + r#"tsc_early_khz="2500000"#, + "tsc_early_khz=0x", + "tsc_early_khz=08", + "tsc_early_khz=-2500000", + "tsc_early_khz=4294967296", + "tsc_early_khz=0x100000000", + ] { + let mut cmdline = parameter.to_owned(); + assert!(matches!( + propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), + Err(TscFrequencyError::Malformed(_)) + )); + } + } } diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index 3bc543c9d85..a143a035fd3 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -72,6 +72,8 @@ pub struct VmWorkerParameters { Option>, /// Notifies the controller after the worker establishes the boundary. pub snapshot_ready: Option>, + /// Whether this cold boot can publish a microVM snapshot. + pub snapshot_capture_enabled: bool, /// Single-use process-local sink for the restore readiness event. pub restore_ready_sink: Option, /// The VM RPC channel. diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 72f68156b19..5883a94f58a 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2944,6 +2944,7 @@ async fn run_control_inner( snapshot_restore_guards: restore.guards, snapshot_boundary_requests: None, snapshot_ready: None, + snapshot_capture_enabled: false, restore_ready_sink, rpc: rpc_recv, notify: notify_send, diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 8b6dc25dfd3..f2a1f8ce266 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1314,6 +1314,7 @@ impl VmService { snapshot_restore_guards: None, snapshot_boundary_requests: None, snapshot_ready: None, + snapshot_capture_enabled: false, restore_ready_sink: None, rpc: recv, notify: notify_send, diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 67cae5a2a77..c85d7969432 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -43,6 +43,7 @@ impl Worker { snapshot_restore_guards: None, snapshot_boundary_requests: None, snapshot_ready: None, + snapshot_capture_enabled: false, restore_ready_sink: None, rpc: rpc_recv, notify: notify_send, From 5751cf35639030fe3d43d2e5bad720e968ee2d6e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 06:11:36 -0700 Subject: [PATCH 108/304] openvmm_entry: capture guest-requested microVM snapshots The worker can establish a snapshot boundary when a microVM guest writes the snapshot-request port, but nothing configures the port or acts on the boundary. Connect the entry point, the worker, and the VM controller so that a guest can request its own snapshot. Add `--snapshot-destination ` and `--snapshot-quiesce-timeout-ms` (default 5000) as the first `MicrovmCli` options. A destination conflicts with `--restore-snapshot`, and it requires shared RAM without explicit hugepages and a nonzero quiesce timeout. Capture does not yet record the boot virtio-console attachment, so it rejects `--virtio-console` until a later commit persists the console. Configuration and launch: - `MicrovmConfigBuilder` adds the snapshot-request port at 0x605 to every microVM and uses the quiesce timeout as the port's input-gate timeout. The machine validator now requires the port in the exact chipset inventory. Petri adds the port without a notification target, so guest requests are ignored there. - The new `MicrovmResources` carries the port's request receiver out of the configuration builder. The new `MicrovmLaunch` turns it into the worker's boundary channels and a controller channel for released boundaries. - When capture is configured without a memory backing file, `MicrovmLaunch` creates temporary RAM backing in the destination's parent, so that publication can hard-link the exact RAM file into the snapshot. The worker maps a duplicate of the exact RAM handle that publication uses. - The worker receives `snapshot_capture_enabled`, so a capture-enabled cold boot gets `tsc_early_khz=`. - Hypervisor selection moves ahead of the worker launch, so that the controller can record the selected backend. The controller handles a released boundary in `handle_guest_snapshot_request`: 1. Without a destination, it releases the boundary, and the guest continues. 2. A preflight checks the profile, the backend, that the destination is still absent, the RAM handle, and the command line. A failed preflight also releases the boundary. 3. It asks the worker to quiesce with `QuiesceForSnapshot`. A rejected request releases the boundary. A rollback-safe failure resumes the guest with `ResumeAfterFailedSnapshot`. An uncertain failure or a lost worker terminates the source with status 1. 4. It builds the machine contract from the quiesce response, writes a manifest that carries it, flushes the RAM handle, and publishes the snapshot. Automatic RAM is published with `write_snapshot_from_owned_memory_file`. User-supplied RAM is copied with `write_snapshot_from_memory_file`. 5. After a commit, the source process exits with status 0 without resuming the guest. A failure after the commit, a failure whose cleanup is uncertain, or a failed rollback exits with status 1. Any other failure rolls back, and the guest continues. Snapshot publication now validates the shape of a manifest's machine contract before it stages anything. The controller also rejects `save-snapshot` and worker restart for a microVM before it pauses the VM or starts a new worker host. Document the options, capture publication, the Windows flush policy, and the management RPCs that a held boundary rejects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/dev_guide/snapshot_format.md | 41 +-- Guide/src/reference/openvmm/management/cli.md | 23 +- Guide/src/user_guide/openvmm/snapshots.md | 64 +++- openvmm/openvmm_defs/src/microvm.rs | 1 + openvmm/openvmm_entry/src/cli_args.rs | 4 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 80 +++++ openvmm/openvmm_entry/src/lib.rs | 34 +- openvmm/openvmm_entry/src/microvm/config.rs | 31 +- openvmm/openvmm_entry/src/microvm/launch.rs | 191 ++++++++++++ openvmm/openvmm_entry/src/microvm/mod.rs | 12 + openvmm/openvmm_entry/src/ttrpc/mod.rs | 1 + openvmm/openvmm_entry/src/vm_controller.rs | 26 +- .../src/vm_controller/microvm.rs | 295 ++++++++++++++++++ .../openvmm_helpers/src/snapshot/publish.rs | 8 + petri/src/vm/openvmm/microvm.rs | 9 + 15 files changed, 771 insertions(+), 49 deletions(-) create mode 100644 openvmm/openvmm_entry/src/microvm/launch.rs create mode 100644 openvmm/openvmm_entry/src/vm_controller/microvm.rs diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index edf8ad78a0b..fc25811260f 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -11,7 +11,7 @@ A snapshot is stored as a directory containing three files: snapshot-dir/ ├── manifest.bin # Protobuf-encoded SnapshotManifest ├── state.bin # Protobuf-encoded device saved state -└── memory.bin # Independent copy of the guest memory backing file +└── memory.bin # Exact automatic RAM file or independent supplied-RAM clone ``` ## Manifest format @@ -46,22 +46,25 @@ default values, forward/backward compatibility) apply. ## Memory (`memory.bin`) -`memory.bin` is an independent copy of the file-backed guest RAM, so resuming -or reusing the source VM cannot modify a published snapshot. Clone support is -used when available, with allocated-range or zero-scan copying as a fallback on -Linux; Windows uses a dense copy. The copy reads through an already-open handle -rather than reopening the backing file's path, so replacing the source path -cannot substitute different bytes. - -## Publication - -`write_snapshot()` writes and flushes `state.bin`, `memory.bin`, and -`manifest.bin` in a uniquely named, private staging directory next to the -destination, flushes the staging directory, and then renames it to the -destination in one operation that never replaces an existing path. The rename -is the commit point; the parent directory is flushed afterwards. A failure -before the commit removes the staging directory and leaves the destination -absent. The destination must not already exist. +For automatically allocated microVM RAM, `memory.bin` is a hard link to the +exact OpenVMM-owned backing handle. State and manifest are written and flushed +first; OpenVMM then flushes the stopped guest's shared RAM mappings and exact +backing handle, creates the link last, and verifies the linked handle's file +identity and EOF before the staging directory is renamed into place. Linux +prefers `linkat(AT_EMPTY_PATH)` and can use a `/proc/self/fd` link with a +device/inode proof. Windows uses handle-relative `FileLinkInformation` and +verifies `FILE_ID_INFO`. Filesystems that cannot create the link fall back to +an independent sparse-aware copy. + +User-supplied RAM always uses the independent-copy path, never a hard link. +Clone support is used when available, with allocated-range or zero-scan copying +as a fallback on Linux; Windows uses a dense copy. Both paths use an +already-open handle rather than reopening its pathname, so replacing the source +path cannot substitute different bytes. +Successful guest-requested capture is terminal for the source. If publication +fails after the automatic link exists, OpenVMM removes and durably flushes the +private staging directory before allowing rollback; uncertain cleanup makes +the source terminate instead of resume. ## Restore @@ -83,8 +86,8 @@ verified artifact broker. ## Code references - Manifest type and I/O: `openvmm/openvmm_helpers/src/snapshot.rs` -- Format validation, publication, restore-side access, and file-system helpers: - `openvmm/openvmm_helpers/src/snapshot/` +- Format validation, publication, restore-side access, file-system helpers, + and the microVM machine contract: `openvmm/openvmm_helpers/src/snapshot/` - Restore entry point: `prepare_snapshot_restore()` in `openvmm/openvmm_entry/src/snapshot_restore/prepare.rs` - File-backed memory: `SharedMemoryFd` type alias in diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 5c2ffbc84a2..f0175d5e095 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -43,7 +43,7 @@ describes the source definitions. `nr_cpus=` from the validated processor topology. It reserves a 1-GiB MMIO gap from 3 to 4 GiB and exposes only PIC/IOAPIC, PIT, a CMOS RTC anchored to UTC, - the microVM portb console, the shutdown port, and the optional fixed virtio + the microVM portb console, lifecycle ports, and the optional fixed virtio devices described below. User arguments cannot override `earlycon=`, `console=`, `virtio_mmio.device=`, or `nr_cpus=`. @@ -59,6 +59,27 @@ describes the source definitions. `microvm` uses one socket and one die, with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. + Guest-requested snapshot capture is available on Linux/KVM, Linux/MSHV, and + Windows/WHP. +* `--snapshot-destination `: Publish a microVM snapshot when the guest + writes to PMIO port `0x605`. The destination must not exist and its parent + must already be a directory. OpenVMM automatically creates file-backed RAM + in that parent when no memory backing file was supplied, quiesces the VM, + writes and flushes a sibling staging directory, and atomically renames it to + `DIR`. After a successful commit, the source VM terminates without executing + the instruction after the snapshot `out`. + + `--snapshot-quiesce-timeout-ms ` sets the bounded quiesce + timeout and defaults to 5000. A request with no configured destination is + ignored and the guest continues. Capture requires 1, 2, 4, or 8 vCPUs, + KVM, MSHV, or WHP, and shared file-backed RAM. It does not yet support + `--virtio-console`. + + ```bash + openvmm --machine microvm --hypervisor kvm --memory 128M \ + --kernel vmlinux --initrd initramfs.cpio.gz \ + --snapshot-destination snapshot + ``` * `--memory `: Configure guest RAM. Defaults to `size=1G`. `SPEC` can be a size-only shorthand, such as `--memory 4G`, or a comma-separated key/value list: diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 6aefd617ad7..2664fe85ca2 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -22,14 +22,17 @@ These are stored as three files in a snapshot directory: ## Prerequisites -Snapshots require **file-backed guest memory**. Pass `file=` in the -`--memory` option when launching the VM so that guest RAM is written to a -file on disk rather than held in anonymous memory. +Host-driven snapshots require **file-backed guest memory**. Pass `file=` +in the `--memory` option when launching a standard VM. A microVM launched with +`--snapshot-destination` automatically creates temporary file-backed RAM in +the destination's parent directory when no backing file was supplied. -```admonish note -OpenVMM copies the memory backing file into `memory.bin`, using file cloning -when the filesystem supports it, so the backing file and the snapshot directory -can be on different filesystems. +```admonish warning +Automatically allocated microVM RAM and the snapshot destination are on the +same filesystem so OpenVMM can promote the exact RAM file by hard link. A +filesystem without hard-link support falls back to copying. Explicit +user-supplied memory is always copied into a uniquely named sibling staging +directory. OpenVMM atomically renames the completed directory into place. ``` ## Saving a snapshot @@ -51,17 +54,50 @@ specifying the output directory: save-snapshot path/to/snapshot-dir ``` -OpenVMM writes and flushes `manifest.bin`, `state.bin`, and a copy of guest -RAM in `memory.bin` in a uniquely named sibling staging directory, and then -atomically renames the completed directory to the specified path. The -destination must not already exist. The rename is the commit point: if saving -fails before it, nothing is published at the destination. +OpenVMM writes and flushes `manifest.bin`, `state.bin`, and `memory.bin` in a +sibling staging directory. Host-driven saves and user-supplied microVM backing +use an independent memory copy. Automatic microVM backing uses its exact RAM +file when the filesystem supports hard links. The destination must not already +exist. Publishing the completed directory is the commit point. + +On Windows, large mapped-RAM flushes use up to eight concurrent, disjoint, +page-aligned ranges to reduce sensitivity to fragmented dirty-page writeback. +Small ranges are flushed inline. Capture waits for every range, including +when a flush fails; an error prevents publication. The subsequent file and +directory durability barriers are unchanged. +Worker startup has a cost on fast storage; this policy targets writeback +stalls rather than guaranteeing lower capture latency on every host. + +The Windows-only `sparse_mmap` test `profile_fragmented_file_flush` reproduces +the fragmented writeback workload with alternating dirty 4-KiB pages in a +128-MiB file. It compares serial and bounded-parallel flushing, including the +subsequent file sync, and verifies the persisted bytes. Run it on an otherwise +idle host: + +```text +cargo nextest run --profile agent --release -p sparse_mmap --run-ignored only -E "test(profile_fragmented_file_flush)" --success-output immediate +``` + +Timing output is diagnostic, not a portable assertion or proof that an +external storage-throttling condition has been eliminated. ```admonish warning -After saving, the VM remains **paused** and the REPL blocks resume. -Use `shutdown` to exit OpenVMM after saving. +After a host-driven save, the VM remains **paused**. Guest-requested microVM +capture instead terminates the source process after publication commits. +If automatic-RAM publication fails after creating its staging link, OpenVMM +removes the complete staging directory before resuming; if cleanup cannot be +proved, it terminates the source instead. ``` +While a guest-requested snapshot boundary is held, VM-worker management RPCs +that can change VM state are rejected rather than queued. This includes memory +writes, pause/resume, reset, interrupt injection, hotplug, and state dumps. +Snapshot lifecycle RPCs and memory reads remain available. Mutating RPCs with +an error result report that the boundary is active; pause, clear-halt, and NMI +requests report a reply-channel error because their result types cannot carry +an application error. Retry a rejected operation after the boundary is +released; ordinary paused VMs are not subject to this restriction. + ## Restoring a snapshot To restore, pass the snapshot directory with `--restore-snapshot`: diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 01368f3d71f..c417a302f24 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -475,6 +475,7 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> ("ioapic", "generic-ioapic"), ("microvm-portb", "microvm-portb"), ("microvm-shutdown", "microvm-shutdown"), + ("microvm-snapshot-request", "microvm-snapshot-request"), ("pic", "pic"), ("pit", "pit"), ], diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index ddef5cac5c0..66537098b41 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -1418,6 +1418,10 @@ Syntax: id= #[cfg(target_os = "linux")] #[clap(long, conflicts_with("pcat"))] pub iommu: Vec, + + /// microVM machine profile options. + #[clap(flatten)] + pub microvm: microvm::MicrovmCli, } impl Options { diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 9425922bbec..af52f446e0f 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -9,6 +9,7 @@ use super::SmtConfigCli; use clap::ValueEnum; use openvmm_defs::config::X2ApicConfig; use openvmm_defs::microvm::MachineProfile; +use std::path::PathBuf; /// Guest-visible machine profile. #[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] @@ -28,6 +29,18 @@ impl From for MachineProfile { } } +/// Options of the microVM machine profile. +#[derive(clap::Args)] +pub struct MicrovmCli { + /// Capture a microVM snapshot to this directory when the guest writes PMIO 0x605. + #[clap(long, value_name = "DIR", conflicts_with = "restore_snapshot")] + pub snapshot_destination: Option, + + /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. + #[clap(long, value_name = "MILLISECONDS", default_value_t = 5000)] + pub snapshot_quiesce_timeout_ms: u64, +} + impl Options { pub(crate) fn validate_microvm_options(&self) -> anyhow::Result<()> { if self.machine != MachineProfileCli::Microvm { @@ -54,6 +67,24 @@ impl Options { && matches!(self.x2apic, X2ApicConfig::Auto), "microVM owns CPU topology and APIC configuration" ); + if self.microvm.snapshot_destination.is_some() { + anyhow::ensure!( + !self.private_memory(), + "microVM snapshot capture requires shared file-backed RAM" + ); + anyhow::ensure!( + !self.memory.hugepages, + "microVM snapshot capture does not support explicit hugepage backing" + ); + anyhow::ensure!( + self.microvm.snapshot_quiesce_timeout_ms != 0, + "microVM snapshot quiesce timeout must be nonzero" + ); + anyhow::ensure!( + self.virtio_console.is_none(), + "microVM snapshot capture does not yet support --virtio-console" + ); + } anyhow::ensure!( self.restore_snapshot.is_none(), "microVM does not support snapshot restore" @@ -272,6 +303,55 @@ mod tests { } } + #[test] + fn test_microvm_snapshot_capture_options() { + let capture = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--processors", + "2", + "--snapshot-destination", + "snapshot", + ]) + .unwrap(); + assert_eq!(capture.microvm.snapshot_quiesce_timeout_ms, 5000); + capture.validate_microvm_options().unwrap(); + + for extra in [ + vec!["--snapshot-quiesce-timeout-ms", "0"], + vec!["--memory", "size=1G,shared=off"], + vec!["--virtio-console", "none"], + ] { + let options = Options::try_parse_from( + [ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + ] + .into_iter() + .chain(extra), + ) + .unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--restore-snapshot", + "snapshot", + ]) + .is_err() + ); + } + #[test] fn test_microvm_command_line_is_owned_and_bounded() { assert_eq!( diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 5883a94f58a..1b943ea221b 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -198,6 +198,7 @@ struct VmResources { consomme_rpc: Option>, ged_rpc: Option>, vtl2_settings: Option, + microvm: microvm::MicrovmResources, /// Receives dirty rectangles from the synthetic video device for the VNC worker. dirty_rect_recv: Option>>, #[cfg(windows)] @@ -2235,7 +2236,7 @@ async fn vm_config_from_command_line( device.port_name ); } - microvm.finish(&mut cfg)?; + microvm.finish(&mut cfg, &mut resources)?; resources.serial_driver = Some(serial_driver); validate_snp_config(&cfg)?; Ok((cfg, resources)) @@ -2788,6 +2789,8 @@ async fn run_control_inner( let mesh = mesh_slot.as_ref().unwrap(); let mut restore = snapshot_restore::SnapshotRestore::open(&opt)?; let (mut vm_config, mut resources) = vm_config_from_command_line(driver, mesh, &opt).await?; + let mut microvm = + microvm::MicrovmLaunch::new(&opt, &vm_config, std::mem::take(&mut resources.microvm))?; let mut vnc_worker = None; if opt.gfx || opt.vnc.vnc { @@ -2908,12 +2911,24 @@ async fn run_control_inner( // spin up the VM let (vm_rpc, rpc_recv) = mesh::channel(); let (notify_send, notify_recv) = mesh::channel(); + let (snapshot_boundary_requests, snapshot_ready, snapshot_requests) = + microvm.snapshot_channels(); + let hypervisor = match &opt.hypervisor { + Some(name) => openvmm_helpers::hypervisor::hypervisor_resource(name)?, + None if opt.machine == MachineProfileCli::Microvm => { + openvmm_helpers::hypervisor::microvm::choose_microvm_hypervisor()? + } + None => openvmm_helpers::hypervisor::choose_hypervisor()?, + }; + let source_hypervisor = hypervisor.id().to_owned(); let vm_worker = { let vm_host = mesh.make_host("vm", opt.log_file.clone()).await?; let (shared_memory, saved_state) = if opt.restore_snapshot.is_some() { let (fd, state_msg) = restore.prepare(&opt)?; (Some(fd), Some(state_msg)) + } else if let Some(shared_memory) = microvm.capture_shared_memory()? { + (Some(shared_memory), None) } else { let shared_memory = opt .memory_backing_file() @@ -2930,21 +2945,15 @@ async fn run_control_inner( let restore_ready_sink = snapshot_restore::restore_ready_sink(&opt)?; let params = VmWorkerParameters { - hypervisor: match &opt.hypervisor { - Some(name) => openvmm_helpers::hypervisor::hypervisor_resource(name)?, - None if opt.machine == MachineProfileCli::Microvm => { - openvmm_helpers::hypervisor::microvm::choose_microvm_hypervisor()? - } - None => openvmm_helpers::hypervisor::choose_hypervisor()?, - }, + hypervisor, cfg: vm_config, saved_state, shared_memory, shared_memory_copy_on_write: restore.shared_memory_copy_on_write, snapshot_restore_guards: restore.guards, - snapshot_boundary_requests: None, - snapshot_ready: None, - snapshot_capture_enabled: false, + snapshot_boundary_requests, + snapshot_ready, + snapshot_capture_enabled: microvm.snapshot_capture_enabled(), restore_ready_sink, rpc: rpc_recv, notify: notify_send, @@ -3005,11 +3014,12 @@ async fn run_control_inner( vm_rpc: vm_rpc.clone(), paravisor_diag: Some(paravisor_diag), igvm_path: opt.igvm.clone(), - memory_backing_file: opt.memory_backing_file().cloned(), + memory_backing_file: microvm.memory_backing_file(&opt), memory: opt.memory_size(), processors: opt.processors, log_file: opt.log_file.clone(), crash_dump_path: opt.crash_dump_path.clone(), + microvm: microvm.into_controller(&opt, source_hypervisor, snapshot_requests), guest_power_actions: vm_controller::GuestPowerActions { shutdown: opt.guest_shutdown_action, reset: opt.guest_reset_action, diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index d8351f3f89e..e9fea57e475 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -3,8 +3,10 @@ //! MicroVM parts of the VM configuration built from the command line. +use super::MicrovmResources; use crate::ConsoleState; use crate::Options; +use crate::VmResources; use crate::cli_args::SerialConfigCli; use crate::cli_args::microvm::MachineProfileCli; use crate::serial_io; @@ -12,6 +14,7 @@ use anyhow::Context; use anyhow::bail; use chipset_resources::microvm::MicrovmPortbHandle; use chipset_resources::microvm::MicrovmShutdownHandle; +use chipset_resources::microvm::MicrovmSnapshotRequestHandle; use futures::AsyncReadExt; use futures::executor::block_on; use futures::io::AllowStdIo; @@ -23,6 +26,7 @@ use pal_async::DefaultDriver; use serial_core::resources::DisconnectedSerialBackendHandle; use std::cell::RefCell; use std::thread; +use std::time::Duration; use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; use vm_manifest_builder::MachineArch; use vm_resource::IntoResource; @@ -39,6 +43,7 @@ pub(crate) struct MicrovmConfigBuilder<'a> { active: bool, console: Option, portb: Option>, + resources: MicrovmResources, } impl<'a> MicrovmConfigBuilder<'a> { @@ -70,6 +75,7 @@ impl<'a> MicrovmConfigBuilder<'a> { active, console, portb: None, + resources: MicrovmResources::default(), }) } @@ -148,7 +154,7 @@ impl<'a> MicrovmConfigBuilder<'a> { Ok(virtio_console_backend) } - /// Adds the portb and shutdown chipset devices. + /// Adds the portb, shutdown, and snapshot-request chipset devices. pub(crate) fn add_chipset_devices( &mut self, chipset_devices: &mut Vec, @@ -156,6 +162,7 @@ impl<'a> MicrovmConfigBuilder<'a> { let Some(io) = self.portb.take() else { return Ok(()); }; + let opt = self.opt; chipset_devices.push(ChipsetDeviceHandle { name: MicrovmPortbHandle::ID.to_owned(), resource: MicrovmPortbHandle { io }.into_resource(), @@ -164,6 +171,20 @@ impl<'a> MicrovmConfigBuilder<'a> { name: MicrovmShutdownHandle::ID.to_owned(), resource: MicrovmShutdownHandle.into_resource(), }); + chipset_devices.push(ChipsetDeviceHandle { + name: MicrovmSnapshotRequestHandle::ID.to_owned(), + resource: { + let (notify, requests) = mesh::channel(); + self.resources.snapshot_requests = Some(requests); + MicrovmSnapshotRequestHandle { + notify: Some(notify), + input_gate_timeout: Duration::from_millis( + opt.microvm.snapshot_quiesce_timeout_ms, + ), + } + .into_resource() + }, + }); Ok(()) } @@ -245,7 +266,11 @@ impl<'a> MicrovmConfigBuilder<'a> { /// Completes the microVM configuration after the storage devices have been /// added, and validates the machine contract of every profile. - pub(crate) fn finish(self, cfg: &mut Config) -> anyhow::Result<()> { + pub(crate) fn finish( + mut self, + cfg: &mut Config, + resources: &mut VmResources, + ) -> anyhow::Result<()> { let opt = self.opt; if self.active { cfg.processor_topology.vps_per_socket = Some(opt.processors); @@ -283,6 +308,8 @@ impl<'a> MicrovmConfigBuilder<'a> { openvmm_defs::microvm::append_microvm_virtio_discovery(cmdline, has_console)?; } openvmm_defs::microvm::validate_machine_config(cfg, requested_hypervisor)?; + + resources.microvm = std::mem::take(&mut self.resources); Ok(()) } } diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs new file mode 100644 index 00000000000..ac4c97de098 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -0,0 +1,191 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM launch state carried from the VM configuration to the VM worker +//! and the VM controller. + +use super::MicrovmResources; +use crate::Options; +use crate::cli_args::microvm::MachineProfileCli; +use crate::vm_controller::MicrovmController; +use anyhow::Context; +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use openvmm_defs::config::Config; +use openvmm_defs::config::LoadMode; +use openvmm_defs::worker::SharedMemoryFd; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +/// MicroVM state derived from the VM configuration before the VM worker is +/// launched. +pub(crate) struct MicrovmLaunch { + active: bool, + effective_command_line: Option, + resources: MicrovmResources, + snapshot_destination: Option, + snapshot_memory_file: Option, + snapshot_memory_handle: Option, +} + +impl MicrovmLaunch { + /// Captures the microVM state of `vm_config` and prepares the RAM backing + /// used by guest-requested snapshot capture. + pub(crate) fn new( + opt: &Options, + vm_config: &Config, + resources: MicrovmResources, + ) -> anyhow::Result { + let effective_command_line = match &vm_config.load_mode { + LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } => Some(cmdline.clone()), + _ => None, + }; + + let snapshot_destination = opt.microvm.snapshot_destination.as_ref().map(|path| { + if path.is_absolute() { + path.clone() + } else { + std::env::current_dir().unwrap_or_default().join(path) + } + }); + let snapshot_memory_file = if let Some(destination) = &snapshot_destination + && opt.memory_backing_file().is_none() + { + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let parent_metadata = fs_err::symlink_metadata(parent).with_context(|| { + format!("failed to inspect snapshot parent {}", parent.display()) + })?; + anyhow::ensure!( + parent_metadata.file_type().is_dir(), + "snapshot parent is not a directory: {}", + parent.display() + ); + anyhow::ensure!( + fs_err::symlink_metadata(destination) + .is_err_and(|error| { error.kind() == io::ErrorKind::NotFound }), + "snapshot destination already exists or cannot be inspected: {}", + destination.display() + ); + let file = tempfile::Builder::new() + .prefix(".openvmm-microvm-memory-") + .tempfile_in(parent) + .context("failed to create snapshot memory backing")?; + openvmm_helpers::snapshot::fs::initialize_snapshot_memory_backing_file( + file.as_file(), + opt.memory_size(), + ) + .context("failed to initialize snapshot memory backing")?; + Some(file) + } else { + None + }; + let snapshot_memory_handle = if snapshot_destination.is_some() { + if let Some(file) = &snapshot_memory_file { + Some( + file.reopen() + .context("failed to duplicate automatic snapshot RAM handle")?, + ) + } else { + opt.memory_backing_file() + .map(|path| { + openvmm_helpers::shared_memory::open_memory_backing_file_handle( + path, + opt.memory_size(), + ) + }) + .transpose()? + } + } else { + None + }; + + Ok(Self { + active: opt.machine == MachineProfileCli::Microvm, + effective_command_line, + resources, + snapshot_destination, + snapshot_memory_file, + snapshot_memory_handle, + }) + } + + /// Returns whether guest-requested snapshot capture is configured. + pub(crate) fn snapshot_capture_enabled(&self) -> bool { + self.snapshot_destination.is_some() + } + + /// Duplicates the exact RAM handle used for guest-requested snapshot + /// capture, as the VM worker's shared guest RAM. + pub(crate) fn capture_shared_memory(&self) -> anyhow::Result> { + let Some(file) = &self.snapshot_memory_handle else { + return Ok(None); + }; + let file = file + .try_clone() + .context("failed to duplicate snapshot RAM handle for worker")?; + let shared_memory = openvmm_helpers::shared_memory::file_to_shared_memory_fd(file)?; + Ok(Some(shared_memory)) + } + + /// Creates the channels that carry guest-requested snapshot boundaries from + /// the VM worker to the VM controller. + pub(crate) fn snapshot_channels( + &mut self, + ) -> ( + Option>, + Option>, + Option>, + ) { + let snapshot_boundary_requests = self.resources.snapshot_requests.take(); + let (snapshot_ready, snapshot_requests) = if snapshot_boundary_requests.is_some() { + let (ready, requests) = mesh::channel(); + (Some(ready), Some(requests)) + } else { + (None, None) + }; + ( + snapshot_boundary_requests, + snapshot_ready, + snapshot_requests, + ) + } + + /// Returns the path of the file backing guest RAM, including the automatic + /// snapshot RAM backing. + pub(crate) fn memory_backing_file(&self, opt: &Options) -> Option { + opt.memory_backing_file().cloned().or_else(|| { + self.snapshot_memory_file + .as_ref() + .map(|file| file.path().to_owned()) + }) + } + + /// Hands the microVM state over to the VM controller. + pub(crate) fn into_controller( + self, + opt: &Options, + source_hypervisor: String, + snapshot_requests: Option>, + ) -> MicrovmController { + MicrovmController { + active: self.active, + snapshot_memory_handle: self.snapshot_memory_handle, + snapshot_requests, + snapshot_destination: self.snapshot_destination, + snapshot_quiesce_timeout: Duration::from_millis( + opt.microvm.snapshot_quiesce_timeout_ms, + ), + source_hypervisor, + effective_command_line: self.effective_command_line, + snapshot_memory_file: self.snapshot_memory_file, + } + } +} diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 3cd46b29a76..2ff9fd46ec1 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -4,5 +4,17 @@ //! MicroVM machine profile support for the OpenVMM entry point. mod config; +mod launch; pub(crate) use config::MicrovmConfigBuilder; +pub(crate) use launch::MicrovmLaunch; + +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; + +/// Host-side microVM resources produced while building the VM configuration +/// and consumed by the snapshot, restore, and teardown paths. +#[derive(Default)] +pub(crate) struct MicrovmResources { + /// Guest-requested snapshot boundaries from the snapshot-request port. + pub(crate) snapshot_requests: Option>, +} diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index f2a1f8ce266..941aad8fa25 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1346,6 +1346,7 @@ impl VmService { processors, log_file: None, crash_dump_path: req_config.crash_dump_path.map(Into::into), + microvm: Default::default(), guest_power_actions, }; diff --git a/openvmm/openvmm_entry/src/vm_controller.rs b/openvmm/openvmm_entry/src/vm_controller.rs index 8fdf17a2052..26965377649 100644 --- a/openvmm/openvmm_entry/src/vm_controller.rs +++ b/openvmm/openvmm_entry/src/vm_controller.rs @@ -18,6 +18,7 @@ use mesh::rpc::Rpc; use mesh::rpc::RpcSend; use mesh_worker::WorkerEvent; use mesh_worker::WorkerHandle; +use microvm::GuestSnapshotAction; use openvmm_defs::rpc::VmRpc; use std::path::Path; use std::path::PathBuf; @@ -26,6 +27,10 @@ use std::sync::Arc; use std::time::Instant; use vmm_core_defs::HaltReason; +mod microvm; + +pub(crate) use microvm::MicrovmController; + /// Inspection target: host-side workers or the paravisor. #[derive(Clone, Copy, mesh::MeshPayload)] pub enum InspectTarget { @@ -126,6 +131,7 @@ pub struct VmController { pub(crate) processors: u32, pub(crate) log_file: Option, pub(crate) crash_dump_path: Option, + pub(crate) microvm: MicrovmController, pub(crate) guest_power_actions: GuestPowerActions, } @@ -184,6 +190,7 @@ impl VmController { Worker(WorkerEvent), VncWorker(WorkerEvent), Halt(HaltReason), + SnapshotRequest, } let mut quit = false; @@ -205,8 +212,12 @@ impl VmController { .flatten() .map(Event::VncWorker); let halt = (&mut notify_recv).map(Event::Halt); + let snapshot_request = + futures::stream::iter(self.microvm.snapshot_requests.as_mut()) + .flatten() + .map(|()| Event::SnapshotRequest); - (rpc.into_stream(), vm, vnc, halt) + (rpc.into_stream(), vm, vnc, halt, snapshot_request) .merge() .next() .await @@ -326,6 +337,13 @@ impl VmController { } } } + Event::SnapshotRequest => { + let action = self.handle_guest_snapshot_request().await; + if let GuestSnapshotAction::Terminate { exit_code } = action { + event_send.send(VmControllerEvent::ExitRequested { code: exit_code }); + break; + } + } } } @@ -420,6 +438,9 @@ impl VmController { } async fn handle_restart(&mut self) -> anyhow::Result<()> { + if self.microvm.active { + anyhow::bail!("worker restart is unavailable for microVM"); + } let vm_host = self .mesh .make_host("vm", self.log_file.clone()) @@ -462,6 +483,9 @@ impl VmController { } async fn handle_save_snapshot(&self, dir: &Path) -> anyhow::Result<()> { + if self.microvm.active { + anyhow::bail!("disk snapshots are unavailable for microVM"); + } let memory_file_path = self .memory_backing_file .as_ref() diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs new file mode 100644 index 00000000000..99b6af6f075 --- /dev/null +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM snapshot capture handling of the VM controller. + +use super::VmController; +use anyhow::Context; +use mesh::rpc::RpcSend; +use openvmm_defs::rpc::SnapshotQuiesceError; +use openvmm_defs::rpc::VmRpc; +use std::path::PathBuf; +use std::time::Duration; + +/// MicroVM state owned by the VM controller. +#[derive(Default)] +pub(crate) struct MicrovmController { + /// Whether the microVM machine profile is selected. + pub(crate) active: bool, + /// Exact open handle of the file backing guest RAM for snapshot capture. + pub(crate) snapshot_memory_handle: Option, + /// Guest-requested snapshot boundaries released by the VM worker. + pub(crate) snapshot_requests: Option>, + /// Directory receiving a guest-requested snapshot. + pub(crate) snapshot_destination: Option, + /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. + pub(crate) snapshot_quiesce_timeout: Duration, + /// Hypervisor backend recorded in captured machine contracts. + pub(crate) source_hypervisor: String, + /// Effective kernel command line of the MP-table boot. + pub(crate) effective_command_line: Option, + /// Automatic RAM backing created for snapshot capture. + pub(crate) snapshot_memory_file: Option, +} + +pub(super) enum GuestSnapshotAction { + Continue, + Terminate { exit_code: i32 }, +} + +impl VmController { + pub(super) async fn handle_guest_snapshot_request(&mut self) -> GuestSnapshotAction { + let Some(destination) = self.microvm.snapshot_destination.clone() else { + tracelimit::warn_ratelimited!( + "ignoring microVM snapshot request because no destination is configured" + ); + return self.release_snapshot_boundary_without_capture().await; + }; + + let preflight = (|| -> anyhow::Result<()> { + anyhow::ensure!( + self.microvm.active, + "guest-requested snapshot capture requires the microVM profile" + ); + anyhow::ensure!( + matches!( + self.microvm.source_hypervisor.as_str(), + "kvm" | "mshv" | "whp" + ), + "microVM snapshot source backend must be KVM, MSHV, or WHP" + ); + anyhow::ensure!( + fs_err::symlink_metadata(&destination) + .is_err_and(|error| { error.kind() == std::io::ErrorKind::NotFound }), + "snapshot destination already exists or cannot be inspected: {}", + destination.display() + ); + let memory_path = self + .memory_backing_file + .clone() + .context("microVM snapshot capture requires file-backed RAM")?; + let memory_file = self + .microvm + .snapshot_memory_handle + .as_ref() + .context("microVM snapshot capture lost its exact RAM handle")?; + anyhow::ensure!( + memory_file + .metadata() + .context("failed to inspect snapshot RAM handle")? + .len() + == self.memory, + "snapshot RAM handle size does not match the VM" + ); + if let Some(file) = &self.microvm.snapshot_memory_file { + anyhow::ensure!( + file.path() == memory_path, + "automatic snapshot memory backing path changed unexpectedly" + ); + } + anyhow::ensure!( + self.microvm.effective_command_line.is_some(), + "microVM snapshot capture requires an effective command line" + ); + Ok(()) + })(); + if let Err(error) = preflight { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "microVM snapshot preflight failed; guest continues" + ); + return self.release_snapshot_boundary_without_capture().await; + } + + let response = match self + .vm_rpc + .call( + VmRpc::QuiesceForSnapshot, + self.microvm.snapshot_quiesce_timeout, + ) + .await + { + Ok(Ok(response)) => response, + Ok(Err(SnapshotQuiesceError::Rejected(error))) => { + tracing::error!( + error = &error as &dyn std::error::Error, + "microVM snapshot request was rejected; guest continues" + ); + return self.release_snapshot_boundary_without_capture().await; + } + Ok(Err(SnapshotQuiesceError::RollbackSafe(error))) => { + return self.rollback_failed_guest_snapshot(error.into()).await; + } + Ok(Err(SnapshotQuiesceError::Uncertain(error))) => { + tracing::error!( + error = &error as &dyn std::error::Error, + "microVM snapshot quiesce left uncertain state; terminating source" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } + Err(error) => { + tracing::error!( + error = &error as &dyn std::error::Error, + "lost VM worker during snapshot quiesce; terminating source" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } + }; + let command_line = response.effective_command_line; + + let result = (|| -> anyhow::Result<()> { + let machine_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( + &self.microvm.source_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + self.processors, + self.memory, + response.state_unit_names, + response.capture_wall_clock, + response.tsc_frequency_hz, + Some(response.apic_frequency_hz), + response.cpu_contract, + )?; + let manifest = openvmm_helpers::snapshot::SnapshotManifest { + version: openvmm_helpers::snapshot::MANIFEST_VERSION, + created_at: std::time::SystemTime::now().into(), + openvmm_version: env!("CARGO_PKG_VERSION").to_owned(), + memory_size_bytes: self.memory, + vp_count: self.processors, + page_size: crate::system_page_size(), + architecture: crate::GUEST_ARCH.to_owned(), + state_size_bytes: 0, + state_sha256: Vec::new(), + memory_sha256: Vec::new(), + machine_contract: Some(machine_contract), + format_magic: openvmm_helpers::snapshot::format::SNAPSHOT_FORMAT_MAGIC.to_vec(), + saved_state_schema_version: + openvmm_helpers::snapshot::format::SAVED_STATE_SCHEMA_VERSION, + saved_state_root_type: openvmm_helpers::snapshot::format::SAVED_STATE_ROOT_TYPE + .to_owned(), + }; + let saved_state_bytes = mesh::payload::encode(response.saved_state); + let memory_file = self + .microvm + .snapshot_memory_handle + .as_ref() + .context("microVM snapshot capture lost its exact RAM handle")?; + let memory_handle_flush = openvmm_defs::profile::ProfileSpan::start(); + memory_file + .sync_all() + .context("failed to flush snapshot RAM handle")?; + memory_handle_flush.complete( + "capture", + "memory_handle_flush", + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(self.memory), + ..Default::default() + }, + ); + let publication = openvmm_defs::profile::ProfileSpan::start(); + let write_result = if self.microvm.snapshot_memory_file.is_some() { + openvmm_helpers::snapshot::publish::write_snapshot_from_owned_memory_file( + &destination, + &manifest, + &saved_state_bytes, + memory_file, + ) + } else { + openvmm_helpers::snapshot::publish::write_snapshot_from_memory_file( + &destination, + &manifest, + &saved_state_bytes, + memory_file, + ) + }; + if write_result.is_ok() { + publication.complete_milestone( + "capture", + "publication", + openvmm_defs::profile::ProfileCounters { + logical_bytes: Some(self.memory), + ..Default::default() + }, + ); + } + write_result.map_err(anyhow::Error::new) + })(); + + match result { + Ok(()) => { + tracing::info!( + path = %destination.display(), + "microVM snapshot committed; terminating source process" + ); + GuestSnapshotAction::Terminate { exit_code: 0 } + } + Err(error) => { + let write_error = + error.downcast_ref::(); + if write_error.is_some_and(|error| error.is_committed()) { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + path = %destination.display(), + "snapshot committed but final durability reporting failed; terminating source" + ); + GuestSnapshotAction::Terminate { exit_code: 1 } + } else if write_error.is_some_and(|error| !error.is_rollback_safe()) { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "snapshot failed before commit but automatic RAM alias cleanup is uncertain; terminating source" + ); + GuestSnapshotAction::Terminate { exit_code: 1 } + } else { + self.rollback_failed_guest_snapshot(error).await + } + } + } + } + + async fn rollback_failed_guest_snapshot( + &mut self, + error: anyhow::Error, + ) -> GuestSnapshotAction { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "microVM snapshot failed before commit; attempting rollback" + ); + match self + .vm_rpc + .call_failable( + VmRpc::ResumeAfterFailedSnapshot, + self.microvm.snapshot_quiesce_timeout, + ) + .await + { + Ok(()) => { + tracing::info!("microVM snapshot rollback succeeded; guest resumed"); + GuestSnapshotAction::Continue + } + Err(rollback_error) => { + tracing::error!( + error = &rollback_error as &dyn std::error::Error, + "microVM snapshot rollback failed; terminating source" + ); + GuestSnapshotAction::Terminate { exit_code: 1 } + } + } + } + + async fn release_snapshot_boundary_without_capture(&mut self) -> GuestSnapshotAction { + match self + .vm_rpc + .call_failable(VmRpc::ReleaseSnapshotBoundary, ()) + .await + { + Ok(()) => GuestSnapshotAction::Continue, + Err(error) => { + tracing::error!( + error = &error as &dyn std::error::Error, + "failed to release microVM snapshot boundary; terminating source" + ); + GuestSnapshotAction::Terminate { exit_code: 1 } + } + } + } +} diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs index 24fbc5f52db..0e9fcdfeec8 100644 --- a/openvmm/openvmm_helpers/src/snapshot/publish.rs +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -30,6 +30,7 @@ use super::fs::sync_directory; use super::fs::validate_directory; use super::fs::verify_hard_link_identity; use super::fs::write_bytes; +use super::microvm; use anyhow::Context; use std::path::Path; use std::path::PathBuf; @@ -192,6 +193,13 @@ fn stage_snapshot( ) -> Result { validate_manifest_header(manifest)?; validate_manifest_version(manifest)?; + if let Some(contract) = &manifest.machine_contract { + microvm::validate_machine_contract_shape( + contract, + manifest.memory_size_bytes, + manifest.vp_count, + )?; + } if manifest.version != MANIFEST_VERSION { return Err(anyhow::anyhow!( "snapshot manifest version {} is not supported for writing (expected {})", diff --git a/petri/src/vm/openvmm/microvm.rs b/petri/src/vm/openvmm/microvm.rs index a0a1955bcd6..a9dba7e3362 100644 --- a/petri/src/vm/openvmm/microvm.rs +++ b/petri/src/vm/openvmm/microvm.rs @@ -10,6 +10,7 @@ use crate::PetriLogFile; use anyhow::Context; use chipset_resources::microvm::MicrovmPortbHandle; use chipset_resources::microvm::MicrovmShutdownHandle; +use chipset_resources::microvm::MicrovmSnapshotRequestHandle; use futures::AsyncWriteExt; use openvmm_defs::config::Config; use openvmm_defs::config::LinuxDirectBootMode; @@ -151,6 +152,14 @@ pub(super) fn attach_chipset_devices( name: MicrovmShutdownHandle::ID.to_owned(), resource: MicrovmShutdownHandle.into_resource(), }, + ChipsetDeviceHandle { + name: MicrovmSnapshotRequestHandle::ID.to_owned(), + resource: MicrovmSnapshotRequestHandle { + notify: None, + input_gate_timeout: std::time::Duration::from_secs(5), + } + .into_resource(), + }, ]); runtime } From 6f19f3fe21613d85a8e28a9f1a67cfbdb30467dc Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 06:39:17 -0700 Subject: [PATCH 109/304] virt: build a reproducible CPU and clock contract for microVM partitions A microVM snapshot records the guest's CPUID surface and TSC frequency, and a restore requires the destination to reproduce both exactly. Part of that surface comes from the backend rather than from OpenVMM's CPUID policy: the TSC frequency and topology results on WHP, `IA32_TSC_ADJUST` on MSHV, and the paravirtual clock and CET shadow stacks on KVM. A contract built from those values would be tied to one host, or would claim state that a snapshot cannot carry. Add `ProtoPartitionConfig::versioned_cpu_contract`. The worker sets it for the microVM profile, and the other callers leave it off. When it is set: - WHP runs the guest TSC at a fixed 1 GHz. A host that rejects the frequency with `ERROR_NOT_SUPPORTED` (now defined in the `whp` crate) or `WHV_E_UNKNOWN_PROPERTY` keeps the host frequency and logs a warning. WHP also sends the vendor, feature, cache, topology, address-size, and core-crystal CPUID leaves to OpenVMM as exits. - MSHV does not expose `IA32_TSC_ADJUST`. Snapshot state cannot preserve that register independently of `IA32_TSC`, so a host TSC correction would otherwise look like per-VP firmware adjustment skew. - KVM exposes its hypervisor CPUID leaves and sets the hypervisor-present bit, but reduces the KVM feature leaf to `KVM_FEATURE_CLOCKSOURCE2` and the stable-clock bit. The KVM backend saves and restores that clock exactly. Without the contract, KVM still hides its hypervisor leaves. On a WHP CPUID exit, OpenVMM now replaces the BSP identity in the partition's topology results with the VP's own APIC-derived identity, and extended topology enumeration ends after the SMT and core levels. KVM also stops advertising CET shadow stacks to every guest. KVM has no API to save or restore the shadow-stack pointer, so a snapshot could silently lose architecturally active state that the CPU contract claims to cover. A test covers the WHP extended topology fixup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/worker/dispatch.rs | 1 + .../src/worker/dispatch/microvm.rs | 4 + tmk/tmk_vmm/src/host_vmm.rs | 1 + vm/whp/src/lib.rs | 2 + vmm_core/virt/src/generic.rs | 2 + .../virt_kvm/src/arch/x86_64/cpu_contract.rs | 71 ++++++++++ vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 7 +- vmm_core/virt_mshv/src/x86_64/mod.rs | 2 +- vmm_core/virt_whp/src/cpu_contract.rs | 130 +++++++++++++++++- vmm_core/virt_whp/src/lib.rs | 10 ++ vmm_core/virt_whp/src/vp.rs | 1 + 11 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index a43c5131c96..68a3c8ddda8 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1261,6 +1261,7 @@ impl InitializedVm { device_assignment_msi_iova_range, user_mode_memory_faults: true, lazy_memory_registration: false, + versioned_cpu_contract: microvm::uses_versioned_cpu_contract(cfg.machine_profile), }) .context("failed to create the prototype partition")?; partition_prototype.complete("startup", "partition_prototype", Default::default()); diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 1927e029669..dc9b8856f80 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -498,6 +498,10 @@ impl VirtioMmioSlots { } } +pub(super) fn uses_versioned_cpu_contract(machine_profile: MachineProfile) -> bool { + machine_profile == MachineProfile::Microvm +} + /// Returns the number of virtio-mmio slots that the memory layout allocates. /// MicroVM devices use fixed slots in the low MMIO aperture instead. pub(super) fn virtio_mmio_count( diff --git a/tmk/tmk_vmm/src/host_vmm.rs b/tmk/tmk_vmm/src/host_vmm.rs index 7cdf922f282..226f169790b 100644 --- a/tmk/tmk_vmm/src/host_vmm.rs +++ b/tmk/tmk_vmm/src/host_vmm.rs @@ -49,6 +49,7 @@ impl RunContext<'_> { device_assignment_msi_iova_range: None, user_mode_memory_faults: true, lazy_memory_registration: false, + versioned_cpu_contract: false, }) .context("failed to create proto partition")?; diff --git a/vm/whp/src/lib.rs b/vm/whp/src/lib.rs index 9b5c03a6b3b..66749535985 100644 --- a/vm/whp/src/lib.rs +++ b/vm/whp/src/lib.rs @@ -166,6 +166,8 @@ pub struct SyntheticProcessorFeatures { pub struct WHvError(NonZeroI32); impl WHvError { + pub const ERROR_NOT_SUPPORTED: Self = Self(NonZeroI32::new(0x8007_0032_u32 as i32).unwrap()); + pub const WHV_E_UNKNOWN_CAPABILITY: Self = Self(NonZeroI32::new(api::WHV_E_UNKNOWN_CAPABILITY).unwrap()); diff --git a/vmm_core/virt/src/generic.rs b/vmm_core/virt/src/generic.rs index dfcbb0a9179..2f777844b3e 100644 --- a/vmm_core/virt/src/generic.rs +++ b/vmm_core/virt/src/generic.rs @@ -362,6 +362,8 @@ pub struct ProtoPartitionConfig<'a> { /// Whether supported backends should register large guest-memory ranges /// incrementally as the guest accesses them. pub lazy_memory_registration: bool, + /// Build the reproducible CPU/clock contract used by a versioned machine profile. + pub versioned_cpu_contract: bool, } /// Partition creation configuration. diff --git a/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs b/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs new file mode 100644 index 00000000000..60d657bbea0 --- /dev/null +++ b/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPUID adjustments for the CPU compatibility contract: KVM paravirtual clock +//! exposure for versioned machine profiles, and hiding CET-SS, whose state KVM +//! cannot save. + +use kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX; +use virt::CpuidLeaf; +use x86defs::cpuid::CpuidFunction; + +/// Returns the leaf to expose for a KVM hypervisor CPUID entry +/// (`0x4000_0000..=0x4fff_ffff`), if any. +/// +/// KVM's entries are filtered out unless the versioned CPU contract exposes +/// the KVM clock. Then the feature leaf is reduced to the clock features and +/// the other entries pass through unchanged. +pub(super) fn hypervisor_leaf( + entry: &kvm::kvm_cpuid_entry2, + expose_kvm_clock: bool, +) -> Option { + if !expose_kvm_clock { + return None; + } + if entry.function == 0x4000_0001 { + const KVM_FEATURE_CLOCKSOURCE2: u32 = 1 << 3; + const KVM_FEATURE_CLOCKSOURCE_STABLE_BIT: u32 = 1 << 24; + return Some(CpuidLeaf::new( + entry.function, + [ + entry.eax & (KVM_FEATURE_CLOCKSOURCE2 | KVM_FEATURE_CLOCKSOURCE_STABLE_BIT), + 0, + 0, + 0, + ], + )); + } + let mut leaf = CpuidLeaf::new(entry.function, [entry.eax, entry.ebx, entry.ecx, entry.edx]); + if entry.flags & KVM_CPUID_FLAG_SIGNIFCANT_INDEX != 0 { + leaf = leaf.indexed(entry.index); + } + + Some(leaf) +} + +/// Returns the leaf that sets the hypervisor-present bit when the KVM clock is +/// exposed. +pub(super) fn hypervisor_bit(expose_kvm_clock: bool) -> Option { + expose_kvm_clock.then(|| { + CpuidLeaf::new(CpuidFunction::VersionAndFeatures.0, [0, 0, 1 << 31, 0]).masked([ + 0, + 0, + 1 << 31, + 0, + ]) + }) +} + +/// Returns the leaf that hides CET-SS from the guest. +/// +/// KVM does not expose an API to save or restore the shadow-stack +/// pointer. Do not advertise CET-SS, or a snapshot could silently lose +/// architecturally active state that the CPU contract claimed to cover. +pub(super) fn hide_cet_ss() -> CpuidLeaf { + let cet_ss_mask: u32 = x86defs::cpuid::ExtendedFeatureSubleaf0Ecx::new() + .with_cet_ss(true) + .into(); + CpuidLeaf::new(CpuidFunction::ExtendedFeatures.0, [0; 4]) + .indexed(0) + .masked([0, 0, cet_ss_mask, 0]) +} diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index 2722e990b11..b4d76935bab 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -5,6 +5,7 @@ #![cfg(all(target_os = "linux", guest_arch = "x86_64"))] +mod cpu_contract; mod regs; pub(crate) mod snp; mod tsc; @@ -190,7 +191,7 @@ impl virt::Hypervisor for Kvm { .filter_map(|entry| { // Filter out KVM CPUID entries. if entry.function & 0xf0000000 == 0x40000000 { - return None; + return cpu_contract::hypervisor_leaf(&entry, config.versioned_cpu_contract); } let mut leaf = CpuidLeaf::new(entry.function, [entry.eax, entry.ebx, entry.ecx, entry.edx]); @@ -202,6 +203,8 @@ impl virt::Hypervisor for Kvm { }) .collect::>(); + cpuid_entries.extend(cpu_contract::hypervisor_bit(config.versioned_cpu_contract)); + // When nested virt is disabled, strip the virtualization // CPUID bit for the host's vendor. if !nested_virt { @@ -260,6 +263,8 @@ impl virt::Hypervisor for Kvm { CpuidLeaf::new(CpuidFunction::SgxEnumeration.0, [0; 4]).indexed(2), // SGX enumeration is subleaf 2 ); + cpuid_entries.push(cpu_contract::hide_cet_ss()); + if let Some(hv_config) = &config.hv_config { if hv_config.vtl2.is_some() { return Err(KvmError::Vtl2NotSupported); diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 616bafda5fb..3f7d85926ce 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -130,7 +130,7 @@ impl virt::Hypervisor for LinuxMshv { x2apic, config.processor_topology.smt_enabled(), )?; - let create_args = tsc::with_features1(create_args, false); + let create_args = tsc::with_features1(create_args, config.versioned_cpu_contract); let vmfd = create_vm_with_retry(&self.mshv, &create_args)?; diff --git a/vmm_core/virt_whp/src/cpu_contract.rs b/vmm_core/virt_whp/src/cpu_contract.rs index 197b93246e9..433e8e5fa62 100644 --- a/vmm_core/virt_whp/src/cpu_contract.rs +++ b/vmm_core/virt_whp/src/cpu_contract.rs @@ -1,8 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! CPUID policy that keeps unsupported host enlightenments from reaching the -//! guest. +//! CPUID policy for the CPU compatibility contract: the fixed TSC frequency +//! and CPUID exits of a versioned CPU contract, the per-VP topology results +//! returned on CPUID exits, and hiding the L0 GPA pinning enlightenment. + +use crate::Error; +use crate::WhpProcessor; +use crate::WhpResultExt; +use inspect::Inspect; +use x86defs::cpuid::CpuidFunction; /// The L0 pinning requirement describes memory owned by OpenVMM, not memory /// owned by OpenVMM's guest. OpenVMM does not expose the GPA pin/unpin @@ -24,8 +31,112 @@ pub(crate) fn mask_gpa_pinning_enlightenment() -> virt::CpuidLeaf { ]) } +/// Sets the fixed TSC frequency of a versioned CPU contract and routes the +/// topology and clock CPUID leaves to the VMM. +pub(crate) fn configure_versioned_contract( + whp_config: &mut whp::PartitionConfig, + extended_exits: &mut whp::abi::WHV_EXTENDED_VM_EXITS, +) -> Result<(), Error> { + const VERSIONED_TSC_FREQUENCY_HZ: u64 = 1_000_000_000; + + match whp_config.set_property(whp::PartitionProperty::ProcessorClockFrequency( + VERSIONED_TSC_FREQUENCY_HZ, + )) { + Ok(_) => {} + Err(err @ (whp::WHvError::ERROR_NOT_SUPPORTED | whp::WHvError::WHV_E_UNKNOWN_PROPERTY)) => { + tracing::warn!( + error = %err, + "WHP cannot set the versioned TSC frequency; using the host frequency" + ); + } + Err(err) => { + return Err(err).for_op("set versioned CPU contract TSC frequency"); + } + } + *extended_exits |= whp::abi::WHV_EXTENDED_VM_EXITS::X64CpuidExit; + let cpuid_exit_list = [ + CpuidFunction::VendorAndMaxFunction.0, + CpuidFunction::VersionAndFeatures.0, + CpuidFunction::CacheParameters.0, + CpuidFunction::ExtendedTopologyEnumeration.0, + CpuidFunction::V2ExtendedTopologyEnumeration.0, + CpuidFunction::ExtendedAddressSpaceSizes.0, + CpuidFunction::ProcessorTopologyDefinition.0, + CpuidFunction::CoreCrystalClockInformation.0, + ]; + whp_config + .set_property(whp::PartitionProperty::CpuidExitList(&cpuid_exit_list)) + .for_op("set versioned CPU contract CPUID exits")?; + Ok(()) +} + +/// Processor topology parameters of the per-VP topology CPUID results. +#[derive(Inspect)] +pub(crate) struct CpuidTopology { + reserved_vps_per_socket: u32, + smt_enabled: bool, +} + +impl CpuidTopology { + pub(crate) fn new(topology: &vm_topology::processor::ProcessorTopology) -> Self { + Self { + reserved_vps_per_socket: topology.reserved_vps_per_socket(), + smt_enabled: topology.smt_enabled(), + } + } +} + +/// Reports `apic_id` for the SMT and core levels of the extended topology +/// leaves, and terminates the enumeration after them. +fn fixup_extended_topology(index: u32, apic_id: u32, result: &mut [u32; 4]) { + if index >= 2 { + *result = [0; 4]; + } else { + result[3] = apic_id; + } +} + +impl WhpProcessor<'_> { + /// Replaces the BSP identity in the partition's topology CPUID results + /// with this VP's APIC-derived identity. + pub(crate) fn fixup_topology_cpuid(&self, function: u32, index: u32, default: &mut [u32; 4]) { + match CpuidFunction(function) { + CpuidFunction::VersionAndFeatures => { + let ebx = x86defs::cpuid::VersionAndFeaturesEbx::from(default[1]); + default[1] = ebx + .with_initial_apic_id(self.inner.vp_info.apic_id as u8) + .into(); + } + CpuidFunction::ExtendedTopologyEnumeration + | CpuidFunction::V2ExtendedTopologyEnumeration => { + fixup_extended_topology(index, self.inner.vp_info.apic_id, default); + } + CpuidFunction::ProcessorTopologyDefinition => { + let topology = &self.vp.partition.cpuid_topology; + let apic_id = self.inner.vp_info.apic_id; + default[0] = x86defs::cpuid::ProcessorTopologyDefinitionEax::from(default[0]) + .with_extended_apic_id(apic_id) + .into(); + let threads_per_core = if topology.smt_enabled { 2 } else { 1 }; + default[1] = x86defs::cpuid::ProcessorTopologyDefinitionEbx::from(default[1]) + .with_compute_unit_id( + ((apic_id % topology.reserved_vps_per_socket) / threads_per_core) as u8, + ) + .with_threads_per_compute_unit((threads_per_core - 1) as u8) + .into(); + default[2] = x86defs::cpuid::ProcessorTopologyDefinitionEcx::from(default[2]) + .with_node_id((apic_id / topology.reserved_vps_per_socket) as u8) + .with_nodes_per_processor(0) + .into(); + } + _ => {} + } + } +} + #[cfg(test)] mod tests { + use super::fixup_extended_topology; use super::mask_gpa_pinning_enlightenment; #[test] @@ -52,4 +163,19 @@ mod tests { assert!(!result.use_gpa_pinning_hypercall()); assert!(result.nested()); } + + #[test] + fn extended_topology_uses_canonical_levels_and_terminates() { + for index in [0, 1] { + let mut result = [1, 2, 3, 99]; + fixup_extended_topology(index, 7, &mut result); + assert_eq!(result, [1, 2, 3, 7]); + } + + for index in [2, 3, u32::MAX] { + let mut result = [1, 2, 3, 99]; + fixup_extended_topology(index, 7, &mut result); + assert_eq!(result, [0; 4]); + } + } } diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 43e3ee6a01a..b7e34b8d226 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -128,6 +128,9 @@ struct WhpPartitionInner { cpuid: virt::CpuidLeafSet, #[cfg(guest_arch = "x86_64")] #[inspect(flatten)] + cpuid_topology: cpu_contract::CpuidTopology, + #[cfg(guest_arch = "x86_64")] + #[inspect(flatten)] clock: tsc::PartitionClock, vtl0_alias_map_offset: Option, monitor_page: MonitorPage, @@ -1366,6 +1369,8 @@ impl WhpPartitionInner { #[cfg(guest_arch = "x86_64")] cpuid, #[cfg(guest_arch = "x86_64")] + cpuid_topology: cpu_contract::CpuidTopology::new(proto_config.processor_topology), + #[cfg(guest_arch = "x86_64")] clock: tsc::PartitionClock::new(tsc_frequency), vtl0_alias_map_offset, monitor_page: MonitorPage::new(), @@ -1525,6 +1530,11 @@ impl VtlPartition { )) .for_op("set processor count")?; + #[cfg(guest_arch = "x86_64")] + if config.versioned_cpu_contract { + cpu_contract::configure_versioned_contract(&mut whp_config, &mut extended_exits)?; + } + #[cfg(guest_arch = "x86_64")] if nested_virt { match whp_config.set_property(whp::PartitionProperty::NestedVirtualization(true)) { diff --git a/vmm_core/virt_whp/src/vp.rs b/vmm_core/virt_whp/src/vp.rs index 8403fe046fd..2698fd5f9aa 100644 --- a/vmm_core/virt_whp/src/vp.rs +++ b/vmm_core/virt_whp/src/vp.rs @@ -1149,6 +1149,7 @@ mod x86 { _ => {} } + self.fixup_topology_cpuid(function, index, &mut default); let [eax, ebx, ecx, edx] = default; if self.vp.partition.vtl2_emulation.is_some() && self.state.active_vtl == Vtl::Vtl0 { From 2c43a18659d2a74fd6c5166da357d5537d579077 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 06:44:04 -0700 Subject: [PATCH 110/304] openvmm_core: validate restored CPU and clock contracts and apply host downtime A microVM snapshot is only valid on a destination that runs the guest with the same CPU surface and clock rates, and the restored guest clocks must account for the time that passed while the snapshot was at rest. The worker has no inputs for either yet. Add four restore inputs to `VmWorkerParameters`: the host downtime, the saved TSC frequency, the saved LAPIC timer frequency, and the saved canonical CPU contract. The new `dispatch::clock` module combines the first three into the restore time contract. The downtime and the TSC frequency must be provided together, and the LAPIC frequency is optional for snapshots that did not record one. Before the saved state is restored: - The destination partition's encoded CPU compatibility contract must equal the saved one. A mismatch reports the first differing CPUID result. Non-x86 guests reject a CPU contract. - A time-adjusted restore requires a saved `partition` state unit, because the downtime is applied to the restored partition state. - The destination TSC frequency must equal the saved frequency, and the worker then requests that frequency from the backend. The destination must expose its LAPIC frequency, which must match a saved one. After the saved state is restored, the worker advances the state units' time by the downtime, advances each VP's TSC and LAPIC timer on x86-64, and lets the backend advance its own snapshot clock state. `MicrovmPartition` gains `set_tsc_frequency_hz` and `advance_snapshot_time` for this, and `WrappedVp` now forwards `Processor::advance_tsc` to the backend VP. Every caller passes no contract yet, so restores behave as before. The entry point supplies the contracts from the snapshot manifest in a later commit. Tests cover the partition-state requirement of a time-adjusted restore. Co-authored-by: Ruize Tang <1466040111@qq.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/partition.rs | 5 + openvmm/openvmm_core/src/partition/microvm.rs | 16 ++ openvmm/openvmm_core/src/worker/dispatch.rs | 6 +- .../openvmm_core/src/worker/dispatch/clock.rs | 175 ++++++++++++++++++ .../src/worker/dispatch/restore.rs | 87 ++++++++- openvmm/openvmm_defs/src/worker.rs | 8 + openvmm/openvmm_entry/src/lib.rs | 4 + openvmm/openvmm_entry/src/ttrpc/mod.rs | 4 + petri/src/worker.rs | 4 + 9 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 openvmm/openvmm_core/src/worker/dispatch/clock.rs diff --git a/openvmm/openvmm_core/src/partition.rs b/openvmm/openvmm_core/src/partition.rs index 304c2071b73..50b70714101 100644 --- a/openvmm/openvmm_core/src/partition.rs +++ b/openvmm/openvmm_core/src/partition.rs @@ -394,6 +394,11 @@ impl Processor for WrappedVp<'_, T> { self.0.access_state(vtl) } + #[cfg(guest_arch = "x86_64")] + fn advance_tsc(&mut self, cycles: u64) -> anyhow::Result<()> { + self.0.advance_tsc(cycles) + } + fn vtl_inspectable(&self, vtl: Vtl) -> bool { self.0.vtl_inspectable(vtl) } diff --git a/openvmm/openvmm_core/src/partition/microvm.rs b/openvmm/openvmm_core/src/partition/microvm.rs index faa17f11c1c..fb730dfec77 100644 --- a/openvmm/openvmm_core/src/partition/microvm.rs +++ b/openvmm/openvmm_core/src/partition/microvm.rs @@ -18,6 +18,12 @@ pub trait MicrovmPartition: Send + Sync { /// Returns the effective guest TSC frequency. fn tsc_frequency_hz(&self) -> anyhow::Result>; + /// Requests the effective guest TSC frequency. + fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> anyhow::Result<()>; + + /// Advances backend-specific guest clock state after snapshot downtime. + fn advance_snapshot_time(&self, duration: std::time::Duration) -> anyhow::Result<()>; + /// Returns the LAPIC interrupt clock frequency when available. fn apic_frequency_hz(&self) -> anyhow::Result>; } @@ -35,6 +41,16 @@ where Ok(Partition::tsc_frequency_hz(self)?) } + fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> anyhow::Result<()> { + Partition::set_tsc_frequency_hz(self, frequency_hz)?; + Ok(()) + } + + fn advance_snapshot_time(&self, duration: std::time::Duration) -> anyhow::Result<()> { + Partition::advance_snapshot_time(self, duration)?; + Ok(()) + } + fn apic_frequency_hz(&self) -> anyhow::Result> { Ok(Partition::apic_frequency_hz(self)?) } diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 68a3c8ddda8..ea3eafd6317 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. mod amd_iommu_wiring; +mod clock; mod dump; mod ecam_config_access; mod intel_vtd_wiring; @@ -359,6 +360,7 @@ impl Worker for VmWorker { manifest, shared_memory, ))?; + restore::validate_restore_cpu_contract(vm.partition.as_ref(), restore_params.cpu_contract)?; let saved_state = parameters .saved_state .map(|m| m.parse()) @@ -3293,11 +3295,11 @@ impl InitializedVm { }; if let Some(saved_state) = saved_state { - let saved_state_restore = this.begin_snapshot_restore(); + let saved_state_restore = this.begin_snapshot_restore(&saved_state)?; this.restore(saved_state) .await .context("loadedvm restore failed")?; - this.finish_snapshot_restore(saved_state_restore).await; + this.finish_snapshot_restore(saved_state_restore).await?; } else { // Assign PCI bus numbers/BARs before building firmware so that the // ACPI tables (specifically the SRAT generic-initiator entries) can diff --git a/openvmm/openvmm_core/src/worker/dispatch/clock.rs b/openvmm/openvmm_core/src/worker/dispatch/clock.rs new file mode 100644 index 00000000000..1c64fc08aad --- /dev/null +++ b/openvmm/openvmm_core/src/worker/dispatch/clock.rs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Restore-time guest clock handling for the VM worker: the saved clock +//! contract, the destination clock checks made before the saved state is +//! restored, and the compensation for the host downtime between snapshot +//! capture and restore. + +use super::LoadedVm; +use anyhow::Context; +use std::time::Duration; + +/// Saved guest-clock contract of a restore: the host downtime to apply, the +/// saved guest TSC frequency, and the saved local APIC timer frequency, when +/// the snapshot recorded one. +pub(super) type RestoreTime = (Duration, u64, Option); + +pub(super) fn restore_time_contract( + downtime: Option, + tsc_frequency_hz: Option, + apic_frequency_hz: Option, +) -> anyhow::Result> { + match (downtime, tsc_frequency_hz, apic_frequency_hz) { + (Some(downtime), Some(tsc_frequency), apic_frequency) => { + Ok(Some((downtime, tsc_frequency, apic_frequency))) + } + (None, None, None) => Ok(None), + _ => anyhow::bail!("restore downtime and TSC frequency must be provided together"), + } +} + +#[cfg(guest_arch = "x86_64")] +pub(super) fn validate_snapshot_restore_partition_presence( + saved_state: &openvmm_defs::worker::SavedState, + restore_time: Option, +) -> anyhow::Result<()> { + anyhow::ensure!( + restore_time.is_none() + || saved_state + .units + .iter() + .any(|unit| unit.name == "partition"), + "time-adjusted snapshot restore requires partition state" + ); + Ok(()) +} + +impl LoadedVm { + /// Checks the destination clock frequencies against the saved contract + /// and requests the saved TSC frequency, before the saved state is + /// restored. + pub(super) fn validate_restore_clock( + &self, + restore_time: Option, + ) -> anyhow::Result<()> { + if let Some((_, saved_frequency, saved_apic_frequency)) = restore_time { + let destination_frequency = self + .inner + .partition + .tsc_frequency_hz()? + .context("destination backend does not expose a guest TSC frequency")?; + anyhow::ensure!( + destination_frequency == saved_frequency, + "destination TSC frequency {destination_frequency} Hz does not match saved frequency {saved_frequency} Hz" + ); + self.inner.partition.set_tsc_frequency_hz(saved_frequency)?; + let destination_apic_frequency = self + .inner + .partition + .apic_frequency_hz()? + .context("destination backend does not expose a local APIC frequency")?; + if let Some(saved_apic_frequency) = saved_apic_frequency { + anyhow::ensure!( + destination_apic_frequency == saved_apic_frequency, + "destination APIC frequency {destination_apic_frequency} Hz does not match saved frequency {saved_apic_frequency} Hz" + ); + } + } + Ok(()) + } + + /// Advances the restored VM's clocks by the snapshot downtime, after the + /// saved state is restored. + pub(super) async fn advance_restored_clock( + &mut self, + restore_time: Option, + ) -> anyhow::Result<()> { + if let Some((downtime, frequency, saved_apic_frequency)) = restore_time { + self.state_units + .advance_time(downtime) + .await + .context("failed to advance restored VM time")?; + #[cfg(guest_arch = "x86_64")] + { + let apic_frequency = + match saved_apic_frequency { + Some(frequency) => frequency, + None => self.inner.partition.apic_frequency_hz()?.context( + "destination backend does not expose a local APIC frequency", + )?, + }; + self.inner + .partition_unit + .advance_tsc(downtime, frequency, Some(apic_frequency)) + .await + .context("failed to advance restored vCPU TSC")?; + } + self.inner + .partition + .advance_snapshot_time(downtime) + .context("failed to advance backend snapshot clock")?; + } + Ok(()) + } +} + +#[cfg(all(test, guest_arch = "x86_64"))] +mod tests { + use super::*; + use openvmm_defs::worker::SavedState; + use state_unit::SavedStateUnit; + use test_with_tracing::test; + use vmcore::save_restore::NoSavedState; + use vmcore::save_restore::SavedStateBlob; + + fn saved_state(unit_names: &[&str]) -> SavedState { + SavedState { + units: unit_names + .iter() + .map(|name| SavedStateUnit { + name: (*name).to_owned(), + state: SavedStateBlob::new(NoSavedState), + }) + .collect(), + inventory: Vec::new(), + } + } + + #[test] + fn restore_without_time_adjustment_allows_missing_partition_state() { + validate_snapshot_restore_partition_presence(&saved_state(&[]), None).unwrap(); + } + + #[test] + fn time_adjusted_restore_allows_partition_state() { + validate_snapshot_restore_partition_presence( + &saved_state(&["partition"]), + Some((Duration::ZERO, 1, None)), + ) + .unwrap(); + } + + #[test] + fn time_adjusted_restore_rejects_missing_partition_state() { + let error = validate_snapshot_restore_partition_presence( + &saved_state(&["other"]), + Some((Duration::ZERO, 1, None)), + ) + .unwrap_err(); + + assert_eq!( + error.to_string(), + "time-adjusted snapshot restore requires partition state" + ); + } + + #[test] + fn time_adjusted_restore_requires_partition_payload_not_inventory() { + let mut saved_state = saved_state(&[]); + saved_state.inventory.push("partition".to_owned()); + + validate_snapshot_restore_partition_presence(&saved_state, Some((Duration::ZERO, 1, None))) + .unwrap_err(); + } +} diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index 5ae42621e70..aa9db3a4168 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -7,6 +7,9 @@ //! (readiness event and VP release). use super::LoadedVm; +use super::clock; +use super::clock::RestoreTime; +use crate::partition::HvlitePartition; use anyhow::Context; use membacking::FileMappingMode; use membacking::SharedMemoryBacking; @@ -25,6 +28,8 @@ use vmm_core::partition_unit::StopGuard; pub(super) struct RestoreParameters { /// Restore state handed to the loaded VM. pub(super) state: SnapshotRestore, + /// Saved canonical CPU contract required by restore. + pub(super) cpu_contract: Option>, /// Mapping mode of the file-backed guest RAM handle. file_mapping_mode: FileMappingMode, /// Snapshot generation handles that must outlive the restored VM. @@ -32,10 +37,18 @@ pub(super) struct RestoreParameters { } impl RestoreParameters { - /// Takes the restore inputs out of the worker parameters. + /// Takes the restore inputs out of the worker parameters and validates + /// the saved time contract. pub(super) fn take(parameters: &mut VmWorkerParameters) -> anyhow::Result { let guards = parameters.snapshot_restore_guards.take(); let ready_sink = parameters.restore_ready_sink.take(); + let restore_time = clock::restore_time_contract( + parameters.restore_downtime, + parameters.restore_tsc_frequency_hz, + parameters.restore_apic_frequency_hz, + )?; + tracing::debug!(?restore_time, "received snapshot restore time contract"); + let cpu_contract = parameters.restore_cpu_contract.take(); let file_mapping_mode = if parameters.shared_memory_copy_on_write { FileMappingMode::CopyOnWrite } else { @@ -43,9 +56,11 @@ impl RestoreParameters { }; Ok(Self { state: SnapshotRestore { + time: restore_time, ready_sink, ..Default::default() }, + cpu_contract, file_mapping_mode, guards, }) @@ -60,6 +75,8 @@ impl RestoreParameters { /// Snapshot-restore state of a [`LoadedVm`]. #[derive(Default)] pub(super) struct SnapshotRestore { + /// Saved guest-clock contract, checked and applied around the restore. + time: Option, /// Whether the VM was loaded from saved state. restored_from_snapshot: bool, /// Holds the VPs stopped after a restore until the VM first starts. @@ -68,6 +85,43 @@ pub(super) struct SnapshotRestore { ready_sink: Option, } +#[cfg(guest_arch = "x86_64")] +pub(super) fn validate_restore_cpu_contract( + partition: &dyn HvlitePartition, + expected_cpu_contract: Option>, +) -> anyhow::Result<()> { + if let Some(expected_cpu_contract) = expected_cpu_contract { + let destination_contract = partition.cpu_compatibility_contract(); + let destination_cpu_contract = mesh::payload::encode(destination_contract.clone()); + if destination_cpu_contract != expected_cpu_contract { + let expected_contract: virt::x86::CpuCompatibilityContract = + mesh::payload::decode(&expected_cpu_contract) + .context("failed to decode snapshot CPU contract")?; + let first_cpuid_difference = expected_contract + .cpuid + .iter() + .zip(&destination_contract.cpuid) + .find(|(expected, destination)| expected != destination); + anyhow::bail!( + "destination CPU contract does not match the snapshot; first CPUID difference: {first_cpuid_difference:?}" + ); + } + } + Ok(()) +} + +#[cfg(not(guest_arch = "x86_64"))] +pub(super) fn validate_restore_cpu_contract( + _partition: &dyn HvlitePartition, + expected_cpu_contract: Option>, +) -> anyhow::Result<()> { + anyhow::ensure!( + expected_cpu_contract.is_none(), + "snapshot CPU contracts are only supported for x86-64 guests" + ); + Ok(()) +} + /// Validates the state-unit inventory of a saved-state envelope. Saved state /// without an inventory is restored without the check. pub(super) fn validate_inventory( @@ -81,19 +135,36 @@ pub(super) fn validate_inventory( } impl LoadedVm { - /// Prepares to restore saved state into the loaded VM: records that the - /// VM is restored, and starts the profile span of the restore. - pub(super) fn begin_snapshot_restore(&mut self) -> ProfileSpan { + /// Prepares to restore `saved_state` into the loaded VM: checks the saved + /// time contract against the saved state and the destination clocks, and + /// starts the profile span of the restore. + pub(super) fn begin_snapshot_restore( + &mut self, + #[cfg_attr(not(guest_arch = "x86_64"), expect(unused_variables))] saved_state: &SavedState, + ) -> anyhow::Result { self.snapshot_restore.restored_from_snapshot = true; - ProfileSpan::start() + let restore_time = self.snapshot_restore.time; + + #[cfg(guest_arch = "x86_64")] + clock::validate_snapshot_restore_partition_presence(saved_state, restore_time)?; + + self.validate_restore_clock(restore_time)?; + Ok(ProfileSpan::start()) } - /// Completes a restore after the saved state is applied: holds the VPs - /// stopped until the VM first starts. - pub(super) async fn finish_snapshot_restore(&mut self, saved_state_restore: ProfileSpan) { + /// Completes a restore after the saved state is applied: advances the + /// guest clocks by the snapshot downtime and holds the VPs stopped until + /// the VM first starts. + pub(super) async fn finish_snapshot_restore( + &mut self, + saved_state_restore: ProfileSpan, + ) -> anyhow::Result<()> { saved_state_restore.complete("restore", "saved_state_restore", Default::default()); + self.advance_restored_clock(self.snapshot_restore.time) + .await?; self.snapshot_restore.start_guard = Some(self.inner.partition_unit.temporarily_stop_vps().await); + Ok(()) } /// Starts the state units for [`LoadedVm::resume`], sequencing the diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index a143a035fd3..99d85b4595a 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -74,6 +74,14 @@ pub struct VmWorkerParameters { pub snapshot_ready: Option>, /// Whether this cold boot can publish a microVM snapshot. pub snapshot_capture_enabled: bool, + /// Host downtime to apply before starting a restored VM. + pub restore_downtime: Option, + /// Saved effective TSC frequency required by restore. + pub restore_tsc_frequency_hz: Option, + /// Saved local APIC timer frequency required by restore. + pub restore_apic_frequency_hz: Option, + /// Saved canonical CPU contract required by restore. + pub restore_cpu_contract: Option>, /// Single-use process-local sink for the restore readiness event. pub restore_ready_sink: Option, /// The VM RPC channel. diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 1b943ea221b..bc444973b77 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2954,6 +2954,10 @@ async fn run_control_inner( snapshot_boundary_requests, snapshot_ready, snapshot_capture_enabled: microvm.snapshot_capture_enabled(), + restore_downtime: None, + restore_tsc_frequency_hz: None, + restore_apic_frequency_hz: None, + restore_cpu_contract: None, restore_ready_sink, rpc: rpc_recv, notify: notify_send, diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 941aad8fa25..745fafce298 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1315,6 +1315,10 @@ impl VmService { snapshot_boundary_requests: None, snapshot_ready: None, snapshot_capture_enabled: false, + restore_downtime: None, + restore_tsc_frequency_hz: None, + restore_apic_frequency_hz: None, + restore_cpu_contract: None, restore_ready_sink: None, rpc: recv, notify: notify_send, diff --git a/petri/src/worker.rs b/petri/src/worker.rs index c85d7969432..9744c2cbe0d 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -44,6 +44,10 @@ impl Worker { snapshot_boundary_requests: None, snapshot_ready: None, snapshot_capture_enabled: false, + restore_downtime: None, + restore_tsc_frequency_hz: None, + restore_apic_frequency_hz: None, + restore_cpu_contract: None, restore_ready_sink: None, rpc: rpc_recv, notify: notify_send, From 12ba2a3b9928d1e37a75dbb0765adc3c340c693c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 06:58:01 -0700 Subject: [PATCH 111/304] openvmm_entry: restore microVMs authoritatively A microVM snapshot records the machine that it was captured on, so a restore must recreate that machine rather than trust the restore command line. Until now, `--machine microvm` rejected `--restore-snapshot`. A microVM restore now works as follows: - `prepare_restore` reads the manifest's machine contract. A microVM snapshot requires `--machine microvm`, a supported ABI and boot layout, and no command-line or `--memory` overrides. The RAM size comes from the manifest. - The configuration builder uses the manifest's effective command line and an inert kernel handle instead of opening a kernel and initrd. `--kernel` and `--initrd` conflict with `--restore-snapshot`, and the restored command line does not get device discovery appended again. - Before the worker starts, the saved contract must pass shape validation and equal the contract that this machine records for the same inputs. This checks the backend kind, processor topology, RAM layout, and fixed device inventory. The state-unit inventory in `state.bin` must also match the manifest. - The host downtime since capture comes from the capture wall clock. The destination clock must not be earlier than the capture time, and the downtime can be at most 30 days. The downtime, the saved TSC and LAPIC frequencies, and the saved CPU contract go to the worker, which validates and applies them. To tell an omitted `--memory` from an explicit one, `--memory` now defaults to an empty specification, which still selects 1 GiB. Restore does not yet support `--virtio-console`, matching capture. Document microVM restore, the CPU and clock contracts, and the TSC and LAPIC command-line parameters. Also correct the device-configuration rules: restore requires an exact state-unit inventory, so a device that the snapshot lacks is rejected rather than started in its default state. Tests cover the downtime bounds and the new CLI rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/dev_guide/snapshot_format.md | 4 +- Guide/src/reference/openvmm/management/cli.md | 17 +- Guide/src/user_guide/openvmm/snapshots.md | 63 ++++--- openvmm/openvmm_entry/src/cli_args.rs | 8 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 53 ++++-- openvmm/openvmm_entry/src/lib.rs | 67 ++++--- openvmm/openvmm_entry/src/microvm/config.rs | 59 ++++-- openvmm/openvmm_entry/src/microvm/launch.rs | 19 ++ openvmm/openvmm_entry/src/microvm/mod.rs | 5 + openvmm/openvmm_entry/src/microvm/restore.rs | 177 ++++++++++++++++++ .../openvmm_entry/src/snapshot_restore/mod.rs | 38 +++- .../src/snapshot_restore/prepare.rs | 39 +++- 12 files changed, 460 insertions(+), 89 deletions(-) create mode 100644 openvmm/openvmm_entry/src/microvm/restore.rs diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index fc25811260f..75f07ac4435 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -33,8 +33,8 @@ validates the manifest format before it reads `state.bin`, and requires The default format is a local machine-state contract, not an authenticated container. All versions receive the same regular-file, no-follow/no-reparse, -bounded decoding, exact-length, and inventory validation, but the on-disk -format does not authenticate same-length payload changes. Export or transport +bounded decoding, exact-length, inventory, and machine-contract validation, +but the on-disk format does not authenticate same-length payload changes. Export or transport layers must provide broader integrity and authentication outside this format. ## Device state (`state.bin`) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index f0175d5e095..9c4cd910460 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -59,8 +59,8 @@ describes the source definitions. `microvm` uses one socket and one die, with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. - Guest-requested snapshot capture is available on Linux/KVM, Linux/MSHV, and - Windows/WHP. + Guest-requested snapshot capture and new-process restore are available on + Linux/KVM, Linux/MSHV, and Windows/WHP. * `--snapshot-destination `: Publish a microVM snapshot when the guest writes to PMIO port `0x605`. The destination must not exist and its parent must already be a directory. OpenVMM automatically creates file-backed RAM @@ -80,6 +80,19 @@ describes the source definitions. --kernel vmlinux --initrd initramfs.cpio.gz \ --snapshot-destination snapshot ``` +* `--restore-snapshot `: Restore a microVM from a committed snapshot. + The manifest supplies the authoritative RAM size, topology, ABI, + fixed device inventory, effective kernel command line, source backend, CPU + contract, and TSC frequency. Kernel, initrd, command-line, ordinary + `--memory`, processor, device, and topology overrides are not accepted. + Repeat the snapshot's exact `--processors` count; a mismatch is rejected + before any VP starts. Restore requires the same backend kind as capture. + It does not yet support `--virtio-console`. + + ```bash + openvmm --machine microvm --hypervisor kvm \ + --restore-snapshot snapshot + ``` * `--memory `: Configure guest RAM. Defaults to `size=1G`. `SPEC` can be a size-only shorthand, such as `--memory 4G`, or a comma-separated key/value list: diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 2664fe85ca2..6fb8dd6cc2f 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -146,18 +146,34 @@ validation error and refuse to start. ## Device configuration on restore -The snapshot only stores device *state*, not device *configuration*. All -device flags (e.g. `--disk`, `--nic`, `--serial`, `--virtio-blk`, etc.) -must be specified on the restore command line exactly as they were when -the snapshot was saved — they are not read from the snapshot. - -The snapshot manifest validates that `--memory`, `--processors`, -architecture, and page size match the values recorded at save time. However, -it does **not** record the list of CLI device flags. Instead, device -configuration compatibility is enforced at the state-unit level: each -emulated device saves its state under a unique name (e.g. `"pit"`, -`"vmbus"`, `"ide"`), and restore matches saved-state entries to the -currently instantiated devices by name. +For standard-machine snapshots, device flags must still be supplied on restore +and must reproduce the saved machine. For microVM snapshots, the manifest is +authoritative for RAM, topology, ABI, fixed devices, placement, +features, interrupts, and the effective Linux direct command line. Restore-time +guest-visible overrides are rejected. + +The CPU contract records the effective CPUID/XSTATE surface and TSC frequency. +Restore recreates and validates that rate before any vCPU runs. KVM snapshots +likewise require the destination to reproduce their saved backend CPU and +clock contract. + +For a microVM boot configured with `--snapshot-destination`, OpenVMM adds +the backend TSC frequency to the effective kernel command line so the captured +guest clock matches this contract. Ordinary boots that cannot publish a +snapshot retain the guest's normal TSC discovery path. + +All cold microVM boots also receive `lapic_timer_hz=` when the backend +reports its LAPIC clock frequency. The NVX kernel uses this authoritative rate +instead of verifying a counting LAPIC against scheduling-sensitive emulated +PIT interrupts. TSC-deadline timers are unchanged. The parameter is canonicalized +before device discovery and `--`; conflicting, duplicate, malformed, or +out-of-range values are rejected. + +Every snapshot records a complete state-unit inventory. Each emulated device +saves state under a unique name (for example `"pit"`, `"vmbus"`, or `"ide"`), +and restore requires the saved and current inventories to match exactly. A +microVM manifest additionally records and validates the exact device inventory +and order. The rules are: @@ -165,24 +181,20 @@ The rules are: |---|---| | Device set matches exactly | Restore succeeds | | Snapshot contains a device not in current config | **Restore fails** — unknown unit name | -| Current config has a device not in snapshot | Restore succeeds — device starts in its default/initial state | +| Current config has a device not in snapshot | **Restore fails** — inventory mismatch | In practice this means: - You must pass the **same device flags** on restore as you did on save. Removing a device that was present at save time will cause restore to fail. -- Adding a *new* device that was not present at save time is technically - allowed — the new device will start in its power-on default state. - This is not tested and the device may not be functional, since the - guest OS will not have enumerated or initialised it during boot. - The supported path is to use the same device flags on save and restore. +- Adding a new device that was not present at save time fails inventory + validation rather than starting an unenumerated device in its default state. ```admonish warning -There is no single error message that tells you "your device configuration -changed". Instead you will see errors like `restore failed: unknown unit -name` when saved-state entries cannot be matched. If you see this, compare -your restore command line with the one used at save time. +Inventory errors identify the saved and current state-unit lists. Compare the +restore configuration with the capture configuration when restoring a standard +machine. ``` ## Device save/restore support @@ -244,6 +256,7 @@ immediately with a clear error if any active device does not support it. - VMs using VPCI or PCIe devices do not currently support save/restore - OpenHCL-based VMs do not currently support this snapshot mechanism - VMs using PCAT firmware do not support save/restore -- `--memory` and `--processors` must be specified on restore and match the - snapshot manifest values. A future version may read these from the snapshot - automatically. +- Standard-machine restore still requires matching `--memory` and + `--processors`. MicroVM restore reads them authoritatively from the manifest + and rejects overrides. Persisted microVM ABI and boot-layout value 2 are + supported. diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index 66537098b41..d9cdef8314e 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -164,7 +164,7 @@ pub struct Options { short = 'm', long, value_name = "PARAMS", - default_value = "1GB", + default_value = "", value_parser = parse_memory_config, conflicts_with = "numa", long_help = r#"Configure guest RAM. @@ -258,7 +258,7 @@ Examples: #[clap( long, value_name = "DIR", - conflicts_with_all = ["deprecated_memory_backing_file", "numa"] + conflicts_with_all = ["deprecated_memory_backing_file", "numa", "kernel", "initrd"] )] pub restore_snapshot: Option, @@ -2038,7 +2038,9 @@ fn parse_smbios(s: &str) -> anyhow::Result { fn parse_memory_config(s: &str) -> anyhow::Result { // Bare shortcut: `--memory 64G` sets only the size. - let memory = if !s.contains('=') && !s.contains(',') { + let memory = if s.is_empty() { + MemoryCli::default() + } else if !s.contains('=') && !s.contains(',') { MemoryCli { size: Some(s.parse::()?), ..Default::default() diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index af52f446e0f..fb5e286e2db 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -85,10 +85,12 @@ impl Options { "microVM snapshot capture does not yet support --virtio-console" ); } - anyhow::ensure!( - self.restore_snapshot.is_none(), - "microVM does not support snapshot restore" - ); + if self.restore_snapshot.is_some() { + anyhow::ensure!( + self.virtio_console.is_none(), + "microVM snapshot restore does not yet support --virtio-console" + ); + } anyhow::ensure!( self.uefi.is_none() && !self.pcat && self.igvm.is_none() && !self.device_tree, "microVM requires Linux direct boot" @@ -350,6 +352,42 @@ mod tests { ]) .is_err() ); + + let restore = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + ]) + .unwrap(); + assert_eq!(restore.memory, Default::default()); + restore.validate_microvm_options().unwrap(); + let restore_with_console = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--virtio-console", + "none", + ]) + .unwrap(); + assert!(restore_with_console.validate_microvm_options().is_err()); + for override_arg in ["--kernel", "--initrd"] { + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + override_arg, + "file", + ]) + .is_err() + ); + } } #[test] @@ -436,13 +474,6 @@ mod tests { "port0", ], vec!["openvmm", "--machine", "microvm", "--net", "consomme"], - vec![ - "openvmm", - "--machine", - "microvm", - "--restore-snapshot", - "snapshot", - ], ] { let options = Options::try_parse_from(args).unwrap(); assert!(options.validate_microvm_options().is_err()); diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index bc444973b77..52432536264 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -324,10 +324,11 @@ async fn vm_config_from_command_line( spawner: impl Spawn, mesh: &VmmMesh, opt: &Options, + microvm_restore: µvm::MicrovmRestore, ) -> anyhow::Result<(Config, VmResources)> { opt.validate_isolation_options()?; opt.validate_igvm_options()?; - let mut microvm = microvm::MicrovmConfigBuilder::new(opt)?; + let mut microvm = microvm::MicrovmConfigBuilder::new(opt, microvm_restore)?; let (_, serial_driver) = DefaultPool::spawn_on_thread("serial"); let uefi = opt.effective_uefi()?; @@ -2784,11 +2785,13 @@ async fn run_control(driver: &DefaultDriver, opt: Options) -> anyhow::Result, - opt: Options, + mut opt: Options, ) -> anyhow::Result { let mesh = mesh_slot.as_ref().unwrap(); let mut restore = snapshot_restore::SnapshotRestore::open(&opt)?; - let (mut vm_config, mut resources) = vm_config_from_command_line(driver, mesh, &opt).await?; + let microvm_restore = microvm::prepare_restore(&mut opt, restore.snapshot())?; + let (mut vm_config, mut resources) = + vm_config_from_command_line(driver, mesh, &opt, µvm_restore).await?; let mut microvm = microvm::MicrovmLaunch::new(&opt, &vm_config, std::mem::take(&mut resources.microvm))?; @@ -2925,7 +2928,7 @@ async fn run_control_inner( let vm_host = mesh.make_host("vm", opt.log_file.clone()).await?; let (shared_memory, saved_state) = if opt.restore_snapshot.is_some() { - let (fd, state_msg) = restore.prepare(&opt)?; + let (fd, state_msg) = restore.prepare(&opt, µvm, &source_hypervisor)?; (Some(fd), Some(state_msg)) } else if let Some(shared_memory) = microvm.capture_shared_memory()? { (Some(shared_memory), None) @@ -2954,10 +2957,10 @@ async fn run_control_inner( snapshot_boundary_requests, snapshot_ready, snapshot_capture_enabled: microvm.snapshot_capture_enabled(), - restore_downtime: None, - restore_tsc_frequency_hz: None, - restore_apic_frequency_hz: None, - restore_cpu_contract: None, + restore_downtime: restore.downtime, + restore_tsc_frequency_hz: restore.tsc_frequency_hz, + restore_apic_frequency_hz: restore.apic_frequency_hz, + restore_cpu_contract: restore.cpu_contract, restore_ready_sink, rpc: rpc_recv, notify: notify_send, @@ -3266,9 +3269,14 @@ mod tests { .unwrap(); let mesh = VmmMesh::new(&driver, true).unwrap(); - let (config, _resources) = vm_config_from_command_line(driver, &mesh, &opt) - .await - .unwrap(); + let (config, _resources) = vm_config_from_command_line( + driver, + &mesh, + &opt, + µvm::MicrovmRestore::default(), + ) + .await + .unwrap(); assert_eq!(config.pcie_devices.len(), 1); assert_eq!(config.pcie_devices[0].port_name, "custom"); @@ -3303,9 +3311,14 @@ mod tests { .unwrap(); let mesh = VmmMesh::new(&driver, true).unwrap(); - let (config, _resources) = vm_config_from_command_line(driver, &mesh, &opt) - .await - .unwrap(); + let (config, _resources) = vm_config_from_command_line( + driver, + &mesh, + &opt, + µvm::MicrovmRestore::default(), + ) + .await + .unwrap(); let port_names: Vec<_> = config .pcie_devices @@ -3342,10 +3355,15 @@ mod tests { .unwrap(); let mesh = VmmMesh::new(&driver, true).unwrap(); - let error = vm_config_from_command_line(driver, &mesh, &opt) - .await - .err() - .unwrap(); + let error = vm_config_from_command_line( + driver, + &mesh, + &opt, + µvm::MicrovmRestore::default(), + ) + .await + .err() + .unwrap(); assert_eq!(error.to_string(), "multiple devices use PCIe port 'custom'"); mesh.shutdown().await; @@ -3376,10 +3394,15 @@ mod tests { .unwrap(); let mesh = VmmMesh::new(&driver, true).unwrap(); - let error = vm_config_from_command_line(driver, &mesh, &opt) - .await - .err() - .unwrap(); + let error = vm_config_from_command_line( + driver, + &mesh, + &opt, + µvm::MicrovmRestore::default(), + ) + .await + .err() + .unwrap(); assert_eq!(error.to_string(), "multiple devices use PCIe port 'custom'"); mesh.shutdown().await; diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index e9fea57e475..7edbbc86df1 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -4,6 +4,7 @@ //! MicroVM parts of the VM configuration built from the command line. use super::MicrovmResources; +use super::MicrovmRestore; use crate::ConsoleState; use crate::Options; use crate::VmResources; @@ -40,6 +41,7 @@ use vmotherboard::ChipsetDeviceHandle; /// line. All methods are no-ops for the standard machine profile. pub(crate) struct MicrovmConfigBuilder<'a> { opt: &'a Options, + restore: &'a MicrovmRestore, active: bool, console: Option, portb: Option>, @@ -48,8 +50,12 @@ pub(crate) struct MicrovmConfigBuilder<'a> { impl<'a> MicrovmConfigBuilder<'a> { /// Validates the microVM options. - pub(crate) fn new(opt: &'a Options) -> anyhow::Result { + pub(crate) fn new(opt: &'a Options, restore: &'a MicrovmRestore) -> anyhow::Result { let active = opt.machine == MachineProfileCli::Microvm; + let restore_machine_contract = restore.machine_contract.as_ref(); + if let Some(contract) = restore_machine_contract { + openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; + } opt.validate_microvm_options()?; if active @@ -72,6 +78,7 @@ impl<'a> MicrovmConfigBuilder<'a> { Ok(Self { opt, + restore, active, console, portb: None, @@ -201,26 +208,39 @@ impl<'a> MicrovmConfigBuilder<'a> { bail!("the microVM profile requires Linux direct boot"); } - let kernel = fs_err::File::open( - (opt.kernel.0) + let (kernel, initrd, cmdline) = if let Some(contract) = &self.restore.machine_contract { + ( + tempfile::tempfile().context("failed to create inert restore kernel handle")?, + None, + contract.effective_command_line.clone(), + ) + } else { + let kernel = fs_err::File::open( + (opt.kernel.0) + .as_ref() + .context("must provide a Linux kernel when using --machine microvm")?, + ) + .context("failed to open Linux kernel")?; + let initrd = (opt.initrd.0) .as_ref() - .context("must provide a Linux kernel when using --machine microvm")?, - ) - .context("failed to open Linux kernel")?; - let initrd = (opt.initrd.0) - .as_ref() - .map(fs_err::File::open) - .transpose() - .context("failed to open Linux initrd")?; + .map(fs_err::File::open) + .transpose() + .context("failed to open Linux initrd")?; + ( + kernel.into(), + initrd.map(Into::into), + build_effective_microvm_command_line( + &opt.cmdline, + opt.processors, + self.console.is_some(), + )?, + ) + }; Ok(Some(LoadMode::Linux { - kernel: kernel.into(), - initrd: initrd.map(Into::into), - cmdline: build_effective_microvm_command_line( - &opt.cmdline, - opt.processors, - self.console.is_some(), - )?, + kernel, + initrd, + cmdline, enable_serial: false, isolation: openvmm_defs::config::LinuxIsolationConfig::None, boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, @@ -272,6 +292,7 @@ impl<'a> MicrovmConfigBuilder<'a> { resources: &mut VmResources, ) -> anyhow::Result<()> { let opt = self.opt; + let restore_machine_contract = self.restore.machine_contract.as_ref(); if self.active { cfg.processor_topology.vps_per_socket = Some(opt.processors); cfg.processor_topology.enable_smt = Some(false); @@ -290,7 +311,7 @@ impl<'a> MicrovmConfigBuilder<'a> { .hypervisor .as_deref() .and_then(|spec| spec.split(':').next()); - if cfg.machine_profile == MachineProfile::Microvm { + if cfg.machine_profile == MachineProfile::Microvm && restore_machine_contract.is_none() { let cmdline = match &mut cfg.load_mode { LoadMode::Linux { cmdline, diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index ac4c97de098..237f3ce750b 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -4,6 +4,7 @@ //! MicroVM launch state carried from the VM configuration to the VM worker //! and the VM controller. +use super::ExpectedRestoreContract; use super::MicrovmResources; use crate::Options; use crate::cli_args::microvm::MachineProfileCli; @@ -158,6 +159,24 @@ impl MicrovmLaunch { ) } + /// Returns the machine contract a microVM snapshot must match to be + /// restored with this configuration. + pub(crate) fn expected_restore_contract<'a>( + &'a self, + opt: &Options, + expected_hypervisor: &'a str, + ) -> anyhow::Result>> { + if opt.machine != MachineProfileCli::Microvm { + return Ok(None); + } + Ok(Some(( + expected_hypervisor, + self.effective_command_line + .as_deref() + .context("microVM restore requires an effective command line")?, + ))) + } + /// Returns the path of the file backing guest RAM, including the automatic /// snapshot RAM backing. pub(crate) fn memory_backing_file(&self, opt: &Options) -> Option { diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 2ff9fd46ec1..5996c3cb09f 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -5,9 +5,14 @@ mod config; mod launch; +mod restore; pub(crate) use config::MicrovmConfigBuilder; pub(crate) use launch::MicrovmLaunch; +pub(crate) use restore::ExpectedRestoreContract; +pub(crate) use restore::MicrovmRestore; +pub(crate) use restore::prepare_restore; +pub(crate) use restore::validate_restore_contract; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs new file mode 100644 index 00000000000..6d59dd26001 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM snapshot restore-time contract checks. + +use crate::Options; +use crate::cli_args::microvm::MachineProfileCli; +use anyhow::Context; +use openvmm_helpers::snapshot::SnapshotManifest; +use openvmm_helpers::snapshot::microvm::SnapshotMachineContract; +use openvmm_helpers::snapshot::restore::OpenedSnapshot; +use std::time::Duration; + +const MAX_SNAPSHOT_DOWNTIME: Duration = Duration::from_secs(30 * 24 * 60 * 60); + +fn calculate_snapshot_downtime( + capture_time: std::time::SystemTime, + destination_time: std::time::SystemTime, +) -> anyhow::Result { + let downtime = destination_time + .duration_since(capture_time) + .context("destination wall clock is before snapshot capture time")?; + anyhow::ensure!( + downtime <= MAX_SNAPSHOT_DOWNTIME, + "snapshot host downtime exceeds the supported 30-day bound" + ); + Ok(downtime) +} + +/// Restore-time inputs that shape a microVM configuration. +#[derive(Default)] +pub(crate) struct MicrovmRestore { + /// The authoritative machine contract of the snapshot being restored. + pub(crate) machine_contract: Option, +} + +/// Validates a snapshot restore against the microVM profile and prepares the +/// restore-time inputs of the microVM configuration. +/// +/// This may adjust `opt`: the snapshot selects the RAM size. +pub(crate) fn prepare_restore( + opt: &mut Options, + restore_snapshot: Option<&OpenedSnapshot>, +) -> anyhow::Result { + if let Some(contract) = + restore_snapshot.and_then(|snapshot| snapshot.manifest().machine_contract.as_ref()) + && contract.machine_profile == "microvm" + { + anyhow::ensure!( + opt.machine == MachineProfileCli::Microvm, + "microVM snapshot restore requires --machine microvm" + ); + openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; + } + let restore_machine_contract = if opt.machine == MachineProfileCli::Microvm + && let Some(snapshot) = restore_snapshot + { + let manifest = snapshot.manifest(); + let contract = manifest + .machine_contract + .as_ref() + .context("microVM snapshot is missing its authoritative machine contract")?; + anyhow::ensure!( + contract.machine_profile == "microvm", + "snapshot machine profile does not match the requested microVM machine" + ); + openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; + anyhow::ensure!( + opt.cmdline.is_empty(), + "restore-time command-line overrides are not allowed" + ); + anyhow::ensure!( + opt.memory == Default::default() + && !opt.deprecated_private_memory + && !opt.deprecated_prefetch + && !opt.deprecated_thp + && opt.deprecated_memory_backing_file.is_none(), + "restore-time memory overrides are not allowed" + ); + opt.memory.size = Some(vmm_cli::MemorySize(manifest.memory_size_bytes)); + Some(contract.clone()) + } else { + None + }; + Ok(MicrovmRestore { + machine_contract: restore_machine_contract, + }) +} + +/// The machine contract a microVM snapshot must match to be restored: the +/// hypervisor and the effective command line. +pub(crate) type ExpectedRestoreContract<'a> = (&'a str, &'a str); + +/// Clock and CPU state recorded at the capture boundary of a restored +/// microVM: host downtime, TSC and APIC frequencies, and the CPU contract. +pub(crate) type RestoreTime = (Duration, u64, Option, Vec); + +/// Validates the authoritative machine contract of a microVM snapshot against +/// the restore-time configuration. +pub(crate) fn validate_restore_contract( + manifest: &SnapshotManifest, + expected_memory_size: u64, + expected_vp_count: u32, + (expected_hypervisor, effective_command_line): ExpectedRestoreContract<'_>, +) -> anyhow::Result { + let saved_contract = manifest + .machine_contract + .as_ref() + .context("microVM snapshot is missing its authoritative machine contract")?; + let expected_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( + expected_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + effective_command_line.to_owned(), + expected_vp_count, + expected_memory_size, + saved_contract.state_unit_names.clone(), + saved_contract.capture_wall_clock, + saved_contract.tsc_frequency_hz, + saved_contract.apic_frequency_hz, + saved_contract.cpu_contract.clone(), + )?; + openvmm_helpers::snapshot::microvm::validate_microvm_machine_contract( + manifest, + &expected_contract, + )?; + let capture_time: std::time::SystemTime = saved_contract + .capture_wall_clock + .try_into() + .context("snapshot capture wall clock is invalid")?; + let downtime = calculate_snapshot_downtime(capture_time, std::time::SystemTime::now())?; + Ok(( + downtime, + saved_contract.tsc_frequency_hz, + saved_contract.apic_frequency_hz, + saved_contract.cpu_contract.clone(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn snapshot_downtime_accepts_supported_elapsed_time() { + let capture = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1); + + assert_eq!( + calculate_snapshot_downtime(capture, capture).unwrap(), + Duration::ZERO + ); + assert_eq!( + calculate_snapshot_downtime(capture, capture + MAX_SNAPSHOT_DOWNTIME).unwrap(), + MAX_SNAPSHOT_DOWNTIME + ); + } + + #[test] + fn snapshot_downtime_rejects_rollback_and_excessive_elapsed_time() { + let capture = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1); + + let rollback = + calculate_snapshot_downtime(capture, std::time::SystemTime::UNIX_EPOCH).unwrap_err(); + assert!( + rollback + .to_string() + .contains("before snapshot capture time") + ); + + let excessive = calculate_snapshot_downtime( + capture, + capture + MAX_SNAPSHOT_DOWNTIME + Duration::from_secs(1), + ) + .unwrap_err(); + assert!(excessive.to_string().contains("exceeds the supported")); + } +} diff --git a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs index 4a4a7805b32..87bf1df73ec 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs @@ -4,10 +4,12 @@ //! Snapshot restore of a VM launched from the command line. //! //! A `--restore-snapshot` launch opens one exact snapshot generation first -//! ([`SnapshotRestore::open`]). Just before the VM worker launches, the opened -//! generation is validated against the VM configuration and its RAM is mapped -//! copy-on-write ([`SnapshotRestore::prepare`]). Its open artifact handles then -//! move to the worker, which keeps the generation pinned until VM teardown. +//! ([`SnapshotRestore::open`]); a microVM derives its restore-time +//! configuration from that generation's manifest. Just before the VM worker +//! launches, the opened generation is validated against the VM configuration +//! and its RAM is mapped copy-on-write ([`SnapshotRestore::prepare`]). Its open +//! artifact handles then move to the worker, which keeps the generation pinned +//! until VM teardown. //! The `--restore-ready-path` endpoint is connected next //! ([`restore_ready_sink`]); the worker writes the restore readiness event to //! it when the restored VM first starts. @@ -23,12 +25,14 @@ mod ready; pub(crate) use ready::restore_ready_sink; use crate::Options; +use crate::microvm::MicrovmLaunch; use anyhow::Context; use mesh::payload::message::ProtobufMessage; use openvmm_defs::profile::ProfileSpan; use openvmm_defs::worker::SharedMemoryFd; use openvmm_defs::worker::SnapshotRestoreGuards; use openvmm_helpers::snapshot::restore::OpenedSnapshot; +use std::time::Duration; /// The snapshot restore of a VM launched from the command line. pub(crate) struct SnapshotRestore { @@ -45,6 +49,14 @@ pub(crate) struct WorkerRestore { pub(crate) shared_memory_copy_on_write: bool, /// Snapshot generation handles that must outlive the restored VM. pub(crate) guards: Option, + /// Host downtime to apply before starting a restored microVM. + pub(crate) downtime: Option, + /// Saved effective TSC frequency of a restored microVM. + pub(crate) tsc_frequency_hz: Option, + /// Saved local APIC timer frequency of a restored microVM. + pub(crate) apic_frequency_hz: Option, + /// Saved canonical CPU contract of a restored microVM. + pub(crate) cpu_contract: Option>, } impl SnapshotRestore { @@ -80,6 +92,12 @@ impl SnapshotRestore { }) } + /// Returns the opened snapshot generation, until [`Self::prepare`] takes + /// it. + pub(crate) fn snapshot(&self) -> Option<&OpenedSnapshot> { + self.snapshot.as_ref() + } + /// Validates the opened snapshot generation against the VM configuration /// and prepares it for the VM worker. /// @@ -88,15 +106,27 @@ impl SnapshotRestore { pub(crate) fn prepare( &mut self, opt: &Options, + microvm: &MicrovmLaunch, + expected_hypervisor: &str, ) -> anyhow::Result<(SharedMemoryFd, ProtobufMessage)> { let prepared = prepare::prepare_snapshot_restore( self.snapshot .take() .context("snapshot restore is missing its opened generation")?, opt, + microvm, + expected_hypervisor, )?; self.worker.shared_memory_copy_on_write = true; self.worker.guards = Some(prepared.guards); + if let Some((downtime, tsc_frequency_hz, apic_frequency_hz, cpu_contract)) = + prepared.restore_time + { + self.worker.downtime = Some(downtime); + self.worker.tsc_frequency_hz = Some(tsc_frequency_hz); + self.worker.apic_frequency_hz = apic_frequency_hz; + self.worker.cpu_contract = Some(cpu_contract); + } Ok((prepared.shared_memory, prepared.saved_state)) } diff --git a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs index 3329bdcf2e5..f9424b74dee 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs @@ -5,7 +5,9 @@ //! and preparation of the copy-on-write guest RAM and saved state it restores. use crate::Options; +use crate::microvm; use anyhow::Context; +use std::time::Duration; /// An opened snapshot generation, validated against the VM configuration and /// prepared for the VM worker. @@ -13,6 +15,7 @@ pub(crate) struct PreparedSnapshotRestore { pub(crate) shared_memory: openvmm_defs::worker::SharedMemoryFd, pub(crate) guards: openvmm_defs::worker::SnapshotRestoreGuards, pub(crate) saved_state: mesh::payload::message::ProtobufMessage, + pub(crate) restore_time: Option<(Duration, u64, Option, Vec)>, } /// Validate an opened snapshot generation against the current VM config. @@ -20,14 +23,23 @@ pub(crate) struct PreparedSnapshotRestore { pub(super) fn prepare_snapshot_restore( snapshot: openvmm_helpers::snapshot::restore::OpenedSnapshot, opt: &Options, + microvm: µvm::MicrovmLaunch, + expected_hypervisor: &str, ) -> anyhow::Result { - prepare_snapshot_restore_for_config(snapshot, opt.memory_size(), opt.processors) + let expected_microvm_contract = microvm.expected_restore_contract(opt, expected_hypervisor)?; + prepare_snapshot_restore_for_config( + snapshot, + opt.memory_size(), + opt.processors, + expected_microvm_contract, + ) } pub(crate) fn prepare_snapshot_restore_for_config( snapshot: openvmm_helpers::snapshot::restore::OpenedSnapshot, expected_memory_size: u64, expected_vp_count: u32, + expected_microvm_contract: Option>, ) -> anyhow::Result { let artifact_prepare = openvmm_defs::profile::ProfileSpan::start(); let manifest = snapshot.manifest(); @@ -39,10 +51,34 @@ pub(crate) fn prepare_snapshot_restore_for_config( expected_vp_count, crate::system_page_size(), )?; + let restore_time = expected_microvm_contract + .map(|contract| { + microvm::validate_restore_contract( + manifest, + expected_memory_size, + expected_vp_count, + contract, + ) + }) + .transpose()?; + // The manifest and state.bin inventories describe the same machine boundary. + // Require them to agree before worker and partition construction. let state_msg: mesh::payload::message::ProtobufMessage = mesh::payload::decode(snapshot.state_bytes()) .context("failed to decode saved state from snapshot")?; + if let Some(contract) = &manifest.machine_contract { + let inventory_msg: mesh::payload::message::ProtobufMessage = + mesh::payload::decode(snapshot.state_bytes()) + .context("failed to decode saved state inventory from snapshot")?; + let saved_state: openvmm_defs::worker::SavedState = inventory_msg + .parse() + .context("failed to parse saved state inventory from snapshot")?; + anyhow::ensure!( + saved_state.inventory == contract.state_unit_names, + "snapshot manifest state-unit inventory does not match state.bin" + ); + } artifact_prepare.complete( "restore", @@ -75,5 +111,6 @@ pub(crate) fn prepare_snapshot_restore_for_config( shared_memory, guards, saved_state: state_msg, + restore_time, }) } From 56a2cdd15bb5cc9a314ba5e310a95a6fab6637c0 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 07:00:16 -0700 Subject: [PATCH 112/304] vmm_tests: check that an unconfigured microVM snapshot request continues The microVM lifecycle test covers boot, the portb console, host-save rejection, and status shutdown, but not the snapshot-request port that every microVM now exposes. Add two commands to the test guest's init script. The snapshot command writes the snapshot-request port (0x605), increments a generation counter, and reports that the guest continued. The state command reports whether the counter is exactly one. Petri attaches the port without a notification target, so the port completes the write at once. The test checks that the guest continues past the write and that its state is intact afterward. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../vmm_tests/tests/tests/x86_64/microvm.rs | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs b/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs index 56593984d5b..6600c3bd756 100644 --- a/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs +++ b/vmm_tests/vmm_tests/tests/tests/x86_64/microvm.rs @@ -31,6 +31,8 @@ async fn phase_1_lifecycle(config: PetriVmBuilder) -> anyho const RAW_MARKER: &[u8] = b"\0\r\n\x7f\xffLINUX-DIRECT-ECHO\n"; const COMMAND_PING: u8 = 1; const COMMAND_ECHO: u8 = 2; + const COMMAND_SNAPSHOT: u8 = 3; + const COMMAND_STATE: u8 = 5; const COMMAND_SHUTDOWN: u8 = 6; let script = format!( @@ -49,6 +51,7 @@ async fn phase_1_lifecycle(config: PetriVmBuilder) -> anyho fi\n\ done\n\ }}\n\ + generation=0\n\ {}\ while :; do\n\ command=$(next_byte)\n\ @@ -59,6 +62,19 @@ async fn phase_1_lifecycle(config: PetriVmBuilder) -> anyho {COMMAND_ECHO})\n\ {}\ ;;\n\ + {COMMAND_SNAPSHOT})\n\ + {}\ + printf '\\x00' | dd of=/dev/port bs=1 seek=1541 count=1 conv=notrunc 2>/dev/null\n\ + generation=$((generation + 1))\n\ + {}\ + ;;\n\ + {COMMAND_STATE})\n\ + if [ \"$generation\" = 1 ]; then\n\ + {}\ + else\n\ + {}\ + fi\n\ + ;;\n\ {COMMAND_SHUTDOWN})\n\ status=$(next_byte)\n\ octal=$(printf '%03o' \"$status\")\n\ @@ -73,6 +89,10 @@ async fn phase_1_lifecycle(config: PetriVmBuilder) -> anyho portb_output(BOOT_MARKER)?, portb_output(b"PONG\n")?, portb_output(RAW_MARKER)?, + portb_output(b"SNAPSHOT-REQUESTED\n")?, + portb_output(b"SNAPSHOT-CONTINUED=1\n")?, + portb_output(b"STATE=1\n")?, + portb_output(b"STATE-INVALID\n")?, portb_output(b"UNKNOWN-COMMAND\n")?, ); let config = config.with_microvm_machine(1); @@ -131,6 +151,27 @@ async fn phase_1_lifecycle(config: PetriVmBuilder) -> anyho .await .context("microVM raw binary echo was not observed")??; + vm.backend() + .write_microvm_portb_input(&[COMMAND_SNAPSHOT]) + .await?; + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled( + vm.backend() + .wait_for_microvm_portb_output("SNAPSHOT-CONTINUED=1"), + ) + .await + .context("microVM snapshot request without a destination did not continue")??; + + vm.backend() + .write_microvm_portb_input(&[COMMAND_STATE]) + .await?; + CancelContext::new() + .with_timeout(TIMEOUT) + .until_cancelled(vm.backend().wait_for_microvm_portb_output("STATE=1")) + .await + .context("microVM guest state did not persist after the snapshot request")??; + vm.backend() .write_microvm_portb_input(&[COMMAND_SHUTDOWN, 37]) .await?; From f802e90f29b4b2609769e01cd01386a3803a067b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 07:08:37 -0700 Subject: [PATCH 113/304] openvmm_entry: add connect= serial backends with bounded timeouts Serial backends can listen for a client, but OpenVMM cannot attach a serial device to an endpoint that another process already listens on. Add `connect=PATH` and `connect=tcp:IP:PORT` to the serial backend syntax. `connect=PATH` connects to an existing Unix socket or Windows named pipe, and `connect=tcp:IP:PORT` connects to an existing TCP listener. Both attempts are bounded by the new five-second `MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS`, so an absent peer fails startup instead of hanging it. The path connection runs on a helper thread, because the underlying connect call has no timeout. MicroVM consoles do not accept the new backends yet. Tests cover the parser. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 2 + openvmm/openvmm_defs/src/microvm.rs | 2 + openvmm/openvmm_entry/src/cli_args.rs | 36 +++++++++++++- openvmm/openvmm_entry/src/lib.rs | 13 +++++ openvmm/openvmm_entry/src/serial_io.rs | 2 + .../openvmm_entry/src/serial_io/connect.rs | 47 +++++++++++++++++++ 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 openvmm/openvmm_entry/src/serial_io/connect.rs diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 9c4cd910460..9ca0fc5e20d 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -399,6 +399,8 @@ The `BACKEND` argument is the same for all serial devices: * `listen=tcp:IP:PORT`: As with `listen=PATH`, but listen for TCP connections on the given IP address and port. Typically IP will be 127.0.0.1, to restrict connections to the current host. + * `connect=PATH`: Connect to an existing named pipe or Unix socket. + * `connect=tcp:IP:PORT`: Connect to an existing TCP listener. ## Guest power events diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index c417a302f24..da24c0c6a9c 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -65,6 +65,8 @@ pub const MICROVM_VIRTIO_FS_IRQ: u32 = 6; pub const MICROVM_VIRTIO_NET_KVM_IRQ: u32 = 10; /// Fixed microVM virtio-net interrupt on WHP. pub const MICROVM_VIRTIO_NET_WHP_IRQ: u32 = 5; +/// MicroVM client console reconnect timeout. +pub const MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS: u64 = 5_000; /// MicroVM virtio MMIO reservations in stable device order. pub const MICROVM_VIRTIO_MMIO_BASES: [u64; 8] = [ MICROVM_VIRTIO_NET_MMIO_BASE, diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index d9cdef8314e..c9dc55e62c7 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -846,7 +846,7 @@ options: /// virtio console device backed by a serial backend (/dev/hvc0 in guest) /// - /// Accepts serial config (console | stderr | listen=\ | + /// Accepts serial config (console | stderr | listen=\ | connect=\ | /// file=\ (overwrites) | listen=tcp:\:\ | /// term[=\]\[,name=\\] | none) #[clap(long)] @@ -2828,7 +2828,7 @@ impl FromStr for ComSerialConfigCli { } } -/// (console | stderr | listen=\ | file=\ (overwrites) | listen=tcp:\:\ | term[=\]\[,name=\\] | none) +/// (console | stderr | listen=\ | connect=\ | file=\ (overwrites) | listen=tcp:\:\ | connect=tcp:\:\ | term[=\]\[,name=\\] | none) #[derive(Clone, Debug, PartialEq)] pub enum SerialConfigCli { None, @@ -2837,6 +2837,8 @@ pub enum SerialConfigCli { Stderr, Pipe(PathBuf), Tcp(SocketAddr), + ConnectPipe(PathBuf), + ConnectTcp(SocketAddr), File(PathBuf), } @@ -2885,6 +2887,21 @@ impl FromStr for SerialConfigCli { "invalid serial configuration: listen requires a value of tcp:addr or pipe", )?, }, + "connect" => match first_value { + Some(path) => { + if let Some(tcp) = path.strip_prefix("tcp:") { + let addr = tcp + .parse() + .map_err(|err| format!("invalid tcp address: {err}"))?; + SerialConfigCli::ConnectTcp(addr) + } else { + SerialConfigCli::ConnectPipe(path.into()) + } + } + None => Err( + "invalid serial configuration: connect requires a value of tcp:addr or pipe", + )?, + }, _ => { return Err(format!( "invalid serial configuration: '{}' is not a known option", @@ -4321,11 +4338,26 @@ mod tests { _ => panic!("Expected Pipe variant"), } + match SerialConfigCli::from_str("connect=tcp:127.0.0.1:1234").unwrap() { + SerialConfigCli::ConnectTcp(addr) => { + assert_eq!(addr.to_string(), "127.0.0.1:1234"); + } + _ => panic!("Expected ConnectTcp variant"), + } + + match SerialConfigCli::from_str("connect=/path/to/pipe").unwrap() { + SerialConfigCli::ConnectPipe(path) => { + assert_eq!(path.to_str().unwrap(), "/path/to/pipe"); + } + _ => panic!("Expected ConnectPipe variant"), + } + // Test error cases assert!(SerialConfigCli::from_str("").is_err()); assert!(SerialConfigCli::from_str("unknown").is_err()); assert!(SerialConfigCli::from_str("file").is_err()); assert!(SerialConfigCli::from_str("listen").is_err()); + assert!(SerialConfigCli::from_str("connect").is_err()); } #[test] diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 52432536264..75279a65287 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -397,6 +397,19 @@ async fn vm_config_from_command_line( SerialConfigCli::Tcp(addr) => { Some(serial_io::bind_tcp_serial(&addr).context("failed to bind serial")?) } + SerialConfigCli::ConnectPipe(path) => Some( + serial_io::connect::connect_serial_with_timeout( + &path, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + ) + .context("failed to connect serial")?, + ), + SerialConfigCli::ConnectTcp(addr) => Some(serial_io::connect::connect_tcp_serial( + &addr, + Duration::from_millis(openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS), + )?), SerialConfigCli::NewConsole(app, window_title) => { let path = console_relay::random_console_path(); let config = diff --git a/openvmm/openvmm_entry/src/serial_io.rs b/openvmm/openvmm_entry/src/serial_io.rs index 18dce9b0a9c..9195232270b 100644 --- a/openvmm/openvmm_entry/src/serial_io.rs +++ b/openvmm/openvmm_entry/src/serial_io.rs @@ -15,6 +15,8 @@ use vm_resource::IntoResource; use vm_resource::Resource; use vm_resource::kind::SerialBackendHandle; +pub(crate) mod connect; + #[cfg(unix)] pub fn anonymous_serial_pair( driver: &(impl Driver + ?Sized), diff --git a/openvmm/openvmm_entry/src/serial_io/connect.rs b/openvmm/openvmm_entry/src/serial_io/connect.rs new file mode 100644 index 00000000000..576a75ffbb4 --- /dev/null +++ b/openvmm/openvmm_entry/src/serial_io/connect.rs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Serial endpoint helpers beyond the upstream ones: connecting to an existing +//! endpoint as a client within a bounded time. + +use anyhow::Context; +use serial_socket::net::OpenSocketSerialConfig; +use std::io; +use std::net::SocketAddr; +use std::net::TcpStream; +use std::path::Path; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::kind::SerialBackendHandle; + +pub(crate) fn connect_serial_with_timeout( + path: &Path, + timeout: std::time::Duration, +) -> io::Result> { + let path = path.to_owned(); + let (send, recv) = std::sync::mpsc::sync_channel(1); + std::thread::Builder::new() + .name("serial-connect".to_owned()) + .spawn(move || { + let _ = send.send(super::connect_serial(&path)); + })?; + match recv.recv_timeout(timeout) { + Ok(result) => result, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(io::Error::new( + io::ErrorKind::TimedOut, + format!("serial endpoint did not connect within {timeout:?}"), + )), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(io::Error::other( + "serial connection worker terminated without a result", + )), + } +} + +pub(crate) fn connect_tcp_serial( + addr: &SocketAddr, + timeout: std::time::Duration, +) -> anyhow::Result> { + let stream = TcpStream::connect_timeout(addr, timeout) + .with_context(|| format!("failed to connect to tcp address {addr}"))?; + Ok(OpenSocketSerialConfig::from(stream).into_resource()) +} From 38177c6bfdd057266c18cca62e302efa3457ae4c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 07:20:47 -0700 Subject: [PATCH 114/304] openvmm: persist the microVM boot console and rebuild its attachment on restore A microVM snapshot cannot include a virtio console yet. The machine contract has no console device, and nothing records how the host end of the console was attached, so capture and restore both reject `--virtio-console`. Record the boot console as a host attachment. `SnapshotAttachment` describes a host resource that a device needs: a stable ID, kind, requiredness, reconnect policy, identity kind and bytes, and a bounded reconnect timeout. The machine contract gains the attachment inventory (field 10). When a console attachment is supplied, `microvm_machine_contract` adds the virtio-console device at its fixed slot (MMIO 0xd0002000, IRQ 7, two 256-entry queues) and requires one of these policies: - `listen=PATH` and `listen=tcp:IP:PORT` recreate the listener (`recreate-listener`). Unix sockets are canonicalized and must be beside the snapshot directory, Windows pipes must use `//./pipe/openvmm-microvm-`, and TCP endpoints must use a nonzero port on a loopback address. - `connect=PATH` and `connect=tcp:IP:PORT` reconnect a required client within five seconds (`reconnect-client`). Restore must resupply the same client endpoint. - `console` requires the restore caller to supply the same inherited terminal (`require-inherited-attachment`). - `none` keeps the device and discards guest output while disconnected (`discard-while-disconnected`). Shape validation bounds the inventory and rejects duplicate or incomplete attachments and unknown policies. Restore compares the attachment inventory exactly. In the entry point, the new `microvm::console` module derives the device attachment and its snapshot identity from `--virtio-console`, or on restore from the manifest. A restore cannot add a console to a snapshot without one. A restore of a console snapshot rebuilds the saved endpoint and requires any supplied endpoint to match it. Listeners are bound without removing an existing socket path. A guard removes the source socket after a committed capture, and at teardown, but only if the path still refers to the socket that OpenVMM created, so that the restored process can bind the same path. Capture and restore now accept `--virtio-console`, and the microVM console accepts the `connect=` backends. Document the attachment policies and how restore rebuilds them. Tests cover the attachment policies, the namespace rules, identity reconstruction, socket ownership, and the console machine contract. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 43 +- Guide/src/user_guide/openvmm/snapshots.md | 9 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 26 +- openvmm/openvmm_entry/src/microvm/config.rs | 87 ++- openvmm/openvmm_entry/src/microvm/console.rs | 629 ++++++++++++++++++ openvmm/openvmm_entry/src/microvm/launch.rs | 2 + openvmm/openvmm_entry/src/microvm/mod.rs | 7 + openvmm/openvmm_entry/src/microvm/restore.rs | 8 +- .../openvmm_entry/src/serial_io/connect.rs | 22 +- .../src/vm_controller/microvm.rs | 21 + .../openvmm_helpers/src/snapshot/microvm.rs | 219 +++++- 11 files changed, 1017 insertions(+), 56 deletions(-) create mode 100644 openvmm/openvmm_entry/src/microvm/console.rs diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 9ca0fc5e20d..10f5e81251e 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -72,8 +72,9 @@ describes the source definitions. `--snapshot-quiesce-timeout-ms ` sets the bounded quiesce timeout and defaults to 5000. A request with no configured destination is ignored and the guest continues. Capture requires 1, 2, 4, or 8 vCPUs, - KVM, MSHV, or WHP, and shared file-backed RAM. It does not yet support - `--virtio-console`. + KVM, MSHV, or WHP, and shared file-backed RAM. An attached virtio console + saves accepted but undelivered input and the offset of a partially forwarded + guest transmit descriptor. ```bash openvmm --machine microvm --hypervisor kvm --memory 128M \ @@ -87,7 +88,13 @@ describes the source definitions. `--memory`, processor, device, and topology overrides are not accepted. Repeat the snapshot's exact `--processors` count; a mismatch is rejected before any VP starts. Restore requires the same backend kind as capture. - It does not yet support `--virtio-console`. + + When the snapshot contains a virtio console, its attachment policy comes + from the manifest. OpenVMM recreates listeners, reconnects required clients, + or requires an inherited replacement before creating the partition. Restore + fails before any vCPU starts when a required attachment cannot be rebuilt. + A listener peer may connect after restore; guest transmit descriptors remain + pending while no peer is connected. ```bash openvmm --machine microvm --hypervisor kvm \ @@ -382,11 +389,27 @@ Serial devices can be configured to appear as different devices inside the guest * `--virtio-console `: Expose a virtio console device. It normally appears as `/dev/hvc0`. Under `--machine microvm`, it occupies fixed MMIO `0xd0002000`, IRQ 7, and is selected as `/dev/hvc1`; the raw portb path - remains available as `hvc0` for early output and recovery. A microVM accepts - `listen=PATH`, `listen=tcp:IP:PORT`, `console`, or `none`. A listener - retains guest output until a client connects. `console` moves portb output - to stderr while the terminal is attached to `hvc1`, and `none` discards - guest output. + remains available as `hvc0` for early output and recovery. + + A microVM accepts these explicit attachment policies: + + * `listen=PATH` or `listen=tcp:IP:PORT`: save the canonical endpoint and + recreate the optional listener on restore. Unix sockets must be beside the + snapshot directory. Windows pipes use the + `//./pipe/openvmm-microvm-` namespace. TCP ports must be nonzero. + TCP addresses must be loopback addresses. + * `connect=PATH` or `connect=tcp:IP:PORT`: require a client connection before + vCPUs start. Cold boot and restore use a five-second timeout. The + restore command must explicitly resupply the matching client attachment. + * `console`: require the restore caller to supply the same inherited terminal + attachment. Portb recovery output moves to stderr while the terminal is + attached to `hvc1`. + * `none`: keep the device present and explicitly discard guest TX while + disconnected. + + A generic byte stream guarantees no replay up to OpenVMM's backend write + boundary; it cannot prove that the remote application consumed bytes without + its own acknowledgment protocol. The `BACKEND` argument is the same for all serial devices: @@ -397,8 +420,8 @@ The `BACKEND` argument is the same for all serial devices: up to listen on the given path. Serial input and output is relayed to this pipe/socket. * `listen=tcp:IP:PORT`: As with `listen=PATH`, but listen for TCP - connections on the given IP address and port. Typically IP will be - 127.0.0.1, to restrict connections to the current host. + connections on the given IP address and port. A microVM requires a + loopback IP such as `127.0.0.1` or `::1`. * `connect=PATH`: Connect to an existing named pipe or Unix socket. * `connect=tcp:IP:PORT`: Connect to an existing TCP listener. diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 6fb8dd6cc2f..65d21cffa68 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -175,6 +175,15 @@ and restore requires the saved and current inventories to match exactly. A microVM manifest additionally records and validates the exact device inventory and order. +For a phase-3 virtio console, the manifest also records its stable attachment +ID, canonical endpoint identity, reconnect policy, requiredness, and timeout. +Native socket, pipe, terminal, and file handles are never serialized. Restore +recreates listeners, reconnects required clients, or requires an inherited +replacement before starting the partition. Accepted host input and a partial +guest transmit offset live in the device-private virtio payload, preserving +their order across a new-process restore. Host input is gated before the vCPU +snapshot boundary and resumed only if capture rolls back. + The rules are: | Scenario | Result | diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index fb5e286e2db..cf6f110daba 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -80,16 +80,6 @@ impl Options { self.microvm.snapshot_quiesce_timeout_ms != 0, "microVM snapshot quiesce timeout must be nonzero" ); - anyhow::ensure!( - self.virtio_console.is_none(), - "microVM snapshot capture does not yet support --virtio-console" - ); - } - if self.restore_snapshot.is_some() { - anyhow::ensure!( - self.virtio_console.is_none(), - "microVM snapshot restore does not yet support --virtio-console" - ); } anyhow::ensure!( self.uefi.is_none() && !self.pcat && self.igvm.is_none() && !self.device_tree, @@ -156,10 +146,12 @@ impl Options { console, SerialConfigCli::Pipe(_) | SerialConfigCli::Tcp(_) + | SerialConfigCli::ConnectPipe(_) + | SerialConfigCli::ConnectTcp(_) | SerialConfigCli::Console | SerialConfigCli::None ), - "microVM virtio-console requires listen=..., console, or none" + "microVM virtio-console requires listen=..., connect=..., console, or none" ); } anyhow::ensure!( @@ -323,7 +315,6 @@ mod tests { for extra in [ vec!["--snapshot-quiesce-timeout-ms", "0"], vec!["--memory", "size=1G,shared=off"], - vec!["--virtio-console", "none"], ] { let options = Options::try_parse_from( [ @@ -363,17 +354,6 @@ mod tests { .unwrap(); assert_eq!(restore.memory, Default::default()); restore.validate_microvm_options().unwrap(); - let restore_with_console = Options::try_parse_from([ - "openvmm", - "--machine", - "microvm", - "--restore-snapshot", - "snapshot", - "--virtio-console", - "none", - ]) - .unwrap(); - assert!(restore_with_console.validate_microvm_options().is_err()); for override_arg in ["--kernel", "--initrd"] { assert!( Options::try_parse_from([ diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 7edbbc86df1..3026f09330b 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -5,6 +5,10 @@ use super::MicrovmResources; use super::MicrovmRestore; +use super::console::ConsoleEndpoint; +use super::console::effective_microvm_console; +use super::console::microvm_console_socket_cleanup; +use super::console::validate_microvm_console_attachment_namespace; use crate::ConsoleState; use crate::Options; use crate::VmResources; @@ -29,6 +33,7 @@ use std::cell::RefCell; use std::thread; use std::time::Duration; use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; use vm_manifest_builder::MachineArch; use vm_resource::IntoResource; use vm_resource::Resource; @@ -43,7 +48,7 @@ pub(crate) struct MicrovmConfigBuilder<'a> { opt: &'a Options, restore: &'a MicrovmRestore, active: bool, - console: Option, + console: Option, portb: Option>, resources: MicrovmResources, } @@ -71,10 +76,25 @@ impl<'a> MicrovmConfigBuilder<'a> { } let console = if active { - opt.virtio_console.clone() + effective_microvm_console(opt.virtio_console.as_ref(), restore_machine_contract)? } else { None }; + if let Some((_, _, attachment)) = &console + && let Some(snapshot_dir) = opt + .restore_snapshot + .as_deref() + .or(opt.microvm.snapshot_destination.as_deref()) + { + validate_microvm_console_attachment_namespace(attachment, snapshot_dir)?; + } + + let resources = MicrovmResources { + console_attachment: console + .as_ref() + .map(|(_, _, attachment)| attachment.clone()), + ..Default::default() + }; Ok(Self { opt, @@ -82,7 +102,7 @@ impl<'a> MicrovmConfigBuilder<'a> { active, console, portb: None, - resources: MicrovmResources::default(), + resources, }) } @@ -112,7 +132,11 @@ impl<'a> MicrovmConfigBuilder<'a> { if !self.active { return Ok(()); } - let backend = if matches!(self.console, Some(SerialConfigCli::Console)) { + let backend = if self + .console + .as_ref() + .is_some_and(|(config, _, _)| matches!(config, SerialConfigCli::Console)) + { SerialConfigCli::Stderr } else { SerialConfigCli::Console @@ -130,21 +154,48 @@ impl<'a> MicrovmConfigBuilder<'a> { /// Connects the host side of the boot virtio-console, returning its /// backend. pub(crate) fn setup_virtio_consoles( - &self, + &mut self, console_state: &RefCell>>, serial_driver: &DefaultDriver, ) -> anyhow::Result>> { - let virtio_console_backend = if let Some(serial_cfg) = self.console.clone() { + let virtio_console_backend = if let Some(serial_cfg) = + self.console.as_ref().map(|(config, _, _)| config.clone()) + { match serial_cfg { SerialConfigCli::Pipe(path) => { - Some(serial_io::bind_serial(&path).with_context(|| { + let backend = serial_io::connect::bind_serial_without_cleanup(&path) + .with_context(|| { + format!( + "failed to bind microVM virtio console listener {}", + path.display() + ) + })?; + self.resources.console_socket_cleanup = microvm_console_socket_cleanup(path)?; + Some(backend) + } + SerialConfigCli::Tcp(address) => Some(serial_io::bind_tcp_serial(&address)?), + SerialConfigCli::ConnectPipe(path) => Some( + serial_io::connect::connect_serial_with_timeout( + &path, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + ) + .with_context(|| { format!( - "failed to bind microVM virtio console listener {}", + "failed to reconnect microVM virtio console client {}", path.display() ) - })?) + })?, + ), + SerialConfigCli::ConnectTcp(address) => { + Some(serial_io::connect::connect_tcp_serial( + &address, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + )?) } - SerialConfigCli::Tcp(address) => Some(serial_io::bind_tcp_serial(&address)?), SerialConfigCli::Console => Some(setup_host_console( "virtio-console", SerialConfigCli::Console, @@ -153,7 +204,7 @@ impl<'a> MicrovmConfigBuilder<'a> { serial_driver, )?), SerialConfigCli::None => Some(DisconnectedSerialBackendHandle.into_resource()), - _ => unreachable!("microVM console backend was validated"), + _ => unreachable!("microVM console backend was validated as an attachment"), } } else { None @@ -264,9 +315,6 @@ impl<'a> MicrovmConfigBuilder<'a> { } /// Builds the boot virtio-console device handle. - /// - /// A microVM retains guest output until a listener's client connects; a - /// disconnected (`none`) console discards it. pub(crate) fn virtio_console_handle( &self, backend: Resource, @@ -274,13 +322,18 @@ impl<'a> MicrovmConfigBuilder<'a> { virtio_resources::console::VirtioConsoleHandle { backend, disconnect_policy: if self.active - && !matches!(self.console, Some(SerialConfigCli::None)) - { + && !self.console.as_ref().is_some_and(|(_, attachment, _)| { + attachment.reconnect_policy + == VirtioConsoleReconnectPolicy::DiscardWhileDisconnected + }) { VirtioConsoleDisconnectPolicy::Retain } else { VirtioConsoleDisconnectPolicy::Discard }, - attachment: None, + attachment: self + .console + .as_ref() + .map(|(_, attachment, _)| attachment.clone()), } } diff --git a/openvmm/openvmm_entry/src/microvm/console.rs b/openvmm/openvmm_entry/src/microvm/console.rs new file mode 100644 index 00000000000..851fdd1c154 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/console.rs @@ -0,0 +1,629 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM virtio-console attachments. + +use crate::cli_args::SerialConfigCli; +use anyhow::Context; +#[cfg(unix)] +use std::io; +use std::path::Path; +use std::path::PathBuf; + +/// A microVM console endpoint: its effective serial backend, device attachment +/// configuration, and snapshot attachment identity. +pub(super) type ConsoleEndpoint = ( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +); + +pub(crate) const MICROVM_CONSOLE_STABLE_ID: &str = "console:microvm-virtio0"; +pub(crate) const MICROVM_CONSOLE_ATTACHMENT_KIND: &str = "virtio-console"; + +pub(crate) struct MicrovmConsoleSocketCleanup { + #[cfg(unix)] + path: PathBuf, + #[cfg(unix)] + device: u64, + #[cfg(unix)] + inode: u64, +} + +impl MicrovmConsoleSocketCleanup { + #[cfg(unix)] + fn new(path: PathBuf) -> anyhow::Result { + use std::os::unix::fs::FileTypeExt as _; + use std::os::unix::fs::MetadataExt as _; + + let metadata = fs_err::symlink_metadata(&path) + .with_context(|| format!("failed to inspect console socket {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_socket(), + "microVM console path is not a Unix socket: {}", + path.display() + ); + Ok(Self { + path, + device: metadata.dev(), + inode: metadata.ino(), + }) + } + + pub(crate) fn remove_if_owned(&self) -> anyhow::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt as _; + use std::os::unix::fs::MetadataExt as _; + + let metadata = match fs_err::symlink_metadata(&self.path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + anyhow::ensure!( + metadata.file_type().is_socket() + && metadata.dev() == self.device + && metadata.ino() == self.inode, + "microVM console socket ownership changed: {}", + self.path.display() + ); + fs_err::remove_file(&self.path).with_context(|| { + format!("failed to remove console socket {}", self.path.display()) + })?; + } + Ok(()) + } +} + +impl Drop for MicrovmConsoleSocketCleanup { + fn drop(&mut self) { + let _ = self.remove_if_owned(); + } +} + +pub(crate) fn microvm_console_socket_cleanup( + path: PathBuf, +) -> anyhow::Result> { + #[cfg(unix)] + { + Ok(Some(MicrovmConsoleSocketCleanup::new(path)?)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(None) + } +} + +fn canonical_microvm_console_path(path: &Path) -> anyhow::Result { + #[cfg(windows)] + if path.starts_with("//./pipe") { + return Ok(path.to_owned()); + } + + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir() + .context("failed to resolve current directory for console endpoint")? + .join(path) + }; + let file_name = absolute + .file_name() + .context("microVM console endpoint must name a socket or pipe")?; + let parent = absolute + .parent() + .context("microVM console endpoint has no parent directory")?; + let parent = fs_err::canonicalize(parent).with_context(|| { + format!( + "failed to canonicalize microVM console endpoint parent {}", + parent.display() + ) + })?; + let canonical = parent.join(file_name); + if let Ok(metadata) = fs_err::symlink_metadata(&canonical) { + anyhow::ensure!( + !metadata.file_type().is_symlink(), + "microVM console endpoint must not be a symbolic link: {}", + canonical.display() + ); + } + Ok(canonical) +} + +fn microvm_console_attachment_from_cli_with_identity( + config: &SerialConfigCli, + stable_id: &'static str, + attachment_kind: &'static str, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + use virtio_resources::console::attachment::VirtioConsoleAttachment; + use virtio_resources::console::attachment::VirtioConsoleAttachmentMode; + use virtio_resources::console::attachment::VirtioConsoleBackendKind; + use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; + + let ( + effective, + backend_kind, + identity_kind, + endpoint_identity, + mode, + reconnect_policy, + reconnect_policy_name, + required, + reconnect_timeout_ms, + ) = match config { + SerialConfigCli::Pipe(path) | SerialConfigCli::ConnectPipe(path) => { + let path = canonical_microvm_console_path(path)?; + let identity = path + .to_str() + .context("microVM console endpoint path is not valid UTF-8")? + .to_owned(); + #[cfg(windows)] + let (backend_kind, identity_kind) = { + let name = path + .to_str() + .and_then(|path| path.strip_prefix("//./pipe/openvmm-microvm-")); + anyhow::ensure!( + name.is_some_and(|name| { + !name.is_empty() + && name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') + }) + }), + "Windows microVM console pipes must use //./pipe/openvmm-microvm-" + ); + (VirtioConsoleBackendKind::NamedPipe, "named-pipe") + }; + #[cfg(not(windows))] + let (backend_kind, identity_kind) = + (VirtioConsoleBackendKind::UnixSocket, "unix-socket"); + let is_client = matches!(config, SerialConfigCli::ConnectPipe(_)); + ( + if is_client { + SerialConfigCli::ConnectPipe(path) + } else { + SerialConfigCli::Pipe(path) + }, + backend_kind, + identity_kind, + identity, + if is_client { + VirtioConsoleAttachmentMode::Connect + } else { + VirtioConsoleAttachmentMode::Listen + }, + if is_client { + VirtioConsoleReconnectPolicy::ReconnectClient + } else { + VirtioConsoleReconnectPolicy::RecreateListener + }, + if is_client { + "reconnect-client" + } else { + "recreate-listener" + }, + is_client, + if is_client { + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS + } else { + 0 + }, + ) + } + SerialConfigCli::Tcp(address) | SerialConfigCli::ConnectTcp(address) => { + anyhow::ensure!( + address.port() != 0, + "microVM console TCP endpoint requires a nonzero stable port" + ); + anyhow::ensure!( + address.ip().is_loopback(), + "microVM console TCP endpoints must use a loopback address" + ); + let is_client = matches!(config, SerialConfigCli::ConnectTcp(_)); + ( + if is_client { + SerialConfigCli::ConnectTcp(*address) + } else { + SerialConfigCli::Tcp(*address) + }, + VirtioConsoleBackendKind::Tcp, + "tcp", + address.to_string(), + if is_client { + VirtioConsoleAttachmentMode::Connect + } else { + VirtioConsoleAttachmentMode::Listen + }, + if is_client { + VirtioConsoleReconnectPolicy::ReconnectClient + } else { + VirtioConsoleReconnectPolicy::RecreateListener + }, + if is_client { + "reconnect-client" + } else { + "recreate-listener" + }, + is_client, + if is_client { + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS + } else { + 0 + }, + ) + } + SerialConfigCli::Console => ( + SerialConfigCli::Console, + VirtioConsoleBackendKind::Inherited, + "provider", + "console".to_owned(), + VirtioConsoleAttachmentMode::Inherited, + VirtioConsoleReconnectPolicy::RequireInheritedAttachment, + "require-inherited-attachment", + true, + 0, + ), + SerialConfigCli::None => ( + SerialConfigCli::None, + VirtioConsoleBackendKind::Disconnected, + "disconnected", + "discard".to_owned(), + VirtioConsoleAttachmentMode::Inherited, + VirtioConsoleReconnectPolicy::DiscardWhileDisconnected, + "discard-while-disconnected", + false, + 0, + ), + _ => anyhow::bail!( + "microVM virtio-console requires listen=..., connect=..., console, or none" + ), + }; + anyhow::ensure!( + !endpoint_identity.is_empty() && endpoint_identity.len() <= 4096, + "microVM console endpoint identity is empty or exceeds 4096 bytes" + ); + + let attachment = VirtioConsoleAttachment { + stable_id: stable_id.to_owned(), + backend_kind, + mode, + endpoint_identity: endpoint_identity.clone(), + reconnect_policy, + required, + reconnect_timeout_ms, + }; + let snapshot_attachment = openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: stable_id.to_owned(), + kind: attachment_kind.to_owned(), + required, + reconnect_policy: reconnect_policy_name.to_owned(), + identity_kind: identity_kind.to_owned(), + identity: endpoint_identity.into_bytes(), + length: 0, + reconnect_timeout_ms, + }; + Ok((effective, attachment, snapshot_attachment)) +} + +pub(crate) fn microvm_console_attachment_from_cli( + config: &SerialConfigCli, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + microvm_console_attachment_from_cli_with_identity( + config, + MICROVM_CONSOLE_STABLE_ID, + MICROVM_CONSOLE_ATTACHMENT_KIND, + ) +} + +pub(crate) fn validate_microvm_console_attachment_namespace( + attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + snapshot_dir: &Path, +) -> anyhow::Result<()> { + match attachment.identity_kind.as_str() { + "unix-socket" => { + let identity = std::str::from_utf8(&attachment.identity) + .context("microVM console socket identity is not valid UTF-8")?; + let endpoint = Path::new(identity); + let endpoint_parent = endpoint + .parent() + .context("microVM console socket identity has no parent")?; + let snapshot_parent = snapshot_dir + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let snapshot_parent = fs_err::canonicalize(snapshot_parent).with_context(|| { + format!( + "failed to canonicalize snapshot parent {}", + snapshot_parent.display() + ) + })?; + anyhow::ensure!( + endpoint_parent == snapshot_parent, + "microVM console socket must be inside the snapshot parent namespace {}", + snapshot_parent.display() + ); + } + "named-pipe" => { + let identity = std::str::from_utf8(&attachment.identity) + .context("microVM console pipe identity is not valid UTF-8")?; + let name = identity + .strip_prefix("//./pipe/openvmm-microvm-") + .filter(|name| !name.is_empty() && !name.contains(['/', '\\'])); + anyhow::ensure!( + name.is_some(), + "microVM console pipe is outside //./pipe/openvmm-microvm-" + ); + } + "tcp" => {} + "provider" => {} + "disconnected" => {} + kind => anyhow::bail!("unsupported microVM console identity kind '{kind}'"), + } + Ok(()) +} + +pub(crate) fn microvm_console_attachment_from_snapshot( + attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + requested: Option<&SerialConfigCli>, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + anyhow::ensure!( + attachment.stable_id == MICROVM_CONSOLE_STABLE_ID + && attachment.kind == MICROVM_CONSOLE_ATTACHMENT_KIND + && attachment.length == 0, + "snapshot has an invalid microVM console attachment" + ); + let identity = std::str::from_utf8(&attachment.identity) + .context("snapshot microVM console identity is not valid UTF-8")?; + if attachment.reconnect_policy == "reconnect-client" { + anyhow::ensure!( + requested.is_some(), + "snapshot requires an explicitly approved restore-time client attachment" + ); + } + let config = match ( + attachment.reconnect_policy.as_str(), + attachment.identity_kind.as_str(), + ) { + ("recreate-listener", "unix-socket" | "named-pipe") => { + SerialConfigCli::Pipe(PathBuf::from(identity)) + } + ("recreate-listener", "tcp") => SerialConfigCli::Tcp( + identity + .parse() + .context("snapshot microVM console TCP identity is invalid")?, + ), + ("reconnect-client", "unix-socket" | "named-pipe") => { + SerialConfigCli::ConnectPipe(PathBuf::from(identity)) + } + ("reconnect-client", "tcp") => SerialConfigCli::ConnectTcp( + identity + .parse() + .context("snapshot microVM console TCP identity is invalid")?, + ), + ("require-inherited-attachment", "provider") => requested + .cloned() + .context("snapshot requires --virtio-console console as a replacement attachment")?, + ("discard-while-disconnected", "disconnected") => SerialConfigCli::None, + (policy, kind) => anyhow::bail!( + "snapshot microVM console policy '{policy}' and backend kind '{kind}' are unsupported" + ), + }; + let reconstructed = microvm_console_attachment_from_cli(&config)?; + anyhow::ensure!( + reconstructed.2 == *attachment, + "snapshot microVM console identity is not canonical" + ); + if let Some(requested) = requested { + let requested = microvm_console_attachment_from_cli(requested)?; + anyhow::ensure!( + requested.2 == *attachment, + "restore-time virtio-console does not match the snapshot attachment" + ); + } + + Ok(reconstructed) +} + +pub(super) fn effective_microvm_console( + requested: Option<&SerialConfigCli>, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, +) -> anyhow::Result< + Option<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )>, +> { + let Some(restore) = restore else { + return requested + .map(microvm_console_attachment_from_cli) + .transpose(); + }; + let has_console = restore + .devices + .iter() + .any(|device| device.stable_id == MICROVM_CONSOLE_STABLE_ID); + let saved_attachment = restore + .attachments + .iter() + .find(|attachment| attachment.stable_id == MICROVM_CONSOLE_STABLE_ID); + anyhow::ensure!( + has_console == saved_attachment.is_some(), + "snapshot microVM console device and attachment inventories disagree" + ); + let Some(saved_attachment) = saved_attachment else { + anyhow::ensure!( + requested.is_none(), + "a restore-time virtio-console cannot be added to a snapshot without one" + ); + return Ok(None); + }; + Ok(Some(microvm_console_attachment_from_snapshot( + saved_attachment, + requested, + )?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; + + fn socket_attachment(path: &Path) -> openvmm_helpers::snapshot::microvm::SnapshotAttachment { + openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: MICROVM_CONSOLE_STABLE_ID.to_owned(), + kind: "virtio-console".to_owned(), + required: false, + reconnect_policy: "recreate-listener".to_owned(), + identity_kind: "unix-socket".to_owned(), + identity: path.to_string_lossy().into_owned().into_bytes(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + #[test] + fn socket_attachment_must_stay_in_snapshot_parent() { + let allowed = tempfile::tempdir().unwrap(); + let allowed = fs_err::canonicalize(allowed.path()).unwrap(); + let snapshot = allowed.join("snapshot"); + let attachment = socket_attachment(&allowed.join("console.sock")); + validate_microvm_console_attachment_namespace(&attachment, &snapshot).unwrap(); + + let outside = tempfile::tempdir().unwrap(); + let outside = fs_err::canonicalize(outside.path()).unwrap(); + let attachment = socket_attachment(&outside.join("console.sock")); + assert!(validate_microvm_console_attachment_namespace(&attachment, &snapshot).is_err()); + } + + #[test] + fn named_pipe_attachment_uses_dedicated_namespace() { + let mut attachment = socket_attachment(Path::new("unused")); + attachment.identity_kind = "named-pipe".to_owned(); + attachment.identity = b"//./pipe/openvmm-microvm-console0".to_vec(); + validate_microvm_console_attachment_namespace(&attachment, Path::new("snapshot")).unwrap(); + + attachment.identity = b"//./pipe/unrelated".to_vec(); + assert!( + validate_microvm_console_attachment_namespace(&attachment, Path::new("snapshot")) + .is_err() + ); + } + + #[test] + fn client_attachment_is_required_and_bounded() { + let requested = SerialConfigCli::ConnectTcp("127.0.0.1:5555".parse().unwrap()); + let (_, resource, snapshot) = microvm_console_attachment_from_cli(&requested).unwrap(); + assert!(snapshot.required); + assert_eq!(snapshot.reconnect_policy, "reconnect-client"); + assert_eq!( + snapshot.reconnect_timeout_ms, + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS + ); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::ReconnectClient + ); + + assert!(microvm_console_attachment_from_snapshot(&snapshot, None).is_err()); + let (restored, _, restored_snapshot) = + microvm_console_attachment_from_snapshot(&snapshot, Some(&requested)).unwrap(); + assert!(matches!(restored, SerialConfigCli::ConnectTcp(_))); + assert_eq!(restored_snapshot, snapshot); + } + + #[test] + fn tcp_attachment_rejects_non_loopback_addresses() { + assert!( + microvm_console_attachment_from_cli(&SerialConfigCli::Tcp( + "0.0.0.0:5555".parse().unwrap() + )) + .is_err() + ); + assert!( + microvm_console_attachment_from_cli(&SerialConfigCli::ConnectTcp( + "192.0.2.1:5555".parse().unwrap() + )) + .is_err() + ); + } + + #[test] + fn inherited_attachment_requires_matching_replacement() { + let (_, _, snapshot) = + microvm_console_attachment_from_cli(&SerialConfigCli::Console).unwrap(); + assert!(snapshot.required); + assert_eq!(snapshot.reconnect_policy, "require-inherited-attachment"); + assert!(microvm_console_attachment_from_snapshot(&snapshot, None).is_err()); + assert!( + microvm_console_attachment_from_snapshot(&snapshot, Some(&SerialConfigCli::Console)) + .is_ok() + ); + assert!( + microvm_console_attachment_from_snapshot( + &snapshot, + Some(&SerialConfigCli::ConnectTcp( + "127.0.0.1:5555".parse().unwrap() + )) + ) + .is_err() + ); + } + + #[test] + fn disconnected_attachment_preserves_discard_policy() { + let (config, resource, snapshot) = + microvm_console_attachment_from_cli(&SerialConfigCli::None).unwrap(); + assert!(matches!(config, SerialConfigCli::None)); + assert!(!snapshot.required); + assert_eq!(snapshot.reconnect_policy, "discard-while-disconnected"); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::DiscardWhileDisconnected + ); + + let (restored, _, restored_snapshot) = + microvm_console_attachment_from_snapshot(&snapshot, None).unwrap(); + assert!(matches!(restored, SerialConfigCli::None)); + assert_eq!(restored_snapshot, snapshot); + } + + #[cfg(unix)] + #[test] + fn restore_bind_does_not_unlink_existing_socket() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("console.sock"); + let _existing = unix_socket::UnixListener::bind(&path).unwrap(); + assert!(crate::serial_io::connect::bind_serial_without_cleanup(&path).is_err()); + assert!(fs_err::symlink_metadata(&path).is_ok()); + } + + #[cfg(unix)] + #[test] + fn cleanup_guard_does_not_remove_replaced_socket() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("console.sock"); + let listener = unix_socket::UnixListener::bind(&path).unwrap(); + let cleanup = MicrovmConsoleSocketCleanup::new(path.clone()).unwrap(); + fs_err::remove_file(&path).unwrap(); + let replacement = unix_socket::UnixListener::bind(&path).unwrap(); + + assert!(cleanup.remove_if_owned().is_err()); + assert!(fs_err::symlink_metadata(&path).is_ok()); + drop((cleanup, replacement, listener)); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 237f3ce750b..d306d26f900 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -174,6 +174,7 @@ impl MicrovmLaunch { self.effective_command_line .as_deref() .context("microVM restore requires an effective command line")?, + self.resources.console_attachment.as_ref(), ))) } @@ -204,6 +205,7 @@ impl MicrovmLaunch { ), source_hypervisor, effective_command_line: self.effective_command_line, + resources: self.resources, snapshot_memory_file: self.snapshot_memory_file, } } diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 5996c3cb09f..17d224e2e8f 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -4,10 +4,12 @@ //! MicroVM machine profile support for the OpenVMM entry point. mod config; +mod console; mod launch; mod restore; pub(crate) use config::MicrovmConfigBuilder; +pub(crate) use console::MicrovmConsoleSocketCleanup; pub(crate) use launch::MicrovmLaunch; pub(crate) use restore::ExpectedRestoreContract; pub(crate) use restore::MicrovmRestore; @@ -15,6 +17,7 @@ pub(crate) use restore::prepare_restore; pub(crate) use restore::validate_restore_contract; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use openvmm_helpers::snapshot::microvm::SnapshotAttachment; /// Host-side microVM resources produced while building the VM configuration /// and consumed by the snapshot, restore, and teardown paths. @@ -22,4 +25,8 @@ use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; pub(crate) struct MicrovmResources { /// Guest-requested snapshot boundaries from the snapshot-request port. pub(crate) snapshot_requests: Option>, + /// Snapshot identity of the boot virtio-console endpoint. + pub(crate) console_attachment: Option, + /// Removes the boot console Unix socket on teardown. + pub(crate) console_socket_cleanup: Option, } diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 6d59dd26001..352d8f47611 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -7,6 +7,7 @@ use crate::Options; use crate::cli_args::microvm::MachineProfileCli; use anyhow::Context; use openvmm_helpers::snapshot::SnapshotManifest; +use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use openvmm_helpers::snapshot::microvm::SnapshotMachineContract; use openvmm_helpers::snapshot::restore::OpenedSnapshot; use std::time::Duration; @@ -88,8 +89,8 @@ pub(crate) fn prepare_restore( } /// The machine contract a microVM snapshot must match to be restored: the -/// hypervisor and the effective command line. -pub(crate) type ExpectedRestoreContract<'a> = (&'a str, &'a str); +/// hypervisor, effective command line, and boot console attachment. +pub(crate) type ExpectedRestoreContract<'a> = (&'a str, &'a str, Option<&'a SnapshotAttachment>); /// Clock and CPU state recorded at the capture boundary of a restored /// microVM: host downtime, TSC and APIC frequencies, and the CPU contract. @@ -101,7 +102,7 @@ pub(crate) fn validate_restore_contract( manifest: &SnapshotManifest, expected_memory_size: u64, expected_vp_count: u32, - (expected_hypervisor, effective_command_line): ExpectedRestoreContract<'_>, + (expected_hypervisor, effective_command_line, console_attachment): ExpectedRestoreContract<'_>, ) -> anyhow::Result { let saved_contract = manifest .machine_contract @@ -111,6 +112,7 @@ pub(crate) fn validate_restore_contract( expected_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, effective_command_line.to_owned(), + console_attachment.cloned(), expected_vp_count, expected_memory_size, saved_contract.state_unit_names.clone(), diff --git a/openvmm/openvmm_entry/src/serial_io/connect.rs b/openvmm/openvmm_entry/src/serial_io/connect.rs index 576a75ffbb4..9b9928fd7f9 100644 --- a/openvmm/openvmm_entry/src/serial_io/connect.rs +++ b/openvmm/openvmm_entry/src/serial_io/connect.rs @@ -1,8 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Serial endpoint helpers beyond the upstream ones: connecting to an existing -//! endpoint as a client within a bounded time. +//! Serial endpoint helpers beyond the upstream ones: binding a listener without +//! removing an existing socket path, and connecting to an existing endpoint as +//! a client within a bounded time. use anyhow::Context; use serial_socket::net::OpenSocketSerialConfig; @@ -10,10 +11,27 @@ use std::io; use std::net::SocketAddr; use std::net::TcpStream; use std::path::Path; +use unix_socket::UnixListener; use vm_resource::IntoResource; use vm_resource::Resource; use vm_resource::kind::SerialBackendHandle; +/// Binds a listener without removing an existing socket path. +pub(crate) fn bind_serial_without_cleanup( + path: &Path, +) -> io::Result> { + // `bind_serial` returns a named pipe before it would remove an existing + // socket path. + #[cfg(windows)] + { + if path.starts_with("//./pipe") { + return super::bind_serial(path); + } + } + + Ok(OpenSocketSerialConfig::from(UnixListener::bind(path)?).into_resource()) +} + pub(crate) fn connect_serial_with_timeout( path: &Path, timeout: std::time::Duration, diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 99b6af6f075..38d0c462535 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -4,6 +4,7 @@ //! MicroVM snapshot capture handling of the VM controller. use super::VmController; +use crate::microvm::MicrovmResources; use anyhow::Context; use mesh::rpc::RpcSend; use openvmm_defs::rpc::SnapshotQuiesceError; @@ -28,6 +29,8 @@ pub(crate) struct MicrovmController { pub(crate) source_hypervisor: String, /// Effective kernel command line of the MP-table boot. pub(crate) effective_command_line: Option, + /// Host attachments and cleanup guards of the microVM devices. + pub(crate) resources: MicrovmResources, /// Automatic RAM backing created for snapshot capture. pub(crate) snapshot_memory_file: Option, } @@ -142,6 +145,7 @@ impl VmController { &self.microvm.source_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, command_line, + self.microvm.resources.console_attachment.clone(), self.processors, self.memory, response.state_unit_names, @@ -217,6 +221,15 @@ impl VmController { match result { Ok(()) => { + if let Some(cleanup) = self.microvm.resources.console_socket_cleanup.take() + && let Err(error) = cleanup.remove_if_owned() + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "snapshot committed but the source console socket could not be removed" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } tracing::info!( path = %destination.display(), "microVM snapshot committed; terminating source process" @@ -227,6 +240,14 @@ impl VmController { let write_error = error.downcast_ref::(); if write_error.is_some_and(|error| error.is_committed()) { + if let Some(cleanup) = self.microvm.resources.console_socket_cleanup.take() + && let Err(cleanup_error) = cleanup.remove_if_owned() + { + tracing::error!( + error = cleanup_error.as_ref() as &dyn std::error::Error, + "committed snapshot console socket could not be removed" + ); + } tracing::error!( error = error.as_ref() as &dyn std::error::Error, path = %destination.display(), diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index c15b015ba7c..db2b70aeeff 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -22,6 +22,8 @@ const MAX_MEMORY_RANGES: usize = 128; const MAX_DEVICES: usize = 64; const MAX_DEVICE_RANGES: usize = 16; const MAX_STATE_UNITS: usize = 512; +const MAX_ATTACHMENTS: usize = 64; +const MAX_ATTACHMENT_IDENTITY_BYTES: usize = 4096; const MAX_COMMAND_LINE_BYTES: usize = 64 * 1024; const MAX_CPU_CONTRACT_BYTES: usize = 1024 * 1024; @@ -112,6 +114,36 @@ pub struct SnapshotDevice { pub queue_max_sizes: Vec, } +/// Stable identity for a host resource required by a device. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotAttachment { + /// Stable attachment ID. + #[mesh(1)] + pub stable_id: String, + /// Attachment kind. + #[mesh(2)] + pub kind: String, + /// Whether restore requires the attachment. + #[mesh(3)] + pub required: bool, + /// Reconnection policy. + #[mesh(4)] + pub reconnect_policy: String, + /// Immutable identity kind. + #[mesh(5)] + pub identity_kind: String, + /// Provider identity or SHA-256 bytes. + #[mesh(6)] + pub identity: Vec, + /// Attachment length when applicable. + #[mesh(7)] + pub length: u64, + /// Bounded reconnect timeout. Zero for non-client policies. + #[mesh(8)] + pub reconnect_timeout_ms: u64, +} + /// Machine composition that becomes authoritative after capture. #[derive(Clone, Debug, PartialEq, Eq, Protobuf)] #[mesh(package = "openvmm.snapshot")] @@ -143,6 +175,9 @@ pub struct SnapshotMachineContract { /// Complete state-unit inventory in stable order. #[mesh(9)] pub state_unit_names: Vec, + /// Required host attachments. + #[mesh(10)] + pub attachments: Vec, /// Host wall time at the stopped capture boundary. #[mesh(11)] pub capture_wall_clock: Timestamp, @@ -235,6 +270,7 @@ pub fn microvm_machine_contract( source_hypervisor: &str, boot_layout_version: u32, effective_command_line: String, + console_attachment: Option, processor_count: u32, memory_size: u64, state_unit_names: Vec, @@ -278,7 +314,7 @@ pub fn microvm_machine_contract( length, }; - let devices = vec![ + let mut devices = vec![ device("partition", "partition", "partition", Vec::new(), None, 0), device("vp0", "partition", "vcpu", Vec::new(), None, 1), device("vmtime", "vmtime", "clock", Vec::new(), None, 2), @@ -333,6 +369,69 @@ pub fn microvm_machine_contract( 10, ), ]; + let mut attachments = Vec::new(); + if let Some(attachment) = console_attachment { + let policy_is_valid = match attachment.reconnect_policy.as_str() { + "recreate-listener" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && matches!( + attachment.identity_kind.as_str(), + "unix-socket" | "named-pipe" | "tcp" + ) + } + "reconnect-client" => { + attachment.required + && attachment.reconnect_timeout_ms + == openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS + && matches!( + attachment.identity_kind.as_str(), + "unix-socket" | "named-pipe" | "tcp" + ) + } + "require-inherited-attachment" => { + attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.identity_kind == "provider" + && attachment.identity == b"console" + } + "discard-while-disconnected" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.identity_kind == "disconnected" + && attachment.identity == b"discard" + } + _ => false, + }; + anyhow::ensure!( + attachment.stable_id == "console:microvm-virtio0" + && attachment.kind == "virtio-console" + && policy_is_valid + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES + && attachment.length == 0, + "microVM console attachment has an unsupported reconnect policy" + ); + devices.push(SnapshotDevice { + stable_id: "console:microvm-virtio0".to_owned(), + state_unit_name: format!( + "virtio-console-{}", + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE + ), + kind: "virtio-console".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio( + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + )], + irq: Some(openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![0x3000_0001, 0x0000_0003], + queue_count: 2, + queue_max_sizes: vec![256, 256], + }); + attachments.push(attachment); + } let mut contract = SnapshotMachineContract { machine_profile: "microvm".to_owned(), @@ -344,6 +443,7 @@ pub fn microvm_machine_contract( topology, devices, state_unit_names, + attachments, capture_wall_clock, tsc_frequency_hz, tsc_tolerance_ppm: 0, @@ -447,6 +547,10 @@ pub fn validate_microvm_machine_contract( contract.state_unit_names == expected.state_unit_names, "snapshot state-unit inventory or order doesn't match the requested machine" ); + anyhow::ensure!( + contract.attachments == expected.attachments, + "snapshot attachment inventory doesn't match the supplied attachments" + ); anyhow::ensure!( contract.tsc_frequency_hz == expected.tsc_frequency_hz && contract.tsc_tolerance_ppm == expected.tsc_tolerance_ppm, @@ -637,6 +741,46 @@ pub(super) fn validate_machine_contract_shape( ); } + anyhow::ensure!( + contract.attachments.len() <= MAX_ATTACHMENTS, + "snapshot attachment inventory is too large" + ); + let mut attachment_ids = HashSet::new(); + for attachment in &contract.attachments { + anyhow::ensure!( + !attachment.stable_id.is_empty() + && attachment_ids.insert(attachment.stable_id.as_str()), + "snapshot contains an empty or duplicate attachment ID" + ); + anyhow::ensure!( + !attachment.kind.is_empty() + && !attachment.reconnect_policy.is_empty() + && !attachment.identity_kind.is_empty() + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES, + "snapshot attachment '{}' has an incomplete identity", + attachment.stable_id, + ); + anyhow::ensure!( + match attachment.reconnect_policy.as_str() { + "recreate-listener" => { + !attachment.required && attachment.reconnect_timeout_ms == 0 + } + "reconnect-client" => { + attachment.required && attachment.reconnect_timeout_ms != 0 + } + "require-inherited-attachment" => { + attachment.required && attachment.reconnect_timeout_ms == 0 + } + "discard-while-disconnected" => { + !attachment.required && attachment.reconnect_timeout_ms == 0 + } + _ => false, + }, + "snapshot attachment '{}' has an invalid reconnect policy", + attachment.stable_id, + ); + } Ok(()) } @@ -707,6 +851,7 @@ fn test_machine_contract() -> SnapshotMachineContract { }, ], state_unit_names: vec!["portb".to_owned(), "shutdown".to_owned()], + attachments: Vec::new(), capture_wall_clock: std::time::SystemTime::now().into(), tsc_frequency_hz: 1_000_000_000, tsc_tolerance_ppm: 0, @@ -729,6 +874,78 @@ mod tests { use super::super::tests::test_manifest; use super::*; + fn microvm_console_attachment() -> SnapshotAttachment { + SnapshotAttachment { + stable_id: "console:microvm-virtio0".to_owned(), + kind: "virtio-console".to_owned(), + required: false, + reconnect_policy: "recreate-listener".to_owned(), + identity_kind: "tcp".to_owned(), + identity: b"127.0.0.1:5555".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + fn generated_console_contract() -> SnapshotMachineContract { + microvm_machine_contract( + "whp", + MICROVM_BOOT_LAYOUT_VERSION, + "earlycon=xe9 console=hvc1 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0002000:7" + .to_owned(), + Some(microvm_console_attachment()), + 1, + 1024, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtio-console-3489669120", + ] + .map(str::to_owned) + .to_vec(), + std::time::SystemTime::now().into(), + 1_000_000_000, + Some(1_000_000_000), + vec![1, 2, 3], + ) + .unwrap() + } + + #[test] + fn generated_microvm_console_contract_has_fixed_abi() { + let contract = generated_console_contract(); + let console = contract.devices.last().unwrap(); + assert_eq!(console.stable_id, "console:microvm-virtio0"); + assert_eq!(console.state_unit_name, "virtio-console-3489669120"); + assert_eq!(console.ranges[0].start, 0xd000_2000); + assert_eq!(console.ranges[0].length, 0x1000); + assert_eq!(console.irq, Some(7)); + assert_eq!(console.transport, "virtio-mmio"); + assert_eq!(console.feature_banks, [0x3000_0001, 0x0000_0003]); + assert_eq!(console.queue_max_sizes, [256, 256]); + assert_eq!(contract.attachments, [microvm_console_attachment()]); + } + + #[test] + fn validate_microvm_console_contract_rejects_attachment_change() { + let contract = generated_console_contract(); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected.attachments[0].identity = b"127.0.0.1:6666".to_vec(); + let error = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(error.to_string().contains("attachment inventory")); + } + #[test] fn validate_microvm_machine_contract_ok() { let mut manifest = test_manifest(); From 085961760439a42b99aaa3f2d643cac1ef2a0494 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 07:35:03 -0700 Subject: [PATCH 115/304] openvmm: attach a portable Consomme NIC to microVMs The microVM profile has no network device. A guest that needs network access must get a fixed, reproducible NIC whose identity does not depend on the host. Add `--net ` for the microVM, together with the required `--network-profile portable`: - `MicrovmNetworkConfig` parses the guest address and prefix (/1 through /30). The first usable subnet address becomes the gateway, and the network, broadcast, and gateway addresses cannot be the guest's. Guest and gateway MAC addresses are derived as 52:54:00::: from their IPv4 addresses. - The NIC sits at its fixed slot, MMIO 0xd0000000, on IRQ 10 for KVM and MSHV or IRQ 5 for WHP. It offers exactly the MAC and virtio 1.0 features, with split rings. - Its endpoint is an in-process Consomme NAT with the static identity, so networking needs no TAP, driver, or host configuration on any backend. - The host appends the guest-bootstrap tokens `virtnet_ip=`, `virtnet_mask=`, `virtnet_gw=`, and `virtnet_dns=` (the gateway) after device discovery. User arguments cannot supply these tokens, and machine validation checks their values and the NIC's discovery token. A microVM accepts at most one `--net`, which must be a bare IPv4/prefix without queue, VTL, Underhill, or PCIe modifiers. The standard machine rejects a bare IPv4/prefix and `--network-profile`. Snapshot capture does not yet support the NIC, and restore rejects `--net`, because a restored NIC takes its address from the snapshot. Document the option. Tests cover the network identity and the CLI rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 34 ++- .../src/worker/dispatch/microvm.rs | 10 +- openvmm/openvmm_defs/src/microvm.rs | 258 +++++++++++++++++- openvmm/openvmm_entry/src/cli_args.rs | 2 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 121 +++++++- openvmm/openvmm_entry/src/lib.rs | 11 + openvmm/openvmm_entry/src/microvm/config.rs | 62 ++++- openvmm/openvmm_entry/src/microvm/mod.rs | 1 + openvmm/openvmm_entry/src/microvm/network.rs | 52 ++++ 9 files changed, 534 insertions(+), 17 deletions(-) create mode 100644 openvmm/openvmm_entry/src/microvm/network.rs diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 10f5e81251e..4b09df80458 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -45,12 +45,13 @@ describes the source definitions. anchored to UTC, the microVM portb console, lifecycle ports, and the optional fixed virtio devices described below. User arguments cannot override `earlycon=`, - `console=`, `virtio_mmio.device=`, or `nr_cpus=`. + `console=`, `virtio_mmio.device=`, `nr_cpus=`, or `virtnet_*=`. - One optional `--virtio-console ` is exposed at MMIO `0xd0002000`, - IRQ 7 as the boot/log console (`hvc1`) and uses split rings. Firmware, ACPI, + One optional `--net ` NIC is exposed at MMIO `0xd0000000`, and + one optional `--virtio-console ` is exposed at MMIO `0xd0002000`, + IRQ 7 as the boot/log console (`hvc1`). Both use split rings. Firmware, ACPI, SMBIOS, PCI, - VMBus, UARTs, storage, networking, graphics, isolation, nested + VMBus, UARTs, storage, graphics, isolation, nested virtualization, and other devices are rejected. Linux discovers contiguous APIC IDs and the IOAPIC from Intel MP 1.4 tables at `0x0` and `0x400`; `boot_params` is at `0x2000`, the command @@ -61,6 +62,31 @@ describes the source definitions. with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. Guest-requested snapshot capture and new-process restore are available on Linux/KVM, Linux/MSHV, and Windows/WHP. +* `--net `: With `--machine microvm`, attach one virtio-net NIC + at MMIO `0xd0000000`. KVM and MSHV use IRQ 10; WHP uses IRQ 5. Prefixes + `/1` through `/30` are accepted. The first usable subnet address becomes + the gateway; network, broadcast, and gateway addresses cannot be assigned + to the guest. Guest and gateway MAC addresses are derived as + `52:54:00:::` from their IPv4 addresses. Networking + requires the only supported capability profile, `--network-profile portable`; + omitting it rejects the command before OpenVMM opens host resources. + + ```bash + openvmm --machine microvm --hypervisor whp \ + --kernel vmlinux --initrd initramfs.cpio.gz \ + --net 10.0.0.2/24 --network-profile portable + ``` + + `portable` uses an in-process Consomme endpoint on Linux/KVM, Linux/MSHV, + and Windows/WHP. It needs no TAP, root access, driver, or host network + configuration. The gateway provides DNS over UDP and + TCP, ICMP echo, and outbound TCP/UDP through ordinary host sockets. Consomme + rejects IPv4 fragments deterministically. Its per-connection TCP buffers start at 16 KiB and + are bounded at 4 MiB; UDP bindings expire after five minutes; and at most + 256 DNS requests are pending at once. At most 128 TCP, 256 UDP, and 16 ICMP + guest flows are active at once; excess flows are deterministically rejected + before a host socket is created. Snapshot capture does not yet support a + microVM NIC. * `--snapshot-destination `: Publish a microVM snapshot when the guest writes to PMIO port `0x605`. The destination must not exist and its parent must already be a directory. OpenVMM automatically creates file-backed RAM diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index dc9b8856f80..165e62af550 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -433,6 +433,10 @@ fn virtio_mmio_config( chipset_mmio: ChipsetMmioRanges, ) -> anyhow::Result<(u64, u64, u32, u64, VirtioMmioInterruptMode)> { let (start, irq) = match id { + "virtio-net" => ( + openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE, + openvmm_defs::microvm::microvm_virtio_net_irq(None)?, + ), "virtio-console" => ( openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ, @@ -447,8 +451,10 @@ fn virtio_mmio_config( .is_some_and(|end| end <= chipset_mmio.low.end()), "microVM virtio slot for '{id}' is outside the fixed low-MMIO aperture" ); - // The fixed slots expose split rings only. - let disabled_features = 1 << 34; + let disabled_features = match id { + "virtio-net" => !openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES, + _ => 1 << 34, + }; let interrupt_mode = VirtioMmioInterruptMode::SharedStatus { status_gpa: openvmm_defs::microvm::microvm_virtio_status_gpa(start) .context("microVM slot has no shared-status word")?, diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index da24c0c6a9c..52f39455fe2 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -16,6 +16,7 @@ use crate::config::X2ApicConfig; use crate::config::X86TopologyConfig; use guid::Guid; use mesh::MeshPayload; +use net_backend_resources::mac_address::MacAddress; use std::fmt::Write as _; use vmotherboard::options::BaseChipsetManifest; @@ -65,6 +66,8 @@ pub const MICROVM_VIRTIO_FS_IRQ: u32 = 6; pub const MICROVM_VIRTIO_NET_KVM_IRQ: u32 = 10; /// Fixed microVM virtio-net interrupt on WHP. pub const MICROVM_VIRTIO_NET_WHP_IRQ: u32 = 5; +/// Exact microVM virtio-net feature mask: MAC and virtio version 1. +pub const MICROVM_VIRTIO_NET_FEATURES: u64 = (1 << 5) | (1 << 32); /// MicroVM client console reconnect timeout. pub const MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS: u64 = 5_000; /// MicroVM virtio MMIO reservations in stable device order. @@ -124,6 +127,148 @@ pub const fn microvm_virtio_status_gpa(mmio_base: u64) -> Option { /// The microVM publishes no level-triggered virtio IRQs in its MP table. pub const MICROVM_LEVEL_TRIGGERED_IRQS: [u32; 0] = []; +/// Static guest-visible network identity for the microVM NIC. +#[derive(MeshPayload, Clone, Debug, PartialEq, Eq)] +pub struct MicrovmNetworkConfig { + /// Required cross-platform host-network implementation contract. + pub profile: MicrovmNetworkProfile, + pub guest_ipv4: std::net::Ipv4Addr, + pub prefix_length: u8, + pub derived_gateway_ipv4: std::net::Ipv4Addr, + pub guest_mac: MacAddress, + pub gateway_mac: MacAddress, +} + +/// Required host-network implementation contract for a microVM NIC. +/// +/// Profiles are explicit so snapshots never silently acquire different host +/// networking semantics on another supported hypervisor. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq)] +pub enum MicrovmNetworkProfile { + /// User-mode Consomme NAT on every supported host backend. + Portable, +} + +impl MicrovmNetworkProfile { + /// Returns the stable command-line and snapshot spelling of this profile. + pub const fn as_str(self) -> &'static str { + match self { + Self::Portable => "portable", + } + } +} + +impl MicrovmNetworkConfig { + /// Returns the subnet mask derived from `prefix_length`. + pub fn netmask(&self) -> std::net::Ipv4Addr { + std::net::Ipv4Addr::from(u32::MAX << (32 - self.prefix_length)) + } + + /// Returns the pinned NVX guest-bootstrap command-line tokens. + pub fn command_line_fragment(&self) -> String { + self.command_line_fragment_with_dns(false) + } + + /// Returns the pinned bootstrap tokens, optionally including gateway DNS. + pub fn command_line_fragment_with_dns(&self, gateway_dns: bool) -> String { + let dns = if gateway_dns { + format!(" virtnet_dns={}", self.derived_gateway_ipv4) + } else { + String::new() + }; + format!( + "virtnet_ip={} virtnet_mask={} virtnet_gw={}{}", + self.guest_ipv4, + self.netmask(), + self.derived_gateway_ipv4, + dns, + ) + } + + fn derive_mac(address: std::net::Ipv4Addr) -> MacAddress { + let [_, second, third, fourth] = address.octets(); + MacAddress::new([0x52, 0x54, 0x00, second, third, fourth]) + } +} + +/// Error returned for an invalid microVM static network specification. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum InvalidMicrovmNetworkConfig { + #[error("expected /, for example 10.0.0.2/24")] + InvalidFormat, + #[error("invalid IPv4 address '{0}'")] + InvalidAddress(String), + #[error("invalid IPv4 prefix '{0}'")] + InvalidPrefix(String), + #[error("IPv4 prefix /{0} is outside the supported range /1 through /30")] + PrefixOutOfRange(u8), + #[error("guest IPv4 address {0} is the subnet network address")] + NetworkAddress(std::net::Ipv4Addr), + #[error("guest IPv4 address {0} is the subnet broadcast address")] + BroadcastAddress(std::net::Ipv4Addr), + #[error("guest IPv4 address {0} collides with the derived gateway")] + GatewayCollision(std::net::Ipv4Addr), +} + +impl std::str::FromStr for MicrovmNetworkConfig { + type Err = InvalidMicrovmNetworkConfig; + + fn from_str(spec: &str) -> Result { + let (address, prefix) = spec + .split_once('/') + .filter(|(_, prefix)| !prefix.contains('/')) + .ok_or(InvalidMicrovmNetworkConfig::InvalidFormat)?; + let guest_ipv4 = address + .parse::() + .map_err(|_| InvalidMicrovmNetworkConfig::InvalidAddress(address.to_owned()))?; + let prefix_length = prefix + .parse::() + .map_err(|_| InvalidMicrovmNetworkConfig::InvalidPrefix(prefix.to_owned()))?; + if !(1..=30).contains(&prefix_length) { + return Err(InvalidMicrovmNetworkConfig::PrefixOutOfRange(prefix_length)); + } + + let mask = u32::MAX << (32 - prefix_length); + let guest = u32::from(guest_ipv4); + let network = guest & mask; + let broadcast = network | !mask; + if guest == network { + return Err(InvalidMicrovmNetworkConfig::NetworkAddress(guest_ipv4)); + } + if guest == broadcast { + return Err(InvalidMicrovmNetworkConfig::BroadcastAddress(guest_ipv4)); + } + + let derived_gateway_ipv4 = std::net::Ipv4Addr::from(network + 1); + if guest_ipv4 == derived_gateway_ipv4 { + return Err(InvalidMicrovmNetworkConfig::GatewayCollision(guest_ipv4)); + } + + Ok(Self { + profile: MicrovmNetworkProfile::Portable, + guest_ipv4, + prefix_length, + derived_gateway_ipv4, + guest_mac: Self::derive_mac(guest_ipv4), + gateway_mac: Self::derive_mac(derived_gateway_ipv4), + }) + } +} + +/// Returns the pinned virtio-net IRQ for the selected microVM backend. +pub fn microvm_virtio_net_irq(hypervisor_id: Option<&str>) -> anyhow::Result { + match hypervisor_id { + Some("kvm" | "mshv") => Ok(MICROVM_VIRTIO_NET_KVM_IRQ), + Some("whp") => Ok(MICROVM_VIRTIO_NET_WHP_IRQ), + Some(other) => anyhow::bail!("microVM virtio-net does not support hypervisor '{other}'"), + None if cfg!(target_os = "linux") => Ok(MICROVM_VIRTIO_NET_KVM_IRQ), + None if cfg!(windows) => Ok(MICROVM_VIRTIO_NET_WHP_IRQ), + None => { + anyhow::bail!("microVM virtio-net requires an explicit KVM, MSHV, or WHP hypervisor") + } + } +} + fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { let bases = &MICROVM_VIRTIO_MMIO_BASES; for (index, base) in bases.iter().copied().enumerate() { @@ -162,6 +307,7 @@ fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { /// Appends sandbox virtio devices in fixed-address order. pub fn append_microvm_virtio_discovery( cmdline: &mut String, + network: Option<(&MicrovmNetworkConfig, u32, bool)>, has_console: bool, ) -> anyhow::Result<()> { anyhow::ensure!( @@ -172,12 +318,29 @@ pub fn append_microvm_virtio_discovery( ); use std::fmt::Write as _; + if let Some((_, irq, _)) = network { + anyhow::ensure!( + matches!(irq, MICROVM_VIRTIO_NET_KVM_IRQ | MICROVM_VIRTIO_NET_WHP_IRQ), + "microVM virtio-net IRQ {irq} is not part of the fixed machine contract" + ); + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_NET_MMIO_BASE:#x}:{irq}" + )?; + } if has_console { write!( cmdline, " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONSOLE_IRQ}" )?; } + if let Some((network, _, gateway_dns)) = network { + write!( + cmdline, + " {}", + network.command_line_fragment_with_dns(gateway_dns) + )?; + } anyhow::ensure!( cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, "microVM kernel command line exceeds the 64-KiB ABI limit after device discovery" @@ -185,7 +348,10 @@ pub fn append_microvm_virtio_discovery( Ok(()) } -fn validate_microvm_command_line(config: &Config) -> anyhow::Result<()> { +fn validate_microvm_command_line( + config: &Config, + hypervisor_id: Option<&str>, +) -> anyhow::Result<()> { let MachineProfile::Microvm = config.machine_profile else { unreachable!("microVM command-line validation requires the microVM profile"); }; @@ -211,6 +377,14 @@ fn validate_microvm_command_line(config: &Config) -> anyhow::Result<()> { .virtio_devices .iter() .any(|(_, device)| device.id() == "virtio-console"); + let has_network = config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtio-net"); + anyhow::ensure!( + has_network == config.microvm.network.is_some(), + "microVM virtio-net device and static network identity must be configured together" + ); let base_tokens = if has_console { MICROVM_CONSOLE_COMMAND_LINE } else { @@ -222,13 +396,21 @@ fn validate_microvm_command_line(config: &Config) -> anyhow::Result<()> { tokens.starts_with(&base_tokens), "microVM command line does not begin with the ABI base tokens" ); - for prefix in ["earlycon=", "console=", "virtio_mmio.device="] { + for prefix in [ + "earlycon=", + "console=", + "virtio_mmio.device=", + "virtnet_ip=", + "virtnet_mask=", + "virtnet_gw=", + ] { let count = tokens .iter() .filter(|token| token.starts_with(prefix)) .count(); let expected = match prefix { "virtio_mmio.device=" => config.virtio_devices.len(), + "virtnet_ip=" | "virtnet_mask=" | "virtnet_gw=" => usize::from(has_network), _ => 1, }; anyhow::ensure!( @@ -236,12 +418,46 @@ fn validate_microvm_command_line(config: &Config) -> anyhow::Result<()> { "microVM command line has an invalid number of {prefix} tokens" ); } + let dns_tokens = tokens + .iter() + .filter(|token| token.starts_with("virtnet_dns=")) + .copied() + .collect::>(); + anyhow::ensure!( + dns_tokens.len() <= 1, + "microVM command line has an invalid number of virtnet_dns= tokens" + ); + if let Some(dns) = dns_tokens.first() { + let network = config + .microvm + .network + .as_ref() + .ok_or_else(|| anyhow::anyhow!("microVM DNS bootstrap requires virtio-net"))?; + anyhow::ensure!( + **dns == format!("virtnet_dns={}", network.derived_gateway_ipv4), + "microVM DNS bootstrap does not match the portable gateway" + ); + } let mut expected_discovery = Vec::new(); + if has_network { + let irq = microvm_virtio_net_irq(hypervisor_id)?; + expected_discovery.push(format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_NET_MMIO_BASE:#x}:{irq}" + )); + } if has_console { expected_discovery.push(format!( "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONSOLE_IRQ}" )); } + if let Some(network) = &config.microvm.network { + expected_discovery.extend( + network + .command_line_fragment_with_dns(!dns_tokens.is_empty()) + .split_ascii_whitespace() + .map(str::to_owned), + ); + } if !expected_discovery.is_empty() { anyhow::ensure!( tokens.ends_with( @@ -266,9 +482,18 @@ pub fn build_microvm_command_line( anyhow::bail!("microVM kernel command line contains an embedded NUL"); } if arg.split_ascii_whitespace().any(|token| { - ["earlycon=", "console=", "virtio_mmio.device=", "nr_cpus="] - .iter() - .any(|reserved| token.starts_with(reserved)) + [ + "earlycon=", + "console=", + "virtio_mmio.device=", + "nr_cpus=", + "virtnet_ip=", + "virtnet_mask=", + "virtnet_gw=", + "virtnet_dns=", + ] + .iter() + .any(|reserved| token.starts_with(reserved)) }) { anyhow::bail!( "microVM kernel command line cannot override profile-owned configuration" @@ -322,7 +547,10 @@ pub enum MachineProfile { /// VM configuration specific to the microVM machine profile /// ([`MachineProfile::Microvm`]), held in [`Config::microvm`]. #[derive(MeshPayload, Debug, Default)] -pub struct MicrovmConfig {} +pub struct MicrovmConfig { + /// Static identity for the optional microVM virtio-net device. + pub network: Option, +} fn validate_machine_load_mode( machine_profile: MachineProfile, @@ -406,7 +634,7 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> }; validate_microvm_virtio_reservations()?; - validate_microvm_command_line(config)?; + validate_microvm_command_line(config, hypervisor_id)?; validate_machine_load_mode(config.machine_profile, &config.load_mode)?; if let Some(hypervisor_id) = hypervisor_id { anyhow::ensure!( @@ -525,6 +753,7 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> config.virtio_devices.len() <= 8, "microVM has too many virtio devices" ); + let mut has_network = false; let mut has_console = false; for (bus, device) in &config.virtio_devices { anyhow::ensure!( @@ -532,6 +761,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> "microVM permits only virtio-mmio devices" ); match device.id() { + "virtio-net" => anyhow::ensure!( + !std::mem::replace(&mut has_network, true), + "microVM permits only one virtio-net device" + ), "virtio-console" => anyhow::ensure!( !std::mem::replace(&mut has_console, true), "microVM permits only one virtio-console device" @@ -539,6 +772,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> id => anyhow::bail!("microVM does not permit virtio device '{id}'"), } } + anyhow::ensure!( + has_network == config.microvm.network.is_some(), + "microVM virtio-net device and static network identity must be configured together" + ); anyhow::ensure!( config.layout.chipset_low_mmio_size == 1024 * 1024 * 1024 && config.layout.chipset_high_mmio_size == 0 @@ -670,6 +907,13 @@ mod tests { assert!(build_microvm_command_line(&["nr_cpus=1".to_owned()], false).is_err()); } + #[test] + fn microvm_network_identity_has_portable_profile() { + let network: MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + assert_eq!(network.profile, MicrovmNetworkProfile::Portable); + assert_eq!(network.profile.as_str(), "portable"); + } + #[test] fn microvm_block_irqs_avoid_rtc_and_are_edge_triggered() { assert_eq!(MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, 3); diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index c9dc55e62c7..7d43cab94e9 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -2952,6 +2952,7 @@ pub enum EndpointConfigCli { Tap { name: String, }, + Microvm(openvmm_defs::microvm::MicrovmNetworkConfig), } /// Parsed host port forwarding configuration from the CLI. @@ -3072,6 +3073,7 @@ impl FromStr for EndpointConfigCli { ["tap", name] => EndpointConfigCli::Tap { name: (*name).to_owned(), }, + [network] if network.contains('/') => microvm::parse_endpoint(network)?, _ => return Err("invalid network backend".into()), }; diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index cf6f110daba..d1cb2f611ce 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -3,10 +3,12 @@ //! Command-line options of the microVM machine profile. +use super::EndpointConfigCli; use super::Options; use super::SerialConfigCli; use super::SmtConfigCli; use clap::ValueEnum; +use openvmm_defs::config::DeviceVtl; use openvmm_defs::config::X2ApicConfig; use openvmm_defs::microvm::MachineProfile; use std::path::PathBuf; @@ -20,6 +22,13 @@ pub enum MachineProfileCli { Microvm, } +/// Required host-network implementation contract for a microVM NIC. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MicrovmNetworkProfileCli { + /// Use the cross-platform user-mode Consomme NAT implementation. + Portable, +} + impl From for MachineProfile { fn from(value: MachineProfileCli) -> Self { match value { @@ -39,11 +48,28 @@ pub struct MicrovmCli { /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. #[clap(long, value_name = "MILLISECONDS", default_value_t = 5000)] pub snapshot_quiesce_timeout_ms: u64, + + /// Required host-network implementation contract for microVM `--net`. + #[clap(long, value_enum, value_name = "PROFILE")] + pub network_profile: Option, +} + +/// Parses a bare `/` `--net` endpoint into the microVM network +/// configuration. +pub(super) fn parse_endpoint(network: &str) -> Result { + network + .parse() + .map(EndpointConfigCli::Microvm) + .map_err(|error| format!("invalid microVM network: {error}")) } impl Options { pub(crate) fn validate_microvm_options(&self) -> anyhow::Result<()> { if self.machine != MachineProfileCli::Microvm { + anyhow::ensure!( + self.microvm.network_profile.is_none(), + "--network-profile requires a microVM machine" + ); return Ok(()); } @@ -80,6 +106,16 @@ impl Options { self.microvm.snapshot_quiesce_timeout_ms != 0, "microVM snapshot quiesce timeout must be nonzero" ); + anyhow::ensure!( + self.net.is_empty(), + "microVM snapshot capture does not yet support --net" + ); + } + if self.restore_snapshot.is_some() { + anyhow::ensure!( + self.net.is_empty(), + "microVM restore takes network addressing from saved state; do not pass --net" + ); } anyhow::ensure!( self.uefi.is_none() && !self.pcat && self.igvm.is_none() && !self.device_tree, @@ -203,8 +239,27 @@ impl Options { "microVM does not expose legacy NIC, MANA, graphics, TPM, watchdog, IMC, battery, or VMGS devices" ); anyhow::ensure!( - self.net.is_empty(), - "microVM does not expose a network device" + self.net.len() <= 1, + "microVM permits at most one virtio-net device" + ); + anyhow::ensure!( + self.net.is_empty() + || self.microvm.network_profile == Some(MicrovmNetworkProfileCli::Portable), + "microVM --net requires --network-profile portable" + ); + anyhow::ensure!( + self.microvm.network_profile.is_none() || !self.net.is_empty(), + "--network-profile portable requires --net" + ); + anyhow::ensure!( + self.net.iter().all(|network| { + matches!(network.endpoint, EndpointConfigCli::Microvm(_)) + && network.vtl == DeviceVtl::Vtl0 + && network.max_queues.is_none() + && !network.underhill + && network.pcie_port.is_none() + }), + "microVM --net requires a bare IPv4/prefix and does not permit queue, VTL, Underhill, or PCIe modifiers" ); anyhow::ensure!( self.cxl_test.is_empty() @@ -370,6 +425,66 @@ mod tests { } } + #[test] + fn test_microvm_network_options() { + let network = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + ]) + .unwrap(); + network.validate_microvm_options().unwrap(); + assert!(matches!( + &network.net[0].endpoint, + EndpointConfigCli::Microvm(config) if config.prefix_length == 24 + )); + + for args in [ + vec!["openvmm", "--machine", "microvm", "--net", "10.0.0.2/24"], + vec![ + "openvmm", + "--machine", + "microvm", + "--network-profile", + "portable", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--net", + "10.0.1.2/24", + "--network-profile", + "portable", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--snapshot-destination", + "snapshot", + ], + vec!["openvmm", "--network-profile", "portable"], + ] { + let options = Options::try_parse_from(args).unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + assert!( + Options::try_parse_from(["openvmm", "--machine", "microvm", "--net", "10.0.0.0/24"]) + .is_err() + ); + } + #[test] fn test_microvm_command_line_is_owned_and_bounded() { assert_eq!( @@ -386,7 +501,7 @@ mod tests { ); let mut with_devices = build_microvm_command_line(&[], true).unwrap(); - append_microvm_virtio_discovery(&mut with_devices, true).unwrap(); + append_microvm_virtio_discovery(&mut with_devices, None, true).unwrap(); assert_eq!( with_devices, format!("{MICROVM_CONSOLE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0002000:7") diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 75279a65287..95a29d40f51 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -796,6 +796,9 @@ async fn vm_config_from_command_line( let mut nic_index = 0; for cli_cfg in &opt.net { + if microvm.is_active() { + continue; + } if cli_cfg.pcie_port.is_some() { anyhow::bail!("`--net` does not support PCIe"); } @@ -1894,6 +1897,11 @@ async fn vm_config_from_command_line( }), }; + microvm.add_virtio_devices( + &mut |bus, resource| add_virtio_device(bus, resource, &mut pcie_devices), + &mut resources, + )?; + for cli_cfg in &opt.virtio_net { if cli_cfg.underhill { anyhow::bail!("use --net uh:[...] to add underhill NICs") @@ -2434,6 +2442,9 @@ fn parse_endpoint( bail!("TAP backend is only supported on Linux") } } + EndpointConfigCli::Microvm(_) => { + bail!("a bare IPv4/prefix --net is only supported by the microVM profile") + } }; // Pick a random MAC address. diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 3026f09330b..d84c8a5e528 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -9,10 +9,14 @@ use super::console::ConsoleEndpoint; use super::console::effective_microvm_console; use super::console::microvm_console_socket_cleanup; use super::console::validate_microvm_console_attachment_namespace; +use super::network::EffectiveMicrovmNetwork; +use super::network::effective_microvm_network; +use super::network::microvm_network_endpoint; use crate::ConsoleState; use crate::Options; use crate::VmResources; use crate::cli_args::SerialConfigCli; +use crate::cli_args::VirtioBusCli; use crate::cli_args::microvm::MachineProfileCli; use crate::serial_io; use anyhow::Context; @@ -23,6 +27,7 @@ use chipset_resources::microvm::MicrovmSnapshotRequestHandle; use futures::AsyncReadExt; use futures::executor::block_on; use futures::io::AllowStdIo; +use net_backend_resources::consomme::static_ipv4::StaticIpv4Config; use openvmm_defs::config::Config; use openvmm_defs::config::LoadMode; use openvmm_defs::microvm::MachineProfile; @@ -39,6 +44,7 @@ use vm_resource::IntoResource; use vm_resource::Resource; use vm_resource::ResourceId; use vm_resource::kind::SerialBackendHandle; +use vm_resource::kind::VirtioDeviceHandle; use vmgs_resources::VmgsResource; use vmotherboard::ChipsetDeviceHandle; @@ -48,6 +54,8 @@ pub(crate) struct MicrovmConfigBuilder<'a> { opt: &'a Options, restore: &'a MicrovmRestore, active: bool, + network: Option, + gateway_dns: bool, console: Option, portb: Option>, resources: MicrovmResources, @@ -62,6 +70,13 @@ impl<'a> MicrovmConfigBuilder<'a> { openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; } opt.validate_microvm_options()?; + let network = if active { + effective_microvm_network(opt)? + } else { + None + }; + // Without an egress policy, the guest may use the gateway's DNS proxy. + let gateway_dns = network.is_some(); if active && (opt.com1.is_some() @@ -100,6 +115,8 @@ impl<'a> MicrovmConfigBuilder<'a> { opt, restore, active, + network, + gateway_dns, console, portb: None, resources, @@ -314,6 +331,36 @@ impl<'a> MicrovmConfigBuilder<'a> { } } + /// Adds the fixed-slot virtio-net device. + pub(crate) fn add_virtio_devices( + &mut self, + add_virtio_device: &mut impl FnMut(VirtioBusCli, Resource), + resources: &mut VmResources, + ) -> anyhow::Result<()> { + if let Some(network) = self.network.as_ref() { + let config = &network.config; + let endpoint = microvm_network_endpoint(config, resources)?; + add_virtio_device( + VirtioBusCli::Mmio, + virtio_resources::net::VirtioNetHandle { + max_queues: Some(1), + mac_address: config.guest_mac, + endpoint, + save_restore: true, + static_ipv4: Some(StaticIpv4Config { + guest_ipv4: config.guest_ipv4, + prefix_length: config.prefix_length, + gateway_ipv4: config.derived_gateway_ipv4, + gateway_mac: config.gateway_mac, + }), + effective_features: Some(openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES), + } + .into_resource(), + ); + } + Ok(()) + } + /// Builds the boot virtio-console device handle. pub(crate) fn virtio_console_handle( &self, @@ -359,6 +406,7 @@ impl<'a> MicrovmConfigBuilder<'a> { )); } } + cfg.microvm.network = self.network.as_ref().map(|network| network.config.clone()); let requested_hypervisor = opt .hypervisor @@ -379,7 +427,19 @@ impl<'a> MicrovmConfigBuilder<'a> { .virtio_devices .iter() .any(|(_, device)| device.id() == "virtio-console"); - openvmm_defs::microvm::append_microvm_virtio_discovery(cmdline, has_console)?; + let network_irq = cfg + .microvm + .network + .as_ref() + .map(|_| openvmm_defs::microvm::microvm_virtio_net_irq(requested_hypervisor)) + .transpose()?; + let network = cfg + .microvm + .network + .as_ref() + .zip(network_irq) + .map(|(network, irq)| (network, irq, self.gateway_dns)); + openvmm_defs::microvm::append_microvm_virtio_discovery(cmdline, network, has_console)?; } openvmm_defs::microvm::validate_machine_config(cfg, requested_hypervisor)?; diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 17d224e2e8f..c1d164f2f15 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -6,6 +6,7 @@ mod config; mod console; mod launch; +mod network; mod restore; pub(crate) use config::MicrovmConfigBuilder; diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs new file mode 100644 index 00000000000..acc0b3b9b05 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM static network identity. + +use crate::Options; +use crate::VmResources; +use crate::cli_args::EndpointConfigCli; +use net_backend_resources::consomme::static_ipv4::StaticIpv4Config; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::kind::NetEndpointHandleKind; + +#[derive(Clone)] +pub(super) struct EffectiveMicrovmNetwork { + pub(super) config: openvmm_defs::microvm::MicrovmNetworkConfig, +} + +pub(super) fn effective_microvm_network( + opt: &Options, +) -> anyhow::Result> { + let requested = match opt.net.as_slice() { + [] => None, + [network] => match &network.endpoint { + EndpointConfigCli::Microvm(config) => Some(config.clone()), + _ => anyhow::bail!("microVM --net was not validated as a static IPv4 identity"), + }, + _ => anyhow::bail!("microVM permits at most one virtio-net device"), + }; + Ok(requested.map(|config| EffectiveMicrovmNetwork { config })) +} + +pub(super) fn microvm_network_endpoint( + network: &openvmm_defs::microvm::MicrovmNetworkConfig, + _resources: &mut VmResources, +) -> anyhow::Result> { + Ok(net_backend_resources::consomme::ConsommeHandle { + cidr: None, + static_ipv4: Some(StaticIpv4Config { + guest_ipv4: network.guest_ipv4, + prefix_length: network.prefix_length, + gateway_ipv4: network.derived_gateway_ipv4, + gateway_mac: network.gateway_mac, + }), + ports: Vec::new(), + recv: None, + allow_host_local_access: None, + map_gateway_to_host_loopback: None, + gateway_loopback_proxy_port: None, + } + .into_resource()) +} From fd2ac51335a0743440cc4757cd8156cb62823d10 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 07:47:26 -0700 Subject: [PATCH 116/304] openvmm: persist the microVM NIC and restore a fresh endpoint generation Capture rejects a microVM with a NIC, because the machine contract does not describe the NIC and restore has no way to rebuild it. Record the NIC in the machine contract: - The new `SnapshotMicrovmNetwork` (contract field 18) holds the profile and the static identity: the guest and gateway addresses, the prefix, and both MAC addresses. Shape validation requires the portable profile and a canonical identity, in which the gateway and the MAC addresses derive from the guest address. - `microvm_machine_contract` adds the virtio-net device at its fixed slot with the backend's IRQ, its exact feature banks, and two 256-entry queues. It also requires the effective command line to contain the NIC's discovery and bootstrap tokens. - The host side of the NIC is a `recreate-endpoint` attachment of the Consomme user-mode NAT, because native sockets and NAT flow tables are not saved. On restore, the entry point takes the network identity from the snapshot instead of `--net`, requires `--network-profile portable`, and creates a fresh Consomme endpoint. The virtio-net saved state already drains owned packets at the capture boundary and advances the endpoint generation on restore. A restore cannot add network options to a snapshot without a NIC. Capture now accepts `--net`. Document NIC persistence and its restore requirements. Tests cover the network contract, identity validation, and the restore rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/dev_guide/snapshot_format.md | 5 +- Guide/src/reference/openvmm/management/cli.md | 19 +- Guide/src/user_guide/openvmm/snapshots.md | 2 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 21 +- openvmm/openvmm_entry/src/microvm/config.rs | 3 +- openvmm/openvmm_entry/src/microvm/launch.rs | 10 +- openvmm/openvmm_entry/src/microvm/mod.rs | 2 + openvmm/openvmm_entry/src/microvm/network.rs | 184 +++++++++++- openvmm/openvmm_entry/src/microvm/restore.rs | 18 +- .../src/vm_controller/microvm.rs | 8 + .../openvmm_helpers/src/snapshot/microvm.rs | 283 ++++++++++++++++++ 11 files changed, 526 insertions(+), 29 deletions(-) diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 75f07ac4435..6f316cf4758 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -150,8 +150,9 @@ Key unsupported categories: - **Virtio devices** — the `VirtioDevice` trait defaults `supports_save_restore()` to `false`. `virtio-blk`, `virtio-console`, `virtio-pmem`, and `virtio-rng` override it to `true`. `virtio-net` enables - it only for resources with an explicit static identity and feature contract; - ordinary virtio-net resources remain disabled. + it only for resources with an explicit static identity and feature contract, + such as the microVM NIC; ordinary virtio-net resources remain + disabled. The transport stores an opaque typed device-private payload in addition to common queue state. Devices with unsupported host-side session state (`virtio-9p`, `virtiofs`) leave save/restore disabled. diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 4b09df80458..c8a0559fa6f 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -85,8 +85,15 @@ describes the source definitions. are bounded at 4 MiB; UDP bindings expire after five minutes; and at most 256 DNS requests are pending at once. At most 128 TCP, 256 UDP, and 16 ICMP guest flows are active at once; excess flows are deterministically rejected - before a host socket is created. Snapshot capture does not yet support a - microVM NIC. + before a host socket is created. + + Networked snapshots record the `portable` profile, drain accepted TX and + endpoint-ready RX at the capture boundary, rewind unused guest RX descriptors, + and recreate a fresh Consomme endpoint generation on restore. + Restore of a networked snapshot requires `--network-profile portable`. Native + sockets and NAT flow tables are not serialized. The capture + protocol does not retain pre-capture endpoint completions; restored guest + software must establish new host-side flows. * `--snapshot-destination `: Publish a microVM snapshot when the guest writes to PMIO port `0x605`. The destination must not exist and its parent must already be a directory. OpenVMM automatically creates file-backed RAM @@ -100,7 +107,9 @@ describes the source definitions. ignored and the guest continues. Capture requires 1, 2, 4, or 8 vCPUs, KVM, MSHV, or WHP, and shared file-backed RAM. An attached virtio console saves accepted but undelivered input and the offset of a partially forwarded - guest transmit descriptor. + guest transmit descriptor. An attached microVM virtio-net device saves its + static identity, queue progress, drained packet ownership, and endpoint + generation. ```bash openvmm --machine microvm --hypervisor kvm --memory 128M \ @@ -122,6 +131,10 @@ describes the source definitions. A listener peer may connect after restore; guest transmit descriptors remain pending while no peer is connected. + When the snapshot contains virtio-net, restore also requires + `--network-profile portable`; the snapshot's profile must match the supplied + portable configuration. + ```bash openvmm --machine microvm --hypervisor kvm \ --restore-snapshot snapshot diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 65d21cffa68..69252e92943 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -233,7 +233,7 @@ OpenVMM snapshots: | VMBus Keyboard / Mouse / Video | VMBus | Yes | | Guest Emulation Log | VMBus | Yes | | virtio-blk | Virtio (PCI/MMIO) | Yes | -| virtio-net | Virtio (PCI/MMIO) | Static identity only | +| virtio-net | Virtio (PCI/MMIO) | microVM only | | virtio-pmem | Virtio (PCI/MMIO) | Yes | | virtio-rng | Virtio (PCI/MMIO) | Yes | | virtio-console | Virtio (PCI/MMIO) | Yes | diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index d1cb2f611ce..4a03831d94b 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -106,10 +106,6 @@ impl Options { self.microvm.snapshot_quiesce_timeout_ms != 0, "microVM snapshot quiesce timeout must be nonzero" ); - anyhow::ensure!( - self.net.is_empty(), - "microVM snapshot capture does not yet support --net" - ); } if self.restore_snapshot.is_some() { anyhow::ensure!( @@ -248,8 +244,10 @@ impl Options { "microVM --net requires --network-profile portable" ); anyhow::ensure!( - self.microvm.network_profile.is_none() || !self.net.is_empty(), - "--network-profile portable requires --net" + self.microvm.network_profile.is_none() + || !self.net.is_empty() + || self.restore_snapshot.is_some(), + "--network-profile portable requires --net or --restore-snapshot" ); anyhow::ensure!( self.net.iter().all(|network| { @@ -463,17 +461,6 @@ mod tests { "--network-profile", "portable", ], - vec![ - "openvmm", - "--machine", - "microvm", - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--snapshot-destination", - "snapshot", - ], vec!["openvmm", "--network-profile", "portable"], ] { let options = Options::try_parse_from(args).unwrap(); diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index d84c8a5e528..024788873a4 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -71,7 +71,7 @@ impl<'a> MicrovmConfigBuilder<'a> { } opt.validate_microvm_options()?; let network = if active { - effective_microvm_network(opt)? + effective_microvm_network(opt, restore_machine_contract)? } else { None }; @@ -108,6 +108,7 @@ impl<'a> MicrovmConfigBuilder<'a> { console_attachment: console .as_ref() .map(|(_, _, attachment)| attachment.clone()), + network_attachment: network.as_ref().map(|network| network.attachment.clone()), ..Default::default() }; diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index d306d26f900..0b046bbb940 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -13,6 +13,7 @@ use anyhow::Context; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; use openvmm_defs::config::Config; use openvmm_defs::config::LoadMode; +use openvmm_defs::microvm::MicrovmNetworkConfig; use openvmm_defs::worker::SharedMemoryFd; use std::io; use std::path::Path; @@ -25,6 +26,7 @@ pub(crate) struct MicrovmLaunch { active: bool, effective_command_line: Option, resources: MicrovmResources, + network: Option, snapshot_destination: Option, snapshot_memory_file: Option, snapshot_memory_handle: Option, @@ -112,6 +114,7 @@ impl MicrovmLaunch { active: opt.machine == MachineProfileCli::Microvm, effective_command_line, resources, + network: vm_config.microvm.network.clone(), snapshot_destination, snapshot_memory_file, snapshot_memory_handle, @@ -169,12 +172,16 @@ impl MicrovmLaunch { if opt.machine != MachineProfileCli::Microvm { return Ok(None); } + let resources = &self.resources; Ok(Some(( expected_hypervisor, self.effective_command_line .as_deref() .context("microVM restore requires an effective command line")?, - self.resources.console_attachment.as_ref(), + self.network + .as_ref() + .zip(resources.network_attachment.as_ref()), + resources.console_attachment.as_ref(), ))) } @@ -206,6 +213,7 @@ impl MicrovmLaunch { source_hypervisor, effective_command_line: self.effective_command_line, resources: self.resources, + network: self.network, snapshot_memory_file: self.snapshot_memory_file, } } diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index c1d164f2f15..89a66112df6 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -30,4 +30,6 @@ pub(crate) struct MicrovmResources { pub(crate) console_attachment: Option, /// Removes the boot console Unix socket on teardown. pub(crate) console_socket_cleanup: Option, + /// Snapshot identity of the portable network attachment. + pub(crate) network_attachment: Option, } diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index acc0b3b9b05..218a121e21d 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -5,19 +5,59 @@ use crate::Options; use crate::VmResources; +use crate::cli_args; use crate::cli_args::EndpointConfigCli; +use anyhow::Context; use net_backend_resources::consomme::static_ipv4::StaticIpv4Config; use vm_resource::IntoResource; use vm_resource::Resource; use vm_resource::kind::NetEndpointHandleKind; +const MICROVM_NETWORK_STABLE_ID: &str = "net:microvm0"; + #[derive(Clone)] pub(super) struct EffectiveMicrovmNetwork { pub(super) config: openvmm_defs::microvm::MicrovmNetworkConfig, + pub(super) attachment: openvmm_helpers::snapshot::microvm::SnapshotAttachment, +} + +fn microvm_network_attachment() -> openvmm_helpers::snapshot::microvm::SnapshotAttachment { + openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: MICROVM_NETWORK_STABLE_ID.to_owned(), + kind: "virtio-net".to_owned(), + required: false, + reconnect_policy: "recreate-endpoint".to_owned(), + identity_kind: "user-mode-nat".to_owned(), + identity: b"consomme".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } +} + +fn microvm_network_from_snapshot( + saved: &openvmm_helpers::snapshot::microvm::SnapshotMicrovmNetwork, +) -> anyhow::Result { + let prefix_length = + u8::try_from(saved.prefix_length).context("snapshot network prefix does not fit in u8")?; + let config = format!( + "{}/{}", + std::net::Ipv4Addr::from(saved.guest_ipv4), + prefix_length + ) + .parse::() + .context("snapshot static network identity is invalid")?; + anyhow::ensure!( + saved.gateway_ipv4 == u32::from(config.derived_gateway_ipv4) + && saved.guest_mac == config.guest_mac.to_bytes() + && saved.gateway_mac == config.gateway_mac.to_bytes(), + "snapshot static network identity is not canonical" + ); + Ok(config) } pub(super) fn effective_microvm_network( opt: &Options, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, ) -> anyhow::Result> { let requested = match opt.net.as_slice() { [] => None, @@ -27,7 +67,51 @@ pub(super) fn effective_microvm_network( }, _ => anyhow::bail!("microVM permits at most one virtio-net device"), }; - Ok(requested.map(|config| EffectiveMicrovmNetwork { config })) + let Some(restore) = restore else { + return Ok(requested.map(|config| EffectiveMicrovmNetwork { + config, + attachment: microvm_network_attachment(), + })); + }; + anyhow::ensure!( + requested.is_none(), + "restore takes microVM network addressing from saved state" + ); + + let has_device = restore + .devices + .iter() + .any(|device| device.stable_id == MICROVM_NETWORK_STABLE_ID); + let saved_attachment = restore + .attachments + .iter() + .find(|attachment| attachment.stable_id == MICROVM_NETWORK_STABLE_ID); + anyhow::ensure!( + has_device == restore.microvm_network.is_some() && has_device == saved_attachment.is_some(), + "snapshot microVM network device, identity, and attachment inventories disagree" + ); + let Some(saved) = restore.microvm_network.as_ref() else { + anyhow::ensure!( + opt.microvm.network_profile.is_none(), + "restore-time network resources cannot be added to a snapshot without a NIC" + ); + return Ok(None); + }; + anyhow::ensure!( + saved.profile == openvmm_defs::microvm::MicrovmNetworkProfile::Portable.as_str(), + "snapshot microVM network profile is unsupported" + ); + anyhow::ensure!( + opt.microvm.network_profile == Some(cli_args::microvm::MicrovmNetworkProfileCli::Portable), + "networked microVM snapshot restore requires --network-profile portable" + ); + let config = microvm_network_from_snapshot(saved)?; + let attachment = microvm_network_attachment(); + anyhow::ensure!( + Some(&attachment) == saved_attachment, + "restore-time network endpoint does not match the snapshot attachment" + ); + Ok(Some(EffectiveMicrovmNetwork { config, attachment })) } pub(super) fn microvm_network_endpoint( @@ -50,3 +134,101 @@ pub(super) fn microvm_network_endpoint( } .into_resource()) } + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use clap::Parser as _; + use openvmm_defs::microvm::build_microvm_command_line; + use test_with_tracing::test; + + pub(crate) fn network_contract() -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract + { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let source_hypervisor = if cfg!(windows) { "whp" } else { "kvm" }; + let irq = openvmm_defs::microvm::microvm_virtio_net_irq(Some(source_hypervisor)).unwrap(); + let mut command_line = build_microvm_command_line(&[], false).unwrap(); + openvmm_defs::microvm::append_microvm_virtio_discovery( + &mut command_line, + Some((&network, irq, true)), + false, + ) + .unwrap(); + openvmm_helpers::snapshot::microvm::microvm_machine_contract( + source_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + Some((&network, microvm_network_attachment())), + None, + 1, + 1024, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtio-net-3489660928", + ] + .map(str::to_owned) + .to_vec(), + std::time::SystemTime::now().into(), + 1_000_000_000, + Some(1_000_000_000), + vec![1, 2, 3], + ) + .unwrap() + } + + fn restore_options(extra: &[&str]) -> Options { + Options::try_parse_from( + [ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + ] + .iter() + .chain(extra), + ) + .unwrap() + } + + #[test] + fn network_restore_takes_identity_from_snapshot() { + let contract = network_contract(); + let options = restore_options(&["--network-profile", "portable"]); + let restored = effective_microvm_network(&options, Some(&contract)) + .unwrap() + .unwrap(); + assert_eq!( + restored.config.guest_ipv4, + std::net::Ipv4Addr::new(10, 0, 0, 2) + ); + assert_eq!(restored.attachment, microvm_network_attachment()); + } + + #[test] + fn network_restore_rejects_attachment_identity_change() { + let mut contract = network_contract(); + let attachment = contract + .attachments + .iter_mut() + .find(|attachment| attachment.stable_id == MICROVM_NETWORK_STABLE_ID) + .unwrap(); + attachment.identity.push(b'x'); + let options = restore_options(&["--network-profile", "portable"]); + assert!(effective_microvm_network(&options, Some(&contract)).is_err()); + } + + #[test] + fn network_restore_requires_portable_profile() { + let contract = network_contract(); + assert!(effective_microvm_network(&restore_options(&[]), Some(&contract)).is_err()); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 352d8f47611..5882ca3d161 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -6,6 +6,7 @@ use crate::Options; use crate::cli_args::microvm::MachineProfileCli; use anyhow::Context; +use openvmm_defs::microvm::MicrovmNetworkConfig; use openvmm_helpers::snapshot::SnapshotManifest; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use openvmm_helpers::snapshot::microvm::SnapshotMachineContract; @@ -89,8 +90,13 @@ pub(crate) fn prepare_restore( } /// The machine contract a microVM snapshot must match to be restored: the -/// hypervisor, effective command line, and boot console attachment. -pub(crate) type ExpectedRestoreContract<'a> = (&'a str, &'a str, Option<&'a SnapshotAttachment>); +/// hypervisor, effective command line, network, and boot console attachment. +pub(crate) type ExpectedRestoreContract<'a> = ( + &'a str, + &'a str, + Option<(&'a MicrovmNetworkConfig, &'a SnapshotAttachment)>, + Option<&'a SnapshotAttachment>, +); /// Clock and CPU state recorded at the capture boundary of a restored /// microVM: host downtime, TSC and APIC frequencies, and the CPU contract. @@ -102,7 +108,12 @@ pub(crate) fn validate_restore_contract( manifest: &SnapshotManifest, expected_memory_size: u64, expected_vp_count: u32, - (expected_hypervisor, effective_command_line, console_attachment): ExpectedRestoreContract<'_>, + ( + expected_hypervisor, + effective_command_line, + network, + console_attachment, + ): ExpectedRestoreContract<'_>, ) -> anyhow::Result { let saved_contract = manifest .machine_contract @@ -112,6 +123,7 @@ pub(crate) fn validate_restore_contract( expected_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, effective_command_line.to_owned(), + network.map(|(config, attachment)| (config, attachment.clone())), console_attachment.cloned(), expected_vp_count, expected_memory_size, diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 38d0c462535..df3da350289 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -31,6 +31,8 @@ pub(crate) struct MicrovmController { pub(crate) effective_command_line: Option, /// Host attachments and cleanup guards of the microVM devices. pub(crate) resources: MicrovmResources, + /// Static identity of the microVM virtio-net device. + pub(crate) network: Option, /// Automatic RAM backing created for snapshot capture. pub(crate) snapshot_memory_file: Option, } @@ -141,10 +143,16 @@ impl VmController { let command_line = response.effective_command_line; let result = (|| -> anyhow::Result<()> { + let network = self + .microvm + .network + .as_ref() + .zip(self.microvm.resources.network_attachment.clone()); let machine_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( &self.microvm.source_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, command_line, + network, self.microvm.resources.console_attachment.clone(), self.processors, self.memory, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index db2b70aeeff..512c14bd3c6 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -144,6 +144,43 @@ pub struct SnapshotAttachment { pub reconnect_timeout_ms: u64, } +/// Canonical guest-visible identity of the microVM network. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMicrovmNetwork { + /// Required cross-platform host-network implementation contract. + #[mesh(9)] + pub profile: String, + /// Guest IPv4 address in network byte order. + #[mesh(1)] + pub guest_ipv4: u32, + /// IPv4 subnet prefix length. + #[mesh(2)] + pub prefix_length: u32, + /// Derived gateway IPv4 address in network byte order. + #[mesh(3)] + pub gateway_ipv4: u32, + /// Deterministic guest MAC address. + #[mesh(4)] + pub guest_mac: Vec, + /// Deterministic gateway MAC address. + #[mesh(5)] + pub gateway_mac: Vec, +} + +impl SnapshotMicrovmNetwork { + fn new(config: &openvmm_defs::microvm::MicrovmNetworkConfig) -> Self { + Self { + profile: config.profile.as_str().to_owned(), + guest_ipv4: u32::from(config.guest_ipv4), + prefix_length: u32::from(config.prefix_length), + gateway_ipv4: u32::from(config.derived_gateway_ipv4), + guest_mac: config.guest_mac.to_bytes().to_vec(), + gateway_mac: config.gateway_mac.to_bytes().to_vec(), + } + } +} + /// Machine composition that becomes authoritative after capture. #[derive(Clone, Debug, PartialEq, Eq, Protobuf)] #[mesh(package = "openvmm.snapshot")] @@ -199,6 +236,9 @@ pub struct SnapshotMachineContract { /// Policy used to advance clocks and deadlines over host downtime. #[mesh(17)] pub clock_policy: String, + /// Static identity of the optional microVM virtio-net device. + #[mesh(18)] + pub microvm_network: Option, /// Effective local APIC timer frequency. #[mesh(20)] pub apic_frequency_hz: Option, @@ -270,6 +310,10 @@ pub fn microvm_machine_contract( source_hypervisor: &str, boot_layout_version: u32, effective_command_line: String, + network: Option<( + &openvmm_defs::microvm::MicrovmNetworkConfig, + SnapshotAttachment, + )>, console_attachment: Option, processor_count: u32, memory_size: u64, @@ -370,6 +414,65 @@ pub fn microvm_machine_contract( ), ]; let mut attachments = Vec::new(); + let microvm_network = if let Some((network, attachment)) = network { + let policy_is_valid = matches!(source_hypervisor, "kvm" | "mshv" | "whp") + && attachment.reconnect_policy == "recreate-endpoint" + && !attachment.required + && attachment.identity_kind == "user-mode-nat" + && attachment.identity == b"consomme"; + anyhow::ensure!( + attachment.stable_id == "net:microvm0" + && attachment.kind == "virtio-net" + && policy_is_valid + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES + && attachment.length == 0 + && attachment.reconnect_timeout_ms == 0, + "microVM network attachment has an unsupported endpoint policy" + ); + let irq = openvmm_defs::microvm::microvm_virtio_net_irq(Some(source_hypervisor))?; + let discovery = format!( + "virtio_mmio.device={:#x}@{:#x}:{irq}", + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE, + ); + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + anyhow::ensure!( + tokens.contains(discovery.as_str()) + && network + .command_line_fragment() + .split_ascii_whitespace() + .all(|token| tokens.contains(token)), + "microVM network command line does not match its saved identity" + ); + devices.push(SnapshotDevice { + stable_id: "net:microvm0".to_owned(), + state_unit_name: format!( + "virtio-net-{}", + openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE + ), + kind: "virtio-net".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio( + openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + )], + irq: Some(irq), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![ + openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES as u32, + (openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES >> 32) as u32, + ], + queue_count: 2, + queue_max_sizes: vec![256, 256], + }); + attachments.push(attachment); + Some(SnapshotMicrovmNetwork::new(network)) + } else { + None + }; if let Some(attachment) = console_attachment { let policy_is_valid = match attachment.reconnect_policy.as_str() { "recreate-listener" => { @@ -451,6 +554,7 @@ pub fn microvm_machine_contract( cpu_contract_sha256: Vec::new(), boot_layout_version, clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), + microvm_network, apic_frequency_hz, virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, @@ -551,6 +655,10 @@ pub fn validate_microvm_machine_contract( contract.attachments == expected.attachments, "snapshot attachment inventory doesn't match the supplied attachments" ); + anyhow::ensure!( + contract.microvm_network == expected.microvm_network, + "snapshot static network identity doesn't match the requested machine" + ); anyhow::ensure!( contract.tsc_frequency_hz == expected.tsc_frequency_hz && contract.tsc_tolerance_ppm == expected.tsc_tolerance_ppm, @@ -741,6 +849,29 @@ pub(super) fn validate_machine_contract_shape( ); } + if let Some(network) = &contract.microvm_network { + anyhow::ensure!( + network.profile == openvmm_defs::microvm::MicrovmNetworkProfile::Portable.as_str(), + "snapshot microVM network profile '{}' is unsupported", + network.profile + ); + let prefix_length = u8::try_from(network.prefix_length) + .context("snapshot network prefix does not fit in u8")?; + let parsed = format!( + "{}/{}", + std::net::Ipv4Addr::from(network.guest_ipv4), + prefix_length + ) + .parse::() + .context("snapshot static network identity is invalid")?; + anyhow::ensure!( + network.gateway_ipv4 == u32::from(parsed.derived_gateway_ipv4) + && network.guest_mac == parsed.guest_mac.to_bytes() + && network.gateway_mac == parsed.gateway_mac.to_bytes(), + "snapshot static network identity is not canonical" + ); + } + anyhow::ensure!( contract.attachments.len() <= MAX_ATTACHMENTS, "snapshot attachment inventory is too large" @@ -775,6 +906,9 @@ pub(super) fn validate_machine_contract_shape( "discard-while-disconnected" => { !attachment.required && attachment.reconnect_timeout_ms == 0 } + "recreate-endpoint" => { + !attachment.required && attachment.reconnect_timeout_ms == 0 + } _ => false, }, "snapshot attachment '{}' has an invalid reconnect policy", @@ -859,6 +993,7 @@ fn test_machine_contract() -> SnapshotMachineContract { cpu_contract_sha256: Vec::new(), boot_layout_version: MICROVM_BOOT_LAYOUT_VERSION, clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), + microvm_network: None, apic_frequency_hz: Some(1_000_000_000), virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, @@ -887,12 +1022,64 @@ mod tests { } } + fn microvm_network_attachment(source_hypervisor: &str) -> SnapshotAttachment { + assert!(matches!(source_hypervisor, "kvm" | "mshv" | "whp")); + SnapshotAttachment { + stable_id: "net:microvm0".to_owned(), + kind: "virtio-net".to_owned(), + required: false, + reconnect_policy: "recreate-endpoint".to_owned(), + identity_kind: "user-mode-nat".to_owned(), + identity: b"consomme".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + fn generated_network_contract(source_hypervisor: &str) -> SnapshotMachineContract { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let irq = openvmm_defs::microvm::microvm_virtio_net_irq(Some(source_hypervisor)).unwrap(); + let command_line = format!( + "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0000000:{irq} {}", + network.command_line_fragment_with_dns(true) + ); + microvm_machine_contract( + source_hypervisor, + MICROVM_BOOT_LAYOUT_VERSION, + command_line, + Some((&network, microvm_network_attachment(source_hypervisor))), + None, + 1, + 1024, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtio-net-3489660928", + ] + .map(str::to_owned) + .to_vec(), + std::time::SystemTime::now().into(), + 1_000_000_000, + Some(1_000_000_000), + vec![1, 2, 3], + ) + .unwrap() + } + fn generated_console_contract() -> SnapshotMachineContract { microvm_machine_contract( "whp", MICROVM_BOOT_LAYOUT_VERSION, "earlycon=xe9 console=hvc1 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0002000:7" .to_owned(), + None, Some(microvm_console_attachment()), 1, 1024, @@ -933,6 +1120,102 @@ mod tests { assert_eq!(contract.attachments, [microvm_console_attachment()]); } + #[test] + fn generated_microvm_network_contract_has_backend_specific_fixed_abi() { + for (source_hypervisor, irq) in [("kvm", 10), ("mshv", 10), ("whp", 5)] { + let contract = generated_network_contract(source_hypervisor); + let network_device = contract.devices.last().unwrap(); + assert_eq!(network_device.stable_id, "net:microvm0"); + assert_eq!(network_device.state_unit_name, "virtio-net-3489660928"); + assert_eq!(network_device.ranges[0].start, 0xd000_0000); + assert_eq!(network_device.ranges[0].length, 0x1000); + assert_eq!(network_device.irq, Some(irq)); + assert_eq!(network_device.transport, "virtio-mmio"); + assert_eq!(network_device.feature_banks, [0x20, 0x1]); + assert_eq!(network_device.queue_count, 2); + assert_eq!(network_device.queue_max_sizes, [256, 256]); + assert_eq!( + contract.attachments, + [microvm_network_attachment(source_hypervisor)] + ); + + let network = contract.microvm_network.unwrap(); + assert_eq!(network.profile, "portable"); + assert_eq!( + network.guest_ipv4, + u32::from(std::net::Ipv4Addr::new(10, 0, 0, 2)) + ); + assert_eq!(network.prefix_length, 24); + assert_eq!( + network.gateway_ipv4, + u32::from(std::net::Ipv4Addr::new(10, 0, 0, 1)) + ); + assert_eq!(network.guest_mac, [0x52, 0x54, 0, 0, 0, 2]); + assert_eq!(network.gateway_mac, [0x52, 0x54, 0, 0, 0, 1]); + } + } + + #[test] + fn validate_microvm_network_contract_rejects_noncanonical_identity() { + let contract = generated_network_contract("whp"); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_network + .as_mut() + .unwrap() + .guest_mac[5] = 3; + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("not canonical")); + } + + #[test] + fn validate_microvm_network_contract_rejects_unsupported_profile() { + let contract = generated_network_contract("whp"); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_network + .as_mut() + .unwrap() + .profile = "other".to_owned(); + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("profile")); + } + + #[test] + fn validate_microvm_network_contract_rejects_legacy_missing_profile() { + let contract = generated_network_contract("whp"); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_network + .as_mut() + .unwrap() + .profile + .clear(); + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("profile '' is unsupported")); + } + #[test] fn validate_microvm_console_contract_rejects_attachment_change() { let contract = generated_console_contract(); From 1ba29fc4982ba6e92df475772dd67d85251655fe Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 07:51:33 -0700 Subject: [PATCH 117/304] net_tap: support persistent TAP attachments A TAP interface that a process creates is destroyed when its last file descriptor closes. When a VM's network attachment must outlive the process that owns it, for example when a snapshot is restored in a new process, the host needs a way to keep the interface after that process exits. Add the primitive: - `linux_net_bindings` gains `tun_set_persist`, the `TUNSETPERSIST` ioctl. As for `TUNSETOFFLOAD`, the kernel reads the flag from the ioctl argument, so the binding passes the value rather than a pointer. - The new `net_tap::tap::persist::set_persistent` sets or clears persistence on an open TAP file descriptor. A failed ioctl reports the new `Error::SetPersistent`, carrying the OS error. The endpoint does not change: persistence is a property of the host interface that the owner of the descriptor chooses before handing the TAP interface to the VM. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/net/linux_net_bindings/src/lib.rs | 6 +++++ vm/devices/net/net_tap/src/tap.rs | 3 +++ vm/devices/net/net_tap/src/tap/persist.rs | 24 ++++++++++++++++++++ 3 files changed, 33 insertions(+) create mode 100644 vm/devices/net/net_tap/src/tap/persist.rs diff --git a/vm/devices/net/linux_net_bindings/src/lib.rs b/vm/devices/net/linux_net_bindings/src/lib.rs index c402dbb9f8b..5f957b60b11 100644 --- a/vm/devices/net/linux_net_bindings/src/lib.rs +++ b/vm/devices/net/linux_net_bindings/src/lib.rs @@ -44,6 +44,12 @@ ioctl_write_ptr_bad!( gen_if::ifreq ); +// #define TUNSETPERSIST _IOW('T', 203, int) +ioctl_write_int_bad!( + tun_set_persist, + request_code_write!(b'T', 203, size_of::()) +); + // #define TUNSETOFFLOAD _IOW('T', 208, unsigned int) // Note: the kernel reads the offload flags directly from the ioctl arg, // not via copy_from_user, so we must pass the value, not a pointer. diff --git a/vm/devices/net/net_tap/src/tap.rs b/vm/devices/net/net_tap/src/tap.rs index cac0082105f..ca925087052 100644 --- a/vm/devices/net/net_tap/src/tap.rs +++ b/vm/devices/net/net_tap/src/tap.rs @@ -7,6 +7,7 @@ #![expect(unsafe_code)] mod async_write; +pub mod persist; use crate::VirtioNetHdr; use futures::AsyncRead; @@ -47,6 +48,8 @@ pub enum Error { SetVnetHdrSize(#[source] io::Error), #[error("TUNSETOFFLOAD ioctl failed")] SetOffload(#[source] io::Error), + #[error("TUNSETPERSIST ioctl failed")] + SetPersistent(#[source] io::Error), #[error("TAP name conversion to C string failed")] TapNameConversion(#[source] std::ffi::NulError), #[error("TAP interface does not have IFF_VNET_HDR set")] diff --git a/vm/devices/net/net_tap/src/tap/persist.rs b/vm/devices/net/net_tap/src/tap/persist.rs new file mode 100644 index 00000000000..2fddb865826 --- /dev/null +++ b/vm/devices/net/net_tap/src/tap/persist.rs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Persistence of TAP interfaces. + +// UNSAFETY: Calling an ioctl. +#![expect(unsafe_code)] + +use super::Error; +use linux_net_bindings::tun_set_persist; +use std::io; +use std::os::fd::OwnedFd; +use std::os::raw::c_int; +use std::os::unix::prelude::AsRawFd; + +/// Sets whether a TAP interface persists after its last fd is closed. +pub fn set_persistent(fd: &OwnedFd, persistent: bool) -> Result<(), Error> { + // SAFETY: calling the ioctl according to implementation requirements. + unsafe { + tun_set_persist(fd.as_raw_fd(), c_int::from(persistent)) + .map_err(|_e| Error::SetPersistent(io::Error::last_os_error()))?; + } + Ok(()) +} From a139f7cd7d666a45e743bd411e28351bcb5d310e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 08:09:13 -0700 Subject: [PATCH 118/304] virtiofs: add the fixed microVM filesystem profile A microVM snapshot must describe its virtio-fs device completely, and the host state that a guest can create through the device must stay bounded. The standard virtio-fs device offers neither: its shape, cache lifetimes, and namespace tables are open-ended. Add a fixed microVM profile of the device: - `VirtioFsHandle` gains a `profile`. Every existing user passes `VirtioFsProfile::Standard`, and `VirtioFsProfile::Microvm` selects the profile with a stable attachment ID, the host root's identity, and the access mode. - `virtiofs::profile` defines the ABI: attachment `fs:microvm0`, tag `microvm`, one request queue, FUSE 7.31 or newer, a 1 MiB maximum write, direct I/O, zero entry and attribute cache lifetimes, no shared memory, and no packed rings. The root is named by an opaque platform identity, the device and inode numbers on Unix or the volume and file ID on Windows, so the host path is not part of the profile. - `VirtioFs::new_microvm` checks the host path against the root identity before and after it opens the volume, so that the attachment cannot be swapped between resource creation and use. The path is not retained. - The filesystem confines the guest to the export: every operation revalidates that the inode's relative paths contain no symbolic link component, child names may not contain `\` or `:`, paths are bounded, and symbolic link creation is refused. - The inode, alias, and handle tables are bounded. An operation checks the bound before it creates host state, and fails with `ENOSPC` or `E2BIG` rather than creating state it cannot track. - `VirtioFsDevice::new_microvm` builds the fixed device, which rejects packed virtqueues and a shared-memory region. The resolver builds it for the microVM profile, which requires the fixed tag and a HostFs backend without mount options. Three hardening changes also apply to standard devices: a request larger than the maximum write plus a header is reported as corruption, a configuration read past the end returns zero instead of panicking, and an invalid child name fails with `EINVAL` instead of an assertion. The table bounds and the relative-path helpers are placed in the `saved_state` and `state` modules, which will hold the device-private state that they bound. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + openvmm/openvmm_entry/src/lib.rs | 2 + openvmm/openvmm_entry/src/ttrpc/mod.rs | 1 + petri/burette/src/tests/virtio_fs.rs | 1 + .../virtio/virtio_resources/src/fs/microvm.rs | 19 + vm/devices/virtio/virtio_resources/src/lib.rs | 3 + vm/devices/virtio/virtiofs/Cargo.toml | 1 + vm/devices/virtio/virtiofs/src/aggregate.rs | 6 +- vm/devices/virtio/virtiofs/src/inode.rs | 46 ++- vm/devices/virtio/virtiofs/src/lib.rs | 79 +++- .../virtio/virtiofs/src/microvm/device.rs | 133 +++++++ vm/devices/virtio/virtiofs/src/microvm/fs.rs | 199 ++++++++++ .../virtio/virtiofs/src/microvm/fs_tests.rs | 81 ++++ .../virtio/virtiofs/src/microvm/inode.rs | 76 ++++ .../virtio/virtiofs/src/microvm/limits.rs | 156 ++++++++ vm/devices/virtio/virtiofs/src/microvm/mod.rs | 22 ++ .../virtio/virtiofs/src/microvm/profile.rs | 347 ++++++++++++++++++ .../virtio/virtiofs/src/microvm/resolver.rs | 152 ++++++++ .../virtiofs/src/microvm/saved_state.rs | 11 + .../virtio/virtiofs/src/microvm/state.rs | 59 +++ vm/devices/virtio/virtiofs/src/resolver.rs | 19 +- vm/devices/virtio/virtiofs/src/virtio.rs | 44 ++- vm/devices/virtio/virtiofs/src/virtio_util.rs | 4 + 23 files changed, 1403 insertions(+), 59 deletions(-) create mode 100644 vm/devices/virtio/virtio_resources/src/fs/microvm.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/device.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/fs.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/inode.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/limits.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/mod.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/profile.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/resolver.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/saved_state.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/state.rs diff --git a/Cargo.lock b/Cargo.lock index c088f162599..74f06050826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10257,6 +10257,7 @@ dependencies = [ "task_control", "tempfile", "test_with_tracing", + "thiserror 2.0.16", "tracelimit", "tracing", "virtio", diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 95a29d40f51..125dfbf8d4c 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -1933,6 +1933,7 @@ async fn vm_config_from_command_line( root_path: args.path.clone(), mount_options: args.options.clone(), }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Standard, } .into_resource(); if let Some(pcie_port) = &args.pcie_port { @@ -1951,6 +1952,7 @@ async fn vm_config_from_command_line( fs: virtio_resources::fs::VirtioFsBackend::SectionFs { root_path: args.path.clone(), }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Standard, } .into_resource(); if let Some(pcie_port) = &args.pcie_port { diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 745fafce298..61b111e5c4b 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -2422,6 +2422,7 @@ fn build_virtio_fs( String::new() }, }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Standard, }) } diff --git a/petri/burette/src/tests/virtio_fs.rs b/petri/burette/src/tests/virtio_fs.rs index 6cb66cfbacd..faba4e75440 100644 --- a/petri/burette/src/tests/virtio_fs.rs +++ b/petri/burette/src/tests/virtio_fs.rs @@ -183,6 +183,7 @@ impl crate::harness::WarmPerfTest for VirtioFsTest { root_path: vfs_root_path, mount_options: String::new(), }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Standard, } .into_resource(), ) diff --git a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs new file mode 100644 index 00000000000..d1451294c55 --- /dev/null +++ b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM profile of the virtio-fs resource. +//! +//! [`VirtioFsProfile`] selects the standard virtio-fs device or the microVM +//! device, which serves an attached host folder. + +use mesh::MeshPayload; + +#[derive(MeshPayload)] +pub enum VirtioFsProfile { + Standard, + Microvm { + stable_id: String, + root_identity: Vec, + read_only: bool, + }, +} diff --git a/vm/devices/virtio/virtio_resources/src/lib.rs b/vm/devices/virtio/virtio_resources/src/lib.rs index 0a7a71ae483..9646f55405e 100644 --- a/vm/devices/virtio/virtio_resources/src/lib.rs +++ b/vm/devices/virtio/virtio_resources/src/lib.rs @@ -41,6 +41,8 @@ pub mod p9 { } pub mod fs { + pub mod microvm; + use mesh::MeshPayload; use vm_resource::ResourceId; use vm_resource::kind::VirtioDeviceHandle; @@ -49,6 +51,7 @@ pub mod fs { pub struct VirtioFsHandle { pub tag: String, pub fs: VirtioFsBackend, + pub profile: microvm::VirtioFsProfile, } #[derive(MeshPayload)] diff --git a/vm/devices/virtio/virtiofs/Cargo.toml b/vm/devices/virtio/virtiofs/Cargo.toml index 321945fb1f0..dca5c4b7ea4 100644 --- a/vm/devices/virtio/virtiofs/Cargo.toml +++ b/vm/devices/virtio/virtiofs/Cargo.toml @@ -24,6 +24,7 @@ anyhow.workspace = true futures.workspace = true pal_async.workspace = true parking_lot.workspace = true +thiserror.workspace = true tracelimit.workspace = true tracing.workspace = true zerocopy.workspace = true diff --git a/vm/devices/virtio/virtiofs/src/aggregate.rs b/vm/devices/virtio/virtiofs/src/aggregate.rs index e20535fd3e6..e560e15b1b9 100644 --- a/vm/devices/virtio/virtiofs/src/aggregate.rs +++ b/vm/devices/virtio/virtiofs/src/aggregate.rs @@ -11,8 +11,6 @@ //! the [`VirtioFs`] methods that operate on it; the core (direct-mode) file //! system lives in the crate root. -use crate::ATTRIBUTE_TIMEOUT; -use crate::ENTRY_TIMEOUT; use crate::VirtioFs; use crate::build_volume; use crate::inode::VirtioFsInode; @@ -245,8 +243,8 @@ impl VirtioFs { let (_, node_id) = self.insert_inode(inode)?; Ok(fuse_entry_out::new( node_id, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } diff --git a/vm/devices/virtio/virtiofs/src/inode.rs b/vm/devices/virtio/virtiofs/src/inode.rs index e27a8f2d135..77d1af7e588 100644 --- a/vm/devices/virtio/virtiofs/src/inode.rs +++ b/vm/devices/virtio/virtiofs/src/inode.rs @@ -40,9 +40,10 @@ pub(crate) fn namespace_ino(volume_id: u32, raw: lx::ino_t) -> lx::ino_t { } pub(crate) struct VirtioFsVolume { - volume: Arc, - id: u32, - readonly: bool, + pub(crate) volume: Arc, + pub(crate) id: u32, + pub(crate) readonly: bool, + pub(crate) strict_paths: bool, } impl VirtioFsVolume { @@ -51,6 +52,7 @@ impl VirtioFsVolume { volume: Arc::new(volume), id, readonly, + strict_paths: false, } } @@ -216,6 +218,7 @@ impl VirtioFsInode { /// Performs a lookup for a child of this inode. pub fn lookup_child(&self, name: &LxStr) -> lx::Result<(VirtioFsInode, fuse_attr)> { + self.validate_confined()?; let path = self.child_path(name)?; let (inode, stat) = VirtioFsInode::new(Arc::clone(&self.volume), path)?; let attr = inode.attr_from_stat(&stat); @@ -224,18 +227,21 @@ impl VirtioFsInode { /// Retrieves the attributes of this inode. pub fn get_attr(&self) -> lx::Result { + self.validate_confined()?; let stat = self.volume.lstat(&*self.get_path())?; Ok(self.attr_from_stat(&stat)) } /// Retrieves the extended attributes of this inode. pub fn get_statx(&self) -> lx::Result { + self.validate_confined()?; let statx = self.volume.statx(&*self.get_path())?; Ok(self.statx_from(&statx)) } /// Sets the attributes of this inode. pub fn set_attr(&self, arg: &fuse_setattr_in, request_uid: lx::uid_t) -> lx::Result { + self.validate_confined()?; let attr = util::fuse_set_attr_to_lxutil(arg, request_uid); // Because FUSE_HANDLE_KILLPRIV is set, set-user-ID and set-group-ID must be cleared @@ -247,6 +253,7 @@ impl VirtioFsInode { /// Opens the inode, creating a file object. pub fn open(self: Arc, flags: u32) -> lx::Result { + self.validate_confined()?; let flags = (flags as i32) | lx::O_NOFOLLOW; let file = self.volume.open(&*self.get_path(), flags, None)?; Ok(VirtioFsFile::new(file, self)) @@ -261,6 +268,7 @@ impl VirtioFsInode { uid: u32, gid: u32, ) -> lx::Result<(VirtioFsInode, fuse_attr, lxutil::LxFile)> { + self.validate_confined()?; let path = self.child_path(name)?; let options = LxCreateOptions::new(mode, uid, gid); let flags = (flags as i32) | lx::O_CREAT | lx::O_NOFOLLOW; @@ -279,6 +287,7 @@ impl VirtioFsInode { uid: u32, gid: u32, ) -> lx::Result<(VirtioFsInode, fuse_attr)> { + self.validate_confined()?; let path = self.child_path(name)?; let stat = self .volume @@ -298,6 +307,7 @@ impl VirtioFsInode { gid: u32, device_id: u32, ) -> lx::Result<(VirtioFsInode, fuse_attr)> { + self.validate_confined()?; let path = self.child_path(name)?; let stat = self.volume.mknod_stat( &path, @@ -318,6 +328,7 @@ impl VirtioFsInode { uid: u32, gid: u32, ) -> lx::Result<(VirtioFsInode, fuse_attr)> { + self.validate_confined()?; let path = self.child_path(name)?; let stat = self.volume.symlink_stat( &path, @@ -332,6 +343,8 @@ impl VirtioFsInode { /// Creates a new hard link as a child of this inode. pub fn link(&self, name: &LxStr, target: &VirtioFsInode) -> lx::Result { + self.validate_confined()?; + target.validate_confined()?; if self.volume.id() != target.volume.id() { return Err(lx::Error::EXDEV); } @@ -344,11 +357,13 @@ impl VirtioFsInode { /// Reads the target of the symbolic link, if this inode is a symbolic link. pub fn read_link(&self) -> lx::Result { + self.validate_confined()?; self.volume.read_link(&*self.get_path()) } /// Removes a file or directory child of this inode. pub fn unlink(&self, name: &LxStr, flags: i32) -> lx::Result<()> { + self.validate_confined()?; let path = self.child_path(name)?; self.volume.unlink(path, flags) } @@ -361,6 +376,8 @@ impl VirtioFsInode { new_name: &LxStr, flags: u32, ) -> lx::Result<()> { + self.validate_confined()?; + new_dir.validate_confined()?; let path = self.child_path(name)?; let new_path = new_dir.child_path(new_name)?; self.volume.rename(path, new_path, flags) @@ -368,6 +385,7 @@ impl VirtioFsInode { /// Gets the attributes of the file system that the inode resides on. pub fn stat_fs(&self) -> lx::Result { + self.validate_confined()?; let stat_fs = self.volume.stat_fs(&*self.get_path())?; Ok(fuse_kstatfs::new( stat_fs.block_count, @@ -383,22 +401,26 @@ impl VirtioFsInode { /// Gets the value or the size of an extended attribute on this inode. pub fn get_xattr(&self, name: &LxStr, value: Option<&mut [u8]>) -> lx::Result { + self.validate_confined()?; self.volume.get_xattr(&*self.get_path(), name, value) } /// Sets an extended attribute on this inode. pub fn set_xattr(&self, name: &LxStr, value: &[u8], flags: u32) -> lx::Result<()> { + self.validate_confined()?; self.volume .set_xattr(&*self.get_path(), name, value, flags as i32) } /// Lists the extended attributes on this inode. pub fn list_xattr(&self, list: Option<&mut [u8]>) -> lx::Result { + self.validate_confined()?; self.volume.list_xattr(&*self.get_path(), list) } /// Removes an extended attribute from this inode. pub fn remove_xattr(&self, name: &LxStr) -> lx::Result<()> { + self.validate_confined()?; self.volume.remove_xattr(&*self.get_path(), name) } @@ -475,14 +497,20 @@ impl VirtioFsInode { /// Appends a child name to this inode's path. pub(crate) fn child_path(&self, name: &LxStr) -> lx::Result { - // Defense in depth: the FUSE request parser already validates names, - // but assert here to catch any bypass. - assert!(!name.is_empty(), "empty child name"); - assert!(!name.as_bytes().contains(&b'/'), "child name contains '/'"); - assert!(name != "." && name != "..", "child name is '.' or '..'"); + let name = name.as_bytes(); + if name.is_empty() + || name.contains(&b'/') + || name.contains(&b'\0') + || name == b"." + || name == b".." + || crate::microvm::inode::child_name_disallowed(&self.volume, name) + { + return Err(lx::Error::EINVAL); + } let mut path = self.clone_path(); - path.push_lx(name)?; + path.push_lx(LxStr::from_bytes(name))?; + crate::microvm::inode::validate_child_path(&self.volume, &path)?; Ok(path) } diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index 676b20cb782..068022f8299 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -9,6 +9,8 @@ mod file; mod inode; #[cfg(test)] mod integration_tests; +mod microvm; +pub use microvm::profile; pub mod resolver; #[cfg(windows)] mod section; @@ -28,6 +30,7 @@ use inode::DedupKey; use inode::VirtioFsInode; use inode::VirtioFsVolume; pub use lxutil::LxVolumeOptions; +use microvm::profile::MicroVmVirtioFsProfile; use parking_lot::RwLock; use std::collections::HashMap; use std::collections::hash_map::Entry; @@ -36,7 +39,6 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -// TODO: Make these configurable. // FUSE likes to spam getattr a lot, so having a small timeout on the attributes avoids excessive // calls. It also means that a lookup/stat sequence can use the attributes returned by lookup // rather than having to call getattr. @@ -54,6 +56,7 @@ struct VirtioFsInner { inodes: RwLock, files: RwLock>>, mode: VirtioFsMode, + microvm_profile: Option, } /// Distinguishes a single-share device from a multi-share aggregate. @@ -99,6 +102,8 @@ pub struct VirtioFs { impl Fuse for VirtioFs { fn init(&self, info: &mut SessionInfo) { + microvm::fs::configure_session(self, info); + // Indicate we support both readdir and readdirplus. if info.capable() & FUSE_DO_READDIRPLUS != 0 { info.want |= FUSE_DO_READDIRPLUS; @@ -133,7 +138,7 @@ impl Fuse for VirtioFs { inode.get_attr()? }; - Ok(fuse_attr_out::new(ATTRIBUTE_TIMEOUT, attr)) + Ok(fuse_attr_out::new(self.attribute_timeout(), attr)) } fn get_statx( @@ -160,7 +165,7 @@ impl Fuse for VirtioFs { inode.get_statx()? }; - Ok(fuse_statx_out::new(ATTRIBUTE_TIMEOUT, flags, statx)) + Ok(fuse_statx_out::new(self.attribute_timeout(), flags, statx)) } fn set_attr(&self, request: &Request, arg: &fuse_setattr_in) -> lx::Result { @@ -189,7 +194,7 @@ impl Fuse for VirtioFs { inode.set_attr(arg, request.uid())? }; - Ok(fuse_attr_out::new(ATTRIBUTE_TIMEOUT, attr)) + Ok(fuse_attr_out::new(self.attribute_timeout(), attr)) } fn lookup(&self, request: &Request, name: &lx::LxStr) -> lx::Result { @@ -215,11 +220,12 @@ impl Fuse for VirtioFs { fn open(&self, request: &Request, flags: u32) -> lx::Result { let inode = self.get_inode(request.node_id())?; self.check_open_readonly(&inode, flags)?; + self.preflight_file_insert()?; let file = inode.open(flags)?; let fh = self.insert_file(file)?; // TODO: Optionally allow caching. - Ok(fuse_open_out::new(fh, FOPEN_DIRECT_IO)) + Ok(fuse_open_out::new(fh, self.open_flags())) } fn create( @@ -233,6 +239,9 @@ impl Fuse for VirtioFs { } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + let path = inode.child_path(name)?; + self.preflight_create_inode(&inode, name, &path)?; + self.preflight_file_insert()?; let (new_inode, attr, file) = inode.create(name, arg.flags, arg.mode, request.uid(), request.gid())?; @@ -243,8 +252,13 @@ impl Fuse for VirtioFs { let file = VirtioFsFile::new(file, new_inode); let fh = self.insert_file(file)?; Ok(CreateOut { - entry: fuse_entry_out::new(node_id, ENTRY_TIMEOUT, ATTRIBUTE_TIMEOUT, attr), - open: fuse_open_out::new(fh, FOPEN_DIRECT_IO), + entry: fuse_entry_out::new( + node_id, + self.entry_timeout(), + self.attribute_timeout(), + attr, + ), + open: fuse_open_out::new(fh, self.open_flags()), }) } @@ -259,12 +273,14 @@ impl Fuse for VirtioFs { } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + let path = inode.child_path(name)?; + self.preflight_new_inode_path(&path)?; let (new_inode, attr) = inode.mkdir(name, arg.mode, request.uid(), request.gid())?; let (_, node_id) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( node_id, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } @@ -280,14 +296,16 @@ impl Fuse for VirtioFs { } let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + let path = inode.child_path(name)?; + self.preflight_new_inode_path(&path)?; let (new_inode, attr) = inode.mknod(name, arg.mode, request.uid(), request.gid(), arg.rdev)?; let (_, node_id) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( node_id, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } @@ -301,6 +319,7 @@ impl Fuse for VirtioFs { if self.is_synthetic_root(request.node_id()) { return Err(lx::Error::EROFS); } + microvm::fs::check_symlink_allowed(self)?; let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; let (new_inode, attr) = inode.symlink(name, target, request.uid(), request.gid())?; @@ -308,8 +327,8 @@ impl Fuse for VirtioFs { let (_, node_id) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( node_id, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } @@ -322,6 +341,7 @@ impl Fuse for VirtioFs { let target_inode = self.get_inode(target)?; self.check_writable(&inode)?; let alias = inode.child_path(name)?; + self.preflight_alias_add(&target_inode, &alias)?; let attr = inode.link(name, &target_inode)?; target_inode.add_alias(alias); @@ -332,8 +352,8 @@ impl Fuse for VirtioFs { // Use the target inode as the reply, with refreshed attributes. Ok(fuse_entry_out::new( target, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } @@ -426,6 +446,7 @@ impl Fuse for VirtioFs { self.check_writable(&inode)?; let old_path = inode.child_path(name)?; let new_path = new_inode.child_path(new_name)?; + self.preflight_rename_aliases(inode.volume_id(), &old_path, &new_path)?; inode.rename(name, &new_inode, new_name, flags)?; let mut inodes = self.inner.inodes.write(); inodes.remove_alias_prefix(inode.volume_id(), &new_path); @@ -585,6 +606,7 @@ impl VirtioFs { inodes: RwLock::new(inodes), files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Direct, + microvm_profile: None, }), }) } @@ -602,21 +624,35 @@ impl VirtioFs { inodes: RwLock::new(InodeMap::new(true)), files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Aggregate(AggregateState::new()), + microvm_profile: None, }), } } fn lookup_helper(&self, inode: &VirtioFsInode, name: &lx::LxStr) -> lx::Result { let (new_inode, attr) = inode.lookup_child(name)?; + self.preflight_inode_insert(&new_inode)?; let (_, new_inode_nr) = self.insert_inode(new_inode)?; Ok(fuse_entry_out::new( new_inode_nr, - ENTRY_TIMEOUT, - ATTRIBUTE_TIMEOUT, + self.entry_timeout(), + self.attribute_timeout(), attr, )) } + fn attribute_timeout(&self) -> Duration { + microvm::fs::attribute_timeout(self) + } + + fn entry_timeout(&self) -> Duration { + microvm::fs::entry_timeout(self) + } + + fn open_flags(&self) -> u32 { + microvm::fs::open_flags(self) + } + /// Removes a file or directory. fn unlink_helper(&self, request: &Request, name: &lx::LxStr, flags: i32) -> lx::Result<()> { if self.is_synthetic_root(request.node_id()) { @@ -635,10 +671,12 @@ impl VirtioFs { /// Retrieve the inode with the specified node ID. fn get_inode(&self, node_id: u64) -> lx::Result> { - self.inner.inodes.read().get(node_id).ok_or_else(|| { + let inode = self.inner.inodes.read().get(node_id).ok_or_else(|| { tracelimit::warn_ratelimited!(node_id, "request for unknown inode"); lx::Error::EINVAL - }) + })?; + inode.validate_confined()?; + Ok(inode) } /// Insert a new inode, and returns the assigned node ID as well as a reference to the inode. @@ -647,7 +685,7 @@ impl VirtioFs { /// number, the existing inode is returned, not the passed in one. fn insert_inode(&self, inode: VirtioFsInode) -> lx::Result<(Arc, u64)> { let mut inodes = self.inner.inodes.write(); - inodes.insert(inode) + microvm::fs::insert_inode(self, &mut inodes, inode) } /// Retrieve the file object with the specified file handle. @@ -664,6 +702,7 @@ impl VirtioFs { /// Insert a new file object, and return the assigned file handle. fn insert_file(&self, file: VirtioFsFile) -> lx::Result { let mut files = self.inner.files.write(); + microvm::fs::validate_file_insert(self, &files)?; files.insert(Arc::new(file)).ok_or(lx::Error::ENOSPC) } diff --git a/vm/devices/virtio/virtiofs/src/microvm/device.rs b/vm/devices/virtio/virtiofs/src/microvm/device.rs new file mode 100644 index 00000000000..f7623569309 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/device.rs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM virtio-fs device construction. + +use super::profile::MICROVM_MOUNT_TAG; +use super::profile::MICROVM_REQUEST_QUEUES; +use super::profile::MicroVmVirtioFsProfile; +use crate::VirtioFs; +use crate::virtio::VirtioFsDevice; +use std::path::Path; +use std::sync::Arc; +use virtio::spec::VirtioDeviceFeatures; +use vmcore::vm_task::VmTaskDriverSource; + +impl VirtioFsDevice { + /// Creates the fixed no-DAX microVM virtio-fs device. + /// + /// The filesystem must have been constructed with + /// [`VirtioFs::new_microvm`], using this exact profile. This keeps the + /// device shape and host-enforced filesystem policy inseparable. + pub fn new_microvm( + driver_source: &VmTaskDriverSource, + profile: MicroVmVirtioFsProfile, + fs: VirtioFs, + notify_corruption: Option>, + ) -> anyhow::Result { + anyhow::ensure!( + fs.microvm_profile() == Some(&profile), + "microVM device profile does not match the HostFs attachment" + ); + let attachment_id = profile.attachment_id().to_owned(); + let mut device = Self::with_num_request_queues( + driver_source, + MICROVM_MOUNT_TAG, + fs, + 0, + notify_corruption, + MICROVM_REQUEST_QUEUES, + ); + device.microvm_attachment_id = Some(attachment_id); + Ok(device) + } + + /// Creates the fixed no-DAX microVM HostFs device directly from the + /// `Microvm` resource fields and its process-local host root. + pub fn new_microvm_hostfs( + driver_source: &VmTaskDriverSource, + stable_id: String, + root_identity: Vec, + read_only: bool, + root_path: impl AsRef, + notify_corruption: Option>, + ) -> anyhow::Result { + let profile = MicroVmVirtioFsProfile::from_attachment(stable_id, root_identity, read_only)?; + let fs = VirtioFs::new_microvm(root_path, profile.clone())?; + Self::new_microvm(driver_source, profile, fs, notify_corruption) + } +} + +pub(crate) fn device_features(device: &VirtioFsDevice) -> VirtioDeviceFeatures { + VirtioDeviceFeatures::new() + .with_ring_event_idx(true) + .with_ring_indirect_desc(true) + .with_ring_packed(device.microvm_attachment_id.is_none()) +} + +pub(crate) fn validate_shared_memory(device: &VirtioFsDevice) -> anyhow::Result<()> { + anyhow::ensure!( + device.microvm_attachment_id.is_none(), + "the microVM virtio-fs profile does not expose shared memory" + ); + Ok(()) +} + +pub(crate) fn validate_queue_features( + device: &VirtioFsDevice, + features: &VirtioDeviceFeatures, +) -> anyhow::Result<()> { + anyhow::ensure!( + device.microvm_attachment_id.is_none() || !features.ring_packed(), + "the microVM virtio-fs profile forbids packed virtqueues" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::MICROVM_ATTACHMENT_ID; + use crate::profile::microvm_root_identity; + use pal_async::DefaultDriver; + use pal_async::async_test; + use virtio::VirtioDevice; + use vmcore::vm_task::SingleDriverBackend; + + #[async_test] + async fn microvm_profile_has_fixed_device_shape(driver: DefaultDriver) { + let temporary_directory = tempfile::tempdir().unwrap(); + let root_identity = microvm_root_identity(temporary_directory.path()).unwrap(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + root_identity, + true, + temporary_directory.path(), + None, + ) + .unwrap(); + + assert_eq!(device.config.num_request_queues, MICROVM_REQUEST_QUEUES); + assert_eq!(device.traits().max_queues, 2); + assert_eq!(device.traits().shared_memory.size, 0); + assert!(!device.traits().device_features.ring_packed()); + assert_eq!(&device.config.tag[..7], b"microvm"); + } + + #[async_test] + async fn microvm_device_rejects_a_non_profile_attachment(driver: DefaultDriver) { + let temporary_directory = tempfile::tempdir().unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(temporary_directory.path()).unwrap(), + true, + ) + .unwrap(); + let fs = VirtioFs::new(temporary_directory.path(), None).unwrap(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + + assert!(VirtioFsDevice::new_microvm(&driver_source, profile, fs, None).is_err()); + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs.rs b/vm/devices/virtio/virtiofs/src/microvm/fs.rs new file mode 100644 index 00000000000..0514ab59152 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/fs.rs @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM filesystem construction and policy hooks. + +use super::profile::MicroVmVirtioFsProfile; +use super::saved_state::MAX_HANDLES; +use crate::ATTRIBUTE_TIMEOUT; +use crate::ENTRY_TIMEOUT; +use crate::HandleMap; +use crate::InodeMap; +use crate::VirtioFs; +use crate::VirtioFsInner; +use crate::VirtioFsMode; +use crate::file::VirtioFsFile; +use crate::inode::VirtioFsInode; +use crate::inode::VirtioFsVolume; +use fuse::SessionInfo; +use fuse::protocol::FOPEN_DIRECT_IO; +use fuse::protocol::FUSE_ROOT_ID; +use lxutil::LxVolumeOptions; +use parking_lot::RwLock; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; + +pub(crate) fn attribute_timeout(fs: &VirtioFs) -> Duration { + fs.microvm_profile().map_or( + ATTRIBUTE_TIMEOUT, + MicroVmVirtioFsProfile::attribute_cache_timeout, + ) +} + +pub(crate) fn entry_timeout(fs: &VirtioFs) -> Duration { + fs.microvm_profile() + .map_or(ENTRY_TIMEOUT, MicroVmVirtioFsProfile::entry_cache_timeout) +} + +pub(crate) fn open_flags(fs: &VirtioFs) -> u32 { + if fs + .microvm_profile() + .is_none_or(MicroVmVirtioFsProfile::direct_io) + { + FOPEN_DIRECT_IO + } else { + 0 + } +} + +pub(crate) fn configure_session(fs: &VirtioFs, info: &mut SessionInfo) { + if let Some(profile) = fs.inner.microvm_profile.as_ref() { + let policy = profile.fuse_negotiation(); + // Session has already selected its supported protocol version; + // make the profile-controlled portion of the response explicit. + info.max_write = policy.maximum_write(); + } +} + +pub(crate) fn check_symlink_allowed(fs: &VirtioFs) -> lx::Result<()> { + // The generic LxVolume API cannot pin every ancestor while resolving + // a symlink. The microVM profile therefore does not create links that + // could later turn a checked relative lookup into an escape. + if fs.is_microvm() { + return Err(lx::Error::ENOTSUP); + } + Ok(()) +} + +pub(crate) fn insert_inode( + fs: &VirtioFs, + inodes: &mut InodeMap, + inode: VirtioFsInode, +) -> lx::Result<(Arc, u64)> { + if fs.is_microvm() { + inodes.insert_microvm(inode) + } else { + inodes.insert(inode) + } +} + +pub(crate) fn validate_file_insert( + fs: &VirtioFs, + files: &HandleMap>, +) -> lx::Result<()> { + if fs.is_microvm() && (files.values.len() >= MAX_HANDLES || !files.can_insert()) { + return Err(lx::Error::ENOSPC); + } + Ok(()) +} + +impl VirtioFs { + /// Creates a filesystem attachment for the fixed microVM profile. + /// + /// `root_path` is deliberately consumed only while opening the attachment; + /// it is not retained in the filesystem state. + pub fn new_microvm( + root_path: impl AsRef, + profile: MicroVmVirtioFsProfile, + ) -> anyhow::Result { + let root_path = root_path.as_ref(); + profile.validate_root_path(root_path)?; + let mut mount_options = LxVolumeOptions::new(); + mount_options.readonly(profile.is_readonly()).sandbox(true); + let volume = mount_options.new_volume(root_path)?; + let mut inodes = InodeMap::new(false); + let volume = Arc::new(VirtioFsVolume::new_with_strict_paths( + volume, + 0, + profile.is_readonly(), + true, + )); + let (root_inode, root_stat) = VirtioFsInode::new(Arc::clone(&volume), PathBuf::new())?; + profile.validate_opened_root(root_path, &root_stat)?; + if inodes.insert(root_inode)?.1 != FUSE_ROOT_ID { + anyhow::bail!("microVM virtio-fs root received an invalid node ID"); + } + Ok(Self { + inner: Arc::new(VirtioFsInner { + inodes: RwLock::new(inodes), + files: RwLock::new(HandleMap::new()), + mode: VirtioFsMode::Direct, + microvm_profile: Some(profile), + }), + }) + } + + pub(crate) fn microvm_profile(&self) -> Option<&MicroVmVirtioFsProfile> { + self.inner.microvm_profile.as_ref() + } + + pub(crate) fn is_microvm(&self) -> bool { + self.inner.microvm_profile.is_some() + } + + pub(crate) fn preflight_inode_insert(&self, inode: &VirtioFsInode) -> lx::Result<()> { + if self.is_microvm() { + self.inner.inodes.read().preflight_microvm_insert(inode) + } else { + Ok(()) + } + } + + pub(crate) fn preflight_new_inode_path(&self, path: &Path) -> lx::Result<()> { + if self.is_microvm() { + self.inner.inodes.read().preflight_microvm_new_inode(path) + } else { + Ok(()) + } + } + + pub(crate) fn preflight_create_inode( + &self, + parent: &VirtioFsInode, + name: &lx::LxStr, + path: &Path, + ) -> lx::Result<()> { + if !self.is_microvm() { + return Ok(()); + } + match parent.lookup_child(name) { + Ok((inode, _)) => self.preflight_inode_insert(&inode), + Err(error) if error == lx::Error::ENOENT => self.preflight_new_inode_path(path), + Err(error) => Err(error), + } + } + + pub(crate) fn preflight_alias_add(&self, inode: &VirtioFsInode, path: &Path) -> lx::Result<()> { + if self.is_microvm() { + self.inner + .inodes + .read() + .preflight_microvm_alias_add(inode, path) + } else { + Ok(()) + } + } + + pub(crate) fn preflight_rename_aliases( + &self, + volume_id: u32, + old: &Path, + new: &Path, + ) -> lx::Result<()> { + if self.is_microvm() { + self.inner + .inodes + .read() + .preflight_microvm_rename(volume_id, old, new) + } else { + Ok(()) + } + } + + pub(crate) fn preflight_file_insert(&self) -> lx::Result<()> { + let files = self.inner.files.read(); + validate_file_insert(self, &files) + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs new file mode 100644 index 00000000000..a4a23b6598a --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for microVM filesystem limits. + +use super::profile::MICROVM_ATTACHMENT_ID; +use super::profile::MicroVmVirtioFsProfile; +use super::profile::microvm_root_identity; +use super::saved_state::MAX_ALIASES_PER_INODE; +use super::saved_state::MAX_PATH_BYTES; +use super::state::validate_relative_path; +use crate::VirtioFs; +use fuse::protocol::FUSE_ROOT_ID; +use std::path::Path; +use std::path::PathBuf; +use tempfile::tempdir; +use test_with_tracing::test; + +fn profile(root_path: &Path) -> MicroVmVirtioFsProfile { + MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root_path).unwrap(), + true, + ) + .unwrap() +} + +#[test] +fn microvm_runtime_path_and_map_limits_are_enforced() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let fs = VirtioFs::new_microvm(temporary_directory.path(), profile).unwrap(); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + let oversized_name = vec![b'x'; MAX_PATH_BYTES + 1]; + assert_eq!( + root.lookup_child(lx::LxStr::from_bytes(&oversized_name)) + .err() + .unwrap(), + lx::Error::E2BIG + ); + + fs.inner.inodes.write().inodes_by_node_id.next_handle = 0; + assert_eq!( + fs.preflight_new_inode_path(Path::new("new-entry")) + .unwrap_err(), + lx::Error::ENOSPC + ); + fs.inner.files.write().next_handle = 0; + assert_eq!(fs.preflight_file_insert().unwrap_err(), lx::Error::ENOSPC); +} + +#[test] +fn microvm_alias_limit_is_enforced_before_linking() { + let temporary_directory = tempdir().unwrap(); + std::fs::write(temporary_directory.path().join("entry"), b"data").unwrap(); + let profile = profile(temporary_directory.path()); + let fs = VirtioFs::new_microvm(temporary_directory.path(), profile).unwrap(); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + let (inode, _) = fs + .insert_inode( + root.lookup_child(lx::LxStr::from_bytes(b"entry")) + .unwrap() + .0, + ) + .unwrap(); + for index in 1..MAX_ALIASES_PER_INODE { + inode.add_alias(PathBuf::from(format!("alias-{index}"))); + } + assert_eq!( + fs.preflight_alias_add(&inode, Path::new("alias-overflow")) + .unwrap_err(), + lx::Error::ENOSPC + ); +} + +#[test] +fn relative_path_validation_rejects_platform_escapes() { + assert!(validate_relative_path(Path::new("../outside"), true).is_err()); + assert!(validate_relative_path(Path::new("/outside"), true).is_err()); + assert!(validate_relative_path(Path::new("alternate:name"), true).is_err()); +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/inode.rs b/vm/devices/virtio/virtiofs/src/microvm/inode.rs new file mode 100644 index 00000000000..8baba75ad30 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/inode.rs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM path confinement. + +use super::saved_state::MAX_PATH_BYTES; +use super::state::relative_path_encoded_len; +use super::state::validate_relative_path; +use crate::inode::VirtioFsInode; +use crate::inode::VirtioFsVolume; +use lxutil::LxVolume; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +impl VirtioFsVolume { + pub(crate) fn new_with_strict_paths( + volume: LxVolume, + id: u32, + readonly: bool, + strict_paths: bool, + ) -> Self { + Self { + volume: Arc::new(volume), + id, + readonly, + strict_paths, + } + } + + pub(crate) fn strict_paths(&self) -> bool { + self.strict_paths + } +} + +impl VirtioFsInode { + /// Checks that a microVM path is relative and has no symlink component. + /// + /// LxVolume intentionally does not promise this property for arbitrary + /// callers, so the profile applies a conservative check before each + /// namespace operation. A handle-relative, race-free traversal primitive + /// remains necessary for a complete cross-platform guarantee. + pub(crate) fn validate_confined(&self) -> lx::Result<()> { + if !self.volume.strict_paths() { + return Ok(()); + } + for path in self.aliases() { + validate_relative_path(&path, true)?; + let mut prefix = PathBuf::new(); + for component in path.components() { + let Component::Normal(component) = component else { + return Err(lx::Error::EINVAL); + }; + prefix.push(component); + let stat = self.volume.lstat(&prefix)?; + if stat.mode & lx::S_IFMT == lx::S_IFLNK { + return Err(lx::Error::ELOOP); + } + } + } + Ok(()) + } +} + +pub(crate) fn child_name_disallowed(volume: &VirtioFsVolume, name: &[u8]) -> bool { + volume.strict_paths() && (name.contains(&b'\\') || name.contains(&b':')) +} + +pub(crate) fn validate_child_path(volume: &VirtioFsVolume, path: &Path) -> lx::Result<()> { + validate_relative_path(path, volume.strict_paths())?; + if volume.strict_paths() && relative_path_encoded_len(path)? > MAX_PATH_BYTES { + return Err(lx::Error::E2BIG); + } + Ok(()) +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/limits.rs b/vm/devices/virtio/virtiofs/src/microvm/limits.rs new file mode 100644 index 00000000000..23d7aa81ce9 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/limits.rs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM inode, alias, and path allocation limits. + +use super::saved_state::MAX_ALIAS_BYTES; +use super::saved_state::MAX_ALIASES; +use super::saved_state::MAX_ALIASES_PER_INODE; +use super::saved_state::MAX_INODES; +use super::saved_state::MAX_PATH_BYTES; +use super::state::relative_path_encoded_len; +use super::state::validate_relative_path; +use crate::InodeMap; +use crate::inode::VirtioFsInode; +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::Arc; + +impl InodeMap { + pub(crate) fn insert_microvm( + &mut self, + inode: VirtioFsInode, + ) -> lx::Result<(Arc, u64)> { + self.preflight_microvm_insert(&inode)?; + self.insert(inode) + } + + pub(crate) fn preflight_microvm_insert(&self, inode: &VirtioFsInode) -> lx::Result<()> { + let path = inode.clone_path(); + Self::validate_microvm_alias_path(&path)?; + if let Some((existing, _)) = self.inodes_by_key.get(&inode.dedup_key()) { + return self.preflight_microvm_alias_add(existing, &path); + } + self.preflight_microvm_new_inode(&path) + } + + pub(crate) fn preflight_microvm_new_inode(&self, path: &Path) -> lx::Result<()> { + Self::validate_microvm_alias_path(path)?; + if self.inodes_by_node_id.values.len() >= MAX_INODES || !self.inodes_by_node_id.can_insert() + { + return Err(lx::Error::ENOSPC); + } + let (alias_count, alias_bytes) = self.microvm_alias_usage()?; + if alias_count >= MAX_ALIASES { + return Err(lx::Error::ENOSPC); + } + let path_bytes = relative_path_encoded_len(path)?; + if alias_bytes + .checked_add(path_bytes) + .is_none_or(|bytes| bytes > MAX_ALIAS_BYTES) + { + return Err(lx::Error::E2BIG); + } + Ok(()) + } + + pub(crate) fn preflight_microvm_alias_add( + &self, + inode: &VirtioFsInode, + path: &Path, + ) -> lx::Result<()> { + Self::validate_microvm_alias_path(path)?; + let aliases = inode.aliases(); + if aliases.iter().any(|alias| alias == path) { + return Ok(()); + } + if aliases.len() >= MAX_ALIASES_PER_INODE { + return Err(lx::Error::ENOSPC); + } + let (alias_count, alias_bytes) = self.microvm_alias_usage()?; + if alias_count >= MAX_ALIASES { + return Err(lx::Error::ENOSPC); + } + let path_bytes = relative_path_encoded_len(path)?; + if alias_bytes + .checked_add(path_bytes) + .is_none_or(|bytes| bytes > MAX_ALIAS_BYTES) + { + return Err(lx::Error::E2BIG); + } + Ok(()) + } + + pub(crate) fn preflight_microvm_rename( + &self, + volume_id: u32, + old: &Path, + new: &Path, + ) -> lx::Result<()> { + Self::validate_microvm_alias_path(new)?; + let mut alias_count = 0usize; + let mut alias_bytes = 0usize; + for inode in self.inodes_by_node_id.values.values() { + let mut aliases: BTreeSet<_> = inode.aliases().into_iter().collect(); + if inode.volume_id() == volume_id { + aliases.retain(|alias| !alias.starts_with(new)); + let replacements: Vec<_> = aliases + .iter() + .filter_map(|alias| { + alias.strip_prefix(old).ok().map(|suffix| { + let mut replacement = new.to_path_buf(); + replacement.push(suffix); + (alias.clone(), replacement) + }) + }) + .collect(); + for (old_alias, new_alias) in replacements { + aliases.remove(&old_alias); + aliases.insert(new_alias); + } + } + if aliases.len() > MAX_ALIASES_PER_INODE { + return Err(lx::Error::ENOSPC); + } + alias_count = alias_count + .checked_add(aliases.len()) + .ok_or(lx::Error::ENOSPC)?; + if alias_count > MAX_ALIASES { + return Err(lx::Error::ENOSPC); + } + for alias in aliases { + Self::validate_microvm_alias_path(&alias)?; + alias_bytes = alias_bytes + .checked_add(relative_path_encoded_len(&alias)?) + .ok_or(lx::Error::E2BIG)?; + if alias_bytes > MAX_ALIAS_BYTES { + return Err(lx::Error::E2BIG); + } + } + } + Ok(()) + } + + fn microvm_alias_usage(&self) -> lx::Result<(usize, usize)> { + let mut count = 0usize; + let mut bytes = 0usize; + for inode in self.inodes_by_node_id.values.values() { + let aliases = inode.aliases(); + count = count.checked_add(aliases.len()).ok_or(lx::Error::ENOSPC)?; + for alias in aliases { + bytes = bytes + .checked_add(relative_path_encoded_len(&alias)?) + .ok_or(lx::Error::E2BIG)?; + } + } + Ok((count, bytes)) + } + + fn validate_microvm_alias_path(path: &Path) -> lx::Result<()> { + validate_relative_path(path, true)?; + if relative_path_encoded_len(path)? > MAX_PATH_BYTES { + return Err(lx::Error::E2BIG); + } + Ok(()) + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/mod.rs b/vm/devices/virtio/virtiofs/src/microvm/mod.rs new file mode 100644 index 00000000000..e06f9a0a585 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/mod.rs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM-specific virtio-fs profile, policy, and device support. + +pub(crate) mod device; +pub(crate) mod fs; +#[cfg(test)] +mod fs_tests; +pub(crate) mod inode; +mod limits; +pub mod profile; +pub(crate) mod resolver; +pub(crate) mod saved_state; +pub(crate) mod state; + +const MAX_FUSE_REQUEST_HEADER_BYTES: usize = 4096; + +pub(crate) const MAX_FUSE_REQUEST_BYTES: usize = + profile::MICROVM_FUSE_MAX_WRITE as usize + MAX_FUSE_REQUEST_HEADER_BYTES; + +const _: () = assert!(MAX_FUSE_REQUEST_BYTES > profile::MICROVM_FUSE_MAX_WRITE as usize); diff --git a/vm/devices/virtio/virtiofs/src/microvm/profile.rs b/vm/devices/virtio/virtiofs/src/microvm/profile.rs new file mode 100644 index 00000000000..632d3693b3c --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/profile.rs @@ -0,0 +1,347 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The fixed virtio-fs contract used by the microVM profile. + +#[cfg(windows)] +use anyhow::Context as _; +use std::path::Path; +use std::time::Duration; + +/// Stable device-private attachment identifier for the microVM share. +pub const MICROVM_ATTACHMENT_ID: &str = "fs:microvm0"; + +/// The only mount tag accepted by the microVM ABI. +pub const MICROVM_MOUNT_TAG: &str = "microvm"; + +/// The number of FUSE request queues in the microVM ABI. +pub const MICROVM_REQUEST_QUEUES: u32 = 1; + +/// The minimum FUSE minor version with which the microVM protocol contract is +/// compatible. +pub const MICROVM_FUSE_MIN_MINOR: u32 = 31; + +/// The FUSE major version used by the microVM protocol contract. +pub const MICROVM_FUSE_MAJOR: u32 = 7; + +/// The largest FUSE request payload negotiated by the microVM profile. +pub const MICROVM_FUSE_MAX_WRITE: u32 = 1024 * 1024; + +const MAX_ROOT_IDENTITY_SIZE: usize = 4096; + +/// Fixed portions of the FUSE negotiation contract. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MicroVmFuseNegotiationPolicy; + +impl MicroVmFuseNegotiationPolicy { + /// Returns the required FUSE major protocol version. + pub const fn protocol_major(self) -> u32 { + MICROVM_FUSE_MAJOR + } + + /// Returns the oldest compatible FUSE minor protocol version. + pub const fn minimum_minor(self) -> u32 { + MICROVM_FUSE_MIN_MINOR + } + + /// Returns the exact maximum FUSE write size. + pub const fn maximum_write(self) -> u32 { + MICROVM_FUSE_MAX_WRITE + } + + /// Returns whether `READDIRPLUS` and `READDIRPLUS_AUTO` are requested + /// whenever the guest advertises them. + pub const fn requests_readdirplus(self) -> bool { + true + } + + /// Returns whether direct-I/O shared mappings are requested whenever the + /// guest advertises the extended flag. + pub const fn requests_direct_io_allow_mmap(self) -> bool { + true + } +} + +/// Access policy enforced by the host filesystem implementation. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MicroVmAccessMode { + /// Reject mutations before they reach the host filesystem. + ReadOnly, + /// Permit the common, host-supported mutation set. + ReadWrite, +} + +/// Errors in the fixed microVM virtio-fs attachment contract. +#[derive(Debug, thiserror::Error)] +pub enum MicroVmProfileError { + /// The resource did not identify the only microVM filesystem attachment. + #[error("microVM virtio-fs stable ID must be {MICROVM_ATTACHMENT_ID}")] + InvalidStableId, + /// The identity did not use the documented bounded attachment format. + #[error("microVM virtio-fs root identity is empty or exceeds {MAX_ROOT_IDENTITY_SIZE} bytes")] + InvalidRootIdentity, + /// The supplied host attachment does not match the resource identity. + #[error("microVM virtio-fs host root identity does not match the attachment")] + RootIdentityMismatch, +} + +/// Immutable profile settings for the microVM virtio-fs device. +/// +/// The profile intentionally contains no host path. The host path is a +/// process-local attachment supplied when constructing or restoring the +/// filesystem and is never part of device-private saved state. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MicroVmVirtioFsProfile { + stable_id: String, + root_identity: Vec, + access_mode: MicroVmAccessMode, +} + +impl MicroVmVirtioFsProfile { + /// Builds the constrained microVM profile from the resource attachment + /// fields. `stable_id`, `root_identity`, and `read_only` correspond + /// exactly to `VirtioFsProfile::Microvm`. + pub fn from_attachment( + stable_id: String, + root_identity: Vec, + read_only: bool, + ) -> Result { + if stable_id != MICROVM_ATTACHMENT_ID { + return Err(MicroVmProfileError::InvalidStableId); + } + if root_identity.is_empty() || root_identity.len() > MAX_ROOT_IDENTITY_SIZE { + return Err(MicroVmProfileError::InvalidRootIdentity); + } + Ok(Self { + stable_id, + root_identity, + access_mode: if read_only { + MicroVmAccessMode::ReadOnly + } else { + MicroVmAccessMode::ReadWrite + }, + }) + } + + /// Returns the stable attachment ID used to match a restore attachment. + pub fn attachment_id(&self) -> &str { + &self.stable_id + } + + /// Returns the opaque, platform-specific root identity from the resource + /// attachment. + pub fn root_identity(&self) -> &[u8] { + &self.root_identity + } + + /// Validates that `root_path` is the host attachment identified by this + /// profile. + pub fn validate_root_path( + &self, + root_path: impl AsRef, + ) -> Result<(), MicroVmProfileError> { + match microvm_root_identity(root_path) { + Ok(identity) if identity == self.root_identity => Ok(()), + Ok(_) | Err(_) => Err(MicroVmProfileError::RootIdentityMismatch), + } + } + + /// Validates the root object reached through an already-opened volume and + /// confirms that the attachment path still names the configured root. + pub(crate) fn validate_opened_root( + &self, + root_path: impl AsRef, + stat: &lx::Stat, + ) -> Result<(), MicroVmProfileError> { + #[cfg(unix)] + { + let mut identity = b"openvmm-microvm-fs-unix-v1\0".to_vec(); + identity.extend_from_slice(&stat.device_nr.to_le_bytes()); + identity.extend_from_slice(&stat.inode_nr.to_le_bytes()); + if identity != self.root_identity { + return Err(MicroVmProfileError::RootIdentityMismatch); + } + } + + #[cfg(windows)] + { + const PREFIX: &[u8] = b"openvmm-microvm-fs-windows-v1\0"; + let identity = self + .root_identity + .strip_prefix(PREFIX) + .ok_or(MicroVmProfileError::RootIdentityMismatch)?; + let length = identity + .get(..size_of::()) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .ok_or(MicroVmProfileError::RootIdentityMismatch)?; + let volume_bytes = usize::try_from(length) + .ok() + .and_then(|length| length.checked_mul(size_of::())) + .ok_or(MicroVmProfileError::RootIdentityMismatch)?; + let file_id = identity + .get(size_of::() + volume_bytes..) + .and_then(|bytes| bytes.try_into().ok()) + .map(u64::from_le_bytes) + .ok_or(MicroVmProfileError::RootIdentityMismatch)?; + if file_id != stat.inode_nr { + return Err(MicroVmProfileError::RootIdentityMismatch); + } + } + + self.validate_root_path(root_path) + } + + /// Returns the fixed FUSE mount tag. + pub const fn mount_tag(&self) -> &'static str { + MICROVM_MOUNT_TAG + } + + /// Returns the fixed request queue count. + pub const fn request_queues(&self) -> u32 { + MICROVM_REQUEST_QUEUES + } + + /// Returns the host-enforced access policy. + pub const fn access_mode(&self) -> MicroVmAccessMode { + self.access_mode + } + + /// Returns whether host mutations must be rejected. + pub const fn is_readonly(&self) -> bool { + matches!(self.access_mode, MicroVmAccessMode::ReadOnly) + } + + /// Returns the entry-cache lifetime required by the ABI. + pub const fn entry_cache_timeout(&self) -> Duration { + Duration::ZERO + } + + /// Returns the attribute-cache lifetime required by the ABI. + pub const fn attribute_cache_timeout(&self) -> Duration { + Duration::ZERO + } + + /// Returns whether direct I/O is required for every opened file. + pub const fn direct_io(&self) -> bool { + true + } + + /// Returns whether the ABI exposes a DAX/shared-memory region. + pub const fn has_shared_memory(&self) -> bool { + false + } + + /// Returns whether the ABI permits packed virtqueues. + pub const fn packed_rings(&self) -> bool { + false + } + + /// Returns the fixed FUSE negotiation policy. + pub const fn fuse_negotiation(&self) -> MicroVmFuseNegotiationPolicy { + MicroVmFuseNegotiationPolicy + } +} + +/// Computes the portable root identity expected in +/// `VirtioFsProfile::Microvm::root_identity`. +pub fn microvm_root_identity(root_path: impl AsRef) -> anyhow::Result> { + #[expect( + clippy::disallowed_methods, + reason = "the attachment identity must resolve the host root's final target" + )] + let canonical = std::fs::canonicalize(root_path)?; + let metadata = std::fs::symlink_metadata(&canonical)?; + anyhow::ensure!( + metadata.file_type().is_dir() && !metadata.file_type().is_symlink(), + "microVM filesystem root is not a plain directory" + ); + + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + let mut identity = b"openvmm-microvm-fs-unix-v1\0".to_vec(); + identity.extend_from_slice(&metadata.dev().to_le_bytes()); + identity.extend_from_slice(&metadata.ino().to_le_bytes()); + Ok(identity) + } + + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + use std::path::Component; + + let stat = pal::windows::fs::query_stat_lx_by_name(&canonical) + .context("failed to query the microVM filesystem root identity")?; + anyhow::ensure!( + stat.FileId != 0, + "microVM filesystem root has no stable file identity" + ); + let volume = canonical + .components() + .next() + .and_then(|component| match component { + Component::Prefix(prefix) => Some(prefix.as_os_str()), + _ => None, + }) + .context("microVM filesystem root has no volume prefix")?; + let volume = volume.encode_wide().collect::>(); + let volume_length = u32::try_from(volume.len()) + .context("microVM filesystem volume identity is too long")?; + let mut identity = b"openvmm-microvm-fs-windows-v1\0".to_vec(); + identity.extend_from_slice(&volume_length.to_le_bytes()); + identity.extend(volume.into_iter().flat_map(u16::to_le_bytes)); + identity.extend_from_slice(&stat.FileId.to_le_bytes()); + Ok(identity) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_is_the_fixed_microvm_abi() { + let temporary_directory = tempfile::tempdir().unwrap(); + let root_identity = microvm_root_identity(temporary_directory.path()).unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + root_identity, + true, + ) + .unwrap(); + assert_eq!(profile.attachment_id(), "fs:microvm0"); + assert_eq!(profile.mount_tag(), "microvm"); + assert_eq!(profile.request_queues(), 1); + assert!(profile.is_readonly()); + assert_eq!(profile.entry_cache_timeout(), Duration::ZERO); + assert_eq!(profile.attribute_cache_timeout(), Duration::ZERO); + assert!(profile.direct_io()); + assert!(!profile.has_shared_memory()); + assert!(!profile.packed_rings()); + assert_eq!(profile.fuse_negotiation().protocol_major(), 7); + assert_eq!(profile.fuse_negotiation().minimum_minor(), 31); + assert_eq!(profile.fuse_negotiation().maximum_write(), 1024 * 1024); + profile + .validate_root_path(temporary_directory.path()) + .unwrap(); + } + + #[test] + fn profile_rejects_an_attachment_identity_mismatch() { + let temporary_directory = tempfile::tempdir().unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + b"not-a-root-identity".to_vec(), + false, + ) + .unwrap(); + + assert!( + profile + .validate_root_path(temporary_directory.path()) + .is_err() + ); + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs new file mode 100644 index 00000000000..0b2632efe35 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM resource-profile resolution. + +use super::profile::MICROVM_MOUNT_TAG; +use crate::virtio::VirtioFsDevice; +use virtio_resources::fs::VirtioFsBackend; +use virtio_resources::fs::VirtioFsHandle; +use virtio_resources::fs::microvm::VirtioFsProfile; +use vmcore::vm_task::VmTaskDriverSource; + +pub(crate) fn resolve( + resource: &VirtioFsHandle, + driver_source: &VmTaskDriverSource, +) -> anyhow::Result> { + let device = match &resource.profile { + VirtioFsProfile::Standard => return Ok(None), + VirtioFsProfile::Microvm { + stable_id, + root_identity, + read_only, + } => { + anyhow::ensure!( + resource.tag == MICROVM_MOUNT_TAG, + "microVM virtio-fs tag must be '{}'", + MICROVM_MOUNT_TAG + ); + let VirtioFsBackend::HostFs { + root_path, + mount_options, + } = &resource.fs + else { + anyhow::bail!("microVM virtio-fs requires a HostFs backend"); + }; + anyhow::ensure!( + mount_options.is_empty(), + "microVM virtio-fs does not accept HostFs mount options" + ); + VirtioFsDevice::new_microvm_hostfs( + driver_source, + stable_id.clone(), + root_identity.clone(), + *read_only, + root_path, + None, + )? + } + }; + Ok(Some(device)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::MICROVM_ATTACHMENT_ID; + use crate::profile::microvm_root_identity; + use crate::resolver::VirtioFsResolver; + use pal_async::DefaultDriver; + use pal_async::async_test; + use virtio::resolve::ResolvedVirtioDevice; + use virtio::resolve::VirtioResolveInput; + use virtio_resources::fs::VirtioFsAggregateChild; + use vm_resource::ResolveResource; + use vmcore::vm_task::SingleDriverBackend; + + fn resolve( + driver: DefaultDriver, + tag: &str, + fs: VirtioFsBackend, + ) -> anyhow::Result { + let root_path = match &fs { + VirtioFsBackend::HostFs { root_path, .. } => Some(root_path), + _ => None, + }; + let root_identity = root_path + .map(microvm_root_identity) + .transpose()? + .unwrap_or_else(|| vec![1]); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + VirtioFsResolver.resolve( + VirtioFsHandle { + tag: tag.to_owned(), + fs, + profile: VirtioFsProfile::Microvm { + stable_id: MICROVM_ATTACHMENT_ID.to_owned(), + root_identity, + read_only: true, + }, + }, + VirtioResolveInput { + driver_source: &driver_source, + }, + ) + } + + #[async_test] + async fn microvm_profile_rejects_non_fixed_tag(driver: DefaultDriver) { + let root = tempfile::tempdir().unwrap(); + let result = resolve( + driver, + "other", + VirtioFsBackend::HostFs { + root_path: root.path().to_string_lossy().into_owned(), + mount_options: String::new(), + }, + ); + assert!(result.is_err()); + } + + #[async_test] + async fn microvm_profile_rejects_mount_options(driver: DefaultDriver) { + let root = tempfile::tempdir().unwrap(); + let result = resolve( + driver, + MICROVM_MOUNT_TAG, + VirtioFsBackend::HostFs { + root_path: root.path().to_string_lossy().into_owned(), + mount_options: "ro".to_owned(), + }, + ); + assert!(result.is_err()); + } + + #[async_test] + async fn microvm_profile_rejects_non_host_backends(driver: DefaultDriver) { + let result = resolve( + driver, + MICROVM_MOUNT_TAG, + VirtioFsBackend::Aggregate { + children: vec![VirtioFsAggregateChild { + name: "child".to_owned(), + root_path: ".".to_owned(), + mount_options: String::new(), + }], + }, + ); + assert!(result.is_err()); + } + + #[async_test] + async fn microvm_profile_rejects_section_backend(driver: DefaultDriver) { + let result = resolve( + driver, + MICROVM_MOUNT_TAG, + VirtioFsBackend::SectionFs { + root_path: ".".to_owned(), + }, + ); + assert!(result.is_err()); + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs new file mode 100644 index 00000000000..02cd8eaeb2f --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Bounds of the microVM virtio-fs inode, alias, and handle tables. + +pub(crate) const MAX_INODES: usize = 4096; +pub(crate) const MAX_HANDLES: usize = 4096; +pub(crate) const MAX_PATH_BYTES: usize = 4096; +pub(crate) const MAX_ALIASES_PER_INODE: usize = 256; +pub(crate) const MAX_ALIASES: usize = 16 * 1024; +pub(crate) const MAX_ALIAS_BYTES: usize = 1024 * 1024; diff --git a/vm/devices/virtio/virtiofs/src/microvm/state.rs b/vm/devices/virtio/virtiofs/src/microvm/state.rs new file mode 100644 index 00000000000..f929085120c --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/state.rs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM virtio-fs relative path validation. + +use std::path::Path; + +pub(crate) fn relative_path_encoded_len(path: &Path) -> lx::Result { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + + Ok(path.as_os_str().as_bytes().len()) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + + path.as_os_str() + .encode_wide() + .count() + .checked_mul(size_of::()) + .ok_or(lx::Error::E2BIG) + } +} + +pub(crate) fn validate_relative_path(path: &Path, strict: bool) -> lx::Result<()> { + for component in path.components() { + let std::path::Component::Normal(component) = component else { + return Err(lx::Error::EINVAL); + }; + if component.is_empty() { + return Err(lx::Error::EINVAL); + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + + let component = component.as_bytes(); + if component.contains(&b'\0') + || (strict && (component.contains(&b'\\') || component.contains(&b':'))) + { + return Err(lx::Error::EINVAL); + } + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + + let units = component.encode_wide().collect::>(); + if units.contains(&0) + || (strict && (units.contains(&(b'\\' as u16)) || units.contains(&(b':' as u16)))) + { + return Err(lx::Error::EINVAL); + } + } + } + Ok(()) +} diff --git a/vm/devices/virtio/virtiofs/src/resolver.rs b/vm/devices/virtio/virtiofs/src/resolver.rs index 4084646a6c0..375d6a76521 100644 --- a/vm/devices/virtio/virtiofs/src/resolver.rs +++ b/vm/devices/virtio/virtiofs/src/resolver.rs @@ -31,6 +31,9 @@ impl ResolveResource for VirtioFsResolver { resource: VirtioFsHandle, input: VirtioResolveInput<'_>, ) -> Result { + if let Some(device) = crate::microvm::resolver::resolve(&resource, input.driver_source)? { + return Ok(device.into()); + } let device = match &resource.fs { VirtioFsBackend::HostFs { root_path, @@ -46,15 +49,13 @@ impl ResolveResource for VirtioFsResolver { None, ), #[cfg(windows)] - VirtioFsBackend::SectionFs { root_path } => { - VirtioFsDevice::new( - input.driver_source, - &resource.tag, - crate::SectionFs::new(root_path)?, - 8 * 1024 * 1024 * 1024, // 8GB of shared memory, - None, - ) - } + VirtioFsBackend::SectionFs { root_path } => VirtioFsDevice::new( + input.driver_source, + &resource.tag, + crate::SectionFs::new(root_path)?, + 8 * 1024 * 1024 * 1024, // 8GB of shared memory, + None, + ), #[cfg(not(windows))] VirtioFsBackend::SectionFs { .. } => { anyhow::bail!("section fs not supported on this platform") diff --git a/vm/devices/virtio/virtiofs/src/virtio.rs b/vm/devices/virtio/virtiofs/src/virtio.rs index 508fa288f33..64726ef4aa4 100644 --- a/vm/devices/virtio/virtiofs/src/virtio.rs +++ b/vm/devices/virtio/virtiofs/src/virtio.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use crate::microvm::MAX_FUSE_REQUEST_BYTES; use crate::virtio_util::VirtioPayloadReader; use crate::virtio_util::VirtioPayloadWriter; use anyhow::Context as _; @@ -50,9 +51,9 @@ const MAX_REQUEST_QUEUES: u32 = 8; /// PCI configuration space values for virtio-fs devices. #[repr(C)] #[derive(IntoBytes, Immutable, KnownLayout)] -struct VirtioFsDeviceConfig { - tag: [u8; 36], - num_request_queues: u32, +pub(crate) struct VirtioFsDeviceConfig { + pub(crate) tag: [u8; 36], + pub(crate) num_request_queues: u32, } /// A virtio-fs PCI device. @@ -61,7 +62,7 @@ pub struct VirtioFsDevice { task_name: Box, driver: VmTaskDriver, #[inspect(skip)] - config: VirtioFsDeviceConfig, + pub(crate) config: VirtioFsDeviceConfig, #[inspect(skip)] fs: Arc, #[inspect(skip)] @@ -72,6 +73,8 @@ pub struct VirtioFsDevice { #[inspect(skip)] notify_corruption: Arc, num_request_queues: u32, + #[inspect(skip)] + pub(crate) microvm_attachment_id: Option, } impl VirtioFsDevice { @@ -142,6 +145,7 @@ impl VirtioFsDevice { shared_memory_region: None, notify_corruption, num_request_queues, + microvm_attachment_id: None, } } } @@ -150,10 +154,7 @@ impl VirtioDevice for VirtioFsDevice { fn traits(&self) -> DeviceTraits { DeviceTraits { device_id: virtio::spec::VirtioDeviceType::FS, - device_features: VirtioDeviceFeatures::new() - .with_ring_event_idx(true) - .with_ring_indirect_desc(true) - .with_ring_packed(true), + device_features: crate::microvm::device::device_features(self), max_queues: 1 + self.num_request_queues as u16, device_register_length: self.config.as_bytes().len() as u32, shared_memory: DeviceTraitsSharedMemory { @@ -166,15 +167,11 @@ impl VirtioDevice for VirtioFsDevice { async fn read_registers_u32(&mut self, offset: u16) -> u32 { let offset = offset as usize; let config = self.config.as_bytes(); - if offset < config.len() { - u32::from_le_bytes( - config[offset..offset + 4] - .try_into() - .expect("Incorrect length"), - ) - } else { - 0 - } + config + .get(offset..offset.saturating_add(4)) + .and_then(|bytes| bytes.try_into().ok()) + .map(u32::from_le_bytes) + .unwrap_or(0) } async fn write_registers_u32(&mut self, offset: u16, val: u32) { @@ -185,6 +182,7 @@ impl VirtioDevice for VirtioFsDevice { &mut self, region: &Arc, ) -> anyhow::Result<()> { + crate::microvm::device::validate_shared_memory(self)?; self.shared_memory_region = Some(region.clone()); Ok(()) } @@ -196,6 +194,7 @@ impl VirtioDevice for VirtioFsDevice { features: &VirtioDeviceFeatures, initial_state: Option, ) -> anyhow::Result<()> { + crate::microvm::device::validate_queue_features(self, features)?; let mut tc = TaskControl::new(VirtioFsWorker { fs: self.fs.clone(), shared_memory_region: self.shared_memory_region.clone(), @@ -309,6 +308,17 @@ fn process_virtiofs_request( mem: &GuestMemory, work: &VirtioQueueCallbackWork, ) -> u32 { + let readable_len = work.get_payload_length(false) as usize; + if readable_len > MAX_FUSE_REQUEST_BYTES { + tracelimit::error_ratelimited!( + readable_len, + max_request_bytes = MAX_FUSE_REQUEST_BYTES, + "virtio-fs request exceeds the fixed maximum size" + ); + (worker.notify_corruption)(); + return 0; + } + // Parse the request. let reader = VirtioPayloadReader::new(mem, work); let request = match fuse::Request::new(reader) { diff --git a/vm/devices/virtio/virtiofs/src/virtio_util.rs b/vm/devices/virtio/virtiofs/src/virtio_util.rs index 0e8e5e9c9b3..c52969964db 100644 --- a/vm/devices/virtio/virtiofs/src/virtio_util.rs +++ b/vm/devices/virtio/virtiofs/src/virtio_util.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use crate::microvm::MAX_FUSE_REQUEST_BYTES; use guestmem::GuestMemory; use std::cmp; use std::io; @@ -110,6 +111,9 @@ impl fuse::RequestReader for VirtioPayloadReader<'_, '_> { } let new_len = buffer.len().checked_add(len).ok_or(lx::Error::E2BIG)?; + if new_len > MAX_FUSE_REQUEST_BYTES { + return Err(lx::Error::E2BIG); + } buffer .try_reserve_exact(len) .map_err(|_| lx::Error::ENOMEM)?; From f716cef399c7948e258768ccd722a2c7c777515c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 08:27:29 -0700 Subject: [PATCH 119/304] openvmm_entry: expose `--mount` for the microVM HostFs slot The microVM ABI reserves a virtio-fs slot at MMIO 0xd0001000, IRQ 6, and virtiofs now implements the fixed microVM profile, but nothing attaches a device to the slot. Add `--mount GUEST_TARGET,HOST_PATH[,ro|rw]`, read-only by default, for the microVM machine: - `openvmm_defs` gains `MicrovmFilesystemConfig`, which holds the guest mount target and the access mode, and `MicrovmConfig.filesystem`. The guest target must be an absolute, non-root Linux path without empty, dot, parent, whitespace, backslash, or `=` components. - Device discovery adds the slot's `virtio_mmio.device=` token, followed after the fixed-address tokens by `virtfs_dir=`, `virtfs_tag=microvm`, and `virtfs_mode=`, so the guest can mount the share at boot. User arguments cannot set `virtfs_*=`. - Machine validation permits one virtio-fs device, requires it for a filesystem policy, and checks the bootstrap token count and order. The standard machine now rejects a microVM filesystem or network configuration. - The worker places the `virtiofs` device at the fixed slot and masks its features to `MICROVM_VIRTIO_FS_FEATURES`. - The entry point canonicalizes the host path. It rejects a path with a parent component or a symbolic-link or reparse-point component, and describes the root by its platform identity: device and inode numbers on Unix, or volume and file ID on Windows. The `VirtioFsHandle` carries that identity in the microVM profile, so the device refuses a directory that replaced the root. The guest memory backing file must be outside the export. `--mount` conflicts with `--virtio-fs` and `--virtio-fs-shmem`, and the standard machine rejects it. Snapshot capture does not yet support `--mount`. Restore rejects it, because a snapshot cannot yet contain the filesystem. Document the option. Tests cover parsing, command-line ownership and discovery, root canonicalization, the private-storage check, and the restore rejection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 36 +- .../src/worker/dispatch/microvm.rs | 5 + openvmm/openvmm_defs/src/microvm.rs | 143 +++++++ openvmm/openvmm_entry/src/cli_args/microvm.rs | 106 ++++- openvmm/openvmm_entry/src/microvm/config.rs | 49 ++- .../openvmm_entry/src/microvm/filesystem.rs | 364 ++++++++++++++++++ openvmm/openvmm_entry/src/microvm/launch.rs | 9 + openvmm/openvmm_entry/src/microvm/mod.rs | 5 + openvmm/openvmm_entry/src/microvm/network.rs | 1 + 9 files changed, 709 insertions(+), 9 deletions(-) create mode 100644 openvmm/openvmm_entry/src/microvm/filesystem.rs diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index c8a0559fa6f..c184542f1ee 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -45,11 +45,12 @@ describes the source definitions. anchored to UTC, the microVM portb console, lifecycle ports, and the optional fixed virtio devices described below. User arguments cannot override `earlycon=`, - `console=`, `virtio_mmio.device=`, `nr_cpus=`, or `virtnet_*=`. + `console=`, `virtio_mmio.device=`, `nr_cpus=`, `virtnet_*=`, or `virtfs_*=`. - One optional `--net ` NIC is exposed at MMIO `0xd0000000`, and - one optional `--virtio-console ` is exposed at MMIO `0xd0002000`, - IRQ 7 as the boot/log console (`hvc1`). Both use split rings. Firmware, ACPI, + One optional `--net ` NIC is exposed at MMIO `0xd0000000`, one + optional `--mount` HostFs device at MMIO `0xd0001000`, IRQ 6, and one + optional `--virtio-console ` at MMIO `0xd0002000`, IRQ 7 as the + boot/log console (`hvc1`). All use split rings. Firmware, ACPI, SMBIOS, PCI, VMBus, UARTs, storage, graphics, isolation, nested virtualization, and other devices are rejected. Linux discovers contiguous @@ -94,6 +95,33 @@ describes the source definitions. sockets and NAT flow tables are not serialized. The capture protocol does not retain pre-capture endpoint completions; restored guest software must establish new host-side flows. +* `--mount `: With `--machine microvm`, attach + one no-DAX HostFs device at MMIO `0xd0001000`, IRQ 6, with tag `microvm`. + The default mode is read-only; `rw` must be explicit. The guest target must + be an absolute, non-root Linux path without dot, parent, empty, whitespace, + backslash, or `=` components. + + ```bash + openvmm --machine microvm --hypervisor kvm \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz \ + --mount /mnt/share,path/to/share,ro + ``` + + The device has one high-priority queue, one request queue, direct-I/O file + behavior, zero entry and attribute cache lifetimes, and no shared-memory + window. `--mount` conflicts with `--virtio-fs` and + `--virtio-fs-shmem`; those standard-machine options cannot select the + microVM filesystem profile. The profile appends + `virtfs_dir= virtfs_tag=microvm virtfs_mode=` to the + kernel command line, so that the guest can mount the share at boot. + + The host path must name a plain directory, and a path with a parent + component or a symbolic-link or reparse-point component is rejected. The + canonical directory is identified by its device and inode numbers on Linux, + or by its volume and file ID on Windows, and the device refuses the + attachment if the path no longer names that directory when it opens the + root. The guest memory backing file must be outside the exported root. + Snapshot capture does not yet support `--mount`, and restore rejects it. * `--snapshot-destination `: Publish a microVM snapshot when the guest writes to PMIO port `0x605`. The destination must not exist and its parent must already be a directory. OpenVMM automatically creates file-backed RAM diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 165e62af550..7b9c5312b77 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -437,6 +437,10 @@ fn virtio_mmio_config( openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE, openvmm_defs::microvm::microvm_virtio_net_irq(None)?, ), + "virtiofs" => ( + openvmm_defs::microvm::MICROVM_VIRTIO_FS_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_FS_IRQ, + ), "virtio-console" => ( openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ, @@ -453,6 +457,7 @@ fn virtio_mmio_config( ); let disabled_features = match id { "virtio-net" => !openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES, + "virtiofs" => !openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES, _ => 1 << 34, }; let interrupt_mode = VirtioMmioInterruptMode::SharedStatus { diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 52f39455fe2..fb76fc1b558 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -68,6 +68,9 @@ pub const MICROVM_VIRTIO_NET_KVM_IRQ: u32 = 10; pub const MICROVM_VIRTIO_NET_WHP_IRQ: u32 = 5; /// Exact microVM virtio-net feature mask: MAC and virtio version 1. pub const MICROVM_VIRTIO_NET_FEATURES: u64 = (1 << 5) | (1 << 32); +/// Exact microVM virtio-fs feature mask: indirect descriptors, event index, +/// virtio version 1, and access-platform. +pub const MICROVM_VIRTIO_FS_FEATURES: u64 = (1 << 28) | (1 << 29) | (1 << 32) | (1 << 33); /// MicroVM client console reconnect timeout. pub const MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS: u64 = 5_000; /// MicroVM virtio MMIO reservations in stable device order. @@ -158,6 +161,87 @@ impl MicrovmNetworkProfile { } } +/// Access policy for the microVM host filesystem. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq)] +pub enum MicrovmFilesystemAccess { + /// Reject guest mutations before invoking host filesystem operations. + ReadOnly, + /// Permit the common cross-platform mutation contract. + ReadWrite, +} + +impl MicrovmFilesystemAccess { + /// Returns the command-line spelling of this policy. + pub fn as_str(self) -> &'static str { + match self { + Self::ReadOnly => "ro", + Self::ReadWrite => "rw", + } + } + + /// Returns whether host filesystem mutations are allowed. + pub fn is_read_only(self) -> bool { + matches!(self, Self::ReadOnly) + } +} + +/// Guest-visible configuration for the microVM virtio-fs device. +#[derive(MeshPayload, Clone, Debug, PartialEq, Eq)] +pub struct MicrovmFilesystemConfig { + /// Absolute guest path at which the initramfs mounts the filesystem. + pub guest_mount_target: String, + /// Snapshot-authoritative access policy. + pub access: MicrovmFilesystemAccess, +} + +impl MicrovmFilesystemConfig { + /// Validates and constructs the microVM filesystem configuration. + pub fn new( + guest_mount_target: String, + access: MicrovmFilesystemAccess, + ) -> Result { + if guest_mount_target.is_empty() + || !guest_mount_target.starts_with('/') + || guest_mount_target == "/" + || guest_mount_target.len() > 4096 + || guest_mount_target.chars().any(|character| { + character.is_whitespace() || matches!(character, '\0' | '\\' | '=') + }) + || guest_mount_target + .split('/') + .skip(1) + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(InvalidMicrovmFilesystemConfig::InvalidGuestTarget( + guest_mount_target, + )); + } + Ok(Self { + guest_mount_target, + access, + }) + } + + /// Returns the pinned guest bootstrap command-line tokens. + pub fn command_line_fragment(&self) -> String { + format!( + "virtfs_dir={} virtfs_tag=microvm virtfs_mode={}", + self.guest_mount_target, + self.access.as_str() + ) + } +} + +/// Error returned for an invalid microVM filesystem specification. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum InvalidMicrovmFilesystemConfig { + /// The guest mount target is not a canonical absolute Linux path. + #[error( + "invalid guest mount target '{0}': expected an absolute non-root Linux path without empty, dot, parent, whitespace, backslash, or '=' components" + )] + InvalidGuestTarget(String), +} + impl MicrovmNetworkConfig { /// Returns the subnet mask derived from `prefix_length`. pub fn netmask(&self) -> std::net::Ipv4Addr { @@ -308,6 +392,7 @@ fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { pub fn append_microvm_virtio_discovery( cmdline: &mut String, network: Option<(&MicrovmNetworkConfig, u32, bool)>, + filesystem: Option<&MicrovmFilesystemConfig>, has_console: bool, ) -> anyhow::Result<()> { anyhow::ensure!( @@ -328,6 +413,12 @@ pub fn append_microvm_virtio_discovery( " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_NET_MMIO_BASE:#x}:{irq}" )?; } + if filesystem.is_some() { + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_FS_MMIO_BASE:#x}:{MICROVM_VIRTIO_FS_IRQ}" + )?; + } if has_console { write!( cmdline, @@ -341,6 +432,9 @@ pub fn append_microvm_virtio_discovery( network.command_line_fragment_with_dns(gateway_dns) )?; } + if let Some(filesystem) = filesystem { + write!(cmdline, " {}", filesystem.command_line_fragment())?; + } anyhow::ensure!( cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, "microVM kernel command line exceeds the 64-KiB ABI limit after device discovery" @@ -381,10 +475,18 @@ fn validate_microvm_command_line( .virtio_devices .iter() .any(|(_, device)| device.id() == "virtio-net"); + let has_filesystem = config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtiofs"); anyhow::ensure!( has_network == config.microvm.network.is_some(), "microVM virtio-net device and static network identity must be configured together" ); + anyhow::ensure!( + config.microvm.filesystem.is_none() || has_filesystem, + "microVM filesystem policy requires a virtio-fs device" + ); let base_tokens = if has_console { MICROVM_CONSOLE_COMMAND_LINE } else { @@ -403,6 +505,9 @@ fn validate_microvm_command_line( "virtnet_ip=", "virtnet_mask=", "virtnet_gw=", + "virtfs_dir=", + "virtfs_tag=", + "virtfs_mode=", ] { let count = tokens .iter() @@ -411,6 +516,9 @@ fn validate_microvm_command_line( let expected = match prefix { "virtio_mmio.device=" => config.virtio_devices.len(), "virtnet_ip=" | "virtnet_mask=" | "virtnet_gw=" => usize::from(has_network), + "virtfs_dir=" | "virtfs_tag=" | "virtfs_mode=" => { + usize::from(config.microvm.filesystem.is_some()) + } _ => 1, }; anyhow::ensure!( @@ -445,6 +553,11 @@ fn validate_microvm_command_line( "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_NET_MMIO_BASE:#x}:{irq}" )); } + if has_filesystem { + expected_discovery.push(format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_FS_MMIO_BASE:#x}:{MICROVM_VIRTIO_FS_IRQ}" + )); + } if has_console { expected_discovery.push(format!( "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONSOLE_IRQ}" @@ -458,6 +571,14 @@ fn validate_microvm_command_line( .map(str::to_owned), ); } + if let Some(filesystem) = &config.microvm.filesystem { + expected_discovery.extend( + filesystem + .command_line_fragment() + .split_ascii_whitespace() + .map(str::to_owned), + ); + } if !expected_discovery.is_empty() { anyhow::ensure!( tokens.ends_with( @@ -491,6 +612,9 @@ pub fn build_microvm_command_line( "virtnet_mask=", "virtnet_gw=", "virtnet_dns=", + "virtfs_dir=", + "virtfs_tag=", + "virtfs_mode=", ] .iter() .any(|reserved| token.starts_with(reserved)) @@ -550,6 +674,8 @@ pub enum MachineProfile { pub struct MicrovmConfig { /// Static identity for the optional microVM virtio-net device. pub network: Option, + /// Guest-visible policy for the optional microVM virtio-fs device. + pub filesystem: Option, } fn validate_machine_load_mode( @@ -630,6 +756,14 @@ fn validate_machine_load_mode( pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> anyhow::Result<()> { let MachineProfile::Microvm = config.machine_profile else { validate_machine_load_mode(config.machine_profile, &config.load_mode)?; + anyhow::ensure!( + config.microvm.network.is_none(), + "static microVM network identity requires the microVM profile" + ); + anyhow::ensure!( + config.microvm.filesystem.is_none(), + "microVM filesystem policy requires the microVM profile" + ); return Ok(()); }; @@ -754,6 +888,7 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> "microVM has too many virtio devices" ); let mut has_network = false; + let mut has_filesystem = false; let mut has_console = false; for (bus, device) in &config.virtio_devices { anyhow::ensure!( @@ -765,6 +900,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> !std::mem::replace(&mut has_network, true), "microVM permits only one virtio-net device" ), + "virtiofs" => anyhow::ensure!( + !std::mem::replace(&mut has_filesystem, true), + "microVM permits only one virtio-fs device" + ), "virtio-console" => anyhow::ensure!( !std::mem::replace(&mut has_console, true), "microVM permits only one virtio-console device" @@ -776,6 +915,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> has_network == config.microvm.network.is_some(), "microVM virtio-net device and static network identity must be configured together" ); + anyhow::ensure!( + config.microvm.filesystem.is_none() || has_filesystem, + "microVM filesystem policy requires a virtio-fs device" + ); anyhow::ensure!( config.layout.chipset_low_mmio_size == 1024 * 1024 * 1024 && config.layout.chipset_high_mmio_size == 0 diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 4a03831d94b..d327ccd6cd7 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -7,11 +7,13 @@ use super::EndpointConfigCli; use super::Options; use super::SerialConfigCli; use super::SmtConfigCli; +use anyhow::Context; use clap::ValueEnum; use openvmm_defs::config::DeviceVtl; use openvmm_defs::config::X2ApicConfig; use openvmm_defs::microvm::MachineProfile; use std::path::PathBuf; +use std::str::FromStr; /// Guest-visible machine profile. #[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] @@ -52,6 +54,51 @@ pub struct MicrovmCli { /// Required host-network implementation contract for microVM `--net`. #[clap(long, value_enum, value_name = "PROFILE")] pub network_profile: Option, + + /// attach the microVM virtio-fs device + #[clap( + long = "mount", + value_name = "GUEST_TARGET,HOST_PATH[,ro|rw]", + conflicts_with_all = ["virtio_fs", "virtio_fs_shmem"] + )] + pub microvm_mount: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MicrovmMountCli { + /// Absolute guest mount target. + pub guest_target: String, + /// Live host directory supplied for this run. + pub host_path: PathBuf, + /// Snapshot-authoritative access policy. + pub access: openvmm_defs::microvm::MicrovmFilesystemAccess, +} + +impl FromStr for MicrovmMountCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let mut fields = value.splitn(3, ','); + let guest_target = fields + .next() + .filter(|value| !value.is_empty()) + .context("expected ,[,ro|rw]")?; + let host_path = fields + .next() + .filter(|value| !value.is_empty()) + .context("expected ,[,ro|rw]")?; + let access = match fields.next().unwrap_or("ro") { + "ro" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + "rw" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + mode => anyhow::bail!("invalid microVM mount mode '{mode}'; expected ro or rw"), + }; + openvmm_defs::microvm::MicrovmFilesystemConfig::new(guest_target.to_owned(), access)?; + Ok(Self { + guest_target: guest_target.to_owned(), + host_path: PathBuf::from(host_path), + access, + }) + } } /// Parses a bare `/` `--net` endpoint into the microVM network @@ -67,8 +114,8 @@ impl Options { pub(crate) fn validate_microvm_options(&self) -> anyhow::Result<()> { if self.machine != MachineProfileCli::Microvm { anyhow::ensure!( - self.microvm.network_profile.is_none(), - "--network-profile requires a microVM machine" + self.microvm.network_profile.is_none() && self.microvm.microvm_mount.is_none(), + "--network-profile and --mount require a microVM machine" ); return Ok(()); } @@ -106,6 +153,10 @@ impl Options { self.microvm.snapshot_quiesce_timeout_ms != 0, "microVM snapshot quiesce timeout must be nonzero" ); + anyhow::ensure!( + self.microvm.microvm_mount.is_none(), + "microVM snapshot capture does not yet support --mount" + ); } if self.restore_snapshot.is_some() { anyhow::ensure!( @@ -368,6 +419,7 @@ mod tests { for extra in [ vec!["--snapshot-quiesce-timeout-ms", "0"], vec!["--memory", "size=1G,shared=off"], + vec!["--mount", "/mnt/share,host"], ] { let options = Options::try_parse_from( [ @@ -488,17 +540,35 @@ mod tests { ); let mut with_devices = build_microvm_command_line(&[], true).unwrap(); - append_microvm_virtio_discovery(&mut with_devices, None, true).unwrap(); + append_microvm_virtio_discovery(&mut with_devices, None, None, true).unwrap(); assert_eq!( with_devices, format!("{MICROVM_CONSOLE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0002000:7") ); + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/mnt/share".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .unwrap(); + let mut with_filesystem = build_microvm_command_line(&[], false).unwrap(); + append_microvm_virtio_discovery(&mut with_filesystem, None, Some(&filesystem), false) + .unwrap(); + assert_eq!( + with_filesystem, + format!( + "{MICROVM_BASE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0001000:6 virtfs_dir=/mnt/share virtfs_tag=microvm virtfs_mode=ro" + ) + ); + for reserved in [ "earlycon=uart", "console=ttyS0", "virtio_mmio.device=bad", "nr_cpus=1", + "virtfs_dir=/other", + "virtfs_tag=other", + "virtfs_mode=rw", ] { assert!(build_microvm_command_line(&[reserved.into()], false).is_err()); } @@ -533,8 +603,18 @@ mod tests { ]) .unwrap(); valid_console.validate_microvm_options().unwrap(); + let valid_filesystem = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--mount", + "/mnt/share,host,ro", + ]) + .unwrap(); + valid_filesystem.validate_microvm_options().unwrap(); for args in [ + vec!["openvmm", "--mount", "/mnt/share,host"], vec!["openvmm", "--machine", "microvm", "--uefi"], vec!["openvmm", "--machine", "microvm", "--hypervisor", "unknown"], vec!["openvmm", "--machine", "microvm", "--virtio-rng"], @@ -561,4 +641,24 @@ mod tests { assert!(options.validate_microvm_options().is_err()); } } + + #[test] + fn test_microvm_mount_from_str() { + let read_only = MicrovmMountCli::from_str("/mnt/share,host").unwrap(); + assert_eq!(read_only.guest_target, "/mnt/share"); + assert_eq!(read_only.host_path, PathBuf::from("host")); + assert_eq!( + read_only.access, + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + ); + + let read_write = MicrovmMountCli::from_str("/srv/data,host,rw").unwrap(); + assert_eq!( + read_write.access, + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + ); + assert!(MicrovmMountCli::from_str("relative,host").is_err()); + assert!(MicrovmMountCli::from_str("/mnt/../escape,host").is_err()); + assert!(MicrovmMountCli::from_str("/mnt/share,host,write").is_err()); + } } diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 024788873a4..f13e7a2b77b 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -9,6 +9,9 @@ use super::console::ConsoleEndpoint; use super::console::effective_microvm_console; use super::console::microvm_console_socket_cleanup; use super::console::validate_microvm_console_attachment_namespace; +use super::filesystem::EffectiveMicrovmFilesystem; +use super::filesystem::MICROVM_FILESYSTEM_STABLE_ID; +use super::filesystem::effective_microvm_filesystem; use super::network::EffectiveMicrovmNetwork; use super::network::effective_microvm_network; use super::network::microvm_network_endpoint; @@ -35,6 +38,7 @@ use openvmm_defs::microvm::build_microvm_command_line; use pal_async::DefaultDriver; use serial_core::resources::DisconnectedSerialBackendHandle; use std::cell::RefCell; +use std::path::PathBuf; use std::thread; use std::time::Duration; use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; @@ -55,6 +59,7 @@ pub(crate) struct MicrovmConfigBuilder<'a> { restore: &'a MicrovmRestore, active: bool, network: Option, + filesystem: Option, gateway_dns: bool, console: Option, portb: Option>, @@ -75,6 +80,14 @@ impl<'a> MicrovmConfigBuilder<'a> { } else { None }; + let filesystem = if active { + effective_microvm_filesystem( + opt.microvm.microvm_mount.as_ref(), + restore_machine_contract, + )? + } else { + None + }; // Without an egress policy, the guest may use the gateway's DNS proxy. let gateway_dns = network.is_some(); @@ -109,6 +122,9 @@ impl<'a> MicrovmConfigBuilder<'a> { .as_ref() .map(|(_, _, attachment)| attachment.clone()), network_attachment: network.as_ref().map(|network| network.attachment.clone()), + filesystem_root_path: filesystem + .as_ref() + .map(|filesystem| PathBuf::from(&filesystem.root_path)), ..Default::default() }; @@ -117,6 +133,7 @@ impl<'a> MicrovmConfigBuilder<'a> { restore, active, network, + filesystem, gateway_dns, console, portb: None, @@ -332,7 +349,7 @@ impl<'a> MicrovmConfigBuilder<'a> { } } - /// Adds the fixed-slot virtio-net device. + /// Adds the fixed-slot virtio-net and virtio-fs devices. pub(crate) fn add_virtio_devices( &mut self, add_virtio_device: &mut impl FnMut(VirtioBusCli, Resource), @@ -359,6 +376,25 @@ impl<'a> MicrovmConfigBuilder<'a> { .into_resource(), ); } + + if let Some(filesystem) = &self.filesystem { + add_virtio_device( + VirtioBusCli::Mmio, + virtio_resources::fs::VirtioFsHandle { + tag: "microvm".to_owned(), + fs: virtio_resources::fs::VirtioFsBackend::HostFs { + root_path: filesystem.root_path.clone(), + mount_options: String::new(), + }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Microvm { + stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(), + root_identity: filesystem.attachment.identity.clone(), + read_only: filesystem.config.access.is_read_only(), + }, + } + .into_resource(), + ); + } Ok(()) } @@ -408,6 +444,10 @@ impl<'a> MicrovmConfigBuilder<'a> { } } cfg.microvm.network = self.network.as_ref().map(|network| network.config.clone()); + cfg.microvm.filesystem = self + .filesystem + .as_ref() + .map(|filesystem| filesystem.config.clone()); let requested_hypervisor = opt .hypervisor @@ -440,7 +480,12 @@ impl<'a> MicrovmConfigBuilder<'a> { .as_ref() .zip(network_irq) .map(|(network, irq)| (network, irq, self.gateway_dns)); - openvmm_defs::microvm::append_microvm_virtio_discovery(cmdline, network, has_console)?; + openvmm_defs::microvm::append_microvm_virtio_discovery( + cmdline, + network, + cfg.microvm.filesystem.as_ref(), + has_console, + )?; } openvmm_defs::microvm::validate_machine_config(cfg, requested_hypervisor)?; diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs new file mode 100644 index 00000000000..dfadb5130c1 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -0,0 +1,364 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM virtio-fs attachment. + +use crate::cli_args; +use anyhow::Context; +use std::path::Path; +use std::path::PathBuf; + +pub(super) const MICROVM_FILESYSTEM_STABLE_ID: &str = "fs:microvm0"; + +#[derive(Clone)] +pub(super) struct EffectiveMicrovmFilesystem { + pub(super) config: openvmm_defs::microvm::MicrovmFilesystemConfig, + pub(super) root_path: String, + pub(super) attachment: openvmm_helpers::snapshot::microvm::SnapshotAttachment, +} + +fn canonical_microvm_filesystem_root( + path: &Path, +) -> anyhow::Result<(PathBuf, &'static str, Vec)> { + anyhow::ensure!( + !path.as_os_str().is_empty(), + "microVM filesystem host path is empty" + ); + let absolute = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir() + .context("failed to resolve current directory for microVM filesystem")? + .join(path) + }; + + let mut current = PathBuf::new(); + for component in absolute.components() { + use std::path::Component; + match component { + Component::Prefix(_) | Component::RootDir => { + current.push(component.as_os_str()); + } + Component::CurDir => {} + Component::ParentDir => { + anyhow::bail!("microVM filesystem host path contains a parent component") + } + Component::Normal(_) => { + current.push(component.as_os_str()); + let metadata = fs_err::symlink_metadata(¤t).with_context(|| { + format!( + "failed to inspect microVM filesystem path component {}", + current.display() + ) + })?; + anyhow::ensure!( + !metadata.file_type().is_symlink(), + "microVM filesystem path component is a symbolic link: {}", + current.display() + ); + #[cfg(windows)] + anyhow::ensure!( + std::os::windows::fs::MetadataExt::file_attributes(&metadata) & 0x400 == 0, + "microVM filesystem path component is a reparse point: {}", + current.display() + ); + } + } + } + + let canonical = fs_err::canonicalize(&absolute).with_context(|| { + format!( + "failed to canonicalize microVM filesystem root {}", + absolute.display() + ) + })?; + let metadata = fs_err::symlink_metadata(&canonical).with_context(|| { + format!( + "failed to inspect microVM filesystem root {}", + canonical.display() + ) + })?; + anyhow::ensure!( + metadata.file_type().is_dir() && !metadata.file_type().is_symlink(), + "microVM filesystem root is not a plain directory: {}", + canonical.display() + ); + + #[cfg(unix)] + let (identity_kind, identity) = { + use std::os::unix::fs::MetadataExt as _; + let mut identity = b"openvmm-microvm-fs-unix-v1\0".to_vec(); + identity.extend_from_slice(&metadata.dev().to_le_bytes()); + identity.extend_from_slice(&metadata.ino().to_le_bytes()); + ("unix-device-inode-v1", identity) + }; + #[cfg(windows)] + let (identity_kind, identity) = { + use std::os::windows::ffi::OsStrExt as _; + let stat = pal::windows::fs::query_stat_lx_by_name(&canonical) + .context("failed to query the microVM filesystem root identity")?; + anyhow::ensure!( + stat.FileId != 0, + "microVM filesystem root has no stable file identity" + ); + let volume = canonical + .components() + .next() + .and_then(|component| match component { + std::path::Component::Prefix(prefix) => Some(prefix.as_os_str()), + _ => None, + }) + .context("microVM filesystem root has no volume prefix")?; + let volume = volume.encode_wide().collect::>(); + let volume_bytes = u32::try_from(volume.len()) + .context("microVM filesystem volume identity is too long")? + .to_le_bytes(); + let mut identity = b"openvmm-microvm-fs-windows-v1\0".to_vec(); + identity.extend_from_slice(&volume_bytes); + identity.extend(volume.into_iter().flat_map(u16::to_le_bytes)); + identity.extend_from_slice(&stat.FileId.to_le_bytes()); + ("windows-volume-file-id-v1", identity) + }; + #[cfg(not(any(unix, windows)))] + let (identity_kind, identity) = + { anyhow::bail!("microVM virtio-fs requires Linux KVM/MSHV or Windows WHP") }; + anyhow::ensure!( + !identity.is_empty() && identity.len() <= 4096, + "microVM filesystem root identity is empty or exceeds 4096 bytes" + ); + + Ok((canonical, identity_kind, identity)) +} + +pub(crate) fn microvm_filesystem_attachment( + host_path: &Path, +) -> anyhow::Result<( + String, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + let (canonical, identity_kind, identity) = canonical_microvm_filesystem_root(host_path)?; + let root_path = canonical + .to_str() + .context("microVM filesystem host path is not valid UTF-8")? + .to_owned(); + Ok(( + root_path, + openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(), + kind: "virtio-fs".to_owned(), + required: true, + reconnect_policy: "live-revalidate".to_owned(), + identity_kind: identity_kind.to_owned(), + identity, + length: 0, + reconnect_timeout_ms: 0, + }, + )) +} + +fn microvm_filesystem_from_mount( + requested: &cli_args::microvm::MicrovmMountCli, +) -> anyhow::Result { + let (root_path, attachment) = microvm_filesystem_attachment(&requested.host_path)?; + let config = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + requested.guest_target.clone(), + requested.access, + )?; + Ok(EffectiveMicrovmFilesystem { + config, + root_path, + attachment, + }) +} + +pub(crate) fn validate_microvm_filesystem_private_storage( + root_path: &Path, + snapshot_destination: Option<&Path>, + restore_snapshot: Option<&Path>, + memory_backing_file: Option<&Path>, +) -> anyhow::Result<()> { + let root_path = fs_err::canonicalize(root_path).with_context(|| { + format!( + "failed to canonicalize microVM filesystem export root {}", + root_path.display() + ) + })?; + let ensure_outside = |path: &Path, description: &str| -> anyhow::Result<()> { + anyhow::ensure!( + !path.starts_with(&root_path), + "{description} must be outside the microVM filesystem export root {}", + root_path.display() + ); + Ok(()) + }; + + if let Some(destination) = snapshot_destination { + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let parent = fs_err::canonicalize(parent).with_context(|| { + format!( + "failed to canonicalize snapshot destination parent {}", + parent.display() + ) + })?; + ensure_outside(&parent, "snapshot destination")?; + } + if let Some(snapshot) = restore_snapshot { + let snapshot = fs_err::canonicalize(snapshot).with_context(|| { + format!( + "failed to canonicalize restore snapshot {}", + snapshot.display() + ) + })?; + ensure_outside(&snapshot, "restore snapshot")?; + } + if let Some(memory) = memory_backing_file { + let memory = fs_err::canonicalize(memory).with_context(|| { + format!( + "failed to canonicalize guest memory backing file {}", + memory.display() + ) + })?; + ensure_outside(&memory, "guest memory backing file")?; + } + Ok(()) +} + +pub(super) fn effective_microvm_filesystem( + requested: Option<&cli_args::microvm::MicrovmMountCli>, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, +) -> anyhow::Result> { + if restore.is_none() { + return requested.map(microvm_filesystem_from_mount).transpose(); + } + anyhow::ensure!( + requested.is_none(), + "snapshot does not support restore-time microVM filesystem attachment" + ); + Ok(None) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Options; + use crate::microvm::network::tests::network_contract; + use clap::Parser as _; + use test_with_tracing::test; + + fn restore_mount_options(root: &Path, mode: &str) -> Options { + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--mount", + &format!("/mnt/share,{},{}", root.display(), mode), + ]) + .unwrap() + } + + #[test] + fn filesystem_mount_resolves_the_canonical_root() { + let root = tempfile::tempdir().unwrap(); + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--mount", + &format!("/mnt/share,{},rw", root.path().display()), + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + let filesystem = effective_microvm_filesystem(options.microvm.microvm_mount.as_ref(), None) + .unwrap() + .unwrap(); + assert_eq!(filesystem.config.guest_mount_target, "/mnt/share"); + assert_eq!( + filesystem.config.access, + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + ); + assert_eq!( + filesystem.root_path, + fs_err::canonicalize(root.path()).unwrap().to_str().unwrap() + ); + assert_eq!( + filesystem.attachment.stable_id, + MICROVM_FILESYSTEM_STABLE_ID + ); + } + + #[test] + fn filesystem_restore_rejects_mount_for_legacy_snapshot_without_slot() { + let root = tempfile::tempdir().unwrap(); + let contract = network_contract(); + let options = restore_mount_options(root.path(), "ro"); + let error = match effective_microvm_filesystem( + options.microvm.microvm_mount.as_ref(), + Some(&contract), + ) { + Err(error) => error, + Ok(_) => panic!("legacy snapshot unexpectedly accepted a restore-time mount"), + }; + assert!( + error + .to_string() + .contains("does not support restore-time microVM filesystem attachment") + ); + } + + #[test] + fn filesystem_root_rejects_parent_components() { + let root = tempfile::tempdir().unwrap(); + assert!(canonical_microvm_filesystem_root(&root.path().join("child").join("..")).is_err()); + } + + #[cfg(unix)] + #[test] + fn filesystem_root_rejects_symbolic_link_components() { + let root = tempfile::tempdir().unwrap(); + let target = root.path().join("target"); + fs_err::create_dir(&target).unwrap(); + let link = root.path().join("link"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!(canonical_microvm_filesystem_root(&link).is_err()); + } + + #[test] + fn filesystem_export_rejects_snapshot_and_memory_storage() { + let parent = tempfile::tempdir().unwrap(); + let root = parent.path().join("share"); + fs_err::create_dir(&root).unwrap(); + let memory = root.join("memory.bin"); + fs_err::write(&memory, b"memory").unwrap(); + let restore = root.join("restore"); + fs_err::create_dir(&restore).unwrap(); + + assert!( + validate_microvm_filesystem_private_storage( + &root, + Some(&root.join("snapshot")), + None, + None, + ) + .is_err() + ); + assert!( + validate_microvm_filesystem_private_storage(&root, None, Some(&restore), None,) + .is_err() + ); + assert!( + validate_microvm_filesystem_private_storage(&root, None, None, Some(&memory),).is_err() + ); + validate_microvm_filesystem_private_storage( + &root, + Some(&parent.path().join("snapshot")), + None, + None, + ) + .unwrap(); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 0b046bbb940..1725d51b9d8 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -6,6 +6,7 @@ use super::ExpectedRestoreContract; use super::MicrovmResources; +use super::validate_microvm_filesystem_private_storage; use crate::Options; use crate::cli_args::microvm::MachineProfileCli; use crate::vm_controller::MicrovmController; @@ -56,6 +57,14 @@ impl MicrovmLaunch { std::env::current_dir().unwrap_or_default().join(path) } }); + if let Some(root_path) = resources.filesystem_root_path.as_deref() { + validate_microvm_filesystem_private_storage( + root_path, + snapshot_destination.as_deref(), + opt.restore_snapshot.as_deref(), + opt.memory_backing_file().map(PathBuf::as_path), + )?; + } let snapshot_memory_file = if let Some(destination) = &snapshot_destination && opt.memory_backing_file().is_none() { diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 89a66112df6..bb61909dd5a 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -5,12 +5,14 @@ mod config; mod console; +mod filesystem; mod launch; mod network; mod restore; pub(crate) use config::MicrovmConfigBuilder; pub(crate) use console::MicrovmConsoleSocketCleanup; +pub(crate) use filesystem::validate_microvm_filesystem_private_storage; pub(crate) use launch::MicrovmLaunch; pub(crate) use restore::ExpectedRestoreContract; pub(crate) use restore::MicrovmRestore; @@ -19,6 +21,7 @@ pub(crate) use restore::validate_restore_contract; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; +use std::path::PathBuf; /// Host-side microVM resources produced while building the VM configuration /// and consumed by the snapshot, restore, and teardown paths. @@ -32,4 +35,6 @@ pub(crate) struct MicrovmResources { pub(crate) console_socket_cleanup: Option, /// Snapshot identity of the portable network attachment. pub(crate) network_attachment: Option, + /// Canonical host path of the live filesystem root. + pub(crate) filesystem_root_path: Option, } diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index 218a121e21d..09283dffde9 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -151,6 +151,7 @@ pub(crate) mod tests { openvmm_defs::microvm::append_microvm_virtio_discovery( &mut command_line, Some((&network, irq, true)), + None, false, ) .unwrap(); From 0eb2e5b002560897835522a30173fb04140fbdde Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 08:34:55 -0700 Subject: [PATCH 120/304] virtiofs: persist microVM FUSE namespaces, handles, and directory cookies The microVM virtio-fs device cannot be saved. Its FUSE session, the node and handle IDs that the guest holds, and its directory offsets exist only in process memory, and nothing can rebuild them against the host directory in a new process. Add typed device-private state for the microVM HostFs profile: - The schema, version 4, records the fixed profile policy, the FUSE INIT negotiation, and the node and handle allocators. Each inode is saved with its node ID, relative aliases, lookup count, guest inode number, and object identity (device, inode, and kind). Each open handle is saved with its node, open flags, object identity, and directory snapshot. The state holds relative names and identities only: no host path, OS handle, or file contents. - The worker takes an admission guard before it dequeues a request, and quiesce closes the gate. After quiesce, an unowned descriptor stays on the virtqueue instead of being completed with an empty reply. Save requires a closed gate with no request in flight and stopped queues. It revalidates every alias and open handle against the host (no symbolic-link component, same object identity) and enforces the table bounds. - Restore first validates the state against the profile: the policy, the bounds, unique IDs, and a FUSE negotiation that the profile reproduces. It then checks the root identity, reopens every alias and handle, and verifies that each still names the same object. A handle is reopened without O_CREAT, O_EXCL, and O_TRUNC, and O_TMPFILE or an unknown flag is rejected. The FUSE session is restored last. The device state validator applies the same checks with the queue state. - A microVM directory handle takes a bounded snapshot of its entries on the first read and serves later reads from it. The snapshot holds each entry's name, cookie, guest inode number, and type, so a cookie that the guest holds stays valid after restore even if the host directory has changed. - `VirtioFs` records the negotiated FUSE contract at INIT and clears it at DESTROY, and `VirtioFsFile::new` takes the open flags so that a handle can be reopened. Snapshot capture still rejects `--mount`, because the machine contract does not describe the filesystem yet. Document the device-private state in the snapshot format guide. Tests cover a restore against the same attachment, the negotiation and policy checks, malformed state, reopen flags, read-only handles, parent and hard-link aliases, directory snapshots across host changes, and the admission gate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + Guide/src/dev_guide/snapshot_format.md | 6 +- vm/devices/virtio/virtiofs/Cargo.toml | 1 + vm/devices/virtio/virtiofs/src/file.rs | 63 ++- vm/devices/virtio/virtiofs/src/inode.rs | 21 +- vm/devices/virtio/virtiofs/src/lib.rs | 40 +- .../virtio/virtiofs/src/microvm/device.rs | 130 ++++- .../virtio/virtiofs/src/microvm/file.rs | 174 +++++++ vm/devices/virtio/virtiofs/src/microvm/fs.rs | 320 +++++++++++- .../virtio/virtiofs/src/microvm/fs_tests.rs | 283 ++++++++++- .../virtio/virtiofs/src/microvm/inode.rs | 28 +- vm/devices/virtio/virtiofs/src/microvm/mod.rs | 3 +- .../virtiofs/src/microvm/saved_state.rs | 141 +++++- .../virtio/virtiofs/src/microvm/state.rs | 471 +++++++++++++++++- vm/devices/virtio/virtiofs/src/virtio.rs | 145 +++++- 15 files changed, 1800 insertions(+), 27 deletions(-) create mode 100644 vm/devices/virtio/virtiofs/src/microvm/file.rs diff --git a/Cargo.lock b/Cargo.lock index 74f06050826..daa5d96b2a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10249,6 +10249,7 @@ dependencies = [ "inspect", "lx", "lxutil", + "mesh", "ntapi", "pal", "pal_async", diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 6f316cf4758..ab12cc807a3 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -154,8 +154,10 @@ Key unsupported categories: such as the microVM NIC; ordinary virtio-net resources remain disabled. The transport stores an opaque typed device-private payload in addition to - common queue state. Devices with unsupported host-side session state - (`virtio-9p`, `virtiofs`) leave save/restore disabled. + common queue state. Devices with unsupported host-side session state, such + as `virtio-9p`, leave save/restore disabled. `virtiofs` enables typed + device-private state only for the constrained microVM HostFs profile; + ordinary, aggregate, and SectionFs resources remain disabled. - **Some VMBus devices** — `GuestCrashDevice`, `GuestEmulationDevice`, `VmbusSerialHost`, `Vmbfs` return `None` from `supports_save_restore()`. diff --git a/vm/devices/virtio/virtiofs/Cargo.toml b/vm/devices/virtio/virtiofs/Cargo.toml index dca5c4b7ea4..5f9bd35353b 100644 --- a/vm/devices/virtio/virtiofs/Cargo.toml +++ b/vm/devices/virtio/virtiofs/Cargo.toml @@ -18,6 +18,7 @@ fuse.workspace = true inspect.workspace = true lx.workspace = true lxutil.workspace = true +mesh.workspace = true task_control.workspace = true anyhow.workspace = true diff --git a/vm/devices/virtio/virtiofs/src/file.rs b/vm/devices/virtio/virtiofs/src/file.rs index 0c97512ff77..03312a5b822 100644 --- a/vm/devices/virtio/virtiofs/src/file.rs +++ b/vm/devices/virtio/virtiofs/src/file.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::inode::VirtioFsInode; +use crate::microvm::saved_state::SavedDirectoryEntry; use crate::util; use fuse::DirEntryWriter; use fuse::protocol::fuse_attr; @@ -12,18 +13,28 @@ use lxutil::LxFile; use parking_lot::RwLock; use std::sync::Arc; +#[derive(Default)] +pub(crate) struct DirectorySnapshot { + pub(crate) built: bool, + pub(crate) entries: Vec, +} + /// Implements file callbacks for virtio-fs. pub struct VirtioFsFile { - file: RwLock, - inode: Arc, + pub(crate) file: RwLock, + pub(crate) inode: Arc, + open_flags: u32, + pub(crate) directory_snapshot: RwLock, } impl VirtioFsFile { /// Create a new file. - pub fn new(file: LxFile, inode: Arc) -> Self { + pub fn new(file: LxFile, inode: Arc, open_flags: u32) -> Self { Self { file: RwLock::new(file), inode, + open_flags, + directory_snapshot: RwLock::new(DirectorySnapshot::default()), } } @@ -32,6 +43,41 @@ impl VirtioFsFile { &self.inode } + pub(crate) fn open_flags(&self) -> u32 { + self.open_flags + } + + pub(crate) fn directory_entries(&self) -> Vec { + self.directory_snapshot.read().entries.clone() + } + + pub(crate) fn directory_snapshot_built(&self) -> bool { + self.directory_snapshot.read().built + } + + pub(crate) fn object_stat(&self) -> lx::Result { + self.file.read().fstat().map(Into::into) + } + + pub(crate) fn restore_directory_snapshot( + &self, + built: bool, + entries: Vec, + ) -> lx::Result<()> { + Self::validate_directory_entries(&entries)?; + if !built && !entries.is_empty() { + return Err(lx::Error::EINVAL); + } + if built { + let stat = self.object_stat()?; + if stat.mode & lx::S_IFMT != lx::S_IFDIR { + return Err(lx::Error::ENOTDIR); + } + } + *self.directory_snapshot.write() = DirectorySnapshot { built, entries }; + Ok(()) + } + /// Gets the attributes of the open file. pub fn get_attr(&self) -> lx::Result { let stat = self.file.read().fstat()?.into(); @@ -76,6 +122,9 @@ impl VirtioFsFile { size: u32, plus: bool, ) -> lx::Result> { + if fs.is_microvm() { + return self.read_dir_microvm(fs, offset, size, plus); + } if size as usize > crate::MAX_GUEST_BUFFER_SIZE { return Err(lx::Error::E2BIG); } @@ -125,7 +174,8 @@ impl VirtioFsFile { } }; - Ok(buffer.dir_entry_plus(&entry.name, entry.offset as u64, fuse_entry)) + let written = buffer.dir_entry_plus(&entry.name, entry.offset as u64, fuse_entry); + Ok(written) } else { // Use the current file's inode number for . and .. entries. // On Windows inode_nr is 0 for these; on Linux it may be @@ -144,12 +194,13 @@ impl VirtioFsFile { self.inode.guest_ino(entry.inode_nr) }; - Ok(buffer.dir_entry( + let written = buffer.dir_entry( &entry.name, inode_nr, entry.offset as u64, entry.file_type as u32, - )) + ); + Ok(written) } })?; diff --git a/vm/devices/virtio/virtiofs/src/inode.rs b/vm/devices/virtio/virtiofs/src/inode.rs index 77d1af7e588..26e1a6bed6b 100644 --- a/vm/devices/virtio/virtiofs/src/inode.rs +++ b/vm/devices/virtio/virtiofs/src/inode.rs @@ -96,9 +96,9 @@ pub(crate) enum DedupKey { /// Implements inode callbacks for virtio-fs. pub struct VirtioFsInode { pub(crate) volume: Arc, - path: RwLock, + pub(crate) path: RwLock, pub(crate) aliases: RwLock>, - lookup_count: AtomicU64, + pub(crate) lookup_count: AtomicU64, inode_nr: lx::ino_t, /// This inode's number as reported to the guest: its namespaced inode /// number under the shared superblock. @@ -151,6 +151,19 @@ impl VirtioFsInode { self.guest_inode_nr } + pub(crate) fn lookup_count(&self) -> u64 { + self.lookup_count.load(Ordering::Acquire) + } + + pub(crate) fn volume(&self) -> Arc { + Arc::clone(&self.volume) + } + + pub(crate) fn object_stat(&self) -> lx::Result { + self.validate_confined()?; + self.volume.lstat(&*self.get_path()) + } + /// Maps a raw host inode number from this inode's volume for guest use. For /// numbers belonging to *other* inodes in the same volume (e.g. readdir /// child entries); for this inode's own number use [`Self::guest_inode_nr`]. @@ -256,7 +269,7 @@ impl VirtioFsInode { self.validate_confined()?; let flags = (flags as i32) | lx::O_NOFOLLOW; let file = self.volume.open(&*self.get_path(), flags, None)?; - Ok(VirtioFsFile::new(file, self)) + Ok(VirtioFsFile::new(file, self, flags as u32)) } /// Creates a new file as a child of this inode, and opens it. @@ -515,7 +528,7 @@ impl VirtioFsInode { } /// Locks the path and returns the value. - fn get_path(&self) -> parking_lot::RwLockReadGuard<'_, PathBuf> { + pub(crate) fn get_path(&self) -> parking_lot::RwLockReadGuard<'_, PathBuf> { self.path.read() } } diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index 068022f8299..747328fca18 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -51,12 +51,29 @@ const ENTRY_TIMEOUT: Duration = Duration::from_secs(0); const MAX_GUEST_BUFFER_SIZE: usize = 1024 * 1024; +#[derive(Clone, Copy, Default, Eq, PartialEq)] +struct FuseNegotiation { + initialized: bool, + major: u32, + minor: u32, + capable: u32, + capable2: u32, + want: u32, + want2: u32, + max_readahead: u32, + max_write: u32, + max_background: u16, + congestion_threshold: u16, + time_gran: u32, +} + /// Shared mutable state behind a [`VirtioFs`] handle. struct VirtioFsInner { inodes: RwLock, files: RwLock>>, mode: VirtioFsMode, microvm_profile: Option, + negotiation: RwLock, } /// Distinguishes a single-share device from a multi-share aggregate. @@ -121,6 +138,24 @@ impl Fuse for VirtioFs { if info.capable2() & FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2 != 0 { info.want2 |= FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2; } + + // The session owns the wire handshake. Keep the complete negotiated + // contract here so device-private state can validate it before a + // restore ever starts guest execution. + *self.inner.negotiation.write() = FuseNegotiation { + initialized: true, + major: info.major(), + minor: info.minor(), + capable: info.capable(), + capable2: info.capable2(), + want: info.want, + want2: info.want2, + max_readahead: info.max_readahead, + max_write: info.max_write, + max_background: info.max_background, + congestion_threshold: info.congestion_threshold, + time_gran: info.time_gran, + }; } fn get_attr(&self, request: &Request, flags: u32, fh: u64) -> lx::Result { @@ -249,7 +284,7 @@ impl Fuse for VirtioFs { // on the inode number (if this is a non-exclusive create), so make sure to associate the // file with the returned inode. let (new_inode, node_id) = self.insert_inode(new_inode)?; - let file = VirtioFsFile::new(file, new_inode); + let file = VirtioFsFile::new(file, new_inode, arg.flags); let fh = self.insert_file(file)?; Ok(CreateOut { entry: fuse_entry_out::new( @@ -545,6 +580,7 @@ impl Fuse for VirtioFs { // To get the file system ready for re-mount, clean out any open files and leaked inodes. self.inner.files.write().clear(); self.inner.inodes.write().clear(); + *self.inner.negotiation.write() = FuseNegotiation::default(); } } @@ -607,6 +643,7 @@ impl VirtioFs { files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Direct, microvm_profile: None, + negotiation: RwLock::new(FuseNegotiation::default()), }), }) } @@ -625,6 +662,7 @@ impl VirtioFs { files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Aggregate(AggregateState::new()), microvm_profile: None, + negotiation: RwLock::new(FuseNegotiation::default()), }), } } diff --git a/vm/devices/virtio/virtiofs/src/microvm/device.rs b/vm/devices/virtio/virtiofs/src/microvm/device.rs index f7623569309..e207e48239a 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/device.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/device.rs @@ -1,16 +1,24 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! microVM virtio-fs device construction. +//! microVM virtio-fs device construction and save/restore. use super::profile::MICROVM_MOUNT_TAG; use super::profile::MICROVM_REQUEST_QUEUES; use super::profile::MicroVmVirtioFsProfile; +use super::saved_state::SavedState; +use super::state::validate_microvm_state; use crate::VirtioFs; use crate::virtio::VirtioFsDevice; use std::path::Path; use std::sync::Arc; +use task_control::TaskControl; +use virtio::device::saved_state::DeviceQueueState; +use virtio::device::saved_state::DeviceStateValidator; use virtio::spec::VirtioDeviceFeatures; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; use vmcore::vm_task::VmTaskDriverSource; impl VirtioFsDevice { @@ -33,12 +41,14 @@ impl VirtioFsDevice { let mut device = Self::with_num_request_queues( driver_source, MICROVM_MOUNT_TAG, - fs, + fs.clone(), 0, notify_corruption, MICROVM_REQUEST_QUEUES, ); device.microvm_attachment_id = Some(attachment_id); + device.microvm_profile = Some(profile); + device.stateful_fs = Some(fs); Ok(device) } @@ -84,6 +94,121 @@ pub(crate) fn validate_queue_features( Ok(()) } +pub(crate) fn reset(device: &mut VirtioFsDevice) { + device.admission.resume(); + device.save_error = None; +} + +pub(crate) fn quiesce_input(device: &mut VirtioFsDevice) { + if device.microvm_attachment_id.is_some() { + device.admission.quiesce(); + } +} + +pub(crate) fn resume_input(device: &mut VirtioFsDevice) { + if device.microvm_attachment_id.is_some() { + device.admission.resume(); + device.save_error = None; + } +} + +pub(crate) fn supports_save_restore(device: &VirtioFsDevice) -> bool { + device.microvm_attachment_id.is_some() +} + +pub(crate) fn save_device( + device: &mut VirtioFsDevice, +) -> Result, SaveError> { + if device.microvm_attachment_id.is_none() { + return Err(SaveError::NotSupported); + } + if let Some(error) = device.save_error.take() { + return Err(SaveError::Other(error)); + } + if device.workers.iter().any(TaskControl::has_state) { + return Err(SaveError::Other(anyhow::anyhow!( + "virtio-fs queues are still running" + ))); + } + device + .admission + .verify_drained() + .map_err(SaveError::Other)?; + let state = match (&device.microvm_profile, &device.stateful_fs) { + (Some(profile), Some(fs)) => fs + .save_microvm_state(profile, device.fs.save_state()) + .map_err(SaveError::Other)?, + _ => { + return Err(SaveError::Other(anyhow::anyhow!( + "microVM virtio-fs profile and attachment state disagree" + ))); + } + }; + Ok(Some(SavedStateBlob::new(state))) +} + +pub(crate) fn restore_device( + device: &mut VirtioFsDevice, + state: Option, +) -> Result<(), RestoreError> { + if device.microvm_attachment_id.is_none() { + return Err(RestoreError::SavedStateNotSupported); + } + let state = state.ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs is missing device-private saved state" + )) + })?; + let state: SavedState = state.parse().map_err(RestoreError::ProtobufDecode)?; + let profile = device.microvm_profile.as_ref().ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "active microVM virtio-fs state requires a host attachment" + )) + })?; + validate_microvm_state(&state, profile).map_err(RestoreError::InvalidSavedState)?; + let fs = device.stateful_fs.as_ref().ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs filesystem attachment is unavailable" + )) + })?; + fs.restore_microvm_state(profile, state, &device.fs) + .map_err(RestoreError::InvalidSavedState) +} + +pub(crate) fn device_state_validator(device: &VirtioFsDevice) -> DeviceStateValidator { + let attachment_id = device.microvm_attachment_id.clone(); + let profile = device.microvm_profile.clone(); + Box::new( + move |state, features, queues: &[DeviceQueueState], _guest_memory| { + if attachment_id.is_none() { + return Err(RestoreError::SavedStateNotSupported); + } + if features.ring_packed() { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs restored packed virtqueue" + ))); + } + if queues.len() != 2 { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs requires one hiprio queue and one request queue" + ))); + } + let state = state.ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "microVM virtio-fs is missing device-private saved state" + )) + })?; + let state: SavedState = state.parse().map_err(RestoreError::ProtobufDecode)?; + let profile = profile.as_ref().ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "active microVM virtio-fs state requires a host attachment" + )) + })?; + validate_microvm_state(&state, profile).map_err(RestoreError::InvalidSavedState) + }, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -113,6 +238,7 @@ mod tests { assert_eq!(device.traits().max_queues, 2); assert_eq!(device.traits().shared_memory.size, 0); assert!(!device.traits().device_features.ring_packed()); + assert!(device.supports_save_restore()); assert_eq!(&device.config.tag[..7], b"microvm"); } diff --git a/vm/devices/virtio/virtiofs/src/microvm/file.rs b/vm/devices/virtio/virtiofs/src/microvm/file.rs new file mode 100644 index 00000000000..50c02764ead --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/file.rs @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM directory enumeration and persisted directory snapshots. + +use super::saved_state::MAX_DIRECTORY_ENTRIES_PER_HANDLE; +use super::saved_state::MAX_DIRECTORY_ENTRY_NAME_BYTES; +use super::saved_state::MAX_DIRECTORY_SNAPSHOT_BYTES; +use super::saved_state::SavedDirectoryEntry; +use crate::VirtioFs; +use crate::file::VirtioFsFile; +use fuse::DirEntryWriter; +use fuse::protocol::fuse_entry_out; + +impl VirtioFsFile { + pub(crate) fn read_dir_microvm( + &self, + fs: &VirtioFs, + offset: u64, + size: u32, + plus: bool, + ) -> lx::Result> { + if size as usize > crate::MAX_GUEST_BUFFER_SIZE { + return Err(lx::Error::E2BIG); + } + + let entries = { + let mut snapshot = self.directory_snapshot.write(); + if !snapshot.built { + if offset != 0 { + return Err(lx::Error::EINVAL); + } + snapshot.entries = self.build_microvm_directory_snapshot()?; + snapshot.built = true; + } + snapshot.entries.clone() + }; + + let start = if offset == 0 { + 0 + } else { + entries + .iter() + .position(|entry| entry.next_cookie == offset) + .map(|index| index + 1) + .ok_or(lx::Error::EINVAL)? + }; + let mut buffer = Vec::new(); + buffer + .try_reserve_exact(size as usize) + .map_err(|_| lx::Error::ENOMEM)?; + + for entry in &entries[start..] { + let name = lx::LxStr::from_bytes(&entry.name); + if plus { + if entry.name == b"." || entry.name == b".." { + if !buffer.dir_entry_plus( + name, + entry.next_cookie, + fuse_entry_out::new_dot(entry.guest_inode_id, entry.kind << 12), + ) { + break; + } + } else { + // This check must happen before lookup_helper, because a + // readdirplus lookup creates a guest lookup reference. + if !buffer.check_dir_entry_plus(name) { + break; + } + let fuse_entry = fs.lookup_helper(&self.inode, name)?; + if fuse_entry.attr.ino != entry.guest_inode_id { + return Err(lx::Error::EIO); + } + if !buffer.dir_entry_plus(name, entry.next_cookie, fuse_entry) { + return Err(lx::Error::EIO); + } + } + } else if !buffer.dir_entry(name, entry.guest_inode_id, entry.next_cookie, entry.kind) { + break; + } + } + + if !entries[start..].is_empty() && buffer.is_empty() { + return Err(lx::Error::EINVAL); + } + Ok(buffer) + } + + fn build_microvm_directory_snapshot(&self) -> lx::Result> { + let stat = self.object_stat()?; + if stat.mode & lx::S_IFMT != lx::S_IFDIR { + return Err(lx::Error::ENOTDIR); + } + + let self_inode_nr = self.inode.guest_inode_nr(); + let mut total_bytes = 0usize; + let mut entries = Vec::new(); + self.file.write().read_dir(0, |entry| { + if entries.len() == MAX_DIRECTORY_ENTRIES_PER_HANDLE { + return Err(lx::Error::E2BIG); + } + let name = entry.name.as_bytes(); + if name.is_empty() || name.len() > MAX_DIRECTORY_ENTRY_NAME_BYTES { + return Err(lx::Error::EINVAL); + } + if name != b"." && name != b".." { + match self.inode.child_path(&entry.name) { + Ok(_) => {} + Err(error) if error == lx::Error::EACCES => return Ok(true), + Err(error) => return Err(error), + } + } + total_bytes = total_bytes + .checked_add(name.len()) + .ok_or(lx::Error::E2BIG)?; + if total_bytes > MAX_DIRECTORY_SNAPSHOT_BYTES { + return Err(lx::Error::E2BIG); + } + let next_cookie = u64::try_from(entries.len()) + .ok() + .and_then(|index| index.checked_add(1)) + .ok_or(lx::Error::E2BIG)?; + entries.try_reserve(1).map_err(|_| lx::Error::ENOMEM)?; + entries.push(SavedDirectoryEntry { + name: name.to_vec(), + next_cookie, + guest_inode_id: if name == b"." || name == b".." { + self_inode_nr + } else { + self.inode.guest_ino(entry.inode_nr) + }, + kind: entry.file_type as u32, + }); + Ok(true) + })?; + Self::validate_directory_entries(&entries)?; + Ok(entries) + } + + pub(crate) fn validate_directory_entries(entries: &[SavedDirectoryEntry]) -> lx::Result<()> { + if entries.len() > MAX_DIRECTORY_ENTRIES_PER_HANDLE { + return Err(lx::Error::E2BIG); + } + let mut total_bytes = 0usize; + let mut previous_cookie = 0; + for (index, entry) in entries.iter().enumerate() { + let dot_entry = entry.name == b"." || entry.name == b".."; + if entry.name.is_empty() + || entry.name.len() > MAX_DIRECTORY_ENTRY_NAME_BYTES + || entry.kind > 15 + || (!dot_entry + && (entry.name.contains(&b'/') + || entry.name.contains(&b'\0') + || entry.name.contains(&b'\\') + || entry.name.contains(&b':'))) + { + return Err(lx::Error::EINVAL); + } + if (index == 0 && entry.next_cookie == 0) + || (index != 0 && entry.next_cookie <= previous_cookie) + { + return Err(lx::Error::EINVAL); + } + previous_cookie = entry.next_cookie; + total_bytes = total_bytes + .checked_add(entry.name.len()) + .ok_or(lx::Error::E2BIG)?; + if total_bytes > MAX_DIRECTORY_SNAPSHOT_BYTES { + return Err(lx::Error::E2BIG); + } + } + Ok(()) + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs.rs b/vm/devices/virtio/virtiofs/src/microvm/fs.rs index 0514ab59152..4c1d56e4106 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/fs.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/fs.rs @@ -1,12 +1,35 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! microVM filesystem construction and policy hooks. +//! microVM filesystem construction, policy hooks, and attachment restore. +use super::MAX_FUSE_REQUEST_BYTES; use super::profile::MicroVmVirtioFsProfile; +use super::saved_state::MAX_ALIAS_BYTES; +use super::saved_state::MAX_ALIASES; +use super::saved_state::MAX_ALIASES_PER_INODE; +use super::saved_state::MAX_DIRECTORY_BYTES; +use super::saved_state::MAX_DIRECTORY_ENTRIES; use super::saved_state::MAX_HANDLES; +use super::saved_state::MAX_INODES; +use super::saved_state::SCHEMA_VERSION; +use super::saved_state::SavedHandle; +use super::saved_state::SavedInode; +use super::saved_state::SavedState; +use super::state::encode_relative_path; +use super::state::fuse_negotiation_from_session; +use super::state::reopen_flags; +use super::state::saved_identity; +use super::state::saved_negotiation; +use super::state::session_state_from_saved; +use super::state::validate_identity; +use super::state::validate_microvm_state; +use super::state::validate_relative_path; +use super::state::validate_reopenable_alias; +use super::state::validate_saved_identity; use crate::ATTRIBUTE_TIMEOUT; use crate::ENTRY_TIMEOUT; +use crate::FuseNegotiation; use crate::HandleMap; use crate::InodeMap; use crate::VirtioFs; @@ -15,11 +38,16 @@ use crate::VirtioFsMode; use crate::file::VirtioFsFile; use crate::inode::VirtioFsInode; use crate::inode::VirtioFsVolume; +use anyhow::Context; +use fuse::Session; use fuse::SessionInfo; +use fuse::SessionState; use fuse::protocol::FOPEN_DIRECT_IO; use fuse::protocol::FUSE_ROOT_ID; use lxutil::LxVolumeOptions; use parking_lot::RwLock; +use std::collections::HashMap; +use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -93,7 +121,7 @@ impl VirtioFs { /// Creates a filesystem attachment for the fixed microVM profile. /// /// `root_path` is deliberately consumed only while opening the attachment; - /// it is not retained in the filesystem state. + /// it is not retained in the filesystem state or in a saved-state blob. pub fn new_microvm( root_path: impl AsRef, profile: MicroVmVirtioFsProfile, @@ -121,6 +149,7 @@ impl VirtioFs { files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Direct, microvm_profile: Some(profile), + negotiation: RwLock::new(FuseNegotiation::default()), }), }) } @@ -196,4 +225,291 @@ impl VirtioFs { let files = self.inner.files.read(); validate_file_insert(self, &files) } + + pub(crate) fn save_microvm_state( + &self, + profile: &MicroVmVirtioFsProfile, + session_state: SessionState, + ) -> anyhow::Result { + anyhow::ensure!( + self.microvm_profile() == Some(profile), + "virtio-fs attachment does not match the microVM profile" + ); + anyhow::ensure!( + self.inner.aggregate().is_none(), + "aggregate virtio-fs is not part of the microVM ABI" + ); + + let (inodes, node_ids, next_node_id) = { + let inodes = self.inner.inodes.read(); + anyhow::ensure!(inodes.inodes_by_node_id.values.len() <= MAX_INODES); + let mut saved = Vec::with_capacity(inodes.inodes_by_node_id.values.len()); + let mut node_ids = HashMap::with_capacity(inodes.inodes_by_node_id.values.len()); + let mut alias_count = 0usize; + let mut alias_bytes = 0usize; + for (&node_id, inode) in &inodes.inodes_by_node_id.values { + let aliases = inode.aliases(); + anyhow::ensure!( + !aliases.is_empty() && aliases.len() <= MAX_ALIASES_PER_INODE, + "inode has no bounded reopenable aliases" + ); + alias_count = alias_count + .checked_add(aliases.len()) + .context("saved alias count overflow")?; + anyhow::ensure!(alias_count <= MAX_ALIASES, "saved alias table is too large"); + let volume = inode.volume(); + let mut relative_aliases = Vec::with_capacity(aliases.len()); + let mut object_identity = None; + for alias in aliases { + validate_relative_path(&alias, true) + .map_err(anyhow::Error::from) + .context("inode has an unsafe relative alias")?; + let stat = validate_reopenable_alias(&volume, &alias) + .context("inode alias cannot be revalidated for save")?; + let identity = saved_identity(&stat); + if let Some(expected) = &object_identity { + validate_saved_identity(&identity, expected) + .context("inode aliases identify different objects")?; + } else { + object_identity = Some(identity); + } + let alias = encode_relative_path(&alias)?; + alias_bytes = alias_bytes + .checked_add(alias.len()) + .context("saved alias bytes overflow")?; + anyhow::ensure!( + alias_bytes <= MAX_ALIAS_BYTES, + "saved aliases exceed the aggregate byte bound" + ); + relative_aliases.push(alias); + } + saved.push(SavedInode { + node_id, + volume_id: inode.volume_id(), + relative_aliases, + lookup_count: inode.lookup_count(), + guest_inode_id: inode.guest_inode_nr(), + object_identity: object_identity.context("inode has no aliases")?, + }); + anyhow::ensure!( + node_ids + .insert(Arc::as_ptr(inode) as usize, node_id) + .is_none(), + "duplicate inode identity in inode table" + ); + } + (saved, node_ids, inodes.inodes_by_node_id.next_handle) + }; + + let handles = { + let files = self.inner.files.read(); + anyhow::ensure!(files.values.len() <= MAX_HANDLES); + let mut saved = Vec::with_capacity(files.values.len()); + let mut directory_entry_count = 0usize; + let mut directory_bytes = 0usize; + for (&handle_id, file) in &files.values { + let node_id = *node_ids + .get(&(std::ptr::from_ref(file.inode()) as usize)) + .context("open handle refers to an untracked inode")?; + let stat = file + .object_stat() + .map_err(anyhow::Error::from) + .context("open handle cannot be revalidated for save")?; + let snapshot_entries = file.directory_entries(); + let directory_snapshot_built = file.directory_snapshot_built(); + anyhow::ensure!( + directory_snapshot_built || snapshot_entries.is_empty(), + "uninitialized directory snapshot retained entries" + ); + VirtioFsFile::validate_directory_entries(&snapshot_entries) + .map_err(anyhow::Error::from) + .context("directory enumeration state is invalid")?; + directory_entry_count = directory_entry_count + .checked_add(snapshot_entries.len()) + .context("saved directory entry count overflow")?; + anyhow::ensure!( + directory_entry_count <= MAX_DIRECTORY_ENTRIES, + "saved directory entries exceed the aggregate bound" + ); + for entry in &snapshot_entries { + directory_bytes = directory_bytes + .checked_add(entry.name.len()) + .context("saved directory bytes overflow")?; + } + anyhow::ensure!( + directory_bytes <= MAX_DIRECTORY_BYTES, + "saved directory entries exceed the aggregate byte bound" + ); + saved.push(SavedHandle { + handle_id, + node_id, + open_flags: file.open_flags(), + kind: stat.mode & lx::S_IFMT, + object_identity: saved_identity(&stat), + directory_entries: snapshot_entries, + directory_snapshot_built, + }); + } + saved + }; + + let negotiation = fuse_negotiation_from_session(session_state); + anyhow::ensure!( + *self.inner.negotiation.read() == negotiation, + "FUSE session and filesystem negotiation state disagree" + ); + Ok(SavedState { + schema_version: SCHEMA_VERSION, + attachment_id: profile.attachment_id().to_owned(), + access_mode: match profile.access_mode() { + super::profile::MicroVmAccessMode::ReadOnly => 1, + super::profile::MicroVmAccessMode::ReadWrite => 2, + }, + request_queues: profile.request_queues(), + shared_memory_size: 0, + packed_rings: false, + direct_io: profile.direct_io(), + entry_cache_timeout_ns: profile.entry_cache_timeout().as_nanos() as u64, + attribute_cache_timeout_ns: profile.attribute_cache_timeout().as_nanos() as u64, + negotiation: saved_negotiation(session_state), + next_node_id, + next_handle_id: self.inner.files.read().next_handle, + inodes, + handles, + attachment_root_identity: profile.root_identity().to_vec(), + maximum_request_size: MAX_FUSE_REQUEST_BYTES as u32, + }) + } + + pub(crate) fn restore_microvm_state( + &self, + profile: &MicroVmVirtioFsProfile, + state: SavedState, + session: &Session, + ) -> anyhow::Result<()> { + anyhow::ensure!( + self.microvm_profile() == Some(profile), + "virtio-fs attachment does not match the microVM profile" + ); + validate_microvm_state(&state, profile)?; + let session_state = session_state_from_saved(&state.negotiation)?; + + let current_root = self.get_inode(FUSE_ROOT_ID).map_err(anyhow::Error::from)?; + let current_root_stat = current_root + .object_stat() + .map_err(anyhow::Error::from) + .context("restore attachment root cannot be inspected")?; + let saved_root = state + .inodes + .iter() + .find(|inode| inode.node_id == FUSE_ROOT_ID) + .context("saved state does not contain a root inode")?; + validate_identity(¤t_root_stat, &saved_root.object_identity) + .context("restore attachment root identity does not match")?; + let volume = current_root.volume(); + + let mut restored_inodes = InodeMap::new(false); + let mut node_ids = HashSet::with_capacity(state.inodes.len()); + let mut alias_paths = HashSet::new(); + for saved in &state.inodes { + anyhow::ensure!(node_ids.insert(saved.node_id), "duplicate saved inode ID"); + anyhow::ensure!(saved.volume_id == 0, "unexpected saved volume ID"); + let mut aliases = Vec::with_capacity(saved.relative_aliases.len()); + let mut first_stat = None; + for saved_alias in &saved.relative_aliases { + let path = super::state::decode_relative_path(saved_alias)?; + validate_relative_path(&path, true).map_err(anyhow::Error::from)?; + if saved.node_id == FUSE_ROOT_ID { + anyhow::ensure!( + path.as_os_str().is_empty(), + "root inode has a non-root alias" + ); + } else { + anyhow::ensure!( + !path.as_os_str().is_empty(), + "non-root inode has a root alias" + ); + } + anyhow::ensure!( + alias_paths.insert(path.clone()), + "saved aliases are duplicated or ambiguous" + ); + let stat = validate_reopenable_alias(&volume, &path) + .context("saved inode alias cannot be reopened")?; + validate_identity(&stat, &saved.object_identity) + .context("saved inode identity does not match the attachment")?; + if first_stat.is_none() { + first_stat = Some(stat); + } + aliases.push(path); + } + let stat = first_stat.context("saved inode has no reopenable aliases")?; + let inode = + VirtioFsInode::from_saved(Arc::clone(&volume), aliases, saved.lookup_count, &stat) + .map_err(anyhow::Error::from)?; + anyhow::ensure!( + inode.guest_inode_nr() == saved.guest_inode_id, + "saved guest inode ID does not match the attachment" + ); + let key = inode.dedup_key(); + anyhow::ensure!( + !restored_inodes.inodes_by_key.contains_key(&key), + "saved inode aliases are ambiguous" + ); + let inode = Arc::new(inode); + restored_inodes + .inodes_by_node_id + .values + .insert(saved.node_id, Arc::clone(&inode)); + restored_inodes + .inodes_by_key + .insert(key, (inode, saved.node_id)); + } + restored_inodes.inodes_by_node_id.next_handle = state.next_node_id; + + let mut restored_files = HandleMap::starting_at(state.next_handle_id); + let mut handle_ids = HashSet::with_capacity(state.handles.len()); + for saved in &state.handles { + anyhow::ensure!( + handle_ids.insert(saved.handle_id), + "duplicate saved handle ID" + ); + let inode = restored_inodes + .get(saved.node_id) + .context("saved handle refers to an unknown inode")?; + let reopen_flags = reopen_flags(saved.open_flags)?; + let file = inode + .open(reopen_flags) + .map_err(anyhow::Error::from) + .context("saved handle cannot be reopened")?; + let stat = file + .object_stat() + .map_err(anyhow::Error::from) + .context("reopened handle cannot be inspected")?; + validate_identity(&stat, &saved.object_identity) + .context("reopened handle identity does not match")?; + anyhow::ensure!( + stat.mode & lx::S_IFMT == saved.kind, + "reopened handle kind does not match" + ); + file.restore_directory_snapshot( + saved.directory_snapshot_built, + saved.directory_entries.clone(), + ) + .map_err(anyhow::Error::from) + .context("saved directory snapshot is invalid")?; + restored_files + .values + .insert(saved.handle_id, Arc::new(file)); + } + + session + .restore_state(session_state) + .map_err(anyhow::Error::from) + .context("saved FUSE negotiation cannot be restored")?; + *self.inner.inodes.write() = restored_inodes; + *self.inner.files.write() = restored_files; + *self.inner.negotiation.write() = fuse_negotiation_from_session(session_state); + Ok(()) + } } diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs index a4a23b6598a..9473d4d39f1 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs @@ -1,18 +1,32 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Tests for microVM filesystem limits. +//! Tests for microVM filesystem persistence and limits. use super::profile::MICROVM_ATTACHMENT_ID; +use super::profile::MICROVM_FUSE_MAJOR; +use super::profile::MICROVM_FUSE_MAX_WRITE; +use super::profile::MICROVM_FUSE_MIN_MINOR; use super::profile::MicroVmVirtioFsProfile; use super::profile::microvm_root_identity; use super::saved_state::MAX_ALIASES_PER_INODE; use super::saved_state::MAX_PATH_BYTES; +use super::state::encode_relative_path; +use super::state::fuse_negotiation_from_session; +use super::state::reopen_flags; +use super::state::validate_microvm_state; use super::state::validate_relative_path; use crate::VirtioFs; +use fuse::Session; +use fuse::SessionInfoState; +use fuse::SessionState; +use fuse::protocol::FUSE_ASYNC_READ; +use fuse::protocol::FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2; +use fuse::protocol::FUSE_INIT_EXT; use fuse::protocol::FUSE_ROOT_ID; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use tempfile::tempdir; use test_with_tracing::test; @@ -25,6 +39,78 @@ fn profile(root_path: &Path) -> MicroVmVirtioFsProfile { .unwrap() } +#[test] +fn microvm_state_restores_against_the_same_attachment() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let destination = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + assert!(destination.get_inode(FUSE_ROOT_ID).is_ok()); +} + +#[test] +fn initialized_microvm_negotiation_restores() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session_state = SessionState { + initialized: true, + info: SessionInfoState { + major: MICROVM_FUSE_MAJOR, + minor: MICROVM_FUSE_MIN_MINOR, + capable: FUSE_ASYNC_READ, + want: FUSE_ASYNC_READ, + max_write: MICROVM_FUSE_MAX_WRITE, + time_gran: 1, + ..Default::default() + }, + }; + *source.inner.negotiation.write() = fuse_negotiation_from_session(session_state); + let state = source.save_microvm_state(&profile, session_state).unwrap(); + + let destination = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + assert_eq!(session.save_state(), session_state); + assert!(session.is_initialized()); +} + +#[test] +fn malformed_allocation_state_is_rejected() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + state.next_node_id = FUSE_ROOT_ID; + + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[test] +fn saved_request_size_mismatch_is_rejected() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + state.maximum_request_size = 0; + + assert!(validate_microvm_state(&state, &profile).is_err()); +} + #[test] fn microvm_runtime_path_and_map_limits_are_enforced() { let temporary_directory = tempdir().unwrap(); @@ -73,6 +159,201 @@ fn microvm_alias_limit_is_enforced_before_linking() { ); } +#[test] +fn saved_negotiation_must_match_the_exact_microvm_policy() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session_state = SessionState { + initialized: true, + info: SessionInfoState { + major: MICROVM_FUSE_MAJOR, + minor: MICROVM_FUSE_MIN_MINOR, + capable: FUSE_ASYNC_READ | FUSE_INIT_EXT, + capable2: FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2, + want: FUSE_ASYNC_READ | FUSE_INIT_EXT, + want2: FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2, + max_write: MICROVM_FUSE_MAX_WRITE, + time_gran: 1, + ..Default::default() + }, + }; + *source.inner.negotiation.write() = fuse_negotiation_from_session(session_state); + let mut state = source.save_microvm_state(&profile, session_state).unwrap(); + + state.negotiation.want2 = 0; + assert!(validate_microvm_state(&state, &profile).is_err()); + state.negotiation.want2 = FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2; + state.negotiation.max_background = 1; + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[test] +fn reopen_flags_preserve_safe_status_bits_and_reject_effects() { + const O_NONBLOCK: u32 = 0x800; + const O_DSYNC: u32 = 0x1000; + const O_CLOEXEC: u32 = 0x80000; + const O_TMPFILE: u32 = 0x410000; + + let saved = lx::O_RDWR as u32 + | lx::O_APPEND as u32 + | O_NONBLOCK + | O_DSYNC + | O_CLOEXEC + | lx::O_CREAT as u32 + | lx::O_EXCL as u32 + | lx::O_TRUNC as u32; + let reopened = reopen_flags(saved).unwrap(); + assert_eq!( + reopened, + saved & !(lx::O_CREAT as u32 | lx::O_EXCL as u32 | lx::O_TRUNC as u32) + ); + assert!(reopen_flags(O_TMPFILE).is_err()); + assert!(reopen_flags(0x80000000).is_err()); +} + +#[test] +fn readonly_profile_rejects_a_writable_saved_handle() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let root = source.get_inode(FUSE_ROOT_ID).unwrap(); + source + .insert_file(Arc::clone(&root).open(lx::O_RDONLY as u32).unwrap()) + .unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + state.handles[0].open_flags = lx::O_RDWR as u32 | lx::O_NOFOLLOW as u32; + + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[test] +fn saved_parent_alias_is_rejected_before_reopen() { + let temporary_directory = tempdir().unwrap(); + let profile = profile(temporary_directory.path()); + let source = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + state.inodes[0].relative_aliases = vec![b"..".to_vec()]; + let destination = VirtioFs::new_microvm(temporary_directory.path(), profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + + assert!( + destination + .restore_microvm_state(&profile, state, &session) + .is_err() + ); +} + +#[test] +fn directory_snapshot_survives_host_mutation_after_restore() { + let temporary_directory = tempdir().unwrap(); + let root_path = temporary_directory.path(); + std::fs::write(root_path.join("alpha"), b"alpha").unwrap(); + std::fs::write(root_path.join("beta"), b"beta").unwrap(); + let profile = profile(root_path); + let source = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let root = source.get_inode(FUSE_ROOT_ID).unwrap(); + let handle = source + .insert_file(Arc::clone(&root).open(lx::O_RDONLY as u32).unwrap()) + .unwrap(); + let file = source.get_file(handle).unwrap(); + + let first_page = file.read_dir(&source, 0, 32, false).unwrap(); + assert!(!first_page.is_empty()); + let snapshot = file.directory_entries(); + assert!(snapshot.len() > 1); + let continuation_offset = snapshot[0].next_cookie; + assert_ne!(continuation_offset, 0); + let expected_continuation = file + .read_dir(&source, continuation_offset, 4096, false) + .unwrap(); + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + + std::fs::rename(root_path.join("beta"), root_path.join("00-beta")).unwrap(); + std::fs::write(root_path.join("later"), b"later").unwrap(); + + let destination = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + let continuation = destination + .get_file(handle) + .unwrap() + .read_dir(&destination, continuation_offset, 4096, false) + .unwrap(); + assert_eq!(continuation, expected_continuation); +} + +#[test] +fn hard_link_aliases_restore_and_reject_missing_or_replaced_names() { + let temporary_directory = tempdir().unwrap(); + let root_path = temporary_directory.path(); + std::fs::write(root_path.join("first"), b"data").unwrap(); + std::fs::hard_link(root_path.join("first"), root_path.join("second")).unwrap(); + let profile = profile(root_path); + let source = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let root = source.get_inode(FUSE_ROOT_ID).unwrap(); + let (_, first_node_id) = source + .insert_inode( + root.lookup_child(lx::LxStr::from_bytes(b"first")) + .unwrap() + .0, + ) + .unwrap(); + let (_, second_node_id) = source + .insert_inode( + root.lookup_child(lx::LxStr::from_bytes(b"second")) + .unwrap() + .0, + ) + .unwrap(); + assert_eq!(first_node_id, second_node_id); + + let missing_state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let inode = missing_state + .inodes + .iter() + .find(|inode| inode.node_id == first_node_id) + .unwrap(); + assert_eq!( + inode.relative_aliases, + vec![ + encode_relative_path(Path::new("first")).unwrap(), + encode_relative_path(Path::new("second")).unwrap(), + ] + ); + let replaced_state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + + std::fs::remove_file(root_path.join("second")).unwrap(); + let destination = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + assert!( + destination + .restore_microvm_state(&profile, missing_state, &session) + .is_err() + ); + + std::fs::write(root_path.join("second"), b"replacement").unwrap(); + let destination = VirtioFs::new_microvm(root_path, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + assert!( + destination + .restore_microvm_state(&profile, replaced_state, &session) + .is_err() + ); +} + #[test] fn relative_path_validation_rejects_platform_escapes() { assert!(validate_relative_path(Path::new("../outside"), true).is_err()); diff --git a/vm/devices/virtio/virtiofs/src/microvm/inode.rs b/vm/devices/virtio/virtiofs/src/microvm/inode.rs index 8baba75ad30..6438b8a4021 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/inode.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/inode.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! microVM path confinement. +//! microVM path confinement and persisted inode reconstruction. use super::saved_state::MAX_PATH_BYTES; use super::state::relative_path_encoded_len; @@ -9,10 +9,12 @@ use super::state::validate_relative_path; use crate::inode::VirtioFsInode; use crate::inode::VirtioFsVolume; use lxutil::LxVolume; +use std::collections::BTreeSet; use std::path::Component; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::AtomicU64; impl VirtioFsVolume { pub(crate) fn new_with_strict_paths( @@ -35,6 +37,30 @@ impl VirtioFsVolume { } impl VirtioFsInode { + /// Rebuilds an inode after a saved attachment identity has been + /// independently revalidated. + pub(crate) fn from_saved( + volume: Arc, + aliases: Vec, + lookup_count: u64, + stat: &lx::Stat, + ) -> lx::Result { + let Some(path) = aliases.first().cloned() else { + return Err(lx::Error::EINVAL); + }; + if lookup_count == 0 { + return Err(lx::Error::EINVAL); + } + let mut inode = Self::with_attr(volume, path, stat); + inode.lookup_count = AtomicU64::new(lookup_count); + let aliases: BTreeSet<_> = aliases.into_iter().collect(); + if aliases.is_empty() { + return Err(lx::Error::EINVAL); + } + *inode.aliases.write() = aliases; + Ok(inode) + } + /// Checks that a microVM path is relative and has no symlink component. /// /// LxVolume intentionally does not promise this property for arbitrary diff --git a/vm/devices/virtio/virtiofs/src/microvm/mod.rs b/vm/devices/virtio/virtiofs/src/microvm/mod.rs index e06f9a0a585..1c41b6fcc5f 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/mod.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/mod.rs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! microVM-specific virtio-fs profile, policy, and device support. +//! microVM-specific virtio-fs profile, policy, persistence, and device support. pub(crate) mod device; +pub(crate) mod file; pub(crate) mod fs; #[cfg(test)] mod fs_tests; diff --git a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs index 02cd8eaeb2f..f3a43bb51fd 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs @@ -1,11 +1,150 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Bounds of the microVM virtio-fs inode, alias, and handle tables. +//! Stable, device-private microVM virtio-fs state. +//! +//! These are deliberately separate from the runtime objects. In particular, +//! they contain relative names and object identities, never `LxFile`s, OS +//! handles, or host attachment paths. +use mesh::payload::Protobuf; +use vmcore::save_restore::SavedStateRoot; + +pub(crate) const SCHEMA_VERSION: u32 = 4; pub(crate) const MAX_INODES: usize = 4096; pub(crate) const MAX_HANDLES: usize = 4096; pub(crate) const MAX_PATH_BYTES: usize = 4096; pub(crate) const MAX_ALIASES_PER_INODE: usize = 256; pub(crate) const MAX_ALIASES: usize = 16 * 1024; pub(crate) const MAX_ALIAS_BYTES: usize = 1024 * 1024; +pub(crate) const MAX_DIRECTORY_ENTRIES_PER_HANDLE: usize = 4096; +pub(crate) const MAX_DIRECTORY_ENTRY_NAME_BYTES: usize = 255; +pub(crate) const MAX_DIRECTORY_SNAPSHOT_BYTES: usize = 64 * 1024; +pub(crate) const MAX_DIRECTORY_ENTRIES: usize = 16 * 1024; +pub(crate) const MAX_DIRECTORY_BYTES: usize = 4 * 1024 * 1024; + +#[derive(Protobuf, SavedStateRoot)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedState { + #[mesh(1)] + pub schema_version: u32, + #[mesh(2)] + pub attachment_id: String, + #[mesh(3)] + pub access_mode: u32, + #[mesh(4)] + pub request_queues: u32, + #[mesh(5)] + pub shared_memory_size: u64, + #[mesh(6)] + pub packed_rings: bool, + #[mesh(7)] + pub direct_io: bool, + #[mesh(8)] + pub entry_cache_timeout_ns: u64, + #[mesh(9)] + pub attribute_cache_timeout_ns: u64, + #[mesh(10)] + pub negotiation: SavedNegotiation, + #[mesh(11)] + pub next_node_id: u64, + #[mesh(12)] + pub next_handle_id: u64, + #[mesh(13)] + pub inodes: Vec, + #[mesh(14)] + pub handles: Vec, + #[mesh(15)] + pub attachment_root_identity: Vec, + #[mesh(16)] + pub maximum_request_size: u32, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedNegotiation { + #[mesh(1)] + pub initialized: bool, + #[mesh(2)] + pub major: u32, + #[mesh(3)] + pub minor: u32, + #[mesh(4)] + pub capable: u32, + #[mesh(5)] + pub capable2: u32, + #[mesh(6)] + pub want: u32, + #[mesh(7)] + pub want2: u32, + #[mesh(8)] + pub max_readahead: u32, + #[mesh(9)] + pub max_write: u32, + #[mesh(10)] + pub max_background: u32, + #[mesh(11)] + pub congestion_threshold: u32, + #[mesh(12)] + pub time_gran: u32, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedObjectIdentity { + #[mesh(1)] + pub device_id: u64, + #[mesh(2)] + pub inode_id: u64, + #[mesh(3)] + pub kind: u32, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedInode { + #[mesh(1)] + pub node_id: u64, + #[mesh(2)] + pub volume_id: u32, + #[mesh(3)] + pub relative_aliases: Vec>, + #[mesh(4)] + pub lookup_count: u64, + #[mesh(5)] + pub guest_inode_id: u64, + #[mesh(6)] + pub object_identity: SavedObjectIdentity, +} + +#[derive(Clone, Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedDirectoryEntry { + #[mesh(1)] + pub name: Vec, + #[mesh(2)] + pub next_cookie: u64, + #[mesh(3)] + pub guest_inode_id: u64, + #[mesh(4)] + pub kind: u32, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.fs")] +pub(crate) struct SavedHandle { + #[mesh(1)] + pub handle_id: u64, + #[mesh(2)] + pub node_id: u64, + #[mesh(3)] + pub open_flags: u32, + #[mesh(4)] + pub kind: u32, + #[mesh(5)] + pub object_identity: SavedObjectIdentity, + #[mesh(6)] + pub directory_entries: Vec, + #[mesh(7)] + pub directory_snapshot_built: bool, +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/state.rs b/vm/devices/virtio/virtiofs/src/microvm/state.rs index f929085120c..992443ddc62 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/state.rs @@ -1,9 +1,448 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! microVM virtio-fs relative path validation. +//! microVM virtio-fs state encoding and validation. +use super::MAX_FUSE_REQUEST_BYTES; +use super::profile::MICROVM_FUSE_MAJOR; +use super::profile::MICROVM_FUSE_MAX_WRITE; +use super::profile::MICROVM_FUSE_MIN_MINOR; +use super::profile::MicroVmAccessMode; +use super::profile::MicroVmVirtioFsProfile; +use super::saved_state::MAX_ALIAS_BYTES; +use super::saved_state::MAX_ALIASES; +use super::saved_state::MAX_ALIASES_PER_INODE; +use super::saved_state::MAX_DIRECTORY_BYTES; +use super::saved_state::MAX_DIRECTORY_ENTRIES; +use super::saved_state::MAX_DIRECTORY_ENTRIES_PER_HANDLE; +use super::saved_state::MAX_HANDLES; +use super::saved_state::MAX_INODES; +use super::saved_state::MAX_PATH_BYTES; +use super::saved_state::SCHEMA_VERSION; +use super::saved_state::SavedNegotiation; +use super::saved_state::SavedObjectIdentity; +use super::saved_state::SavedState; +use crate::FuseNegotiation; +use crate::file::VirtioFsFile; +use crate::inode::VirtioFsVolume; +use anyhow::Context; +use fuse::SessionInfoState; +use fuse::SessionState; +use fuse::protocol::*; +use std::collections::HashSet; +use std::ffi::OsString; use std::path::Path; +use std::path::PathBuf; + +const MICROVM_SESSION_DEFAULT_WANT: u32 = FUSE_ASYNC_READ + | FUSE_PARALLEL_DIROPS + | FUSE_AUTO_INVAL_DATA + | FUSE_HANDLE_KILLPRIV + | FUSE_ASYNC_DIO + | FUSE_ATOMIC_O_TRUNC + | FUSE_BIG_WRITES + | FUSE_MAX_PAGES + | FUSE_INIT_EXT; + +fn saved_access_mode(access_mode: MicroVmAccessMode) -> u32 { + match access_mode { + MicroVmAccessMode::ReadOnly => 1, + MicroVmAccessMode::ReadWrite => 2, + } +} + +pub(crate) fn fuse_negotiation_from_session(state: SessionState) -> FuseNegotiation { + FuseNegotiation { + initialized: state.initialized, + major: state.info.major, + minor: state.info.minor, + capable: state.info.capable, + capable2: state.info.capable2, + want: state.info.want, + want2: state.info.want2, + max_readahead: state.info.max_readahead, + max_write: state.info.max_write, + max_background: state.info.max_background, + congestion_threshold: state.info.congestion_threshold, + time_gran: state.info.time_gran, + } +} + +pub(crate) fn saved_negotiation(state: SessionState) -> SavedNegotiation { + SavedNegotiation { + initialized: state.initialized, + major: state.info.major, + minor: state.info.minor, + capable: state.info.capable, + capable2: state.info.capable2, + want: state.info.want, + want2: state.info.want2, + max_readahead: state.info.max_readahead, + max_write: state.info.max_write, + max_background: state.info.max_background.into(), + congestion_threshold: state.info.congestion_threshold.into(), + time_gran: state.info.time_gran, + } +} + +pub(crate) fn session_state_from_saved( + negotiation: &SavedNegotiation, +) -> anyhow::Result { + let state = SessionState { + initialized: negotiation.initialized, + info: SessionInfoState { + major: negotiation.major, + minor: negotiation.minor, + max_readahead: negotiation.max_readahead, + capable: negotiation.capable, + capable2: negotiation.capable2, + want: negotiation.want, + want2: negotiation.want2, + max_background: negotiation + .max_background + .try_into() + .context("saved FUSE max_background is out of range")?, + congestion_threshold: negotiation + .congestion_threshold + .try_into() + .context("saved FUSE congestion_threshold is out of range")?, + max_write: negotiation.max_write, + time_gran: negotiation.time_gran, + }, + }; + state + .validate() + .map_err(anyhow::Error::from) + .context("saved FUSE session state is invalid")?; + Ok(state) +} + +pub(crate) fn saved_identity(stat: &lx::Stat) -> SavedObjectIdentity { + SavedObjectIdentity { + device_id: stat.device_nr, + inode_id: stat.inode_nr, + kind: stat.mode & lx::S_IFMT, + } +} + +pub(crate) fn validate_identity( + stat: &lx::Stat, + saved: &SavedObjectIdentity, +) -> anyhow::Result<()> { + anyhow::ensure!( + saved.kind == stat.mode & lx::S_IFMT, + "object kind changed from {:#x} to {:#x}", + saved.kind, + stat.mode & lx::S_IFMT + ); + anyhow::ensure!( + saved.device_id == stat.device_nr && saved.inode_id == stat.inode_nr, + "object identity changed" + ); + Ok(()) +} + +pub(crate) fn validate_saved_identity( + actual: &SavedObjectIdentity, + expected: &SavedObjectIdentity, +) -> anyhow::Result<()> { + anyhow::ensure!( + actual.kind == expected.kind + && actual.device_id == expected.device_id + && actual.inode_id == expected.inode_id, + "object identity changed between aliases" + ); + Ok(()) +} + +pub(crate) fn validate_reopenable_alias( + volume: &VirtioFsVolume, + path: &Path, +) -> anyhow::Result { + validate_relative_path(path, true).map_err(anyhow::Error::from)?; + let mut prefix = PathBuf::new(); + for component in path.components() { + let std::path::Component::Normal(component) = component else { + anyhow::bail!("alias contains a non-normal path component"); + }; + prefix.push(component); + let stat = volume + .lstat(&prefix) + .map_err(anyhow::Error::from) + .context("alias component cannot be inspected")?; + anyhow::ensure!( + stat.mode & lx::S_IFMT != lx::S_IFLNK, + "alias contains a symbolic-link component" + ); + } + volume + .lstat(path) + .map_err(anyhow::Error::from) + .context("alias cannot be inspected") +} + +pub(crate) fn validate_microvm_state( + state: &SavedState, + profile: &MicroVmVirtioFsProfile, +) -> anyhow::Result<()> { + anyhow::ensure!( + state.schema_version == SCHEMA_VERSION, + "unsupported virtio-fs state schema version {}", + state.schema_version + ); + anyhow::ensure!( + state.attachment_id == profile.attachment_id(), + "saved attachment ID does not match the microVM ABI" + ); + anyhow::ensure!( + state.attachment_root_identity == profile.root_identity(), + "saved root identity does not match the restore attachment" + ); + anyhow::ensure!( + state.access_mode == saved_access_mode(profile.access_mode()), + "saved access mode does not match the restore profile" + ); + anyhow::ensure!( + state.request_queues == profile.request_queues() + && state.shared_memory_size == 0 + && !state.packed_rings + && state.direct_io + && state.entry_cache_timeout_ns == 0 + && state.attribute_cache_timeout_ns == 0 + && state.maximum_request_size == MAX_FUSE_REQUEST_BYTES as u32, + "saved device policy does not match the fixed microVM ABI" + ); + anyhow::ensure!( + state.inodes.len() <= MAX_INODES, + "saved inode table is too large" + ); + anyhow::ensure!( + state.handles.len() <= MAX_HANDLES, + "saved handle table is too large" + ); + anyhow::ensure!( + state.next_node_id != 0 && state.next_handle_id != 0, + "saved allocation state is invalid" + ); + + let session_state = session_state_from_saved(&state.negotiation)?; + if session_state.initialized { + let expected_want = (MICROVM_SESSION_DEFAULT_WANT & state.negotiation.capable) + | (state.negotiation.capable & (FUSE_DO_READDIRPLUS | FUSE_READDIRPLUS_AUTO)); + let expected_want2 = if expected_want & FUSE_INIT_EXT != 0 { + state.negotiation.capable2 & FUSE_DIRECT_IO_ALLOW_MMAP_FLAG2 + } else { + 0 + }; + anyhow::ensure!( + state.negotiation.major == MICROVM_FUSE_MAJOR + && state.negotiation.minor >= MICROVM_FUSE_MIN_MINOR + && state.negotiation.minor <= FUSE_KERNEL_MINOR_VERSION + && state.negotiation.want == expected_want + && state.negotiation.want2 == expected_want2 + && state.negotiation.max_background == 0 + && state.negotiation.congestion_threshold == 0 + && state.negotiation.time_gran == 1 + && state.negotiation.max_write == MICROVM_FUSE_MAX_WRITE, + "saved FUSE negotiation is not reproducible by the microVM profile" + ); + } + + let mut root_count = 0; + let mut largest_node_id = 0; + let mut inode_ids = HashSet::with_capacity(state.inodes.len()); + let mut alias_paths = HashSet::new(); + let mut alias_count = 0usize; + let mut alias_bytes = 0usize; + for inode in &state.inodes { + anyhow::ensure!( + inode.node_id != 0 && inode_ids.insert(inode.node_id), + "saved inode IDs are invalid or duplicated" + ); + largest_node_id = largest_node_id.max(inode.node_id); + if inode.node_id == FUSE_ROOT_ID { + root_count += 1; + } + anyhow::ensure!( + inode.volume_id == 0, + "saved aggregate volumes are unsupported" + ); + anyhow::ensure!( + inode.lookup_count != 0, + "saved inode lookup count is invalid" + ); + anyhow::ensure!( + inode.object_identity.kind != lx::S_IFLNK, + "saved symlink identities are unsupported by the microVM profile" + ); + anyhow::ensure!( + !inode.relative_aliases.is_empty() + && inode.relative_aliases.len() <= MAX_ALIASES_PER_INODE, + "saved inode has no bounded aliases" + ); + alias_count = alias_count + .checked_add(inode.relative_aliases.len()) + .context("saved alias count overflow")?; + anyhow::ensure!(alias_count <= MAX_ALIASES, "saved alias table is too large"); + for alias in &inode.relative_aliases { + anyhow::ensure!( + alias.len() <= MAX_PATH_BYTES, + "saved relative alias is too long" + ); + alias_bytes = alias_bytes + .checked_add(alias.len()) + .context("saved alias bytes overflow")?; + anyhow::ensure!( + alias_bytes <= MAX_ALIAS_BYTES, + "saved aliases exceed the aggregate byte bound" + ); + let path = decode_relative_path(alias)?; + validate_relative_path(&path, true).map_err(anyhow::Error::from)?; + if inode.node_id == FUSE_ROOT_ID { + anyhow::ensure!( + path.as_os_str().is_empty(), + "root inode has a non-root alias" + ); + } else { + anyhow::ensure!( + !path.as_os_str().is_empty(), + "non-root inode has a root alias" + ); + } + anyhow::ensure!( + alias_paths.insert(path), + "saved aliases are duplicated or ambiguous" + ); + } + } + anyhow::ensure!(root_count == 1, "saved inode table must contain one root"); + anyhow::ensure!( + state.next_node_id > largest_node_id, + "saved next inode ID can reuse an active ID" + ); + + let mut largest_handle_id = 0; + let mut handle_ids = HashSet::with_capacity(state.handles.len()); + let mut directory_entry_count = 0usize; + let mut directory_bytes = 0usize; + for handle in &state.handles { + anyhow::ensure!( + handle.handle_id != 0 && handle_ids.insert(handle.handle_id), + "saved handle IDs are invalid or duplicated" + ); + largest_handle_id = largest_handle_id.max(handle.handle_id); + anyhow::ensure!( + inode_ids.contains(&handle.node_id), + "saved handle refers to an unknown inode" + ); + anyhow::ensure!( + handle.object_identity.kind == handle.kind && handle.kind != lx::S_IFLNK, + "saved handle has an unsupported object kind" + ); + let reopen_flags = reopen_flags(handle.open_flags)?; + if profile.is_readonly() { + anyhow::ensure!( + reopen_flags & lx::O_ACCESS_MASK as u32 == lx::O_RDONLY as u32, + "read-only microVM profile cannot restore a writable handle" + ); + } + anyhow::ensure!( + handle.directory_entries.len() <= MAX_DIRECTORY_ENTRIES_PER_HANDLE, + "saved directory enumeration is too large" + ); + anyhow::ensure!( + handle.directory_snapshot_built || handle.directory_entries.is_empty(), + "uninitialized directory snapshot retained entries" + ); + anyhow::ensure!( + !handle.directory_snapshot_built || handle.kind == lx::S_IFDIR, + "saved directory snapshot belongs to a non-directory handle" + ); + VirtioFsFile::validate_directory_entries(&handle.directory_entries) + .map_err(anyhow::Error::from)?; + directory_entry_count = directory_entry_count + .checked_add(handle.directory_entries.len()) + .context("saved directory entry count overflow")?; + anyhow::ensure!( + directory_entry_count <= MAX_DIRECTORY_ENTRIES, + "saved directory entries exceed the aggregate bound" + ); + for entry in &handle.directory_entries { + directory_bytes = directory_bytes + .checked_add(entry.name.len()) + .context("saved directory bytes overflow")?; + } + anyhow::ensure!( + directory_bytes <= MAX_DIRECTORY_BYTES, + "saved directory entries exceed the aggregate byte bound" + ); + } + anyhow::ensure!( + state.next_handle_id > largest_handle_id, + "saved next handle ID can reuse an active ID" + ); + + Ok(()) +} + +pub(crate) fn reopen_flags(saved: u32) -> anyhow::Result { + const O_NOCTTY: u32 = 0x100; + const O_NONBLOCK: u32 = 0x800; + const O_DSYNC: u32 = 0x1000; + const O_SYNC: u32 = 0x101000; + const O_LARGEFILE: u32 = 0x8000; + const O_CLOEXEC: u32 = 0x80000; + const O_TMPFILE: u32 = 0x410000; + + anyhow::ensure!( + saved & O_TMPFILE != O_TMPFILE, + "saved handle uses unsupported O_TMPFILE semantics" + ); + let allowed = lx::O_ACCESS_MASK as u32 + | lx::O_CREAT as u32 + | lx::O_EXCL as u32 + | lx::O_TRUNC as u32 + | lx::O_APPEND as u32 + | lx::O_DIRECTORY as u32 + | lx::O_NOFOLLOW as u32 + | lx::O_NOATIME as u32 + | O_NOCTTY + | O_NONBLOCK + | O_DSYNC + | O_SYNC + | O_LARGEFILE + | O_CLOEXEC; + anyhow::ensure!( + saved & !allowed == 0, + "saved handle has unsupported open flags {:#x}", + saved + ); + anyhow::ensure!( + saved & lx::O_ACCESS_MASK as u32 != lx::O_NOACCESS as u32, + "saved handle has invalid access mode" + ); + Ok(saved & !(lx::O_CREAT as u32 | lx::O_EXCL as u32 | lx::O_TRUNC as u32)) +} + +pub(crate) fn encode_relative_path(path: &Path) -> anyhow::Result> { + validate_relative_path(path, true).map_err(anyhow::Error::from)?; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + + Ok(path.as_os_str().as_bytes().to_vec()) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + + let mut bytes = Vec::new(); + for unit in path.as_os_str().encode_wide() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + Ok(bytes) + } +} pub(crate) fn relative_path_encoded_len(path: &Path) -> lx::Result { #[cfg(unix)] @@ -24,6 +463,36 @@ pub(crate) fn relative_path_encoded_len(path: &Path) -> lx::Result { } } +pub(crate) fn decode_relative_path(bytes: &[u8]) -> anyhow::Result { + anyhow::ensure!( + bytes.len() <= MAX_PATH_BYTES, + "saved relative alias is too long" + ); + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + Ok(PathBuf::from(OsString::from_vec(bytes.to_vec()))) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStringExt; + + anyhow::ensure!( + bytes.len().is_multiple_of(2), + "saved UTF-16 path is malformed" + ); + let units = bytes + .as_chunks::<2>() + .0 + .iter() + .copied() + .map(u16::from_le_bytes) + .collect::>(); + Ok(PathBuf::from(OsString::from_wide(&units))) + } +} + pub(crate) fn validate_relative_path(path: &Path, strict: bool) -> lx::Result<()> { for component in path.components() { let std::path::Component::Normal(component) = component else { diff --git a/vm/devices/virtio/virtiofs/src/virtio.rs b/vm/devices/virtio/virtiofs/src/virtio.rs index 64726ef4aa4..5656445ab09 100644 --- a/vm/devices/virtio/virtiofs/src/virtio.rs +++ b/vm/devices/virtio/virtiofs/src/virtio.rs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use crate::VirtioFs; use crate::microvm::MAX_FUSE_REQUEST_BYTES; +use crate::profile::MicroVmVirtioFsProfile; use crate::virtio_util::VirtioPayloadReader; use crate::virtio_util::VirtioPayloadWriter; use anyhow::Context as _; @@ -10,6 +12,7 @@ use guestmem::GuestMemory; use guestmem::MappedMemoryRegion; use inspect::InspectMut; use pal_async::wait::PolledWait; +use parking_lot::Mutex; use std::io; use std::io::Write; use std::sync::Arc; @@ -23,8 +26,12 @@ use virtio::QueueResources; use virtio::VirtioDevice; use virtio::VirtioQueue; use virtio::VirtioQueueCallbackWork; +use virtio::device::saved_state::DeviceStateValidator; use virtio::queue::QueueState; use virtio::spec::VirtioDeviceFeatures; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; use vmcore::vm_task::VmTaskDriver; use vmcore::vm_task::VmTaskDriverSource; use zerocopy::Immutable; @@ -64,9 +71,9 @@ pub struct VirtioFsDevice { #[inspect(skip)] pub(crate) config: VirtioFsDeviceConfig, #[inspect(skip)] - fs: Arc, + pub(crate) fs: Arc, #[inspect(skip)] - workers: Vec>, + pub(crate) workers: Vec>, shmem_size: u64, #[inspect(skip)] shared_memory_region: Option>, @@ -75,6 +82,80 @@ pub struct VirtioFsDevice { num_request_queues: u32, #[inspect(skip)] pub(crate) microvm_attachment_id: Option, + #[inspect(skip)] + pub(crate) microvm_profile: Option, + #[inspect(skip)] + pub(crate) stateful_fs: Option, + #[inspect(skip)] + pub(crate) admission: Arc, + #[inspect(skip)] + pub(crate) save_error: Option, +} + +struct AdmissionState { + accepting: bool, + in_flight: u32, +} + +pub(crate) struct RequestAdmission { + state: Mutex, +} + +struct RequestGuard { + admission: Arc, +} + +impl RequestAdmission { + fn new() -> Self { + Self { + state: Mutex::new(AdmissionState { + accepting: true, + in_flight: 0, + }), + } + } + + fn accept(self: &Arc) -> Option { + let mut state = self.state.lock(); + if !state.accepting { + return None; + } + state.in_flight = state.in_flight.checked_add(1)?; + Some(RequestGuard { + admission: Arc::clone(self), + }) + } + + pub(crate) fn quiesce(&self) { + let mut state = self.state.lock(); + state.accepting = false; + } + + pub(crate) fn resume(&self) { + let mut state = self.state.lock(); + state.accepting = true; + } + + pub(crate) fn verify_drained(&self) -> anyhow::Result<()> { + let state = self.state.lock(); + anyhow::ensure!( + !state.accepting, + "virtio-fs save was not preceded by input quiesce" + ); + anyhow::ensure!( + state.in_flight == 0, + "virtio-fs has {} unrepresented in-flight request(s)", + state.in_flight + ); + Ok(()) + } +} + +impl Drop for RequestGuard { + fn drop(&mut self) { + let mut state = self.admission.state.lock(); + state.in_flight = state.in_flight.saturating_sub(1); + } } impl VirtioFsDevice { @@ -146,6 +227,10 @@ impl VirtioFsDevice { notify_corruption, num_request_queues, microvm_attachment_id: None, + microvm_profile: None, + stateful_fs: None, + admission: Arc::new(RequestAdmission::new()), + save_error: None, } } } @@ -200,6 +285,7 @@ impl VirtioDevice for VirtioFsDevice { shared_memory_region: self.shared_memory_region.clone(), shared_memory_size: self.shmem_size, notify_corruption: self.notify_corruption.clone(), + admission: Arc::clone(&self.admission), }); let queue_event = PolledWait::new(&self.driver, resources.event) @@ -232,6 +318,7 @@ impl VirtioDevice for VirtioFsDevice { shared_memory_region: None, shared_memory_size: 0, notify_corruption: self.notify_corruption.clone(), + admission: Arc::clone(&self.admission), }) }); } @@ -251,6 +338,7 @@ impl VirtioDevice for VirtioFsDevice { async fn reset(&mut self) { self.workers.clear(); + crate::microvm::device::reset(self); if let Some(region) = &self.shared_memory_region { if let Err(e) = region.unmap(0, self.shmem_size as usize) { tracing::error!( @@ -262,16 +350,43 @@ impl VirtioDevice for VirtioFsDevice { self.shared_memory_region = None; self.fs.destroy(); } + + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + crate::microvm::device::quiesce_input(self); + Ok(()) + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + crate::microvm::device::resume_input(self); + Ok(()) + } + + fn supports_save_restore(&self) -> bool { + crate::microvm::device::supports_save_restore(self) + } + + fn save_device(&mut self) -> Result, SaveError> { + crate::microvm::device::save_device(self) + } + + fn restore_device(&mut self, state: Option) -> Result<(), RestoreError> { + crate::microvm::device::restore_device(self, state) + } + + fn device_state_validator(&self) -> DeviceStateValidator { + crate::microvm::device::device_state_validator(self) + } } -struct VirtioFsWorker { +pub(crate) struct VirtioFsWorker { fs: Arc, shared_memory_region: Option>, shared_memory_size: u64, notify_corruption: Arc, + admission: Arc, } -struct VirtioFsQueue { +pub(crate) struct VirtioFsQueue { queue: VirtioQueue, mem: GuestMemory, } @@ -283,11 +398,17 @@ impl AsyncRun for VirtioFsWorker { state: &mut VirtioFsQueue, ) -> Result<(), Cancelled> { loop { + // Admission must precede queue.next(): after save closes the + // gate, an unowned descriptor must remain on the virtqueue rather + // than being dequeued and falsely completed with an empty reply. + let Some(request_guard) = self.admission.accept() else { + break; + }; let work = stop.until_stopped(state.queue.next()).await?; let Some(work) = work else { break }; match work { Ok(work) => { - let bytes = process_virtiofs_request(self, &state.mem, &work); + let bytes = process_virtiofs_request(self, &state.mem, &work, request_guard); state.queue.complete(work, bytes); } Err(err) => { @@ -307,6 +428,7 @@ fn process_virtiofs_request( worker: &VirtioFsWorker, mem: &GuestMemory, work: &VirtioQueueCallbackWork, + _request_guard: RequestGuard, ) -> u32 { let readable_len = work.get_payload_length(false) as usize; if readable_len > MAX_FUSE_REQUEST_BYTES { @@ -435,6 +557,7 @@ mod tests { use crate::VirtioFs; use pal_async::DefaultDriver; use pal_async::async_test; + use test_with_tracing::test; use vmcore::vm_task::SingleDriverBackend; fn make_device( @@ -490,4 +613,16 @@ mod tests { assert_eq!(device.config.num_request_queues, 3); assert_eq!(device.traits().max_queues, 4); } + + #[test] + fn closed_admission_rejects_new_work_until_all_requests_drain() { + let admission = Arc::new(RequestAdmission::new()); + let request = admission.accept().unwrap(); + admission.quiesce(); + assert!(admission.accept().is_none()); + assert!(admission.verify_drained().is_err()); + + drop(request); + admission.verify_drained().unwrap(); + } } From f68d1eae3430c5ccaff6ef572cb604a9b9d86492 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 08:45:13 -0700 Subject: [PATCH 121/304] openvmm: snapshot the microVM filesystem and revalidate it on restore Snapshot capture rejects `--mount`. The machine contract cannot describe the filesystem, so restore could neither rebuild the device nor check that the host directory is the one that was captured. Record the filesystem in the machine contract: - The new `SnapshotMicrovmFilesystem` (contract field 19) holds the guest mount target, the access mode, the `live-revalidate` restore mode, the fixed queue, cache, and shared-memory policy, and the canonical host path. Shape validation requires its canonical form. - `microvm_machine_contract` adds the virtio-fs device `fs:microvm0` at its fixed slot with IRQ 6, its exact feature banks, and two 256-entry queues. It also adds a required `live-revalidate` attachment whose identity is the root identity: `unix-device-inode-v1` on KVM and MSHV, and `windows-volume-file-id-v1` on WHP. The command line must contain the slot's discovery token and the bootstrap tokens. The device, the policy, and the attachment must appear together. - Capture records the filesystem and warns that the snapshot excludes the host directory, which remains external live state. - Restore requires `--mount` again with the snapshot's guest target, access mode, and canonical host path, and with a root whose identity matches the saved attachment. The device-private state then revalidates every saved object before a vCPU runs. A snapshot without the filesystem still rejects `--mount`. Like the guest memory backing file, the snapshot destination and the restore snapshot must be outside the exported root. Document filesystem snapshots and their restore requirements. Tests cover the contract ABI and its canonical form, and a restore that changes the root, its path, the access mode, or omits `--mount`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 19 +- Guide/src/user_guide/openvmm/snapshots.md | 22 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 8 +- openvmm/openvmm_entry/src/microvm/config.rs | 12 + .../openvmm_entry/src/microvm/filesystem.rs | 158 ++++++++- openvmm/openvmm_entry/src/microvm/launch.rs | 9 + openvmm/openvmm_entry/src/microvm/mod.rs | 2 + openvmm/openvmm_entry/src/microvm/network.rs | 1 + openvmm/openvmm_entry/src/microvm/restore.rs | 17 +- .../src/vm_controller/microvm.rs | 10 + .../openvmm_helpers/src/snapshot/microvm.rs | 323 ++++++++++++++++++ 11 files changed, 566 insertions(+), 15 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index c184542f1ee..14f88d57245 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -120,8 +120,13 @@ describes the source definitions. canonical directory is identified by its device and inode numbers on Linux, or by its volume and file ID on Windows, and the device refuses the attachment if the path no longer names that directory when it opens the - root. The guest memory backing file must be outside the exported root. - Snapshot capture does not yet support `--mount`, and restore rejects it. + root. The guest memory backing file, the snapshot destination, and the + restore snapshot must be outside the exported root. + + Filesystem snapshots contain guest-visible FUSE and queue state, not host + directory contents or native handles. Restore requires `--mount` again with + the exact canonical host path, guest target, and access mode; the live root + and every saved object identity are also revalidated before vCPUs start. * `--snapshot-destination `: Publish a microVM snapshot when the guest writes to PMIO port `0x605`. The destination must not exist and its parent must already be a directory. OpenVMM automatically creates file-backed RAM @@ -137,7 +142,9 @@ describes the source definitions. saves accepted but undelivered input and the offset of a partially forwarded guest transmit descriptor. An attached microVM virtio-net device saves its static identity, queue progress, drained packet ownership, and endpoint - generation. + generation. An attached microVM virtio-fs device saves its negotiated FUSE + policy, namespace and handle identifiers, aliases, and directory cookies. + The host tree remains external live state. ```bash openvmm --machine microvm --hypervisor kvm --memory 128M \ @@ -159,6 +166,12 @@ describes the source definitions. A listener peer may connect after restore; guest transmit descriptors remain pending while no peer is connected. + When the snapshot contains a virtio-fs attachment, restore requires a fresh + `--mount`. The argument must reproduce the manifest's exact canonical host + path, guest target, and `ro`/`rw` mode while also supplying a live root with + the same saved identity. A snapshot without the filesystem rejects + `--mount`. + When the snapshot contains virtio-net, restore also requires `--network-profile portable`; the snapshot's profile must match the supplied portable configuration. diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 69252e92943..dda5fa626e7 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -184,6 +184,25 @@ guest transmit offset live in the device-private virtio payload, preserving their order across a new-process restore. Host input is gated before the vCPU snapshot boundary and resumed only if capture rolls back. +For microVM virtio-fs, the manifest records the stable attachment ID, exact +canonical host path, pinned root identity, guest mount target, access mode, +no-DAX queue policy, and `live-revalidate` restore mode. Its device-private +payload records FUSE negotiation, namespace IDs and aliases, lookup counts, +reopenable handles, bounded directory-entry snapshots and cookies, and queue +progress. Native file descriptors and Windows handles are never serialized. + +Restoring such a snapshot requires a fresh +`--mount ` attachment with the same canonical +host path, target, and mode. OpenVMM independently validates the root and every +saved object identity before starting a vCPU. + +```admonish warning +The host directory is external live state, not snapshot content. Host +mutations after capture can change restored reads or make restore fail. A +read-write restore also changes the shared host directory, and restoring the +same VM snapshot again does not roll those changes back. +``` + The rules are: | Scenario | Result | @@ -244,7 +263,8 @@ OpenVMM snapshots: | Assigned PCI (pass-through) | PCI | **No** | | Relayed vPCI | PCI | **No** | | PCAT BIOS firmware | Chipset (ISA) | **No** (see limitations) | -| virtio-9p, virtiofs | Virtio (PCI/MMIO) | **No** | +| virtio-fs | Virtio (MMIO) | microVM HostFs only | +| virtio-9p | Virtio (PCI/MMIO) | **No** | | Guest Crash Device | VMBus | **No** | | Guest Emulation Device (GED) | VMBus | **No** | | VMBus serial (host) | VMBus | **No** | diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index d327ccd6cd7..9eb8fa8219b 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -56,6 +56,9 @@ pub struct MicrovmCli { pub network_profile: Option, /// attach the microVM virtio-fs device + /// + /// A snapshot with the filesystem requires the same canonical host path, + /// guest target, and mode on restore. #[clap( long = "mount", value_name = "GUEST_TARGET,HOST_PATH[,ro|rw]", @@ -153,10 +156,6 @@ impl Options { self.microvm.snapshot_quiesce_timeout_ms != 0, "microVM snapshot quiesce timeout must be nonzero" ); - anyhow::ensure!( - self.microvm.microvm_mount.is_none(), - "microVM snapshot capture does not yet support --mount" - ); } if self.restore_snapshot.is_some() { anyhow::ensure!( @@ -419,7 +418,6 @@ mod tests { for extra in [ vec!["--snapshot-quiesce-timeout-ms", "0"], vec!["--memory", "size=1G,shared=off"], - vec!["--mount", "/mnt/share,host"], ] { let options = Options::try_parse_from( [ diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index f13e7a2b77b..4c7c8d88412 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -88,6 +88,15 @@ impl<'a> MicrovmConfigBuilder<'a> { } else { None }; + if let Some(filesystem) = &filesystem + && opt.microvm.snapshot_destination.is_some() + { + tracing::warn!( + stable_id = MICROVM_FILESYSTEM_STABLE_ID, + access_mode = filesystem.config.access.as_str(), + "microVM snapshot excludes live host filesystem contents; restore revalidates the external directory and may fail after host changes" + ); + } // Without an egress policy, the guest may use the gateway's DNS proxy. let gateway_dns = network.is_some(); @@ -122,6 +131,9 @@ impl<'a> MicrovmConfigBuilder<'a> { .as_ref() .map(|(_, _, attachment)| attachment.clone()), network_attachment: network.as_ref().map(|network| network.attachment.clone()), + filesystem_attachment: filesystem + .as_ref() + .map(|filesystem| filesystem.attachment.clone()), filesystem_root_path: filesystem .as_ref() .map(|filesystem| PathBuf::from(&filesystem.root_path)), diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index dfadb5130c1..1df9b2d609f 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -226,18 +226,62 @@ pub(crate) fn validate_microvm_filesystem_private_storage( Ok(()) } +pub(crate) fn microvm_filesystem_from_snapshot( + saved: &openvmm_helpers::snapshot::microvm::SnapshotMicrovmFilesystem, +) -> anyhow::Result { + let access = match saved.access_mode.as_str() { + "ro" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + "rw" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + mode => anyhow::bail!("snapshot microVM filesystem access mode '{mode}' is unsupported"), + }; + openvmm_defs::microvm::MicrovmFilesystemConfig::new(saved.guest_mount_target.clone(), access) + .context("snapshot microVM filesystem policy is invalid") +} + pub(super) fn effective_microvm_filesystem( requested: Option<&cli_args::microvm::MicrovmMountCli>, restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, ) -> anyhow::Result> { - if restore.is_none() { + let Some(restore) = restore else { return requested.map(microvm_filesystem_from_mount).transpose(); - } + }; + + let saved_attachment = restore + .attachments + .iter() + .find(|attachment| attachment.stable_id == MICROVM_FILESYSTEM_STABLE_ID); + anyhow::ensure!( + saved_attachment.is_some() == restore.microvm_filesystem.is_some(), + "snapshot microVM filesystem policy and attachment inventories disagree" + ); + let Some(saved) = restore.microvm_filesystem.as_ref() else { + anyhow::ensure!( + requested.is_none(), + "snapshot does not support restore-time microVM filesystem attachment" + ); + return Ok(None); + }; + let requested = requested + .context("snapshot restore requires a fresh --mount attachment for fs:microvm0")?; + let config = microvm_filesystem_from_snapshot(saved)?; + anyhow::ensure!( + requested.guest_target == config.guest_mount_target && requested.access == config.access, + "restore-time mount target or access mode does not match the snapshot contract" + ); + let effective = microvm_filesystem_from_mount(requested)?; + anyhow::ensure!( + !saved.canonical_host_path.is_empty(), + "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" + ); + anyhow::ensure!( + effective.root_path == saved.canonical_host_path, + "restore-time filesystem canonical host path does not match the snapshot contract" + ); anyhow::ensure!( - requested.is_none(), - "snapshot does not support restore-time microVM filesystem attachment" + Some(&effective.attachment) == saved_attachment, + "restore-time filesystem root identity does not match the snapshot attachment" ); - Ok(None) + Ok(Some(effective)) } #[cfg(test)] @@ -246,8 +290,57 @@ mod tests { use crate::Options; use crate::microvm::network::tests::network_contract; use clap::Parser as _; + use openvmm_defs::microvm::build_microvm_command_line; use test_with_tracing::test; + fn filesystem_contract( + root: &Path, + ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/mnt/share".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .unwrap(); + let (root_path, attachment) = microvm_filesystem_attachment(root).unwrap(); + let mut command_line = build_microvm_command_line(&[], false).unwrap(); + openvmm_defs::microvm::append_microvm_virtio_discovery( + &mut command_line, + None, + Some(&filesystem), + false, + ) + .unwrap(); + openvmm_helpers::snapshot::microvm::microvm_machine_contract( + if cfg!(windows) { "whp" } else { "kvm" }, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + None, + Some((&filesystem, Path::new(&root_path), attachment)), + None, + 1, + 1024, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtiofs-3489665024", + ] + .map(str::to_owned) + .to_vec(), + std::time::SystemTime::now().into(), + 1_000_000_000, + Some(1_000_000_000), + vec![1, 2, 3], + ) + .unwrap() + } + fn restore_mount_options(root: &Path, mode: &str) -> Options { Options::try_parse_from([ "openvmm", @@ -291,6 +384,61 @@ mod tests { ); } + #[test] + fn filesystem_restore_requires_same_live_root_identity() { + let root = tempfile::tempdir().unwrap(); + let contract = filesystem_contract(root.path()); + let options = restore_mount_options(root.path(), "ro"); + let restored = + effective_microvm_filesystem(options.microvm.microvm_mount.as_ref(), Some(&contract)) + .unwrap() + .unwrap(); + assert_eq!(restored.config.guest_mount_target, "/mnt/share"); + assert_eq!(restored.attachment, contract.attachments[0]); + + let replacement = tempfile::tempdir().unwrap(); + let replacement_options = restore_mount_options(replacement.path(), "ro"); + assert!( + effective_microvm_filesystem( + replacement_options.microvm.microvm_mount.as_ref(), + Some(&contract) + ) + .is_err() + ); + } + + #[test] + fn filesystem_restore_rejects_same_root_at_a_new_path() { + let parent = tempfile::tempdir().unwrap(); + let original = parent.path().join("original"); + let moved = parent.path().join("moved"); + fs_err::create_dir(&original).unwrap(); + let contract = filesystem_contract(&original); + fs_err::rename(&original, &moved).unwrap(); + + let options = restore_mount_options(&moved, "ro"); + assert!( + effective_microvm_filesystem(options.microvm.microvm_mount.as_ref(), Some(&contract)) + .is_err() + ); + } + + #[test] + fn filesystem_restore_rejects_missing_or_changed_policy() { + let root = tempfile::tempdir().unwrap(); + let contract = filesystem_contract(root.path()); + assert!(effective_microvm_filesystem(None, Some(&contract)).is_err()); + + let changed_mode = restore_mount_options(root.path(), "rw"); + assert!( + effective_microvm_filesystem( + changed_mode.microvm.microvm_mount.as_ref(), + Some(&contract), + ) + .is_err() + ); + } + #[test] fn filesystem_restore_rejects_mount_for_legacy_snapshot_without_slot() { let root = tempfile::tempdir().unwrap(); diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 1725d51b9d8..c5830b2d651 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -14,6 +14,7 @@ use anyhow::Context; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; use openvmm_defs::config::Config; use openvmm_defs::config::LoadMode; +use openvmm_defs::microvm::MicrovmFilesystemConfig; use openvmm_defs::microvm::MicrovmNetworkConfig; use openvmm_defs::worker::SharedMemoryFd; use std::io; @@ -28,6 +29,7 @@ pub(crate) struct MicrovmLaunch { effective_command_line: Option, resources: MicrovmResources, network: Option, + filesystem: Option, snapshot_destination: Option, snapshot_memory_file: Option, snapshot_memory_handle: Option, @@ -124,6 +126,7 @@ impl MicrovmLaunch { effective_command_line, resources, network: vm_config.microvm.network.clone(), + filesystem: vm_config.microvm.filesystem.clone(), snapshot_destination, snapshot_memory_file, snapshot_memory_handle, @@ -190,6 +193,11 @@ impl MicrovmLaunch { self.network .as_ref() .zip(resources.network_attachment.as_ref()), + self.filesystem + .as_ref() + .zip(resources.filesystem_root_path.as_deref()) + .zip(resources.filesystem_attachment.as_ref()) + .map(|((filesystem, root_path), attachment)| (filesystem, root_path, attachment)), resources.console_attachment.as_ref(), ))) } @@ -223,6 +231,7 @@ impl MicrovmLaunch { effective_command_line: self.effective_command_line, resources: self.resources, network: self.network, + filesystem: self.filesystem, snapshot_memory_file: self.snapshot_memory_file, } } diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index bb61909dd5a..1e6ea02e9e7 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -35,6 +35,8 @@ pub(crate) struct MicrovmResources { pub(crate) console_socket_cleanup: Option, /// Snapshot identity of the portable network attachment. pub(crate) network_attachment: Option, + /// Snapshot identity of the live filesystem root. + pub(crate) filesystem_attachment: Option, /// Canonical host path of the live filesystem root. pub(crate) filesystem_root_path: Option, } diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index 09283dffde9..130bd988c59 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -161,6 +161,7 @@ pub(crate) mod tests { command_line, Some((&network, microvm_network_attachment())), None, + None, 1, 1024, [ diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 5882ca3d161..be5be56b522 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -6,11 +6,13 @@ use crate::Options; use crate::cli_args::microvm::MachineProfileCli; use anyhow::Context; +use openvmm_defs::microvm::MicrovmFilesystemConfig; use openvmm_defs::microvm::MicrovmNetworkConfig; use openvmm_helpers::snapshot::SnapshotManifest; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use openvmm_helpers::snapshot::microvm::SnapshotMachineContract; use openvmm_helpers::snapshot::restore::OpenedSnapshot; +use std::path::Path; use std::time::Duration; const MAX_SNAPSHOT_DOWNTIME: Duration = Duration::from_secs(30 * 24 * 60 * 60); @@ -90,11 +92,17 @@ pub(crate) fn prepare_restore( } /// The machine contract a microVM snapshot must match to be restored: the -/// hypervisor, effective command line, network, and boot console attachment. +/// hypervisor, effective command line, network, filesystem, and boot console +/// attachment. pub(crate) type ExpectedRestoreContract<'a> = ( &'a str, &'a str, Option<(&'a MicrovmNetworkConfig, &'a SnapshotAttachment)>, + Option<( + &'a MicrovmFilesystemConfig, + &'a Path, + &'a SnapshotAttachment, + )>, Option<&'a SnapshotAttachment>, ); @@ -112,6 +120,7 @@ pub(crate) fn validate_restore_contract( expected_hypervisor, effective_command_line, network, + filesystem, console_attachment, ): ExpectedRestoreContract<'_>, ) -> anyhow::Result { @@ -119,11 +128,17 @@ pub(crate) fn validate_restore_contract( .machine_contract .as_ref() .context("microVM snapshot is missing its authoritative machine contract")?; + let filesystem = saved_contract + .microvm_filesystem + .as_ref() + .and(filesystem) + .map(|(config, root_path, attachment)| (config, root_path, attachment.clone())); let expected_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( expected_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, effective_command_line.to_owned(), network.map(|(config, attachment)| (config, attachment.clone())), + filesystem, console_attachment.cloned(), expected_vp_count, expected_memory_size, diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index df3da350289..7ed160e02e4 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -33,6 +33,8 @@ pub(crate) struct MicrovmController { pub(crate) resources: MicrovmResources, /// Static identity of the microVM virtio-net device. pub(crate) network: Option, + /// Guest-visible policy of the active microVM filesystem. + pub(crate) filesystem: Option, /// Automatic RAM backing created for snapshot capture. pub(crate) snapshot_memory_file: Option, } @@ -148,11 +150,19 @@ impl VmController { .network .as_ref() .zip(self.microvm.resources.network_attachment.clone()); + let filesystem = self + .microvm + .filesystem + .as_ref() + .zip(self.microvm.resources.filesystem_root_path.as_deref()) + .zip(self.microvm.resources.filesystem_attachment.clone()) + .map(|((filesystem, root_path), attachment)| (filesystem, root_path, attachment)); let machine_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( &self.microvm.source_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, command_line, network, + filesystem, self.microvm.resources.console_attachment.clone(), self.processors, self.memory, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 512c14bd3c6..28ec51b008f 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -11,6 +11,7 @@ use mesh::payload::Protobuf; use mesh::payload::Timestamp; use sha2::Digest; use std::collections::HashSet; +use std::path::Path; /// Linux-direct MP-table boot layout with shared interrupt status. pub const MICROVM_BOOT_LAYOUT_VERSION: u32 = 2; @@ -168,6 +169,66 @@ pub struct SnapshotMicrovmNetwork { pub gateway_mac: Vec, } +/// Canonical guest-visible policy of the microVM filesystem. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMicrovmFilesystem { + /// Absolute guest mount target. + #[mesh(1)] + pub guest_mount_target: String, + /// Snapshot-authoritative `ro` or `rw` access mode. + #[mesh(2)] + pub access_mode: String, + /// Host attachment policy. + #[mesh(3)] + pub restore_mode: String, + /// Fixed virtio-fs tag. + #[mesh(4)] + pub tag: String, + /// Number of high-priority queues. + #[mesh(5)] + pub high_priority_queue_count: u32, + /// Number of request queues. + #[mesh(6)] + pub request_queue_count: u32, + /// DAX/shared-memory window size. + #[mesh(7)] + pub shared_memory_size: u64, + /// Whether all opened files use direct I/O. + #[mesh(8)] + pub direct_io: bool, + /// Guest entry-cache lifetime in nanoseconds. + #[mesh(9)] + pub entry_cache_timeout_ns: u64, + /// Guest attribute-cache lifetime in nanoseconds. + #[mesh(10)] + pub attribute_cache_timeout_ns: u64, + /// Canonical absolute host export path. + #[mesh(11)] + pub canonical_host_path: String, +} + +impl SnapshotMicrovmFilesystem { + fn new( + config: &openvmm_defs::microvm::MicrovmFilesystemConfig, + canonical_host_path: &str, + ) -> Self { + Self { + guest_mount_target: config.guest_mount_target.clone(), + access_mode: config.access.as_str().to_owned(), + restore_mode: "live-revalidate".to_owned(), + tag: "microvm".to_owned(), + high_priority_queue_count: 1, + request_queue_count: 1, + shared_memory_size: 0, + direct_io: true, + entry_cache_timeout_ns: 0, + attribute_cache_timeout_ns: 0, + canonical_host_path: canonical_host_path.to_owned(), + } + } +} + impl SnapshotMicrovmNetwork { fn new(config: &openvmm_defs::microvm::MicrovmNetworkConfig) -> Self { Self { @@ -239,6 +300,9 @@ pub struct SnapshotMachineContract { /// Static identity of the optional microVM virtio-net device. #[mesh(18)] pub microvm_network: Option, + /// Guest-visible policy of the optional microVM virtio-fs device. + #[mesh(19)] + pub microvm_filesystem: Option, /// Effective local APIC timer frequency. #[mesh(20)] pub apic_frequency_hz: Option, @@ -314,6 +378,11 @@ pub fn microvm_machine_contract( &openvmm_defs::microvm::MicrovmNetworkConfig, SnapshotAttachment, )>, + filesystem: Option<( + &openvmm_defs::microvm::MicrovmFilesystemConfig, + &Path, + SnapshotAttachment, + )>, console_attachment: Option, processor_count: u32, memory_size: u64, @@ -473,6 +542,84 @@ pub fn microvm_machine_contract( } else { None }; + if filesystem.is_some() { + let discovery = format!( + "virtio_mmio.device={:#x}@{:#x}:{}", + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_FS_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_FS_IRQ, + ); + anyhow::ensure!( + effective_command_line + .split_ascii_whitespace() + .any(|token| token == discovery), + "microVM virtio-fs slot is missing from the effective command line" + ); + devices.push(SnapshotDevice { + stable_id: "fs:microvm0".to_owned(), + state_unit_name: format!( + "virtiofs-{}", + openvmm_defs::microvm::MICROVM_VIRTIO_FS_MMIO_BASE + ), + kind: "virtio-fs".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio( + openvmm_defs::microvm::MICROVM_VIRTIO_FS_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + )], + irq: Some(openvmm_defs::microvm::MICROVM_VIRTIO_FS_IRQ), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![ + openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES as u32, + (openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES >> 32) as u32, + ], + queue_count: 2, + queue_max_sizes: vec![256, 256], + }); + } + let microvm_filesystem = if let Some((filesystem, canonical_host_path, attachment)) = filesystem + { + let canonical_host_path = canonical_host_path + .to_str() + .context("microVM filesystem canonical host path is not valid UTF-8")?; + anyhow::ensure!( + !canonical_host_path.is_empty(), + "microVM filesystem canonical host path is empty" + ); + anyhow::ensure!( + attachment.stable_id == "fs:microvm0" + && attachment.kind == "virtio-fs" + && attachment.required + && attachment.reconnect_policy == "live-revalidate" + && match source_hypervisor { + "kvm" | "mshv" => attachment.identity_kind == "unix-device-inode-v1", + "whp" => attachment.identity_kind == "windows-volume-file-id-v1", + _ => false, + } + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES + && attachment.length == 0 + && attachment.reconnect_timeout_ms == 0, + "microVM filesystem attachment has an unsupported live-revalidation policy" + ); + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + anyhow::ensure!( + filesystem + .command_line_fragment() + .split_ascii_whitespace() + .all(|token| tokens.contains(token)), + "microVM filesystem command line does not match its saved policy" + ); + attachments.push(attachment); + Some(SnapshotMicrovmFilesystem::new( + filesystem, + canonical_host_path, + )) + } else { + None + }; if let Some(attachment) = console_attachment { let policy_is_valid = match attachment.reconnect_policy.as_str() { "recreate-listener" => { @@ -555,6 +702,7 @@ pub fn microvm_machine_contract( boot_layout_version, clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), microvm_network, + microvm_filesystem, apic_frequency_hz, virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, @@ -659,6 +807,10 @@ pub fn validate_microvm_machine_contract( contract.microvm_network == expected.microvm_network, "snapshot static network identity doesn't match the requested machine" ); + anyhow::ensure!( + contract.microvm_filesystem == expected.microvm_filesystem, + "snapshot filesystem policy doesn't match the requested machine" + ); anyhow::ensure!( contract.tsc_frequency_hz == expected.tsc_frequency_hz && contract.tsc_tolerance_ppm == expected.tsc_tolerance_ppm, @@ -872,6 +1024,41 @@ pub(super) fn validate_machine_contract_shape( ); } + if let Some(filesystem) = &contract.microvm_filesystem { + anyhow::ensure!( + !filesystem.canonical_host_path.is_empty(), + "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" + ); + let access = match filesystem.access_mode.as_str() { + "ro" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + "rw" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + mode => anyhow::bail!("snapshot filesystem access mode '{mode}' is unsupported"), + }; + let parsed = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + filesystem.guest_mount_target.clone(), + access, + ) + .context("snapshot filesystem policy is invalid")?; + anyhow::ensure!( + *filesystem == SnapshotMicrovmFilesystem::new(&parsed, &filesystem.canonical_host_path), + "snapshot filesystem policy is not canonical" + ); + } + + let has_filesystem_device = contract + .devices + .iter() + .any(|device| device.stable_id == "fs:microvm0"); + let has_filesystem_attachment = contract + .attachments + .iter() + .any(|attachment| attachment.stable_id == "fs:microvm0"); + anyhow::ensure!( + has_filesystem_device == contract.microvm_filesystem.is_some() + && has_filesystem_attachment == contract.microvm_filesystem.is_some(), + "snapshot microVM filesystem device, policy, and attachment inventories disagree" + ); + anyhow::ensure!( contract.attachments.len() <= MAX_ATTACHMENTS, "snapshot attachment inventory is too large" @@ -909,6 +1096,11 @@ pub(super) fn validate_machine_contract_shape( "recreate-endpoint" => { !attachment.required && attachment.reconnect_timeout_ms == 0 } + "live-revalidate" => { + attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.length == 0 + } _ => false, }, "snapshot attachment '{}' has an invalid reconnect policy", @@ -994,6 +1186,7 @@ fn test_machine_contract() -> SnapshotMachineContract { boot_layout_version: MICROVM_BOOT_LAYOUT_VERSION, clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), microvm_network: None, + microvm_filesystem: None, apic_frequency_hz: Some(1_000_000_000), virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, @@ -1049,6 +1242,7 @@ mod tests { command_line, Some((&network, microvm_network_attachment(source_hypervisor))), None, + None, 1, 1024, [ @@ -1073,6 +1267,73 @@ mod tests { .unwrap() } + fn microvm_filesystem_attachment(source_hypervisor: &str) -> SnapshotAttachment { + SnapshotAttachment { + stable_id: "fs:microvm0".to_owned(), + kind: "virtio-fs".to_owned(), + required: true, + reconnect_policy: "live-revalidate".to_owned(), + identity_kind: match source_hypervisor { + "kvm" | "mshv" => "unix-device-inode-v1", + "whp" => "windows-volume-file-id-v1", + _ => unreachable!(), + } + .to_owned(), + identity: b"root-object-v1".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + fn generated_filesystem_contract(source_hypervisor: &str) -> SnapshotMachineContract { + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/mnt/share".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .unwrap(); + let command_line = format!( + "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6 {}", + filesystem.command_line_fragment() + ); + microvm_machine_contract( + source_hypervisor, + MICROVM_BOOT_LAYOUT_VERSION, + command_line, + None, + Some(( + &filesystem, + Path::new(if cfg!(windows) { + r"C:\microvm-share" + } else { + "/microvm-share" + }), + microvm_filesystem_attachment(source_hypervisor), + )), + None, + 1, + 1024, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtiofs-3489665024", + ] + .map(str::to_owned) + .to_vec(), + std::time::SystemTime::now().into(), + 1_000_000_000, + Some(1_000_000_000), + vec![1, 2, 3], + ) + .unwrap() + } + fn generated_console_contract() -> SnapshotMachineContract { microvm_machine_contract( "whp", @@ -1080,6 +1341,7 @@ mod tests { "earlycon=xe9 console=hvc1 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0002000:7" .to_owned(), None, + None, Some(microvm_console_attachment()), 1, 1024, @@ -1155,6 +1417,67 @@ mod tests { } } + #[test] + fn generated_microvm_filesystem_contract_has_fixed_abi() { + for source_hypervisor in ["kvm", "mshv", "whp"] { + let contract = generated_filesystem_contract(source_hypervisor); + let filesystem_device = contract.devices.last().unwrap(); + assert_eq!(filesystem_device.stable_id, "fs:microvm0"); + assert_eq!(filesystem_device.state_unit_name, "virtiofs-3489665024"); + assert_eq!(filesystem_device.ranges[0].start, 0xd000_1000); + assert_eq!(filesystem_device.ranges[0].length, 0x1000); + assert_eq!(filesystem_device.irq, Some(6)); + assert_eq!(filesystem_device.transport, "virtio-mmio"); + assert_eq!(filesystem_device.feature_banks, [0x3000_0000, 0x0000_0003]); + assert_eq!(filesystem_device.queue_count, 2); + assert_eq!(filesystem_device.queue_max_sizes, [256, 256]); + assert_eq!( + contract.attachments, + [microvm_filesystem_attachment(source_hypervisor)] + ); + + let filesystem = contract.microvm_filesystem.unwrap(); + assert_eq!(filesystem.guest_mount_target, "/mnt/share"); + assert_eq!(filesystem.access_mode, "ro"); + assert_eq!( + filesystem.canonical_host_path, + if cfg!(windows) { + r"C:\microvm-share" + } else { + "/microvm-share" + } + ); + assert_eq!(filesystem.restore_mode, "live-revalidate"); + assert_eq!(filesystem.tag, "microvm"); + assert_eq!(filesystem.high_priority_queue_count, 1); + assert_eq!(filesystem.request_queue_count, 1); + assert_eq!(filesystem.shared_memory_size, 0); + assert!(filesystem.direct_io); + assert_eq!(filesystem.entry_cache_timeout_ns, 0); + assert_eq!(filesystem.attribute_cache_timeout_ns, 0); + } + } + + #[test] + fn validate_microvm_filesystem_contract_rejects_policy_change() { + let contract = generated_filesystem_contract("whp"); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_filesystem + .as_mut() + .unwrap() + .request_queue_count = 2; + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!(error.to_string().contains("not canonical")); + } + #[test] fn validate_microvm_network_contract_rejects_noncanonical_identity() { let contract = generated_network_contract("whp"); From 7e8fa8d09b19d8d80995edd474b9419efd117193 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 09:01:28 -0700 Subject: [PATCH 122/304] openvmm: reserve a dormant microVM filesystem slot A guest booted without `--mount` cannot gain a share after restore. The device inventory is part of the snapshot contract, and restore cannot add a device that the snapshot does not contain. Reserve the virtio-fs slot in every cold-booted microVM: - Without `--mount`, the slot at MMIO 0xd0001000, IRQ 6, holds a dormant device. `VirtioFsProfile::MicrovmDormant` with the new `VirtioFsBackend::Dormant` resolves to `VirtioFsDevice::new_microvm_dormant`, which has no host attachment, uses emulated doorbells, and saves an explicit dormant state. The slot's discovery token is therefore always on the kernel command line. - The device-private schema becomes version 5, which adds the dormant flag. A dormant state restores into a dormant device or into a new active attachment. An active state requires a host attachment. Version 4 states, which are always active, remain restorable. - `append_microvm_virtio_discovery` takes the slot separately from the filesystem policy. `MicrovmConfig.filesystem_bootstrap` records whether the command line carries the `virtfs_*` bootstrap tokens: a restored guest keeps its snapshot's command line, so a new attachment adds no tokens. - The machine contract records `microvm_filesystem_slot_version` (field 22). A version 1 contract always contains the device, and its policy and attachment appear together. Version 0 contracts keep the earlier rule that the device, the policy, and the attachment appear together. - Restore without `--mount` keeps a dormant slot dormant. Restore with `--mount` binds a new attachment to a dormant slot. Because the cold-boot mount hook has already run, the guest must then mount the `microvm` tag itself. A snapshot without the slot capability still rejects a restore-time attachment. Add the virtio-fs reference page, and document the dormant slot. Tests cover the dormant device shape and its save and restore, including a staged restore through an inactive transport, the slot contract, and restore with and without an attachment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + Guide/src/SUMMARY.md | 2 +- .../src/reference/devices/virtio/virtio-fs.md | 137 +++++++++ Guide/src/reference/openvmm/management/cli.md | 39 ++- Guide/src/user_guide/openvmm/snapshots.md | 25 +- openvmm/openvmm_defs/src/microvm.rs | 26 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 9 +- openvmm/openvmm_entry/src/microvm/config.rs | 45 ++- .../openvmm_entry/src/microvm/filesystem.rs | 113 +++++++- openvmm/openvmm_entry/src/microvm/launch.rs | 7 + openvmm/openvmm_entry/src/microvm/network.rs | 2 + openvmm/openvmm_entry/src/microvm/restore.rs | 3 + .../src/vm_controller/microvm.rs | 3 + .../openvmm_helpers/src/snapshot/microvm.rs | 102 ++++++- .../virtio/virtio_resources/src/fs/microvm.rs | 6 +- vm/devices/virtio/virtio_resources/src/lib.rs | 1 + vm/devices/virtio/virtiofs/Cargo.toml | 1 + .../virtio/virtiofs/src/microvm/device.rs | 265 ++++++++++++++++-- vm/devices/virtio/virtiofs/src/microvm/fs.rs | 1 + .../virtio/virtiofs/src/microvm/resolver.rs | 12 + .../virtiofs/src/microvm/saved_state.rs | 5 +- .../virtio/virtiofs/src/microvm/state.rs | 72 ++++- vm/devices/virtio/virtiofs/src/resolver.rs | 3 + vm/devices/virtio/virtiofs/src/virtio.rs | 5 + 24 files changed, 816 insertions(+), 69 deletions(-) create mode 100644 Guide/src/reference/devices/virtio/virtio-fs.md diff --git a/Cargo.lock b/Cargo.lock index daa5d96b2a9..dde7090f6e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10243,6 +10243,7 @@ name = "virtiofs" version = "0.0.0" dependencies = [ "anyhow", + "chipset_device", "fuse", "futures", "guestmem", diff --git a/Guide/src/SUMMARY.md b/Guide/src/SUMMARY.md index c27086aae6d..f16d50a088b 100644 --- a/Guide/src/SUMMARY.md +++ b/Guide/src/SUMMARY.md @@ -111,7 +111,7 @@ - [Devices]() - [PCI IDs](./reference/devices/pci_ids.md) - [Virtio]() - - [virtio-fs]() + - [virtio-fs](./reference/devices/virtio/virtio-fs.md) - [virtio-9p]() - [virtio-net]() - [virtio-pmem]() diff --git a/Guide/src/reference/devices/virtio/virtio-fs.md b/Guide/src/reference/devices/virtio/virtio-fs.md new file mode 100644 index 00000000000..d662366f0da --- /dev/null +++ b/Guide/src/reference/devices/virtio/virtio-fs.md @@ -0,0 +1,137 @@ +# virtio-fs + +OpenVMM can expose a host directory to a Linux guest through `virtio-fs`. + +## Standard machine + +For the standard machine, `--virtio-fs` creates a HostFs device with a +caller-selected tag and host path: + +```bash +openvmm --virtio-fs myfs,path/to/share +``` + +Mount it in the guest with the same tag: + +```bash +mount -t virtiofs myfs /mnt/share +``` + +Standard-machine virtio-fs may use the normal PCI, VPCI, or MMIO placement +rules. It does not support snapshot and restore. + +## microVM + +The microVM profile reserves one fixed virtio-fs slot. Without `--mount`, the +slot is guest-discoverable but dormant and has no HostFs backend or filesystem +policy. Configure an active attachment with: + +```bash +openvmm --machine microvm \ + --mount /mnt/share,path/to/share,ro \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz +``` + +The format is `GUEST_TARGET,HOST_PATH[,ro|rw]`. The default mode is `ro`; +read-write access must be explicit. The profile fixes the remaining +guest-visible configuration: + +| Property | Value | +|---|---| +| Stable ID | `fs:microvm0` | +| Tag | `microvm` | +| Transport | virtio-mmio at `0xd0001000` | +| Interrupt | IRQ 6 | +| Queues | One high-priority and one request queue | +| Features | Indirect descriptors, event index, version 1, access-platform | +| DAX window | None | +| Cache policy | Zero entry and attribute lifetimes | +| File policy | Direct I/O | +| Maximum write | 1 MiB payload plus protocol headers | + +For an active cold-boot attachment, the profile adds `virtfs_dir`, +`virtfs_tag`, and `virtfs_mode` bootstrap tokens to the kernel command line. +The fixed transport is always discoverable. Active attachment policy and the +canonical absolute host path become snapshot-authoritative. + +`SectionFs`, aggregate roots, alternate tags, PCI transport, DAX, and extra +queues are not part of the microVM profile. + +## Snapshot attachments + +A microVM snapshot stores FUSE negotiation, namespace identifiers, lookup +counts, reopenable handles, directory cookies, and virtqueue progress. It +does not copy the host directory or serialize native file descriptors and +Windows handles. + +An open directory continues from its bounded captured entry snapshot, so +later host additions do not appear midway through that enumeration. New +lookups and newly opened directories still observe the live host tree. + +Restoring a snapshot captured with an active attachment requires a fresh +`--mount` argument and the exact same canonical host path, guest target, and +mode. Identity validation remains independent: before any vCPU starts, +OpenVMM pins the supplied root and validates its saved root and object +identities. Missing, moved, replaced, ambiguous, or no-longer-reopenable +objects fail restore. + +A snapshot captured without `--mount` records the fixed slot as dormant. It +may restore without an attachment, or bind a new `--mount` attachment. Because +execution resumes after the cold-boot mount hook, the guest must mount the +newly attached backend explicitly: + +```bash +mkdir -p /mnt/share +mount -t virtiofs microvm /mnt/share +``` + +Snapshots created before the dormant-slot capability cannot add a restore-time +attachment and fail with a compatibility error. + +```admonish warning +An ordinary host directory is live external state. Host changes after capture +can be visible after restore or make identity validation fail. Restoring the +same read-write VM snapshot does not roll the host directory back. +Quiesce external host writers when deterministic replay is required. +``` + +The snapshot contract uses the `live-revalidate` policy. Immutable filesystem +generations and private writable clones are not currently exposed. + +Snapshot destinations, restore directories, and explicit guest-memory backing +files must be outside the exported host tree. OpenVMM rejects configurations +that would expose guest RAM or snapshot files through virtio-fs. + +## Security model + +Guest FUSE requests, paths, and saved aliases are untrusted. HostFs rejects +absolute and parent-relative aliases, does not follow symbolic links or +Windows reparse points while resolving saved objects, and enforces read-only +mode before invoking a host mutation. + +```admonish warning +The current cross-platform `LxVolume` interface does not provide fully +handle-relative component walking for every operation. Do not allow an +untrusted host process to concurrently replace or rename directories inside +the export; quiesce external namespace mutation during capture and restore. +``` + +Host filesystem behavior differs where Windows cannot represent a POSIX +operation. Unsupported operations return a Linux error rather than reporting +false success. + +## Code references + +- Device implementation: + `vm/devices/virtio/virtiofs/` +- FUSE session implementation: + `vm/devices/support/fs/fuse/` +- Resource contract: + `vm/devices/virtio/virtio_resources/src/lib.rs` and + `vm/devices/virtio/virtio_resources/src/fs/microvm.rs` +- microVM composition and restore attachment validation: + `openvmm/openvmm_entry/src/microvm/filesystem.rs` +- Snapshot manifest and microVM filesystem contract: + `openvmm/openvmm_helpers/src/snapshot.rs` and + `openvmm/openvmm_helpers/src/snapshot/microvm.rs` +- [`virtiofs` rustdoc](https://openvmm.dev/rustdoc/linux/virtiofs/index.html) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 14f88d57245..d95efe3f362 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -47,10 +47,12 @@ describes the source definitions. devices described below. User arguments cannot override `earlycon=`, `console=`, `virtio_mmio.device=`, `nr_cpus=`, `virtnet_*=`, or `virtfs_*=`. - One optional `--net ` NIC is exposed at MMIO `0xd0000000`, one - optional `--mount` HostFs device at MMIO `0xd0001000`, IRQ 6, and one - optional `--virtio-console ` at MMIO `0xd0002000`, IRQ 7 as the - boot/log console (`hvc1`). All use split rings. Firmware, ACPI, + One optional `--net ` NIC is exposed at MMIO `0xd0000000`. + One virtio-fs slot is exposed at MMIO `0xd0001000`, IRQ 6 and remains + dormant when `--mount` is omitted; an optional `--mount` binds HostFs to it; + and one optional `--virtio-console ` is exposed at MMIO + `0xd0002000`, IRQ 7 as the boot/log console (`hvc1`). All use split rings. + Firmware, ACPI, SMBIOS, PCI, VMBus, UARTs, storage, graphics, isolation, nested virtualization, and other devices are rejected. Linux discovers contiguous @@ -124,9 +126,14 @@ describes the source definitions. restore snapshot must be outside the exported root. Filesystem snapshots contain guest-visible FUSE and queue state, not host - directory contents or native handles. Restore requires `--mount` again with - the exact canonical host path, guest target, and access mode; the live root - and every saved object identity are also revalidated before vCPUs start. + directory contents or native handles. An active snapshot requires + `--mount` again with the exact canonical host path, guest target, and access + mode; the live root and every saved object identity are also revalidated + before vCPUs start. A snapshot captured without `--mount` may remain dormant + or bind a new attachment. The resumed guest must then explicitly run + `mount -t virtiofs microvm ` because its cold-boot mount hook + has already completed. + See [virtio-fs](../../devices/virtio/virtio-fs.md). * `--snapshot-destination `: Publish a microVM snapshot when the guest writes to PMIO port `0x605`. The destination must not exist and its parent must already be a directory. OpenVMM automatically creates file-backed RAM @@ -142,9 +149,10 @@ describes the source definitions. saves accepted but undelivered input and the offset of a partially forwarded guest transmit descriptor. An attached microVM virtio-net device saves its static identity, queue progress, drained packet ownership, and endpoint - generation. An attached microVM virtio-fs device saves its negotiated FUSE - policy, namespace and handle identifiers, aliases, and directory cookies. - The host tree remains external live state. + generation. + The fixed microVM virtio-fs slot saves either an explicit dormant state or, + when attached, its negotiated FUSE policy, namespace and handle identifiers, + aliases, and directory cookies. The host tree remains external live state. ```bash openvmm --machine microvm --hypervisor kvm --memory 128M \ @@ -166,11 +174,12 @@ describes the source definitions. A listener peer may connect after restore; guest transmit descriptors remain pending while no peer is connected. - When the snapshot contains a virtio-fs attachment, restore requires a fresh - `--mount`. The argument must reproduce the manifest's exact canonical host - path, guest target, and `ro`/`rw` mode while also supplying a live root with - the same saved identity. A snapshot without the filesystem rejects - `--mount`. + When the snapshot contains an active virtio-fs attachment, restore requires + a fresh `--mount`. The argument must reproduce the manifest's exact + canonical host path, guest target, and `ro`/`rw` mode while also supplying a + live root with the same saved identity. A snapshot advertising the dormant + slot may instead accept a new attachment; snapshots without that capability + reject additive attachment. When the snapshot contains virtio-net, restore also requires `--network-profile portable`; the snapshot's profile must match the supplied diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index dda5fa626e7..b2fe55ce40c 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -184,17 +184,23 @@ guest transmit offset live in the device-private virtio payload, preserving their order across a new-process restore. Host input is gated before the vCPU snapshot boundary and resumed only if capture rolls back. -For microVM virtio-fs, the manifest records the stable attachment ID, exact -canonical host path, pinned root identity, guest mount target, access mode, -no-DAX queue policy, and `live-revalidate` restore mode. Its device-private -payload records FUSE negotiation, namespace IDs and aliases, lookup counts, -reopenable handles, bounded directory-entry snapshots and cookies, and queue -progress. Native file descriptors and Windows handles are never serialized. - -Restoring such a snapshot requires a fresh +For microVM virtio-fs, the manifest always records the fixed, guest-discoverable +slot. A dormant slot has no host attachment or filesystem policy and carries +explicit dormant device-private state. An active slot also records the stable +attachment ID, exact canonical host path, pinned root identity, guest mount +target, access mode, no-DAX queue policy, and `live-revalidate` restore mode. +Its device-private payload records FUSE negotiation, namespace IDs and aliases, +lookup counts, reopenable handles, bounded directory-entry snapshots and +cookies, and queue progress. Native file descriptors and Windows handles are +never serialized. + +Restoring an active slot requires a fresh `--mount ` attachment with the same canonical host path, target, and mode. OpenVMM independently validates the root and every -saved object identity before starting a vCPU. +saved object identity before starting a vCPU. A dormant-slot snapshot may +restore without an attachment or bind a new one. For a new attachment, the +resumed guest explicitly mounts tag `microvm`; the cold-boot mount hook does not +run again. ```admonish warning The host directory is external live state, not snapshot content. Host @@ -208,6 +214,7 @@ The rules are: | Scenario | Result | |---|---| | Device set matches exactly | Restore succeeds | +| Dormant microVM virtio-fs slot becomes attached | **Restore succeeds** — the only additive transition | | Snapshot contains a device not in current config | **Restore fails** — unknown unit name | | Current config has a device not in snapshot | **Restore fails** — inventory mismatch | diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index fb76fc1b558..dcf85117322 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -392,6 +392,7 @@ fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { pub fn append_microvm_virtio_discovery( cmdline: &mut String, network: Option<(&MicrovmNetworkConfig, u32, bool)>, + filesystem_slot: bool, filesystem: Option<&MicrovmFilesystemConfig>, has_console: bool, ) -> anyhow::Result<()> { @@ -401,6 +402,10 @@ pub fn append_microvm_virtio_discovery( .any(|token| token.starts_with("virtio_mmio.device=")), "microVM command line already contains virtio-mmio discovery" ); + anyhow::ensure!( + filesystem.is_none() || filesystem_slot, + "microVM filesystem policy requires the fixed virtio-fs slot" + ); use std::fmt::Write as _; if let Some((_, irq, _)) = network { @@ -413,7 +418,7 @@ pub fn append_microvm_virtio_discovery( " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_NET_MMIO_BASE:#x}:{irq}" )?; } - if filesystem.is_some() { + if filesystem_slot { write!( cmdline, " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_FS_MMIO_BASE:#x}:{MICROVM_VIRTIO_FS_IRQ}" @@ -487,6 +492,10 @@ fn validate_microvm_command_line( config.microvm.filesystem.is_none() || has_filesystem, "microVM filesystem policy requires a virtio-fs device" ); + anyhow::ensure!( + !config.microvm.filesystem_bootstrap || config.microvm.filesystem.is_some(), + "microVM filesystem bootstrap requires an active filesystem policy" + ); let base_tokens = if has_console { MICROVM_CONSOLE_COMMAND_LINE } else { @@ -517,7 +526,7 @@ fn validate_microvm_command_line( "virtio_mmio.device=" => config.virtio_devices.len(), "virtnet_ip=" | "virtnet_mask=" | "virtnet_gw=" => usize::from(has_network), "virtfs_dir=" | "virtfs_tag=" | "virtfs_mode=" => { - usize::from(config.microvm.filesystem.is_some()) + usize::from(config.microvm.filesystem_bootstrap) } _ => 1, }; @@ -571,7 +580,12 @@ fn validate_microvm_command_line( .map(str::to_owned), ); } - if let Some(filesystem) = &config.microvm.filesystem { + if config.microvm.filesystem_bootstrap { + let filesystem = config + .microvm + .filesystem + .as_ref() + .expect("filesystem bootstrap policy was validated above"); expected_discovery.extend( filesystem .command_line_fragment() @@ -676,6 +690,8 @@ pub struct MicrovmConfig { pub network: Option, /// Guest-visible policy for the optional microVM virtio-fs device. pub filesystem: Option, + /// Whether the effective command line bootstraps the active microVM filesystem. + pub filesystem_bootstrap: bool, } fn validate_machine_load_mode( @@ -919,6 +935,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> config.microvm.filesystem.is_none() || has_filesystem, "microVM filesystem policy requires a virtio-fs device" ); + anyhow::ensure!( + !config.microvm.filesystem_bootstrap || config.microvm.filesystem.is_some(), + "microVM filesystem bootstrap requires an active filesystem policy" + ); anyhow::ensure!( config.layout.chipset_low_mmio_size == 1024 * 1024 * 1024 && config.layout.chipset_high_mmio_size == 0 diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 9eb8fa8219b..a1ef55ea34a 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -57,8 +57,9 @@ pub struct MicrovmCli { /// attach the microVM virtio-fs device /// - /// A snapshot with the filesystem requires the same canonical host path, - /// guest target, and mode on restore. + /// An active snapshot requires the same canonical host path, guest target, + /// and mode. A dormant-slot snapshot may bind a new attachment on restore; + /// the resumed guest must mount the `microvm` tag explicitly. #[clap( long = "mount", value_name = "GUEST_TARGET,HOST_PATH[,ro|rw]", @@ -538,7 +539,7 @@ mod tests { ); let mut with_devices = build_microvm_command_line(&[], true).unwrap(); - append_microvm_virtio_discovery(&mut with_devices, None, None, true).unwrap(); + append_microvm_virtio_discovery(&mut with_devices, None, false, None, true).unwrap(); assert_eq!( with_devices, format!("{MICROVM_CONSOLE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0002000:7") @@ -550,7 +551,7 @@ mod tests { ) .unwrap(); let mut with_filesystem = build_microvm_command_line(&[], false).unwrap(); - append_microvm_virtio_discovery(&mut with_filesystem, None, Some(&filesystem), false) + append_microvm_virtio_discovery(&mut with_filesystem, None, true, Some(&filesystem), false) .unwrap(); assert_eq!( with_filesystem, diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 4c7c8d88412..6a01813fd4a 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -12,6 +12,7 @@ use super::console::validate_microvm_console_attachment_namespace; use super::filesystem::EffectiveMicrovmFilesystem; use super::filesystem::MICROVM_FILESYSTEM_STABLE_ID; use super::filesystem::effective_microvm_filesystem; +use super::filesystem::microvm_filesystem_slot_from_snapshot; use super::network::EffectiveMicrovmNetwork; use super::network::effective_microvm_network; use super::network::microvm_network_endpoint; @@ -59,6 +60,7 @@ pub(crate) struct MicrovmConfigBuilder<'a> { restore: &'a MicrovmRestore, active: bool, network: Option, + filesystem_slot: bool, filesystem: Option, gateway_dns: bool, console: Option, @@ -80,6 +82,14 @@ impl<'a> MicrovmConfigBuilder<'a> { } else { None }; + let filesystem_slot = if active { + restore_machine_contract + .map(microvm_filesystem_slot_from_snapshot) + .transpose()? + .unwrap_or(true) + } else { + false + }; let filesystem = if active { effective_microvm_filesystem( opt.microvm.microvm_mount.as_ref(), @@ -145,6 +155,7 @@ impl<'a> MicrovmConfigBuilder<'a> { restore, active, network, + filesystem_slot, filesystem, gateway_dns, console, @@ -389,20 +400,33 @@ impl<'a> MicrovmConfigBuilder<'a> { ); } - if let Some(filesystem) = &self.filesystem { - add_virtio_device( - VirtioBusCli::Mmio, - virtio_resources::fs::VirtioFsHandle { - tag: "microvm".to_owned(), - fs: virtio_resources::fs::VirtioFsBackend::HostFs { + if self.filesystem_slot { + let (fs, profile) = if let Some(filesystem) = &self.filesystem { + ( + virtio_resources::fs::VirtioFsBackend::HostFs { root_path: filesystem.root_path.clone(), mount_options: String::new(), }, - profile: virtio_resources::fs::microvm::VirtioFsProfile::Microvm { + virtio_resources::fs::microvm::VirtioFsProfile::Microvm { stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(), root_identity: filesystem.attachment.identity.clone(), read_only: filesystem.config.access.is_read_only(), }, + ) + } else { + ( + virtio_resources::fs::VirtioFsBackend::Dormant, + virtio_resources::fs::microvm::VirtioFsProfile::MicrovmDormant { + stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(), + }, + ) + }; + add_virtio_device( + VirtioBusCli::Mmio, + virtio_resources::fs::VirtioFsHandle { + tag: "microvm".to_owned(), + fs, + profile, } .into_resource(), ); @@ -456,10 +480,14 @@ impl<'a> MicrovmConfigBuilder<'a> { } } cfg.microvm.network = self.network.as_ref().map(|network| network.config.clone()); - cfg.microvm.filesystem = self + let microvm_filesystem = self .filesystem .as_ref() .map(|filesystem| filesystem.config.clone()); + cfg.microvm.filesystem_bootstrap = restore_machine_contract + .map(|contract| contract.microvm_filesystem.is_some()) + .unwrap_or_else(|| microvm_filesystem.is_some()); + cfg.microvm.filesystem = microvm_filesystem; let requested_hypervisor = opt .hypervisor @@ -495,6 +523,7 @@ impl<'a> MicrovmConfigBuilder<'a> { openvmm_defs::microvm::append_microvm_virtio_discovery( cmdline, network, + self.filesystem_slot, cfg.microvm.filesystem.as_ref(), has_console, )?; diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index 1df9b2d609f..9300c28acb2 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -238,6 +238,28 @@ pub(crate) fn microvm_filesystem_from_snapshot( .context("snapshot microVM filesystem policy is invalid") } +pub(crate) fn microvm_filesystem_slot_from_snapshot( + contract: &openvmm_helpers::snapshot::microvm::SnapshotMachineContract, +) -> anyhow::Result { + let has_device = contract + .devices + .iter() + .any(|device| device.stable_id == MICROVM_FILESYSTEM_STABLE_ID); + match contract.microvm_filesystem_slot_version { + 0 => Ok(has_device), + openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION => { + anyhow::ensure!( + has_device, + "snapshot advertises a restore-attachable microVM filesystem slot but omits its fixed device" + ); + Ok(true) + } + version => anyhow::bail!( + "snapshot microVM filesystem slot capability version {version} is unsupported" + ), + } +} + pub(super) fn effective_microvm_filesystem( requested: Option<&cli_args::microvm::MicrovmMountCli>, restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, @@ -246,20 +268,28 @@ pub(super) fn effective_microvm_filesystem( return requested.map(microvm_filesystem_from_mount).transpose(); }; + let has_device = microvm_filesystem_slot_from_snapshot(restore)?; let saved_attachment = restore .attachments .iter() .find(|attachment| attachment.stable_id == MICROVM_FILESYSTEM_STABLE_ID); anyhow::ensure!( - saved_attachment.is_some() == restore.microvm_filesystem.is_some(), - "snapshot microVM filesystem policy and attachment inventories disagree" + saved_attachment.is_some() == restore.microvm_filesystem.is_some() + && (restore.microvm_filesystem_slot_version + == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION + || has_device == restore.microvm_filesystem.is_some()), + "snapshot microVM filesystem slot, policy, and attachment inventories disagree" ); let Some(saved) = restore.microvm_filesystem.as_ref() else { + let Some(requested) = requested else { + return Ok(None); + }; anyhow::ensure!( - requested.is_none(), + restore.microvm_filesystem_slot_version + == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION, "snapshot does not support restore-time microVM filesystem attachment" ); - return Ok(None); + return microvm_filesystem_from_mount(requested).map(Some); }; let requested = requested .context("snapshot restore requires a fresh --mount attachment for fs:microvm0")?; @@ -306,6 +336,7 @@ mod tests { openvmm_defs::microvm::append_microvm_virtio_discovery( &mut command_line, None, + true, Some(&filesystem), false, ) @@ -315,6 +346,7 @@ mod tests { openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, command_line, None, + true, Some((&filesystem, Path::new(&root_path), attachment)), None, 1, @@ -341,6 +373,49 @@ mod tests { .unwrap() } + fn dormant_filesystem_contract() -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract + { + let mut command_line = build_microvm_command_line(&[], false).unwrap(); + openvmm_defs::microvm::append_microvm_virtio_discovery( + &mut command_line, + None, + true, + None, + false, + ) + .unwrap(); + openvmm_helpers::snapshot::microvm::microvm_machine_contract( + if cfg!(windows) { "whp" } else { "kvm" }, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + None, + true, + None, + None, + 1, + 1024, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtiofs-3489665024", + ] + .map(str::to_owned) + .to_vec(), + std::time::SystemTime::now().into(), + 1_000_000_000, + Some(1_000_000_000), + vec![1, 2, 3], + ) + .unwrap() + } + fn restore_mount_options(root: &Path, mode: &str) -> Options { Options::try_parse_from([ "openvmm", @@ -439,6 +514,36 @@ mod tests { ); } + #[test] + fn filesystem_restore_without_mount_preserves_dormant_slot() { + let contract = dormant_filesystem_contract(); + assert!( + effective_microvm_filesystem(None, Some(&contract)) + .unwrap() + .is_none() + ); + } + + #[test] + fn filesystem_restore_attaches_mount_to_dormant_slot() { + let root = tempfile::tempdir().unwrap(); + let contract = dormant_filesystem_contract(); + let options = restore_mount_options(root.path(), "rw"); + let filesystem = + effective_microvm_filesystem(options.microvm.microvm_mount.as_ref(), Some(&contract)) + .unwrap() + .unwrap(); + assert_eq!(filesystem.config.guest_mount_target, "/mnt/share"); + assert_eq!( + filesystem.config.access, + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + ); + assert_eq!( + filesystem.root_path, + fs_err::canonicalize(root.path()).unwrap().to_str().unwrap() + ); + } + #[test] fn filesystem_restore_rejects_mount_for_legacy_snapshot_without_slot() { let root = tempfile::tempdir().unwrap(); diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index c5830b2d651..c157b621e18 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -29,6 +29,7 @@ pub(crate) struct MicrovmLaunch { effective_command_line: Option, resources: MicrovmResources, network: Option, + filesystem_slot: bool, filesystem: Option, snapshot_destination: Option, snapshot_memory_file: Option, @@ -51,6 +52,10 @@ impl MicrovmLaunch { } => Some(cmdline.clone()), _ => None, }; + let filesystem_slot = vm_config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtiofs"); let snapshot_destination = opt.microvm.snapshot_destination.as_ref().map(|path| { if path.is_absolute() { @@ -126,6 +131,7 @@ impl MicrovmLaunch { effective_command_line, resources, network: vm_config.microvm.network.clone(), + filesystem_slot, filesystem: vm_config.microvm.filesystem.clone(), snapshot_destination, snapshot_memory_file, @@ -231,6 +237,7 @@ impl MicrovmLaunch { effective_command_line: self.effective_command_line, resources: self.resources, network: self.network, + filesystem_slot: self.filesystem_slot, filesystem: self.filesystem, snapshot_memory_file: self.snapshot_memory_file, } diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index 130bd988c59..e5692d28099 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -151,6 +151,7 @@ pub(crate) mod tests { openvmm_defs::microvm::append_microvm_virtio_discovery( &mut command_line, Some((&network, irq, true)), + false, None, false, ) @@ -160,6 +161,7 @@ pub(crate) mod tests { openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, command_line, Some((&network, microvm_network_attachment())), + false, None, None, 1, diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index be5be56b522..dcd60b65eea 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -3,6 +3,7 @@ //! MicroVM snapshot restore-time contract checks. +use super::filesystem::microvm_filesystem_slot_from_snapshot; use crate::Options; use crate::cli_args::microvm::MachineProfileCli; use anyhow::Context; @@ -128,6 +129,7 @@ pub(crate) fn validate_restore_contract( .machine_contract .as_ref() .context("microVM snapshot is missing its authoritative machine contract")?; + let filesystem_slot = microvm_filesystem_slot_from_snapshot(saved_contract)?; let filesystem = saved_contract .microvm_filesystem .as_ref() @@ -138,6 +140,7 @@ pub(crate) fn validate_restore_contract( openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, effective_command_line.to_owned(), network.map(|(config, attachment)| (config, attachment.clone())), + filesystem_slot, filesystem, console_attachment.cloned(), expected_vp_count, diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 7ed160e02e4..d62535061b2 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -33,6 +33,8 @@ pub(crate) struct MicrovmController { pub(crate) resources: MicrovmResources, /// Static identity of the microVM virtio-net device. pub(crate) network: Option, + /// Whether the microVM virtio-fs slot is present. + pub(crate) filesystem_slot: bool, /// Guest-visible policy of the active microVM filesystem. pub(crate) filesystem: Option, /// Automatic RAM backing created for snapshot capture. @@ -162,6 +164,7 @@ impl VmController { openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, command_line, network, + self.microvm.filesystem_slot, filesystem, self.microvm.resources.console_attachment.clone(), self.processors, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 28ec51b008f..41a222ba50d 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -13,6 +13,8 @@ use sha2::Digest; use std::collections::HashSet; use std::path::Path; +/// Capability version for the always-present dormant microVM virtio-fs slot. +pub const MICROVM_FILESYSTEM_SLOT_VERSION: u32 = 1; /// Linux-direct MP-table boot layout with shared interrupt status. pub const MICROVM_BOOT_LAYOUT_VERSION: u32 = 2; /// Snapshot contract name for shared-status edge interrupts. @@ -306,6 +308,9 @@ pub struct SnapshotMachineContract { /// Effective local APIC timer frequency. #[mesh(20)] pub apic_frequency_hz: Option, + /// Version of the reserved restore-attachable microVM virtio-fs slot. + #[mesh(22)] + pub microvm_filesystem_slot_version: u32, /// Virtio interrupt-delivery mode. #[mesh(24)] pub virtio_interrupt_mode: String, @@ -378,6 +383,7 @@ pub fn microvm_machine_contract( &openvmm_defs::microvm::MicrovmNetworkConfig, SnapshotAttachment, )>, + filesystem_slot: bool, filesystem: Option<( &openvmm_defs::microvm::MicrovmFilesystemConfig, &Path, @@ -542,7 +548,11 @@ pub fn microvm_machine_contract( } else { None }; - if filesystem.is_some() { + anyhow::ensure!( + filesystem.is_none() || filesystem_slot, + "microVM filesystem policy requires the reserved virtio-fs slot" + ); + if filesystem_slot { let discovery = format!( "virtio_mmio.device={:#x}@{:#x}:{}", openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, @@ -704,6 +714,11 @@ pub fn microvm_machine_contract( microvm_network, microvm_filesystem, apic_frequency_hz, + microvm_filesystem_slot_version: if filesystem_slot { + MICROVM_FILESYSTEM_SLOT_VERSION + } else { + 0 + }, virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, virtio_shared_status_page_size: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, @@ -759,6 +774,10 @@ pub fn validate_microvm_machine_contract( contract.microvm_abi_version, expected.microvm_abi_version, ); + anyhow::ensure!( + contract.microvm_filesystem_slot_version == expected.microvm_filesystem_slot_version, + "snapshot microVM filesystem slot capability doesn't match the requested machine" + ); anyhow::ensure!( contract.source_hypervisor == expected.source_hypervisor, "snapshot source hypervisor '{}' doesn't match expected '{}'", @@ -1053,11 +1072,21 @@ pub(super) fn validate_machine_contract_shape( .attachments .iter() .any(|attachment| attachment.stable_id == "fs:microvm0"); - anyhow::ensure!( - has_filesystem_device == contract.microvm_filesystem.is_some() - && has_filesystem_attachment == contract.microvm_filesystem.is_some(), - "snapshot microVM filesystem device, policy, and attachment inventories disagree" - ); + match contract.microvm_filesystem_slot_version { + 0 => anyhow::ensure!( + has_filesystem_device == contract.microvm_filesystem.is_some() + && has_filesystem_attachment == contract.microvm_filesystem.is_some(), + "legacy snapshot microVM filesystem device, policy, and attachment inventories disagree" + ), + MICROVM_FILESYSTEM_SLOT_VERSION => anyhow::ensure!( + has_filesystem_device + && has_filesystem_attachment == contract.microvm_filesystem.is_some(), + "snapshot reserved microVM filesystem slot, policy, and attachment inventories disagree" + ), + version => anyhow::bail!( + "snapshot microVM filesystem slot capability version {version} is unsupported" + ), + } anyhow::ensure!( contract.attachments.len() <= MAX_ATTACHMENTS, @@ -1187,6 +1216,7 @@ fn test_machine_contract() -> SnapshotMachineContract { clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), microvm_network: None, microvm_filesystem: None, + microvm_filesystem_slot_version: 0, apic_frequency_hz: Some(1_000_000_000), virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, @@ -1241,6 +1271,7 @@ mod tests { MICROVM_BOOT_LAYOUT_VERSION, command_line, Some((&network, microvm_network_attachment(source_hypervisor))), + false, None, None, 1, @@ -1300,6 +1331,7 @@ mod tests { MICROVM_BOOT_LAYOUT_VERSION, command_line, None, + true, Some(( &filesystem, Path::new(if cfg!(windows) { @@ -1334,6 +1366,40 @@ mod tests { .unwrap() } + fn generated_dormant_filesystem_contract() -> SnapshotMachineContract { + microvm_machine_contract( + "whp", + MICROVM_BOOT_LAYOUT_VERSION, + "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6" + .to_owned(), + None, + true, + None, + None, + 1, + 1024, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtiofs-3489665024", + ] + .map(str::to_owned) + .to_vec(), + std::time::SystemTime::now().into(), + 1_000_000_000, + Some(1_000_000_000), + vec![1, 2, 3], + ) + .unwrap() + } + fn generated_console_contract() -> SnapshotMachineContract { microvm_machine_contract( "whp", @@ -1341,6 +1407,7 @@ mod tests { "earlycon=xe9 console=hvc1 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0002000:7" .to_owned(), None, + false, None, Some(microvm_console_attachment()), 1, @@ -1458,6 +1525,29 @@ mod tests { } } + #[test] + fn generated_dormant_microvm_filesystem_contract_reserves_fixed_slot() { + let contract = generated_dormant_filesystem_contract(); + assert_eq!( + contract.microvm_filesystem_slot_version, + MICROVM_FILESYSTEM_SLOT_VERSION + ); + assert!(contract.microvm_filesystem.is_none()); + assert!(contract.attachments.is_empty()); + let filesystem = contract.devices.last().unwrap(); + assert_eq!(filesystem.stable_id, "fs:microvm0"); + assert_eq!(filesystem.state_unit_name, "virtiofs-3489665024"); + assert_eq!(filesystem.ranges[0].start, 0xd000_1000); + assert_eq!(filesystem.irq, Some(6)); + } + + #[test] + fn dormant_microvm_filesystem_capability_requires_fixed_device() { + let mut contract = generated_dormant_filesystem_contract(); + contract.devices.pop(); + assert!(validate_machine_contract_shape(&contract, 1024, 1).is_err()); + } + #[test] fn validate_microvm_filesystem_contract_rejects_policy_change() { let contract = generated_filesystem_contract("whp"); diff --git a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs index d1451294c55..0e1da4191a1 100644 --- a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs +++ b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs @@ -4,7 +4,8 @@ //! microVM profile of the virtio-fs resource. //! //! [`VirtioFsProfile`] selects the standard virtio-fs device or the microVM -//! device, which serves an attached host folder. +//! device, which serves either an attached host folder or a dormant slot +//! backed by [`super::VirtioFsBackend::Dormant`]. use mesh::MeshPayload; @@ -16,4 +17,7 @@ pub enum VirtioFsProfile { root_identity: Vec, read_only: bool, }, + MicrovmDormant { + stable_id: String, + }, } diff --git a/vm/devices/virtio/virtio_resources/src/lib.rs b/vm/devices/virtio/virtio_resources/src/lib.rs index 9646f55405e..7980efaf053 100644 --- a/vm/devices/virtio/virtio_resources/src/lib.rs +++ b/vm/devices/virtio/virtio_resources/src/lib.rs @@ -69,6 +69,7 @@ pub mod fs { Aggregate { children: Vec, }, + Dormant, } /// A single host folder exposed as a named child of a [`VirtioFsBackend::Aggregate`]. diff --git a/vm/devices/virtio/virtiofs/Cargo.toml b/vm/devices/virtio/virtiofs/Cargo.toml index 5f9bd35353b..b07c564aa85 100644 --- a/vm/devices/virtio/virtiofs/Cargo.toml +++ b/vm/devices/virtio/virtiofs/Cargo.toml @@ -44,6 +44,7 @@ windows-sys = { workspace = true, features = [ workspace = true [dev-dependencies] +chipset_device.workspace = true pal_async.workspace = true pal_event.workspace = true tempfile.workspace = true diff --git a/vm/devices/virtio/virtiofs/src/microvm/device.rs b/vm/devices/virtio/virtiofs/src/microvm/device.rs index e207e48239a..f7420144fca 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/device.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/device.rs @@ -1,12 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! microVM virtio-fs device construction and save/restore. +//! microVM virtio-fs device construction and dormant-slot save/restore. +use super::profile::MICROVM_ATTACHMENT_ID; use super::profile::MICROVM_MOUNT_TAG; use super::profile::MICROVM_REQUEST_QUEUES; use super::profile::MicroVmVirtioFsProfile; use super::saved_state::SavedState; +use super::state::save_dormant_microvm_state; +use super::state::validate_dormant_microvm_state; use super::state::validate_microvm_state; use crate::VirtioFs; use crate::virtio::VirtioFsDevice; @@ -21,6 +24,10 @@ use vmcore::save_restore::SaveError; use vmcore::save_restore::SavedStateBlob; use vmcore::vm_task::VmTaskDriverSource; +struct DormantMicrovmFs; + +impl fuse::Fuse for DormantMicrovmFs {} + impl VirtioFsDevice { /// Creates the fixed no-DAX microVM virtio-fs device. /// @@ -52,6 +59,28 @@ impl VirtioFsDevice { Ok(device) } + /// Creates the fixed no-DAX microVM virtio-fs device without a host attachment. + pub fn new_microvm_dormant( + driver_source: &VmTaskDriverSource, + stable_id: String, + notify_corruption: Option>, + ) -> anyhow::Result { + anyhow::ensure!( + stable_id == MICROVM_ATTACHMENT_ID, + "microVM virtio-fs attachment ID must be '{MICROVM_ATTACHMENT_ID}'" + ); + let mut device = Self::with_num_request_queues( + driver_source, + MICROVM_MOUNT_TAG, + DormantMicrovmFs, + 0, + notify_corruption, + MICROVM_REQUEST_QUEUES, + ); + device.microvm_attachment_id = Some(stable_id); + Ok(device) + } + /// Creates the fixed no-DAX microVM HostFs device directly from the /// `Microvm` resource fields and its process-local host root. pub fn new_microvm_hostfs( @@ -75,6 +104,10 @@ pub(crate) fn device_features(device: &VirtioFsDevice) -> VirtioDeviceFeatures { .with_ring_packed(device.microvm_attachment_id.is_none()) } +pub(crate) fn supports_accelerated_doorbells(device: &VirtioFsDevice) -> bool { + device.microvm_attachment_id.is_none() || device.microvm_profile.is_some() +} + pub(crate) fn validate_shared_memory(device: &VirtioFsDevice) -> anyhow::Result<()> { anyhow::ensure!( device.microvm_attachment_id.is_none(), @@ -119,9 +152,9 @@ pub(crate) fn supports_save_restore(device: &VirtioFsDevice) -> bool { pub(crate) fn save_device( device: &mut VirtioFsDevice, ) -> Result, SaveError> { - if device.microvm_attachment_id.is_none() { + let Some(attachment_id) = device.microvm_attachment_id.as_deref() else { return Err(SaveError::NotSupported); - } + }; if let Some(error) = device.save_error.take() { return Err(SaveError::Other(error)); } @@ -138,6 +171,8 @@ pub(crate) fn save_device( (Some(profile), Some(fs)) => fs .save_microvm_state(profile, device.fs.save_state()) .map_err(SaveError::Other)?, + (None, None) => save_dormant_microvm_state(attachment_id, device.fs.save_state()) + .map_err(SaveError::Other)?, _ => { return Err(SaveError::Other(anyhow::anyhow!( "microVM virtio-fs profile and attachment state disagree" @@ -151,15 +186,24 @@ pub(crate) fn restore_device( device: &mut VirtioFsDevice, state: Option, ) -> Result<(), RestoreError> { - if device.microvm_attachment_id.is_none() { - return Err(RestoreError::SavedStateNotSupported); - } + let attachment_id = device + .microvm_attachment_id + .as_deref() + .ok_or(RestoreError::SavedStateNotSupported)?; let state = state.ok_or_else(|| { RestoreError::InvalidSavedState(anyhow::anyhow!( "microVM virtio-fs is missing device-private saved state" )) })?; let state: SavedState = state.parse().map_err(RestoreError::ProtobufDecode)?; + if state.dormant { + let session_state = validate_dormant_microvm_state(&state, attachment_id) + .map_err(RestoreError::InvalidSavedState)?; + return device + .fs + .restore_state(session_state) + .map_err(|error| RestoreError::InvalidSavedState(error.into())); + } let profile = device.microvm_profile.as_ref().ok_or_else(|| { RestoreError::InvalidSavedState(anyhow::anyhow!( "active microVM virtio-fs state requires a host attachment" @@ -180,9 +224,9 @@ pub(crate) fn device_state_validator(device: &VirtioFsDevice) -> DeviceStateVali let profile = device.microvm_profile.clone(); Box::new( move |state, features, queues: &[DeviceQueueState], _guest_memory| { - if attachment_id.is_none() { - return Err(RestoreError::SavedStateNotSupported); - } + let attachment_id = attachment_id + .as_deref() + .ok_or(RestoreError::SavedStateNotSupported)?; if features.ring_packed() { return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( "microVM virtio-fs restored packed virtqueue" @@ -199,12 +243,18 @@ pub(crate) fn device_state_validator(device: &VirtioFsDevice) -> DeviceStateVali )) })?; let state: SavedState = state.parse().map_err(RestoreError::ProtobufDecode)?; - let profile = profile.as_ref().ok_or_else(|| { - RestoreError::InvalidSavedState(anyhow::anyhow!( - "active microVM virtio-fs state requires a host attachment" - )) - })?; - validate_microvm_state(&state, profile).map_err(RestoreError::InvalidSavedState) + if state.dormant { + validate_dormant_microvm_state(&state, attachment_id) + .map(|_| ()) + .map_err(RestoreError::InvalidSavedState) + } else { + let profile = profile.as_ref().ok_or_else(|| { + RestoreError::InvalidSavedState(anyhow::anyhow!( + "active microVM virtio-fs state requires a host attachment" + )) + })?; + validate_microvm_state(&state, profile).map_err(RestoreError::InvalidSavedState) + } }, ) } @@ -212,11 +262,17 @@ pub(crate) fn device_state_validator(device: &VirtioFsDevice) -> DeviceStateVali #[cfg(test)] mod tests { use super::*; - use crate::profile::MICROVM_ATTACHMENT_ID; use crate::profile::microvm_root_identity; + use chipset_device::io::IoResult; + use chipset_device::mmio::MmioIntercept; + use guestmem::GuestMemory; use pal_async::DefaultDriver; use pal_async::async_test; use virtio::VirtioDevice; + use virtio::transport::VirtioMmioDevice; + use vmcore::device_state::ChangeDeviceState; + use vmcore::line_interrupt::LineInterrupt; + use vmcore::save_restore::SaveRestore; use vmcore::vm_task::SingleDriverBackend; #[async_test] @@ -239,9 +295,75 @@ mod tests { assert_eq!(device.traits().shared_memory.size, 0); assert!(!device.traits().device_features.ring_packed()); assert!(device.supports_save_restore()); + assert!(device.supports_accelerated_doorbells()); assert_eq!(&device.config.tag[..7], b"microvm"); } + #[async_test] + async fn dormant_microvm_profile_uses_emulated_doorbells(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let device = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + + assert_eq!(device.traits().max_queues, 2); + assert!(device.supports_save_restore()); + assert!(!device.supports_accelerated_doorbells()); + } + + #[async_test] + async fn microvm_dormant_state_restores_into_active_attachment(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let mut source = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + source.quiesce_input().await.unwrap(); + let state = source.save_device().unwrap().unwrap(); + + let root = tempfile::tempdir().unwrap(); + let mut destination = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root.path()).unwrap(), + false, + root.path(), + None, + ) + .unwrap(); + destination.restore_device(Some(state)).unwrap(); + } + + #[async_test] + async fn microvm_active_state_rejects_dormant_destination(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let root = tempfile::tempdir().unwrap(); + let mut source = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root.path()).unwrap(), + false, + root.path(), + None, + ) + .unwrap(); + source.quiesce_input().await.unwrap(); + let state = source.save_device().unwrap().unwrap(); + + let mut destination = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + assert!(destination.restore_device(Some(state)).is_err()); + } + #[async_test] async fn microvm_device_rejects_a_non_profile_attachment(driver: DefaultDriver) { let temporary_directory = tempfile::tempdir().unwrap(); @@ -256,4 +378,115 @@ mod tests { assert!(VirtioFsDevice::new_microvm(&driver_source, profile, fs, None).is_err()); } + + #[async_test] + async fn inactive_transport_restore_defers_microvm_hostfs_state(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let source_device = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + let mut source = VirtioMmioDevice::new( + Box::new(source_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + source.quiesce_input().await.unwrap(); + source.stop().await; + let saved = source.save().unwrap(); + assert!( + saved + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .dormant + ); + + let root = tempfile::tempdir().unwrap(); + let destination_device = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root.path()).unwrap(), + false, + root.path(), + None, + ) + .unwrap(); + let mut destination = VirtioMmioDevice::new( + Box::new(destination_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + destination.restore(saved).unwrap(); + destination.start_fallible().await.unwrap(); + destination.quiesce_input().await.unwrap(); + destination.stop().await; + let mut staged = destination.save().unwrap(); + assert!( + staged + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap() + .dormant + ); + + destination.start_fallible().await.unwrap(); + let mut config = [0; 4]; + match destination.mmio_read(0x100, &mut config) { + IoResult::Defer(token) => token.read_future(&mut config).await.unwrap(), + other => panic!("expected deferred config read, got {other:?}"), + } + destination.stop().await; + let activated = destination.save().unwrap(); + assert!( + !activated + .device_state + .unwrap() + .parse::() + .unwrap() + .dormant + ); + + let mut invalid_private = staged + .device_state + .as_ref() + .unwrap() + .parse::() + .unwrap(); + invalid_private.attachment_id = "wrong-attachment".to_owned(); + staged.device_state = Some(SavedStateBlob::new(invalid_private)); + let invalid_device = VirtioFsDevice::new_microvm_dormant( + &driver_source, + MICROVM_ATTACHMENT_ID.to_owned(), + None, + ) + .unwrap(); + let mut invalid_destination = VirtioMmioDevice::new( + Box::new(invalid_device), + &driver, + GuestMemory::empty(), + LineInterrupt::detached(), + None, + 0, + 0x1000, + ) + .unwrap(); + assert!(invalid_destination.restore(staged).is_err()); + } } diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs.rs b/vm/devices/virtio/virtiofs/src/microvm/fs.rs index 4c1d56e4106..1d5cbea732e 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/fs.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/fs.rs @@ -378,6 +378,7 @@ impl VirtioFs { handles, attachment_root_identity: profile.root_identity().to_vec(), maximum_request_size: MAX_FUSE_REQUEST_BYTES as u32, + dormant: false, }) } diff --git a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs index 0b2632efe35..4702f3b60ac 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs @@ -16,6 +16,18 @@ pub(crate) fn resolve( ) -> anyhow::Result> { let device = match &resource.profile { VirtioFsProfile::Standard => return Ok(None), + VirtioFsProfile::MicrovmDormant { stable_id } => { + anyhow::ensure!( + resource.tag == MICROVM_MOUNT_TAG, + "microVM virtio-fs tag must be '{}'", + MICROVM_MOUNT_TAG + ); + anyhow::ensure!( + matches!(resource.fs, VirtioFsBackend::Dormant), + "dormant microVM virtio-fs cannot have an active backend" + ); + VirtioFsDevice::new_microvm_dormant(driver_source, stable_id.clone(), None)? + } VirtioFsProfile::Microvm { stable_id, root_identity, diff --git a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs index f3a43bb51fd..acbe29362bd 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs @@ -10,7 +10,8 @@ use mesh::payload::Protobuf; use vmcore::save_restore::SavedStateRoot; -pub(crate) const SCHEMA_VERSION: u32 = 4; +pub(crate) const PREVIOUS_SCHEMA_VERSION: u32 = 4; +pub(crate) const SCHEMA_VERSION: u32 = 5; pub(crate) const MAX_INODES: usize = 4096; pub(crate) const MAX_HANDLES: usize = 4096; pub(crate) const MAX_PATH_BYTES: usize = 4096; @@ -58,6 +59,8 @@ pub(crate) struct SavedState { pub attachment_root_identity: Vec, #[mesh(16)] pub maximum_request_size: u32, + #[mesh(17)] + pub dormant: bool, } #[derive(Protobuf)] diff --git a/vm/devices/virtio/virtiofs/src/microvm/state.rs b/vm/devices/virtio/virtiofs/src/microvm/state.rs index 992443ddc62..cfe09aa3e0d 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/state.rs @@ -7,6 +7,7 @@ use super::MAX_FUSE_REQUEST_BYTES; use super::profile::MICROVM_FUSE_MAJOR; use super::profile::MICROVM_FUSE_MAX_WRITE; use super::profile::MICROVM_FUSE_MIN_MINOR; +use super::profile::MICROVM_REQUEST_QUEUES; use super::profile::MicroVmAccessMode; use super::profile::MicroVmVirtioFsProfile; use super::saved_state::MAX_ALIAS_BYTES; @@ -18,6 +19,7 @@ use super::saved_state::MAX_DIRECTORY_ENTRIES_PER_HANDLE; use super::saved_state::MAX_HANDLES; use super::saved_state::MAX_INODES; use super::saved_state::MAX_PATH_BYTES; +use super::saved_state::PREVIOUS_SCHEMA_VERSION; use super::saved_state::SCHEMA_VERSION; use super::saved_state::SavedNegotiation; use super::saved_state::SavedObjectIdentity; @@ -181,12 +183,80 @@ pub(crate) fn validate_reopenable_alias( .context("alias cannot be inspected") } +pub(crate) fn save_dormant_microvm_state( + attachment_id: &str, + session_state: SessionState, +) -> anyhow::Result { + anyhow::ensure!( + session_state == SessionState::default(), + "dormant microVM virtio-fs has initialized FUSE state" + ); + Ok(SavedState { + schema_version: SCHEMA_VERSION, + attachment_id: attachment_id.to_owned(), + access_mode: 0, + request_queues: MICROVM_REQUEST_QUEUES, + shared_memory_size: 0, + packed_rings: false, + direct_io: true, + entry_cache_timeout_ns: 0, + attribute_cache_timeout_ns: 0, + negotiation: saved_negotiation(session_state), + next_node_id: 0, + next_handle_id: 0, + inodes: Vec::new(), + handles: Vec::new(), + attachment_root_identity: Vec::new(), + maximum_request_size: MAX_FUSE_REQUEST_BYTES as u32, + dormant: true, + }) +} + +pub(crate) fn validate_dormant_microvm_state( + state: &SavedState, + attachment_id: &str, +) -> anyhow::Result { + anyhow::ensure!( + state.schema_version == SCHEMA_VERSION && state.dormant, + "saved virtio-fs state is not a supported dormant slot" + ); + anyhow::ensure!( + state.attachment_id == attachment_id, + "saved attachment ID does not match the microVM ABI" + ); + anyhow::ensure!( + state.access_mode == 0 + && state.request_queues == MICROVM_REQUEST_QUEUES + && state.shared_memory_size == 0 + && !state.packed_rings + && state.direct_io + && state.entry_cache_timeout_ns == 0 + && state.attribute_cache_timeout_ns == 0 + && state.maximum_request_size == MAX_FUSE_REQUEST_BYTES as u32 + && state.next_node_id == 0 + && state.next_handle_id == 0 + && state.inodes.is_empty() + && state.handles.is_empty() + && state.attachment_root_identity.is_empty(), + "dormant virtio-fs state contains active filesystem policy or objects" + ); + let session_state = session_state_from_saved(&state.negotiation)?; + anyhow::ensure!( + session_state == SessionState::default(), + "dormant microVM virtio-fs contains initialized FUSE state" + ); + Ok(session_state) +} + pub(crate) fn validate_microvm_state( state: &SavedState, profile: &MicroVmVirtioFsProfile, ) -> anyhow::Result<()> { anyhow::ensure!( - state.schema_version == SCHEMA_VERSION, + matches!( + state.schema_version, + PREVIOUS_SCHEMA_VERSION | SCHEMA_VERSION + ) && !state.dormant, "unsupported virtio-fs state schema version {}", state.schema_version ); diff --git a/vm/devices/virtio/virtiofs/src/resolver.rs b/vm/devices/virtio/virtiofs/src/resolver.rs index 375d6a76521..c66cfe9977c 100644 --- a/vm/devices/virtio/virtiofs/src/resolver.rs +++ b/vm/devices/virtio/virtiofs/src/resolver.rs @@ -35,6 +35,9 @@ impl ResolveResource for VirtioFsResolver { return Ok(device.into()); } let device = match &resource.fs { + VirtioFsBackend::Dormant => { + anyhow::bail!("standard virtio-fs requires an active backend") + } VirtioFsBackend::HostFs { root_path, mount_options, diff --git a/vm/devices/virtio/virtiofs/src/virtio.rs b/vm/devices/virtio/virtiofs/src/virtio.rs index 5656445ab09..5e5ac1591d7 100644 --- a/vm/devices/virtio/virtiofs/src/virtio.rs +++ b/vm/devices/virtio/virtiofs/src/virtio.rs @@ -249,6 +249,10 @@ impl VirtioDevice for VirtioFsDevice { } } + fn supports_accelerated_doorbells(&self) -> bool { + crate::microvm::device::supports_accelerated_doorbells(self) + } + async fn read_registers_u32(&mut self, offset: u16) -> u32 { let offset = offset as usize; let config = self.config.as_bytes(); @@ -585,6 +589,7 @@ mod tests { device.traits().max_queues, 1 + DEFAULT_NUM_REQUEST_QUEUES as u16 ); + assert!(device.supports_accelerated_doorbells()); } #[async_test] From 2236eb7c19ae3dfd554d1c54af4465b7dbb61286 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 09:17:25 -0700 Subject: [PATCH 123/304] openvmm: add fixed-role microVM sandbox block slots The microVM rejects every storage device. A sandbox needs read-only image layers and a writable overlay upper, at addresses that the guest can rely on. Add fixed-role sandbox block devices: - `openvmm_defs` gains `MicrovmSandboxBlockRole` with four roles, each at a fixed MMIO slot and IRQ: `distro` at 0xd0003000 (IRQ 4), `runtime` at 0xd0004000 (IRQ 12), `custom` at 0xd0005000 (IRQ 9), and `scratch` at 0xd0006000 (IRQ 11). `microvm_sandbox_block_features` returns each role's exact virtio-blk feature mask, which marks the lower layers read-only. `MicrovmConfig.sandbox_blocks` holds the configured roles. - A topology has at most three read-only layers and one writable scratch device, in fixed role order without duplicates, and a nonempty topology ends with scratch. - Device discovery emits each block's `virtio_mmio.device=` token after the console. Machine validation requires the roles to match the virtio-blk inventory, and the standard machine rejects them. - The worker receives the roles in a new microVM manifest. It places each virtio-blk device at its role's slot and masks its features. - `--microvm-sandbox-block ROLE:DISK` attaches a plain VTL0 disk backend to a role. The storage builder adds the devices in role order, independent of option order. Ordinary `--virtio-blk` remains rejected. Snapshot capture and restore do not yet support sandbox blocks. Document the roles, and the microVM's deterministic SMP topology, in the run guide, and add the slots to the machine description. Tests cover the slot addresses, topology validation, and CLI parsing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 10 +- Guide/src/user_guide/openvmm/run.md | 51 ++++ openvmm/openvmm_core/src/worker/dispatch.rs | 6 +- .../src/worker/dispatch/microvm.rs | 63 +++- openvmm/openvmm_defs/src/microvm.rs | 269 ++++++++++++++++++ openvmm/openvmm_entry/src/cli_args/microvm.rs | 183 +++++++++++- openvmm/openvmm_entry/src/lib.rs | 2 + openvmm/openvmm_entry/src/microvm/config.rs | 29 ++ .../openvmm_entry/src/microvm/filesystem.rs | 2 + openvmm/openvmm_entry/src/microvm/network.rs | 1 + openvmm/openvmm_entry/src/storage_builder.rs | 10 +- .../src/storage_builder/microvm.rs | 77 +++++ 12 files changed, 682 insertions(+), 21 deletions(-) create mode 100644 openvmm/openvmm_entry/src/storage_builder/microvm.rs diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index d95efe3f362..ff067674435 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -50,11 +50,11 @@ describes the source definitions. One optional `--net ` NIC is exposed at MMIO `0xd0000000`. One virtio-fs slot is exposed at MMIO `0xd0001000`, IRQ 6 and remains dormant when `--mount` is omitted; an optional `--mount` binds HostFs to it; - and one optional `--virtio-console ` is exposed at MMIO - `0xd0002000`, IRQ 7 as the boot/log console (`hvc1`). All use split rings. - Firmware, ACPI, - SMBIOS, PCI, - VMBus, UARTs, storage, graphics, isolation, nested + one optional `--virtio-console ` is exposed at MMIO `0xd0002000`, + IRQ 7 as the boot/log console (`hvc1`); and `--microvm-sandbox-block` + exposes fixed distro, runtime, custom, and scratch slots starting at MMIO + `0xd0003000`. Ordinary `--virtio-blk` is rejected. All use split rings. + Firmware, ACPI, SMBIOS, PCI, VMBus, UARTs, graphics, isolation, nested virtualization, and other devices are rejected. Linux discovers contiguous APIC IDs and the IOAPIC from Intel MP 1.4 tables at `0x0` and `0x400`; `boot_params` is at `0x2000`, the command diff --git a/Guide/src/user_guide/openvmm/run.md b/Guide/src/user_guide/openvmm/run.md index f7841d07631..a0f289fd7f3 100644 --- a/Guide/src/user_guide/openvmm/run.md +++ b/Guide/src/user_guide/openvmm/run.md @@ -36,6 +36,57 @@ To run these examples using a pre-compiled copy of OpenVMM, swap `cargo run --` with `/path/to/openvmm`. ``` +### microVM sandbox block devices + +`--machine microvm` is the only microVM profile. It assigns up to three +read-only lower layers and one writable scratch device to fixed virtio-mmio +locations: + +| Role | Access | MMIO address | IRQ | +| --- | --- | ---: | ---: | +| `distro` | read-only | `0xd0003000` | 4 | +| `runtime` | read-only | `0xd0004000` | 12 | +| `custom` | read-only | `0xd0005000` | 9 | +| `scratch` | writable | `0xd0006000` | 11 | + +Use `--microvm-sandbox-block ROLE:DISK`, in the order shown. Lower-layer +roles require the normal disk `,ro` option and a non-empty topology must end +with `scratch`; ordinary `--virtio-blk` is intentionally rejected. +For example: + +```shell +openvmm --machine microvm --kernel vmlinux --initrd initramfs.cpio.gz \ + --microvm-sandbox-block distro:file:distro.erofs,ro \ + --microvm-sandbox-block runtime:file:runtime.erofs,ro \ + --microvm-sandbox-block custom:file:custom.erofs,ro \ + --microvm-sandbox-block scratch:file:scratch.img +``` + +Snapshot capture and restore do not yet support sandbox blocks. + +### microVM deterministic SMP + +`--machine microvm --processors N` selects the microVM machine. +`N` must be exactly `1`, `2`, `4`, or `8`. The guest topology is independent +of the host: one socket, one die, `N` cores, one thread per core, no SMT or +NUMA, xAPIC mode, and contiguous APIC IDs `0..N-1`; APIC ID 0 is the BSP. +Custom socket, SMT, APIC, x2APIC, and NUMA options are rejected. + +The microVM uses fixed virtio device slots and sandbox block roles. Its +persisted ABI and boot layout remain value 2. The +MP floating pointer begins at `0x0`, the MP configuration table at `0x400`, +the boot GDT at `0x1000`, and the Linux zero page at `0x2000`. No ACPI MADT or +SMBIOS data is exposed. + +Snapshots record the ABI version, processor count, full topology, APIC IDs, +and boot-layout version. Restore requires an exact match before any VP starts. +For example: + +```shell +openvmm --machine microvm --processors 8 \ + --kernel vmlinux --initrd initramfs.cpio.gz +``` + ~~~admonish tip title="UEFI firmware required when running outside cargo" When running via `cargo run`, environment variables in `.cargo/config.toml` automatically point OpenVMM to the `mu_msvm` UEFI firmware (`MSVM.fd`) diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index ea3eafd6317..ff57bfb62d0 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -211,6 +211,7 @@ impl Manifest { fn from_config(config: Config) -> Self { Self { machine_profile: config.machine_profile, + microvm: config.microvm.into(), load_mode: config.load_mode, floppy_disks: config.floppy_disks, ide_disks: config.ide_disks, @@ -290,6 +291,7 @@ pub struct Manifest { layout: vmm_core_defs::LayoutConfig, rtc_delta_milliseconds: i64, machine_profile: MachineProfile, + microvm: microvm::MicrovmManifest, } async fn open_simple_disk( @@ -3081,7 +3083,8 @@ impl InitializedVm { // allocation indexed by the order of VirtioBus::Mmio devices. let mut pci_device_number = 10; let mut virtio_mmio_index = 0; - let mut microvm_virtio_slots = microvm::VirtioMmioSlots::new(chipset_mmio); + let mut microvm_virtio_slots = + microvm::VirtioMmioSlots::new(&cfg.microvm.sandbox_blocks, chipset_mmio); // Avoid an ISA interrupt to avoid conflicts and to avoid needing to // configure the line as level-triggered in the MADT (necessary for @@ -4442,6 +4445,7 @@ impl LoadedVm { let manifest = Manifest { machine_profile: self.inner.machine_profile, + microvm: Default::default(), // TODO load_mode: self.inner.load_mode, floppy_disks: vec![], // TODO ide_disks: vec![], // TODO diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 7b9c5312b77..4f86c49713c 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -17,10 +17,12 @@ use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; use guestmem::GuestMemory; use hvdef::Vtl; use memory_range::MemoryRange; +use mesh::MeshPayload; use mesh::error::RemoteError; use mesh::rpc::Rpc; use mesh_worker::WorkerRpc; use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::MicrovmConfig; use openvmm_defs::rpc::PulseSaveRestoreError; use openvmm_defs::rpc::VmRpc; use openvmm_defs::worker::VmWorkerParameters; @@ -33,6 +35,26 @@ use vm_loader::InitialLoad; use vmcore::vm_task::VmTaskDriverSource; use vmm_core::partition_unit::StopGuard; +/// The microVM part of the worker [`Manifest`]: the subset of +/// [`MicrovmConfig`] that the worker consumes after validating the +/// configuration. +#[derive(MeshPayload, Default)] +pub(super) struct MicrovmManifest { + pub(super) sandbox_blocks: Vec, +} + +impl From for MicrovmManifest { + fn from(config: MicrovmConfig) -> Self { + let MicrovmConfig { + network: _, + filesystem: _, + sandbox_blocks, + filesystem_bootstrap: _, + } = config; + Self { sandbox_blocks } + } +} + /// MicroVM inputs taken from the [`VmWorkerParameters`]. pub(super) struct MicrovmParameters { /// Snapshot boundary channels handed to the loaded VM. @@ -430,6 +452,8 @@ impl LoadedVm { fn virtio_mmio_config( id: &str, + sandbox_blocks: &[openvmm_defs::microvm::MicrovmSandboxBlockConfig], + sandbox_block_index: &mut usize, chipset_mmio: ChipsetMmioRanges, ) -> anyhow::Result<(u64, u64, u32, u64, VirtioMmioInterruptMode)> { let (start, irq) = match id { @@ -445,6 +469,13 @@ fn virtio_mmio_config( openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ, ), + "virtio-blk" => { + let block = sandbox_blocks + .get(*sandbox_block_index) + .context("microVM virtio-blk device has no sandbox role")?; + *sandbox_block_index += 1; + (block.role.mmio_base(), block.role.irq()) + } _ => anyhow::bail!("unsupported microVM virtio device '{id}' reached worker construction"), }; let len = openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN; @@ -458,6 +489,13 @@ fn virtio_mmio_config( let disabled_features = match id { "virtio-net" => !openvmm_defs::microvm::MICROVM_VIRTIO_NET_FEATURES, "virtiofs" => !openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES, + "virtio-blk" => { + let block = sandbox_blocks + .iter() + .find(|block| block.role.mmio_base() == start) + .context("microVM block slot has no role")?; + !openvmm_defs::microvm::microvm_sandbox_block_features(block.role) + } _ => 1 << 34, }; let interrupt_mode = VirtioMmioInterruptMode::SharedStatus { @@ -469,13 +507,22 @@ fn virtio_mmio_config( /// Assigns the fixed virtio-mmio slots of the microVM profile to devices, in /// device order. -pub(super) struct VirtioMmioSlots { +pub(super) struct VirtioMmioSlots<'a> { + sandbox_blocks: &'a [openvmm_defs::microvm::MicrovmSandboxBlockConfig], + sandbox_block_index: usize, chipset_mmio: ChipsetMmioRanges, } -impl VirtioMmioSlots { - pub(super) fn new(chipset_mmio: ChipsetMmioRanges) -> Self { - Self { chipset_mmio } +impl<'a> VirtioMmioSlots<'a> { + pub(super) fn new( + sandbox_blocks: &'a [openvmm_defs::microvm::MicrovmSandboxBlockConfig], + chipset_mmio: ChipsetMmioRanges, + ) -> Self { + Self { + sandbox_blocks, + sandbox_block_index: 0, + chipset_mmio, + } } /// Adds a virtio-mmio device at its fixed microVM slot. @@ -488,8 +535,12 @@ impl VirtioMmioSlots { id: &str, device: ResolvedVirtioDevice, ) -> anyhow::Result<()> { - let (mmio_start, mmio_len, irq, disabled_features, interrupt_mode) = - virtio_mmio_config(id, self.chipset_mmio)?; + let (mmio_start, mmio_len, irq, disabled_features, interrupt_mode) = virtio_mmio_config( + id, + self.sandbox_blocks, + &mut self.sandbox_block_index, + self.chipset_mmio, + )?; let id = format!("{id}-{mmio_start}"); let gm = gm.clone(); chipset_builder.arc_mutex_device(id).try_add(|services| { diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index dcf85117322..760bfbbe632 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -130,6 +130,96 @@ pub const fn microvm_virtio_status_gpa(mmio_base: u64) -> Option { /// The microVM publishes no level-triggered virtio IRQs in its MP table. pub const MICROVM_LEVEL_TRIGGERED_IRQS: [u32; 0] = []; +/// The stable role of a microVM sandbox block device. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum MicrovmSandboxBlockRole { + /// The lowest, widest-shared read-only layer. + Distro, + /// The read-only runtime layer above the distro layer. + Runtime, + /// The optional read-only customer layer above the runtime layer. + Custom, + /// The writable overlayfs upper and work directories. + Scratch, +} + +impl MicrovmSandboxBlockRole { + /// Returns the canonical manifest and CLI name of this role. + pub const fn as_str(self) -> &'static str { + match self { + Self::Distro => "distro", + Self::Runtime => "runtime", + Self::Custom => "custom", + Self::Scratch => "scratch", + } + } + + /// Returns the role's fixed virtio-mmio address. + pub const fn mmio_base(self) -> u64 { + MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[self.index()] + } + + /// Returns the role's fixed interrupt. + pub const fn irq(self) -> u32 { + match self { + Self::Distro => MICROVM_VIRTIO_BLK_IRQ, + Self::Runtime => MICROVM_VIRTIO_RUNTIME_BLK_IRQ, + Self::Custom => MICROVM_VIRTIO_CUSTOM_BLK_IRQ, + Self::Scratch => MICROVM_VIRTIO_SCRATCH_BLK_IRQ, + } + } + + /// Returns whether the role must be read-only. + pub const fn is_read_only(self) -> bool { + !matches!(self, Self::Scratch) + } + + const fn index(self) -> usize { + match self { + Self::Distro => 0, + Self::Runtime => 1, + Self::Custom => 2, + Self::Scratch => 3, + } + } +} + +/// Returns the fixed virtio-blk feature mask for a sandbox role. +pub const fn microvm_sandbox_block_features(role: MicrovmSandboxBlockRole) -> u64 { + const RING_INDIRECT_DESC: u64 = 1 << 28; + const RING_EVENT_IDX: u64 = 1 << 29; + const VERSION_1: u64 = 1 << 32; + const ACCESS_PLATFORM: u64 = 1 << 33; + const BLK_SEG_MAX: u64 = 1 << 2; + const BLK_READ_ONLY: u64 = 1 << 5; + const BLK_SIZE: u64 = 1 << 6; + const BLK_FLUSH: u64 = 1 << 9; + const BLK_TOPOLOGY: u64 = 1 << 10; + + RING_INDIRECT_DESC + | RING_EVENT_IDX + | VERSION_1 + | ACCESS_PLATFORM + | BLK_SEG_MAX + | BLK_SIZE + | BLK_FLUSH + | BLK_TOPOLOGY + | if role.is_read_only() { + BLK_READ_ONLY + } else { + 0 + } +} + +/// The immutable role and access mode of a microVM sandbox block device. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq)] +pub struct MicrovmSandboxBlockConfig { + /// The fixed guest-visible role and transport location. + pub role: MicrovmSandboxBlockRole, + /// Whether writes are rejected by the VMM. + pub read_only: bool, +} + /// Static guest-visible network identity for the microVM NIC. #[derive(MeshPayload, Clone, Debug, PartialEq, Eq)] pub struct MicrovmNetworkConfig { @@ -388,6 +478,40 @@ fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { Ok(()) } +fn validate_microvm_sandbox_blocks(blocks: &[MicrovmSandboxBlockConfig]) -> anyhow::Result<()> { + anyhow::ensure!( + blocks.len() <= MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES.len(), + "microVM permits at most three read-only layers and one writable scratch device" + ); + for (index, block) in blocks.iter().enumerate() { + anyhow::ensure!( + block.read_only == block.role.is_read_only(), + "microVM sandbox block role {:?} must be {}", + block.role, + if block.role.is_read_only() { + "read-only" + } else { + "writable" + } + ); + if let Some(previous) = index.checked_sub(1).and_then(|index| blocks.get(index)) { + anyhow::ensure!( + previous.role < block.role, + "microVM sandbox block roles must be unique and in fixed order" + ); + } + } + if !blocks.is_empty() { + anyhow::ensure!( + blocks + .last() + .is_some_and(|block| block.role == MicrovmSandboxBlockRole::Scratch), + "microVM sandbox block topology requires a writable scratch device" + ); + } + Ok(()) +} + /// Appends sandbox virtio devices in fixed-address order. pub fn append_microvm_virtio_discovery( cmdline: &mut String, @@ -395,7 +519,9 @@ pub fn append_microvm_virtio_discovery( filesystem_slot: bool, filesystem: Option<&MicrovmFilesystemConfig>, has_console: bool, + blocks: &[MicrovmSandboxBlockConfig], ) -> anyhow::Result<()> { + validate_microvm_sandbox_blocks(blocks)?; anyhow::ensure!( !cmdline .split_ascii_whitespace() @@ -430,6 +556,14 @@ pub fn append_microvm_virtio_discovery( " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONSOLE_IRQ}" )?; } + for block in blocks { + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{:#x}:{}", + block.role.mmio_base(), + block.role.irq() + )?; + } if let Some((network, _, gateway_dns)) = network { write!( cmdline, @@ -476,6 +610,11 @@ fn validate_microvm_command_line( .virtio_devices .iter() .any(|(_, device)| device.id() == "virtio-console"); + let block_count = config + .virtio_devices + .iter() + .filter(|(_, device)| device.id() == "virtio-blk") + .count(); let has_network = config .virtio_devices .iter() @@ -496,6 +635,11 @@ fn validate_microvm_command_line( !config.microvm.filesystem_bootstrap || config.microvm.filesystem.is_some(), "microVM filesystem bootstrap requires an active filesystem policy" ); + validate_microvm_sandbox_blocks(&config.microvm.sandbox_blocks)?; + anyhow::ensure!( + block_count == config.microvm.sandbox_blocks.len(), + "microVM sandbox block roles do not match the virtio-blk device inventory" + ); let base_tokens = if has_console { MICROVM_CONSOLE_COMMAND_LINE } else { @@ -572,6 +716,13 @@ fn validate_microvm_command_line( "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONSOLE_IRQ}" )); } + expected_discovery.extend(config.microvm.sandbox_blocks.iter().map(|block| { + format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{:#x}:{}", + block.role.mmio_base(), + block.role.irq() + ) + })); if let Some(network) = &config.microvm.network { expected_discovery.extend( network @@ -690,6 +841,8 @@ pub struct MicrovmConfig { pub network: Option, /// Guest-visible policy for the optional microVM virtio-fs device. pub filesystem: Option, + /// Stable sandbox block-device roles in virtio-blk device order. + pub sandbox_blocks: Vec, /// Whether the effective command line bootstraps the active microVM filesystem. pub filesystem_bootstrap: bool, } @@ -780,6 +933,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> config.microvm.filesystem.is_none(), "microVM filesystem policy requires the microVM profile" ); + anyhow::ensure!( + config.microvm.sandbox_blocks.is_empty(), + "microVM sandbox block roles require the microVM profile" + ); return Ok(()); }; @@ -906,6 +1063,7 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> let mut has_network = false; let mut has_filesystem = false; let mut has_console = false; + let mut block_count = 0; for (bus, device) in &config.virtio_devices { anyhow::ensure!( *bus == VirtioBus::Mmio, @@ -924,9 +1082,15 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> !std::mem::replace(&mut has_console, true), "microVM permits only one virtio-console device" ), + "virtio-blk" => block_count += 1, id => anyhow::bail!("microVM does not permit virtio device '{id}'"), } } + validate_microvm_sandbox_blocks(&config.microvm.sandbox_blocks)?; + anyhow::ensure!( + block_count == config.microvm.sandbox_blocks.len(), + "microVM sandbox block roles do not match the virtio-blk device inventory" + ); anyhow::ensure!( has_network == config.microvm.network.is_some(), "microVM virtio-net device and static network identity must be configured together" @@ -1077,6 +1241,42 @@ mod tests { assert_eq!(network.profile.as_str(), "portable"); } + #[test] + fn microvm_sandbox_block_slots_are_stable() { + let blocks = [ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Runtime, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Custom, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ]; + validate_microvm_sandbox_blocks(&blocks).unwrap(); + + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery(&mut cmdline, None, false, None, false, &blocks).unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_BASE_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0003000:4 \ + virtio_mmio.device=0x1000@0xd0004000:12 \ + virtio_mmio.device=0x1000@0xd0005000:9 \ + virtio_mmio.device=0x1000@0xd0006000:11" + ) + ); + } + #[test] fn microvm_block_irqs_avoid_rtc_and_are_edge_triggered() { assert_eq!(MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, 3); @@ -1105,4 +1305,73 @@ mod tests { } assert_eq!(microvm_virtio_status_gpa(0xd000_8000), None); } + #[test] + fn microvm_sandbox_block_validation_rejects_invalid_layouts() { + assert!( + validate_microvm_sandbox_blocks(&[MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: true, + }]) + .is_err() + ); + assert!( + validate_microvm_sandbox_blocks(&[ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Runtime, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ]) + .is_err() + ); + assert!( + validate_microvm_sandbox_blocks(&[ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ]) + .is_err() + ); + assert!( + validate_microvm_sandbox_blocks(&[ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Runtime, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Custom, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ]) + .is_err() + ); + } } diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index a1ef55ea34a..61617913c3e 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -3,6 +3,7 @@ //! Command-line options of the microVM machine profile. +use super::DiskCli; use super::EndpointConfigCli; use super::Options; use super::SerialConfigCli; @@ -12,6 +13,7 @@ use clap::ValueEnum; use openvmm_defs::config::DeviceVtl; use openvmm_defs::config::X2ApicConfig; use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::MicrovmSandboxBlockRole; use std::path::PathBuf; use std::str::FromStr; @@ -51,6 +53,15 @@ pub struct MicrovmCli { #[clap(long, value_name = "MILLISECONDS", default_value_t = 5000)] pub snapshot_quiesce_timeout_ms: u64, + /// Attach a fixed-role microVM sandbox block device. + /// + /// The value is `:`, where the roles are `distro`, `runtime`, + /// `custom`, and `scratch`. Lower-layer roles must use `,ro`; `scratch` + /// must be writable. The profile assigns each role a fixed virtio-mmio + /// address and IRQ independent of option order. + #[clap(long, value_name = "ROLE:DISK")] + pub microvm_sandbox_block: Vec, + /// Required host-network implementation contract for microVM `--net`. #[clap(long, value_enum, value_name = "PROFILE")] pub network_profile: Option, @@ -105,6 +116,38 @@ impl FromStr for MicrovmMountCli { } } +/// A fixed-role microVM sandbox block-device CLI argument. +#[derive(Clone)] +pub struct MicrovmSandboxBlockCli { + /// The stable guest-visible role. + pub role: MicrovmSandboxBlockRole, + /// The generic disk backend and access mode. + pub disk: DiskCli, +} + +impl FromStr for MicrovmSandboxBlockCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> anyhow::Result { + let (role, disk) = value + .split_once(':') + .context("expected ROLE:DISK for --microvm-sandbox-block")?; + let role = match role { + "distro" => MicrovmSandboxBlockRole::Distro, + "runtime" => MicrovmSandboxBlockRole::Runtime, + "custom" => MicrovmSandboxBlockRole::Custom, + "scratch" => MicrovmSandboxBlockRole::Scratch, + _ => anyhow::bail!( + "unknown microVM sandbox block role '{role}'; expected distro, runtime, custom, or scratch" + ), + }; + Ok(Self { + role, + disk: disk.parse()?, + }) + } +} + /// Parses a bare `/` `--net` endpoint into the microVM network /// configuration. pub(super) fn parse_endpoint(network: &str) -> Result { @@ -118,8 +161,10 @@ impl Options { pub(crate) fn validate_microvm_options(&self) -> anyhow::Result<()> { if self.machine != MachineProfileCli::Microvm { anyhow::ensure!( - self.microvm.network_profile.is_none() && self.microvm.microvm_mount.is_none(), - "--network-profile and --mount require a microVM machine" + self.microvm.network_profile.is_none() + && self.microvm.microvm_mount.is_none() + && self.microvm.microvm_sandbox_block.is_empty(), + "--network-profile, --mount, and --microvm-sandbox-block require a microVM machine" ); return Ok(()); } @@ -157,12 +202,20 @@ impl Options { self.microvm.snapshot_quiesce_timeout_ms != 0, "microVM snapshot quiesce timeout must be nonzero" ); + anyhow::ensure!( + self.microvm.microvm_sandbox_block.is_empty(), + "microVM snapshot capture does not yet support sandbox blocks" + ); } if self.restore_snapshot.is_some() { anyhow::ensure!( self.net.is_empty(), "microVM restore takes network addressing from saved state; do not pass --net" ); + anyhow::ensure!( + self.microvm.microvm_sandbox_block.is_empty(), + "microVM restore does not yet support sandbox blocks" + ); } anyhow::ensure!( self.uefi.is_none() && !self.pcat && self.igvm.is_none() && !self.device_tree, @@ -248,10 +301,47 @@ impl Options { && self.vmbus_scsi.is_empty() && self.openhcl_controller.is_empty() && self.ide.is_empty() - && self.floppy.is_empty() - && self.virtio_blk.is_empty(), - "microVM does not expose storage devices" + && self.floppy.is_empty(), + "microVM supports only its fixed MMIO storage devices" + ); + anyhow::ensure!( + self.virtio_blk.is_empty(), + "microVM requires --microvm-sandbox-block instead of --virtio-blk" + ); + anyhow::ensure!( + self.microvm.microvm_sandbox_block.len() <= 4, + "microVM permits at most three read-only layers and one writable scratch device" ); + for (index, block) in self.microvm.microvm_sandbox_block.iter().enumerate() { + anyhow::ensure!( + block.disk.read_only == block.role.is_read_only(), + "microVM sandbox block role {:?} must be {}", + block.role, + if block.role.is_read_only() { + "read-only" + } else { + "writable" + } + ); + if let Some(previous) = index + .checked_sub(1) + .and_then(|index| self.microvm.microvm_sandbox_block.get(index)) + { + anyhow::ensure!( + previous.role < block.role, + "microVM sandbox block roles must be unique and in fixed order" + ); + } + } + if !self.microvm.microvm_sandbox_block.is_empty() && self.restore_snapshot.is_none() { + anyhow::ensure!( + self.microvm + .microvm_sandbox_block + .last() + .is_some_and(|block| block.role == MicrovmSandboxBlockRole::Scratch), + "microVM sandbox block topology requires a writable scratch device" + ); + } anyhow::ensure!( self.virtio_9p.is_empty() && self.virtio_fs.is_empty() @@ -419,6 +509,12 @@ mod tests { for extra in [ vec!["--snapshot-quiesce-timeout-ms", "0"], vec!["--memory", "size=1G,shared=off"], + vec![ + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ], ] { let options = Options::try_parse_from( [ @@ -458,6 +554,17 @@ mod tests { .unwrap(); assert_eq!(restore.memory, Default::default()); restore.validate_microvm_options().unwrap(); + let restore_blocks = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + ]) + .unwrap(); + assert!(restore_blocks.validate_microvm_options().is_err()); for override_arg in ["--kernel", "--initrd"] { assert!( Options::try_parse_from([ @@ -523,6 +630,59 @@ mod tests { ); } + #[test] + fn test_microvm_sandbox_block_parser_and_validation() { + let valid = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "runtime:mem:1M,ro", + "--microvm-sandbox-block", + "custom:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + valid.validate_microvm_options().unwrap(); + + for args in [ + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "distro:mem:1M", + "--microvm-sandbox-block", + "scratch:mem:1M", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "scratch:mem:1M,ro", + ], + vec!["openvmm", "--machine", "microvm", "--virtio-blk", "mem:1M"], + ] { + let options = Options::try_parse_from(args).unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } + #[test] fn test_microvm_command_line_is_owned_and_bounded() { assert_eq!( @@ -539,7 +699,7 @@ mod tests { ); let mut with_devices = build_microvm_command_line(&[], true).unwrap(); - append_microvm_virtio_discovery(&mut with_devices, None, false, None, true).unwrap(); + append_microvm_virtio_discovery(&mut with_devices, None, false, None, true, &[]).unwrap(); assert_eq!( with_devices, format!("{MICROVM_CONSOLE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0002000:7") @@ -551,8 +711,15 @@ mod tests { ) .unwrap(); let mut with_filesystem = build_microvm_command_line(&[], false).unwrap(); - append_microvm_virtio_discovery(&mut with_filesystem, None, true, Some(&filesystem), false) - .unwrap(); + append_microvm_virtio_discovery( + &mut with_filesystem, + None, + true, + Some(&filesystem), + false, + &[], + ) + .unwrap(); assert_eq!( with_filesystem, format!( diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 125dfbf8d4c..8968d518e6f 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -678,6 +678,8 @@ async fn vm_config_from_command_line( .await?; } + microvm.add_sandbox_blocks(&mut storage).await?; + for &cli_args::IdeDiskCli { ref kind, read_only, diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 6a01813fd4a..dffff726c10 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -23,6 +23,7 @@ use crate::cli_args::SerialConfigCli; use crate::cli_args::VirtioBusCli; use crate::cli_args::microvm::MachineProfileCli; use crate::serial_io; +use crate::storage_builder::StorageBuilder; use anyhow::Context; use anyhow::bail; use chipset_resources::microvm::MicrovmPortbHandle; @@ -33,6 +34,7 @@ use futures::executor::block_on; use futures::io::AllowStdIo; use net_backend_resources::consomme::static_ipv4::StaticIpv4Config; use openvmm_defs::config::Config; +use openvmm_defs::config::DeviceVtl; use openvmm_defs::config::LoadMode; use openvmm_defs::microvm::MachineProfile; use openvmm_defs::microvm::build_microvm_command_line; @@ -270,6 +272,32 @@ impl<'a> MicrovmConfigBuilder<'a> { Ok(virtio_console_backend) } + /// Adds the fixed-role sandbox block devices. + pub(crate) async fn add_sandbox_blocks( + &self, + storage: &mut StorageBuilder, + ) -> anyhow::Result<()> { + let opt = self.opt; + for block in &opt.microvm.microvm_sandbox_block { + let disk = &block.disk; + anyhow::ensure!( + disk.vtl == DeviceVtl::Vtl0 + && !disk.is_dvd + && disk.underhill.is_none() + && disk.pcie_port.is_none() + && disk.controller.is_none() + && disk.nsid.is_none() + && disk.lun.is_none() + && disk.relay.is_none(), + "--microvm-sandbox-block accepts only a plain VTL0 disk backend" + ); + storage + .add_microvm_sandbox_block(block.role, &disk.kind, disk.read_only) + .await?; + } + Ok(()) + } + /// Adds the portb, shutdown, and snapshot-request chipset devices. pub(crate) fn add_chipset_devices( &mut self, @@ -526,6 +554,7 @@ impl<'a> MicrovmConfigBuilder<'a> { self.filesystem_slot, cfg.microvm.filesystem.as_ref(), has_console, + &cfg.microvm.sandbox_blocks, )?; } openvmm_defs::microvm::validate_machine_config(cfg, requested_hypervisor)?; diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index 9300c28acb2..f0a3eb12efe 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -339,6 +339,7 @@ mod tests { true, Some(&filesystem), false, + &[], ) .unwrap(); openvmm_helpers::snapshot::microvm::microvm_machine_contract( @@ -382,6 +383,7 @@ mod tests { true, None, false, + &[], ) .unwrap(); openvmm_helpers::snapshot::microvm::microvm_machine_contract( diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index e5692d28099..6d91f8e2fa8 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -154,6 +154,7 @@ pub(crate) mod tests { false, None, false, + &[], ) .unwrap(); openvmm_helpers::snapshot::microvm::microvm_machine_contract( diff --git a/openvmm/openvmm_entry/src/storage_builder.rs b/openvmm/openvmm_entry/src/storage_builder.rs index 454c256266f..393858e7987 100644 --- a/openvmm/openvmm_entry/src/storage_builder.rs +++ b/openvmm/openvmm_entry/src/storage_builder.rs @@ -37,6 +37,8 @@ use vtl2_settings_proto::Lun; use vtl2_settings_proto::StorageController; use vtl2_settings_proto::storage_controller; +pub(crate) mod microvm; + /// Namespace GUID for deriving deterministic GUIDs from controller names. /// This is hashed together with the name via SHA-256 to produce a UUIDv8. const OPENVMM_CONTROLLER_NS: Guid = guid::guid!("a3f1e2d4-5b6c-4a8d-9e0f-1234567890ab"); @@ -139,6 +141,7 @@ pub(super) struct StorageBuilder { struct VirtioBlkDisk { disk: Resource, read_only: bool, + microvm: Option, } #[derive(Clone)] @@ -513,7 +516,11 @@ impl StorageBuilder { if is_dvd { anyhow::bail!("dvd not supported with virtio-blk"); } - let vblk = VirtioBlkDisk { disk, read_only }; + let vblk = VirtioBlkDisk { + disk, + read_only, + microvm: None, + }; if let Some(port) = pcie_port { self.pcie_virtio_blk_disks.push((port, vblk)); } else { @@ -880,6 +887,7 @@ impl StorageBuilder { resources.nvme_vtl2_rpc = Some(send); } + self.build_microvm_sandbox_blocks(config)?; for (i, vblk) in std::mem::take(&mut self.vtl0_virtio_blk_disks) .into_iter() .enumerate() diff --git a/openvmm/openvmm_entry/src/storage_builder/microvm.rs b/openvmm/openvmm_entry/src/storage_builder/microvm.rs new file mode 100644 index 00000000000..782f9662e86 --- /dev/null +++ b/openvmm/openvmm_entry/src/storage_builder/microvm.rs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Fixed-role microVM sandbox block devices. + +use super::StorageBuilder; +use super::VirtioBlkDisk; +use crate::cli_args::DiskCliKind; +use crate::disk_open; +use openvmm_defs::config::Config; +use openvmm_defs::config::VirtioBus; +use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::MicrovmSandboxBlockConfig; +use openvmm_defs::microvm::MicrovmSandboxBlockRole; +use virtio_resources::blk::VirtioBlkHandle; +use vm_resource::IntoResource; + +/// The fixed role of a microVM sandbox block device. +pub(super) struct SandboxBlock { + role: MicrovmSandboxBlockRole, +} + +impl StorageBuilder { + /// Adds a fixed-role microVM sandbox block device. + pub async fn add_microvm_sandbox_block( + &mut self, + role: MicrovmSandboxBlockRole, + kind: &DiskCliKind, + read_only: bool, + ) -> anyhow::Result<()> { + self.vtl0_virtio_blk_disks.push(VirtioBlkDisk { + disk: disk_open(kind, read_only).await?, + read_only, + microvm: Some(SandboxBlock { role }), + }); + Ok(()) + } + + /// Adds the sandbox block devices of a microVM at their fixed virtio-mmio + /// slots, in role order. + pub(super) fn build_microvm_sandbox_blocks( + &mut self, + config: &mut Config, + ) -> anyhow::Result<()> { + if config.machine_profile != MachineProfile::Microvm { + return Ok(()); + } + let mut vtl0_virtio_blk_disks = std::mem::take(&mut self.vtl0_virtio_blk_disks); + anyhow::ensure!( + vtl0_virtio_blk_disks + .iter() + .all(|disk| disk.microvm.is_some()), + "microVM requires roles for every virtio-blk device" + ); + vtl0_virtio_blk_disks.sort_by_key(|disk| disk.microvm.as_ref().map(|block| block.role)); + for mut vblk in vtl0_virtio_blk_disks { + if let Some(block) = vblk.microvm.take() { + config + .microvm + .sandbox_blocks + .push(MicrovmSandboxBlockConfig { + role: block.role, + read_only: vblk.read_only, + }); + } + config.virtio_devices.push(( + VirtioBus::Mmio, + VirtioBlkHandle { + disk: vblk.disk, + read_only: vblk.read_only, + } + .into_resource(), + )); + } + Ok(()) + } +} From b94811f1390b06ba650d3309ceb309f14abd74f3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 09:37:40 -0700 Subject: [PATCH 124/304] openvmm: snapshot sandbox blocks with paired or fresh scratch Snapshot capture and restore reject sandbox blocks. A snapshot of a guest with a mounted overlay must also carry the writable scratch image that matches guest memory, and the read-only layers must be the same images on restore. Record sandbox blocks in snapshots: - The guest selects the scratch policy with the byte that it writes to the snapshot-request port. Zero requests a fresh scratch for a boundary before scratch is mounted; any other value requests a paired scratch. The worker forwards the policy with the snapshot boundary. - The new `SnapshotMicrovmSandboxBlock` (contract field 21) records each block's role, access, length, logical and physical block sizes, and identity. A read-only layer is identified by its SHA-256. A paired scratch records its SHA-256 and the artifact `scratch.img`, and a fresh scratch records the `fresh` kind. The contract adds each block's virtio-blk device at its role's slot with its feature banks. Shape validation requires one to three layers and a scratch in role order, with valid geometry and identities. - A snapshot-capable block must be a cached regular raw file, which is opened without following a symbolic link or reparse point. Capture hashes the layers. For a paired scratch, capture flushes the image and publishes it as `scratch.img`, and it verifies the published digest before commit. - Manifest version 4 permits sandbox blocks and the optional `scratch.img`. Versions 2 and 3 remain restorable but cannot contain blocks. Reading a manifest validates its machine contract before the contract selects the expected artifacts. - Restore repeats the read-only layer arguments, whose identities must match. A paired snapshot supplies its scratch from a verified process-private copy of `scratch.img`, which lives as long as the VM. A fresh-scratch snapshot requires a writable scratch argument of matching geometry. Document sandbox block snapshots, `scratch.img`, and manifest version 4. Tests cover scratch publication and digest verification, the block contract, and the manifest version rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + Guide/src/dev_guide/snapshot_format.md | 23 +- Guide/src/reference/openvmm/management/cli.md | 20 +- Guide/src/user_guide/openvmm/run.md | 7 +- Guide/src/user_guide/openvmm/snapshots.md | 21 +- .../src/worker/dispatch/microvm.rs | 5 +- openvmm/openvmm_defs/src/worker.rs | 3 +- openvmm/openvmm_entry/Cargo.toml | 1 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 25 -- openvmm/openvmm_entry/src/lib.rs | 8 +- openvmm/openvmm_entry/src/microvm/config.rs | 13 +- .../openvmm_entry/src/microvm/filesystem.rs | 2 + openvmm/openvmm_entry/src/microvm/launch.rs | 30 +- openvmm/openvmm_entry/src/microvm/mod.rs | 3 + openvmm/openvmm_entry/src/microvm/network.rs | 1 + openvmm/openvmm_entry/src/microvm/restore.rs | 87 +++- .../src/snapshot_restore/prepare.rs | 3 +- openvmm/openvmm_entry/src/storage_builder.rs | 2 +- .../src/storage_builder/microvm.rs | 192 ++++++++- openvmm/openvmm_entry/src/vm_controller.rs | 8 +- .../src/vm_controller/microvm.rs | 50 ++- openvmm/openvmm_helpers/src/snapshot.rs | 6 +- .../openvmm_helpers/src/snapshot/format.rs | 34 +- openvmm/openvmm_helpers/src/snapshot/fs.rs | 79 +++- .../openvmm_helpers/src/snapshot/microvm.rs | 382 +++++++++++++++--- .../openvmm_helpers/src/snapshot/publish.rs | 176 +++++++- .../openvmm_helpers/src/snapshot/restore.rs | 96 ++++- vm/devices/chipset/src/microvm.rs | 42 +- vm/devices/chipset_resources/src/microvm.rs | 11 + 29 files changed, 1163 insertions(+), 168 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dde7090f6e8..84b82931ed6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6022,6 +6022,7 @@ dependencies = [ "input_core", "inspect", "inspect_proto", + "libc", "memory_range", "mesh", "mesh_process", diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index ab12cc807a3..ab03a3e8428 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -5,13 +5,15 @@ for developers working on the save/restore subsystem. ## Directory layout -A snapshot is stored as a directory containing three files: +A snapshot is stored as a directory containing three required files and, for +a mounted microVM scratch capture, one paired scratch image: ```text snapshot-dir/ ├── manifest.bin # Protobuf-encoded SnapshotManifest ├── state.bin # Protobuf-encoded device saved state -└── memory.bin # Exact automatic RAM file or independent supplied-RAM clone +├── memory.bin # Exact automatic RAM file or independent supplied-RAM clone +└── scratch.img # Optional paired microVM writable scratch image ``` ## Manifest format @@ -21,11 +23,12 @@ The manifest is a protobuf message defined as in `openvmm/openvmm_helpers/src/snapshot.rs`, encoded using the `mesh` crate's protobuf encoding. -New snapshots use manifest version 3, which records a format magic, the +New snapshots use manifest version 4, which records a format magic, the saved-state schema version and protobuf root type, and the exact length of `state.bin`. The legacy `state_sha256` and `memory_sha256` protobuf tags remain -reserved so version 2 manifests can be decoded; version 3 requires both fields -to be absent. Restore accepts versions 2 and 3. +reserved so version 2 manifests can be decoded; versions 3 and 4 require both +fields to be absent. Restore accepts versions 2 through 4; only version 4 may +contain microVM sandbox blocks. Reading a snapshot bounds `manifest.bin` to 1 MiB and `state.bin` to 256 MiB, validates the manifest format before it reads `state.bin`, and requires @@ -34,8 +37,11 @@ validates the manifest format before it reads `state.bin`, and requires The default format is a local machine-state contract, not an authenticated container. All versions receive the same regular-file, no-follow/no-reparse, bounded decoding, exact-length, inventory, and machine-contract validation, -but the on-disk format does not authenticate same-length payload changes. Export or transport -layers must provide broader integrity and authentication outside this format. +but the on-disk format does not authenticate same-length payload changes. +Version 4 records the SHA-256 and exact length of `scratch.img`, because guest +RAM and a mounted writable filesystem must be restored as one exact pair. +Export or transport layers must provide broader integrity and authentication +outside this format. ## Device state (`state.bin`) @@ -70,7 +76,8 @@ the source terminate instead of resume. Restore opens the snapshot directory once and resolves its artifacts relative to that handle. The directory must contain exactly `manifest.bin`, `state.bin`, -and `memory.bin`, each a regular file. Guest RAM is a private copy-on-write +and `memory.bin`, plus `scratch.img` when the manifest declares a paired +scratch, each a regular file. Guest RAM is a private copy-on-write mapping of the opened `memory.bin` handle, so guest writes never reach the snapshot and it can be restored repeatedly. Windows uses read-only handles with `FILE_SHARE_READ` only, rejects reparse points, compares `FILE_ID_INFO` and EOF diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index ff067674435..ce5c997803c 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -145,9 +145,10 @@ describes the source definitions. `--snapshot-quiesce-timeout-ms ` sets the bounded quiesce timeout and defaults to 5000. A request with no configured destination is ignored and the guest continues. Capture requires 1, 2, 4, or 8 vCPUs, - KVM, MSHV, or WHP, and shared file-backed RAM. An attached virtio console - saves accepted but undelivered input and the offset of a partially forwarded - guest transmit descriptor. An attached microVM virtio-net device saves its + KVM, MSHV, or WHP, and shared file-backed RAM. Sandbox block media must + be cached regular raw files with nonzero 512-byte-aligned geometry. An + attached virtio console saves accepted but undelivered input and the offset + of a partially forwarded guest transmit descriptor. An attached microVM virtio-net device saves its static identity, queue progress, drained packet ownership, and endpoint generation. The fixed microVM virtio-fs slot saves either an explicit dormant state or, @@ -159,6 +160,12 @@ describes the source definitions. --kernel vmlinux --initrd initramfs.cpio.gz \ --snapshot-destination snapshot ``` + + `/sbin/nvx-snapshot` requests a paired capture by default. When scratch is + mounted, it freezes the workload cgroup with a bounded wait, syncs, freezes + the scratch filesystem, and asks OpenVMM to drain queues and atomically + publish `scratch.img`. `/sbin/nvx-snapshot --fresh-scratch` is for a + pre-mount boundary and records that restore must supply a fresh scratch. * `--restore-snapshot `: Restore a microVM from a committed snapshot. The manifest supplies the authoritative RAM size, topology, ABI, fixed device inventory, effective kernel command line, source backend, CPU @@ -185,6 +192,13 @@ describes the source definitions. `--network-profile portable`; the snapshot's profile must match the supplied portable configuration. + For sandbox-block snapshots, restore repeats each read-only + `--microvm-sandbox-block` argument. Its role, access, geometry, and SHA-256 + must match the manifest. A paired snapshot supplies scratch internally from + a verified process-private copy of `scratch.img`; passing another scratch is + rejected. A fresh-scratch snapshot instead requires a writable scratch + argument with matching geometry. + ```bash openvmm --machine microvm --hypervisor kvm \ --restore-snapshot snapshot diff --git a/Guide/src/user_guide/openvmm/run.md b/Guide/src/user_guide/openvmm/run.md index a0f289fd7f3..3b833300fb7 100644 --- a/Guide/src/user_guide/openvmm/run.md +++ b/Guide/src/user_guide/openvmm/run.md @@ -62,7 +62,12 @@ openvmm --machine microvm --kernel vmlinux --initrd initramfs.cpio.gz \ --microvm-sandbox-block scratch:file:scratch.img ``` -Snapshot capture and restore do not yet support sandbox blocks. +Capture and restore support cached regular raw files. Capture records +the role, access mode, exact geometry, and SHA-256 of every read-only layer. A +normal snapshot request pairs the writable scratch as `scratch.img`; restore +accepts the read-only layer arguments again and creates a private scratch copy +from that artifact. A pre-mount request may select fresh-scratch policy instead, +in which case restore requires a new writable scratch file of matching size. ### microVM deterministic SMP diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index b2fe55ce40c..66757bf4dd0 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -5,20 +5,23 @@ the complete state of a running VM and resume it later. ## Overview -A snapshot captures three pieces of state: +A snapshot captures three required pieces of state and may pair writable +microVM scratch: - **Guest RAM** — the full contents of guest memory - **Device state** — the saved state of all emulated devices - **Manifest** — metadata describing the snapshot (architecture, memory size, VP count, page size, etc.) +- **Scratch** — the exact microVM writable image when capture occurs after mount -These are stored as three files in a snapshot directory: +These are stored as three required files and one optional paired file: | File | Contents | |-----------------|---------------------------------------------| | `manifest.bin` | Protobuf-encoded snapshot metadata | | `state.bin` | Serialized device state | | `memory.bin` | Memory backing file | +| `scratch.img` | Paired microVM scratch, when declared | ## Prerequisites @@ -130,12 +133,14 @@ read while resume is in progress; on Windows, flush completion waits until the named-pipe peer consumes the complete frame. ```admonish warning -Version 3 does not contain or validate embedded checksums for `state.bin` or -`memory.bin`. Restore still requires regular files, bounded manifest and state -decoding, and exact artifact lengths, but same-length payload changes are not -detected. Protect snapshot directories with host access controls. Integrity or -authentication for export and transport must be supplied outside the default -snapshot format. +Versions 3 and 4 do not contain or validate embedded checksums for +`state.bin` or `memory.bin`. Restore still requires regular files, bounded +manifest and state decoding, exact artifact lengths, and a compatible machine +contract, but same-length payload changes are not detected. Paired +`scratch.img` does have an exact length and SHA-256 identity because it must +match captured guest filesystem state. Protect snapshot directories with host +access controls. Integrity or authentication for export and transport must be +supplied outside the default snapshot format. ``` ```admonish note diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 4f86c49713c..e1bb1aa8d7f 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -14,6 +14,7 @@ use crate::worker::memory_layout::ChipsetMmioRanges; use anyhow::Context; use chipset_device_resources::IRQ_LINE_SET; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use chipset_resources::microvm::MicrovmSnapshotScratchPolicy; use guestmem::GuestMemory; use hvdef::Vtl; use memory_range::MemoryRange; @@ -108,7 +109,7 @@ pub(super) struct SnapshotBoundary { /// Deferred snapshot PMIO requests awaiting an exact post-OUT boundary. requests: Option>, /// Notifies the controller after the worker establishes a boundary. - ready: Option>, + ready: Option>, /// Holds the vCPUs stopped while a boundary is active. stop_guard: Option, /// Completes the guest's snapshot transaction when the boundary is released. @@ -241,7 +242,7 @@ impl LoadedVm { self.snapshot_boundary.capture_wall_clock = Some(std::time::SystemTime::now().into()); self.snapshot_boundary.input_gate_timeout = Some(request.input_gate_timeout); - snapshot_ready.send(()); + snapshot_ready.send(request.scratch_policy); true } Err(error) => { diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index 99d85b4595a..7c04ff0c5df 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -71,7 +71,8 @@ pub struct VmWorkerParameters { pub snapshot_boundary_requests: Option>, /// Notifies the controller after the worker establishes the boundary. - pub snapshot_ready: Option>, + pub snapshot_ready: + Option>, /// Whether this cold boot can publish a microVM snapshot. pub snapshot_capture_enabled: bool, /// Host downtime to apply before starting a restored VM. diff --git a/openvmm/openvmm_entry/Cargo.toml b/openvmm/openvmm_entry/Cargo.toml index b5dec3dcb17..fa923d77420 100644 --- a/openvmm/openvmm_entry/Cargo.toml +++ b/openvmm/openvmm_entry/Cargo.toml @@ -101,6 +101,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } unicycle.workspace = true [target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true net_tap.workspace = true [target.'cfg(windows)'.dependencies] diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 61617913c3e..cd3b12bff34 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -202,20 +202,12 @@ impl Options { self.microvm.snapshot_quiesce_timeout_ms != 0, "microVM snapshot quiesce timeout must be nonzero" ); - anyhow::ensure!( - self.microvm.microvm_sandbox_block.is_empty(), - "microVM snapshot capture does not yet support sandbox blocks" - ); } if self.restore_snapshot.is_some() { anyhow::ensure!( self.net.is_empty(), "microVM restore takes network addressing from saved state; do not pass --net" ); - anyhow::ensure!( - self.microvm.microvm_sandbox_block.is_empty(), - "microVM restore does not yet support sandbox blocks" - ); } anyhow::ensure!( self.uefi.is_none() && !self.pcat && self.igvm.is_none() && !self.device_tree, @@ -509,12 +501,6 @@ mod tests { for extra in [ vec!["--snapshot-quiesce-timeout-ms", "0"], vec!["--memory", "size=1G,shared=off"], - vec![ - "--microvm-sandbox-block", - "distro:mem:1M,ro", - "--microvm-sandbox-block", - "scratch:mem:1M", - ], ] { let options = Options::try_parse_from( [ @@ -554,17 +540,6 @@ mod tests { .unwrap(); assert_eq!(restore.memory, Default::default()); restore.validate_microvm_options().unwrap(); - let restore_blocks = Options::try_parse_from([ - "openvmm", - "--machine", - "microvm", - "--restore-snapshot", - "snapshot", - "--microvm-sandbox-block", - "distro:mem:1M,ro", - ]) - .unwrap(); - assert!(restore_blocks.validate_microvm_options().is_err()); for override_arg in ["--kernel", "--initrd"] { assert!( Options::try_parse_from([ diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 8968d518e6f..9f226bfda20 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2820,8 +2820,12 @@ async fn run_control_inner( let microvm_restore = microvm::prepare_restore(&mut opt, restore.snapshot())?; let (mut vm_config, mut resources) = vm_config_from_command_line(driver, mesh, &opt, µvm_restore).await?; - let mut microvm = - microvm::MicrovmLaunch::new(&opt, &vm_config, std::mem::take(&mut resources.microvm))?; + let mut microvm = microvm::MicrovmLaunch::new( + &opt, + &vm_config, + std::mem::take(&mut resources.microvm), + microvm_restore, + )?; let mut vnc_worker = None; if opt.gfx || opt.vnc.vnc { diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index dffff726c10..5b9b52f7071 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -292,7 +292,12 @@ impl<'a> MicrovmConfigBuilder<'a> { "--microvm-sandbox-block accepts only a plain VTL0 disk backend" ); storage - .add_microvm_sandbox_block(block.role, &disk.kind, disk.read_only) + .add_microvm_sandbox_block( + block.role, + &disk.kind, + disk.read_only, + opt.microvm.snapshot_destination.is_some() || opt.restore_snapshot.is_some(), + ) .await?; } Ok(()) @@ -559,7 +564,11 @@ impl<'a> MicrovmConfigBuilder<'a> { } openvmm_defs::microvm::validate_machine_config(cfg, requested_hypervisor)?; - resources.microvm = std::mem::take(&mut self.resources); + let sandbox_block_sources = std::mem::take(&mut resources.microvm.sandbox_block_sources); + resources.microvm = MicrovmResources { + sandbox_block_sources, + ..std::mem::take(&mut self.resources) + }; Ok(()) } } diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index f0a3eb12efe..454b8b0d728 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -350,6 +350,7 @@ mod tests { true, Some((&filesystem, Path::new(&root_path), attachment)), None, + Vec::new(), 1, 1024, [ @@ -394,6 +395,7 @@ mod tests { true, None, None, + Vec::new(), 1, 1024, [ diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index c157b621e18..cccefabbdde 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -6,17 +6,20 @@ use super::ExpectedRestoreContract; use super::MicrovmResources; +use super::MicrovmRestore; use super::validate_microvm_filesystem_private_storage; use crate::Options; use crate::cli_args::microvm::MachineProfileCli; use crate::vm_controller::MicrovmController; use anyhow::Context; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use chipset_resources::microvm::MicrovmSnapshotScratchPolicy; use openvmm_defs::config::Config; use openvmm_defs::config::LoadMode; use openvmm_defs::microvm::MicrovmFilesystemConfig; use openvmm_defs::microvm::MicrovmNetworkConfig; use openvmm_defs::worker::SharedMemoryFd; +use openvmm_helpers::snapshot::SnapshotManifest; use std::io; use std::path::Path; use std::path::PathBuf; @@ -34,6 +37,7 @@ pub(crate) struct MicrovmLaunch { snapshot_destination: Option, snapshot_memory_file: Option, snapshot_memory_handle: Option, + restore: MicrovmRestore, } impl MicrovmLaunch { @@ -43,6 +47,7 @@ impl MicrovmLaunch { opt: &Options, vm_config: &Config, resources: MicrovmResources, + restore: MicrovmRestore, ) -> anyhow::Result { let effective_command_line = match &vm_config.load_mode { LoadMode::Linux { @@ -136,6 +141,7 @@ impl MicrovmLaunch { snapshot_destination, snapshot_memory_file, snapshot_memory_handle, + restore, }) } @@ -163,8 +169,8 @@ impl MicrovmLaunch { &mut self, ) -> ( Option>, - Option>, - Option>, + Option>, + Option>, ) { let snapshot_boundary_requests = self.resources.snapshot_requests.take(); let (snapshot_ready, snapshot_requests) = if snapshot_boundary_requests.is_some() { @@ -185,11 +191,27 @@ impl MicrovmLaunch { pub(crate) fn expected_restore_contract<'a>( &'a self, opt: &Options, + manifest: &SnapshotManifest, expected_hypervisor: &'a str, ) -> anyhow::Result>> { if opt.machine != MachineProfileCli::Microvm { return Ok(None); } + let scratch_policy = if manifest + .machine_contract + .as_ref() + .and_then(|contract| contract.microvm_sandbox_blocks.last()) + .is_some_and(|block| { + block.artifact == openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME + }) { + MicrovmSnapshotScratchPolicy::Paired + } else { + MicrovmSnapshotScratchPolicy::Fresh + }; + let sandbox_blocks = crate::storage_builder::microvm::snapshot_block_contract( + &self.resources.sandbox_block_sources, + scratch_policy, + )?; let resources = &self.resources; Ok(Some(( expected_hypervisor, @@ -205,6 +227,7 @@ impl MicrovmLaunch { .zip(resources.filesystem_attachment.as_ref()) .map(|((filesystem, root_path), attachment)| (filesystem, root_path, attachment)), resources.console_attachment.as_ref(), + sandbox_blocks, ))) } @@ -223,7 +246,7 @@ impl MicrovmLaunch { self, opt: &Options, source_hypervisor: String, - snapshot_requests: Option>, + snapshot_requests: Option>, ) -> MicrovmController { MicrovmController { active: self.active, @@ -240,6 +263,7 @@ impl MicrovmLaunch { filesystem_slot: self.filesystem_slot, filesystem: self.filesystem, snapshot_memory_file: self.snapshot_memory_file, + _private_scratch_dir: self.restore.private_scratch_dir, } } } diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 1e6ea02e9e7..c74007e7509 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -19,6 +19,7 @@ pub(crate) use restore::MicrovmRestore; pub(crate) use restore::prepare_restore; pub(crate) use restore::validate_restore_contract; +use crate::storage_builder::microvm::MicrovmSandboxBlockSource; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use std::path::PathBuf; @@ -29,6 +30,8 @@ use std::path::PathBuf; pub(crate) struct MicrovmResources { /// Guest-requested snapshot boundaries from the snapshot-request port. pub(crate) snapshot_requests: Option>, + /// Snapshot sources of the fixed-role sandbox blocks, in role order. + pub(crate) sandbox_block_sources: Vec, /// Snapshot identity of the boot virtio-console endpoint. pub(crate) console_attachment: Option, /// Removes the boot console Unix socket on teardown. diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index 6d91f8e2fa8..bd9f13f2caa 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -165,6 +165,7 @@ pub(crate) mod tests { false, None, None, + Vec::new(), 1, 1024, [ diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index dcd60b65eea..569fd8afcff 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -5,13 +5,17 @@ use super::filesystem::microvm_filesystem_slot_from_snapshot; use crate::Options; +use crate::cli_args; +use crate::cli_args::DiskCliKind; use crate::cli_args::microvm::MachineProfileCli; use anyhow::Context; +use openvmm_defs::config::DeviceVtl; use openvmm_defs::microvm::MicrovmFilesystemConfig; use openvmm_defs::microvm::MicrovmNetworkConfig; use openvmm_helpers::snapshot::SnapshotManifest; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use openvmm_helpers::snapshot::microvm::SnapshotMachineContract; +use openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock; use openvmm_helpers::snapshot::restore::OpenedSnapshot; use std::path::Path; use std::time::Duration; @@ -37,16 +41,20 @@ fn calculate_snapshot_downtime( pub(crate) struct MicrovmRestore { /// The authoritative machine contract of the snapshot being restored. pub(crate) machine_contract: Option, + /// Private copy of a paired scratch image, kept alive for the VM lifetime. + pub(crate) private_scratch_dir: Option, } /// Validates a snapshot restore against the microVM profile and prepares the /// restore-time inputs of the microVM configuration. /// -/// This may adjust `opt`: the snapshot selects the RAM size. +/// This may adjust `opt`: the snapshot selects the RAM size, and a paired +/// scratch image is replaced by a private copy. pub(crate) fn prepare_restore( opt: &mut Options, restore_snapshot: Option<&OpenedSnapshot>, ) -> anyhow::Result { + let mut private_scratch_dir = None; if let Some(contract) = restore_snapshot.and_then(|snapshot| snapshot.manifest().machine_contract.as_ref()) && contract.machine_profile == "microvm" @@ -61,6 +69,10 @@ pub(crate) fn prepare_restore( && let Some(snapshot) = restore_snapshot { let manifest = snapshot.manifest(); + let snapshot_dir = opt + .restore_snapshot + .as_deref() + .expect("restore manifest requires a snapshot path"); let contract = manifest .machine_contract .as_ref() @@ -83,18 +95,84 @@ pub(crate) fn prepare_restore( "restore-time memory overrides are not allowed" ); opt.memory.size = Some(vmm_cli::MemorySize(manifest.memory_size_bytes)); + if !contract.microvm_sandbox_blocks.is_empty() { + let scratch = contract + .microvm_sandbox_blocks + .last() + .filter(|block| block.role == "scratch") + .context("microVM snapshot is missing its scratch contract")?; + if scratch.artifact == openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME { + anyhow::ensure!( + !opt.microvm.microvm_sandbox_block.iter().any(|block| { + block.role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + }), + "paired snapshot restore supplies scratch.img; do not pass a scratch block" + ); + let source = snapshot + .open_paired_scratch_file()? + .context("paired snapshot is missing scratch.img")?; + let parent = snapshot_dir + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let temp_dir = tempfile::Builder::new() + .prefix(".openvmm-private-scratch-") + .tempdir_in(parent) + .context("failed to create private restore scratch directory")?; + let private_path = temp_dir + .path() + .join(openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME); + openvmm_helpers::snapshot::restore::copy_verified_file( + &source, + &private_path, + scratch.length, + &scratch.identity, + openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME, + )?; + opt.microvm + .microvm_sandbox_block + .push(cli_args::microvm::MicrovmSandboxBlockCli { + role: openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch, + disk: cli_args::DiskCli { + vtl: DeviceVtl::Vtl0, + kind: DiskCliKind::File { + path: private_path, + create_with_len: None, + direct: false, + }, + read_only: false, + is_dvd: false, + underhill: None, + pcie_port: None, + controller: None, + nsid: None, + lun: None, + relay: None, + }, + }); + private_scratch_dir = Some(temp_dir); + } else { + anyhow::ensure!( + opt.microvm.microvm_sandbox_block.iter().any(|block| { + block.role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + }), + "fresh-scratch snapshot restore requires a scratch block" + ); + } + } Some(contract.clone()) } else { None }; Ok(MicrovmRestore { machine_contract: restore_machine_contract, + private_scratch_dir, }) } /// The machine contract a microVM snapshot must match to be restored: the -/// hypervisor, effective command line, network, filesystem, and boot console -/// attachment. +/// hypervisor, effective command line, network, filesystem, boot console +/// attachment, and sandbox blocks. pub(crate) type ExpectedRestoreContract<'a> = ( &'a str, &'a str, @@ -105,6 +183,7 @@ pub(crate) type ExpectedRestoreContract<'a> = ( &'a SnapshotAttachment, )>, Option<&'a SnapshotAttachment>, + Vec, ); /// Clock and CPU state recorded at the capture boundary of a restored @@ -123,6 +202,7 @@ pub(crate) fn validate_restore_contract( network, filesystem, console_attachment, + sandbox_blocks, ): ExpectedRestoreContract<'_>, ) -> anyhow::Result { let saved_contract = manifest @@ -143,6 +223,7 @@ pub(crate) fn validate_restore_contract( filesystem_slot, filesystem, console_attachment.cloned(), + sandbox_blocks, expected_vp_count, expected_memory_size, saved_contract.state_unit_names.clone(), diff --git a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs index f9424b74dee..dc1caace388 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs @@ -26,7 +26,8 @@ pub(super) fn prepare_snapshot_restore( microvm: µvm::MicrovmLaunch, expected_hypervisor: &str, ) -> anyhow::Result { - let expected_microvm_contract = microvm.expected_restore_contract(opt, expected_hypervisor)?; + let expected_microvm_contract = + microvm.expected_restore_contract(opt, snapshot.manifest(), expected_hypervisor)?; prepare_snapshot_restore_for_config( snapshot, opt.memory_size(), diff --git a/openvmm/openvmm_entry/src/storage_builder.rs b/openvmm/openvmm_entry/src/storage_builder.rs index 393858e7987..cfd6e6508ca 100644 --- a/openvmm/openvmm_entry/src/storage_builder.rs +++ b/openvmm/openvmm_entry/src/storage_builder.rs @@ -887,7 +887,7 @@ impl StorageBuilder { resources.nvme_vtl2_rpc = Some(send); } - self.build_microvm_sandbox_blocks(config)?; + self.build_microvm_sandbox_blocks(config, resources)?; for (i, vblk) in std::mem::take(&mut self.vtl0_virtio_blk_disks) .into_iter() .enumerate() diff --git a/openvmm/openvmm_entry/src/storage_builder/microvm.rs b/openvmm/openvmm_entry/src/storage_builder/microvm.rs index 782f9662e86..f3fb00e4dcf 100644 --- a/openvmm/openvmm_entry/src/storage_builder/microvm.rs +++ b/openvmm/openvmm_entry/src/storage_builder/microvm.rs @@ -5,19 +5,85 @@ use super::StorageBuilder; use super::VirtioBlkDisk; +use crate::VmResources; use crate::cli_args::DiskCliKind; use crate::disk_open; +use anyhow::Context; +use mesh::CellUpdater; use openvmm_defs::config::Config; use openvmm_defs::config::VirtioBus; use openvmm_defs::microvm::MachineProfile; use openvmm_defs::microvm::MicrovmSandboxBlockConfig; use openvmm_defs::microvm::MicrovmSandboxBlockRole; +use std::time::Duration; use virtio_resources::blk::VirtioBlkHandle; use vm_resource::IntoResource; +use vm_resource::Resource; /// The fixed role of a microVM sandbox block device. pub(super) struct SandboxBlock { role: MicrovmSandboxBlockRole, + snapshot_source: Option, +} + +pub(crate) struct MicrovmSandboxBlockSource { + pub(crate) role: MicrovmSandboxBlockRole, + pub(crate) read_only: bool, + pub(crate) length: u64, + pub(crate) logical_block_size: u32, + pub(crate) physical_block_size: u32, + pub(crate) file: std::fs::File, +} + +pub(crate) fn snapshot_block_contract( + sources: &[MicrovmSandboxBlockSource], + scratch_policy: chipset_resources::microvm::MicrovmSnapshotScratchPolicy, +) -> anyhow::Result> { + sources + .iter() + .map(|source| { + if !source.read_only + && scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + { + source.file.sync_all().with_context(|| { + format!("failed to flush microVM {} block", source.role.as_str()) + })?; + } + let (identity_kind, identity, artifact) = if source.role + == MicrovmSandboxBlockRole::Scratch + && scratch_policy == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + { + ("fresh", Vec::new(), String::new()) + } else { + ( + "sha256", + openvmm_helpers::snapshot::fs::file_sha256( + &source.file, + source.length, + &format!("microVM {} block", source.role.as_str()), + )?, + if source.role == MicrovmSandboxBlockRole::Scratch { + openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME.to_owned() + } else { + String::new() + }, + ) + }; + Ok( + openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock { + role: source.role.as_str().to_owned(), + read_only: source.read_only, + length: source.length, + identity_kind: identity_kind.to_owned(), + identity, + artifact, + logical_block_size: source.logical_block_size, + physical_block_size: source.physical_block_size, + }, + ) + }) + .collect() } impl StorageBuilder { @@ -27,11 +93,129 @@ impl StorageBuilder { role: MicrovmSandboxBlockRole, kind: &DiskCliKind, read_only: bool, + snapshot_capable: bool, ) -> anyhow::Result<()> { + if !snapshot_capable { + self.vtl0_virtio_blk_disks.push(VirtioBlkDisk { + disk: disk_open(kind, read_only).await?, + read_only, + microvm: Some(SandboxBlock { + role, + snapshot_source: None, + }), + }); + return Ok(()); + } + let (kind, delay_ms) = match kind { + DiskCliKind::DelayDiskWrapper { delay_ms, disk } => (disk.as_ref(), Some(*delay_ms)), + kind => (kind, None), + }; + let DiskCliKind::File { + path, + create_with_len, + direct, + } = kind + else { + anyhow::bail!("snapshot-capable microVM sandbox blocks require a plain file backend"); + }; + anyhow::ensure!( + !direct, + "snapshot-capable microVM sandbox blocks do not support direct I/O" + ); + anyhow::ensure!( + !matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("vhd" | "vhdx" | "vmgs" | "iso") + ), + "snapshot-capable microVM sandbox blocks require an unformatted raw file backend" + ); + + let mut options = std::fs::OpenOptions::new(); + options + .read(true) + .write(!read_only || create_with_len.is_some()); + if create_with_len.is_some() { + options.create(true).truncate(true); + } + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE); + } + let file = options + .open(path) + .with_context(|| format!("failed to open sandbox block {}", path.display()))?; + if let Some(length) = create_with_len { + file.set_len(*length) + .with_context(|| format!("failed to size sandbox block {}", path.display()))?; + } + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect sandbox block {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_file(), + "microVM sandbox block is not a regular file: {}", + path.display() + ); + let length = metadata.len(); + anyhow::ensure!( + length != 0 && length % 512 == 0, + "microVM sandbox block length must be nonzero and 512-byte aligned" + ); + let logical_block_size: u32 = 512; + #[cfg(target_os = "linux")] + let physical_block_size = { + use std::os::unix::fs::MetadataExt as _; + u32::try_from(metadata.blksize()) + .context("sandbox block filesystem block size does not fit in u32")? + }; + #[cfg(windows)] + let physical_block_size: u32 = 4096; + #[cfg(not(any(target_os = "linux", windows)))] + let physical_block_size = logical_block_size; + anyhow::ensure!( + physical_block_size >= logical_block_size && physical_block_size.is_power_of_two(), + "microVM sandbox block has unsupported physical block geometry" + ); + let disk_file = file + .try_clone() + .context("failed to duplicate microVM sandbox block handle")?; + #[cfg(target_os = "linux")] + let disk = Resource::new(disk_backend_resources::BlockDeviceDiskHandle { file: disk_file }); + #[cfg(not(target_os = "linux"))] + let disk = Resource::new(disk_backend_resources::FileDiskHandle(disk_file)); + let disk = if let Some(delay_ms) = delay_ms { + Resource::new(disk_backend_resources::DelayDiskHandle { + disk, + delay: CellUpdater::new(Duration::from_millis(delay_ms)).cell(), + }) + } else { + disk + }; self.vtl0_virtio_blk_disks.push(VirtioBlkDisk { - disk: disk_open(kind, read_only).await?, + disk, read_only, - microvm: Some(SandboxBlock { role }), + microvm: Some(SandboxBlock { + role, + snapshot_source: Some(MicrovmSandboxBlockSource { + role, + read_only, + length, + logical_block_size, + physical_block_size, + file, + }), + }), }); Ok(()) } @@ -41,6 +225,7 @@ impl StorageBuilder { pub(super) fn build_microvm_sandbox_blocks( &mut self, config: &mut Config, + resources: &mut VmResources, ) -> anyhow::Result<()> { if config.machine_profile != MachineProfile::Microvm { return Ok(()); @@ -62,6 +247,9 @@ impl StorageBuilder { role: block.role, read_only: vblk.read_only, }); + if let Some(source) = block.snapshot_source { + resources.microvm.sandbox_block_sources.push(source); + } } config.virtio_devices.push(( VirtioBus::Mmio, diff --git a/openvmm/openvmm_entry/src/vm_controller.rs b/openvmm/openvmm_entry/src/vm_controller.rs index 26965377649..b819103e905 100644 --- a/openvmm/openvmm_entry/src/vm_controller.rs +++ b/openvmm/openvmm_entry/src/vm_controller.rs @@ -190,7 +190,7 @@ impl VmController { Worker(WorkerEvent), VncWorker(WorkerEvent), Halt(HaltReason), - SnapshotRequest, + SnapshotRequest(chipset_resources::microvm::MicrovmSnapshotScratchPolicy), } let mut quit = false; @@ -215,7 +215,7 @@ impl VmController { let snapshot_request = futures::stream::iter(self.microvm.snapshot_requests.as_mut()) .flatten() - .map(|()| Event::SnapshotRequest); + .map(Event::SnapshotRequest); (rpc.into_stream(), vm, vnc, halt, snapshot_request) .merge() @@ -337,8 +337,8 @@ impl VmController { } } } - Event::SnapshotRequest => { - let action = self.handle_guest_snapshot_request().await; + Event::SnapshotRequest(scratch_policy) => { + let action = self.handle_guest_snapshot_request(scratch_policy).await; if let GuestSnapshotAction::Terminate { exit_code } = action { event_send.send(VmControllerEvent::ExitRequested { code: exit_code }); break; diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index d62535061b2..11ffcc501bc 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -20,7 +20,8 @@ pub(crate) struct MicrovmController { /// Exact open handle of the file backing guest RAM for snapshot capture. pub(crate) snapshot_memory_handle: Option, /// Guest-requested snapshot boundaries released by the VM worker. - pub(crate) snapshot_requests: Option>, + pub(crate) snapshot_requests: + Option>, /// Directory receiving a guest-requested snapshot. pub(crate) snapshot_destination: Option, /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. @@ -39,6 +40,8 @@ pub(crate) struct MicrovmController { pub(crate) filesystem: Option, /// Automatic RAM backing created for snapshot capture. pub(crate) snapshot_memory_file: Option, + /// Private copy of a paired scratch image, kept alive for the VM lifetime. + pub(crate) _private_scratch_dir: Option, } pub(super) enum GuestSnapshotAction { @@ -47,7 +50,10 @@ pub(super) enum GuestSnapshotAction { } impl VmController { - pub(super) async fn handle_guest_snapshot_request(&mut self) -> GuestSnapshotAction { + pub(super) async fn handle_guest_snapshot_request( + &mut self, + scratch_policy: chipset_resources::microvm::MicrovmSnapshotScratchPolicy, + ) -> GuestSnapshotAction { let Some(destination) = self.microvm.snapshot_destination.clone() else { tracelimit::warn_ratelimited!( "ignoring microVM snapshot request because no destination is configured" @@ -67,6 +73,20 @@ impl VmController { ), "microVM snapshot source backend must be KVM, MSHV, or WHP" ); + anyhow::ensure!( + self.microvm.resources.sandbox_block_sources.is_empty() + || (self.microvm.resources.sandbox_block_sources.len() >= 2 + && self + .microvm + .resources + .sandbox_block_sources + .last() + .is_some_and(|source| { + source.role + == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + })), + "microVM snapshot requires either no blocks or at least one lower layer and scratch" + ); anyhow::ensure!( fs_err::symlink_metadata(&destination) .is_err_and(|error| { error.kind() == std::io::ErrorKind::NotFound }), @@ -159,6 +179,10 @@ impl VmController { .zip(self.microvm.resources.filesystem_root_path.as_deref()) .zip(self.microvm.resources.filesystem_attachment.clone()) .map(|((filesystem, root_path), attachment)| (filesystem, root_path, attachment)); + let blocks = crate::storage_builder::microvm::snapshot_block_contract( + &self.microvm.resources.sandbox_block_sources, + scratch_policy, + )?; let machine_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( &self.microvm.source_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, @@ -167,6 +191,7 @@ impl VmController { self.microvm.filesystem_slot, filesystem, self.microvm.resources.console_attachment.clone(), + blocks, self.processors, self.memory, response.state_unit_names, @@ -211,20 +236,37 @@ impl VmController { ..Default::default() }, ); + let scratch_file = (!self.microvm.resources.sandbox_block_sources.is_empty() + && scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired) + .then(|| { + self.microvm + .resources + .sandbox_block_sources + .iter() + .find(|source| { + source.role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + }) + .map(|source| &source.file) + .context("paired snapshot lost its scratch backing handle") + }) + .transpose()?; let publication = openvmm_defs::profile::ProfileSpan::start(); let write_result = if self.microvm.snapshot_memory_file.is_some() { - openvmm_helpers::snapshot::publish::write_snapshot_from_owned_memory_file( + openvmm_helpers::snapshot::publish::write_snapshot_from_owned_memory_and_scratch_files( &destination, &manifest, &saved_state_bytes, memory_file, + scratch_file, ) } else { - openvmm_helpers::snapshot::publish::write_snapshot_from_memory_file( + openvmm_helpers::snapshot::publish::write_snapshot_from_memory_and_scratch_files( &destination, &manifest, &saved_state_bytes, memory_file, + scratch_file, ) }; if write_result.is_ok() { diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index 1a529c5dbea..b294580f441 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -15,7 +15,7 @@ pub mod restore; pub use publish::write_snapshot; /// Current manifest format version. Bump when making incompatible changes. -pub const MANIFEST_VERSION: u32 = 3; +pub const MANIFEST_VERSION: u32 = 4; /// Manifest describing a VM snapshot. #[derive(Clone, Protobuf)] @@ -45,10 +45,10 @@ pub struct SnapshotManifest { /// Length of `state.bin` in bytes. #[mesh(8)] pub state_size_bytes: u64, - /// Legacy v2 SHA-256 digest of `state.bin`; empty in v3. + /// Legacy v2 SHA-256 digest of `state.bin`; empty in v3 and v4. #[mesh(9)] pub state_sha256: Vec, - /// Legacy v2 SHA-256 digest of `memory.bin`; empty in v3. + /// Legacy v2 SHA-256 digest of `memory.bin`; empty in v3 and v4. #[mesh(10)] pub memory_sha256: Vec, /// Authoritative machine composition for versioned machine profiles. diff --git a/openvmm/openvmm_helpers/src/snapshot/format.rs b/openvmm/openvmm_helpers/src/snapshot/format.rs index ff887c8f53f..5ca5fdfde4a 100644 --- a/openvmm/openvmm_helpers/src/snapshot/format.rs +++ b/openvmm/openvmm_helpers/src/snapshot/format.rs @@ -6,11 +6,14 @@ use super::MANIFEST_VERSION; use super::SnapshotManifest; +use super::microvm; use mesh::payload::Timestamp; use sha2::Digest; /// Magic identifying the OpenVMM snapshot manifest format. -pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V3\0"; +pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V4\0"; +pub(super) const PREVIOUS_MANIFEST_VERSION: u32 = 3; +pub(super) const PREVIOUS_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V3\0"; pub(super) const LEGACY_MANIFEST_VERSION: u32 = 2; pub(super) const LEGACY_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V2\0"; /// Saved-state schema version used by the VM worker envelope. @@ -21,6 +24,8 @@ pub const SAVED_STATE_ROOT_TYPE: &str = "openvmm.SavedState"; pub(super) const MANIFEST_FILE_NAME: &str = "manifest.bin"; pub(super) const STATE_FILE_NAME: &str = "state.bin"; pub(super) const MEMORY_FILE_NAME: &str = "memory.bin"; +/// Fixed snapshot-relative name of a paired scratch image. +pub const SCRATCH_FILE_NAME: &str = "scratch.img"; pub(super) const MAX_MANIFEST_SIZE_BYTES: u64 = 1024 * 1024; pub(super) const MAX_SAVED_STATE_SIZE_BYTES: u64 = 256 * 1024 * 1024; pub(super) const SHA256_SIZE: usize = 32; @@ -80,6 +85,7 @@ pub(super) fn verify_digest( pub(super) fn validate_manifest_header(manifest: &SnapshotManifest) -> anyhow::Result<()> { let expected_magic = match manifest.version { LEGACY_MANIFEST_VERSION => LEGACY_SNAPSHOT_FORMAT_MAGIC, + PREVIOUS_MANIFEST_VERSION => PREVIOUS_SNAPSHOT_FORMAT_MAGIC, MANIFEST_VERSION => SNAPSHOT_FORMAT_MAGIC, version => anyhow::bail!( "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {MANIFEST_VERSION})" @@ -115,13 +121,23 @@ pub(super) fn validate_manifest_version(manifest: &SnapshotManifest) -> anyhow:: "legacy memory.bin SHA-256 digest has invalid length {}", manifest.memory_sha256.len(), ); + anyhow::ensure!( + !microvm::has_sandbox_blocks(manifest), + "snapshot manifest version {LEGACY_MANIFEST_VERSION} cannot contain microVM sandbox blocks" + ); } - MANIFEST_VERSION => { + PREVIOUS_MANIFEST_VERSION | MANIFEST_VERSION => { anyhow::ensure!( manifest.state_sha256.is_empty() && manifest.memory_sha256.is_empty(), "snapshot manifest version {} must not contain legacy artifact digests", manifest.version, ); + if manifest.version == PREVIOUS_MANIFEST_VERSION { + anyhow::ensure!( + !microvm::has_sandbox_blocks(manifest), + "snapshot manifest version {PREVIOUS_MANIFEST_VERSION} cannot contain microVM sandbox blocks" + ); + } } version => anyhow::bail!( "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {MANIFEST_VERSION})" @@ -145,19 +161,11 @@ mod tests { } #[test] - fn legacy_v2_manifest_requires_payload_digests() { + fn previous_v3_manifest_remains_accepted() { let mut manifest = test_manifest(); - manifest.version = LEGACY_MANIFEST_VERSION; - manifest.format_magic = LEGACY_SNAPSHOT_FORMAT_MAGIC.to_vec(); - let error = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); - assert!( - error - .to_string() - .contains("legacy state.bin SHA-256 digest") - ); + manifest.version = PREVIOUS_MANIFEST_VERSION; + manifest.format_magic = PREVIOUS_SNAPSHOT_FORMAT_MAGIC.to_vec(); - manifest.state_sha256 = vec![0; SHA256_SIZE]; - manifest.memory_sha256 = vec![0; SHA256_SIZE]; validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap(); } diff --git a/openvmm/openvmm_helpers/src/snapshot/fs.rs b/openvmm/openvmm_helpers/src/snapshot/fs.rs index 9a5304d7047..89257d18629 100644 --- a/openvmm/openvmm_helpers/src/snapshot/fs.rs +++ b/openvmm/openvmm_helpers/src/snapshot/fs.rs @@ -4,10 +4,12 @@ //! File-system helpers for snapshot artifacts, including their //! platform-specific implementations: private files and directories, //! no-replace renames and directory flushes, directory-relative artifact -//! access and file generations, exact-file hard links, and sparse-aware clone -//! and copy with allocation accounting. +//! access and file generations, exact-file hard links, sparse-aware clone and +//! copy with allocation accounting, and SHA-256 over file handles. +use super::format::validate_sha256; use anyhow::Context; +use sha2::Digest; use std::io::Read; use std::io::Seek; use std::io::SeekFrom; @@ -1028,6 +1030,79 @@ pub(super) fn read_bounded_open_file( Ok(bytes) } +/// Computes SHA-256 over an exact-length regular file handle. +pub fn file_sha256( + file: &std::fs::File, + expected_length: u64, + description: &str, +) -> anyhow::Result> { + let mut file = file + .try_clone() + .with_context(|| format!("failed to duplicate {description} handle"))?; + file.seek(SeekFrom::Start(0)) + .with_context(|| format!("failed to rewind {description}"))?; + let actual_length = file + .metadata() + .with_context(|| format!("failed to inspect {description}"))? + .len(); + anyhow::ensure!( + actual_length == expected_length, + "{description} size ({actual_length} bytes) doesn't match expected ({expected_length} bytes)" + ); + let mut digest = sha2::Sha256::new(); + let mut buffer = vec![0_u8; COPY_BUFFER_SIZE]; + let mut total = 0_u64; + loop { + let count = file + .read(&mut buffer) + .with_context(|| format!("failed to read {description}"))?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + total = total + .checked_add(count as u64) + .context("file length overflowed u64 while hashing")?; + anyhow::ensure!( + total <= expected_length, + "{description} grew while it was being hashed" + ); + } + anyhow::ensure!( + total == expected_length, + "{description} changed while it was being hashed" + ); + Ok(digest.finalize().to_vec()) +} + +pub(super) fn verify_file_digest( + file: &std::fs::File, + expected_length: u64, + expected_digest: &[u8], + description: &str, +) -> anyhow::Result<()> { + validate_sha256(expected_digest, description)?; + anyhow::ensure!( + file_sha256(file, expected_length, description)? == expected_digest, + "{description} SHA-256 digest mismatch" + ); + Ok(()) +} + +pub(super) fn open_file_with_length( + path: &Path, + expected_length: u64, + artifact_name: &str, +) -> anyhow::Result { + let file = open_regular_file(path, artifact_name)?; + let length = opened_file_generation(&file, artifact_name)?.length(); + anyhow::ensure!( + length == expected_length, + "{artifact_name} size ({length} bytes) doesn't match manifest ({expected_length} bytes)", + ); + Ok(file) +} + #[cfg(unix)] pub(super) fn sync_directory(path: &Path) -> anyhow::Result<()> { fs_err::File::open(path) diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 41a222ba50d..caa82f2eb6d 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -4,6 +4,9 @@ //! microVM snapshot machine-contract types, construction, and validation. use super::SnapshotManifest; +use super::format::SCRATCH_FILE_NAME; +#[cfg(test)] +use super::format::SHA256_SIZE; use super::format::validate_sha256; use super::format::verify_digest; use anyhow::Context; @@ -210,6 +213,36 @@ pub struct SnapshotMicrovmFilesystem { pub canonical_host_path: String, } +/// Authoritative identity and snapshot policy for a microVM sandbox block. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMicrovmSandboxBlock { + /// Stable role (`distro`, `runtime`, `custom`, or `scratch`). + #[mesh(1)] + pub role: String, + /// Whether the guest sees the device as read-only. + #[mesh(2)] + pub read_only: bool, + /// Logical device length in bytes. + #[mesh(3)] + pub length: u64, + /// Kind of immutable identity carried in `identity`. + #[mesh(4)] + pub identity_kind: String, + /// Immutable layer identity or paired-scratch digest. + #[mesh(5)] + pub identity: Vec, + /// Fixed snapshot-relative artifact name; empty for external layers. + #[mesh(6)] + pub artifact: String, + /// Guest-visible logical block size in bytes. + #[mesh(7)] + pub logical_block_size: u32, + /// Guest-visible physical block size in bytes. + #[mesh(8)] + pub physical_block_size: u32, +} + impl SnapshotMicrovmFilesystem { fn new( config: &openvmm_defs::microvm::MicrovmFilesystemConfig, @@ -308,6 +341,9 @@ pub struct SnapshotMachineContract { /// Effective local APIC timer frequency. #[mesh(20)] pub apic_frequency_hz: Option, + /// Fixed-role sandbox blocks in guest-visible order. + #[mesh(21)] + pub microvm_sandbox_blocks: Vec, /// Version of the reserved restore-attachable microVM virtio-fs slot. #[mesh(22)] pub microvm_filesystem_slot_version: u32, @@ -390,6 +426,7 @@ pub fn microvm_machine_contract( SnapshotAttachment, )>, console_attachment: Option, + sandbox_blocks: Vec, processor_count: u32, memory_size: u64, state_unit_names: Vec, @@ -693,6 +730,45 @@ pub fn microvm_machine_contract( attachments.push(attachment); } + for block in &sandbox_blocks { + let role = match block.role.as_str() { + "distro" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Distro, + "runtime" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Runtime, + "custom" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Custom, + "scratch" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch, + role => anyhow::bail!("snapshot sandbox block role '{role}' is unsupported"), + }; + let discovery = format!( + "virtio_mmio.device={:#x}@{:#x}:{}", + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + role.mmio_base(), + role.irq(), + ); + anyhow::ensure!( + effective_command_line + .split_ascii_whitespace() + .any(|token| token == discovery), + "microVM sandbox block '{}' is missing from the effective command line", + block.role + ); + let features = openvmm_defs::microvm::microvm_sandbox_block_features(role); + devices.push(SnapshotDevice { + stable_id: format!("blk:sandbox:{}", role.as_str()), + state_unit_name: format!("virtio-blk-{}", role.mmio_base()), + kind: "virtio-blk".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio( + role.mmio_base(), + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + )], + irq: Some(role.irq()), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![features as u32, (features >> 32) as u32], + queue_count: 1, + queue_max_sizes: vec![256], + }); + } + let mut contract = SnapshotMachineContract { machine_profile: "microvm".to_owned(), microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, @@ -714,6 +790,7 @@ pub fn microvm_machine_contract( microvm_network, microvm_filesystem, apic_frequency_hz, + microvm_sandbox_blocks: sandbox_blocks, microvm_filesystem_slot_version: if filesystem_slot { MICROVM_FILESYSTEM_SLOT_VERSION } else { @@ -729,6 +806,23 @@ pub fn microvm_machine_contract( Ok(contract) } +pub(super) fn has_sandbox_blocks(manifest: &SnapshotManifest) -> bool { + manifest + .machine_contract + .as_ref() + .is_some_and(|contract| !contract.microvm_sandbox_blocks.is_empty()) +} +pub(super) fn paired_scratch_block( + manifest: &SnapshotManifest, +) -> Option<&SnapshotMicrovmSandboxBlock> { + manifest + .machine_contract + .as_ref()? + .microvm_sandbox_blocks + .iter() + .find(|block| block.artifact == SCRATCH_FILE_NAME) +} + /// Rejects a snapshot contract that does not use the supported persisted microVM identities. pub fn validate_supported_microvm_contract( contract: &SnapshotMachineContract, @@ -830,6 +924,10 @@ pub fn validate_microvm_machine_contract( contract.microvm_filesystem == expected.microvm_filesystem, "snapshot filesystem policy doesn't match the requested machine" ); + anyhow::ensure!( + contract.microvm_sandbox_blocks == expected.microvm_sandbox_blocks, + "snapshot sandbox block topology or identity doesn't match the requested machine" + ); anyhow::ensure!( contract.tsc_frequency_hz == expected.tsc_frequency_hz && contract.tsc_tolerance_ppm == expected.tsc_tolerance_ppm, @@ -961,64 +1059,6 @@ pub(super) fn validate_machine_contract_shape( *topology == microvm_snapshot_topology(vp_count)?, "snapshot processor topology is not canonical for the microVM" ); - anyhow::ensure!( - contract.devices.len() <= MAX_DEVICES, - "snapshot device inventory is too large" - ); - let mut device_ids = HashSet::new(); - for (index, device) in contract.devices.iter().enumerate() { - anyhow::ensure!( - device.order == index as u32, - "snapshot device order is not canonical" - ); - anyhow::ensure!( - !device.stable_id.is_empty() && device_ids.insert(device.stable_id.as_str()), - "snapshot contains an empty or duplicate device ID" - ); - anyhow::ensure!( - !device.state_unit_name.is_empty(), - "snapshot device '{}' has no state-unit name", - device.stable_id, - ); - anyhow::ensure!( - device.ranges.len() <= MAX_DEVICE_RANGES, - "snapshot device '{}' has too many address ranges", - device.stable_id, - ); - for range in &device.ranges { - anyhow::ensure!( - matches!(range.address_space.as_str(), "pmio" | "mmio") && range.length != 0, - "snapshot device '{}' has an invalid address range", - device.stable_id, - ); - range.start.checked_add(range.length).with_context(|| { - format!("snapshot device '{}' range overflows", device.stable_id) - })?; - } - anyhow::ensure!( - device.queue_count as usize == device.queue_max_sizes.len(), - "snapshot device '{}' queue inventory is inconsistent", - device.stable_id, - ); - } - - anyhow::ensure!( - !contract.state_unit_names.is_empty() && contract.state_unit_names.len() <= MAX_STATE_UNITS, - "snapshot state-unit inventory size is invalid" - ); - ensure_unique(&contract.state_unit_names, "state-unit name")?; - let state_units = contract - .state_unit_names - .iter() - .map(String::as_str) - .collect::>(); - for device in &contract.devices { - anyhow::ensure!( - state_units.contains(device.state_unit_name.as_str()), - "snapshot device '{}' references an unknown state unit", - device.stable_id, - ); - } if let Some(network) = &contract.microvm_network { anyhow::ensure!( @@ -1088,6 +1128,145 @@ pub(super) fn validate_machine_contract_shape( ), } + if !contract.microvm_sandbox_blocks.is_empty() { + anyhow::ensure!( + contract.microvm_sandbox_blocks.len() >= 2 + && contract.microvm_sandbox_blocks.len() <= 4, + "microVM snapshot must contain one to three layers and scratch" + ); + let mut previous_role = None; + for block in &contract.microvm_sandbox_blocks { + let role = match block.role.as_str() { + "distro" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Distro, + "runtime" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Runtime, + "custom" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Custom, + "scratch" => openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch, + role => anyhow::bail!("snapshot sandbox block role '{role}' is unsupported"), + }; + anyhow::ensure!( + previous_role.is_none_or(|previous| previous < role), + "snapshot sandbox block roles are duplicated or out of order" + ); + previous_role = Some(role); + anyhow::ensure!( + block.read_only == role.is_read_only(), + "snapshot sandbox block '{}' has an invalid access mode", + block.role + ); + anyhow::ensure!( + block.length != 0 && block.length % 512 == 0, + "snapshot sandbox block '{}' has invalid geometry", + block.role + ); + anyhow::ensure!( + block.logical_block_size >= 512 + && block.logical_block_size.is_power_of_two() + && block.physical_block_size >= block.logical_block_size + && block.physical_block_size.is_power_of_two() + && block.length % u64::from(block.logical_block_size) == 0, + "snapshot sandbox block '{}' has invalid block geometry", + block.role + ); + if role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch { + anyhow::ensure!( + block.artifact.is_empty() || block.artifact == SCRATCH_FILE_NAME, + "snapshot scratch artifact name is invalid" + ); + if block.artifact.is_empty() { + anyhow::ensure!( + block.identity_kind == "fresh" && block.identity.is_empty(), + "snapshot fresh scratch policy is invalid" + ); + } else { + anyhow::ensure!( + block.identity_kind == "sha256", + "snapshot paired scratch has an unsupported identity kind" + ); + validate_sha256(&block.identity, "scratch block")?; + } + } else { + anyhow::ensure!( + block.artifact.is_empty() + && matches!(block.identity_kind.as_str(), "sha256" | "unbound"), + "snapshot read-only layer '{}' has an invalid identity policy", + block.role + ); + if block.identity_kind == "sha256" { + validate_sha256(&block.identity, &format!("{} block", block.role))?; + } else { + anyhow::ensure!( + block.identity.is_empty(), + "snapshot unbound layer '{}' carries an identity", + block.role + ); + } + } + } + anyhow::ensure!( + previous_role == Some(openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch), + "microVM snapshot is missing its scratch role" + ); + } + + anyhow::ensure!( + contract.devices.len() <= MAX_DEVICES, + "snapshot device inventory is too large" + ); + let mut device_ids = HashSet::new(); + for (index, device) in contract.devices.iter().enumerate() { + anyhow::ensure!( + device.order == index as u32, + "snapshot device order is not canonical" + ); + anyhow::ensure!( + !device.stable_id.is_empty() && device_ids.insert(device.stable_id.as_str()), + "snapshot contains an empty or duplicate device ID" + ); + anyhow::ensure!( + !device.state_unit_name.is_empty(), + "snapshot device '{}' has no state-unit name", + device.stable_id, + ); + anyhow::ensure!( + device.ranges.len() <= MAX_DEVICE_RANGES, + "snapshot device '{}' has too many address ranges", + device.stable_id, + ); + for range in &device.ranges { + anyhow::ensure!( + matches!(range.address_space.as_str(), "pmio" | "mmio") && range.length != 0, + "snapshot device '{}' has an invalid address range", + device.stable_id, + ); + range.start.checked_add(range.length).with_context(|| { + format!("snapshot device '{}' range overflows", device.stable_id) + })?; + } + anyhow::ensure!( + device.queue_count as usize == device.queue_max_sizes.len(), + "snapshot device '{}' queue inventory is inconsistent", + device.stable_id, + ); + } + + anyhow::ensure!( + !contract.state_unit_names.is_empty() && contract.state_unit_names.len() <= MAX_STATE_UNITS, + "snapshot state-unit inventory size is invalid" + ); + ensure_unique(&contract.state_unit_names, "state-unit name")?; + let state_units = contract + .state_unit_names + .iter() + .map(String::as_str) + .collect::>(); + for device in &contract.devices { + anyhow::ensure!( + state_units.contains(device.state_unit_name.as_str()), + "snapshot device '{}' references an unknown state unit", + device.stable_id, + ); + } + anyhow::ensure!( contract.attachments.len() <= MAX_ATTACHMENTS, "snapshot attachment inventory is too large" @@ -1151,6 +1330,40 @@ where Ok(()) } +#[cfg(test)] +const TEST_DISTRO_IDENTITY_BYTE: u8 = 0x11; + +#[cfg(test)] +pub(super) fn paired_scratch_manifest(scratch: &[u8]) -> SnapshotManifest { + let mut manifest = super::tests::test_manifest(); + let mut contract = test_machine_contract(); + contract.microvm_sandbox_blocks = vec![ + SnapshotMicrovmSandboxBlock { + role: "distro".to_owned(), + read_only: true, + length: 512, + identity_kind: "sha256".to_owned(), + identity: vec![TEST_DISTRO_IDENTITY_BYTE; SHA256_SIZE], + artifact: String::new(), + logical_block_size: 512, + physical_block_size: 4096, + }, + SnapshotMicrovmSandboxBlock { + role: "scratch".to_owned(), + read_only: false, + length: scratch.len() as u64, + identity_kind: "sha256".to_owned(), + identity: sha2::Sha256::digest(scratch).to_vec(), + artifact: SCRATCH_FILE_NAME.to_owned(), + logical_block_size: 512, + physical_block_size: 4096, + }, + ]; + contract.set_effective_command_line("console=hvc0".to_owned()); + manifest.machine_contract = Some(contract); + manifest +} + #[cfg(test)] fn test_machine_contract() -> SnapshotMachineContract { let mut contract = SnapshotMachineContract { @@ -1216,6 +1429,7 @@ fn test_machine_contract() -> SnapshotMachineContract { clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), microvm_network: None, microvm_filesystem: None, + microvm_sandbox_blocks: Vec::new(), microvm_filesystem_slot_version: 0, apic_frequency_hz: Some(1_000_000_000), virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), @@ -1229,7 +1443,12 @@ fn test_machine_contract() -> SnapshotMachineContract { #[cfg(test)] mod tests { + use super::super::format::LEGACY_MANIFEST_VERSION; + use super::super::format::LEGACY_SNAPSHOT_FORMAT_MAGIC; + use super::super::format::PREVIOUS_MANIFEST_VERSION; + use super::super::format::PREVIOUS_SNAPSHOT_FORMAT_MAGIC; use super::super::tests::test_manifest; + use super::super::validate_manifest; use super::*; fn microvm_console_attachment() -> SnapshotAttachment { @@ -1274,6 +1493,7 @@ mod tests { false, None, None, + Vec::new(), 1, 1024, [ @@ -1342,6 +1562,7 @@ mod tests { microvm_filesystem_attachment(source_hypervisor), )), None, + Vec::new(), 1, 1024, [ @@ -1376,6 +1597,7 @@ mod tests { true, None, None, + Vec::new(), 1, 1024, [ @@ -1410,6 +1632,7 @@ mod tests { false, None, Some(microvm_console_attachment()), + Vec::new(), 1, 1024, [ @@ -1874,4 +2097,47 @@ mod tests { let err = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); assert!(err.to_string().contains("overlap in GPA space")); } + + #[test] + fn legacy_formats_reject_abi_v2_blocks() { + let scratch = vec![0x5a_u8; 1024]; + for (version, magic) in [ + (LEGACY_MANIFEST_VERSION, LEGACY_SNAPSHOT_FORMAT_MAGIC), + (PREVIOUS_MANIFEST_VERSION, PREVIOUS_SNAPSHOT_FORMAT_MAGIC), + ] { + let mut manifest = paired_scratch_manifest(&scratch); + manifest.version = version; + manifest.format_magic = magic.to_vec(); + if version == LEGACY_MANIFEST_VERSION { + manifest.state_sha256 = vec![0; SHA256_SIZE]; + manifest.memory_sha256 = vec![0; SHA256_SIZE]; + } + let error = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!( + error + .to_string() + .contains("cannot contain microVM sandbox blocks") + ); + } + } + + #[test] + fn abi_v2_contract_rejects_scratch_without_a_lower_layer() { + let scratch = vec![0x5a_u8; 1024]; + let mut manifest = paired_scratch_manifest(&scratch); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + .remove(0); + let contract = manifest.machine_contract.clone().unwrap(); + + let error = validate_microvm_machine_contract(&manifest, &contract).unwrap_err(); + assert!( + error + .to_string() + .contains("one to three layers and scratch") + ); + } } diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs index 0e9fcdfeec8..d4d3ad9e422 100644 --- a/openvmm/openvmm_helpers/src/snapshot/publish.rs +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -12,6 +12,7 @@ use super::format::MANIFEST_FILE_NAME; use super::format::MAX_MANIFEST_SIZE_BYTES; use super::format::MAX_SAVED_STATE_SIZE_BYTES; use super::format::MEMORY_FILE_NAME; +use super::format::SCRATCH_FILE_NAME; use super::format::STATE_FILE_NAME; use super::format::validate_manifest_header; use super::format::validate_manifest_version; @@ -22,12 +23,14 @@ use super::fs::create_hard_link_from_handle; use super::fs::create_private_directory; use super::fs::ensure_path_absent; use super::fs::hard_link_is_unsupported; +use super::fs::open_file_with_length; use super::fs::open_regular_file; use super::fs::path_exists; use super::fs::rename_no_replace; use super::fs::snapshot_parent; use super::fs::sync_directory; use super::fs::validate_directory; +use super::fs::verify_file_digest; use super::fs::verify_hard_link_identity; use super::fs::write_bytes; use super::microvm; @@ -112,12 +115,30 @@ pub fn write_snapshot_from_memory_file( manifest: &SnapshotManifest, saved_state_bytes: &[u8], memory_file: &std::fs::File, +) -> Result<(), SnapshotWriteError> { + write_snapshot_from_memory_and_scratch_files( + dir, + manifest, + saved_state_bytes, + memory_file, + None, + ) +} + +/// Writes a snapshot with an optional scratch image paired to the VM state. +pub fn write_snapshot_from_memory_and_scratch_files( + dir: &Path, + manifest: &SnapshotManifest, + saved_state_bytes: &[u8], + memory_file: &std::fs::File, + scratch_file: Option<&std::fs::File>, ) -> Result<(), SnapshotWriteError> { write_snapshot_with_memory_publication( dir, manifest, saved_state_bytes, memory_file, + scratch_file, MemoryPublication::IndependentCopy, ) } @@ -131,17 +152,19 @@ pub fn write_snapshot_from_memory_file( /// source may resume. [`SnapshotWriteError::CleanupUncertain`] requires source /// termination because a private staging alias may remain. Unsupported hard /// links fall back to an independent sparse-aware copy. -pub fn write_snapshot_from_owned_memory_file( +pub fn write_snapshot_from_owned_memory_and_scratch_files( dir: &Path, manifest: &SnapshotManifest, saved_state_bytes: &[u8], memory_file: &std::fs::File, + scratch_file: Option<&std::fs::File>, ) -> Result<(), SnapshotWriteError> { write_snapshot_with_memory_publication( dir, manifest, saved_state_bytes, memory_file, + scratch_file, MemoryPublication::OwnedExactFile, ) } @@ -157,6 +180,7 @@ fn write_snapshot_with_memory_publication( manifest: &SnapshotManifest, saved_state_bytes: &[u8], memory_file: &std::fs::File, + scratch_file: Option<&std::fs::File>, memory_publication: MemoryPublication, ) -> Result<(), SnapshotWriteError> { let mut staging = stage_snapshot( @@ -164,6 +188,7 @@ fn write_snapshot_with_memory_publication( manifest, saved_state_bytes, memory_file, + scratch_file, memory_publication, )?; let commit = openvmm_defs::profile::ProfileSpan::start(); @@ -189,6 +214,7 @@ fn stage_snapshot( manifest: &SnapshotManifest, saved_state_bytes: &[u8], memory_file: &std::fs::File, + scratch_file: Option<&std::fs::File>, memory_publication: MemoryPublication, ) -> Result { validate_manifest_header(manifest)?; @@ -247,10 +273,40 @@ fn stage_snapshot( profile_path_counters(&memory_path, manifest.memory_size_bytes), ); } + match (microvm::paired_scratch_block(manifest), scratch_file) { + (Some(scratch), Some(scratch_file)) => { + let scratch_path = staging.path().join(SCRATCH_FILE_NAME); + let scratch_publish = openvmm_defs::profile::ProfileSpan::start(); + copy_exact( + scratch_file, + &scratch_path, + scratch.length, + "scratch backing file", + "snapshot scratch", + )?; + verify_file_digest( + &open_file_with_length(&scratch_path, scratch.length, SCRATCH_FILE_NAME)?, + scratch.length, + &scratch.identity, + "scratch.img", + )?; + scratch_publish.complete( + "capture", + "publication_scratch", + profile_path_counters(&scratch_path, scratch.length), + ); + } + (Some(_), None) => anyhow::bail!("snapshot contract requires a paired scratch image"), + (None, Some(_)) => { + anyhow::bail!("snapshot contract does not declare a scratch image") + } + (None, None) => {} + } + let mut published_manifest = manifest.clone(); published_manifest.state_size_bytes = saved_state_bytes.len() as u64; - // Current local snapshots use strict structure and length checks - // without RAM-sized in-band hashing. + // Current local snapshots use strict structure, length, generation, + // and machine-contract checks without RAM-sized in-band hashing. published_manifest.state_sha256.clear(); published_manifest.memory_sha256.clear(); @@ -500,13 +556,106 @@ fn publish_owned_memory_file( #[cfg(test)] mod tests { + use super::super::format::SHA256_SIZE; + use super::super::fs::file_sha256; use super::super::fs::initialize_snapshot_memory_backing_file; + use super::super::microvm::paired_scratch_manifest; + use super::super::restore::open_paired_scratch_file; + use super::super::restore::read_snapshot_manifest; use super::super::tests::test_manifest; use super::*; use std::io::Seek; use std::io::SeekFrom; use std::io::Write; + #[test] + fn paired_scratch_is_published_and_verified() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("memory.bin"); + let scratch_path = dir.path().join("scratch.img"); + let scratch = vec![0x5a_u8; 1024]; + std::fs::write(&memory_path, vec![0_u8; 1024]).unwrap(); + std::fs::write(&scratch_path, &scratch).unwrap(); + let memory_file = std::fs::File::open(memory_path).unwrap(); + let scratch_file = std::fs::File::open(scratch_path).unwrap(); + let manifest = paired_scratch_manifest(&scratch); + + write_snapshot_from_memory_and_scratch_files( + &snap_dir, + &manifest, + b"state", + &memory_file, + Some(&scratch_file), + ) + .unwrap(); + + let read_manifest = read_snapshot_manifest(&snap_dir).unwrap(); + let verified = open_paired_scratch_file(&snap_dir, &read_manifest) + .unwrap() + .unwrap(); + assert_eq!( + file_sha256(&verified, 1024, SCRATCH_FILE_NAME).unwrap(), + manifest.machine_contract.unwrap().microvm_sandbox_blocks[1].identity + ); + drop(verified); + + let published = snap_dir.join(SCRATCH_FILE_NAME); + std::fs::write(&published, vec![0xa5_u8; 1024]).unwrap(); + assert!( + open_paired_scratch_file(&snap_dir, &read_manifest) + .unwrap_err() + .to_string() + .contains("digest mismatch") + ); + + std::fs::write(&published, vec![0_u8; 512]).unwrap(); + assert!( + open_paired_scratch_file(&snap_dir, &read_manifest) + .unwrap_err() + .to_string() + .contains("doesn't match manifest") + ); + + std::fs::remove_file(&published).unwrap(); + let error = match read_snapshot_manifest(&snap_dir) { + Ok(_) => panic!("snapshot without scratch.img unexpectedly validated"), + Err(error) => error, + }; + assert!(error.to_string().contains("incomplete")); + } + + #[test] + fn paired_scratch_digest_mismatch_is_not_published() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("memory.bin"); + let scratch_path = dir.path().join("scratch.img"); + let scratch = vec![0x5a_u8; 1024]; + std::fs::write(&memory_path, vec![0_u8; 1024]).unwrap(); + std::fs::write(&scratch_path, &scratch).unwrap(); + let memory_file = std::fs::File::open(memory_path).unwrap(); + let scratch_file = std::fs::File::open(scratch_path).unwrap(); + let mut manifest = paired_scratch_manifest(&scratch); + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks[1] + .identity = vec![0xff; SHA256_SIZE]; + + let error = write_snapshot_from_memory_and_scratch_files( + &snap_dir, + &manifest, + b"state", + &memory_file, + Some(&scratch_file), + ) + .unwrap_err(); + assert!(error.to_string().contains("digest mismatch")); + assert!(!snap_dir.exists()); + } + #[test] fn write_snapshot_rejects_missing_parent() { let dir = tempfile::tempdir().unwrap(); @@ -552,8 +701,14 @@ mod tests { .open(&memory_path) .unwrap(); - write_snapshot_from_owned_memory_file(&snap_dir, &test_manifest(), b"state", &memory_file) - .unwrap(); + write_snapshot_from_owned_memory_and_scratch_files( + &snap_dir, + &test_manifest(), + b"state", + &memory_file, + None, + ) + .unwrap(); let published = std::fs::File::open(snap_dir.join(MEMORY_FILE_NAME)).unwrap(); verify_hard_link_identity(&memory_file, &published, 1024).unwrap(); @@ -575,8 +730,14 @@ mod tests { std::fs::rename(&memory_path, &moved_path).unwrap(); std::fs::write(&memory_path, vec![0xa5_u8; 1024]).unwrap(); - write_snapshot_from_owned_memory_file(&snap_dir, &test_manifest(), b"state", &memory_file) - .unwrap(); + write_snapshot_from_owned_memory_and_scratch_files( + &snap_dir, + &test_manifest(), + b"state", + &memory_file, + None, + ) + .unwrap(); let published = std::fs::File::open(snap_dir.join(MEMORY_FILE_NAME)).unwrap(); verify_hard_link_identity(&memory_file, &published, 1024).unwrap(); @@ -603,6 +764,7 @@ mod tests { &test_manifest(), b"state", &memory_file, + None, MemoryPublication::OwnedExactFile, ) .unwrap(); diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs index 5d8f168d4f5..6827a658531 100644 --- a/openvmm/openvmm_helpers/src/snapshot/restore.rs +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -2,21 +2,27 @@ // Licensed under the MIT License. //! Restore-side snapshot access: opening one published generation through a -//! retained directory handle, and structural validation against its manifest. +//! retained directory handle, structural validation against its manifest, +//! and paired scratch verification. use super::SnapshotManifest; use super::format::MANIFEST_FILE_NAME; use super::format::MAX_MANIFEST_SIZE_BYTES; use super::format::MAX_SAVED_STATE_SIZE_BYTES; use super::format::MEMORY_FILE_NAME; +use super::format::SCRATCH_FILE_NAME; use super::format::STATE_FILE_NAME; use super::format::validate_manifest_header; use super::format::validate_manifest_version; use super::fs::OpenedFileGeneration; use super::fs::OpenedSnapshotDirectory; use super::fs::allocated_file_bytes; +use super::fs::copy_exact; +use super::fs::open_file_with_length; use super::fs::opened_file_generation; use super::fs::read_bounded_open_file; +use super::fs::verify_file_digest; +use super::microvm; use anyhow::Context; use std::collections::HashSet; use std::path::Path; @@ -44,7 +50,7 @@ impl OpenedSnapshot { let state_file = directory.open_regular_file(STATE_FILE_NAME, "saved state")?; let memory_file = directory.open_regular_file(MEMORY_FILE_NAME, "snapshot memory")?; let manifest = decode_snapshot_manifest(&manifest_file)?; - validate_snapshot_directory(&directory)?; + validate_snapshot_directory(&directory, &manifest)?; anyhow::ensure!( manifest.state_size_bytes <= MAX_SAVED_STATE_SIZE_BYTES, "state.bin length in the manifest exceeds the maximum size of \ @@ -109,6 +115,11 @@ impl OpenedSnapshot { }) } + /// Opens the paired scratch artifact relative to this snapshot generation. + pub fn open_paired_scratch_file(&self) -> anyhow::Result> { + open_paired_scratch_file_in_directory(&self.directory, &self.manifest) + } + /// Duplicates the exact memory handle used to create a private mapping. pub fn duplicate_memory_file_for_mapping( &self, @@ -176,6 +187,13 @@ fn decode_snapshot_manifest(manifest_file: &std::fs::File) -> anyhow::Result anyhow::Result { let directory = OpenedSnapshotDirectory::open(dir)?; let (manifest, _) = read_snapshot_manifest_from_directory(&directory)?; - validate_snapshot_directory(&directory)?; + validate_snapshot_directory(&directory, &manifest)?; Ok(manifest) } @@ -231,7 +249,14 @@ pub fn read_snapshot_artifacts_with_memory( let directory = OpenedSnapshotDirectory::open(dir)?; validate_manifest_header(manifest)?; validate_manifest_version(manifest)?; - validate_snapshot_directory(&directory)?; + if let Some(contract) = &manifest.machine_contract { + microvm::validate_machine_contract_shape( + contract, + manifest.memory_size_bytes, + manifest.vp_count, + )?; + } + validate_snapshot_directory(&directory, manifest)?; anyhow::ensure!( manifest.state_size_bytes <= MAX_SAVED_STATE_SIZE_BYTES, "state.bin length in the manifest exceeds the maximum size of \ @@ -261,21 +286,76 @@ pub fn read_snapshot_artifacts_with_memory( Ok((state_bytes, memory_file)) } -fn validate_snapshot_directory(directory: &OpenedSnapshotDirectory) -> anyhow::Result<()> { +/// Opens and verifies the scratch image paired to a snapshot, if present. +pub fn open_paired_scratch_file( + dir: &Path, + manifest: &SnapshotManifest, +) -> anyhow::Result> { + let directory = OpenedSnapshotDirectory::open(dir)?; + open_paired_scratch_file_in_directory(&directory, manifest) +} + +fn open_paired_scratch_file_in_directory( + directory: &OpenedSnapshotDirectory, + manifest: &SnapshotManifest, +) -> anyhow::Result> { + let Some(scratch) = microvm::paired_scratch_block(manifest) else { + return Ok(None); + }; + let file = + directory.open_file_with_length(SCRATCH_FILE_NAME, scratch.length, SCRATCH_FILE_NAME)?; + verify_file_digest(&file, scratch.length, &scratch.identity, SCRATCH_FILE_NAME)?; + Ok(Some(file)) +} + +/// Copies a verified immutable artifact to a new private path. +pub fn copy_verified_file( + source: &std::fs::File, + destination: &Path, + expected_length: u64, + expected_digest: &[u8], + description: &str, +) -> anyhow::Result<()> { + verify_file_digest(source, expected_length, expected_digest, description)?; + copy_exact( + source, + destination, + expected_length, + description, + "private scratch copy", + )?; + let copy = open_file_with_length(destination, expected_length, "private scratch copy")?; + verify_file_digest( + ©, + expected_length, + expected_digest, + "private scratch copy", + ) +} + +fn validate_snapshot_directory( + directory: &OpenedSnapshotDirectory, + manifest: &SnapshotManifest, +) -> anyhow::Result<()> { + let has_scratch = microvm::paired_scratch_block(manifest).is_some(); let mut entries = HashSet::new(); for name in directory.entry_names()? { anyhow::ensure!( - name == MANIFEST_FILE_NAME || name == STATE_FILE_NAME || name == MEMORY_FILE_NAME, + name == MANIFEST_FILE_NAME + || name == STATE_FILE_NAME + || name == MEMORY_FILE_NAME + || (has_scratch && name == SCRATCH_FILE_NAME), "unexpected artifact in snapshot directory: {}", directory.display_path(&name).display(), ); entries.insert(name); } anyhow::ensure!( - entries.len() == 3 + entries.len() == 3 + usize::from(has_scratch) && entries.contains(std::ffi::OsStr::new(MANIFEST_FILE_NAME)) && entries.contains(std::ffi::OsStr::new(STATE_FILE_NAME)) - && entries.contains(std::ffi::OsStr::new(MEMORY_FILE_NAME)), + && entries.contains(std::ffi::OsStr::new(MEMORY_FILE_NAME)) + && (!has_scratch || entries.contains(std::ffi::OsStr::new(SCRATCH_FILE_NAME))), "snapshot directory is incomplete" ); Ok(()) diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index f09261264cf..06fc969b2e0 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -450,7 +450,7 @@ impl PortIoIntercept for MicrovmSnapshotRequest { IoResult::Ok } - fn io_write(&mut self, io_port: u16, _data: &[u8]) -> IoResult { + fn io_write(&mut self, io_port: u16, data: &[u8]) -> IoResult { use mesh::rpc::RpcSend; if io_port != SNAPSHOT_PORT { @@ -464,11 +464,17 @@ impl PortIoIntercept for MicrovmSnapshotRequest { return IoResult::Ok; } if let Some(notify) = &self.notify { + let scratch_policy = if data.first().copied().unwrap_or(0) == 0 { + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + } else { + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + }; let (deferred_write, token) = defer_write(); let (release_write, release_recv) = mesh::oneshot(); let (write_completed, write_completed_recv) = mesh::oneshot(); let transaction_complete = notify.call( |transaction_complete| chipset_resources::microvm::MicrovmSnapshotBoundaryRequest { + scratch_policy, release_write, write_completed: write_completed_recv, input_gate_timeout: self.input_gate_timeout, @@ -750,6 +756,10 @@ mod tests { IoResult::Ok )); let mut first = recv.try_recv().unwrap(); + assert_eq!( + first.scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + ); assert!(recv.try_recv().is_err()); assert!( deferred_write @@ -781,7 +791,10 @@ mod tests { snapshot.io_write(SNAPSHOT_PORT, &[]), IoResult::Defer(_) )); - recv.try_recv().unwrap(); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); } #[test] @@ -812,7 +825,10 @@ mod tests { snapshot.io_write(SNAPSHOT_PORT, &[0]), IoResult::Defer(_) )); - recv.try_recv().unwrap(); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); } #[test] @@ -841,7 +857,10 @@ mod tests { Pin::new(&mut first.write_completed).poll(&mut cx), Poll::Ready(Ok(())) )); - recv.try_recv().unwrap(); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + ); } #[test] @@ -869,7 +888,10 @@ mod tests { Pin::new(&mut first.write_completed).poll(&mut cx), Poll::Ready(Err(_)) )); - recv.try_recv().unwrap(); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); } #[test] @@ -948,7 +970,10 @@ mod tests { snapshot.io_write(SNAPSHOT_PORT, &[0]), IoResult::Defer(_) )); - recv.try_recv().unwrap(); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); } } @@ -986,7 +1011,10 @@ mod tests { snapshot.io_write(SNAPSHOT_PORT, &[0]), IoResult::Defer(_) )); - recv.try_recv().unwrap(); + assert_eq!( + recv.try_recv().unwrap().scratch_policy, + chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Fresh + ); } } } diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs index 26fa5ced4e9..482182e4b94 100644 --- a/vm/devices/chipset_resources/src/microvm.rs +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -9,9 +9,20 @@ use vm_resource::ResourceId; use vm_resource::kind::ChipsetDeviceHandleKind; use vm_resource::kind::SerialBackendHandle; +/// Scratch handling requested at a microVM snapshot boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq, MeshPayload)] +pub enum MicrovmSnapshotScratchPolicy { + /// Scratch is unmounted and restore must attach a fresh image. + Fresh, + /// Scratch is mounted and must be paired with VM state. + Paired, +} + /// Worker-local request used to establish the exact post-OUT snapshot boundary. #[derive(MeshPayload)] pub struct MicrovmSnapshotBoundaryRequest { + /// Whether capture must pair the current scratch image. + pub scratch_policy: MicrovmSnapshotScratchPolicy, /// Signals the device after all vCPUs have stopped. pub release_write: mesh::OneshotSender<()>, /// Completes after the deferred PMIO write has completed. From 409efdd84c9ed92d3ec302f900e08145be68e180 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 09:55:33 -0700 Subject: [PATCH 125/304] openvmm: add clone and resume tiers with single-use resume claims A sandbox block snapshot does not say how it may be restored. A snapshot taken before an image is mounted can seed any tenant and must not bind the read-only layer images. A snapshot taken at the workload handoff is a reusable clone of one tenant configuration. A checkpoint of one live instance must be resumed at most once, because a second restore would fork its identity and its scratch. Record a capture tier and a restore policy in sandbox block snapshots: - `--snapshot-tier platform|workload-start|instance-checkpoint` is required exactly when a microVM with sandbox blocks is captured, and it requires `--snapshot-destination`. A cold boot appends the host-owned `nvx_snapshot_tier=` token to the kernel command line. Supplying that token as a kernel argument is rejected like the other profile-owned tokens. - Capture checks the guest's scratch policy against the tier: a platform capture takes a fresh scratch, and the other tiers take a paired scratch. A blockless capture must not use a tier. A platform capture records its read-only layers with the `unbound` identity kind and no digest. - Manifest version 5 adds `snapshot_tier` (field 15), `restore_policy` (field 16, `clone` or `resume`), and `consumed_config_sections` (field 17, a mask of the invariant, image-binding, and sandbox sections). Capture writes the new format magic. - A version 5 block snapshot must use a canonical combination: platform with clone policy, a fresh scratch, and only the invariant section; workload-start with clone policy, a paired scratch, and all sections; or instance-checkpoint with resume policy, a paired scratch, and all sections. The saved command line must contain exactly the matching tier token, and only platform manifests may leave layers unbound. Blockless snapshots and versions 2 through 4 carry no tier metadata. - A platform command line may contain only invariant tokens: the fixed console, reboot, and panic settings, `nvx_sandbox=1`, the fixed `nvx_config` window, the tier token, device discovery, network addressing, and the TSC, processor-limit, and LAPIC parameters. Those parameters must match the machine contract, so tenant configuration cannot leak into a fleet-wide snapshot. - Restore of a platform snapshot compares its read-only layers without binding their identities. Later tiers still require the exact images. - Restoring a resume-policy snapshot claims it by creating `resume.claim` in the opened snapshot directory with create-new semantics, then flushes the claim and the directory. The claim is taken after artifact and configuration validation but before worker construction, so a failed startup still consumes the attempt. Opening a claimed resume snapshot is rejected. Document the tiers, `--snapshot-tier`, resume claims, and manifest version 5, and fold the snapshot format restore text into the memory section with a new scratch section. Tests cover the canonical tier combinations, platform command-line validation (tenant tokens, processor limit, TSC and LAPIC frequencies, and the worker's effective command line), the host-owned tier token, the version 4 compatibility rules, the CLI tier requirement, and single-use resume claims. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/dev_guide/snapshot_format.md | 57 +-- Guide/src/reference/openvmm/management/cli.md | 21 +- Guide/src/user_guide/openvmm/snapshots.md | 24 +- openvmm/openvmm_defs/src/microvm.rs | 10 +- openvmm/openvmm_defs/src/worker.rs | 2 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 110 +++++ openvmm/openvmm_entry/src/microvm/config.rs | 13 + openvmm/openvmm_entry/src/microvm/launch.rs | 1 + .../src/snapshot_restore/prepare.rs | 2 + .../src/vm_controller/microvm.rs | 61 ++- openvmm/openvmm_helpers/src/snapshot.rs | 15 +- .../openvmm_helpers/src/snapshot/format.rs | 61 ++- openvmm/openvmm_helpers/src/snapshot/fs.rs | 47 ++ .../openvmm_helpers/src/snapshot/microvm.rs | 429 +++++++++++++++++- .../openvmm_helpers/src/snapshot/restore.rs | 139 +++++- 15 files changed, 943 insertions(+), 49 deletions(-) diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index ab03a3e8428..08274904ce9 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -23,22 +23,18 @@ The manifest is a protobuf message defined as in `openvmm/openvmm_helpers/src/snapshot.rs`, encoded using the `mesh` crate's protobuf encoding. -New snapshots use manifest version 4, which records a format magic, the -saved-state schema version and protobuf root type, and the exact length of -`state.bin`. The legacy `state_sha256` and `memory_sha256` protobuf tags remain -reserved so version 2 manifests can be decoded; versions 3 and 4 require both -fields to be absent. Restore accepts versions 2 through 4; only version 4 may -contain microVM sandbox blocks. - -Reading a snapshot bounds `manifest.bin` to 1 MiB and `state.bin` to 256 MiB, -validates the manifest format before it reads `state.bin`, and requires -`state.bin` and `memory.bin` to have the lengths that the manifest records. +New snapshots use manifest version 5. The legacy `state_sha256` and +`memory_sha256` protobuf tags remain reserved so version 2 manifests can be +decoded; versions 3 through 5 require both fields to be absent. Restore accepts +versions 2 through 4 for compatibility. Tiered microVM snapshots require version +5, which records capture tier, clone/resume policy, and consumed configuration +sections. The default format is a local machine-state contract, not an authenticated container. All versions receive the same regular-file, no-follow/no-reparse, bounded decoding, exact-length, inventory, and machine-contract validation, but the on-disk format does not authenticate same-length payload changes. -Version 4 records the SHA-256 and exact length of `scratch.img`, because guest +Versions 4 and 5 record the SHA-256 and exact length of `scratch.img`, because guest RAM and a mounted writable filesystem must be restored as one exact pair. Export or transport layers must provide broader integrity and authentication outside this format. @@ -72,23 +68,28 @@ fails after the automatic link exists, OpenVMM removes and durably flushes the private staging directory before allowing rollback; uncertain cleanup makes the source terminate instead of resume. -## Restore - -Restore opens the snapshot directory once and resolves its artifacts relative -to that handle. The directory must contain exactly `manifest.bin`, `state.bin`, -and `memory.bin`, plus `scratch.img` when the manifest declares a paired -scratch, each a regular file. Guest RAM is a private copy-on-write -mapping of the opened `memory.bin` handle, so guest writes never reach the -snapshot and it can be restored repeatedly. Windows uses read-only handles with -`FILE_SHARE_READ` only, rejects reparse points, compares `FILE_ID_INFO` and EOF -before and after creating the private COW section, and keeps the directory and -artifact guards in the VM worker until teardown. Linux keeps the exact -`O_NOFOLLOW` directory and regular-file descriptors and rejects observable -metadata changes before handoff, so renaming or replacing the original path -cannot substitute another generation. Linux file descriptors do not provide -mandatory write exclusion; deployments that need authenticated or write-proof -local artifacts must add a stronger mode such as a lease, fs-verity, or a -verified artifact broker. +Restore likewise opens the snapshot directory once and resolves its artifacts +relative to that handle. Windows uses read-only handles with `FILE_SHARE_READ` +only, rejects reparse points, compares `FILE_ID_INFO` and EOF before and after +creating the private COW section, and keeps the directory and artifact guards +in the VM worker until teardown. Linux keeps the exact `O_NOFOLLOW` directory +and regular-file descriptors and rejects observable metadata changes before +handoff, so renaming or replacing the original path cannot substitute another +generation. Linux file descriptors do not provide mandatory write exclusion; +deployments that need authenticated or write-proof local artifacts must add a +stronger mode such as a lease, fs-verity, or a verified artifact broker. + +## Scratch (`scratch.img`) + +The microVM block contract records every fixed role, access mode, geometry, and +immutable read-only layer digest. A paired capture copies the exact opened +writable scratch handle into the same staging directory after device queues +drain, verifies its SHA-256, and publishes it atomically with VM state. Restore +verifies the artifact before worker construction and makes a private copy for +each process, so repeated restores cannot mutate the snapshot. + +A pre-mount capture instead records the `fresh` scratch policy and geometry, +contains no `scratch.img`, and requires a new matching scratch file on restore. ## Code references diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index ce5c997803c..56cbfe752f1 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -63,8 +63,8 @@ describes the source definitions. `microvm` uses one socket and one die, with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. - Guest-requested snapshot capture and new-process restore are available on - Linux/KVM, Linux/MSHV, and Windows/WHP. + Guest-requested snapshot capture and new-process restore are available for + blockless and fixed-block machines on Linux/KVM, Linux/MSHV, and Windows/WHP. * `--net `: With `--machine microvm`, attach one virtio-net NIC at MMIO `0xd0000000`. KVM and MSHV use IRQ 10; WHP uses IRQ 5. Prefixes `/1` through `/30` are accepted. The first usable subnet address becomes @@ -203,6 +203,23 @@ describes the source definitions. openvmm --machine microvm --hypervisor kvm \ --restore-snapshot snapshot ``` +* `--snapshot-tier `: Required for snapshot capture with sandbox blocks. Choose + `platform`, `workload-start`, or `instance-checkpoint`. The first two are + reusable clone policies; instance checkpoints use single-use resume policy. + +A committed snapshot contains `manifest.bin`, `state.bin`, `memory.bin`, and +optionally the manifest-declared `scratch.img`. Restore rejects unknown files, +symlinks, malformed or oversized data, length or scratch-digest mismatches, and +incompatible machine contracts before starting a vCPU. `memory.bin` uses a +private writable copy-on-write mapping and paired scratch is privately copied, +so clone-policy snapshots can be restored repeatedly without modifying +artifacts. Instance-checkpoint snapshots permit one restore attempt. + +Versions 3 through 5 do not embed or validate checksums for `state.bin` or `memory.bin`; +legacy version 2 checksum fields are accepted without re-hashing their +payloads. This format does not detect same-length payload changes, +authenticate, or encrypt a snapshot. Treat all three artifacts as sensitive +guest state and protect the directory with host access controls. * `--memory `: Configure guest RAM. Defaults to `size=1G`. `SPEC` can be a size-only shorthand, such as `--memory 4G`, or a comma-separated key/value list: diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 66757bf4dd0..26f0556c789 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -28,7 +28,11 @@ These are stored as three required files and one optional paired file: Host-driven snapshots require **file-backed guest memory**. Pass `file=` in the `--memory` option when launching a standard VM. A microVM launched with `--snapshot-destination` automatically creates temporary file-backed RAM in -the destination's parent directory when no backing file was supplied. +the destination's parent directory when no backing file was supplied. Capture +with sandbox blocks additionally requires +`--snapshot-tier platform|workload-start|instance-checkpoint`. Platform and +workload-start snapshots are reusable clones; instance checkpoints are +single-use resumes. ```admonish warning Automatically allocated microVM RAM and the snapshot destination are on the @@ -132,8 +136,15 @@ a later resume to start the guest without the event. The peer must accept and read while resume is in progress; on Windows, flush completion waits until the named-pipe peer consumes the complete frame. +For a tiered microVM restore, platform manifests +leave read-only layer identities unbound, while later tiers require exact image +identities. An instance-checkpoint restore attempt atomically creates +`resume.claim`; subsequent restores are rejected. The claim is committed after +artifact and configuration validation but before worker construction, so the +restore attempt remains consumed if later worker startup fails. + ```admonish warning -Versions 3 and 4 do not contain or validate embedded checksums for +Versions 3 through 5 do not contain or validate embedded checksums for `state.bin` or `memory.bin`. Restore still requires regular files, bounded manifest and state decoding, exact artifact lengths, and a compatible machine contract, but same-length payload changes are not detected. Paired @@ -172,7 +183,9 @@ reports its LAPIC clock frequency. The NVX kernel uses this authoritative rate instead of verifying a counting LAPIC against scheduling-sensitive emulated PIT interrupts. TSC-deadline timers are unchanged. The parameter is canonicalized before device discovery and `--`; conflicting, duplicate, malformed, or -out-of-range values are rejected. +out-of-range values are rejected. Platform snapshot validation checks a saved parameter +against its APIC frequency contract, while snapshots without the parameter +remain supported. Every snapshot records a complete state-unit inventory. Each emulated device saves state under a unique name (for example `"pit"`, `"vmbus"`, or `"ide"`), @@ -292,8 +305,9 @@ immediately with a clear error if any active device does not support it. - Snapshots are **not portable** across architectures (e.g., you cannot restore an x86_64 snapshot on aarch64) -- Restores use private copy-on-write RAM, so a snapshot can be restored - repeatedly without copying it or modifying `memory.bin`. +- Restores use private copy-on-write RAM, so a clone-policy snapshot can be + restored repeatedly without copying it or modifying `memory.bin`. + Instance-checkpoint snapshots permit one restore attempt. - VMs using VPCI or PCIe devices do not currently support save/restore - OpenHCL-based VMs do not currently support this snapshot mechanism - VMs using PCAT firmware do not support save/restore diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 760bfbbe632..8df8ea032fe 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -772,7 +772,6 @@ pub fn build_microvm_command_line( "earlycon=", "console=", "virtio_mmio.device=", - "nr_cpus=", "virtnet_ip=", "virtnet_mask=", "virtnet_gw=", @@ -780,6 +779,8 @@ pub fn build_microvm_command_line( "virtfs_dir=", "virtfs_tag=", "virtfs_mode=", + "nvx_snapshot_tier=", + "nr_cpus=", ] .iter() .any(|reserved| token.starts_with(reserved)) @@ -1225,6 +1226,13 @@ mod tests { ); } + #[test] + fn microvm_snapshot_tier_command_line_token_is_host_owned() { + assert!( + build_microvm_command_line(&["nvx_snapshot_tier=platform".to_owned()], false).is_err() + ); + } + #[test] fn microvm_processor_limit_is_host_owned() { let mut cmdline = build_microvm_command_line(&[], false).unwrap(); diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index 7c04ff0c5df..644a1afee56 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -30,7 +30,7 @@ pub const RESTORE_READY_EVENT_V1: &[u8] = b"OPENVMM_RESTORE_READY_V1\n"; /// Exact snapshot-generation handles retained for a restored VM's lifetime. #[derive(MeshPayload)] pub struct SnapshotRestoreGuards { - /// Open snapshot directory used for relative artifact access. + /// Open snapshot directory used for relative artifact access and resume claims. pub directory: std::fs::File, /// Open manifest artifact. pub manifest: std::fs::File, diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index cd3b12bff34..f9ab05b0f65 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -33,6 +33,44 @@ pub enum MicrovmNetworkProfileCli { Portable, } +/// Capture tier for a microVM sandbox snapshot. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum SnapshotTierCli { + /// Fleet-wide clone point before image or sandbox configuration is consumed. + Platform, + /// Tenant-scoped reusable clone point at the workload handoff. + WorkloadStart, + /// Single-use continuation of one stopped instance. + InstanceCheckpoint, +} + +impl SnapshotTierCli { + pub(crate) fn manifest_name(self) -> &'static str { + match self { + Self::Platform => openvmm_helpers::snapshot::format::SNAPSHOT_TIER_PLATFORM, + Self::WorkloadStart => openvmm_helpers::snapshot::format::SNAPSHOT_TIER_WORKLOAD_START, + Self::InstanceCheckpoint => { + openvmm_helpers::snapshot::format::SNAPSHOT_TIER_INSTANCE_CHECKPOINT + } + } + } + + pub(crate) fn restore_policy(self) -> &'static str { + match self { + Self::Platform | Self::WorkloadStart => { + openvmm_helpers::snapshot::format::SNAPSHOT_RESTORE_POLICY_CLONE + } + Self::InstanceCheckpoint => { + openvmm_helpers::snapshot::format::SNAPSHOT_RESTORE_POLICY_RESUME + } + } + } + + pub(crate) fn requires_paired_scratch(self) -> bool { + !matches!(self, Self::Platform) + } +} + impl From for MachineProfile { fn from(value: MachineProfileCli) -> Self { match value { @@ -49,6 +87,15 @@ pub struct MicrovmCli { #[clap(long, value_name = "DIR", conflicts_with = "restore_snapshot")] pub snapshot_destination: Option, + /// Sandbox capture tier. Required for microVM snapshot capture with sandbox blocks. + #[clap( + long, + value_enum, + value_name = "TIER", + requires = "snapshot_destination" + )] + pub snapshot_tier: Option, + /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. #[clap(long, value_name = "MILLISECONDS", default_value_t = 5000)] pub snapshot_quiesce_timeout_ms: u64, @@ -202,6 +249,11 @@ impl Options { self.microvm.snapshot_quiesce_timeout_ms != 0, "microVM snapshot quiesce timeout must be nonzero" ); + anyhow::ensure!( + self.microvm.snapshot_tier.is_some() + != self.microvm.microvm_sandbox_block.is_empty(), + "--snapshot-tier is required exactly for microVM snapshot capture with sandbox blocks" + ); } if self.restore_snapshot.is_some() { anyhow::ensure!( @@ -658,6 +710,64 @@ mod tests { } } + #[test] + fn test_microvm_snapshot_tier_is_explicit() { + for tier in ["platform", "workload-start", "instance-checkpoint"] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + tier, + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + } + + let missing = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + assert!(missing.validate_microvm_options().is_err()); + + let blockless = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--processors", + "2", + "--snapshot-destination", + "snapshot", + ]) + .unwrap(); + blockless.validate_microvm_options().unwrap(); + + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-tier", + "platform", + ]) + .is_err() + ); + } + #[test] fn test_microvm_command_line_is_owned_and_bounded() { assert_eq!( diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 5b9b52f7071..acdbba1fb54 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -553,6 +553,19 @@ impl<'a> MicrovmConfigBuilder<'a> { .as_ref() .zip(network_irq) .map(|(network, irq)| (network, irq, self.gateway_dns)); + if let Some(snapshot_tier) = opt.microvm.snapshot_tier { + anyhow::ensure!( + !cmdline + .split_ascii_whitespace() + .any(|token| token.starts_with("nvx_snapshot_tier=")), + "nvx_snapshot_tier is reserved for the host snapshot policy" + ); + if !cmdline.is_empty() { + cmdline.push(' '); + } + cmdline.push_str("nvx_snapshot_tier="); + cmdline.push_str(snapshot_tier.manifest_name()); + } openvmm_defs::microvm::append_microvm_virtio_discovery( cmdline, network, diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index cccefabbdde..1218822630e 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -253,6 +253,7 @@ impl MicrovmLaunch { snapshot_memory_handle: self.snapshot_memory_handle, snapshot_requests, snapshot_destination: self.snapshot_destination, + snapshot_tier: opt.microvm.snapshot_tier, snapshot_quiesce_timeout: Duration::from_millis( opt.microvm.snapshot_quiesce_timeout_ms, ), diff --git a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs index dc1caace388..04fc22412e6 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs @@ -81,6 +81,8 @@ pub(crate) fn prepare_snapshot_restore_for_config( ); } + snapshot.claim_for_restore()?; + artifact_prepare.complete( "restore", "artifact_prepare", diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 11ffcc501bc..7fb5d933838 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -24,6 +24,8 @@ pub(crate) struct MicrovmController { Option>, /// Directory receiving a guest-requested snapshot. pub(crate) snapshot_destination: Option, + /// Sandbox capture tier of a guest-requested snapshot. + pub(crate) snapshot_tier: Option, /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. pub(crate) snapshot_quiesce_timeout: Duration, /// Hypervisor backend recorded in captured machine contracts. @@ -87,6 +89,29 @@ impl VmController { })), "microVM snapshot requires either no blocks or at least one lower layer and scratch" ); + if self.microvm.resources.sandbox_block_sources.is_empty() { + anyhow::ensure!( + self.microvm.snapshot_tier.is_none(), + "blockless microVM snapshot capture does not use a tier" + ); + } else { + let tier = self + .microvm + .snapshot_tier + .context("microVM sandbox snapshot capture requires a tier")?; + let paired_scratch = scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired; + anyhow::ensure!( + paired_scratch == tier.requires_paired_scratch(), + "snapshot tier '{}' requires {} scratch capture", + tier.manifest_name(), + if tier.requires_paired_scratch() { + "paired" + } else { + "fresh" + } + ); + } anyhow::ensure!( fs_err::symlink_metadata(&destination) .is_err_and(|error| { error.kind() == std::io::ErrorKind::NotFound }), @@ -179,10 +204,18 @@ impl VmController { .zip(self.microvm.resources.filesystem_root_path.as_deref()) .zip(self.microvm.resources.filesystem_attachment.clone()) .map(|((filesystem, root_path), attachment)| (filesystem, root_path, attachment)); - let blocks = crate::storage_builder::microvm::snapshot_block_contract( + let mut blocks = crate::storage_builder::microvm::snapshot_block_contract( &self.microvm.resources.sandbox_block_sources, scratch_policy, )?; + if self.microvm.snapshot_tier + == Some(crate::cli_args::microvm::SnapshotTierCli::Platform) + { + for block in blocks.iter_mut().filter(|block| block.read_only) { + block.identity_kind = "unbound".to_owned(); + block.identity.clear(); + } + } let machine_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( &self.microvm.source_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, @@ -217,6 +250,32 @@ impl VmController { openvmm_helpers::snapshot::format::SAVED_STATE_SCHEMA_VERSION, saved_state_root_type: openvmm_helpers::snapshot::format::SAVED_STATE_ROOT_TYPE .to_owned(), + snapshot_tier: self + .microvm + .snapshot_tier + .map(crate::cli_args::microvm::SnapshotTierCli::manifest_name) + .unwrap_or_default() + .to_owned(), + restore_policy: self + .microvm + .snapshot_tier + .map(crate::cli_args::microvm::SnapshotTierCli::restore_policy) + .unwrap_or_default() + .to_owned(), + consumed_config_sections: match self.microvm.snapshot_tier { + Some(crate::cli_args::microvm::SnapshotTierCli::Platform) => { + openvmm_helpers::snapshot::format::SNAPSHOT_CONFIG_INVARIANTS + } + Some( + crate::cli_args::microvm::SnapshotTierCli::WorkloadStart + | crate::cli_args::microvm::SnapshotTierCli::InstanceCheckpoint, + ) => { + openvmm_helpers::snapshot::format::SNAPSHOT_CONFIG_INVARIANTS + | openvmm_helpers::snapshot::format::SNAPSHOT_CONFIG_IMAGE_BINDING + | openvmm_helpers::snapshot::format::SNAPSHOT_CONFIG_SANDBOX + } + None => 0, + }, }; let saved_state_bytes = mesh::payload::encode(response.saved_state); let memory_file = self diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index b294580f441..33b5d34d511 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -15,7 +15,7 @@ pub mod restore; pub use publish::write_snapshot; /// Current manifest format version. Bump when making incompatible changes. -pub const MANIFEST_VERSION: u32 = 4; +pub const MANIFEST_VERSION: u32 = 5; /// Manifest describing a VM snapshot. #[derive(Clone, Protobuf)] @@ -45,10 +45,10 @@ pub struct SnapshotManifest { /// Length of `state.bin` in bytes. #[mesh(8)] pub state_size_bytes: u64, - /// Legacy v2 SHA-256 digest of `state.bin`; empty in v3 and v4. + /// Legacy v2 SHA-256 digest of `state.bin`; empty in v3 through v5. #[mesh(9)] pub state_sha256: Vec, - /// Legacy v2 SHA-256 digest of `memory.bin`; empty in v3 and v4. + /// Legacy v2 SHA-256 digest of `memory.bin`; empty in v3 through v5. #[mesh(10)] pub memory_sha256: Vec, /// Authoritative machine composition for versioned machine profiles. @@ -63,6 +63,15 @@ pub struct SnapshotManifest { /// Fully qualified protobuf root type stored in `state.bin`. #[mesh(14)] pub saved_state_root_type: String, + /// Sandbox capture tier. Empty for blockless microVM snapshots. + #[mesh(15)] + pub snapshot_tier: String, + /// `clone` for reusable artifacts or `resume` for single-use artifacts. + #[mesh(16)] + pub restore_policy: String, + /// Bitmask of configuration sections consumed before capture. + #[mesh(17)] + pub consumed_config_sections: u32, } /// Validate that a snapshot manifest is compatible with the running VM config. diff --git a/openvmm/openvmm_helpers/src/snapshot/format.rs b/openvmm/openvmm_helpers/src/snapshot/format.rs index 5ca5fdfde4a..0d720936afa 100644 --- a/openvmm/openvmm_helpers/src/snapshot/format.rs +++ b/openvmm/openvmm_helpers/src/snapshot/format.rs @@ -2,7 +2,8 @@ // Licensed under the MIT License. //! Snapshot format identifiers and manifest format validation: format magic -//! and supported versions, saved-state schema, artifact names, and size limits. +//! and supported versions, saved-state schema, capture tiers, restore +//! policies, configuration sections, artifact names, and size limits. use super::MANIFEST_VERSION; use super::SnapshotManifest; @@ -11,7 +12,9 @@ use mesh::payload::Timestamp; use sha2::Digest; /// Magic identifying the OpenVMM snapshot manifest format. -pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V4\0"; +pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V5\0"; +const VERSION_4_MANIFEST_VERSION: u32 = 4; +const VERSION_4_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V4\0"; pub(super) const PREVIOUS_MANIFEST_VERSION: u32 = 3; pub(super) const PREVIOUS_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V3\0"; pub(super) const LEGACY_MANIFEST_VERSION: u32 = 2; @@ -20,10 +23,29 @@ pub(super) const LEGACY_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V2\0"; pub const SAVED_STATE_SCHEMA_VERSION: u32 = 1; /// Protobuf root type stored in `state.bin`. pub const SAVED_STATE_ROOT_TYPE: &str = "openvmm.SavedState"; +/// Fleet-wide snapshot captured before image and sandbox configuration is consumed. +pub const SNAPSHOT_TIER_PLATFORM: &str = "platform"; +/// Tenant-scoped snapshot captured at a warm workload handoff point. +pub const SNAPSHOT_TIER_WORKLOAD_START: &str = "workload-start"; +/// Single-instance checkpoint captured during a live workload. +pub const SNAPSHOT_TIER_INSTANCE_CHECKPOINT: &str = "instance-checkpoint"; +/// Reusable restore policy for snapshots that create independent instances. +pub const SNAPSHOT_RESTORE_POLICY_CLONE: &str = "clone"; +/// Single-use restore policy for snapshots that continue one instance. +pub const SNAPSHOT_RESTORE_POLICY_RESUME: &str = "resume"; +/// The invariant configuration section was consumed before capture. +pub const SNAPSHOT_CONFIG_INVARIANTS: u32 = 1 << 0; +/// The image-binding configuration section was consumed before capture. +pub const SNAPSHOT_CONFIG_IMAGE_BINDING: u32 = 1 << 1; +/// The per-sandbox configuration section was consumed before capture. +pub const SNAPSHOT_CONFIG_SANDBOX: u32 = 1 << 2; +pub(super) const SNAPSHOT_CONFIG_ALL: u32 = + SNAPSHOT_CONFIG_INVARIANTS | SNAPSHOT_CONFIG_IMAGE_BINDING | SNAPSHOT_CONFIG_SANDBOX; pub(super) const MANIFEST_FILE_NAME: &str = "manifest.bin"; pub(super) const STATE_FILE_NAME: &str = "state.bin"; pub(super) const MEMORY_FILE_NAME: &str = "memory.bin"; +pub(super) const RESUME_CLAIM_FILE_NAME: &str = "resume.claim"; /// Fixed snapshot-relative name of a paired scratch image. pub const SCRATCH_FILE_NAME: &str = "scratch.img"; pub(super) const MAX_MANIFEST_SIZE_BYTES: u64 = 1024 * 1024; @@ -56,6 +78,9 @@ impl Default for SnapshotManifest { format_magic: SNAPSHOT_FORMAT_MAGIC.to_vec(), saved_state_schema_version: SAVED_STATE_SCHEMA_VERSION, saved_state_root_type: SAVED_STATE_ROOT_TYPE.to_owned(), + snapshot_tier: String::new(), + restore_policy: String::new(), + consumed_config_sections: 0, } } } @@ -86,6 +111,7 @@ pub(super) fn validate_manifest_header(manifest: &SnapshotManifest) -> anyhow::R let expected_magic = match manifest.version { LEGACY_MANIFEST_VERSION => LEGACY_SNAPSHOT_FORMAT_MAGIC, PREVIOUS_MANIFEST_VERSION => PREVIOUS_SNAPSHOT_FORMAT_MAGIC, + VERSION_4_MANIFEST_VERSION => VERSION_4_SNAPSHOT_FORMAT_MAGIC, MANIFEST_VERSION => SNAPSHOT_FORMAT_MAGIC, version => anyhow::bail!( "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {MANIFEST_VERSION})" @@ -109,6 +135,15 @@ pub(super) fn validate_manifest_header(manifest: &SnapshotManifest) -> anyhow::R } pub(super) fn validate_manifest_version(manifest: &SnapshotManifest) -> anyhow::Result<()> { + if manifest.version < MANIFEST_VERSION { + anyhow::ensure!( + manifest.snapshot_tier.is_empty() + && manifest.restore_policy.is_empty() + && manifest.consumed_config_sections == 0, + "snapshot manifest version {} cannot contain snapshot tier metadata", + manifest.version, + ); + } match manifest.version { LEGACY_MANIFEST_VERSION => { anyhow::ensure!( @@ -126,7 +161,7 @@ pub(super) fn validate_manifest_version(manifest: &SnapshotManifest) -> anyhow:: "snapshot manifest version {LEGACY_MANIFEST_VERSION} cannot contain microVM sandbox blocks" ); } - PREVIOUS_MANIFEST_VERSION | MANIFEST_VERSION => { + PREVIOUS_MANIFEST_VERSION | VERSION_4_MANIFEST_VERSION | MANIFEST_VERSION => { anyhow::ensure!( manifest.state_sha256.is_empty() && manifest.memory_sha256.is_empty(), "snapshot manifest version {} must not contain legacy artifact digests", @@ -138,6 +173,9 @@ pub(super) fn validate_manifest_version(manifest: &SnapshotManifest) -> anyhow:: "snapshot manifest version {PREVIOUS_MANIFEST_VERSION} cannot contain microVM sandbox blocks" ); } + if manifest.version == MANIFEST_VERSION { + microvm::validate_snapshot_tier(manifest)?; + } } version => anyhow::bail!( "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {MANIFEST_VERSION})" @@ -148,10 +186,27 @@ pub(super) fn validate_manifest_version(manifest: &SnapshotManifest) -> anyhow:: #[cfg(test)] mod tests { + use super::super::microvm::paired_scratch_manifest; use super::super::tests::test_manifest; use super::super::validate_manifest; use super::*; + #[test] + fn version_4_manifest_has_no_snapshot_tier_metadata() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + manifest.version = VERSION_4_MANIFEST_VERSION; + manifest.format_magic = VERSION_4_SNAPSHOT_FORMAT_MAGIC.to_vec(); + manifest.snapshot_tier.clear(); + manifest.restore_policy.clear(); + manifest.consumed_config_sections = 0; + validate_manifest_header(&manifest).unwrap(); + validate_manifest_version(&manifest).unwrap(); + + manifest.snapshot_tier = SNAPSHOT_TIER_WORKLOAD_START.to_owned(); + assert!(validate_manifest_version(&manifest).is_err()); + } + #[test] fn current_manifest_rejects_legacy_payload_digests() { let mut manifest = test_manifest(); diff --git a/openvmm/openvmm_helpers/src/snapshot/fs.rs b/openvmm/openvmm_helpers/src/snapshot/fs.rs index 89257d18629..c74acef44ad 100644 --- a/openvmm/openvmm_helpers/src/snapshot/fs.rs +++ b/openvmm/openvmm_helpers/src/snapshot/fs.rs @@ -837,6 +837,53 @@ impl OpenedSnapshotDirectory { } } + pub(super) fn create_new_file(&self, name: &str) -> std::io::Result { + #[cfg(target_os = "linux")] + { + use nix::fcntl::OFlag; + use nix::sys::stat::Mode; + + nix::fcntl::openat( + &self.file, + name, + OFlag::O_WRONLY + | OFlag::O_CREAT + | OFlag::O_EXCL + | OFlag::O_CLOEXEC + | OFlag::O_NOFOLLOW, + Mode::S_IRUSR | Mode::S_IWUSR, + ) + .map(std::fs::File::from) + .map_err(nix_error) + } + #[cfg(windows)] + { + pal::windows::fs::relative::create_relative_new(&self.file, std::ffi::OsStr::new(name)) + } + #[cfg(not(any(target_os = "linux", windows)))] + { + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(self.path.join(name)) + } + } + + pub(super) fn sync(&self) -> anyhow::Result<()> { + #[cfg(unix)] + { + self.file.sync_all().context("failed to flush directory") + } + #[cfg(not(unix))] + { + Ok(()) + } + } + pub(super) fn display_path(&self, name: impl AsRef) -> PathBuf { self.path.join(name) } diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index caa82f2eb6d..325bdbfe070 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -7,6 +7,13 @@ use super::SnapshotManifest; use super::format::SCRATCH_FILE_NAME; #[cfg(test)] use super::format::SHA256_SIZE; +use super::format::SNAPSHOT_CONFIG_ALL; +use super::format::SNAPSHOT_CONFIG_INVARIANTS; +use super::format::SNAPSHOT_RESTORE_POLICY_CLONE; +use super::format::SNAPSHOT_RESTORE_POLICY_RESUME; +use super::format::SNAPSHOT_TIER_INSTANCE_CHECKPOINT; +use super::format::SNAPSHOT_TIER_PLATFORM; +use super::format::SNAPSHOT_TIER_WORKLOAD_START; use super::format::validate_sha256; use super::format::verify_digest; use anyhow::Context; @@ -925,7 +932,18 @@ pub fn validate_microvm_machine_contract( "snapshot filesystem policy doesn't match the requested machine" ); anyhow::ensure!( - contract.microvm_sandbox_blocks == expected.microvm_sandbox_blocks, + { + let mut expected_blocks = expected.microvm_sandbox_blocks.clone(); + if manifest.snapshot_tier == SNAPSHOT_TIER_PLATFORM { + for block in &mut expected_blocks { + if block.read_only { + block.identity_kind = "unbound".to_owned(); + block.identity.clear(); + } + } + } + contract.microvm_sandbox_blocks == expected_blocks + }, "snapshot sandbox block topology or identity doesn't match the requested machine" ); anyhow::ensure!( @@ -1318,6 +1336,157 @@ pub(super) fn validate_machine_contract_shape( Ok(()) } +pub(super) fn validate_snapshot_tier(manifest: &SnapshotManifest) -> anyhow::Result<()> { + let Some(contract) = manifest.machine_contract.as_ref() else { + anyhow::ensure!( + manifest.snapshot_tier.is_empty() + && manifest.restore_policy.is_empty() + && manifest.consumed_config_sections == 0, + "snapshot tier metadata requires a microVM machine contract" + ); + return Ok(()); + }; + if contract.microvm_sandbox_blocks.is_empty() { + anyhow::ensure!( + manifest.snapshot_tier.is_empty() + && manifest.restore_policy.is_empty() + && manifest.consumed_config_sections == 0, + "snapshot tier metadata requires microVM sandbox blocks" + ); + return Ok(()); + } + + let paired_scratch = paired_scratch_block(manifest).is_some(); + let expected_consumed_sections = match manifest.snapshot_tier.as_str() { + SNAPSHOT_TIER_PLATFORM => SNAPSHOT_CONFIG_INVARIANTS, + SNAPSHOT_TIER_WORKLOAD_START | SNAPSHOT_TIER_INSTANCE_CHECKPOINT => SNAPSHOT_CONFIG_ALL, + _ => 0, + }; + let valid = manifest.consumed_config_sections == expected_consumed_sections + && matches!( + ( + manifest.snapshot_tier.as_str(), + manifest.restore_policy.as_str(), + paired_scratch, + ), + (SNAPSHOT_TIER_PLATFORM, SNAPSHOT_RESTORE_POLICY_CLONE, false) + | ( + SNAPSHOT_TIER_WORKLOAD_START, + SNAPSHOT_RESTORE_POLICY_CLONE, + true + ) + | ( + SNAPSHOT_TIER_INSTANCE_CHECKPOINT, + SNAPSHOT_RESTORE_POLICY_RESUME, + true + ) + ); + anyhow::ensure!( + valid, + "snapshot tier '{}', restore policy '{}', and scratch policy are not a canonical microVM combination", + manifest.snapshot_tier, + manifest.restore_policy, + ); + let expected_tier_token = format!("nvx_snapshot_tier={}", manifest.snapshot_tier); + let tier_tokens = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| token.starts_with("nvx_snapshot_tier=")) + .collect::>(); + anyhow::ensure!( + tier_tokens == [expected_tier_token.as_str()], + "snapshot tier '{}' does not match its saved host policy", + manifest.snapshot_tier, + ); + let layers_are_unbound = contract + .microvm_sandbox_blocks + .iter() + .filter(|block| block.read_only) + .all(|block| block.identity_kind == "unbound" && block.identity.is_empty()); + anyhow::ensure!( + layers_are_unbound == (manifest.snapshot_tier == SNAPSHOT_TIER_PLATFORM), + "snapshot layer identity binding does not match tier '{}'", + manifest.snapshot_tier, + ); + if manifest.snapshot_tier == SNAPSHOT_TIER_PLATFORM { + let expected_tsc_frequency = format!("tsc_early_khz={}", contract.tsc_frequency_hz / 1000); + let tsc_frequency_tokens = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| token.starts_with("tsc_early_khz=")) + .collect::>(); + anyhow::ensure!( + tsc_frequency_tokens == [expected_tsc_frequency.as_str()], + "platform snapshot command line TSC frequency does not match its machine contract" + ); + let apic_frequency_tokens = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| token.starts_with("lapic_timer_hz=")) + .collect::>(); + if !apic_frequency_tokens.is_empty() { + let expected = contract + .apic_frequency_hz + .map(|frequency| format!("lapic_timer_hz={frequency}")); + anyhow::ensure!( + expected + .as_deref() + .is_some_and(|expected| apic_frequency_tokens == [expected]), + "platform snapshot command line LAPIC frequency does not match its machine contract" + ); + } + let processor_limit_tokens = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| token.starts_with("nr_cpus=")) + .collect::>(); + anyhow::ensure!( + processor_limit_tokens.len() <= 1, + "platform snapshot command line contains duplicate processor capacity" + ); + if let Some(processor_limit) = processor_limit_tokens.first() { + let expected = format!("nr_cpus={}", contract.topology.apic_ids.len()); + anyhow::ensure!( + **processor_limit == expected, + "platform snapshot command line processor capacity does not match its machine contract" + ); + } + anyhow::ensure!( + contract + .effective_command_line + .split_ascii_whitespace() + .all(platform_command_line_token_is_invariant), + "platform snapshot command line contains tenant or unsupported configuration" + ); + } + Ok(()) +} + +fn platform_command_line_token_is_invariant(token: &str) -> bool { + matches!( + token, + "earlycon=xe9" + | "console=hvc0" + | "console=hvc1" + | "reboot=t" + | "panic=-1" + | "nvx_sandbox=1" + | "nvx_config=0xd0010000,65536" + | "nvx_snapshot_tier=platform" + ) || token.starts_with("tsc_early_khz=") + || token.starts_with("nr_cpus=") + || token.starts_with("lapic_timer_hz=") + || [ + "virtio_mmio.device=", + "virtnet_ip=", + "virtnet_mask=", + "virtnet_gw=", + "virtnet_dns=", + ] + .iter() + .any(|prefix| token.starts_with(prefix)) +} + fn ensure_unique(values: &[T], description: &str) -> anyhow::Result<()> where T: Eq + std::hash::Hash, @@ -1359,8 +1528,11 @@ pub(super) fn paired_scratch_manifest(scratch: &[u8]) -> SnapshotManifest { physical_block_size: 4096, }, ]; - contract.set_effective_command_line("console=hvc0".to_owned()); + contract.set_effective_command_line("console=hvc0 nvx_snapshot_tier=workload-start".to_owned()); manifest.machine_contract = Some(contract); + manifest.snapshot_tier = SNAPSHOT_TIER_WORKLOAD_START.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); + manifest.consumed_config_sections = SNAPSHOT_CONFIG_ALL; manifest } @@ -1447,6 +1619,7 @@ mod tests { use super::super::format::LEGACY_SNAPSHOT_FORMAT_MAGIC; use super::super::format::PREVIOUS_MANIFEST_VERSION; use super::super::format::PREVIOUS_SNAPSHOT_FORMAT_MAGIC; + use super::super::format::validate_manifest_version; use super::super::tests::test_manifest; use super::super::validate_manifest; use super::*; @@ -1949,6 +2122,57 @@ mod tests { validate_microvm_machine_contract(&manifest, &contract).unwrap(); } + #[test] + fn platform_snapshot_checks_apic_frequency_parameter() { + for (parameter, frequency, valid) in [ + ("", Some(1_000_000_000), true), + ("lapic_timer_hz=1000000000", Some(1_000_000_000), true), + ("lapic_timer_hz=200000000", Some(1_000_000_000), false), + ("lapic_timer_hz=1000000000", None, false), + ( + "lapic_timer_hz=1000000000 lapic_timer_hz=1000000000", + Some(1_000_000_000), + false, + ), + ] { + let mut manifest = paired_scratch_manifest(&[0x5a; 512]); + manifest.snapshot_tier = SNAPSHOT_TIER_PLATFORM.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); + manifest.consumed_config_sections = SNAPSHOT_CONFIG_INVARIANTS; + let contract = manifest.machine_contract.as_mut().unwrap(); + for block in contract + .microvm_sandbox_blocks + .iter_mut() + .filter(|block| block.read_only) + { + block.identity_kind = "unbound".to_owned(); + block.identity.clear(); + } + let scratch = contract.microvm_sandbox_blocks.last_mut().unwrap(); + scratch.identity_kind = "fresh".to_owned(); + scratch.identity.clear(); + scratch.artifact.clear(); + contract.apic_frequency_hz = frequency; + contract.set_effective_command_line(format!( + "console=hvc0 nvx_snapshot_tier=platform tsc_early_khz=1000000 {parameter}" + )); + let result = validate_manifest_version(&manifest); + if valid { + result.unwrap(); + } else { + assert!( + result + .unwrap_err() + .to_string() + .contains("LAPIC frequency does not match") + ); + } + } + assert!(platform_command_line_token_is_invariant( + "lapic_timer_hz=200000000" + )); + } + #[test] fn validate_microvm_machine_contract_rejects_unsupported_boot_layout() { let mut manifest = test_manifest(); @@ -2098,6 +2322,204 @@ mod tests { assert!(err.to_string().contains("overlap in GPA space")); } + fn canonical_worker_platform_command_line( + tsc_frequency_hz: u64, + processor_count: u32, + ) -> String { + let mut command_line = openvmm_defs::microvm::build_microvm_command_line( + &[ + "nvx_sandbox=1".to_owned(), + "nvx_config=0xd0010000,65536".to_owned(), + ], + true, + ) + .unwrap(); + openvmm_defs::microvm::append_microvm_processor_limit(&mut command_line, processor_count) + .unwrap(); + command_line.push_str(&format!( + " nvx_snapshot_tier=platform tsc_early_khz={}", + tsc_frequency_hz / 1000 + )); + openvmm_defs::microvm::append_microvm_virtio_discovery( + &mut command_line, + None, + false, + None, + true, + &[ + openvmm_defs::microvm::MicrovmSandboxBlockConfig { + role: openvmm_defs::microvm::MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + openvmm_defs::microvm::MicrovmSandboxBlockConfig { + role: openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ], + ) + .unwrap(); + command_line + } + + fn make_platform_snapshot(manifest: &mut SnapshotManifest) { + manifest.snapshot_tier = SNAPSHOT_TIER_PLATFORM.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); + manifest.consumed_config_sections = SNAPSHOT_CONFIG_INVARIANTS; + let contract = manifest.machine_contract.as_mut().unwrap(); + for block in contract + .microvm_sandbox_blocks + .iter_mut() + .filter(|block| block.read_only) + { + block.identity_kind = "unbound".to_owned(); + block.identity.clear(); + } + let scratch = contract.microvm_sandbox_blocks.last_mut().unwrap(); + scratch.identity_kind = "fresh".to_owned(); + scratch.identity.clear(); + scratch.artifact.clear(); + let processor_count = u32::try_from(contract.topology.apic_ids.len()).unwrap(); + contract.set_effective_command_line(canonical_worker_platform_command_line( + contract.tsc_frequency_hz, + processor_count, + )); + } + + #[test] + fn abi_v2_snapshot_tier_contract_is_canonical() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + validate_manifest_version(&manifest).unwrap(); + + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest.consumed_config_sections = SNAPSHOT_CONFIG_ALL; + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + validate_manifest_version(&manifest).unwrap(); + + manifest.snapshot_tier = SNAPSHOT_TIER_WORKLOAD_START.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); + assert!(validate_manifest_version(&manifest).is_err()); + + manifest.snapshot_tier = SNAPSHOT_TIER_PLATFORM.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); + manifest.consumed_config_sections = SNAPSHOT_CONFIG_INVARIANTS; + assert!(validate_manifest_version(&manifest).is_err()); + + make_platform_snapshot(&mut manifest); + validate_manifest_version(&manifest).unwrap(); + } + + #[test] + fn platform_snapshot_rejects_tenant_command_line() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + let contract = manifest.machine_contract.as_mut().unwrap(); + contract.set_effective_command_line(contract.effective_command_line.replace( + "nvx_snapshot_tier=platform", + "nvx_snapshot_tier=platform nvx_entrypoint=/tenant", + )); + + assert!( + validate_manifest_version(&manifest) + .unwrap_err() + .to_string() + .contains("contains tenant or unsupported configuration") + ); + + make_platform_snapshot(&mut manifest); + validate_manifest_version(&manifest).unwrap(); + + let contract = manifest.machine_contract.as_mut().unwrap(); + contract.set_effective_command_line( + contract + .effective_command_line + .replace("nvx_config=0xd0010000,65536", "nvx_config=tenant-data"), + ); + assert!( + validate_manifest_version(&manifest) + .unwrap_err() + .to_string() + .contains("contains tenant or unsupported configuration") + ); + } + + #[test] + fn platform_snapshot_rejects_mismatched_processor_limit() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + let contract = manifest.machine_contract.as_mut().unwrap(); + contract.set_effective_command_line( + contract + .effective_command_line + .replace("nr_cpus=2", "nr_cpus=1"), + ); + + assert!( + validate_manifest_version(&manifest) + .unwrap_err() + .to_string() + .contains("processor capacity") + ); + } + + #[test] + fn platform_snapshot_rejects_invalid_tsc_frequency_tokens() { + let scratch = vec![0x5a; 512]; + for invalid in [ + "", + "tsc_early_khz=999999", + "tsc_early_khz=1000000 tsc_early_khz=1000000", + ] { + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + let contract = manifest.machine_contract.as_mut().unwrap(); + contract.set_effective_command_line( + contract + .effective_command_line + .replace("tsc_early_khz=1000000", invalid), + ); + + assert!( + validate_manifest_version(&manifest) + .unwrap_err() + .to_string() + .contains("TSC frequency does not match") + ); + } + } + + #[test] + fn platform_snapshot_accepts_worker_effective_canonical_command_line() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + + assert_eq!( + manifest + .machine_contract + .as_ref() + .unwrap() + .effective_command_line, + "earlycon=xe9 console=hvc1 reboot=t panic=-1 \ + nvx_sandbox=1 nvx_config=0xd0010000,65536 nr_cpus=2 \ + nvx_snapshot_tier=platform \ + tsc_early_khz=1000000 \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0003000:4 \ + virtio_mmio.device=0x1000@0xd0006000:11" + ); + validate_manifest_version(&manifest).unwrap(); + } + #[test] fn legacy_formats_reject_abi_v2_blocks() { let scratch = vec![0x5a_u8; 1024]; @@ -2108,6 +2530,9 @@ mod tests { let mut manifest = paired_scratch_manifest(&scratch); manifest.version = version; manifest.format_magic = magic.to_vec(); + manifest.snapshot_tier.clear(); + manifest.restore_policy.clear(); + manifest.consumed_config_sections = 0; if version == LEGACY_MANIFEST_VERSION { manifest.state_sha256 = vec![0; SHA256_SIZE]; manifest.memory_sha256 = vec![0; SHA256_SIZE]; diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs index 6827a658531..3329f3e40af 100644 --- a/openvmm/openvmm_helpers/src/snapshot/restore.rs +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -3,14 +3,16 @@ //! Restore-side snapshot access: opening one published generation through a //! retained directory handle, structural validation against its manifest, -//! and paired scratch verification. +//! paired scratch verification, and single-use resume claims. use super::SnapshotManifest; use super::format::MANIFEST_FILE_NAME; use super::format::MAX_MANIFEST_SIZE_BYTES; use super::format::MAX_SAVED_STATE_SIZE_BYTES; use super::format::MEMORY_FILE_NAME; +use super::format::RESUME_CLAIM_FILE_NAME; use super::format::SCRATCH_FILE_NAME; +use super::format::SNAPSHOT_RESTORE_POLICY_RESUME; use super::format::STATE_FILE_NAME; use super::format::validate_manifest_header; use super::format::validate_manifest_version; @@ -25,6 +27,7 @@ use super::fs::verify_file_digest; use super::microvm; use anyhow::Context; use std::collections::HashSet; +use std::io::Write; use std::path::Path; /// One structurally validated snapshot generation opened for restore. @@ -120,6 +123,11 @@ impl OpenedSnapshot { open_paired_scratch_file_in_directory(&self.directory, &self.manifest) } + /// Claims this exact opened generation for a single-use resume. + pub fn claim_for_restore(&self) -> anyhow::Result<()> { + claim_snapshot_for_restore_in_directory(&self.directory, &self.manifest) + } + /// Duplicates the exact memory handle used to create a private mapping. pub fn duplicate_memory_file_for_mapping( &self, @@ -333,6 +341,52 @@ pub fn copy_verified_file( ) } +/// Atomically consumes a single-use resume snapshot. +/// +/// Call this after artifact and configuration validation and before constructing +/// execution-owned workers. A later startup failure does not roll back the claim. +/// Clone snapshots are unchanged. +pub fn claim_snapshot_for_restore(dir: &Path, manifest: &SnapshotManifest) -> anyhow::Result<()> { + let directory = OpenedSnapshotDirectory::open(dir)?; + claim_snapshot_for_restore_in_directory(&directory, manifest) +} + +fn claim_snapshot_for_restore_in_directory( + directory: &OpenedSnapshotDirectory, + manifest: &SnapshotManifest, +) -> anyhow::Result<()> { + validate_manifest_header(manifest)?; + validate_manifest_version(manifest)?; + if manifest.restore_policy != SNAPSHOT_RESTORE_POLICY_RESUME { + return Ok(()); + } + + let mut claim = match directory.create_new_file(RESUME_CLAIM_FILE_NAME) { + Ok(claim) => claim, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + anyhow::bail!("resume snapshot has already been claimed") + } + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to claim resume snapshot at {}", + directory.display_path(RESUME_CLAIM_FILE_NAME).display() + ) + }); + } + }; + claim + .write_all(b"OPENVMM_RESUME_CLAIM_V1\n") + .context("failed to write resume snapshot claim")?; + claim + .sync_all() + .context("failed to flush resume snapshot claim")?; + directory + .sync() + .context("failed to commit resume snapshot claim")?; + Ok(()) +} + fn validate_snapshot_directory( directory: &OpenedSnapshotDirectory, manifest: &SnapshotManifest, @@ -340,6 +394,11 @@ fn validate_snapshot_directory( let has_scratch = microvm::paired_scratch_block(manifest).is_some(); let mut entries = HashSet::new(); for name in directory.entry_names()? { + if name == RESUME_CLAIM_FILE_NAME + && manifest.restore_policy == SNAPSHOT_RESTORE_POLICY_RESUME + { + anyhow::bail!("resume snapshot has already been claimed"); + } anyhow::ensure!( name == MANIFEST_FILE_NAME || name == STATE_FILE_NAME @@ -366,12 +425,45 @@ mod tests { use super::super::format::LEGACY_MANIFEST_VERSION; use super::super::format::LEGACY_SNAPSHOT_FORMAT_MAGIC; use super::super::format::SHA256_SIZE; + use super::super::format::SNAPSHOT_TIER_INSTANCE_CHECKPOINT; + use super::super::microvm::paired_scratch_manifest; use super::super::publish::write_snapshot; + #[cfg(target_os = "linux")] + use super::super::publish::write_snapshot_from_memory_and_scratch_files; use super::super::tests::test_manifest; use super::*; use std::io::Read; - #[cfg(target_os = "linux")] - use std::io::Write; + + #[test] + fn resume_snapshot_claim_is_single_use() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + let dir = tempfile::tempdir().unwrap(); + + claim_snapshot_for_restore(dir.path(), &manifest).unwrap(); + let error = claim_snapshot_for_restore(dir.path(), &manifest).unwrap_err(); + assert!(error.to_string().contains("already been claimed")); + } + + #[test] + fn clone_snapshot_claim_is_a_noop() { + let scratch = vec![0x5a; 512]; + let manifest = paired_scratch_manifest(&scratch); + let dir = tempfile::tempdir().unwrap(); + + claim_snapshot_for_restore(dir.path(), &manifest).unwrap(); + claim_snapshot_for_restore(dir.path(), &manifest).unwrap(); + assert!(!dir.path().join(RESUME_CLAIM_FILE_NAME).exists()); + } #[test] fn read_snapshot_accepts_same_length_state_change_without_legacy_checksum_validation() { @@ -632,6 +724,47 @@ mod tests { assert_eq!(bytes, vec![0x5a_u8; 1024]); } + #[cfg(target_os = "linux")] + #[test] + fn resume_claim_targets_opened_directory_generation() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let moved_dir = dir.path().join("opened-snapshot"); + let memory_source = dir.path().join("memory-source.bin"); + let scratch_source = dir.path().join("scratch-source.bin"); + let scratch = vec![0x5a_u8; 512]; + std::fs::write(&memory_source, vec![0_u8; 1024]).unwrap(); + std::fs::write(&scratch_source, &scratch).unwrap(); + let memory_file = std::fs::File::open(&memory_source).unwrap(); + let scratch_file = std::fs::File::open(&scratch_source).unwrap(); + let mut manifest = paired_scratch_manifest(&scratch); + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + write_snapshot_from_memory_and_scratch_files( + &snap_dir, + &manifest, + b"state", + &memory_file, + Some(&scratch_file), + ) + .unwrap(); + + let snapshot = OpenedSnapshot::open(&snap_dir).unwrap(); + std::fs::rename(&snap_dir, &moved_dir).unwrap(); + std::fs::create_dir(&snap_dir).unwrap(); + snapshot.claim_for_restore().unwrap(); + + assert!(moved_dir.join(RESUME_CLAIM_FILE_NAME).exists()); + assert!(!snap_dir.join(RESUME_CLAIM_FILE_NAME).exists()); + } + #[cfg(target_os = "linux")] #[test] fn opened_snapshot_detects_same_length_memory_write_before_mapping() { From 34ca126b577ccb17b5b2d5340f5beec946f4b47d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 10:13:21 -0700 Subject: [PATCH 126/304] chipset: expose a stable generation ID and restore entropy on portb A restored microVM continues with the RNG state and the identity of the guest that was captured. Every clone of one snapshot would then produce the same random numbers and present the same identity, which is unsafe for cryptographic workloads. The guest needs fresh entropy on restore and a way to tell that it is running in a new VM process. Add two private input channels to the microVM portb device: - Every portb device carries a 16-byte generation ID. Status bit 5 advertises it. Writing 0xa6 to the status port selects it, and the next 16 data-port reads return it. The ID can be selected repeatedly, is stable for the VM process, and is never saved: a restored process presents its own ID. - A restore packet is exposed only through selector 0xa5. Status bit 1 reports an unread packet. While the packet is selected, data-port reads consume it instead of console input, and the selection ends when the packet is empty. The packet and the selection are not saved. `MicrovmPortbHandle` gains the generation ID and the packet. The entry creates a random generation ID for each VM process. With `--restore-entropy`, which requires `--restore-snapshot`, it instead draws 64 bytes of fresh entropy, exposes them as an `OPENVMM_ENTROPY_V1\0` packet, and uses their first 16 bytes as the generation ID, so the guest's repair path can refresh its clone identity without another port transfer. A microVM restore without `--restore-entropy` warns that cloned RNG state is unsafe for cryptographic workloads. Add the `openvmm-reseed` test fixture, a static guest program that credits a 64-byte entropy payload to the kernel and reseeds its CRNG, with instructions to regenerate its base64 encoding. Document `--restore-entropy` and the generation ID. Tests cover the generation ID selector, restore-packet selection and its privacy across save and restore, the status bits, and the V1 packet layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 12 +- Guide/src/user_guide/openvmm/snapshots.md | 8 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 4 + openvmm/openvmm_entry/src/microvm/config.rs | 14 +- openvmm/openvmm_entry/src/microvm/restore.rs | 42 +++++ petri/src/vm/openvmm/microvm.rs | 7 +- vm/devices/chipset/src/microvm.rs | 164 ++++++++++++++++-- vm/devices/chipset/src/microvm/resolver.rs | 7 +- vm/devices/chipset_resources/src/microvm.rs | 4 + .../tests/tests/x86_64/data/openvmm-reseed.S | 61 +++++++ .../tests/x86_64/data/openvmm-reseed.b64 | 1 + 11 files changed, 308 insertions(+), 16 deletions(-) create mode 100644 vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.S create mode 100644 vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.b64 diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 56cbfe752f1..ab006038b29 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -201,8 +201,18 @@ describes the source definitions. ```bash openvmm --machine microvm --hypervisor kvm \ - --restore-snapshot snapshot + --restore-snapshot snapshot --restore-entropy ``` +* `--restore-entropy`: Make a fresh `OPENVMM_ENTROPY_V1` packet available on + the private portb restore channel. The guest must consume the packet and + explicitly reseed its RNG. Restoring cloned RNG state without this option is + unsafe for cryptographic workloads and emits a warning. + Every microVM portb device also reports generation-ID support in status bit + 5. Writing `0xa6` to the status port and reading 16 bytes from the data port + returns an opaque ID that is stable for that VM process and may be selected + repeatedly. OpenVMM creates it before vCPU entry and does not serialize it. + On restore, it is the first 16 bytes of the fresh entropy packet, allowing + the guest repair path to update clone identity without additional port I/O. * `--snapshot-tier `: Required for snapshot capture with sandbox blocks. Choose `platform`, `workload-start`, or `instance-checkpoint`. The first two are reusable clone policies; instance checkpoints use single-use resume policy. diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 26f0556c789..8036f60b499 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -143,6 +143,14 @@ identities. An instance-checkpoint restore attempt atomically creates artifact and configuration validation but before worker construction, so the restore attempt remains consumed if later worker startup fails. +The no-ACPI microVM portb contract exposes a process-local 16-byte generation +ID. Status bit 5 advertises the feature; writing `0xa6` to status port `0xea` +and reading 16 bytes from data port `0xe9` returns the ID. It is repeatable +within one process and is never restored from snapshot state. A restore derives +the ID from the first 16 bytes of the fresh entropy packet, so the guest can +reject an unchanged clone identity, reseed Linux, and refresh runtime +identifiers without another PMIO transfer. + ```admonish warning Versions 3 through 5 do not contain or validate embedded checksums for `state.bin` or `memory.bin`. Restore still requires regular files, bounded diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index f9ab05b0f65..3b2af8c4c4b 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -83,6 +83,10 @@ impl From for MachineProfile { /// Options of the microVM machine profile. #[derive(clap::Args)] pub struct MicrovmCli { + /// Expose a fresh OPENVMM_ENTROPY_V1 packet through the private portb restore channel. + #[clap(long, requires = "restore_snapshot")] + pub restore_entropy: bool, + /// Capture a microVM snapshot to this directory when the guest writes PMIO 0x605. #[clap(long, value_name = "DIR", conflicts_with = "restore_snapshot")] pub snapshot_destination: Option, diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index acdbba1fb54..f2c56ef548c 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -16,6 +16,8 @@ use super::filesystem::microvm_filesystem_slot_from_snapshot; use super::network::EffectiveMicrovmNetwork; use super::network::effective_microvm_network; use super::network::microvm_network_endpoint; +use super::restore::fresh_microvm_generation_id; +use super::restore::fresh_microvm_restore_packet; use crate::ConsoleState; use crate::Options; use crate::VmResources; @@ -312,9 +314,19 @@ impl<'a> MicrovmConfigBuilder<'a> { return Ok(()); }; let opt = self.opt; + let (generation_id, restore_entropy) = if opt.microvm.restore_entropy { + fresh_microvm_restore_packet()? + } else { + (fresh_microvm_generation_id()?, Vec::new()) + }; chipset_devices.push(ChipsetDeviceHandle { name: MicrovmPortbHandle::ID.to_owned(), - resource: MicrovmPortbHandle { io }.into_resource(), + resource: MicrovmPortbHandle { + io, + generation_id, + restore_entropy, + } + .into_resource(), }); chipset_devices.push(ChipsetDeviceHandle { name: MicrovmShutdownHandle::ID.to_owned(), diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 569fd8afcff..6fb64923cde 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -36,6 +36,34 @@ fn calculate_snapshot_downtime( Ok(downtime) } +fn microvm_restore_packet(entropy: &[u8; 64]) -> Vec { + let mut packet = b"OPENVMM_ENTROPY_V1\0".to_vec(); + packet.extend(entropy); + packet +} + +fn microvm_generation_id(entropy: &[u8; 64]) -> [u8; 16] { + let mut generation_id = [0; 16]; + generation_id.copy_from_slice(&entropy[..16]); + generation_id +} + +pub(crate) fn fresh_microvm_generation_id() -> anyhow::Result<[u8; 16]> { + let mut generation_id = [0; 16]; + getrandom::fill(&mut generation_id).context("failed to generate microVM generation ID")?; + Ok(generation_id) +} + +pub(crate) fn fresh_microvm_restore_packet() -> anyhow::Result<([u8; 16], Vec)> { + let generation_id_create = openvmm_defs::profile::ProfileSpan::start(); + let mut entropy = [0_u8; 64]; + getrandom::fill(&mut entropy).context("failed to generate restore entropy")?; + let generation_id = microvm_generation_id(&entropy); + let packet = microvm_restore_packet(&entropy); + generation_id_create.complete("restore", "generation_id_create", Default::default()); + Ok((generation_id, packet)) +} + /// Restore-time inputs that shape a microVM configuration. #[derive(Default)] pub(crate) struct MicrovmRestore { @@ -164,6 +192,11 @@ pub(crate) fn prepare_restore( } else { None }; + if restore_machine_contract.is_some() && !opt.microvm.restore_entropy { + tracing::warn!( + "restoring cloned guest RNG state without fresh entropy injection; cryptographic workloads are unsafe" + ); + } Ok(MicrovmRestore { machine_contract: restore_machine_contract, private_scratch_dir, @@ -287,4 +320,13 @@ mod tests { .unwrap_err(); assert!(excessive.to_string().contains("exceeds the supported")); } + + #[test] + fn restore_packet_preserves_entropy() { + let entropy = [0x5a; 64]; + assert_eq!(microvm_generation_id(&entropy), [0x5a; 16]); + let v1 = microvm_restore_packet(&entropy); + assert_eq!(&v1[..19], b"OPENVMM_ENTROPY_V1\0"); + assert_eq!(&v1[19..], &entropy); + } } diff --git a/petri/src/vm/openvmm/microvm.rs b/petri/src/vm/openvmm/microvm.rs index a9dba7e3362..f88da32ea85 100644 --- a/petri/src/vm/openvmm/microvm.rs +++ b/petri/src/vm/openvmm/microvm.rs @@ -146,7 +146,12 @@ pub(super) fn attach_chipset_devices( chipset_devices.extend([ ChipsetDeviceHandle { name: MicrovmPortbHandle::ID.to_owned(), - resource: MicrovmPortbHandle { io }.into_resource(), + resource: MicrovmPortbHandle { + io, + generation_id: [0x5a; 16], + restore_entropy: Vec::new(), + } + .into_resource(), }, ChipsetDeviceHandle { name: MicrovmShutdownHandle::ID.to_owned(), diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index 06fc969b2e0..a25f30deb69 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -37,7 +37,12 @@ const DATA_PORT: u16 = 0xe9; const STATUS_PORT: u16 = 0xea; const SHUTDOWN_PORT: u16 = 0x604; const SNAPSHOT_PORT: u16 = 0x605; +const RESTORE_ENTROPY_SELECT: u8 = 0xa5; +const GENERATION_ID_SELECT: u8 = 0xa6; +const GENERATION_ID_SIZE: usize = 16; const STATUS_INPUT_AVAILABLE: u8 = 1 << 0; +const STATUS_RESTORE_PACKET_AVAILABLE: u8 = 1 << 1; +const STATUS_GENERATION_ID_AVAILABLE: u8 = 1 << 5; const BUFFER_MAX: usize = 1024 * 1024; /// Raw bidirectional microVM portb console. @@ -51,6 +56,11 @@ pub struct MicrovmPortb { rx_buffer: VecDeque, #[inspect(with = "VecDeque::len")] tx_buffer: VecDeque, + generation_id: [u8; GENERATION_ID_SIZE], + generation_id_read_index: Option, + #[inspect(with = "VecDeque::len")] + restore_entropy: VecDeque, + restore_entropy_selected: bool, #[inspect(skip)] rx_waker: Option, #[inspect(skip)] @@ -59,12 +69,20 @@ pub struct MicrovmPortb { impl MicrovmPortb { /// Creates a portb console using `io` as its host endpoint. - pub fn new(io: Box) -> Self { + pub fn new( + io: Box, + generation_id: [u8; GENERATION_ID_SIZE], + restore_entropy: Vec, + ) -> Self { Self { io_region: ("microvm-portb", DATA_PORT..=STATUS_PORT), io, rx_buffer: VecDeque::new(), tx_buffer: VecDeque::new(), + generation_id, + generation_id_read_index: None, + restore_entropy: restore_entropy.into(), + restore_entropy_selected: false, rx_waker: None, tx_waker: None, } @@ -148,6 +166,9 @@ impl ChangeDeviceState for MicrovmPortb { async fn reset(&mut self) { self.rx_buffer.clear(); self.tx_buffer.clear(); + self.restore_entropy.clear(); + self.generation_id_read_index = None; + self.restore_entropy_selected = false; } } @@ -189,15 +210,33 @@ impl PortIoIntercept for MicrovmPortb { data.fill(0); match io_port { DATA_PORT => { - data[0] = self.rx_buffer.pop_front().unwrap_or(0); - self.wake_rx(); + if let Some(index) = self.generation_id_read_index { + data[0] = self.generation_id[index]; + self.generation_id_read_index = + (index + 1 < self.generation_id.len()).then_some(index + 1); + } else if self.restore_entropy_selected { + data[0] = self.restore_entropy.pop_front().unwrap_or(0); + if self.restore_entropy.is_empty() { + self.restore_entropy_selected = false; + } + } else { + data[0] = self.rx_buffer.pop_front().unwrap_or(0); + self.wake_rx(); + } } STATUS_PORT => { - data[0] = if !self.rx_buffer.is_empty() { + data[0] = if self.generation_id_read_index.is_none() + && !self.restore_entropy_selected + && !self.rx_buffer.is_empty() + { STATUS_INPUT_AVAILABLE } else { 0 }; + if !self.restore_entropy.is_empty() { + data[0] |= STATUS_RESTORE_PACKET_AVAILABLE; + } + data[0] |= STATUS_GENERATION_ID_AVAILABLE; } _ => return IoResult::Err(IoError::InvalidRegister), } @@ -217,7 +256,17 @@ impl PortIoIntercept for MicrovmPortb { } self.wake_tx(); } - STATUS_PORT => {} + STATUS_PORT => match data.first() { + Some(&RESTORE_ENTROPY_SELECT) if !self.restore_entropy.is_empty() => { + self.generation_id_read_index = None; + self.restore_entropy_selected = true; + } + Some(&GENERATION_ID_SELECT) => { + self.restore_entropy_selected = false; + self.generation_id_read_index = Some(0); + } + _ => {} + }, _ => return IoResult::Err(IoError::InvalidRegister), } IoResult::Ok @@ -250,6 +299,8 @@ impl SaveRestore for MicrovmPortb { } self.rx_buffer = state.rx_buffer.into(); self.tx_buffer = state.tx_buffer.into(); + self.generation_id_read_index = None; + self.restore_entropy_selected = false; Ok(()) } } @@ -529,6 +580,8 @@ mod tests { use std::task::Poll; use test_with_tracing::test; + const TEST_GENERATION_ID: [u8; GENERATION_ID_SIZE] = [0x3c; GENERATION_ID_SIZE]; + #[derive(Default)] struct OutputState { bytes: Vec, @@ -600,7 +653,11 @@ mod tests { error: Some(ErrorKind::Other), ..Default::default() })); - let mut portb = MicrovmPortb::new(Box::new(Connected::new(BufferedOutput(state.clone())))); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + Vec::new(), + ); assert!(matches!(portb.io_write(DATA_PORT, b"lost"), IoResult::Ok)); portb.poll_device(&mut cx); state.lock().error = None; @@ -674,7 +731,7 @@ mod tests { #[test] fn portb_preserves_wide_binary_output_and_zero_fills_reads() { - let mut portb = MicrovmPortb::new(Box::new(Disconnected)); + let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()); assert!(matches!( portb.io_write(DATA_PORT, b"\0\xffA\x80"), IoResult::Ok @@ -687,19 +744,102 @@ mod tests { portb.io_read(STATUS_PORT, &mut status), IoResult::Ok )); - assert_eq!(status, [STATUS_INPUT_AVAILABLE, 0, 0, 0]); + assert_eq!( + status, + [ + STATUS_INPUT_AVAILABLE | STATUS_GENERATION_ID_AVAILABLE, + 0, + 0, + 0 + ] + ); let mut data = [0xff; 4]; assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); assert_eq!(data, [0x5a, 0, 0, 0]); } + #[test] + fn pending_portb_bytes_survive_restore_and_fresh_entropy_is_private() { + let mut portb = MicrovmPortb::new(Box::new(Disconnected), [1; 16], Vec::new()); + portb.rx_buffer.extend([1, 2, 3]); + portb.tx_buffer.extend([4, 5, 6]); + let state = portb.save().unwrap(); + + let mut restored = MicrovmPortb::new(Box::new(Disconnected), [2; 16], vec![7, 8]); + restored.restore(state).unwrap(); + assert_eq!(restored.rx_buffer, [1, 2, 3]); + assert_eq!(restored.tx_buffer, [4, 5, 6]); + assert_eq!(restored.generation_id, [2; 16]); + + let mut data = [0]; + for expected in [1, 2, 3] { + assert!(matches!( + restored.io_read(DATA_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [expected]); + } + assert!(matches!( + restored.io_read(DATA_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [0]); + assert_eq!(restored.restore_entropy, [7, 8]); + + assert!(matches!( + restored.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), + IoResult::Ok + )); + assert!(matches!( + restored.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!( + data, + [STATUS_RESTORE_PACKET_AVAILABLE | STATUS_GENERATION_ID_AVAILABLE] + ); + for expected in [7, 8] { + assert!(matches!( + restored.io_read(DATA_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [expected]); + } + } + + #[test] + fn generation_id_is_repeatable_and_not_consumed() { + let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()); + let mut data = [0]; + + for _ in 0..2 { + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [STATUS_GENERATION_ID_AVAILABLE]); + assert!(matches!( + portb.io_write(STATUS_PORT, &[GENERATION_ID_SELECT]), + IoResult::Ok + )); + for expected in TEST_GENERATION_ID { + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [expected]); + } + } + } + #[test] fn accepted_connection_is_polled_immediately() { - let mut portb = MicrovmPortb::new(Box::new(ConnectWithByte { - connected: false, - byte: Some(0x5a), - })); + let mut portb = MicrovmPortb::new( + Box::new(ConnectWithByte { + connected: false, + byte: Some(0x5a), + }), + TEST_GENERATION_ID, + Vec::new(), + ); portb.poll_device(&mut Context::from_waker(Waker::noop())); assert_eq!(portb.rx_buffer, [0x5a]); } diff --git a/vm/devices/chipset/src/microvm/resolver.rs b/vm/devices/chipset/src/microvm/resolver.rs index 3c0642de1a6..8eea35676ae 100644 --- a/vm/devices/chipset/src/microvm/resolver.rs +++ b/vm/devices/chipset/src/microvm/resolver.rs @@ -60,7 +60,12 @@ impl AsyncResolveResource for Micro ) .await .map_err(ResolveMicrovmPortbError::ResolveBackend)?; - Ok(MicrovmPortb::new(io.0.into_io()).into()) + Ok(MicrovmPortb::new( + io.0.into_io(), + resource.generation_id, + resource.restore_entropy, + ) + .into()) } } diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs index 482182e4b94..31c114fd326 100644 --- a/vm/devices/chipset_resources/src/microvm.rs +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -38,6 +38,10 @@ pub struct MicrovmSnapshotBoundaryRequest { pub struct MicrovmPortbHandle { /// Host serial endpoint used for raw input and output. pub io: Resource, + /// Fresh generation ID for this microVM instance. + pub generation_id: [u8; 16], + /// Fresh entropy exposed only through the private restore-input selector. + pub restore_entropy: Vec, } impl ResourceId for MicrovmPortbHandle { diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.S b/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.S new file mode 100644 index 00000000000..b4ad3e1ea59 --- /dev/null +++ b/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.S @@ -0,0 +1,61 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# Credits a 64-byte /tmp/entropy payload, then immediately reseeds the CRNG. +# Regenerate the adjacent fixture with: +# as --64 -o openvmm-reseed.o openvmm-reseed.S +# ld -N -s -o openvmm-reseed openvmm-reseed.o +# base64 -w0 openvmm-reseed > openvmm-reseed.b64 +.global _start +.section .text +_start: + mov $2, %rax + lea entropy_path(%rip), %rdi + xor %rsi, %rsi + xor %rdx, %rdx + syscall + test %rax, %rax + js fail + mov %rax, %rdi + xor %rax, %rax + lea entropy_buf+8(%rip), %rsi + mov $64, %rdx + syscall + cmp $64, %rax + jne fail + mov $2, %rax + lea random_path(%rip), %rdi + mov $1, %rsi + xor %rdx, %rdx + syscall + test %rax, %rax + js fail + mov %rax, %rdi + mov $16, %rax + mov $0x40085203, %rsi + lea entropy_buf(%rip), %rdx + syscall + test %rax, %rax + js fail + mov $16, %rax + mov $0x5207, %rsi + xor %rdx, %rdx + syscall + test %rax, %rax + js fail + xor %rdi, %rdi + jmp exit +fail: + mov $1, %rdi +exit: + mov $60, %rax + syscall +.section .rodata +entropy_path: .asciz "/tmp/entropy" +random_path: .asciz "/dev/random" +.section .data +.align 8 +entropy_buf: + .long 512 + .long 64 + .zero 64 diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.b64 b/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.b64 new file mode 100644 index 00000000000..00c8f210a82 --- /dev/null +++ b/vmm_tests/vmm_tests/tests/tests/x86_64/data/openvmm-reseed.b64 @@ -0,0 +1 @@ +f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAeABAAAAAAABAAAAAAAAAAKABAAAAAAAAAAAAAEAAOAABAEAABQAEAAEAAAAHAAAAeAAAAAAAAAB4AEAAAAAAAHgAQAAAAAAACAEAAAAAAAAIAQAAAAAAAAgAAAAAAAAASMfAAgAAAEiNPZQAAABIMfZIMdIPBUiFwHh3SInHSDHASI01oAAAAEjHwkAAAAAPBUiD+EB1W0jHwAIAAABIjT1qAAAASMfGAQAAAEgx0g8FSIXAeDxIicdIx8AQAAAASMfGA1IIQEiNFVIAAAAPBUiFwHgdSMfAEAAAAEjHxgdSAABIMdIPBUiFwHgFSDH/6wdIx8cBAAAASMfAPAAAAA8FL3RtcC9lbnRyb3B5AC9kZXYvcmFuZG9tAAAAAAAAAAIAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC5zaHN0cnRhYgAudGV4dAAucm9kYXRhAC5kYXRhAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAEAAAAHAAAAAAAAAHgAQAAAAAAAeAAAAAAAAACiAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAABEAAAABAAAAAgAAAAAAAAAaAUAAAAAAABoBAAAAAAAAGQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAZAAAAAQAAAAMAAAAAAAAAOAFAAAAAAAA4AQAAAAAAAEgAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAQAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAgAEAAAAAAAAfAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAA== From 782f822cad6c5cb171db4229c406053d95284313 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:17:13 -0700 Subject: [PATCH 127/304] openvmm: gate external input until post-restore repair completes A restored guest resumes with state that belongs to the captured VM: its RNG, its clone identity, and connections that the host no longer has. Until the guest has repaired that state, host input such as network packets and console bytes can reach software that still acts on stale state. Restore needs a gate that holds external input until the guest reports that repair is complete. Gate host input for tiered microVM restores: - A restore requires the gate when its manifest is version 5 with the microVM ABI 2 contract and a snapshot tier (`requires_post_restore_gate`). A gated restore always injects fresh entropy, so the guest can reseed during repair. - The worker receives the new `restore_gate_timeout` parameter. It quiesces input on every state unit before the units start, publishes the restore readiness event, arms the gate deadline, and releases the VPs. - The guest acknowledges repair by writing the existing snapshot port (0x605). The worker establishes the usual post-write boundary without quiescing input again, and releasing that boundary resumes input, clears the gate, and records the `guest_repair_gate` restore milestone. An acknowledgement that misses the deadline stops the VM. - portb honors the gate: while input is gated it stops reading the host endpoint, returns zero for console data reads, and clears the input bit in its status, while guest output still flows. The generation ID and the restore packet stay readable, so the guest can repair without console input. - `--restore-gate-timeout-ms` bounds the repair and defaults to 60000 milliseconds. A zero timeout is rejected. Document the gate and the new option, and add the `--restore-ready-path` option to the CLI reference. Tests cover portb input gating, the default timeout, and the zero-timeout rejection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 14 +++++ Guide/src/user_guide/openvmm/snapshots.md | 35 ++++++----- openvmm/openvmm_core/src/worker/dispatch.rs | 15 ++++- .../src/worker/dispatch/microvm.rs | 47 +++++++++------ .../src/worker/dispatch/restore.rs | 58 ++++++++++++++++++- openvmm/openvmm_defs/src/worker.rs | 2 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 21 +++++++ openvmm/openvmm_entry/src/lib.rs | 1 + openvmm/openvmm_entry/src/microvm/launch.rs | 7 +++ openvmm/openvmm_entry/src/microvm/restore.rs | 13 ++++- openvmm/openvmm_entry/src/ttrpc/mod.rs | 1 + .../openvmm_helpers/src/snapshot/microvm.rs | 13 +++++ petri/src/worker.rs | 1 + vm/devices/chipset/src/microvm.rs | 55 +++++++++++++++++- 14 files changed, 238 insertions(+), 45 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index ab006038b29..a93e50f5910 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -203,6 +203,18 @@ describes the source definitions. openvmm --machine microvm --hypervisor kvm \ --restore-snapshot snapshot --restore-entropy ``` +* `--restore-ready-path `: Connect to an existing Unix domain socket on + Linux or a `//./pipe/...` named pipe on Windows and write exactly + `OPENVMM_RESTORE_READY_V1\n` once all restored state, required attachments, + and execution-owned workers are ready. Ungated restores flush the event + before releasing the restored vCPU. Gated microVM restores flush it after the + guest acknowledges post-restore repair and external input is re-enabled, + while the restored vCPU remains stopped. + It is valid only with `--restore-snapshot` and is process-local; it is not + saved in the snapshot. A connection, write, or flush failure aborts startup + and stops the VM. The peer must accept and read the event while startup is + in progress; Windows flush completion waits for the named-pipe peer to + consume the complete frame. * `--restore-entropy`: Make a fresh `OPENVMM_ENTROPY_V1` packet available on the private portb restore channel. The guest must consume the packet and explicitly reseed its RNG. Restoring cloned RNG state without this option is @@ -213,6 +225,8 @@ describes the source definitions. repeatedly. OpenVMM creates it before vCPU entry and does not serialize it. On restore, it is the first 16 bytes of the fresh entropy packet, allowing the guest repair path to update clone identity without additional port I/O. +* `--restore-gate-timeout-ms `: Bound microVM guest repair and + gate acknowledgement after restore. The default is 60000 milliseconds. * `--snapshot-tier `: Required for snapshot capture with sandbox blocks. Choose `platform`, `workload-start`, or `instance-checkpoint`. The first two are reusable clone policies; instance checkpoints use single-use resume policy. diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 8036f60b499..43c43974b5f 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -124,19 +124,24 @@ directory, so `file=...` should not be specified in `--memory` (the two options are mutually exclusive). Guest writes use a private copy-on-write mapping and do not modify the snapshot artifact. -Orchestrators can add `--restore-ready-path `, which requires -`--restore-snapshot`. OpenVMM connects to an existing Unix domain socket on -Linux or a `//./pipe/...` named pipe on Windows and writes -`OPENVMM_RESTORE_READY_V1\n` after restore validation and state-unit startup, -before releasing a restored vCPU. The event is single-use and is not -serialized. A connection, write, or flush failure aborts startup and stops the -started units. With `--paused`, the first successful `resume` publishes the -event; if that resume fails, OpenVMM exits with an error rather than allowing -a later resume to start the guest without the event. The peer must accept and -read while resume is in progress; on Windows, flush completion waits until the -named-pipe peer consumes the complete frame. - -For a tiered microVM restore, platform manifests +MicroVM orchestrators can add `--restore-ready-path `. OpenVMM connects +to an existing Unix domain socket on Linux or named pipe on Windows and writes +`OPENVMM_RESTORE_READY_V1\n` after restore validation, attachment resolution, +and state-unit startup. Ungated restores publish it before releasing a restored +vCPU. Gated microVM restores publish it after the guest acknowledges repair and +host input is re-enabled, while the restored vCPU remains stopped. The event +is single-use and is not serialized. +Failure to write and flush it stops the started units and fails restore without +releasing gated input. The peer must accept and read while resume is in +progress; on Windows, flush completion waits until the named-pipe peer consumes +the complete frame. + +For a tiered microVM restore, OpenVMM starts device workers with network and +control input gated. The guest performs post-restore repair and writes the +existing snapshot port (`0x605`) to acknowledge completion. OpenVMM stops at +that exact post-write boundary, completes the deferred write while the vCPU is +stopped, and then releases device input before resuming the vCPU. The acknowledgement is bounded by +`--restore-gate-timeout-ms` (60000 milliseconds by default). Platform manifests leave read-only layer identities unbound, while later tiers require exact image identities. An instance-checkpoint restore attempt atomically creates `resume.claim`; subsequent restores are rejected. The claim is committed after @@ -148,8 +153,8 @@ ID. Status bit 5 advertises the feature; writing `0xa6` to status port `0xea` and reading 16 bytes from data port `0xe9` returns the ID. It is repeatable within one process and is never restored from snapshot state. A restore derives the ID from the first 16 bytes of the fresh entropy packet, so the guest can -reject an unchanged clone identity, reseed Linux, and refresh runtime -identifiers without another PMIO transfer. +reject an unchanged clone identity, reseed Linux, refresh runtime identifiers, +and acknowledge the input gate without another PMIO transfer. ```admonish warning Versions 3 through 5 do not contain or validate embedded checksums for diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index ff57bfb62d0..0cc281bd804 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -95,8 +95,8 @@ use openvmm_defs::worker::VmWorkerParameters; use openvmm_pcat_locator::RomFileLocation; use pal_async::DefaultDriver; use pal_async::DefaultPool; +use pal_async::driver::SpawnDriver; use pal_async::local::block_with_io; -use pal_async::task::Spawn; use pal_async::task::Task; use pal_async::timer::PolledTimer; use pci_core::PciInterruptPin; @@ -3819,7 +3819,7 @@ impl LoadedVm { pub async fn run( mut self, - driver: &impl Spawn, + driver: &impl SpawnDriver, mut rpc_recv: mesh::Receiver, mut worker_rpc: mesh::Receiver>, ) { @@ -3828,6 +3828,7 @@ impl LoadedVm { VmRpc(Result), Halt(Result), SnapshotBoundary(microvm::SnapshotBoundaryEvent), + RestoreGateTimeout, } // Start a task to handle state unit inspections by filtering the worker @@ -3856,7 +3857,11 @@ impl LoadedVm { let b = worker_rpc.recv().map(Event::WorkerRpc); let c = self.inner.halt_recv.recv().map(Event::Halt); let d = self.snapshot_boundary.recv().map(Event::SnapshotBoundary); - (a, b, c, d).race().await + let e = self + .snapshot_restore + .gate_expired(driver) + .map(|()| Event::RestoreGateTimeout); + (a, b, c, d, e).race().await }; let event = match event { @@ -4308,6 +4313,10 @@ impl LoadedVm { break; } } + Event::RestoreGateTimeout => { + self.handle_restore_gate_timeout().await; + break; + } } } diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index e1bb1aa8d7f..e2879e1b2b9 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -196,33 +196,35 @@ impl LoadedVm { return true; }; - let input_gate = openvmm_defs::profile::ProfileSpan::start(); - if let Err(error) = self - .state_units - .quiesce_input_for_save(request.input_gate_timeout) - .await - { - tracelimit::error_ratelimited!( - error = error.as_ref() as &dyn std::error::Error, - "failed to gate host input before snapshot boundary" - ); - if let Err(resume_error) = self + if !self.snapshot_restore.input_gated { + let input_gate = openvmm_defs::profile::ProfileSpan::start(); + if let Err(error) = self .state_units - .resume_input_after_save(request.input_gate_timeout) + .quiesce_input_for_save(request.input_gate_timeout) .await { tracelimit::error_ratelimited!( - error = resume_error.as_ref() as &dyn std::error::Error, - "host-input gate rollback is uncertain; terminating VM worker" + error = error.as_ref() as &dyn std::error::Error, + "failed to gate host input before snapshot boundary" ); + if let Err(resume_error) = self + .state_units + .resume_input_after_save(request.input_gate_timeout) + .await + { + tracelimit::error_ratelimited!( + error = resume_error.as_ref() as &dyn std::error::Error, + "host-input gate rollback is uncertain; terminating VM worker" + ); + request.transaction_complete.complete(()); + return false; + } + request.release_write.send(()); request.transaction_complete.complete(()); - return false; + return true; } - request.release_write.send(()); - request.transaction_complete.complete(()); - return true; + input_gate.complete("capture", "input_gate", Default::default()); } - input_gate.complete("capture", "input_gate", Default::default()); let vp_stop_at_io_boundary = openvmm_defs::profile::ProfileSpan::start(); match self @@ -274,10 +276,17 @@ impl LoadedVm { .stop_guard .take() .context("snapshot boundary is missing its vCPU stop guard")?; + let restore_gate_profile = self.snapshot_restore.gate_profile.take(); self.snapshot_boundary.capture_wall_clock = None; self.snapshot_boundary.input_gate_timeout = None; + self.snapshot_restore.gate_timeout = None; + self.snapshot_restore.gate_deadline = None; + self.snapshot_restore.input_gated = false; transaction_complete.complete(()); drop(stop_guard); + if let Some(profile) = restore_gate_profile { + profile.complete_milestone("restore", "guest_repair_gate", Default::default()); + } Ok(()) } diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index aa9db3a4168..7e75c4e9958 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -4,7 +4,7 @@ //! Snapshot restore support for the VM worker: the restore inputs taken from //! the worker parameters, the restore state kept by [`LoadedVm`], the steps //! around applying the saved state, and the post-restore start sequence -//! (readiness event and VP release). +//! (input gate, readiness event, and VP release). use super::LoadedVm; use super::clock; @@ -19,9 +19,14 @@ use openvmm_defs::worker::SavedState; use openvmm_defs::worker::SharedMemoryFd; use openvmm_defs::worker::SnapshotRestoreGuards; use openvmm_defs::worker::VmWorkerParameters; +use pal_async::driver::Driver; +use pal_async::timer::Instant; +use pal_async::timer::PolledTimer; use state_unit::StateUnits; use std::fs::File; +use std::future::Future; use std::io::Write as _; +use std::time::Duration; use vmm_core::partition_unit::StopGuard; /// Snapshot-restore inputs taken from the [`VmWorkerParameters`]. @@ -42,6 +47,7 @@ impl RestoreParameters { pub(super) fn take(parameters: &mut VmWorkerParameters) -> anyhow::Result { let guards = parameters.snapshot_restore_guards.take(); let ready_sink = parameters.restore_ready_sink.take(); + let gate_timeout = parameters.restore_gate_timeout.take(); let restore_time = clock::restore_time_contract( parameters.restore_downtime, parameters.restore_tsc_frequency_hz, @@ -58,6 +64,7 @@ impl RestoreParameters { state: SnapshotRestore { time: restore_time, ready_sink, + gate_timeout, ..Default::default() }, cpu_contract, @@ -83,6 +90,30 @@ pub(super) struct SnapshotRestore { start_guard: Option, /// Single-use sink for the restore readiness event. ready_sink: Option, + /// Timeout for the post-restore input gate, when required. + pub(super) gate_timeout: Option, + /// Deadline for the guest to acknowledge the post-restore input gate. + pub(super) gate_deadline: Option, + /// Profile span covering the post-restore input gate. + pub(super) gate_profile: Option, + /// Whether host input is gated by the post-restore input gate. + pub(super) input_gated: bool, +} + +impl SnapshotRestore { + /// Returns a future that completes when the armed post-restore input gate + /// expires, and never completes while the gate is not armed. + pub(super) fn gate_expired(&self, driver: &impl Driver) -> impl Future { + let deadline = self.gate_deadline; + async move { + match deadline { + Some(deadline) => { + PolledTimer::new(driver).sleep_until(deadline).await; + } + None => std::future::pending().await, + } + } + } } #[cfg(guest_arch = "x86_64")] @@ -168,9 +199,16 @@ impl LoadedVm { } /// Starts the state units for [`LoadedVm::resume`], sequencing the - /// restore readiness event and the release of the restored VPs around the - /// start. + /// post-restore input gate, the restore readiness event, and the release + /// of the restored VPs around the start. pub(super) async fn start_state_units(&mut self) -> anyhow::Result<()> { + if let Some(timeout) = self.snapshot_restore.gate_timeout { + self.state_units + .quiesce_input_for_save(timeout) + .await + .context("failed to establish post-restore input gate")?; + self.snapshot_restore.input_gated = true; + } let device_start = ProfileSpan::start(); self.state_units .start() @@ -204,7 +242,21 @@ impl LoadedVm { return Err(error).context("failed to publish restore readiness event"); } } + if let Some(timeout) = self.snapshot_restore.gate_timeout { + self.snapshot_restore.gate_deadline = Some(Instant::now().saturating_add(timeout)); + self.snapshot_restore.gate_profile = Some(ProfileSpan::start()); + } self.snapshot_restore.start_guard.take(); Ok(()) } + + /// Stops the VM after the guest failed to acknowledge the post-restore + /// input gate in time. + pub(super) async fn handle_restore_gate_timeout(&mut self) { + tracing::error!("post-restore input gate acknowledgement timed out"); + if self.running { + self.state_units.stop().await; + self.running = false; + } + } } diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index 644a1afee56..07eedc80922 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -85,6 +85,8 @@ pub struct VmWorkerParameters { pub restore_cpu_contract: Option>, /// Single-use process-local sink for the restore readiness event. pub restore_ready_sink: Option, + /// Timeout for the microVM post-restore input gate, when required. + pub restore_gate_timeout: Option, /// The VM RPC channel. pub rpc: mesh::Receiver, /// The notification channel. diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 3b2af8c4c4b..aa29693af5e 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -87,6 +87,10 @@ pub struct MicrovmCli { #[clap(long, requires = "restore_snapshot")] pub restore_entropy: bool, + /// Maximum time allowed for a microVM guest to complete post-restore repair. + #[clap(long, value_name = "MILLISECONDS", default_value_t = 60000)] + pub restore_gate_timeout_ms: u64, + /// Capture a microVM snapshot to this directory when the guest writes PMIO 0x605. #[clap(long, value_name = "DIR", conflicts_with = "restore_snapshot")] pub snapshot_destination: Option, @@ -264,6 +268,10 @@ impl Options { self.net.is_empty(), "microVM restore takes network addressing from saved state; do not pass --net" ); + anyhow::ensure!( + self.microvm.restore_gate_timeout_ms != 0, + "microVM post-restore gate timeout must be nonzero" + ); } anyhow::ensure!( self.uefi.is_none() && !self.pcat && self.igvm.is_none() && !self.device_tree, @@ -731,6 +739,7 @@ mod tests { "scratch:mem:1M", ]) .unwrap(); + assert_eq!(options.microvm.restore_gate_timeout_ms, 60_000); options.validate_microvm_options().unwrap(); } @@ -770,6 +779,18 @@ mod tests { ]) .is_err() ); + + let zero_timeout = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--restore-gate-timeout-ms", + "0", + ]) + .unwrap(); + assert!(zero_timeout.validate_microvm_options().is_err()); } #[test] diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 9f226bfda20..33712e93b8e 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2994,6 +2994,7 @@ async fn run_control_inner( restore_apic_frequency_hz: restore.apic_frequency_hz, restore_cpu_contract: restore.cpu_contract, restore_ready_sink, + restore_gate_timeout: microvm.restore_gate_timeout(&opt), rpc: rpc_recv, notify: notify_send, }; diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 1218822630e..845aff38020 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -163,6 +163,13 @@ impl MicrovmLaunch { Ok(Some(shared_memory)) } + /// Returns the time allowed for post-restore guest repair, if required. + pub(crate) fn restore_gate_timeout(&self, opt: &Options) -> Option { + self.restore + .gate_required + .then_some(Duration::from_millis(opt.microvm.restore_gate_timeout_ms)) + } + /// Creates the channels that carry guest-requested snapshot boundaries from /// the VM worker to the VM controller. pub(crate) fn snapshot_channels( diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 6fb64923cde..c9c3abc3d72 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -69,6 +69,8 @@ pub(crate) fn fresh_microvm_restore_packet() -> anyhow::Result<([u8; 16], Vec, + /// Whether the guest must complete post-restore repair before readiness. + pub(crate) gate_required: bool, /// Private copy of a paired scratch image, kept alive for the VM lifetime. pub(crate) private_scratch_dir: Option, } @@ -76,13 +78,14 @@ pub(crate) struct MicrovmRestore { /// Validates a snapshot restore against the microVM profile and prepares the /// restore-time inputs of the microVM configuration. /// -/// This may adjust `opt`: the snapshot selects the RAM size, and a paired -/// scratch image is replaced by a private copy. +/// This may adjust `opt`: the snapshot selects the RAM size, a paired scratch +/// image is replaced by a private copy, and restore gates require entropy. pub(crate) fn prepare_restore( opt: &mut Options, restore_snapshot: Option<&OpenedSnapshot>, ) -> anyhow::Result { let mut private_scratch_dir = None; + let mut restore_gate_required = false; if let Some(contract) = restore_snapshot.and_then(|snapshot| snapshot.manifest().machine_contract.as_ref()) && contract.machine_profile == "microvm" @@ -101,6 +104,8 @@ pub(crate) fn prepare_restore( .restore_snapshot .as_deref() .expect("restore manifest requires a snapshot path"); + restore_gate_required = + openvmm_helpers::snapshot::microvm::requires_post_restore_gate(manifest); let contract = manifest .machine_contract .as_ref() @@ -192,6 +197,9 @@ pub(crate) fn prepare_restore( } else { None }; + if restore_gate_required { + opt.microvm.restore_entropy = true; + } if restore_machine_contract.is_some() && !opt.microvm.restore_entropy { tracing::warn!( "restoring cloned guest RNG state without fresh entropy injection; cryptographic workloads are unsafe" @@ -199,6 +207,7 @@ pub(crate) fn prepare_restore( } Ok(MicrovmRestore { machine_contract: restore_machine_contract, + gate_required: restore_gate_required, private_scratch_dir, }) } diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 61b111e5c4b..ed54dd06334 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1320,6 +1320,7 @@ impl VmService { restore_apic_frequency_hz: None, restore_cpu_contract: None, restore_ready_sink: None, + restore_gate_timeout: None, rpc: recv, notify: notify_send, }, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 325bdbfe070..f8ab95e5317 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -3,6 +3,7 @@ //! microVM snapshot machine-contract types, construction, and validation. +use super::MANIFEST_VERSION; use super::SnapshotManifest; use super::format::SCRATCH_FILE_NAME; #[cfg(test)] @@ -813,6 +814,18 @@ pub fn microvm_machine_contract( Ok(contract) } +/// Returns whether restore must hold external device input until guest repair completes. +pub fn requires_post_restore_gate(manifest: &SnapshotManifest) -> bool { + manifest.version == MANIFEST_VERSION + && manifest.machine_contract.as_ref().is_some_and(|contract| { + matches!( + contract.microvm_abi_version, + openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + ) + }) + && !manifest.snapshot_tier.is_empty() +} + pub(super) fn has_sandbox_blocks(manifest: &SnapshotManifest) -> bool { manifest .machine_contract diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 9744c2cbe0d..3903295ad82 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -49,6 +49,7 @@ impl Worker { restore_apic_frequency_hz: None, restore_cpu_contract: None, restore_ready_sink: None, + restore_gate_timeout: None, rpc: rpc_recv, notify: notify_send, }; diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index a25f30deb69..cd3dceb7267 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -61,6 +61,7 @@ pub struct MicrovmPortb { #[inspect(with = "VecDeque::len")] restore_entropy: VecDeque, restore_entropy_selected: bool, + input_gated: bool, #[inspect(skip)] rx_waker: Option, #[inspect(skip)] @@ -83,6 +84,7 @@ impl MicrovmPortb { generation_id_read_index: None, restore_entropy: restore_entropy.into(), restore_entropy_selected: false, + input_gated: false, rx_waker: None, tx_waker: None, } @@ -156,6 +158,17 @@ impl MicrovmPortb { impl ChangeDeviceState for MicrovmPortb { fn start(&mut self) {} + async fn quiesce_input(&mut self) -> anyhow::Result<()> { + self.input_gated = true; + Ok(()) + } + + async fn resume_input(&mut self) -> anyhow::Result<()> { + self.input_gated = false; + self.wake_rx(); + Ok(()) + } + async fn stop(&mut self) { // Drain every byte the endpoint accepts immediately. Any remaining // VMM-owned bytes are serialized and retried against the reconstructed @@ -169,6 +182,7 @@ impl ChangeDeviceState for MicrovmPortb { self.restore_entropy.clear(); self.generation_id_read_index = None; self.restore_entropy_selected = false; + self.input_gated = false; } } @@ -197,7 +211,9 @@ impl PollDevice for MicrovmPortb { Poll::Pending => return, } } - self.poll_rx(cx); + if !self.input_gated { + self.poll_rx(cx); + } let _ = self.poll_tx(cx); } } @@ -219,13 +235,14 @@ impl PortIoIntercept for MicrovmPortb { if self.restore_entropy.is_empty() { self.restore_entropy_selected = false; } - } else { + } else if !self.input_gated { data[0] = self.rx_buffer.pop_front().unwrap_or(0); self.wake_rx(); } } STATUS_PORT => { - data[0] = if self.generation_id_read_index.is_none() + data[0] = if !self.input_gated + && self.generation_id_read_index.is_none() && !self.restore_entropy_selected && !self.rx_buffer.is_empty() { @@ -844,6 +861,38 @@ mod tests { assert_eq!(portb.rx_buffer, [0x5a]); } + #[test] + fn portb_input_gate_blocks_rx_but_not_tx() { + let mut portb = MicrovmPortb::new( + Box::new(ConnectWithByte { + connected: true, + byte: Some(0x5a), + }), + TEST_GENERATION_ID, + Vec::new(), + ); + portb.rx_buffer.push_back(0x44); + futures::executor::block_on(portb.quiesce_input()).unwrap(); + assert!(matches!(portb.io_write(DATA_PORT, b"output"), IoResult::Ok)); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + let mut data = [0xff]; + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [STATUS_GENERATION_ID_AVAILABLE]); + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [0]); + assert_eq!(portb.rx_buffer, [0x44]); + assert!(portb.tx_buffer.is_empty()); + + futures::executor::block_on(portb.resume_input()).unwrap(); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + assert_eq!(portb.rx_buffer, [0x44, 0x5a]); + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [0x44]); + } + #[test] fn lifecycle_ports_preserve_status_and_remain_nonblocking() { let requests = Arc::new(Mutex::new(Vec::new())); From b80b54fc7a91859ab020b9f966b5fedd130f813f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:22:04 -0700 Subject: [PATCH 128/304] vmm_core: instantiate an active VP prefix A microVM snapshot fixes the VP capacity of its guest: the topology, the APIC IDs, and the saved VP inventory always describe every VP. A restore that brings only some of those VPs online still has to build the full topology, and a backend may want to instantiate only the VPs that will run. Let a partition unit instantiate a prefix of its topology: - `PartitionUnitParams` gains `active_vp_count`, the number of leading VPs to instantiate. `None` instantiates the whole topology, as before, and a count outside `1..=capacity` fails with the new `InvalidActiveVpCount` error. The whole topology remains visible to the guest. - `VpSet` records its capacity. Saving requires every VP to be instantiated, because a prefix cannot produce a complete saved VP inventory. Restoring validates the complete saved inventory, with exactly one state for each VP of the capacity, before it restores the states of the instantiated prefix; a missing, duplicate, or unknown VP state is now rejected. Dump and debugger access to a VP outside the prefix fails instead of indexing past the instantiated VPs. OpenVMM and OpenHCL pass `None`, so they still instantiate every VP. Tests cover the inventory validation, the prefix selection, the save restriction, and access to an uninstantiated VP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openhcl/underhill_core/src/worker.rs | 1 + openvmm/openvmm_core/src/worker/dispatch.rs | 1 + vmm_core/src/partition_unit.rs | 12 +- vmm_core/src/partition_unit/snapshot.rs | 21 ++- vmm_core/src/partition_unit/vp_set.rs | 23 ++- .../src/partition_unit/vp_set/boundary.rs | 2 +- vmm_core/src/partition_unit/vp_set/prefix.rs | 170 ++++++++++++++++++ 7 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 vmm_core/src/partition_unit/vp_set/prefix.rs diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index e29358b2ddb..047b284ffad 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -3913,6 +3913,7 @@ async fn new_underhill_vm( WrappedPartition(partition.clone()), PartitionUnitParams { processor_topology: &processor_topology, + active_vp_count: None, halt_vps, halt_request_recv, client_notify_send: halt_notify_send, diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 0cc281bd804..14485cc5158 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -3191,6 +3191,7 @@ impl InitializedVm { partition.clone().into_vm_partition(), PartitionUnitParams { processor_topology: &processor_topology, + active_vp_count: None, halt_vps, halt_request_recv, client_notify_send: halt_send, diff --git a/vmm_core/src/partition_unit.rs b/vmm_core/src/partition_unit.rs index 3a30c1338a8..28b4a1da2fa 100644 --- a/vmm_core/src/partition_unit.rs +++ b/vmm_core/src/partition_unit.rs @@ -141,6 +141,8 @@ enum PartitionRequest { pub struct PartitionUnitParams<'a> { pub vtl_guest_memory: [Option<&'a GuestMemory>; 3], pub processor_topology: &'a ProcessorTopology, + /// VP prefix to instantiate. The full topology remains guest-visible. + pub active_vp_count: Option, /// Tracks the halt state of VPs. pub halt_vps: Arc, /// The receiver returned from `Halt::new()`. @@ -168,6 +170,8 @@ pub enum Error { NameInUse(NameInUse), #[error("missing guest memory required for gdb support")] MissingGuestMemory, + #[error("active VP count {active} is outside topology capacity 1..={capacity}")] + InvalidActiveVpCount { active: u32, capacity: u32 }, } /// Error returned by [`PartitionUnit::set_initial_regs()`]. @@ -202,10 +206,16 @@ impl PartitionUnit { return Err(Error::DebuggingNotSupported); } - let mut vp_set = VpSet::new(params.vtl_guest_memory.map(|m| m.cloned()), params.halt_vps); + let active_vp_count = snapshot::active_vp_count(¶ms)?; + let mut vp_set = VpSet::new( + params.vtl_guest_memory.map(|m| m.cloned()), + params.halt_vps, + params.processor_topology.vp_count() as usize, + ); let vps = params .processor_topology .vps_arch() + .take(active_vp_count as usize) .map(|vp| vp_set.add(vp)) .collect(); diff --git a/vmm_core/src/partition_unit/snapshot.rs b/vmm_core/src/partition_unit/snapshot.rs index 2620e38f5b0..6dfeca9e777 100644 --- a/vmm_core/src/partition_unit/snapshot.rs +++ b/vmm_core/src/partition_unit/snapshot.rs @@ -1,11 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Partition unit support for snapshots: stopping VPs at a deferred I/O -//! boundary for capture, and advancing TSC after restore downtime. +//! Partition unit support for snapshots: validating the instantiated VP prefix, +//! stopping VPs at a deferred I/O boundary for capture, and advancing TSC after +//! restore downtime. +use super::Error; use super::PartitionRequest; use super::PartitionUnit; +use super::PartitionUnitParams; use super::PartitionUnitRunner; use super::StopGuard; use mesh::rpc::FailableRpc; @@ -18,6 +21,20 @@ pub(super) enum SnapshotRequest { AdvanceTsc(FailableRpc<(std::time::Duration, u64, Option), ()>), } +/// Returns the number of VPs to instantiate, validated against the topology's +/// VP count. +pub(super) fn active_vp_count(params: &PartitionUnitParams<'_>) -> Result { + let vp_capacity = params.processor_topology.vp_count(); + let active_vp_count = params.active_vp_count.unwrap_or(vp_capacity); + if !(1..=vp_capacity).contains(&active_vp_count) { + return Err(Error::InvalidActiveVpCount { + active: active_vp_count, + capacity: vp_capacity, + }); + } + Ok(active_vp_count) +} + impl PartitionUnit { /// Stops VPs after queuing stop events but before completing deferred I/O. /// diff --git a/vmm_core/src/partition_unit/vp_set.rs b/vmm_core/src/partition_unit/vp_set.rs index 829d29e50bc..888bb687348 100644 --- a/vmm_core/src/partition_unit/vp_set.rs +++ b/vmm_core/src/partition_unit/vp_set.rs @@ -4,6 +4,7 @@ //! Virtual processor state management. mod boundary; +mod prefix; #[cfg(guest_arch = "x86_64")] mod tsc; @@ -766,6 +767,7 @@ pub struct VpSet { inner: Arc, #[inspect(rename = "vp", iter_by_index, safe)] vps: Vec, + vp_capacity: usize, #[inspect(skip)] started: bool, } @@ -781,7 +783,11 @@ struct Vp { } impl VpSet { - pub fn new(vtl_guest_memory: [Option; NUM_VTLS], halt: Arc) -> Self { + pub fn new( + vtl_guest_memory: [Option; NUM_VTLS], + halt: Arc, + vp_capacity: usize, + ) -> Self { let inner = Inner { vtl_guest_memory, halt, @@ -789,6 +795,7 @@ impl VpSet { Self { inner: Arc::new(inner), vps: Vec::new(), + vp_capacity, started: false, } } @@ -895,6 +902,7 @@ impl VpSet { pub async fn save(&mut self) -> Result, SaveError> { assert!(!self.started); + self.validate_save_vp_count()?; self.vps .iter() .enumerate() @@ -917,6 +925,7 @@ impl VpSet { states: impl IntoIterator, ) -> Result<(), RestoreError> { assert!(!self.started); + let states = self.select_instantiated_vp_states(states)?; states .into_iter() .map(|(vp_index, data)| { @@ -1001,7 +1010,7 @@ impl VpSet { vp: VpIndex, vtl: Vtl, ) -> anyhow::Result { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call(|x| VpEvent::State(StateEvent::GetDumpVpState(x)), vtl) .await @@ -1017,7 +1026,7 @@ impl VpSet { vp: VpIndex, state: virt::x86::DebugState, ) -> anyhow::Result<()> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::SetDebugState(x))), @@ -1046,7 +1055,7 @@ impl VpSet { vp: VpIndex, state: Box, ) -> anyhow::Result<()> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::SetVpState(x))), @@ -1057,7 +1066,7 @@ impl VpSet { } pub async fn get_vp_state(&self, vp: VpIndex) -> anyhow::Result> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::GetVpState(x))), @@ -1073,7 +1082,7 @@ impl VpSet { gva: u64, len: usize, ) -> anyhow::Result> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::ReadVirtualMemory(x))), @@ -1089,7 +1098,7 @@ impl VpSet { gva: u64, data: Vec, ) -> anyhow::Result<()> { - self.vps[vp.index() as usize] + self.instantiated_vp(vp)? .send .call( |x| VpEvent::State(StateEvent::Debug(DebugEvent::WriteVirtualMemory(x))), diff --git a/vmm_core/src/partition_unit/vp_set/boundary.rs b/vmm_core/src/partition_unit/vp_set/boundary.rs index 406eb3cdc71..aecba8b75ae 100644 --- a/vmm_core/src/partition_unit/vp_set/boundary.rs +++ b/vmm_core/src/partition_unit/vp_set/boundary.rs @@ -54,7 +54,7 @@ mod tests { fn vp_set(count: u32) -> (VpSet, Vec) { let (halt, _halt_recv) = Halt::new(); - let mut vps = VpSet::new([None, None, None], Arc::new(halt)); + let mut vps = VpSet::new([None, None, None], Arc::new(halt), count as usize); let runners = (0..count) .map(|index| { vps.add(TargetVpInfo { diff --git a/vmm_core/src/partition_unit/vp_set/prefix.rs b/vmm_core/src/partition_unit/vp_set/prefix.rs new file mode 100644 index 00000000000..55cb08079b3 --- /dev/null +++ b/vmm_core/src/partition_unit/vp_set/prefix.rs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The instantiated VP prefix. +//! +//! A VP set may instantiate only a prefix of the VPs in its capacity (the +//! topology's VP count). Save requires every VP to be instantiated. Restore +//! validates the complete saved VP inventory before it selects the states of +//! the instantiated prefix. Dump and debug access to an uninstantiated VP +//! fails. + +#[cfg(any(feature = "dump", feature = "gdb"))] +use super::Vp; +use super::VpSet; +#[cfg(any(test, feature = "dump", feature = "gdb"))] +use anyhow::Context as _; +use virt::VpIndex; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; + +fn validate_restore_vp_indices( + vp_count: usize, + indices: impl IntoIterator, +) -> Result<(), RestoreError> { + let mut present = vec![false; vp_count]; + for vp_index in indices { + let index = vp_index.index() as usize; + let slot = present + .get_mut(index) + .ok_or_else(|| RestoreError::UnknownEntryId(format!("vp{}", vp_index.index())))?; + if std::mem::replace(slot, true) { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "snapshot contains duplicate state for vp{}", + vp_index.index() + ))); + } + } + if let Some(index) = present.iter().position(|present| !present) { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "snapshot is missing state for vp{index}" + ))); + } + Ok(()) +} + +fn select_instantiated_vp_states( + vp_capacity: usize, + instantiated_vp_count: usize, + states: Vec<(VpIndex, T)>, +) -> Result, RestoreError> { + validate_restore_vp_indices(vp_capacity, states.iter().map(|(vp_index, _)| *vp_index))?; + Ok(states + .into_iter() + .filter(|(vp_index, _)| vp_index.index() < instantiated_vp_count as u32) + .collect()) +} + +fn validate_save_vp_count( + instantiated_vp_count: usize, + vp_capacity: usize, +) -> Result<(), SaveError> { + if instantiated_vp_count != vp_capacity { + return Err(SaveError::NotSupported); + } + Ok(()) +} + +#[cfg(any(test, feature = "dump", feature = "gdb"))] +fn instantiated_vp_index(vp_count: usize, vp: VpIndex) -> anyhow::Result { + let index = vp.index() as usize; + (index < vp_count) + .then_some(index) + .with_context(|| format!("vp{} is not instantiated", vp.index())) +} + +impl VpSet { + /// Fails unless every VP is instantiated, because a prefix cannot produce + /// a complete saved VP inventory. + pub(super) fn validate_save_vp_count(&self) -> Result<(), SaveError> { + validate_save_vp_count(self.vps.len(), self.vp_capacity) + } + + /// Validates the complete saved VP inventory and returns the states of the + /// instantiated prefix. + pub(super) fn select_instantiated_vp_states( + &self, + states: impl IntoIterator, + ) -> Result, RestoreError> { + select_instantiated_vp_states( + self.vp_capacity, + self.vps.len(), + states.into_iter().collect(), + ) + } + + /// Returns VP `vp`, or an error if it is not instantiated. + #[cfg(any(feature = "dump", feature = "gdb"))] + pub(super) fn instantiated_vp(&self, vp: VpIndex) -> anyhow::Result<&Vp> { + Ok(&self.vps[instantiated_vp_index(self.vps.len(), vp)?]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn requires_exactly_one_restore_entry_per_vp() { + validate_restore_vp_indices(4, (0..4).map(VpIndex::new)).unwrap(); + + let missing = validate_restore_vp_indices(4, [0, 1, 3].map(VpIndex::new)).unwrap_err(); + let RestoreError::InvalidSavedState(missing) = missing else { + panic!("expected invalid saved state"); + }; + assert!(missing.to_string().contains("missing state for vp2")); + + let duplicate = validate_restore_vp_indices(4, [0, 1, 1, 3].map(VpIndex::new)).unwrap_err(); + let RestoreError::InvalidSavedState(duplicate) = duplicate else { + panic!("expected invalid saved state"); + }; + assert!(duplicate.to_string().contains("duplicate state for vp1")); + + let unknown = validate_restore_vp_indices(4, [0, 1, 2, 4].map(VpIndex::new)).unwrap_err(); + assert!(unknown.to_string().contains("unknown entry id: vp4")); + } + + #[test] + fn validates_full_inventory_before_selecting_instantiated_prefix() { + let states = (0..4).map(|index| (VpIndex::new(index), index)).collect(); + let selected = select_instantiated_vp_states(4, 2, states).unwrap(); + assert_eq!( + selected + .into_iter() + .map(|(vp_index, state)| (vp_index.index(), state)) + .collect::>(), + [(0, 0), (1, 1)] + ); + + let missing_dormant_vp = [0, 1, 2] + .map(|index| (VpIndex::new(index), index)) + .into_iter() + .collect(); + let error = select_instantiated_vp_states(4, 2, missing_dormant_vp).unwrap_err(); + let RestoreError::InvalidSavedState(error) = error else { + panic!("expected invalid saved state"); + }; + assert!(error.to_string().contains("missing state for vp3")); + } + + #[test] + fn rejects_saving_an_instantiated_prefix() { + assert!(validate_save_vp_count(4, 4).is_ok()); + assert!(matches!( + validate_save_vp_count(2, 4), + Err(SaveError::NotSupported) + )); + } + + #[test] + fn rejects_access_to_an_uninstantiated_vp() { + assert_eq!(instantiated_vp_index(2, VpIndex::new(1)).unwrap(), 1); + assert!( + instantiated_vp_index(2, VpIndex::new(2)) + .unwrap_err() + .to_string() + .contains("vp2 is not instantiated") + ); + } +} From 15313864adee34938ad55b54c590cbcc000edfd5 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:24:33 -0700 Subject: [PATCH 129/304] openvmm: bring a restore-time processor prefix online A microVM template that boots with few online VPs can be cloned into instances that need more processors, but the snapshot fixes the VP capacity and a restore could only resume the captured online set. The guest needs to learn how many VPs a restore expects online, and the host must reject targets that the template cannot support. Add opt-in restore-time VP activation: - A template opts in by booting with an explicit `maxcpus=1`, `2`, `4`, or `8` value no larger than its VP capacity. The machine contract records that boot-online count as `boot_online_vp_count` (field 23); zero means that the snapshot does not support activation. Contract validation requires the count to match the saved command line, and a restore compares it with the requested machine. The topology, the APIC IDs, and the saved VP inventory still describe the full capacity. - `--restore-processors ` requires `--restore-snapshot`. The count must be 1, 2, 4, or 8, at least the boot-online count, and at most the capacity, and snapshots without activation metadata reject it. A standard machine rejects the option. - A restore target implies the post-restore gate and fresh entropy, and the restore packet becomes `OPENVMM_ENTROPY_V2\0`, followed by the one-byte online target and the 64 bytes of entropy. portb status bit 2 reports a processor target until the packet is consumed. The guest onlines and verifies the prefix before it acknowledges the gate. - The target also reaches the VM worker as `restore_vp_count`; the launches without a restore target pass `None`. Document the option, the V2 packet, and the activation contract, along with the per-backend TSC handling of snapshot restore: MSHV hides `IA32_TSC_ADJUST` from versioned CPU contracts, KVM advances downtime through TSC offsets, MSHV and WHP freeze time and align VP counters before the first run, and restored multi-VP WHP partitions read timestamps from one reference epoch. Tests cover the boot-online parsing and target bounds, the CLI target validation, the V2 packet layout, and the portb processor-target status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 12 ++ Guide/src/user_guide/openvmm/snapshots.md | 42 +++++++ openvmm/openvmm_defs/src/worker.rs | 2 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 40 +++++- openvmm/openvmm_entry/src/lib.rs | 1 + openvmm/openvmm_entry/src/microvm/config.rs | 2 +- openvmm/openvmm_entry/src/microvm/restore.rs | 40 ++++-- openvmm/openvmm_entry/src/ttrpc/mod.rs | 1 + .../openvmm_helpers/src/snapshot/microvm.rs | 118 +++++++++++++++++- petri/src/worker.rs | 1 + vm/devices/chipset/src/microvm.rs | 43 +++++++ 11 files changed, 290 insertions(+), 12 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index a93e50f5910..b8266689e6b 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -219,12 +219,24 @@ describes the source definitions. the private portb restore channel. The guest must consume the packet and explicitly reseed its RNG. Restoring cloned RNG state without this option is unsafe for cryptographic workloads and emits a warning. + Processor activation uses `OPENVMM_ENTROPY_V2`. Every microVM portb device also reports generation-ID support in status bit 5. Writing `0xa6` to the status port and reading 16 bytes from the data port returns an opaque ID that is stable for that VM process and may be selected repeatedly. OpenVMM creates it before vCPU entry and does not serialize it. On restore, it is the first 16 bytes of the fresh entropy packet, allowing the guest repair path to update clone identity without additional port I/O. +* `--restore-processors `: For an opt-in microVM snapshot, bring the + contiguous VP prefix `0..COUNT-1` online before restore readiness. The + snapshot's manifest VP count remains immutable capacity and must still match + `--processors`. The target must be 1, 2, 4, or 8 and satisfy + `boot-online <= target <= capacity`. This option implies a version-2 private + restore packet and the post-restore gate. Snapshots without activation + metadata reject it. An + explicit MSHV restore instantiates and binds only the requested prefix while + validating the full saved VP inventory; that reduced-prefix process cannot + be saved again. MSHV restores without this option, and KVM and WHP restores, + instantiate the full VP capacity. * `--restore-gate-timeout-ms `: Bound microVM guest repair and gate acknowledgement after restore. The default is 60000 milliseconds. * `--snapshot-tier `: Required for snapshot capture with sandbox blocks. Choose diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 43c43974b5f..5d98c6ec914 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -156,6 +156,48 @@ the ID from the first 16 bytes of the fresh entropy packet, so the guest can reject an unchanged clone identity, reseed Linux, refresh runtime identifiers, and acknowledge the input gate without another PMIO transfer. +A microVM template may opt into restore-time vCPU activation by booting with an +explicit canonical `maxcpus=1`, `2`, `4`, or `8` value below or equal to its +configured VP capacity. The snapshot records that boot-online count while its +topology, APIC IDs, and saved VP inventory remain fixed at capacity. +`--restore-processors ` requests the contiguous online prefix +`0..COUNT-1` and must satisfy `boot-online <= COUNT <= capacity`. The guest +onlines and verifies that prefix before acknowledging the restore gate. +On MSHV, an explicit target instantiates and binds only that VP prefix; saving +such a reduced-prefix runtime is unsupported. MSHV restores without an explicit +target, and all KVM and WHP restores, instantiate the full VP capacity. +Versioned MSHV CPU contracts do not expose `IA32_TSC_ADJUST` because snapshot +state cannot preserve that register independently of `IA32_TSC`; this prevents +host-side TSC correction from appearing as per-VP firmware adjustment skew. +KVM advances snapshot downtime through each VP's TSC offset rather than an +`IA32_TSC` write. KVM's synchronization heuristic can discard sub-second +counter writes, leaving `kvm-clock` ahead of the TSC and triggering Linux's +clocksource watchdog. Relative offset updates preserve per-VP synchronization +without adding host read/write latency. KVM snapshot resume requires +`KVM_VCPU_TSC_CTRL` / `KVM_VCPU_TSC_OFFSET` support; unavailable offset access +fails restore explicitly instead of falling back to imprecise counter writes. +All backends read back the adjusted TSC before resume and reject a discarded +or incomplete downtime adjustment. +After restoring counters and advancing snapshot time, MSHV and WHP freeze +partition time and align every VP's TSC to the BSP's advanced counter before +any VP runs. The first VP run thaws time. Setting counters while time is +running would introduce inter-VP skew from host scheduling delays, which can +make Linux reject the TSC clocksource during CPU activation. +For restored WHP partitions with multiple VPs, RDTSC, RDTSCP, and +`IA32_TSC` reads additionally use one partition-reference-time epoch anchored +to the advanced BSP counter. This clock has 100-nanosecond resolution and +keeps unmodified guest counters synchronized after time resumes; equal +frozen WHP register values alone do not guarantee equal live counters. +RDTSCP retains the guest's TSC_AUX value, and timestamp access restrictions +remain enforced. Explicit guest writes to `IA32_TSC`, or changes to +`IA32_TSC_ADJUST`, retain native WHP counter and deadline-timer semantics on +the affected VP. Reset restores the original intercept configuration. +Ordinary boots, single-VP partitions, and configurations exposing nested +virtualization to the guest retain their native counter path. +Snapshots without the explicit capture-time `maxcpus` opt-in, including legacy +snapshots, reject a restore target. This is not a post-readiness hotplug API and +cannot add VPs absent from the saved topology. + ```admonish warning Versions 3 through 5 do not contain or validate embedded checksums for `state.bin` or `memory.bin`. Restore still requires regular files, bounded diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index 07eedc80922..80a73028411 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -87,6 +87,8 @@ pub struct VmWorkerParameters { pub restore_ready_sink: Option, /// Timeout for the microVM post-restore input gate, when required. pub restore_gate_timeout: Option, + /// MSHV VP prefix instantiated for an explicit restore-time activation target. + pub restore_vp_count: Option, /// The VM RPC channel. pub rpc: mesh::Receiver, /// The notification channel. diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index aa29693af5e..9d4efa83e0d 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -87,6 +87,10 @@ pub struct MicrovmCli { #[clap(long, requires = "restore_snapshot")] pub restore_entropy: bool, + /// Bring this contiguous prefix of capacity VPs online before restore readiness. + #[clap(long, value_name = "COUNT", requires = "restore_snapshot")] + pub restore_processors: Option, + /// Maximum time allowed for a microVM guest to complete post-restore repair. #[clap(long, value_name = "MILLISECONDS", default_value_t = 60000)] pub restore_gate_timeout_ms: u64, @@ -218,8 +222,9 @@ impl Options { anyhow::ensure!( self.microvm.network_profile.is_none() && self.microvm.microvm_mount.is_none() - && self.microvm.microvm_sandbox_block.is_empty(), - "--network-profile, --mount, and --microvm-sandbox-block require a microVM machine" + && self.microvm.microvm_sandbox_block.is_empty() + && self.microvm.restore_processors.is_none(), + "--network-profile, --mount, --microvm-sandbox-block, and --restore-processors require a microVM machine" ); return Ok(()); } @@ -272,6 +277,12 @@ impl Options { self.microvm.restore_gate_timeout_ms != 0, "microVM post-restore gate timeout must be nonzero" ); + if let Some(restore_processors) = self.microvm.restore_processors { + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(restore_processors,), + "microVM does not support a restore-online count of {restore_processors}" + ); + } } anyhow::ensure!( self.uefi.is_none() && !self.pcat && self.igvm.is_none() && !self.device_tree, @@ -535,6 +546,31 @@ mod tests { assert!(options.validate_microvm_options().is_err()); } + for restore_processors in [1, 2, 4, 8] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--restore-processors", + &restore_processors.to_string(), + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + } + let noncanonical_restore = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--restore-processors", + "3", + ]) + .unwrap(); + assert!(noncanonical_restore.validate_microvm_options().is_err()); + for args in [ vec!["openvmm", "--machine", "microvm", "--vps-per-socket", "1"], vec!["openvmm", "--machine", "microvm", "--smt", "off"], diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 33712e93b8e..b1de09bcea6 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2995,6 +2995,7 @@ async fn run_control_inner( restore_cpu_contract: restore.cpu_contract, restore_ready_sink, restore_gate_timeout: microvm.restore_gate_timeout(&opt), + restore_vp_count: opt.microvm.restore_processors, rpc: rpc_recv, notify: notify_send, }; diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index f2c56ef548c..82d3a7bbbcc 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -315,7 +315,7 @@ impl<'a> MicrovmConfigBuilder<'a> { }; let opt = self.opt; let (generation_id, restore_entropy) = if opt.microvm.restore_entropy { - fresh_microvm_restore_packet()? + fresh_microvm_restore_packet(opt.microvm.restore_processors)? } else { (fresh_microvm_generation_id()?, Vec::new()) }; diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index c9c3abc3d72..d4ad0d6f79f 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -36,10 +36,20 @@ fn calculate_snapshot_downtime( Ok(downtime) } -fn microvm_restore_packet(entropy: &[u8; 64]) -> Vec { - let mut packet = b"OPENVMM_ENTROPY_V1\0".to_vec(); +fn microvm_restore_packet( + entropy: &[u8; 64], + restore_online_vp_count: Option, +) -> anyhow::Result> { + let mut packet = if let Some(count) = restore_online_vp_count { + let count = u8::try_from(count).context("restore-online VP count does not fit in u8")?; + let mut packet = b"OPENVMM_ENTROPY_V2\0".to_vec(); + packet.push(count); + packet + } else { + b"OPENVMM_ENTROPY_V1\0".to_vec() + }; packet.extend(entropy); - packet + Ok(packet) } fn microvm_generation_id(entropy: &[u8; 64]) -> [u8; 16] { @@ -54,12 +64,14 @@ pub(crate) fn fresh_microvm_generation_id() -> anyhow::Result<[u8; 16]> { Ok(generation_id) } -pub(crate) fn fresh_microvm_restore_packet() -> anyhow::Result<([u8; 16], Vec)> { +pub(crate) fn fresh_microvm_restore_packet( + restore_online_vp_count: Option, +) -> anyhow::Result<([u8; 16], Vec)> { let generation_id_create = openvmm_defs::profile::ProfileSpan::start(); let mut entropy = [0_u8; 64]; getrandom::fill(&mut entropy).context("failed to generate restore entropy")?; let generation_id = microvm_generation_id(&entropy); - let packet = microvm_restore_packet(&entropy); + let packet = microvm_restore_packet(&entropy, restore_online_vp_count)?; generation_id_create.complete("restore", "generation_id_create", Default::default()); Ok((generation_id, packet)) } @@ -115,6 +127,13 @@ pub(crate) fn prepare_restore( "snapshot machine profile does not match the requested microVM machine" ); openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; + if let Some(restore_processors) = opt.microvm.restore_processors { + openvmm_helpers::snapshot::microvm::validate_restore_online_vp_count( + manifest, + restore_processors, + )?; + restore_gate_required = true; + } anyhow::ensure!( opt.cmdline.is_empty(), "restore-time command-line overrides are not allowed" @@ -197,7 +216,7 @@ pub(crate) fn prepare_restore( } else { None }; - if restore_gate_required { + if restore_gate_required || opt.microvm.restore_processors.is_some() { opt.microvm.restore_entropy = true; } if restore_machine_contract.is_some() && !opt.microvm.restore_entropy { @@ -331,11 +350,16 @@ mod tests { } #[test] - fn restore_packet_preserves_entropy() { + fn restore_packet_versions_preserve_entropy_and_online_target() { let entropy = [0x5a; 64]; assert_eq!(microvm_generation_id(&entropy), [0x5a; 16]); - let v1 = microvm_restore_packet(&entropy); + let v1 = microvm_restore_packet(&entropy, None).unwrap(); assert_eq!(&v1[..19], b"OPENVMM_ENTROPY_V1\0"); assert_eq!(&v1[19..], &entropy); + + let v2 = microvm_restore_packet(&entropy, Some(8)).unwrap(); + assert_eq!(&v2[..19], b"OPENVMM_ENTROPY_V2\0"); + assert_eq!(v2[19], 8); + assert_eq!(&v2[20..], &entropy); } } diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index ed54dd06334..283df8ee08b 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1321,6 +1321,7 @@ impl VmService { restore_cpu_contract: None, restore_ready_sink: None, restore_gate_timeout: None, + restore_vp_count: None, rpc: recv, notify: notify_send, }, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index f8ab95e5317..8d3236df45a 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -355,6 +355,9 @@ pub struct SnapshotMachineContract { /// Version of the reserved restore-attachable microVM virtio-fs slot. #[mesh(22)] pub microvm_filesystem_slot_version: u32, + /// Virtual processors online at boot, or zero when restore activation is disabled. + #[mesh(23)] + pub boot_online_vp_count: u32, /// Virtio interrupt-delivery mode. #[mesh(24)] pub virtio_interrupt_mode: String, @@ -394,6 +397,66 @@ fn microvm_snapshot_topology(processor_count: u32) -> anyhow::Result anyhow::Result { + let mut boot_online_vp_count = None; + for token in effective_command_line.split_ascii_whitespace() { + let Some(value) = token.strip_prefix("maxcpus=") else { + continue; + }; + anyhow::ensure!( + boot_online_vp_count.is_none(), + "microVM command line contains multiple maxcpus values" + ); + let count = value + .parse::() + .context("microVM maxcpus value is invalid")?; + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(count), + "microVM does not support a boot-online count of {count}" + ); + anyhow::ensure!( + count <= vp_capacity, + "microVM boot-online count {count} exceeds VP capacity {vp_capacity}" + ); + boot_online_vp_count = Some(count); + } + Ok(boot_online_vp_count.unwrap_or(0)) +} + +/// Validates a restore-time online VP target against an opt-in snapshot contract. +pub fn validate_restore_online_vp_count( + manifest: &SnapshotManifest, + restore_online_vp_count: u32, +) -> anyhow::Result<()> { + let contract = manifest + .machine_contract + .as_ref() + .context("snapshot is missing the authoritative machine contract")?; + validate_supported_microvm_contract(contract)?; + anyhow::ensure!( + contract.boot_online_vp_count != 0, + "snapshot does not declare restore-time VP activation support" + ); + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(restore_online_vp_count), + "restore-online VP count {restore_online_vp_count} is not supported" + ); + anyhow::ensure!( + restore_online_vp_count >= contract.boot_online_vp_count, + "restore-online VP count {restore_online_vp_count} is below boot-online count {}", + contract.boot_online_vp_count + ); + anyhow::ensure!( + restore_online_vp_count <= manifest.vp_count, + "restore-online VP count {restore_online_vp_count} exceeds VP capacity {}", + manifest.vp_count + ); + Ok(()) +} + fn canonical_microvm_memory_ranges(memory_size: u64) -> anyhow::Result> { const LOW_RAM_END: u64 = 3 * 1024 * 1024 * 1024; const HIGH_RAM_START: u64 = 4 * 1024 * 1024 * 1024; @@ -448,6 +511,9 @@ pub fn microvm_machine_contract( "microVM snapshots require the KVM, MSHV, or WHP hypervisor" ); let topology = microvm_snapshot_topology(processor_count)?; + let boot_online_vp_count = + microvm_boot_online_vp_count(processor_count, &effective_command_line)?; + let memory_ranges = canonical_microvm_memory_ranges(memory_size)?; let device = |stable_id: &str, @@ -804,6 +870,7 @@ pub fn microvm_machine_contract( } else { 0 }, + boot_online_vp_count, virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, virtio_shared_status_page_size: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, @@ -924,6 +991,11 @@ pub fn validate_microvm_machine_contract( contract.topology == expected.topology, "snapshot processor topology doesn't match the requested machine" ); + anyhow::ensure!( + contract.boot_online_vp_count == 0 + || contract.boot_online_vp_count == expected.boot_online_vp_count, + "snapshot boot-online VP count doesn't match the requested machine" + ); anyhow::ensure!( contract.devices == expected.devices, "snapshot device inventory, order, or configuration doesn't match the requested machine" @@ -1090,6 +1162,13 @@ pub(super) fn validate_machine_contract_shape( *topology == microvm_snapshot_topology(vp_count)?, "snapshot processor topology is not canonical for the microVM" ); + if contract.boot_online_vp_count != 0 { + anyhow::ensure!( + contract.boot_online_vp_count + == microvm_boot_online_vp_count(vp_count, &contract.effective_command_line,)?, + "snapshot boot-online VP count does not match the effective command line" + ); + } if let Some(network) = &contract.microvm_network { anyhow::ensure!( @@ -1614,9 +1693,10 @@ fn test_machine_contract() -> SnapshotMachineContract { clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), microvm_network: None, microvm_filesystem: None, + apic_frequency_hz: Some(1_000_000_000), microvm_sandbox_blocks: Vec::new(), microvm_filesystem_slot_version: 0, - apic_frequency_hz: Some(1_000_000_000), + boot_online_vp_count: 0, virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, virtio_shared_status_page_size: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, @@ -1637,6 +1717,42 @@ mod tests { use super::super::validate_manifest; use super::*; + #[test] + fn restore_online_vp_count_is_bounded_by_template() { + assert_eq!( + microvm_boot_online_vp_count(8, "console=hvc0 maxcpus=1").unwrap(), + 1 + ); + assert_eq!(microvm_boot_online_vp_count(8, "console=hvc0").unwrap(), 0); + assert!(microvm_boot_online_vp_count(8, "maxcpus=3").is_err()); + + let mut manifest = test_manifest(); + manifest.vp_count = 8; + let mut contract = test_machine_contract(); + contract.boot_online_vp_count = 2; + manifest.machine_contract = Some(contract); + + for target in [2, 4, 8] { + validate_restore_online_vp_count(&manifest, target).unwrap(); + } + for target in [1, 3, 16] { + assert!(validate_restore_online_vp_count(&manifest, target).is_err()); + } + + manifest.vp_count = 4; + let error = validate_restore_online_vp_count(&manifest, 8).unwrap_err(); + assert!(error.to_string().contains("exceeds VP capacity 4")); + manifest.vp_count = 8; + + manifest + .machine_contract + .as_mut() + .unwrap() + .boot_online_vp_count = 0; + let error = validate_restore_online_vp_count(&manifest, 8).unwrap_err(); + assert!(error.to_string().contains("does not declare")); + } + fn microvm_console_attachment() -> SnapshotAttachment { SnapshotAttachment { stable_id: "console:microvm-virtio0".to_owned(), diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 3903295ad82..e6a415b60cd 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -50,6 +50,7 @@ impl Worker { restore_cpu_contract: None, restore_ready_sink: None, restore_gate_timeout: None, + restore_vp_count: None, rpc: rpc_recv, notify: notify_send, }; diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index cd3dceb7267..12179a0c19d 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -40,8 +40,10 @@ const SNAPSHOT_PORT: u16 = 0x605; const RESTORE_ENTROPY_SELECT: u8 = 0xa5; const GENERATION_ID_SELECT: u8 = 0xa6; const GENERATION_ID_SIZE: usize = 16; +const RESTORE_PROCESSOR_TARGET_PACKET_HEADER: &[u8] = b"OPENVMM_ENTROPY_V2\0"; const STATUS_INPUT_AVAILABLE: u8 = 1 << 0; const STATUS_RESTORE_PACKET_AVAILABLE: u8 = 1 << 1; +const STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE: u8 = 1 << 2; const STATUS_GENERATION_ID_AVAILABLE: u8 = 1 << 5; const BUFFER_MAX: usize = 1024 * 1024; @@ -61,6 +63,7 @@ pub struct MicrovmPortb { #[inspect(with = "VecDeque::len")] restore_entropy: VecDeque, restore_entropy_selected: bool, + restore_processor_target_available: bool, input_gated: bool, #[inspect(skip)] rx_waker: Option, @@ -75,6 +78,8 @@ impl MicrovmPortb { generation_id: [u8; GENERATION_ID_SIZE], restore_entropy: Vec, ) -> Self { + let restore_processor_target_available = + restore_entropy.starts_with(RESTORE_PROCESSOR_TARGET_PACKET_HEADER); Self { io_region: ("microvm-portb", DATA_PORT..=STATUS_PORT), io, @@ -84,6 +89,7 @@ impl MicrovmPortb { generation_id_read_index: None, restore_entropy: restore_entropy.into(), restore_entropy_selected: false, + restore_processor_target_available, input_gated: false, rx_waker: None, tx_waker: None, @@ -182,6 +188,7 @@ impl ChangeDeviceState for MicrovmPortb { self.restore_entropy.clear(); self.generation_id_read_index = None; self.restore_entropy_selected = false; + self.restore_processor_target_available = false; self.input_gated = false; } } @@ -234,6 +241,7 @@ impl PortIoIntercept for MicrovmPortb { data[0] = self.restore_entropy.pop_front().unwrap_or(0); if self.restore_entropy.is_empty() { self.restore_entropy_selected = false; + self.restore_processor_target_available = false; } } else if !self.input_gated { data[0] = self.rx_buffer.pop_front().unwrap_or(0); @@ -253,6 +261,9 @@ impl PortIoIntercept for MicrovmPortb { if !self.restore_entropy.is_empty() { data[0] |= STATUS_RESTORE_PACKET_AVAILABLE; } + if self.restore_processor_target_available { + data[0] |= STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE; + } data[0] |= STATUS_GENERATION_ID_AVAILABLE; } _ => return IoResult::Err(IoError::InvalidRegister), @@ -847,6 +858,38 @@ mod tests { } } + #[test] + fn processor_target_restore_packet_has_distinct_status() { + let packet = [RESTORE_PROCESSOR_TARGET_PACKET_HEADER, &[2]].concat(); + let mut portb = + MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, packet.clone()); + let mut data = [0]; + + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!( + data, + [STATUS_RESTORE_PACKET_AVAILABLE + | STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE] + ); + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), + IoResult::Ok + )); + for expected in packet { + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [expected]); + } + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [STATUS_GENERATION_ID_AVAILABLE]); + } + #[test] fn accepted_connection_is_polled_immediately() { let mut portb = MicrovmPortb::new( From d23857033212d720afec1916697f6dbd721e26b5 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:28:27 -0700 Subject: [PATCH 130/304] openvmm_core: back expandable restore RAM with fresh private memory A restored microVM gets exactly the RAM size of its snapshot. Growing the guest on restore needs a layout whose device apertures do not move when RAM grows, and guest RAM that combines the snapshot's copy-on-write mapping of `memory.bin` with fresh memory for the new ranges. Add the worker side of restore-time RAM expansion: - `MemoryLayoutInput` gains an optional RAM capacity for a single-node VM. The layout places node 0 as if it had the full capacity, so the MMIO apertures stay where a larger restore needs them, and publishes only the active RAM as guest RAM. The capacity must be page-aligned and at least the active RAM size. - `MicrovmConfig` gains the RAM capacity, the snapshot-backed GPA ranges, and the fresh expansion ranges of a restore, and the worker manifest carries them. A microVM capacity must be at least the RAM size and both must be 128-MiB aligned (`MICROVM_MEMORY_BLOCK_SIZE_BYTES`); ranges require a capacity, and the standard machine rejects all three. - When a restore declares snapshot-backed ranges, the worker maps them from the snapshot's memory backing with its file mapping mode and backs the expansion ranges with fresh private memory. The coalesced ranges must equal the active RAM of the layout. Also enable the lazy GPA registration that WHP gained earlier, now that a pure copy-on-write microVM restore exists to use it: without expansion ranges, VTL2, VPCI devices, or prefetched RAM, WHP registers guest RAM lazily and resolves its faults itself instead of in user mode. The entry does not request a capacity or ranges yet, so the layout of every VM is unchanged. Tests cover the reserved aperture and the published RAM of a capacity layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_core/src/worker/dispatch.rs | 15 ++- .../src/worker/dispatch/microvm.rs | 117 +++++++++++++++++- .../openvmm_core/src/worker/memory_layout.rs | 8 ++ .../src/worker/memory_layout/microvm.rs | 103 ++++++++++++++- openvmm/openvmm_defs/src/microvm.rs | 34 ++++- vmm_core/virt_whp/src/lib.rs | 2 +- 6 files changed, 272 insertions(+), 7 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 14485cc5158..0918fc70d68 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1252,6 +1252,9 @@ impl InitializedVm { #[cfg(guest_arch = "aarch64")] let device_assignment_msi_iova_range = resolve_device_assignment_msi_iova_range(platform_info.device_assignment_msi_iova); + let lazy_memory_registration = + microvm::uses_lazy_memory_registration(&cfg, shared_memory.as_ref()); + let user_mode_memory_faults = !lazy_memory_registration; let partition_prototype = openvmm_defs::profile::ProfileSpan::start(); let proto = hypervisor @@ -1263,8 +1266,8 @@ impl InitializedVm { nested_virt: cfg.hypervisor.nested_virt, #[cfg(guest_arch = "aarch64")] device_assignment_msi_iova_range, - user_mode_memory_faults: true, - lazy_memory_registration: false, + user_mode_memory_faults, + lazy_memory_registration, versioned_cpu_contract: microvm::uses_versioned_cpu_contract(cfg.machine_profile), }) .context("failed to create the prototype partition")?; @@ -1339,6 +1342,7 @@ impl InitializedVm { }; let resolved_layout = resolve_memory_layout(MemoryLayoutInput { node_mem_sizes: &node_mem_sizes, + memory_capacity: cfg.microvm.memory_capacity, layout: cfg.layout.clone(), pcie_root_complexes: &cfg.pcie_root_complexes, virtio_mmio_count, @@ -1475,6 +1479,13 @@ impl InitializedVm { .x86_legacy_support( matches!(cfg.load_mode, LoadMode::Pcat { .. }) || cfg.chipset.with_hyperv_vga, ); + memory_builder = microvm::add_snapshot_restore_backing( + memory_builder, + &cfg, + &mut ranges_by_node, + nodes_with_ranges, + &mut existing_mappable, + )?; for (vnode, ranges) in ranges_by_node.into_iter().enumerate() { if ranges.is_empty() { diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index e2879e1b2b9..189519c577e 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -17,6 +17,10 @@ use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; use chipset_resources::microvm::MicrovmSnapshotScratchPolicy; use guestmem::GuestMemory; use hvdef::Vtl; +use membacking::FileMappingMode; +use membacking::GuestMemoryBuilder; +use membacking::Mappable; +use membacking::SharedMemoryBacking; use memory_range::MemoryRange; use mesh::MeshPayload; use mesh::error::RemoteError; @@ -42,6 +46,9 @@ use vmm_core::partition_unit::StopGuard; #[derive(MeshPayload, Default)] pub(super) struct MicrovmManifest { pub(super) sandbox_blocks: Vec, + pub(super) memory_capacity: Option, + pub(super) snapshot_memory_ranges: Vec, + pub(super) restore_memory_ranges: Vec, } impl From for MicrovmManifest { @@ -51,8 +58,16 @@ impl From for MicrovmManifest { filesystem: _, sandbox_blocks, filesystem_bootstrap: _, + memory_capacity, + snapshot_memory_ranges, + restore_memory_ranges, } = config; - Self { sandbox_blocks } + Self { + sandbox_blocks, + memory_capacity, + snapshot_memory_ranges, + restore_memory_ranges, + } } } @@ -570,6 +585,106 @@ impl<'a> VirtioMmioSlots<'a> { } } +/// Adds the split guest RAM backings of a microVM snapshot restore. The +/// backed RAM ranges are removed from `ranges_by_node`, so the generic +/// per-node backings skip them. +pub(super) fn add_snapshot_restore_backing( + mut memory_builder: GuestMemoryBuilder, + cfg: &Manifest, + ranges_by_node: &mut [Vec], + nodes_with_ranges: usize, + existing_mappable: &mut Option<(Mappable, FileMappingMode)>, +) -> anyhow::Result { + if cfg.microvm.snapshot_memory_ranges.is_empty() { + return Ok(memory_builder); + } + + anyhow::ensure!( + cfg.machine_profile == MachineProfile::Microvm && nodes_with_ranges == 1, + "snapshot RAM range restore requires a single-node microVM" + ); + let active_ranges = ranges_by_node + .iter_mut() + .find(|ranges| !ranges.is_empty()) + .expect("nodes_with_ranges is one"); + let mut restored_ranges = cfg.microvm.snapshot_memory_ranges.clone(); + restored_ranges.extend_from_slice(&cfg.microvm.restore_memory_ranges); + anyhow::ensure!( + coalesce_adjacent_ranges(&restored_ranges)? == *active_ranges, + "snapshot base and expansion ranges do not match the selected RAM layout" + ); + + let (mappable, file_mapping_mode) = existing_mappable + .take() + .context("snapshot RAM ranges require an existing memory backing")?; + let mem = cfg.numa.nodes[0] + .mem + .as_ref() + .context("snapshot RAM ranges require node 0 memory configuration")?; + let base_backing = + membacking::RamBackingRequest::new(cfg.microvm.snapshot_memory_ranges.clone()) + .prefetch(mem.prefetch_memory) + .transparent_hugepages(mem.transparent_hugepages) + .host_numa_node(mem.host_numa_node) + .existing_mappable(mappable) + .file_mapping_mode(file_mapping_mode); + memory_builder = memory_builder.add_backing(base_backing); + + if !cfg.microvm.restore_memory_ranges.is_empty() { + let expansion_backing = + membacking::RamBackingRequest::new(cfg.microvm.restore_memory_ranges.clone()) + .prefetch(mem.prefetch_memory) + .private_memory(true) + .transparent_hugepages(mem.transparent_hugepages) + .host_numa_node(mem.host_numa_node); + memory_builder = memory_builder.add_backing(expansion_backing); + } + active_ranges.clear(); + Ok(memory_builder) +} + +fn coalesce_adjacent_ranges(ranges: &[MemoryRange]) -> anyhow::Result> { + let mut coalesced: Vec = Vec::with_capacity(ranges.len()); + for &range in ranges { + anyhow::ensure!(!range.is_empty(), "snapshot RAM range is empty"); + if let Some(previous) = coalesced.last_mut() { + anyhow::ensure!( + range.start() >= previous.end(), + "snapshot RAM ranges overlap or are out of order" + ); + if range.start() == previous.end() { + *previous = MemoryRange::new(previous.start()..range.end()); + continue; + } + } + coalesced.push(range); + } + Ok(coalesced) +} + +pub(super) fn uses_lazy_memory_registration( + cfg: &Manifest, + shared_memory: Option<&SharedMemoryBacking>, +) -> bool { + #[cfg(all(windows, feature = "virt_whp"))] + let has_vpci_resources = !cfg.vpci_resources.is_empty(); + #[cfg(not(all(windows, feature = "virt_whp")))] + let has_vpci_resources = false; + let prefetch_memory = cfg + .numa + .nodes + .iter() + .any(|node| node.mem.as_ref().is_some_and(|mem| mem.prefetch_memory)); + + cfg!(all(windows, feature = "virt_whp", guest_arch = "x86_64")) + && cfg.machine_profile == MachineProfile::Microvm + && cfg.hypervisor.with_vtl2.is_none() + && cfg.microvm.restore_memory_ranges.is_empty() + && !has_vpci_resources + && !prefetch_memory + && shared_memory.is_some_and(SharedMemoryBacking::is_copy_on_write) +} + pub(super) fn uses_versioned_cpu_contract(machine_profile: MachineProfile) -> bool { machine_profile == MachineProfile::Microvm } diff --git a/openvmm/openvmm_core/src/worker/memory_layout.rs b/openvmm/openvmm_core/src/worker/memory_layout.rs index 48b1cb978a9..adb552ac062 100644 --- a/openvmm/openvmm_core/src/worker/memory_layout.rs +++ b/openvmm/openvmm_core/src/worker/memory_layout.rs @@ -121,6 +121,10 @@ pub(super) struct MemoryLayoutInput<'a> { /// memory-less nodes (e.g. device-only NUMA nodes). The request /// order is the vnode assignment order. pub node_mem_sizes: &'a [u64], + /// Optional single-node RAM capacity used for restore-time expansion. + /// The layout reserves this full amount while publishing only + /// `node_mem_sizes[0]` as guest-visible RAM. + pub memory_capacity: Option, /// Chipset MMIO sizing from the manifest builder. pub layout: vmm_core_defs::LayoutConfig, /// PCIe root complex address-space intents. These are resolved by this @@ -158,6 +162,7 @@ pub(super) fn resolve_memory_layout( input: MemoryLayoutInput<'_>, ) -> anyhow::Result { validate_node_mem_sizes(input.node_mem_sizes)?; + microvm::validate_memory_capacity(input.node_mem_sizes, input.memory_capacity)?; let mut ram_ranges_by_node = vec![Vec::new(); input.node_mem_sizes.len()]; let mut pcie_root_complex_ranges = input @@ -382,6 +387,7 @@ pub(super) fn resolve_memory_layout( if ram_size == 0 { continue; } + let ram_size = microvm::layout_ram_size(vnode, ram_size, input.memory_capacity); let ram_alignment = if ram_size < GB { TWO_MB } else { GB }; builder.ram(format!("ram{vnode}"), ram_ranges, ram_size, ram_alignment); } @@ -482,6 +488,7 @@ pub(super) fn resolve_memory_layout( } } + let ram_ranges_by_node = microvm::active_ram_ranges(ram_ranges_by_node, input.node_mem_sizes); let ram = ram_ranges_by_node .into_iter() .enumerate() @@ -643,6 +650,7 @@ mod tests { ) -> MemoryLayoutInput<'_> { MemoryLayoutInput { node_mem_sizes, + memory_capacity: None, layout: DEFAULT_LAYOUT, pcie_root_complexes: &[], virtio_mmio_count: 0, diff --git a/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs b/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs index 37a8a3d6c5c..6e49143e41c 100644 --- a/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs +++ b/openvmm/openvmm_core/src/worker/memory_layout/microvm.rs @@ -1,14 +1,76 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! microVM memory layout tests: the fixed microVM chipset layout reserves the -//! 3-GiB to 4-GiB range for MMIO and resumes RAM above 4 GiB. +//! microVM memory-capacity layout support. + +use memory_range::MemoryRange; + +pub(super) fn validate_memory_capacity( + node_mem_sizes: &[u64], + memory_capacity: Option, +) -> anyhow::Result<()> { + if let Some(memory_capacity) = memory_capacity { + anyhow::ensure!( + node_mem_sizes.len() == 1, + "RAM capacity requires a single NUMA node" + ); + anyhow::ensure!( + memory_capacity >= node_mem_sizes[0], + "RAM capacity {memory_capacity:#x} is below active RAM size {:#x}", + node_mem_sizes[0] + ); + anyhow::ensure!( + memory_capacity.is_multiple_of(super::PAGE_SIZE), + "RAM capacity {memory_capacity:#x} is not page-aligned" + ); + } + Ok(()) +} + +pub(super) fn layout_ram_size(vnode: usize, active_size: u64, memory_capacity: Option) -> u64 { + if vnode == 0 { + memory_capacity.unwrap_or(active_size) + } else { + active_size + } +} + +/// Trims each node's layout RAM to the guest-visible active RAM, excluding the +/// reserved restore-time capacity. +pub(super) fn active_ram_ranges( + ranges_by_node: Vec>, + node_mem_sizes: &[u64], +) -> Vec> { + ranges_by_node + .into_iter() + .zip(node_mem_sizes) + .map(|(ranges, &active_size)| { + memory_range_prefix(&ranges, active_size).expect("layout RAM must cover active RAM") + }) + .collect() +} + +fn memory_range_prefix(ranges: &[MemoryRange], size: u64) -> anyhow::Result> { + let mut remaining = size; + let mut prefix = Vec::new(); + for range in ranges { + if remaining == 0 { + break; + } + let length = range.len().min(remaining); + prefix.push(MemoryRange::new(range.start()..range.start() + length)); + remaining -= length; + } + anyhow::ensure!(remaining == 0, "RAM ranges do not cover requested prefix"); + Ok(prefix) +} #[cfg(test)] mod tests { use super::super::*; use test_with_tracing::test; + const MB: u64 = 1024 * 1024; #[cfg(guest_arch = "x86_64")] const DEFAULT_CHIPSET_LOW_MMIO_SIZE: u32 = 128 * 1024 * 1024; #[cfg(guest_arch = "aarch64")] @@ -27,6 +89,7 @@ mod tests { ) -> MemoryLayoutInput<'_> { MemoryLayoutInput { node_mem_sizes, + memory_capacity: None, layout: DEFAULT_LAYOUT, pcie_root_complexes: &[], virtio_mmio_count: 0, @@ -63,4 +126,40 @@ mod tests { ] ); } + + #[test] + fn memory_capacity_reserves_aperture_without_publishing_it_as_ram() { + let mut config = input(&[512 * MB], None); + config.memory_capacity = Some(2 * GB); + config.layout = vmm_core_defs::LayoutConfig { + chipset_low_mmio_size: GB as u32, + chipset_high_mmio_size: GB, + vtl2_chipset_mmio_size: 0, + }; + let layout = config.layout.clone(); + + let resolved = resolve_memory_layout(config).unwrap(); + + assert_eq!( + resolved.memory_layout.ram(), + &[MemoryRangeWithNode { + range: MemoryRange::new(0..512 * MB), + vnode: 0, + }] + ); + let reserved_high_mmio_start = resolved.chipset_mmio.high.start(); + + let mut expanded = input(&[GB], None); + expanded.memory_capacity = Some(2 * GB); + expanded.layout = layout; + let expanded = resolve_memory_layout(expanded).unwrap(); + assert_eq!( + expanded.memory_layout.ram(), + &[MemoryRangeWithNode { + range: MemoryRange::new(0..GB), + vnode: 0, + }] + ); + assert_eq!(expanded.chipset_mmio.high.start(), reserved_high_mmio_start); + } } diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 8df8ea032fe..c21b1c0f878 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -15,6 +15,7 @@ use crate::config::VirtioBus; use crate::config::X2ApicConfig; use crate::config::X86TopologyConfig; use guid::Guid; +use memory_range::MemoryRange; use mesh::MeshPayload; use net_backend_resources::mac_address::MacAddress; use std::fmt::Write as _; @@ -22,6 +23,8 @@ use vmotherboard::options::BaseChipsetManifest; /// The persisted microVM ABI version. pub const MICROVM_ABI_VERSION_2: u32 = 2; +/// Linux memory-block granularity used for microVM restore-time expansion. +pub const MICROVM_MEMORY_BLOCK_SIZE_BYTES: u64 = 128 * 1024 * 1024; /// Returns whether a processor count is valid for the microVM. pub const fn microvm_processor_count_supported(processor_count: u32) -> bool { @@ -846,6 +849,12 @@ pub struct MicrovmConfig { pub sandbox_blocks: Vec, /// Whether the effective command line bootstraps the active microVM filesystem. pub filesystem_bootstrap: bool, + /// Immutable RAM capacity reserved by the microVM layout. + pub memory_capacity: Option, + /// Snapshot-backed GPA ranges restored from the base `memory.bin`. + pub snapshot_memory_ranges: Vec, + /// Fresh private GPA ranges selected for this restore launch. + pub restore_memory_ranges: Vec, } fn validate_machine_load_mode( @@ -938,6 +947,12 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> config.microvm.sandbox_blocks.is_empty(), "microVM sandbox block roles require the microVM profile" ); + anyhow::ensure!( + config.microvm.memory_capacity.is_none() + && config.microvm.snapshot_memory_ranges.is_empty() + && config.microvm.restore_memory_ranges.is_empty(), + "microVM memory expansion configuration requires the microVM profile" + ); return Ok(()); }; @@ -973,7 +988,24 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> config.numa.nodes.len() == 1 && config.numa.distances.is_empty(), "microVM requires a single NUMA node" ); - anyhow::ensure!(config.numa.nodes[0].mem.is_some(), "microVM requires RAM"); + let Some(memory) = config.numa.nodes[0].mem.as_ref() else { + anyhow::bail!("microVM requires RAM"); + }; + let memory_size = memory.mem_size; + if let Some(memory_capacity) = config.microvm.memory_capacity { + anyhow::ensure!( + memory_size <= memory_capacity + && memory_size.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES) + && memory_capacity.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "microVM RAM size and capacity must be ordered and 128-MiB aligned" + ); + } else { + anyhow::ensure!( + config.microvm.snapshot_memory_ranges.is_empty() + && config.microvm.restore_memory_ranges.is_empty(), + "microVM snapshot RAM ranges require a RAM capacity" + ); + } anyhow::ensure!( !config.hypervisor.with_hv && config.hypervisor.with_vtl2.is_none() diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index b7e34b8d226..34c166f5a31 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -1621,7 +1621,7 @@ impl VtlPartition { // for ROM regions, resulting in an extra syscall and C++ exception for // each such exit. We know locally whether memory is supposed to be // mapped writable, so we can avoid this. - // Pure writable-COW restores leave these faults to WHP instead, + // Pure writable-COW microVM restores leave these faults to WHP instead, // avoiding one VP exit and populate call per first-touch page. // TODO-aarch64 if cfg!(guest_arch = "x86_64") && config.user_mode_memory_faults { From e01fb70c3b647cb37545207326eebc128b317b81 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:31:35 -0700 Subject: [PATCH 131/304] openvmm: add bounded restore-time memory expansion A microVM template must capture with the RAM it booted with, and every clone restores exactly that size, so a template for large workloads has to boot and snapshot all of its RAM. Capture should instead record how far a clone may grow, and restore should let a clone choose its RAM up to that bound while `memory.bin` holds only the captured base. Add opt-in RAM expansion on restore: - `--memory-capacity ` requires `--snapshot-destination`. The capacity must be at least the base `--memory` size, and both must be aligned to the 128-MiB guest memory block. The layout reserves the capacity's canonical GPA aperture, while the boot RAM map and `memory.bin` contain only the base. - The machine contract records the expansion capability in fields 27 through 30: the contract version (`MICROVM_MEMORY_EXPANSION_VERSION`), the capacity, the memory block size, and the canonical ranges between the base and the capacity (`SnapshotMemoryExpansionRange`). Contract validation requires those values to be canonical, a snapshot without the capability to leave them empty, and a restore to match the requested machine. - `--restore-memory ` requires `--restore-snapshot` and an expansion-capable snapshot. The size must be block-aligned, from the captured base through the capacity, and selects the canonical prefix of the expansion ranges. The base remains the private copy-on-write mapping of `memory.bin`, and the selected ranges get fresh zeroed private memory in the worker. - An explicit target sends the new `OPENVMM_ENTROPY_V3\0` packet: the one-byte online-VP target (zero when absent), the one-byte range count, the little-endian GPA and length of each range, and 64 bytes of entropy. Selecting the base size still sends V3 with no ranges, and omitting the option keeps V1 and V2. portb status bit 3 reports a V3 memory target, and bit 4 reports that the packet has ranges, so a zero-range target needs no repair. A target with ranges implies the post-restore gate. - A standard machine rejects both options. The CLI restore path now validates the manifest against its own base RAM size, because the launch size may be a larger expansion target, and records the selected size in the restore profile. Document both options and the V3 packet. Tests cover the capacity contract and its canonical ranges, the selection and rejection of restore targets, the V3 packet layout, the portb memory status bits, and the CLI parsing and alignment checks, which replace the earlier capture-options test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 30 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 112 +++--- openvmm/openvmm_entry/src/microvm/config.rs | 42 ++- .../openvmm_entry/src/microvm/filesystem.rs | 2 + openvmm/openvmm_entry/src/microvm/launch.rs | 1 + openvmm/openvmm_entry/src/microvm/network.rs | 1 + openvmm/openvmm_entry/src/microvm/restore.rs | 136 ++++++- .../src/snapshot_restore/prepare.rs | 5 +- .../src/vm_controller/microvm.rs | 3 + .../openvmm_helpers/src/snapshot/microvm.rs | 335 ++++++++++++++++++ vm/devices/chipset/src/microvm.rs | 151 +++++++- 11 files changed, 748 insertions(+), 70 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index b8266689e6b..e0cbac94a47 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -155,6 +155,12 @@ describes the source definitions. when attached, its negotiated FUSE policy, namespace and handle identifiers, aliases, and directory cookies. The host tree remains external live state. + `--memory-capacity ` opts the snapshot into restore-time memory + expansion. `SIZE` is an immutable 128-MiB-aligned upper bound, must be at + least the base `--memory` size, and reserves the complete canonical GPA + aperture without adding it to the initial Linux direct e820 RAM map or + `memory.bin`. + ```bash openvmm --machine microvm --hypervisor kvm --memory 128M \ --kernel vmlinux --initrd initramfs.cpio.gz \ @@ -170,9 +176,10 @@ describes the source definitions. The manifest supplies the authoritative RAM size, topology, ABI, fixed device inventory, effective kernel command line, source backend, CPU contract, and TSC frequency. Kernel, initrd, command-line, ordinary - `--memory`, processor, device, and topology overrides are not accepted. - Repeat the snapshot's exact `--processors` count; a mismatch is rejected - before any VP starts. Restore requires the same backend kind as capture. + `--memory`, processor, device, and topology overrides are not accepted; + expansion-capable snapshots use only `--restore-memory`. Repeat the + snapshot's exact `--processors` count; a mismatch is rejected before any VP + starts. Restore requires the same backend kind as capture. When the snapshot contains a virtio console, its attachment policy comes from the manifest. OpenVMM recreates listeners, reconnects required clients, @@ -199,6 +206,12 @@ describes the source definitions. rejected. A fresh-scratch snapshot instead requires a writable scratch argument with matching geometry. + `--restore-memory ` selects the total RAM for this launch. It requires + an expansion-capable snapshot and a 128-MiB-aligned value from the exact + captured base through the immutable capacity. Base RAM remains a private + copy-on-write mapping of `memory.bin`; selected expansion ranges use fresh + zeroed private backing. Expansion implies the post-restore repair gate. + ```bash openvmm --machine microvm --hypervisor kvm \ --restore-snapshot snapshot --restore-entropy @@ -219,7 +232,16 @@ describes the source definitions. the private portb restore channel. The guest must consume the packet and explicitly reseed its RNG. Restoring cloned RNG state without this option is unsafe for cryptographic workloads and emits a warning. - Processor activation uses `OPENVMM_ENTROPY_V2`. + Processor activation uses `OPENVMM_ENTROPY_V2`. Memory expansion uses the + backward-compatible `OPENVMM_ENTROPY_V3` packet. Its exact format is the + 19-byte `OPENVMM_ENTROPY_V3\0` header, a one-byte online-VP target (zero + means none), a one-byte expansion-range count, that many little-endian + `(u64 GPA start, u64 byte length)` pairs, and 64 bytes of fresh entropy. + Explicitly selecting the snapshot base size with `--restore-memory` still + emits V3 with an expansion-range count of zero; omitting the option preserves + V1/V2 behavior. Private portb status bit 3 reports a V3 memory target, while + bit 4 additionally reports that the packet contains one or more expansion + ranges, allowing a zero-range target to avoid post-restore repair. Every microVM portb device also reports generation-ID support in status bit 5. Writing `0xa6` to the status port and reading 16 bytes from the data port returns an opaque ID that is stable for that VM process and may be selected diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 9d4efa83e0d..26518af7198 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -91,6 +91,10 @@ pub struct MicrovmCli { #[clap(long, value_name = "COUNT", requires = "restore_snapshot")] pub restore_processors: Option, + /// Restore a capable microVM snapshot with this total guest RAM size. + #[clap(long, value_name = "SIZE", requires = "restore_snapshot")] + pub restore_memory: Option, + /// Maximum time allowed for a microVM guest to complete post-restore repair. #[clap(long, value_name = "MILLISECONDS", default_value_t = 60000)] pub restore_gate_timeout_ms: u64, @@ -99,6 +103,10 @@ pub struct MicrovmCli { #[clap(long, value_name = "DIR", conflicts_with = "restore_snapshot")] pub snapshot_destination: Option, + /// Reserve this immutable total RAM capacity in a captured microVM snapshot. + #[clap(long, value_name = "SIZE", requires = "snapshot_destination")] + pub memory_capacity: Option, + /// Sandbox capture tier. Required for microVM snapshot capture with sandbox blocks. #[clap( long, @@ -223,8 +231,10 @@ impl Options { self.microvm.network_profile.is_none() && self.microvm.microvm_mount.is_none() && self.microvm.microvm_sandbox_block.is_empty() - && self.microvm.restore_processors.is_none(), - "--network-profile, --mount, --microvm-sandbox-block, and --restore-processors require a microVM machine" + && self.microvm.restore_processors.is_none() + && self.microvm.restore_memory.is_none() + && self.microvm.memory_capacity.is_none(), + "--network-profile, --mount, --microvm-sandbox-block, --restore-processors, --restore-memory, and --memory-capacity require a microVM machine" ); return Ok(()); } @@ -267,6 +277,20 @@ impl Options { != self.microvm.microvm_sandbox_block.is_empty(), "--snapshot-tier is required exactly for microVM snapshot capture with sandbox blocks" ); + if let Some(memory_capacity) = self.microvm.memory_capacity { + anyhow::ensure!( + memory_capacity.0 >= self.memory_size(), + "--memory-capacity must be at least the base --memory size" + ); + anyhow::ensure!( + self.memory_size().is_multiple_of( + openvmm_helpers::snapshot::microvm::MICROVM_MEMORY_BLOCK_SIZE_BYTES + ) && memory_capacity.0.is_multiple_of( + openvmm_helpers::snapshot::microvm::MICROVM_MEMORY_BLOCK_SIZE_BYTES + ), + "--memory and --memory-capacity must be aligned to the 128-MiB microVM memory block size" + ); + } } if self.restore_snapshot.is_some() { anyhow::ensure!( @@ -584,50 +608,23 @@ mod tests { } #[test] - fn test_microvm_snapshot_capture_options() { + fn test_microvm_memory_capacity_and_restore_target_parsing() { let capture = Options::try_parse_from([ "openvmm", "--machine", "microvm", - "--processors", - "2", + "--memory", + "512M", "--snapshot-destination", "snapshot", + "--memory-capacity", + "2G", ]) .unwrap(); - assert_eq!(capture.microvm.snapshot_quiesce_timeout_ms, 5000); capture.validate_microvm_options().unwrap(); - - for extra in [ - vec!["--snapshot-quiesce-timeout-ms", "0"], - vec!["--memory", "size=1G,shared=off"], - ] { - let options = Options::try_parse_from( - [ - "openvmm", - "--machine", - "microvm", - "--snapshot-destination", - "snapshot", - ] - .into_iter() - .chain(extra), - ) - .unwrap(); - assert!(options.validate_microvm_options().is_err()); - } - - assert!( - Options::try_parse_from([ - "openvmm", - "--machine", - "microvm", - "--snapshot-destination", - "snapshot", - "--restore-snapshot", - "snapshot", - ]) - .is_err() + assert_eq!( + capture.microvm.memory_capacity.unwrap().0, + 2 * 1024 * 1024 * 1024 ); let restore = Options::try_parse_from([ @@ -636,24 +633,37 @@ mod tests { "microvm", "--restore-snapshot", "snapshot", + "--restore-memory", + "512M", ]) .unwrap(); - assert_eq!(restore.memory, Default::default()); restore.validate_microvm_options().unwrap(); - for override_arg in ["--kernel", "--initrd"] { - assert!( - Options::try_parse_from([ - "openvmm", - "--machine", - "microvm", - "--restore-snapshot", - "snapshot", - override_arg, - "file", - ]) + assert_eq!(restore.microvm.restore_memory.unwrap().0, 512 * 1024 * 1024); + + assert!( + Options::try_parse_from( + ["openvmm", "--machine", "microvm", "--memory-capacity", "2G",] + ) + .is_err() + ); + assert!( + Options::try_parse_from(["openvmm", "--machine", "microvm", "--restore-memory", "1G",]) .is_err() - ); - } + ); + + let unaligned = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--memory", + "513M", + "--snapshot-destination", + "snapshot", + "--memory-capacity", + "2G", + ]) + .unwrap(); + assert!(unaligned.validate_microvm_options().is_err()); } #[test] diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 82d3a7bbbcc..1613b074d0f 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -314,11 +314,16 @@ impl<'a> MicrovmConfigBuilder<'a> { return Ok(()); }; let opt = self.opt; - let (generation_id, restore_entropy) = if opt.microvm.restore_entropy { - fresh_microvm_restore_packet(opt.microvm.restore_processors)? - } else { - (fresh_microvm_generation_id()?, Vec::new()) - }; + let (generation_id, restore_entropy) = + if opt.microvm.restore_entropy || self.restore.memory_target_requested { + fresh_microvm_restore_packet( + opt.microvm.restore_processors, + self.restore.memory_target_requested, + &self.restore.memory_ranges, + )? + } else { + (fresh_microvm_generation_id()?, Vec::new()) + }; chipset_devices.push(ChipsetDeviceHandle { name: MicrovmPortbHandle::ID.to_owned(), resource: MicrovmPortbHandle { @@ -533,6 +538,33 @@ impl<'a> MicrovmConfigBuilder<'a> { .map(|contract| contract.microvm_filesystem.is_some()) .unwrap_or_else(|| microvm_filesystem.is_some()); cfg.microvm.filesystem = microvm_filesystem; + cfg.microvm.memory_capacity = restore_machine_contract + .and_then(|contract| { + (contract.memory_expansion_version != 0).then_some(contract.memory_capacity_bytes) + }) + .or(opt.microvm.memory_capacity.map(|capacity| capacity.0)); + cfg.microvm.snapshot_memory_ranges = restore_machine_contract + .filter(|contract| contract.memory_expansion_version != 0) + .map(|contract| { + contract + .memory_ranges + .iter() + .map(|range| { + memory_range::MemoryRange::new( + range.gpa_start..range.gpa_start + range.length, + ) + }) + .collect() + }) + .unwrap_or_default(); + cfg.microvm.restore_memory_ranges = self + .restore + .memory_ranges + .iter() + .map(|range| { + memory_range::MemoryRange::new(range.gpa_start..range.gpa_start + range.length) + }) + .collect(); let requested_hypervisor = opt .hypervisor diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index 454b8b0d728..6626e245d03 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -353,6 +353,7 @@ mod tests { Vec::new(), 1, 1024, + None, [ "partition", "vmtime", @@ -398,6 +399,7 @@ mod tests { Vec::new(), 1, 1024, + None, [ "partition", "vmtime", diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 845aff38020..0628c0791cc 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -258,6 +258,7 @@ impl MicrovmLaunch { MicrovmController { active: self.active, snapshot_memory_handle: self.snapshot_memory_handle, + memory_capacity: opt.microvm.memory_capacity.map(|capacity| capacity.0), snapshot_requests, snapshot_destination: self.snapshot_destination, snapshot_tier: opt.microvm.snapshot_tier, diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index bd9f13f2caa..d49d286bd02 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -168,6 +168,7 @@ pub(crate) mod tests { Vec::new(), 1, 1024, + None, [ "partition", "vmtime", diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index d4ad0d6f79f..c3c2c9af3b2 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! MicroVM snapshot restore-time contract checks. +//! MicroVM snapshot restore packets and restore-time contract checks. use super::filesystem::microvm_filesystem_slot_from_snapshot; use crate::Options; @@ -15,6 +15,7 @@ use openvmm_defs::microvm::MicrovmNetworkConfig; use openvmm_helpers::snapshot::SnapshotManifest; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use openvmm_helpers::snapshot::microvm::SnapshotMachineContract; +use openvmm_helpers::snapshot::microvm::SnapshotMemoryExpansionRange; use openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock; use openvmm_helpers::snapshot::restore::OpenedSnapshot; use std::path::Path; @@ -39,8 +40,39 @@ fn calculate_snapshot_downtime( fn microvm_restore_packet( entropy: &[u8; 64], restore_online_vp_count: Option, + restore_memory_target_requested: bool, + restore_memory_ranges: &[SnapshotMemoryExpansionRange], ) -> anyhow::Result> { - let mut packet = if let Some(count) = restore_online_vp_count { + anyhow::ensure!( + restore_memory_target_requested || restore_memory_ranges.is_empty(), + "restore memory ranges require an explicit memory target" + ); + let mut packet = if restore_memory_target_requested { + // V3 is: 19-byte NUL-terminated header, u8 online-VP target (zero + // means absent), u8 range count, repeated little-endian (u64 GPA, + // u64 length) pairs, and 64 bytes of entropy. The exact GPA ranges + // keep guest repair independent of the host's layout implementation. + let range_count = u8::try_from(restore_memory_ranges.len()) + .context("restore memory range count does not fit in u8")?; + let mut packet = b"OPENVMM_ENTROPY_V3\0".to_vec(); + let online_vp_count = restore_online_vp_count + .map(u8::try_from) + .transpose() + .context("restore-online VP count does not fit in u8")? + .unwrap_or(0); + packet.push(online_vp_count); + packet.push(range_count); + for range in restore_memory_ranges { + anyhow::ensure!(range.length != 0, "restore memory range is empty"); + range + .gpa_start + .checked_add(range.length) + .context("restore memory range overflows GPA space")?; + packet.extend_from_slice(&range.gpa_start.to_le_bytes()); + packet.extend_from_slice(&range.length.to_le_bytes()); + } + packet + } else if let Some(count) = restore_online_vp_count { let count = u8::try_from(count).context("restore-online VP count does not fit in u8")?; let mut packet = b"OPENVMM_ENTROPY_V2\0".to_vec(); packet.push(count); @@ -66,12 +98,19 @@ pub(crate) fn fresh_microvm_generation_id() -> anyhow::Result<[u8; 16]> { pub(crate) fn fresh_microvm_restore_packet( restore_online_vp_count: Option, + restore_memory_target_requested: bool, + restore_memory_ranges: &[SnapshotMemoryExpansionRange], ) -> anyhow::Result<([u8; 16], Vec)> { let generation_id_create = openvmm_defs::profile::ProfileSpan::start(); let mut entropy = [0_u8; 64]; getrandom::fill(&mut entropy).context("failed to generate restore entropy")?; let generation_id = microvm_generation_id(&entropy); - let packet = microvm_restore_packet(&entropy, restore_online_vp_count)?; + let packet = microvm_restore_packet( + &entropy, + restore_online_vp_count, + restore_memory_target_requested, + restore_memory_ranges, + )?; generation_id_create.complete("restore", "generation_id_create", Default::default()); Ok((generation_id, packet)) } @@ -83,6 +122,10 @@ pub(crate) struct MicrovmRestore { pub(crate) machine_contract: Option, /// Whether the guest must complete post-restore repair before readiness. pub(crate) gate_required: bool, + /// Whether `--restore-memory` selected an explicit RAM target. + pub(crate) memory_target_requested: bool, + /// Fresh private GPA ranges selected for this restore. + pub(crate) memory_ranges: Vec, /// Private copy of a paired scratch image, kept alive for the VM lifetime. pub(crate) private_scratch_dir: Option, } @@ -98,6 +141,8 @@ pub(crate) fn prepare_restore( ) -> anyhow::Result { let mut private_scratch_dir = None; let mut restore_gate_required = false; + let restore_memory_target_requested = opt.microvm.restore_memory.is_some(); + let mut restore_memory_ranges = Vec::new(); if let Some(contract) = restore_snapshot.and_then(|snapshot| snapshot.manifest().machine_contract.as_ref()) && contract.machine_profile == "microvm" @@ -134,6 +179,21 @@ pub(crate) fn prepare_restore( )?; restore_gate_required = true; } + let restore_memory_size = opt + .microvm + .restore_memory + .map(|memory| memory.0) + .unwrap_or(manifest.memory_size_bytes); + if restore_memory_target_requested { + restore_memory_ranges = + openvmm_helpers::snapshot::microvm::validate_restore_memory_target( + manifest, + restore_memory_size, + )?; + } + if !restore_memory_ranges.is_empty() { + restore_gate_required = true; + } anyhow::ensure!( opt.cmdline.is_empty(), "restore-time command-line overrides are not allowed" @@ -146,7 +206,7 @@ pub(crate) fn prepare_restore( && opt.deprecated_memory_backing_file.is_none(), "restore-time memory overrides are not allowed" ); - opt.memory.size = Some(vmm_cli::MemorySize(manifest.memory_size_bytes)); + opt.memory.size = Some(vmm_cli::MemorySize(restore_memory_size)); if !contract.microvm_sandbox_blocks.is_empty() { let scratch = contract .microvm_sandbox_blocks @@ -219,7 +279,10 @@ pub(crate) fn prepare_restore( if restore_gate_required || opt.microvm.restore_processors.is_some() { opt.microvm.restore_entropy = true; } - if restore_machine_contract.is_some() && !opt.microvm.restore_entropy { + if restore_machine_contract.is_some() + && !opt.microvm.restore_entropy + && !restore_memory_target_requested + { tracing::warn!( "restoring cloned guest RNG state without fresh entropy injection; cryptographic workloads are unsafe" ); @@ -227,6 +290,8 @@ pub(crate) fn prepare_restore( Ok(MicrovmRestore { machine_contract: restore_machine_contract, gate_required: restore_gate_required, + memory_target_requested: restore_memory_target_requested, + memory_ranges: restore_memory_ranges, private_scratch_dir, }) } @@ -287,6 +352,8 @@ pub(crate) fn validate_restore_contract( sandbox_blocks, expected_vp_count, expected_memory_size, + (saved_contract.memory_expansion_version != 0) + .then_some(saved_contract.memory_capacity_bytes), saved_contract.state_unit_names.clone(), saved_contract.capture_wall_clock, saved_contract.tsc_frequency_hz, @@ -313,6 +380,7 @@ pub(crate) fn validate_restore_contract( #[cfg(test)] mod tests { use super::*; + use openvmm_helpers::snapshot::microvm::SnapshotMemoryExpansionRange; use test_with_tracing::test; #[test] @@ -353,13 +421,67 @@ mod tests { fn restore_packet_versions_preserve_entropy_and_online_target() { let entropy = [0x5a; 64]; assert_eq!(microvm_generation_id(&entropy), [0x5a; 16]); - let v1 = microvm_restore_packet(&entropy, None).unwrap(); + let v1 = microvm_restore_packet(&entropy, None, false, &[]).unwrap(); assert_eq!(&v1[..19], b"OPENVMM_ENTROPY_V1\0"); assert_eq!(&v1[19..], &entropy); - let v2 = microvm_restore_packet(&entropy, Some(8)).unwrap(); + let v2 = microvm_restore_packet(&entropy, Some(8), false, &[]).unwrap(); assert_eq!(&v2[..19], b"OPENVMM_ENTROPY_V2\0"); assert_eq!(v2[19], 8); assert_eq!(&v2[20..], &entropy); + + let ranges = [ + SnapshotMemoryExpansionRange { + gpa_start: 0x2000_0000, + length: 0x2000_0000, + }, + SnapshotMemoryExpansionRange { + gpa_start: 0x1_0000_0000, + length: 0x4000_0000, + }, + ]; + let v3 = microvm_restore_packet(&entropy, Some(4), true, &ranges).unwrap(); + assert_eq!(&v3[..19], b"OPENVMM_ENTROPY_V3\0"); + assert_eq!(v3[19], 4); + assert_eq!(v3[20], 2); + assert_eq!( + &v3[21..37], + &[ + ranges[0].gpa_start.to_le_bytes(), + ranges[0].length.to_le_bytes(), + ] + .concat() + ); + assert_eq!( + &v3[37..53], + &[ + ranges[1].gpa_start.to_le_bytes(), + ranges[1].length.to_le_bytes(), + ] + .concat() + ); + assert_eq!(&v3[53..], &entropy); + + let v3_without_cpu = microvm_restore_packet(&entropy, None, true, &ranges[..1]).unwrap(); + assert_eq!(&v3_without_cpu[..19], b"OPENVMM_ENTROPY_V3\0"); + assert_eq!(v3_without_cpu[19], 0); + assert_eq!(v3_without_cpu[20], 1); + assert_eq!( + &v3_without_cpu[21..37], + &[ + ranges[0].gpa_start.to_le_bytes(), + ranges[0].length.to_le_bytes(), + ] + .concat() + ); + assert_eq!(&v3_without_cpu[37..], &entropy); + + let v3_explicit_base = microvm_restore_packet(&entropy, None, true, &[]).unwrap(); + assert_eq!(&v3_explicit_base[..19], b"OPENVMM_ENTROPY_V3\0"); + assert_eq!(v3_explicit_base[19], 0); + assert_eq!(v3_explicit_base[20], 0); + assert_eq!(&v3_explicit_base[21..], &entropy); + + assert!(microvm_restore_packet(&entropy, None, false, &ranges[..1]).is_err()); } } diff --git a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs index 04fc22412e6..70b81ed8059 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs @@ -26,10 +26,12 @@ pub(super) fn prepare_snapshot_restore( microvm: µvm::MicrovmLaunch, expected_hypervisor: &str, ) -> anyhow::Result { + let base_memory_size = snapshot.manifest().memory_size_bytes; let expected_microvm_contract = microvm.expected_restore_contract(opt, snapshot.manifest(), expected_hypervisor)?; prepare_snapshot_restore_for_config( snapshot, + base_memory_size, opt.memory_size(), opt.processors, expected_microvm_contract, @@ -39,6 +41,7 @@ pub(super) fn prepare_snapshot_restore( pub(crate) fn prepare_snapshot_restore_for_config( snapshot: openvmm_helpers::snapshot::restore::OpenedSnapshot, expected_memory_size: u64, + selected_memory_size: u64, expected_vp_count: u32, expected_microvm_contract: Option>, ) -> anyhow::Result { @@ -87,7 +90,7 @@ pub(crate) fn prepare_snapshot_restore_for_config( "restore", "artifact_prepare", openvmm_defs::profile::ProfileCounters { - logical_bytes: Some(expected_memory_size), + logical_bytes: Some(selected_memory_size), ..Default::default() }, ); diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 7fb5d933838..a8294c54fd1 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -19,6 +19,8 @@ pub(crate) struct MicrovmController { pub(crate) active: bool, /// Exact open handle of the file backing guest RAM for snapshot capture. pub(crate) snapshot_memory_handle: Option, + /// Immutable RAM capacity reserved in captured snapshots. + pub(crate) memory_capacity: Option, /// Guest-requested snapshot boundaries released by the VM worker. pub(crate) snapshot_requests: Option>, @@ -227,6 +229,7 @@ impl VmController { blocks, self.processors, self.memory, + self.microvm.memory_capacity, response.state_unit_names, response.capture_wall_clock, response.tsc_frequency_hz, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 8d3236df45a..aeb804ff4a5 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -28,6 +28,11 @@ use std::path::Path; pub const MICROVM_FILESYSTEM_SLOT_VERSION: u32 = 1; /// Linux-direct MP-table boot layout with shared interrupt status. pub const MICROVM_BOOT_LAYOUT_VERSION: u32 = 2; +/// Contract version for one-shot restore-time microVM memory expansion. +pub const MICROVM_MEMORY_EXPANSION_VERSION: u32 = 1; +/// Linux memory-block granularity used by the x86-64 microVM guest. +pub const MICROVM_MEMORY_BLOCK_SIZE_BYTES: u64 = + openvmm_defs::microvm::MICROVM_MEMORY_BLOCK_SIZE_BYTES; /// Snapshot contract name for shared-status edge interrupts. pub const MICROVM_SHARED_STATUS_INTERRUPT_MODE: &str = "edge-shared-status"; /// Clock policy applied when a snapshot is restored. @@ -56,6 +61,18 @@ pub struct SnapshotMemoryRange { pub file_offset: u64, } +/// A restore-attachable guest RAM range that is absent from `memory.bin`. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotMemoryExpansionRange { + /// Guest physical base address. + #[mesh(1)] + pub gpa_start: u64, + /// Range length in bytes. + #[mesh(2)] + pub length: u64, +} + /// Processor topology that must be reproduced during restore. #[derive(Clone, Debug, PartialEq, Eq, Protobuf)] #[mesh(package = "openvmm.snapshot")] @@ -367,6 +384,18 @@ pub struct SnapshotMachineContract { /// Size of the shared interrupt-status page, or zero when absent. #[mesh(26)] pub virtio_shared_status_page_size: u64, + /// Restore-time memory expansion capability version, or zero when absent. + #[mesh(27)] + pub memory_expansion_version: u32, + /// Immutable maximum guest RAM size for restore-time expansion. + #[mesh(28)] + pub memory_capacity_bytes: u64, + /// Required alignment of every restore-time memory target and range. + #[mesh(29)] + pub memory_block_size_bytes: u64, + /// Canonical capacity ranges absent from the captured boot memory map. + #[mesh(30)] + pub memory_expansion_ranges: Vec, } impl SnapshotMachineContract { @@ -457,6 +486,41 @@ pub fn validate_restore_online_vp_count( Ok(()) } +/// Validates and selects the expansion ranges for a restore-time RAM target. +pub fn validate_restore_memory_target( + manifest: &SnapshotManifest, + restore_memory_size: u64, +) -> anyhow::Result> { + let contract = manifest + .machine_contract + .as_ref() + .context("snapshot is missing the authoritative machine contract")?; + validate_machine_contract_shape(contract, manifest.memory_size_bytes, manifest.vp_count)?; + anyhow::ensure!( + contract.memory_expansion_version == MICROVM_MEMORY_EXPANSION_VERSION, + "snapshot does not declare restore-time memory expansion support" + ); + anyhow::ensure!( + restore_memory_size >= manifest.memory_size_bytes, + "restore memory target {restore_memory_size} is below snapshot RAM {}", + manifest.memory_size_bytes + ); + anyhow::ensure!( + restore_memory_size <= contract.memory_capacity_bytes, + "restore memory target {restore_memory_size} exceeds RAM capacity {}", + contract.memory_capacity_bytes + ); + anyhow::ensure!( + restore_memory_size.is_multiple_of(contract.memory_block_size_bytes), + "restore memory target {restore_memory_size} is not aligned to the {}-byte memory block size", + contract.memory_block_size_bytes + ); + memory_expansion_prefix( + &contract.memory_expansion_ranges, + restore_memory_size - manifest.memory_size_bytes, + ) +} + fn canonical_microvm_memory_ranges(memory_size: u64) -> anyhow::Result> { const LOW_RAM_END: u64 = 3 * 1024 * 1024 * 1024; const HIGH_RAM_START: u64 = 4 * 1024 * 1024 * 1024; @@ -481,6 +545,56 @@ fn canonical_microvm_memory_ranges(memory_size: u64) -> anyhow::Result anyhow::Result> { + anyhow::ensure!( + memory_capacity >= memory_size, + "RAM capacity {memory_capacity} is below snapshot RAM {memory_size}" + ); + let mut ranges = Vec::new(); + for capacity_range in canonical_microvm_memory_ranges(memory_capacity)? { + let logical_start = capacity_range.file_offset.max(memory_size); + let logical_end = capacity_range + .file_offset + .checked_add(capacity_range.length) + .context("microVM capacity range overflows")?; + if logical_start >= logical_end { + continue; + } + ranges.push(SnapshotMemoryExpansionRange { + gpa_start: capacity_range.gpa_start + logical_start - capacity_range.file_offset, + length: logical_end - logical_start, + }); + } + Ok(ranges) +} + +fn memory_expansion_prefix( + ranges: &[SnapshotMemoryExpansionRange], + size: u64, +) -> anyhow::Result> { + let mut remaining = size; + let mut prefix = Vec::new(); + for range in ranges { + if remaining == 0 { + break; + } + let length = range.length.min(remaining); + prefix.push(SnapshotMemoryExpansionRange { + gpa_start: range.gpa_start, + length, + }); + remaining -= length; + } + anyhow::ensure!( + remaining == 0, + "memory expansion ranges do not cover target" + ); + Ok(prefix) +} + /// Builds the authoritative microVM machine contract. pub fn microvm_machine_contract( source_hypervisor: &str, @@ -500,6 +614,7 @@ pub fn microvm_machine_contract( sandbox_blocks: Vec, processor_count: u32, memory_size: u64, + memory_capacity: Option, state_unit_names: Vec, capture_wall_clock: Timestamp, tsc_frequency_hz: u64, @@ -515,6 +630,29 @@ pub fn microvm_machine_contract( microvm_boot_online_vp_count(processor_count, &effective_command_line)?; let memory_ranges = canonical_microvm_memory_ranges(memory_size)?; + let ( + memory_expansion_version, + memory_capacity_bytes, + memory_block_size_bytes, + memory_expansion_ranges, + ) = if let Some(memory_capacity) = memory_capacity { + anyhow::ensure!( + memory_size.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "snapshot RAM {memory_size} is not aligned to the {MICROVM_MEMORY_BLOCK_SIZE_BYTES}-byte memory block size" + ); + anyhow::ensure!( + memory_capacity.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "RAM capacity {memory_capacity} is not aligned to the {MICROVM_MEMORY_BLOCK_SIZE_BYTES}-byte memory block size" + ); + ( + MICROVM_MEMORY_EXPANSION_VERSION, + memory_capacity, + MICROVM_MEMORY_BLOCK_SIZE_BYTES, + canonical_memory_expansion_ranges(memory_size, memory_capacity)?, + ) + } else { + (0, 0, 0, Vec::new()) + }; let device = |stable_id: &str, state_unit_name: &str, @@ -874,6 +1012,10 @@ pub fn microvm_machine_contract( virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, virtio_shared_status_page_size: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + memory_expansion_version, + memory_capacity_bytes, + memory_block_size_bytes, + memory_expansion_ranges, }; contract.set_effective_command_line(effective_command_line); contract.set_cpu_compatibility_contract(cpu_contract); @@ -987,6 +1129,13 @@ pub fn validate_microvm_machine_contract( contract.memory_ranges == expected.memory_ranges, "snapshot RAM layout doesn't match the requested machine" ); + anyhow::ensure!( + contract.memory_expansion_version == expected.memory_expansion_version + && contract.memory_capacity_bytes == expected.memory_capacity_bytes + && contract.memory_block_size_bytes == expected.memory_block_size_bytes + && contract.memory_expansion_ranges == expected.memory_expansion_ranges, + "snapshot RAM capacity contract doesn't match the requested machine" + ); anyhow::ensure!( contract.topology == expected.topology, "snapshot processor topology doesn't match the requested machine" @@ -1147,6 +1296,48 @@ pub(super) fn validate_machine_contract_shape( total_memory == memory_size, "snapshot RAM ranges cover {total_memory} bytes, expected {memory_size}" ); + match contract.memory_expansion_version { + 0 => anyhow::ensure!( + contract.memory_capacity_bytes == 0 + && contract.memory_block_size_bytes == 0 + && contract.memory_expansion_ranges.is_empty(), + "legacy snapshot has an unexpected RAM capacity contract" + ), + MICROVM_MEMORY_EXPANSION_VERSION => { + anyhow::ensure!( + memory_size.is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "snapshot RAM is not memory-block aligned" + ); + anyhow::ensure!( + contract.memory_block_size_bytes == MICROVM_MEMORY_BLOCK_SIZE_BYTES, + "snapshot memory block size {} is unsupported", + contract.memory_block_size_bytes + ); + anyhow::ensure!( + contract.memory_capacity_bytes >= memory_size + && contract + .memory_capacity_bytes + .is_multiple_of(MICROVM_MEMORY_BLOCK_SIZE_BYTES), + "snapshot RAM capacity is invalid" + ); + anyhow::ensure!( + contract.memory_ranges == canonical_microvm_memory_ranges(memory_size)?, + "snapshot base RAM ranges are not canonical" + ); + anyhow::ensure!( + contract.memory_expansion_ranges + == canonical_memory_expansion_ranges( + memory_size, + contract.memory_capacity_bytes, + )?, + "snapshot memory expansion ranges are not canonical" + ); + } + version => { + anyhow::bail!("snapshot memory expansion contract version {version} is unsupported") + } + } + let topology = &contract.topology; let topology_vp_count = u64::from(topology.sockets) .checked_mul(u64::from(topology.dies_per_socket)) @@ -1700,6 +1891,10 @@ fn test_machine_contract() -> SnapshotMachineContract { virtio_interrupt_mode: MICROVM_SHARED_STATUS_INTERRUPT_MODE.to_owned(), virtio_shared_status_page_gpa: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_GPA, virtio_shared_status_page_size: openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, + memory_expansion_version: 0, + memory_capacity_bytes: 0, + memory_block_size_bytes: 0, + memory_expansion_ranges: Vec::new(), }; contract.set_effective_command_line("console=hvc0".to_owned()); contract.set_cpu_compatibility_contract(vec![1, 2, 3]); @@ -1798,6 +1993,7 @@ mod tests { Vec::new(), 1, 1024, + None, [ "partition", "vmtime", @@ -1867,6 +2063,7 @@ mod tests { Vec::new(), 1, 1024, + None, [ "partition", "vmtime", @@ -1902,6 +2099,7 @@ mod tests { Vec::new(), 1, 1024, + None, [ "partition", "vmtime", @@ -1937,6 +2135,7 @@ mod tests { Vec::new(), 1, 1024, + None, [ "partition", "vmtime", @@ -2451,6 +2650,142 @@ mod tests { assert!(err.to_string().contains("overlap in GPA space")); } + fn expandable_memory_manifest(base_memory: u64, capacity: u64) -> SnapshotManifest { + let mut manifest = test_manifest(); + manifest.memory_size_bytes = base_memory; + let mut contract = test_machine_contract(); + contract.memory_ranges = canonical_microvm_memory_ranges(base_memory).unwrap(); + contract.memory_expansion_version = MICROVM_MEMORY_EXPANSION_VERSION; + contract.memory_capacity_bytes = capacity; + contract.memory_block_size_bytes = MICROVM_MEMORY_BLOCK_SIZE_BYTES; + contract.memory_expansion_ranges = + canonical_memory_expansion_ranges(base_memory, capacity).unwrap(); + manifest.machine_contract = Some(contract); + manifest + } + + #[test] + fn restore_memory_targets_select_canonical_prefixes() { + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * MB; + let manifest = expandable_memory_manifest(512 * MB, 2 * GB); + + assert_eq!( + validate_restore_memory_target(&manifest, 512 * MB).unwrap(), + [] + ); + assert_eq!( + validate_restore_memory_target(&manifest, GB).unwrap(), + [SnapshotMemoryExpansionRange { + gpa_start: 512 * MB, + length: 512 * MB, + }] + ); + assert_eq!( + validate_restore_memory_target(&manifest, 2 * GB).unwrap(), + [SnapshotMemoryExpansionRange { + gpa_start: 512 * MB, + length: 1536 * MB, + }] + ); + } + + #[test] + fn restore_memory_target_validation_rejects_invalid_requests() { + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * MB; + let manifest = expandable_memory_manifest(512 * MB, 2 * GB); + + assert!( + validate_restore_memory_target(&manifest, 384 * MB) + .unwrap_err() + .to_string() + .contains("below snapshot RAM") + ); + assert!( + validate_restore_memory_target(&manifest, 640 * MB + 1) + .unwrap_err() + .to_string() + .contains("not aligned") + ); + assert!( + validate_restore_memory_target(&manifest, 2 * GB + 128 * MB) + .unwrap_err() + .to_string() + .contains("exceeds RAM capacity") + ); + } + + #[test] + fn restore_memory_contract_rejects_malformed_and_legacy_snapshots() { + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * MB; + + let mut overlap = expandable_memory_manifest(512 * MB, 2 * GB); + overlap + .machine_contract + .as_mut() + .unwrap() + .memory_expansion_ranges[0] + .gpa_start = 256 * MB; + assert!( + validate_restore_memory_target(&overlap, GB) + .unwrap_err() + .to_string() + .contains("not canonical") + ); + + let mut version = expandable_memory_manifest(512 * MB, 2 * GB); + version + .machine_contract + .as_mut() + .unwrap() + .memory_expansion_version += 1; + assert!( + validate_restore_memory_target(&version, GB) + .unwrap_err() + .to_string() + .contains("unsupported") + ); + + let mut legacy = test_manifest(); + legacy.machine_contract = Some(test_machine_contract()); + assert!( + validate_restore_memory_target(&legacy, 1024) + .unwrap_err() + .to_string() + .contains("does not declare") + ); + } + + #[test] + fn memory_capacity_contract_rejects_alignment_and_overflow() { + const MB: u64 = 1024 * 1024; + assert!(canonical_memory_expansion_ranges(512 * MB, 512 * MB - 1).is_err()); + assert!( + microvm_machine_contract( + "whp", + MICROVM_BOOT_LAYOUT_VERSION, + "console=hvc0".to_owned(), + None, + false, + None, + None, + Vec::new(), + 1, + 512 * MB, + Some(512 * MB + 1), + Vec::new(), + std::time::SystemTime::now().into(), + 1_000_000_000, + Some(1_000_000_000), + vec![1], + ) + .is_err() + ); + assert!(canonical_microvm_memory_ranges(u64::MAX).is_err()); + } + fn canonical_worker_platform_command_line( tsc_frequency_hz: u64, processor_count: u32, diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index 12179a0c19d..5e36ea62191 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -41,9 +41,12 @@ const RESTORE_ENTROPY_SELECT: u8 = 0xa5; const GENERATION_ID_SELECT: u8 = 0xa6; const GENERATION_ID_SIZE: usize = 16; const RESTORE_PROCESSOR_TARGET_PACKET_HEADER: &[u8] = b"OPENVMM_ENTROPY_V2\0"; +const RESTORE_MEMORY_TARGET_PACKET_HEADER: &[u8] = b"OPENVMM_ENTROPY_V3\0"; const STATUS_INPUT_AVAILABLE: u8 = 1 << 0; const STATUS_RESTORE_PACKET_AVAILABLE: u8 = 1 << 1; const STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE: u8 = 1 << 2; +const STATUS_RESTORE_MEMORY_TARGET_AVAILABLE: u8 = 1 << 3; +const STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE: u8 = 1 << 4; const STATUS_GENERATION_ID_AVAILABLE: u8 = 1 << 5; const BUFFER_MAX: usize = 1024 * 1024; @@ -64,6 +67,8 @@ pub struct MicrovmPortb { restore_entropy: VecDeque, restore_entropy_selected: bool, restore_processor_target_available: bool, + restore_memory_target_available: bool, + restore_memory_expansion_available: bool, input_gated: bool, #[inspect(skip)] rx_waker: Option, @@ -78,8 +83,17 @@ impl MicrovmPortb { generation_id: [u8; GENERATION_ID_SIZE], restore_entropy: Vec, ) -> Self { - let restore_processor_target_available = - restore_entropy.starts_with(RESTORE_PROCESSOR_TARGET_PACKET_HEADER); + let restore_processor_target_available = restore_entropy + .starts_with(RESTORE_PROCESSOR_TARGET_PACKET_HEADER) + || (restore_entropy.starts_with(RESTORE_MEMORY_TARGET_PACKET_HEADER) + && restore_entropy + .get(RESTORE_MEMORY_TARGET_PACKET_HEADER.len()) + .is_some_and(|online_vp_count| *online_vp_count != 0)); + let restore_memory_target_available = + restore_entropy.starts_with(RESTORE_MEMORY_TARGET_PACKET_HEADER); + let restore_memory_expansion_available = restore_entropy + .get(RESTORE_MEMORY_TARGET_PACKET_HEADER.len() + 1) + .is_some_and(|range_count| restore_memory_target_available && *range_count != 0); Self { io_region: ("microvm-portb", DATA_PORT..=STATUS_PORT), io, @@ -90,6 +104,8 @@ impl MicrovmPortb { restore_entropy: restore_entropy.into(), restore_entropy_selected: false, restore_processor_target_available, + restore_memory_target_available, + restore_memory_expansion_available, input_gated: false, rx_waker: None, tx_waker: None, @@ -189,6 +205,8 @@ impl ChangeDeviceState for MicrovmPortb { self.generation_id_read_index = None; self.restore_entropy_selected = false; self.restore_processor_target_available = false; + self.restore_memory_target_available = false; + self.restore_memory_expansion_available = false; self.input_gated = false; } } @@ -242,6 +260,8 @@ impl PortIoIntercept for MicrovmPortb { if self.restore_entropy.is_empty() { self.restore_entropy_selected = false; self.restore_processor_target_available = false; + self.restore_memory_target_available = false; + self.restore_memory_expansion_available = false; } } else if !self.input_gated { data[0] = self.rx_buffer.pop_front().unwrap_or(0); @@ -264,6 +284,12 @@ impl PortIoIntercept for MicrovmPortb { if self.restore_processor_target_available { data[0] |= STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE; } + if self.restore_memory_target_available { + data[0] |= STATUS_RESTORE_MEMORY_TARGET_AVAILABLE; + } + if self.restore_memory_expansion_available { + data[0] |= STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE; + } data[0] |= STATUS_GENERATION_ID_AVAILABLE; } _ => return IoResult::Err(IoError::InvalidRegister), @@ -890,6 +916,127 @@ mod tests { assert_eq!(data, [STATUS_GENERATION_ID_AVAILABLE]); } + #[test] + fn base_memory_target_restore_packet_has_memory_status() { + let packet = [RESTORE_MEMORY_TARGET_PACKET_HEADER, &[0, 0], &[0x5a; 64]].concat(); + let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, packet); + let mut data = [0]; + + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!( + data, + [STATUS_RESTORE_PACKET_AVAILABLE + | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE] + ); + } + + #[test] + fn memory_target_restore_packet_has_distinct_status() { + let packet = [ + RESTORE_MEMORY_TARGET_PACKET_HEADER, + &[2, 1], + &0x2000_0000_u64.to_le_bytes(), + &0x2000_0000_u64.to_le_bytes(), + &[0x5a; 64], + ] + .concat(); + let mut portb = + MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, packet.clone()); + let mut data = [0]; + + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!( + data, + [STATUS_RESTORE_PACKET_AVAILABLE + | STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE + | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE + | STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE] + ); + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), + IoResult::Ok + )); + for expected in packet { + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [expected]); + } + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!(data, [STATUS_GENERATION_ID_AVAILABLE]); + + let memory_only_packet = [ + RESTORE_MEMORY_TARGET_PACKET_HEADER, + &[0, 1], + &0x2000_0000_u64.to_le_bytes(), + &0x2000_0000_u64.to_le_bytes(), + &[0x5a; 64], + ] + .concat(); + let mut portb = MicrovmPortb::new( + Box::new(Disconnected), + TEST_GENERATION_ID, + memory_only_packet, + ); + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!( + data, + [STATUS_RESTORE_PACKET_AVAILABLE + | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE + | STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE] + ); + + let processor_only_packet = + [RESTORE_MEMORY_TARGET_PACKET_HEADER, &[2, 0], &[0x5a; 64]].concat(); + let mut portb = MicrovmPortb::new( + Box::new(Disconnected), + TEST_GENERATION_ID, + processor_only_packet, + ); + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!( + data, + [STATUS_RESTORE_PACKET_AVAILABLE + | STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE + | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE] + ); + + let explicit_base_packet = + [RESTORE_MEMORY_TARGET_PACKET_HEADER, &[0, 0], &[0x5a; 64]].concat(); + let mut portb = MicrovmPortb::new( + Box::new(Disconnected), + TEST_GENERATION_ID, + explicit_base_packet, + ); + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + assert_eq!( + data, + [STATUS_RESTORE_PACKET_AVAILABLE + | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE] + ); + } + #[test] fn accepted_connection_is_polled_immediately() { let mut portb = MicrovmPortb::new( From e3a0ccfa79f7295d9f3d21a4b2661bb295acfa96 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:33:13 -0700 Subject: [PATCH 132/304] openvmm_entry: pre-expand the Linux descriptor table at startup Restoring a microVM opens the snapshot files, memory backing, and device endpoints after the process has started its worker threads. On Linux, the first time a process needs a descriptor beyond the inline set, the kernel grows the descriptor table, and once several threads share the table the growth waits for an RCU grace period. That wait lands on the restore path. Call `pal::unix::fd_table::expand_fd_table` first thing in `openvmm_main`, while the process is still single-threaded, so the first dynamic table is allocated up front. The expansion is bounded and leaves inherited descriptors alone. A failure only prints a warning, because the process works correctly without the expansion, just with the possible stall. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_entry/src/lib.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index b1de09bcea6..a6b75868548 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -152,6 +152,11 @@ use vmotherboard::ChipsetDeviceHandle; use vnc_worker_defs::VncParameters; pub fn openvmm_main() { + #[cfg(target_os = "linux")] + if let Err(error) = pal::unix::fd_table::expand_fd_table() { + eprintln!("warning: failed to expand the file descriptor table: {error}"); + } + // Save the current state of the terminal so we can restore it back to // normal before exiting. #[cfg(unix)] From 8aac9a8d2be439403b8e82f656c539af9f77913e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:38:10 -0700 Subject: [PATCH 133/304] net_backend_resources: define a canonical bound IPv4 egress policy A microVM runs untrusted workloads, and its only network path is one static IPv4 link. The host needs a way to restrict what the guest may send, and the restriction must be exact enough to bind into a snapshot: two policies that allow the same traffic must encode to the same bytes, and a restore must be able to prove that it enforces the policy that the snapshot was captured with. Add `net_backend_resources::egress`, a policy that is bound to one static link and validates guest frames before a backend transmits them: - A policy binds a mode to the guest IPv4 address, prefix, and MAC and to the derived gateway. `EgressPolicy::bind` rejects identities that the link cannot use, and canonicalizes the rules by sorting and removing duplicates; `validate` rebinds a policy that crossed a trust boundary and rejects any difference. - The modes are allow-all, deny-all, an allow list or a block list of IPv4 prefixes, exact TCP endpoints, and generic rules. Rules (`` or `::`) have allow and deny sets with deny precedence and a default action. - The policy derives the next hops that the guest may resolve with ARP: the gateway, or for exact endpoints the on-link endpoints themselves, and none when no destination can be reached. - A host-loopback posture can deny the gateway's host-loopback mapping while exempting one exact TCP proxy endpoint on the gateway. - `authorize_frame` checks the Ethernet source, at most one VLAN tag (none for exact endpoints), canonical ARP requests for an allowed next hop, and IPv4 headers without options, with a valid checksum, total length, and source address. The destination and, where rules need them, the TCP or UDP port decide the verdict; port-specific rules deny IPv4 fragments. Allow-all and allow-by-default rules without deny rules do not parse frames at all. - `canonical_bytes_for_version` encodes a policy for a snapshot digest. Version 1 covers the addresses and the mode, version 2 adds the prefix, the guest MAC, and the next hops, and version 3 adds the host-loopback posture and the proxy exception. New digests use version 3; the older versions stay available to verify existing snapshots. This commit combines the plan's separate steps for the endpoint policy, the directional defaults, the generic rules, and the host-loopback exception, whose history is not available to replay one by one. Tests cover parsing and canonicalization, next-hop derivation, identity validation, ARP and frame filtering in every mode, rule precedence, the proxy exception, and the fast paths of allow-all and deny-all. Co-authored-by: Huzaifa Danish Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + .../net/net_backend_resources/Cargo.toml | 3 + .../net/net_backend_resources/src/egress.rs | 1726 +++++++++++++++++ .../net/net_backend_resources/src/lib.rs | 1 + 4 files changed, 1731 insertions(+) create mode 100644 vm/devices/net/net_backend_resources/src/egress.rs diff --git a/Cargo.lock b/Cargo.lock index 84b82931ed6..a2d07929bc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5049,6 +5049,7 @@ dependencies = [ "guid", "inspect", "mesh", + "test_with_tracing", "thiserror 2.0.16", "vm_resource", ] diff --git a/vm/devices/net/net_backend_resources/Cargo.toml b/vm/devices/net/net_backend_resources/Cargo.toml index 57cb80d0d35..5b7777f1a0d 100644 --- a/vm/devices/net/net_backend_resources/Cargo.toml +++ b/vm/devices/net/net_backend_resources/Cargo.toml @@ -15,5 +15,8 @@ mesh.workspace = true thiserror.workspace = true +[dev-dependencies] +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/vm/devices/net/net_backend_resources/src/egress.rs b/vm/devices/net/net_backend_resources/src/egress.rs new file mode 100644 index 00000000000..1e5661c2458 --- /dev/null +++ b/vm/devices/net/net_backend_resources/src/egress.rs @@ -0,0 +1,1726 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Canonical microVM IPv4 egress policy and packet validation. + +use crate::mac_address::MacAddress; +use mesh::MeshPayload; +use std::net::Ipv4Addr; +use std::str::FromStr; +use thiserror::Error; + +/// Current encoding version used for snapshot egress-policy digests. +pub const EGRESS_POLICY_ENCODING_VERSION: u32 = 3; +const PREVIOUS_EGRESS_POLICY_ENCODING_VERSION: u32 = 2; +const LEGACY_EGRESS_POLICY_ENCODING_VERSION: u32 = 1; +const ETHERNET_BROADCAST: [u8; 6] = [0xff; 6]; +const ETHERNET_UNSPECIFIED: [u8; 6] = [0; 6]; + +/// A canonical IPv4 network prefix. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] +pub struct Ipv4Cidr { + network: Ipv4Addr, + prefix_length: u8, +} + +impl Ipv4Cidr { + /// Returns whether `address` belongs to this prefix. + pub fn contains(self, address: Ipv4Addr) -> bool { + let mask = prefix_mask(self.prefix_length); + u32::from(address) & mask == u32::from(self.network) + } + + /// Returns the canonical network address. + pub fn network(self) -> Ipv4Addr { + self.network + } + + /// Returns the prefix length. + pub fn prefix_length(self) -> u8 { + self.prefix_length + } +} + +/// Error returned when parsing an IPv4 address or CIDR policy rule. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ParseIpv4CidrError { + /// The address portion is not IPv4. + #[error("invalid IPv4 address '{0}'")] + InvalidAddress(String), + /// The prefix portion is not an integer. + #[error("invalid IPv4 prefix '{0}'")] + InvalidPrefix(String), + /// The prefix is greater than 32. + #[error("IPv4 prefix /{0} is outside the supported range /0 through /32")] + PrefixOutOfRange(u8), + /// The rule has more than one prefix separator. + #[error("invalid IPv4 CIDR '{0}'")] + InvalidFormat(String), +} + +impl FromStr for Ipv4Cidr { + type Err = ParseIpv4CidrError; + + fn from_str(value: &str) -> Result { + let (address, prefix) = match value.split_once('/') { + Some((address, prefix)) if !prefix.contains('/') => (address, Some(prefix)), + Some(_) => return Err(ParseIpv4CidrError::InvalidFormat(value.to_owned())), + None => (value, None), + }; + let address = address + .parse::() + .map_err(|_| ParseIpv4CidrError::InvalidAddress(address.to_owned()))?; + let prefix_length = prefix + .map(|prefix| { + prefix + .parse::() + .map_err(|_| ParseIpv4CidrError::InvalidPrefix(prefix.to_owned())) + }) + .transpose()? + .unwrap_or(32); + if prefix_length > 32 { + return Err(ParseIpv4CidrError::PrefixOutOfRange(prefix_length)); + } + let network = Ipv4Addr::from(u32::from(address) & prefix_mask(prefix_length)); + Ok(Self { + network, + prefix_length, + }) + } +} + +/// An exact IPv4 TCP destination. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] +pub struct TcpEndpoint { + address: Ipv4Addr, + port: u16, +} + +impl TcpEndpoint { + /// Returns the destination address. + pub fn address(self) -> Ipv4Addr { + self.address + } + + /// Returns the destination TCP port. + pub fn port(self) -> u16 { + self.port + } +} + +/// Error returned when parsing an exact TCP endpoint policy rule. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ParseTcpEndpointError { + /// The endpoint is missing its port separator. + #[error("expected :")] + InvalidFormat, + /// The address portion is not IPv4. + #[error("invalid IPv4 address '{0}'")] + InvalidAddress(String), + /// The port portion is not a `u16`. + #[error("invalid TCP port '{0}'")] + InvalidPort(String), + /// Port zero is not a connectable endpoint. + #[error("TCP port must be nonzero")] + ZeroPort, +} + +impl FromStr for TcpEndpoint { + type Err = ParseTcpEndpointError; + + fn from_str(value: &str) -> Result { + let (address, port) = value + .rsplit_once(':') + .ok_or(ParseTcpEndpointError::InvalidFormat)?; + let address = address + .parse::() + .map_err(|_| ParseTcpEndpointError::InvalidAddress(address.to_owned()))?; + let port = port + .parse::() + .map_err(|_| ParseTcpEndpointError::InvalidPort(port.to_owned()))?; + if port == 0 { + return Err(ParseTcpEndpointError::ZeroPort); + } + Ok(Self { address, port }) + } +} + +/// Default action for a rule-based egress policy. +#[derive(Clone, Copy, Debug, PartialEq, Eq, MeshPayload)] +pub enum EgressAction { + /// Permit traffic that matches no rule. + Allow, + /// Deny traffic that matches no rule. + Deny, +} + +/// Optional transport restriction for an egress rule. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] +pub enum EgressTransport { + /// Match every IPv4 protocol and port. + Any, + /// Match one TCP destination port. + Tcp, + /// Match one UDP destination port. + Udp, +} + +/// A canonical IPv4 destination rule with an optional TCP or UDP port. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] +pub struct EgressRule { + destination: Ipv4Cidr, + transport: EgressTransport, + port: u16, +} + +impl EgressRule { + /// Returns the destination prefix. + pub fn destination(self) -> Ipv4Cidr { + self.destination + } + + /// Returns the optional transport restriction. + pub fn transport(self) -> EgressTransport { + self.transport + } + + /// Returns the restricted port, or zero for an address-only rule. + pub fn port(self) -> u16 { + self.port + } + + fn matches(self, destination: Ipv4Addr, transport: Option<(EgressTransport, u16)>) -> bool { + if !self.destination.contains(destination) { + return false; + } + match self.transport { + EgressTransport::Any => true, + expected => { + transport.is_some_and(|(actual, port)| actual == expected && port == self.port) + } + } + } +} + +/// Error returned when parsing an L3/L4 egress rule. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum ParseEgressRuleError { + /// The rule did not use the supported address or address/protocol/port form. + #[error("expected or ::")] + InvalidFormat, + /// The destination prefix is invalid. + #[error(transparent)] + InvalidDestination(#[from] ParseIpv4CidrError), + /// The transport is not TCP or UDP. + #[error("invalid egress transport '{0}'; expected tcp or udp")] + InvalidTransport(String), + /// The port is not a `u16`. + #[error("invalid egress port '{0}'")] + InvalidPort(String), + /// Port zero is not a connectable destination. + #[error("egress rule port must be nonzero")] + ZeroPort, +} + +impl FromStr for EgressRule { + type Err = ParseEgressRuleError; + + fn from_str(value: &str) -> Result { + let fields = value.split(':').collect::>(); + let (destination, transport, port) = match fields.as_slice() { + [destination] => (destination.parse()?, EgressTransport::Any, 0), + [destination, protocol, port] => { + let transport = match *protocol { + "tcp" => EgressTransport::Tcp, + "udp" => EgressTransport::Udp, + other => { + return Err(ParseEgressRuleError::InvalidTransport(other.to_owned())); + } + }; + let port = port + .parse::() + .map_err(|_| ParseEgressRuleError::InvalidPort((*port).to_owned()))?; + if port == 0 { + return Err(ParseEgressRuleError::ZeroPort); + } + (destination.parse()?, transport, port) + } + _ => return Err(ParseEgressRuleError::InvalidFormat), + }; + Ok(Self { + destination, + transport, + port, + }) + } +} + +/// Run-scoped egress policy mode. +#[derive(Clone, Debug, PartialEq, Eq, MeshPayload)] +pub enum EgressPolicyMode { + /// No packet filtering. + AllowAll, + /// Permit only destinations in these IPv4 prefixes. + AllowList(Vec), + /// Permit IPv4 destinations except those in these prefixes. + BlockList(Vec), + /// Permit only exact IPv4 TCP destinations. + TcpEndpoints(Vec), + /// Deny every guest-originated frame. + DenyAll, + /// Apply canonical allow and deny rules with deny precedence. + Rules { + /// Action for traffic that matches no rule. + default_action: EgressAction, + /// Rules that permit matching traffic. + allow: Vec, + /// Rules that reject matching traffic before allow evaluation. + deny: Vec, + }, +} + +/// Reason an endpoint address cannot be used by exact endpoint policy. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum InvalidEndpointAddress { + /// The address is `0.0.0.0`. + #[error("it is unspecified")] + Unspecified, + /// The address belongs to the reserved current-network block. + #[error("it belongs to the reserved 0.0.0.0/8 current-network block")] + CurrentNetwork, + /// The address belongs to IPv4 loopback. + #[error("it is loopback")] + Loopback, + /// The address is IPv4 link-local. + #[error("it is link-local")] + LinkLocal, + /// The address is multicast. + #[error("it is multicast")] + Multicast, + /// The address is the limited broadcast address. + #[error("it is the limited broadcast address")] + LimitedBroadcast, + /// The address belongs to the reserved high-address block. + #[error("it belongs to the reserved 240.0.0.0/4 block")] + Reserved, + /// The address is the guest itself. + #[error("it matches the guest IPv4 address")] + Guest, + /// The address is the guest subnet's network address. + #[error("it is the guest subnet network address")] + SubnetNetwork, + /// The address is the guest subnet's broadcast address. + #[error("it is the guest subnet broadcast address")] + SubnetBroadcast, +} + +/// Error returned when binding an egress policy to a static network identity. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum InvalidEgressPolicy { + /// The prefix cannot describe a microVM subnet. + #[error("IPv4 prefix /{0} is outside the supported range /1 through /30")] + PrefixOutOfRange(u8), + /// The guest address is the subnet network address. + #[error("guest IPv4 address {0} is the subnet network address")] + GuestNetworkAddress(Ipv4Addr), + /// The guest address is the subnet broadcast address. + #[error("guest IPv4 address {0} is the subnet broadcast address")] + GuestBroadcastAddress(Ipv4Addr), + /// The guest address collides with the canonical gateway. + #[error("guest IPv4 address {0} collides with the canonical gateway")] + GuestGatewayCollision(Ipv4Addr), + /// The gateway is not the first usable address in the guest subnet. + #[error("gateway IPv4 address {actual} does not match canonical gateway {expected}")] + GatewayMismatch { + /// Supplied gateway address. + actual: Ipv4Addr, + /// Canonical first usable subnet address. + expected: Ipv4Addr, + }, + /// The guest MAC cannot identify a transmitting endpoint. + #[error("guest MAC address {0} is not a nonzero unicast address")] + InvalidGuestMac(MacAddress), + /// The gateway cannot serve as an endpoint next hop. + #[error("gateway IPv4 address {address} is not a valid endpoint next hop: {reason}")] + InvalidGateway { + /// Invalid gateway address. + address: Ipv4Addr, + /// Reason the address is invalid. + reason: InvalidEndpointAddress, + }, + /// An exact endpoint address is invalid. + #[error("endpoint IPv4 address {address} is invalid: {reason}")] + InvalidEndpoint { + /// Invalid endpoint address. + address: Ipv4Addr, + /// Reason the address is invalid. + reason: InvalidEndpointAddress, + }, + /// A decoded policy did not preserve canonical rule order and uniqueness. + #[error("egress policy rules are not canonical")] + NonCanonicalRules, + /// A decoded policy carried next hops that do not match its bound rules. + #[error("egress policy next hops do not match the bound endpoint rules")] + InvalidNextHops, + /// A proxy exception did not use the guest-visible gateway address. + #[error("network proxy address {actual} must match the guest gateway {expected}")] + InvalidProxyAddress { + /// Supplied proxy address. + actual: Ipv4Addr, + /// Required guest-visible address. + expected: Ipv4Addr, + }, +} + +/// Egress policy bound to one static microVM link. +#[derive(Clone, Debug, PartialEq, Eq, MeshPayload)] +pub struct EgressPolicy { + guest_ipv4: Ipv4Addr, + prefix_length: u8, + guest_mac: MacAddress, + gateway_ipv4: Ipv4Addr, + next_hops: Vec, + mode: EgressPolicyMode, + host_loopback: EgressAction, + proxy_endpoint: Option, +} + +impl EgressPolicy { + /// Binds a policy to a validated static identity and canonicalizes its rules. + pub fn bind( + guest_ipv4: Ipv4Addr, + prefix_length: u8, + guest_mac: MacAddress, + gateway_ipv4: Ipv4Addr, + mut mode: EgressPolicyMode, + ) -> Result { + if !(1..=30).contains(&prefix_length) { + return Err(InvalidEgressPolicy::PrefixOutOfRange(prefix_length)); + } + let mask = prefix_mask(prefix_length); + let network = Ipv4Addr::from(u32::from(guest_ipv4) & mask); + let broadcast = Ipv4Addr::from(u32::from(network) | !mask); + if guest_ipv4 == network { + return Err(InvalidEgressPolicy::GuestNetworkAddress(guest_ipv4)); + } + if guest_ipv4 == broadcast { + return Err(InvalidEgressPolicy::GuestBroadcastAddress(guest_ipv4)); + } + let expected_gateway = Ipv4Addr::from(u32::from(network) + 1); + if guest_ipv4 == expected_gateway { + return Err(InvalidEgressPolicy::GuestGatewayCollision(guest_ipv4)); + } + if gateway_ipv4 != expected_gateway { + return Err(InvalidEgressPolicy::GatewayMismatch { + actual: gateway_ipv4, + expected: expected_gateway, + }); + } + let guest_mac_bytes = guest_mac.to_bytes(); + if guest_mac_bytes == ETHERNET_UNSPECIFIED || guest_mac_bytes[0] & 1 != 0 { + return Err(InvalidEgressPolicy::InvalidGuestMac(guest_mac)); + } + + match &mut mode { + EgressPolicyMode::AllowList(rules) | EgressPolicyMode::BlockList(rules) => { + rules.sort_unstable(); + rules.dedup(); + } + EgressPolicyMode::TcpEndpoints(endpoints) => { + endpoints.sort_unstable(); + endpoints.dedup(); + } + EgressPolicyMode::Rules { allow, deny, .. } => { + allow.sort_unstable(); + allow.dedup(); + deny.sort_unstable(); + deny.dedup(); + } + EgressPolicyMode::AllowAll | EgressPolicyMode::DenyAll => {} + } + + let next_hops = match &mode { + EgressPolicyMode::AllowAll | EgressPolicyMode::DenyAll => Vec::new(), + EgressPolicyMode::AllowList(_) | EgressPolicyMode::BlockList(_) => { + vec![gateway_ipv4] + } + EgressPolicyMode::TcpEndpoints(endpoints) => { + if let Some(reason) = + invalid_special_endpoint_address(gateway_ipv4, guest_ipv4, network, broadcast) + { + return Err(InvalidEgressPolicy::InvalidGateway { + address: gateway_ipv4, + reason, + }); + } + let mut next_hops = Vec::with_capacity(endpoints.len()); + for endpoint in endpoints { + if let Some(reason) = invalid_special_endpoint_address( + endpoint.address, + guest_ipv4, + network, + broadcast, + ) { + return Err(InvalidEgressPolicy::InvalidEndpoint { + address: endpoint.address, + reason, + }); + } + next_hops.push(if contains_address(network, mask, endpoint.address) { + endpoint.address + } else { + gateway_ipv4 + }); + } + next_hops.sort_unstable(); + next_hops.dedup(); + next_hops + } + EgressPolicyMode::Rules { + default_action, + allow, + .. + } => { + if *default_action == EgressAction::Deny && allow.is_empty() { + Vec::new() + } else { + vec![gateway_ipv4] + } + } + }; + + Ok(Self { + guest_ipv4, + prefix_length, + guest_mac, + gateway_ipv4, + next_hops, + mode, + host_loopback: EgressAction::Allow, + proxy_endpoint: None, + }) + } + + /// Applies the host-loopback posture and optional exact proxy exception. + pub fn with_host_loopback( + mut self, + host_loopback: EgressAction, + proxy_endpoint: Option, + ) -> Result { + if let Some(proxy) = proxy_endpoint + && proxy.address != self.gateway_ipv4 + { + return Err(InvalidEgressPolicy::InvalidProxyAddress { + actual: proxy.address, + expected: self.gateway_ipv4, + }); + } + if proxy_endpoint.is_some() && !self.next_hops.contains(&self.gateway_ipv4) { + self.next_hops.push(self.gateway_ipv4); + self.next_hops.sort_unstable(); + self.next_hops.dedup(); + } + self.host_loopback = host_loopback; + self.proxy_endpoint = proxy_endpoint; + Ok(self) + } + + /// Revalidates canonical fields after a serialized policy crosses a boundary. + pub fn validate(&self) -> Result<(), InvalidEgressPolicy> { + let rebound = Self::bind( + self.guest_ipv4, + self.prefix_length, + self.guest_mac, + self.gateway_ipv4, + self.mode.clone(), + )? + .with_host_loopback(self.host_loopback, self.proxy_endpoint)?; + if rebound.mode != self.mode { + return Err(InvalidEgressPolicy::NonCanonicalRules); + } + if rebound.next_hops != self.next_hops { + return Err(InvalidEgressPolicy::InvalidNextHops); + } + Ok(()) + } + + /// Returns the canonical policy mode. + pub fn mode(&self) -> &EgressPolicyMode { + &self.mode + } + + /// Returns the stable manifest name of this policy mode. + pub fn mode_name(&self) -> &'static str { + match self.mode { + EgressPolicyMode::AllowAll => "allow-all", + EgressPolicyMode::DenyAll => "deny-all", + EgressPolicyMode::AllowList(_) => "allow-list", + EgressPolicyMode::BlockList(_) => "block-list", + EgressPolicyMode::TcpEndpoints(_) => "endpoint", + EgressPolicyMode::Rules { .. } => "rules", + } + } + + /// Returns the canonical next hops that ARP may resolve. + pub fn next_hops(&self) -> &[Ipv4Addr] { + &self.next_hops + } + + /// Returns whether general host-loopback connectivity is permitted. + pub fn host_loopback_action(&self) -> EgressAction { + self.host_loopback + } + + /// Returns the exact proxy endpoint exempted from host-loopback denial. + pub fn proxy_endpoint(&self) -> Option { + self.proxy_endpoint + } + + /// Returns whether packet parsing and filtering are active. + pub fn is_active(&self) -> bool { + match &self.mode { + EgressPolicyMode::AllowAll => false, + EgressPolicyMode::Rules { + default_action: EgressAction::Allow, + deny, + .. + } if deny.is_empty() => false, + _ => true, + } + } + + /// Returns whether the gateway DNS proxy is reachable under this policy. + pub fn allows_gateway_dns(&self) -> bool { + match &self.mode { + EgressPolicyMode::AllowAll => true, + EgressPolicyMode::DenyAll => false, + EgressPolicyMode::AllowList(rules) => { + rules.iter().any(|rule| rule.contains(self.gateway_ipv4)) + } + EgressPolicyMode::BlockList(rules) => { + !rules.iter().any(|rule| rule.contains(self.gateway_ipv4)) + } + EgressPolicyMode::TcpEndpoints(_) => false, + EgressPolicyMode::Rules { .. } => { + self.rules_allow_destination(self.gateway_ipv4, Some((EgressTransport::Tcp, 53))) + || self.rules_allow_destination( + self.gateway_ipv4, + Some((EgressTransport::Udp, 53)), + ) + } + } + } + + /// Returns canonical rule counts without exposing destinations. + pub fn rule_counts(&self) -> (usize, usize) { + match &self.mode { + EgressPolicyMode::Rules { allow, deny, .. } => (allow.len(), deny.len()), + EgressPolicyMode::AllowList(rules) => (rules.len(), 0), + EgressPolicyMode::BlockList(rules) => (0, rules.len()), + EgressPolicyMode::TcpEndpoints(rules) => (rules.len(), 0), + EgressPolicyMode::AllowAll | EgressPolicyMode::DenyAll => (0, 0), + } + } + + fn rules_allow_destination( + &self, + destination: Ipv4Addr, + transport: Option<(EgressTransport, u16)>, + ) -> bool { + let EgressPolicyMode::Rules { + default_action, + allow, + deny, + } = &self.mode + else { + return false; + }; + if deny.iter().any(|rule| rule.matches(destination, transport)) { + return false; + } + if allow + .iter() + .any(|rule| rule.matches(destination, transport)) + { + return true; + } + *default_action == EgressAction::Allow + } + + fn rules_allow_arp(&self, target: Ipv4Addr) -> bool { + let EgressPolicyMode::Rules { + default_action, + allow, + deny, + } = &self.mode + else { + return false; + }; + if target == self.gateway_ipv4 { + return *default_action == EgressAction::Allow || !allow.is_empty(); + } + if deny + .iter() + .any(|rule| rule.transport == EgressTransport::Any && rule.destination.contains(target)) + { + return false; + } + if allow.iter().any(|rule| rule.destination.contains(target)) { + return true; + } + *default_action == EgressAction::Allow + } + + fn rules_have_transport_restrictions(&self) -> bool { + matches!( + &self.mode, + EgressPolicyMode::Rules { allow, deny, .. } + if allow + .iter() + .chain(deny) + .any(|rule| rule.transport != EgressTransport::Any) + ) + } + + /// Returns stable bytes suitable for a policy digest. + pub fn canonical_bytes(&self) -> Vec { + self.canonical_bytes_v3() + } + + /// Returns canonical bytes for a supported snapshot encoding version. + pub fn canonical_bytes_for_version(&self, version: u32) -> Option> { + match version { + LEGACY_EGRESS_POLICY_ENCODING_VERSION => Some(self.canonical_bytes_v1()), + PREVIOUS_EGRESS_POLICY_ENCODING_VERSION => Some(self.canonical_bytes_v2()), + EGRESS_POLICY_ENCODING_VERSION => Some(self.canonical_bytes_v3()), + _ => None, + } + } + + fn canonical_bytes_v1(&self) -> Vec { + let mut bytes = Vec::new(); + bytes.push(LEGACY_EGRESS_POLICY_ENCODING_VERSION as u8); + bytes.extend_from_slice(&self.guest_ipv4.octets()); + bytes.extend_from_slice(&self.gateway_ipv4.octets()); + append_policy_mode(&mut bytes, &self.mode); + bytes + } + + fn canonical_bytes_v2(&self) -> Vec { + let mut bytes = Vec::new(); + bytes.push(EGRESS_POLICY_ENCODING_VERSION as u8); + bytes.extend_from_slice(&self.guest_ipv4.octets()); + bytes.push(self.prefix_length); + bytes.extend_from_slice(&self.guest_mac.to_bytes()); + bytes.extend_from_slice(&self.gateway_ipv4.octets()); + append_policy_mode(&mut bytes, &self.mode); + bytes.push(0xff); + bytes.extend_from_slice(&(self.next_hops.len() as u64).to_be_bytes()); + for next_hop in &self.next_hops { + bytes.extend_from_slice(&next_hop.octets()); + } + bytes + } + + fn canonical_bytes_v3(&self) -> Vec { + let mut bytes = self.canonical_bytes_v2(); + bytes[0] = EGRESS_POLICY_ENCODING_VERSION as u8; + bytes.push(match self.host_loopback { + EgressAction::Allow => 0, + EgressAction::Deny => 1, + }); + if let Some(proxy) = self.proxy_endpoint { + bytes.push(1); + bytes.extend_from_slice(&proxy.address.octets()); + bytes.extend_from_slice(&proxy.port.to_be_bytes()); + } else { + bytes.push(0); + } + bytes + } + + /// Validates and authorizes an Ethernet frame before backend submission. + pub fn authorize_frame( + &self, + frame_prefix: &[u8], + frame_length: usize, + ) -> Result<(), EgressDenied> { + if !self.is_active() { + return Ok(()); + } + if matches!(self.mode, EgressPolicyMode::DenyAll) && self.proxy_endpoint.is_none() { + return Err(EgressDenied::AllTrafficDenied); + } + ensure_available(frame_prefix, frame_length, 14, "Ethernet header")?; + if frame_prefix[6..12] != self.guest_mac.to_bytes() { + return Err(EgressDenied::SourceMacDenied); + } + let mut ether_type = read_u16(frame_prefix, 12); + let mut l3_offset = 14; + let vlan = matches!(ether_type, 0x8100 | 0x88a8); + if vlan { + if matches!(self.mode, EgressPolicyMode::TcpEndpoints(_)) { + return Err(EgressDenied::VlanDenied); + } + ensure_available(frame_prefix, frame_length, 18, "VLAN header")?; + ether_type = read_u16(frame_prefix, 16); + l3_offset = 18; + if matches!(ether_type, 0x8100 | 0x88a8) { + return Err(EgressDenied::Malformed("stacked VLAN header")); + } + } + + match ether_type { + 0x0806 => self.authorize_arp(frame_prefix, frame_length, l3_offset), + 0x0800 => self.authorize_ipv4(frame_prefix, frame_length, l3_offset), + other => Err(EgressDenied::UnsupportedEtherType(other)), + } + } + + fn authorize_arp( + &self, + frame: &[u8], + frame_length: usize, + offset: usize, + ) -> Result<(), EgressDenied> { + ensure_available(frame, frame_length, offset + 28, "ARP packet")?; + if read_u16(frame, offset) != 1 + || read_u16(frame, offset + 2) != 0x0800 + || frame[offset + 4] != 6 + || frame[offset + 5] != 4 + || read_u16(frame, offset + 6) != 1 + { + return Err(EgressDenied::Malformed("ARP request")); + } + let ethernet_destination = &frame[..6]; + let sender_hardware = &frame[offset + 8..offset + 14]; + let target_hardware = &frame[offset + 18..offset + 24]; + let broadcast_request = + ethernet_destination == ETHERNET_BROADCAST && target_hardware == ETHERNET_UNSPECIFIED; + let unicast_refresh = ethernet_destination[0] & 1 == 0 + && ethernet_destination != ETHERNET_UNSPECIFIED + && target_hardware == ethernet_destination; + let sender = read_ipv4(frame, offset + 14); + let target = read_ipv4(frame, offset + 24); + if sender_hardware != self.guest_mac.to_bytes() + || sender != self.guest_ipv4 + || !(match &self.mode { + EgressPolicyMode::Rules { .. } => self.rules_allow_arp(target), + _ => self.next_hops.contains(&target), + }) + || (!broadcast_request && !unicast_refresh) + { + return Err(EgressDenied::ArpDenied); + } + Ok(()) + } + + fn authorize_ipv4( + &self, + frame: &[u8], + frame_length: usize, + offset: usize, + ) -> Result<(), EgressDenied> { + ensure_available(frame, frame_length, offset + 20, "IPv4 header")?; + let version_ihl = frame[offset]; + let header_length = usize::from(version_ihl & 0x0f) * 4; + if version_ihl >> 4 != 4 || header_length < 20 { + return Err(EgressDenied::Malformed("IPv4 header")); + } + if header_length != 20 { + return Err(EgressDenied::Ipv4OptionsDenied); + } + ensure_available(frame, frame_length, offset + header_length, "IPv4 options")?; + let total_length = usize::from(read_u16(frame, offset + 2)); + if total_length < header_length + || offset + .checked_add(total_length) + .is_none_or(|end| end > frame_length) + { + return Err(EgressDenied::Malformed("IPv4 total length")); + } + if !ipv4_checksum_is_valid(&frame[offset..offset + header_length]) { + return Err(EgressDenied::Malformed("IPv4 checksum")); + } + if read_ipv4(frame, offset + 12) != self.guest_ipv4 { + return Err(EgressDenied::SourceAddressDenied); + } + + let destination = read_ipv4(frame, offset + 16); + let fragments = read_u16(frame, offset + 6) & 0x3fff; + if let Some(proxy) = self.proxy_endpoint + && destination == proxy.address + && fragments == 0 + && frame[offset + 9] == 6 + { + validate_transport( + frame, + frame_length, + offset, + header_length, + total_length, + fragments, + )?; + if read_u16(frame, offset + header_length + 2) == proxy.port { + return Ok(()); + } + } + match &self.mode { + EgressPolicyMode::AllowAll => Ok(()), + EgressPolicyMode::DenyAll => Err(EgressDenied::AllTrafficDenied), + EgressPolicyMode::AllowList(rules) => { + validate_transport( + frame, + frame_length, + offset, + header_length, + total_length, + fragments, + )?; + if rules.iter().any(|rule| rule.contains(destination)) { + Ok(()) + } else { + Err(EgressDenied::DestinationDenied) + } + } + EgressPolicyMode::BlockList(rules) => { + validate_transport( + frame, + frame_length, + offset, + header_length, + total_length, + fragments, + )?; + if rules.iter().any(|rule| rule.contains(destination)) { + Err(EgressDenied::DestinationDenied) + } else { + Ok(()) + } + } + EgressPolicyMode::TcpEndpoints(endpoints) => { + if fragments != 0 || frame[offset + 9] != 6 { + return Err(EgressDenied::EndpointDenied); + } + let tcp_offset = offset + header_length; + ensure_available(frame, frame_length, tcp_offset + 20, "TCP header")?; + if total_length < header_length + 20 { + return Err(EgressDenied::Malformed("TCP length")); + } + let tcp_header_length = usize::from(frame[tcp_offset + 12] >> 4) * 4; + if tcp_header_length < 20 || total_length < header_length + tcp_header_length { + return Err(EgressDenied::Malformed("TCP header")); + } + let destination_port = read_u16(frame, tcp_offset + 2); + if endpoints.iter().any(|endpoint| { + endpoint.address == destination && endpoint.port == destination_port + }) { + Ok(()) + } else { + Err(EgressDenied::EndpointDenied) + } + } + EgressPolicyMode::Rules { .. } => { + let transport = rule_transport( + frame, + frame_length, + offset, + header_length, + total_length, + fragments, + self.rules_have_transport_restrictions(), + )?; + if self.rules_allow_destination(destination, transport) { + Ok(()) + } else { + Err(EgressDenied::DestinationDenied) + } + } + } + } +} + +/// Reason an active egress policy rejected a frame. +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum EgressDenied { + /// The configured posture denies every guest-originated frame. + #[error("all egress traffic is denied")] + AllTrafficDenied, + /// A required packet structure was truncated or internally inconsistent. + #[error("malformed {0}")] + Malformed(&'static str), + /// The policy does not permit this layer-2 protocol. + #[error("unsupported Ethernet type {0:#06x}")] + UnsupportedEtherType(u16), + /// Exact endpoint mode does not permit VLAN encapsulation. + #[error("VLAN traffic is denied by exact endpoint policy")] + VlanDenied, + /// The Ethernet source does not match the bound guest identity. + #[error("Ethernet source does not match the configured guest")] + SourceMacDenied, + /// ARP was not a canonical request for an authorized next hop. + #[error("ARP request does not resolve an authorized next hop")] + ArpDenied, + /// The packet tried to spoof another source IPv4 address. + #[error("IPv4 source does not match the configured guest")] + SourceAddressDenied, + /// IPv4 options are not supported because source routing can change the + /// effective destination after policy evaluation. + #[error("IPv4 options are denied by policy")] + Ipv4OptionsDenied, + /// An allow-list or block-list denied the destination. + #[error("IPv4 destination is denied")] + DestinationDenied, + /// Exact endpoint mode denied the protocol, address, or TCP port. + #[error("traffic does not match an allowed TCP endpoint")] + EndpointDenied, + /// Port-specific rules cannot safely authorize fragmented transport headers. + #[error("IPv4 fragments are denied by port-specific egress policy")] + FragmentDenied, +} + +fn prefix_mask(prefix_length: u8) -> u32 { + if prefix_length == 0 { + 0 + } else { + u32::MAX << (32 - prefix_length) + } +} + +fn contains_address(network: Ipv4Addr, mask: u32, address: Ipv4Addr) -> bool { + u32::from(address) & mask == u32::from(network) +} + +fn invalid_special_endpoint_address( + address: Ipv4Addr, + guest: Ipv4Addr, + network: Ipv4Addr, + broadcast: Ipv4Addr, +) -> Option { + let first = address.octets()[0]; + if address.is_unspecified() { + Some(InvalidEndpointAddress::Unspecified) + } else if first == 0 { + Some(InvalidEndpointAddress::CurrentNetwork) + } else if address.is_loopback() { + Some(InvalidEndpointAddress::Loopback) + } else if address.is_link_local() { + Some(InvalidEndpointAddress::LinkLocal) + } else if address.is_multicast() { + Some(InvalidEndpointAddress::Multicast) + } else if address.is_broadcast() { + Some(InvalidEndpointAddress::LimitedBroadcast) + } else if first >= 240 { + Some(InvalidEndpointAddress::Reserved) + } else if address == guest { + Some(InvalidEndpointAddress::Guest) + } else if address == network { + Some(InvalidEndpointAddress::SubnetNetwork) + } else if address == broadcast { + Some(InvalidEndpointAddress::SubnetBroadcast) + } else { + None + } +} + +fn append_policy_mode(bytes: &mut Vec, mode: &EgressPolicyMode) { + match mode { + EgressPolicyMode::AllowAll => bytes.push(0), + EgressPolicyMode::DenyAll => bytes.push(4), + EgressPolicyMode::AllowList(rules) => { + bytes.push(1); + append_cidrs(bytes, rules); + } + EgressPolicyMode::BlockList(rules) => { + bytes.push(2); + append_cidrs(bytes, rules); + } + EgressPolicyMode::TcpEndpoints(endpoints) => { + bytes.push(3); + let mut endpoints = endpoints.clone(); + endpoints.sort_unstable(); + endpoints.dedup(); + for endpoint in endpoints { + bytes.extend_from_slice(&endpoint.address.octets()); + bytes.extend_from_slice(&endpoint.port.to_be_bytes()); + } + } + EgressPolicyMode::Rules { + default_action, + allow, + deny, + } => { + bytes.push(5); + bytes.push(match default_action { + EgressAction::Allow => 0, + EgressAction::Deny => 1, + }); + append_rules(bytes, allow); + append_rules(bytes, deny); + } + } +} + +fn append_cidrs(bytes: &mut Vec, rules: &[Ipv4Cidr]) { + let mut rules = rules.to_vec(); + rules.sort_unstable(); + rules.dedup(); + for rule in rules { + bytes.extend_from_slice(&rule.network.octets()); + bytes.push(rule.prefix_length); + } +} + +fn append_rules(bytes: &mut Vec, rules: &[EgressRule]) { + let mut rules = rules.to_vec(); + rules.sort_unstable(); + rules.dedup(); + bytes.extend_from_slice(&(rules.len() as u64).to_be_bytes()); + for rule in rules { + bytes.extend_from_slice(&rule.destination.network.octets()); + bytes.push(rule.destination.prefix_length); + bytes.push(match rule.transport { + EgressTransport::Any => 0, + EgressTransport::Tcp => 1, + EgressTransport::Udp => 2, + }); + bytes.extend_from_slice(&rule.port.to_be_bytes()); + } +} + +fn ensure_available( + prefix: &[u8], + frame_length: usize, + needed: usize, + description: &'static str, +) -> Result<(), EgressDenied> { + if needed > frame_length || needed > prefix.len() { + Err(EgressDenied::Malformed(description)) + } else { + Ok(()) + } +} + +fn read_u16(bytes: &[u8], offset: usize) -> u16 { + u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) +} + +fn read_ipv4(bytes: &[u8], offset: usize) -> Ipv4Addr { + Ipv4Addr::new( + bytes[offset], + bytes[offset + 1], + bytes[offset + 2], + bytes[offset + 3], + ) +} + +fn ipv4_checksum_is_valid(header: &[u8]) -> bool { + let mut sum = header + .chunks_exact(2) + .fold(0u32, |sum, word| sum + u32::from(read_u16(word, 0))); + while sum > u32::from(u16::MAX) { + sum = (sum & u32::from(u16::MAX)) + (sum >> 16); + } + sum == u32::from(u16::MAX) +} + +fn validate_transport( + frame: &[u8], + frame_length: usize, + ip_offset: usize, + ip_header_length: usize, + ip_total_length: usize, + fragments: u16, +) -> Result<(), EgressDenied> { + if fragments != 0 { + return Ok(()); + } + let transport_offset = ip_offset + ip_header_length; + let payload_length = ip_total_length - ip_header_length; + match frame[ip_offset + 9] { + 6 => { + ensure_available(frame, frame_length, transport_offset + 20, "TCP header")?; + if payload_length < 20 { + return Err(EgressDenied::Malformed("TCP length")); + } + let header_length = usize::from(frame[transport_offset + 12] >> 4) * 4; + if header_length < 20 || header_length > payload_length { + return Err(EgressDenied::Malformed("TCP header")); + } + } + 17 => { + ensure_available(frame, frame_length, transport_offset + 8, "UDP header")?; + let length = usize::from(read_u16(frame, transport_offset + 4)); + if length < 8 || length > payload_length { + return Err(EgressDenied::Malformed("UDP length")); + } + } + 1 => { + ensure_available(frame, frame_length, transport_offset + 8, "ICMP header")?; + if payload_length < 8 { + return Err(EgressDenied::Malformed("ICMP length")); + } + } + _ => {} + } + Ok(()) +} + +fn rule_transport( + frame: &[u8], + frame_length: usize, + ip_offset: usize, + ip_header_length: usize, + ip_total_length: usize, + fragments: u16, + has_transport_restrictions: bool, +) -> Result, EgressDenied> { + validate_transport( + frame, + frame_length, + ip_offset, + ip_header_length, + ip_total_length, + fragments, + )?; + if fragments != 0 { + return if has_transport_restrictions { + Err(EgressDenied::FragmentDenied) + } else { + Ok(None) + }; + } + let transport_offset = ip_offset + ip_header_length; + Ok(match frame[ip_offset + 9] { + 6 => Some((EgressTransport::Tcp, read_u16(frame, transport_offset + 2))), + 17 => Some((EgressTransport::Udp, read_u16(frame, transport_offset + 2))), + _ => None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + const GUEST_IPV4: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2); + const PREFIX_LENGTH: u8 = 24; + const GATEWAY_IPV4: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); + const GUEST_MAC_BYTES: [u8; 6] = [0x52, 0x54, 0, 0, 0, 2]; + + fn bind(mode: EgressPolicyMode) -> EgressPolicy { + EgressPolicy::bind( + GUEST_IPV4, + PREFIX_LENGTH, + MacAddress::new(GUEST_MAC_BYTES), + GATEWAY_IPV4, + mode, + ) + .unwrap() + } + + fn set_ipv4_checksum(frame: &mut [u8], ip_offset: usize) { + frame[ip_offset + 10..ip_offset + 12].fill(0); + let header_length = usize::from(frame[ip_offset] & 0x0f) * 4; + let header = &frame[ip_offset..ip_offset + header_length]; + let mut sum = header + .chunks_exact(2) + .fold(0u32, |sum, word| sum + u32::from(read_u16(word, 0))); + while sum > u32::from(u16::MAX) { + sum = (sum & u32::from(u16::MAX)) + (sum >> 16); + } + frame[ip_offset + 10..ip_offset + 12].copy_from_slice(&(!(sum as u16)).to_be_bytes()); + } + + fn tcp_frame(destination: Ipv4Addr, destination_port: u16) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 20]; + frame[6..12].copy_from_slice(&GUEST_MAC_BYTES); + frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes()); + let ip = &mut frame[14..]; + ip[0] = 0x45; + ip[2..4].copy_from_slice(&40u16.to_be_bytes()); + ip[6..8].copy_from_slice(&0x4000u16.to_be_bytes()); + ip[8] = 64; + ip[9] = 6; + ip[12..16].copy_from_slice(&Ipv4Addr::new(10, 0, 0, 2).octets()); + ip[16..20].copy_from_slice(&destination.octets()); + ip[20..22].copy_from_slice(&12345u16.to_be_bytes()); + ip[22..24].copy_from_slice(&destination_port.to_be_bytes()); + ip[32] = 5 << 4; + set_ipv4_checksum(&mut frame, 14); + frame + } + + fn udp_frame(destination: Ipv4Addr, destination_port: u16) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 8]; + frame[6..12].copy_from_slice(&GUEST_MAC_BYTES); + frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes()); + let ip = &mut frame[14..]; + ip[0] = 0x45; + ip[2..4].copy_from_slice(&28u16.to_be_bytes()); + ip[6..8].copy_from_slice(&0x4000u16.to_be_bytes()); + ip[8] = 64; + ip[9] = 17; + ip[12..16].copy_from_slice(&Ipv4Addr::new(10, 0, 0, 2).octets()); + ip[16..20].copy_from_slice(&destination.octets()); + ip[20..22].copy_from_slice(&12345u16.to_be_bytes()); + ip[22..24].copy_from_slice(&destination_port.to_be_bytes()); + ip[24..26].copy_from_slice(&8u16.to_be_bytes()); + set_ipv4_checksum(&mut frame, 14); + frame + } + + fn arp_request( + target: Ipv4Addr, + ethernet_destination: [u8; 6], + target_hardware: [u8; 6], + ) -> Vec { + let mut frame = vec![0u8; 42]; + frame[..6].copy_from_slice(ðernet_destination); + frame[6..12].copy_from_slice(&GUEST_MAC_BYTES); + frame[12..14].copy_from_slice(&0x0806u16.to_be_bytes()); + frame[14..16].copy_from_slice(&1u16.to_be_bytes()); + frame[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + frame[18] = 6; + frame[19] = 4; + frame[20..22].copy_from_slice(&1u16.to_be_bytes()); + frame[22..28].copy_from_slice(&GUEST_MAC_BYTES); + frame[28..32].copy_from_slice(&GUEST_IPV4.octets()); + frame[32..38].copy_from_slice(&target_hardware); + frame[38..42].copy_from_slice(&target.octets()); + frame + } + + #[test] + fn policy_rules_are_parsed_and_canonicalized() { + let policy = bind(EgressPolicyMode::AllowList(vec![ + "192.168.1.9/24".parse().unwrap(), + "10.0.0.1".parse().unwrap(), + "192.168.1.0/24".parse().unwrap(), + ])); + let EgressPolicyMode::AllowList(rules) = policy.mode() else { + panic!("unexpected policy mode") + }; + assert_eq!(rules.len(), 2); + assert_eq!(rules[1].network(), Ipv4Addr::new(192, 168, 1, 0)); + assert_eq!(policy.next_hops(), &[GATEWAY_IPV4]); + assert!(policy.allows_gateway_dns()); + assert!(policy.validate().is_ok()); + assert_ne!( + policy + .canonical_bytes_for_version(LEGACY_EGRESS_POLICY_ENCODING_VERSION) + .unwrap(), + policy.canonical_bytes() + ); + } + + #[test] + fn exact_endpoint_policy_filters_before_transmission() { + let policy = bind(EgressPolicyMode::TcpEndpoints(vec![ + "192.0.2.7:443".parse().unwrap(), + ])); + let allowed = tcp_frame(Ipv4Addr::new(192, 0, 2, 7), 443); + policy.authorize_frame(&allowed, allowed.len()).unwrap(); + + let denied = tcp_frame(Ipv4Addr::new(192, 0, 2, 7), 80); + assert_eq!( + policy.authorize_frame(&denied, denied.len()), + Err(EgressDenied::EndpointDenied) + ); + let mut malformed = allowed.clone(); + malformed[24] ^= 1; + assert_eq!( + policy.authorize_frame(&malformed, malformed.len()), + Err(EgressDenied::Malformed("IPv4 checksum")) + ); + let mut spoofed_mac = allowed.clone(); + spoofed_mac[11] ^= 1; + assert_eq!( + policy.authorize_frame(&spoofed_mac, spoofed_mac.len()), + Err(EgressDenied::SourceMacDenied) + ); + + let mut with_options = allowed.clone(); + with_options.splice(34..34, [1, 1, 1, 0]); + with_options[14] = 0x46; + with_options[16..18].copy_from_slice(&44u16.to_be_bytes()); + set_ipv4_checksum(&mut with_options, 14); + assert_eq!( + policy.authorize_frame(&with_options, with_options.len()), + Err(EgressDenied::Ipv4OptionsDenied) + ); + + let udp = udp_frame(Ipv4Addr::new(192, 0, 2, 7), 443); + assert_eq!( + policy.authorize_frame(&udp, udp.len()), + Err(EgressDenied::EndpointDenied) + ); + let mut ipv6 = vec![0u8; 14 + 40]; + ipv6[6..12].copy_from_slice(&GUEST_MAC_BYTES); + ipv6[12..14].copy_from_slice(&0x86ddu16.to_be_bytes()); + assert_eq!( + policy.authorize_frame(&ipv6, ipv6.len()), + Err(EgressDenied::UnsupportedEtherType(0x86dd)) + ); + let mut vlan = vec![0u8; allowed.len() + 4]; + vlan[..12].copy_from_slice(&allowed[..12]); + vlan[12..14].copy_from_slice(&0x8100u16.to_be_bytes()); + vlan[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + vlan[18..].copy_from_slice(&allowed[14..]); + assert_eq!( + policy.authorize_frame(&vlan, vlan.len()), + Err(EgressDenied::VlanDenied) + ); + } + + #[test] + fn endpoint_policy_resolves_only_required_next_hops_without_widening_ipv4() { + let on_link = Ipv4Addr::new(10, 0, 0, 9); + let off_subnet = Ipv4Addr::new(192, 0, 2, 7); + let unrelated = Ipv4Addr::new(10, 0, 0, 10); + let policy = bind(EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:8443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + "10.0.0.9:443".parse().unwrap(), + "10.0.0.9:443".parse().unwrap(), + ])); + + assert_eq!(policy.next_hops(), &[GATEWAY_IPV4, on_link]); + for target in [GATEWAY_IPV4, on_link] { + let request = arp_request(target, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy + .authorize_frame(&request, request.len()) + .expect("required next hop must be resolvable"); + } + let unrelated_request = arp_request(unrelated, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + assert_eq!( + policy.authorize_frame(&unrelated_request, unrelated_request.len()), + Err(EgressDenied::ArpDenied) + ); + + policy + .authorize_frame(&tcp_frame(on_link, 443), 54) + .unwrap(); + policy + .authorize_frame(&tcp_frame(off_subnet, 443), 54) + .unwrap(); + let wrong_port = tcp_frame(on_link, 80); + assert_eq!( + policy.authorize_frame(&wrong_port, wrong_port.len()), + Err(EgressDenied::EndpointDenied) + ); + let unrelated_ipv4 = tcp_frame(unrelated, 443); + assert_eq!( + policy.authorize_frame(&unrelated_ipv4, unrelated_ipv4.len()), + Err(EgressDenied::EndpointDenied) + ); + } + + #[test] + fn endpoint_policy_rejects_invalid_destination_identities() { + let invalid = [ + (Ipv4Addr::UNSPECIFIED, InvalidEndpointAddress::Unspecified), + ( + Ipv4Addr::new(0, 1, 2, 3), + InvalidEndpointAddress::CurrentNetwork, + ), + ( + Ipv4Addr::new(127, 0, 0, 1), + InvalidEndpointAddress::Loopback, + ), + ( + Ipv4Addr::new(169, 254, 1, 1), + InvalidEndpointAddress::LinkLocal, + ), + ( + Ipv4Addr::new(224, 0, 0, 1), + InvalidEndpointAddress::Multicast, + ), + ( + Ipv4Addr::BROADCAST, + InvalidEndpointAddress::LimitedBroadcast, + ), + ( + Ipv4Addr::new(240, 0, 0, 1), + InvalidEndpointAddress::Reserved, + ), + (GUEST_IPV4, InvalidEndpointAddress::Guest), + ( + Ipv4Addr::new(10, 0, 0, 0), + InvalidEndpointAddress::SubnetNetwork, + ), + ( + Ipv4Addr::new(10, 0, 0, 255), + InvalidEndpointAddress::SubnetBroadcast, + ), + ]; + + for (address, reason) in invalid { + let endpoint = format!("{address}:443").parse().unwrap(); + assert_eq!( + EgressPolicy::bind( + GUEST_IPV4, + PREFIX_LENGTH, + MacAddress::new(GUEST_MAC_BYTES), + GATEWAY_IPV4, + EgressPolicyMode::TcpEndpoints(vec![endpoint]), + ), + Err(InvalidEgressPolicy::InvalidEndpoint { address, reason }) + ); + } + } + + #[test] + fn decoded_policy_must_preserve_canonical_rules_and_next_hops() { + let mut noncanonical_rules = bind(EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + ])); + let EgressPolicyMode::TcpEndpoints(endpoints) = &mut noncanonical_rules.mode else { + panic!("expected endpoint policy") + }; + endpoints.push("10.0.0.9:443".parse().unwrap()); + assert_eq!( + noncanonical_rules.validate(), + Err(InvalidEgressPolicy::NonCanonicalRules) + ); + + let mut invalid_next_hops = bind(EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + ])); + invalid_next_hops + .next_hops + .push(Ipv4Addr::new(10, 0, 0, 10)); + assert_eq!( + invalid_next_hops.validate(), + Err(InvalidEgressPolicy::InvalidNextHops) + ); + } + + #[test] + fn arp_requires_canonical_guest_requests() { + let on_link = Ipv4Addr::new(10, 0, 0, 9); + let peer_mac = [0x52, 0x54, 0, 0, 0, 9]; + let policy = bind(EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + ])); + + let broadcast = arp_request(on_link, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy.authorize_frame(&broadcast, broadcast.len()).unwrap(); + let unicast_refresh = arp_request(on_link, peer_mac, peer_mac); + policy + .authorize_frame(&unicast_refresh, unicast_refresh.len()) + .unwrap(); + + let mut ethernet_spoof = broadcast.clone(); + ethernet_spoof[11] ^= 1; + assert_eq!( + policy.authorize_frame(ðernet_spoof, ethernet_spoof.len()), + Err(EgressDenied::SourceMacDenied) + ); + let mut sender_hardware_spoof = broadcast.clone(); + sender_hardware_spoof[27] ^= 1; + assert_eq!( + policy.authorize_frame(&sender_hardware_spoof, sender_hardware_spoof.len()), + Err(EgressDenied::ArpDenied) + ); + let mut sender_ip_spoof = broadcast.clone(); + sender_ip_spoof[31] ^= 1; + assert_eq!( + policy.authorize_frame(&sender_ip_spoof, sender_ip_spoof.len()), + Err(EgressDenied::ArpDenied) + ); + let mut noncanonical_target_hardware = broadcast.clone(); + noncanonical_target_hardware[37] = 1; + assert_eq!( + policy.authorize_frame( + &noncanonical_target_hardware, + noncanonical_target_hardware.len() + ), + Err(EgressDenied::ArpDenied) + ); + let mut reply = broadcast.clone(); + reply[20..22].copy_from_slice(&2u16.to_be_bytes()); + assert_eq!( + policy.authorize_frame(&reply, reply.len()), + Err(EgressDenied::Malformed("ARP request")) + ); + assert_eq!( + policy.authorize_frame(&broadcast[..40], broadcast.len()), + Err(EgressDenied::Malformed("ARP packet")) + ); + } + + #[test] + fn list_policies_filter_destinations_and_allow_only_gateway_arp() { + let allowed_destination = Ipv4Addr::new(192, 0, 2, 7); + let blocked_destination = Ipv4Addr::new(198, 51, 100, 9); + let allow = bind(EgressPolicyMode::AllowList(vec![ + "192.0.2.0/24".parse().unwrap(), + ])); + let allowed = tcp_frame(allowed_destination, 80); + allow.authorize_frame(&allowed, allowed.len()).unwrap(); + let denied = tcp_frame(blocked_destination, 80); + assert_eq!( + allow.authorize_frame(&denied, denied.len()), + Err(EgressDenied::DestinationDenied) + ); + + let block = bind(EgressPolicyMode::BlockList(vec![ + "198.51.100.0/24".parse().unwrap(), + ])); + block.authorize_frame(&allowed, allowed.len()).unwrap(); + assert_eq!( + block.authorize_frame(&denied, denied.len()), + Err(EgressDenied::DestinationDenied) + ); + + let gateway_request = arp_request(GATEWAY_IPV4, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + allow + .authorize_frame(&gateway_request, gateway_request.len()) + .unwrap(); + let other_arp = arp_request( + Ipv4Addr::new(10, 0, 0, 9), + ETHERNET_BROADCAST, + ETHERNET_UNSPECIFIED, + ); + assert_eq!( + allow.authorize_frame(&other_arp, other_arp.len()), + Err(EgressDenied::ArpDenied) + ); + } + + #[test] + fn l3_l4_rules_parse_and_apply_deny_precedence() { + assert_eq!( + "192.0.2.9/24:tcp:443".parse::().unwrap(), + EgressRule { + destination: "192.0.2.0/24".parse().unwrap(), + transport: EgressTransport::Tcp, + port: 443, + } + ); + assert_eq!( + "198.51.100.0/24".parse::().unwrap().transport(), + EgressTransport::Any + ); + for invalid in [ + "192.0.2.0/24:icmp:8", + "192.0.2.0/24:tcp:0", + "192.0.2.0/24:tcp", + "192.0.2.0/24:tcp:443:extra", + ] { + assert!(invalid.parse::().is_err(), "{invalid}"); + } + + let policy = bind(EgressPolicyMode::Rules { + default_action: EgressAction::Deny, + allow: vec![ + "192.0.2.0/24:tcp:443".parse().unwrap(), + "192.0.2.0/24:udp:53".parse().unwrap(), + "10.0.0.9".parse().unwrap(), + "192.0.2.0/24:tcp:443".parse().unwrap(), + ], + deny: vec!["192.0.2.7:tcp:443".parse().unwrap()], + }); + assert_eq!(policy.mode_name(), "rules"); + assert_eq!(policy.rule_counts(), (3, 1)); + assert_eq!(policy.next_hops(), &[GATEWAY_IPV4]); + + let allowed_tcp = tcp_frame(Ipv4Addr::new(192, 0, 2, 8), 443); + policy + .authorize_frame(&allowed_tcp, allowed_tcp.len()) + .unwrap(); + let denied_by_precedence = tcp_frame(Ipv4Addr::new(192, 0, 2, 7), 443); + assert_eq!( + policy.authorize_frame(&denied_by_precedence, denied_by_precedence.len()), + Err(EgressDenied::DestinationDenied) + ); + let wrong_port = tcp_frame(Ipv4Addr::new(192, 0, 2, 8), 80); + assert_eq!( + policy.authorize_frame(&wrong_port, wrong_port.len()), + Err(EgressDenied::DestinationDenied) + ); + let allowed_udp = udp_frame(Ipv4Addr::new(192, 0, 2, 7), 53); + policy + .authorize_frame(&allowed_udp, allowed_udp.len()) + .unwrap(); + + let on_link = Ipv4Addr::new(10, 0, 0, 9); + let on_link_arp = arp_request(on_link, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy + .authorize_frame(&on_link_arp, on_link_arp.len()) + .unwrap(); + + let mut fragment = allowed_tcp; + fragment[20..22].copy_from_slice(&0x2000u16.to_be_bytes()); + set_ipv4_checksum(&mut fragment, 14); + assert_eq!( + policy.authorize_frame(&fragment, fragment.len()), + Err(EgressDenied::FragmentDenied) + ); + assert!(policy.validate().is_ok()); + } + + #[test] + fn host_loopback_denial_preserves_only_the_exact_proxy() { + let proxy: TcpEndpoint = "10.0.0.1:8443".parse().unwrap(); + let policy = bind(EgressPolicyMode::DenyAll) + .with_host_loopback(EgressAction::Deny, Some(proxy)) + .unwrap(); + assert_eq!(policy.host_loopback_action(), EgressAction::Deny); + assert_eq!(policy.proxy_endpoint(), Some(proxy)); + assert_eq!(policy.next_hops(), &[GATEWAY_IPV4]); + + let arp = arp_request(GATEWAY_IPV4, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy.authorize_frame(&arp, arp.len()).unwrap(); + let allowed = tcp_frame(GATEWAY_IPV4, 8443); + policy.authorize_frame(&allowed, allowed.len()).unwrap(); + let denied = tcp_frame(GATEWAY_IPV4, 8080); + assert_eq!( + policy.authorize_frame(&denied, denied.len()), + Err(EgressDenied::AllTrafficDenied) + ); + assert_ne!( + policy + .canonical_bytes_for_version(PREVIOUS_EGRESS_POLICY_ENCODING_VERSION) + .unwrap(), + policy.canonical_bytes() + ); + assert!(policy.validate().is_ok()); + + let invalid_proxy: TcpEndpoint = "192.0.2.1:8443".parse().unwrap(); + assert_eq!( + bind(EgressPolicyMode::AllowAll) + .with_host_loopback(EgressAction::Deny, Some(invalid_proxy)), + Err(InvalidEgressPolicy::InvalidProxyAddress { + actual: Ipv4Addr::new(192, 0, 2, 1), + expected: GATEWAY_IPV4, + }) + ); + } + + #[test] + fn allow_all_does_not_parse_guest_frames() { + let policy = bind(EgressPolicyMode::AllowAll); + policy.authorize_frame(&[], 0).unwrap(); + } + + #[test] + fn deny_all_rejects_every_guest_frame_before_parsing() { + let policy = bind(EgressPolicyMode::DenyAll); + assert!(policy.is_active()); + assert!(!policy.allows_gateway_dns()); + assert!(policy.next_hops().is_empty()); + + for frame in [ + tcp_frame(Ipv4Addr::new(192, 0, 2, 7), 443), + arp_request(GATEWAY_IPV4, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED), + Vec::new(), + ] { + assert_eq!( + policy.authorize_frame(&frame, frame.len()), + Err(EgressDenied::AllTrafficDenied) + ); + } + } +} diff --git a/vm/devices/net/net_backend_resources/src/lib.rs b/vm/devices/net/net_backend_resources/src/lib.rs index 6eb030799f0..fdb650b0c8d 100644 --- a/vm/devices/net/net_backend_resources/src/lib.rs +++ b/vm/devices/net/net_backend_resources/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] +pub mod egress; pub mod mac_address; /// Null backend. From 5330b0cb412ae7aac29831e6fa48a07a46a62d65 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:39:44 -0700 Subject: [PATCH 134/304] net_backend: let endpoints accept a run-scoped egress policy An egress policy is only useful if the backend that transmits guest frames enforces it. Backends need a way to receive the policy for a run, and an endpoint that cannot enforce it must fail the configuration instead of silently forwarding everything. Add `Endpoint::set_egress_policy`, which configures a policy before any queue is created. A backend that accepts an active policy must apply it to the immutable packet bytes immediately before externally visible transmission. The default implementation accepts only inactive policies (allow-all, or allow-by-default rules without deny rules) and rejects an active one with an error that names the endpoint type. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/net/net_backend/src/lib.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/vm/devices/net/net_backend/src/lib.rs b/vm/devices/net/net_backend/src/lib.rs index 66085cbd1df..3a975172855 100644 --- a/vm/devices/net/net_backend/src/lib.rs +++ b/vm/devices/net/net_backend/src/lib.rs @@ -53,6 +53,7 @@ use inspect::InspectMut; use inspect_counters::Counter; use mesh::rpc::Rpc; use mesh::rpc::RpcSend; +use net_backend_resources::egress::EgressPolicy; use null::NullEndpoint; use pal_async::driver::Driver; use std::future::pending; @@ -94,6 +95,19 @@ pub trait Endpoint: Send + Sync + InspectMut { queues: &mut Vec>, ) -> anyhow::Result<()>; + /// Configures a run-scoped egress policy before queues are created. + /// + /// Backends that accept an active policy must apply it to the immutable + /// packet bytes immediately before externally visible transmission. + fn set_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + anyhow::ensure!( + !policy.is_active(), + "network backend '{}' does not support active egress policy", + self.endpoint_type() + ); + Ok(()) + } + /// Stops the endpoint. /// /// All queues returned via `get_queues` must have been dropped. From 5cb02a19237e1c7339ff3b22a04604c8a7f6a6ee Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:41:22 -0700 Subject: [PATCH 135/304] net_consomme: enforce the egress policy on final transmitted bytes Consomme is the portable network backend of a microVM, so it must honor an active egress policy. A check on descriptors or on a partial copy of a frame would let the guest change the frame between the check and the send; the policy has to judge the exact bytes that the backend sends. Accept a run-scoped policy in the Consomme endpoint. The policy can be installed only while no queue is active, and the queue's transmit path assembles each guest frame from guest memory into its scratch buffer and authorizes that buffer before it hands the same bytes to the Consomme stack. A denied frame is completed without being sent and counts as a transmit drop. Without a policy, transmission is unchanged. Tests cover an exact-endpoint policy: the gateway answers an ARP request for an on-link endpoint, while a request for an unlisted target or with a spoofed source MAC is completed without a reply. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + vm/devices/net/net_consomme/Cargo.toml | 3 + vm/devices/net/net_consomme/src/egress.rs | 111 +++++++++++------ vm/devices/net/net_consomme/src/lib.rs | 13 ++ vm/devices/net/net_consomme/src/tests.rs | 144 ++++++++++++++++++++++ 5 files changed, 232 insertions(+), 40 deletions(-) create mode 100644 vm/devices/net/net_consomme/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index a2d07929bc1..46e8842cbbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5061,6 +5061,7 @@ dependencies = [ "anyhow", "async-trait", "consomme", + "guestmem", "inspect", "inspect_counters", "mesh", diff --git a/vm/devices/net/net_consomme/Cargo.toml b/vm/devices/net/net_consomme/Cargo.toml index 3ef73901ab1..f1a50f2cfcd 100644 --- a/vm/devices/net/net_consomme/Cargo.toml +++ b/vm/devices/net/net_consomme/Cargo.toml @@ -25,5 +25,8 @@ async-trait.workspace = true parking_lot.workspace = true tracing.workspace = true +[dev-dependencies] +guestmem.workspace = true + [lints] workspace = true diff --git a/vm/devices/net/net_consomme/src/egress.rs b/vm/devices/net/net_consomme/src/egress.rs index 67ae820ac0b..c41d7fc44c2 100644 --- a/vm/devices/net/net_consomme/src/egress.rs +++ b/vm/devices/net/net_consomme/src/egress.rs @@ -1,68 +1,100 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Egress processing of the Consomme queue. +//! Egress policy enforcement of the Consomme endpoint. //! -//! The queue's transmit path assembles each guest frame from guest memory and -//! sends it to the Consomme stack. +//! The endpoint accepts a run-scoped [`EgressPolicy`] while no queue is +//! active. The queue's transmit path assembles each guest frame from guest +//! memory and drops the frames that the policy denies before they reach the +//! Consomme stack. +use crate::ConsommeEndpoint; use crate::ConsommeQueue; +use anyhow::Context as _; use consomme::ChecksumState; use net_backend::BufferAccess; use net_backend::TxSegmentType; +use net_backend_resources::egress::EgressPolicy; + +impl ConsommeEndpoint { + /// Implements `set_egress_policy`. + pub(crate) fn install_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + self.endpoint_state + .lock() + .as_mut() + .context("cannot configure Consomme egress policy while a queue is active")? + .egress_policy = Some(policy); + Ok(()) + } +} impl ConsommeQueue { - /// Sends the queued guest frames to the Consomme stack and completes all of - /// them. + /// Sends the queued guest frames that the egress policy allows to the + /// Consomme stack and completes all of them. pub(crate) fn process_tx(&mut self, pool: &mut dyn BufferAccess) { while let Some(head) = self.state.tx_avail.front() { - let TxSegmentType::Head(meta) = &head.ty else { + let TxSegmentType::Head(metadata) = &head.ty else { unreachable!() }; - let tx_id = meta.id; + let tx_id = metadata.id; let checksum = ChecksumState { - ipv4: meta.flags.offload_ip_header_checksum(), - tcp: meta.flags.offload_tcp_checksum(), - udp: meta.flags.offload_udp_checksum(), - tso: meta + ipv4: metadata.flags.offload_ip_header_checksum(), + tcp: metadata.flags.offload_tcp_checksum(), + udp: metadata.flags.offload_udp_checksum(), + tso: metadata .flags .offload_tcp_segmentation() - .then_some(meta.max_segment_size), - gso: meta + .then_some(metadata.max_segment_size), + gso: metadata .flags .offload_udp_segmentation() - .then_some(meta.max_segment_size), + .then_some(metadata.max_segment_size), }; + let segment_count = metadata.segment_count as usize; + let packet_len = metadata.len as usize; - // Reuse the scratch buffer to avoid per-packet heap allocation. - // TSO caps the assembled packet at 64 KiB; assert so a buggy - // upstream caller can't permanently inflate the scratch buffer - // (and thus the queue's steady-state memory) by feeding an - // oversized `meta.len`. - debug_assert!( - meta.len as usize <= 64 * 1024, - "tx packet len {} exceeds 64 KiB TSO bound", - meta.len - ); - let mut buf = std::mem::take(&mut self.state.tx_scratch); - buf.clear(); - buf.resize(meta.len as usize, 0); - let gm = pool.guest_memory(); - let mut offset = 0; - for segment in self.state.tx_avail.drain(..meta.segment_count as usize) { - let dest = &mut buf[offset..offset + segment.len as usize]; - if let Err(err) = gm.read_at(segment.gpa, dest) { + let mut buffer = std::mem::take(&mut self.state.tx_scratch); + buffer.clear(); + buffer.resize(packet_len, 0); + let guest_memory = pool.guest_memory(); + let mut offset = 0usize; + for segment in self.state.tx_avail.drain(..segment_count) { + let Some(end) = offset.checked_add(segment.len as usize) else { + tracing::error!("network TX segment length overflow"); + break; + }; + let Some(destination) = buffer.get_mut(offset..end) else { + tracing::error!(packet_len, end, "network TX segments exceed packet length"); + break; + }; + if let Err(error) = guest_memory.read_at(segment.gpa, destination) { tracing::error!( - error = &err as &dyn std::error::Error, - "memory write failure" + error = &error as &dyn std::error::Error, + "network TX guest-memory read failure" ); } - offset += segment.len as usize; + offset = end; } - if let Err(err) = self.with_consomme(pool, |c| c.send(&buf, &checksum)) { - tracing::debug!(error = &err as &dyn std::error::Error, "tx packet ignored"); - match err { + let policy_result = (offset == packet_len) + .then(|| { + self.endpoint_state + .as_ref() + .and_then(|state| state.egress_policy.as_ref()) + .map(|policy| policy.authorize_frame(&buffer, buffer.len())) + }) + .flatten(); + if policy_result.is_some_and(|result| result.is_err()) { + self.stats.tx_dropped.increment(); + } else if offset == packet_len + && let Err(error) = + self.with_consomme(pool, |consomme| consomme.send(&buffer, &checksum)) + { + tracing::debug!( + error = &error as &dyn std::error::Error, + "tx packet ignored" + ); + match error { consomme::DropReason::SendBufferFull | consomme::DropReason::DestinationNotAllowed | consomme::DropReason::TcpConnectionLimit @@ -86,8 +118,7 @@ impl ConsommeQueue { | consomme::DropReason::MalformedPacket => self.stats.tx_errors.increment(), } } - self.state.tx_scratch = buf; - + self.state.tx_scratch = buffer; self.state.tx_ready.push_back(tx_id); } } diff --git a/vm/devices/net/net_consomme/src/lib.rs b/vm/devices/net/net_consomme/src/lib.rs index f7f7435e20c..714f9349c96 100644 --- a/vm/devices/net/net_consomme/src/lib.rs +++ b/vm/devices/net/net_consomme/src/lib.rs @@ -8,6 +8,9 @@ mod egress; mod quiesce; pub mod resolver; +#[cfg(test)] +mod tests; + use anyhow::Context as _; use async_trait::async_trait; use consomme::ChecksumState; @@ -36,6 +39,7 @@ use net_backend::TxSegment; use net_backend_resources::consomme::ConsommeRequest; use net_backend_resources::consomme::HostPortConfig; use net_backend_resources::consomme::HostPortProtocol; +use net_backend_resources::egress::EgressPolicy; use pal_async::driver::Driver; use parking_lot::Mutex; use std::collections::VecDeque; @@ -113,6 +117,7 @@ struct EndpointState { recv: Option>, port_recv: Option>, port_forwards: Vec, + egress_policy: Option, } impl ConsommeEndpoint { @@ -123,6 +128,7 @@ impl ConsommeEndpoint { recv: None, port_recv: None, port_forwards: Vec::new(), + egress_policy: None, }))), } } @@ -135,6 +141,7 @@ impl ConsommeEndpoint { recv: None, port_recv: None, port_forwards: ports, + egress_policy: None, }))), } } @@ -149,6 +156,7 @@ impl ConsommeEndpoint { recv: Some(recv), port_recv: None, port_forwards: Vec::new(), + egress_policy: None, }))), }, ConsommeControl { send }, @@ -168,6 +176,7 @@ impl ConsommeEndpoint { recv: None, port_recv: Some(port_recv), port_forwards: ports, + egress_policy: None, }))), } } @@ -420,6 +429,10 @@ impl net_backend::Endpoint for ConsommeEndpoint { Ok(()) } + fn set_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + self.install_egress_policy(policy) + } + async fn stop(&mut self) { assert!(self.endpoint_state.lock().is_some()); } diff --git a/vm/devices/net/net_consomme/src/tests.rs b/vm/devices/net/net_consomme/src/tests.rs new file mode 100644 index 00000000000..cddd44bf22b --- /dev/null +++ b/vm/devices/net/net_consomme/src/tests.rs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests of the egress policy of the Consomme endpoint. + +use super::*; +use guestmem::GuestMemory; +use net_backend::Endpoint as _; +use net_backend::QueueConfig; +use net_backend::TxMetadata; +use net_backend::TxSegmentType; +use net_backend_resources::egress::EgressPolicyMode; +use net_backend_resources::mac_address::MacAddress; +use pal_async::DefaultDriver; +use std::future::poll_fn; + +const GUEST_IPV4: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2); +const GATEWAY_IPV4: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1); +const GUEST_MAC: [u8; 6] = [0x52, 0x54, 0, 0, 0, 2]; +const GATEWAY_MAC: [u8; 6] = [0x52, 0x54, 0, 0, 0, 1]; + +fn arp_request(target: Ipv4Addr) -> Vec { + let mut frame = vec![0u8; 42]; + frame[..6].fill(0xff); + frame[6..12].copy_from_slice(&GUEST_MAC); + frame[12..14].copy_from_slice(&0x0806u16.to_be_bytes()); + frame[14..16].copy_from_slice(&1u16.to_be_bytes()); + frame[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + frame[18] = 6; + frame[19] = 4; + frame[20..22].copy_from_slice(&1u16.to_be_bytes()); + frame[22..28].copy_from_slice(&GUEST_MAC); + frame[28..32].copy_from_slice(&GUEST_IPV4.octets()); + frame[38..42].copy_from_slice(&target.octets()); + frame +} + +fn tx_segment(id: TxId, frame_length: usize) -> TxSegment { + TxSegment { + ty: TxSegmentType::Head(TxMetadata { + id, + segment_count: 1, + len: frame_length as u32, + ..Default::default() + }), + gpa: 0, + len: frame_length as u32, + } +} + +#[pal_async::async_test] +async fn endpoint_policy_proxy_arps_only_bound_on_link_targets(driver: DefaultDriver) { + let mut params = ConsommeParams::new().unwrap(); + params + .set_static_ipv4(GUEST_IPV4, 24, GATEWAY_IPV4, GATEWAY_MAC) + .unwrap(); + params.client_mac.0 = GUEST_MAC; + let mut endpoint = ConsommeEndpoint::new(params); + endpoint + .set_egress_policy( + EgressPolicy::bind( + GUEST_IPV4, + 24, + MacAddress::new(GUEST_MAC), + GATEWAY_IPV4, + EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + ]), + ) + .unwrap(), + ) + .unwrap(); + + let layout = net_backend::tests::test_layout(); + let memory = GuestMemory::allocate(layout.end_of_ram() as usize); + let mut pool = net_backend::tests::Bufs::new(memory.clone()); + let mut queues = Vec::new(); + endpoint + .get_queues( + vec![QueueConfig { + driver: Box::new(driver), + }], + None, + &mut queues, + ) + .await + .unwrap(); + let queue = &mut queues[0]; + + let allowed = arp_request(Ipv4Addr::new(10, 0, 0, 9)); + memory.write_at(0, &allowed).unwrap(); + queue.rx_avail(&mut pool, &[RxId(1)]); + assert_eq!( + queue + .tx_avail(&mut pool, &[tx_segment(TxId(7), allowed.len())]) + .unwrap(), + (false, 1) + ); + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + + let mut completed = [TxId(0)]; + assert_eq!(queue.tx_poll(&mut pool, &mut completed).unwrap(), 1); + assert_eq!(completed[0].0, 7); + let mut received = [RxId(0)]; + assert_eq!(queue.rx_poll(&mut pool, &mut received).unwrap(), 1); + assert_eq!(received[0].0, 1); + let metadata = pool.rx_metadata(RxId(1)).unwrap(); + assert_eq!(metadata.len, 42); + let mut reply = vec![0u8; metadata.len]; + memory.read_at(2048, &mut reply).unwrap(); + assert_eq!(&reply[..6], &GUEST_MAC); + assert_eq!(&reply[6..12], &GATEWAY_MAC); + assert_eq!(u16::from_be_bytes([reply[20], reply[21]]), 2); + assert_eq!(&reply[22..28], &GATEWAY_MAC); + assert_eq!(&reply[28..32], &Ipv4Addr::new(10, 0, 0, 9).octets()); + assert_eq!(&reply[32..38], &GUEST_MAC); + assert_eq!(&reply[38..42], &GUEST_IPV4.octets()); + + let denied = arp_request(Ipv4Addr::new(10, 0, 0, 10)); + memory.write_at(0, &denied).unwrap(); + queue.rx_avail(&mut pool, &[RxId(2)]); + queue + .tx_avail(&mut pool, &[tx_segment(TxId(8), denied.len())]) + .unwrap(); + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + assert_eq!(queue.tx_poll(&mut pool, &mut completed).unwrap(), 1); + assert_eq!(completed[0].0, 8); + assert_eq!(queue.rx_poll(&mut pool, &mut received).unwrap(), 0); + + let mut spoofed = allowed; + spoofed[11] ^= 1; + memory.write_at(0, &spoofed).unwrap(); + queue + .tx_avail(&mut pool, &[tx_segment(TxId(9), spoofed.len())]) + .unwrap(); + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + assert_eq!(queue.tx_poll(&mut pool, &mut completed).unwrap(), 1); + assert_eq!(completed[0].0, 9); + assert_eq!(queue.rx_poll(&mut pool, &mut received).unwrap(), 0); + + drop(queues); + endpoint.stop().await; +} From 94b18978549061836952001030c2ff29cc555776 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:43:27 -0700 Subject: [PATCH 136/304] net_tap: enforce the egress policy on TAP transmit A TAP backend hands guest frames to the host network stack, so an active egress policy must also bind it; otherwise a microVM could bypass its policy by using TAP instead of Consomme. Accept a run-scoped policy in the TAP endpoint and pass it to each queue's transmit state. The transmit path authorizes the assembled frame immediately before it writes the frame to the TAP device, and completes a denied frame without writing it. Tests cover an exact-endpoint policy that forwards the guest's ARP request for an on-link endpoint to the TAP device, which answers it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/net/net_tap/src/lib.rs | 17 ++- vm/devices/net/net_tap/src/tx.rs | 14 ++- vm/devices/net/net_tap/tests/tap_tests.rs | 6 + .../net/net_tap/tests/tap_tests/egress.rs | 109 ++++++++++++++++++ 4 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 vm/devices/net/net_tap/tests/tap_tests/egress.rs diff --git a/vm/devices/net/net_tap/src/lib.rs b/vm/devices/net/net_tap/src/lib.rs index 988404288bb..364969af6a2 100644 --- a/vm/devices/net/net_tap/src/lib.rs +++ b/vm/devices/net/net_tap/src/lib.rs @@ -28,6 +28,7 @@ use net_backend::TxId; use net_backend::TxMetadata; use net_backend::TxOffloadSupport; use net_backend::TxSegment; +use net_backend_resources::egress::EgressPolicy; use pal_async::driver::Driver; use parking_lot::Mutex; use std::collections::VecDeque; @@ -109,6 +110,7 @@ pub use vnet_hdr::*; /// An endpoint based on a TAP interface. pub struct TapEndpoint { tap: Arc>>, + egress_policy: Option, } impl TapEndpoint { @@ -135,6 +137,7 @@ impl TapEndpoint { Ok(Self { tap: Arc::new(Mutex::new(Some(tap))), + egress_policy: None, }) } } @@ -163,10 +166,16 @@ impl Endpoint for TapEndpoint { queues.push(Box::new(TapQueue::new( config.driver.as_ref(), self.tap.clone(), + self.egress_policy.clone(), )?)); Ok(()) } + fn set_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + self.egress_policy = Some(policy); + Ok(()) + } + async fn stop(&mut self) { assert!(self.tap.lock().is_some(), "queue has not been dropped"); } @@ -218,7 +227,11 @@ impl Drop for TapQueue { } impl TapQueue { - fn new(driver: &dyn Driver, slot: Arc>>) -> anyhow::Result { + fn new( + driver: &dyn Driver, + slot: Arc>>, + egress_policy: Option, + ) -> anyhow::Result { let tap = slot.lock().take().expect("queue is already in use"); let tap = tap.polled(driver)?; Ok(Self { @@ -229,7 +242,7 @@ impl TapQueue { rx_ready: VecDeque::new(), }, buffer: vec![0; 65535 + size_of::()].into_boxed_slice(), - tx: tx::TxState::new(), + tx: tx::TxState::new(egress_policy), input_quiesced: false, }) } diff --git a/vm/devices/net/net_tap/src/tx.rs b/vm/devices/net/net_tap/src/tx.rs index eb62c632da0..6c3b658ca02 100644 --- a/vm/devices/net/net_tap/src/tx.rs +++ b/vm/devices/net/net_tap/src/tx.rs @@ -6,7 +6,8 @@ //! Each `tx_avail` call writes at most one guest packet. When the TAP //! interface applies backpressure, the queue keeps the packet, and with it //! the ownership of its descriptors, until `poll_ready` writes it; `tx_poll` -//! then completes it. +//! then completes it. Packets that the run-scoped egress policy denies are +//! completed without being written. use crate::TapQueue; use crate::VirtioNetHdr; @@ -21,6 +22,7 @@ use net_backend::TxId; use net_backend::TxSegment; use net_backend::linearize; use net_backend::next_packet; +use net_backend_resources::egress::EgressPolicy; use std::collections::VecDeque; use std::io::ErrorKind; use std::io::Write; @@ -34,6 +36,7 @@ pub(crate) struct TxState { pub(crate) pending: Option, pub(crate) ready: VecDeque, pub(crate) error: Option, + egress_policy: Option, } /// A packet accepted from the guest and not yet written to the TAP interface. @@ -44,11 +47,12 @@ pub(crate) struct PendingTx { } impl TxState { - pub(crate) fn new() -> Self { + pub(crate) fn new(egress_policy: Option) -> Self { Self { pending: None, ready: VecDeque::new(), error: None, + egress_policy, } } } @@ -119,6 +123,12 @@ impl TapQueue { if metadata.flags.offload_tcp_segmentation() && metadata.flags.is_ipv6() { fixup_ipv6_payload_length(&mut packet, metadata.l2_len as usize); } + if let Some(policy) = &self.tx.egress_policy + && policy.authorize_frame(&packet, packet.len()).is_err() + { + return Ok((true, segment_count)); + } + let tap = self.tap.as_mut().context("TAP queue is unavailable")?; let header_bytes = header.as_bytes(); let bufs = [ diff --git a/vm/devices/net/net_tap/tests/tap_tests.rs b/vm/devices/net/net_tap/tests/tap_tests.rs index 9e4cb0b843e..e1328a2360e 100644 --- a/vm/devices/net/net_tap/tests/tap_tests.rs +++ b/vm/devices/net/net_tap/tests/tap_tests.rs @@ -20,6 +20,8 @@ mod tap_tests { // configuration (ioctls for link-up, address, netmask). #![expect(unsafe_code)] + mod egress; + use libtest_mimic::Arguments; use libtest_mimic::Trial; use net_backend::Endpoint; @@ -639,6 +641,10 @@ mod tap_tests { async_trial("tap_get_queues", test_tap_get_queues), async_trial("tap_tx_sends_frame", test_tap_tx_sends_frame), async_trial("tap_rx_receives_packet", test_tap_rx_receives_packet), + async_trial( + "tap_endpoint_policy_forwards_on_link_arp", + egress::test_tap_endpoint_policy_forwards_on_link_arp, + ), async_trial( "tap_tx_backpressure_preserves_ownership", test_tap_tx_backpressure_preserves_ownership, diff --git a/vm/devices/net/net_tap/tests/tap_tests/egress.rs b/vm/devices/net/net_tap/tests/tap_tests/egress.rs new file mode 100644 index 00000000000..45c6513846a --- /dev/null +++ b/vm/devices/net/net_tap/tests/tap_tests/egress.rs @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Egress policy tests of the TAP endpoint. + +use super::configure_tap; +use super::make_pool; +use super::new_endpoint; +use net_backend::Endpoint; +use net_backend::QueueConfig; +use net_backend::RxId; +use net_backend::TxId; +use net_backend::TxMetadata; +use net_backend::TxSegment; +use net_backend::TxSegmentType; +use net_backend_resources::egress::EgressPolicy; +use net_backend_resources::egress::EgressPolicyMode; +use net_backend_resources::mac_address::MacAddress; +use pal_async::DefaultDriver; +use std::future::poll_fn; + +fn endpoint_arp_request(target: std::net::Ipv4Addr) -> Vec { + let guest_mac = [0x52, 0x54, 0, 0, 0, 2]; + let mut frame = vec![0u8; 42]; + frame[..6].fill(0xff); + frame[6..12].copy_from_slice(&guest_mac); + frame[12..14].copy_from_slice(&0x0806u16.to_be_bytes()); + frame[14..16].copy_from_slice(&1u16.to_be_bytes()); + frame[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + frame[18] = 6; + frame[19] = 4; + frame[20..22].copy_from_slice(&1u16.to_be_bytes()); + frame[22..28].copy_from_slice(&guest_mac); + frame[28..32].copy_from_slice(&[10, 0, 0, 2]); + frame[38..42].copy_from_slice(&target.octets()); + frame +} + +/// Validates that exact endpoint policy forwards an on-link ARP request to TAP. +pub(super) async fn test_tap_endpoint_policy_forwards_on_link_arp(driver: DefaultDriver) { + let target = std::net::Ipv4Addr::new(10, 0, 0, 9); + let mut endpoint = new_endpoint("tap_policy").unwrap(); + configure_tap("tap_policy", "10.0.0.9/24"); + endpoint + .set_egress_policy( + EgressPolicy::bind( + std::net::Ipv4Addr::new(10, 0, 0, 2), + 24, + MacAddress::new([0x52, 0x54, 0, 0, 0, 2]), + std::net::Ipv4Addr::new(10, 0, 0, 1), + EgressPolicyMode::TcpEndpoints(vec!["10.0.0.9:443".parse().unwrap()]), + ) + .unwrap(), + ) + .unwrap(); + + let (mut pool, mem) = make_pool(); + let initial_rx: Vec<_> = (1..128).map(RxId).collect(); + let mut queues = Vec::new(); + endpoint + .get_queues( + vec![QueueConfig { + driver: Box::new(driver), + }], + None, + &mut queues, + ) + .await + .unwrap(); + let queue = &mut queues[0]; + queue.rx_avail(&mut pool, &initial_rx); + + let frame = endpoint_arp_request(target); + mem.write_at(0, &frame).unwrap(); + let segments = [TxSegment { + ty: TxSegmentType::Head(TxMetadata { + id: TxId(0), + segment_count: 1, + len: frame.len() as u32, + ..Default::default() + }), + gpa: 0, + len: frame.len() as u32, + }]; + let (completed, count) = queue.tx_avail(&mut pool, &segments).unwrap(); + assert_eq!(count, 1); + if !completed { + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + let mut done = [TxId(0)]; + assert_eq!(queue.tx_poll(&mut pool, &mut done).unwrap(), 1); + } + + poll_fn(|cx| queue.poll_ready(cx, &mut pool)).await; + let mut packets = [RxId(0); 128]; + let n = queue.rx_poll(&mut pool, &mut packets).unwrap(); + let found_reply = packets[..n].iter().any(|rx_id| { + let mut reply = [0u8; 42]; + mem.read_at(rx_id.0 as u64 * 2048, &mut reply).unwrap(); + reply[12..14] == 0x0806u16.to_be_bytes() + && reply[20..22] == 2u16.to_be_bytes() + && reply[28..32] == target.octets() + && reply[32..38] == [0x52, 0x54, 0, 0, 0, 2] + && reply[38..42] == [10, 0, 0, 2] + }); + assert!( + found_reply, + "expected TAP to answer the allowed endpoint ARP" + ); +} From 76d3ec0de177ccb125a296266521939fc35fa44e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:46:49 -0700 Subject: [PATCH 137/304] virtio_net: install and prefilter the egress policy The virtio-net device is where a microVM's guest frames enter the host, so it is the natural owner of a run's egress policy: it can reject an invalid policy before the backend starts, install the policy on the backend, and reject frames early. The final decision must still be the backend's, because the guest can rewrite a frame in shared memory after the device has looked at it. Add the egress policy to the virtio-net device: - `VirtioNetHandle` gains an optional `egress_policy`, which the resolver passes to the new `NicBuilder::egress_policy`. Existing handles pass `None`. - Building the device revalidates the policy and installs it on the endpoint with `set_egress_policy`, so a backend that cannot enforce an active policy fails the build. - Before handing a frame to the endpoint, the transmit path now peeks far enough to cover the Ethernet, VLAN, maximal IPv4, and TCP headers, and prefilters that prefix against the policy. A denied frame, like a frame longer than the 65535-byte backend bound, is completed without being handed to the backend. Tests cover a denied frame that completes without backend ownership, ARP forwarding for bound targets only, and backend enforcement of frames and ARP targets that the guest mutates after the device's check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_entry/src/lib.rs | 1 + openvmm/openvmm_entry/src/microvm/config.rs | 1 + openvmm/openvmm_entry/src/ttrpc/mod.rs | 1 + petri/burette/src/tests/network.rs | 1 + petri/src/vm/openvmm/modify.rs | 2 + vm/devices/virtio/virtio_net/src/egress.rs | 60 ++++++++ vm/devices/virtio/virtio_net/src/lib.rs | 25 +++- vm/devices/virtio/virtio_net/src/resolver.rs | 3 + vm/devices/virtio/virtio_net/src/tests.rs | 1 + .../virtio/virtio_net/src/tests/egress.rs | 134 ++++++++++++++++++ .../virtio/virtio_net/src/tests/harness.rs | 120 +++++++++++++++- vm/devices/virtio/virtio_resources/src/lib.rs | 1 + vmm_tests/vmm_tests/tests/tests/x86_64.rs | 1 + 13 files changed, 343 insertions(+), 8 deletions(-) create mode 100644 vm/devices/virtio/virtio_net/src/egress.rs create mode 100644 vm/devices/virtio/virtio_net/src/tests/egress.rs diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index a6b75868548..4ff93ffe938 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -1918,6 +1918,7 @@ async fn vm_config_from_command_line( max_queues: vport.max_queues, mac_address: vport.mac_address, endpoint: vport.endpoint, + egress_policy: None, save_restore: false, static_ipv4: None, effective_features: None, diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 1613b074d0f..bf5fc878646 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -437,6 +437,7 @@ impl<'a> MicrovmConfigBuilder<'a> { max_queues: Some(1), mac_address: config.guest_mac, endpoint, + egress_policy: None, save_restore: true, static_ipv4: Some(StaticIpv4Config { guest_ipv4: config.guest_ipv4, diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 283df8ee08b..d2159349b10 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -2353,6 +2353,7 @@ async fn build_virtio_device( .parse::() .context("invalid mac address")?, endpoint, + egress_policy: None, save_restore: false, static_ipv4: None, effective_features: None, diff --git a/petri/burette/src/tests/network.rs b/petri/burette/src/tests/network.rs index d6714de584b..10e3168eeeb 100644 --- a/petri/burette/src/tests/network.rs +++ b/petri/burette/src/tests/network.rs @@ -510,6 +510,7 @@ mod tap { max_queues: None, mac_address: TAP_MAC_ADDRESS, endpoint, + egress_policy: None, save_restore: false, static_ipv4: None, effective_features: None, diff --git a/petri/src/vm/openvmm/modify.rs b/petri/src/vm/openvmm/modify.rs index b5635b0a79c..af2538ac138 100644 --- a/petri/src/vm/openvmm/modify.rs +++ b/petri/src/vm/openvmm/modify.rs @@ -247,6 +247,7 @@ impl PetriVmConfigOpenVmm { max_queues: None, mac_address, endpoint, + egress_policy: None, save_restore: false, static_ipv4: None, effective_features: None, @@ -291,6 +292,7 @@ impl PetriVmConfigOpenVmm { max_queues: None, mac_address, endpoint, + egress_policy: None, save_restore: false, static_ipv4: None, effective_features: None, diff --git a/vm/devices/virtio/virtio_net/src/egress.rs b/vm/devices/virtio/virtio_net/src/egress.rs new file mode 100644 index 00000000000..1a8fa49e6ff --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/egress.rs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Egress policy enforcement for virtio-net transmit. +//! +//! [`NicBuilder::egress_policy`] configures a run-scoped [`EgressPolicy`]. The +//! device validates the policy and installs it on the endpoint, which applies +//! it to the bytes it transmits, and checks the prefix of each guest frame +//! against the policy before handing the frame to the endpoint. + +use crate::NicBuilder; +use crate::TxPacketError; +use crate::Worker; +use anyhow::Context as _; +use net_backend::Endpoint; +use net_backend_resources::egress::EgressPolicy; + +impl NicBuilder { + pub fn egress_policy(mut self, egress_policy: EgressPolicy) -> Self { + self.egress_policy = Some(egress_policy); + self + } + + /// Validates the configured egress policy and installs it on `endpoint`. + pub(crate) fn configure_egress( + &self, + mut endpoint: Box, + ) -> anyhow::Result> { + if let Some(policy) = &self.egress_policy { + policy + .validate() + .context("network device received an invalid egress policy")?; + endpoint + .set_egress_policy(policy.clone()) + .with_context(|| { + format!( + "network backend '{}' rejected egress policy", + endpoint.endpoint_type() + ) + })?; + } + Ok(endpoint) + } +} + +impl Worker { + /// Checks the prefix of a guest TX frame against the egress policy. + pub(crate) fn authorize_egress( + &self, + packet_prefix: &[u8], + packet_len: u32, + ) -> Result<(), TxPacketError> { + if let Some(policy) = &self.egress_policy { + policy + .authorize_frame(packet_prefix, packet_len as usize) + .map_err(TxPacketError::EgressDenied)?; + } + Ok(()) + } +} diff --git a/vm/devices/virtio/virtio_net/src/lib.rs b/vm/devices/virtio/virtio_net/src/lib.rs index a018b2a8a4d..ae855736a00 100644 --- a/vm/devices/virtio/virtio_net/src/lib.rs +++ b/vm/devices/virtio/virtio_net/src/lib.rs @@ -12,6 +12,7 @@ #![forbid(unsafe_code)] mod buffers; +mod egress; mod quiesce; pub mod resolver; mod saved_state; @@ -38,6 +39,8 @@ use net_backend::TxMetadata; use net_backend::TxOffloadSupport; use net_backend::TxSegment; use net_backend::TxSegmentType; +use net_backend_resources::egress::EgressDenied; +use net_backend_resources::egress::EgressPolicy; use net_backend_resources::mac_address::MacAddress; use pal_async::wait::PolledWait; use std::future::pending; @@ -233,6 +236,7 @@ struct Adapter { tx_fast_completions: bool, mac_address: MacAddress, tx_offload_support: TxOffloadSupport, + egress_policy: Option, save_restore: Option, } @@ -574,6 +578,7 @@ struct PendingTxPacket { pub struct NicBuilder { max_queue_pairs: u16, + egress_policy: Option, save_restore: Option, } @@ -605,6 +610,7 @@ impl NicBuilder { endpoint.endpoint_type() ); } + let endpoint = self.configure_egress(endpoint)?; // TODO: Implement VIRTIO_NET_F_MQ and VIRTIO_NET_F_RSS logic based on mulitqueue support. // let multiqueue = endpoint.multiqueue_support(); @@ -619,6 +625,7 @@ impl NicBuilder { tx_fast_completions: endpoint.tx_fast_completions(), mac_address, tx_offload_support, + egress_policy: self.egress_policy, save_restore: self.save_restore, }); @@ -656,6 +663,7 @@ impl Device { pub fn builder() -> NicBuilder { NicBuilder { max_queue_pairs: !0, + egress_policy: None, save_restore: None, } } @@ -714,6 +722,7 @@ impl Device { active_state, negotiated_features, negotiated_features_bank1, + egress_policy: self.adapter.egress_policy.clone(), }; let coordinator = self.coordinator.state_mut().unwrap(); let worker_task = &mut coordinator.workers[idx]; @@ -899,8 +908,12 @@ enum TxPacketError { Empty, #[error("too many segments")] TooManySegments, + #[error("packet length {0} exceeds the 65535-byte backend bound")] + TooLarge(u32), #[error("descriptor index {0} already in use")] DuplicateIndex(u16), + #[error("egress policy denied packet")] + EgressDenied(#[source] EgressDenied), } impl From for WorkerError { @@ -917,6 +930,8 @@ struct Worker { negotiated_features: NetworkFeaturesBank0, #[inspect(skip)] negotiated_features_bank1: NetworkFeaturesBank1, + #[inspect(skip)] + egress_policy: Option, } impl Worker { @@ -1065,15 +1080,18 @@ impl Worker { .checked_sub(header_size()) .and_then(|len| u32::try_from(len).ok()) .ok_or(TxPacketError::Empty)?; + if packet_len > u16::MAX.into() { + return Err(TxPacketError::TooLarge(packet_len)); + } // Read the virtio-net header + enough of the Ethernet frame to parse // the EtherType (and a potential VLAN tag). - const ETH_PEEK: usize = 18; // 14 standard + 4 for VLAN tag - let mut peek_buf = [0u8; size_of::() + ETH_PEEK]; + const PACKET_PEEK: usize = 98; // Ethernet + VLAN + max IPv4 + TCP header. + let mut peek_buf = [0u8; size_of::() + PACKET_PEEK]; let bytes_read = work .read( self.active_state.pending_rx_packets.mem(), - &mut peek_buf[..header_size() + ETH_PEEK], + &mut peek_buf[..header_size() + PACKET_PEEK], ) .map_err(TxPacketError::ReadHeader)?; @@ -1085,6 +1103,7 @@ impl Worker { } else { &[] }; + self.authorize_egress(packet_prefix, packet_len)?; let segments = &mut self.active_state.data.tx_segments; let seg_start = segments.len(); diff --git a/vm/devices/virtio/virtio_net/src/resolver.rs b/vm/devices/virtio/virtio_net/src/resolver.rs index 87fee532481..b42503a3655 100644 --- a/vm/devices/virtio/virtio_net/src/resolver.rs +++ b/vm/devices/virtio/virtio_net/src/resolver.rs @@ -38,6 +38,9 @@ impl AsyncResolveResource for VirtioNetReso builder = builder.max_queues(max_queues); } builder = builder.save_restore_resource(&resource)?; + if let Some(egress_policy) = resource.egress_policy { + builder = builder.egress_policy(egress_policy); + } let endpoint = resolver .resolve( diff --git a/vm/devices/virtio/virtio_net/src/tests.rs b/vm/devices/virtio/virtio_net/src/tests.rs index c0b61bb7d69..09b07b1603b 100644 --- a/vm/devices/virtio/virtio_net/src/tests.rs +++ b/vm/devices/virtio/virtio_net/src/tests.rs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +mod egress; mod harness; mod quiesce; mod saved_state; diff --git a/vm/devices/virtio/virtio_net/src/tests/egress.rs b/vm/devices/virtio/virtio_net/src/tests/egress.rs new file mode 100644 index 00000000000..6eab3350f62 --- /dev/null +++ b/vm/devices/virtio/virtio_net/src/tests/egress.rs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Egress policy tests: frames that the policy denies complete without +//! reaching the endpoint, and the endpoint checks the bytes it transmits. + +use super::TestHarness; +use net_backend_resources::egress::EgressPolicy; +use net_backend_resources::egress::EgressPolicyMode; +use net_backend_resources::mac_address::MacAddress; +use pal_async::DefaultDriver; +use pal_async::async_test; +use test_with_tracing::test; + +#[async_test] +async fn denied_tx_completes_without_backend_ownership(driver: DefaultDriver) { + let policy = EgressPolicy::bind( + std::net::Ipv4Addr::new(10, 0, 0, 2), + 24, + MacAddress::new([0x52, 0x54, 0, 0, 0, 2]), + std::net::Ipv4Addr::new(10, 0, 0, 1), + EgressPolicyMode::TcpEndpoints(vec!["192.0.2.7:443".parse().unwrap()]), + ) + .unwrap(); + let mut harness = TestHarness::new_with_egress_policy(&driver, Some(policy)); + let handle = harness.enable_and_get_handle().await; + + harness.post_tx_and_signal(0, 64); + assert_eq!(harness.wait_for_used().await, (0, 0)); + assert!(handle.take_tx_avail_log().is_empty()); +} + +fn policy_tcp_frame(destination_port: u16) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 20]; + frame[6..12].copy_from_slice(&[0x52, 0x54, 0, 0, 0, 2]); + frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes()); + let ip = &mut frame[14..]; + ip[0] = 0x45; + ip[2..4].copy_from_slice(&40u16.to_be_bytes()); + ip[6..8].copy_from_slice(&0x4000u16.to_be_bytes()); + ip[8] = 64; + ip[9] = 6; + ip[12..16].copy_from_slice(&[10, 0, 0, 2]); + ip[16..20].copy_from_slice(&[192, 0, 2, 7]); + ip[20..22].copy_from_slice(&12345u16.to_be_bytes()); + ip[22..24].copy_from_slice(&destination_port.to_be_bytes()); + ip[32] = 5 << 4; + let mut sum = ip[..20].chunks_exact(2).fold(0u32, |sum, word| { + sum + u32::from(u16::from_be_bytes([word[0], word[1]])) + }); + while sum > u32::from(u16::MAX) { + sum = (sum & u32::from(u16::MAX)) + (sum >> 16); + } + ip[10..12].copy_from_slice(&(!(sum as u16)).to_be_bytes()); + frame +} + +fn policy_arp_frame(target: std::net::Ipv4Addr) -> Vec { + let guest_mac = [0x52, 0x54, 0, 0, 0, 2]; + let mut frame = vec![0u8; 42]; + frame[..6].fill(0xff); + frame[6..12].copy_from_slice(&guest_mac); + frame[12..14].copy_from_slice(&0x0806u16.to_be_bytes()); + frame[14..16].copy_from_slice(&1u16.to_be_bytes()); + frame[16..18].copy_from_slice(&0x0800u16.to_be_bytes()); + frame[18] = 6; + frame[19] = 4; + frame[20..22].copy_from_slice(&1u16.to_be_bytes()); + frame[22..28].copy_from_slice(&guest_mac); + frame[28..32].copy_from_slice(&[10, 0, 0, 2]); + frame[38..42].copy_from_slice(&target.octets()); + frame +} + +fn endpoint_policy() -> EgressPolicy { + EgressPolicy::bind( + std::net::Ipv4Addr::new(10, 0, 0, 2), + 24, + MacAddress::new([0x52, 0x54, 0, 0, 0, 2]), + std::net::Ipv4Addr::new(10, 0, 0, 1), + EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + ]), + ) + .unwrap() +} + +#[async_test] +async fn endpoint_policy_forwards_only_bound_arp_targets(driver: DefaultDriver) { + let mut allowed_harness = TestHarness::new_with_egress_policy(&driver, Some(endpoint_policy())); + let allowed_handle = allowed_harness.enable_and_get_handle().await; + allowed_harness + .post_tx_frame_and_signal(0, &policy_arp_frame(std::net::Ipv4Addr::new(10, 0, 0, 9))); + assert_eq!(allowed_harness.wait_for_used().await, (0, 0)); + assert!(!allowed_handle.take_tx_avail_log().is_empty()); + + let mut denied_harness = TestHarness::new_with_egress_policy(&driver, Some(endpoint_policy())); + let denied_handle = denied_harness.enable_and_get_handle().await; + denied_harness + .post_tx_frame_and_signal(0, &policy_arp_frame(std::net::Ipv4Addr::new(10, 0, 0, 10))); + assert_eq!(denied_harness.wait_for_used().await, (0, 0)); + assert!(denied_handle.take_tx_avail_log().is_empty()); +} + +#[async_test] +async fn endpoint_policy_checks_transmitted_bytes_after_guest_mutation(driver: DefaultDriver) { + let policy = EgressPolicy::bind( + std::net::Ipv4Addr::new(10, 0, 0, 2), + 24, + MacAddress::new([0x52, 0x54, 0, 0, 0, 2]), + std::net::Ipv4Addr::new(10, 0, 0, 1), + EgressPolicyMode::TcpEndpoints(vec!["192.0.2.7:443".parse().unwrap()]), + ) + .unwrap(); + let mut harness = TestHarness::new_with_egress_policy(&driver, Some(policy)); + let handle = harness.enable_and_get_handle().await; + handle.replace_next_tx(policy_tcp_frame(80)); + + harness.post_tx_frame_and_signal(0, &policy_tcp_frame(443)); + assert_eq!(harness.wait_for_used().await, (0, 0)); + assert!(handle.take_tx_avail_log().is_empty()); +} + +#[async_test] +async fn endpoint_policy_rechecks_mutated_arp_target_at_backend(driver: DefaultDriver) { + let mut harness = TestHarness::new_with_egress_policy(&driver, Some(endpoint_policy())); + let handle = harness.enable_and_get_handle().await; + handle.replace_next_tx(policy_arp_frame(std::net::Ipv4Addr::new(10, 0, 0, 10))); + + harness.post_tx_frame_and_signal(0, &policy_arp_frame(std::net::Ipv4Addr::new(10, 0, 0, 9))); + assert_eq!(harness.wait_for_used().await, (0, 0)); + assert!(handle.take_tx_avail_log().is_empty()); +} diff --git a/vm/devices/virtio/virtio_net/src/tests/harness.rs b/vm/devices/virtio/virtio_net/src/tests/harness.rs index 453bef09255..de3a95bc9e2 100644 --- a/vm/devices/virtio/virtio_net/src/tests/harness.rs +++ b/vm/devices/virtio/virtio_net/src/tests/harness.rs @@ -1,20 +1,27 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Test harness extensions for the quiesce and saved-state tests. +//! Test harness extensions for the egress policy, quiesce and saved-state +//! tests. //! -//! [`ForkMockEndpoint`] wraps the mock endpoint and wraps each mock queue in a -//! [`ForkMockQueue`], which supports quiesce. +//! [`ForkMockEndpoint`] wraps the mock endpoint. It accepts an egress policy +//! and wraps each mock queue in a [`ForkMockQueue`], which applies the policy +//! to the frames it transmits, can replace the next transmitted frame (to model +//! a guest that rewrites a descriptor after the device checked it), and +//! supports quiesce. use super::MockEndpoint; use super::MockQueue; use super::MockQueueHandle; +use super::NET_HEADER_SIZE; use super::QUEUE_SIZE; use super::RX_AVAIL_ADDR; use super::RX_DESC_ADDR; use super::RX_USED_ADDR; +use super::TX_AVAIL_ADDR; use super::TestHarness; use super::new_mock_queue; +use super::post_tx_packet; use crate::Device; use async_trait::async_trait; use futures::StreamExt; @@ -29,9 +36,14 @@ use net_backend::TxError; use net_backend::TxId; use net_backend::TxOffloadSupport; use net_backend::TxSegment; +use net_backend::linearize; +use net_backend::next_packet; use net_backend::quiesce::QueueQuiesceResult; +use net_backend_resources::egress::EgressPolicy; use net_backend_resources::mac_address::MacAddress; use pal_async::DefaultDriver; +use parking_lot::Mutex; +use std::sync::Arc; use std::task::Context; use std::task::Poll; use std::time::Duration; @@ -39,6 +51,7 @@ use virtio::QueueResources; use virtio::VirtioDevice; use virtio::queue::QueueParams; use virtio::spec::VirtioDeviceFeatures; +use virtio::test_helpers::make_available; use vmcore::interrupt::Interrupt; use vmcore::vm_task::SingleDriverBackend; use vmcore::vm_task::VmTaskDriverSource; @@ -47,10 +60,15 @@ use vmcore::vm_task::VmTaskDriverSource; /// [`ForkMockEndpoint`]. #[derive(Default)] pub(super) struct QueueHandleExt { + replace_next_tx: Arc>>>, quiesce_notify: mesh::Receiver<()>, } impl MockQueueHandle { + pub(super) fn replace_next_tx(&self, frame: Vec) { + *self.ext.replace_next_tx.lock() = Some(frame); + } + pub(super) async fn wait_for_quiesce(&mut self) { mesh::CancelContext::new() .with_timeout(Duration::from_secs(5)) @@ -61,9 +79,11 @@ impl MockQueueHandle { } } -/// A [`MockEndpoint`] that creates [`ForkMockQueue`]s. +/// A [`MockEndpoint`] that accepts an egress policy and creates +/// [`ForkMockQueue`]s. struct ForkMockEndpoint { endpoint: MockEndpoint, + egress_policy: Option, } impl InspectMut for ForkMockEndpoint { @@ -87,6 +107,8 @@ impl Endpoint for ForkMockEndpoint { let (queue, mut handle) = new_mock_queue(); let queue = ForkMockQueue { queue, + egress_policy: self.egress_policy.clone(), + replace_next_tx: handle.ext.replace_next_tx.clone(), quiesce_notify: handle.ext.quiesce_notify.sender(), }; self.endpoint.queue_tx.send(handle); @@ -94,6 +116,11 @@ impl Endpoint for ForkMockEndpoint { Ok(()) } + fn set_egress_policy(&mut self, policy: EgressPolicy) -> anyhow::Result<()> { + self.egress_policy = Some(policy); + Ok(()) + } + async fn stop(&mut self) { self.endpoint.stop().await } @@ -119,9 +146,12 @@ impl Endpoint for ForkMockEndpoint { } } -/// A [`MockQueue`] that supports quiesce. +/// A [`MockQueue`] that applies an egress policy to transmitted frames, can +/// replace the next transmitted frame, and supports quiesce. struct ForkMockQueue { queue: MockQueue, + egress_policy: Option, + replace_next_tx: Arc>>>, quiesce_notify: mesh::Sender<()>, } @@ -170,6 +200,28 @@ impl net_backend::Queue for ForkMockQueue { pool: &mut dyn net_backend::BufferAccess, segments: &[TxSegment], ) -> anyhow::Result<(bool, usize)> { + if let Some(replacement) = self.replace_next_tx.lock().take() { + let (metadata, packet_segments, _) = next_packet(segments); + anyhow::ensure!(replacement.len() == metadata.len as usize); + let mut offset = 0usize; + for segment in packet_segments { + let end = offset + segment.len as usize; + pool.guest_memory() + .write_at(segment.gpa, &replacement[offset..end])?; + offset = end; + } + anyhow::ensure!(offset == replacement.len()); + } + if let Some(policy) = &self.egress_policy { + let mut remaining = segments; + while !remaining.is_empty() { + let frame = linearize(pool, &mut remaining)?; + if policy.authorize_frame(&frame, frame.len()).is_err() { + return Ok((true, segments.len())); + } + } + } + self.queue.tx_avail(pool, segments) } @@ -183,6 +235,34 @@ impl net_backend::Queue for ForkMockQueue { } impl TestHarness { + /// Replaces the harness device with one built with `egress_policy` over a + /// [`ForkMockEndpoint`]. + pub(super) fn new_with_egress_policy( + driver: &DefaultDriver, + egress_policy: Option, + ) -> Self { + let mut harness = Self::new(driver); + let (queue_tx, queue_handle_rx) = mesh::channel(); + let endpoint = ForkMockEndpoint { + endpoint: MockEndpoint { + queue_tx, + is_ordered: true, + }, + egress_policy: None, + }; + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let mac = MacAddress::new([0x00, 0x15, 0x5d, 0xaa, 0xbb, 0xcc]); + let mut builder = Device::builder(); + if let Some(egress_policy) = egress_policy { + builder = builder.egress_policy(egress_policy); + } + harness.device = builder + .build(&driver_source, Box::new(endpoint), mac) + .unwrap(); + harness.queue_handle_rx = queue_handle_rx; + harness + } + /// Replaces the harness device with one built for save and restore over a /// [`ForkMockEndpoint`]. pub(super) fn new_save_restore(driver: &DefaultDriver) -> Self { @@ -193,6 +273,7 @@ impl TestHarness { queue_tx, is_ordered: true, }, + egress_policy: None, }; let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); let mac = MacAddress::new([0x52, 0x54, 0, 0, 0, 2]); @@ -237,4 +318,33 @@ impl TestHarness { .await .unwrap(); } + + pub(super) fn post_tx_frame_and_signal(&mut self, desc_index: u16, frame: &[u8]) { + let header_gpa = self.alloc_data(NET_HEADER_SIZE); + let data_len = u32::try_from(frame.len()).unwrap(); + let data_gpa = self.alloc_data(data_len); + + // Write a zero virtio-net header + let header_bytes = vec![0u8; NET_HEADER_SIZE as usize]; + self.mem.write_at(header_gpa, &header_bytes).unwrap(); + + self.mem.write_at(data_gpa, frame).unwrap(); + + post_tx_packet( + &self.mem, + desc_index, + header_gpa, + NET_HEADER_SIZE, + &[(data_gpa, data_len)], + ); + + make_available( + &self.mem, + TX_AVAIL_ADDR, + QUEUE_SIZE, + desc_index, + &mut self.tx_avail_idx, + ); + self.tx_event.signal(); + } } diff --git a/vm/devices/virtio/virtio_resources/src/lib.rs b/vm/devices/virtio/virtio_resources/src/lib.rs index 7980efaf053..5ac589fcec6 100644 --- a/vm/devices/virtio/virtio_resources/src/lib.rs +++ b/vm/devices/virtio/virtio_resources/src/lib.rs @@ -147,6 +147,7 @@ pub mod net { pub max_queues: Option, pub mac_address: MacAddress, pub endpoint: Resource, + pub egress_policy: Option, pub save_restore: bool, pub static_ipv4: Option, pub effective_features: Option, diff --git a/vmm_tests/vmm_tests/tests/tests/x86_64.rs b/vmm_tests/vmm_tests/tests/tests/x86_64.rs index 36533e9a37c..99ba91d5d5d 100644 --- a/vmm_tests/vmm_tests/tests/tests/x86_64.rs +++ b/vmm_tests/vmm_tests/tests/tests/x86_64.rs @@ -253,6 +253,7 @@ async fn vpci_filter(config: PetriVmBuilder) -> anyhow::Res max_queues: None, mac_address: MacAddress::new([0x00, 0x15, 0x5D, 0x12, 0x12, 0x12]), endpoint: NullHandle.into_resource(), + egress_policy: None, save_restore: false, static_ipv4: None, effective_features: None, From 5865c2ec66d94924dbcc0990604772bf8734561a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:48:52 -0700 Subject: [PATCH 138/304] openvmm: add microVM network egress policy and bind it into snapshots The microVM NIC reaches the host network through Consomme, and until now it could reach every destination. Orchestrators need to restrict a sandbox's traffic, and a restore must not be able to loosen the policy that a snapshot was captured under. Add egress policy options for the portable microVM network profile: - `--network-egress ` and `--network-ingress ` set the directional defaults. Ingress defaults to `deny`, and `allow` is rejected because the portable NAT exposes no inbound listener. - `--allow-host`, `--block-host`, and `--allow-endpoint` select the mutually exclusive allow-list, block-list, and exact-endpoint modes. `--network-egress-allow` and `--network-egress-deny` select generic L3/L4 rules instead; they require an explicit `--network-egress` default, accept at most 256 rules each, and cannot be mixed with the other modes. Contradictory defaults and rules are rejected. - `--host-loopback ` controls the gateway's host-loopback mapping. `deny` may keep one exact gateway TCP endpoint with `--network-proxy`. `allow` requires explicit `--host-loopback-forward ` port publishing (at most 64 unique forwards), which snapshots do not support. - `--net-tap` is reserved and rejected for the portable profile. The standard machine rejects every option, and policy options require `--net` or a networked snapshot restore. The entry binds the policy to the static identity while it validates the options, before any host resource is opened. It installs the policy on the virtio-net device, which prefilters frames and passes the policy to Consomme for the final check, configures Consomme's host-loopback access, proxy port, and forwards, and adds the gateway DNS bootstrap only when the policy allows gateway DNS. Bind the policy into snapshots: - `SnapshotMicrovmNetwork` records the policy mode, whether the policy is active, the canonical policy digest, and its encoding version. Capture records the current version 3 digest, and contract validation checks that the mode and the requirement agree and that the version is known. - Restore rebuilds the policy from the restore-time options and requires the same mode, requirement, and digest, computed with the snapshot's encoding version (an absent version means version 1). For those older encodings, the expected contract adopts the validated saved digest. This commit combines the plan's separate steps for the options and for the snapshot binding, which share the policy plumbing and its tests. Document the options, the portable profile, and the digest encoding. Tests cover option parsing and every rejection, the directional defaults, endpoint identity checks, the host-loopback and proxy rules, the recorded contract fields, legacy digest validation, and restore with a matching or missing policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + Guide/src/dev_guide/snapshot_format.md | 6 + Guide/src/reference/backends/consomme.md | 49 +- Guide/src/reference/backends/networking.md | 8 + Guide/src/reference/openvmm/management/cli.md | 90 ++- openvmm/openvmm_entry/src/cli_args/microvm.rs | 635 ++++++++++++++++-- openvmm/openvmm_entry/src/microvm/config.rs | 15 +- openvmm/openvmm_entry/src/microvm/launch.rs | 4 +- openvmm/openvmm_entry/src/microvm/mod.rs | 3 + openvmm/openvmm_entry/src/microvm/network.rs | 184 ++++- openvmm/openvmm_entry/src/microvm/restore.rs | 31 +- .../src/vm_controller/microvm.rs | 4 +- openvmm/openvmm_helpers/Cargo.toml | 1 + .../openvmm_helpers/src/snapshot/microvm.rs | 144 +++- 14 files changed, 1061 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46e8842cbbd..0bf1622c77f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6149,6 +6149,7 @@ dependencies = [ "hypervisor_resources", "libc", "mesh", + "net_backend_resources", "nix 0.30.1", "openvmm_defs", "pal", diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 08274904ce9..54c22bb74dd 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -39,6 +39,12 @@ RAM and a mounted writable filesystem must be restored as one exact pair. Export or transport layers must provide broader integrity and authentication outside this format. +The optional microVM network contract stores a versioned SHA-256 digest of its +bound egress policy. Encoding version 2 includes the static prefix, guest MAC, +and canonical ARP next-hop set in addition to the policy rules. An absent +encoding-version field denotes version 1, allowing network snapshots produced +before endpoint next-hop binding to retain their original digest semantics. + ## Device state (`state.bin`) The device state contains every device's saved state, collected via the diff --git a/Guide/src/reference/backends/consomme.md b/Guide/src/reference/backends/consomme.md index 9501ac2c9fa..1d3067f5130 100644 --- a/Guide/src/reference/backends/consomme.md +++ b/Guide/src/reference/backends/consomme.md @@ -38,6 +38,48 @@ flowchart TB NIC --> Internet((Internet)) ``` +## microVM portable profile + +OpenVMM microVMs select this backend with +`--net --network-profile portable`. This is the sole microVM +network profile and has the same Consomme behavior on Linux/KVM, Linux/MSHV, +and Windows/WHP. It provides gateway DNS over UDP and TCP, ICMP echo, and +outbound TCP/UDP subject to the microVM egress policy. IPv4 fragments are +rejected deterministically. `--net-tap` is incompatible. + +The portable profile accepts directional network defaults through +`--network-egress ` and `--network-ingress `. It +supports both egress actions and ingress `deny`. Stateful replies to an +allowed guest-initiated flow are not treated as new inbound connections. +Ingress `allow` is rejected before VM resources are opened because portable +NAT does not expose arbitrary guest listeners. + +The generic egress rule form accepts destination IPv4 addresses or CIDRs with +an optional TCP or UDP destination port. Deny rules take precedence over allow +rules, and the explicit directional default handles traffic that matches +neither list. Filtering and malformed-packet rejection occur before Consomme +creates a host socket. Port-specific policies reject IPv4 fragments rather +than allowing later fragments to bypass transport checks. + +The portable profile does not provide generic bidirectional host-loopback +connectivity. Explicit `--host-loopback allow` without any +`--host-loopback-forward` is rejected before VM resources are opened. +With deliberate TCP/UDP forwards, `allow` maps the guest-visible gateway to +host loopback, permits host-local destinations subject to egress policy, and +publishes only the specified localhost-to-guest ports. This is port publishing, +not a generic bidirectional allow contract. + +Omitting `--host-loopback` preserves the existing guest-to-host mapping but +does not publish guest ports. `deny` rejects general gateway socket traffic +and all host-to-guest forwards. One exact gateway TCP port may remain mapped +as a proxy exception; UDP on that same port and other host service ports stay +blocked even when ordinary egress is allowed. + +Snapshot restore creates a fresh endpoint generation. Host sockets and NAT +flow tables are not saved. Virtio-net capture drains descriptor ownership +before state is saved, and restored guest software must establish new +host-side flows. + ## Default network topology | Role | IPv4 Address | @@ -197,8 +239,11 @@ are not relayed — see limitations). ### ARP and NDP -- **ARP** — Responds to requests for the gateway MAC address. All other - ARP traffic is dropped. +- **ARP** — Responds to requests for the gateway and for on-link next hops + admitted by exact endpoint policy, using the gateway MAC as the proxy + address. The shared egress policy accepts only guest-identity requests for + its canonical next-hop set, so resolving an endpoint does not authorize + unrelated IPv4 traffic. - **NDP** — Responds to Router Solicitations (advertising the IPv6 prefix and DNS servers) and Neighbor Solicitations for the gateway's link-local address. DAD is silently ignored. diff --git a/Guide/src/reference/backends/networking.md b/Guide/src/reference/backends/networking.md index c3956db77fd..577b8eea541 100644 --- a/Guide/src/reference/backends/networking.md +++ b/Guide/src/reference/backends/networking.md @@ -164,3 +164,11 @@ own async task. Currently `netvsp` and `net_mana` support multi-queue; `virtio_net` is limited to a single queue pair. + +## microVM portable profile + +The microVM `portable` network profile always selects Consomme on Linux/KVM, +Linux/MSHV, and Windows/WHP. It is explicitly required with +`--network-profile portable` whenever a microVM uses `--net`; `--net-tap` is +not part of this profile. Snapshot restore recreates a new endpoint generation +instead of serializing host sockets or protocol flows. diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index e0cbac94a47..66a85985c30 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -47,7 +47,6 @@ describes the source definitions. devices described below. User arguments cannot override `earlycon=`, `console=`, `virtio_mmio.device=`, `nr_cpus=`, `virtnet_*=`, or `virtfs_*=`. - One optional `--net ` NIC is exposed at MMIO `0xd0000000`. One virtio-fs slot is exposed at MMIO `0xd0001000`, IRQ 6 and remains dormant when `--mount` is omitted; an optional `--mount` binds HostFs to it; one optional `--virtio-console ` is exposed at MMIO `0xd0002000`, @@ -82,19 +81,86 @@ describes the source definitions. `portable` uses an in-process Consomme endpoint on Linux/KVM, Linux/MSHV, and Windows/WHP. It needs no TAP, root access, driver, or host network - configuration. The gateway provides DNS over UDP and + configuration. `--net-tap` is incompatible and is rejected before any + endpoint or host resource is created. The gateway provides DNS over UDP and TCP, ICMP echo, and outbound TCP/UDP through ordinary host sockets. Consomme - rejects IPv4 fragments deterministically. Its per-connection TCP buffers start at 16 KiB and + rejects IPv4 fragments deterministically; policy filtering remains before + host socket creation. Its per-connection TCP buffers start at 16 KiB and are bounded at 4 MiB; UDP bindings expire after five minutes; and at most 256 DNS requests are pending at once. At most 128 TCP, 256 UDP, and 16 ICMP guest flows are active at once; excess flows are deterministically rejected before a host socket is created. + `--allow-host `, `--block-host `, and + `--allow-endpoint ` are repeatable, mutually exclusive + egress modes. Filtering runs before host socket creation. Active policy + fails closed for malformed packets, non-IPv4 traffic, and IPv4 options. + Exact endpoint mode also rejects UDP, ICMP, VLAN, fragments, and every TCP + destination not listed. Endpoint addresses must be usable unicast identities; + unspecified, current-network, loopback, link-local, multicast, reserved, + guest-self, subnet-network, and subnet-broadcast addresses are rejected before + host resources are opened. For each endpoint, ARP may resolve the endpoint + itself when it is on-link, or the gateway otherwise. Duplicate endpoint + addresses share one canonical next hop. This layer-2 permission does not relax + the independent destination, TCP, or port check. No implicit DNS exception is + added. + + `--network-egress ` and `--network-ingress ` map + directional network default actions onto the portable profile. Egress defaults + to `allow`; ingress defaults to `deny`, preserving the profile's existing + behavior when neither option is present. Egress `deny` without allow rules + blocks every guest-originated frame before Consomme opens a host socket. + Responses belonging to a guest-initiated flow remain permitted when ingress + is denied; unsolicited connections toward the guest remain unavailable. + + The portable profile cannot truthfully provide unrestricted inbound + connectivity, so `--network-ingress allow` is rejected before VM resources + are opened. Egress `allow` may accompany `--block-host`; egress `deny` may + accompany `--allow-host` or `--allow-endpoint`. Contradictory default and + rule combinations are rejected at the same validation boundary. + + `--network-egress-allow ` and `--network-egress-deny ` provide + the generic L3/L4 policy form. A rule is either `IPv4[/PREFIX]` or + `IPv4[/PREFIX]:tcp:PORT` / `IPv4[/PREFIX]:udp:PORT`. These flags require an + explicit `--network-egress` default, accept at most 256 rules in each list, + and cannot be mixed with the legacy `--allow-host`, `--block-host`, or + `--allow-endpoint` forms. Deny rules are evaluated before allow rules. + Address-only rules apply to every IPv4 protocol; port-specific policies + reject fragmented IPv4 traffic because later fragments do not carry a + verifiable transport header. Parsing, canonicalization, and contradictory + option checks complete before VM resources are opened. + + `--host-loopback ` controls host-local access. The portable + profile cannot provide generic bidirectional host-loopback connectivity: + explicit `allow` without `--host-loopback-forward` is rejected before VM + resources are opened. With deliberate forwards, `allow` preserves + guest-to-host access subject to egress policy and publishes only the named + host-to-guest ports. Omitting the option preserves the existing + guest-gateway-to-host-loopback mapping without publishing guest ports. + `deny` blocks general gateway and host-local destinations and rejects every + `--host-loopback-forward`. + + `--network-proxy ` preserves one exact proxy endpoint when + host loopback is denied. The guest-visible address must equal the derived + gateway, and only that TCP port is translated to host loopback. UDP on the + same port is not exempt, even with ordinary egress allowed. The exception is + included in the snapshot policy digest. + + `--host-loopback-forward ` binds one localhost + port and forwards it into the guest. It requires explicit + `--host-loopback allow`; duplicate bindings and more than 64 forwards are + rejected before resources are opened. Live forwards are process-local + attachments and are rejected for snapshot capture or restore. + Explicit forwarding is port publishing, not support for a generic + bidirectional host-loopback allow policy. + Networked snapshots record the `portable` profile, drain accepted TX and - endpoint-ready RX at the capture boundary, rewind unused guest RX descriptors, - and recreate a fresh Consomme endpoint generation on restore. - Restore of a networked snapshot requires `--network-profile portable`. Native - sockets and NAT flow tables are not serialized. The capture + endpoint-ready RX at the capture boundary, rewind unused guest RX + descriptors, and recreate a fresh Consomme endpoint generation on restore. + Restore of a networked snapshot requires `--network-profile portable` and + the same active egress policy rules. The policy digest binds the saved static + identity and its derived ARP next hops, which are reconstructed before vCPUs + start. Native sockets and NAT flow tables are not serialized. The capture protocol does not retain pre-capture endpoint completions; restored guest software must establish new host-side flows. * `--mount `: With `--machine microvm`, attach @@ -148,9 +214,9 @@ describes the source definitions. KVM, MSHV, or WHP, and shared file-backed RAM. Sandbox block media must be cached regular raw files with nonzero 512-byte-aligned geometry. An attached virtio console saves accepted but undelivered input and the offset - of a partially forwarded guest transmit descriptor. An attached microVM virtio-net device saves its - static identity, queue progress, drained packet ownership, and endpoint - generation. + of a partially forwarded guest transmit descriptor. An attached microVM + virtio-net device saves its static identity, queue progress, drained packet + ownership, endpoint generation, and policy requirement. The fixed microVM virtio-fs slot saves either an explicit dormant state or, when attached, its negotiated FUSE policy, namespace and handle identifiers, aliases, and directory cookies. The host tree remains external live state. @@ -196,8 +262,8 @@ describes the source definitions. reject additive attachment. When the snapshot contains virtio-net, restore also requires - `--network-profile portable`; the snapshot's profile must match the supplied - portable configuration. + `--network-profile portable`; the snapshot's profile and canonical egress + policy must match the supplied portable configuration. For sandbox-block snapshots, restore repeats each read-only `--microvm-sandbox-block` argument. Its role, access, geometry, and SHA-256 diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 26518af7198..e0e50f0f2b5 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -13,6 +13,7 @@ use clap::ValueEnum; use openvmm_defs::config::DeviceVtl; use openvmm_defs::config::X2ApicConfig; use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::MicrovmNetworkProfile; use openvmm_defs::microvm::MicrovmSandboxBlockRole; use std::path::PathBuf; use std::str::FromStr; @@ -33,6 +34,64 @@ pub enum MicrovmNetworkProfileCli { Portable, } +/// Default action for one direction of microVM network traffic. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MicrovmNetworkActionCli { + /// Permit traffic in this direction. + Allow, + /// Deny traffic in this direction. + Deny, +} + +/// Protocol for a localhost-to-guest port forward. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum MicrovmLoopbackForwardProtocol { + /// Forward a TCP listener. + Tcp, + /// Forward a UDP socket. + Udp, +} + +/// Explicit localhost port allowed to initiate traffic toward the guest. +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct MicrovmLoopbackForwardCli { + pub(crate) protocol: MicrovmLoopbackForwardProtocol, + pub(crate) host_port: u16, + pub(crate) guest_port: u16, +} + +impl FromStr for MicrovmLoopbackForwardCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let fields = value.split(':').collect::>(); + let [protocol, host_port, guest_port] = fields.as_slice() else { + anyhow::bail!("expected ::"); + }; + let protocol = match *protocol { + "tcp" => MicrovmLoopbackForwardProtocol::Tcp, + "udp" => MicrovmLoopbackForwardProtocol::Udp, + other => anyhow::bail!("invalid loopback-forward protocol '{other}'"), + }; + let host_port = host_port + .parse::() + .context("invalid loopback-forward host port")?; + let guest_port = guest_port + .parse::() + .context("invalid loopback-forward guest port")?; + anyhow::ensure!(host_port != 0, "loopback-forward host port must be nonzero"); + anyhow::ensure!( + guest_port != 0, + "loopback-forward guest port must be nonzero" + ); + Ok(Self { + protocol, + host_port, + guest_port, + }) + } +} + /// Capture tier for a microVM sandbox snapshot. #[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] pub enum SnapshotTierCli { @@ -71,6 +130,23 @@ impl SnapshotTierCli { } } +impl From for MicrovmNetworkProfile { + fn from(value: MicrovmNetworkProfileCli) -> Self { + match value { + MicrovmNetworkProfileCli::Portable => Self::Portable, + } + } +} + +impl From for net_backend_resources::egress::EgressAction { + fn from(value: MicrovmNetworkActionCli) -> Self { + match value { + MicrovmNetworkActionCli::Allow => Self::Allow, + MicrovmNetworkActionCli::Deny => Self::Deny, + } + } +} + impl From for MachineProfile { fn from(value: MachineProfileCli) -> Self { match value { @@ -133,6 +209,64 @@ pub struct MicrovmCli { #[clap(long, value_enum, value_name = "PROFILE")] pub network_profile: Option, + /// Default action for connections initiated by the microVM guest. + #[clap(long, value_enum, value_name = "ACTION")] + pub network_egress: Option, + + /// Default action for new connections initiated toward the microVM guest. + /// + /// The portable profile supports only `deny`: its NAT admits responses to + /// guest-initiated flows but exposes no listener for new inbound connections. + #[clap(long, value_enum, value_name = "ACTION")] + pub network_ingress: Option, + + /// Permit matching IPv4 destinations, optionally restricted by TCP/UDP port. + #[clap( + long = "network-egress-allow", + value_name = "IPv4[/PREFIX][:tcp|udp:PORT]" + )] + pub network_egress_allow: Vec, + + /// Deny matching IPv4 destinations before evaluating allow rules. + #[clap( + long = "network-egress-deny", + value_name = "IPv4[/PREFIX][:tcp|udp:PORT]" + )] + pub network_egress_deny: Vec, + + /// Deny host-loopback access, or allow it with explicit localhost forwards. + /// + /// Explicit `allow` without forwards is unsupported: portable NAT cannot + /// provide generic bidirectional host-loopback connectivity. + #[clap(long, value_enum, value_name = "ACTION")] + pub host_loopback: Option, + + /// Exact guest-gateway TCP endpoint retained when host loopback is denied. + #[clap(long, value_name = "IPv4:TCP-PORT")] + pub network_proxy: Option, + + /// Forward one localhost TCP/UDP port into the guest. + #[clap(long, value_name = "tcp|udp:HOST-PORT:GUEST-PORT")] + pub host_loopback_forward: Vec, + + /// Select a preconfigured Linux TAP for a microVM NIC. + /// + /// This is incompatible with the portable microVM network profile. + #[clap(long, value_name = "NAME")] + pub net_tap: Option, + + /// Permit only these IPv4 destinations or CIDRs from the microVM guest. + #[clap(long, value_name = "IPv4[/PREFIX]", conflicts_with_all = ["block_host", "allow_endpoint"])] + pub allow_host: Vec, + + /// Permit IPv4 except for these destinations or CIDRs from the microVM guest. + #[clap(long, value_name = "IPv4[/PREFIX]", conflicts_with_all = ["allow_host", "allow_endpoint"])] + pub block_host: Vec, + + /// Permit only these exact IPv4 TCP destinations from the microVM guest. + #[clap(long, value_name = "IPv4:TCP-PORT", conflicts_with_all = ["allow_host", "block_host"])] + pub allow_endpoint: Vec, + /// attach the microVM virtio-fs device /// /// An active snapshot requires the same canonical host path, guest target, @@ -225,16 +359,39 @@ pub(super) fn parse_endpoint(network: &str) -> Result } impl Options { + /// Rejects unsupported microVM combinations before opening host resources. + pub(crate) fn validate_microvm_host_loopback(&self) -> anyhow::Result<()> { + anyhow::ensure!( + self.machine != MachineProfileCli::Microvm + || self.microvm.host_loopback != Some(MicrovmNetworkActionCli::Allow) + || !self.microvm.host_loopback_forward.is_empty(), + "the portable microVM network profile does not support generic host-loopback connectivity; explicit --host-loopback allow requires --host-loopback-forward for deliberate port publishing" + ); + Ok(()) + } + pub(crate) fn validate_microvm_options(&self) -> anyhow::Result<()> { + self.validate_microvm_host_loopback()?; if self.machine != MachineProfileCli::Microvm { anyhow::ensure!( - self.microvm.network_profile.is_none() + self.microvm.net_tap.is_none() + && self.microvm.network_profile.is_none() + && self.microvm.network_egress.is_none() + && self.microvm.network_ingress.is_none() + && self.microvm.network_egress_allow.is_empty() + && self.microvm.network_egress_deny.is_empty() + && self.microvm.host_loopback.is_none() + && self.microvm.network_proxy.is_none() + && self.microvm.host_loopback_forward.is_empty() + && self.microvm.allow_host.is_empty() + && self.microvm.block_host.is_empty() + && self.microvm.allow_endpoint.is_empty() && self.microvm.microvm_mount.is_none() && self.microvm.microvm_sandbox_block.is_empty() && self.microvm.restore_processors.is_none() && self.microvm.restore_memory.is_none() && self.microvm.memory_capacity.is_none(), - "--network-profile, --mount, --microvm-sandbox-block, --restore-processors, --restore-memory, and --memory-capacity require a microVM machine" + "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --restore-processors, --restore-memory, --memory-capacity, and microVM network policy require a microVM machine" ); return Ok(()); } @@ -491,6 +648,92 @@ impl Options { }), "microVM --net requires a bare IPv4/prefix and does not permit queue, VTL, Underhill, or PCIe modifiers" ); + if let [network] = self.net.as_slice() + && let EndpointConfigCli::Microvm(config) = &network.endpoint + { + self.microvm_egress_policy(config)?; + } + anyhow::ensure!( + self.microvm.net_tap.is_none(), + "--net-tap is incompatible with the portable microVM network profile" + ); + anyhow::ensure!( + self.microvm.network_ingress != Some(MicrovmNetworkActionCli::Allow), + "--network-ingress allow is unsupported by the portable microVM network profile" + ); + anyhow::ensure!( + self.microvm.network_egress_allow.len() <= 256 + && self.microvm.network_egress_deny.len() <= 256, + "microVM egress policy permits at most 256 allow rules and 256 deny rules" + ); + anyhow::ensure!( + self.microvm.host_loopback_forward.len() <= 64, + "microVM host loopback permits at most 64 explicit port forwards" + ); + if !self.microvm.host_loopback_forward.is_empty() { + anyhow::ensure!( + self.microvm.host_loopback == Some(MicrovmNetworkActionCli::Allow), + "--host-loopback-forward requires explicit --host-loopback allow" + ); + anyhow::ensure!( + self.microvm.snapshot_destination.is_none() && self.restore_snapshot.is_none(), + "microVM snapshots do not support live host-loopback port forwards" + ); + let mut forwards = self.microvm.host_loopback_forward.clone(); + forwards.sort_unstable(); + forwards.dedup(); + anyhow::ensure!( + forwards.len() == self.microvm.host_loopback_forward.len(), + "microVM host-loopback port forwards must be unique" + ); + anyhow::ensure!( + forwards.windows(2).all(|pair| { + pair[0].protocol != pair[1].protocol || pair[0].host_port != pair[1].host_port + }), + "microVM host-loopback forwards cannot bind one protocol and host port more than once" + ); + } + anyhow::ensure!( + self.microvm.network_egress_allow.is_empty() + && self.microvm.network_egress_deny.is_empty() + || self.microvm.network_egress.is_some(), + "--network-egress is required with --network-egress-allow or --network-egress-deny" + ); + anyhow::ensure!( + (self.microvm.network_egress_allow.is_empty() + && self.microvm.network_egress_deny.is_empty()) + || (self.microvm.allow_host.is_empty() + && self.microvm.block_host.is_empty() + && self.microvm.allow_endpoint.is_empty()), + "L3/L4 egress rules cannot be combined with legacy --allow-host, --block-host, or --allow-endpoint policy" + ); + anyhow::ensure!( + !matches!( + self.microvm.network_egress, + Some(MicrovmNetworkActionCli::Allow) + ) || (self.microvm.allow_host.is_empty() && self.microvm.allow_endpoint.is_empty()), + "--network-egress allow conflicts with default-deny egress allow rules" + ); + anyhow::ensure!( + self.microvm.network_egress != Some(MicrovmNetworkActionCli::Deny) + || self.microvm.block_host.is_empty(), + "--network-egress deny conflicts with default-allow egress block rules" + ); + anyhow::ensure!( + (self.microvm.allow_host.is_empty() + && self.microvm.block_host.is_empty() + && self.microvm.allow_endpoint.is_empty() + && self.microvm.network_egress_allow.is_empty() + && self.microvm.network_egress_deny.is_empty() + && self.microvm.host_loopback.is_none() + && self.microvm.network_proxy.is_none() + && self.microvm.host_loopback_forward.is_empty() + && self.microvm.network_egress.is_none() + && self.microvm.network_ingress.is_none()) + || !self.net.is_empty() + || self.restore_snapshot.is_some(), + "microVM network policy requires --net or a networked snapshot restore" + ); anyhow::ensure!( self.cxl_test.is_empty() && self.pcie_root_complex.is_empty() @@ -515,6 +758,56 @@ impl Options { Ok(()) } + + pub(crate) fn microvm_egress_policy( + &self, + network: &openvmm_defs::microvm::MicrovmNetworkConfig, + ) -> Result< + net_backend_resources::egress::EgressPolicy, + net_backend_resources::egress::InvalidEgressPolicy, + > { + use net_backend_resources::egress::EgressPolicyMode; + + let mode = if !self.microvm.network_egress_allow.is_empty() + || !self.microvm.network_egress_deny.is_empty() + { + EgressPolicyMode::Rules { + default_action: self + .microvm + .network_egress + .unwrap_or(MicrovmNetworkActionCli::Allow) + .into(), + allow: self.microvm.network_egress_allow.clone(), + deny: self.microvm.network_egress_deny.clone(), + } + } else if !self.microvm.allow_host.is_empty() { + EgressPolicyMode::AllowList(self.microvm.allow_host.clone()) + } else if !self.microvm.block_host.is_empty() { + EgressPolicyMode::BlockList(self.microvm.block_host.clone()) + } else if !self.microvm.allow_endpoint.is_empty() { + EgressPolicyMode::TcpEndpoints(self.microvm.allow_endpoint.clone()) + } else if self.microvm.network_egress == Some(MicrovmNetworkActionCli::Deny) { + EgressPolicyMode::DenyAll + } else { + EgressPolicyMode::AllowAll + }; + net_backend_resources::egress::EgressPolicy::bind( + network.guest_ipv4, + network.prefix_length, + network.guest_mac, + network.derived_gateway_ipv4, + mode, + ) + .and_then(|policy| { + policy.with_host_loopback( + self.microvm + .host_loopback + .unwrap_or(MicrovmNetworkActionCli::Allow) + .into(), + self.microvm.network_proxy, + ) + }) + } } #[cfg(test)] @@ -666,55 +959,6 @@ mod tests { assert!(unaligned.validate_microvm_options().is_err()); } - #[test] - fn test_microvm_network_options() { - let network = Options::try_parse_from([ - "openvmm", - "--machine", - "microvm", - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - ]) - .unwrap(); - network.validate_microvm_options().unwrap(); - assert!(matches!( - &network.net[0].endpoint, - EndpointConfigCli::Microvm(config) if config.prefix_length == 24 - )); - - for args in [ - vec!["openvmm", "--machine", "microvm", "--net", "10.0.0.2/24"], - vec![ - "openvmm", - "--machine", - "microvm", - "--network-profile", - "portable", - ], - vec![ - "openvmm", - "--machine", - "microvm", - "--net", - "10.0.0.2/24", - "--net", - "10.0.1.2/24", - "--network-profile", - "portable", - ], - vec!["openvmm", "--network-profile", "portable"], - ] { - let options = Options::try_parse_from(args).unwrap(); - assert!(options.validate_microvm_options().is_err()); - } - assert!( - Options::try_parse_from(["openvmm", "--machine", "microvm", "--net", "10.0.0.0/24"]) - .is_err() - ); - } - #[test] fn test_microvm_sandbox_block_parser_and_validation() { let valid = Options::try_parse_from([ @@ -887,6 +1131,10 @@ mod tests { "earlycon=uart", "console=ttyS0", "virtio_mmio.device=bad", + "virtnet_ip=10.0.0.3", + "virtnet_mask=255.255.255.0", + "virtnet_gw=10.0.0.1", + "virtnet_dns=10.0.0.1", "nr_cpus=1", "virtfs_dir=/other", "virtfs_tag=other", @@ -925,6 +1173,42 @@ mod tests { ]) .unwrap(); valid_console.validate_microvm_options().unwrap(); + let valid_network = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + ]) + .unwrap(); + valid_network.validate_microvm_options().unwrap(); + match EndpointConfigCli::from_str("10.0.0.2/24").unwrap() { + EndpointConfigCli::Microvm(network) => { + assert_eq!(network.guest_ipv4, std::net::Ipv4Addr::new(10, 0, 0, 2)); + assert_eq!(network.prefix_length, 24); + assert_eq!(network.netmask(), std::net::Ipv4Addr::new(255, 255, 255, 0)); + assert_eq!( + network.derived_gateway_ipv4, + std::net::Ipv4Addr::new(10, 0, 0, 1) + ); + assert_eq!(network.guest_mac.to_bytes(), [0x52, 0x54, 0, 0, 0, 2]); + assert_eq!(network.gateway_mac.to_bytes(), [0x52, 0x54, 0, 0, 0, 1]); + } + _ => panic!("Expected microVM network variant"), + } + for invalid in [ + "10.0.0.2", + "10.0.0.2/0", + "10.0.0.2/31", + "10.0.0.0/24", + "10.0.0.1/24", + "10.0.0.255/24", + "fd00::2/64", + ] { + assert!(EndpointConfigCli::from_str(invalid).is_err(), "{invalid}"); + } let valid_filesystem = Options::try_parse_from([ "openvmm", "--machine", @@ -936,7 +1220,16 @@ mod tests { valid_filesystem.validate_microvm_options().unwrap(); for args in [ - vec!["openvmm", "--mount", "/mnt/share,host"], + vec!["openvmm", "--machine", "microvm", "--net", "10.0.0.2/24"], + vec![ + "openvmm", + "--machine", + "microvm", + "--network-profile", + "portable", + "--net-tap", + "tap0", + ], vec!["openvmm", "--machine", "microvm", "--uefi"], vec!["openvmm", "--machine", "microvm", "--hypervisor", "unknown"], vec!["openvmm", "--machine", "microvm", "--virtio-rng"], @@ -958,12 +1251,250 @@ mod tests { "port0", ], vec!["openvmm", "--machine", "microvm", "--net", "consomme"], + vec![ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--net", + "10.0.1.2/24", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--net", + "queues=1:10.0.0.2/24", + ], ] { let options = Options::try_parse_from(args).unwrap(); assert!(options.validate_microvm_options().is_err()); } } + #[test] + fn test_microvm_host_loopback_generic_allow_is_rejected() { + let common = [ + "openvmm", + "--machine", + "microvm", + "--network-profile", + "portable", + "--host-loopback", + "allow", + ]; + for extra in [ + vec!["--net", "10.0.0.2/24"], + vec!["--net", "10.0.0.2/24", "--snapshot-destination", "snapshot"], + vec!["--restore-snapshot", "snapshot"], + ] { + let options = Options::try_parse_from(common.into_iter().chain(extra)).unwrap(); + let error = options.validate_microvm_options().unwrap_err(); + assert!( + error + .to_string() + .contains("generic host-loopback connectivity"), + "{error}" + ); + } + let forwarded = Options::try_parse_from(common.into_iter().chain([ + "--net", + "10.0.0.2/24", + "--network-ingress", + "deny", + "--host-loopback-forward", + "tcp:3000:8080", + "--host-loopback-forward", + "udp:3000:8080", + ])) + .unwrap(); + forwarded.validate_microvm_options().unwrap(); + } + + #[test] + fn test_microvm_egress_policy_is_typed_and_canonical() { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--allow-host", + "192.168.1.9/24", + "--allow-host", + "10.0.0.1", + "--allow-host", + "192.168.1.0/24", + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = options.microvm_egress_policy(&network).unwrap(); + let net_backend_resources::egress::EgressPolicyMode::AllowList(rules) = policy.mode() + else { + panic!("expected allow-list policy") + }; + assert_eq!(rules.len(), 2); + assert_eq!(rules[0].network(), std::net::Ipv4Addr::new(10, 0, 0, 1)); + assert_eq!(rules[1].network(), std::net::Ipv4Addr::new(192, 168, 1, 0)); + assert!(policy.allows_gateway_dns()); + + let endpoint = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--allow-endpoint", + "10.0.0.9:8443", + "--allow-endpoint", + "192.0.2.7:443", + "--allow-endpoint", + "10.0.0.9:443", + ]) + .unwrap(); + endpoint.validate_microvm_options().unwrap(); + let endpoint_policy = endpoint.microvm_egress_policy(&network).unwrap(); + assert_eq!( + endpoint_policy.next_hops(), + &[ + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(10, 0, 0, 9), + ] + ); + + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--allow-host", + "192.0.2.0/24", + "--block-host", + "198.51.100.1", + ]) + .is_err() + ); + let missing_network = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--allow-host", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(missing_network.validate_microvm_options().is_err()); + let standard = + Options::try_parse_from(["openvmm", "--allow-endpoint", "192.0.2.7:443"]).unwrap(); + assert!(standard.validate_microvm_options().is_err()); + } + + #[test] + fn test_microvm_directional_network_policy_is_independent_and_fail_closed() { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + for (arguments, expected_mode) in [ + ( + ["--network-egress", "allow", "--network-ingress", "deny"].as_slice(), + "allow-all", + ), + ( + ["--network-egress", "deny", "--network-ingress", "deny"].as_slice(), + "deny-all", + ), + (["--network-egress", "deny"].as_slice(), "deny-all"), + (["--network-ingress", "deny"].as_slice(), "allow-all"), + ( + ["--network-egress", "allow", "--block-host", "192.0.2.1"].as_slice(), + "block-list", + ), + ( + ["--network-egress", "deny", "--allow-host", "192.0.2.1"].as_slice(), + "allow-list", + ), + ] { + let options = Options::try_parse_from( + [ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + ] + .into_iter() + .chain(arguments.iter().copied()), + ) + .unwrap(); + options.validate_microvm_options().unwrap(); + assert_eq!( + options.microvm_egress_policy(&network).unwrap().mode_name(), + expected_mode + ); + } + + for arguments in [ + ["--network-ingress", "allow"].as_slice(), + ["--network-egress", "deny", "--network-ingress", "allow"].as_slice(), + ["--network-egress", "allow", "--allow-host", "192.0.2.1"].as_slice(), + ["--network-egress", "deny", "--block-host", "192.0.2.1"].as_slice(), + ] { + let options = Options::try_parse_from( + [ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + ] + .into_iter() + .chain(arguments.iter().copied()), + ) + .unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } + + #[test] + fn test_microvm_endpoint_policy_rejects_invalid_identities_before_resources() { + for address in [ + "0.0.0.0", + "127.0.0.1", + "169.254.1.1", + "224.0.0.1", + "240.0.0.1", + "10.0.0.0", + "10.0.0.2", + "10.0.0.255", + ] { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--allow-endpoint", + &format!("{address}:443"), + ]) + .unwrap(); + assert!( + options.validate_microvm_options().is_err(), + "invalid endpoint address {address} was accepted" + ); + } + } + #[test] fn test_microvm_mount_from_str() { let read_only = MicrovmMountCli::from_str("/mnt/share,host").unwrap(); diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index bf5fc878646..09356f1b737 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -111,8 +111,9 @@ impl<'a> MicrovmConfigBuilder<'a> { "microVM snapshot excludes live host filesystem contents; restore revalidates the external directory and may fail after host changes" ); } - // Without an egress policy, the guest may use the gateway's DNS proxy. - let gateway_dns = network.is_some(); + let gateway_dns = network + .as_ref() + .is_some_and(|network| network.policy.allows_gateway_dns()); if active && (opt.com1.is_some() @@ -145,6 +146,7 @@ impl<'a> MicrovmConfigBuilder<'a> { .as_ref() .map(|(_, _, attachment)| attachment.clone()), network_attachment: network.as_ref().map(|network| network.attachment.clone()), + egress_policy: network.as_ref().map(|network| network.policy.clone()), filesystem_attachment: filesystem .as_ref() .map(|filesystem| filesystem.attachment.clone()), @@ -430,14 +432,19 @@ impl<'a> MicrovmConfigBuilder<'a> { ) -> anyhow::Result<()> { if let Some(network) = self.network.as_ref() { let config = &network.config; - let endpoint = microvm_network_endpoint(config, resources)?; + let endpoint = microvm_network_endpoint( + config, + &network.policy, + &self.opt.microvm.host_loopback_forward, + resources, + )?; add_virtio_device( VirtioBusCli::Mmio, virtio_resources::net::VirtioNetHandle { max_queues: Some(1), mac_address: config.guest_mac, endpoint, - egress_policy: None, + egress_policy: Some(network.policy.clone()), save_restore: true, static_ipv4: Some(StaticIpv4Config { guest_ipv4: config.guest_ipv4, diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 0628c0791cc..b9776020fe3 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -227,7 +227,9 @@ impl MicrovmLaunch { .context("microVM restore requires an effective command line")?, self.network .as_ref() - .zip(resources.network_attachment.as_ref()), + .zip(resources.egress_policy.as_ref()) + .zip(resources.network_attachment.as_ref()) + .map(|((network, policy), attachment)| (network, policy, attachment)), self.filesystem .as_ref() .zip(resources.filesystem_root_path.as_deref()) diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index c74007e7509..ed11ec94477 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -21,6 +21,7 @@ pub(crate) use restore::validate_restore_contract; use crate::storage_builder::microvm::MicrovmSandboxBlockSource; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use net_backend_resources::egress::EgressPolicy; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use std::path::PathBuf; @@ -38,6 +39,8 @@ pub(crate) struct MicrovmResources { pub(crate) console_socket_cleanup: Option, /// Snapshot identity of the portable network attachment. pub(crate) network_attachment: Option, + /// The bound run-scoped egress policy. + pub(crate) egress_policy: Option, /// Snapshot identity of the live filesystem root. pub(crate) filesystem_attachment: Option, /// Canonical host path of the live filesystem root. diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index d49d286bd02..b73e4376b4c 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! MicroVM static network identity. +//! MicroVM static network identity and egress policy. use crate::Options; use crate::VmResources; @@ -18,6 +18,7 @@ const MICROVM_NETWORK_STABLE_ID: &str = "net:microvm0"; #[derive(Clone)] pub(super) struct EffectiveMicrovmNetwork { pub(super) config: openvmm_defs::microvm::MicrovmNetworkConfig, + pub(super) policy: net_backend_resources::egress::EgressPolicy, pub(super) attachment: openvmm_helpers::snapshot::microvm::SnapshotAttachment, } @@ -68,10 +69,16 @@ pub(super) fn effective_microvm_network( _ => anyhow::bail!("microVM permits at most one virtio-net device"), }; let Some(restore) = restore else { - return Ok(requested.map(|config| EffectiveMicrovmNetwork { - config, - attachment: microvm_network_attachment(), - })); + return requested + .map(|config| { + let policy = opt.microvm_egress_policy(&config)?; + Ok(EffectiveMicrovmNetwork { + config, + policy, + attachment: microvm_network_attachment(), + }) + }) + .transpose(); }; anyhow::ensure!( requested.is_none(), @@ -92,7 +99,18 @@ pub(super) fn effective_microvm_network( ); let Some(saved) = restore.microvm_network.as_ref() else { anyhow::ensure!( - opt.microvm.network_profile.is_none(), + opt.microvm.network_profile.is_none() + && opt.microvm.net_tap.is_none() + && opt.microvm.network_egress.is_none() + && opt.microvm.network_ingress.is_none() + && opt.microvm.allow_host.is_empty() + && opt.microvm.block_host.is_empty() + && opt.microvm.allow_endpoint.is_empty() + && opt.microvm.network_egress_allow.is_empty() + && opt.microvm.network_egress_deny.is_empty() + && opt.microvm.host_loopback.is_none() + && opt.microvm.network_proxy.is_none() + && opt.microvm.host_loopback_forward.is_empty(), "restore-time network resources cannot be added to a snapshot without a NIC" ); return Ok(None); @@ -106,18 +124,46 @@ pub(super) fn effective_microvm_network( "networked microVM snapshot restore requires --network-profile portable" ); let config = microvm_network_from_snapshot(saved)?; + let policy = opt.microvm_egress_policy(&config)?; + openvmm_helpers::snapshot::microvm::validate_microvm_network_policy(saved, &policy)?; let attachment = microvm_network_attachment(); anyhow::ensure!( Some(&attachment) == saved_attachment, "restore-time network endpoint does not match the snapshot attachment" ); - Ok(Some(EffectiveMicrovmNetwork { config, attachment })) + Ok(Some(EffectiveMicrovmNetwork { + config, + policy, + attachment, + })) } pub(super) fn microvm_network_endpoint( network: &openvmm_defs::microvm::MicrovmNetworkConfig, + policy: &net_backend_resources::egress::EgressPolicy, + loopback_forwards: &[cli_args::microvm::MicrovmLoopbackForwardCli], _resources: &mut VmResources, ) -> anyhow::Result> { + let ports = loopback_forwards + .iter() + .map(|forward| net_backend_resources::consomme::HostPortConfig { + protocol: match forward.protocol { + cli_args::microvm::MicrovmLoopbackForwardProtocol::Tcp => { + net_backend_resources::consomme::HostPortProtocol::Tcp + } + cli_args::microvm::MicrovmLoopbackForwardProtocol::Udp => { + net_backend_resources::consomme::HostPortProtocol::Udp + } + }, + host_address: Some(net_backend_resources::consomme::HostIpAddress::Ipv4( + std::net::Ipv4Addr::LOCALHOST, + )), + host_port: net_backend_resources::consomme::HostPort::Fixed(forward.host_port), + guest_port: forward.guest_port, + }) + .collect(); + let allow_host_loopback = + policy.host_loopback_action() == net_backend_resources::egress::EgressAction::Allow; Ok(net_backend_resources::consomme::ConsommeHandle { cidr: None, static_ipv4: Some(StaticIpv4Config { @@ -126,11 +172,11 @@ pub(super) fn microvm_network_endpoint( gateway_ipv4: network.derived_gateway_ipv4, gateway_mac: network.gateway_mac, }), - ports: Vec::new(), + ports, recv: None, - allow_host_local_access: None, - map_gateway_to_host_loopback: None, - gateway_loopback_proxy_port: None, + allow_host_local_access: Some(allow_host_loopback), + map_gateway_to_host_loopback: Some(allow_host_loopback), + gateway_loopback_proxy_port: policy.proxy_endpoint().map(|endpoint| endpoint.port()), } .into_resource()) } @@ -138,6 +184,7 @@ pub(super) fn microvm_network_endpoint( #[cfg(test)] pub(crate) mod tests { use super::*; + use crate::microvm::restore::align_legacy_network_policy_contract; use clap::Parser as _; use openvmm_defs::microvm::build_microvm_command_line; use test_with_tracing::test; @@ -145,12 +192,24 @@ pub(crate) mod tests { pub(crate) fn network_contract() -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = net_backend_resources::egress::EgressPolicy::bind( + network.guest_ipv4, + network.prefix_length, + network.guest_mac, + network.derived_gateway_ipv4, + net_backend_resources::egress::EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:8443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + "10.0.0.9:443".parse().unwrap(), + ]), + ) + .unwrap(); let source_hypervisor = if cfg!(windows) { "whp" } else { "kvm" }; let irq = openvmm_defs::microvm::microvm_virtio_net_irq(Some(source_hypervisor)).unwrap(); let mut command_line = build_microvm_command_line(&[], false).unwrap(); openvmm_defs::microvm::append_microvm_virtio_discovery( &mut command_line, - Some((&network, irq, true)), + Some((&network, irq, policy.allows_gateway_dns())), false, None, false, @@ -161,7 +220,7 @@ pub(crate) mod tests { source_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, command_line, - Some((&network, microvm_network_attachment())), + Some((&network, &policy, microvm_network_attachment())), false, None, None, @@ -191,25 +250,25 @@ pub(crate) mod tests { .unwrap() } - fn restore_options(extra: &[&str]) -> Options { - Options::try_parse_from( - [ - "openvmm", - "--machine", - "microvm", - "--restore-snapshot", - "snapshot", - ] - .iter() - .chain(extra), - ) - .unwrap() - } - #[test] - fn network_restore_takes_identity_from_snapshot() { + fn network_restore_rebinds_endpoint_next_hops_and_requires_policy() { let contract = network_contract(); - let options = restore_options(&["--network-profile", "portable"]); + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--network-profile", + "portable", + "--allow-endpoint", + "192.0.2.7:443", + "--allow-endpoint", + "10.0.0.9:443", + "--allow-endpoint", + "10.0.0.9:8443", + ]) + .unwrap(); let restored = effective_microvm_network(&options, Some(&contract)) .unwrap() .unwrap(); @@ -217,7 +276,41 @@ pub(crate) mod tests { restored.config.guest_ipv4, std::net::Ipv4Addr::new(10, 0, 0, 2) ); - assert_eq!(restored.attachment, microvm_network_attachment()); + assert_eq!( + restored.policy.next_hops(), + &[ + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(10, 0, 0, 9), + ] + ); + let restored_again = effective_microvm_network(&options, Some(&contract)) + .unwrap() + .unwrap(); + assert_eq!(restored_again.policy, restored.policy); + + let missing_policy = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--network-profile", + "portable", + ]) + .unwrap(); + assert!(effective_microvm_network(&missing_policy, Some(&contract)).is_err()); + } + + #[test] + fn legacy_network_policy_contract_alignment_preserves_validated_digest() { + let mut saved = network_contract(); + let saved_network = saved.microvm_network.as_mut().unwrap(); + saved_network.egress_policy_encoding_version = 0; + saved_network.egress_policy_sha256 = vec![0x5a; 32]; + let mut expected = network_contract(); + + align_legacy_network_policy_contract(&saved, &mut expected); + assert_eq!(saved.microvm_network, expected.microvm_network); } #[test] @@ -229,13 +322,38 @@ pub(crate) mod tests { .find(|attachment| attachment.stable_id == MICROVM_NETWORK_STABLE_ID) .unwrap(); attachment.identity.push(b'x'); - let options = restore_options(&["--network-profile", "portable"]); + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--network-profile", + "portable", + "--allow-endpoint", + "192.0.2.7:443", + "--allow-endpoint", + "10.0.0.9:443", + "--allow-endpoint", + "10.0.0.9:8443", + ]) + .unwrap(); assert!(effective_microvm_network(&options, Some(&contract)).is_err()); } #[test] fn network_restore_requires_portable_profile() { let contract = network_contract(); - assert!(effective_microvm_network(&restore_options(&[]), Some(&contract)).is_err()); + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--allow-host", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(effective_microvm_network(&options, Some(&contract)).is_err()); } } diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index c3c2c9af3b2..7de5c502463 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -9,6 +9,7 @@ use crate::cli_args; use crate::cli_args::DiskCliKind; use crate::cli_args::microvm::MachineProfileCli; use anyhow::Context; +use net_backend_resources::egress::EgressPolicy; use openvmm_defs::config::DeviceVtl; use openvmm_defs::microvm::MicrovmFilesystemConfig; use openvmm_defs::microvm::MicrovmNetworkConfig; @@ -115,6 +116,24 @@ pub(crate) fn fresh_microvm_restore_packet( Ok((generation_id, packet)) } +pub(super) fn align_legacy_network_policy_contract( + saved: &SnapshotMachineContract, + expected: &mut SnapshotMachineContract, +) { + let (Some(saved), Some(expected)) = ( + saved.microvm_network.as_ref(), + expected.microvm_network.as_mut(), + ) else { + return; + }; + if matches!(saved.egress_policy_encoding_version, 0 | 1) { + expected.egress_policy_encoding_version = saved.egress_policy_encoding_version; + expected + .egress_policy_sha256 + .clone_from(&saved.egress_policy_sha256); + } +} + /// Restore-time inputs that shape a microVM configuration. #[derive(Default)] pub(crate) struct MicrovmRestore { @@ -302,7 +321,11 @@ pub(crate) fn prepare_restore( pub(crate) type ExpectedRestoreContract<'a> = ( &'a str, &'a str, - Option<(&'a MicrovmNetworkConfig, &'a SnapshotAttachment)>, + Option<( + &'a MicrovmNetworkConfig, + &'a EgressPolicy, + &'a SnapshotAttachment, + )>, Option<( &'a MicrovmFilesystemConfig, &'a Path, @@ -335,17 +358,18 @@ pub(crate) fn validate_restore_contract( .machine_contract .as_ref() .context("microVM snapshot is missing its authoritative machine contract")?; + let network = network.map(|(config, policy, attachment)| (config, policy, attachment.clone())); let filesystem_slot = microvm_filesystem_slot_from_snapshot(saved_contract)?; let filesystem = saved_contract .microvm_filesystem .as_ref() .and(filesystem) .map(|(config, root_path, attachment)| (config, root_path, attachment.clone())); - let expected_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( + let mut expected_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( expected_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, effective_command_line.to_owned(), - network.map(|(config, attachment)| (config, attachment.clone())), + network, filesystem_slot, filesystem, console_attachment.cloned(), @@ -360,6 +384,7 @@ pub(crate) fn validate_restore_contract( saved_contract.apic_frequency_hz, saved_contract.cpu_contract.clone(), )?; + align_legacy_network_policy_contract(saved_contract, &mut expected_contract); openvmm_helpers::snapshot::microvm::validate_microvm_machine_contract( manifest, &expected_contract, diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index a8294c54fd1..a7d5225dc6d 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -198,7 +198,9 @@ impl VmController { .microvm .network .as_ref() - .zip(self.microvm.resources.network_attachment.clone()); + .zip(self.microvm.resources.egress_policy.as_ref()) + .zip(self.microvm.resources.network_attachment.clone()) + .map(|((network, policy), attachment)| (network, policy, attachment)); let filesystem = self .microvm .filesystem diff --git a/openvmm/openvmm_helpers/Cargo.toml b/openvmm/openvmm_helpers/Cargo.toml index 6bf2ceb9c8b..abd3b0447d1 100644 --- a/openvmm/openvmm_helpers/Cargo.toml +++ b/openvmm/openvmm_helpers/Cargo.toml @@ -12,6 +12,7 @@ disk_vhd1.workspace = true disklayer_vhdx.workspace = true get_resources.workspace = true hypervisor_resources.workspace = true +net_backend_resources.workspace = true openvmm_defs.workspace = true pal.workspace = true vm_resource.workspace = true diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index aeb804ff4a5..b879615f3d5 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -197,6 +197,18 @@ pub struct SnapshotMicrovmNetwork { /// Deterministic gateway MAC address. #[mesh(5)] pub gateway_mac: Vec, + /// Canonical run-scoped egress policy mode. + #[mesh(6)] + pub egress_policy_mode: String, + /// SHA-256 of the canonical run-scoped egress policy. + #[mesh(7)] + pub egress_policy_sha256: Vec, + /// Whether restore must supply the same policy contract. + #[mesh(8)] + pub egress_policy_required: bool, + /// Version of the canonical egress-policy digest encoding. + #[mesh(10)] + pub egress_policy_encoding_version: u32, } /// Canonical guest-visible policy of the microVM filesystem. @@ -290,7 +302,10 @@ impl SnapshotMicrovmFilesystem { } impl SnapshotMicrovmNetwork { - fn new(config: &openvmm_defs::microvm::MicrovmNetworkConfig) -> Self { + fn new( + config: &openvmm_defs::microvm::MicrovmNetworkConfig, + egress_policy: &net_backend_resources::egress::EgressPolicy, + ) -> Self { Self { profile: config.profile.as_str().to_owned(), guest_ipv4: u32::from(config.guest_ipv4), @@ -298,10 +313,39 @@ impl SnapshotMicrovmNetwork { gateway_ipv4: u32::from(config.derived_gateway_ipv4), guest_mac: config.guest_mac.to_bytes().to_vec(), gateway_mac: config.gateway_mac.to_bytes().to_vec(), + egress_policy_mode: egress_policy.mode_name().to_owned(), + egress_policy_sha256: sha2::Sha256::digest(egress_policy.canonical_bytes()).to_vec(), + egress_policy_required: egress_policy.is_active(), + egress_policy_encoding_version: + net_backend_resources::egress::EGRESS_POLICY_ENCODING_VERSION, } } } +/// Validates a restore-time policy against the snapshot's canonical contract. +pub fn validate_microvm_network_policy( + saved: &SnapshotMicrovmNetwork, + policy: &net_backend_resources::egress::EgressPolicy, +) -> anyhow::Result<()> { + let encoding_version = match saved.egress_policy_encoding_version { + 0 => 1, + version => version, + }; + let canonical = policy + .canonical_bytes_for_version(encoding_version) + .with_context(|| { + format!("snapshot egress policy encoding version {encoding_version} is unsupported") + })?; + let digest = sha2::Sha256::digest(canonical); + anyhow::ensure!( + saved.egress_policy_mode == policy.mode_name() + && saved.egress_policy_sha256 == digest.as_slice() + && saved.egress_policy_required == policy.is_active(), + "restore-time egress policy does not match the snapshot contract" + ); + Ok(()) +} + /// Machine composition that becomes authoritative after capture. #[derive(Clone, Debug, PartialEq, Eq, Protobuf)] #[mesh(package = "openvmm.snapshot")] @@ -602,6 +646,7 @@ pub fn microvm_machine_contract( effective_command_line: String, network: Option<( &openvmm_defs::microvm::MicrovmNetworkConfig, + &net_backend_resources::egress::EgressPolicy, SnapshotAttachment, )>, filesystem_slot: bool, @@ -738,7 +783,7 @@ pub fn microvm_machine_contract( ), ]; let mut attachments = Vec::new(); - let microvm_network = if let Some((network, attachment)) = network { + let microvm_network = if let Some((network, egress_policy, attachment)) = network { let policy_is_valid = matches!(source_hypervisor, "kvm" | "mshv" | "whp") && attachment.reconnect_policy == "recreate-endpoint" && !attachment.required @@ -793,7 +838,7 @@ pub fn microvm_machine_contract( queue_max_sizes: vec![256, 256], }); attachments.push(attachment); - Some(SnapshotMicrovmNetwork::new(network)) + Some(SnapshotMicrovmNetwork::new(network, egress_policy)) } else { None }; @@ -1382,8 +1427,26 @@ pub(super) fn validate_machine_contract_shape( && network.gateway_mac == parsed.gateway_mac.to_bytes(), "snapshot static network identity is not canonical" ); + let valid_policy_requirement = match network.egress_policy_mode.as_str() { + "allow-all" => !network.egress_policy_required, + "deny-all" | "allow-list" | "block-list" | "endpoint" => network.egress_policy_required, + "rules" => true, + _ => false, + }; + anyhow::ensure!( + valid_policy_requirement, + "snapshot egress policy requirement is invalid" + ); + anyhow::ensure!( + matches!( + network.egress_policy_encoding_version, + 0 | 1 | 2 | net_backend_resources::egress::EGRESS_POLICY_ENCODING_VERSION + ), + "snapshot egress policy encoding version {} is unsupported", + network.egress_policy_encoding_version + ); + validate_sha256(&network.egress_policy_sha256, "egress policy")?; } - if let Some(filesystem) = &contract.microvm_filesystem { anyhow::ensure!( !filesystem.canonical_host_path.is_empty(), @@ -1976,17 +2039,41 @@ mod tests { } fn generated_network_contract(source_hypervisor: &str) -> SnapshotMachineContract { + generated_network_contract_with_mode( + source_hypervisor, + net_backend_resources::egress::EgressPolicyMode::AllowList(vec![ + "192.0.2.0/24".parse().unwrap(), + ]), + ) + } + + fn generated_network_contract_with_mode( + source_hypervisor: &str, + mode: net_backend_resources::egress::EgressPolicyMode, + ) -> SnapshotMachineContract { let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let egress_policy = net_backend_resources::egress::EgressPolicy::bind( + network.guest_ipv4, + network.prefix_length, + network.guest_mac, + network.derived_gateway_ipv4, + mode, + ) + .unwrap(); let irq = openvmm_defs::microvm::microvm_virtio_net_irq(Some(source_hypervisor)).unwrap(); let command_line = format!( "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0000000:{irq} {}", - network.command_line_fragment_with_dns(true) + network.command_line_fragment_with_dns(egress_policy.allows_gateway_dns()) ); microvm_machine_contract( source_hypervisor, MICROVM_BOOT_LAYOUT_VERSION, command_line, - Some((&network, microvm_network_attachment(source_hypervisor))), + Some(( + &network, + &egress_policy, + microvm_network_attachment(source_hypervisor), + )), false, None, None, @@ -2205,9 +2292,54 @@ mod tests { ); assert_eq!(network.guest_mac, [0x52, 0x54, 0, 0, 0, 2]); assert_eq!(network.gateway_mac, [0x52, 0x54, 0, 0, 0, 1]); + assert_eq!(network.egress_policy_mode, "allow-list"); + assert_eq!(network.egress_policy_sha256.len(), 32); + assert!(network.egress_policy_required); + assert_eq!( + network.egress_policy_encoding_version, + net_backend_resources::egress::EGRESS_POLICY_ENCODING_VERSION + ); } } + #[test] + fn generated_microvm_network_contract_accepts_deny_all_egress() { + let contract = generated_network_contract_with_mode( + "whp", + net_backend_resources::egress::EgressPolicyMode::DenyAll, + ); + + let network = contract.microvm_network.as_ref().unwrap(); + assert_eq!(network.egress_policy_mode, "deny-all"); + assert!(network.egress_policy_required); + assert!(!contract.effective_command_line.contains("virtnet_dns=")); + validate_supported_microvm_contract(&contract).unwrap(); + } + + #[test] + fn legacy_network_policy_digest_remains_valid() { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = net_backend_resources::egress::EgressPolicy::bind( + network.guest_ipv4, + network.prefix_length, + network.guest_mac, + network.derived_gateway_ipv4, + net_backend_resources::egress::EgressPolicyMode::TcpEndpoints(vec![ + "10.0.0.9:443".parse().unwrap(), + "192.0.2.7:443".parse().unwrap(), + ]), + ) + .unwrap(); + let mut saved = SnapshotMicrovmNetwork::new(&network, &policy); + saved.egress_policy_encoding_version = 0; + saved.egress_policy_sha256 = + sha2::Sha256::digest(policy.canonical_bytes_for_version(1).unwrap()).to_vec(); + + validate_microvm_network_policy(&saved, &policy).unwrap(); + saved.egress_policy_encoding_version = u32::MAX; + assert!(validate_microvm_network_policy(&saved, &policy).is_err()); + } + #[test] fn generated_microvm_filesystem_contract_has_fixed_abi() { for source_hypervisor in ["kvm", "mshv", "whp"] { From d085f44f32c2921a28dd614e0fc4182b3e9a241c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:53:09 -0700 Subject: [PATCH 139/304] virtiofs: enforce denied subtrees by object identity A microVM filesystem export often contains host paths that the sandbox must not see, such as credentials next to the workload's data. Hiding a path by name alone is not enough: the guest can reach the same object through another name, such as a hard link, or look it up again after a rename. Let the microVM virtio-fs profile hide canonical subtrees of its root: - `VirtioFsProfile::Microvm` gains `denied_paths`, which the resolver passes to the microVM profile. The profile accepts at most 128 paths, each a canonical relative path of normal components without separators, whitespace, NUL, backslash, or colon, sorted and with no path nested in another. - When the device attaches, it resolves the identity (device and inode) of every denied path. Lookups, reopen after restore, and every confined namespace operation reject a denied identity or a path under a denied subtree with `EACCES`, so the object stays hidden under any name. - On restore, the device rebuilds the profile from its resource and rejects saved aliases and handles that fall under a denied subtree or identity. The entry still passes no denied paths; the option that selects them follows. Tests cover the path canonicalization rules and a denied subtree that cannot be looked up. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_entry/src/microvm/config.rs | 1 + .../virtio/virtio_resources/src/fs/microvm.rs | 1 + vm/devices/virtio/virtiofs/src/inode.rs | 5 ++ .../virtio/virtiofs/src/microvm/device.rs | 13 +++- vm/devices/virtio/virtiofs/src/microvm/fs.rs | 11 +++ .../virtio/virtiofs/src/microvm/fs_tests.rs | 31 ++++++++ .../virtio/virtiofs/src/microvm/inode.rs | 29 ++++++- .../virtio/virtiofs/src/microvm/profile.rs | 76 +++++++++++++++++++ .../virtio/virtiofs/src/microvm/resolver.rs | 3 + .../virtio/virtiofs/src/microvm/state.rs | 3 + 10 files changed, 171 insertions(+), 2 deletions(-) diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 09356f1b737..94d69dd5eb9 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -469,6 +469,7 @@ impl<'a> MicrovmConfigBuilder<'a> { stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(), root_identity: filesystem.attachment.identity.clone(), read_only: filesystem.config.access.is_read_only(), + denied_paths: Vec::new(), }, ) } else { diff --git a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs index 0e1da4191a1..9a8c150de23 100644 --- a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs +++ b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs @@ -16,6 +16,7 @@ pub enum VirtioFsProfile { stable_id: String, root_identity: Vec, read_only: bool, + denied_paths: Vec, }, MicrovmDormant { stable_id: String, diff --git a/vm/devices/virtio/virtiofs/src/inode.rs b/vm/devices/virtio/virtiofs/src/inode.rs index 26e1a6bed6b..c6056894415 100644 --- a/vm/devices/virtio/virtiofs/src/inode.rs +++ b/vm/devices/virtio/virtiofs/src/inode.rs @@ -44,6 +44,8 @@ pub(crate) struct VirtioFsVolume { pub(crate) id: u32, pub(crate) readonly: bool, pub(crate) strict_paths: bool, + pub(crate) denied_paths: Vec, + pub(crate) denied_identities: Vec<(u64, u64)>, } impl VirtioFsVolume { @@ -53,6 +55,8 @@ impl VirtioFsVolume { id, readonly, strict_paths: false, + denied_paths: Vec::new(), + denied_identities: Vec::new(), } } @@ -109,6 +113,7 @@ impl VirtioFsInode { /// Create a new inode for the specified path. pub fn new(volume: Arc, path: PathBuf) -> lx::Result<(Self, lx::Stat)> { let stat = volume.lstat(&path)?; + volume.ensure_identity_allowed(&stat)?; let inode = Self::with_attr(volume, path, &stat); Ok((inode, stat)) } diff --git a/vm/devices/virtio/virtiofs/src/microvm/device.rs b/vm/devices/virtio/virtiofs/src/microvm/device.rs index f7420144fca..7f7d275ae78 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/device.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/device.rs @@ -88,10 +88,16 @@ impl VirtioFsDevice { stable_id: String, root_identity: Vec, read_only: bool, + denied_paths: Vec, root_path: impl AsRef, notify_corruption: Option>, ) -> anyhow::Result { - let profile = MicroVmVirtioFsProfile::from_attachment(stable_id, root_identity, read_only)?; + let profile = MicroVmVirtioFsProfile::from_attachment( + stable_id, + root_identity, + read_only, + denied_paths, + )?; let fs = VirtioFs::new_microvm(root_path, profile.clone())?; Self::new_microvm(driver_source, profile, fs, notify_corruption) } @@ -285,6 +291,7 @@ mod tests { MICROVM_ATTACHMENT_ID.to_owned(), root_identity, true, + Vec::new(), temporary_directory.path(), None, ) @@ -332,6 +339,7 @@ mod tests { MICROVM_ATTACHMENT_ID.to_owned(), microvm_root_identity(root.path()).unwrap(), false, + Vec::new(), root.path(), None, ) @@ -348,6 +356,7 @@ mod tests { MICROVM_ATTACHMENT_ID.to_owned(), microvm_root_identity(root.path()).unwrap(), false, + Vec::new(), root.path(), None, ) @@ -371,6 +380,7 @@ mod tests { MICROVM_ATTACHMENT_ID.to_owned(), microvm_root_identity(temporary_directory.path()).unwrap(), true, + Vec::new(), ) .unwrap(); let fs = VirtioFs::new(temporary_directory.path(), None).unwrap(); @@ -417,6 +427,7 @@ mod tests { MICROVM_ATTACHMENT_ID.to_owned(), microvm_root_identity(root.path()).unwrap(), false, + Vec::new(), root.path(), None, ) diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs.rs b/vm/devices/virtio/virtiofs/src/microvm/fs.rs index 1d5cbea732e..1db445af50f 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/fs.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/fs.rs @@ -131,12 +131,23 @@ impl VirtioFs { let mut mount_options = LxVolumeOptions::new(); mount_options.readonly(profile.is_readonly()).sandbox(true); let volume = mount_options.new_volume(root_path)?; + let denied_identities = profile + .denied_paths() + .iter() + .map(|path| { + volume + .lstat(path) + .map(|stat| (stat.device_nr, stat.inode_nr)) + }) + .collect::>>()?; let mut inodes = InodeMap::new(false); let volume = Arc::new(VirtioFsVolume::new_with_strict_paths( volume, 0, profile.is_readonly(), true, + profile.denied_paths().to_vec(), + denied_identities, )); let (root_inode, root_stat) = VirtioFsInode::new(Arc::clone(&volume), PathBuf::new())?; profile.validate_opened_root(root_path, &root_stat)?; diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs index 9473d4d39f1..f7f7f3312e7 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/fs_tests.rs @@ -35,6 +35,7 @@ fn profile(root_path: &Path) -> MicroVmVirtioFsProfile { MICROVM_ATTACHMENT_ID.to_owned(), microvm_root_identity(root_path).unwrap(), true, + Vec::new(), ) .unwrap() } @@ -135,6 +136,36 @@ fn microvm_runtime_path_and_map_limits_are_enforced() { assert_eq!(fs.preflight_file_insert().unwrap_err(), lx::Error::ENOSPC); } +#[test] +fn microvm_denied_subtree_is_not_lookupable() { + let temporary_directory = tempdir().unwrap(); + let root_path = temporary_directory.path(); + std::fs::create_dir(root_path.join("allowed")).unwrap(); + std::fs::create_dir(root_path.join("secrets")).unwrap(); + std::fs::write(root_path.join("secrets").join("token"), b"secret").unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root_path).unwrap(), + false, + vec!["secrets".to_owned()], + ) + .unwrap(); + let fs = VirtioFs::new_microvm(root_path, profile).unwrap(); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + + let denied = match root.lookup_child(lx::LxStr::from_bytes(b"secrets")) { + Err(error) => error, + Ok(_) => panic!("denied subtree was lookupable"), + }; + assert_eq!(denied, lx::Error::EACCES); + root.lookup_child(lx::LxStr::from_bytes(b"allowed")) + .unwrap(); + assert_eq!( + root.child_path(lx::LxStr::from_bytes(b"..")).unwrap_err(), + lx::Error::EINVAL + ); +} + #[test] fn microvm_alias_limit_is_enforced_before_linking() { let temporary_directory = tempdir().unwrap(); diff --git a/vm/devices/virtio/virtiofs/src/microvm/inode.rs b/vm/devices/virtio/virtiofs/src/microvm/inode.rs index 6438b8a4021..b07c77feb68 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/inode.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/inode.rs @@ -22,18 +22,43 @@ impl VirtioFsVolume { id: u32, readonly: bool, strict_paths: bool, + denied_paths: Vec, + denied_identities: Vec<(u64, u64)>, ) -> Self { Self { volume: Arc::new(volume), id, readonly, strict_paths, + denied_paths, + denied_identities, } } pub(crate) fn strict_paths(&self) -> bool { self.strict_paths } + + pub(crate) fn ensure_path_allowed(&self, path: &Path) -> lx::Result<()> { + if self + .denied_paths + .iter() + .any(|denied| path.starts_with(denied)) + { + return Err(lx::Error::EACCES); + } + Ok(()) + } + + pub(crate) fn ensure_identity_allowed(&self, stat: &lx::Stat) -> lx::Result<()> { + if self + .denied_identities + .contains(&(stat.device_nr, stat.inode_nr)) + { + return Err(lx::Error::EACCES); + } + Ok(()) + } } impl VirtioFsInode { @@ -51,6 +76,7 @@ impl VirtioFsInode { if lookup_count == 0 { return Err(lx::Error::EINVAL); } + volume.ensure_identity_allowed(stat)?; let mut inode = Self::with_attr(volume, path, stat); inode.lookup_count = AtomicU64::new(lookup_count); let aliases: BTreeSet<_> = aliases.into_iter().collect(); @@ -73,6 +99,7 @@ impl VirtioFsInode { } for path in self.aliases() { validate_relative_path(&path, true)?; + self.volume.ensure_path_allowed(&path)?; let mut prefix = PathBuf::new(); for component in path.components() { let Component::Normal(component) = component else { @@ -98,5 +125,5 @@ pub(crate) fn validate_child_path(volume: &VirtioFsVolume, path: &Path) -> lx::R if volume.strict_paths() && relative_path_encoded_len(path)? > MAX_PATH_BYTES { return Err(lx::Error::E2BIG); } - Ok(()) + volume.ensure_path_allowed(path) } diff --git a/vm/devices/virtio/virtiofs/src/microvm/profile.rs b/vm/devices/virtio/virtiofs/src/microvm/profile.rs index 632d3693b3c..986cf32ff09 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/profile.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/profile.rs @@ -6,6 +6,7 @@ #[cfg(windows)] use anyhow::Context as _; use std::path::Path; +use std::path::PathBuf; use std::time::Duration; /// Stable device-private attachment identifier for the microVM share. @@ -83,6 +84,9 @@ pub enum MicroVmProfileError { /// The supplied host attachment does not match the resource identity. #[error("microVM virtio-fs host root identity does not match the attachment")] RootIdentityMismatch, + /// The denied-path list was not canonical. + #[error("microVM virtio-fs denied paths are invalid")] + InvalidDeniedPaths, } /// Immutable profile settings for the microVM virtio-fs device. @@ -95,6 +99,7 @@ pub struct MicroVmVirtioFsProfile { stable_id: String, root_identity: Vec, access_mode: MicroVmAccessMode, + denied_paths: Vec, } impl MicroVmVirtioFsProfile { @@ -105,6 +110,7 @@ impl MicroVmVirtioFsProfile { stable_id: String, root_identity: Vec, read_only: bool, + denied_paths: Vec, ) -> Result { if stable_id != MICROVM_ATTACHMENT_ID { return Err(MicroVmProfileError::InvalidStableId); @@ -112,6 +118,7 @@ impl MicroVmVirtioFsProfile { if root_identity.is_empty() || root_identity.len() > MAX_ROOT_IDENTITY_SIZE { return Err(MicroVmProfileError::InvalidRootIdentity); } + let denied_paths = Self::parse_denied_paths(denied_paths)?; Ok(Self { stable_id, root_identity, @@ -120,6 +127,7 @@ impl MicroVmVirtioFsProfile { } else { MicroVmAccessMode::ReadWrite }, + denied_paths, }) } @@ -212,11 +220,54 @@ impl MicroVmVirtioFsProfile { matches!(self.access_mode, MicroVmAccessMode::ReadOnly) } + /// Returns canonical host-relative paths hidden from the guest. + pub fn denied_paths(&self) -> &[PathBuf] { + &self.denied_paths + } + /// Returns the entry-cache lifetime required by the ABI. pub const fn entry_cache_timeout(&self) -> Duration { Duration::ZERO } + fn parse_denied_paths(paths: Vec) -> Result, MicroVmProfileError> { + if paths.len() > 128 { + return Err(MicroVmProfileError::InvalidDeniedPaths); + } + let mut parsed = Vec::with_capacity(paths.len()); + for path in paths { + if path.is_empty() + || path.len() > 4096 + || path.starts_with('/') + || path.ends_with('/') + || path.chars().any(|character| { + character.is_whitespace() || matches!(character, '\0' | '\\' | ':') + }) + { + return Err(MicroVmProfileError::InvalidDeniedPaths); + } + let mut relative = PathBuf::new(); + for component in path.split('/') { + if component.is_empty() || matches!(component, "." | "..") { + return Err(MicroVmProfileError::InvalidDeniedPaths); + } + relative.push(component); + } + parsed.push(relative); + } + let mut canonical = parsed.clone(); + canonical.sort_unstable(); + if canonical != parsed { + return Err(MicroVmProfileError::InvalidDeniedPaths); + } + for pair in parsed.windows(2) { + if pair[1].starts_with(&pair[0]) { + return Err(MicroVmProfileError::InvalidDeniedPaths); + } + } + Ok(parsed) + } + /// Returns the attribute-cache lifetime required by the ABI. pub const fn attribute_cache_timeout(&self) -> Duration { Duration::ZERO @@ -309,12 +360,14 @@ mod tests { MICROVM_ATTACHMENT_ID.to_owned(), root_identity, true, + vec!["secrets".to_owned()], ) .unwrap(); assert_eq!(profile.attachment_id(), "fs:microvm0"); assert_eq!(profile.mount_tag(), "microvm"); assert_eq!(profile.request_queues(), 1); assert!(profile.is_readonly()); + assert_eq!(profile.denied_paths(), &[PathBuf::from("secrets")]); assert_eq!(profile.entry_cache_timeout(), Duration::ZERO); assert_eq!(profile.attribute_cache_timeout(), Duration::ZERO); assert!(profile.direct_io()); @@ -335,6 +388,7 @@ mod tests { MICROVM_ATTACHMENT_ID.to_owned(), b"not-a-root-identity".to_vec(), false, + Vec::new(), ) .unwrap(); @@ -344,4 +398,26 @@ mod tests { .is_err() ); } + + #[test] + fn profile_rejects_noncanonical_or_overlapping_denied_paths() { + let root = tempfile::tempdir().unwrap(); + let identity = microvm_root_identity(root.path()).unwrap(); + for denied_paths in [ + vec!["nested/path".to_owned(), "alpha".to_owned()], + vec!["secrets".to_owned(), "secrets/nested".to_owned()], + vec!["../outside".to_owned()], + vec!["alternate:name".to_owned()], + ] { + assert!( + MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + identity.clone(), + true, + denied_paths, + ) + .is_err() + ); + } + } } diff --git a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs index 4702f3b60ac..979ce74741f 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs @@ -32,6 +32,7 @@ pub(crate) fn resolve( stable_id, root_identity, read_only, + denied_paths, } => { anyhow::ensure!( resource.tag == MICROVM_MOUNT_TAG, @@ -54,6 +55,7 @@ pub(crate) fn resolve( stable_id.clone(), root_identity.clone(), *read_only, + denied_paths.clone(), root_path, None, )? @@ -98,6 +100,7 @@ mod tests { stable_id: MICROVM_ATTACHMENT_ID.to_owned(), root_identity, read_only: true, + denied_paths: Vec::new(), }, }, VirtioResolveInput { diff --git a/vm/devices/virtio/virtiofs/src/microvm/state.rs b/vm/devices/virtio/virtiofs/src/microvm/state.rs index cfe09aa3e0d..6c5568fc865 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/state.rs @@ -162,6 +162,9 @@ pub(crate) fn validate_reopenable_alias( path: &Path, ) -> anyhow::Result { validate_relative_path(path, true).map_err(anyhow::Error::from)?; + volume + .ensure_path_allowed(path) + .map_err(anyhow::Error::from)?; let mut prefix = PathBuf::new(); for component in path.components() { let std::path::Component::Normal(component) = component else { From dbe0903f17074dfec2d750a47c60b655d2bdea41 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 15:55:24 -0700 Subject: [PATCH 140/304] openvmm: add --mount-deny and record denied paths in snapshots The previous commit taught the microVM virtio-fs server to hide canonical subtrees of its export, but nothing selected them yet. Let the user choose them on the command line and make the choice part of the snapshot contract, so a restore cannot quietly expose a path that the captured VM could not see. `--mount-deny ` is repeatable, requires `--mount`, and is rejected for the standard machine and when it is given more than 128 times. Before any resource is opened, the entry canonicalizes each path against the export root: - An empty path or one with a dot or parent component is rejected. A relative path is resolved against the export root. - The canonical path must lie inside the root and cannot be the root itself. - No component may be a symbolic link or, on Windows, a reparse point. On Unix, a path on a different device than the root (a nested mount) is rejected. - The resulting list is sorted, must contain no duplicates or nested entries, and is encoded as `/`-separated UTF-8 relative paths. `MicrovmFilesystemConfig` gains `denied_paths` and `with_denied_paths`, which enforce the same canonical form the device profile accepts: at most 128 paths and 16 KiB in total, each path at most 4096 bytes, with no leading or trailing separator, empty, dot, or parent component, whitespace, NUL, backslash, or colon, in sorted order and without overlap. Each rule has its own `InvalidMicrovmFilesystemConfig` variant. The entry passes the validated list to `VirtioFsProfile::Microvm` instead of the empty list it used until now. The snapshot machine contract records the list in `SnapshotMicrovmFilesystem.denied_paths` (mesh field 12). Shape validation re-parses the saved list through `with_denied_paths`, so a non-canonical list fails as a non-canonical policy, and restore rebuilds the saved configuration with the same list. Restoring an active attachment requires the fresh `--mount-deny` set to produce exactly the saved configuration. A snapshot written before this change decodes with an empty list. The CLI reference documents `--mount-deny` and the restore requirement. Its `--mount` entry no longer repeats the bootstrap command-line tokens and host-path identity rules, which the virtio-fs page covers. The virtio-fs page gains a `--mount-deny` example, the canonicalization rules, the denied-path set in the restore requirements, and how the server enforces the policy. A new entry test, which replaces the plain canonical-root test, checks that a denied path inside the export is recorded as a relative entry and that a path outside the export and a duplicate are rejected. The existing restore tests pass an empty deny list. The generated filesystem contract in the helpers tests now carries a denied path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../src/reference/devices/virtio/virtio-fs.md | 15 +- Guide/src/reference/openvmm/management/cli.md | 27 ++- openvmm/openvmm_defs/src/microvm.rs | 69 ++++++ openvmm/openvmm_entry/src/cli_args/microvm.rs | 13 ++ openvmm/openvmm_entry/src/microvm/config.rs | 3 +- .../openvmm_entry/src/microvm/filesystem.rs | 205 +++++++++++++++--- .../openvmm_helpers/src/snapshot/microvm.rs | 6 + 7 files changed, 288 insertions(+), 50 deletions(-) diff --git a/Guide/src/reference/devices/virtio/virtio-fs.md b/Guide/src/reference/devices/virtio/virtio-fs.md index d662366f0da..2b2ad27e73b 100644 --- a/Guide/src/reference/devices/virtio/virtio-fs.md +++ b/Guide/src/reference/devices/virtio/virtio-fs.md @@ -29,6 +29,7 @@ policy. Configure an active attachment with: ```bash openvmm --machine microvm \ --mount /mnt/share,path/to/share,ro \ + --mount-deny path/to/share/secrets \ --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz ``` @@ -53,6 +54,10 @@ For an active cold-boot attachment, the profile adds `virtfs_dir`, `virtfs_tag`, and `virtfs_mode` bootstrap tokens to the kernel command line. The fixed transport is always discoverable. Active attachment policy and the canonical absolute host path become snapshot-authoritative. +Repeat `--mount-deny` to hide existing host files or directories. OpenVMM +canonicalizes each entry relative to the export and rejects paths outside the +root, the root itself, overlapping entries, symlink/reparse components, and +nested-mount crossings before opening the device. `SectionFs`, aggregate roots, alternate tags, PCI transport, DAX, and extra queues are not part of the microVM profile. @@ -69,8 +74,8 @@ later host additions do not appear midway through that enumeration. New lookups and newly opened directories still observe the live host tree. Restoring a snapshot captured with an active attachment requires a fresh -`--mount` argument and the exact same canonical host path, guest target, and -mode. Identity validation remains independent: before any vCPU starts, +`--mount` argument and the exact same denied-path set, canonical host path, +guest target, and mode. Identity validation remains independent: before any vCPU starts, OpenVMM pins the supplied root and validates its saved root and object identities. Missing, moved, replaced, ambiguous, or no-longer-reopenable objects fail restore. @@ -109,6 +114,12 @@ absolute and parent-relative aliases, does not follow symbolic links or Windows reparse points while resolving saved objects, and enforces read-only mode before invoking a host mutation. +Denied paths are enforced in the server namespace rather than by guest mount +layout. Lookup and mutation operations reject denied prefixes, directory +enumeration omits their names, and denied root object identities reject +hard-link, junction, and bind-mount aliases. Mounting the same virtio-fs tag at +another guest path does not change the policy. + ```admonish warning The current cross-platform `LxVolume` interface does not provide fully handle-relative component walking for every operation. Do not allow an diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 66a85985c30..46e1c5a3631 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -179,23 +179,22 @@ describes the source definitions. behavior, zero entry and attribute cache lifetimes, and no shared-memory window. `--mount` conflicts with `--virtio-fs` and `--virtio-fs-shmem`; those standard-machine options cannot select the - microVM filesystem profile. The profile appends - `virtfs_dir= virtfs_tag=microvm virtfs_mode=` to the - kernel command line, so that the guest can mount the share at boot. - - The host path must name a plain directory, and a path with a parent - component or a symbolic-link or reparse-point component is rejected. The - canonical directory is identified by its device and inode numbers on Linux, - or by its volume and file ID on Windows, and the device refuses the - attachment if the path no longer names that directory when it opens the - root. The guest memory backing file, the snapshot destination, and the - restore snapshot must be outside the exported root. + microVM filesystem profile. + + `--mount-deny ` is repeatable and hides an existing file or + directory inside the exported root. Paths are canonicalized to + host-relative policy entries before resources are opened. The complete root, + paths outside the root, duplicates, overlaps, symlink/reparse components, + and nested-mount crossings are rejected. The virtio-fs server blocks the + denied subtree and its root object identity, so `..`, a second mount of the + same device, hard-link aliases, symlinks, junctions, and bind-mount aliases + cannot re-expose it. Filesystem snapshots contain guest-visible FUSE and queue state, not host directory contents or native handles. An active snapshot requires - `--mount` again with the exact canonical host path, guest target, and access - mode; the live root and every saved object identity are also revalidated - before vCPUs start. A snapshot captured without `--mount` may remain dormant + `--mount` again with the exact canonical host path, guest target, access + mode, and denied-path set; the live root and every saved object identity are + also revalidated before vCPUs start. A snapshot captured without `--mount` may remain dormant or bind a new attachment. The resumed guest must then explicitly run `mount -t virtiofs microvm ` because its cold-boot mount hook has already completed. diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index c21b1c0f878..447312b7edf 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -285,6 +285,8 @@ pub struct MicrovmFilesystemConfig { pub guest_mount_target: String, /// Snapshot-authoritative access policy. pub access: MicrovmFilesystemAccess, + /// Canonical host-relative paths hidden by the virtio-fs server. + pub denied_paths: Vec, } impl MicrovmFilesystemConfig { @@ -312,9 +314,59 @@ impl MicrovmFilesystemConfig { Ok(Self { guest_mount_target, access, + denied_paths: Vec::new(), }) } + /// Adds a canonical, non-overlapping denied-path policy. + pub fn with_denied_paths( + mut self, + denied_paths: Vec, + ) -> Result { + if denied_paths.len() > 128 { + return Err(InvalidMicrovmFilesystemConfig::TooManyDeniedPaths); + } + let total_bytes = denied_paths + .iter() + .try_fold(0usize, |total, path| total.checked_add(path.len())) + .ok_or(InvalidMicrovmFilesystemConfig::DeniedPathsTooLarge)?; + if total_bytes > 16 * 1024 { + return Err(InvalidMicrovmFilesystemConfig::DeniedPathsTooLarge); + } + for path in &denied_paths { + if path.is_empty() + || path.len() > 4096 + || path.starts_with('/') + || path.ends_with('/') + || path.chars().any(|character| { + character.is_whitespace() || matches!(character, '\0' | '\\' | ':') + }) + || path + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(InvalidMicrovmFilesystemConfig::InvalidDeniedPath( + path.clone(), + )); + } + } + let mut canonical = denied_paths.clone(); + canonical.sort_unstable(); + if canonical != denied_paths { + return Err(InvalidMicrovmFilesystemConfig::NonCanonicalDeniedPaths); + } + for pair in denied_paths.windows(2) { + if pair[1] + .strip_prefix(&pair[0]) + .is_some_and(|suffix| suffix.starts_with('/')) + { + return Err(InvalidMicrovmFilesystemConfig::OverlappingDeniedPaths); + } + } + self.denied_paths = denied_paths; + Ok(self) + } + /// Returns the pinned guest bootstrap command-line tokens. pub fn command_line_fragment(&self) -> String { format!( @@ -333,6 +385,23 @@ pub enum InvalidMicrovmFilesystemConfig { "invalid guest mount target '{0}': expected an absolute non-root Linux path without empty, dot, parent, whitespace, backslash, or '=' components" )] InvalidGuestTarget(String), + /// A denied path was not a canonical relative path. + #[error( + "invalid denied path '{0}': expected a relative path without empty, dot, parent, whitespace, backslash, or ':' components" + )] + InvalidDeniedPath(String), + /// The denied-path list exceeded its count bound. + #[error("microVM filesystem permits at most 128 denied paths")] + TooManyDeniedPaths, + /// The denied-path list exceeded its aggregate byte bound. + #[error("microVM filesystem denied paths exceed the 16-KiB aggregate limit")] + DeniedPathsTooLarge, + /// The denied-path list did not preserve canonical lexical order. + #[error("microVM filesystem denied paths are not in canonical order")] + NonCanonicalDeniedPaths, + /// One denied path contained another denied path. + #[error("microVM filesystem denied paths overlap")] + OverlappingDeniedPaths, } impl MicrovmNetworkConfig { diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index e0e50f0f2b5..1bc4b5c7feb 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -278,6 +278,14 @@ pub struct MicrovmCli { conflicts_with_all = ["virtio_fs", "virtio_fs_shmem"] )] pub microvm_mount: Option, + + /// Hide an existing host path inside the microVM filesystem export. + #[clap( + long = "mount-deny", + value_name = "HOST_PATH", + requires = "microvm_mount" + )] + pub microvm_mount_deny: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -387,6 +395,7 @@ impl Options { && self.microvm.block_host.is_empty() && self.microvm.allow_endpoint.is_empty() && self.microvm.microvm_mount.is_none() + && self.microvm.microvm_mount_deny.is_empty() && self.microvm.microvm_sandbox_block.is_empty() && self.microvm.restore_processors.is_none() && self.microvm.restore_memory.is_none() @@ -560,6 +569,10 @@ impl Options { self.microvm.microvm_sandbox_block.len() <= 4, "microVM permits at most three read-only layers and one writable scratch device" ); + anyhow::ensure!( + self.microvm.microvm_mount_deny.len() <= 128, + "microVM filesystem permits at most 128 denied paths" + ); for (index, block) in self.microvm.microvm_sandbox_block.iter().enumerate() { anyhow::ensure!( block.disk.read_only == block.role.is_read_only(), diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 94d69dd5eb9..ae20368d5fa 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -97,6 +97,7 @@ impl<'a> MicrovmConfigBuilder<'a> { let filesystem = if active { effective_microvm_filesystem( opt.microvm.microvm_mount.as_ref(), + &opt.microvm.microvm_mount_deny, restore_machine_contract, )? } else { @@ -469,7 +470,7 @@ impl<'a> MicrovmConfigBuilder<'a> { stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(), root_identity: filesystem.attachment.identity.clone(), read_only: filesystem.config.access.is_read_only(), - denied_paths: Vec::new(), + denied_paths: filesystem.config.denied_paths.clone(), }, ) } else { diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index 6626e245d03..b767799f4bb 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! MicroVM virtio-fs attachment. +//! MicroVM virtio-fs attachment and filesystem policy. use crate::cli_args; use anyhow::Context; @@ -130,6 +130,114 @@ fn canonical_microvm_filesystem_root( Ok((canonical, identity_kind, identity)) } +fn canonical_microvm_filesystem_denied_paths( + root_path: &Path, + requested: &[PathBuf], +) -> anyhow::Result> { + #[cfg(unix)] + let root_metadata = fs_err::symlink_metadata(root_path).with_context(|| { + format!( + "failed to inspect microVM filesystem root {}", + root_path.display() + ) + })?; + let mut relative_paths = Vec::with_capacity(requested.len()); + for denied in requested { + anyhow::ensure!( + !denied.as_os_str().is_empty() + && !denied.components().any(|component| matches!( + component, + std::path::Component::CurDir | std::path::Component::ParentDir + )), + "microVM denied path is empty or contains a dot or parent component: {}", + denied.display() + ); + let absolute = if denied.is_absolute() { + denied.clone() + } else { + root_path.join(denied) + }; + let canonical = fs_err::canonicalize(&absolute).with_context(|| { + format!( + "failed to canonicalize microVM denied path {}", + absolute.display() + ) + })?; + let relative = canonical.strip_prefix(root_path).with_context(|| { + format!( + "microVM denied path resolves outside the filesystem export root: {}", + denied.display() + ) + })?; + anyhow::ensure!( + !relative.as_os_str().is_empty(), + "microVM denied path cannot hide the complete filesystem export" + ); + + let mut current = root_path.to_owned(); + for component in relative.components() { + let std::path::Component::Normal(component) = component else { + anyhow::bail!("microVM denied path contains a non-normal component"); + }; + current.push(component); + let metadata = fs_err::symlink_metadata(¤t).with_context(|| { + format!( + "failed to inspect microVM denied path component {}", + current.display() + ) + })?; + anyhow::ensure!( + !metadata.file_type().is_symlink(), + "microVM denied path component is a symbolic link: {}", + current.display() + ); + #[cfg(windows)] + anyhow::ensure!( + std::os::windows::fs::MetadataExt::file_attributes(&metadata) & 0x400 == 0, + "microVM denied path component is a reparse point: {}", + current.display() + ); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let denied_metadata = fs_err::symlink_metadata(&canonical)?; + anyhow::ensure!( + denied_metadata.dev() == root_metadata.dev(), + "microVM denied path crosses a nested mount: {}", + denied.display() + ); + } + relative_paths.push(relative.to_owned()); + } + relative_paths.sort_unstable(); + for pair in relative_paths.windows(2) { + anyhow::ensure!( + pair[0] != pair[1] && !pair[1].starts_with(&pair[0]), + "microVM denied paths must be unique and non-overlapping" + ); + } + let encoded = relative_paths + .iter() + .map(|path| { + path.iter() + .map(|component| { + component + .to_str() + .context("microVM denied path is not valid UTF-8") + }) + .collect::>>() + .map(|components| components.join("/")) + }) + .collect::>>()?; + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/unused".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + )? + .with_denied_paths(encoded.clone())?; + Ok(encoded) +} + pub(crate) fn microvm_filesystem_attachment( host_path: &Path, ) -> anyhow::Result<( @@ -158,12 +266,16 @@ pub(crate) fn microvm_filesystem_attachment( fn microvm_filesystem_from_mount( requested: &cli_args::microvm::MicrovmMountCli, + denied_paths: &[PathBuf], ) -> anyhow::Result { let (root_path, attachment) = microvm_filesystem_attachment(&requested.host_path)?; + let denied_paths = + canonical_microvm_filesystem_denied_paths(Path::new(&root_path), denied_paths)?; let config = openvmm_defs::microvm::MicrovmFilesystemConfig::new( requested.guest_target.clone(), requested.access, - )?; + )? + .with_denied_paths(denied_paths)?; Ok(EffectiveMicrovmFilesystem { config, root_path, @@ -234,7 +346,8 @@ pub(crate) fn microvm_filesystem_from_snapshot( "rw" => openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, mode => anyhow::bail!("snapshot microVM filesystem access mode '{mode}' is unsupported"), }; - openvmm_defs::microvm::MicrovmFilesystemConfig::new(saved.guest_mount_target.clone(), access) + openvmm_defs::microvm::MicrovmFilesystemConfig::new(saved.guest_mount_target.clone(), access)? + .with_denied_paths(saved.denied_paths.clone()) .context("snapshot microVM filesystem policy is invalid") } @@ -262,10 +375,13 @@ pub(crate) fn microvm_filesystem_slot_from_snapshot( pub(super) fn effective_microvm_filesystem( requested: Option<&cli_args::microvm::MicrovmMountCli>, + denied_paths: &[PathBuf], restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, ) -> anyhow::Result> { let Some(restore) = restore else { - return requested.map(microvm_filesystem_from_mount).transpose(); + return requested + .map(|requested| microvm_filesystem_from_mount(requested, denied_paths)) + .transpose(); }; let has_device = microvm_filesystem_slot_from_snapshot(restore)?; @@ -289,7 +405,7 @@ pub(super) fn effective_microvm_filesystem( == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION, "snapshot does not support restore-time microVM filesystem attachment" ); - return microvm_filesystem_from_mount(requested).map(Some); + return microvm_filesystem_from_mount(requested, denied_paths).map(Some); }; let requested = requested .context("snapshot restore requires a fresh --mount attachment for fs:microvm0")?; @@ -298,7 +414,7 @@ pub(super) fn effective_microvm_filesystem( requested.guest_target == config.guest_mount_target && requested.access == config.access, "restore-time mount target or access mode does not match the snapshot contract" ); - let effective = microvm_filesystem_from_mount(requested)?; + let effective = microvm_filesystem_from_mount(requested, denied_paths)?; anyhow::ensure!( !saved.canonical_host_path.is_empty(), "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" @@ -311,6 +427,10 @@ pub(super) fn effective_microvm_filesystem( Some(&effective.attachment) == saved_attachment, "restore-time filesystem root identity does not match the snapshot attachment" ); + anyhow::ensure!( + effective.config == config, + "restore-time filesystem denied paths do not match the snapshot contract" + ); Ok(Some(effective)) } @@ -436,32 +556,38 @@ mod tests { } #[test] - fn filesystem_mount_resolves_the_canonical_root() { + fn filesystem_denied_paths_are_canonical_and_root_scoped() { let root = tempfile::tempdir().unwrap(); + let secrets = root.path().join("secrets"); + std::fs::create_dir(&secrets).unwrap(); let options = Options::try_parse_from([ "openvmm", "--machine", "microvm", "--mount", &format!("/mnt/share,{},rw", root.path().display()), + "--mount-deny", + secrets.to_str().unwrap(), ]) .unwrap(); options.validate_microvm_options().unwrap(); - let filesystem = effective_microvm_filesystem(options.microvm.microvm_mount.as_ref(), None) - .unwrap() - .unwrap(); - assert_eq!(filesystem.config.guest_mount_target, "/mnt/share"); - assert_eq!( - filesystem.config.access, - openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite - ); - assert_eq!( - filesystem.root_path, - fs_err::canonicalize(root.path()).unwrap().to_str().unwrap() + let filesystem = effective_microvm_filesystem( + options.microvm.microvm_mount.as_ref(), + &options.microvm.microvm_mount_deny, + None, + ) + .unwrap() + .unwrap(); + assert_eq!(filesystem.config.denied_paths, vec!["secrets".to_owned()]); + + let outside = tempfile::tempdir().unwrap(); + assert!( + canonical_microvm_filesystem_denied_paths(root.path(), &[outside.path().to_owned()]) + .is_err() ); - assert_eq!( - filesystem.attachment.stable_id, - MICROVM_FILESYSTEM_STABLE_ID + assert!( + canonical_microvm_filesystem_denied_paths(root.path(), &[secrets.clone(), secrets]) + .is_err() ); } @@ -470,10 +596,13 @@ mod tests { let root = tempfile::tempdir().unwrap(); let contract = filesystem_contract(root.path()); let options = restore_mount_options(root.path(), "ro"); - let restored = - effective_microvm_filesystem(options.microvm.microvm_mount.as_ref(), Some(&contract)) - .unwrap() - .unwrap(); + let restored = effective_microvm_filesystem( + options.microvm.microvm_mount.as_ref(), + &[], + Some(&contract), + ) + .unwrap() + .unwrap(); assert_eq!(restored.config.guest_mount_target, "/mnt/share"); assert_eq!(restored.attachment, contract.attachments[0]); @@ -482,6 +611,7 @@ mod tests { assert!( effective_microvm_filesystem( replacement_options.microvm.microvm_mount.as_ref(), + &[], Some(&contract) ) .is_err() @@ -499,8 +629,12 @@ mod tests { let options = restore_mount_options(&moved, "ro"); assert!( - effective_microvm_filesystem(options.microvm.microvm_mount.as_ref(), Some(&contract)) - .is_err() + effective_microvm_filesystem( + options.microvm.microvm_mount.as_ref(), + &[], + Some(&contract) + ) + .is_err() ); } @@ -508,12 +642,13 @@ mod tests { fn filesystem_restore_rejects_missing_or_changed_policy() { let root = tempfile::tempdir().unwrap(); let contract = filesystem_contract(root.path()); - assert!(effective_microvm_filesystem(None, Some(&contract)).is_err()); + assert!(effective_microvm_filesystem(None, &[], Some(&contract)).is_err()); let changed_mode = restore_mount_options(root.path(), "rw"); assert!( effective_microvm_filesystem( changed_mode.microvm.microvm_mount.as_ref(), + &[], Some(&contract), ) .is_err() @@ -524,7 +659,7 @@ mod tests { fn filesystem_restore_without_mount_preserves_dormant_slot() { let contract = dormant_filesystem_contract(); assert!( - effective_microvm_filesystem(None, Some(&contract)) + effective_microvm_filesystem(None, &[], Some(&contract)) .unwrap() .is_none() ); @@ -535,10 +670,13 @@ mod tests { let root = tempfile::tempdir().unwrap(); let contract = dormant_filesystem_contract(); let options = restore_mount_options(root.path(), "rw"); - let filesystem = - effective_microvm_filesystem(options.microvm.microvm_mount.as_ref(), Some(&contract)) - .unwrap() - .unwrap(); + let filesystem = effective_microvm_filesystem( + options.microvm.microvm_mount.as_ref(), + &[], + Some(&contract), + ) + .unwrap() + .unwrap(); assert_eq!(filesystem.config.guest_mount_target, "/mnt/share"); assert_eq!( filesystem.config.access, @@ -557,6 +695,7 @@ mod tests { let options = restore_mount_options(root.path(), "ro"); let error = match effective_microvm_filesystem( options.microvm.microvm_mount.as_ref(), + &[], Some(&contract), ) { Err(error) => error, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index b879615f3d5..4226ed8dba2 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -248,6 +248,9 @@ pub struct SnapshotMicrovmFilesystem { /// Canonical absolute host export path. #[mesh(11)] pub canonical_host_path: String, + /// Canonical host-relative paths hidden by the virtio-fs server. + #[mesh(12)] + pub denied_paths: Vec, } /// Authoritative identity and snapshot policy for a microVM sandbox block. @@ -297,6 +300,7 @@ impl SnapshotMicrovmFilesystem { entry_cache_timeout_ns: 0, attribute_cache_timeout_ns: 0, canonical_host_path: canonical_host_path.to_owned(), + denied_paths: config.denied_paths.clone(), } } } @@ -1461,6 +1465,7 @@ pub(super) fn validate_machine_contract_shape( filesystem.guest_mount_target.clone(), access, ) + .and_then(|config| config.with_denied_paths(filesystem.denied_paths.clone())) .context("snapshot filesystem policy is invalid")?; anyhow::ensure!( *filesystem == SnapshotMicrovmFilesystem::new(&parsed, &filesystem.canonical_host_path), @@ -2126,6 +2131,7 @@ mod tests { "/mnt/share".to_owned(), openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, ) + .and_then(|config| config.with_denied_paths(vec!["secrets".to_owned()])) .unwrap(); let command_line = format!( "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6 {}", From 4296c40af0e0bc68208690c3fbf2fb95193d54ad Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:00:12 -0700 Subject: [PATCH 141/304] serial_core: expose local peer identity and active-peer eviction A later microVM control console accepts host clients through the existing serial backends. It must learn which local user is connected, and it must be able to drop a client that fails to authenticate without tearing down the listener. `SerialIo` offers neither today. Add both to serial_core: - `LocalPeerIdentity` is a mesh-serializable operating-system identity of a local peer. It holds a Unix effective user ID, or a Windows SID in a zero-padded 68-byte buffer with its meaningful length, or `Unsupported` for a platform without an implementation. `LocalPeerIdentity::windows_sid` rejects a length outside 8..=68 bytes, the size range of a valid SID. - `SerialIo::local_peer_identity` returns the authenticated identity of the connected peer. The default returns `Ok(None)`: a generic backend makes no authentication promise, and control endpoints must reject such a peer. - `SerialIo::disconnect_current` closes the current connection and keeps the listener, so another client can connect. The default returns `ErrorKind::Unsupported`. Both methods have defaults, so existing backends are unchanged. The socket and named-pipe backends implement them in the next commit. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/serial/serial_core/src/lib.rs | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/vm/devices/serial/serial_core/src/lib.rs b/vm/devices/serial/serial_core/src/lib.rs index dab358bfbe4..78efdf2c7a6 100644 --- a/vm/devices/serial/serial_core/src/lib.rs +++ b/vm/devices/serial/serial_core/src/lib.rs @@ -13,9 +13,39 @@ pub mod serial_io; use futures::io::AsyncRead; use futures::io::AsyncWrite; use inspect::InspectMut; +use mesh::MeshPayload; use std::task::Context; use std::task::Poll; +/// Authenticated operating-system identity of a local serial peer. +#[derive(Clone, Debug, Eq, PartialEq, MeshPayload)] +pub enum LocalPeerIdentity { + /// Effective Unix user ID obtained from the connected socket. + UnixUid(u32), + /// Windows security identifier in its self-relative binary representation. + WindowsSid { + /// Binary SID bytes, zero-padded to the maximum SID size. + bytes: [u8; 68], + /// Number of meaningful bytes in `bytes`. + length: u8, + }, + /// No peer-identity implementation exists for this platform. + Unsupported, +} + +impl LocalPeerIdentity { + /// Constructs a validated Windows SID identity. + pub fn windows_sid(bytes: [u8; 68], length: u8) -> std::io::Result { + if !(8..=68).contains(&length) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Windows SID length is outside the supported range", + )); + } + Ok(Self::WindowsSid { bytes, length }) + } +} + /// Trait for types providing serial IO. pub trait SerialIo: AsyncRead + AsyncWrite + Send + InspectMut + Unpin { /// Returns true if the backend is already connected. @@ -29,4 +59,23 @@ pub trait SerialIo: AsyncRead + AsyncWrite + Send + InspectMut + Unpin { /// Polls for the serial backend to disconnect. fn poll_disconnect(&mut self, cx: &mut Context<'_>) -> Poll>; + + /// Returns the authenticated local identity of the connected peer. + /// + /// Generic serial backends do not promise peer authentication. Control + /// endpoints must reject `None`. + fn local_peer_identity(&self) -> std::io::Result> { + Ok(None) + } + + /// Closes the current connection while preserving a reconnectable listener. + /// + /// Control endpoints use this to evict unauthenticated clients. Generic + /// serial backends may return [`std::io::ErrorKind::Unsupported`]. + fn disconnect_current(&mut self) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "serial backend cannot actively disconnect its peer", + )) + } } From 297e593a395b7969c2d26f6b957a89b9d5e18312 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:02:22 -0700 Subject: [PATCH 142/304] serial_socket: report Unix and named-pipe peer identities Implement the peer-identity and eviction hooks from the previous commit for the socket and Windows named-pipe serial backends, so a control endpoint built on them can authenticate its client. Socket backend: - On Linux, when the connected socket is a Unix-domain socket, `local_peer_identity` reports the peer's effective user ID from `SO_PEERCRED` through `pal::unix::credentials`. A TCP socket, and any socket on other platforms, reports no identity. - `disconnect_current` drops the current connection. A listener, if there is one, keeps accepting clients. - Linux gains `libc` and `pal` dependencies for the address-family check and the credential query. Named-pipe backend: - When a client connects, or when the backend starts from an already-connected pipe, it resolves the client process of the pipe and that process's token user SID through `pal`, and keeps the result as the peer identity until the connection is dropped. A failed lookup fails the connection. - `disconnect_current` disconnects the pipe instance and listens again, which is what the backend already did when a client closed. Tests check that one end of a Unix socket pair reports the current effective user ID and that a named-pipe pair reports the current process's user SID. The named-pipe reconnect test also calls `disconnect_current` after the client has closed, which must be a harmless no-op before a replacement client connects. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 2 + vm/devices/serial/serial_socket/Cargo.toml | 5 ++ vm/devices/serial/serial_socket/src/net.rs | 46 +++++++++++++++ .../serial/serial_socket/src/windows.rs | 59 +++++++++++++++++-- .../serial/serial_socket/src/windows_tests.rs | 1 + 5 files changed, 107 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0bf1622c77f..2d5e8459313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7775,12 +7775,14 @@ dependencies = [ "futures", "getrandom 0.4.2", "inspect", + "libc", "mesh", "pal", "pal_async", "pal_async_test", "serial_core", "socket2", + "test_with_tracing", "tracing", "unix_socket", "vm_resource", diff --git a/vm/devices/serial/serial_socket/Cargo.toml b/vm/devices/serial/serial_socket/Cargo.toml index 1b24cd191ea..c89e709f481 100644 --- a/vm/devices/serial/serial_socket/Cargo.toml +++ b/vm/devices/serial/serial_socket/Cargo.toml @@ -17,6 +17,10 @@ pal_async.workspace = true futures.workspace = true socket2.workspace = true +[target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true +pal.workspace = true + [target.'cfg(windows)'.dependencies] pal.workspace = true tracing.workspace = true @@ -27,6 +31,7 @@ windows-sys.workspace = true [dev-dependencies] getrandom.workspace = true pal_async_test.workspace = true +test_with_tracing.workspace = true [lints] workspace = true diff --git a/vm/devices/serial/serial_socket/src/net.rs b/vm/devices/serial/serial_socket/src/net.rs index 095d2aca717..a98657684f1 100644 --- a/vm/devices/serial/serial_socket/src/net.rs +++ b/vm/devices/serial/serial_socket/src/net.rs @@ -12,6 +12,7 @@ use pal_async::driver::Driver; use pal_async::interest::PollEvents; use pal_async::socket::PollReady; use pal_async::socket::PolledSocket; +use serial_core::LocalPeerIdentity; use serial_core::SerialIo; use serial_core::resources::ResolveSerialBackendParams; use serial_core::resources::ResolvedSerialBackend; @@ -172,6 +173,35 @@ impl SerialIo for SocketSerialBackend { } Poll::Ready(Ok(())) } + + fn local_peer_identity(&self) -> io::Result> { + let Some(current) = &self.current else { + return Ok(None); + }; + local_peer_identity(current.get()) + } + + fn disconnect_current(&mut self) -> io::Result<()> { + self.current = None; + Ok(()) + } +} + +#[cfg(target_os = "linux")] +fn local_peer_identity(socket: &Socket) -> io::Result> { + use std::os::fd::AsFd; + + if socket.local_addr()?.family() as i32 != libc::AF_UNIX { + return Ok(None); + } + Ok(Some(LocalPeerIdentity::UnixUid( + pal::unix::credentials::unix_socket_peer_user_id(socket.as_fd())?, + ))) +} + +#[cfg(not(target_os = "linux"))] +fn local_peer_identity(_socket: &Socket) -> io::Result> { + Ok(None) } impl AsyncRead for SocketSerialBackend { @@ -229,3 +259,19 @@ impl AsyncWrite for SocketSerialBackend { Poll::Ready(r) } } + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn unix_socket_reports_same_uid_peer() { + let (left, _right) = + Socket::pair(socket2::Domain::UNIX, socket2::Type::STREAM, None).unwrap(); + assert_eq!( + local_peer_identity(&left).unwrap(), + Some(LocalPeerIdentity::UnixUid(pal::unix::effective_user_id())) + ); + } +} diff --git a/vm/devices/serial/serial_socket/src/windows.rs b/vm/devices/serial/serial_socket/src/windows.rs index 22c2cb1f89e..12683207280 100644 --- a/vm/devices/serial/serial_socket/src/windows.rs +++ b/vm/devices/serial/serial_socket/src/windows.rs @@ -13,6 +13,7 @@ use pal::windows::pipe::PipeExt; use pal_async::driver::Driver; use pal_async::pipe::PolledPipe; use pal_async::windows::pipe::ListeningPipe; +use serial_core::LocalPeerIdentity; use serial_core::SerialIo; use serial_core::resources::ResolveSerialBackendParams; use serial_core::resources::ResolvedSerialBackend; @@ -44,6 +45,8 @@ pub struct WindowsPipeSerialBackend { #[inspect(skip)] driver: Box, state: PipeState, + #[inspect(skip)] + peer_identity: Option, } enum PipeState { @@ -89,17 +92,28 @@ impl ResolveResource impl WindowsPipeSerialBackend { pub fn new(driver: Box, config: OpenWindowsPipeSerialConfig) -> io::Result { - let state = if let Some(file) = config.pipe { + let (state, peer_identity) = if let Some(file) = config.pipe { if file.is_pipe_connected()? { - PipeState::Connected(PolledPipe::new(&driver, file)?) + let peer_identity = Some(named_pipe_peer_identity(&file)?); + ( + PipeState::Connected(PolledPipe::new(&driver, file)?), + peer_identity, + ) } else { - PipeState::Listening(ListeningPipe::new(&driver, file)?) + ( + PipeState::Listening(ListeningPipe::new(&driver, file)?), + None, + ) } } else { - PipeState::Done + (PipeState::Done, None) }; - Ok(Self { driver, state }) + Ok(Self { + driver, + state, + peer_identity, + }) } pub fn into_config(self) -> OpenWindowsPipeSerialConfig { @@ -116,6 +130,7 @@ impl WindowsPipeSerialBackend { return Ok(()); } if let PipeState::Connected(pipe) = std::mem::replace(&mut self.state, PipeState::Done) { + self.peer_identity = None; let pipe = pipe.into_inner(); match pipe.disconnect_pipe() { Ok(()) => {} @@ -148,8 +163,10 @@ impl SerialIo for WindowsPipeSerialBackend { PipeState::Done => Poll::Pending, PipeState::Listening(accept) => { let file = ready!(accept.poll_unpin(cx)); + let file = file?; + self.peer_identity = Some(named_pipe_peer_identity(&file)?); self.state = PipeState::Done; - self.state = PipeState::Connected(PolledPipe::new(&self.driver, file?)?); + self.state = PipeState::Connected(PolledPipe::new(&self.driver, file)?); Poll::Ready(Ok(())) } PipeState::Connected(_) => Poll::Ready(Ok(())), @@ -171,6 +188,21 @@ impl SerialIo for WindowsPipeSerialBackend { } } } + + fn disconnect_current(&mut self) -> io::Result<()> { + self.disconnect() + } + + fn local_peer_identity(&self) -> io::Result> { + Ok(self.peer_identity.clone()) + } +} + +fn named_pipe_peer_identity(pipe: &File) -> io::Result { + let process_id = pal::windows::pipe::peer::client_process_id(pipe)?; + let sid = pal::windows::security::user_sid::process_user_sid(process_id)?; + let (bytes, length) = sid.to_fixed_bytes(); + LocalPeerIdentity::windows_sid(bytes, length) } impl AsyncRead for WindowsPipeSerialBackend { @@ -246,3 +278,18 @@ impl AsyncWrite for WindowsPipeSerialBackend { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn named_pipe_reports_the_current_process_identity() { + let (server, _client) = pal::windows::pipe::bidirectional_pair(false).unwrap(); + let actual = named_pipe_peer_identity(&server).unwrap(); + let sid = pal::windows::security::user_sid::current_process_user_sid().unwrap(); + let (bytes, length) = sid.to_fixed_bytes(); + let expected = LocalPeerIdentity::windows_sid(bytes, length).unwrap(); + assert_eq!(actual, expected); + } +} diff --git a/vm/devices/serial/serial_socket/src/windows_tests.rs b/vm/devices/serial/serial_socket/src/windows_tests.rs index 90691e214bf..509ca3083b9 100644 --- a/vm/devices/serial/serial_socket/src/windows_tests.rs +++ b/vm/devices/serial/serial_socket/src/windows_tests.rs @@ -43,6 +43,7 @@ async fn reconnects_after_client_close(driver: DefaultDriver) { poll_fn(|cx| backend.poll_connect(cx)).await.unwrap(); drop(client); poll_fn(|cx| backend.poll_disconnect(cx)).await.unwrap(); + backend.disconnect_current().unwrap(); let _replacement = OpenOptions::new() .read(true) From d671447d386b5c73553583d6484c194da9259073 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:03:56 -0700 Subject: [PATCH 143/304] virtio_console: define the control-session record protocol The microVM control console will carry an authenticated session between a host client and the guest through OpenVMM. Both ends must agree on a framing that survives fragmentation, rejects a desynchronized stream instead of guessing, and lets OpenVMM account for every byte it buffers. Define that framing as a crate-private protocol module, frozen at version 1, so that the broker in the following commits and the NVX guest share one specification. Every record has a 44-byte little-endian header with the `NVXS` magic, the version, the record type, zero flags, a 16-byte broker instance ID, an epoch, a direction-local sequence, and the payload length. The nine record types are `GUEST_ATTACH`, `HOST_ATTACH` (exactly 32 capability bytes), `RESET`, `ACK` (initial receive window), `DATA` (1 through 65,536 bytes), `WAIT`, `READY`, `ERROR` (only the authentication error code), and `CREDIT` (a receive-credit increment). The attach records are bootstrap records and must carry zero session identity. The module provides: - `Record`, whose `Debug` output redacts the capability bytes. - `encode` and `decode_exact` for complete records. - `Parser`, an incremental parser that accepts bytes up to the end of one record and leaves any further bytes with the caller. It validates the header before allocating the body, so a hostile length cannot make it allocate more than the largest valid payload, and it does not resynchronize after a bad magic. - `ParserSnapshot`, from which a parser can be saved and rebuilt, with validation of a restored snapshot. - Receive-credit payload helpers bounded to 65,536 through 4,194,314 bytes. The Guide gains a protocol reference covering the header, the record types, credit rules, disconnect and restore behavior, and buffer accounting. Language-neutral golden vectors and boundary cases live in `test_data`, so other implementations can check against the same data. Nothing consumes the module yet. An `expect(dead_code)` on its declaration, limited to non-test builds, keeps the build clean until the broker in the next commit uses it. Tests check the golden vectors round-trip, the boundary and invalid cases, fragmentation at every byte, coalesced records stopping at the record boundary, payload-length limits, rejection of malformed headers before body allocation, no resynchronization after bad magic, payload and error-code rules, capability redaction, and invalid parser snapshots. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/SUMMARY.md | 1 + .../management/control_session_protocol.md | 86 ++ .../src/control_session_protocol.rs | 943 ++++++++++++++++++ vm/devices/virtio/virtio_console/src/lib.rs | 5 + .../control_session_protocol_cases.txt | 37 + .../control_session_protocol_vectors.txt | 9 + 6 files changed, 1081 insertions(+) create mode 100644 Guide/src/reference/openvmm/management/control_session_protocol.md create mode 100644 vm/devices/virtio/virtio_console/src/control_session_protocol.rs create mode 100644 vm/devices/virtio/virtio_console/test_data/control_session_protocol_cases.txt create mode 100644 vm/devices/virtio/virtio_console/test_data/control_session_protocol_vectors.txt diff --git a/Guide/src/SUMMARY.md b/Guide/src/SUMMARY.md index f16d50a088b..d4f602bc8c6 100644 --- a/Guide/src/SUMMARY.md +++ b/Guide/src/SUMMARY.md @@ -88,6 +88,7 @@ - [OpenVMM Features](./reference/openvmm.md) - [Configuration and Management](./reference/openvmm/management.md) - [CLI](./reference/openvmm/management/cli.md) + - [Control-session Protocol](./reference/openvmm/management/control_session_protocol.md) - [Interactive Console](./reference/openvmm/management/interactive_console.md) - [gRPC / ttrpc](./reference/openvmm/management/grpc.md) - [Graphical Console](./reference/openvmm/graphical_console.md) diff --git a/Guide/src/reference/openvmm/management/control_session_protocol.md b/Guide/src/reference/openvmm/management/control_session_protocol.md new file mode 100644 index 00000000000..289b182158a --- /dev/null +++ b/Guide/src/reference/openvmm/management/control_session_protocol.md @@ -0,0 +1,86 @@ +# Control-session protocol + +The microVM control console uses an aligned record stream between the guest, +OpenVMM's broker, and the authenticated host endpoint. The protocol is frozen +at version 1. + +## Header + +Every record has a 44-byte little-endian header: + +| Offset | Size | Field | +| --- | ---: | --- | +| 0 | 4 | ASCII magic `NVXS` | +| 4 | 2 | protocol version | +| 6 | 1 | record type | +| 7 | 1 | flags, currently zero | +| 8 | 16 | broker instance ID | +| 24 | 8 | epoch | +| 32 | 8 | direction-local sequence | +| 40 | 4 | payload length | + +`GUEST_ATTACH` and `HOST_ATTACH` are bootstrap records and use zero instance, +epoch, and sequence fields. Post-bootstrap records use the current instance +and epoch. + +| Value | Record | Payload | +| ---: | --- | --- | +| 1 | `GUEST_ATTACH` | empty | +| 2 | `HOST_ATTACH` | exactly 32 capability bytes | +| 3 | `RESET` | empty | +| 4 | `ACK` | four-byte little-endian initial receive window | +| 5 | `DATA` | 1 through 65,536 opaque bytes | +| 6 | `WAIT` | empty | +| 7 | `READY` | empty | +| 8 | `ERROR` | four-byte little-endian error code | +| 9 | `CREDIT` | four-byte little-endian receive-credit increment | + +The sole valid `ERROR` code is `1` (`Authentication`). ACK and CREDIT carry +receive-credit values: + +* ACK contains one four-byte little-endian initial receive window. +* CREDIT contains one four-byte little-endian increment. +* Credit counts DATA payload bytes, not record headers. +* The initial window is from 65,536 through 4,194,314 bytes. +* A CREDIT increment is nonzero and cannot make available credit exceed the + initial window. + +The guest sends ACK only after the replacement consumer and its bounded ingress +state are ready. ACK, CREDIT, and guest DATA share the ordered guest-to-broker +sequence. CREDIT is generation-scoped by the instance and epoch fields and is +not forwarded to the host. + +The broker reserves credit for an entire DATA payload before enqueueing any +part of that record toward the guest. A record that has started physical +transmission remains prepaid and finishes to preserve stream alignment. +Unstarted DATA from a retired epoch is discarded. + +When credit is exhausted, one complete host record may remain in the broker's +bounded pending slot. This is ordinary backpressure, not a protocol failure. +Transport disconnect polling, RESET generation, and control-record output do +not require DATA credit. + +On an active host disconnect, the broker advances the epoch, clears usable +credit, drops pending and unstarted DATA, finishes any physically started +record, and then emits RESET. The replacement epoch receives fresh credit only +from its ACK. Device reset and snapshot restore follow the same no-credit-reuse +rule. Broker saved-state schema 2 contains no usable receive credit; a restored +instance obtains a fresh window from its new ACK. + +Malformed lengths or credit values, wrong-state records, wrong instance or +epoch values, and duplicate or out-of-order sequences fail closed. Existing +peer-identity and capability authentication happen before host DATA is +accepted. + +## Buffer accounting + +The advertised credit window bounds bytes charged to guest ingress, including +bytes in transport, parsing, or the guest's pending ingress buffer until the +guest releases them. Implementations may account a fixed inner duplex +separately; the NVX guest uses a 128-KiB inner duplex. Credit must be returned +only when charged bytes leave the bounded ingress buffer, not merely when the +outer record is read. + +OpenVMM separately bounds each output queue to 16 queued records and 1 MiB, +plus one current record. Its host input uses one bounded parser, one pending +record, and a 4-KiB transport slab. diff --git a/vm/devices/virtio/virtio_console/src/control_session_protocol.rs b/vm/devices/virtio/virtio_console/src/control_session_protocol.rs new file mode 100644 index 00000000000..d04ab004cf3 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/control_session_protocol.rs @@ -0,0 +1,943 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Frozen outer protocol for the control-session broker. + +use core::fmt; +use thiserror::Error; + +pub const HEADER_LEN: usize = 44; +pub const MAX_DATA_LEN: usize = 65_536; +pub const MIN_RECEIVE_CREDIT: u32 = MAX_DATA_LEN as u32; +pub const MAX_RECEIVE_CREDIT: u32 = 4 * 1024 * 1024 + 10; +const MAGIC: [u8; 4] = *b"NVXS"; +const VERSION: u16 = 1; + +/// A control-session record type. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum RecordType { + GuestAttach = 1, + HostAttach = 2, + Reset = 3, + Ack = 4, + Data = 5, + Wait = 6, + Ready = 7, + Error = 8, + Credit = 9, +} + +impl TryFrom for RecordType { + type Error = ProtocolError; + + fn try_from(value: u8) -> Result { + match value { + 1 => Ok(Self::GuestAttach), + 2 => Ok(Self::HostAttach), + 3 => Ok(Self::Reset), + 4 => Ok(Self::Ack), + 5 => Ok(Self::Data), + 6 => Ok(Self::Wait), + 7 => Ok(Self::Ready), + 8 => Ok(Self::Error), + 9 => Ok(Self::Credit), + _ => Err(ProtocolError::InvalidType(value)), + } + } +} + +/// A protocol-v1 Error record code. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u32)] +pub enum ErrorCode { + /// The host failed capability authentication. + Authentication = 1, +} + +impl TryFrom for ErrorCode { + type Error = ProtocolError; + + fn try_from(value: u32) -> Result { + match value { + 1 => Ok(Self::Authentication), + _ => Err(ProtocolError::InvalidErrorCode(value)), + } + } +} + +/// A validated protocol-v1 record. +#[derive(Clone, Eq, PartialEq)] +pub struct Record { + pub record_type: RecordType, + pub instance_id: [u8; 16], + pub epoch: u64, + pub sequence: u64, + pub payload: Vec, +} + +impl fmt::Debug for Record { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Record") + .field("record_type", &self.record_type) + .field("instance_id", &self.instance_id) + .field("epoch", &self.epoch) + .field("sequence", &self.sequence) + .field("payload_len", &self.payload.len()) + .finish() + } +} + +impl Record { + /// Creates a bootstrap record. Bootstrap records never consume a sequence. + #[cfg_attr(not(test), expect(dead_code, reason = "used by protocol tests"))] + pub fn bootstrap(record_type: RecordType, payload: Vec) -> Self { + Self { + record_type, + instance_id: [0; 16], + epoch: 0, + sequence: 0, + payload, + } + } + + /// Creates a post-bootstrap record. + pub fn session( + record_type: RecordType, + instance_id: [u8; 16], + epoch: u64, + sequence: u64, + payload: Vec, + ) -> Self { + Self { + record_type, + instance_id, + epoch, + sequence, + payload, + } + } +} + +/// Protocol decoding and validation failures. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum ProtocolError { + #[error("invalid control-session magic")] + InvalidMagic, + #[error("unsupported control-session version {0}")] + InvalidVersion(u16), + #[error("unknown control-session record type {0}")] + InvalidType(u8), + #[error("control-session flags must be zero, got {0}")] + InvalidFlags(u8), + #[error("invalid payload length {length} for {record_type:?}")] + InvalidPayloadLength { + record_type: RecordType, + length: u32, + }, + #[error("invalid control-session error code {0}")] + InvalidErrorCode(u32), + #[error("invalid control-session receive credit {0}")] + InvalidReceiveCredit(u32), + #[error("bootstrap record contains session identity")] + InvalidBootstrapIdentity, + #[error("invalid parser snapshot: {0}")] + InvalidSnapshot(&'static str), + #[error("encoded record length overflows the platform size")] + LengthOverflow, +} + +/// Serializable-neutral state for an incremental parser. +#[derive(Clone, Eq, PartialEq)] +pub struct ParserSnapshot { + pub header_bytes: Vec, + pub header_count: usize, + pub body_bytes: Vec, + pub declared_body_len: Option, +} + +impl fmt::Debug for ParserSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ParserSnapshot") + .field("header_count", &self.header_count) + .field("body_len", &self.body_bytes.len()) + .field("declared_body_len", &self.declared_body_len) + .finish() + } +} + +/// The result of accepting bytes into a parser. +#[derive(Debug, Eq, PartialEq)] +pub struct ParseProgress { + pub consumed: usize, + pub record: Option, +} + +#[derive(Clone, Copy)] +struct ParsedHeader { + record_type: RecordType, + instance_id: [u8; 16], + epoch: u64, + sequence: u64, + payload_len: usize, +} + +/// An aligned, bounded incremental protocol parser. +#[derive(Clone)] +pub struct Parser { + header: [u8; HEADER_LEN], + header_count: usize, + body: Vec, + declared_body_len: Option, +} + +impl fmt::Debug for Parser { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Parser") + .field("header_count", &self.header_count) + .field("body_len", &self.body.len()) + .field("declared_body_len", &self.declared_body_len) + .finish() + } +} + +impl Default for Parser { + fn default() -> Self { + Self::new() + } +} + +impl Parser { + pub fn new() -> Self { + Self { + header: [0; HEADER_LEN], + header_count: 0, + body: Vec::new(), + declared_body_len: None, + } + } + + /// Accepts bytes up to and including one complete record. + /// + /// Any bytes after the returned record remain unconsumed with the caller. + pub fn accept(&mut self, input: &[u8]) -> Result { + let mut consumed = 0; + if self.header_count < HEADER_LEN { + let take = (HEADER_LEN - self.header_count).min(input.len()); + self.header[self.header_count..self.header_count + take] + .copy_from_slice(&input[..take]); + self.header_count += take; + consumed += take; + if self.header_count < HEADER_LEN { + return Ok(ParseProgress { + consumed, + record: None, + }); + } + + let header = parse_header(&self.header)?; + self.declared_body_len = Some(header.payload_len); + if header.payload_len == 0 { + let record = record_from_parts(header, Vec::new())?; + self.reset(); + return Ok(ParseProgress { + consumed, + record: Some(record), + }); + } + self.body = Vec::with_capacity(header.payload_len); + } + + let declared = self + .declared_body_len + .ok_or(ProtocolError::InvalidSnapshot( + "missing declared body length", + ))?; + let remaining = + declared + .checked_sub(self.body.len()) + .ok_or(ProtocolError::InvalidSnapshot( + "body exceeds declared length", + ))?; + let available = &input[consumed..]; + let take = remaining.min(available.len()); + self.body.extend_from_slice(&available[..take]); + consumed += take; + if self.body.len() != declared { + return Ok(ParseProgress { + consumed, + record: None, + }); + } + + let header = parse_header(&self.header)?; + let body = core::mem::take(&mut self.body); + let record = record_from_parts(header, body)?; + self.reset(); + Ok(ParseProgress { + consumed, + record: Some(record), + }) + } + + pub fn snapshot(&self) -> ParserSnapshot { + ParserSnapshot { + header_bytes: self.header.to_vec(), + header_count: self.header_count, + body_bytes: self.body.clone(), + declared_body_len: self.declared_body_len.map(|len| len as u32), + } + } + + pub fn validate_snapshot(snapshot: &ParserSnapshot) -> Result<(), ProtocolError> { + if snapshot.header_bytes.len() != HEADER_LEN { + return Err(ProtocolError::InvalidSnapshot( + "header storage must be exactly 44 bytes", + )); + } + if snapshot.header_count > HEADER_LEN { + return Err(ProtocolError::InvalidSnapshot( + "header count exceeds header length", + )); + } + if snapshot.header_count < HEADER_LEN { + if snapshot.declared_body_len.is_some() || !snapshot.body_bytes.is_empty() { + return Err(ProtocolError::InvalidSnapshot( + "partial header has body state", + )); + } + } else { + let mut header = [0; HEADER_LEN]; + header.copy_from_slice(&snapshot.header_bytes); + let parsed = parse_header(&header)?; + let declared = snapshot + .declared_body_len + .ok_or(ProtocolError::InvalidSnapshot( + "complete header has no declared body length", + ))?; + if declared as usize != parsed.payload_len { + return Err(ProtocolError::InvalidSnapshot( + "declared body length disagrees with header", + )); + } + if parsed.payload_len == 0 || snapshot.body_bytes.len() >= parsed.payload_len { + return Err(ProtocolError::InvalidSnapshot( + "complete record cannot remain in parser state", + )); + } + } + Ok(()) + } + + /// Restores parser state after validating all bounds before allocation. + pub fn restore(snapshot: ParserSnapshot) -> Result { + Self::validate_snapshot(&snapshot)?; + + let mut header = [0; HEADER_LEN]; + header.copy_from_slice(&snapshot.header_bytes); + let body_capacity = snapshot.declared_body_len.unwrap_or(0) as usize; + let mut body = Vec::with_capacity(body_capacity); + body.extend_from_slice(&snapshot.body_bytes); + Ok(Self { + header, + header_count: snapshot.header_count, + body, + declared_body_len: snapshot.declared_body_len.map(|len| len as usize), + }) + } + + pub fn is_aligned(&self) -> bool { + self.header_count == 0 + } + + fn reset(&mut self) { + self.header = [0; HEADER_LEN]; + self.header_count = 0; + self.body.clear(); + self.declared_body_len = None; + } +} + +/// Encodes a validated record using the frozen 44-byte header. +pub fn encode(record: &Record) -> Result, ProtocolError> { + validate_record(record)?; + let payload_len = + u32::try_from(record.payload.len()).map_err(|_| ProtocolError::LengthOverflow)?; + let total_len = HEADER_LEN + .checked_add(record.payload.len()) + .ok_or(ProtocolError::LengthOverflow)?; + let mut bytes = Vec::with_capacity(total_len); + bytes.extend_from_slice(&MAGIC); + bytes.extend_from_slice(&VERSION.to_le_bytes()); + bytes.push(record.record_type as u8); + bytes.push(0); + bytes.extend_from_slice(&record.instance_id); + bytes.extend_from_slice(&record.epoch.to_le_bytes()); + bytes.extend_from_slice(&record.sequence.to_le_bytes()); + bytes.extend_from_slice(&payload_len.to_le_bytes()); + bytes.extend_from_slice(&record.payload); + Ok(bytes) +} + +/// Decodes exactly one complete record. +pub fn decode_exact(bytes: &[u8]) -> Result { + let mut parser = Parser::new(); + let progress = parser.accept(bytes)?; + if progress.consumed != bytes.len() || !parser.is_aligned() { + return Err(ProtocolError::InvalidSnapshot( + "encoded record is truncated or contains trailing bytes", + )); + } + progress.record.ok_or(ProtocolError::InvalidSnapshot( + "encoded record is incomplete", + )) +} + +fn parse_header(bytes: &[u8; HEADER_LEN]) -> Result { + if bytes[..4] != MAGIC { + return Err(ProtocolError::InvalidMagic); + } + let version = u16::from_le_bytes([bytes[4], bytes[5]]); + if version != VERSION { + return Err(ProtocolError::InvalidVersion(version)); + } + let record_type = RecordType::try_from(bytes[6])?; + if bytes[7] != 0 { + return Err(ProtocolError::InvalidFlags(bytes[7])); + } + let mut instance_id = [0; 16]; + instance_id.copy_from_slice(&bytes[8..24]); + let epoch = u64::from_le_bytes(bytes[24..32].try_into().map_err(|_| { + ProtocolError::InvalidSnapshot("epoch field has an invalid encoded length") + })?); + let sequence = u64::from_le_bytes(bytes[32..40].try_into().map_err(|_| { + ProtocolError::InvalidSnapshot("sequence field has an invalid encoded length") + })?); + let payload_len = u32::from_le_bytes(bytes[40..44].try_into().map_err(|_| { + ProtocolError::InvalidSnapshot("payload field has an invalid encoded length") + })?); + validate_payload_length(record_type, payload_len)?; + if matches!( + record_type, + RecordType::GuestAttach | RecordType::HostAttach + ) && (instance_id != [0; 16] || epoch != 0 || sequence != 0) + { + return Err(ProtocolError::InvalidBootstrapIdentity); + } + Ok(ParsedHeader { + record_type, + instance_id, + epoch, + sequence, + payload_len: payload_len as usize, + }) +} + +fn record_from_parts(header: ParsedHeader, payload: Vec) -> Result { + let record = Record { + record_type: header.record_type, + instance_id: header.instance_id, + epoch: header.epoch, + sequence: header.sequence, + payload, + }; + validate_record(&record)?; + Ok(record) +} + +fn validate_record(record: &Record) -> Result<(), ProtocolError> { + let payload_len = + u32::try_from(record.payload.len()).map_err(|_| ProtocolError::LengthOverflow)?; + validate_payload_length(record.record_type, payload_len)?; + if matches!( + record.record_type, + RecordType::GuestAttach | RecordType::HostAttach + ) && (record.instance_id != [0; 16] || record.epoch != 0 || record.sequence != 0) + { + return Err(ProtocolError::InvalidBootstrapIdentity); + } + if record.record_type == RecordType::Error { + let code_bytes: [u8; 4] = record.payload.as_slice().try_into().map_err(|_| { + ProtocolError::InvalidPayloadLength { + record_type: RecordType::Error, + length: payload_len, + } + })?; + ErrorCode::try_from(u32::from_le_bytes(code_bytes))?; + } + match record.record_type { + RecordType::Ack => { + let credit = decode_credit_payload(&record.payload)?; + if !(MIN_RECEIVE_CREDIT..=MAX_RECEIVE_CREDIT).contains(&credit) { + return Err(ProtocolError::InvalidReceiveCredit(credit)); + } + } + RecordType::Credit => { + let credit = decode_credit_payload(&record.payload)?; + if !(1..=MAX_RECEIVE_CREDIT).contains(&credit) { + return Err(ProtocolError::InvalidReceiveCredit(credit)); + } + } + _ => {} + } + Ok(()) +} + +fn validate_payload_length(record_type: RecordType, payload_len: u32) -> Result<(), ProtocolError> { + let valid = match record_type { + RecordType::Ack | RecordType::Credit => payload_len == 4, + RecordType::GuestAttach | RecordType::Reset | RecordType::Wait | RecordType::Ready => { + payload_len == 0 + } + RecordType::HostAttach => payload_len == 32, + RecordType::Data => (1..=MAX_DATA_LEN as u32).contains(&payload_len), + RecordType::Error => payload_len == 4, + }; + if valid { + Ok(()) + } else { + Err(ProtocolError::InvalidPayloadLength { + record_type, + length: payload_len, + }) + } +} + +#[cfg_attr( + not(test), + expect(dead_code, reason = "used by broker tests and guest codec") +)] +pub fn credit_payload(credit: u32) -> Vec { + credit.to_le_bytes().to_vec() +} + +pub fn decode_credit_payload(payload: &[u8]) -> Result { + let bytes: [u8; 4] = payload + .try_into() + .map_err(|_| ProtocolError::InvalidPayloadLength { + record_type: RecordType::Credit, + length: u32::try_from(payload.len()).unwrap_or(u32::MAX), + })?; + Ok(u32::from_le_bytes(bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + fn sample(record_type: RecordType, payload: Vec) -> Record { + if matches!( + record_type, + RecordType::GuestAttach | RecordType::HostAttach + ) { + Record::bootstrap(record_type, payload) + } else { + Record::session(record_type, [0x11; 16], 7, 9, payload) + } + } + + fn decode_hex(hex: &str) -> Result, String> { + if !hex.len().is_multiple_of(2) { + return Err("odd hex length".into()); + } + hex.as_bytes() + .chunks_exact(2) + .map(|pair| { + let text = core::str::from_utf8(pair).map_err(|error| error.to_string())?; + u8::from_str_radix(text, 16).map_err(|error| error.to_string()) + }) + .collect() + } + + #[test] + fn golden_vectors_round_trip() -> Result<(), Box> { + let vectors = include_str!("../test_data/control_session_protocol_vectors.txt"); + let mut count = 0; + for line in vectors.lines().filter(|line| !line.is_empty()) { + let (name, hex) = line + .split_once('|') + .ok_or_else(|| format!("invalid vector line: {line}"))?; + let bytes = decode_hex(hex)?; + let record = decode_exact(&bytes)?; + let expected_type = match name { + "GUEST_ATTACH" => RecordType::GuestAttach, + "HOST_ATTACH" => RecordType::HostAttach, + "RESET" => RecordType::Reset, + "ACK_INITIAL_CREDIT" => RecordType::Ack, + "CREDIT" => RecordType::Credit, + "DATA_EMBEDDED_MAGIC" => RecordType::Data, + "WAIT" => RecordType::Wait, + "READY" => RecordType::Ready, + "ERROR_AUTHENTICATION" => RecordType::Error, + _ => return Err(format!("unknown vector name: {name}").into()), + }; + assert_eq!(record.record_type, expected_type, "{name}"); + if matches!( + expected_type, + RecordType::GuestAttach | RecordType::HostAttach + ) { + assert_eq!(record.instance_id, [0; 16], "{name}"); + assert_eq!((record.epoch, record.sequence), (0, 0), "{name}"); + } else { + assert_eq!( + record.instance_id, + core::array::from_fn(|index| index as u8), + "{name}" + ); + assert_eq!((record.epoch, record.sequence), (7, 9), "{name}"); + } + if expected_type == RecordType::HostAttach { + assert_eq!( + record.payload, + (0..32).map(|value| value as u8).collect::>() + ); + } + if expected_type == RecordType::Data { + assert_eq!(record.payload, b"NVXSNVXS"); + } + if expected_type == RecordType::Error { + let code = u32::from_le_bytes(record.payload.as_slice().try_into()?); + assert_eq!(ErrorCode::try_from(code)?, ErrorCode::Authentication); + } + if expected_type == RecordType::Ack { + assert_eq!(decode_credit_payload(&record.payload)?, MAX_RECEIVE_CREDIT); + } + if expected_type == RecordType::Credit { + assert_eq!(decode_credit_payload(&record.payload)?, MIN_RECEIVE_CREDIT); + } + assert_eq!(encode(&record)?, bytes, "{name}"); + count += 1; + } + assert_eq!(count, 9); + assert!(vectors.contains("4e5658534e565853")); + Ok(()) + } + + #[test] + fn language_neutral_boundary_and_invalid_cases() -> Result<(), Box> { + const EXPECTED_CASE_NAMES: [&str; 37] = [ + "VALID_MIN", + "VALID_MAX", + "INVALID_EMPTY", + "INVALID_OVERSIZE", + "VALID_MIN", + "VALID_MAX", + "INVALID_BELOW_MIN", + "INVALID_ABOVE_MAX", + "VALID_MIN", + "VALID_MAX", + "INVALID_ZERO", + "INVALID_ABOVE_MAX", + "INVALID_MAGIC", + "INVALID_VERSION", + "INVALID_TYPE", + "INVALID_FLAGS", + "INVALID_DATA_LENGTH", + "AUTHENTICATION", + "INVALID_GUEST_ATTACH_PAYLOAD", + "INVALID_HOST_ATTACH_SHORT", + "INVALID_HOST_ATTACH_LONG", + "INVALID_RESET_PAYLOAD", + "INVALID_ACK_PAYLOAD", + "INVALID_WAIT_PAYLOAD", + "INVALID_READY_PAYLOAD", + "INVALID_ERROR_SHORT", + "INVALID_ERROR_LONG", + "INVALID_GUEST_ATTACH_INSTANCE", + "INVALID_HOST_ATTACH_EPOCH", + "INVALID_HOST_ATTACH_SEQUENCE", + "INVALID_ERROR_CODE_0", + "INVALID_ERROR_CODE_2", + "INVALID_ERROR_CODE_3", + "INVALID_ERROR_CODE_4", + "INVALID_ERROR_CODE_5", + "INVALID_ERROR_CODE_6", + "INVALID_ERROR_CODE_MAX", + ]; + + let cases = include_str!("../test_data/control_session_protocol_cases.txt"); + let instance_id = core::array::from_fn(|index| index as u8); + let mut names = Vec::new(); + for line in cases.lines().filter(|line| !line.is_empty()) { + let fields = line.split('|').collect::>(); + match fields.as_slice() { + ["DATA_LENGTH", name, length] => { + names.push(*name); + let length = length.parse::()?; + let result = encode(&Record::session( + RecordType::Data, + instance_id, + 7, + 9, + vec![0x5a; length], + )); + match *name { + "VALID_MIN" | "VALID_MAX" => assert!(result.is_ok(), "{name}"), + "INVALID_EMPTY" => assert!(matches!( + result, + Err(ProtocolError::InvalidPayloadLength { length: 0, .. }) + )), + "INVALID_OVERSIZE" => assert!(matches!( + result, + Err(ProtocolError::InvalidPayloadLength { length: 65_537, .. }) + )), + _ => return Err(format!("unknown data-length case: {name}").into()), + } + } + ["INVALID_HEADER", name, hex] => { + names.push(*name); + let bytes = decode_hex(hex)?; + assert_eq!(bytes.len(), HEADER_LEN, "{name}"); + let mut parser = Parser::new(); + let error = parser.accept(&bytes).expect_err(name); + let intended = matches!( + (*name, &error), + ("INVALID_MAGIC", ProtocolError::InvalidMagic) + | ("INVALID_VERSION", ProtocolError::InvalidVersion(2)) + | ("INVALID_TYPE", ProtocolError::InvalidType(99)) + | ("INVALID_FLAGS", ProtocolError::InvalidFlags(1)) + | ( + "INVALID_DATA_LENGTH", + ProtocolError::InvalidPayloadLength { + record_type: RecordType::Data, + length: 65_537, + }, + ) + ); + assert!(intended, "{name}: unexpected error {error:?}"); + } + ["ERROR_CODE", name, value] => { + names.push(*name); + let value = value.parse::()?; + match *name { + "AUTHENTICATION" => { + assert_eq!(ErrorCode::try_from(value)?, ErrorCode::Authentication); + } + _ => return Err(format!("unknown error-code mapping: {name}").into()), + } + } + ["INVALID_RECORD", name, expected_error, hex] => { + names.push(*name); + let bytes = decode_hex(hex)?; + let error = decode_exact(&bytes).expect_err(name); + let intended = matches!( + (*expected_error, &error), + ( + "INVALID_PAYLOAD_LENGTH", + ProtocolError::InvalidPayloadLength { .. }, + ) | ( + "INVALID_BOOTSTRAP_IDENTITY", + ProtocolError::InvalidBootstrapIdentity, + ) | ("INVALID_ERROR_CODE", ProtocolError::InvalidErrorCode(_)) + ); + assert!(intended, "{name}: unexpected error {error:?}"); + } + ["ACK_CREDIT", name, value] => { + names.push(*name); + let value = value.parse::()?; + let result = encode(&Record::session( + RecordType::Ack, + instance_id, + 7, + 9, + credit_payload(value), + )); + assert_eq!(result.is_ok(), name.starts_with("VALID_"), "{name}"); + } + ["CREDIT_INCREMENT", name, value] => { + names.push(*name); + let value = value.parse::()?; + let result = encode(&Record::session( + RecordType::Credit, + instance_id, + 7, + 9, + credit_payload(value), + )); + assert_eq!(result.is_ok(), name.starts_with("VALID_"), "{name}"); + } + _ => return Err(format!("invalid language-neutral case: {line}").into()), + } + } + assert_eq!(names, EXPECTED_CASE_NAMES); + Ok(()) + } + + #[test] + fn every_byte_fragmentation_preserves_state() -> Result<(), ProtocolError> { + for record in [ + sample(RecordType::Ack, credit_payload(MIN_RECEIVE_CREDIT)), + sample(RecordType::Credit, credit_payload(17)), + sample(RecordType::Data, vec![0x33; 257]), + ] { + let encoded = encode(&record)?; + for split in 0..encoded.len() { + let mut parser = Parser::new(); + let first = parser.accept(&encoded[..split])?; + assert!(first.record.is_none()); + let restored = Parser::restore(parser.snapshot())?; + parser = restored; + let second = parser.accept(&encoded[split..])?; + assert_eq!(second.record, Some(record.clone())); + } + } + Ok(()) + } + + #[test] + fn coalesced_records_stop_at_exact_boundary() -> Result<(), ProtocolError> { + let first = sample(RecordType::Credit, credit_payload(17)); + let second = sample(RecordType::Data, b"xNVXSy".to_vec()); + let mut bytes = encode(&first)?; + bytes.extend_from_slice(&encode(&second)?); + let mut parser = Parser::new(); + let progress = parser.accept(&bytes)?; + assert_eq!(progress.record, Some(first)); + assert_eq!(progress.consumed, HEADER_LEN + 4); + let progress = parser.accept(&bytes[progress.consumed..])?; + assert_eq!(progress.record, Some(second)); + Ok(()) + } + + #[test] + fn payload_boundaries() -> Result<(), ProtocolError> { + assert!(encode(&sample(RecordType::Data, vec![1])).is_ok()); + assert!(encode(&sample(RecordType::Data, vec![1; MAX_DATA_LEN])).is_ok()); + assert!(matches!( + encode(&sample(RecordType::Data, Vec::new())), + Err(ProtocolError::InvalidPayloadLength { length: 0, .. }) + )); + assert!(matches!( + encode(&sample(RecordType::Data, vec![1; MAX_DATA_LEN + 1])), + Err(ProtocolError::InvalidPayloadLength { length: 65_537, .. }) + )); + Ok(()) + } + + #[test] + fn malformed_headers_are_rejected_before_body_allocation() -> Result<(), ProtocolError> { + let valid = encode(&sample(RecordType::Data, vec![1]))?; + for (offset, value) in [(0, b'X'), (4, 2), (6, 99), (7, 1)] { + let mut malformed = valid.clone(); + malformed[offset] = value; + let mut parser = Parser::new(); + assert!(parser.accept(&malformed[..HEADER_LEN]).is_err()); + assert!(parser.body.capacity() <= MAX_DATA_LEN); + } + let mut oversized = valid; + oversized[40..44].copy_from_slice(&65_537u32.to_le_bytes()); + let mut parser = Parser::new(); + assert!(matches!( + parser.accept(&oversized[..HEADER_LEN]), + Err(ProtocolError::InvalidPayloadLength { length: 65_537, .. }) + )); + assert_eq!(parser.body.capacity(), 0); + Ok(()) + } + + #[test] + fn malformed_magic_is_not_resynchronized() -> Result<(), ProtocolError> { + let valid = encode(&sample(RecordType::Data, b"NVXS".to_vec()))?; + let mut malformed = vec![0; HEADER_LEN]; + malformed.extend_from_slice(&valid); + let mut parser = Parser::new(); + assert_eq!(parser.accept(&malformed), Err(ProtocolError::InvalidMagic)); + Ok(()) + } + + #[test] + fn payload_rules_and_error_codes_are_enforced() { + assert!(encode(&sample(RecordType::GuestAttach, vec![1])).is_err()); + assert!(encode(&sample(RecordType::HostAttach, vec![0; 31])).is_err()); + assert!(encode(&sample(RecordType::Reset, vec![1])).is_err()); + assert!(encode(&sample(RecordType::Ack, Vec::new())).is_err()); + assert!(encode(&sample(RecordType::Ack, credit_payload(MIN_RECEIVE_CREDIT))).is_ok()); + assert!(encode(&sample(RecordType::Wait, vec![1])).is_err()); + assert!(encode(&sample(RecordType::Ready, vec![1])).is_err()); + assert_eq!( + ErrorCode::try_from(1), + Ok(ErrorCode::Authentication), + "the frozen authentication code must remain stable" + ); + for code in [0, 2, 3, 4, 5, 6, u32::MAX] { + assert_eq!( + ErrorCode::try_from(code), + Err(ProtocolError::InvalidErrorCode(code)) + ); + assert!(encode(&sample(RecordType::Error, code.to_le_bytes().to_vec())).is_err()); + } + assert!(encode(&sample(RecordType::Credit, credit_payload(1))).is_ok()); + assert!(encode(&sample(RecordType::Credit, credit_payload(0))).is_err()); + } + + fn assert_capability_redacted(label: &str, debug: &str, capability: &[u8; 32]) { + assert!( + !debug.contains(&format!("{capability:?}")), + "{label} contains the complete capability: {debug}" + ); + for window in capability.windows(8) { + let recognizable_sequence = window + .iter() + .map(u8::to_string) + .collect::>() + .join(", "); + assert!( + !debug.contains(&recognizable_sequence), + "{label} contains capability bytes: {debug}" + ); + } + let marker = core::str::from_utf8(capability).unwrap(); + assert!( + !debug.contains(marker), + "{label} contains the capability marker: {debug}" + ); + } + + #[test] + fn debug_output_redacts_capability_storage_transitively() -> Result<(), ProtocolError> { + let capability = *b"NVX-CAPABILITY-SECRET-0123456789"; + let record = Record::bootstrap(RecordType::HostAttach, capability.to_vec()); + let encoded = encode(&record)?; + + let mut parser = Parser::new(); + let partial = parser.accept(&encoded[..HEADER_LEN + 16])?; + assert_eq!(partial.record, None); + let snapshot = parser.snapshot(); + + let mut complete_parser = Parser::new(); + let progress = complete_parser.accept(&encoded)?; + assert_eq!(progress.record, Some(record.clone())); + + let debug_outputs = [ + ("record", format!("{record:?}")), + ("partial parser", format!("{parser:?}")), + ("parser snapshot", format!("{snapshot:?}")), + ("parse progress", format!("{progress:?}")), + ]; + for (label, debug) in &debug_outputs { + assert_capability_redacted(label, debug, &capability); + } + + assert!(debug_outputs[0].1.contains("payload_len: 32")); + assert!(debug_outputs[1].1.contains("body_len: 16")); + assert!(debug_outputs[2].1.contains("body_len: 16")); + assert!(debug_outputs[3].1.contains("payload_len: 32")); + Ok(()) + } + + #[test] + fn invalid_parser_snapshots_are_rejected() { + let mut snapshot = Parser::new().snapshot(); + snapshot.header_count = HEADER_LEN + 1; + assert!(Parser::restore(snapshot).is_err()); + + let mut snapshot = Parser::new().snapshot(); + snapshot.body_bytes.push(1); + assert!(Parser::restore(snapshot).is_err()); + + let mut snapshot = Parser::new().snapshot(); + snapshot.header_bytes.pop(); + assert!(Parser::restore(snapshot).is_err()); + } +} diff --git a/vm/devices/virtio/virtio_console/src/lib.rs b/vm/devices/virtio/virtio_console/src/lib.rs index 59c330372ab..5b6dabc9ef4 100644 --- a/vm/devices/virtio/virtio_console/src/lib.rs +++ b/vm/devices/virtio/virtio_console/src/lib.rs @@ -34,6 +34,11 @@ #![forbid(unsafe_code)] +#[cfg_attr( + not(test), + expect(dead_code, reason = "the control-session broker consumes the protocol") +)] +pub(crate) mod control_session_protocol; mod direct; pub mod resolver; mod saved_state; diff --git a/vm/devices/virtio/virtio_console/test_data/control_session_protocol_cases.txt b/vm/devices/virtio/virtio_console/test_data/control_session_protocol_cases.txt new file mode 100644 index 00000000000..74ce5710898 --- /dev/null +++ b/vm/devices/virtio/virtio_console/test_data/control_session_protocol_cases.txt @@ -0,0 +1,37 @@ +DATA_LENGTH|VALID_MIN|1 +DATA_LENGTH|VALID_MAX|65536 +DATA_LENGTH|INVALID_EMPTY|0 +DATA_LENGTH|INVALID_OVERSIZE|65537 +ACK_CREDIT|VALID_MIN|65536 +ACK_CREDIT|VALID_MAX|4194314 +ACK_CREDIT|INVALID_BELOW_MIN|65535 +ACK_CREDIT|INVALID_ABOVE_MAX|4194315 +CREDIT_INCREMENT|VALID_MIN|1 +CREDIT_INCREMENT|VALID_MAX|4194314 +CREDIT_INCREMENT|INVALID_ZERO|0 +CREDIT_INCREMENT|INVALID_ABOVE_MAX|4194315 +INVALID_HEADER|INVALID_MAGIC|5856585301000500000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000 +INVALID_HEADER|INVALID_VERSION|4e56585302000500000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000 +INVALID_HEADER|INVALID_TYPE|4e56585301006300000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000 +INVALID_HEADER|INVALID_FLAGS|4e56585301000501000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000 +INVALID_HEADER|INVALID_DATA_LENGTH|4e56585301000500000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000100 +ERROR_CODE|AUTHENTICATION|1 +INVALID_RECORD|INVALID_GUEST_ATTACH_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000100000000000000000000000000000000000000000000000000000000000000000001000000a1 +INVALID_RECORD|INVALID_HOST_ATTACH_SHORT|INVALID_PAYLOAD_LENGTH|4e5658530100020000000000000000000000000000000000000000000000000000000000000000001f00000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_HOST_ATTACH_LONG|INVALID_PAYLOAD_LENGTH|4e56585301000200000000000000000000000000000000000000000000000000000000000000000021000000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_RESET_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000300000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000a2 +INVALID_RECORD|INVALID_ACK_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000400000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000a3 +INVALID_RECORD|INVALID_WAIT_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000600000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000a4 +INVALID_RECORD|INVALID_READY_PAYLOAD|INVALID_PAYLOAD_LENGTH|4e56585301000700000102030405060708090a0b0c0d0e0f0700000000000000090000000000000001000000a5 +INVALID_RECORD|INVALID_ERROR_SHORT|INVALID_PAYLOAD_LENGTH|4e56585301000800000102030405060708090a0b0c0d0e0f0700000000000000090000000000000003000000010000 +INVALID_RECORD|INVALID_ERROR_LONG|INVALID_PAYLOAD_LENGTH|4e56585301000800000102030405060708090a0b0c0d0e0f07000000000000000900000000000000050000000100000000 +INVALID_RECORD|INVALID_GUEST_ATTACH_INSTANCE|INVALID_BOOTSTRAP_IDENTITY|4e56585301000100010000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_HOST_ATTACH_EPOCH|INVALID_BOOTSTRAP_IDENTITY|4e565853010002000000000000000000000000000000000001000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_HOST_ATTACH_SEQUENCE|INVALID_BOOTSTRAP_IDENTITY|4e565853010002000000000000000000000000000000000000000000000000000100000000000000200000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_RECORD|INVALID_ERROR_CODE_0|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000000000000 +INVALID_RECORD|INVALID_ERROR_CODE_2|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000002000000 +INVALID_RECORD|INVALID_ERROR_CODE_3|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000003000000 +INVALID_RECORD|INVALID_ERROR_CODE_4|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000004000000 +INVALID_RECORD|INVALID_ERROR_CODE_5|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000005000000 +INVALID_RECORD|INVALID_ERROR_CODE_6|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000006000000 +INVALID_RECORD|INVALID_ERROR_CODE_MAX|INVALID_ERROR_CODE|4e56585301000800000102030405060708090a0b0c0d0e0f0700000000000000090000000000000004000000ffffffff diff --git a/vm/devices/virtio/virtio_console/test_data/control_session_protocol_vectors.txt b/vm/devices/virtio/virtio_console/test_data/control_session_protocol_vectors.txt new file mode 100644 index 00000000000..0edd125fb21 --- /dev/null +++ b/vm/devices/virtio/virtio_console/test_data/control_session_protocol_vectors.txt @@ -0,0 +1,9 @@ +GUEST_ATTACH|4e56585301000100000000000000000000000000000000000000000000000000000000000000000000000000 +HOST_ATTACH|4e56585301000200000000000000000000000000000000000000000000000000000000000000000020000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f +RESET|4e56585301000300000102030405060708090a0b0c0d0e0f0700000000000000090000000000000000000000 +ACK_INITIAL_CREDIT|4e56585301000400000102030405060708090a0b0c0d0e0f07000000000000000900000000000000040000000a004000 +CREDIT|4e56585301000900000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000000000100 +DATA_EMBEDDED_MAGIC|4e56585301000500000102030405060708090a0b0c0d0e0f07000000000000000900000000000000080000004e5658534e565853 +WAIT|4e56585301000600000102030405060708090a0b0c0d0e0f0700000000000000090000000000000000000000 +READY|4e56585301000700000102030405060708090a0b0c0d0e0f0700000000000000090000000000000000000000 +ERROR_AUTHENTICATION|4e56585301000800000102030405060708090a0b0c0d0e0f070000000000000009000000000000000400000001000000 From d3c37ceb459cd486f0a7bf03e34d55d29c6d1ffe Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:05:16 -0700 Subject: [PATCH 144/304] virtio_console: add the control-session broker state machine The control console places OpenVMM between the guest's control agent and one authenticated host client, using the record protocol from the previous commit. Add the broker that runs that session as a pure state machine. It performs no I/O: an adapter feeds it bytes from each side and writes out the records it produces, so every protocol decision can be tested without devices or sockets. A broker is created with an instance ID and a 32-byte host capability, and starts at epoch 1: - The guest attaches with `GUEST_ATTACH`. The broker queues `RESET` and, once that record has been completely written to the guest, waits for the guest's `ACK`, which carries the initial receive window. - A host reserves the single host slot only after the adapter has verified its operating-system identity, and must then present the capability in `HOST_ATTACH`. The capability is compared in constant time. A wrong capability is rejected without advancing the epoch, and the host can only be detached. - An authenticated host receives `WAIT` until the guest has acknowledged the session, then `READY`, and `DATA` then flows in both directions with an independent sequence per direction. The instance, epoch, and sequence of every record are checked, and stale, duplicate, or out-of-order records are rejected. - Host `DATA` toward the guest is gated by receive credit. A whole payload is reserved before any of it is queued, and the guest returns credit with `CREDIT` records, which cannot exceed the window. When credit or queue space runs out, one complete record waits in a pending slot and the input reports backpressure instead of losing data. - Losing an active host advances the epoch, drops credit, pending records, and output that has not started, lets a record that has partially reached the guest finish so the stream stays aligned, and then queues `RESET`. The next session gets credit only from a fresh `ACK`. A host that drops before it is active does not advance the epoch. Each output leg holds one record in flight plus at most 16 queued records and 1 MiB. The adapter moves bytes with `begin_output`, `peek_output`, and `advance_output`. Counters record protocol, authentication, sequence, acknowledgment, reset, reconnect, and backpressure errors for inspection, and epoch or sequence overflow puts the broker in a failed state. `BrokerSnapshot` records the broker for save and restore, without receive credit, queued records, or host output. Restore requires a new instance ID and keeps only the guest parser, the part of a guest record that was already written, and the counters. It starts at epoch 1 with `RESET` queued behind that partial record, and discards records that the guest still sends for the old instance until the guest acknowledges the new one. `reset_for_device` returns the broker to its initial state for a device reset. Snapshot validation checks every bound before anything is allocated, and the `Debug` output of a snapshot redacts the encoded records. The worker does not use the broker yet. An `expect(dead_code)` on the module declaration, limited to non-test builds, keeps the build clean until the next commit attaches it to serial I/O. The protocol module no longer needs its own expectation, because the broker uses it. Tests cover cold-boot authentication, a wrong capability, stale and duplicate acknowledgments, `DATA` before a host is ready, disconnects at every point of a record, rapid reconnects, queue and credit limits, invalid credit, backpressure in both directions, streaming a maximal ttrpc message under bounded credit, epoch and sequence overflow, restore alignment and fresh credit, invalid snapshots, snapshot redaction, repeated restores, and device reset. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../src/control_session_broker.rs | 2288 +++++++++++++++++ vm/devices/virtio/virtio_console/src/lib.rs | 3 +- 2 files changed, 2290 insertions(+), 1 deletion(-) create mode 100644 vm/devices/virtio/virtio_console/src/control_session_broker.rs diff --git a/vm/devices/virtio/virtio_console/src/control_session_broker.rs b/vm/devices/virtio/virtio_console/src/control_session_broker.rs new file mode 100644 index 00000000000..a22fc474136 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/control_session_broker.rs @@ -0,0 +1,2288 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Pure reconnect broker for the control console. + +use crate::control_session_protocol; +use crate::control_session_protocol::Parser; +use crate::control_session_protocol::ParserSnapshot; +use crate::control_session_protocol::ProtocolError; +use crate::control_session_protocol::Record; +use crate::control_session_protocol::RecordType; +use std::collections::VecDeque; +use std::fmt; +use thiserror::Error; + +/// Maximum records waiting behind the one current in-flight record. +pub const MAX_QUEUED_RECORDS_PER_LEG: usize = 16; +/// Maximum encoded bytes waiting behind the one current in-flight record. +pub const MAX_QUEUED_BYTES_PER_LEG: usize = 1024 * 1024; + +/// The broker's externally meaningful state. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum BrokerState { + AwaitGuestAttach = 1, + AwaitGuestAck = 2, + ReadyNoHost = 3, + Active = 4, + ResetPending = 5, + Failed = 6, +} + +impl BrokerState { + fn from_snapshot(value: u8) -> Result { + match value { + 1 => Ok(Self::AwaitGuestAttach), + 2 => Ok(Self::AwaitGuestAck), + 3 => Ok(Self::ReadyNoHost), + 4 => Ok(Self::Active), + 5 => Ok(Self::ResetPending), + 6 => Ok(Self::Failed), + _ => Err(BrokerError::InvalidSnapshot("invalid broker state")), + } + } +} + +/// An output leg driven by a later physical-I/O adapter. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum OutputLegId { + Guest, + Host, +} + +/// Result of accepting input into one broker leg. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct InputProgress { + pub consumed: usize, + pub status: InputStatus, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum InputStatus { + NeedMore, + RecordAccepted, + Backpressured, +} + +/// Error counters safe to expose through inspection. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct BrokerCounters { + pub protocol_errors: u64, + pub authentication_errors: u64, + pub sequence_errors: u64, + pub ack_errors: u64, + pub reset_errors: u64, + pub reconnect_errors: u64, + pub backpressure_errors: u64, +} + +/// Pure broker failures. +#[derive(Debug, Error, Eq, PartialEq)] +pub enum BrokerError { + #[error(transparent)] + Protocol(#[from] ProtocolError), + #[error("host capability authentication failed")] + Authentication, + #[error("a host attachment already occupies the broker slot")] + HostSlotBusy, + #[error("record {record_type:?} is illegal in state {state:?}")] + IllegalRecord { + state: BrokerState, + record_type: RecordType, + }, + #[error("record instance does not match the broker")] + InstanceMismatch, + #[error("record epoch {actual} does not match expected epoch {expected}")] + EpochMismatch { expected: u64, actual: u64 }, + #[error("record sequence {actual} does not match expected sequence {expected}")] + SequenceMismatch { expected: u64, actual: u64 }, + #[error("guest acknowledged a session before RESET was emitted")] + AckBeforeReset, + #[error( + "receive credit increment {increment} exceeds charged bytes with {available} available of window {window}" + )] + InvalidCredit { + available: u32, + increment: u32, + window: u32, + }, + #[error("output leg {0:?} is backpressured")] + Backpressure(OutputLegId), + #[error("control-session epoch overflow")] + EpochOverflow, + #[error("control-session sequence overflow")] + SequenceOverflow, + #[error("invalid output progress")] + InvalidOutputProgress, + #[error("invalid broker snapshot: {0}")] + InvalidSnapshot(&'static str), + #[error("broker is in a failed state")] + Failed, +} + +/// Serializable-neutral state for one partially emitted record. +#[derive(Clone, Eq, PartialEq)] +pub struct EncodedRecordSnapshot { + pub bytes: Vec, + pub offset: usize, +} + +impl fmt::Debug for EncodedRecordSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("EncodedRecordSnapshot") + .field("bytes_len", &self.bytes.len()) + .field("offset", &self.offset) + .finish() + } +} + +/// Serializable-neutral state for an output leg. +#[derive(Clone, Default, Eq, PartialEq)] +pub struct OutputSnapshot { + pub current: Option, + pub queued_records: Vec>, +} + +impl fmt::Debug for OutputSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let queued_bytes = self + .queued_records + .iter() + .fold(0usize, |total, record| total.saturating_add(record.len())); + f.debug_struct("OutputSnapshot") + .field("current", &self.current) + .field("queued_record_count", &self.queued_records.len()) + .field("queued_bytes", &queued_bytes) + .finish() + } +} + +/// Serializable-neutral broker state. It intentionally contains no capability +/// or live host-attachment identity. +#[derive(Clone, Eq, PartialEq)] +pub struct BrokerSnapshot { + pub state: u8, + pub instance_id: [u8; 16], + pub drain_foreign_instance_records: bool, + pub epoch: u64, + pub guest_parser: ParserSnapshot, + pub guest_output: OutputSnapshot, + pub host_output: OutputSnapshot, + pub guest_receive_sequence: u64, + pub guest_send_sequence: u64, + pub host_receive_sequence: u64, + pub host_send_sequence: u64, + pub pending_guest_record: Option, + pub pending_host_record: Option, + pub counters: BrokerCounters, +} + +impl fmt::Debug for BrokerSnapshot { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BrokerSnapshot") + .field("state", &self.state) + .field("instance_id", &self.instance_id) + .field( + "drain_foreign_instance_records", + &self.drain_foreign_instance_records, + ) + .field("epoch", &self.epoch) + .field("guest_parser", &self.guest_parser) + .field("guest_output", &self.guest_output) + .field("host_output", &self.host_output) + .field("guest_receive_sequence", &self.guest_receive_sequence) + .field("guest_send_sequence", &self.guest_send_sequence) + .field("host_receive_sequence", &self.host_receive_sequence) + .field("host_send_sequence", &self.host_send_sequence) + .field("pending_guest_record", &self.pending_guest_record) + .field("pending_host_record", &self.pending_host_record) + .field("counters", &self.counters) + .finish() + } +} + +#[derive(Clone, Debug)] +struct EncodedRecord { + bytes: Vec, + offset: usize, +} + +#[derive(Clone, Debug, Default)] +struct OutputLeg { + current: Option, + queue: VecDeque>, + queued_bytes: usize, +} + +impl OutputLeg { + fn can_enqueue(&self, encoded_len: usize) -> bool { + self.queue.len() < MAX_QUEUED_RECORDS_PER_LEG + && self + .queued_bytes + .checked_add(encoded_len) + .is_some_and(|total| total <= MAX_QUEUED_BYTES_PER_LEG) + } + + fn enqueue(&mut self, bytes: Vec) -> Result<(), BrokerError> { + if !self.can_enqueue(bytes.len()) { + return Err(BrokerError::InvalidSnapshot( + "output queue exceeded configured bound", + )); + } + self.queued_bytes += bytes.len(); + self.queue.push_back(bytes); + Ok(()) + } + + fn begin(&mut self) -> bool { + if self.current.is_some() { + return true; + } + let Some(bytes) = self.queue.pop_front() else { + return false; + }; + self.queued_bytes -= bytes.len(); + self.current = Some(EncodedRecord { bytes, offset: 0 }); + true + } + + fn peek(&self, max_bytes: usize) -> Option<&[u8]> { + let current = self.current.as_ref()?; + let end = current + .offset + .saturating_add(max_bytes) + .min(current.bytes.len()); + Some(¤t.bytes[current.offset..end]) + } + + fn advance(&mut self, count: usize) -> Result<(), BrokerError> { + let current = self + .current + .as_mut() + .ok_or(BrokerError::InvalidOutputProgress)?; + let remaining = current.bytes.len() - current.offset; + if count > remaining { + return Err(BrokerError::InvalidOutputProgress); + } + current.offset += count; + if current.offset == current.bytes.len() { + self.current = None; + } + Ok(()) + } + + fn discard_unstarted(&mut self) { + self.queue.clear(); + self.queued_bytes = 0; + if self + .current + .as_ref() + .is_some_and(|current| current.offset == 0) + { + self.current = None; + } + } + + fn clear(&mut self) { + self.current = None; + self.queue.clear(); + self.queued_bytes = 0; + } + + fn snapshot(&self) -> OutputSnapshot { + OutputSnapshot { + current: self.current.as_ref().map(|current| EncodedRecordSnapshot { + bytes: current.bytes.clone(), + offset: current.offset, + }), + queued_records: self.queue.iter().cloned().collect(), + } + } + + fn restore(snapshot: OutputSnapshot) -> Result { + Self::validate_snapshot(&snapshot)?; + let current = if let Some(current) = snapshot.current { + Some(EncodedRecord { + bytes: current.bytes, + offset: current.offset, + }) + } else { + None + }; + + let mut queue = VecDeque::with_capacity(snapshot.queued_records.len()); + let mut queued_bytes = 0usize; + for bytes in snapshot.queued_records { + queued_bytes = + queued_bytes + .checked_add(bytes.len()) + .ok_or(BrokerError::InvalidSnapshot( + "queued output byte count overflow", + ))?; + if queued_bytes > MAX_QUEUED_BYTES_PER_LEG { + return Err(BrokerError::InvalidSnapshot( + "queued output bytes exceed configured bound", + )); + } + queue.push_back(bytes); + } + Ok(Self { + current, + queue, + queued_bytes, + }) + } + + fn validate_snapshot(snapshot: &OutputSnapshot) -> Result<(), BrokerError> { + if let Some(current) = &snapshot.current { + validate_encoded_output(¤t.bytes)?; + if current.offset >= current.bytes.len() { + return Err(BrokerError::InvalidSnapshot( + "current output offset is out of bounds", + )); + } + } + if snapshot.queued_records.len() > MAX_QUEUED_RECORDS_PER_LEG { + return Err(BrokerError::InvalidSnapshot( + "too many queued output records", + )); + } + let mut queued_bytes = 0usize; + for bytes in &snapshot.queued_records { + validate_encoded_output(bytes)?; + queued_bytes = + queued_bytes + .checked_add(bytes.len()) + .ok_or(BrokerError::InvalidSnapshot( + "queued output byte count overflow", + ))?; + if queued_bytes > MAX_QUEUED_BYTES_PER_LEG { + return Err(BrokerError::InvalidSnapshot( + "queued output bytes exceed configured bound", + )); + } + } + Ok(()) + } +} + +fn validate_encoded_output(bytes: &[u8]) -> Result<(), BrokerError> { + let record = control_session_protocol::decode_exact(bytes)?; + if matches!( + record.record_type, + RecordType::GuestAttach | RecordType::HostAttach + ) { + return Err(BrokerError::InvalidSnapshot( + "bootstrap record cannot be broker output", + )); + } + Ok(()) +} + +/// Pure protocol and reconnect state machine. +pub struct ControlSessionBroker { + instance_id: [u8; 16], + capability: [u8; 32], + epoch: u64, + state: BrokerState, + guest_parser: Parser, + host_parser: Parser, + guest_output: OutputLeg, + host_output: OutputLeg, + guest_receive_sequence: u64, + guest_send_sequence: u64, + host_receive_sequence: u64, + host_send_sequence: u64, + guest_receive_window: u32, + guest_receive_credit: u32, + host_connected: bool, + host_authenticated: bool, + host_rejected: bool, + host_bound_epoch: u64, + pending_guest_record: Option, + pending_host_record: Option, + drain_foreign_instance_records: bool, + counters: BrokerCounters, +} + +impl ControlSessionBroker { + pub fn new(instance_id: [u8; 16], capability: [u8; 32]) -> Self { + Self { + instance_id, + capability, + epoch: 1, + state: BrokerState::AwaitGuestAttach, + guest_parser: Parser::new(), + host_parser: Parser::new(), + guest_output: OutputLeg::default(), + host_output: OutputLeg::default(), + guest_receive_sequence: 0, + guest_send_sequence: 0, + host_receive_sequence: 0, + host_send_sequence: 0, + guest_receive_window: 0, + guest_receive_credit: 0, + host_connected: false, + host_authenticated: false, + host_rejected: false, + host_bound_epoch: 0, + pending_guest_record: None, + pending_host_record: None, + drain_foreign_instance_records: false, + counters: BrokerCounters::default(), + } + } + + pub fn state(&self) -> BrokerState { + self.state + } + + #[cfg_attr(not(test), expect(dead_code, reason = "used by protocol tests"))] + pub fn instance_id(&self) -> [u8; 16] { + self.instance_id + } + + pub fn epoch(&self) -> u64 { + self.epoch + } + + pub fn counters(&self) -> &BrokerCounters { + &self.counters + } + + pub fn guest_receive_window(&self) -> u32 { + self.guest_receive_window + } + + pub fn guest_receive_credit(&self) -> u32 { + self.guest_receive_credit + } + + pub fn host_is_authenticated(&self) -> bool { + self.host_authenticated + } + + pub fn host_is_connected(&self) -> bool { + self.host_connected + } + + pub fn output_record_count(&self, leg: OutputLegId) -> usize { + let output = self.output(leg); + output.queue.len() + usize::from(output.current.is_some()) + } + + pub fn output_byte_count(&self, leg: OutputLegId) -> usize { + let output = self.output(leg); + output.queued_bytes + + output + .current + .as_ref() + .map_or(0, |current| current.bytes.len() - current.offset) + } + + pub fn guest_parser_buffered_bytes(&self) -> usize { + let snapshot = self.guest_parser.snapshot(); + snapshot.header_count + snapshot.body_bytes.len() + } + + pub fn has_pending_guest_record(&self) -> bool { + self.pending_guest_record.is_some() + } + + pub fn has_pending_host_record(&self) -> bool { + self.pending_host_record.is_some() + } + + /// Resets the device-facing protocol state without changing constructor + /// credentials. A still-open host transport must be detached separately by + /// the physical adapter before it can attach again. + pub fn reset_for_device(&mut self) { + *self = Self::new(self.instance_id, self.capability); + } + + /// Reserves the single host slot after OS-level identity verification. + pub fn begin_host_attachment(&mut self) -> Result<(), BrokerError> { + if self.host_connected { + increment(&mut self.counters.reconnect_errors); + return Err(BrokerError::HostSlotBusy); + } + if self.state == BrokerState::Failed { + return Err(BrokerError::Failed); + } + self.host_connected = true; + self.host_authenticated = false; + self.host_rejected = false; + self.host_bound_epoch = 0; + self.host_parser = Parser::new(); + self.host_output.clear(); + self.pending_host_record = None; + self.host_receive_sequence = 0; + self.host_send_sequence = 0; + Ok(()) + } + + pub fn accept_guest_input(&mut self, input: &[u8]) -> Result { + if self.state == BrokerState::Failed { + return Err(BrokerError::Failed); + } + if let Some(record) = self.pending_guest_record.take() { + if let Err(error) = self.handle_guest_record(record.clone()) { + if error == BrokerError::Backpressure(OutputLegId::Host) { + self.pending_guest_record = Some(record); + return Ok(InputProgress { + consumed: 0, + status: InputStatus::Backpressured, + }); + } + return Err(error); + } + if input.is_empty() { + return Ok(InputProgress { + consumed: 0, + status: InputStatus::RecordAccepted, + }); + } + } + + let progress = match self.guest_parser.accept(input) { + Ok(progress) => progress, + Err(error) => { + increment(&mut self.counters.protocol_errors); + return Err(error.into()); + } + }; + let Some(record) = progress.record else { + return Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::NeedMore, + }); + }; + match self.handle_guest_record(record.clone()) { + Ok(()) => Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::RecordAccepted, + }), + Err(BrokerError::Backpressure(OutputLegId::Host)) => { + self.pending_guest_record = Some(record); + Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::Backpressured, + }) + } + Err(error) => Err(error), + } + } + + pub fn accept_host_input(&mut self, input: &[u8]) -> Result { + if !self.host_connected || self.host_rejected { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::IllegalRecord { + state: self.state, + record_type: RecordType::HostAttach, + }); + } + if self.state == BrokerState::Failed { + return Err(BrokerError::Failed); + } + if let Some(record) = self.pending_host_record.take() { + if let Err(error) = self.handle_host_record(record.clone()) { + if error == BrokerError::Backpressure(OutputLegId::Guest) { + self.pending_host_record = Some(record); + return Ok(InputProgress { + consumed: 0, + status: InputStatus::Backpressured, + }); + } + return Err(error); + } + if input.is_empty() { + return Ok(InputProgress { + consumed: 0, + status: InputStatus::RecordAccepted, + }); + } + } + + let progress = match self.host_parser.accept(input) { + Ok(progress) => progress, + Err(error) => { + increment(&mut self.counters.protocol_errors); + return Err(error.into()); + } + }; + let Some(record) = progress.record else { + return Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::NeedMore, + }); + }; + match self.handle_host_record(record.clone()) { + Ok(()) => Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::RecordAccepted, + }), + Err(BrokerError::Backpressure(OutputLegId::Guest)) => { + self.pending_host_record = Some(record); + Ok(InputProgress { + consumed: progress.consumed, + status: InputStatus::Backpressured, + }) + } + Err(error) => Err(error), + } + } + + /// Drops a host attachment. Only loss of an active authenticated host + /// advances the epoch. + pub fn host_disconnected(&mut self) -> Result<(), BrokerError> { + let active_loss = + self.host_connected && self.host_authenticated && self.state == BrokerState::Active; + self.host_connected = false; + self.host_authenticated = false; + self.host_rejected = false; + self.host_bound_epoch = 0; + self.host_parser = Parser::new(); + self.host_output.clear(); + self.pending_host_record = None; + self.host_receive_sequence = 0; + self.host_send_sequence = 0; + if !active_loss { + return Ok(()); + } + + self.state = BrokerState::ResetPending; + let Some(next_epoch) = self.epoch.checked_add(1) else { + increment(&mut self.counters.reset_errors); + return Err(self.fail(BrokerError::EpochOverflow)); + }; + self.epoch = next_epoch; + self.guest_output.discard_unstarted(); + self.pending_guest_record = None; + self.guest_receive_sequence = 0; + self.guest_send_sequence = 0; + self.guest_receive_window = 0; + self.guest_receive_credit = 0; + self.enqueue_guest_control(RecordType::Reset)?; + Ok(()) + } + + /// Promotes one complete record to the current physical record. + pub fn begin_output(&mut self, leg: OutputLegId) -> bool { + if self.state == BrokerState::Failed { + return false; + } + self.output_mut(leg).begin() + } + + /// Returns bytes from the current physical record without advancing them. + pub fn peek_output(&self, leg: OutputLegId, max_bytes: usize) -> Option<&[u8]> { + if self.state == BrokerState::Failed { + return None; + } + self.output(leg).peek(max_bytes) + } + + /// Commits bytes reported written by the physical adapter. + pub fn advance_output(&mut self, leg: OutputLegId, count: usize) -> Result<(), BrokerError> { + if self.state == BrokerState::Failed { + return Err(BrokerError::Failed); + } + let reset_completed = + if leg == OutputLegId::Guest && self.state == BrokerState::ResetPending { + self.guest_output + .current + .as_ref() + .filter(|current| current.bytes.len() - current.offset == count) + .map(|current| control_session_protocol::decode_exact(¤t.bytes)) + .transpose()? + .is_some_and(|record| { + record.record_type == RecordType::Reset + && record.instance_id == self.instance_id + && record.epoch == self.epoch + }) + } else { + false + }; + self.output_mut(leg).advance(count)?; + if reset_completed { + self.state = BrokerState::AwaitGuestAck; + } + Ok(()) + } + + pub fn snapshot(&self) -> BrokerSnapshot { + let mut guest_output = self.guest_output.snapshot(); + guest_output.queued_records.clear(); + if guest_output + .current + .as_ref() + .is_some_and(|current| current.offset == 0) + { + guest_output.current = None; + } + BrokerSnapshot { + state: self.state as u8, + instance_id: self.instance_id, + drain_foreign_instance_records: self.drain_foreign_instance_records, + epoch: self.epoch, + guest_parser: self.guest_parser.snapshot(), + guest_output, + host_output: OutputSnapshot::default(), + guest_receive_sequence: self.guest_receive_sequence, + guest_send_sequence: self.guest_send_sequence, + host_receive_sequence: self.host_receive_sequence, + host_send_sequence: self.host_send_sequence, + pending_guest_record: None, + pending_host_record: None, + counters: self.counters.clone(), + } + } + + /// Restores only physical guest alignment state, then starts a fresh + /// instance at epoch one and queues RESET. + pub fn restore( + snapshot: BrokerSnapshot, + new_instance_id: [u8; 16], + new_capability: [u8; 32], + ) -> Result { + Self::validate_restore(&snapshot, new_instance_id)?; + let guest_parser = Parser::restore(snapshot.guest_parser)?; + let mut saved_guest_output = OutputLeg::restore(snapshot.guest_output)?; + let _saved_host_output = OutputLeg::restore(snapshot.host_output)?; + + saved_guest_output.queue.clear(); + saved_guest_output.queued_bytes = 0; + if saved_guest_output + .current + .as_ref() + .is_some_and(|current| current.offset == 0) + { + saved_guest_output.current = None; + } + + let mut broker = Self::new(new_instance_id, new_capability); + broker.state = BrokerState::ResetPending; + broker.guest_parser = guest_parser; + broker.guest_output = saved_guest_output; + broker.drain_foreign_instance_records = true; + broker.counters = snapshot.counters; + broker.enqueue_guest_control(RecordType::Reset)?; + Ok(broker) + } + + pub fn validate_restore( + snapshot: &BrokerSnapshot, + new_instance_id: [u8; 16], + ) -> Result<(), BrokerError> { + Self::validate_snapshot(snapshot)?; + if new_instance_id == [0; 16] { + return Err(BrokerError::InvalidSnapshot("instance ID is zero")); + } + if snapshot.instance_id == new_instance_id { + return Err(BrokerError::InvalidSnapshot( + "restore reused the saved instance ID", + )); + } + if let Some(current) = &snapshot.guest_output.current + && current.offset != 0 + { + let record = control_session_protocol::decode_exact(¤t.bytes)?; + if record.instance_id == [0; 16] || record.instance_id == new_instance_id { + return Err(BrokerError::InvalidSnapshot( + "partial guest output does not belong to an old instance", + )); + } + } + Ok(()) + } + + /// Validates all snapshot bounds and encoded values without constructing a + /// broker or allocating output queues. + pub fn validate_snapshot(snapshot: &BrokerSnapshot) -> Result<(), BrokerError> { + let _saved_state = BrokerState::from_snapshot(snapshot.state)?; + if snapshot.epoch == 0 { + return Err(BrokerError::InvalidSnapshot("saved epoch is zero")); + } + if snapshot.instance_id == [0; 16] { + return Err(BrokerError::InvalidSnapshot("instance ID is zero")); + } + validate_pending(snapshot.pending_guest_record.as_ref())?; + validate_pending(snapshot.pending_host_record.as_ref())?; + Parser::validate_snapshot(&snapshot.guest_parser)?; + OutputLeg::validate_snapshot(&snapshot.guest_output)?; + OutputLeg::validate_snapshot(&snapshot.host_output)?; + Ok(()) + } + + fn handle_guest_record(&mut self, record: Record) -> Result<(), BrokerError> { + if self.drain_foreign_instance_records + && matches!( + self.state, + BrokerState::ResetPending | BrokerState::AwaitGuestAck + ) + && record.instance_id != self.instance_id + { + return Ok(()); + } + match self.state { + BrokerState::AwaitGuestAttach => { + if record.record_type != RecordType::GuestAttach { + return self.illegal(record.record_type); + } + self.enqueue_guest_control(RecordType::Reset)?; + self.state = BrokerState::ResetPending; + Ok(()) + } + BrokerState::AwaitGuestAck | BrokerState::ResetPending => { + self.handle_guest_while_awaiting_ack(record) + } + BrokerState::ReadyNoHost => { + if record.record_type == RecordType::Ack { + increment(&mut self.counters.ack_errors); + } + self.illegal(record.record_type) + } + BrokerState::Active => { + if record.record_type == RecordType::Ack { + increment(&mut self.counters.ack_errors); + return self.illegal(record.record_type); + } + if record.record_type == RecordType::Credit { + self.validate_current_guest_record(&record)?; + let credit_increment = + control_session_protocol::decode_credit_payload(&record.payload)?; + let Some(available) = self.guest_receive_credit.checked_add(credit_increment) + else { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::InvalidCredit { + available: self.guest_receive_credit, + increment: credit_increment, + window: self.guest_receive_window, + }); + }; + if available > self.guest_receive_window { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::InvalidCredit { + available: self.guest_receive_credit, + increment: credit_increment, + window: self.guest_receive_window, + }); + } + let next_guest_receive = + self.checked_next_sequence(self.guest_receive_sequence)?; + self.guest_receive_credit = available; + self.guest_receive_sequence = next_guest_receive; + return Ok(()); + } + if record.record_type != RecordType::Data { + return self.illegal(record.record_type); + } + self.validate_current_guest_record(&record)?; + let encoded = self.make_host_data(&record.payload)?; + if !self.host_output.can_enqueue(encoded.len()) { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Host)); + } + let next_guest_receive = self.checked_next_sequence(self.guest_receive_sequence)?; + let next_host_send = self.checked_next_sequence(self.host_send_sequence)?; + self.host_output.enqueue(encoded)?; + self.guest_receive_sequence = next_guest_receive; + self.host_send_sequence = next_host_send; + Ok(()) + } + BrokerState::Failed => Err(BrokerError::Failed), + } + } + + fn handle_guest_while_awaiting_ack(&mut self, record: Record) -> Result<(), BrokerError> { + if self.state == BrokerState::ResetPending + && record.record_type == RecordType::Ack + && record.instance_id == self.instance_id + && record.epoch == self.epoch + { + increment(&mut self.counters.ack_errors); + return Err(BrokerError::AckBeforeReset); + } + if record.record_type != RecordType::Ack { + if record.instance_id == self.instance_id && record.epoch < self.epoch { + return Ok(()); + } + return self.illegal(record.record_type); + } + if record.instance_id != self.instance_id { + increment(&mut self.counters.ack_errors); + return Err(BrokerError::InstanceMismatch); + } + if record.epoch != self.epoch { + increment(&mut self.counters.ack_errors); + return Err(BrokerError::EpochMismatch { + expected: self.epoch, + actual: record.epoch, + }); + } + if record.sequence != self.guest_receive_sequence { + increment(&mut self.counters.ack_errors); + increment(&mut self.counters.sequence_errors); + return Err(BrokerError::SequenceMismatch { + expected: self.guest_receive_sequence, + actual: record.sequence, + }); + } + let initial_credit = control_session_protocol::decode_credit_payload(&record.payload)?; + let next_guest_receive = self.checked_next_sequence(self.guest_receive_sequence)?; + if self.host_authenticated { + self.enqueue_host_control(RecordType::Ready, Vec::new())?; + self.state = BrokerState::Active; + } else { + self.state = BrokerState::ReadyNoHost; + } + self.guest_receive_sequence = next_guest_receive; + self.guest_receive_window = initial_credit; + self.guest_receive_credit = initial_credit; + self.drain_foreign_instance_records = false; + Ok(()) + } + + fn handle_host_record(&mut self, record: Record) -> Result<(), BrokerError> { + if !self.host_authenticated { + if record.record_type != RecordType::HostAttach { + return self.illegal(record.record_type); + } + if !constant_time_eq_32(&self.capability, &record.payload) { + increment(&mut self.counters.authentication_errors); + self.host_rejected = true; + // Authentication rejection is close-only; the adapter detaches the host. + return Err(BrokerError::Authentication); + } + self.host_authenticated = true; + self.host_bound_epoch = self.epoch; + self.host_receive_sequence = 0; + self.host_send_sequence = 0; + self.host_output.clear(); + match self.state { + BrokerState::ReadyNoHost => { + self.enqueue_host_control(RecordType::Ready, Vec::new())?; + self.state = BrokerState::Active; + } + BrokerState::AwaitGuestAttach + | BrokerState::AwaitGuestAck + | BrokerState::ResetPending => { + self.enqueue_host_control(RecordType::Wait, Vec::new())?; + } + BrokerState::Active => { + increment(&mut self.counters.reconnect_errors); + return Err(BrokerError::HostSlotBusy); + } + BrokerState::Failed => return Err(BrokerError::Failed), + } + return Ok(()); + } + + if self.state != BrokerState::Active || record.record_type != RecordType::Data { + return self.illegal(record.record_type); + } + if record.instance_id != self.instance_id { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::InstanceMismatch); + } + if record.epoch != self.host_bound_epoch || record.epoch != self.epoch { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::EpochMismatch { + expected: self.epoch, + actual: record.epoch, + }); + } + if record.sequence != self.host_receive_sequence { + increment(&mut self.counters.sequence_errors); + return Err(BrokerError::SequenceMismatch { + expected: self.host_receive_sequence, + actual: record.sequence, + }); + } + let payload_len = + u32::try_from(record.payload.len()).map_err(|_| ProtocolError::LengthOverflow)?; + if payload_len > self.guest_receive_credit { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Guest)); + } + let encoded = self.make_guest_data(&record.payload)?; + if !self.guest_output.can_enqueue(encoded.len()) { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Guest)); + } + let next_host_receive = self.checked_next_sequence(self.host_receive_sequence)?; + let next_guest_send = self.checked_next_sequence(self.guest_send_sequence)?; + self.guest_output.enqueue(encoded)?; + self.guest_receive_credit -= payload_len; + self.host_receive_sequence = next_host_receive; + self.guest_send_sequence = next_guest_send; + Ok(()) + } + + fn validate_current_guest_record(&mut self, record: &Record) -> Result<(), BrokerError> { + if record.instance_id != self.instance_id { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::InstanceMismatch); + } + if record.epoch != self.epoch { + increment(&mut self.counters.protocol_errors); + return Err(BrokerError::EpochMismatch { + expected: self.epoch, + actual: record.epoch, + }); + } + if record.sequence != self.guest_receive_sequence { + increment(&mut self.counters.sequence_errors); + return Err(BrokerError::SequenceMismatch { + expected: self.guest_receive_sequence, + actual: record.sequence, + }); + } + Ok(()) + } + + fn enqueue_guest_control(&mut self, record_type: RecordType) -> Result<(), BrokerError> { + let record = Record::session( + record_type, + self.instance_id, + self.epoch, + self.guest_send_sequence, + Vec::new(), + ); + let encoded = control_session_protocol::encode(&record)?; + if !self.guest_output.can_enqueue(encoded.len()) { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Guest)); + } + let next_guest_send = self.checked_next_sequence(self.guest_send_sequence)?; + self.guest_output.enqueue(encoded)?; + self.guest_send_sequence = next_guest_send; + Ok(()) + } + + fn enqueue_host_control( + &mut self, + record_type: RecordType, + payload: Vec, + ) -> Result<(), BrokerError> { + let record = Record::session( + record_type, + self.instance_id, + self.epoch, + self.host_send_sequence, + payload, + ); + let encoded = control_session_protocol::encode(&record)?; + if !self.host_output.can_enqueue(encoded.len()) { + increment(&mut self.counters.backpressure_errors); + return Err(BrokerError::Backpressure(OutputLegId::Host)); + } + let next_host_send = self.checked_next_sequence(self.host_send_sequence)?; + self.host_output.enqueue(encoded)?; + self.host_send_sequence = next_host_send; + Ok(()) + } + + fn make_guest_data(&self, payload: &[u8]) -> Result, BrokerError> { + Ok(control_session_protocol::encode(&Record::session( + RecordType::Data, + self.instance_id, + self.epoch, + self.guest_send_sequence, + payload.to_vec(), + ))?) + } + + fn make_host_data(&self, payload: &[u8]) -> Result, BrokerError> { + Ok(control_session_protocol::encode(&Record::session( + RecordType::Data, + self.instance_id, + self.epoch, + self.host_send_sequence, + payload.to_vec(), + ))?) + } + + fn checked_next_sequence(&mut self, sequence: u64) -> Result { + sequence + .checked_add(1) + .ok_or(BrokerError::SequenceOverflow) + .map_err(|error| self.fail(error)) + } + + fn fail(&mut self, error: BrokerError) -> BrokerError { + self.state = BrokerState::Failed; + self.guest_parser = Parser::new(); + self.host_parser = Parser::new(); + self.guest_output.clear(); + self.host_output.clear(); + self.pending_guest_record = None; + self.pending_host_record = None; + error + } + + fn illegal(&mut self, record_type: RecordType) -> Result { + increment(&mut self.counters.protocol_errors); + Err(BrokerError::IllegalRecord { + state: self.state, + record_type, + }) + } + + fn output(&self, leg: OutputLegId) -> &OutputLeg { + match leg { + OutputLegId::Guest => &self.guest_output, + OutputLegId::Host => &self.host_output, + } + } + + fn output_mut(&mut self, leg: OutputLegId) -> &mut OutputLeg { + match leg { + OutputLegId::Guest => &mut self.guest_output, + OutputLegId::Host => &mut self.host_output, + } + } +} + +fn validate_pending(record: Option<&Record>) -> Result<(), BrokerError> { + if let Some(record) = record { + control_session_protocol::encode(record)?; + } + Ok(()) +} + +fn increment(counter: &mut u64) { + *counter = counter.saturating_add(1); +} + +fn constant_time_eq_32(expected: &[u8; 32], actual: &[u8]) -> bool { + let mut difference = actual.len() ^ expected.len(); + for (index, expected_byte) in expected.iter().enumerate() { + let actual_byte = actual.get(index).copied().unwrap_or(0); + difference |= usize::from(*expected_byte ^ actual_byte); + } + difference == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + const INSTANCE: [u8; 16] = [0x22; 16]; + const CAPABILITY: [u8; 32] = [0x33; 32]; + + fn feed_guest( + broker: &mut ControlSessionBroker, + record: &Record, + ) -> Result { + let bytes = control_session_protocol::encode(record)?; + broker.accept_guest_input(&bytes) + } + + fn feed_host( + broker: &mut ControlSessionBroker, + record: &Record, + ) -> Result { + let bytes = control_session_protocol::encode(record)?; + broker.accept_host_input(&bytes) + } + + fn drain_record( + broker: &mut ControlSessionBroker, + leg: OutputLegId, + ) -> Result { + assert!(broker.begin_output(leg)); + let bytes = broker + .peek_output(leg, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .to_vec(); + broker.advance_output(leg, bytes.len())?; + Ok(control_session_protocol::decode_exact(&bytes)?) + } + + fn ack(broker: &ControlSessionBroker) -> Record { + ack_with_credit(broker, control_session_protocol::MIN_RECEIVE_CREDIT) + } + + fn ack_with_credit(broker: &ControlSessionBroker, credit: u32) -> Record { + Record::session( + RecordType::Ack, + broker.instance_id(), + broker.epoch(), + 0, + control_session_protocol::credit_payload(credit), + ) + } + + fn make_active() -> Result { + make_active_with_window(control_session_protocol::MIN_RECEIVE_CREDIT) + } + + fn make_active_with_window(window: u32) -> Result { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + let guest_ack = ack_with_credit(&broker, window); + feed_guest(&mut broker, &guest_ack)?; + broker.begin_host_attachment()?; + feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, CAPABILITY.to_vec()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Host)?.record_type, + RecordType::Ready + ); + assert_eq!(broker.state(), BrokerState::Active); + Ok(broker) + } + + #[test] + fn cold_boot_authentication_and_independent_sequences() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + let reset = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!((reset.epoch, reset.sequence), (1, 0)); + + broker.begin_host_attachment()?; + feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, CAPABILITY.to_vec()), + )?; + let wait = drain_record(&mut broker, OutputLegId::Host)?; + assert_eq!((wait.record_type, wait.sequence), (RecordType::Wait, 0)); + + let guest_ack = ack(&broker); + feed_guest(&mut broker, &guest_ack)?; + let ready = drain_record(&mut broker, OutputLegId::Host)?; + assert_eq!((ready.record_type, ready.sequence), (RecordType::Ready, 1)); + + feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, b"guest".to_vec()), + )?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, b"host".to_vec()), + )?; + let to_host = drain_record(&mut broker, OutputLegId::Host)?; + let to_guest = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!((to_host.sequence, to_host.payload), (2, b"guest".to_vec())); + assert_eq!((to_guest.sequence, to_guest.payload), (1, b"host".to_vec())); + Ok(()) + } + + #[test] + fn wrong_capability_fails_without_advancing_epoch() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + broker.begin_host_attachment()?; + let error = feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, vec![0x44; 32]), + ) + .expect_err("wrong capability must fail"); + assert_eq!(error, BrokerError::Authentication); + assert_eq!(broker.epoch(), 1); + assert!(!broker.host_is_authenticated()); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert_eq!(broker.counters().authentication_errors, 1); + Ok(()) + } + + #[test] + fn stale_future_wrong_and_duplicate_acks_are_rejected() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + let early_ack = ack(&broker); + assert_eq!( + feed_guest(&mut broker, &early_ack), + Err(BrokerError::AckBeforeReset) + ); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + let wrong_instance = Record::session( + RecordType::Ack, + [9; 16], + 1, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + assert_eq!( + feed_guest(&mut broker, &wrong_instance), + Err(BrokerError::InstanceMismatch) + ); + let stale = Record::session( + RecordType::Ack, + INSTANCE, + 0, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + assert!(matches!( + feed_guest(&mut broker, &stale), + Err(BrokerError::EpochMismatch { actual: 0, .. }) + )); + let future = Record::session( + RecordType::Ack, + INSTANCE, + 2, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + assert!(matches!( + feed_guest(&mut broker, &future), + Err(BrokerError::EpochMismatch { actual: 2, .. }) + )); + let wrong_sequence = Record::session( + RecordType::Ack, + INSTANCE, + 1, + 1, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + assert!(matches!( + feed_guest(&mut broker, &wrong_sequence), + Err(BrokerError::SequenceMismatch { actual: 1, .. }) + )); + let guest_ack = ack(&broker); + feed_guest(&mut broker, &guest_ack)?; + assert!( + feed_guest( + &mut broker, + &Record::session( + RecordType::Ack, + INSTANCE, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ) + ) + .is_err() + ); + assert_eq!(broker.counters().ack_errors, 6); + Ok(()) + } + + #[test] + fn ready_without_host_rejects_data() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + let guest_ack = ack(&broker); + feed_guest(&mut broker, &guest_ack)?; + assert_eq!(broker.state(), BrokerState::ReadyNoHost); + assert!( + feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, vec![1]) + ) + .is_err() + ); + assert!(broker.pending_guest_record.is_none()); + Ok(()) + } + + #[test] + fn partial_output_disconnect_finishes_record_then_reset() -> Result<(), BrokerError> { + let mut broker = make_active()?; + for (sequence, payload) in [(0, b"first".as_slice()), (1, b"second".as_slice())] { + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, sequence, payload.to_vec()), + )?; + } + assert!(broker.begin_output(OutputLegId::Guest)); + let first_len = broker + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + broker.advance_output(OutputLegId::Guest, first_len - 1)?; + broker.host_disconnected()?; + assert_eq!(broker.state(), BrokerState::ResetPending); + assert_eq!(broker.epoch(), 2); + + let remainder = broker + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + assert_eq!(remainder, 1); + broker.advance_output(OutputLegId::Guest, remainder)?; + let reset = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!( + (reset.record_type, reset.epoch, reset.sequence), + (RecordType::Reset, 2, 0) + ); + assert!(!broker.begin_output(OutputLegId::Guest)); + Ok(()) + } + + #[test] + fn disconnect_after_last_byte_drops_unstarted_next_record() -> Result<(), BrokerError> { + let mut broker = make_active()?; + for sequence in 0..2 { + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + sequence, + vec![sequence as u8], + ), + )?; + } + let _first = drain_record(&mut broker, OutputLegId::Guest)?; + broker.host_disconnected()?; + let next = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!(next.record_type, RecordType::Reset); + Ok(()) + } + + #[test] + fn disconnect_at_every_host_record_offset_forwards_no_partial_data() -> Result<(), BrokerError> + { + let encoded = control_session_protocol::encode(&Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + b"body-with-NVXS".to_vec(), + ))?; + for split in 0..encoded.len() { + let mut broker = make_active()?; + let progress = broker.accept_host_input(&encoded[..split])?; + assert_eq!(progress.status, InputStatus::NeedMore); + broker.host_disconnected()?; + let output = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!(output.record_type, RecordType::Reset, "split {split}"); + assert!(!broker.begin_output(OutputLegId::Guest), "split {split}"); + } + Ok(()) + } + + #[test] + fn rapid_reconnect_waits_without_another_epoch() -> Result<(), BrokerError> { + let mut broker = make_active()?; + broker.host_disconnected()?; + assert_eq!(broker.epoch(), 2); + broker.begin_host_attachment()?; + feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, CAPABILITY.to_vec()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Host)?.record_type, + RecordType::Wait + ); + assert_eq!(broker.epoch(), 2); + assert_eq!( + broker.begin_host_attachment(), + Err(BrokerError::HostSlotBusy) + ); + + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + let current_ack = Record::session( + RecordType::Ack, + INSTANCE, + 2, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ); + feed_guest(&mut broker, ¤t_ack)?; + assert_eq!(broker.state(), BrokerState::Active); + assert_eq!( + drain_record(&mut broker, OutputLegId::Host)?.record_type, + RecordType::Ready + ); + Ok(()) + } + + #[test] + fn bounded_queue_returns_backpressure_without_data_loss() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let payload = vec![0x5a; control_session_protocol::MAX_DATA_LEN]; + let mut sequence = 0; + loop { + let progress = feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, sequence + 1, payload.clone()), + )?; + if progress.status == InputStatus::Backpressured { + break; + } + sequence += 1; + assert!(sequence <= MAX_QUEUED_RECORDS_PER_LEG as u64); + } + assert!(broker.pending_guest_record.is_some()); + assert_eq!(broker.counters().backpressure_errors, 1); + assert!(broker.output_byte_count(OutputLegId::Host) <= MAX_QUEUED_BYTES_PER_LEG); + let _ = drain_record(&mut broker, OutputLegId::Host)?; + let progress = broker.accept_guest_input(&[])?; + assert_eq!(progress.status, InputStatus::RecordAccepted); + assert!(broker.pending_guest_record.is_none()); + Ok(()) + } + + #[test] + fn queue_limits_exclude_the_current_in_flight_record() -> Result<(), BrokerError> { + let mut records = OutputLeg::default(); + for _ in 0..MAX_QUEUED_RECORDS_PER_LEG { + records.enqueue(vec![0])?; + } + assert!(records.begin()); + assert!(records.can_enqueue(1)); + records.enqueue(vec![0])?; + assert_eq!(records.queue.len(), MAX_QUEUED_RECORDS_PER_LEG); + assert!(records.current.is_some()); + assert!(!records.can_enqueue(1)); + + let mut bytes = OutputLeg::default(); + bytes.enqueue(vec![0])?; + assert!(bytes.begin()); + assert!(bytes.can_enqueue(MAX_QUEUED_BYTES_PER_LEG)); + bytes.enqueue(vec![0; MAX_QUEUED_BYTES_PER_LEG])?; + assert_eq!(bytes.queued_bytes, MAX_QUEUED_BYTES_PER_LEG); + assert!(bytes.current.is_some()); + assert!(!bytes.can_enqueue(1)); + Ok(()) + } + + #[test] + fn credit_gates_complete_records_and_releases_atomically() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let first_payload = vec![0x5a; control_session_protocol::MAX_DATA_LEN]; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, first_payload.clone()), + )?; + assert_eq!(broker.guest_receive_credit(), 0); + assert_eq!( + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, vec![0x44]) + )? + .status, + InputStatus::Backpressured + ); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.payload, + first_payload + ); + assert_eq!( + broker.accept_host_input(&[])?.status, + InputStatus::Backpressured + ); + + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 1, + 1, + control_session_protocol::credit_payload(1), + ), + )?; + assert_eq!( + broker.accept_host_input(&[])?.status, + InputStatus::RecordAccepted + ); + assert_eq!(broker.guest_receive_credit(), 0); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.payload, + vec![0x44] + ); + Ok(()) + } + + #[test] + fn queue_backpressure_does_not_spend_pending_record_credit() -> Result<(), BrokerError> { + let mut broker = make_active_with_window(control_session_protocol::MAX_RECEIVE_CREDIT)?; + let payload = vec![0x5a; control_session_protocol::MAX_DATA_LEN]; + let mut accepted = 0u32; + loop { + let progress = feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + accepted as u64, + payload.clone(), + ), + )?; + if progress.status == InputStatus::Backpressured { + break; + } + accepted += 1; + } + let credit_before_retry = broker.guest_receive_credit(); + assert_eq!( + credit_before_retry, + control_session_protocol::MAX_RECEIVE_CREDIT + - accepted * control_session_protocol::MIN_RECEIVE_CREDIT + ); + assert_eq!( + broker.accept_host_input(&[])?.status, + InputStatus::Backpressured + ); + assert_eq!(broker.guest_receive_credit(), credit_before_retry); + + let _ = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!( + broker.accept_host_input(&[])?.status, + InputStatus::RecordAccepted + ); + assert_eq!( + broker.guest_receive_credit(), + credit_before_retry - control_session_protocol::MIN_RECEIVE_CREDIT + ); + Ok(()) + } + + #[test] + fn invalid_duplicate_and_overflowing_credits_do_not_mint_capacity() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let credit = Record::session( + RecordType::Credit, + INSTANCE, + 1, + 1, + control_session_protocol::credit_payload(1), + ); + assert!(matches!( + feed_guest(&mut broker, &credit), + Err(BrokerError::InvalidCredit { .. }) + )); + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + vec![0; control_session_protocol::MAX_DATA_LEN], + ), + )?; + feed_guest(&mut broker, &credit)?; + assert_eq!(broker.guest_receive_credit(), 1); + assert!(matches!( + feed_guest(&mut broker, &credit), + Err(BrokerError::SequenceMismatch { .. }) + )); + assert_eq!(broker.guest_receive_credit(), 1); + + assert!(matches!( + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 2, + 2, + control_session_protocol::credit_payload(1), + ) + ), + Err(BrokerError::EpochMismatch { .. }) + )); + assert_eq!(broker.guest_receive_credit(), 1); + + broker.guest_receive_sequence = u64::MAX; + let before = broker.guest_receive_credit(); + assert_eq!( + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 1, + u64::MAX, + control_session_protocol::credit_payload(1), + ) + ), + Err(BrokerError::SequenceOverflow) + ); + assert_eq!(broker.guest_receive_credit(), before); + Ok(()) + } + + #[test] + fn zero_credit_does_not_block_disconnect_or_fresh_ack() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + vec![0x6a; control_session_protocol::MAX_DATA_LEN], + ), + )?; + let _ = drain_record(&mut broker, OutputLegId::Guest)?; + assert_eq!( + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, vec![1]) + )? + .status, + InputStatus::Backpressured + ); + + broker.host_disconnected()?; + assert!(!broker.has_pending_host_record()); + assert_eq!( + (broker.guest_receive_window(), broker.guest_receive_credit()), + (0, 0) + ); + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 1, + 1, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!( + (broker.guest_receive_window(), broker.guest_receive_credit()), + (0, 0) + ); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + + broker.begin_host_attachment()?; + feed_host( + &mut broker, + &Record::bootstrap(RecordType::HostAttach, CAPABILITY.to_vec()), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Host)?.record_type, + RecordType::Wait + ); + feed_guest( + &mut broker, + &Record::session( + RecordType::Ack, + INSTANCE, + 2, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(broker.state(), BrokerState::Active); + assert_eq!( + broker.guest_receive_credit(), + control_session_protocol::MIN_RECEIVE_CREDIT + ); + Ok(()) + } + + #[test] + fn disconnect_preserves_each_started_record_prefix_only() -> Result<(), BrokerError> { + let payload = b"body-with-NVXS".to_vec(); + for split in 0..control_session_protocol::HEADER_LEN + payload.len() { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, payload.clone()), + )?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, b"drop".to_vec()), + )?; + assert!(broker.begin_output(OutputLegId::Guest)); + let encoded = broker + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .to_vec(); + broker.advance_output(OutputLegId::Guest, split)?; + broker.host_disconnected()?; + if split != 0 { + assert_eq!( + broker + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)?, + &encoded[split..] + ); + broker.advance_output(OutputLegId::Guest, encoded.len() - split)?; + } + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + assert!(!broker.begin_output(OutputLegId::Guest)); + } + Ok(()) + } + + #[test] + fn reverse_backpressure_cannot_block_disconnect_recovery() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let payload = vec![0x5a; control_session_protocol::MAX_DATA_LEN]; + let mut sequence = 1; + loop { + let progress = feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, sequence, payload.clone()), + )?; + if progress.status == InputStatus::Backpressured { + break; + } + sequence += 1; + } + assert!(broker.has_pending_guest_record()); + broker.host_disconnected()?; + assert!(!broker.has_pending_guest_record()); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + Ok(()) + } + + #[test] + fn streams_maximum_ttrpc_message_with_bounded_credit() -> Result<(), BrokerError> { + let mut broker = make_active()?; + let mut remaining = control_session_protocol::MAX_RECEIVE_CREDIT as usize; + let mut host_sequence = 0; + let mut guest_sequence = 1; + let mut offset = 0usize; + while remaining != 0 { + let length = remaining.min(control_session_protocol::MAX_DATA_LEN); + let payload = (offset..offset + length) + .map(|index| (index % 251) as u8) + .collect::>(); + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + host_sequence, + payload.clone(), + ), + )?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.payload, + payload + ); + feed_guest( + &mut broker, + &Record::session( + RecordType::Credit, + INSTANCE, + 1, + guest_sequence, + control_session_protocol::credit_payload(length as u32), + ), + )?; + host_sequence += 1; + guest_sequence += 1; + offset += length; + remaining -= length; + } + assert_eq!( + offset, + control_session_protocol::MAX_RECEIVE_CREDIT as usize + ); + assert_eq!( + broker.guest_receive_credit(), + control_session_protocol::MIN_RECEIVE_CREDIT + ); + Ok(()) + } + + #[test] + fn epoch_overflow_fails_closed() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, b"queued".to_vec()), + )?; + assert!(broker.output_record_count(OutputLegId::Guest) != 0); + broker.epoch = u64::MAX; + assert_eq!(broker.host_disconnected(), Err(BrokerError::EpochOverflow)); + assert_eq!(broker.state(), BrokerState::Failed); + assert_eq!(broker.counters().reset_errors, 1); + assert_eq!(broker.output_record_count(OutputLegId::Guest), 0); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert!(!broker.begin_output(OutputLegId::Guest)); + assert_eq!(broker.peek_output(OutputLegId::Guest, 1), None); + assert_eq!( + broker.advance_output(OutputLegId::Guest, 0), + Err(BrokerError::Failed) + ); + Ok(()) + } + + #[test] + fn sequence_overflow_discards_enqueued_output() -> Result<(), BrokerError> { + let mut broker = make_active()?; + broker.guest_send_sequence = u64::MAX; + assert_eq!( + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, b"queued".to_vec()) + ), + Err(BrokerError::SequenceOverflow) + ); + assert_eq!(broker.state(), BrokerState::Failed); + assert_eq!(broker.output_record_count(OutputLegId::Guest), 0); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert!(!broker.begin_output(OutputLegId::Guest)); + Ok(()) + } + + #[test] + fn restore_preserves_only_partial_guest_physical_records() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 0, b"outbound".to_vec()), + )?; + assert!(broker.begin_output(OutputLegId::Guest)); + broker.advance_output(OutputLegId::Guest, 7)?; + + let old_guest_record = + Record::session(RecordType::Data, INSTANCE, 1, 1, b"inbound".to_vec()); + let old_bytes = control_session_protocol::encode(&old_guest_record)?; + let split = control_session_protocol::HEADER_LEN + 2; + let progress = broker.accept_guest_input(&old_bytes[..split])?; + assert_eq!(progress.status, InputStatus::NeedMore); + + let snapshot = broker.snapshot(); + let new_instance = [0x77; 16]; + let new_capability = [0x88; 32]; + let mut restored = ControlSessionBroker::restore(snapshot, new_instance, new_capability)?; + assert_eq!(restored.state(), BrokerState::ResetPending); + assert_eq!(restored.epoch(), 1); + assert_eq!(restored.instance_id(), new_instance); + assert!(!restored.host_is_authenticated()); + + let progress = restored.accept_guest_input(&old_bytes[split..])?; + assert_eq!(progress.status, InputStatus::RecordAccepted); + let remainder = restored + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + restored.advance_output(OutputLegId::Guest, remainder)?; + let reset = drain_record(&mut restored, OutputLegId::Guest)?; + assert_eq!( + (reset.record_type, reset.instance_id, reset.epoch), + (RecordType::Reset, new_instance, 1) + ); + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(restored.state(), BrokerState::ReadyNoHost); + assert!( + feed_guest( + &mut restored, + &Record::session(RecordType::Data, INSTANCE, 1, 4, vec![1]) + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn restore_keeps_alignment_but_requires_fresh_credit() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_host( + &mut broker, + &Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + b"partially-emitted".to_vec(), + ), + )?; + assert!(broker.begin_output(OutputLegId::Guest)); + broker.advance_output(OutputLegId::Guest, 7)?; + assert!(broker.guest_receive_credit() < control_session_protocol::MIN_RECEIVE_CREDIT); + + let snapshot = broker.snapshot(); + let new_instance = [0x77; 16]; + let mut restored = ControlSessionBroker::restore(snapshot, new_instance, [0x88; 32])?; + assert_eq!( + ( + restored.guest_receive_window(), + restored.guest_receive_credit() + ), + (0, 0) + ); + + let remainder = restored + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + restored.advance_output(OutputLegId::Guest, remainder)?; + assert_eq!( + drain_record(&mut restored, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!( + restored.guest_receive_credit(), + control_session_protocol::MIN_RECEIVE_CREDIT + ); + Ok(()) + } + + #[test] + fn invalid_snapshot_fields_are_rejected() { + let broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + let mut snapshot = broker.snapshot(); + snapshot.state = 99; + assert!(ControlSessionBroker::restore(snapshot, [1; 16], [2; 32]).is_err()); + + let mut snapshot = broker.snapshot(); + snapshot.guest_output.current = Some(EncodedRecordSnapshot { + bytes: vec![1, 2, 3], + offset: 4, + }); + assert!(ControlSessionBroker::restore(snapshot, [1; 16], [2; 32]).is_err()); + } + + #[test] + fn snapshot_debug_redacts_encoded_payloads() { + let current_secret = (0x80..0xa0).collect::>(); + let queued_secret = (0xa0..0xc0).collect::>(); + let pending_secret = (0xc0..0xe0).collect::>(); + let output = OutputSnapshot { + current: Some(EncodedRecordSnapshot { + bytes: current_secret.clone(), + offset: 7, + }), + queued_records: vec![queued_secret.clone()], + }; + let mut snapshot = ControlSessionBroker::new(INSTANCE, CAPABILITY).snapshot(); + snapshot.guest_output = output.clone(); + snapshot.pending_guest_record = Some(Record::session( + RecordType::Data, + INSTANCE, + 1, + 0, + pending_secret.clone(), + )); + + let debug_outputs = [ + format!("{:?}", &output.current), + format!("{output:?}"), + format!("{snapshot:?}"), + ]; + for debug in &debug_outputs { + for secret in [¤t_secret, &queued_secret, &pending_secret] { + assert!(!debug.contains(&format!("{secret:?}"))); + } + } + assert!(debug_outputs[0].contains("bytes_len: 32")); + assert!(debug_outputs[0].contains("offset: 7")); + assert!(debug_outputs[1].contains("queued_record_count: 1")); + assert!(debug_outputs[1].contains("queued_bytes: 32")); + assert!(debug_outputs[2].contains("payload_len: 32")); + } + + #[test] + fn restore_drains_multiple_old_instance_records_and_ack() -> Result<(), BrokerError> { + let broker = make_active()?; + let snapshot = broker.snapshot(); + let new_instance = [0x77; 16]; + let mut restored = ControlSessionBroker::restore(snapshot, new_instance, [0x88; 32])?; + + for (record_type, sequence) in [ + (RecordType::Data, 1), + (RecordType::Data, 2), + (RecordType::Ack, 3), + ] { + let payload = if record_type == RecordType::Data { + vec![sequence as u8] + } else { + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ) + }; + feed_guest( + &mut restored, + &Record::session(record_type, INSTANCE, 1, sequence, payload), + )?; + } + + assert_eq!( + drain_record(&mut restored, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(restored.state(), BrokerState::ReadyNoHost); + Ok(()) + } + + #[test] + fn restore_finishes_old_reset_before_new_reset() -> Result<(), BrokerError> { + let mut broker = ControlSessionBroker::new(INSTANCE, CAPABILITY); + feed_guest( + &mut broker, + &Record::bootstrap(RecordType::GuestAttach, Vec::new()), + )?; + assert!(broker.begin_output(OutputLegId::Guest)); + broker.advance_output(OutputLegId::Guest, 7)?; + + let new_instance = [0x77; 16]; + let mut restored = + ControlSessionBroker::restore(broker.snapshot(), new_instance, [0x88; 32])?; + let old_remainder = restored + .peek_output(OutputLegId::Guest, usize::MAX) + .ok_or(BrokerError::InvalidOutputProgress)? + .len(); + restored.advance_output(OutputLegId::Guest, old_remainder)?; + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + INSTANCE, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + let reset = drain_record(&mut restored, OutputLegId::Guest)?; + assert_eq!( + (reset.record_type, reset.instance_id, reset.epoch), + (RecordType::Reset, new_instance, 1) + ); + feed_guest( + &mut restored, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(restored.state(), BrokerState::ReadyNoHost); + Ok(()) + } + + #[test] + fn repeated_completed_restores_do_not_accumulate_identity_state() -> Result<(), BrokerError> { + let mut broker = make_active()?; + for index in 0..8u8 { + let new_instance = [0x80 + index; 16]; + broker = + ControlSessionBroker::restore(broker.snapshot(), new_instance, [0x40 + index; 32])?; + assert_eq!( + drain_record(&mut broker, OutputLegId::Guest)?.record_type, + RecordType::Reset + ); + feed_guest( + &mut broker, + &Record::session( + RecordType::Ack, + new_instance, + 1, + 0, + control_session_protocol::credit_payload( + control_session_protocol::MIN_RECEIVE_CREDIT, + ), + ), + )?; + assert_eq!(broker.state(), BrokerState::ReadyNoHost); + assert!(!broker.drain_foreign_instance_records); + } + Ok(()) + } + + #[test] + fn device_reset_discards_attachment_and_protocol_progress() -> Result<(), BrokerError> { + let mut broker = make_active()?; + feed_guest( + &mut broker, + &Record::session(RecordType::Data, INSTANCE, 1, 1, b"queued".to_vec()), + )?; + assert!(broker.output_record_count(OutputLegId::Host) != 0); + + broker.reset_for_device(); + + assert_eq!(broker.state(), BrokerState::AwaitGuestAttach); + assert_eq!(broker.epoch(), 1); + assert!(!broker.host_is_connected()); + assert!(!broker.host_is_authenticated()); + assert_eq!(broker.output_record_count(OutputLegId::Guest), 0); + assert_eq!(broker.output_record_count(OutputLegId::Host), 0); + assert_eq!(broker.guest_parser_buffered_bytes(), 0); + Ok(()) + } +} diff --git a/vm/devices/virtio/virtio_console/src/lib.rs b/vm/devices/virtio/virtio_console/src/lib.rs index 5b6dabc9ef4..efe77bd980d 100644 --- a/vm/devices/virtio/virtio_console/src/lib.rs +++ b/vm/devices/virtio/virtio_console/src/lib.rs @@ -36,8 +36,9 @@ #[cfg_attr( not(test), - expect(dead_code, reason = "the control-session broker consumes the protocol") + expect(dead_code, reason = "the broker worker drives the state machine") )] +pub(crate) mod control_session_broker; pub(crate) mod control_session_protocol; mod direct; pub mod resolver; From cf99b6a67d6b8e4f1b3e36c9977273af26a40103 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:08:04 -0700 Subject: [PATCH 145/304] virtio_console: attach the broker to serial I/O with receive credits The broker from the previous commit is a pure state machine. Give it a device: a virtio-console whose worker moves bytes between the guest queues, the broker, and a reconnectable host serial endpoint. Resource and resolver: - `VirtioControlConsoleHandle`, with resource ID `virtio-control-console`, names the host serial backend, an optional console attachment, and a `VirtioControlConsoleBrokerConfig`: the broker instance ID and capability, the local peer identity the host must have, and the authentication timeout. virtio_resources depends on serial_core for the identity type. - `VirtioControlConsoleResolver` checks the attachment and the config (nonzero instance ID and capability, a timeout from 1 ms to 60 s), resolves the serial backend, and builds the device with `VirtioConsoleDevice::new_broker`. openvmm_resources registers it. The console worker gains a `Broker` mode next to direct forwarding: - The host slot is reserved only for a peer whose `local_peer_identity` equals the configured identity. Any other peer, or a backend that reports no identity, is disconnected with `disconnect_current` before the broker reads any of its bytes. - Attaching starts the authentication deadline. A host that has not authenticated when it expires is detached without advancing the epoch. - The host transport is tracked as waiting for a connection, connected, waiting for a disconnect (when the backend cannot evict its peer), or disabled after a connect or disconnect error. A disabled transport stops host traffic, but the guest side keeps running. - Guest output records are written into receive buffers, and transmit buffers are fed to the broker, keeping the offset into a partially consumed descriptor across backpressure. Host output is written with bounded writes, and host input is read in 4-KiB slabs. While the broker holds a pending host record, because the guest has no credit or the guest queue is full, the worker stops reading from the host, so buffered host input stays bounded. - No host input is read while console input is gated for a snapshot. - Invalid host input detaches the host. A worker fault, such as invalid guest input, detaches the host and parks the worker until a device reset rebuilds the broker. - A device reset resets the broker and disconnects the host, which must attach again. A host that connected before the guest first attached is kept. - Inspection reports the broker state, epoch, transport state, authentication, queue depths, and error counters. The broker's saved state follows in the next commit. Until then, saving a control console fails with `SaveError::NotSupported`, and restoring one fails with `SavedStateNotSupported`. The broker's snapshot API is still unused outside tests, so its module declaration keeps a non-test `expect(dead_code)` until then. The new broker tests drive the device through real virtio queues with a mock serial backend that can block writes, inject connect and disconnect errors, and report a chosen peer identity. They cover cold attach with data in both directions, rejecting a wrong identity before the host slot is reserved, a wrong capability and an authentication timeout without epoch advance, input gating, host lifecycle errors that leave the guest running, a disconnect that sends `RESET` without guest EOF, disconnect and reconnect with zero credit, finishing a partial `DATA` record before `RESET`, device resets, fragmentation, partial host writes and backpressure, and malformed input from either side. A resource test checks that the control-console ID differs from the plain console's. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 1 + openvmm/openvmm_resources/src/lib.rs | 1 + .../virtio/virtio_console/src/broker.rs | 577 +++++++++++++++ .../virtio/virtio_console/src/direct.rs | 16 +- vm/devices/virtio/virtio_console/src/lib.rs | 17 +- .../virtio/virtio_console/src/resolver.rs | 1 + .../virtio_console/src/resolver/control.rs | 81 +++ .../virtio/virtio_console/src/saved_state.rs | 25 +- vm/devices/virtio/virtio_console/src/tests.rs | 1 + .../virtio/virtio_console/src/tests/broker.rs | 663 ++++++++++++++++++ .../virtio_console/src/tests/harness.rs | 181 ++++- vm/devices/virtio/virtio_resources/Cargo.toml | 1 + .../virtio_resources/src/console/control.rs | 54 ++ vm/devices/virtio/virtio_resources/src/lib.rs | 1 + 14 files changed, 1598 insertions(+), 22 deletions(-) create mode 100644 vm/devices/virtio/virtio_console/src/broker.rs create mode 100644 vm/devices/virtio/virtio_console/src/resolver/control.rs create mode 100644 vm/devices/virtio/virtio_console/src/tests/broker.rs create mode 100644 vm/devices/virtio/virtio_resources/src/console/control.rs diff --git a/Cargo.lock b/Cargo.lock index 2d5e8459313..2727f6bfd35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10175,6 +10175,7 @@ version = "0.0.0" dependencies = [ "mesh", "net_backend_resources", + "serial_core", "unix_socket", "vm_resource", ] diff --git a/openvmm/openvmm_resources/src/lib.rs b/openvmm/openvmm_resources/src/lib.rs index 53c6272b869..55eb1edc361 100644 --- a/openvmm/openvmm_resources/src/lib.rs +++ b/openvmm/openvmm_resources/src/lib.rs @@ -103,6 +103,7 @@ vm_resource::register_static_resolvers! { // Virtio devices virtio_blk::resolver::VirtioBlkResolver, + virtio_console::resolver::control::VirtioControlConsoleResolver, virtio_console::resolver::VirtioConsoleResolver, #[cfg(any(windows, target_os = "linux"))] virtiofs::resolver::VirtioFsResolver, diff --git a/vm/devices/virtio/virtio_console/src/broker.rs b/vm/devices/virtio/virtio_console/src/broker.rs new file mode 100644 index 00000000000..18608dd96c4 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/broker.rs @@ -0,0 +1,577 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Control-session broker worker mode. +//! +//! [`VirtioConsoleDevice::new_broker`] creates the control console. Its worker +//! runs the control-session broker between the guest queues and a +//! reconnectable host endpoint: it only attaches a host whose local peer +//! identity matches the configured one, bounds host authentication with a +//! timeout, and tracks the host endpoint's connection lifecycle in +//! [`HostTransportState`]. + +use crate::BUF_SIZE; +use crate::ConsoleWorker; +use crate::ConsoleWorkerState; +use crate::VirtioConsoleDevice; +use crate::WorkerError; +use crate::control_session_broker; +use crate::direct::ConsoleWorkerMode; +use crate::spec::VirtioConsoleConfig; +use guestmem::GuestMemory; +use inspect::Inspect; +use pal_async::timer::Instant; +use pal_async::timer::PolledTimer; +use serial_core::SerialIo; +use std::collections::VecDeque; +use std::future::poll_fn; +use std::pin::Pin; +use std::task::Context; +use std::task::Poll; +use std::time::Duration; +use task_control::TaskControl; +use virtio_resources::console::control::VirtioControlConsoleBrokerConfig; +use vmcore::vm_task::VmTaskDriverSource; + +impl VirtioConsoleDevice { + /// Creates the control console with a reconnectable host endpoint and a + /// VMM-resident session broker. + pub fn new_broker( + driver_source: &VmTaskDriverSource, + host_io: Box, + config: VirtioControlConsoleBrokerConfig, + ) -> Self { + let driver = driver_source.simple(); + let transport_state = initial_host_transport_state(&*host_io); + let broker = control_session_broker::ControlSessionBroker::new( + config.instance_id, + config.capability, + ); + let auth_timer = PolledTimer::new(&driver); + let mut worker = TaskControl::new(ConsoleWorker { + mode: ConsoleWorkerMode::Broker(Box::new(BrokerWorker { + host_io, + broker, + config, + transport_state, + host_input: VecDeque::new(), + auth_timer, + auth_deadline: None, + })), + }); + worker.insert( + &driver, + "virtio-control-console", + ConsoleWorkerState { + receiveq: None, + transmitq: None, + mem: GuestMemory::empty(), + partial_transmit: 0, + staged_rx: VecDeque::new(), + input_gated: false, + }, + ); + Self { + driver, + config: VirtioConsoleConfig::default(), + worker, + } + } +} + +pub(crate) struct BrokerWorker { + pub(crate) host_io: Box, + pub(crate) broker: control_session_broker::ControlSessionBroker, + pub(crate) config: VirtioControlConsoleBrokerConfig, + pub(crate) transport_state: HostTransportState, + pub(crate) host_input: VecDeque, + auth_timer: PolledTimer, + pub(crate) auth_deadline: Option, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum HostTransportState { + WaitingForDisconnect, + WaitingForConnect, + Connected, + Disabled, +} + +pub(crate) fn initial_host_transport_state(host_io: &dyn SerialIo) -> HostTransportState { + if host_io.is_connected() { + HostTransportState::Connected + } else { + HostTransportState::WaitingForConnect + } +} + +pub(crate) fn disconnect_host_transport(host_io: &mut dyn SerialIo) -> HostTransportState { + if !host_io.is_connected() || host_io.disconnect_current().is_ok() { + HostTransportState::WaitingForConnect + } else { + HostTransportState::WaitingForDisconnect + } +} + +impl Inspect for BrokerWorker { + fn inspect(&self, req: inspect::Request<'_>) { + let counters = self.broker.counters(); + req.respond() + .field("mode", "broker") + .field("broker_state", format!("{:?}", self.broker.state())) + .field("epoch", self.broker.epoch()) + .field("host_transport", format!("{:?}", self.transport_state)) + .field("host_authenticated", self.broker.host_is_authenticated()) + .field( + "guest_output_records", + self.broker + .output_record_count(control_session_broker::OutputLegId::Guest), + ) + .field( + "guest_output_bytes", + self.broker + .output_byte_count(control_session_broker::OutputLegId::Guest), + ) + .field( + "host_output_records", + self.broker + .output_record_count(control_session_broker::OutputLegId::Host), + ) + .field( + "host_output_bytes", + self.broker + .output_byte_count(control_session_broker::OutputLegId::Host), + ) + .field( + "guest_parser_bytes", + self.broker.guest_parser_buffered_bytes(), + ) + .field("guest_receive_window", self.broker.guest_receive_window()) + .field("guest_receive_credit", self.broker.guest_receive_credit()) + .field("host_input_bytes", self.host_input.len()) + .field("protocol_errors", counters.protocol_errors) + .field("authentication_errors", counters.authentication_errors) + .field("sequence_errors", counters.sequence_errors) + .field("ack_errors", counters.ack_errors) + .field("reset_errors", counters.reset_errors) + .field("reconnect_errors", counters.reconnect_errors) + .field("backpressure_errors", counters.backpressure_errors); + } +} + +impl BrokerWorker { + /// Applies a guest-initiated device reset to the broker and host transport. + pub(crate) fn reset_for_device(&mut self) { + let preserve_unstarted_host = self.broker.state() + == control_session_broker::BrokerState::AwaitGuestAttach + && !self.broker.host_is_connected() + && self.transport_state == HostTransportState::Connected; + self.broker.reset_for_device(); + self.host_input.clear(); + self.auth_deadline = None; + self.transport_state = if preserve_unstarted_host { + if self.host_io.is_connected() { + HostTransportState::Connected + } else { + HostTransportState::WaitingForDisconnect + } + } else { + disconnect_host_transport(&mut *self.host_io) + }; + } + + pub(crate) async fn run_loop( + &mut self, + state: &mut ConsoleWorkerState, + ) -> Result<(), WorkerError> { + if state.receiveq.is_none() && state.transmitq.is_none() { + std::future::pending::<()>().await; + } + match poll_fn(|cx| self.poll_once(state, cx)).await { + Ok(()) => Ok(()), + Err(error) => { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "control-console worker faulted" + ); + if let Err(detach_error) = self.detach_host() { + tracelimit::error_ratelimited!( + error = &detach_error as &dyn std::error::Error, + "control-console host detach failed after worker fault" + ); + } + // Keep TaskControl restartable. Device reset clears the broker + // and queue state before the worker is started again. + std::future::pending().await + } + } + } + + fn poll_once( + &mut self, + state: &mut ConsoleWorkerState, + cx: &mut Context<'_>, + ) -> Poll> { + let mut made_progress = false; + + match self.transport_state { + HostTransportState::WaitingForDisconnect => match self.host_io.poll_disconnect(cx) { + Poll::Ready(Ok(())) => { + self.detach_host()?; + self.transport_state = HostTransportState::WaitingForConnect; + made_progress = true; + } + Poll::Ready(Err(error)) => { + self.disable_host_transport(error)?; + made_progress = true; + } + Poll::Pending => {} + }, + HostTransportState::WaitingForConnect => match self.host_io.poll_connect(cx) { + Poll::Ready(Ok(())) => { + self.transport_state = HostTransportState::Connected; + self.begin_verified_host_attachment()?; + made_progress = true; + } + Poll::Ready(Err(error)) => { + self.disable_host_transport(error)?; + made_progress = true; + } + Poll::Pending => {} + }, + HostTransportState::Connected => { + if !self.broker.host_is_connected() { + self.begin_verified_host_attachment()?; + made_progress = true; + } + match self.host_io.poll_disconnect(cx) { + Poll::Ready(Ok(())) => { + self.detach_host()?; + made_progress = true; + } + Poll::Ready(Err(error)) => { + self.disable_host_transport(error)?; + made_progress = true; + } + Poll::Pending => {} + } + } + HostTransportState::Disabled => {} + } + + if self.transport_state == HostTransportState::Connected + && self.broker.host_is_connected() + && !self.broker.host_is_authenticated() + && let Some(deadline) = self.auth_deadline + && self.auth_timer.poll_until(cx, deadline).is_ready() + { + tracelimit::warn_ratelimited!("control-console host authentication timed out"); + self.detach_host()?; + made_progress = true; + } + + made_progress |= self.poll_guest_output(state, cx)?; + made_progress |= self.poll_guest_input(state, cx)?; + + if self.transport_state == HostTransportState::Connected { + made_progress |= self.poll_host_output(cx)?; + if self.transport_state == HostTransportState::Connected && !state.input_gated { + made_progress |= self.poll_host_input(cx)?; + } + if self.broker.host_is_authenticated() { + self.auth_deadline = None; + } + } + + if made_progress { + cx.waker().wake_by_ref(); + } + Poll::Pending + } + + fn poll_guest_output( + &mut self, + state: &mut ConsoleWorkerState, + cx: &mut Context<'_>, + ) -> Result { + let Some(receiveq) = state.receiveq.as_mut() else { + return Ok(false); + }; + if !self + .broker + .begin_output(control_session_broker::OutputLegId::Guest) + { + return Ok(false); + } + + let work = match receiveq.try_peek().map_err(WorkerError::Virtio)? { + Some(work) => work, + None => { + return Ok(receiveq.poll_kick(cx).is_ready()); + } + }; + let writeable_len = work + .payload() + .iter() + .filter(|payload| payload.writeable) + .map(|payload| payload.length as usize) + .sum::(); + if writeable_len == 0 { + let work = work.consume(); + receiveq.complete(work, 0); + return Ok(true); + } + + let mut bytes = [0; BUF_SIZE]; + let count = { + let output = self + .broker + .peek_output( + control_session_broker::OutputLegId::Guest, + writeable_len.min(BUF_SIZE), + ) + .ok_or(control_session_broker::BrokerError::InvalidOutputProgress) + .map_err(WorkerError::Broker)?; + bytes[..output.len()].copy_from_slice(output); + output.len() + }; + let work = work.consume(); + if let Err(error) = work.write(&state.mem, &bytes[..count]) { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "failed to write broker output to guest receive buffer" + ); + receiveq.complete(work, 0); + } else { + self.broker + .advance_output(control_session_broker::OutputLegId::Guest, count) + .map_err(WorkerError::Broker)?; + receiveq.complete(work, count as u32); + } + Ok(true) + } + + fn poll_guest_input( + &mut self, + state: &mut ConsoleWorkerState, + cx: &mut Context<'_>, + ) -> Result { + if self.broker.has_pending_guest_record() { + let progress = self + .broker + .accept_guest_input(&[]) + .map_err(WorkerError::Broker)?; + if progress.status != control_session_broker::InputStatus::Backpressured { + return Ok(true); + } + } + + let Some(transmitq) = state.transmitq.as_mut() else { + return Ok(false); + }; + let work = match transmitq.try_peek().map_err(WorkerError::Virtio)? { + Some(work) => work, + None => { + return Ok(transmitq.poll_kick(cx).is_ready()); + } + }; + let readable_len = work.readable_length() as usize; + if state.partial_transmit >= readable_len { + state.partial_transmit = 0; + let work = work.consume(); + transmitq.complete(work, 0); + return Ok(true); + } + + let mut bytes = [0; BUF_SIZE]; + let requested = (readable_len - state.partial_transmit).min(BUF_SIZE); + let read = work + .read_at_offset( + state.partial_transmit as u64, + &state.mem, + &mut bytes[..requested], + ) + .map_err(WorkerError::GuestMemory)?; + if read == 0 { + return Ok(false); + } + + let mut offset = 0; + while offset < read { + let progress = self + .broker + .accept_guest_input(&bytes[offset..read]) + .map_err(WorkerError::Broker)?; + offset += progress.consumed; + state.partial_transmit += progress.consumed; + if progress.status == control_session_broker::InputStatus::Backpressured + || progress.consumed == 0 + { + break; + } + } + if state.partial_transmit == readable_len { + state.partial_transmit = 0; + let work = work.consume(); + transmitq.complete(work, 0); + } + Ok(offset != 0) + } + + fn poll_host_output(&mut self, cx: &mut Context<'_>) -> Result { + if !self + .broker + .begin_output(control_session_broker::OutputLegId::Host) + { + return Ok(false); + } + let mut bytes = [0; BUF_SIZE]; + let count = { + let output = self + .broker + .peek_output(control_session_broker::OutputLegId::Host, BUF_SIZE) + .ok_or(control_session_broker::BrokerError::InvalidOutputProgress) + .map_err(WorkerError::Broker)?; + bytes[..output.len()].copy_from_slice(output); + output.len() + }; + match Pin::new(&mut *self.host_io).poll_write(cx, &bytes[..count]) { + Poll::Ready(Ok(0)) => { + tracelimit::warn_ratelimited!( + broker_state = ?self.broker.state(), + "control-console host write returned EOF" + ); + self.detach_host()?; + Ok(true) + } + Poll::Ready(Ok(written)) => { + self.broker + .advance_output(control_session_broker::OutputLegId::Host, written) + .map_err(WorkerError::Broker)?; + Ok(true) + } + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + broker_state = ?self.broker.state(), + error = &error as &dyn std::error::Error, + "control-console host write failed" + ); + self.detach_host()?; + Ok(true) + } + Poll::Pending => Ok(false), + } + } + + fn poll_host_input(&mut self, cx: &mut Context<'_>) -> Result { + if self.broker.has_pending_host_record() || !self.host_input.is_empty() { + let progress = if self.broker.has_pending_host_record() { + self.broker.accept_host_input(&[]) + } else { + let input = self.host_input.make_contiguous(); + self.broker.accept_host_input(input) + }; + match progress { + Ok(progress) => { + self.host_input.drain(..progress.consumed); + return Ok(progress.consumed != 0 + || progress.status != control_session_broker::InputStatus::Backpressured); + } + Err(error) => { + tracelimit::warn_ratelimited!( + error = &error as &dyn std::error::Error, + "control-console host protocol input rejected" + ); + self.detach_host()?; + return Ok(true); + } + } + } + + let mut bytes = [0; BUF_SIZE]; + match Pin::new(&mut *self.host_io).poll_read(cx, &mut bytes) { + Poll::Ready(Ok(0)) => { + tracelimit::warn_ratelimited!( + broker_state = ?self.broker.state(), + "control-console host read returned EOF" + ); + self.detach_host()?; + Ok(true) + } + Poll::Ready(Ok(read)) => { + self.host_input.extend(&bytes[..read]); + Ok(true) + } + Poll::Ready(Err(error)) => { + tracelimit::error_ratelimited!( + broker_state = ?self.broker.state(), + error = &error as &dyn std::error::Error, + "control-console host read failed" + ); + self.detach_host()?; + Ok(true) + } + Poll::Pending => Ok(false), + } + } + + fn detach_host(&mut self) -> Result<(), WorkerError> { + let broker_result = self.broker.host_disconnected().map_err(WorkerError::Broker); + self.host_input.clear(); + self.auth_deadline = None; + self.transport_state = disconnect_host_transport(&mut *self.host_io); + broker_result + } + + fn begin_verified_host_attachment(&mut self) -> Result<(), WorkerError> { + let identity = self.host_io.local_peer_identity(); + let identity_status = match &identity { + Ok(Some(identity)) if identity == &self.config.expected_peer_identity => "expected", + Ok(Some(serial_core::LocalPeerIdentity::Unsupported)) => "unsupported", + Ok(Some(_)) => "unexpected", + Ok(None) => "missing", + Err(_) => "error", + }; + let identity_error_kind = identity.as_ref().err().map(std::io::Error::kind); + if !matches!( + identity, + Ok(Some(ref identity)) if identity == &self.config.expected_peer_identity + ) { + tracelimit::warn_ratelimited!( + broker_state = ?self.broker.state(), + identity_status, + identity_error_kind = ?identity_error_kind, + "control-console host rejected because its local peer identity is unavailable or unexpected" + ); + self.detach_host()?; + return Ok(()); + } + match self.broker.begin_host_attachment() { + Ok(()) => { + self.auth_deadline = Some( + Instant::now() + .saturating_add(Duration::from_millis(self.config.auth_timeout_ms)), + ); + } + Err(error) => { + tracelimit::warn_ratelimited!( + error = &error as &dyn std::error::Error, + "control-console host attachment rejected" + ); + self.auth_deadline = None; + self.detach_host()?; + } + } + Ok(()) + } + + fn disable_host_transport(&mut self, error: std::io::Error) -> Result<(), WorkerError> { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "control-console host transport disabled after a lifecycle error" + ); + self.detach_host()?; + self.transport_state = HostTransportState::Disabled; + Ok(()) + } +} diff --git a/vm/devices/virtio/virtio_console/src/direct.rs b/vm/devices/virtio/virtio_console/src/direct.rs index c116efa7874..9787ae20f0c 100644 --- a/vm/devices/virtio/virtio_console/src/direct.rs +++ b/vm/devices/virtio/virtio_console/src/direct.rs @@ -3,8 +3,9 @@ //! Console worker modes and the direct forwarding mode. //! -//! The worker runs in a [`ConsoleWorkerMode`]: direct forwarding between the -//! virtio queues and a [`SerialIo`] backend. In direct mode a +//! The worker runs in one of two [`ConsoleWorkerMode`]s: direct forwarding +//! between the virtio queues and a [`SerialIo`] backend, or the control-session +//! broker (see [`crate::broker`]). In direct mode a //! [`VirtioConsoleDisconnectPolicy`] selects whether guest output is discarded //! or retained while the backend is disconnected, and host input is staged in //! the worker state before it is copied to a guest buffer, so input already @@ -15,9 +16,11 @@ use crate::ConsoleWorker; use crate::ConsoleWorkerState; use crate::VirtioConsoleDevice; use crate::WorkerError; +use crate::broker::BrokerWorker; use crate::spec::VirtioConsoleConfig; use futures::AsyncRead; use guestmem::GuestMemory; +use inspect::Inspect; use inspect::InspectMut; use serial_core::SerialIo; use std::collections::VecDeque; @@ -34,12 +37,17 @@ pub(crate) enum ConsoleWorkerMode { io: Box, disconnect_policy: VirtioConsoleDisconnectPolicy, }, + Broker(Box), } impl InspectMut for ConsoleWorker { fn inspect_mut(&mut self, req: inspect::Request<'_>) { - let ConsoleWorkerMode::Direct { io, .. } = &mut self.mode; - req.respond().field("mode", "direct").field_mut("io", io); + match &mut self.mode { + ConsoleWorkerMode::Direct { io, .. } => { + req.respond().field("mode", "direct").field_mut("io", io); + } + ConsoleWorkerMode::Broker(mode) => mode.inspect(req), + } } } diff --git a/vm/devices/virtio/virtio_console/src/lib.rs b/vm/devices/virtio/virtio_console/src/lib.rs index efe77bd980d..12088526177 100644 --- a/vm/devices/virtio/virtio_console/src/lib.rs +++ b/vm/devices/virtio/virtio_console/src/lib.rs @@ -34,9 +34,10 @@ #![forbid(unsafe_code)] +mod broker; #[cfg_attr( not(test), - expect(dead_code, reason = "the broker worker drives the state machine") + expect(dead_code, reason = "saved state consumes the broker snapshot") )] pub(crate) mod control_session_broker; pub(crate) mod control_session_protocol; @@ -252,6 +253,8 @@ enum WorkerError { Serial(#[source] std::io::Error), #[error("guest memory error")] GuestMemory(#[source] guestmem::GuestMemoryError), + #[error("control-session broker error")] + Broker(#[source] control_session_broker::BrokerError), } impl ConsoleWorker { @@ -261,11 +264,13 @@ impl ConsoleWorker { /// So, be careful not to leave any state in a weird intermediate state across /// an await point. async fn run_loop(&mut self, state: &mut ConsoleWorkerState) -> Result<(), WorkerError> { - let direct::ConsoleWorkerMode::Direct { - io: serial_io, - disconnect_policy, - } = &mut self.mode; - let disconnect_policy = *disconnect_policy; + let (serial_io, disconnect_policy) = match &mut self.mode { + direct::ConsoleWorkerMode::Direct { + io, + disconnect_policy, + } => (io, *disconnect_policy), + direct::ConsoleWorkerMode::Broker(mode) => return mode.run_loop(state).await, + }; let mut connected: bool = serial_io.is_connected(); let receiveq = &mut state.receiveq; let transmitq = &mut state.transmitq; diff --git a/vm/devices/virtio/virtio_console/src/resolver.rs b/vm/devices/virtio/virtio_console/src/resolver.rs index 9218edfdb40..e5025f8be0d 100644 --- a/vm/devices/virtio/virtio_console/src/resolver.rs +++ b/vm/devices/virtio/virtio_console/src/resolver.rs @@ -4,6 +4,7 @@ //! Resource resolver for virtio-console devices. mod attachment; +pub mod control; use crate::VirtioConsoleDevice; use async_trait::async_trait; diff --git a/vm/devices/virtio/virtio_console/src/resolver/control.rs b/vm/devices/virtio/virtio_console/src/resolver/control.rs new file mode 100644 index 00000000000..3acac5cd637 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/resolver/control.rs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resource resolver for the control console. +//! +//! The control console is a virtio-console device whose worker runs the +//! control-session broker (see [`VirtioConsoleDevice::new_broker`]). + +use super::attachment::validate_attachment; +use crate::VirtioConsoleDevice; +use async_trait::async_trait; +use serial_core::resources::ResolveSerialBackendParams; +use virtio::resolve::ResolvedVirtioDevice; +use virtio::resolve::VirtioResolveInput; +use virtio_resources::console::control::VirtioControlConsoleBrokerConfig; +use virtio_resources::console::control::VirtioControlConsoleHandle; +use vm_resource::AsyncResolveResource; +use vm_resource::ResourceResolver; +use vm_resource::declare_static_async_resolver; +use vm_resource::kind::VirtioDeviceHandle; + +/// Resolver for the microVM control console resource identity. +pub struct VirtioControlConsoleResolver; + +declare_static_async_resolver! { + VirtioControlConsoleResolver, + (VirtioDeviceHandle, VirtioControlConsoleHandle), +} + +#[async_trait] +impl AsyncResolveResource + for VirtioControlConsoleResolver +{ + type Output = ResolvedVirtioDevice; + type Error = anyhow::Error; + + async fn resolve( + &self, + resolver: &ResourceResolver, + resource: VirtioControlConsoleHandle, + input: VirtioResolveInput<'_>, + ) -> Result { + validate_attachment(resource.attachment.as_ref())?; + validate_broker_config(&resource.broker_config)?; + let io = resolve_backend(resolver, &input, resource.backend).await?; + Ok(VirtioConsoleDevice::new_broker(input.driver_source, io, resource.broker_config).into()) + } +} + +fn validate_broker_config(config: &VirtioControlConsoleBrokerConfig) -> anyhow::Result<()> { + anyhow::ensure!( + config.instance_id != [0; 16], + "control-console broker instance ID must not be zero" + ); + anyhow::ensure!( + config.capability != [0; 32], + "control-console broker capability must not be zero" + ); + anyhow::ensure!( + (1..=60_000).contains(&config.auth_timeout_ms), + "control-console broker authentication timeout must be between 1 and 60000 ms" + ); + Ok(()) +} + +async fn resolve_backend( + resolver: &ResourceResolver, + input: &VirtioResolveInput<'_>, + backend: vm_resource::Resource, +) -> anyhow::Result> { + let io = resolver + .resolve( + backend, + ResolveSerialBackendParams { + driver: Box::new(input.driver_source.simple()), + _async_trait_workaround: &(), + }, + ) + .await?; + Ok(io.0.into_io()) +} diff --git a/vm/devices/virtio/virtio_console/src/saved_state.rs b/vm/devices/virtio/virtio_console/src/saved_state.rs index b93a1596e42..dc1b18ddfb9 100644 --- a/vm/devices/virtio/virtio_console/src/saved_state.rs +++ b/vm/devices/virtio/virtio_console/src/saved_state.rs @@ -38,12 +38,15 @@ impl VirtioConsoleDevice { /// Resets the private state that persists across queue restarts. pub(crate) fn reset_private_state(&mut self) { self.config = VirtioConsoleConfig::default(); - let (_, mut state) = self.worker.get_mut(); + let (worker, mut state) = self.worker.get_mut(); let state = state.as_mut().unwrap(); state.partial_transmit = 0; state.staged_rx.clear(); state.input_gated = false; state.mem = GuestMemory::empty(); + if let ConsoleWorkerMode::Broker(mode) = &mut worker.mode { + mode.reset_for_device(); + } } /// Stops or resumes accepting new host input, preserving device and queue @@ -73,7 +76,10 @@ impl VirtioConsoleDevice { } let ConsoleWorkerMode::Direct { disconnect_policy, .. - } = &worker.mode; + } = &worker.mode + else { + return Err(SaveError::NotSupported); + }; Ok(Some(SavedStateBlob::new(SavedState { schema_version: DIRECT_SAVED_STATE_VERSION, columns: self.config.cols.into(), @@ -151,14 +157,17 @@ pub(crate) fn check_restored_tx_offset( #[derive(Clone, Copy)] enum SavedStateMode { Direct(VirtioConsoleDisconnectPolicy), + Broker, } impl ConsoleWorkerMode { fn validation_mode(&self) -> SavedStateMode { - let Self::Direct { - disconnect_policy, .. - } = self; - SavedStateMode::Direct(*disconnect_policy) + match self { + Self::Direct { + disconnect_policy, .. + } => SavedStateMode::Direct(*disconnect_policy), + Self::Broker(_) => SavedStateMode::Broker, + } } } @@ -184,7 +193,9 @@ fn validate_saved_state( )); } let saved: SavedState = state.parse()?; - let SavedStateMode::Direct(disconnect_policy) = mode; + let SavedStateMode::Direct(disconnect_policy) = mode else { + return Err(RestoreError::SavedStateNotSupported); + }; if saved.schema_version != DIRECT_SAVED_STATE_VERSION { return Err(invalid_saved_state(format!( "direct console requires schema version {DIRECT_SAVED_STATE_VERSION}, got {}", diff --git a/vm/devices/virtio/virtio_console/src/tests.rs b/vm/devices/virtio/virtio_console/src/tests.rs index 74a0b0468ff..b9ef62271d8 100644 --- a/vm/devices/virtio/virtio_console/src/tests.rs +++ b/vm/devices/virtio/virtio_console/src/tests.rs @@ -7,6 +7,7 @@ //! serial IO backend, and real virtio queues — then drive requests through the //! descriptor rings just as a guest driver would. +mod broker; mod direct; mod harness; mod saved_state; diff --git a/vm/devices/virtio/virtio_console/src/tests/broker.rs b/vm/devices/virtio/virtio_console/src/tests/broker.rs new file mode 100644 index 00000000000..95050185115 --- /dev/null +++ b/vm/devices/virtio/virtio_console/src/tests/broker.rs @@ -0,0 +1,663 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the control-session broker worker mode. + +use super::QUEUE_SIZE; +use super::TestHarness; +use super::harness::BROKER_CAPABILITY; +use super::harness::BROKER_INSTANCE; +use super::harness::ack; +use super::harness::decode; +use super::harness::encode; +use super::harness::yield_now; +use super::harness::yield_until; +use crate::broker::HostTransportState; +use crate::broker::disconnect_host_transport; +use crate::broker::initial_host_transport_state; +use crate::control_session_protocol; +use crate::control_session_protocol::Record; +use crate::control_session_protocol::RecordType; +use pal_async::DefaultDriver; +use pal_async::async_test; +use serial_core::LocalPeerIdentity; +use serial_core::disconnected::Disconnected; +use std::time::Duration; +use test_with_tracing::test; +use virtio::VirtioDevice; + +#[test] +fn disconnected_broker_transport_waits_for_connect() { + let mut io = Disconnected; + assert_eq!( + initial_host_transport_state(&io), + HostTransportState::WaitingForConnect + ); + assert_eq!( + disconnect_host_transport(&mut io), + HostTransportState::WaitingForConnect + ); +} + +async fn activate_broker(harness: &mut TestHarness) { + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!(reset.record_type, RecordType::Reset); + assert_eq!(reset.instance_id, BROKER_INSTANCE); + + harness + .send_guest_bytes(1, &encode(&ack(BROKER_INSTANCE, 1))) + .await; + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let ready = decode(&harness.handle.take_tx_data()); + assert_eq!(ready.record_type, RecordType::Ready); +} + +#[async_test] +async fn broker_cold_attach_ready_and_bidirectional_data(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!(wait.record_type, RecordType::Wait); + + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 1)); + harness + .send_guest_bytes(1, &encode(&ack(BROKER_INSTANCE, 1))) + .await; + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Ready + ); + + harness + .send_guest_bytes( + 2, + &encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + b"guest-to-host".to_vec(), + )), + ) + .await; + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN + 13) + .await; + let host_record = decode(&harness.handle.take_tx_data()); + assert_eq!(host_record.payload, b"guest-to-host"); + + harness.handle.inject_rx_data(&encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + b"host-to-guest".to_vec(), + ))); + let guest_record = decode(&harness.receive_guest_bytes(1, 128).await); + assert_eq!(guest_record.payload, b"host-to-guest"); +} + +#[async_test] +async fn broker_rejects_wrong_identity_before_reserving_host_slot(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness + .handle + .set_peer_identity(Some(LocalPeerIdentity::UnixUid(1001))); + harness.enable().await; + yield_until(|| !harness.handle.is_connected()).await; + + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 1)); + + harness + .handle + .set_peer_identity(Some(LocalPeerIdentity::UnixUid(1000))); + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!((wait.record_type, wait.epoch), (RecordType::Wait, 1)); +} + +#[async_test] +async fn broker_rejects_wrong_capability_without_epoch_advance(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + [0x7d; 32].to_vec(), + ))); + yield_until(|| !harness.handle.is_connected()).await; + assert!(harness.handle.tx_data().is_empty()); + + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 1)); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!((wait.record_type, wait.epoch), (RecordType::Wait, 1)); +} + +#[async_test] +async fn broker_auth_timeout_frees_slot_without_epoch_advance(driver: DefaultDriver) { + let mut harness = + TestHarness::new_broker_with_timeout(&driver, BROKER_INSTANCE, BROKER_CAPABILITY, 5); + harness.enable().await; + let mut timer = pal_async::timer::PolledTimer::new(&driver); + timer.sleep(Duration::from_millis(20)).await; + yield_until(|| !harness.handle.is_connected()).await; + + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 1)); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!((wait.record_type, wait.epoch), (RecordType::Wait, 1)); +} + +#[async_test] +async fn broker_quiesce_stops_new_host_input(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + harness.device.quiesce_input().await.unwrap(); + let host_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + b"after-quiesce".to_vec(), + )); + harness.handle.inject_rx_data(&host_data); + for _ in 0..20 { + yield_now().await; + } + assert_eq!(harness.handle.pending_rx_len(), host_data.len()); + + harness.device.resume_input().await.unwrap(); + yield_until(|| harness.handle.pending_rx_len() == 0).await; + let record = decode( + &harness + .receive_guest_bytes(3, control_session_protocol::HEADER_LEN as u32 + 32) + .await, + ); + assert_eq!( + (record.record_type, record.payload), + (RecordType::Data, b"after-quiesce".to_vec()) + ); +} + +#[async_test] +async fn broker_host_lifecycle_error_does_not_stop_guest_worker(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + harness.handle.set_disconnect_error(); + let reset = decode( + &harness + .receive_guest_bytes(3, control_session_protocol::HEADER_LEN as u32) + .await, + ); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 2)); + harness + .send_guest_bytes(4, &encode(&ack(BROKER_INSTANCE, 2))) + .await; + assert!(harness.device.stop_queue(0).await.is_some()); + assert!(harness.device.stop_queue(1).await.is_some()); + let (worker, state) = harness.device.worker.get(); + let crate::direct::ConsoleWorkerMode::Broker(mode) = &worker.mode else { + panic!("expected broker worker"); + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::ReadyNoHost + ); + assert!(state.is_some()); +} + +#[async_test] +async fn broker_disconnect_emits_reset_without_guest_eof(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + harness.handle.disconnect(); + let reset = decode(&harness.receive_guest_bytes(1, 128).await); + assert_eq!( + (reset.record_type, reset.instance_id, reset.epoch), + (RecordType::Reset, BROKER_INSTANCE, 2) + ); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!((wait.record_type, wait.epoch), (RecordType::Wait, 2)); +} + +#[async_test] +async fn broker_zero_credit_disconnects_and_reconnects_without_guest_release( + driver: DefaultDriver, +) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + let full_host_record = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + vec![0x5a; control_session_protocol::MAX_DATA_LEN], + )); + let full_guest_record = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + vec![0x5a; control_session_protocol::MAX_DATA_LEN], + )); + harness.handle.inject_rx_data(&full_host_record); + assert_eq!( + harness + .receive_guest_exact(2, full_guest_record.len()) + .await, + full_guest_record + ); + + let blocked = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + vec![0x44], + )); + harness.handle.inject_rx_data(&blocked); + yield_until(|| harness.handle.pending_rx_len() == 0).await; + + harness.handle.disconnect(); + let reset = decode( + &harness + .receive_guest_exact(4, control_session_protocol::HEADER_LEN) + .await, + ); + assert_eq!((reset.record_type, reset.epoch), (RecordType::Reset, 2)); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Wait + ); + harness + .send_guest_bytes(5, &encode(&ack(BROKER_INSTANCE, 2))) + .await; + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Ready + ); +} + +#[async_test] +async fn broker_disconnect_finishes_partial_data_before_reset(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + let host_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + b"partially-emitted".to_vec(), + )); + let guest_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + b"partially-emitted".to_vec(), + )); + harness.handle.inject_rx_data(&host_data); + let first = harness.receive_guest_bytes(2, 13).await; + assert_eq!(first, guest_data[..13]); + + harness.handle.disconnect(); + let remainder = harness.receive_guest_exact(3, guest_data.len() - 13).await; + let mut completed = first; + completed.extend(remainder); + assert_eq!(completed, guest_data); + assert_eq!( + decode( + &harness + .receive_guest_exact(5, control_session_protocol::HEADER_LEN) + .await, + ) + .record_type, + RecordType::Reset + ); +} + +#[async_test] +async fn broker_initial_device_reset_preserves_connected_host(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + assert!(harness.handle.is_connected()); + + harness.device.reset().await; + { + let (worker, _) = harness.device.worker.get(); + let mode = match &worker.mode { + crate::direct::ConsoleWorkerMode::Broker(mode) => mode, + _ => unreachable!(), + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::AwaitGuestAttach + ); + assert!(!mode.broker.host_is_connected()); + } + assert!(harness.handle.is_connected()); + + harness.enable().await; + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Wait + ); + assert!(harness.handle.is_connected()); +} + +#[async_test] +async fn broker_device_reset_keeps_quarantined_host_in_waiting_for_disconnect( + driver: DefaultDriver, +) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness + .handle + .set_peer_identity(Some(LocalPeerIdentity::UnixUid(4242))); + harness.handle.set_disconnect_current_error(); + harness.enable().await; + + yield_until(|| harness.handle.disconnect_poll_count() > 0).await; + let receive_state = harness.device.stop_queue(0).await; + let transmit_state = harness.device.stop_queue(1).await; + { + let (worker, _) = harness.device.worker.get(); + let mode = match &worker.mode { + crate::direct::ConsoleWorkerMode::Broker(mode) => mode, + _ => unreachable!(), + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::AwaitGuestAttach + ); + assert!(!mode.broker.host_is_connected()); + assert_eq!( + mode.transport_state, + HostTransportState::WaitingForDisconnect + ); + } + assert!(harness.handle.is_connected()); + + harness.device.reset().await; + { + let (worker, _) = harness.device.worker.get(); + let mode = match &worker.mode { + crate::direct::ConsoleWorkerMode::Broker(mode) => mode, + _ => unreachable!(), + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::AwaitGuestAttach + ); + assert!(!mode.broker.host_is_connected()); + assert_eq!( + mode.transport_state, + HostTransportState::WaitingForDisconnect + ); + } + assert!(harness.handle.is_connected()); + + harness + .enable_with_state(receive_state, transmit_state) + .await; + harness + .handle + .set_peer_identity(Some(LocalPeerIdentity::UnixUid(1000))); + let host_attach = encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + )); + harness.handle.inject_rx_data(&host_attach); + for _ in 0..20 { + yield_now().await; + } + assert_eq!(harness.handle.pending_rx_len(), host_attach.len()); + assert!(harness.handle.tx_data().is_empty()); + + let disconnect_polls_before = harness.handle.disconnect_poll_count(); + harness.handle.disconnect(); + yield_until(|| harness.handle.disconnect_poll_count() > disconnect_polls_before).await; + harness.handle.reconnect(); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Wait + ); +} + +#[async_test] +async fn broker_device_reset_requires_a_fresh_host_attachment(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + harness.device.stop_queue(0).await; + harness.device.stop_queue(1).await; + harness.device.reset().await; + { + let (worker, _) = harness.device.worker.get(); + let mode = match &worker.mode { + crate::direct::ConsoleWorkerMode::Broker(mode) => mode, + _ => unreachable!(), + }; + assert_eq!( + mode.broker.state(), + crate::control_session_broker::BrokerState::AwaitGuestAttach + ); + assert!(!mode.broker.host_is_connected()); + } + + harness.handle.disconnect(); + harness.reset_rings(); + harness.enable().await; + harness + .send_guest_bytes( + 0, + &encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())), + ) + .await; + let reset = decode(&harness.receive_guest_bytes(0, 128).await); + assert_eq!( + (reset.record_type, reset.instance_id, reset.epoch), + (RecordType::Reset, BROKER_INSTANCE, 1) + ); +} + +#[async_test] +async fn broker_fragmentation_partial_host_writes_and_backpressure(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + + let guest_attach = encode(&Record::bootstrap(RecordType::GuestAttach, Vec::new())); + harness.send_guest_bytes(0, &guest_attach[..17]).await; + harness.send_guest_bytes(1, &guest_attach[17..]).await; + assert_eq!( + decode(&harness.receive_guest_bytes(0, 128).await).record_type, + RecordType::Reset + ); + harness + .send_guest_bytes(2, &encode(&ack(BROKER_INSTANCE, 1))) + .await; + + harness.handle.set_max_write_size(3); + let host_attach = encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + )); + harness.handle.inject_rx_data(&host_attach[..11]); + for _ in 0..5 { + yield_now().await; + } + harness.handle.inject_rx_data(&host_attach[11..]); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Ready + ); + + harness.handle.set_write_blocked(true); + for sequence in 1..=18 { + harness + .send_guest_bytes( + ((sequence + 2) % u64::from(QUEUE_SIZE)) as u16, + &encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + sequence, + vec![sequence as u8], + )), + ) + .await; + } + assert!(harness.handle.tx_data().is_empty()); + harness.handle.set_write_blocked(false); + let expected = 18 * (control_session_protocol::HEADER_LEN + 1); + yield_until(|| harness.handle.tx_data().len() >= expected).await; + let bytes = harness.handle.take_tx_data(); + let mut offset = 0; + for sequence in 1..=18 { + let end = offset + control_session_protocol::HEADER_LEN + 1; + let record = decode(&bytes[offset..end]); + assert_eq!( + (record.sequence, record.payload), + (sequence, vec![sequence as u8]) + ); + offset = end; + } + assert_eq!(offset, bytes.len()); +} + +#[async_test] +async fn malformed_host_input_detaches_without_panicking(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + harness + .handle + .inject_rx_data(&[0x99; control_session_protocol::HEADER_LEN]); + for _ in 0..20 { + yield_now().await; + } + yield_until(|| !harness.handle.is_connected()).await; + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + assert_eq!( + decode(&harness.handle.take_tx_data()).record_type, + RecordType::Wait + ); +} + +#[async_test] +async fn malformed_guest_input_recovers_after_device_reset(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + harness.post_tx_and_signal(2, &[0x99; control_session_protocol::HEADER_LEN]); + for _ in 0..20 { + yield_now().await; + } + + assert!(harness.device.stop_queue(0).await.is_some()); + assert!(harness.device.stop_queue(1).await.is_some()); + assert!(!harness.handle.is_connected()); + assert!(harness.handle.tx_data().is_empty()); + + harness.device.reset().await; + harness.handle.reconnect(); + harness.reset_rings(); + harness.enable().await; + activate_broker(&mut harness).await; +} diff --git a/vm/devices/virtio/virtio_console/src/tests/harness.rs b/vm/devices/virtio/virtio_console/src/tests/harness.rs index 5d25dce6d4a..fe70813f0aa 100644 --- a/vm/devices/virtio/virtio_console/src/tests/harness.rs +++ b/vm/devices/virtio/virtio_console/src/tests/harness.rs @@ -1,36 +1,68 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Test harness extensions for the direct-mode and save/restore tests. +//! Test harness extensions for the direct-mode, broker, and save/restore tests. //! //! [`ControlledMockSerialIo`] wraps the mock serial backend and applies the -//! fault injection configured in [`MockControls`]. [`TestHarness`] gains policy -//! device variants backed by it and device replacement for restore tests. The -//! remaining helpers poll the executor. +//! fault injection, write backpressure, and local peer identity configured in +//! [`MockControls`]. [`TestHarness`] gains policy and broker device variants +//! backed by it, device replacement for restore tests, and helpers that move +//! whole buffers through the guest queues. The remaining helpers poll the +//! executor and encode control-session records. use super::MockSerialHandle; use super::MockSerialIo; +use super::QUEUE_SIZE; use super::TestHarness; use super::new_mock_serial; use crate::VirtioConsoleDevice; +use crate::control_session_protocol; +use crate::control_session_protocol::Record; +use crate::control_session_protocol::RecordType; use futures::AsyncRead; use futures::AsyncWrite; use inspect::InspectMut; use pal_async::DefaultDriver; +use serial_core::LocalPeerIdentity; use serial_core::SerialIo; use std::io; use std::pin::Pin; use std::task::Context; use std::task::Poll; +use std::task::Waker; use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use virtio_resources::console::control::VirtioControlConsoleBrokerConfig; use vmcore::vm_task::SingleDriverBackend; use vmcore::vm_task::VmTaskDriverSource; +pub(super) const BROKER_INSTANCE: [u8; 16] = [0x51; 16]; +pub(super) const BROKER_CAPABILITY: [u8; 32] = [0xa7; 32]; + /// Additional mock backend state, applied by [`ControlledMockSerialIo`]. -#[derive(Default)] pub(super) struct MockControls { + write_blocked: bool, + write_waker: Option, read_error_then_disconnect: bool, + connect_error: bool, + disconnect_error: bool, + disconnect_current_error: bool, disconnect_poll_count: usize, + peer_identity: Option, +} + +impl Default for MockControls { + fn default() -> Self { + Self { + write_blocked: false, + write_waker: None, + read_error_then_disconnect: false, + connect_error: false, + disconnect_error: false, + disconnect_current_error: false, + disconnect_poll_count: 0, + peer_identity: Some(LocalPeerIdentity::UnixUid(1000)), + } + } } /// The mock serial backend with [`MockControls`] applied before each call is @@ -54,6 +86,13 @@ impl SerialIo for ControlledMockSerialIo { } fn poll_connect(&mut self, cx: &mut Context<'_>) -> Poll> { + { + let mut shared = self.0.shared.lock(); + if shared.controls.connect_error { + shared.controls.connect_error = false; + return Poll::Ready(Err(io::Error::other("injected connect error"))); + } + } self.0.poll_connect(cx) } @@ -61,6 +100,10 @@ impl SerialIo for ControlledMockSerialIo { { let mut shared = self.0.shared.lock(); shared.controls.disconnect_poll_count += 1; + if shared.controls.disconnect_error { + shared.controls.disconnect_error = false; + return Poll::Ready(Err(io::Error::other("injected disconnect error"))); + } if shared.controls.read_error_then_disconnect { shared.controls.read_error_then_disconnect = false; shared.connected = false; @@ -69,6 +112,19 @@ impl SerialIo for ControlledMockSerialIo { } self.0.poll_disconnect(cx) } + + fn local_peer_identity(&self) -> io::Result> { + Ok(self.0.shared.lock().controls.peer_identity.clone()) + } + + fn disconnect_current(&mut self) -> io::Result<()> { + let mut shared = self.0.shared.lock(); + if shared.controls.disconnect_current_error { + return Err(io::Error::other("injected disconnect_current error")); + } + shared.connected = false; + Ok(()) + } } impl AsyncRead for ControlledMockSerialIo { @@ -93,6 +149,13 @@ impl AsyncWrite for ControlledMockSerialIo { cx: &mut Context<'_>, buf: &[u8], ) -> Poll> { + { + let mut shared = self.0.shared.lock(); + if shared.connected && shared.controls.write_blocked { + shared.controls.write_waker = Some(cx.waker().clone()); + return Poll::Pending; + } + } Pin::new(&mut self.0).poll_write(cx, buf) } @@ -114,6 +177,16 @@ impl MockSerialHandle { self.shared.lock().rx_buf.len() } + pub(super) fn set_write_blocked(&self, blocked: bool) { + let mut shared = self.shared.lock(); + shared.controls.write_blocked = blocked; + if !blocked { + if let Some(waker) = shared.controls.write_waker.take() { + waker.wake(); + } + } + } + pub(super) fn set_read_error_then_disconnect(&self) { let mut shared = self.shared.lock(); shared.controls.read_error_then_disconnect = true; @@ -125,6 +198,26 @@ impl MockSerialHandle { pub(super) fn disconnect_poll_count(&self) -> usize { self.shared.lock().controls.disconnect_poll_count } + + pub(super) fn is_connected(&self) -> bool { + self.shared.lock().connected + } + + pub(super) fn set_disconnect_error(&self) { + let mut shared = self.shared.lock(); + shared.controls.disconnect_error = true; + if let Some(waker) = shared.disconnect_waker.take() { + waker.wake(); + } + } + + pub(super) fn set_disconnect_current_error(&self) { + self.shared.lock().controls.disconnect_current_error = true; + } + + pub(super) fn set_peer_identity(&self, identity: Option) { + self.shared.lock().controls.peer_identity = identity; + } } impl TestHarness { @@ -137,6 +230,34 @@ impl TestHarness { }) } + pub(super) fn new_broker( + driver: &DefaultDriver, + instance_id: [u8; 16], + capability: [u8; 32], + ) -> Self { + Self::new_broker_with_timeout(driver, instance_id, capability, 5000) + } + + pub(super) fn new_broker_with_timeout( + driver: &DefaultDriver, + instance_id: [u8; 16], + capability: [u8; 32], + auth_timeout_ms: u64, + ) -> Self { + Self::new_with_device(driver, |driver_source, io| { + VirtioConsoleDevice::new_broker( + driver_source, + io, + VirtioControlConsoleBrokerConfig { + instance_id, + capability, + expected_peer_identity: LocalPeerIdentity::UnixUid(1000), + auth_timeout_ms, + }, + ) + }) + } + /// Builds the standard harness, then swaps in the device returned by /// `make_device` together with a fresh controlled mock backend. fn new_with_device( @@ -158,6 +279,38 @@ impl TestHarness { VirtioConsoleDevice::new_with_policy(&driver_source, Box::new(io), disconnect_policy); self.handle = handle; } + + pub(super) async fn send_guest_bytes(&mut self, desc_index: u16, bytes: &[u8]) { + self.post_tx_and_signal(desc_index, bytes); + let (used_id, used_len) = self.wait_for_tx_used().await; + assert_eq!(used_id, desc_index); + assert_eq!(used_len, 0); + } + + pub(super) async fn receive_guest_bytes(&mut self, desc_index: u16, size: u32) -> Vec { + let gpa = self.post_rx_buffer_and_signal(desc_index, size); + let (used_id, used_len) = self.wait_for_rx_used().await; + assert_eq!(used_id, desc_index); + let mut bytes = vec![0; used_len as usize]; + self.mem.read_at(gpa, &mut bytes).unwrap(); + bytes + } + + pub(super) async fn receive_guest_exact( + &mut self, + first_desc_index: u16, + mut size: usize, + ) -> Vec { + let mut bytes = Vec::with_capacity(size); + let mut desc_index = first_desc_index; + while size != 0 { + let chunk = size.min(crate::BUF_SIZE); + bytes.extend(self.receive_guest_bytes(desc_index, chunk as u32).await); + size -= chunk; + desc_index = (desc_index + 1) % QUEUE_SIZE; + } + bytes + } } pub(super) async fn yield_now() { @@ -183,3 +336,21 @@ pub(super) async fn yield_until(mut condition: impl FnMut() -> bool) { } assert!(condition(), "condition did not become true"); } + +pub(super) fn encode(record: &Record) -> Vec { + control_session_protocol::encode(record).unwrap() +} + +pub(super) fn decode(bytes: &[u8]) -> Record { + control_session_protocol::decode_exact(bytes).unwrap() +} + +pub(super) fn ack(instance_id: [u8; 16], epoch: u64) -> Record { + Record::session( + RecordType::Ack, + instance_id, + epoch, + 0, + control_session_protocol::credit_payload(control_session_protocol::MIN_RECEIVE_CREDIT), + ) +} diff --git a/vm/devices/virtio/virtio_resources/Cargo.toml b/vm/devices/virtio/virtio_resources/Cargo.toml index 127c8f51a44..6aa40421685 100644 --- a/vm/devices/virtio/virtio_resources/Cargo.toml +++ b/vm/devices/virtio/virtio_resources/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] net_backend_resources.workspace = true +serial_core.workspace = true unix_socket = { workspace = true, features = ["mesh"] } vm_resource.workspace = true diff --git a/vm/devices/virtio/virtio_resources/src/console/control.rs b/vm/devices/virtio/virtio_resources/src/console/control.rs new file mode 100644 index 00000000000..137cf7013be --- /dev/null +++ b/vm/devices/virtio/virtio_resources/src/console/control.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Control-console resource. +//! +//! A [`VirtioControlConsoleHandle`] requests a virtio console whose guest +//! session is served by the VMM-resident control-session broker, configured by +//! [`VirtioControlConsoleBrokerConfig`], in front of a reconnectable host +//! endpoint. + +use super::attachment::VirtioConsoleAttachment; +use mesh::MeshPayload; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::SerialBackendHandle; +use vm_resource::kind::VirtioDeviceHandle; + +#[derive(Clone, MeshPayload)] +pub struct VirtioControlConsoleBrokerConfig { + pub instance_id: [u8; 16], + pub capability: [u8; 32], + pub expected_peer_identity: serial_core::LocalPeerIdentity, + pub auth_timeout_ms: u64, +} + +#[derive(MeshPayload)] +pub struct VirtioControlConsoleHandle { + /// Reconnectable host endpoint used by the VMM-resident broker. + pub backend: Resource, + pub broker_config: VirtioControlConsoleBrokerConfig, + pub attachment: Option, +} + +impl ResourceId for VirtioControlConsoleHandle { + const ID: &'static str = "virtio-control-console"; +} + +#[cfg(test)] +mod tests { + use super::super::VirtioConsoleHandle; + use super::*; + + #[test] + fn console_resource_ids_are_distinct() { + assert_eq!( + >::ID, + "virtio-console" + ); + assert_eq!( + >::ID, + "virtio-control-console" + ); + } +} diff --git a/vm/devices/virtio/virtio_resources/src/lib.rs b/vm/devices/virtio/virtio_resources/src/lib.rs index 5ac589fcec6..7be8b798491 100644 --- a/vm/devices/virtio/virtio_resources/src/lib.rs +++ b/vm/devices/virtio/virtio_resources/src/lib.rs @@ -160,6 +160,7 @@ pub mod net { pub mod console { pub mod attachment; + pub mod control; use mesh::MeshPayload; use vm_resource::Resource; From 6dc780a9c1e7c388352ebe3c63cbd4186aa93079 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:10:33 -0700 Subject: [PATCH 146/304] virtio_console: save and restore broker state A microVM snapshot must be able to carry a control console. Replace the temporary rejection of control-console save and restore with real broker state. The private state gains an optional `broker` field (mesh field 7), and a control console writes schema version 2 while the direct console keeps version 1. The broker snapshot is stored as mesh types for the state, the guest parser, both output legs, the sequences, pending records, and the error counters, converted to and from the broker's own snapshot types. - Save stores the broker snapshot. A control console never stages direct-mode input, so staged input fails the save. - Validation requires the schema that matches the device mode, rejects broker state in a direct console and direct staged input in a control console, and checks every broker field against the protocol and queue bounds. It also requires the restoring device to have a different instance ID than the saved one, so a restored guest can tell the new session from the old one. - Restore rebuilds the broker with the configured, new instance ID and capability. As the broker defines, only the guest parser, a partially written guest record, and the counters carry over. Restore disconnects a host that is still connected, clears buffered host input and the authentication deadline, and waits for a new host connection, which must authenticate again. The broker's snapshot API is now used outside tests, so its module declaration drops the non-test `expect(dead_code)`. Tests check that direct and control-console saved states cannot restore into the other mode, that the state validator rejects malformed broker state, and that a restored control console finishes the old instance's partial output, then sends `RESET` for the new instance, accepts a host with the new capability, and accepts the guest's `ACK` for the new instance. The existing saved-state tests fill the new field with `None`. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- vm/devices/virtio/virtio_console/src/lib.rs | 4 - .../virtio/virtio_console/src/saved_state.rs | 579 +++++++++++++++++- .../virtio/virtio_console/src/tests/broker.rs | 92 +++ .../virtio_console/src/tests/harness.rs | 16 + .../virtio_console/src/tests/saved_state.rs | 91 +++ 5 files changed, 752 insertions(+), 30 deletions(-) diff --git a/vm/devices/virtio/virtio_console/src/lib.rs b/vm/devices/virtio/virtio_console/src/lib.rs index 12088526177..40e492df665 100644 --- a/vm/devices/virtio/virtio_console/src/lib.rs +++ b/vm/devices/virtio/virtio_console/src/lib.rs @@ -35,10 +35,6 @@ #![forbid(unsafe_code)] mod broker; -#[cfg_attr( - not(test), - expect(dead_code, reason = "saved state consumes the broker snapshot") -)] pub(crate) mod control_session_broker; pub(crate) mod control_session_protocol; mod direct; diff --git a/vm/devices/virtio/virtio_console/src/saved_state.rs b/vm/devices/virtio/virtio_console/src/saved_state.rs index dc1b18ddfb9..4626b79de8f 100644 --- a/vm/devices/virtio/virtio_console/src/saved_state.rs +++ b/vm/devices/virtio/virtio_console/src/saved_state.rs @@ -4,13 +4,22 @@ //! Save and restore of the device-private virtio-console state. //! //! The worker state outlives queue restarts so that the TX progress of the -//! current descriptor and staged host input can be captured once both queues -//! are stopped. This module defines the versioned +//! current descriptor, staged host input, and the control-session broker can be +//! captured once both queues are stopped. This module defines the versioned //! saved-state format ([`SavedState`]), validates it before restore, and //! implements save, restore, input quiescing, and reset of that state. use crate::BUF_SIZE; use crate::VirtioConsoleDevice; +use crate::broker::HostTransportState; +use crate::control_session_broker; +use crate::control_session_broker::BrokerCounters; +use crate::control_session_broker::BrokerSnapshot; +use crate::control_session_broker::EncodedRecordSnapshot; +use crate::control_session_broker::OutputSnapshot; +use crate::control_session_protocol; +use crate::control_session_protocol::ParserSnapshot; +use crate::control_session_protocol::Record; use crate::direct::ConsoleWorkerMode; use crate::spec::VirtioConsoleConfig; use guestmem::GuestMemory; @@ -31,6 +40,7 @@ use vmcore::save_restore::SavedStateRoot; pub(crate) const MAX_STAGED_RX_BYTES: usize = BUF_SIZE; pub(crate) const MAX_SAVED_STATE_BYTES: usize = 512 * 1024; pub(crate) const DIRECT_SAVED_STATE_VERSION: u32 = 1; +pub(crate) const BROKER_SAVED_STATE_VERSION: u32 = 2; #[cfg(test)] pub(crate) const SAVED_STATE_VERSION: u32 = DIRECT_SAVED_STATE_VERSION; @@ -74,19 +84,33 @@ impl VirtioConsoleDevice { "virtio-console staged RX exceeds its ABI bound" ))); } - let ConsoleWorkerMode::Direct { - disconnect_policy, .. - } = &worker.mode - else { - return Err(SaveError::NotSupported); + let (schema_version, staged_rx, disconnect_policy_id, broker) = match &worker.mode { + ConsoleWorkerMode::Direct { + disconnect_policy, .. + } => ( + DIRECT_SAVED_STATE_VERSION, + state.staged_rx.iter().copied().collect(), + disconnect_policy_id(*disconnect_policy), + None, + ), + ConsoleWorkerMode::Broker(mode) => { + if !state.staged_rx.is_empty() { + return Err(SaveError::InvalidChildSavedState(anyhow::anyhow!( + "control-console broker has direct-mode staged RX" + ))); + } + let broker = SavedBrokerSnapshot::from(mode.broker.snapshot()); + (BROKER_SAVED_STATE_VERSION, Vec::new(), 0, Some(broker)) + } }; Ok(Some(SavedStateBlob::new(SavedState { - schema_version: DIRECT_SAVED_STATE_VERSION, + schema_version, columns: self.config.cols.into(), rows: self.config.rows.into(), partial_transmit: state.partial_transmit as u64, - staged_rx: state.staged_rx.iter().copied().collect(), - disconnect_policy_id: disconnect_policy_id(*disconnect_policy), + staged_rx, + disconnect_policy_id, + broker, }))) } @@ -95,7 +119,7 @@ impl VirtioConsoleDevice { state: Option, ) -> Result<(), RestoreError> { let (worker, runtime) = self.worker.get_mut(); - let saved = validate_saved_state(state.as_ref(), worker.mode.validation_mode())?; + let mut saved = validate_saved_state(state.as_ref(), worker.mode.validation_mode())?; let runtime = runtime.ok_or_else(|| { RestoreError::Other(anyhow::anyhow!( "virtio-console worker state is unavailable" @@ -113,12 +137,45 @@ impl VirtioConsoleDevice { let partial_transmit = usize::try_from(saved.partial_transmit) .map_err(|_| invalid_saved_state("console TX offset is out of range"))?; + let restored_broker = match &mut worker.mode { + ConsoleWorkerMode::Direct { .. } => None, + ConsoleWorkerMode::Broker(mode) => { + let saved_broker = saved + .broker + .take() + .ok_or_else(|| invalid_saved_state("missing control-console broker state"))?; + let snapshot = saved_broker.try_into().map_err(invalid_saved_state)?; + let broker = control_session_broker::ControlSessionBroker::restore( + snapshot, + mode.config.instance_id, + mode.config.capability, + ) + .map_err(|error| invalid_saved_state(error.to_string()))?; + if mode.host_io.is_connected() { + mode.host_io.disconnect_current().map_err(|error| { + RestoreError::Other( + anyhow::Error::new(error) + .context("failed to disconnect control-console host for restore"), + ) + })?; + } + Some(broker) + } + }; + self.config = VirtioConsoleConfig { cols: columns, rows, }; runtime.partial_transmit = partial_transmit; runtime.staged_rx = saved.staged_rx.into(); + if let (ConsoleWorkerMode::Broker(mode), Some(broker)) = (&mut worker.mode, restored_broker) + { + mode.broker = broker; + mode.host_input.clear(); + mode.transport_state = HostTransportState::WaitingForConnect; + mode.auth_deadline = None; + } Ok(()) } @@ -157,7 +214,7 @@ pub(crate) fn check_restored_tx_offset( #[derive(Clone, Copy)] enum SavedStateMode { Direct(VirtioConsoleDisconnectPolicy), - Broker, + Broker { current_instance_id: [u8; 16] }, } impl ConsoleWorkerMode { @@ -166,7 +223,9 @@ impl ConsoleWorkerMode { Self::Direct { disconnect_policy, .. } => SavedStateMode::Direct(*disconnect_policy), - Self::Broker(_) => SavedStateMode::Broker, + Self::Broker(mode) => SavedStateMode::Broker { + current_instance_id: mode.config.instance_id, + }, } } } @@ -193,19 +252,50 @@ fn validate_saved_state( )); } let saved: SavedState = state.parse()?; - let SavedStateMode::Direct(disconnect_policy) = mode else { - return Err(RestoreError::SavedStateNotSupported); - }; - if saved.schema_version != DIRECT_SAVED_STATE_VERSION { - return Err(invalid_saved_state(format!( - "direct console requires schema version {DIRECT_SAVED_STATE_VERSION}, got {}", - saved.schema_version - ))); - } - if saved.disconnect_policy_id != disconnect_policy_id(disconnect_policy) { - return Err(invalid_saved_state( - "console disconnect policy does not match the saved policy", - )); + match mode { + SavedStateMode::Direct(disconnect_policy) => { + if saved.schema_version != DIRECT_SAVED_STATE_VERSION { + return Err(invalid_saved_state(format!( + "direct console requires schema version {DIRECT_SAVED_STATE_VERSION}, got {}", + saved.schema_version + ))); + } + if saved.broker.is_some() { + return Err(invalid_saved_state( + "direct console saved state contains broker state", + )); + } + if saved.disconnect_policy_id != disconnect_policy_id(disconnect_policy) { + return Err(invalid_saved_state( + "console disconnect policy does not match the saved policy", + )); + } + } + SavedStateMode::Broker { + current_instance_id, + } => { + if saved.schema_version != BROKER_SAVED_STATE_VERSION { + return Err(invalid_saved_state(format!( + "control console requires schema version {BROKER_SAVED_STATE_VERSION}, got {}", + saved.schema_version + ))); + } + if !saved.staged_rx.is_empty() { + return Err(invalid_saved_state( + "control-console saved state contains direct-mode staged RX", + )); + } + let broker = saved + .broker + .as_ref() + .ok_or_else(|| invalid_saved_state("missing control-console broker state"))?; + let snapshot = validate_saved_broker(broker)?; + control_session_broker::ControlSessionBroker::validate_restore( + &snapshot, + current_instance_id, + ) + .map_err(|error| invalid_saved_state(error.to_string()))?; + } } u16::try_from(saved.columns) .map_err(|_| invalid_saved_state("console column count is out of range"))?; @@ -273,4 +363,441 @@ pub struct SavedState { pub staged_rx: Vec, #[mesh(6)] pub disconnect_policy_id: u32, + #[mesh(7)] + pub broker: Option, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedBrokerSnapshot { + #[mesh(1)] + pub state: u32, + #[mesh(2)] + pub instance_id: Vec, + #[mesh(3)] + pub drain_foreign_instance_records: bool, + #[mesh(4)] + pub epoch: u64, + #[mesh(5)] + pub guest_parser: SavedParserSnapshot, + #[mesh(6)] + pub guest_output: SavedOutputSnapshot, + #[mesh(7)] + pub host_output: SavedOutputSnapshot, + #[mesh(8)] + pub guest_receive_sequence: u64, + #[mesh(9)] + pub guest_send_sequence: u64, + #[mesh(10)] + pub host_receive_sequence: u64, + #[mesh(11)] + pub host_send_sequence: u64, + #[mesh(12)] + pub pending_guest_record: Option, + #[mesh(13)] + pub pending_host_record: Option, + #[mesh(14)] + pub counters: SavedBrokerCounters, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedParserSnapshot { + #[mesh(1)] + pub header_bytes: Vec, + #[mesh(2)] + pub header_count: u32, + #[mesh(3)] + pub body_bytes: Vec, + #[mesh(4)] + pub declared_body_len: Option, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedEncodedRecord { + #[mesh(1)] + pub bytes: Vec, + #[mesh(2)] + pub offset: u64, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedOutputSnapshot { + #[mesh(1)] + pub current: Option, + #[mesh(2)] + pub queued_records: Vec>, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedRecord { + #[mesh(1)] + pub record_type: u32, + #[mesh(2)] + pub instance_id: Vec, + #[mesh(3)] + pub epoch: u64, + #[mesh(4)] + pub sequence: u64, + #[mesh(5)] + pub payload: Vec, +} + +#[derive(Protobuf)] +#[mesh(package = "virtio.console")] +pub struct SavedBrokerCounters { + #[mesh(1)] + pub protocol_errors: u64, + #[mesh(2)] + pub authentication_errors: u64, + #[mesh(3)] + pub sequence_errors: u64, + #[mesh(4)] + pub ack_errors: u64, + #[mesh(5)] + pub reset_errors: u64, + #[mesh(6)] + pub reconnect_errors: u64, + #[mesh(7)] + pub backpressure_errors: u64, +} + +impl From for SavedBrokerSnapshot { + fn from(snapshot: BrokerSnapshot) -> Self { + Self { + state: snapshot.state.into(), + instance_id: snapshot.instance_id.to_vec(), + drain_foreign_instance_records: snapshot.drain_foreign_instance_records, + epoch: snapshot.epoch, + guest_parser: snapshot.guest_parser.into(), + guest_output: snapshot.guest_output.into(), + host_output: snapshot.host_output.into(), + guest_receive_sequence: snapshot.guest_receive_sequence, + guest_send_sequence: snapshot.guest_send_sequence, + host_receive_sequence: snapshot.host_receive_sequence, + host_send_sequence: snapshot.host_send_sequence, + pending_guest_record: snapshot.pending_guest_record.map(Into::into), + pending_host_record: snapshot.pending_host_record.map(Into::into), + counters: snapshot.counters.into(), + } + } +} + +impl TryFrom for BrokerSnapshot { + type Error = String; + + fn try_from(snapshot: SavedBrokerSnapshot) -> Result { + let state = + u8::try_from(snapshot.state).map_err(|_| "broker state is out of range".to_string())?; + let instance_id = fixed_bytes(snapshot.instance_id, "broker instance ID")?; + let broker = Self { + state, + instance_id, + drain_foreign_instance_records: snapshot.drain_foreign_instance_records, + epoch: snapshot.epoch, + guest_parser: snapshot.guest_parser.try_into()?, + guest_output: snapshot.guest_output.try_into()?, + host_output: snapshot.host_output.try_into()?, + guest_receive_sequence: snapshot.guest_receive_sequence, + guest_send_sequence: snapshot.guest_send_sequence, + host_receive_sequence: snapshot.host_receive_sequence, + host_send_sequence: snapshot.host_send_sequence, + pending_guest_record: snapshot + .pending_guest_record + .map(TryInto::try_into) + .transpose()?, + pending_host_record: snapshot + .pending_host_record + .map(TryInto::try_into) + .transpose()?, + counters: snapshot.counters.into(), + }; + control_session_broker::ControlSessionBroker::validate_snapshot(&broker) + .map_err(|error| error.to_string())?; + Ok(broker) + } +} + +impl From for SavedParserSnapshot { + fn from(snapshot: ParserSnapshot) -> Self { + Self { + header_bytes: snapshot.header_bytes, + header_count: snapshot.header_count as u32, + body_bytes: snapshot.body_bytes, + declared_body_len: snapshot.declared_body_len, + } + } +} + +impl TryFrom for ParserSnapshot { + type Error = String; + + fn try_from(snapshot: SavedParserSnapshot) -> Result { + Ok(Self { + header_bytes: snapshot.header_bytes, + header_count: snapshot.header_count as usize, + body_bytes: snapshot.body_bytes, + declared_body_len: snapshot.declared_body_len, + }) + } +} + +impl From for SavedOutputSnapshot { + fn from(snapshot: OutputSnapshot) -> Self { + Self { + current: snapshot.current.map(Into::into), + queued_records: snapshot.queued_records, + } + } +} + +impl TryFrom for OutputSnapshot { + type Error = String; + + fn try_from(snapshot: SavedOutputSnapshot) -> Result { + Ok(Self { + current: snapshot.current.map(TryInto::try_into).transpose()?, + queued_records: snapshot.queued_records, + }) + } +} + +impl From for SavedEncodedRecord { + fn from(record: EncodedRecordSnapshot) -> Self { + Self { + bytes: record.bytes, + offset: record.offset as u64, + } + } +} + +impl TryFrom for EncodedRecordSnapshot { + type Error = String; + + fn try_from(record: SavedEncodedRecord) -> Result { + Ok(Self { + bytes: record.bytes, + offset: usize::try_from(record.offset) + .map_err(|_| "broker output offset is out of range".to_string())?, + }) + } +} + +impl From for SavedRecord { + fn from(record: Record) -> Self { + Self { + record_type: record.record_type as u32, + instance_id: record.instance_id.to_vec(), + epoch: record.epoch, + sequence: record.sequence, + payload: record.payload, + } + } +} + +impl TryFrom for Record { + type Error = String; + + fn try_from(record: SavedRecord) -> Result { + let record_type = u8::try_from(record.record_type) + .map_err(|_| "broker record type is out of range".to_string())?; + Ok(Self { + record_type: control_session_protocol::RecordType::try_from(record_type) + .map_err(|error| error.to_string())?, + instance_id: fixed_bytes(record.instance_id, "broker record instance ID")?, + epoch: record.epoch, + sequence: record.sequence, + payload: record.payload, + }) + } +} + +impl From for SavedBrokerCounters { + fn from(counters: BrokerCounters) -> Self { + Self { + protocol_errors: counters.protocol_errors, + authentication_errors: counters.authentication_errors, + sequence_errors: counters.sequence_errors, + ack_errors: counters.ack_errors, + reset_errors: counters.reset_errors, + reconnect_errors: counters.reconnect_errors, + backpressure_errors: counters.backpressure_errors, + } + } +} + +impl From for BrokerCounters { + fn from(counters: SavedBrokerCounters) -> Self { + Self { + protocol_errors: counters.protocol_errors, + authentication_errors: counters.authentication_errors, + sequence_errors: counters.sequence_errors, + ack_errors: counters.ack_errors, + reset_errors: counters.reset_errors, + reconnect_errors: counters.reconnect_errors, + backpressure_errors: counters.backpressure_errors, + } + } +} + +fn fixed_bytes(bytes: Vec, name: &str) -> Result<[u8; N], String> { + bytes + .try_into() + .map_err(|_| format!("{name} must be exactly {N} bytes")) +} + +fn validate_saved_broker(saved: &SavedBrokerSnapshot) -> Result { + use control_session_protocol::HEADER_LEN; + use control_session_protocol::MAX_DATA_LEN; + + if !(1..=6).contains(&saved.state) { + return Err(invalid_saved_state("invalid broker state")); + } + if saved.instance_id.len() != 16 || saved.instance_id.iter().all(|byte| *byte == 0) { + return Err(invalid_saved_state("invalid broker instance ID")); + } + if saved.epoch == 0 { + return Err(invalid_saved_state("saved broker epoch is zero")); + } + if saved.guest_parser.header_bytes.len() != HEADER_LEN + || saved.guest_parser.header_count as usize > HEADER_LEN + || saved.guest_parser.body_bytes.len() > MAX_DATA_LEN + || saved + .guest_parser + .declared_body_len + .is_some_and(|length| length as usize > MAX_DATA_LEN) + { + return Err(invalid_saved_state("invalid broker parser bounds")); + } + validate_saved_output(&saved.guest_output)?; + validate_saved_output(&saved.host_output)?; + if let Some(record) = &saved.pending_guest_record { + validate_saved_record(record)?; + } + if let Some(record) = &saved.pending_host_record { + validate_saved_record(record)?; + } + + let snapshot = SavedBrokerSnapshot { + state: saved.state, + instance_id: saved.instance_id.clone(), + drain_foreign_instance_records: saved.drain_foreign_instance_records, + epoch: saved.epoch, + guest_parser: SavedParserSnapshot { + header_bytes: saved.guest_parser.header_bytes.clone(), + header_count: saved.guest_parser.header_count, + body_bytes: saved.guest_parser.body_bytes.clone(), + declared_body_len: saved.guest_parser.declared_body_len, + }, + guest_output: clone_saved_output(&saved.guest_output), + host_output: clone_saved_output(&saved.host_output), + guest_receive_sequence: saved.guest_receive_sequence, + guest_send_sequence: saved.guest_send_sequence, + host_receive_sequence: saved.host_receive_sequence, + host_send_sequence: saved.host_send_sequence, + pending_guest_record: saved.pending_guest_record.as_ref().map(clone_saved_record), + pending_host_record: saved.pending_host_record.as_ref().map(clone_saved_record), + counters: SavedBrokerCounters { + protocol_errors: saved.counters.protocol_errors, + authentication_errors: saved.counters.authentication_errors, + sequence_errors: saved.counters.sequence_errors, + ack_errors: saved.counters.ack_errors, + reset_errors: saved.counters.reset_errors, + reconnect_errors: saved.counters.reconnect_errors, + backpressure_errors: saved.counters.backpressure_errors, + }, + }; + snapshot.try_into().map_err(invalid_saved_state) +} + +fn validate_saved_output(saved: &SavedOutputSnapshot) -> Result<(), RestoreError> { + use control_session_broker::MAX_QUEUED_BYTES_PER_LEG; + use control_session_broker::MAX_QUEUED_RECORDS_PER_LEG; + use control_session_protocol::HEADER_LEN; + use control_session_protocol::MAX_DATA_LEN; + + if saved.queued_records.len() > MAX_QUEUED_RECORDS_PER_LEG { + return Err(invalid_saved_state("too many broker output records")); + } + let mut queued_bytes = 0usize; + for bytes in &saved.queued_records { + if !(HEADER_LEN..=HEADER_LEN + MAX_DATA_LEN).contains(&bytes.len()) { + return Err(invalid_saved_state("invalid broker output record length")); + } + control_session_protocol::decode_exact(bytes) + .map_err(|error| invalid_saved_state(error.to_string()))?; + queued_bytes = queued_bytes + .checked_add(bytes.len()) + .ok_or_else(|| invalid_saved_state("broker output byte count overflow"))?; + if queued_bytes > MAX_QUEUED_BYTES_PER_LEG { + return Err(invalid_saved_state( + "broker output bytes exceed configured bound", + )); + } + } + if let Some(current) = &saved.current { + if !(HEADER_LEN..=HEADER_LEN + MAX_DATA_LEN).contains(¤t.bytes.len()) { + return Err(invalid_saved_state( + "invalid current broker output record length", + )); + } + control_session_protocol::decode_exact(¤t.bytes) + .map_err(|error| invalid_saved_state(error.to_string()))?; + let offset = usize::try_from(current.offset) + .map_err(|_| invalid_saved_state("broker output offset is out of range"))?; + if offset >= current.bytes.len() { + return Err(invalid_saved_state( + "broker output offset exceeds record length", + )); + } + } + Ok(()) +} + +fn validate_saved_record(saved: &SavedRecord) -> Result<(), RestoreError> { + let record_type = u8::try_from(saved.record_type) + .ok() + .and_then(|record_type| control_session_protocol::RecordType::try_from(record_type).ok()) + .ok_or_else(|| invalid_saved_state("invalid pending broker record type"))?; + let payload_len = saved.payload.len(); + let payload_is_valid = match record_type { + control_session_protocol::RecordType::Ack + | control_session_protocol::RecordType::Credit => payload_len == 4, + control_session_protocol::RecordType::GuestAttach + | control_session_protocol::RecordType::Reset + | control_session_protocol::RecordType::Wait + | control_session_protocol::RecordType::Ready => payload_len == 0, + control_session_protocol::RecordType::HostAttach => payload_len == 32, + control_session_protocol::RecordType::Data => { + (1..=control_session_protocol::MAX_DATA_LEN).contains(&payload_len) + } + control_session_protocol::RecordType::Error => payload_len == 4, + }; + if saved.instance_id.len() != 16 || !payload_is_valid { + return Err(invalid_saved_state("invalid pending broker record")); + } + Ok(()) +} + +fn clone_saved_output(saved: &SavedOutputSnapshot) -> SavedOutputSnapshot { + SavedOutputSnapshot { + current: saved.current.as_ref().map(|record| SavedEncodedRecord { + bytes: record.bytes.clone(), + offset: record.offset, + }), + queued_records: saved.queued_records.clone(), + } +} + +fn clone_saved_record(saved: &SavedRecord) -> SavedRecord { + SavedRecord { + record_type: saved.record_type, + instance_id: saved.instance_id.clone(), + epoch: saved.epoch, + sequence: saved.sequence, + payload: saved.payload.clone(), + } } diff --git a/vm/devices/virtio/virtio_console/src/tests/broker.rs b/vm/devices/virtio/virtio_console/src/tests/broker.rs index 95050185115..50f0ae946bf 100644 --- a/vm/devices/virtio/virtio_console/src/tests/broker.rs +++ b/vm/devices/virtio/virtio_console/src/tests/broker.rs @@ -617,6 +617,98 @@ async fn broker_fragmentation_partial_host_writes_and_backpressure(driver: Defau assert_eq!(offset, bytes.len()); } +#[async_test] +async fn broker_restore_finishes_old_output_then_uses_fresh_identity(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + activate_broker(&mut harness).await; + + let old_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 0, + b"partially-emitted".to_vec(), + )); + harness.handle.inject_rx_data(&old_data); + let first = harness.receive_guest_bytes(1, 13).await; + assert_eq!(first, old_data[..13]); + + let old_guest_data = encode(&Record::session( + RecordType::Data, + BROKER_INSTANCE, + 1, + 1, + b"partially-parsed".to_vec(), + )); + harness.send_guest_bytes(2, &old_guest_data[..19]).await; + + let receive_state = harness.device.stop_queue(0).await.unwrap(); + let transmit_state = harness.device.stop_queue(1).await.unwrap(); + let saved = harness.device.save_device().unwrap().unwrap(); + + const NEW_INSTANCE: [u8; 16] = [0x62; 16]; + const NEW_CAPABILITY: [u8; 32] = [0xb8; 32]; + harness.replace_with_broker(NEW_INSTANCE, NEW_CAPABILITY); + harness.device.restore_device(Some(saved)).unwrap(); + assert!(!harness.handle.is_connected()); + { + let (worker, _) = harness.device.worker.get(); + let crate::direct::ConsoleWorkerMode::Broker(mode) = &worker.mode else { + panic!("expected broker worker"); + }; + assert_eq!( + ( + mode.broker.guest_receive_window(), + mode.broker.guest_receive_credit() + ), + (0, 0) + ); + } + harness + .enable_with_state(Some(receive_state), Some(transmit_state)) + .await; + + let remainder = harness.receive_guest_bytes(2, 128).await; + let mut completed_old_output = first; + completed_old_output.extend(remainder); + let completed_old_output = decode(&completed_old_output); + assert_eq!(completed_old_output.payload, b"partially-emitted"); + assert_eq!(completed_old_output.instance_id, BROKER_INSTANCE); + let new_reset = decode(&harness.receive_guest_bytes(3, 128).await); + assert_eq!( + ( + new_reset.record_type, + new_reset.instance_id, + new_reset.epoch + ), + (RecordType::Reset, NEW_INSTANCE, 1) + ); + + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + BROKER_CAPABILITY.to_vec(), + ))); + yield_until(|| !harness.handle.is_connected()).await; + harness.handle.reconnect(); + harness.handle.inject_rx_data(&encode(&Record::bootstrap( + RecordType::HostAttach, + NEW_CAPABILITY.to_vec(), + ))); + yield_until(|| harness.handle.tx_data().len() >= control_session_protocol::HEADER_LEN).await; + let wait = decode(&harness.handle.take_tx_data()); + assert_eq!( + (wait.record_type, wait.instance_id, wait.epoch), + (RecordType::Wait, NEW_INSTANCE, 1) + ); + + harness.send_guest_bytes(3, &old_guest_data[19..]).await; + harness + .send_guest_bytes(4, &encode(&ack(NEW_INSTANCE, 1))) + .await; +} + #[async_test] async fn malformed_host_input_detaches_without_panicking(driver: DefaultDriver) { let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); diff --git a/vm/devices/virtio/virtio_console/src/tests/harness.rs b/vm/devices/virtio/virtio_console/src/tests/harness.rs index fe70813f0aa..551ce651a6b 100644 --- a/vm/devices/virtio/virtio_console/src/tests/harness.rs +++ b/vm/devices/virtio/virtio_console/src/tests/harness.rs @@ -280,6 +280,22 @@ impl TestHarness { self.handle = handle; } + pub(super) fn replace_with_broker(&mut self, instance_id: [u8; 16], capability: [u8; 32]) { + let (io, handle) = new_controlled_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(self.driver.clone())); + self.device = VirtioConsoleDevice::new_broker( + &driver_source, + Box::new(io), + VirtioControlConsoleBrokerConfig { + instance_id, + capability, + expected_peer_identity: LocalPeerIdentity::UnixUid(1000), + auth_timeout_ms: 5000, + }, + ); + self.handle = handle; + } + pub(super) async fn send_guest_bytes(&mut self, desc_index: u16, bytes: &[u8]) { self.post_tx_and_signal(desc_index, bytes); let (used_id, used_len) = self.wait_for_tx_used().await; diff --git a/vm/devices/virtio/virtio_console/src/tests/saved_state.rs b/vm/devices/virtio/virtio_console/src/tests/saved_state.rs index 38d3aac97b0..20757c493dd 100644 --- a/vm/devices/virtio/virtio_console/src/tests/saved_state.rs +++ b/vm/devices/virtio/virtio_console/src/tests/saved_state.rs @@ -10,9 +10,14 @@ use super::TX_AVAIL_ADDR; use super::TX_DESC_ADDR; use super::TX_USED_ADDR; use super::TestHarness; +use super::harness::BROKER_CAPABILITY; +use super::harness::BROKER_INSTANCE; +use super::harness::encode; use super::harness::yield_until; use super::new_mock_serial; use crate::VirtioConsoleDevice; +use crate::control_session_protocol::Record; +use crate::control_session_protocol::RecordType; use chipset_device::io::IoResult; use chipset_device::mmio::MmioIntercept; use guestmem::GuestMemory; @@ -106,6 +111,7 @@ async fn saved_state_validator_rejects_wrong_schema(driver: DefaultDriver) { partial_transmit: 0, staged_rx: Vec::new(), disconnect_policy_id: 0, + broker: None, }); assert!( validator( @@ -148,6 +154,7 @@ async fn saved_state_validator_rejects_tx_offset_past_descriptor(driver: Default partial_transmit: 6, staged_rx: Vec::new(), disconnect_policy_id: 0, + broker: None, }); let queues = [ DeviceQueueState { @@ -191,6 +198,7 @@ async fn inactive_transport_restore_defers_console_private_state(driver: Default partial_transmit: 0, staged_rx: staged_rx.clone(), disconnect_policy_id: 0, + broker: None, })); let destination_harness = TestHarness::new(&driver); @@ -235,6 +243,7 @@ async fn inactive_transport_restore_defers_console_private_state(driver: Default partial_transmit: 0, staged_rx: vec![0; crate::saved_state::MAX_STAGED_RX_BYTES + 1], disconnect_policy_id: 0, + broker: None, })); let invalid_harness = TestHarness::new(&driver); let mut invalid_destination = VirtioMmioDevice::new( @@ -250,6 +259,40 @@ async fn inactive_transport_restore_defers_console_private_state(driver: Default assert!(invalid_destination.restore(invalid).is_err()); } +#[async_test] +async fn direct_and_broker_saved_state_schemas_are_not_interchangeable(driver: DefaultDriver) { + let mut direct = TestHarness::new(&driver); + direct.enable().await; + direct.device.stop_queue(0).await; + direct.device.stop_queue(1).await; + let direct_saved = direct.device.save_device().unwrap().unwrap(); + let direct_state: crate::saved_state::SavedState = direct_saved.parse().unwrap(); + assert_eq!( + direct_state.schema_version, + crate::saved_state::DIRECT_SAVED_STATE_VERSION + ); + assert!(direct_state.broker.is_none()); + + let mut broker = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + assert!(broker.device.restore_device(Some(direct_saved)).is_err()); + + broker.enable().await; + broker.device.stop_queue(0).await; + broker.device.stop_queue(1).await; + let broker_saved = broker.device.save_device().unwrap().unwrap(); + let broker_state: crate::saved_state::SavedState = broker_saved.parse().unwrap(); + assert_eq!( + broker_state.schema_version, + crate::saved_state::BROKER_SAVED_STATE_VERSION + ); + assert!(broker_state.broker.is_some()); + + let (io, _) = new_mock_serial(); + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let mut direct_device = VirtioConsoleDevice::new(&driver_source, Box::new(io)); + assert!(direct_device.restore_device(Some(broker_saved)).is_err()); +} + #[async_test] async fn direct_schema_v1_allows_staged_rx_without_receive_queue(driver: DefaultDriver) { let (io, _) = new_mock_serial(); @@ -262,6 +305,7 @@ async fn direct_schema_v1_allows_staged_rx_without_receive_queue(driver: Default partial_transmit: 0, staged_rx: vec![1], disconnect_policy_id: 0, + broker: None, }; let saved = SavedStateBlob::new(state); let validator = device.device_state_validator(); @@ -288,6 +332,7 @@ async fn oversized_saved_state_is_rejected_before_nested_decode(driver: DefaultD partial_transmit: 0, staged_rx: vec![0; crate::saved_state::MAX_SAVED_STATE_BYTES], disconnect_policy_id: 0, + broker: None, }; let saved = SavedStateBlob::new(state); assert!(saved.encoded_len() > crate::saved_state::MAX_SAVED_STATE_BYTES); @@ -302,3 +347,49 @@ async fn oversized_saved_state_is_rejected_before_nested_decode(driver: DefaultD .is_err() ); } + +#[async_test] +async fn broker_state_validator_rejects_malformed_private_state(driver: DefaultDriver) { + let mut harness = TestHarness::new_broker(&driver, BROKER_INSTANCE, BROKER_CAPABILITY); + harness.enable().await; + harness.device.stop_queue(0).await; + harness.device.stop_queue(1).await; + let saved = harness.device.save_device().unwrap().unwrap(); + let mut state: crate::saved_state::SavedState = saved.parse().unwrap(); + state.broker.as_mut().unwrap().guest_parser.header_count = u32::MAX; + let malformed = SavedStateBlob::new(state); + let validator = harness.device.device_state_validator(); + assert!( + validator( + Some(&malformed), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_err() + ); + + let mut state: crate::saved_state::SavedState = saved.parse().unwrap(); + let broker = state.broker.as_mut().unwrap(); + broker.instance_id = [0x44; 16].to_vec(); + broker.guest_output.current = Some(crate::saved_state::SavedEncodedRecord { + bytes: encode(&Record::session( + RecordType::Reset, + BROKER_INSTANCE, + 1, + 0, + Vec::new(), + )), + offset: 1, + }); + let malformed = SavedStateBlob::new(state); + assert!( + validator( + Some(&malformed), + &VirtioDeviceFeatures::new(), + &[], + &GuestMemory::empty(), + ) + .is_err() + ); +} From a9996abd1dd4f271ec33420d21f1e61a5e4f525c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:12:52 -0700 Subject: [PATCH 147/304] openvmm: reserve the dedicated microVM control-console slot The control console is a second virtio-console for the guest's control agent, next to the boot console. Give it a fixed place in the microVM machine contract before any endpoint can attach to it, so its discovery order and kernel command line cannot be shaped by the user. The profile already reserves MMIO `0xd0007000`, IRQ 3 for it. Complete the reservation: - `MICROVM_VIRTIO_CONTROL_CONSOLE_ID` names the device resource (`virtio-control-console`), and the worker maps it to its MMIO slot and IRQ. The machine configuration allows at most one control console and requires the boot console, so the control console is always `hvc2` and the boot console keeps `console=hvc1`. - With a control console, `append_microvm_virtio_discovery` appends its `virtio_mmio.device=` token after the boot console's and adds the host-owned `nvx_control_tty=hvc2` token. Command-line validation expects exactly these tokens. - `build_microvm_control_command_line` builds the command line for a machine with a control console. On top of the usual reserved tokens, it rejects user arguments that could change the control tty or the discovery order: `nvx_control_tty`, `driver_async_probe`, and `virtio_mmio.device` in either `_` or `-` spelling, quotes, and the `--` delimiter that ends kernel parameters. Validation applies the same rules to the effective command line. A machine without a control console keeps accepting all of these. - `append_microvm_processor_limit` moves below the command-line builders. It is unchanged. The entry detects the control-console device when it finishes the configuration and passes it to discovery. Nothing adds the device yet; the endpoints come in a following commit. The helpers test that models the worker's platform command line passes no control console for now. The CLI reference describes the slot, the `nvx_control_tty=hvc2` token, and the fixed discovery order. It also drops `nr_cpus=` from the list of user-overridable tokens, because the paragraph already describes it as profile-owned. Tests check the stable control-console slot, that the control restrictions leave command lines without a control console unchanged, that ambiguous or overridden discovery is rejected, and that the control command-line builder rejects each reserved token. The CLI discovery test also covers sandbox-block and network tokens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 20 +- .../src/worker/dispatch/microvm.rs | 4 + openvmm/openvmm_defs/src/microvm.rs | 225 ++++++++++++++++-- openvmm/openvmm_entry/src/cli_args/microvm.rs | 93 +++++++- openvmm/openvmm_entry/src/microvm/config.rs | 4 + .../openvmm_entry/src/microvm/filesystem.rs | 2 + openvmm/openvmm_entry/src/microvm/network.rs | 1 + .../openvmm_helpers/src/snapshot/microvm.rs | 1 + 8 files changed, 318 insertions(+), 32 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 46e1c5a3631..51402b01eab 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -45,21 +45,31 @@ describes the source definitions. anchored to UTC, the microVM portb console, lifecycle ports, and the optional fixed virtio devices described below. User arguments cannot override `earlycon=`, - `console=`, `virtio_mmio.device=`, `nr_cpus=`, `virtnet_*=`, or `virtfs_*=`. + `console=`, `virtio_mmio.device=`, `virtnet_*=`, or `virtfs_*=`. One virtio-fs slot is exposed at MMIO `0xd0001000`, IRQ 6 and remains dormant when `--mount` is omitted; an optional `--mount` binds HostFs to it; one optional `--virtio-console ` is exposed at MMIO `0xd0002000`, IRQ 7 as the boot/log console (`hvc1`); and `--microvm-sandbox-block` exposes fixed distro, runtime, custom, and scratch slots starting at MMIO - `0xd0003000`. Ordinary `--virtio-blk` is rejected. All use split rings. - Firmware, ACPI, SMBIOS, PCI, VMBus, UARTs, graphics, isolation, nested - virtualization, and other devices are rejected. Linux discovers contiguous - APIC IDs and the IOAPIC from Intel + `0xd0003000`. The profile also reserves MMIO `0xd0007000`, IRQ 3 for the + dedicated control console (`hvc2`), selected by the host-owned + `nvx_control_tty=hvc2` token. The reserved slot and resource identity do not + expose a live control endpoint. Both consoles + remain virtio-console devices from the guest's perspective. Ordinary + `--virtio-blk` is rejected. All use split rings. Firmware, ACPI, SMBIOS, PCI, + VMBus, UARTs, graphics, isolation, nested virtualization, and other devices + are rejected. Linux discovers contiguous APIC IDs and the IOAPIC from Intel MP 1.4 tables at `0x0` and `0x400`; `boot_params` is at `0x2000`, the command line starts at `0x20000`, and no ACPI or SMBIOS data is exposed. Host-driven save/restore, pulse-save/restore, and worker restart remain unavailable. + `microvm` may also expose a dedicated control virtio-console at MMIO + `0xd0007000`, IRQ 3. It requires the boot virtio-console, preserves + `console=hvc1`, and publishes `nvx_control_tty=hvc2`. The profile fixes + boot-before-control discovery order and rejects user overrides that could + change it. + `microvm` uses one socket and one die, with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. Guest-requested snapshot capture and new-process restore are available for diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 189519c577e..310bcc6c5be 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -494,6 +494,10 @@ fn virtio_mmio_config( openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ, ), + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_ID => ( + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, + ), "virtio-blk" => { let block = sandbox_blocks .get(*sandbox_block_index) diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 447312b7edf..33bfd95d6df 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -76,6 +76,10 @@ pub const MICROVM_VIRTIO_NET_FEATURES: u64 = (1 << 5) | (1 << 32); pub const MICROVM_VIRTIO_FS_FEATURES: u64 = (1 << 28) | (1 << 29) | (1 << 32) | (1 << 33); /// MicroVM client console reconnect timeout. pub const MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS: u64 = 5_000; +/// Resource identity of the microVM control virtio-console device. +pub const MICROVM_VIRTIO_CONTROL_CONSOLE_ID: &str = "virtio-control-console"; +/// Host-owned kernel command-line token identifying the control tty. +pub const MICROVM_CONTROL_TTY_COMMAND_LINE: &str = "nvx_control_tty=hvc2"; /// MicroVM virtio MMIO reservations in stable device order. pub const MICROVM_VIRTIO_MMIO_BASES: [u64; 8] = [ MICROVM_VIRTIO_NET_MMIO_BASE, @@ -591,13 +595,18 @@ pub fn append_microvm_virtio_discovery( filesystem_slot: bool, filesystem: Option<&MicrovmFilesystemConfig>, has_console: bool, + has_control_console: bool, blocks: &[MicrovmSandboxBlockConfig], ) -> anyhow::Result<()> { validate_microvm_sandbox_blocks(blocks)?; anyhow::ensure!( - !cmdline - .split_ascii_whitespace() - .any(|token| token.starts_with("virtio_mmio.device=")), + !cmdline.split_ascii_whitespace().any(|token| { + if has_control_console { + kernel_parameter_name_matches(token, "virtio_mmio.device") + } else { + token.starts_with("virtio_mmio.device=") + } + }), "microVM command line already contains virtio-mmio discovery" ); anyhow::ensure!( @@ -636,6 +645,16 @@ pub fn append_microvm_virtio_discovery( block.role.irq() )?; } + if has_control_console { + anyhow::ensure!( + has_console, + "microVM control console requires the boot virtio-console" + ); + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ} {MICROVM_CONTROL_TTY_COMMAND_LINE}" + )?; + } if let Some((network, _, gateway_dns)) = network { write!( cmdline, @@ -682,6 +701,11 @@ fn validate_microvm_command_line( .virtio_devices .iter() .any(|(_, device)| device.id() == "virtio-console"); + let has_control_console = config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == MICROVM_VIRTIO_CONTROL_CONSOLE_ID); + validate_microvm_control_console_command_line(&tokens, has_control_console)?; let block_count = config .virtio_devices .iter() @@ -712,6 +736,10 @@ fn validate_microvm_command_line( block_count == config.microvm.sandbox_blocks.len(), "microVM sandbox block roles do not match the virtio-blk device inventory" ); + anyhow::ensure!( + !has_control_console || has_console, + "microVM control console requires the boot console" + ); let base_tokens = if has_console { MICROVM_CONSOLE_COMMAND_LINE } else { @@ -734,10 +762,17 @@ fn validate_microvm_command_line( "virtfs_tag=", "virtfs_mode=", ] { - let count = tokens - .iter() - .filter(|token| token.starts_with(prefix)) - .count(); + let count = if prefix == "virtio_mmio.device=" && has_control_console { + tokens + .iter() + .filter(|token| kernel_parameter_name_matches(token, "virtio_mmio.device")) + .count() + } else { + tokens + .iter() + .filter(|token| token.starts_with(prefix)) + .count() + }; let expected = match prefix { "virtio_mmio.device=" => config.virtio_devices.len(), "virtnet_ip=" | "virtnet_mask=" | "virtnet_gw=" => usize::from(has_network), @@ -795,6 +830,12 @@ fn validate_microvm_command_line( block.role.irq() ) })); + if has_control_console { + expected_discovery.push(format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ}" + )); + expected_discovery.push(MICROVM_CONTROL_TTY_COMMAND_LINE.to_owned()); + } if let Some(network) = &config.microvm.network { expected_discovery.extend( network @@ -834,11 +875,90 @@ fn validate_microvm_command_line( pub fn build_microvm_command_line( user_args: &[String], has_console: bool, +) -> anyhow::Result { + build_microvm_command_line_inner(user_args, has_console, false) +} + +/// Builds the microVM command line with control-console tokens reserved. +pub fn build_microvm_control_command_line( + user_args: &[String], + has_console: bool, +) -> anyhow::Result { + build_microvm_command_line_inner(user_args, has_console, true) +} + +/// Appends the host-owned processor capacity to a microVM command line. +pub fn append_microvm_processor_limit( + cmdline: &mut String, + processor_count: u32, +) -> anyhow::Result<()> { + anyhow::ensure!( + microvm_processor_count_supported(processor_count), + "microVM does not support {processor_count} vCPUs" + ); + write!(cmdline, " nr_cpus={processor_count}")?; + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM kernel command line exceeds the 64-KiB ABI limit" + ); + Ok(()) +} + +fn kernel_parameter_name_matches(token: &str, expected: &str) -> bool { + let Some((name, _)) = token.split_once('=') else { + return false; + }; + name.len() == expected.len() + && name + .bytes() + .zip(expected.bytes()) + .all(|(actual, expected)| actual == expected || (actual == b'-' && expected == b'_')) +} + +fn validate_microvm_control_console_command_line( + tokens: &[&str], + has_control_console: bool, +) -> anyhow::Result<()> { + if !has_control_console { + return Ok(()); + } + anyhow::ensure!( + !tokens.iter().any(|token| token.contains('"')) && !tokens.contains(&"--"), + "microVM control-console command line cannot contain quotes or the kernel argument delimiter" + ); + anyhow::ensure!( + !tokens + .iter() + .any(|token| kernel_parameter_name_matches(token, "driver_async_probe")), + "microVM control-console command line cannot override driver probe ordering" + ); + anyhow::ensure!( + tokens + .iter() + .filter(|token| kernel_parameter_name_matches(token, "nvx_control_tty")) + .count() + == 1, + "microVM command line must contain exactly one nvx_control_tty= token" + ); + Ok(()) +} + +fn build_microvm_command_line_inner( + user_args: &[String], + has_console: bool, + reserve_control_console: bool, ) -> anyhow::Result { for arg in user_args { if arg.contains('\0') { anyhow::bail!("microVM kernel command line contains an embedded NUL"); } + if reserve_control_console + && (arg.contains('"') || arg.split_ascii_whitespace().any(|token| token == "--")) + { + anyhow::bail!( + "microVM control-console command line cannot contain quotes or the kernel argument delimiter" + ); + } if arg.split_ascii_whitespace().any(|token| { [ "earlycon=", @@ -856,6 +976,10 @@ pub fn build_microvm_command_line( ] .iter() .any(|reserved| token.starts_with(reserved)) + || (reserve_control_console + && (kernel_parameter_name_matches(token, "nvx_control_tty") + || kernel_parameter_name_matches(token, "driver_async_probe") + || kernel_parameter_name_matches(token, "virtio_mmio.device"))) }) { anyhow::bail!( "microVM kernel command line cannot override profile-owned configuration" @@ -879,23 +1003,6 @@ pub fn build_microvm_command_line( Ok(cmdline) } -/// Appends the host-owned processor capacity to a microVM command line. -pub fn append_microvm_processor_limit( - cmdline: &mut String, - processor_count: u32, -) -> anyhow::Result<()> { - anyhow::ensure!( - microvm_processor_count_supported(processor_count), - "microVM does not support {processor_count} vCPUs" - ); - write!(cmdline, " nr_cpus={processor_count}")?; - anyhow::ensure!( - cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, - "microVM kernel command line exceeds the 64-KiB ABI limit" - ); - Ok(()) -} - /// The guest-visible machine contract, independent of the hypervisor backend. #[derive(MeshPayload, Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum MachineProfile { @@ -1165,6 +1272,7 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> let mut has_network = false; let mut has_filesystem = false; let mut has_console = false; + let mut has_control_console = false; let mut block_count = 0; for (bus, device) in &config.virtio_devices { anyhow::ensure!( @@ -1184,10 +1292,20 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> !std::mem::replace(&mut has_console, true), "microVM permits only one virtio-console device" ), + MICROVM_VIRTIO_CONTROL_CONSOLE_ID => { + anyhow::ensure!( + !std::mem::replace(&mut has_control_console, true), + "microVM permits only one control console" + ); + } "virtio-blk" => block_count += 1, id => anyhow::bail!("microVM does not permit virtio device '{id}'"), } } + anyhow::ensure!( + !has_control_console || has_console, + "microVM control console requires the boot console" + ); validate_microvm_sandbox_blocks(&config.microvm.sandbox_blocks)?; anyhow::ensure!( block_count == config.microvm.sandbox_blocks.len(), @@ -1327,6 +1445,46 @@ mod tests { ); } + #[test] + fn control_console_restrictions_preserve_existing_command_lines() { + let user_args = [ + r#"note="left right""#.to_owned(), + "--".to_owned(), + "driver_async_probe=virtio_mmio".to_owned(), + "nvx_control_tty=hvc9".to_owned(), + "virtio-mmio.device=0x1000@0xc0000000:1".to_owned(), + ]; + let cmdline = build_microvm_command_line(&user_args, true).unwrap(); + let tokens = cmdline.split_ascii_whitespace().collect::>(); + validate_microvm_control_console_command_line(&tokens, false).unwrap(); + + let mut cmdline = cmdline; + append_microvm_virtio_discovery(&mut cmdline, None, false, None, true, false, &[]).unwrap(); + + assert!(build_microvm_control_command_line(&user_args, true).is_err()); + } + + #[test] + fn control_console_rejects_ambiguous_or_overridden_discovery() { + for cmdline in [ + r#"earlycon=xe9 console=hvc1 reboot=t panic=-1 note="left right" nvx_control_tty=hvc2"#, + "earlycon=xe9 console=hvc1 reboot=t panic=-1 -- nvx_control_tty=hvc2", + "earlycon=xe9 console=hvc1 reboot=t panic=-1 driver-async-probe=virtio_mmio nvx_control_tty=hvc2", + "earlycon=xe9 console=hvc1 reboot=t panic=-1", + "earlycon=xe9 console=hvc1 reboot=t panic=-1 nvx_control_tty=hvc2 nvx-control-tty=hvc2", + ] { + let tokens = cmdline.split_ascii_whitespace().collect::>(); + assert!(validate_microvm_control_console_command_line(&tokens, true).is_err()); + } + + let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); + cmdline.push_str(" virtio-mmio.device=0x1000@0xc0000000:1"); + assert!( + append_microvm_virtio_discovery(&mut cmdline, None, false, None, true, true, &[]) + .is_err() + ); + } + #[test] fn microvm_snapshot_tier_command_line_token_is_host_owned() { assert!( @@ -1373,7 +1531,8 @@ mod tests { validate_microvm_sandbox_blocks(&blocks).unwrap(); let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); - append_microvm_virtio_discovery(&mut cmdline, None, false, None, false, &blocks).unwrap(); + append_microvm_virtio_discovery(&mut cmdline, None, false, None, false, false, &blocks) + .unwrap(); assert_eq!( cmdline, format!( @@ -1386,6 +1545,21 @@ mod tests { ); } + #[test] + fn microvm_control_console_slot_is_stable() { + let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery(&mut cmdline, None, false, None, true, true, &[]).unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_CONSOLE_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0007000:3 \ + {MICROVM_CONTROL_TTY_COMMAND_LINE}" + ) + ); + } + #[test] fn microvm_block_irqs_avoid_rtc_and_are_edge_triggered() { assert_eq!(MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, 3); @@ -1414,6 +1588,7 @@ mod tests { } assert_eq!(microvm_virtio_status_gpa(0xd000_8000), None); } + #[test] fn microvm_sandbox_block_validation_rejects_invalid_layouts() { assert!( diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 1bc4b5c7feb..e7507845de4 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -832,6 +832,7 @@ mod tests { use openvmm_defs::microvm::MICROVM_CONSOLE_COMMAND_LINE; use openvmm_defs::microvm::append_microvm_virtio_discovery; use openvmm_defs::microvm::build_microvm_command_line; + use openvmm_defs::microvm::build_microvm_control_command_line; use test_with_tracing::test; #[test] @@ -1112,12 +1113,85 @@ mod tests { ); let mut with_devices = build_microvm_command_line(&[], true).unwrap(); - append_microvm_virtio_discovery(&mut with_devices, None, false, None, true, &[]).unwrap(); + let blocks = [ + openvmm_defs::microvm::MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + openvmm_defs::microvm::MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ]; + append_microvm_virtio_discovery(&mut with_devices, None, false, None, true, false, &blocks) + .unwrap(); assert_eq!( with_devices, - format!("{MICROVM_CONSOLE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0002000:7") + format!( + "{MICROVM_CONSOLE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0002000:7 virtio_mmio.device=0x1000@0xd0003000:4 virtio_mmio.device=0x1000@0xd0006000:11" + ) + ); + let mut with_control_console = build_microvm_control_command_line(&[], true).unwrap(); + append_microvm_virtio_discovery( + &mut with_control_console, + None, + false, + None, + true, + true, + &[], + ) + .unwrap(); + assert_eq!( + with_control_console, + format!( + "{MICROVM_CONSOLE_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0007000:3 \ + {}", + openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE + ) ); + let network = "10.0.0.2/24".parse().unwrap(); + let mut with_network = build_microvm_command_line(&[], false).unwrap(); + append_microvm_virtio_discovery( + &mut with_network, + Some(( + &network, + openvmm_defs::microvm::MICROVM_VIRTIO_NET_KVM_IRQ, + false, + )), + false, + None, + false, + false, + &[], + ) + .unwrap(); + assert_eq!( + with_network, + format!( + "{MICROVM_BASE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0000000:10 virtnet_ip=10.0.0.2 virtnet_mask=255.255.255.0 virtnet_gw=10.0.0.1" + ) + ); + let mut with_whp_network = build_microvm_command_line(&[], false).unwrap(); + append_microvm_virtio_discovery( + &mut with_whp_network, + Some(( + &network, + openvmm_defs::microvm::MICROVM_VIRTIO_NET_WHP_IRQ, + true, + )), + false, + None, + false, + false, + &[], + ) + .unwrap(); + assert!(with_whp_network.ends_with("virtnet_dns=10.0.0.1")); + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( "/mnt/share".to_owned(), openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, @@ -1130,6 +1204,7 @@ mod tests { true, Some(&filesystem), false, + false, &[], ) .unwrap(); @@ -1155,6 +1230,20 @@ mod tests { ] { assert!(build_microvm_command_line(&[reserved.into()], false).is_err()); } + for reserved in [ + "nvx_control_tty=hvc9", + "nvx-control-tty=hvc9", + "driver_async_probe=virtio_console", + "driver-async-probe=virtio_console", + "virtio-mmio.device=0x1000@0xd0007000:3", + ] { + assert!(build_microvm_control_command_line(&[reserved.into()], false).is_err()); + assert!(build_microvm_command_line(&[reserved.into()], false).is_ok()); + } + for delimiter in ["--", "\"driver-async-probe=virtio_console\""] { + assert!(build_microvm_control_command_line(&[delimiter.into()], false).is_err()); + assert!(build_microvm_command_line(&[delimiter.into()], false).is_ok()); + } assert!(build_microvm_command_line(&["foo=bar\0baz".into()], false).is_err()); assert!( build_microvm_command_line(&["x".repeat(MICROVM_COMMAND_LINE_MAX_SIZE)], false) diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index ae20368d5fa..cbe0e17ee66 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -595,6 +595,9 @@ impl<'a> MicrovmConfigBuilder<'a> { .virtio_devices .iter() .any(|(_, device)| device.id() == "virtio-console"); + let has_control_console = cfg.virtio_devices.iter().any(|(_, device)| { + device.id() == openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_ID + }); let network_irq = cfg .microvm .network @@ -626,6 +629,7 @@ impl<'a> MicrovmConfigBuilder<'a> { self.filesystem_slot, cfg.microvm.filesystem.as_ref(), has_console, + has_control_console, &cfg.microvm.sandbox_blocks, )?; } diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index b767799f4bb..be1f96fa907 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -459,6 +459,7 @@ mod tests { true, Some(&filesystem), false, + false, &[], ) .unwrap(); @@ -505,6 +506,7 @@ mod tests { true, None, false, + false, &[], ) .unwrap(); diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index b73e4376b4c..9863f3c3f68 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -213,6 +213,7 @@ pub(crate) mod tests { false, None, false, + false, &[], ) .unwrap(); diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 4226ed8dba2..2e24b6db712 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -2948,6 +2948,7 @@ mod tests { false, None, true, + false, &[ openvmm_defs::microvm::MicrovmSandboxBlockConfig { role: openvmm_defs::microvm::MicrovmSandboxBlockRole::Distro, From 96056b29962054b595cc8534f726c163906308da Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:15:16 -0700 Subject: [PATCH 148/304] openvmm_helpers: record the control console in the microVM machine contract A snapshot of a microVM with a control console must describe that console, so a restore recreates the same device and only accepts an endpoint that the snapshot allows. Teach the machine contract about the control console before the entry can attach one. `microvm_machine_contract` takes an optional control-console attachment. With one: - The attachment must be `console:microvm-control0` of kind `virtio-control-console`, not required, with no reconnect timeout, a bounded identity, and one of two policies: `broker-authenticated-listener` for a Unix socket or named pipe that the broker authenticates, or `broker-disconnected` for a console with no host endpoint. - The effective command line must contain the control console's discovery token and `nvx_control_tty=hvc2`. - The contract lists the device at MMIO `0xd0007000`, IRQ 3, with its virtio-mmio feature banks, two 256-entry queues, and state unit `virtio-control-console-`, and records the attachment. Shape validation accepts the two broker policies with the same rules. A platform snapshot's command line may carry `nvx_control_tty=hvc2` as an invariant platform token, at most once. The entry passes no control-console attachment yet, both when it captures a snapshot and when it validates a restore; the endpoints that produce one come next. Tests check the control console's fixed ABI and its separation from the boot console, that a broker listener attachment is accepted, that a changed attachment fails validation, and that platform snapshots reject a wrong or duplicated control tty. The test that models the worker's platform command line now includes a control console. The shared test helpers gain control-console attachments and contract builders, a state unit name helper, and fixed test frequencies, and the attachment and contract helpers are grouped by kind. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../openvmm_entry/src/microvm/filesystem.rs | 2 + openvmm/openvmm_entry/src/microvm/network.rs | 1 + openvmm/openvmm_entry/src/microvm/restore.rs | 1 + .../src/vm_controller/microvm.rs | 1 + .../openvmm_helpers/src/snapshot/microvm.rs | 416 +++++++++++++++--- 5 files changed, 367 insertions(+), 54 deletions(-) diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index be1f96fa907..a832ba21976 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -471,6 +471,7 @@ mod tests { true, Some((&filesystem, Path::new(&root_path), attachment)), None, + None, Vec::new(), 1, 1024, @@ -518,6 +519,7 @@ mod tests { true, None, None, + None, Vec::new(), 1, 1024, diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index 9863f3c3f68..028cd33fd13 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -225,6 +225,7 @@ pub(crate) mod tests { false, None, None, + None, Vec::new(), 1, 1024, diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 7de5c502463..921805efcf1 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -373,6 +373,7 @@ pub(crate) fn validate_restore_contract( filesystem_slot, filesystem, console_attachment.cloned(), + None, sandbox_blocks, expected_vp_count, expected_memory_size, diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index a7d5225dc6d..6660a437249 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -228,6 +228,7 @@ impl VmController { self.microvm.filesystem_slot, filesystem, self.microvm.resources.console_attachment.clone(), + None, blocks, self.processors, self.memory, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 2e24b6db712..5e60802d0ae 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -660,6 +660,7 @@ pub fn microvm_machine_contract( SnapshotAttachment, )>, console_attachment: Option, + control_console_attachment: Option, sandbox_blocks: Vec, processor_count: u32, memory_size: u64, @@ -1029,6 +1030,67 @@ pub fn microvm_machine_contract( queue_max_sizes: vec![256], }); } + if let Some(attachment) = control_console_attachment { + let policy_is_valid = match attachment.reconnect_policy.as_str() { + "broker-authenticated-listener" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && matches!( + attachment.identity_kind.as_str(), + "unix-socket" | "named-pipe" + ) + } + "broker-disconnected" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.identity_kind == "disconnected" + && attachment.identity == b"discard" + } + _ => false, + }; + anyhow::ensure!( + attachment.stable_id == "console:microvm-control0" + && attachment.kind == "virtio-control-console" + && policy_is_valid + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES + && attachment.length == 0, + "microVM control console attachment has an unsupported reconnect policy" + ); + let discovery = format!( + "virtio_mmio.device={:#x}@{:#x}:{}", + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, + ); + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + anyhow::ensure!( + tokens.contains(discovery.as_str()) + && tokens.contains(openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE), + "microVM control console command line does not match its saved identity" + ); + devices.push(SnapshotDevice { + stable_id: "console:microvm-control0".to_owned(), + state_unit_name: format!( + "virtio-control-console-{}", + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE + ), + kind: "virtio-control-console".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio( + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + )], + irq: Some(openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![0x3000_0001, 0x0000_0003], + queue_count: 2, + queue_max_sizes: vec![256, 256], + }); + attachments.push(attachment); + } let mut contract = SnapshotMachineContract { machine_profile: "microvm".to_owned(), @@ -1670,6 +1732,20 @@ pub(super) fn validate_machine_contract_shape( "discard-while-disconnected" => { !attachment.required && attachment.reconnect_timeout_ms == 0 } + "broker-authenticated-listener" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && matches!( + attachment.identity_kind.as_str(), + "unix-socket" | "named-pipe" + ) + } + "broker-disconnected" => { + !attachment.required + && attachment.reconnect_timeout_ms == 0 + && attachment.identity_kind == "disconnected" + && attachment.identity == b"discard" + } "recreate-endpoint" => { !attachment.required && attachment.reconnect_timeout_ms == 0 } @@ -1770,6 +1846,15 @@ pub(super) fn validate_snapshot_tier(manifest: &SnapshotManifest) -> anyhow::Res tsc_frequency_tokens == [expected_tsc_frequency.as_str()], "platform snapshot command line TSC frequency does not match its machine contract" ); + let control_tty_count = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| *token == openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE) + .count(); + anyhow::ensure!( + control_tty_count <= 1, + "platform snapshot command line contains duplicate control tty configuration" + ); let apic_frequency_tokens = contract .effective_command_line .split_ascii_whitespace() @@ -1826,6 +1911,7 @@ fn platform_command_line_token_is_invariant(token: &str) -> bool { | "nvx_snapshot_tier=platform" ) || token.starts_with("tsc_early_khz=") || token.starts_with("nr_cpus=") + || token == openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE || token.starts_with("lapic_timer_hz=") || [ "virtio_mmio.device=", @@ -1888,7 +1974,7 @@ pub(super) fn paired_scratch_manifest(scratch: &[u8]) -> SnapshotManifest { } #[cfg(test)] -fn test_machine_contract() -> SnapshotMachineContract { +pub(super) fn test_machine_contract() -> SnapshotMachineContract { let mut contract = SnapshotMachineContract { machine_profile: "microvm".to_owned(), microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, @@ -1980,6 +2066,13 @@ mod tests { use super::super::validate_manifest; use super::*; + const TEST_FREQUENCY_HZ: u64 = 1_000_000_000; + const TEST_SCRATCH_IDENTITY_BYTE: u8 = 0x22; + + fn virtio_state_unit_name(kind: &str, mmio_base: u64) -> String { + format!("{kind}-{mmio_base}") + } + #[test] fn restore_online_vp_count_is_bounded_by_template() { assert_eq!( @@ -2029,6 +2122,32 @@ mod tests { } } + fn microvm_control_console_attachment() -> SnapshotAttachment { + SnapshotAttachment { + stable_id: "console:microvm-control0".to_owned(), + kind: "virtio-control-console".to_owned(), + required: false, + reconnect_policy: "broker-disconnected".to_owned(), + identity_kind: "disconnected".to_owned(), + identity: b"discard".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + + fn microvm_control_console_listener_attachment() -> SnapshotAttachment { + SnapshotAttachment { + stable_id: "console:microvm-control0".to_owned(), + kind: "virtio-control-console".to_owned(), + required: false, + reconnect_policy: "broker-authenticated-listener".to_owned(), + identity_kind: "unix-socket".to_owned(), + identity: b"/run/nvx/control.sock".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + fn microvm_network_attachment(source_hypervisor: &str) -> SnapshotAttachment { assert!(matches!(source_hypervisor, "kvm" | "mshv" | "whp")); SnapshotAttachment { @@ -2043,6 +2162,24 @@ mod tests { } } + fn microvm_filesystem_attachment(source_hypervisor: &str) -> SnapshotAttachment { + SnapshotAttachment { + stable_id: "fs:microvm0".to_owned(), + kind: "virtio-fs".to_owned(), + required: true, + reconnect_policy: "live-revalidate".to_owned(), + identity_kind: match source_hypervisor { + "kvm" | "mshv" => "unix-device-inode-v1", + "whp" => "windows-volume-file-id-v1", + _ => unreachable!(), + } + .to_owned(), + identity: b"root-object-v1".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + } + } + fn generated_network_contract(source_hypervisor: &str) -> SnapshotMachineContract { generated_network_contract_with_mode( source_hypervisor, @@ -2082,6 +2219,7 @@ mod tests { false, None, None, + None, Vec::new(), 1, 1024, @@ -2108,50 +2246,16 @@ mod tests { .unwrap() } - fn microvm_filesystem_attachment(source_hypervisor: &str) -> SnapshotAttachment { - SnapshotAttachment { - stable_id: "fs:microvm0".to_owned(), - kind: "virtio-fs".to_owned(), - required: true, - reconnect_policy: "live-revalidate".to_owned(), - identity_kind: match source_hypervisor { - "kvm" | "mshv" => "unix-device-inode-v1", - "whp" => "windows-volume-file-id-v1", - _ => unreachable!(), - } - .to_owned(), - identity: b"root-object-v1".to_vec(), - length: 0, - reconnect_timeout_ms: 0, - } - } - - fn generated_filesystem_contract(source_hypervisor: &str) -> SnapshotMachineContract { - let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( - "/mnt/share".to_owned(), - openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, - ) - .and_then(|config| config.with_denied_paths(vec!["secrets".to_owned()])) - .unwrap(); - let command_line = format!( - "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6 {}", - filesystem.command_line_fragment() - ); + fn generated_console_contract() -> SnapshotMachineContract { microvm_machine_contract( - source_hypervisor, + "whp", MICROVM_BOOT_LAYOUT_VERSION, - command_line, + "earlycon=xe9 console=hvc1 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0002000:7" + .to_owned(), None, - true, - Some(( - &filesystem, - Path::new(if cfg!(windows) { - r"C:\microvm-share" - } else { - "/microvm-share" - }), - microvm_filesystem_attachment(source_hypervisor), - )), + false, + None, + Some(microvm_console_attachment()), None, Vec::new(), 1, @@ -2167,7 +2271,7 @@ mod tests { "microvm-portb", "microvm-shutdown", "microvm-snapshot-request", - "virtiofs-3489665024", + "virtio-console-3489669120", ] .map(str::to_owned) .to_vec(), @@ -2179,14 +2283,130 @@ mod tests { .unwrap() } - fn generated_dormant_filesystem_contract() -> SnapshotMachineContract { + fn generated_control_console_contract_with_attachment( + control_console_attachment: SnapshotAttachment, + ) -> SnapshotMachineContract { + let command_line = format!( + "{} \ + virtio_mmio.device={:#x}@{:#x}:{} \ + virtio_mmio.device={:#x}@{:#x}:{} \ + virtio_mmio.device={:#x}@{:#x}:{} \ + virtio_mmio.device={:#x}@{:#x}:{} \ + {}", + openvmm_defs::microvm::MICROVM_CONSOLE_COMMAND_LINE, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_BLK_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_BLK_IRQ, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[3], + openvmm_defs::microvm::MICROVM_VIRTIO_SCRATCH_BLK_IRQ, + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, + openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE, + ); microvm_machine_contract( "whp", MICROVM_BOOT_LAYOUT_VERSION, - "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6" - .to_owned(), + command_line, + None, + false, + None, + Some(microvm_console_attachment()), + Some(control_console_attachment), + vec![ + SnapshotMicrovmSandboxBlock { + role: "distro".to_owned(), + read_only: true, + length: 512, + identity_kind: "sha256".to_owned(), + identity: vec![0x11; SHA256_SIZE], + artifact: String::new(), + logical_block_size: 512, + physical_block_size: 4096, + }, + SnapshotMicrovmSandboxBlock { + role: "scratch".to_owned(), + read_only: false, + length: 512, + identity_kind: "sha256".to_owned(), + identity: vec![TEST_SCRATCH_IDENTITY_BYTE; SHA256_SIZE], + artifact: SCRATCH_FILE_NAME.to_owned(), + logical_block_size: 512, + physical_block_size: 4096, + }, + ], + 1, + 1024, + None, + vec![ + "partition".to_owned(), + "vmtime".to_owned(), + "pic".to_owned(), + "ioapic".to_owned(), + "pit".to_owned(), + "rtc".to_owned(), + "microvm-portb".to_owned(), + "microvm-shutdown".to_owned(), + "microvm-snapshot-request".to_owned(), + virtio_state_unit_name( + "virtio-console", + openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, + ), + virtio_state_unit_name( + "virtio-blk", + openvmm_defs::microvm::MICROVM_VIRTIO_BLK_MMIO_BASE, + ), + virtio_state_unit_name( + "virtio-blk", + openvmm_defs::microvm::MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[3], + ), + virtio_state_unit_name( + "virtio-control-console", + openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + ), + ], + std::time::SystemTime::now().into(), + TEST_FREQUENCY_HZ, + Some(TEST_FREQUENCY_HZ), + vec![1, 2, 3], + ) + .unwrap() + } + + fn generated_control_console_contract() -> SnapshotMachineContract { + generated_control_console_contract_with_attachment(microvm_control_console_attachment()) + } + + fn generated_filesystem_contract(source_hypervisor: &str) -> SnapshotMachineContract { + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/mnt/share".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .and_then(|config| config.with_denied_paths(vec!["secrets".to_owned()])) + .unwrap(); + let command_line = format!( + "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6 {}", + filesystem.command_line_fragment() + ); + microvm_machine_contract( + source_hypervisor, + MICROVM_BOOT_LAYOUT_VERSION, + command_line, None, true, + Some(( + &filesystem, + Path::new(if cfg!(windows) { + r"C:\microvm-share" + } else { + "/microvm-share" + }), + microvm_filesystem_attachment(source_hypervisor), + )), None, None, Vec::new(), @@ -2215,16 +2435,17 @@ mod tests { .unwrap() } - fn generated_console_contract() -> SnapshotMachineContract { + fn generated_dormant_filesystem_contract() -> SnapshotMachineContract { microvm_machine_contract( "whp", MICROVM_BOOT_LAYOUT_VERSION, - "earlycon=xe9 console=hvc1 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0002000:7" + "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6" .to_owned(), None, - false, + true, + None, + None, None, - Some(microvm_console_attachment()), Vec::new(), 1, 1024, @@ -2239,7 +2460,7 @@ mod tests { "microvm-portb", "microvm-shutdown", "microvm-snapshot-request", - "virtio-console-3489669120", + "virtiofs-3489665024", ] .map(str::to_owned) .to_vec(), @@ -2266,6 +2487,45 @@ mod tests { assert_eq!(contract.attachments, [microvm_console_attachment()]); } + #[test] + fn generated_microvm_control_console_contract_has_distinct_fixed_abi() { + let contract = generated_control_console_contract(); + let boot = contract + .devices + .iter() + .find(|device| device.stable_id == "console:microvm-virtio0") + .unwrap(); + let control = contract + .devices + .iter() + .find(|device| device.stable_id == "console:microvm-control0") + .unwrap(); + assert_eq!(boot.stable_id, "console:microvm-virtio0"); + assert_eq!(control.stable_id, "console:microvm-control0"); + assert_eq!(control.state_unit_name, "virtio-control-console-3489689600"); + assert_eq!(control.ranges[0].start, 0xd000_7000); + assert_eq!(control.ranges[0].length, 0x1000); + assert_eq!(control.irq, Some(3)); + assert_eq!(control.transport, "virtio-mmio"); + assert_eq!(control.feature_banks, boot.feature_banks); + assert_eq!(control.queue_max_sizes, [256, 256]); + assert_eq!( + contract.attachments, + [ + microvm_console_attachment(), + microvm_control_console_attachment() + ] + ); + } + + #[test] + fn generated_microvm_control_console_contract_accepts_broker_listener() { + let attachment = microvm_control_console_listener_attachment(); + let contract = generated_control_console_contract_with_attachment(attachment.clone()); + assert_eq!(contract.attachments[1], attachment); + validate_machine_contract_shape(&contract, 1024, 1).unwrap(); + } + #[test] fn generated_microvm_network_contract_has_backend_specific_fixed_abi() { for (source_hypervisor, irq) in [("kvm", 10), ("mshv", 10), ("whp", 5)] { @@ -2504,6 +2764,24 @@ mod tests { assert!(error.to_string().contains("attachment inventory")); } + #[test] + fn validate_microvm_control_console_contract_rejects_attachment_change() { + let contract = generated_control_console_contract(); + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + let mut expected = contract; + expected + .attachments + .iter_mut() + .find(|attachment| attachment.stable_id == "console:microvm-control0") + .unwrap() + .identity = b"unexpected".to_vec(); + let error = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!(error.to_string().contains("attachment inventory")); + } + #[test] fn validate_microvm_machine_contract_ok() { let mut manifest = test_manifest(); @@ -2909,6 +3187,7 @@ mod tests { false, None, None, + None, Vec::new(), 1, 512 * MB, @@ -2928,7 +3207,7 @@ mod tests { tsc_frequency_hz: u64, processor_count: u32, ) -> String { - let mut command_line = openvmm_defs::microvm::build_microvm_command_line( + let mut command_line = openvmm_defs::microvm::build_microvm_control_command_line( &[ "nvx_sandbox=1".to_owned(), "nvx_config=0xd0010000,65536".to_owned(), @@ -2948,7 +3227,7 @@ mod tests { false, None, true, - false, + true, &[ openvmm_defs::microvm::MicrovmSandboxBlockConfig { role: openvmm_defs::microvm::MicrovmSandboxBlockRole::Distro, @@ -3118,11 +3397,40 @@ mod tests { tsc_early_khz=1000000 \ virtio_mmio.device=0x1000@0xd0002000:7 \ virtio_mmio.device=0x1000@0xd0003000:4 \ - virtio_mmio.device=0x1000@0xd0006000:11" + virtio_mmio.device=0x1000@0xd0006000:11 \ + virtio_mmio.device=0x1000@0xd0007000:3 \ + nvx_control_tty=hvc2" ); validate_manifest_version(&manifest).unwrap(); } + #[test] + fn platform_snapshot_rejects_invalid_control_tokens() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + let canonical = manifest + .machine_contract + .as_ref() + .unwrap() + .effective_command_line + .clone(); + + for invalid_control in [ + "nvx_control_tty=hvc9", + "nvx_control_tty=hvc2 nvx_control_tty=hvc2", + ] { + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + canonical.replace("nvx_control_tty=hvc2", invalid_control), + ); + assert!(validate_manifest_version(&manifest).is_err()); + } + } + #[test] fn legacy_formats_reject_abi_v2_blocks() { let scratch = vec![0x5a_u8; 1024]; From 86860103759228d591cb8002978d33f5e4bb30c8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:23:18 -0700 Subject: [PATCH 149/304] openvmm_entry: add authenticated microVM control-console endpoints With the slot, the broker device, and the snapshot contract in place, let a launcher attach a local host client to the microVM control console, and let snapshots carry that console. `--microvm-control-console ` exposes the control console with `listen=PATH` or `none`. It requires the boot `--virtio-console` on a fresh boot and a distinct endpoint from the boot console, and it is rejected for the standard machine. Every other serial backend, including TCP, client connections, the terminal, files, and stdio, is rejected. A live endpoint is protected at three layers: - The listener is private. On Linux, `listen=PATH` is always a Unix socket whose parent must be an existing non-symlink directory owned by the OpenVMM user with mode 0700; OpenVMM binds the socket exclusively, sets and checks mode 0600, and removes it on teardown. On Windows, the path must be `//./pipe/`, and the named pipe gets a protected DACL that grants access only to LocalSystem and the OpenVMM user. - The broker attaches only a peer whose local identity is the OpenVMM user: its effective UID on Linux, or the token user SID of the connecting process on Windows. - The client must present a 32-byte capability, which the launcher passes through the hidden `--microvm-control-auth-stdin` option: stdin must be a one-way pipe holding exactly the 32 nonzero bytes, with every writer closed. OpenVMM duplicates stdin, reads it through EOF without blocking, and never logs or snapshots the capability. Because stdin carries the capability, the option disables the stdin REPL (the REPL runs headless and only handles controller events), keeps the boot console off the terminal, sends portb output to stderr, and cannot be combined with the management RPC servers, the console relay, `--write-saved-state-proto`, or `--paused`. `--microvm-control-auth-timeout-ms` (default 5000, from 1 to 60000) bounds authentication. `none` attaches a disconnected console with a random capability that no client can present, which keeps disconnected process tests working; it does not read stdin. Each boot or restore uses a fresh random instance ID. A live endpoint is only available on Linux and Windows. The control console builds the kernel command line with `build_microvm_control_command_line`, so user arguments cannot move the control tty or the discovery order. Snapshots record the endpoint as `console:microvm-control0` with the `broker-authenticated-listener` or `broker-disconnected` policy, and never record the capability or the peer UID or SID. Capture passes the attachment to the machine contract, and restore validates it against the snapshot. A live endpoint must be requested again on restore, together with a fresh capability. The endpoint must not lie inside the snapshot or restore directory, and a committed snapshot removes the source's control socket like the boot console's. The console attachment code takes the stable ID, attachment kind, and control-console policies as parameters, so the boot and control consoles share one implementation. The boot console's snapshot wrapper is left for the management RPC, which uses it later, so it carries a non-test `expect(dead_code)` until then. The CLI reference documents the option, its endpoint rules on each platform, the launcher contract, and the snapshot behavior. The Guide maintenance skill maps virtio-fs and control-console code to their reference pages. Tests cover the control-console options and their conflicts, the explicit and exclusive stdin contract, the capability pipe rules on Linux and Windows (exact length, closed writers, non-pipe input, a prepared child stdin), the private socket permissions and exclusive path, case-insensitive named-pipe endpoint comparison, the control attachment's identity and rejection of TCP, fresh nonzero secrets for a disconnected console, the headless REPL, and a command line without a control console that keeps control-free user arguments. Co-authored-by: Enrique Saurez Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../doc-code-sync.instructions.md | 8 + Guide/src/reference/openvmm/management/cli.md | 59 +++ openvmm/openvmm_entry/src/cli_args/microvm.rs | 225 ++++++++- openvmm/openvmm_entry/src/lib.rs | 3 + openvmm/openvmm_entry/src/microvm/config.rs | 136 +++++- openvmm/openvmm_entry/src/microvm/console.rs | 341 +++++++++++++- openvmm/openvmm_entry/src/microvm/launch.rs | 3 +- openvmm/openvmm_entry/src/microvm/mod.rs | 4 + openvmm/openvmm_entry/src/microvm/restore.rs | 8 +- openvmm/openvmm_entry/src/repl.rs | 4 + openvmm/openvmm_entry/src/repl/headless.rs | 88 +++- openvmm/openvmm_entry/src/repl/launch.rs | 7 +- openvmm/openvmm_entry/src/serial_io.rs | 1 + .../openvmm_entry/src/serial_io/microvm.rs | 428 ++++++++++++++++++ .../src/vm_controller/microvm.rs | 20 +- 15 files changed, 1299 insertions(+), 36 deletions(-) create mode 100644 openvmm/openvmm_entry/src/serial_io/microvm.rs diff --git a/.github/instructions/doc-code-sync.instructions.md b/.github/instructions/doc-code-sync.instructions.md index fd097299a5b..cc2e29493be 100644 --- a/.github/instructions/doc-code-sync.instructions.md +++ b/.github/instructions/doc-code-sync.instructions.md @@ -13,6 +13,14 @@ does, the PR should update the Guide or flag a follow-up. - CLI args changed in `openvmm_entry` → `Guide/src/reference/openvmm/management/cli.md` - Device crate under `vm/devices/` changed → look for a matching page under `Guide/src/reference/` +- virtio-fs backends, queue/cache policy, save/restore, or microVM attachment + semantics changed (`vm/devices/virtio/virtiofs/`, + `vm/devices/virtio/virtio_resources/`, `vm/devices/support/fs/fuse/`) → + `Guide/src/reference/devices/virtio/virtio-fs.md` +- Control-session framing, record types, payload rules, receive credits, + authentication, or reconnect behavior changed + (`vm/devices/virtio/virtio_console/`) → + `Guide/src/reference/openvmm/management/control_session_protocol.md` - OpenHCL internals changed (`openhcl/`) → `Guide/src/reference/architecture/openhcl/` - Crate renamed or moved → grep `Guide/src/` for the old name - New crate under `vm/devices/` → consider whether it needs a reference page diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 51402b01eab..7a94bddda9c 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -664,6 +664,65 @@ Serial devices can be configured to appear as different devices inside the guest boundary; it cannot prove that the remote application consumed bytes without its own acknowledgment protocol. +* `--microvm-control-console `: With `--machine microvm`, expose a + second independent single-port virtio console at fixed MMIO `0xd0007000`, IRQ + 3. `--virtio-console` is required on a fresh boot and remains the only kernel + console. The control device normally appears as the profile-owned + `nvx_control_tty=hvc2`. + + On Linux, the only live backend is `listen=PATH`, and PATH is always an + AF_UNIX socket. Its parent must already be an owned, non-symlink directory + with mode `0700`; OpenVMM exclusively binds the socket, sets and verifies + mode `0600`, and verifies `SO_PEERCRED` before accepting the protocol + attachment. + + On Windows, `listen=//./pipe/openvmm-microvm-` creates one byte-mode + named pipe with a protected DACL granting access only to LocalSystem and the + OpenVMM process user. OpenVMM obtains the connecting process ID from the pipe, + resolves its token user SID, and requires it to match the OpenVMM process + user before capability authentication. TCP, client-connect, terminal, file, + stdout/stderr, and inherited control backends are rejected on every platform. + + A live endpoint also requires the hidden launcher option + `--microvm-control-auth-stdin`. The launcher must attach a prepared readable + one-way pipe to standard input, containing exactly 32 random capability + bytes, and close every writer before starting OpenVMM. OpenVMM safely + duplicates stdin into an owned file, performs one bounded nonblocking read + through EOF, and closes the duplicate. An all-zero capability is rejected. + Standard input remains at EOF and is reserved for authentication: the stdin + REPL is disabled, the boot console must use a socket or `none`, and portb + recovery output goes to stderr. This option cannot be combined with the + management RPC server, console relay, or `--paused`. Capability + bytes must never appear in arguments, environment variables, endpoint names, + logs, snapshots, or attachment identities. The first host record must prove + that capability. Peer identity is checked first. Authentication must complete + within `--microvm-control-auth-timeout-ms` (default 5000, range 1 to 60000). + A stalled or failed authentication attempt closes the connection without a + protocol Error record and without changing the broker epoch. + + The outer protocol uses byte-counted guest receive credits so host DATA is + backpressured before the guest's bounded ingress storage is exhausted. See + [Control-session Protocol](./control_session_protocol.md). + + `none` does not consume stdin and rejects `--microvm-control-auth-stdin`. + OpenVMM generates an unreachable random capability so disconnected process + tests remain supported. + + The numeric `--microvm-control-auth-handle` interface is not supported. + Launchers must explicitly select the stdin contract; there is no fallback + to an unauthenticated endpoint. + + Boot and control endpoints must be distinct. Snapshot capture records only + the separate `console:microvm-control0` endpoint and broker-authenticated + reconnect policy. It never records a capability or UID/SID. Restore validates + the saved endpoint contract, requires a fresh launcher-provided capability + for a live endpoint, and generates a fresh VMM instance ID; saved credentials + and stale capabilities are never reused. + + Restore snapshots containing this device through the CLI. The OpenVMM + management RPC does not expose control-console restore attachments and + rejects these snapshots explicitly. + The `BACKEND` argument is the same for all serial devices: * `none`: Serial output is dropped. diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index e7507845de4..0cd0ca30679 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -286,6 +286,31 @@ pub struct MicrovmCli { requires = "microvm_mount" )] pub microvm_mount_deny: Vec, + + /// dedicated microVM control console backed by a local serial endpoint + /// + /// Accepts listen=\ or none. The boot + /// virtio-console is required and remains the only kernel console. + #[clap(long, value_name = "SERIAL")] + pub microvm_control_console: Option, + + /// read the control-console capability from a prepared stdin pipe + #[clap( + long, + hide = true, + requires("microvm_control_console"), + conflicts_with_all = ["rpc", "ttrpc", "grpc", "relay_console_path", "write_saved_state_proto", "paused"] + )] + pub microvm_control_auth_stdin: bool, + + /// maximum time for a control-console client to authenticate + #[clap( + long = "microvm-control-auth-timeout-ms", + value_name = "MILLISECONDS", + default_value_t = 5000, + hide = true + )] + pub microvm_control_auth_timeout_ms: u64, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -399,8 +424,10 @@ impl Options { && self.microvm.microvm_sandbox_block.is_empty() && self.microvm.restore_processors.is_none() && self.microvm.restore_memory.is_none() - && self.microvm.memory_capacity.is_none(), - "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --restore-processors, --restore-memory, --memory-capacity, and microVM network policy require a microVM machine" + && self.microvm.memory_capacity.is_none() + && self.microvm.microvm_control_console.is_none() + && !self.microvm.microvm_control_auth_stdin, + "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-control-console, --microvm-control-auth-stdin, --restore-processors, --restore-memory, --memory-capacity, and microVM network policy require a microVM machine" ); return Ok(()); } @@ -551,6 +578,44 @@ impl Options { self.virtio_console.is_some() || self.virtio_console_pcie_port.is_none(), "--virtio-console-pcie-port requires --virtio-console" ); + self.validate_control_stdin_console(self.virtio_console.as_ref())?; + if let Some(control_console) = &self.microvm.microvm_control_console { + anyhow::ensure!( + self.virtio_console.is_some() || self.restore_snapshot.is_some(), + "--microvm-control-console requires --virtio-console for a fresh boot" + ); + anyhow::ensure!( + matches!( + control_console, + SerialConfigCli::Pipe(_) | SerialConfigCli::None + ), + "microVM control console requires listen=... or none" + ); + anyhow::ensure!( + (1..=60_000).contains(&self.microvm.microvm_control_auth_timeout_ms), + "--microvm-control-auth-timeout-ms must be between 1 and 60000" + ); + if matches!(control_console, SerialConfigCli::None) { + anyhow::ensure!( + !self.microvm.microvm_control_auth_stdin, + "--microvm-control-auth-stdin is not used with a disconnected control console" + ); + } else { + anyhow::ensure!( + cfg!(any(target_os = "linux", windows)), + "live microVM control consoles require Linux Unix sockets or Windows named pipes" + ); + anyhow::ensure!( + self.microvm.microvm_control_auth_stdin, + "live microVM control console requires --microvm-control-auth-stdin" + ); + } + } else { + anyhow::ensure!( + !self.microvm.microvm_control_auth_stdin, + "--microvm-control-auth-stdin requires --microvm-control-console" + ); + } anyhow::ensure!( self.disk.is_empty() && self.nvme.is_empty() @@ -772,6 +837,18 @@ impl Options { Ok(()) } + pub(crate) fn validate_control_stdin_console( + &self, + boot_console: Option<&SerialConfigCli>, + ) -> anyhow::Result<()> { + anyhow::ensure!( + !self.microvm.microvm_control_auth_stdin + || !matches!(boot_console, Some(SerialConfigCli::Console)), + "--microvm-control-auth-stdin reserves stdin; use a socket or none for the boot console" + ); + Ok(()) + } + pub(crate) fn microvm_egress_policy( &self, network: &openvmm_defs::microvm::MicrovmNetworkConfig, @@ -1275,6 +1352,54 @@ mod tests { ]) .unwrap(); valid_console.validate_microvm_options().unwrap(); + let valid_control_console = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "none", + ]) + .unwrap(); + valid_control_console.validate_microvm_options().unwrap(); + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-control-protocol-version", + "1", + ]) + .is_err() + ); + let control_endpoint = if cfg!(windows) { + "listen=//./pipe/openvmm-microvm-restore-test" + } else { + "listen=control.sock" + }; + let valid_restore_control_console = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--microvm-control-console", + control_endpoint, + "--microvm-control-auth-stdin", + ]) + .unwrap(); + if cfg!(any(target_os = "linux", windows)) { + valid_restore_control_console + .validate_microvm_options() + .unwrap(); + } else { + assert!( + valid_restore_control_console + .validate_microvm_options() + .is_err() + ); + } let valid_network = Options::try_parse_from([ "openvmm", "--machine", @@ -1352,6 +1477,41 @@ mod tests { "--virtio-console-pcie-port", "port0", ], + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-control-console", + "none", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "listen=tcp:127.0.0.1:5555", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "listen=control.sock", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "none", + "--microvm-control-auth-stdin", + ], vec!["openvmm", "--machine", "microvm", "--net", "consomme"], vec![ "openvmm", @@ -1375,6 +1535,67 @@ mod tests { } } + #[test] + fn control_auth_stdin_is_explicit_and_exclusive() { + let endpoint = if cfg!(windows) { + "listen=//./pipe/openvmm-microvm-test" + } else { + "listen=control.sock" + }; + let args = [ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + endpoint, + "--microvm-control-auth-stdin", + ]; + let options = Options::try_parse_from(args).unwrap(); + assert!(options.microvm.microvm_control_auth_stdin); + assert_eq!( + options.validate_microvm_options().is_ok(), + cfg!(any(target_os = "linux", windows)) + ); + assert!( + options + .validate_control_stdin_console(Some(&SerialConfigCli::Console)) + .unwrap_err() + .to_string() + .contains("reserves stdin") + ); + for console in [ + SerialConfigCli::None, + SerialConfigCli::Pipe("boot.sock".into()), + ] { + options + .validate_control_stdin_console(Some(&console)) + .unwrap(); + } + for extra in [ + vec!["--rpc", "path=management.sock"], + vec!["--ttrpc", "management.sock"], + vec!["--grpc", "management.sock"], + vec!["--relay-console-path", "console.sock"], + vec!["--write-saved-state-proto", "proto"], + vec!["--paused"], + vec!["--microvm-control-auth-handle", "0"], + ] { + assert!(Options::try_parse_from(args.into_iter().chain(extra)).is_err()); + } + assert!(Options::try_parse_from(["openvmm", "--microvm-control-auth-stdin"]).is_err()); + assert!( + Options::try_parse_from([ + "openvmm", + "--microvm-control-console", + "listen=control.sock", + "--microvm-control-auth-stdin=0", + ]) + .is_err() + ); + } + #[test] fn test_microvm_host_loopback_generic_allow_is_rejected() { let common = [ diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 4ff93ffe938..dfdad53fcaf 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2032,6 +2032,9 @@ async fn vm_config_from_command_line( add_virtio_device(VirtioBusCli::Auto, resource, &mut pcie_devices); } } + microvm.add_control_console(&mut |bus, resource| { + add_virtio_device(bus, resource, &mut pcie_devices) + })?; // Handle --vhost-user arguments. #[cfg(target_os = "linux")] diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index cbe0e17ee66..e48f83bf737 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -7,7 +7,10 @@ use super::MicrovmResources; use super::MicrovmRestore; use super::console::ConsoleEndpoint; use super::console::effective_microvm_console; +use super::console::effective_microvm_control_console; +use super::console::microvm_console_attachments_share_endpoint; use super::console::microvm_console_socket_cleanup; +use super::console::microvm_control_broker_config; use super::console::validate_microvm_console_attachment_namespace; use super::filesystem::EffectiveMicrovmFilesystem; use super::filesystem::MICROVM_FILESYSTEM_STABLE_ID; @@ -40,6 +43,7 @@ use openvmm_defs::config::DeviceVtl; use openvmm_defs::config::LoadMode; use openvmm_defs::microvm::MachineProfile; use openvmm_defs::microvm::build_microvm_command_line; +use openvmm_defs::microvm::build_microvm_control_command_line; use pal_async::DefaultDriver; use serial_core::resources::DisconnectedSerialBackendHandle; use std::cell::RefCell; @@ -48,6 +52,7 @@ use std::thread; use std::time::Duration; use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; +use virtio_resources::console::control::VirtioControlConsoleBrokerConfig; use vm_manifest_builder::MachineArch; use vm_resource::IntoResource; use vm_resource::Resource; @@ -69,11 +74,15 @@ pub(crate) struct MicrovmConfigBuilder<'a> { gateway_dns: bool, console: Option, portb: Option>, + control_console: Option, + control_broker_config: Option, + control_console_backend: Option>, resources: MicrovmResources, } impl<'a> MicrovmConfigBuilder<'a> { - /// Validates the microVM options. + /// Validates the microVM options and resolves the effective network, + /// filesystem, and console attachments. pub(crate) fn new(opt: &'a Options, restore: &'a MicrovmRestore) -> anyhow::Result { let active = opt.machine == MachineProfileCli::Microvm; let restore_machine_contract = restore.machine_contract.as_ref(); @@ -133,6 +142,31 @@ impl<'a> MicrovmConfigBuilder<'a> { } else { None }; + let control_console = if active { + effective_microvm_control_console( + opt.microvm.microvm_control_console.as_ref(), + restore_machine_contract, + )? + } else { + None + }; + opt.validate_control_stdin_console(console.as_ref().map(|(config, _, _)| config))?; + let control_broker_config = control_console + .as_ref() + .map(|(endpoint, _, _)| microvm_control_broker_config(opt, endpoint)) + .transpose()?; + anyhow::ensure!( + control_console.is_none() || console.is_some(), + "microVM control console requires the boot virtio-console" + ); + if let (Some((_, _, boot)), Some((_, _, control))) = (&console, &control_console) + && control.identity_kind != "disconnected" + { + anyhow::ensure!( + !microvm_console_attachments_share_endpoint(boot, control), + "microVM boot and control consoles require distinct endpoints" + ); + } if let Some((_, _, attachment)) = &console && let Some(snapshot_dir) = opt .restore_snapshot @@ -141,11 +175,22 @@ impl<'a> MicrovmConfigBuilder<'a> { { validate_microvm_console_attachment_namespace(attachment, snapshot_dir)?; } + if let Some((_, _, attachment)) = &control_console + && let Some(snapshot_dir) = opt + .restore_snapshot + .as_deref() + .or(opt.microvm.snapshot_destination.as_deref()) + { + validate_microvm_console_attachment_namespace(attachment, snapshot_dir)?; + } let resources = MicrovmResources { console_attachment: console .as_ref() .map(|(_, _, attachment)| attachment.clone()), + control_console_attachment: control_console + .as_ref() + .map(|(_, _, attachment)| attachment.clone()), network_attachment: network.as_ref().map(|network| network.attachment.clone()), egress_policy: network.as_ref().map(|network| network.policy.clone()), filesystem_attachment: filesystem @@ -166,6 +211,9 @@ impl<'a> MicrovmConfigBuilder<'a> { filesystem, gateway_dns, console, + control_console, + control_broker_config, + control_console_backend: None, portb: None, resources, }) @@ -197,10 +245,11 @@ impl<'a> MicrovmConfigBuilder<'a> { if !self.active { return Ok(()); } - let backend = if self - .console - .as_ref() - .is_some_and(|(config, _, _)| matches!(config, SerialConfigCli::Console)) + let backend = if self.opt.microvm.microvm_control_auth_stdin + || self + .console + .as_ref() + .is_some_and(|(config, _, _)| matches!(config, SerialConfigCli::Console)) { SerialConfigCli::Stderr } else { @@ -216,8 +265,8 @@ impl<'a> MicrovmConfigBuilder<'a> { Ok(()) } - /// Connects the host side of the boot virtio-console, returning its - /// backend. + /// Connects the host side of the boot virtio-console and the dedicated + /// control console, returning the boot console backend. pub(crate) fn setup_virtio_consoles( &mut self, console_state: &RefCell>>, @@ -274,6 +323,28 @@ impl<'a> MicrovmConfigBuilder<'a> { } else { None }; + if let Some(serial_cfg) = self + .control_console + .as_ref() + .map(|(config, _, _)| config.clone()) + { + self.control_console_backend = match serial_cfg { + SerialConfigCli::Pipe(path) => { + let backend = + serial_io::microvm::bind_control_serial(&path).with_context(|| { + format!( + "failed to bind microVM control console listener {}", + path.display() + ) + })?; + self.resources.control_console_socket_cleanup = + microvm_console_socket_cleanup(path)?; + Some(backend) + } + SerialConfigCli::None => Some(DisconnectedSerialBackendHandle.into_resource()), + _ => unreachable!("microVM control console backend was validated"), + }; + } Ok(virtio_console_backend) } @@ -395,6 +466,7 @@ impl<'a> MicrovmConfigBuilder<'a> { &opt.cmdline, opt.processors, self.console.is_some(), + self.control_console.is_some(), )?, ) }; @@ -517,6 +589,30 @@ impl<'a> MicrovmConfigBuilder<'a> { } } + /// Adds the dedicated control virtio-console device. + pub(crate) fn add_control_console( + &mut self, + add_virtio_device: &mut impl FnMut(VirtioBusCli, Resource), + ) -> anyhow::Result<()> { + if let Some(backend) = self.control_console_backend.take() { + let broker_config = self.control_broker_config.take().context( + "control-console backend is missing broker authentication configuration", + )?; + let resource: Resource = + virtio_resources::console::control::VirtioControlConsoleHandle { + backend, + broker_config, + attachment: self + .control_console + .as_ref() + .map(|(_, attachment, _)| attachment.clone()), + } + .into_resource(); + add_virtio_device(VirtioBusCli::Mmio, resource); + } + Ok(()) + } + /// Completes the microVM configuration after the storage devices have been /// added, and validates the machine contract of every profile. pub(crate) fn finish( @@ -695,8 +791,32 @@ fn build_effective_microvm_command_line( user_args: &[String], processor_count: u32, has_console: bool, + has_control_console: bool, ) -> anyhow::Result { - let mut cmdline = build_microvm_command_line(user_args, has_console)?; + let mut cmdline = if has_control_console { + build_microvm_control_command_line(user_args, has_console) + } else { + build_microvm_command_line(user_args, has_console) + }?; openvmm_defs::microvm::append_microvm_processor_limit(&mut cmdline, processor_count)?; Ok(cmdline) } + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn boot_only_command_line_preserves_control_free_arguments() { + let user_args = [ + r#"note="left right""#.to_owned(), + "--".to_owned(), + "driver_async_probe=virtio_mmio".to_owned(), + "nvx_control_tty=hvc9".to_owned(), + "virtio-mmio.device=0x1000@0xc0000000:1".to_owned(), + ]; + assert!(build_effective_microvm_command_line(&user_args, 1, true, false).is_ok()); + assert!(build_effective_microvm_command_line(&user_args, 1, true, true).is_err()); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/console.rs b/openvmm/openvmm_entry/src/microvm/console.rs index 851fdd1c154..37b4c509f75 100644 --- a/openvmm/openvmm_entry/src/microvm/console.rs +++ b/openvmm/openvmm_entry/src/microvm/console.rs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! MicroVM virtio-console attachments. +//! MicroVM virtio-console and control-console attachments. +use crate::Options; use crate::cli_args::SerialConfigCli; +use crate::serial_io; use anyhow::Context; #[cfg(unix)] use std::io; @@ -19,7 +21,24 @@ pub(super) type ConsoleEndpoint = ( ); pub(crate) const MICROVM_CONSOLE_STABLE_ID: &str = "console:microvm-virtio0"; +pub(crate) const MICROVM_CONTROL_CONSOLE_STABLE_ID: &str = "console:microvm-control0"; pub(crate) const MICROVM_CONSOLE_ATTACHMENT_KIND: &str = "virtio-console"; +pub(crate) const MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND: &str = "virtio-control-console"; + +pub(super) fn microvm_console_attachments_share_endpoint( + left: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + right: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, +) -> bool { + left.identity_kind == right.identity_kind + && if left.identity_kind == "named-pipe" { + std::str::from_utf8(&left.identity) + .ok() + .zip(std::str::from_utf8(&right.identity).ok()) + .is_some_and(|(left, right)| left.eq_ignore_ascii_case(right)) + } else { + left.identity == right.identity + } +} pub(crate) struct MicrovmConsoleSocketCleanup { #[cfg(unix)] @@ -324,6 +343,64 @@ pub(crate) fn microvm_console_attachment_from_cli( ) } +fn microvm_control_console_attachment_from_cli( + config: &SerialConfigCli, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + anyhow::ensure!( + matches!(config, SerialConfigCli::Pipe(_) | SerialConfigCli::None), + "microVM control console requires listen=... or none" + ); + let config = match config { + #[cfg(unix)] + SerialConfigCli::Pipe(path) => { + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + let absolute = if path.is_absolute() { + path.clone() + } else { + std::env::current_dir() + .context("failed to resolve current directory for control endpoint")? + .join(path) + }; + let parent = absolute + .parent() + .context("microVM control endpoint has no parent directory")?; + let metadata = fs_err::symlink_metadata(parent).with_context(|| { + format!( + "failed to inspect microVM control endpoint parent {}", + parent.display() + ) + })?; + anyhow::ensure!( + metadata.is_dir() + && !metadata.file_type().is_symlink() + && metadata.uid() == pal::unix::effective_user_id() + && metadata.permissions().mode() & 0o7777 == 0o700, + "microVM control endpoint parent must be a non-symlink directory owned by OpenVMM with mode 0700" + ); + SerialConfigCli::Pipe(canonical_microvm_console_path(&absolute)?) + } + _ => config.clone(), + }; + let (effective, attachment, mut snapshot) = microvm_console_attachment_from_cli_with_identity( + &config, + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + )?; + snapshot.reconnect_policy = match config { + SerialConfigCli::Pipe(_) => "broker-authenticated-listener", + SerialConfigCli::None => "broker-disconnected", + _ => unreachable!("control-console backend was validated"), + } + .to_owned(); + Ok((effective, attachment, snapshot)) +} + pub(crate) fn validate_microvm_console_attachment_namespace( attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, snapshot_dir: &Path, @@ -371,17 +448,20 @@ pub(crate) fn validate_microvm_console_attachment_namespace( Ok(()) } -pub(crate) fn microvm_console_attachment_from_snapshot( +fn microvm_console_attachment_from_snapshot_with_identity( attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, requested: Option<&SerialConfigCli>, + stable_id: &'static str, + attachment_kind: &'static str, + control_console: bool, ) -> anyhow::Result<( SerialConfigCli, virtio_resources::console::attachment::VirtioConsoleAttachment, openvmm_helpers::snapshot::microvm::SnapshotAttachment, )> { anyhow::ensure!( - attachment.stable_id == MICROVM_CONSOLE_STABLE_ID - && attachment.kind == MICROVM_CONSOLE_ATTACHMENT_KIND + attachment.stable_id == stable_id + && attachment.kind == attachment_kind && attachment.length == 0, "snapshot has an invalid microVM console attachment" ); @@ -393,11 +473,26 @@ pub(crate) fn microvm_console_attachment_from_snapshot( "snapshot requires an explicitly approved restore-time client attachment" ); } + if control_console + && matches!( + attachment.reconnect_policy.as_str(), + "broker-authenticated-listener" + ) + { + anyhow::ensure!( + requested.is_some(), + "snapshot requires an explicitly approved restore-time control attachment" + ); + } let config = match ( attachment.reconnect_policy.as_str(), attachment.identity_kind.as_str(), ) { - ("recreate-listener", "unix-socket" | "named-pipe") => { + ("recreate-listener", "unix-socket" | "named-pipe") + | ("broker-authenticated-listener", "unix-socket" | "named-pipe") + if !control_console + || attachment.reconnect_policy == "broker-authenticated-listener" => + { SerialConfigCli::Pipe(PathBuf::from(identity)) } ("recreate-listener", "tcp") => SerialConfigCli::Tcp( @@ -405,7 +500,7 @@ pub(crate) fn microvm_console_attachment_from_snapshot( .parse() .context("snapshot microVM console TCP identity is invalid")?, ), - ("reconnect-client", "unix-socket" | "named-pipe") => { + ("reconnect-client", "unix-socket" | "named-pipe") if !control_console => { SerialConfigCli::ConnectPipe(PathBuf::from(identity)) } ("reconnect-client", "tcp") => SerialConfigCli::ConnectTcp( @@ -416,18 +511,31 @@ pub(crate) fn microvm_console_attachment_from_snapshot( ("require-inherited-attachment", "provider") => requested .cloned() .context("snapshot requires --virtio-console console as a replacement attachment")?, - ("discard-while-disconnected", "disconnected") => SerialConfigCli::None, + ("discard-while-disconnected", "disconnected") + | ("broker-disconnected", "disconnected") + if !control_console || attachment.reconnect_policy == "broker-disconnected" => + { + SerialConfigCli::None + } (policy, kind) => anyhow::bail!( "snapshot microVM console policy '{policy}' and backend kind '{kind}' are unsupported" ), }; - let reconstructed = microvm_console_attachment_from_cli(&config)?; + let reconstructed = if control_console { + microvm_control_console_attachment_from_cli(&config)? + } else { + microvm_console_attachment_from_cli(&config)? + }; anyhow::ensure!( reconstructed.2 == *attachment, "snapshot microVM console identity is not canonical" ); if let Some(requested) = requested { - let requested = microvm_console_attachment_from_cli(requested)?; + let requested = if control_console { + microvm_control_console_attachment_from_cli(requested)? + } else { + microvm_console_attachment_from_cli(requested)? + }; anyhow::ensure!( requested.2 == *attachment, "restore-time virtio-console does not match the snapshot attachment" @@ -437,9 +545,33 @@ pub(crate) fn microvm_console_attachment_from_snapshot( Ok(reconstructed) } -pub(super) fn effective_microvm_console( +#[cfg_attr( + not(test), + expect(dead_code, reason = "the management RPC restores through it") +)] +pub(crate) fn microvm_console_attachment_from_snapshot( + attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + requested: Option<&SerialConfigCli>, +) -> anyhow::Result<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, +)> { + microvm_console_attachment_from_snapshot_with_identity( + attachment, + requested, + MICROVM_CONSOLE_STABLE_ID, + MICROVM_CONSOLE_ATTACHMENT_KIND, + false, + ) +} + +fn effective_microvm_console_with_identity( requested: Option<&SerialConfigCli>, restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, + stable_id: &'static str, + attachment_kind: &'static str, + control_console: bool, ) -> anyhow::Result< Option<( SerialConfigCli, @@ -449,17 +581,23 @@ pub(super) fn effective_microvm_console( > { let Some(restore) = restore else { return requested - .map(microvm_console_attachment_from_cli) + .map(|requested| { + if control_console { + microvm_control_console_attachment_from_cli(requested) + } else { + microvm_console_attachment_from_cli(requested) + } + }) .transpose(); }; let has_console = restore .devices .iter() - .any(|device| device.stable_id == MICROVM_CONSOLE_STABLE_ID); + .any(|device| device.stable_id == stable_id); let saved_attachment = restore .attachments .iter() - .find(|attachment| attachment.stable_id == MICROVM_CONSOLE_STABLE_ID); + .find(|attachment| attachment.stable_id == stable_id); anyhow::ensure!( has_console == saved_attachment.is_some(), "snapshot microVM console device and attachment inventories disagree" @@ -471,15 +609,114 @@ pub(super) fn effective_microvm_console( ); return Ok(None); }; - Ok(Some(microvm_console_attachment_from_snapshot( - saved_attachment, + Ok(Some( + microvm_console_attachment_from_snapshot_with_identity( + saved_attachment, + requested, + stable_id, + attachment_kind, + control_console, + )?, + )) +} + +pub(super) fn effective_microvm_console( + requested: Option<&SerialConfigCli>, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, +) -> anyhow::Result< + Option<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )>, +> { + effective_microvm_console_with_identity( + requested, + restore, + MICROVM_CONSOLE_STABLE_ID, + MICROVM_CONSOLE_ATTACHMENT_KIND, + false, + ) +} + +pub(super) fn effective_microvm_control_console( + requested: Option<&SerialConfigCli>, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, +) -> anyhow::Result< + Option<( + SerialConfigCli, + virtio_resources::console::attachment::VirtioConsoleAttachment, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )>, +> { + effective_microvm_console_with_identity( requested, - )?)) + restore, + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + true, + ) +} + +fn random_nonzero_bytes(description: &'static str) -> anyhow::Result<[u8; N]> { + loop { + let mut bytes = [0; N]; + getrandom::fill(&mut bytes).with_context(|| format!("failed to generate {description}"))?; + if bytes != [0; N] { + return Ok(bytes); + } + } +} + +pub(super) fn microvm_control_broker_config( + opt: &Options, + endpoint: &SerialConfigCli, +) -> anyhow::Result { + let capability = if matches!(endpoint, SerialConfigCli::None) { + random_nonzero_bytes("disconnected control-console capability")? + } else { + anyhow::ensure!( + opt.microvm.microvm_control_auth_stdin, + "live microVM control console requires --microvm-control-auth-stdin" + ); + #[cfg(any(target_os = "linux", windows))] + { + serial_io::microvm::read_control_capability_from_stdin() + .context("failed to read control-console authentication capability from stdin")? + } + #[cfg(not(any(target_os = "linux", windows)))] + { + anyhow::bail!("secure live microVM control consoles are unavailable on this platform") + } + }; + #[cfg(target_os = "linux")] + let expected_peer_identity = + serial_core::LocalPeerIdentity::UnixUid(pal::unix::effective_user_id()); + #[cfg(windows)] + let expected_peer_identity = { + let sid = pal::windows::security::user_sid::current_process_user_sid() + .context("failed to resolve the OpenVMM process user SID")?; + let (bytes, length) = sid.to_fixed_bytes(); + serial_core::LocalPeerIdentity::windows_sid(bytes, length) + .context("failed to encode the OpenVMM process user SID")? + }; + #[cfg(not(any(target_os = "linux", windows)))] + let expected_peer_identity = serial_core::LocalPeerIdentity::Unsupported; + + Ok( + virtio_resources::console::control::VirtioControlConsoleBrokerConfig { + instance_id: random_nonzero_bytes("control-console instance ID")?, + capability, + expected_peer_identity, + auth_timeout_ms: opt.microvm.microvm_control_auth_timeout_ms, + }, + ) } #[cfg(test)] mod tests { use super::*; + use clap::Parser as _; use test_with_tracing::test; use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; @@ -524,6 +761,20 @@ mod tests { ); } + #[test] + fn named_pipe_endpoint_identity_is_case_insensitive() { + let mut left = socket_attachment(Path::new("unused")); + left.identity_kind = "named-pipe".to_owned(); + left.identity = b"//./pipe/openvmm-microvm-Control".to_vec(); + let mut right = left.clone(); + right.identity = b"//./pipe/openvmm-microvm-control".to_vec(); + assert!(microvm_console_attachments_share_endpoint(&left, &right)); + + left.identity_kind = "unix-socket".to_owned(); + right.identity_kind = "unix-socket".to_owned(); + assert!(!microvm_console_attachments_share_endpoint(&left, &right)); + } + #[test] fn client_attachment_is_required_and_bounded() { let requested = SerialConfigCli::ConnectTcp("127.0.0.1:5555".parse().unwrap()); @@ -602,13 +853,69 @@ mod tests { assert_eq!(restored_snapshot, snapshot); } + #[test] + fn control_attachment_has_distinct_identity_and_rejects_tcp() { + let (_, resource, snapshot) = + microvm_control_console_attachment_from_cli(&SerialConfigCli::None).unwrap(); + assert_eq!(resource.stable_id, MICROVM_CONTROL_CONSOLE_STABLE_ID); + assert_eq!(snapshot.stable_id, MICROVM_CONTROL_CONSOLE_STABLE_ID); + assert_eq!(snapshot.kind, MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND); + assert_ne!( + snapshot.stable_id, + microvm_console_attachment_from_cli(&SerialConfigCli::None) + .unwrap() + .2 + .stable_id + ); + + let (_, restored_resource, restored_snapshot) = + microvm_console_attachment_from_snapshot_with_identity( + &snapshot, + None, + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + true, + ) + .unwrap(); + assert_eq!(restored_resource.stable_id, resource.stable_id); + assert_eq!(restored_snapshot, snapshot); + + assert!( + microvm_control_console_attachment_from_cli(&SerialConfigCli::Tcp( + "127.0.0.1:5555".parse().unwrap() + )) + .is_err() + ); + } + + #[test] + fn disconnected_control_console_uses_fresh_nonzero_runtime_secrets() { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "none", + ]) + .unwrap(); + let first = microvm_control_broker_config(&options, &SerialConfigCli::None).unwrap(); + let second = microvm_control_broker_config(&options, &SerialConfigCli::None).unwrap(); + assert_ne!(first.instance_id, [0; 16]); + assert_ne!(second.instance_id, [0; 16]); + assert_ne!(first.instance_id, second.instance_id); + assert_ne!(first.capability, [0; 32]); + assert_ne!(second.capability, [0; 32]); + } + #[cfg(unix)] #[test] fn restore_bind_does_not_unlink_existing_socket() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("console.sock"); let _existing = unix_socket::UnixListener::bind(&path).unwrap(); - assert!(crate::serial_io::connect::bind_serial_without_cleanup(&path).is_err()); + assert!(serial_io::connect::bind_serial_without_cleanup(&path).is_err()); assert!(fs_err::symlink_metadata(&path).is_ok()); } diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index b9776020fe3..551ca74802f 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -4,9 +4,9 @@ //! MicroVM launch state carried from the VM configuration to the VM worker //! and the VM controller. -use super::ExpectedRestoreContract; use super::MicrovmResources; use super::MicrovmRestore; +use super::restore::ExpectedRestoreContract; use super::validate_microvm_filesystem_private_storage; use crate::Options; use crate::cli_args::microvm::MachineProfileCli; @@ -236,6 +236,7 @@ impl MicrovmLaunch { .zip(resources.filesystem_attachment.as_ref()) .map(|((filesystem, root_path), attachment)| (filesystem, root_path, attachment)), resources.console_attachment.as_ref(), + resources.control_console_attachment.as_ref(), sandbox_blocks, ))) } diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index ed11ec94477..f195e93c4e9 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -37,6 +37,10 @@ pub(crate) struct MicrovmResources { pub(crate) console_attachment: Option, /// Removes the boot console Unix socket on teardown. pub(crate) console_socket_cleanup: Option, + /// Snapshot identity of the control virtio-console endpoint. + pub(crate) control_console_attachment: Option, + /// Removes the control console Unix socket on teardown. + pub(crate) control_console_socket_cleanup: Option, /// Snapshot identity of the portable network attachment. pub(crate) network_attachment: Option, /// The bound run-scoped egress policy. diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 921805efcf1..7b415080778 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -316,8 +316,8 @@ pub(crate) fn prepare_restore( } /// The machine contract a microVM snapshot must match to be restored: the -/// hypervisor, effective command line, network, filesystem, boot console -/// attachment, and sandbox blocks. +/// hypervisor, effective command line, network, filesystem, boot and control +/// console attachments, and sandbox blocks. pub(crate) type ExpectedRestoreContract<'a> = ( &'a str, &'a str, @@ -332,6 +332,7 @@ pub(crate) type ExpectedRestoreContract<'a> = ( &'a SnapshotAttachment, )>, Option<&'a SnapshotAttachment>, + Option<&'a SnapshotAttachment>, Vec, ); @@ -351,6 +352,7 @@ pub(crate) fn validate_restore_contract( network, filesystem, console_attachment, + control_console_attachment, sandbox_blocks, ): ExpectedRestoreContract<'_>, ) -> anyhow::Result { @@ -373,7 +375,7 @@ pub(crate) fn validate_restore_contract( filesystem_slot, filesystem, console_attachment.cloned(), - None, + control_console_attachment.cloned(), sandbox_blocks, expected_vp_count, expected_memory_size, diff --git a/openvmm/openvmm_entry/src/repl.rs b/openvmm/openvmm_entry/src/repl.rs index 0f6feefaefa..2e6d5874acf 100644 --- a/openvmm/openvmm_entry/src/repl.rs +++ b/openvmm/openvmm_entry/src/repl.rs @@ -464,6 +464,10 @@ pub(crate) async fn run_repl( mut launch, } = resources; + if !launch.stdin_enabled { + return headless::run(&mut vm_controller_events).await; + } + let (console_command_send, console_command_recv) = mesh::channel(); let (inspect_completion_engine_send, inspect_completion_engine_recv) = mesh::channel(); diff --git a/openvmm/openvmm_entry/src/repl/headless.rs b/openvmm/openvmm_entry/src/repl/headless.rs index 8d45e1ba982..638d786b9c0 100644 --- a/openvmm/openvmm_entry/src/repl/headless.rs +++ b/openvmm/openvmm_entry/src/repl/headless.rs @@ -4,12 +4,58 @@ //! Headless operation of the REPL, for runs driven by another process instead //! of an interactive console. //! -//! With a stdin that is not a terminal, such as a pipe, the stdio thread reads -//! it without switching the console to raw mode. +//! Without stdin (`--microvm-control-auth-stdin` reserves stdin for the +//! control-console capability), the REPL starts no stdio thread and handles +//! only controller events, until one of them ends the run. With a stdin that +//! is not a terminal, such as a pipe, the stdio thread reads it without +//! switching the console to raw mode. +use crate::vm_controller::VmControllerEvent; +use futures::StreamExt; use std::io; use std::io::IsTerminal; +/// Target of the events logged here: the REPL's own, so that the headless +/// REPL logs controller events exactly as the interactive one does. +const REPL_TARGET: &str = "openvmm_entry::repl"; + +/// Runs the REPL without reading stdin. +/// +/// No command can arrive, so this handles only the controller events, as the +/// interactive loop of [`super::run_repl`] does, and returns the exit status of +/// the event that ends the run. +pub(super) async fn run( + vm_controller_events: &mut mesh::Receiver, +) -> anyhow::Result { + while let Some(event) = vm_controller_events.next().await { + if let Some(code) = controller_exit(event)? { + return Ok(code); + } + } + // As in the interactive loop, whose other event sources stay open, the + // controller closing its event channel does not end the run. + std::future::pending().await +} + +/// Maps a controller event to the exit status of the REPL, or to `None` when +/// the REPL keeps running. +fn controller_exit(event: VmControllerEvent) -> anyhow::Result> { + match event { + VmControllerEvent::WorkerStopped { error } => { + if let Some(err) = &error { + tracing::error!(target: REPL_TARGET, error = err.as_str(), "vm worker stopped"); + } + Ok(Some(i32::from(error.is_some()))) + } + VmControllerEvent::VncWorkerStopped { .. } => Ok(None), + VmControllerEvent::GuestHalt(reason) => { + tracing::info!(target: REPL_TARGET, reason = reason.as_str(), "guest halted"); + Ok(None) + } + VmControllerEvent::ExitRequested { code } => Ok(Some(code)), + } +} + /// Enables raw console mode for the console input of the stdio thread, unless /// stdin is not a terminal. Returns whether raw mode was enabled. pub(super) fn enable_raw_mode(stdin: &io::Stdin) -> io::Result { @@ -27,3 +73,41 @@ pub(super) fn disable_raw_mode(enabled: bool) -> io::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use crate::repl::ReplResources; + use crate::repl::launch::ReplLaunch; + use crate::repl::run_repl; + use crate::vm_controller::VmControllerEvent; + use pal_async::DefaultDriver; + use pal_async::async_test; + use std::pin::pin; + + #[async_test] + async fn headless_repl_waits_for_controller_without_stdin(driver: DefaultDriver) { + let (vm_rpc, _vm_requests) = mesh::channel(); + let (vm_controller, _controller_requests) = mesh::channel(); + let (events, vm_controller_events) = mesh::channel(); + let resources = ReplResources { + vm_rpc, + vm_controller, + vm_controller_events, + scsi_rpc: None, + nvme_vtl2_rpc: None, + consomme_rpc: None, + shutdown_ic: None, + kvp_ic: None, + console_in: None, + has_vtl2: false, + launch: ReplLaunch { + restore_ready_pending: false, + stdin_enabled: false, + }, + }; + let mut repl = pin!(run_repl(&driver, resources)); + assert!(futures::poll!(repl.as_mut()).is_pending()); + events.send(VmControllerEvent::ExitRequested { code: 23 }); + assert_eq!(repl.await.unwrap(), 23); + } +} diff --git a/openvmm/openvmm_entry/src/repl/launch.rs b/openvmm/openvmm_entry/src/repl/launch.rs index 8fa7f13d75e..1270298d85e 100644 --- a/openvmm/openvmm_entry/src/repl/launch.rs +++ b/openvmm/openvmm_entry/src/repl/launch.rs @@ -2,8 +2,8 @@ // Licensed under the MIT License. //! How a VM launch configures the REPL beyond the resources it shares with it: -//! whether a paused snapshot restore awaits its first resume -//! ([`super::restore_ready`]). +//! whether the REPL reads stdin ([`super::headless`] otherwise), and whether a +//! paused snapshot restore awaits its first resume ([`super::restore_ready`]). use crate::Options; @@ -12,6 +12,8 @@ pub(crate) struct ReplLaunch { /// Whether a paused snapshot restore waits for its first successful /// `resume`, which publishes its restore readiness event. pub(crate) restore_ready_pending: bool, + /// Whether the REPL reads stdin. Otherwise, it runs headless. + pub(crate) stdin_enabled: bool, } impl ReplLaunch { @@ -19,6 +21,7 @@ impl ReplLaunch { pub(crate) fn from_options(opt: &Options) -> Self { Self { restore_ready_pending: opt.paused && opt.restore_ready_path.is_some(), + stdin_enabled: !opt.microvm.microvm_control_auth_stdin, } } } diff --git a/openvmm/openvmm_entry/src/serial_io.rs b/openvmm/openvmm_entry/src/serial_io.rs index 9195232270b..b1e345d3aa0 100644 --- a/openvmm/openvmm_entry/src/serial_io.rs +++ b/openvmm/openvmm_entry/src/serial_io.rs @@ -16,6 +16,7 @@ use vm_resource::Resource; use vm_resource::kind::SerialBackendHandle; pub(crate) mod connect; +pub mod microvm; #[cfg(unix)] pub fn anonymous_serial_pair( diff --git a/openvmm/openvmm_entry/src/serial_io/microvm.rs b/openvmm/openvmm_entry/src/serial_io/microvm.rs new file mode 100644 index 00000000000..833d1c609c9 --- /dev/null +++ b/openvmm/openvmm_entry/src/serial_io/microvm.rs @@ -0,0 +1,428 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Local serial endpoints and capability input of the microVM control console. + +#[cfg(target_os = "linux")] +use serial_socket::net::OpenSocketSerialConfig; +use std::fs::File; +use std::io; +#[cfg(any(unix, windows))] +use std::io::Read; +use std::path::Path; +#[cfg(target_os = "linux")] +use unix_socket::UnixListener; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::kind::SerialBackendHandle; + +#[cfg(target_os = "linux")] +pub fn bind_control_serial(path: &Path) -> io::Result> { + use std::os::unix::fs::FileTypeExt; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + + let parent = path.parent().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "control endpoint has no parent directory", + ) + })?; + let parent_metadata = fs_err::symlink_metadata(parent)?; + if parent_metadata.file_type().is_symlink() + || !parent_metadata.is_dir() + || parent_metadata.uid() != pal::unix::effective_user_id() + || parent_metadata.mode() & 0o7777 != 0o700 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "control endpoint parent must be a non-symlink directory owned by OpenVMM with mode 0700", + )); + } + + let listener = UnixListener::bind(path)?; + let bound_metadata = fs_err::symlink_metadata(path)?; + let prepare_result = (|| { + fs_err::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + let socket_metadata = fs_err::symlink_metadata(path)?; + if !socket_metadata.file_type().is_socket() + || socket_metadata.dev() != bound_metadata.dev() + || socket_metadata.ino() != bound_metadata.ino() + || socket_metadata.uid() != parent_metadata.uid() + || socket_metadata.mode() & 0o7777 != 0o600 + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "control endpoint must be an owned Unix socket with mode 0600", + )); + } + Ok(()) + })(); + if let Err(error) = prepare_result { + drop(listener); + if let Ok(current) = fs_err::symlink_metadata(path) + && current.file_type().is_socket() + && current.dev() == bound_metadata.dev() + && current.ino() == bound_metadata.ino() + { + let _ = fs_err::remove_file(path); + } + return Err(error); + } + Ok(OpenSocketSerialConfig::from(listener).into_resource()) +} + +#[cfg(windows)] +pub fn bind_control_serial(path: &Path) -> io::Result> { + use pal::windows::security::LocalSecurityDescriptor; + use serial_socket::windows::OpenWindowsPipeSerialConfig; + + const NAMED_PIPE_PREFIX: &str = "//./pipe/"; + + let normalized = path.to_string_lossy().replace('\\', "/"); + let name = normalized.strip_prefix(NAMED_PIPE_PREFIX).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "control endpoint must name a Windows //./pipe/... endpoint", + ) + })?; + if name.is_empty() || name.contains('/') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "control endpoint must contain one nonempty Windows named-pipe name", + )); + } + + let user_sid = pal::windows::security::user_sid::current_process_user_sid()?; + let descriptor = format!("D:P(A;;GA;;;SY)(A;;GA;;;{})", user_sid.to_string_sid()) + .parse::()?; + let pipe = pal::windows::pipe::peer::new_named_pipe_with_security( + path, + windows_sys::Win32::Foundation::GENERIC_READ + | windows_sys::Win32::Foundation::GENERIC_WRITE, + pal::windows::pipe::Disposition::Create, + pal::windows::pipe::PipeMode::Byte, + &descriptor, + )?; + Ok(OpenWindowsPipeSerialConfig::from(pipe).into_resource()) +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub fn bind_control_serial(_path: &Path) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure control-console local endpoints are only supported on Linux", + )) +} + +/// Consumes a one-way pipe containing exactly one nonzero 32-byte control capability. +#[cfg(any(unix, windows))] +pub fn read_control_capability(mut file: File) -> io::Result<[u8; 32]> { + const CONTROL_CAPABILITY_LEN: usize = 32; + + #[cfg(unix)] + { + use std::os::unix::fs::FileTypeExt; + + if !file.metadata()?.file_type().is_fifo() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "control authentication input is not a one-way pipe", + )); + } + pal::unix::pipe::set_nonblocking(&file, true)?; + } + #[cfg(windows)] + { + use pal::windows::pipe::PipeExt as _; + use windows_sys::Win32::System::Pipes::PIPE_NOWAIT; + + if !pal::windows::pipe::peer::is_pipe(&file) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "control authentication input is not a one-way pipe", + )); + } + file.set_pipe_mode(PIPE_NOWAIT)?; + } + + let mut capability = [0u8; CONTROL_CAPABILITY_LEN]; + let mut offset = 0; + while offset != capability.len() { + match file.read(&mut capability[offset..]) { + Ok(0) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "control authentication payload has an invalid length", + )); + } + Ok(count) => offset += count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) if control_pipe_would_block(&error) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "control authentication writer was not closed", + )); + } + Err(error) if control_pipe_is_closed(&error) => { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "control authentication payload has an invalid length", + )); + } + Err(error) => return Err(error), + } + } + + let mut trailing = [0u8; 1]; + loop { + match file.read(&mut trailing) { + Ok(0) => break, + Ok(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "control authentication payload has an invalid length", + )); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) if control_pipe_would_block(&error) => { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "control authentication writer was not closed", + )); + } + Err(error) if control_pipe_is_closed(&error) => break, + Err(error) => return Err(error), + } + } + if capability == [0; CONTROL_CAPABILITY_LEN] { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "control authentication capability must not be zero", + )); + } + Ok(capability) +} + +fn control_pipe_would_block(error: &io::Error) -> bool { + if error.kind() == io::ErrorKind::WouldBlock { + return true; + } + #[cfg(windows)] + { + error.raw_os_error() == Some(windows_sys::Win32::Foundation::ERROR_NO_DATA as i32) + } + #[cfg(not(windows))] + { + false + } +} + +fn control_pipe_is_closed(error: &io::Error) -> bool { + #[cfg(windows)] + { + matches!( + error.raw_os_error().map(|value| value as u32), + Some( + windows_sys::Win32::Foundation::ERROR_BROKEN_PIPE + | windows_sys::Win32::Foundation::ERROR_PIPE_NOT_CONNECTED + ) + ) + } + #[cfg(not(windows))] + { + let _ = error; + false + } +} + +/// Reads the prepared authentication pipe without taking ownership of descriptor 0. +#[cfg(target_os = "linux")] +pub fn read_control_capability_from_stdin() -> io::Result<[u8; 32]> { + use std::os::fd::AsFd; + + let stdin = io::stdin(); + read_control_capability(File::from(stdin.as_fd().try_clone_to_owned()?)) +} + +/// Reads the prepared authentication pipe without taking ownership of handle 0. +#[cfg(windows)] +pub fn read_control_capability_from_stdin() -> io::Result<[u8; 32]> { + use std::os::windows::io::AsHandle as _; + + let stdin = io::stdin(); + read_control_capability(File::from(stdin.as_handle().try_clone_to_owned()?)) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + use std::io::Write as _; + use std::os::unix::fs::FileTypeExt; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + use test_with_tracing::test; + + fn read_capability_payload(payload: &[u8], keep_writer_open: bool) -> io::Result<[u8; 32]> { + let (read, mut write) = pal::pipe_pair()?; + write.write_all(payload)?; + if !keep_writer_open { + drop(write); + } + read_control_capability(read) + } + + #[test] + fn control_capability_requires_exact_closed_pipe_payload() { + let mut capability = [0x5a; 32]; + capability[0] = 0; + assert_eq!( + read_capability_payload(&capability, false).unwrap(), + capability + ); + for length in [0, 1, 31] { + assert_eq!( + read_capability_payload(&vec![0x5a; length], false) + .unwrap_err() + .kind(), + io::ErrorKind::UnexpectedEof + ); + } + for length in [33, 64] { + assert_eq!( + read_capability_payload(&vec![0x5a; length], false) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidData + ); + } + for length in [0, 31, 32] { + assert_eq!( + read_capability_payload(&vec![0x5a; length], true) + .unwrap_err() + .kind(), + io::ErrorKind::TimedOut + ); + } + assert_eq!( + read_capability_payload(&[0; 32], false).unwrap_err().kind(), + io::ErrorKind::InvalidData + ); + } + + #[test] + fn control_capability_rejects_non_pipe_input() { + assert_eq!( + read_control_capability(tempfile::tempfile().unwrap()) + .unwrap_err() + .kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn control_capability_stdin_child() { + let Some(expected) = std::env::var_os("OPENVMM_TEST_CONTROL_STDIN") else { + return; + }; + let result = read_control_capability_from_stdin(); + match expected.to_str().unwrap() { + "valid" => { + let mut capability = [0x5a; 32]; + capability[0] = 0; + assert_eq!(result.unwrap(), capability); + assert_eq!(io::stdin().read(&mut [0]).unwrap(), 0); + } + "open-writer" => assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut), + "non-pipe" => assert_eq!(result.unwrap_err().kind(), io::ErrorKind::InvalidInput), + _ => panic!("unexpected child-test scenario"), + } + } + + #[test] + fn control_capability_uses_prepared_child_stdin() { + use std::process::Command; + use std::process::Stdio; + use std::time::Duration; + use std::time::Instant; + + for scenario in ["valid", "open-writer", "non-pipe"] { + let (read, mut write) = pal::pipe_pair().unwrap(); + let mut capability = [0x5a; 32]; + capability[0] = 0; + write.write_all(&capability).unwrap(); + let _writer = (scenario == "open-writer").then_some(write); + let input = if scenario == "non-pipe" { + tempfile::tempfile().unwrap() + } else { + read + }; + let mut child = Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "serial_io::microvm::tests::control_capability_stdin_child", + "--nocapture", + ]) + .env("OPENVMM_TEST_CONTROL_STDIN", scenario) + .stdin(Stdio::from(input)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + while child.try_wait().unwrap().is_none() { + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!("stdin capability scenario {scenario} timed out: {output:?}"); + } + std::thread::sleep(Duration::from_millis(10)); + } + let output = child.wait_with_output().unwrap(); + assert!( + output.status.success(), + "stdin scenario {scenario}: {output:?}" + ); + } + } + + #[test] + fn control_listener_has_private_permissions_and_exclusive_path() { + let directory = tempfile::tempdir().unwrap(); + fs_err::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let path = directory.path().join("control.sock"); + + let listener = bind_control_serial(&path).unwrap(); + let metadata = fs_err::symlink_metadata(&path).unwrap(); + assert!(metadata.file_type().is_socket()); + assert_eq!(metadata.mode() & 0o777, 0o600); + assert_eq!(metadata.uid(), pal::unix::effective_user_id()); + assert!(bind_control_serial(&path).is_err()); + drop(listener); + } +} + +#[cfg(all(test, windows))] +mod windows_tests { + use super::*; + use std::io::Write as _; + use test_with_tracing::test; + + #[test] + fn control_capability_requires_an_exact_closed_windows_pipe() { + let (read, mut write) = pal::windows::pipe::pair().unwrap(); + let mut capability = [0x5a; 32]; + capability[0] = 0; + write.write_all(&capability).unwrap(); + drop(write); + assert_eq!(read_control_capability(read).unwrap(), capability); + + let (read, mut write) = pal::windows::pipe::pair().unwrap(); + write.write_all(&capability[..31]).unwrap(); + drop(write); + assert_eq!( + read_control_capability(read).unwrap_err().kind(), + io::ErrorKind::UnexpectedEof + ); + } +} diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 6660a437249..a167c3240dc 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -228,7 +228,7 @@ impl VmController { self.microvm.filesystem_slot, filesystem, self.microvm.resources.console_attachment.clone(), - None, + self.microvm.resources.control_console_attachment.clone(), blocks, self.processors, self.memory, @@ -358,6 +358,15 @@ impl VmController { ); return GuestSnapshotAction::Terminate { exit_code: 1 }; } + if let Some(cleanup) = self.microvm.resources.control_console_socket_cleanup.take() + && let Err(error) = cleanup.remove_if_owned() + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "snapshot committed but the source control console socket could not be removed" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } tracing::info!( path = %destination.display(), "microVM snapshot committed; terminating source process" @@ -376,6 +385,15 @@ impl VmController { "committed snapshot console socket could not be removed" ); } + if let Some(cleanup) = + self.microvm.resources.control_console_socket_cleanup.take() + && let Err(cleanup_error) = cleanup.remove_if_owned() + { + tracing::error!( + error = cleanup_error.as_ref() as &dyn std::error::Error, + "committed snapshot control console socket could not be removed" + ); + } tracing::error!( error = error.as_ref() as &dyn std::error::Error, path = %destination.display(), From 9d4ebe19dc02475916cd918d189424c907edc10e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:39:17 -0700 Subject: [PATCH 150/304] openvmm_entry: drain portb output before guest-requested exit A microVM workload often reports its result on the portb console and then asks OpenVMM to exit with a status. The process used to exit as soon as the guest asked, so console bytes that the device had accepted, or that the host relay had not yet written to stdout or stderr, could be lost, and a supervisor would see a successful exit with truncated output. Drain the output before exiting: - The portb device can take an output-drain request channel (`MicrovmPortbHandle.output_drain`). On a request, it finishes writing the bytes it has accepted, flushes the endpoint, closes it so the relay sees EOF, and completes the request with the result. - `spawn_output`, which relays a console to the host stdout or stderr, moves to `microvm/output.rs` and returns a completion that resolves after the relay has copied and flushed everything. `MicrovmOutputDrain` drains portb, then waits for its relay, and fails after five seconds. - A guest process-status shutdown (port `0x604`) now ends the process with the guest's status, and a power event configured to exit keeps doing so. In both cases the controller drains the output first. A drain failure is reported with the new `ExitFailed` controller event, which fails the run in the interactive and headless REPLs instead of exiting successfully. - The controller also reports whether the VM worker and the auxiliary workers stopped cleanly, and a microVM run fails if they did not. A VM worker that stops without a quit request is now reported as an error. The teardown error carries that status for the outcome report added later, and an `expect(dead_code)` covers it until the report reads it. The ttrpc server logs `ExitFailed` like an unexpected exit request for now; its microVM exit handling comes with the management plane. Its controller task discards the new teardown status. Petri attaches no drain. The CLI reference describes the drain. Tests cover the portb drain waiting for pending writes and the flush before closing and reporting an output failure, the drain waiting for both the device and the relay with each failure reported, the bounded deadline, relay completion after the last write or on a write failure, the exit status of a successful drain, `ExitFailed` for a failed drain, and the REPL propagating `ExitFailed`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 7 + openvmm/openvmm_entry/src/lib.rs | 4 +- openvmm/openvmm_entry/src/microvm/config.rs | 57 ++--- openvmm/openvmm_entry/src/microvm/mod.rs | 4 + openvmm/openvmm_entry/src/microvm/output.rs | 217 ++++++++++++++++++ openvmm/openvmm_entry/src/repl.rs | 1 + openvmm/openvmm_entry/src/repl/headless.rs | 18 ++ openvmm/openvmm_entry/src/ttrpc/mod.rs | 12 +- openvmm/openvmm_entry/src/vm_controller.rs | 48 +++- .../src/vm_controller/microvm.rs | 101 +++++++- petri/src/vm/openvmm/microvm.rs | 1 + vm/devices/chipset/src/microvm.rs | 109 ++++++++- vm/devices/chipset/src/microvm/resolver.rs | 1 + vm/devices/chipset_resources/src/microvm.rs | 2 + 14 files changed, 529 insertions(+), 53 deletions(-) create mode 100644 openvmm/openvmm_entry/src/microvm/output.rs diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 7a94bddda9c..9fc3783358d 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -764,6 +764,13 @@ status code as `exit:` (0-255); a bare `exit` uses 0: A bare `exit` exits with status 0; `exit:` exits with that code instead, so a supervisor can tell the exit reasons apart. +For microVMs, a process-status shutdown through port `0x604`, or a power event +configured to exit, drains previously accepted portb console output before the +process exits. This includes the host stdout/stderr relay, not just the device's +transmit buffer. Draining is bounded to five seconds; an output error or timeout +is reported as a failure rather than a successful exit with truncated output. +Snapshot capture still preserves pending console bytes in the snapshot. + * `--crash-dump-path `: when the guest triple-faults, write a WinDbg-compatible `.vmrs` dump of the VM's processor state and guest memory to `PATH` before the `--guest-crash-action` is applied (see diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index dfdad53fcaf..2d921394933 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -3105,12 +3105,12 @@ async fn run_control_inner( // Wait for the controller task to finish (it stops the VM worker and // shuts down the mesh). - controller_task.await; + let teardown = controller_task.await; drop(serial_driver); // run_repl returns the exit status: the code the guest drove via an opt-in // exit (VmControllerEvent::ExitRequested), or 0 when the VM stopped normally. - repl_result + teardown.enforce(opt.machine == MachineProfileCli::Microvm, repl_result) } struct DiagDialer { diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index e48f83bf737..d965b35d10a 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -19,6 +19,9 @@ use super::filesystem::microvm_filesystem_slot_from_snapshot; use super::network::EffectiveMicrovmNetwork; use super::network::effective_microvm_network; use super::network::microvm_network_endpoint; +use super::output::MicrovmOutputDrain; +use super::output::OutputCompletion; +use super::output::spawn_output; use super::restore::fresh_microvm_generation_id; use super::restore::fresh_microvm_restore_packet; use crate::ConsoleState; @@ -35,8 +38,6 @@ use chipset_resources::microvm::MicrovmPortbHandle; use chipset_resources::microvm::MicrovmShutdownHandle; use chipset_resources::microvm::MicrovmSnapshotRequestHandle; use futures::AsyncReadExt; -use futures::executor::block_on; -use futures::io::AllowStdIo; use net_backend_resources::consomme::static_ipv4::StaticIpv4Config; use openvmm_defs::config::Config; use openvmm_defs::config::DeviceVtl; @@ -48,7 +49,6 @@ use pal_async::DefaultDriver; use serial_core::resources::DisconnectedSerialBackendHandle; use std::cell::RefCell; use std::path::PathBuf; -use std::thread; use std::time::Duration; use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; use virtio_resources::console::attachment::VirtioConsoleReconnectPolicy; @@ -73,10 +73,10 @@ pub(crate) struct MicrovmConfigBuilder<'a> { filesystem: Option, gateway_dns: bool, console: Option, - portb: Option>, control_console: Option, control_broker_config: Option, control_console_backend: Option>, + portb: Option<(Resource, OutputCompletion)>, resources: MicrovmResources, } @@ -310,13 +310,16 @@ impl<'a> MicrovmConfigBuilder<'a> { ), )?) } - SerialConfigCli::Console => Some(setup_host_console( - "virtio-console", - SerialConfigCli::Console, - "hvc1", - console_state, - serial_driver, - )?), + SerialConfigCli::Console => { + let (backend, _) = setup_host_console( + "virtio-console", + SerialConfigCli::Console, + "hvc1", + console_state, + serial_driver, + )?; + Some(backend) + } SerialConfigCli::None => Some(DisconnectedSerialBackendHandle.into_resource()), _ => unreachable!("microVM console backend was validated as an attachment"), } @@ -384,10 +387,12 @@ impl<'a> MicrovmConfigBuilder<'a> { &mut self, chipset_devices: &mut Vec, ) -> anyhow::Result<()> { - let Some(io) = self.portb.take() else { + let Some((io, output_completion)) = self.portb.take() else { return Ok(()); }; let opt = self.opt; + let (drain, output_drain) = MicrovmOutputDrain::new(Some(output_completion)); + self.resources.output_drain = Some(drain); let (generation_id, restore_entropy) = if opt.microvm.restore_entropy || self.restore.memory_target_requested { fresh_microvm_restore_packet( @@ -404,6 +409,7 @@ impl<'a> MicrovmConfigBuilder<'a> { io, generation_id, restore_entropy, + output_drain: Some(output_drain), } .into_resource(), }); @@ -741,14 +747,14 @@ impl<'a> MicrovmConfigBuilder<'a> { } /// Connects a portb or boot virtio-console endpoint to the host console or -/// stderr, returning its backend. +/// stderr, returning its backend and output-relay completion. fn setup_host_console( name: &str, backend: SerialConfigCli, device: &'static str, console_state: &RefCell>>, serial_driver: &DefaultDriver, -) -> anyhow::Result> { +) -> anyhow::Result<(Resource, OutputCompletion)> { Ok(match backend { SerialConfigCli::Console => { if let Some(console_state) = console_state.borrow().as_ref() { @@ -760,33 +766,18 @@ fn setup_host_console( device, input: Box::new(serial_write), }); - spawn_output(name, serial_read, term::raw_stdout())?; - config + let completed = spawn_output(name, serial_read, term::raw_stdout())?; + (config, completed) } SerialConfigCli::Stderr => { let (config, serial) = serial_io::anonymous_serial_pair(serial_driver)?; - spawn_output(name, serial, term::raw_stderr())?; - config + let completed = spawn_output(name, serial, term::raw_stderr())?; + (config, completed) } _ => unreachable!("microVM host consoles use the console or stderr"), }) } -/// Relays `input` to `output` on a dedicated thread. -fn spawn_output( - name: &str, - input: impl futures::AsyncRead + Send + Unpin + 'static, - output: impl std::io::Write + Send + 'static, -) -> anyhow::Result<()> { - thread::Builder::new() - .name(name.to_owned()) - .spawn(move || { - let _ = block_on(futures::io::copy(input, &mut AllowStdIo::new(output))); - }) - .with_context(|| format!("failed to spawn the {name} output relay"))?; - Ok(()) -} - fn build_effective_microvm_command_line( user_args: &[String], processor_count: u32, diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index f195e93c4e9..1d981f6a828 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -8,6 +8,7 @@ mod console; mod filesystem; mod launch; mod network; +pub(crate) mod output; mod restore; pub(crate) use config::MicrovmConfigBuilder; @@ -23,12 +24,15 @@ use crate::storage_builder::microvm::MicrovmSandboxBlockSource; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; use net_backend_resources::egress::EgressPolicy; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; +use output::MicrovmOutputDrain; use std::path::PathBuf; /// Host-side microVM resources produced while building the VM configuration /// and consumed by the snapshot, restore, and teardown paths. #[derive(Default)] pub(crate) struct MicrovmResources { + /// Drains portb and its host output relay before a guest-requested exit. + pub(crate) output_drain: Option, /// Guest-requested snapshot boundaries from the snapshot-request port. pub(crate) snapshot_requests: Option>, /// Snapshot sources of the fixed-role sandbox blocks, in role order. diff --git a/openvmm/openvmm_entry/src/microvm/output.rs b/openvmm/openvmm_entry/src/microvm/output.rs new file mode 100644 index 00000000000..793a359fd43 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/output.rs @@ -0,0 +1,217 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Drains the microVM portb device and its host output relay before process exit. + +use anyhow::Context; +use futures::AsyncRead; +use futures::AsyncWriteExt; +use futures::executor::block_on; +use futures::io::AllowStdIo; +use mesh::CancelContext; +use mesh::OneshotReceiver; +use mesh::oneshot; +use mesh::rpc::FailableRpc; +use mesh::rpc::RpcSend; +use std::io; +use std::thread; +use std::time::Duration; + +pub(crate) type OutputCompletion = OneshotReceiver>; + +pub(crate) fn spawn_output( + name: &str, + input: impl AsyncRead + Send + Unpin + 'static, + output: impl io::Write + Send + 'static, +) -> io::Result { + let (complete, completed) = oneshot(); + thread::Builder::new() + .name(name.to_owned()) + .spawn(move || { + let result = block_on(async { + let mut output = AllowStdIo::new(output); + futures::io::copy(input, &mut output).await?; + output.flush().await + }); + complete.send(result); + })?; + Ok(completed) +} + +pub(crate) struct MicrovmOutputDrain { + portb: mesh::Sender>, + output: Option, +} + +impl MicrovmOutputDrain { + pub(crate) fn new( + output: Option, + ) -> (Self, mesh::Receiver>) { + let (portb, requests) = mesh::channel(); + (Self { portb, output }, requests) + } + + pub(crate) async fn drain(self) -> anyhow::Result<()> { + self.drain_with_timeout(Duration::from_secs(5)).await + } + + async fn drain_with_timeout(self, timeout: Duration) -> anyhow::Result<()> { + CancelContext::new() + .with_timeout(timeout) + .until_cancelled(async { + self.portb + .call_failable(std::convert::identity, ()) + .await + .context("failed to drain the microVM portb endpoint")?; + if let Some(output) = self.output { + output + .await + .context("microVM console output relay stopped without completing")? + .context("microVM console output relay failed")?; + } + tracing::debug!("microVM console output drained"); + anyhow::Ok(()) + }) + .await + .context("microVM console output drain timed out")? + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::FutureExt; + use futures::poll; + use parking_lot::Mutex; + use std::pin::pin; + use std::sync::Arc; + use std::sync::mpsc; + use test_with_tracing::test; + + #[test] + fn drain_waits_for_both_device_and_relay() { + block_on(async { + let (complete, completed) = oneshot(); + let (drain, mut requests) = MicrovmOutputDrain::new(Some(completed)); + let mut drain = pin!(drain.drain()); + assert!(poll!(&mut drain).is_pending()); + requests.recv().await.unwrap().complete(Ok(())); + assert!(poll!(&mut drain).is_pending()); + complete.send(Ok(())); + drain.await.unwrap(); + }); + } + + #[test] + fn drain_reports_endpoint_failure() { + block_on(async { + let (drain, mut requests) = MicrovmOutputDrain::new(None); + let mut drain = pin!(drain.drain()); + assert!(poll!(&mut drain).is_pending()); + requests + .recv() + .await + .unwrap() + .fail(io::Error::from(io::ErrorKind::BrokenPipe)); + assert!(format!("{:#}", drain.await.unwrap_err()).contains("portb endpoint")); + }); + } + + #[test] + fn drain_reports_relay_failure() { + block_on(async { + let (complete, completed) = oneshot(); + let (drain, mut requests) = MicrovmOutputDrain::new(Some(completed)); + let mut drain = pin!(drain.drain()); + assert!(poll!(&mut drain).is_pending()); + requests.recv().await.unwrap().complete(Ok(())); + complete.send(Err(io::Error::from(io::ErrorKind::BrokenPipe))); + assert!(format!("{:#}", drain.await.unwrap_err()).contains("relay failed")); + }); + } + + #[test] + fn drain_has_a_bounded_deadline() { + block_on(async { + let (drain, _requests) = MicrovmOutputDrain::new(None); + let error = drain + .drain_with_timeout(Duration::from_millis(10)) + .await + .unwrap_err(); + assert!(format!("{error:#}").contains("timed out")); + }); + } + + #[test] + fn output_completion_follows_the_final_write() { + struct DelayedOutput { + bytes: Arc>>, + entered: mpsc::Sender<()>, + release: mpsc::Receiver<()>, + } + impl io::Write for DelayedOutput { + fn write(&mut self, buffer: &[u8]) -> io::Result { + self.entered.send(()).unwrap(); + self.release.recv().unwrap(); + self.bytes.lock().extend_from_slice(buffer); + Ok(buffer.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let payload = b"OPENVMM-SNAPSHOT-RESTORE-OK\n"; + let bytes = Arc::new(Mutex::new(Vec::new())); + let (entered, waiting) = mpsc::channel(); + let (release, released) = mpsc::channel(); + let mut completed = spawn_output( + "microvm-output-test", + futures::io::Cursor::new(payload), + DelayedOutput { + bytes: bytes.clone(), + entered, + release: released, + }, + ) + .unwrap(); + waiting.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!((&mut completed).now_or_never().is_none()); + release.send(()).unwrap(); + block_on(completed).unwrap().unwrap(); + assert_eq!(*bytes.lock(), payload); + } + + #[test] + fn output_completion_reports_write_failure() { + struct FailingOutput; + impl io::Write for FailingOutput { + fn write(&mut self, _buffer: &[u8]) -> io::Result { + Err(io::ErrorKind::BrokenPipe.into()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + let completed = spawn_output( + "microvm-output-error-test", + futures::io::Cursor::new(b"marker"), + FailingOutput, + ) + .unwrap(); + assert_eq!( + block_on(completed).unwrap().unwrap_err().kind(), + io::ErrorKind::BrokenPipe + ); + } + + #[test] + fn drain_without_relay_only_waits_for_the_endpoint() { + let (drain, mut requests) = MicrovmOutputDrain::new(None); + let mut drain = Box::pin(drain.drain()); + assert!(drain.as_mut().now_or_never().is_none()); + requests.try_recv().unwrap().complete(Ok(())); + block_on(drain).unwrap(); + } +} diff --git a/openvmm/openvmm_entry/src/repl.rs b/openvmm/openvmm_entry/src/repl.rs index 2e6d5874acf..7ec596518f1 100644 --- a/openvmm/openvmm_entry/src/repl.rs +++ b/openvmm/openvmm_entry/src/repl.rs @@ -806,6 +806,7 @@ pub(crate) async fn run_repl( tracing::info!(reason = reason.as_str(), "guest halted"); } VmControllerEvent::ExitRequested { code } => break code, + VmControllerEvent::ExitFailed { error } => return Err(anyhow::anyhow!(error)), } continue; } diff --git a/openvmm/openvmm_entry/src/repl/headless.rs b/openvmm/openvmm_entry/src/repl/headless.rs index 638d786b9c0..7b7db621ea0 100644 --- a/openvmm/openvmm_entry/src/repl/headless.rs +++ b/openvmm/openvmm_entry/src/repl/headless.rs @@ -53,6 +53,7 @@ fn controller_exit(event: VmControllerEvent) -> anyhow::Result> { Ok(None) } VmControllerEvent::ExitRequested { code } => Ok(Some(code)), + VmControllerEvent::ExitFailed { error } => Err(anyhow::anyhow!(error)), } } @@ -76,6 +77,7 @@ pub(super) fn disable_raw_mode(enabled: bool) -> io::Result<()> { #[cfg(test)] mod tests { + use super::controller_exit; use crate::repl::ReplResources; use crate::repl::launch::ReplLaunch; use crate::repl::run_repl; @@ -83,6 +85,7 @@ mod tests { use pal_async::DefaultDriver; use pal_async::async_test; use std::pin::pin; + use test_with_tracing::test; #[async_test] async fn headless_repl_waits_for_controller_without_stdin(driver: DefaultDriver) { @@ -110,4 +113,19 @@ mod tests { events.send(VmControllerEvent::ExitRequested { code: 23 }); assert_eq!(repl.await.unwrap(), 23); } + + #[test] + fn repl_propagates_exit_failures_and_preserves_requested_statuses() { + let error = controller_exit(VmControllerEvent::ExitFailed { + error: "console output drain timed out".to_owned(), + }) + .unwrap_err(); + assert!(error.to_string().contains("console output drain timed out")); + for code in [0, 37] { + assert_eq!( + controller_exit(VmControllerEvent::ExitRequested { code }).unwrap(), + Some(code) + ); + } + } } diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index d2159349b10..4b3d4788291 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1357,10 +1357,11 @@ impl VmService { }; // Spawn the controller task. - let controller_task = self.driver.spawn( - "vm-controller", - controller.run(vm_controller_recv, event_send, notify_recv), - ); + let controller_task = self.driver.spawn("vm-controller", async move { + let _ = controller + .run(vm_controller_recv, event_send, notify_recv) + .await; + }); self.vm_controller = Some(vm_controller_send); self.vm_controller_events = Some(event_recv); @@ -1480,6 +1481,9 @@ impl VmService { // out from under its clients. tracing::warn!(code, "unexpected exit request in server mode"); } + VmControllerEvent::ExitFailed { error } => { + tracing::error!(error = %error, "guest-requested exit failed in server mode"); + } VmControllerEvent::WorkerStopped { error } => { if let Some(err) = &error { tracing::error!(error = %err, "VM worker stopped with error"); diff --git a/openvmm/openvmm_entry/src/vm_controller.rs b/openvmm/openvmm_entry/src/vm_controller.rs index b819103e905..cc8da71eb5e 100644 --- a/openvmm/openvmm_entry/src/vm_controller.rs +++ b/openvmm/openvmm_entry/src/vm_controller.rs @@ -30,6 +30,7 @@ use vmm_core_defs::HaltReason; mod microvm; pub(crate) use microvm::MicrovmController; +pub(crate) use microvm::MicrovmTeardownStatus; /// Inspection target: host-side workers or the paravisor. #[derive(Clone, Copy, mesh::MeshPayload)] @@ -112,6 +113,8 @@ pub enum VmControllerEvent { /// The controller requests that the process exit with this code, because the /// guest drove a power event the user opted into exiting on. ExitRequested { code: i32 }, + /// A guest-requested process exit failed and must terminate the runner. + ExitFailed { error: String }, } /// Owns exclusive VM resources and services RPCs from the REPL. @@ -183,7 +186,7 @@ impl VmController { mut rpc_recv: mesh::Receiver, event_send: mesh::Sender, mut notify_recv: mesh::Receiver, - ) { + ) -> MicrovmTeardownStatus { enum Event { Rpc(VmControllerRpc), RpcClosed, @@ -243,7 +246,9 @@ impl VmController { } else { tracing::error!("vm worker unexpectedly stopped"); } - event_send.send(VmControllerEvent::WorkerStopped { error: None }); + event_send.send(VmControllerEvent::WorkerStopped { + error: (!quit).then(|| "VM worker unexpectedly stopped".to_owned()), + }); break; } WorkerEvent::Failed(err) => { @@ -289,6 +294,13 @@ impl VmController { }, Event::Halt(reason) => { tracing::info!(?reason, "guest halted"); + if let HaltReason::PowerOffWithStatus { code } = reason { + self.request_exit(i32::from(code), &event_send).await; + return MicrovmTeardownStatus { + vm_worker_stopped: true, + auxiliary_workers_stopped: true, + }; + } // On a guest crash, write a `.vmrs` dump (if configured) // before applying the crash action, since a `Reset` action // would wipe the guest state we want to capture. @@ -315,10 +327,11 @@ impl VmController { // are parked, so don't stop it here; signal the runner to // exit instead. tracing::info!(exit_code = code, "requesting exit on guest halt"); - event_send.send(VmControllerEvent::ExitRequested { - code: i32::from(code), - }); - return; + self.request_exit(i32::from(code), &event_send).await; + return MicrovmTeardownStatus { + vm_worker_stopped: true, + auxiliary_workers_stopped: true, + }; } GuestPowerAction::Reset => { // Reboot the VM in place. @@ -349,13 +362,18 @@ impl VmController { // Ensure all workers are cleaned up before shutting down the mesh. self.vm_worker.stop(); - if let Err(err) = self.vm_worker.join().await { - tracing::error!( - error = err.as_ref() as &dyn std::error::Error, - "vm worker join failed" - ); - } + let vm_worker_stopped = match self.vm_worker.join().await { + Ok(()) => true, + Err(err) => { + tracing::error!( + error = err.as_ref() as &dyn std::error::Error, + "vm worker join failed" + ); + false + } + }; + let mut auxiliary_workers_stopped = true; if let Some(mut vnc) = self.vnc_worker.take() { vnc.stop(); if let Err(err) = vnc.join().await { @@ -363,6 +381,7 @@ impl VmController { error = err.as_ref() as &dyn std::error::Error, "vnc worker join failed" ); + auxiliary_workers_stopped = false; } } @@ -373,10 +392,15 @@ impl VmController { error = err.as_ref() as &dyn std::error::Error, "gdb worker join failed" ); + auxiliary_workers_stopped = false; } } self.mesh.shutdown().await; + MicrovmTeardownStatus { + vm_worker_stopped, + auxiliary_workers_stopped, + } } async fn handle_rpc(&mut self, rpc: VmControllerRpc, quit: &mut bool) { diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index a167c3240dc..3dbb40b48dc 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! MicroVM snapshot capture handling of the VM controller. +//! MicroVM snapshot capture, guest exit, and teardown handling of the VM controller. use super::VmController; +use super::VmControllerEvent; use crate::microvm::MicrovmResources; use anyhow::Context; use mesh::rpc::RpcSend; @@ -48,12 +49,74 @@ pub(crate) struct MicrovmController { pub(crate) _private_scratch_dir: Option, } +#[derive(Clone, Copy, Debug)] +pub(crate) struct MicrovmTeardownStatus { + pub(crate) vm_worker_stopped: bool, + pub(crate) auxiliary_workers_stopped: bool, +} + +impl MicrovmTeardownStatus { + pub(crate) fn complete(self) -> bool { + self.vm_worker_stopped && self.auxiliary_workers_stopped + } + + /// Fails a run whose workers did not stop cleanly when `enforce` is set. + pub(crate) fn enforce(self, enforce: bool, result: anyhow::Result) -> anyhow::Result { + if enforce && !self.complete() { + let teardown_error = MicrovmTeardownError(self); + return match result { + Ok(_) => Err(teardown_error.into()), + Err(error) => Err(error.context(teardown_error)), + }; + } + result + } +} + +#[derive(Debug)] +#[expect(dead_code, reason = "the microVM outcome report reads the status")] +pub(crate) struct MicrovmTeardownError(pub(crate) MicrovmTeardownStatus); + +impl std::fmt::Display for MicrovmTeardownError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("one or more microVM workers failed to stop cleanly") + } +} + +impl std::error::Error for MicrovmTeardownError {} + pub(super) enum GuestSnapshotAction { Continue, Terminate { exit_code: i32 }, } +async fn guest_exit_event( + code: i32, + drain: Option, +) -> VmControllerEvent { + if let Some(drain) = drain + && let Err(error) = drain.drain().await + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "failed to drain microVM console output before exit" + ); + return VmControllerEvent::ExitFailed { + error: format!("failed to drain microVM console output: {error:#}"), + }; + } + VmControllerEvent::ExitRequested { code } +} + impl VmController { + pub(super) async fn request_exit( + &mut self, + code: i32, + events: &mesh::Sender, + ) { + events.send(guest_exit_event(code, self.microvm.resources.output_drain.take()).await); + } + pub(super) async fn handle_guest_snapshot_request( &mut self, scratch_policy: chipset_resources::microvm::MicrovmSnapshotScratchPolicy, @@ -460,3 +523,39 @@ impl VmController { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::microvm::output::MicrovmOutputDrain; + use futures::executor::block_on; + use test_with_tracing::test; + + #[test] + fn successful_drain_preserves_guest_exit_status() { + block_on(async { + for code in [0, 37] { + let (drain, mut requests) = MicrovmOutputDrain::new(None); + let (event, ()) = futures::join!(guest_exit_event(code, Some(drain)), async { + requests.recv().await.unwrap().complete(Ok(())); + }); + assert!(matches!( + event, + VmControllerEvent::ExitRequested { code: actual } if actual == code + )); + } + }); + } + + #[test] + fn failed_drain_requests_process_exit_not_worker_stopped() { + let (drain, requests) = MicrovmOutputDrain::new(None); + drop(requests); + let event = block_on(guest_exit_event(0, Some(drain))); + assert!(matches!( + event, + VmControllerEvent::ExitFailed { error } + if error.contains("failed to drain microVM console output") + )); + } +} diff --git a/petri/src/vm/openvmm/microvm.rs b/petri/src/vm/openvmm/microvm.rs index f88da32ea85..81c8f4d8557 100644 --- a/petri/src/vm/openvmm/microvm.rs +++ b/petri/src/vm/openvmm/microvm.rs @@ -150,6 +150,7 @@ pub(super) fn attach_chipset_devices( io, generation_id: [0x5a; 16], restore_entropy: Vec::new(), + output_drain: None, } .into_resource(), }, diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index 5e36ea62191..ee9a8dc059b 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -18,6 +18,7 @@ use inspect::InspectMut; use power_resources::PowerRequest; use power_resources::PowerRequestClient; use serial_core::SerialIo; +use serial_core::disconnected::Disconnected; use std::collections::VecDeque; use std::io; use std::io::ErrorKind; @@ -74,6 +75,10 @@ pub struct MicrovmPortb { rx_waker: Option, #[inspect(skip)] tx_waker: Option, + #[inspect(skip)] + output_drain_requests: Option>>, + #[inspect(skip)] + output_drain: Option>, } impl MicrovmPortb { @@ -109,9 +114,20 @@ impl MicrovmPortb { input_gated: false, rx_waker: None, tx_waker: None, + output_drain_requests: None, + output_drain: None, } } + /// Installs the process-exit output drain channel. + pub fn with_output_drain( + mut self, + requests: Option>>, + ) -> Self { + self.output_drain_requests = requests; + self + } + fn poll_rx(&mut self, cx: &mut Context<'_>) { let mut buffer = [0; 256]; loop { @@ -223,6 +239,21 @@ impl ChipsetDevice for MicrovmPortb { impl PollDevice for MicrovmPortb { fn poll_device(&mut self, cx: &mut Context<'_>) { + if self.output_drain.is_none() + && let Some(requests) = &mut self.output_drain_requests + { + match requests.poll_recv(cx) { + Poll::Ready(Ok(request)) => { + tracing::debug!( + buffered_bytes = self.tx_buffer.len(), + "draining microVM portb output" + ); + self.output_drain = Some(request); + } + Poll::Ready(Err(_)) => self.output_drain_requests = None, + Poll::Pending => {} + } + } if !self.io.is_connected() { match self.io.poll_connect(cx) { Poll::Ready(Ok(())) => {} @@ -239,7 +270,22 @@ impl PollDevice for MicrovmPortb { if !self.input_gated { self.poll_rx(cx); } - let _ = self.poll_tx(cx); + let output = self.poll_tx(cx); + if self.output_drain.is_some() { + let output = match output { + Poll::Ready(Ok(())) => Pin::new(&mut self.io).poll_flush(cx), + output => output, + }; + if let Poll::Ready(result) = output { + // Closing the endpoint publishes EOF only after every accepted + // byte has reached it. The controller also waits for the relay. + self.io = Box::new(Disconnected); + self.output_drain_requests = None; + if let Some(request) = self.output_drain.take() { + request.handle_failable_sync(|()| result); + } + } + } } } @@ -624,6 +670,8 @@ mod tests { use super::*; use futures::AsyncRead; use futures::AsyncWrite; + use futures::FutureExt; + use mesh::rpc::RpcSend; use parking_lot::Mutex; use serial_core::disconnected::Disconnected; use serial_core::serial_io::Connected; @@ -641,11 +689,18 @@ mod tests { bytes: Vec, writable: bool, flushed: bool, + closed: bool, error: Option, } struct BufferedOutput(Arc>); + impl Drop for BufferedOutput { + fn drop(&mut self) { + self.0.lock().closed = true; + } + } + impl AsyncRead for BufferedOutput { fn poll_read( self: Pin<&mut Self>, @@ -687,6 +742,58 @@ mod tests { } } + #[test] + fn portb_exit_drain_waits_for_pending_writes_and_flush_before_closing() { + let state = Arc::new(Mutex::new(OutputState::default())); + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + Vec::new(), + ) + .with_output_drain(Some(receiver)); + let payload = b"OPENVMM-SNAPSHOT-RESTORE-OK\n\0\xff"; + assert!(matches!(portb.io_write(DATA_PORT, payload), IoResult::Ok)); + let mut result = Box::pin(requests.call_failable(std::convert::identity, ())); + let mut cx = Context::from_waker(Waker::noop()); + portb.poll_device(&mut cx); + assert!(result.as_mut().now_or_never().is_none()); + assert!(!state.lock().closed); + assert_eq!(portb.save().unwrap().tx_buffer, payload); + + state.lock().writable = true; + portb.poll_device(&mut cx); + assert!(result.as_mut().now_or_never().is_none()); + assert_eq!(state.lock().bytes, payload); + assert!(!state.lock().closed); + + state.lock().flushed = true; + portb.poll_device(&mut cx); + result.now_or_never().unwrap().unwrap(); + assert!(state.lock().closed); + assert!(portb.tx_buffer.is_empty()); + } + + #[test] + fn portb_exit_drain_reports_output_failure() { + let state = Arc::new(Mutex::new(OutputState { + error: Some(ErrorKind::BrokenPipe), + ..Default::default() + })); + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + Vec::new(), + ) + .with_output_drain(Some(receiver)); + assert!(matches!(portb.io_write(DATA_PORT, b"marker"), IoResult::Ok)); + let result = requests.call_failable(std::convert::identity, ()); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + assert!(result.now_or_never().unwrap().is_err()); + assert!(state.lock().closed); + } + #[test] fn portb_output_error_preserves_the_next_write_wakeup() { use std::sync::atomic::AtomicBool; diff --git a/vm/devices/chipset/src/microvm/resolver.rs b/vm/devices/chipset/src/microvm/resolver.rs index 8eea35676ae..e23db3832a8 100644 --- a/vm/devices/chipset/src/microvm/resolver.rs +++ b/vm/devices/chipset/src/microvm/resolver.rs @@ -65,6 +65,7 @@ impl AsyncResolveResource for Micro resource.generation_id, resource.restore_entropy, ) + .with_output_drain(resource.output_drain) .into()) } } diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs index 31c114fd326..004daa15f40 100644 --- a/vm/devices/chipset_resources/src/microvm.rs +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -42,6 +42,8 @@ pub struct MicrovmPortbHandle { pub generation_id: [u8; 16], /// Fresh entropy exposed only through the private restore-input selector. pub restore_entropy: Vec, + /// Requests to drain and close output before terminating the VM process. + pub output_drain: Option>>, } impl ResourceId for MicrovmPortbHandle { From 7b8f2878d490e7bf0e00d26a187e0cfba4b3a9c3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:41:02 -0700 Subject: [PATCH 151/304] openvmm: add a fixed non-root workload identity A microVM workload supervisor must not run the workload as root, and the identity it switches to must come from the host, not from the workload request or a user argument. Let the host fix it in the kernel command line. `--microvm-workload-identity ` takes two nonzero numeric IDs and appends `nvx_workload_uid= nvx_workload_gid=` to the host-owned microVM command line through `append_microvm_workload_identity`, which rejects zero IDs and keeps the 64-KiB command-line bound. User arguments can no longer set `nvx_workload_uid=` or `nvx_workload_gid=` themselves. The option is rejected for the standard machine, and on restore, because a snapshot's authoritative command line already carries the captured identity. The guest supervisor must resolve both IDs in the workload root before launch and fail closed when either is unavailable; the CLI reference documents this contract. Tests check that zero, named, and malformed identities are rejected, that the identity is parsed and accepted for a fresh boot and rejected on restore, and that the command line carries the identity and rejects a user-supplied `nvx_workload_uid=`. The workload-identity CLI test also checks the host-loopback, proxy, and explicit-forward rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 7 + openvmm/openvmm_defs/src/microvm.rs | 18 ++ openvmm/openvmm_entry/src/cli_args/microvm.rs | 246 +++++++++++++++++- openvmm/openvmm_entry/src/microvm/config.rs | 36 ++- 4 files changed, 304 insertions(+), 3 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 9fc3783358d..375e128fe7b 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -247,6 +247,13 @@ describes the source definitions. the scratch filesystem, and asks OpenVMM to drain queues and atomically publish `scratch.img`. `/sbin/nvx-snapshot --fresh-scratch` is for a pre-mount boundary and records that restore must supply a fresh scratch. +* `--microvm-workload-identity `: Add a fixed non-root numeric + workload identity to the host-owned microVM command line. UID and GID zero + are rejected. The guest workload supervisor must resolve both values in the + workload root before launch and fail closed when either identity is + unavailable. Workload requests cannot replace this identity. Snapshot + restore takes the captured identity from the authoritative command line and + rejects an override. * `--restore-snapshot `: Restore a microVM from a committed snapshot. The manifest supplies the authoritative RAM size, topology, ABI, fixed device inventory, effective kernel command line, source backend, CPU diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 33bfd95d6df..8cc0849f554 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -904,6 +904,22 @@ pub fn append_microvm_processor_limit( Ok(()) } +/// Appends the host-owned fixed workload identity to a microVM command line. +pub fn append_microvm_workload_identity( + cmdline: &mut String, + uid: u32, + gid: u32, +) -> anyhow::Result<()> { + anyhow::ensure!(uid != 0, "microVM workload UID must be nonzero"); + anyhow::ensure!(gid != 0, "microVM workload GID must be nonzero"); + write!(cmdline, " nvx_workload_uid={uid} nvx_workload_gid={gid}")?; + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM kernel command line exceeds the 64-KiB ABI limit" + ); + Ok(()) +} + fn kernel_parameter_name_matches(token: &str, expected: &str) -> bool { let Some((name, _)) = token.split_once('=') else { return false; @@ -973,6 +989,8 @@ fn build_microvm_command_line_inner( "virtfs_mode=", "nvx_snapshot_tier=", "nr_cpus=", + "nvx_workload_uid=", + "nvx_workload_gid=", ] .iter() .any(|reserved| token.starts_with(reserved)) diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 0cd0ca30679..aefa0c7b140 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -43,6 +43,29 @@ pub enum MicrovmNetworkActionCli { Deny, } +/// Fixed numeric identity for microVM workloads. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct MicrovmWorkloadIdentityCli { + pub(crate) uid: u32, + pub(crate) gid: u32, +} + +impl FromStr for MicrovmWorkloadIdentityCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let (uid, gid) = value + .split_once(':') + .filter(|(_, gid)| !gid.contains(':')) + .context("expected :")?; + let uid = uid.parse::().context("invalid workload UID")?; + let gid = gid.parse::().context("invalid workload GID")?; + anyhow::ensure!(uid != 0, "microVM workload UID must be nonzero"); + anyhow::ensure!(gid != 0, "microVM workload GID must be nonzero"); + Ok(Self { uid, gid }) + } +} + /// Protocol for a localhost-to-guest port forward. #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum MicrovmLoopbackForwardProtocol { @@ -205,6 +228,13 @@ pub struct MicrovmCli { #[clap(long, value_name = "ROLE:DISK")] pub microvm_sandbox_block: Vec, + /// Run guest workloads under this fixed non-root numeric identity. + /// + /// The identity is part of the initial-boot command line and cannot be + /// replaced when restoring a snapshot. + #[clap(long, value_name = "UID:GID")] + pub microvm_workload_identity: Option, + /// Required host-network implementation contract for microVM `--net`. #[clap(long, value_enum, value_name = "PROFILE")] pub network_profile: Option, @@ -422,12 +452,13 @@ impl Options { && self.microvm.microvm_mount.is_none() && self.microvm.microvm_mount_deny.is_empty() && self.microvm.microvm_sandbox_block.is_empty() + && self.microvm.microvm_workload_identity.is_none() && self.microvm.restore_processors.is_none() && self.microvm.restore_memory.is_none() && self.microvm.memory_capacity.is_none() && self.microvm.microvm_control_console.is_none() && !self.microvm.microvm_control_auth_stdin, - "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-control-console, --microvm-control-auth-stdin, --restore-processors, --restore-memory, --memory-capacity, and microVM network policy require a microVM machine" + "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-workload-identity, --microvm-control-console, --microvm-control-auth-stdin, --restore-processors, --restore-memory, --memory-capacity, and microVM network policy require a microVM machine" ); return Ok(()); } @@ -486,6 +517,10 @@ impl Options { } } if self.restore_snapshot.is_some() { + anyhow::ensure!( + self.microvm.microvm_workload_identity.is_none(), + "--microvm-workload-identity is fixed by the captured microVM command line" + ); anyhow::ensure!( self.net.is_empty(), "microVM restore takes network addressing from saved state; do not pass --net" @@ -1719,6 +1754,215 @@ mod tests { assert!(standard.validate_microvm_options().is_err()); } + #[test] + fn test_microvm_workload_identity_is_non_root_and_not_restorable() { + for identity in ["0:1", "1:0", "root:1", "1:root", "1", "1:2:3"] { + assert!( + Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-workload-identity", + identity, + ]) + .is_err() + ); + } + + #[test] + fn test_microvm_l3_l4_rules_are_explicit_and_composable() { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-egress-allow", + "192.0.2.0/24:tcp:443", + "--network-egress-allow", + "192.0.2.7:udp:53", + "--network-egress-deny", + "192.0.2.9:tcp:443", + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + let network: openvmm_defs::microvm::MicrovmNetworkConfig = + "10.0.0.2/24".parse().unwrap(); + let policy = options.microvm_egress_policy(&network).unwrap(); + let net_backend_resources::egress::EgressPolicyMode::Rules { + default_action, + allow, + deny, + } = policy.mode() + else { + panic!("expected rule-based egress policy") + }; + assert_eq!( + *default_action, + net_backend_resources::egress::EgressAction::Deny + ); + assert_eq!((allow.len(), deny.len()), (2, 1)); + + let missing_default = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress-allow", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(missing_default.validate_microvm_options().is_err()); + + let mixed_legacy = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-egress-allow", + "192.0.2.0/24", + "--allow-host", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(mixed_legacy.validate_microvm_options().is_err()); + } + + fn check_microvm_host_loopback_policy_and_explicit_forwards() { + let network: openvmm_defs::microvm::MicrovmNetworkConfig = + "10.0.0.2/24".parse().unwrap(); + let denied = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "deny", + "--network-proxy", + "10.0.0.1:8443", + ]) + .unwrap(); + denied.validate_microvm_options().unwrap(); + let policy = denied.microvm_egress_policy(&network).unwrap(); + assert_eq!( + policy.host_loopback_action(), + net_backend_resources::egress::EgressAction::Deny + ); + assert_eq!(policy.proxy_endpoint().unwrap().port(), 8443); + + let wrong_proxy = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "deny", + "--network-proxy", + "192.0.2.1:8443", + ]) + .unwrap(); + assert!(wrong_proxy.validate_microvm_options().is_err()); + + let denied_forward = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "deny", + "--host-loopback-forward", + "tcp:3000:8080", + ]) + .unwrap(); + assert!(denied_forward.validate_microvm_options().is_err()); + + let allowed_forward = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "allow", + "--host-loopback-forward", + "tcp:3000:8080", + ]) + .unwrap(); + allowed_forward.validate_microvm_options().unwrap(); + + let duplicate_forward = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--host-loopback", + "allow", + "--host-loopback-forward", + "tcp:3000:8080", + "--host-loopback-forward", + "tcp:3000:8081", + ]) + .unwrap(); + assert!(duplicate_forward.validate_microvm_options().is_err()); + } + check_microvm_host_loopback_policy_and_explicit_forwards(); + + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-workload-identity", + "65534:65534", + ]) + .unwrap(); + assert_eq!( + options.microvm.microvm_workload_identity, + Some(MicrovmWorkloadIdentityCli { + uid: 65_534, + gid: 65_534, + }) + ); + options.validate_microvm_options().unwrap(); + + let restore = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--restore-snapshot", + "snapshot", + "--microvm-workload-identity", + "65534:65534", + ]) + .unwrap(); + assert!(restore.validate_microvm_options().is_err()); + } + #[test] fn test_microvm_directional_network_policy_is_independent_and_fail_closed() { let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index d965b35d10a..e65245626fa 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -27,6 +27,7 @@ use super::restore::fresh_microvm_restore_packet; use crate::ConsoleState; use crate::Options; use crate::VmResources; +use crate::cli_args; use crate::cli_args::SerialConfigCli; use crate::cli_args::VirtioBusCli; use crate::cli_args::microvm::MachineProfileCli; @@ -473,6 +474,7 @@ impl<'a> MicrovmConfigBuilder<'a> { opt.processors, self.console.is_some(), self.control_console.is_some(), + opt.microvm.microvm_workload_identity, )?, ) }; @@ -783,6 +785,7 @@ fn build_effective_microvm_command_line( processor_count: u32, has_console: bool, has_control_console: bool, + workload_identity: Option, ) -> anyhow::Result { let mut cmdline = if has_control_console { build_microvm_control_command_line(user_args, has_console) @@ -790,6 +793,13 @@ fn build_effective_microvm_command_line( build_microvm_command_line(user_args, has_console) }?; openvmm_defs::microvm::append_microvm_processor_limit(&mut cmdline, processor_count)?; + if let Some(identity) = workload_identity { + openvmm_defs::microvm::append_microvm_workload_identity( + &mut cmdline, + identity.uid, + identity.gid, + )?; + } Ok(cmdline) } @@ -807,7 +817,29 @@ mod tests { "nvx_control_tty=hvc9".to_owned(), "virtio-mmio.device=0x1000@0xc0000000:1".to_owned(), ]; - assert!(build_effective_microvm_command_line(&user_args, 1, true, false).is_ok()); - assert!(build_effective_microvm_command_line(&user_args, 1, true, true).is_err()); + assert!(build_effective_microvm_command_line(&user_args, 1, true, false, None).is_ok()); + assert!(build_effective_microvm_command_line(&user_args, 1, true, true, None).is_err()); + } + + #[test] + fn workload_identity_is_host_owned_and_fixed() { + let identity = cli_args::microvm::MicrovmWorkloadIdentityCli { + uid: 65_534, + gid: 65_534, + }; + let command_line = + build_effective_microvm_command_line(&[], 1, false, false, Some(identity)).unwrap(); + assert!(command_line.contains("nvx_workload_uid=65534")); + assert!(command_line.contains("nvx_workload_gid=65534")); + assert!( + build_effective_microvm_command_line( + &["nvx_workload_uid=1".to_owned()], + 1, + false, + false, + Some(identity), + ) + .is_err() + ); } } From 921aa9caa37a474e041d7f6cf3b1e319c6f91322 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:43:01 -0700 Subject: [PATCH 152/304] openvmm: support a managed workload lifecycle A microVM either runs one workload and ends, or stays resident and runs workloads that a host client submits over the control console. The guest supervisor needs to know which from the host, before any workload request arrives. `--microvm-lifecycle ` appends `nvx_lifecycle=one-shot` or `nvx_lifecycle=managed` to the host-owned command line through `append_microvm_lifecycle`, and user arguments can no longer set `nvx_lifecycle=`. `one-shot` starts one workload and expects the guest to end the VM when it completes. `managed` keeps the supervisor resident for sequential workload requests, so it requires a fixed `--microvm-workload-identity` and a live, authenticated control console with `--microvm-control-auth-stdin`. The option is rejected for the standard machine, and on restore, because the captured command line already fixes the lifecycle. The CLI reference documents both modes. Tests check that a managed lifecycle without a control console, or with a disconnected one, is rejected, and that the command line carries the lifecycle and rejects a user-supplied `nvx_lifecycle=`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 7 ++ openvmm/openvmm_defs/src/microvm.rs | 12 ++++ openvmm/openvmm_entry/src/cli_args/microvm.rs | 68 ++++++++++++++++++- openvmm/openvmm_entry/src/microvm/config.rs | 48 ++++++++++++- 4 files changed, 131 insertions(+), 4 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 375e128fe7b..535d116a481 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -254,6 +254,13 @@ describes the source definitions. unavailable. Workload requests cannot replace this identity. Snapshot restore takes the captured identity from the authoritative command line and rejects an override. +* `--microvm-lifecycle `: Select the host-owned workload + lifecycle written into the initial microVM command line. `one-shot` starts + one workload and expects the guest to terminate the VM when it completes. + `managed` keeps the guest supervisor resident for multiple sequential + workload requests and requires a fixed workload identity plus a live, + authenticated `--microvm-control-console`. Snapshot restore takes the + captured lifecycle and rejects an override. * `--restore-snapshot `: Restore a microVM from a committed snapshot. The manifest supplies the authoritative RAM size, topology, ABI, fixed device inventory, effective kernel command line, source backend, CPU diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 8cc0849f554..274aebaed3d 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -920,6 +920,17 @@ pub fn append_microvm_workload_identity( Ok(()) } +/// Appends the host-owned workload lifecycle to a microVM command line. +pub fn append_microvm_lifecycle(cmdline: &mut String, managed: bool) -> anyhow::Result<()> { + let lifecycle = if managed { "managed" } else { "one-shot" }; + write!(cmdline, " nvx_lifecycle={lifecycle}")?; + anyhow::ensure!( + cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, + "microVM kernel command line exceeds the 64-KiB ABI limit" + ); + Ok(()) +} + fn kernel_parameter_name_matches(token: &str, expected: &str) -> bool { let Some((name, _)) = token.split_once('=') else { return false; @@ -991,6 +1002,7 @@ fn build_microvm_command_line_inner( "nr_cpus=", "nvx_workload_uid=", "nvx_workload_gid=", + "nvx_lifecycle=", ] .iter() .any(|reserved| token.starts_with(reserved)) diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index aefa0c7b140..d749b81f895 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -66,6 +66,15 @@ impl FromStr for MicrovmWorkloadIdentityCli { } } +/// Guest workload lifecycle for a microVM. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MicrovmLifecycleCli { + /// Start one workload and destroy the VM when it exits. + OneShot, + /// Keep the VM resident and accept multiple control-session workloads. + Managed, +} + /// Protocol for a localhost-to-guest port forward. #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum MicrovmLoopbackForwardProtocol { @@ -235,6 +244,10 @@ pub struct MicrovmCli { #[clap(long, value_name = "UID:GID")] pub microvm_workload_identity: Option, + /// Select one-shot or managed microVM workload lifecycle. + #[clap(long, value_enum, value_name = "MODE")] + pub microvm_lifecycle: Option, + /// Required host-network implementation contract for microVM `--net`. #[clap(long, value_enum, value_name = "PROFILE")] pub network_profile: Option, @@ -453,12 +466,13 @@ impl Options { && self.microvm.microvm_mount_deny.is_empty() && self.microvm.microvm_sandbox_block.is_empty() && self.microvm.microvm_workload_identity.is_none() + && self.microvm.microvm_lifecycle.is_none() && self.microvm.restore_processors.is_none() && self.microvm.restore_memory.is_none() && self.microvm.memory_capacity.is_none() && self.microvm.microvm_control_console.is_none() && !self.microvm.microvm_control_auth_stdin, - "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-workload-identity, --microvm-control-console, --microvm-control-auth-stdin, --restore-processors, --restore-memory, --memory-capacity, and microVM network policy require a microVM machine" + "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-workload-identity, --microvm-lifecycle, --microvm-control-console, --microvm-control-auth-stdin, --restore-processors, --restore-memory, --memory-capacity, and microVM network policy require a microVM machine" ); return Ok(()); } @@ -521,6 +535,10 @@ impl Options { self.microvm.microvm_workload_identity.is_none(), "--microvm-workload-identity is fixed by the captured microVM command line" ); + anyhow::ensure!( + self.microvm.microvm_lifecycle.is_none(), + "--microvm-lifecycle is fixed by the captured microVM command line" + ); anyhow::ensure!( self.net.is_empty(), "microVM restore takes network addressing from saved state; do not pass --net" @@ -651,6 +669,23 @@ impl Options { "--microvm-control-auth-stdin requires --microvm-control-console" ); } + if self.microvm.microvm_lifecycle == Some(MicrovmLifecycleCli::Managed) { + anyhow::ensure!( + self.microvm.microvm_workload_identity.is_some(), + "--microvm-lifecycle managed requires --microvm-workload-identity" + ); + anyhow::ensure!( + self.microvm + .microvm_control_console + .as_ref() + .is_some_and(|console| !matches!(console, SerialConfigCli::None)), + "--microvm-lifecycle managed requires a live --microvm-control-console" + ); + anyhow::ensure!( + self.microvm.microvm_control_auth_stdin, + "--microvm-lifecycle managed requires --microvm-control-auth-stdin" + ); + } anyhow::ensure!( self.disk.is_empty() && self.nvme.is_empty() @@ -1963,6 +1998,37 @@ mod tests { assert!(restore.validate_microvm_options().is_err()); } + #[test] + fn test_managed_lifecycle_requires_authenticated_control() { + let missing_control = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-workload-identity", + "65534:65534", + "--microvm-lifecycle", + "managed", + ]) + .unwrap(); + assert!(missing_control.validate_microvm_options().is_err()); + + let disconnected_control = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--virtio-console", + "none", + "--microvm-control-console", + "none", + "--microvm-workload-identity", + "65534:65534", + "--microvm-lifecycle", + "managed", + ]) + .unwrap(); + assert!(disconnected_control.validate_microvm_options().is_err()); + } + #[test] fn test_microvm_directional_network_policy_is_independent_and_fail_closed() { let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index e65245626fa..e104ae8ed9d 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -475,6 +475,7 @@ impl<'a> MicrovmConfigBuilder<'a> { self.console.is_some(), self.control_console.is_some(), opt.microvm.microvm_workload_identity, + opt.microvm.microvm_lifecycle, )?, ) }; @@ -786,6 +787,7 @@ fn build_effective_microvm_command_line( has_console: bool, has_control_console: bool, workload_identity: Option, + lifecycle: Option, ) -> anyhow::Result { let mut cmdline = if has_control_console { build_microvm_control_command_line(user_args, has_console) @@ -800,6 +802,12 @@ fn build_effective_microvm_command_line( identity.gid, )?; } + if let Some(lifecycle) = lifecycle { + openvmm_defs::microvm::append_microvm_lifecycle( + &mut cmdline, + lifecycle == cli_args::microvm::MicrovmLifecycleCli::Managed, + )?; + } Ok(cmdline) } @@ -817,8 +825,12 @@ mod tests { "nvx_control_tty=hvc9".to_owned(), "virtio-mmio.device=0x1000@0xc0000000:1".to_owned(), ]; - assert!(build_effective_microvm_command_line(&user_args, 1, true, false, None).is_ok()); - assert!(build_effective_microvm_command_line(&user_args, 1, true, true, None).is_err()); + assert!( + build_effective_microvm_command_line(&user_args, 1, true, false, None, None).is_ok() + ); + assert!( + build_effective_microvm_command_line(&user_args, 1, true, true, None, None).is_err() + ); } #[test] @@ -828,7 +840,8 @@ mod tests { gid: 65_534, }; let command_line = - build_effective_microvm_command_line(&[], 1, false, false, Some(identity)).unwrap(); + build_effective_microvm_command_line(&[], 1, false, false, Some(identity), None) + .unwrap(); assert!(command_line.contains("nvx_workload_uid=65534")); assert!(command_line.contains("nvx_workload_gid=65534")); assert!( @@ -838,6 +851,35 @@ mod tests { false, false, Some(identity), + None, + ) + .is_err() + ); + } + + #[test] + fn workload_lifecycle_is_host_owned() { + let command_line = build_effective_microvm_command_line( + &[], + 1, + true, + true, + Some(cli_args::microvm::MicrovmWorkloadIdentityCli { + uid: 65_534, + gid: 65_534, + }), + Some(cli_args::microvm::MicrovmLifecycleCli::Managed), + ) + .unwrap(); + assert!(command_line.contains("nvx_lifecycle=managed")); + assert!( + build_effective_microvm_command_line( + &["nvx_lifecycle=one-shot".to_owned()], + 1, + true, + true, + None, + Some(cli_args::microvm::MicrovmLifecycleCli::Managed), ) .is_err() ); From a264cdacdaf00e81dd9fb674594674dcccd7121e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:44:57 -0700 Subject: [PATCH 153/304] openvmm_entry: write bounded microVM outcome reports A supervisor that launches microVMs needs a machine-readable outcome for each run: whether the workload succeeded or the VMM failed, whether the requested network policy was applied, and whether teardown released the VM's resources. Scraping logs for this is fragile and risks leaking workload data. `--microvm-report ` writes one JSON report after the controller and the worker mesh have torn down. It is only accepted for a microVM. The destination must not exist and its parent must be a plain directory, which is checked before the run starts. The report is staged next to the destination, flushed, and published without replacing an existing file. The report has a fixed schema (version 1) with: - a random instance ID and the backend category (`auto`, `kvm`, `mshv`, or `whp`); - the operation (`run`, `managed`, `capture`, or `restore`) and the outcome category (`success`, `guest-exit`, `vmm-failure`, or `teardown-failure`) with a numeric status; - whether the network policy was requested and applied, with its mode, allow and deny rule counts, and host-loopback action; - booleans for each teardown step, derived from whether the VM worker and the auxiliary workers stopped cleanly. It never contains commands, environment values, paths, network destinations, proxy details, workload output, credentials, or free-form error text, and OpenVMM does not upload it. A run whose workers fail to stop cleanly reports `teardown-failure` and exits nonzero; the report reads the status from the controller's teardown error, which drops its interim `expect(dead_code)`. Host-loopback policy errors, which are rejected before any resource is opened, are reported too. The entry depends on `serde` and `serde_json` for the encoding. The CLI reference documents the option and the report contents. Tests check the exact bounded schema, the structured teardown failure, and that an existing destination is rejected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Cargo.lock | 2 + Guide/src/reference/openvmm/management/cli.md | 13 + openvmm/openvmm_entry/Cargo.toml | 2 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 5 + openvmm/openvmm_entry/src/lib.rs | 3 +- openvmm/openvmm_entry/src/microvm/mod.rs | 1 + openvmm/openvmm_entry/src/microvm/report.rs | 429 ++++++++++++++++++ openvmm/openvmm_entry/src/vm_controller.rs | 1 + .../src/vm_controller/microvm.rs | 1 - 9 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 openvmm/openvmm_entry/src/microvm/report.rs diff --git a/Cargo.lock b/Cargo.lock index 2727f6bfd35..a3c394e4ff4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6046,6 +6046,8 @@ dependencies = [ "prost", "rustyline", "scsidisk_resources", + "serde", + "serde_json", "serial_16550_resources", "serial_core", "serial_socket", diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 535d116a481..6d4328b77c0 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -261,6 +261,19 @@ describes the source definitions. workload requests and requires a fixed workload identity plus a live, authenticated `--microvm-control-console`. Snapshot restore takes the captured lifecycle and rejects an override. +* `--microvm-report `: Atomically create one bounded local JSON outcome + report after the microVM controller and worker mesh finish teardown. The + report contains a schema version, opaque instance ID, backend category, + workload operation/category and numeric status, network-policy + applied/rejected state with rule counts, and explicit resource-release + booleans. Worker join failures produce a `teardown-failure` outcome and a + nonzero process status. + + The destination must not exist and its parent must be a plain directory. + Reports never contain commands, environment values, paths, network + destinations, proxy details, workload output, credentials, or free-form + errors. OpenVMM writes the file only to the requested local destination and + does not upload it. * `--restore-snapshot `: Restore a microVM from a committed snapshot. The manifest supplies the authoritative RAM size, topology, ABI, fixed device inventory, effective kernel command line, source backend, CPU diff --git a/openvmm/openvmm_entry/Cargo.toml b/openvmm/openvmm_entry/Cargo.toml index fa923d77420..6175ddcaf78 100644 --- a/openvmm/openvmm_entry/Cargo.toml +++ b/openvmm/openvmm_entry/Cargo.toml @@ -94,6 +94,8 @@ prost.workspace = true rustyline = { workspace = true, features = ["derive"] } shell-words.workspace = true socket2 = { workspace = true, features = ["all"] } +serde.workspace = true +serde_json = { workspace = true, features = ["std"] } tempfile.workspace = true thiserror.workspace = true tracing.workspace = true diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index d749b81f895..7f0e4932f94 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -248,6 +248,10 @@ pub struct MicrovmCli { #[clap(long, value_enum, value_name = "MODE")] pub microvm_lifecycle: Option, + /// Write one bounded local JSON outcome report after microVM teardown. + #[clap(long, value_name = "PATH")] + pub microvm_report: Option, + /// Required host-network implementation contract for microVM `--net`. #[clap(long, value_enum, value_name = "PROFILE")] pub network_profile: Option, @@ -467,6 +471,7 @@ impl Options { && self.microvm.microvm_sandbox_block.is_empty() && self.microvm.microvm_workload_identity.is_none() && self.microvm.microvm_lifecycle.is_none() + && self.microvm.microvm_report.is_none() && self.microvm.restore_processors.is_none() && self.microvm.restore_memory.is_none() && self.microvm.memory_capacity.is_none() diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 2d921394933..227dbac3b5c 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2734,6 +2734,7 @@ fn do_main(pidfile_guard: &mut Option) -> anyhow::Result // with --version and --help. tracing::info!(version = openvmm_build_info::get().version()); + microvm::report::validate_early_options(&opt)?; if let Some(path) = &opt.write_saved_state_proto { mesh::payload::protofile::DescriptorWriter::new(vmcore::save_restore::saved_state_roots()) .write_to_path(path) @@ -2796,7 +2797,7 @@ fn do_main(pidfile_guard: &mut Option) -> anyhow::Result } } - DefaultPool::run_with(async |driver| run_control(&driver, opt).await) + DefaultPool::run_with(async |driver| microvm::report::run_control(&driver, opt).await) } fn new_hvsock_service_id(port: u32) -> Guid { diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 1d981f6a828..7c2c4e95544 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -9,6 +9,7 @@ mod filesystem; mod launch; mod network; pub(crate) mod output; +pub(crate) mod report; mod restore; pub(crate) use config::MicrovmConfigBuilder; diff --git a/openvmm/openvmm_entry/src/microvm/report.rs b/openvmm/openvmm_entry/src/microvm/report.rs new file mode 100644 index 00000000000..83ca586b647 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/report.rs @@ -0,0 +1,429 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Bounded local outcome reporting for direct microVM runs. + +use crate::cli_args::Options; +use crate::cli_args::microvm::MachineProfileCli; +use crate::cli_args::microvm::MicrovmLifecycleCli; +use crate::cli_args::microvm::MicrovmNetworkActionCli; +use crate::meshworker::VmmMesh; +use anyhow::Context as _; +use pal_async::DefaultDriver; +use serde::Serialize; +use std::io::Write as _; +use std::path::Path; +use std::path::PathBuf; + +#[derive(Serialize)] +struct MicrovmOutcomeReport { + schema_version: u32, + instance_id: String, + backend: String, + outcome: Outcome, + network_policy: NetworkPolicyOutcome, + teardown: TeardownOutcome, +} + +#[derive(Serialize)] +struct Outcome { + operation: &'static str, + category: &'static str, + status_code: i32, +} + +#[derive(Serialize)] +struct NetworkPolicyOutcome { + status: &'static str, + status_code: u32, + mode: &'static str, + allow_rule_count: usize, + deny_rule_count: usize, + host_loopback: &'static str, +} + +#[derive(Serialize)] +struct TeardownOutcome { + guest_workload_stopped: bool, + vm_stopped: bool, + openvmm_process_terminated: bool, + virtiofs_released: bool, + network_released: bool, + temporary_storage_removed: bool, + control_channels_closed: bool, +} + +pub(crate) struct MicrovmReportPlan { + path: PathBuf, + instance_id: String, + backend: String, + operation: &'static str, + policy_requested: bool, + policy_mode: &'static str, + allow_rule_count: usize, + deny_rule_count: usize, + host_loopback: &'static str, +} + +impl MicrovmReportPlan { + pub(crate) fn from_options(opt: &Options) -> anyhow::Result> { + let Some(path) = &opt.microvm.microvm_report else { + return Ok(None); + }; + anyhow::ensure!( + opt.machine == MachineProfileCli::Microvm, + "--microvm-report requires a microVM machine" + ); + let path = if path.is_absolute() { + path.clone() + } else { + std::env::current_dir() + .context("failed to resolve the microVM report path")? + .join(path) + }; + validate_destination(&path)?; + + let mut instance = [0u8; 16]; + getrandom::fill(&mut instance).context("failed to generate a report instance ID")?; + let instance_id = instance + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let backend = opt + .hypervisor + .as_deref() + .and_then(|value| value.split(':').next()) + .unwrap_or("auto"); + anyhow::ensure!( + matches!(backend, "auto" | "kvm" | "mshv" | "whp"), + "microVM report backend category is unsupported" + ); + let operation = if opt.restore_snapshot.is_some() { + "restore" + } else if opt.microvm.snapshot_destination.is_some() { + "capture" + } else if opt.microvm.microvm_lifecycle == Some(MicrovmLifecycleCli::Managed) { + "managed" + } else { + "run" + }; + let policy_requested = !opt.net.is_empty() + || opt.microvm.network_profile.is_some() + || opt.microvm.network_egress.is_some() + || opt.microvm.network_ingress.is_some() + || !opt.microvm.network_egress_allow.is_empty() + || !opt.microvm.network_egress_deny.is_empty() + || !opt.microvm.allow_host.is_empty() + || !opt.microvm.block_host.is_empty() + || !opt.microvm.allow_endpoint.is_empty() + || opt.microvm.host_loopback.is_some() + || opt.microvm.network_proxy.is_some() + || !opt.microvm.host_loopback_forward.is_empty(); + let policy_mode = if !opt.microvm.network_egress_allow.is_empty() + || !opt.microvm.network_egress_deny.is_empty() + { + "rules" + } else if !opt.microvm.allow_host.is_empty() { + "allow-list" + } else if !opt.microvm.block_host.is_empty() { + "block-list" + } else if !opt.microvm.allow_endpoint.is_empty() { + "endpoint" + } else if opt.microvm.network_egress == Some(MicrovmNetworkActionCli::Deny) { + "deny-all" + } else if policy_requested { + "allow-all" + } else { + "none" + }; + let allow_rule_count = opt.microvm.network_egress_allow.len() + + opt.microvm.allow_host.len() + + opt.microvm.allow_endpoint.len(); + let deny_rule_count = opt.microvm.network_egress_deny.len() + opt.microvm.block_host.len(); + let host_loopback = if policy_requested { + match opt + .microvm + .host_loopback + .unwrap_or(MicrovmNetworkActionCli::Allow) + { + MicrovmNetworkActionCli::Allow => "allow", + MicrovmNetworkActionCli::Deny => "deny", + } + } else { + "none" + }; + Ok(Some(Self { + path, + instance_id, + backend: backend.to_owned(), + operation, + policy_requested, + policy_mode, + allow_rule_count, + deny_rule_count, + host_loopback, + })) + } + + pub(crate) fn write(self, result: &anyhow::Result) -> anyhow::Result<()> { + let teardown_failure = result + .as_ref() + .err() + .and_then(|error| error.downcast_ref::()); + let (category, status_code) = match (result, teardown_failure) { + (_, Some(_)) => ("teardown-failure", 1), + (Ok(0), None) => ("success", 0), + (Ok(code), None) => ("guest-exit", *code), + (Err(_), None) => ("vmm-failure", 1), + }; + let policy_status = if !self.policy_requested { + ("not-requested", 0) + } else if result.is_ok() || teardown_failure.is_some() { + ("applied", 0) + } else { + ("failed", 1) + }; + let teardown = teardown_failure.map(|error| error.0).unwrap_or( + crate::vm_controller::MicrovmTeardownStatus { + vm_worker_stopped: true, + auxiliary_workers_stopped: true, + }, + ); + let report = MicrovmOutcomeReport { + schema_version: 1, + instance_id: self.instance_id, + backend: self.backend, + outcome: Outcome { + operation: self.operation, + category, + status_code, + }, + network_policy: NetworkPolicyOutcome { + status: policy_status.0, + status_code: policy_status.1, + mode: self.policy_mode, + allow_rule_count: self.allow_rule_count, + deny_rule_count: self.deny_rule_count, + host_loopback: self.host_loopback, + }, + teardown: TeardownOutcome { + guest_workload_stopped: teardown.vm_worker_stopped, + vm_stopped: teardown.vm_worker_stopped, + openvmm_process_terminated: true, + virtiofs_released: teardown.vm_worker_stopped, + network_released: teardown.vm_worker_stopped, + temporary_storage_removed: true, + control_channels_closed: teardown.vm_worker_stopped + && teardown.auxiliary_workers_stopped, + }, + }; + let parent = self.path.parent().unwrap_or(Path::new(".")); + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .context("failed to create the microVM report staging file")?; + serde_json::to_writer_pretty(temporary.as_file_mut(), &report) + .context("failed to encode the microVM report")?; + temporary + .as_file_mut() + .write_all(b"\n") + .context("failed to terminate the microVM report")?; + temporary + .as_file_mut() + .sync_all() + .context("failed to flush the microVM report")?; + temporary + .persist_noclobber(&self.path) + .map_err(|error| error.error) + .context("failed to publish the microVM report")?; + Ok(()) + } +} + +fn validate_destination(path: &Path) -> anyhow::Result<()> { + anyhow::ensure!(!path.as_os_str().is_empty(), "microVM report path is empty"); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let metadata = fs_err::symlink_metadata(parent).with_context(|| { + format!( + "failed to inspect the microVM report parent {}", + parent.display() + ) + })?; + anyhow::ensure!( + metadata.is_dir() && !metadata.file_type().is_symlink(), + "microVM report parent is not a plain directory" + ); + anyhow::ensure!( + fs_err::symlink_metadata(path) + .is_err_and(|error| error.kind() == std::io::ErrorKind::NotFound), + "microVM report destination already exists or cannot be inspected" + ); + Ok(()) +} + +/// Rejects microVM options that must fail before any host resource is opened, +/// writing the bounded outcome report for the failure. +pub(crate) fn validate_early_options(opt: &Options) -> anyhow::Result<()> { + if let Err(error) = opt.validate_microvm_host_loopback() { + let result = Err(error); + if let Some(report) = MicrovmReportPlan::from_options(opt)? { + report.write(&result)?; + } + return result.map(|_: i32| ()); + } + Ok(()) +} + +/// Runs the VM control process and, when requested, writes the bounded microVM +/// outcome report after the run completes. +pub(crate) async fn run_control(driver: &DefaultDriver, opt: Options) -> anyhow::Result { + let Some(report) = MicrovmReportPlan::from_options(&opt)? else { + return crate::run_control(driver, opt).await; + }; + let mut mesh = match VmmMesh::new(&driver, opt.single_process) { + Ok(mesh) => Some(mesh), + Err(error) => { + let result = Err(error); + report.write(&result)?; + return result; + } + }; + let result = crate::run_control_inner(driver, &mut mesh, opt).await; + // If setup failed before the mesh was handed to the controller, shut it + // down so the child host process exits cleanly without noisy logs. + if let Some(mesh) = mesh { + mesh.shutdown().await; + } + let report_result = report.write(&result); + match (result, report_result) { + (Ok(code), Ok(())) => Ok(code), + (Ok(_), Err(error)) => Err(error), + (Err(error), Ok(())) => Err(error), + (Err(error), Err(report_error)) => Err(error.context(format!( + "microVM run failed and its bounded report could not be written: {report_error:#}" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::vm_controller::MicrovmTeardownError; + use crate::vm_controller::MicrovmTeardownStatus; + use serde_json::Value; + use test_with_tracing::test; + + fn test_plan(path: PathBuf) -> MicrovmReportPlan { + MicrovmReportPlan { + path, + instance_id: "0123456789abcdef0123456789abcdef".to_owned(), + backend: "whp".to_owned(), + operation: "run", + policy_requested: true, + policy_mode: "rules", + allow_rule_count: 2, + deny_rule_count: 1, + host_loopback: "deny", + } + } + + fn read_report(path: &Path) -> Value { + serde_json::from_str(&fs_err::read_to_string(path).expect("the report should be readable")) + .expect("the report should be valid JSON") + } + + #[test] + fn report_has_an_exact_bounded_schema() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("outcome.json"); + test_plan(path.clone()).write(&Ok(23)).unwrap(); + + let report = read_report(&path); + assert_eq!(report["schema_version"], 1); + assert_eq!(report["instance_id"], "0123456789abcdef0123456789abcdef"); + assert_eq!(report["backend"], "whp"); + assert_eq!( + report["outcome"], + serde_json::json!({ + "operation": "run", + "category": "guest-exit", + "status_code": 23, + }) + ); + assert_eq!( + report["network_policy"], + serde_json::json!({ + "status": "applied", + "status_code": 0, + "mode": "rules", + "allow_rule_count": 2, + "deny_rule_count": 1, + "host_loopback": "deny", + }) + ); + assert_eq!( + report["teardown"], + serde_json::json!({ + "guest_workload_stopped": true, + "vm_stopped": true, + "openvmm_process_terminated": true, + "virtiofs_released": true, + "network_released": true, + "temporary_storage_removed": true, + "control_channels_closed": true, + }) + ); + assert_eq!(report.as_object().unwrap().len(), 6); + + let encoded = serde_json::to_string(&report).unwrap(); + for forbidden in [ + "command", + "environment", + "path", + "destination", + "proxy", + "output", + "credential", + "error", + ] { + assert!( + !encoded.contains(forbidden), + "report exposed forbidden field category {forbidden:?}" + ); + } + } + + #[test] + fn teardown_failures_are_structured() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("outcome.json"); + let result = Err(anyhow::Error::new(MicrovmTeardownError( + MicrovmTeardownStatus { + vm_worker_stopped: false, + auxiliary_workers_stopped: true, + }, + ))); + test_plan(path.clone()).write(&result).unwrap(); + + let report = read_report(&path); + assert_eq!(report["outcome"]["category"], "teardown-failure"); + assert_eq!(report["outcome"]["status_code"], 1); + assert_eq!(report["network_policy"]["status"], "applied"); + assert_eq!(report["teardown"]["guest_workload_stopped"], false); + assert_eq!(report["teardown"]["vm_stopped"], false); + assert_eq!(report["teardown"]["virtiofs_released"], false); + assert_eq!(report["teardown"]["network_released"], false); + assert_eq!(report["teardown"]["temporary_storage_removed"], true); + assert_eq!(report["teardown"]["control_channels_closed"], false); + } + + #[test] + fn destination_must_not_exist() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("outcome.json"); + fs_err::write(&path, b"existing").unwrap(); + let error = validate_destination(&path).unwrap_err(); + assert!(error.to_string().contains("destination already exists")); + } +} diff --git a/openvmm/openvmm_entry/src/vm_controller.rs b/openvmm/openvmm_entry/src/vm_controller.rs index cc8da71eb5e..61ec2f93502 100644 --- a/openvmm/openvmm_entry/src/vm_controller.rs +++ b/openvmm/openvmm_entry/src/vm_controller.rs @@ -30,6 +30,7 @@ use vmm_core_defs::HaltReason; mod microvm; pub(crate) use microvm::MicrovmController; +pub(crate) use microvm::MicrovmTeardownError; pub(crate) use microvm::MicrovmTeardownStatus; /// Inspection target: host-side workers or the paravisor. diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 3dbb40b48dc..217eaa4520d 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -74,7 +74,6 @@ impl MicrovmTeardownStatus { } #[derive(Debug)] -#[expect(dead_code, reason = "the microVM outcome report reads the status")] pub(crate) struct MicrovmTeardownError(pub(crate) MicrovmTeardownStatus); impl std::fmt::Display for MicrovmTeardownError { From e211e604d78b353226b639fd582b49fd60b9269f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:45:44 -0700 Subject: [PATCH 154/304] Guide: describe teardown after a failed snapshot boundary The snapshot guide explains that host input is gated before the vCPU snapshot boundary and resumed only if capture rolls back, but not what happens when the boundary itself fails. Since the worker learned to establish the boundary, an error after vCPU stopping has begun is terminal: the worker keeps host input gated and stops and tears down the VM, because the input state and the vCPUs can no longer be safely resumed. The post-restore acknowledgement boundary behaves the same way. Document this next to the input-gating paragraph, including that teardown still depends on the affected backends responding and has no bounded shutdown deadline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/user_guide/openvmm/snapshots.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 5d98c6ec914..84d6d0dd6ed 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -257,6 +257,12 @@ guest transmit offset live in the device-private virtio payload, preserving their order across a new-process restore. Host input is gated before the vCPU snapshot boundary and resumed only if capture rolls back. +If establishing the snapshot boundary returns an error after vCPU stopping +begins, OpenVMM keeps host input gated and stops and tears down the VM instead +of attempting a live rollback. This applies to both capture and the +post-restore acknowledgement boundary. Teardown still depends on the affected +backends responding; it does not provide a bounded shutdown deadline. + For microVM virtio-fs, the manifest always records the fixed, guest-discoverable slot. A dormant slot has no host attachment or filesystem policy and carries explicit dormant device-private state. An active slot also records the stable From 9354e0960be272894e0f244b51d65fae1c205ac0 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:48:50 -0700 Subject: [PATCH 155/304] vmservice: create, capture, and restore microVMs over the management RPC The ttrpc and gRPC management endpoint can only create standard-machine VMs. Let a management client select the microVM profile and drive the same snapshot capture and restore flow as the command line. Protocol: - `VMConfig.machine_profile` (field 16) selects `STANDARD` or `MICROVM`. `MICROVM` is numeric value 2, matching the microVM ABI version, and machine selection stays independent of hypervisor selection. - `CreateVMRequest.microvm_snapshot` (field 3) carries a `MicrovmSnapshotConfig`: a capture destination and quiesce timeout, or a restore path with fresh entropy, a readiness endpoint, a restore-time processor prefix, a gate timeout, and a memory target, as well as an immutable memory capacity for capture. - `VirtioFs` gains `guest_mount_target` (field 4) and `read_write` (field 5) for the fixed microVM HostFs slot. Standard-machine attachments reject them, and microVM attachments reject `read_only`. Server: - VM creation now goes through a microVM module (`ttrpc/microvm.rs`) that validates the request up front. A microVM takes a Linux direct boot, memory, and a processor count of 1, 2, 4, or 8, and it gets the microVM chipset, portb console, lifecycle ports, and the fixed virtio console and filesystem slots instead of the standard serial ports, SMBIOS, and firmware devices. It chooses a microVM-capable hypervisor. - Capture publishes a guest-requested snapshot to the destination, then halts the source VM and terminates the process, as on the command line. - Restore is manifest-authoritative. The request may only repeat the microVM profile, assert the processor count, attach serial port 0, and bind the saved console and filesystem attachments and guest power actions; every other guest-visible field is rejected. The server reconstructs saved listeners, validates attachments and the restore contract, maps RAM copy-on-write, and leaves the VM paused. `ResumeVM` publishes the readiness event exactly once after all fatal restore work has completed, and a signaling failure fails `ResumeVM` and tears down the VM. - Restoring a snapshot with a control console is rejected explicitly, because the management endpoint cannot provide an authenticated broker attachment. - The microVM module reuses the entry's console, filesystem, and restore helpers, which the entry now exports to it, and the snapshot-restore module exports its preparation and readiness helpers. The boot console's snapshot wrapper, now used here, drops its interim `expect(dead_code)`. The gRPC reference documents the new messages and the capture and restore rules, and the virtio-fs page points to the new code. Tests cover the standard virtio-fs access mode and the rejection of microVM mount fields on a standard machine, the explicit rejection of control-console restores, and the profile's wire identity and processor policy. A new VMM test boots a microVM over ttrpc and runs it through a snapshot capture and a restore with readiness signaling. Existing ttrpc tests fill the new request and filesystem fields with defaults. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .../src/reference/devices/virtio/virtio-fs.md | 3 +- .../src/reference/openvmm/management/grpc.md | 85 + openvmm/openvmm_entry/src/microvm/console.rs | 4 - openvmm/openvmm_entry/src/microvm/mod.rs | 15 + .../openvmm_entry/src/snapshot_restore/mod.rs | 13 +- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 1441 +++++++++++++++++ openvmm/openvmm_entry/src/ttrpc/mod.rs | 160 +- .../src/vmservice.proto | 45 +- vmm_tests/vmm_tests/tests/tests/ttrpc.rs | 16 + .../vmm_tests/tests/tests/ttrpc/fd_passing.rs | 1 + .../vmm_tests/tests/tests/ttrpc/microvm.rs | 707 ++++++++ 11 files changed, 2436 insertions(+), 54 deletions(-) create mode 100644 openvmm/openvmm_entry/src/ttrpc/microvm.rs create mode 100644 vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs diff --git a/Guide/src/reference/devices/virtio/virtio-fs.md b/Guide/src/reference/devices/virtio/virtio-fs.md index 2b2ad27e73b..48e2dc5df97 100644 --- a/Guide/src/reference/devices/virtio/virtio-fs.md +++ b/Guide/src/reference/devices/virtio/virtio-fs.md @@ -141,7 +141,8 @@ false success. `vm/devices/virtio/virtio_resources/src/lib.rs` and `vm/devices/virtio/virtio_resources/src/fs/microvm.rs` - microVM composition and restore attachment validation: - `openvmm/openvmm_entry/src/microvm/filesystem.rs` + `openvmm/openvmm_entry/src/microvm/filesystem.rs` and + `openvmm/openvmm_entry/src/ttrpc/microvm.rs` - Snapshot manifest and microVM filesystem contract: `openvmm/openvmm_helpers/src/snapshot.rs` and `openvmm/openvmm_helpers/src/snapshot/microvm.rs` diff --git a/Guide/src/reference/openvmm/management/grpc.md b/Guide/src/reference/openvmm/management/grpc.md index c5c3da8a18c..99163837111 100644 --- a/Guide/src/reference/openvmm/management/grpc.md +++ b/Guide/src/reference/openvmm/management/grpc.md @@ -117,4 +117,89 @@ cannot exceed the physical SMMU's width with acceleration. SMMU configuration cannot be changed at runtime. See [the CLI reference](cli.md) and [Arm SMMUv3](../../emulated/iommu/smmuv3.md) for platform requirements. +## microVM snapshots + +`CreateVMRequest.microvm_snapshot` exposes the microVM capture and +restore flow over both transports: + +* `destination_path` configures guest-requested capture. Supply a microVM + configuration, including `DirectBoot` kernel/initrd files, memory, and the + processor count. The path must not exist. `quiesce_timeout_ms` defaults to + five seconds when zero. + `memory_capacity_bytes` optionally reserves an immutable, 128-MiB-aligned + RAM capacity while keeping the configured base memory as the exact + `memory.bin` payload and initial Linux direct e820 map. +* `restore_path` selects manifest-authoritative restore. `config` may be + absent, or may contain only the matching microVM profile, an optional exact + processor-count assertion, serial port 0 host + attachment, matching `DevicesConfig.virtio_console` and + `DevicesConfig.virtiofs_config` attachments, and guest power actions. + Guest-visible boot, memory, processor topology, other device, NUMA, and PCIe + fields are rejected. A saved listener is reconstructed from the manifest; a saved + client requires the matching path configuration. +* `restore_entropy` requests fresh entropy and is valid only with + `restore_path`. Every microVM process also receives a fresh, non-serialized + 16-byte generation ID through the fixed portb selector. On restore, the ID is + the first 16 bytes of the entropy packet. +* `restore_processor_count` requests restore-time activation of the contiguous + VP prefix `0..count-1`. Zero preserves legacy behavior. A nonzero value is + valid only for a snapshot that advertises processor activation, implies fresh entropy and the + post-restore gate, and must satisfy the snapshot's boot-online and immutable + capacity bounds. `ProcessorConfig.processor_count`, when present, remains an + exact capacity assertion. +* `restore_memory_bytes` selects a 128-MiB-aligned total RAM target between the + snapshot base and immutable capacity. Zero selects the base. Expansion uses + fresh private zeroed backing, implies fresh restore packet delivery and the + post-restore repair gate, and is rejected for legacy snapshots. An explicit + base-size value emits restore packet V3 with zero expansion ranges; zero + preserves V1/V2 packet selection. +* `restore_gate_timeout_ms` bounds gated guest repair. Zero selects the + 60-second default; a nonzero value is valid only with `restore_path`. +* `restore_ready_path` names an existing Unix domain socket on Linux or a + `//./pipe/...` named pipe on Windows. `ResumeVM` writes and flushes exactly + `OPENVMM_RESTORE_READY_V1\n` after all fatal restore startup work completes. + For a gated restore, this occurs after guest repair succeeds and host input + is re-enabled, while the restored vCPU remains stopped. Signaling failure makes `ResumeVM` fail and tears down the + managed VM. The peer must accept and read concurrently with `ResumeVM`; + Windows flush completion waits until the complete frame has been consumed. + +On cold boot, `DevicesConfig.virtio_console` may configure one microVM Unix +socket or named-pipe endpoint. Listener mode recreates the path on restore. +Client mode is required and uses a five-second connection timeout +before vCPUs start. The device uses MMIO `0xd0002000`, IRQ 7, and selects +`hvc1`; its canonical path and policy become the stable restore attachment. + +`DevicesConfig.virtiofs_config` may bind one HostFs attachment to the fixed +microVM slot. Set `tag` to `microvm`, supply `root_path`, and set +`guest_mount_target` to an absolute Linux path. `read_write=false` selects the +default read-only policy. Restoring an active attachment requires the exact +same canonical root path, tag, guest target, and access mode, and the live root +must retain its saved identity. A snapshot captured with the slot dormant may +omit the attachment or supply a new one; after `ResumeVM`, the guest explicitly +mounts tag `microvm`. The fixed device uses MMIO `0xd0001000`, IRQ 6, one +request queue, and no DAX window. + +Standard-machine attachments reject the microVM mount fields, and microVM +attachments reject `read_only=true` before opening device resources. Use +`read_write` to select a microVM attachment's access mode. + +Capture and restore paths are mutually exclusive. A successful capture halts +the managed source VM at the committed boundary and terminates the OpenVMM +source process; clients observe the transport closing. Restore creates a private +copy-on-write RAM view and leaves the VM paused until `ResumeVM`. +The readiness endpoint is a process-local orchestration attachment and is not +part of saved state. Each successful restore publishes one event; validation, +attachment, or worker-start failure publishes none. + +`VMConfig.MICROVM` is numeric value 2, matching the microVM ABI version, and +accepts exactly 1, 2, 4, or 8 processors. Numeric value 1 is not a valid profile +and is rejected before host resources are opened. RPC construction is currently +blockless; role-bearing sandbox blocks remain CLI-only. Snapshot ABI and boot +layout remain value 2, which identifies the Linux-direct MP-table layout. Value +1 snapshots are unsupported. + +The API has the same KVM/MSHV/WHP backend, no-block device, artifact integrity, +and security restrictions documented under [`--snapshot-destination`]. + [`vmservice.proto`]: https://github.com/microsoft/openvmm/blob/main/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto +[`--snapshot-destination`]: ./cli.md diff --git a/openvmm/openvmm_entry/src/microvm/console.rs b/openvmm/openvmm_entry/src/microvm/console.rs index 37b4c509f75..7e2fab20bf3 100644 --- a/openvmm/openvmm_entry/src/microvm/console.rs +++ b/openvmm/openvmm_entry/src/microvm/console.rs @@ -545,10 +545,6 @@ fn microvm_console_attachment_from_snapshot_with_identity( Ok(reconstructed) } -#[cfg_attr( - not(test), - expect(dead_code, reason = "the management RPC restores through it") -)] pub(crate) fn microvm_console_attachment_from_snapshot( attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, requested: Option<&SerialConfigCli>, diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 7c2c4e95544..2f366acc459 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -13,11 +13,26 @@ pub(crate) mod report; mod restore; pub(crate) use config::MicrovmConfigBuilder; +#[cfg(test)] +pub(crate) use console::MICROVM_CONSOLE_ATTACHMENT_KIND; +#[cfg(test)] +pub(crate) use console::MICROVM_CONSOLE_STABLE_ID; +pub(crate) use console::MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND; +pub(crate) use console::MICROVM_CONTROL_CONSOLE_STABLE_ID; pub(crate) use console::MicrovmConsoleSocketCleanup; +pub(crate) use console::microvm_console_attachment_from_cli; +pub(crate) use console::microvm_console_attachment_from_snapshot; +pub(crate) use console::microvm_console_socket_cleanup; +pub(crate) use console::validate_microvm_console_attachment_namespace; +pub(crate) use filesystem::microvm_filesystem_attachment; +pub(crate) use filesystem::microvm_filesystem_from_snapshot; +pub(crate) use filesystem::microvm_filesystem_slot_from_snapshot; pub(crate) use filesystem::validate_microvm_filesystem_private_storage; pub(crate) use launch::MicrovmLaunch; pub(crate) use restore::ExpectedRestoreContract; pub(crate) use restore::MicrovmRestore; +pub(crate) use restore::fresh_microvm_generation_id; +pub(crate) use restore::fresh_microvm_restore_packet; pub(crate) use restore::prepare_restore; pub(crate) use restore::validate_restore_contract; diff --git a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs index 87bf1df73ec..ff655796e12 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs @@ -1,15 +1,16 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Snapshot restore of a VM launched from the command line. +//! Snapshot restore of a VM launched from the command line, and the restore +//! steps it shares with the management RPC endpoint. //! //! A `--restore-snapshot` launch opens one exact snapshot generation first //! ([`SnapshotRestore::open`]); a microVM derives its restore-time //! configuration from that generation's manifest. Just before the VM worker -//! launches, the opened generation is validated against the VM configuration -//! and its RAM is mapped copy-on-write ([`SnapshotRestore::prepare`]). Its open -//! artifact handles then move to the worker, which keeps the generation pinned -//! until VM teardown. +//! launches, the opened generation is validated against the VM configuration, +//! claimed if it is a single-use resume snapshot, and its RAM is mapped +//! copy-on-write ([`SnapshotRestore::prepare`]). Its open artifact handles then +//! move to the worker, which keeps the generation pinned until VM teardown. //! The `--restore-ready-path` endpoint is connected next //! ([`restore_ready_sink`]); the worker writes the restore readiness event to //! it when the restored VM first starts. @@ -22,6 +23,8 @@ mod prepare; mod ready; +pub(crate) use prepare::prepare_snapshot_restore_for_config; +pub(crate) use ready::connect_restore_ready_sink; pub(crate) use ready::restore_ready_sink; use crate::Options; diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs new file mode 100644 index 00000000000..c854427b38d --- /dev/null +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -0,0 +1,1441 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MicroVM-specific support for the management RPC endpoint. + +use crate::cli_args::SerialConfigCli; +use crate::serial_io::connect::bind_serial_without_cleanup; +use crate::vm_controller::MicrovmController; +use anyhow::Context; +use chipset_resources::microvm::MicrovmPortbHandle; +use chipset_resources::microvm::MicrovmShutdownHandle; +use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; +use chipset_resources::microvm::MicrovmSnapshotRequestHandle; +use chipset_resources::microvm::MicrovmSnapshotScratchPolicy; +use memory_range::MemoryRange; +use openvmm_defs::config::ArchTopologyConfig; +use openvmm_defs::config::Config; +use openvmm_defs::config::LoadMode; +use openvmm_defs::config::MemoryConfig; +use openvmm_defs::config::NumaNode; +use openvmm_defs::config::NumaTopology; +use openvmm_defs::config::VpAssignment; +use openvmm_defs::config::X86TopologyConfig; +use openvmm_defs::microvm::MICROVM_ABI_VERSION_2; +use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::build_microvm_command_line; +use openvmm_defs::worker::SharedMemoryFd; +use openvmm_defs::worker::SnapshotRestoreGuards; +use openvmm_ttrpc_vmservice as vmservice; +use serial_core::resources::DisconnectedSerialBackendHandle; +use std::fs::File; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use virtio_resources::console::attachment::VirtioConsoleDisconnectPolicy; +use vm_manifest_builder::BaseChipsetType; +use vm_resource::IntoResource; +use vm_resource::Resource; +use vm_resource::ResourceId; +use vm_resource::kind::SerialBackendHandle; +use vm_resource::kind::VirtioDeviceHandle; +use vmotherboard::ChipsetDeviceHandle; + +type RestoreTime = (Duration, u64, Option, Vec); +type SerialPorts = [Option>; 4]; + +struct AuthoritativeRestore { + path: PathBuf, + snapshot: Option, + base_memory_size: u64, + selected_memory_size: u64, + restore_memory_target_requested: bool, + restore_memory_ranges: Vec, + vp_count: u32, + machine_contract: openvmm_helpers::snapshot::microvm::SnapshotMachineContract, +} + +struct RestoredFilesystem { + config: openvmm_defs::microvm::MicrovmFilesystemConfig, + root_path: String, + attachment: openvmm_helpers::snapshot::microvm::SnapshotAttachment, +} + +/// MicroVM state accumulated while a management-RPC VM is being created. +pub(super) struct CreateVm { + active: bool, + machine_profile: MachineProfile, + processor_count: u32, + authoritative_restore: Option, + restore_console_config: Option, + restore_filesystem_config: Option, + restored_filesystem: Option, + snapshot_destination: Option, + restore_entropy: bool, + restore_online_vp_count: Option, + restore_gate_timeout: Duration, + snapshot_quiesce_timeout: Duration, + memory_capacity_bytes: u64, + restore_ready_path: String, + source_hypervisor: String, + shared_memory: Option, + snapshot_restore_guards: Option, + saved_state: Option, + restore_time: Option, + snapshot_notify: Option>, + snapshot_ready: Option>, + snapshot_requests: Option>, + portb: Option>, + portb_has_output: bool, + resources: crate::microvm::MicrovmResources, + filesystem_slot: bool, + filesystem: Option, + effective_command_line: Option, + snapshot_memory_file: Option, + snapshot_memory_handle: Option, + snapshot_memory_path: Option, + memory_capacity: Option, +} + +impl CreateVm { + /// Parses the microVM portion of a create request and returns the effective + /// VM configuration. + pub(super) fn new( + request: vmservice::CreateVmRequest, + ) -> anyhow::Result<(Self, vmservice::VmConfig)> { + let vmservice::CreateVmRequest { + config: requested_config, + microvm_snapshot, + .. + } = request; + let vmservice::MicrovmSnapshotConfig { + destination_path, + restore_path, + restore_entropy, + quiesce_timeout_ms, + restore_ready_path, + restore_processor_count, + restore_gate_timeout_ms, + memory_capacity_bytes, + restore_memory_bytes, + } = microvm_snapshot.unwrap_or_default(); + let restore_online_vp_count = + (restore_processor_count != 0).then_some(restore_processor_count); + let restore_gate_timeout = Duration::from_millis(if restore_gate_timeout_ms == 0 { + 60_000 + } else { + restore_gate_timeout_ms + }); + let resolve_path = |value: String| -> anyhow::Result> { + if value.is_empty() { + return Ok(None); + } + let path = PathBuf::from(value); + if path.is_absolute() { + Ok(Some(path)) + } else { + Ok(Some( + std::env::current_dir() + .context("failed to resolve current directory")? + .join(path), + )) + } + }; + let snapshot_destination = resolve_path(destination_path)?; + let restore_path = resolve_path(restore_path)?; + anyhow::ensure!( + snapshot_destination.is_none() || restore_path.is_none(), + "snapshot capture and restore paths are mutually exclusive" + ); + anyhow::ensure!( + !restore_entropy || restore_path.is_some(), + "restore_entropy requires restore_path" + ); + anyhow::ensure!( + restore_online_vp_count.is_none() || restore_path.is_some(), + "restore_processor_count requires restore_path" + ); + anyhow::ensure!( + restore_gate_timeout_ms == 0 || restore_path.is_some(), + "restore_gate_timeout_ms requires restore_path" + ); + anyhow::ensure!( + memory_capacity_bytes == 0 || snapshot_destination.is_some(), + "memory_capacity_bytes requires destination_path" + ); + anyhow::ensure!( + restore_memory_bytes == 0 || restore_path.is_some(), + "restore_memory_bytes requires restore_path" + ); + anyhow::ensure!( + restore_ready_path.is_empty() || restore_path.is_some(), + "restore_ready_path requires restore_path" + ); + anyhow::ensure!( + quiesce_timeout_ms == 0 || snapshot_destination.is_some(), + "quiesce_timeout_ms requires destination_path" + ); + let snapshot_quiesce_timeout = Duration::from_millis(if quiesce_timeout_ms == 0 { + 5_000 + } else { + quiesce_timeout_ms + }); + + let authoritative_restore = if let Some(path) = restore_path { + let snapshot = openvmm_helpers::snapshot::restore::OpenedSnapshot::open(&path)?; + let manifest = snapshot.manifest(); + openvmm_helpers::snapshot::validate_manifest( + manifest, + crate::GUEST_ARCH, + manifest.memory_size_bytes, + manifest.vp_count, + crate::system_page_size(), + )?; + let machine_contract = manifest + .machine_contract + .as_ref() + .context("microVM snapshot is missing its authoritative machine contract")?; + anyhow::ensure!( + machine_contract.machine_profile == "microvm", + "snapshot machine profile is not microvm" + ); + openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract( + machine_contract, + )?; + validate_restore_console_attachments(&machine_contract.attachments)?; + anyhow::ensure!( + machine_contract.microvm_sandbox_blocks.is_empty(), + "TTRPC restore does not support microVM sandbox-block snapshots" + ); + let base_memory_size = manifest.memory_size_bytes; + let restore_memory_target_requested = restore_memory_bytes != 0; + let selected_memory_size = if restore_memory_target_requested { + restore_memory_bytes + } else { + base_memory_size + }; + let restore_memory_ranges = if restore_memory_target_requested { + openvmm_helpers::snapshot::microvm::validate_restore_memory_target( + manifest, + selected_memory_size, + )? + } else { + Vec::new() + }; + let vp_count = manifest.vp_count; + if let Some(restore_online_vp_count) = restore_online_vp_count { + openvmm_helpers::snapshot::microvm::validate_restore_online_vp_count( + manifest, + restore_online_vp_count, + )?; + } + let machine_contract = machine_contract.clone(); + Some(AuthoritativeRestore { + path, + snapshot: Some(snapshot), + base_memory_size, + selected_memory_size, + restore_memory_target_requested, + restore_memory_ranges, + vp_count, + machine_contract, + }) + } else { + None + }; + + let mut restore_console_config = None; + let mut restore_filesystem_config = None; + let req_config = if let Some(restore) = &authoritative_restore { + let restore_profile = + ttrpc_machine_profile(restore.machine_contract.microvm_abi_version)?; + let requested = requested_config.unwrap_or_else(|| vmservice::VmConfig { + machine_profile: restore_profile as i32, + ..Default::default() + }); + anyhow::ensure!( + requested.machine_profile == restore_profile as i32, + "microVM snapshot restore requires the matching microVM ABI profile" + ); + anyhow::ensure!( + requested.memory_config.is_none() + && requested.boot_config.is_none() + && requested.windows_options.is_none() + && requested.hvsocket_config.is_none() + && requested.numa_config.is_none() + && requested.pcie.is_none(), + "restore configuration may contain only processor identity, serial attachments, and guest power actions" + ); + if let Some(processors) = &requested.processor_config { + anyhow::ensure!( + processors.processor_count == restore.vp_count + && processors.processor_weight == 0 + && processors.processor_limit == 0, + "restore processor count does not match the snapshot" + ); + } + if let Some(devices) = &requested.devices_config { + anyhow::ensure!( + devices.scsi_disks.is_empty() + && devices.vpmem_disks.is_empty() + && devices.nic_config.is_empty() + && devices.windows_device.is_empty() + && devices.virtiofs_config.len() <= 1, + "restore devices_config may contain only one virtio-fs and one virtio-console attachment" + ); + restore_console_config = devices.virtio_console.clone(); + restore_filesystem_config = devices.virtiofs_config.first().cloned(); + } + vmservice::VmConfig { + serial_config: requested.serial_config, + guest_power_actions: requested.guest_power_actions, + machine_profile: restore_profile as i32, + boot_config: Some(vmservice::vm_config::BootConfig::DirectBoot( + Default::default(), + )), + ..Default::default() + } + } else { + requested_config.context("missing configuration")? + }; + + let requested_profile = + vmservice::vm_config::MachineProfile::from_i32(req_config.machine_profile) + .with_context(|| { + format!("unknown machine profile {}", req_config.machine_profile) + })?; + let machine_profile = openvmm_machine_profile(requested_profile); + let active = machine_profile == MachineProfile::Microvm; + let processor_count = authoritative_restore + .as_ref() + .map(|restore| restore.vp_count) + .or_else(|| { + req_config + .processor_config + .as_ref() + .map(|config| config.processor_count) + }) + .unwrap_or(1); + anyhow::ensure!( + active || (snapshot_destination.is_none() && authoritative_restore.is_none()), + "microVM snapshot options require the microVM machine profile" + ); + + Ok(( + Self { + active, + machine_profile, + processor_count, + authoritative_restore, + restore_console_config, + restore_filesystem_config, + restored_filesystem: None, + snapshot_destination, + restore_entropy, + restore_online_vp_count, + restore_gate_timeout, + snapshot_quiesce_timeout, + memory_capacity_bytes, + restore_ready_path, + source_hypervisor: String::new(), + shared_memory: None, + snapshot_restore_guards: None, + saved_state: None, + restore_time: None, + snapshot_notify: None, + snapshot_ready: None, + snapshot_requests: None, + portb: None, + portb_has_output: false, + resources: Default::default(), + filesystem_slot: active, + filesystem: None, + effective_command_line: None, + snapshot_memory_file: None, + snapshot_memory_handle: None, + snapshot_memory_path: None, + memory_capacity: None, + }, + req_config, + )) + } + + /// Completes restore preparation after the hypervisor backend has been + /// selected. + pub(super) fn prepare_restore(&mut self, source_hypervisor: String) -> anyhow::Result<()> { + self.source_hypervisor = source_hypervisor; + if let Some(restore) = &self.authoritative_restore { + anyhow::ensure!( + restore.machine_contract.source_hypervisor == self.source_hypervisor, + "snapshot source hypervisor '{}' does not match destination '{}'", + restore.machine_contract.source_hypervisor, + self.source_hypervisor + ); + self.filesystem_slot = + crate::microvm::microvm_filesystem_slot_from_snapshot(&restore.machine_contract)?; + } + + self.restored_filesystem = if let Some(restore) = &self.authoritative_restore { + let saved_policy = restore.machine_contract.microvm_filesystem.as_ref(); + let saved_attachment = restore + .machine_contract + .attachments + .iter() + .find(|attachment| attachment.stable_id == "fs:microvm0"); + anyhow::ensure!( + saved_policy.is_some() == saved_attachment.is_some() + && (restore.machine_contract.microvm_filesystem_slot_version + == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION + || self.filesystem_slot == saved_policy.is_some()), + "snapshot microVM filesystem slot, policy, and attachment inventories disagree" + ); + match (saved_policy, saved_attachment) { + (Some(saved_policy), Some(saved_attachment)) => { + let requested = self.restore_filesystem_config.as_ref().context( + "snapshot restore requires a fresh virtiofs_config attachment for fs:microvm0", + )?; + let config = crate::microvm::microvm_filesystem_from_snapshot(saved_policy)?; + anyhow::ensure!( + requested.tag == "microvm" + && requested.guest_mount_target == config.guest_mount_target + && requested.read_write != config.access.is_read_only() + && !requested.root_path.is_empty(), + "restore-time virtiofs_config does not match the snapshot policy" + ); + let (root_path, attachment) = crate::microvm::microvm_filesystem_attachment( + Path::new(&requested.root_path), + )?; + anyhow::ensure!( + !saved_policy.canonical_host_path.is_empty(), + "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" + ); + anyhow::ensure!( + root_path == saved_policy.canonical_host_path, + "restore-time filesystem canonical host path does not match the snapshot contract" + ); + anyhow::ensure!( + &attachment == saved_attachment, + "restore-time filesystem root identity does not match the snapshot attachment" + ); + Some(RestoredFilesystem { + config, + root_path, + attachment, + }) + } + (None, None) => { + if let Some(requested) = self.restore_filesystem_config.as_ref() { + anyhow::ensure!( + restore.machine_contract.microvm_filesystem_slot_version + == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION, + "snapshot does not support restore-time microVM filesystem attachment" + ); + anyhow::ensure!( + requested.tag == "microvm" && !requested.root_path.is_empty(), + "restore-time virtiofs_config does not match the fixed microVM slot" + ); + let config = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + requested.guest_mount_target.clone(), + if requested.read_write { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + } else { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + }, + )?; + let (root_path, attachment) = + crate::microvm::microvm_filesystem_attachment(Path::new( + &requested.root_path, + ))?; + Some(RestoredFilesystem { + config, + root_path, + attachment, + }) + } else { + None + } + } + _ => anyhow::bail!( + "snapshot microVM filesystem policy and attachment inventories disagree" + ), + } + } else { + None + }; + + if let Some(restore) = &mut self.authoritative_restore { + anyhow::ensure!( + restore.machine_contract.microvm_network.is_none(), + "ttrpc restore does not yet expose microVM network attachments" + ); + let prepared = crate::snapshot_restore::prepare_snapshot_restore_for_config( + restore + .snapshot + .take() + .context("snapshot restore is missing its opened generation")?, + restore.base_memory_size, + restore.selected_memory_size, + restore.vp_count, + Some(( + &self.source_hypervisor, + &restore.machine_contract.effective_command_line, + None, + self.restored_filesystem.as_ref().map(|filesystem| { + ( + &filesystem.config, + Path::new(&filesystem.root_path), + &filesystem.attachment, + ) + }), + restore + .machine_contract + .attachments + .iter() + .find(|attachment| attachment.stable_id == "console:microvm-virtio0"), + None, + restore.machine_contract.microvm_sandbox_blocks.clone(), + )), + )?; + let restore_time = prepared + .restore_time + .context("microVM snapshot is missing its restore-time contract")?; + if !self.restore_entropy + && self.restore_online_vp_count.is_none() + && !restore.restore_memory_target_requested + { + tracing::warn!( + "restoring cloned guest RNG state without fresh entropy injection; cryptographic workloads are unsafe" + ); + } + self.shared_memory = Some(prepared.shared_memory); + self.snapshot_restore_guards = Some(prepared.guards); + self.saved_state = Some(prepared.saved_state); + self.restore_time = Some(restore_time); + } + Ok(()) + } + + /// Takes the process-local restore readiness endpoint path. + pub(super) fn take_restore_ready_path(&mut self) -> String { + std::mem::take(&mut self.restore_ready_path) + } + + /// Creates snapshot coordination channels and validates the effective + /// microVM request. + pub(super) fn finish_prepare( + &mut self, + req_config: &vmservice::VmConfig, + ) -> anyhow::Result<()> { + if self.active { + let (notify, requests) = mesh::channel(); + self.snapshot_notify = Some(notify); + self.resources.snapshot_requests = Some(requests); + let (ready, requests) = mesh::channel(); + self.snapshot_ready = Some(ready); + self.snapshot_requests = Some(requests); + } + + if self.active { + anyhow::ensure!( + cfg!(guest_arch = "x86_64"), + "microVM requires an x86-64 guest" + ); + if self.authoritative_restore.is_none() { + anyhow::ensure!( + matches!( + req_config.boot_config.as_ref(), + Some(vmservice::vm_config::BootConfig::DirectBoot(_)) + ), + "the microVM profile requires direct_boot" + ); + } + anyhow::ensure!( + openvmm_defs::microvm::microvm_processor_count_supported(self.processor_count), + "microVM does not support {} vCPUs", + self.processor_count + ); + anyhow::ensure!( + req_config.numa_config.is_none(), + "microVM does not support custom NUMA topology" + ); + anyhow::ensure!(req_config.pcie.is_none(), "microVM does not support PCIe"); + anyhow::ensure!( + req_config.iommufds.is_empty(), + "microVM ABI version 1 does not support iommufd contexts" + ); + anyhow::ensure!( + req_config.hvsocket_config.is_none(), + "microVM does not support hvsocket" + ); + + let serial_ports = req_config + .serial_config + .iter() + .flat_map(|config| &config.ports) + .collect::>(); + anyhow::ensure!( + serial_ports.len() <= 1 && serial_ports.iter().all(|port| port.port == 0), + "microVM accepts only serial port 0 as its portb endpoint" + ); + if let Some(devices) = &req_config.devices_config { + anyhow::ensure!( + devices.scsi_disks.is_empty() + && devices.vpmem_disks.is_empty() + && devices.nic_config.is_empty() + && devices.windows_device.is_empty() + && devices.virtiofs_config.len() <= 1, + "microVM supports only one fixed virtio-fs and one fixed virtio-console device" + ); + if let Some(filesystem) = devices.virtiofs_config.first() { + anyhow::ensure!( + !filesystem.read_only, + "microVM virtio-fs uses read_write rather than read_only" + ); + anyhow::ensure!( + filesystem.tag == "microvm" && !filesystem.root_path.is_empty(), + "microVM virtio-fs requires tag 'microvm' and a host root path" + ); + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + filesystem.guest_mount_target.clone(), + if filesystem.read_write { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + } else { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + }, + )?; + } + if let Some(console) = &devices.virtio_console { + anyhow::ensure!( + !console.socket_path.is_empty(), + "microVM virtio-console requires a socket path" + ); + } + } + } + Ok(()) + } + + /// Returns whether the microVM machine profile is active. + pub(super) fn is_active(&self) -> bool { + self.active + } + + /// Returns the effective virtual processor count. + pub(super) fn processor_count(&self) -> u32 { + self.processor_count + } + + /// Returns whether a cold boot requested its fixed virtio-console. + pub(super) fn has_requested_console(&self, config: &vmservice::VmConfig) -> bool { + self.active + && self.authoritative_restore.is_none() + && config + .devices_config + .as_ref() + .and_then(|devices| devices.virtio_console.as_ref()) + .is_some() + } + + /// Creates the inert kernel handle used by an authoritative restore. + pub(super) fn restore_kernel(&self) -> anyhow::Result> { + if self.authoritative_restore.is_some() { + return Ok(Some( + tempfile::tempfile().context("failed to create inert restore kernel handle")?, + )); + } + Ok(None) + } + + /// Builds the effective direct-boot kernel command line. + pub(super) fn command_line( + &self, + requested: String, + has_requested_console: bool, + ) -> anyhow::Result { + if let Some(restore) = &self.authoritative_restore { + return Ok(restore.machine_contract.effective_command_line.clone()); + } + if self.active { + let mut cmdline = build_microvm_command_line(&[requested], has_requested_console)?; + openvmm_defs::microvm::append_microvm_processor_limit( + &mut cmdline, + self.processor_count, + )?; + Ok(cmdline) + } else { + Ok(requested) + } + } + + /// Returns the direct-boot mode selected by the machine profile. + pub(super) fn direct_boot_mode(&self) -> openvmm_defs::config::LinuxDirectBootMode { + if self.active { + openvmm_defs::config::LinuxDirectBootMode::MpTable + } else { + openvmm_defs::config::LinuxDirectBootMode::Acpi + } + } + + /// Returns the base chipset selected by the machine profile. + pub(super) fn base_chipset_type(&self) -> BaseChipsetType { + if self.active { + BaseChipsetType::Microvm + } else { + BaseChipsetType::HyperVGen2LinuxDirect + } + } + + /// Removes the microVM portb endpoint from the standard serial-port array. + pub(super) fn configure_serial_ports( + &mut self, + mut ports: SerialPorts, + ) -> anyhow::Result> { + if !self.active { + return Ok(Some(ports)); + } + self.portb_has_output = ports[0].is_some(); + if ports.iter().skip(1).any(Option::is_some) { + anyhow::bail!("microVM accepts only serial port 0 as its portb endpoint"); + } + self.portb = Some( + ports[0] + .take() + .unwrap_or_else(|| DisconnectedSerialBackendHandle.into_resource()), + ); + Ok(None) + } + + /// Adds the fixed microVM chipset devices to the manifest. + pub(super) fn add_chipset_devices( + &mut self, + devices: &mut Vec, + ) -> anyhow::Result<()> { + let Some(io) = self.portb.take() else { + return Ok(()); + }; + let output_drain = self.portb_has_output.then(|| { + let (drain, requests) = crate::microvm::output::MicrovmOutputDrain::new(None); + self.resources.output_drain = Some(drain); + requests + }); + let restore_memory_ranges = self + .authoritative_restore + .as_ref() + .map(|restore| restore.restore_memory_ranges.as_slice()) + .unwrap_or_default(); + let restore_memory_target_requested = self + .authoritative_restore + .as_ref() + .is_some_and(|restore| restore.restore_memory_target_requested); + let (generation_id, restore_entropy) = if self.restore_entropy + || self.restore_online_vp_count.is_some() + || restore_memory_target_requested + { + crate::microvm::fresh_microvm_restore_packet( + self.restore_online_vp_count, + restore_memory_target_requested, + restore_memory_ranges, + )? + } else { + (crate::microvm::fresh_microvm_generation_id()?, Vec::new()) + }; + devices.extend([ + ChipsetDeviceHandle { + name: MicrovmPortbHandle::ID.to_owned(), + resource: MicrovmPortbHandle { + io, + generation_id, + restore_entropy, + output_drain, + } + .into_resource(), + }, + ChipsetDeviceHandle { + name: MicrovmShutdownHandle::ID.to_owned(), + resource: MicrovmShutdownHandle.into_resource(), + }, + ChipsetDeviceHandle { + name: MicrovmSnapshotRequestHandle::ID.to_owned(), + resource: MicrovmSnapshotRequestHandle { + notify: self.snapshot_notify.take(), + input_gate_timeout: self.snapshot_quiesce_timeout, + } + .into_resource(), + }, + ]); + Ok(()) + } + + /// Returns the snapshot-selected NUMA topology for an authoritative + /// restore. + pub(super) fn restore_numa(&self) -> Option<(NumaTopology, u64)> { + self.authoritative_restore.as_ref().map(|restore| { + let mem_size = restore.selected_memory_size; + let numa = NumaTopology { + nodes: vec![NumaNode { + mem: Some(MemoryConfig { + mem_size, + prefetch_memory: false, + private_memory: false, + transparent_hugepages: true, + hugepages: false, + hugepage_size: None, + host_numa_node: None, + }), + vps: VpAssignment::FromTopology, + }], + distances: vec![], + }; + (numa, mem_size) + }) + } + + /// Applies machine-profile-specific values to an otherwise standard VM + /// configuration. + pub(super) fn apply_config(&self, config: &mut Config) { + config.machine_profile = self.machine_profile; + if self.active { + config.processor_topology.vps_per_socket = Some(self.processor_count); + config.processor_topology.enable_smt = Some(false); + config.processor_topology.arch = Some(ArchTopologyConfig::X86(X86TopologyConfig { + apic_id_offset: 0, + x2apic: openvmm_defs::config::X2ApicConfig::Unsupported, + })); + config.hypervisor.with_hv = false; + config.vmbus = None; + } + config.microvm.filesystem_bootstrap = self + .authoritative_restore + .as_ref() + .is_some_and(|restore| restore.machine_contract.microvm_filesystem.is_some()); + config.microvm.memory_capacity = self + .authoritative_restore + .as_ref() + .and_then(|restore| { + (restore.machine_contract.memory_expansion_version != 0) + .then_some(restore.machine_contract.memory_capacity_bytes) + }) + .or((self.memory_capacity_bytes != 0).then_some(self.memory_capacity_bytes)); + config.microvm.snapshot_memory_ranges = self + .authoritative_restore + .as_ref() + .filter(|restore| restore.machine_contract.memory_expansion_version != 0) + .map(|restore| { + restore + .machine_contract + .memory_ranges + .iter() + .map(|range| MemoryRange::new(range.gpa_start..range.gpa_start + range.length)) + .collect() + }) + .unwrap_or_default(); + config.microvm.restore_memory_ranges = self + .authoritative_restore + .as_ref() + .map(|restore| { + restore + .restore_memory_ranges + .iter() + .map(|range| MemoryRange::new(range.gpa_start..range.gpa_start + range.length)) + .collect() + }) + .unwrap_or_default(); + } + + /// Adds devices reconstructed from an authoritative snapshot contract. + pub(super) fn add_restored_devices(&mut self, config: &mut Config) -> anyhow::Result<()> { + if let Some(filesystem) = self.restored_filesystem.take() { + self.resources.filesystem_root_path = Some(PathBuf::from(&filesystem.root_path)); + config.microvm.filesystem = Some(filesystem.config.clone()); + config.virtio_devices.push(( + openvmm_defs::config::VirtioBus::Mmio, + virtio_resources::fs::VirtioFsHandle { + tag: "microvm".to_owned(), + fs: virtio_resources::fs::VirtioFsBackend::HostFs { + root_path: filesystem.root_path, + mount_options: String::new(), + }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Microvm { + stable_id: "fs:microvm0".to_owned(), + root_identity: filesystem.attachment.identity.clone(), + read_only: filesystem.config.access.is_read_only(), + denied_paths: Vec::new(), + }, + } + .into_resource(), + )); + self.resources.filesystem_attachment = Some(filesystem.attachment); + } + + let Some(restore) = &self.authoritative_restore else { + return Ok(()); + }; + let has_console = restore + .machine_contract + .devices + .iter() + .any(|device| device.stable_id == "console:microvm-virtio0"); + let attachment = restore + .machine_contract + .attachments + .iter() + .find(|attachment| attachment.stable_id == "console:microvm-virtio0"); + anyhow::ensure!( + has_console == attachment.is_some(), + "snapshot microVM console device and attachment inventories disagree" + ); + if let Some(attachment) = attachment { + let requested_attachment = self.restore_console_config.as_ref().map(|console| { + if console.connect { + SerialConfigCli::ConnectPipe(PathBuf::from(&console.socket_path)) + } else { + SerialConfigCli::Pipe(PathBuf::from(&console.socket_path)) + } + }); + let (endpoint_config, resource_attachment, snapshot_attachment) = + crate::microvm::microvm_console_attachment_from_snapshot( + attachment, + requested_attachment.as_ref(), + )?; + crate::microvm::validate_microvm_console_attachment_namespace( + &snapshot_attachment, + &restore.path, + )?; + let (backend, disconnect_policy) = match endpoint_config { + SerialConfigCli::Pipe(path) => { + let backend = bind_serial_without_cleanup(&path).with_context(|| { + format!( + "failed to recreate virtio console listener: {}", + path.display() + ) + })?; + self.resources.console_socket_cleanup = + crate::microvm::microvm_console_socket_cleanup(path)?; + (backend, VirtioConsoleDisconnectPolicy::Retain) + } + SerialConfigCli::Tcp(address) => ( + crate::serial_io::bind_tcp_serial(&address)?, + VirtioConsoleDisconnectPolicy::Retain, + ), + SerialConfigCli::ConnectPipe(path) => ( + crate::serial_io::connect::connect_serial_with_timeout( + &path, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + ) + .with_context(|| { + format!( + "failed to reconnect virtio console client: {}", + path.display() + ) + })?, + VirtioConsoleDisconnectPolicy::Retain, + ), + SerialConfigCli::ConnectTcp(address) => ( + crate::serial_io::connect::connect_tcp_serial( + &address, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + )?, + VirtioConsoleDisconnectPolicy::Retain, + ), + SerialConfigCli::None => ( + DisconnectedSerialBackendHandle.into_resource(), + VirtioConsoleDisconnectPolicy::Discard, + ), + _ => unreachable!("saved microVM console was validated as an attachment"), + }; + config.virtio_devices.push(( + openvmm_defs::config::VirtioBus::Mmio, + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy, + attachment: Some(resource_attachment), + } + .into_resource(), + )); + self.resources.console_attachment = Some(snapshot_attachment); + } + Ok(()) + } + + /// Consumes microVM-only devices so the caller can retain the unchanged + /// standard-device construction path. + pub(super) fn take_devices( + &mut self, + config: &mut Config, + devices: &mut vmservice::DevicesConfig, + ) -> anyhow::Result<()> { + if !self.active { + return Ok(()); + } + for filesystem in std::mem::take(&mut devices.virtiofs_config) { + self.add_filesystem(config, filesystem)?; + } + if let Some(console) = devices.virtio_console.take() { + self.add_console(config, console)?; + } + Ok(()) + } + + fn add_filesystem( + &mut self, + config: &mut Config, + filesystem: vmservice::VirtioFs, + ) -> anyhow::Result<()> { + anyhow::ensure!( + config.microvm.filesystem.is_none() + && filesystem.tag == "microvm" + && !filesystem.root_path.is_empty(), + "microVM permits one virtio-fs attachment with tag 'microvm'" + ); + let filesystem_config = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + filesystem.guest_mount_target, + if filesystem.read_write { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + } else { + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + }, + )?; + let (root_path, attachment) = + crate::microvm::microvm_filesystem_attachment(Path::new(&filesystem.root_path))?; + self.resources.filesystem_root_path = Some(PathBuf::from(&root_path)); + let resource = virtio_resources::fs::VirtioFsHandle { + tag: "microvm".to_owned(), + fs: virtio_resources::fs::VirtioFsBackend::HostFs { + root_path, + mount_options: String::new(), + }, + profile: virtio_resources::fs::microvm::VirtioFsProfile::Microvm { + stable_id: "fs:microvm0".to_owned(), + root_identity: attachment.identity.clone(), + read_only: filesystem_config.access.is_read_only(), + denied_paths: Vec::new(), + }, + } + .into_resource(); + if self.snapshot_destination.is_some() { + tracing::warn!( + stable_id = "fs:microvm0", + access_mode = filesystem_config.access.as_str(), + "microVM snapshot excludes live host filesystem contents; restore revalidates the external directory and may fail after host changes" + ); + } + config.microvm.filesystem = Some(filesystem_config); + config.microvm.filesystem_bootstrap = true; + self.resources.filesystem_attachment = Some(attachment); + config + .virtio_devices + .push((openvmm_defs::config::VirtioBus::Mmio, resource)); + Ok(()) + } + + fn add_console( + &mut self, + config: &mut Config, + console: vmservice::VirtioConsoleConfig, + ) -> anyhow::Result<()> { + if console.socket_path.is_empty() { + return Ok(()); + } + let endpoint_config = if console.connect { + SerialConfigCli::ConnectPipe(PathBuf::from(&console.socket_path)) + } else { + SerialConfigCli::Pipe(PathBuf::from(&console.socket_path)) + }; + let (endpoint_config, attachment, snapshot_attachment) = + crate::microvm::microvm_console_attachment_from_cli(&endpoint_config)?; + if let Some(snapshot_dir) = &self.snapshot_destination { + crate::microvm::validate_microvm_console_attachment_namespace( + &snapshot_attachment, + snapshot_dir, + )?; + } + let backend = match endpoint_config { + SerialConfigCli::Pipe(path) => { + let backend = bind_serial_without_cleanup(&path).with_context(|| { + format!("failed to bind virtio console socket: {}", path.display()) + })?; + self.resources.console_socket_cleanup = + crate::microvm::microvm_console_socket_cleanup(path)?; + backend + } + SerialConfigCli::ConnectPipe(path) => { + crate::serial_io::connect::connect_serial_with_timeout( + &path, + Duration::from_millis( + openvmm_defs::microvm::MICROVM_CONSOLE_RECONNECT_TIMEOUT_MS, + ), + ) + .with_context(|| { + format!( + "failed to connect virtio console socket: {}", + path.display() + ) + })? + } + _ => unreachable!("path input produced a non-path attachment"), + }; + self.resources.console_attachment = Some(snapshot_attachment); + let resource: Resource = + virtio_resources::console::VirtioConsoleHandle { + backend, + disconnect_policy: VirtioConsoleDisconnectPolicy::Retain, + attachment: Some(attachment), + } + .into_resource(); + config + .virtio_devices + .push((openvmm_defs::config::VirtioBus::Mmio, resource)); + Ok(()) + } + + /// Finalizes the fixed virtio-mmio device inventory and discovery command + /// line. + pub(super) fn finish_devices(&self, config: &mut Config) -> anyhow::Result<()> { + if self.filesystem_slot + && !config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtiofs") + { + config.virtio_devices.push(( + openvmm_defs::config::VirtioBus::Mmio, + virtio_resources::fs::VirtioFsHandle { + tag: "microvm".to_owned(), + fs: virtio_resources::fs::VirtioFsBackend::Dormant, + profile: virtio_resources::fs::microvm::VirtioFsProfile::MicrovmDormant { + stable_id: "fs:microvm0".to_owned(), + }, + } + .into_resource(), + )); + } + + if self.active && self.authoritative_restore.is_none() { + let has_console = config + .virtio_devices + .iter() + .any(|(_, device)| device.id() == "virtio-console"); + let LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } = &mut config.load_mode + else { + unreachable!("microVM was validated with direct_boot"); + }; + openvmm_defs::microvm::append_microvm_virtio_discovery( + cmdline, + None, + self.filesystem_slot, + config.microvm.filesystem.as_ref(), + has_console, + config.virtio_devices.iter().any(|(_, device)| { + device.id() == openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_ID + }), + &config.microvm.sandbox_blocks, + )?; + } + Ok(()) + } + + /// Validates the finished configuration and prepares snapshot RAM state. + pub(super) fn prepare_launch( + &mut self, + config: &Config, + config_mem_size: u64, + ) -> anyhow::Result<()> { + openvmm_defs::microvm::validate_machine_config(config, None)?; + self.effective_command_line = match &config.load_mode { + LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } => Some(cmdline.clone()), + _ => None, + }; + self.filesystem.clone_from(&config.microvm.filesystem); + if let Some(root_path) = self.resources.filesystem_root_path.as_deref() { + crate::microvm::validate_microvm_filesystem_private_storage( + root_path, + self.snapshot_destination.as_deref(), + self.authoritative_restore + .as_ref() + .map(|restore| restore.path.as_path()), + None, + )?; + } + self.snapshot_memory_file = if let Some(destination) = &self.snapshot_destination { + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let parent_metadata = fs_err::symlink_metadata(parent).with_context(|| { + format!("failed to inspect snapshot parent {}", parent.display()) + })?; + anyhow::ensure!( + parent_metadata.file_type().is_dir(), + "snapshot parent is not a directory: {}", + parent.display() + ); + anyhow::ensure!( + fs_err::symlink_metadata(destination) + .is_err_and(|error| error.kind() == io::ErrorKind::NotFound), + "snapshot destination already exists or cannot be inspected: {}", + destination.display() + ); + let file = tempfile::Builder::new() + .prefix(".openvmm-microvm-memory-") + .tempfile_in(parent) + .context("failed to create snapshot memory backing")?; + openvmm_helpers::snapshot::fs::initialize_snapshot_memory_backing_file( + file.as_file(), + config_mem_size, + ) + .context("failed to initialize snapshot memory backing")?; + Some(file) + } else { + None + }; + self.snapshot_memory_path = self + .snapshot_memory_file + .as_ref() + .map(|file| file.path().to_owned()); + self.snapshot_memory_handle = self + .snapshot_memory_file + .as_ref() + .map(|file| { + file.reopen() + .context("failed to duplicate automatic snapshot RAM handle") + }) + .transpose()?; + self.memory_capacity = config.microvm.memory_capacity; + Ok(()) + } + + /// Takes the microVM-derived VM worker launch fields. + pub(super) fn worker_fields(&mut self) -> anyhow::Result { + if self.shared_memory.is_none() + && let Some(file) = &self.snapshot_memory_handle + { + let file = file + .try_clone() + .context("failed to duplicate snapshot RAM handle for worker")?; + self.shared_memory = Some(openvmm_helpers::shared_memory::file_to_shared_memory_fd( + file, + )?); + } + let restore_gate_timeout = (self.restore_online_vp_count.is_some() + || self + .authoritative_restore + .as_ref() + .is_some_and(|restore| !restore.restore_memory_ranges.is_empty())) + .then_some(self.restore_gate_timeout); + Ok(WorkerFields { + saved_state: self.saved_state.take(), + shared_memory: self.shared_memory.take(), + shared_memory_copy_on_write: self.authoritative_restore.is_some(), + snapshot_restore_guards: self.snapshot_restore_guards.take(), + snapshot_boundary_requests: self.resources.snapshot_requests.take(), + snapshot_ready: self.snapshot_ready.take(), + snapshot_capture_enabled: self.snapshot_destination.is_some(), + restore_time: self.restore_time.take(), + restore_gate_timeout, + restore_vp_count: self.restore_online_vp_count, + }) + } + + /// Converts the remaining state into VM-controller launch fields. + pub(super) fn into_controller(self) -> ControllerFields { + ControllerFields { + memory_backing_file: self.snapshot_memory_path, + microvm: MicrovmController { + active: self.active, + snapshot_memory_handle: self.snapshot_memory_handle, + memory_capacity: self.memory_capacity, + snapshot_requests: self.snapshot_requests, + snapshot_destination: self.snapshot_destination, + snapshot_tier: None, + snapshot_quiesce_timeout: self.snapshot_quiesce_timeout, + source_hypervisor: self.source_hypervisor, + effective_command_line: self.effective_command_line, + resources: self.resources, + network: None, + filesystem_slot: self.filesystem_slot, + filesystem: self.filesystem, + snapshot_memory_file: self.snapshot_memory_file, + _private_scratch_dir: None, + }, + } + } +} + +/// MicroVM-derived values passed to the VM worker. +pub(super) struct WorkerFields { + pub(super) saved_state: Option, + pub(super) shared_memory: Option, + pub(super) shared_memory_copy_on_write: bool, + pub(super) snapshot_restore_guards: Option, + pub(super) snapshot_boundary_requests: Option>, + pub(super) snapshot_ready: Option>, + pub(super) snapshot_capture_enabled: bool, + pub(super) restore_time: Option, + pub(super) restore_gate_timeout: Option, + pub(super) restore_vp_count: Option, +} + +/// MicroVM-derived values passed to the VM controller. +pub(super) struct ControllerFields { + pub(super) memory_backing_file: Option, + pub(super) microvm: MicrovmController, +} + +fn openvmm_machine_profile(profile: vmservice::vm_config::MachineProfile) -> MachineProfile { + match profile { + vmservice::vm_config::MachineProfile::Standard => MachineProfile::Standard, + vmservice::vm_config::MachineProfile::Microvm => MachineProfile::Microvm, + } +} + +fn ttrpc_machine_profile(abi_version: u32) -> anyhow::Result { + anyhow::ensure!( + abi_version == MICROVM_ABI_VERSION_2, + "unsupported microVM ABI version {abi_version}; this OpenVMM supports version {MICROVM_ABI_VERSION_2}" + ); + Ok(vmservice::vm_config::MachineProfile::Microvm) +} + +fn validate_restore_console_attachments( + attachments: &[openvmm_helpers::snapshot::microvm::SnapshotAttachment], +) -> anyhow::Result<()> { + anyhow::ensure!( + !attachments.iter().any(|attachment| { + attachment.stable_id == crate::microvm::MICROVM_CONTROL_CONSOLE_STABLE_ID + || attachment.kind == crate::microvm::MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND + }), + "OpenVMM management RPC cannot restore control-console snapshots before authenticated broker activation" + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::*; + use super::*; + use openvmm_defs::microvm::MachineProfile as OpenvmmMachineProfile; + use test_with_tracing::test; + + #[test] + fn ttrpc_standard_virtio_fs_preserves_access_mode() { + for read_only in [false, true] { + let handle = build_virtio_fs(vmservice::VirtioFs { + tag: "share".to_owned(), + root_path: "host-root".to_owned(), + read_only, + ..Default::default() + }) + .unwrap(); + assert!(matches!( + handle.profile, + virtio_resources::fs::microvm::VirtioFsProfile::Standard + )); + let virtio_resources::fs::VirtioFsBackend::HostFs { + root_path, + mount_options, + } = handle.fs + else { + panic!("standard virtio-fs must use HostFs"); + }; + assert_eq!(root_path, "host-root"); + assert_eq!(mount_options, if read_only { "ro" } else { "" }); + } + } + + #[test] + fn ttrpc_standard_virtio_fs_rejects_microvm_mount_fields() { + for (guest_mount_target, read_write) in [("/share", false), ("", true)] { + let error = build_virtio_fs(vmservice::VirtioFs { + tag: "share".to_owned(), + root_path: "host-root".to_owned(), + guest_mount_target: guest_mount_target.to_owned(), + read_write, + ..Default::default() + }) + .err() + .expect("microVM mount fields must be rejected"); + assert!( + error + .to_string() + .contains("standard-machine virtio-fs does not accept microVM mount fields") + ); + } + } + + #[test] + fn management_restore_rejects_control_console_attachments_explicitly() { + let boot = openvmm_helpers::snapshot::microvm::SnapshotAttachment { + stable_id: crate::microvm::MICROVM_CONSOLE_STABLE_ID.to_owned(), + kind: crate::microvm::MICROVM_CONSOLE_ATTACHMENT_KIND.to_owned(), + required: false, + reconnect_policy: "discard-while-disconnected".to_owned(), + identity_kind: "disconnected".to_owned(), + identity: b"discard".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + }; + validate_restore_console_attachments(&[]).unwrap(); + validate_restore_console_attachments(std::slice::from_ref(&boot)).unwrap(); + + let mut control = boot.clone(); + control.stable_id = crate::microvm::MICROVM_CONTROL_CONSOLE_STABLE_ID.to_owned(); + control.kind = crate::microvm::MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND.to_owned(); + for policy in ["discard-while-disconnected", "recreate-listener"] { + control.reconnect_policy = policy.to_owned(); + let error = + validate_restore_console_attachments(&[boot.clone(), control.clone()]).unwrap_err(); + assert!( + error + .to_string() + .contains("management RPC cannot restore control-console snapshots") + ); + assert!( + error + .to_string() + .contains("before authenticated broker activation") + ); + } + } + + #[test] + fn ttrpc_microvm_profile_preserves_wire_identity_and_processor_policy() { + assert_eq!( + vmservice::vm_config::MachineProfile::Microvm as i32, + MICROVM_ABI_VERSION_2 as i32 + ); + assert_eq!( + openvmm_machine_profile(vmservice::vm_config::MachineProfile::Microvm), + OpenvmmMachineProfile::Microvm + ); + assert_eq!( + ttrpc_machine_profile(MICROVM_ABI_VERSION_2).unwrap(), + vmservice::vm_config::MachineProfile::Microvm + ); + assert!(vmservice::vm_config::MachineProfile::from_i32(1).is_none()); + assert!(ttrpc_machine_profile(1).is_err()); + + for processor_count in [1, 2, 4, 8] { + assert!(openvmm_defs::microvm::microvm_processor_count_supported( + processor_count + )); + } + for processor_count in [0, 3, 5, 16] { + assert!(!openvmm_defs::microvm::microvm_processor_count_supported( + processor_count + )); + } + } +} diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 4b3d4788291..b0ec5ce47b5 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -8,6 +8,7 @@ // The fd-passing protocol relies on `SCM_RIGHTS` and so exists only on unix. #[cfg(unix)] mod fd_passing; +mod microvm; #[cfg(unix)] use fd_passing::FdRegistry; @@ -90,6 +91,8 @@ use pal_async::task::Task; use scsidisk_resources::SimpleScsiDiskHandle; use std::fs::File; use std::future::Future; +use std::io; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -374,6 +377,7 @@ impl Worker for TtrpcWorker { controller_task: None, wait_vm_response: None, lifecycle: VmLifecycle::Uninitialized, + restore_ready_pending: false, rpc_tasks: Vec::new(), transport: self.transport, registry: FdRegistry::default(), @@ -677,6 +681,7 @@ struct VmService { controller_task: Option>, wait_vm_response: Option<(mesh::CancelContext, mesh::OneshotSender>)>, lifecycle: VmLifecycle, + restore_ready_pending: bool, rpc_tasks: Vec>, transport: ResolvedTransport, /// Registry of file descriptors passed in over the fd-passing protocol, @@ -845,14 +850,31 @@ impl VmService { } async fn create_vm(&mut self, request: vmservice::CreateVmRequest) -> anyhow::Result<()> { - let mut req_config = request.config.context("missing configuration")?; - if self.vm.is_some() { bail!("VM already created"); } + let (mut microvm, mut req_config) = microvm::CreateVm::new(request)?; validate_platform_config(&req_config)?; + let hypervisor = if microvm.is_active() { + openvmm_helpers::hypervisor::microvm::choose_microvm_hypervisor()? + } else { + openvmm_helpers::hypervisor::choose_hypervisor()? + }; + let source_hypervisor = hypervisor.id().to_owned(); + microvm.prepare_restore(source_hypervisor)?; + let restore_ready_path = microvm.take_restore_ready_path(); + let restore_ready_sink = if restore_ready_path.is_empty() { + None + } else { + Some( + crate::snapshot_restore::connect_restore_ready_sink(Path::new(&restore_ready_path)) + .context("failed to connect restore readiness endpoint")?, + ) + }; + let restore_ready_required = restore_ready_sink.is_some(); + microvm.finish_prepare(&req_config)?; let iommufds = IommufdContexts::new(std::mem::take(&mut req_config.iommufds))?; // Snapshot the fd registry so tap NIC backends can resolve descriptors @@ -874,6 +896,7 @@ impl VmService { } let any_serial_configured = ports.iter().any(|port| port.is_some()); let com1_configured = ports[0].is_some(); + let has_requested_microvm_console = microvm.has_requested_console(&req_config); #[cfg(guest_arch = "aarch64")] let arch = vm_manifest_builder::MachineArch::Aarch64; @@ -911,23 +934,33 @@ impl VmService { if isolation.is_some() { bail!("VM-service SNP isolation currently supports only IGVM boot"); } - let kernel = File::open(boot.kernel_path).context("failed to open kernel")?; + let kernel = if let Some(kernel) = microvm.restore_kernel()? { + kernel + } else { + File::open(boot.kernel_path).context("failed to open kernel")? + }; let initrd = if boot.initrd_path.is_empty() { None } else { Some(File::open(boot.initrd_path).context("failed to open initrd")?) }; + let cmdline = + microvm.command_line(boot.kernel_cmdline, has_requested_microvm_console)?; ( LoadMode::Linux { kernel, initrd, - cmdline: boot.kernel_cmdline, - enable_serial: true, + cmdline, + enable_serial: !microvm.is_active(), isolation: openvmm_defs::config::LinuxIsolationConfig::None, - boot_mode: openvmm_defs::config::LinuxDirectBootMode::Acpi, - smbios, + boot_mode: microvm.direct_boot_mode(), + smbios: if microvm.is_active() { + Box::default() + } else { + smbios + }, }, - vm_manifest_builder::BaseChipsetType::HyperVGen2LinuxDirect, + microvm.base_chipset_type(), None, None, ) @@ -1043,8 +1076,11 @@ impl VmService { } }; - let mut chipset_builder = - VmManifestBuilder::new(base_chipset_type, arch).with_serial(ports); + let ports = microvm.configure_serial_ports(ports)?; + let mut chipset_builder = VmManifestBuilder::new(base_chipset_type, arch); + if let Some(ports) = ports { + chipset_builder = chipset_builder.with_serial(ports); + } if req_config.disable_vmbus { chipset_builder = chipset_builder.without_vmbus(); } @@ -1065,15 +1101,18 @@ impl VmService { )); } let layout_config = chipset_builder.layout_config(); - let chipset = chipset_builder + let mut chipset = chipset_builder .build() .context("failed to build vm configuration")?; + microvm.add_chipset_devices(&mut chipset.chipset_devices)?; // Build the NUMA topology. A `MemoryConfig` and an explicit // `NumaConfig` are mutually exclusive (mirrors the CLI `--memory` vs // `--numa` conflict). `config_mem_size` is the total guest memory // reported to the `VmController`. - let (numa, config_mem_size) = if let Some(numa_config) = req_config.numa_config.take() { + let (numa, config_mem_size) = if let Some(restored) = microvm.restore_numa() { + restored + } else if let Some(numa_config) = req_config.numa_config.take() { if req_config.memory_config.is_some() { bail!("memory_config and numa_config are mutually exclusive"); } @@ -1104,11 +1143,7 @@ impl VmService { (numa, mem_size) }; - let config_proc_count = req_config - .processor_config - .as_ref() - .map(|c| c.processor_count) - .unwrap_or(1); + let config_proc_count = microvm.processor_count(); let arch = parse_arch_topology_overrides(req_config.processor_config.as_ref())?; // Build the PCIe topology (root complexes, switches, and the devices @@ -1167,6 +1202,7 @@ impl VmService { layout: layout_config, rtc_delta_milliseconds: 0, }; + microvm.apply_config(&mut config); let guest_power_actions = { use vmservice::vm_config::GuestPowerAction as ProtoAction; @@ -1191,7 +1227,9 @@ impl VmService { let mut scsi_rpc = None; let mut consomme_rpc = None; - if let Some(devices_config) = req_config.devices_config { + microvm.add_restored_devices(&mut config)?; + if let Some(mut devices_config) = req_config.devices_config { + microvm.take_devices(&mut config, &mut devices_config)?; if !devices_config.scsi_disks.is_empty() { let mut devices = Vec::new(); for disk in devices_config.scsi_disks { @@ -1280,6 +1318,8 @@ impl VmService { } } + microvm.finish_devices(&mut config)?; + if let Some(hvsocket_config) = req_config.hvsocket_config { let vmbus = config .vmbus @@ -1292,6 +1332,9 @@ impl VmService { vmbus.vsock_path = Some(hvsocket_config.path); } + microvm.prepare_launch(&config, config_mem_size)?; + let worker_fields = microvm.worker_fields()?; + let (send, recv) = mesh::channel(); let (notify_send, notify_recv) = mesh::channel(); @@ -1306,22 +1349,33 @@ impl VmService { .launch_worker( VM_WORKER, VmWorkerParameters { - hypervisor: openvmm_helpers::hypervisor::choose_hypervisor()?, + hypervisor, cfg: config, - saved_state: None, - shared_memory: None, - shared_memory_copy_on_write: false, - snapshot_restore_guards: None, - snapshot_boundary_requests: None, - snapshot_ready: None, - snapshot_capture_enabled: false, - restore_downtime: None, - restore_tsc_frequency_hz: None, - restore_apic_frequency_hz: None, - restore_cpu_contract: None, - restore_ready_sink: None, - restore_gate_timeout: None, - restore_vp_count: None, + saved_state: worker_fields.saved_state, + shared_memory: worker_fields.shared_memory, + shared_memory_copy_on_write: worker_fields.shared_memory_copy_on_write, + snapshot_restore_guards: worker_fields.snapshot_restore_guards, + snapshot_boundary_requests: worker_fields.snapshot_boundary_requests, + snapshot_ready: worker_fields.snapshot_ready, + snapshot_capture_enabled: worker_fields.snapshot_capture_enabled, + restore_downtime: worker_fields + .restore_time + .as_ref() + .map(|(downtime, _, _, _)| *downtime), + restore_tsc_frequency_hz: worker_fields + .restore_time + .as_ref() + .map(|(_, frequency, _, _)| *frequency), + restore_apic_frequency_hz: worker_fields + .restore_time + .as_ref() + .and_then(|(_, _, frequency, _)| *frequency), + restore_cpu_contract: worker_fields + .restore_time + .map(|(_, _, _, cpu_contract)| cpu_contract), + restore_ready_sink, + restore_gate_timeout: worker_fields.restore_gate_timeout, + restore_vp_count: worker_fields.restore_vp_count, rpc: recv, notify: notify_send, }, @@ -1334,6 +1388,7 @@ impl VmService { // Create channels for VmController. let (vm_controller_send, vm_controller_recv) = mesh::channel(); let (event_send, event_recv) = mesh::channel(); + let controller_fields = microvm.into_controller(); // Build VmController with no paravisor-specific fields. let controller = VmController { @@ -1347,12 +1402,12 @@ impl VmService { vm_rpc: send.clone(), paravisor_diag: None, igvm_path, - memory_backing_file: None, + memory_backing_file: controller_fields.memory_backing_file, memory, processors, log_file: None, crash_dump_path: req_config.crash_dump_path.map(Into::into), - microvm: Default::default(), + microvm: controller_fields.microvm, guest_power_actions, }; @@ -1372,6 +1427,7 @@ impl VmService { worker_rpc: send, iommufds: Arc::new(iommufds), })); + self.restore_ready_pending = restore_ready_required; self.lifecycle = VmLifecycle::Paused; Ok(()) } @@ -1390,6 +1446,7 @@ impl VmService { task.await; } self.vm_controller_events.take(); + self.restore_ready_pending = false; self.lifecycle = VmLifecycle::Uninitialized; if let Some((_, response)) = self.wait_vm_response.take() { response.send(Err(grpc_error(anyhow!("VM torn down")))); @@ -1455,11 +1512,22 @@ impl VmService { async fn resume_vm(&mut self) -> anyhow::Result<()> { let vm = self.vm.clone().context("VM not created yet")?; - vm.worker_rpc - .call_failable(VmRpc::Resume, ()) - .await - .map(drop) - .context("resume failed")?; + let resumed = match vm.worker_rpc.call_failable(VmRpc::Resume, ()).await { + Ok(resumed) => resumed, + Err(error) => { + if self.restore_ready_pending { + self.teardown_vm() + .await + .context("failed to tear down VM after restore readiness failure")?; + } + return Err(error).context("resume failed"); + } + }; + anyhow::ensure!( + resumed, + "VM did not resume; a state unit failed to start or the VM was already running" + ); + self.restore_ready_pending = false; if !matches!(self.lifecycle, VmLifecycle::Halted(_)) { self.lifecycle = VmLifecycle::Running; } @@ -1700,7 +1768,7 @@ impl VmService { fn open_socket_backend( connect: bool, ) -> ( - fn(&std::path::Path) -> std::io::Result>, + fn(&Path) -> io::Result>, &'static str, ) { if connect { @@ -2206,7 +2274,7 @@ fn build_vfio_device( if host_pci_address.contains('/') || host_pci_address.contains("..") { anyhow::bail!("PCI address must not contain path separators"); } - let sysfs_path = std::path::Path::new("/sys/bus/pci/devices").join(&host_pci_address); + let sysfs_path = Path::new("/sys/bus/pci/devices").join(&host_pci_address); if let Some(iommu_id) = iommufd_id { let iommufd = iommufds.get(&iommu_id)?; let vfio_dev_dir = sysfs_path.join("vfio-dev"); @@ -2220,7 +2288,7 @@ fn build_vfio_device( .next() .context("no vfio-dev entry found")? .context("failed to read vfio-dev entry")?; - let dev_path = std::path::Path::new("/dev/vfio/devices").join(entry.file_name()); + let dev_path = Path::new("/dev/vfio/devices").join(entry.file_name()); let cdev = File::options() .read(true) .write(true) @@ -2400,8 +2468,14 @@ fn build_virtio_fs( tag, root_path, read_only, + guest_mount_target, + read_write, } = config; const VIRTIO_FS_TAG_LEN: usize = 36; + anyhow::ensure!( + guest_mount_target.is_empty() && !read_write, + "standard-machine virtio-fs does not accept microVM mount fields" + ); anyhow::ensure!(!tag.is_empty(), "virtio-fs tag must not be empty"); anyhow::ensure!( !tag.contains('\0'), diff --git a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto index 8842c942771..5693abb795f 100644 --- a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto +++ b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto @@ -613,6 +613,13 @@ message PcieAttachment { } message VMConfig { + // Guest-visible machine contract. + enum MachineProfile { + STANDARD = 0; + // Deterministic SMP-capable microVM ABI. + MICROVM = 2; + } + // Action for the VMM to take when the guest triggers a power event. enum GuestPowerAction { // Use the default for this event. @@ -671,12 +678,15 @@ message VMConfig { // so AddPcieDevice can reference them. No runtime context add/remove. repeated IommufdConfig iommufds = 15; // Run without VMBus. Incompatible with VMBus devices and HVSocket. - bool disable_vmbus = 16; + // Upstream assigns field 16, which this fork uses for machine_profile. + bool disable_vmbus = 20; // Do not expose Hyper-V (HV#1) enlightenments. Requires disable_vmbus. // Unsupported for x86_64 UEFI boot. bool disable_hv = 17; // Confidential-computing isolation mode. IsolationConfig isolation_config = 19; + // Machine selection is independent from hypervisor selection. + MachineProfile machine_profile = 16; } // WindowsOptions contains virtual machine configurations that are only present on a Windows host. @@ -747,12 +757,39 @@ message SMBIOSConfig { System system = 2; } +// Snapshot options for the microVM machine profile. +message MicrovmSnapshotConfig { + // Publish a guest-requested snapshot to this previously nonexistent path. + string destination_path = 1; + // Restore authoritatively from this snapshot directory. + string restore_path = 2; + // Expose fresh host entropy through the private portb restore channel. + bool restore_entropy = 3; + // Maximum quiesce time for capture. Zero selects the five-second default. + uint64 quiesce_timeout_ms = 4; + // Unix socket (Linux) or named pipe (Windows) that receives exactly one + // OPENVMM_RESTORE_READY_V1 event before restored guest execution begins. + string restore_ready_path = 5; + // Bring this contiguous prefix of capacity VPs online before restore readiness. + // Zero preserves legacy restore behavior. + uint32 restore_processor_count = 6; + // Maximum time allowed for gated guest repair. Zero selects 60 seconds. + uint64 restore_gate_timeout_ms = 7; + // Immutable total RAM capacity reserved by a captured microVM snapshot. + // Zero disables restore-time memory expansion. + uint64 memory_capacity_bytes = 8; + // Per-launch total RAM target for restore. Zero selects the snapshot base. + uint64 restore_memory_bytes = 9; +} + message CreateVMRequest { VMConfig config = 1; // Optional ID to be used by the VM service in log messages. It's up to the // server/virtstack to make use of this field. Useful for debugging to be able to // correlate events in the virtstack for a given vm that the client launched. string log_id = 2; + // Optional microVM snapshot capture or restore configuration. + MicrovmSnapshotConfig microvm_snapshot = 3; } message MemoryStats { @@ -906,6 +943,12 @@ message VirtioFs { string tag = 1; string root_path = 2; bool read_only = 3; + // Absolute guest target for the microVM fixed HostFs device. + // Must be empty for a standard-machine virtio-fs device. + string guest_mount_target = 4; + // Enable the microVM common read-write contract. False selects read-only. + // Must be false for a standard-machine virtio-fs device. + bool read_write = 5; } message VirtioConsoleConfig { diff --git a/vmm_tests/vmm_tests/tests/tests/ttrpc.rs b/vmm_tests/vmm_tests/tests/tests/ttrpc.rs index bd6a6da4ac8..67f50c52999 100644 --- a/vmm_tests/vmm_tests/tests/tests/ttrpc.rs +++ b/vmm_tests/vmm_tests/tests/tests/ttrpc.rs @@ -8,12 +8,14 @@ // Linux. #[cfg(target_os = "linux")] mod fd_passing; +mod microvm; use anyhow::Context; use futures::AsyncBufReadExt; use futures::AsyncReadExt; use guid::Guid; use mesh::CancelContext; +use microvm::test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot; use openvmm_ttrpc_vmservice as vmservice; use pal_async::DefaultDriver; use pal_async::pipe::PolledPipe; @@ -34,6 +36,13 @@ use std::time::Duration; use unix_socket::UnixListener; use unix_socket::UnixStream; +// Registered here, rather than in the `microvm` module, to keep the test named +// `ttrpc::test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot`. +petri::test!( + test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot, + microvm::lifecycle_and_snapshot_artifacts +); + petri::test!(test_ttrpc_interface, |resolver| { let openvmm = resolver.require(artifacts::OPENVMM_NATIVE); let kernel = resolver.require(artifacts::loadable::LINUX_DIRECT_TEST_KERNEL_NATIVE); @@ -164,6 +173,7 @@ async fn test_ttrpc_no_vmbus( ..Default::default() }), log_id: String::new(), + microvm_snapshot: None, }, ) .await @@ -311,6 +321,7 @@ async fn test_ttrpc_interface( vmservice::CreateVmRequest { config: Some(vmservice::VmConfig::default()), log_id: String::new(), + microvm_snapshot: None, }, ) .await @@ -616,6 +627,7 @@ async fn test_ttrpc_interface( tag: "testfs".to_string(), root_path: virtiofs_root.to_string_lossy().into(), read_only: i == 0, + ..Default::default() }], // A SCSI controller keeps a request channel // alive for the lifetime of the VM, which used @@ -637,6 +649,7 @@ async fn test_ttrpc_interface( ..Default::default() }), log_id: String::new(), + microvm_snapshot: None, }, ) .await @@ -769,6 +782,7 @@ async fn test_ttrpc_interface( tag: "hotplugfs".to_string(), root_path: hotplug_virtiofs_root.to_string_lossy().into(), read_only: true, + ..Default::default() }, ))), }, @@ -1103,6 +1117,7 @@ async fn test_ttrpc_uefi_boot( ..Default::default() }), log_id: String::new(), + microvm_snapshot: None, }, ) .await @@ -1296,6 +1311,7 @@ fn virtio_fs_vpci_request( tag: tag.to_string(), root_path: root_path.to_string_lossy().into_owned(), read_only, + ..Default::default() }, ))), } diff --git a/vmm_tests/vmm_tests/tests/tests/ttrpc/fd_passing.rs b/vmm_tests/vmm_tests/tests/tests/ttrpc/fd_passing.rs index addf32758a7..5c190f85988 100644 --- a/vmm_tests/vmm_tests/tests/tests/ttrpc/fd_passing.rs +++ b/vmm_tests/vmm_tests/tests/tests/ttrpc/fd_passing.rs @@ -288,5 +288,6 @@ fn create_vm_request( ..Default::default() }), log_id: String::new(), + microvm_snapshot: None, } } diff --git a/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs b/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs new file mode 100644 index 00000000000..7283642bf71 --- /dev/null +++ b/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs @@ -0,0 +1,707 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! microVM lifecycle and snapshot tests for the TTRPC interface. + +use super::launch_openvmm; +use anyhow::Context; +use futures::AsyncBufReadExt; +use futures::AsyncReadExt; +use futures::AsyncWriteExt; +#[cfg(windows)] +use guid::Guid; +use mesh::CancelContext; +use openvmm_ttrpc_vmservice as vmservice; +use pal_async::DefaultDriver; +#[cfg(windows)] +use pal_async::pipe::PolledPipe; +use pal_async::socket::PolledSocket; +#[cfg(windows)] +use pal_async::windows::pipe::ListeningPipe; +#[cfg(windows)] +use pal_async::windows::pipe::NamedPipeServer; +use petri::ResolvedArtifact; +use petri_artifacts_vmm_test::artifacts; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +#[cfg(unix)] +use unix_socket::UnixListener; +use unix_socket::UnixStream; + +/// Resolves the artifacts of +/// [`test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot`], which is +/// registered by the parent module to keep its `ttrpc::` test name. +pub(super) fn lifecycle_and_snapshot_artifacts( + resolver: &petri::ArtifactResolver<'_>, +) -> Option<[ResolvedArtifact; 3]> { + Some([ + resolver.require(artifacts::OPENVMM_NATIVE).erase(), + resolver + .require(artifacts::loadable::LINUX_DIRECT_TEST_KERNEL_X64) + .erase(), + resolver + .require(artifacts::loadable::LINUX_DIRECT_TEST_INITRD_X64) + .erase(), + ]) +} + +fn microvm_portb_config(path: &Path) -> vmservice::SerialConfig { + vmservice::SerialConfig { + ports: vec![vmservice::serial_config::Config { + port: 0, + socket_path: path.to_string_lossy().into_owned(), + connect: false, + }], + } +} + +async fn read_restore_ready_event( + reader: impl futures::AsyncRead + Unpin, +) -> anyhow::Result> { + let mut reader = futures::io::BufReader::new(reader); + let mut bytes = Vec::new(); + CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(reader.read_until(b'\n', &mut bytes)) + .await + .with_context(|| { + format!( + "timed out after {} bytes of the restore readiness event", + bytes.len() + ) + })??; + Ok(bytes) +} + +#[cfg(unix)] +struct RestoreReadyListener { + path: PathBuf, + listener: UnixListener, +} + +#[cfg(windows)] +struct RestoreReadyListener { + path: PathBuf, + _server: NamedPipeServer, + listener: ListeningPipe, +} + +#[cfg(unix)] +impl RestoreReadyListener { + fn bind(_driver: &DefaultDriver, path: PathBuf) -> anyhow::Result { + let listener = UnixListener::bind(&path)?; + Ok(Self { path, listener }) + } + + async fn read_event(self, driver: &DefaultDriver) -> anyhow::Result> { + let mut listener = PolledSocket::new(driver, self.listener)?; + let (connection, _) = listener.accept().await?; + read_restore_ready_event(PolledSocket::new(driver, connection)?).await + } +} + +#[cfg(windows)] +impl RestoreReadyListener { + fn bind(driver: &DefaultDriver, _path: PathBuf) -> anyhow::Result { + let path = PathBuf::from(format!( + "//./pipe/openvmm-restore-ready-{}-{}", + std::process::id(), + Guid::new_random() + )); + let server = NamedPipeServer::create(&path)?; + let listener = server.accept(driver)?; + Ok(Self { + path, + _server: server, + listener, + }) + } + + async fn read_event(self, driver: &DefaultDriver) -> anyhow::Result> { + let connection = CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(self.listener) + .await + .context("timed out accepting restore readiness pipe")??; + read_restore_ready_event(PolledPipe::new(driver, connection)?).await + } +} + +impl RestoreReadyListener { + fn path(&self) -> &Path { + &self.path + } +} + +fn microvm_restore_request( + snapshot_path: &Path, + portb_path: &Path, + restore_ready_path: &Path, + restore_memory_bytes: u64, +) -> vmservice::CreateVmRequest { + vmservice::CreateVmRequest { + config: Some(vmservice::VmConfig { + serial_config: Some(microvm_portb_config(portb_path)), + machine_profile: vmservice::vm_config::MachineProfile::Microvm as i32, + ..Default::default() + }), + log_id: String::new(), + microvm_snapshot: Some(vmservice::MicrovmSnapshotConfig { + restore_path: snapshot_path.to_string_lossy().into_owned(), + restore_entropy: true, + restore_processor_count: 1, + restore_memory_bytes, + restore_ready_path: restore_ready_path.to_string_lossy().into_owned(), + ..Default::default() + }), + } +} + +pub(super) async fn test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot( + params: petri::PetriTestParams<'_>, + driver: DefaultDriver, + [openvmm, kernel, initrd]: [ResolvedArtifact; 3], +) -> anyhow::Result<()> { + use std::hash::Hash; + use std::hash::Hasher; + + const MEMORY_MB: u64 = 128; + const READY_MARKER: &[u8] = b"OPENVMM-LINUX-MPTABLE-SNAPSHOT-READY"; + const SNAPSHOT_CONTINUED_MARKER: &[u8] = b"SNAPSHOT-CONTINUED=1"; + const STATE_MARKER: &[u8] = b"STATE=1"; + const COMMAND_STATE: u8 = 5; + const COMMAND_SHUTDOWN: u8 = 6; + const KERNEL_OVERRIDE: &str = "OPENVMM_MICROVM_TEST_KERNEL"; + const INITRD_OVERRIDE: &str = "OPENVMM_MICROVM_TEST_INITRD"; + + let portb_output = |bytes: &[u8]| -> anyhow::Result { + use std::fmt::Write as _; + let mut output = String::new(); + for byte in bytes { + writeln!( + output, + "printf '\\x{:02x}' | dd of=/dev/port bs=1 seek=233 count=1 conv=notrunc 2>/dev/null", + byte + )?; + } + Ok(output) + }; + + let kernel_override = std::env::var_os(KERNEL_OVERRIDE).map(PathBuf::from); + let initrd_override = std::env::var_os(INITRD_OVERRIDE).map(PathBuf::from); + anyhow::ensure!( + kernel_override.is_some() == initrd_override.is_some(), + "{KERNEL_OVERRIDE} and {INITRD_OVERRIDE} must be set together" + ); + let kernel_path = kernel_override.as_deref().unwrap_or_else(|| kernel.get()); + let initrd_path = initrd_override.as_deref().unwrap_or_else(|| initrd.get()); + anyhow::ensure!( + kernel_path.is_file(), + "microVM test kernel does not exist: {}", + kernel_path.display() + ); + anyhow::ensure!( + initrd_path.is_file(), + "microVM test initrd does not exist: {}", + initrd_path.display() + ); + let processor_counts: &[u32] = if kernel_override.is_some() { + &[1, 2, 4, 8] + } else { + // The packaged OpenVMM dependency kernel has CONFIG_X86_MPPARSE disabled. + &[1] + }; + + let base_initrd = std::fs::read(initrd_path) + .with_context(|| format!("failed to read initrd {}", initrd_path.display()))?; + for &processor_count in processor_counts { + let ready_marker = format!("OPENVMM-LINUX-MPTABLE-READY-{processor_count}"); + let cpu_failure_marker = format!("OPENVMM-LINUX-MPTABLE-CPU-FAILED-{processor_count}"); + let ioapic_failure_marker = + format!("OPENVMM-LINUX-MPTABLE-IOAPIC-FAILED-{processor_count}"); + let acpi_failure_marker = format!("OPENVMM-LINUX-MPTABLE-ACPI-FAILED-{processor_count}"); + let smbios_failure_marker = + format!("OPENVMM-LINUX-MPTABLE-SMBIOS-FAILED-{processor_count}"); + let script = format!( + "#!/bin/busybox sh\n\ + mount -t devtmpfs devtmpfs /dev 2>/dev/null || true\n\ + mount -t proc proc /proc 2>/dev/null || true\n\ + mount -t sysfs sysfs /sys 2>/dev/null || true\n\ + cpus=$(grep -c '^processor' /proc/cpuinfo)\n\ + ioapic=0\n\ + grep -Eqi 'IO-?APIC' /proc/iomem && ioapic=1\n\ + grep -Eqi 'IO-?APIC' /proc/interrupts && ioapic=1\n\ + dmesg | grep -Eqi 'IO-?APIC' && ioapic=1\n\ + if [ \"$cpus\" != \"{processor_count}\" ]; then\n\ + {}\ + elif [ \"$ioapic\" != 1 ]; then\n\ + {}\ + elif [ -d /sys/firmware/acpi ]; then\n\ + {}\ + elif [ -e /sys/firmware/dmi/tables/DMI ] \\\n\ + || [ -e /sys/firmware/dmi/tables/smbios_entry_point ]; then\n\ + {}\ + else\n\ + {}\ + fi\n\ + printf '\\x00' | dd of=/dev/port bs=1 seek=1540 count=1 conv=notrunc 2>/dev/null\n\ + while :; do sleep 1; done\n", + portb_output(cpu_failure_marker.as_bytes())?, + portb_output(ioapic_failure_marker.as_bytes())?, + portb_output(acpi_failure_marker.as_bytes())?, + portb_output(smbios_failure_marker.as_bytes())?, + portb_output(ready_marker.as_bytes())?, + ); + let probe_dir = if cfg!(target_os = "linux") { + tempfile::Builder::new() + .prefix("openvmm-ttrpc-linux-mptable-probe-") + .tempdir_in("/tmp") + } else { + tempfile::tempdir() + }?; + let probe_initrd = initrd_cpio::inject_into_initrd( + &base_initrd, + "microvm-test", + script.as_bytes(), + 0o100755, + ) + .context("failed to inject the microVM lifecycle script")?; + let probe_initrd_path = probe_dir.path().join("microvm-initrd.cpio.gz"); + std::fs::write(&probe_initrd_path, probe_initrd) + .context("failed to write the microVM lifecycle initrd")?; + let rpc_path = probe_dir.path().join("rpc.sock"); + let pidfile_path = probe_dir.path().join("openvmm.pid"); + let portb_path = probe_dir.path().join("portb.sock"); + let (mut child, client, _stderr_task) = + launch_openvmm(&driver, ¶ms, &openvmm, &rpc_path, &pidfile_path).await?; + client + .call() + .start( + vmservice::Vm::CreateVm, + vmservice::CreateVmRequest { + config: Some(vmservice::VmConfig { + memory_config: Some(vmservice::MemoryConfig { + memory_mb: MEMORY_MB, + ..Default::default() + }), + processor_config: Some(vmservice::ProcessorConfig { + processor_count, + ..Default::default() + }), + serial_config: Some(microvm_portb_config(&portb_path)), + boot_config: Some(vmservice::vm_config::BootConfig::DirectBoot( + vmservice::DirectBoot { + kernel_path: kernel_path.to_string_lossy().into_owned(), + initrd_path: probe_initrd_path.to_string_lossy().into_owned(), + kernel_cmdline: "rdinit=/microvm-test".to_owned(), + }, + )), + machine_profile: vmservice::vm_config::MachineProfile::Microvm as i32, + ..Default::default() + }), + log_id: String::new(), + microvm_snapshot: None, + }, + ) + .await + .map_err(|status| { + anyhow::anyhow!( + "Linux direct {processor_count}-vCPU CreateVM failed: {}", + status.message + ) + })?; + let portb = PolledSocket::new(&driver, UnixStream::connect(&portb_path)?)?; + let (mut portb_read, _portb_write) = portb.split(); + client + .call() + .start(vmservice::Vm::ResumeVm, ()) + .await + .map_err(|status| { + anyhow::anyhow!( + "Linux direct {processor_count}-vCPU ResumeVM failed: {}", + status.message + ) + })?; + let mut output = Vec::new(); + wait_for_bytes(&mut portb_read, &mut output, ready_marker.as_bytes()).await?; + CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(drain_until_closed(&mut portb_read, &mut output)) + .await + .with_context(|| { + format!("timed out draining Linux direct {processor_count}-vCPU server") + })??; + anyhow::ensure!( + child.wait().await?.success(), + "Linux direct {processor_count}-vCPU server failed" + ); + } + + let script = format!( + r#"#!/bin/busybox sh +mount -t devtmpfs devtmpfs /dev 2>/dev/null || true +read_port() {{ + dd if=/dev/port bs=1 skip="$1" count=1 2>/dev/null | od -An -tu1 | tr -d ' ' +}} +write_port() {{ + octal=$(printf '%03o' "$2") + printf "\\$octal" | dd of=/dev/port bs=1 seek="$1" count=1 conv=notrunc 2>/dev/null +}} +stty -F /dev/hvc0 raw -echo +next_byte() {{ + dd if=/dev/hvc0 bs=1 count=1 2>/dev/null | od -An -tu1 | tr -d ' ' +}} +generation=0 +{} +write_port 1541 0 +generation=$((generation + 1)) +{} +status=$(read_port 234) +if [ $((status & 8)) -eq 0 ]; then +{} + write_port 1540 255 + while :; do sleep 1; done +fi +write_port 234 165 +index=0 +while [ "$index" -lt 19 ]; do + read_port 233 >/dev/null + index=$((index + 1)) +done +target=$(read_port 233) +range_count=$(read_port 233) +remaining=$((range_count * 16 + 64)) +index=0 +while [ "$index" -lt "$remaining" ]; do + read_port 233 >/dev/null + index=$((index + 1)) +done +write_port 1541 2 +if [ "$target" != 1 ]; then +{} + write_port 1540 255 + while :; do sleep 1; done +fi +case "$range_count" in + 0) +{} + ;; + 1) +{} + ;; + *) +{} + write_port 1540 255 + while :; do sleep 1; done + ;; +esac +while :; do + command=$(next_byte) + case "$command" in + {COMMAND_STATE}) + if [ "$generation" = 1 ]; then +{} + else +{} + fi + ;; + {COMMAND_SHUTDOWN}) + exit_status=$(next_byte) + write_port 1540 "$exit_status" + while :; do sleep 1; done + ;; + *) +{} + ;; + esac +done +"#, + portb_output(READY_MARKER)?, + portb_output(SNAPSHOT_CONTINUED_MARKER)?, + portb_output(b"RESTORE-PACKET-MISSING")?, + portb_output(b"RESTORE-TARGET-INVALID")?, + portb_output(b"RESTORE-TARGET=1 MEMORY-RANGES=0")?, + portb_output(b"RESTORE-TARGET=1 MEMORY-RANGES=1")?, + portb_output(b"RESTORE-RANGE-COUNT-INVALID")?, + portb_output(STATE_MARKER)?, + portb_output(b"STATE-INVALID")?, + portb_output(b"UNKNOWN-COMMAND")?, + ); + + let tempdir = if cfg!(target_os = "linux") { + tempfile::Builder::new() + .prefix("openvmm-ttrpc-linux-mptable-") + .tempdir_in("/tmp") + } else { + tempfile::tempdir() + }?; + let snapshot_path = tempdir.path().join("snapshot"); + let fingerprint = |path: &Path| -> anyhow::Result { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + std::fs::read(path)?.hash(&mut hasher); + Ok(hasher.finish()) + }; + let initrd_data = + initrd_cpio::inject_into_initrd(&base_initrd, "microvm-test", script.as_bytes(), 0o100755) + .context("failed to inject the microVM snapshot script")?; + let initrd = tempdir.path().join("microvm-initrd.cpio.gz"); + std::fs::write(&initrd, initrd_data).context("failed to write the microVM test initrd")?; + + let rpc_path = tempdir.path().join("capture-rpc.sock"); + let pidfile_path = tempdir.path().join("capture.pid"); + let portb_path = tempdir.path().join("capture-portb.sock"); + let (mut child, client, _stderr_task) = + launch_openvmm(&driver, ¶ms, &openvmm, &rpc_path, &pidfile_path).await?; + client + .call() + .start( + vmservice::Vm::CreateVm, + vmservice::CreateVmRequest { + config: Some(vmservice::VmConfig { + memory_config: Some(vmservice::MemoryConfig { + memory_mb: MEMORY_MB, + ..Default::default() + }), + processor_config: Some(vmservice::ProcessorConfig { + processor_count: 2, + ..Default::default() + }), + serial_config: Some(microvm_portb_config(&portb_path)), + boot_config: Some(vmservice::vm_config::BootConfig::DirectBoot( + vmservice::DirectBoot { + kernel_path: kernel_path.to_string_lossy().into_owned(), + initrd_path: initrd.to_string_lossy().into_owned(), + kernel_cmdline: "maxcpus=1 rdinit=/microvm-test".to_owned(), + }, + )), + machine_profile: vmservice::vm_config::MachineProfile::Microvm as i32, + ..Default::default() + }), + log_id: String::new(), + microvm_snapshot: Some(vmservice::MicrovmSnapshotConfig { + destination_path: snapshot_path.to_string_lossy().into_owned(), + quiesce_timeout_ms: 5_000, + memory_capacity_bytes: 512 * 1024 * 1024, + ..Default::default() + }), + }, + ) + .await + .map_err(|status| { + anyhow::anyhow!("Linux direct capture CreateVM failed: {}", status.message) + })?; + let portb = PolledSocket::new(&driver, UnixStream::connect(&portb_path)?)?; + let (mut portb_read, _portb_write) = portb.split(); + client + .call() + .start(vmservice::Vm::ResumeVm, ()) + .await + .map_err(|status| { + anyhow::anyhow!("Linux direct capture ResumeVM failed: {}", status.message) + })?; + let mut source_output = Vec::new(); + wait_for_bytes(&mut portb_read, &mut source_output, READY_MARKER).await?; + CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(drain_until_closed(&mut portb_read, &mut source_output)) + .await + .context("timed out draining Linux direct capture source")??; + anyhow::ensure!( + child.wait().await?.success(), + "Linux direct capture server failed" + ); + let (manifest, _) = + openvmm_helpers::snapshot::restore::read_snapshot(&snapshot_path, MEMORY_MB * 1024 * 1024)?; + let contract = manifest + .machine_contract + .context("captured microVM snapshot is missing its machine contract")?; + assert_eq!( + contract.boot_layout_version, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION + ); + anyhow::ensure!( + !pidfile_path.exists(), + "Linux direct capture source PID remained after snapshot commit" + ); + + let before = ["manifest.bin", "state.bin", "memory.bin"] + .map(|name| fingerprint(&snapshot_path.join(name))) + .into_iter() + .collect::>>()?; + + for restore_index in 0..2 { + let restore_memory_bytes = if restore_index == 0 { + MEMORY_MB * 1024 * 1024 + } else { + 256 * 1024 * 1024 + }; + let restore_target_marker = if restore_index == 0 { + b"RESTORE-TARGET=1 MEMORY-RANGES=0".as_slice() + } else { + b"RESTORE-TARGET=1 MEMORY-RANGES=1".as_slice() + }; + let rpc_path = tempdir + .path() + .join(format!("restore-{restore_index}-rpc.sock")); + let pidfile_path = tempdir.path().join(format!("restore-{restore_index}.pid")); + let portb_path = tempdir + .path() + .join(format!("restore-{restore_index}-portb.sock")); + let restore_ready = RestoreReadyListener::bind( + &driver, + tempdir + .path() + .join(format!("restore-{restore_index}-ready.sock")), + )?; + let (mut child, client, _stderr_task) = + launch_openvmm(&driver, ¶ms, &openvmm, &rpc_path, &pidfile_path).await?; + client + .call() + .start( + vmservice::Vm::CreateVm, + microvm_restore_request( + &snapshot_path, + &portb_path, + restore_ready.path(), + restore_memory_bytes, + ), + ) + .await + .map_err(|status| { + anyhow::anyhow!( + "Linux direct restore {restore_index} CreateVM failed: {}", + status.message + ) + })?; + let portb = PolledSocket::new(&driver, UnixStream::connect(&portb_path)?)?; + let (mut portb_read, mut portb_write) = portb.split(); + let mut resume_cancel = CancelContext::new().with_timeout(Duration::from_secs(15)); + let (resume, readiness) = futures::join!( + resume_cancel.until_cancelled(client.call().start(vmservice::Vm::ResumeVm, ())), + restore_ready.read_event(&driver) + ); + resume + .with_context(|| format!("timed out resuming Linux direct restore {restore_index}"))? + .map_err(|status| { + anyhow::anyhow!( + "Linux direct restore {restore_index} ResumeVM failed: {}", + status.message + ) + })?; + anyhow::ensure!( + readiness? == openvmm_defs::worker::RESTORE_READY_EVENT_V1, + "Linux direct restore {restore_index} did not publish readiness" + ); + + let mut restore_output = Vec::new(); + wait_for_bytes( + &mut portb_read, + &mut restore_output, + SNAPSHOT_CONTINUED_MARKER, + ) + .await + .with_context(|| { + format!("Linux direct restore {restore_index} did not continue after snapshot") + })?; + wait_for_bytes(&mut portb_read, &mut restore_output, restore_target_marker) + .await + .with_context(|| { + format!("Linux direct restore {restore_index} did not report restore targets") + })?; + portb_write.write_all(&[COMMAND_STATE]).await?; + portb_write.flush().await?; + wait_for_bytes(&mut portb_read, &mut restore_output, STATE_MARKER) + .await + .with_context(|| { + format!("Linux direct restore {restore_index} did not answer state query") + })?; + portb_write.write_all(&[COMMAND_SHUTDOWN, 0]).await?; + portb_write.flush().await?; + CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(drain_until_closed(&mut portb_read, &mut restore_output)) + .await + .with_context(|| { + let tail = restore_output.len().saturating_sub(256); + format!( + "timed out draining Linux direct restore {restore_index} after output {:?}", + String::from_utf8_lossy(&restore_output[tail..]) + ) + })??; + let status = CancelContext::new() + .with_timeout(Duration::from_secs(15)) + .until_cancelled(child.wait()) + .await + .with_context(|| { + format!("timed out waiting for Linux direct restore {restore_index} server") + })??; + anyhow::ensure!( + status.success(), + "Linux direct restore {restore_index} server failed" + ); + anyhow::ensure!( + !pidfile_path.exists(), + "Linux direct restore {restore_index} PID remained after shutdown" + ); + let after = ["manifest.bin", "state.bin", "memory.bin"] + .map(|name| fingerprint(&snapshot_path.join(name))) + .into_iter() + .collect::>>()?; + anyhow::ensure!( + before == after, + "Linux direct restore {restore_index} modified snapshot artifacts" + ); + } + Ok(()) +} + +async fn wait_for_bytes( + reader: &mut (impl futures::AsyncRead + Unpin), + output: &mut Vec, + marker: &[u8], +) -> anyhow::Result<()> { + CancelContext::new() + .with_timeout(Duration::from_secs(60)) + .until_cancelled(async { + let mut buffer = [0_u8; 4096]; + loop { + let count = reader.read(&mut buffer).await?; + anyhow::ensure!( + count != 0, + "portb closed before the expected marker after bytes {:?}", + String::from_utf8_lossy(&output[..output.len().min(256)]) + ); + output.extend_from_slice(&buffer[..count]); + if output.windows(marker.len()).any(|window| window == marker) { + return Ok(()); + } + } + }) + .await + .context("timed out waiting for portb output")? +} + +async fn drain_until_closed( + reader: &mut (impl futures::AsyncRead + Unpin), + output: &mut Vec, +) -> std::io::Result<()> { + let mut buffer = [0_u8; 4096]; + loop { + match reader.read(&mut buffer).await { + Ok(0) => return Ok(()), + Ok(count) => output.extend_from_slice(&buffer[..count]), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::ConnectionAborted + | std::io::ErrorKind::BrokenPipe + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + } + } +} From 9a60f9f1348df12d5151bac758293a664e1ec037 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:51:11 -0700 Subject: [PATCH 156/304] ttrpc: propagate nonzero guest exit status A microVM guest can end its run with a process status through the shutdown port, and on the command line that status becomes the OpenVMM exit status. The management server only logged such exits, so a client waiting in `WaitVM` never learned that the guest had failed, and the server kept running a VM that had already exited. Handle guest exits in the server: - A zero exit status completes a pending `WaitVM` successfully, marks the VM halted, and stops the server cleanly. - A nonzero status, or a failed output drain before exit, fails the pending `WaitVM` with the reason, marks the VM halted, and ends the server with that error, so the process exits with a failure. The controller-event handler now reports whether an event ends the server, and the server returns the recorded exit error after its RPC task has stopped. The CLI reference notes that a drain failure in RPC-server mode fails pending `WaitVM` requests and terminates the server. A test runs the RPC service against a successful exit, a failed exit, and a drain failure, and checks both the service result and the `WaitVM` response. Co-authored-by: Ruize Tang <1466040111@qq.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 2 + openvmm/openvmm_entry/src/ttrpc/microvm.rs | 94 +++++++++++++++++++ openvmm/openvmm_entry/src/ttrpc/mod.rs | 42 ++++++--- 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 6d4328b77c0..4ebf162bef6 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -796,6 +796,8 @@ configured to exit, drains previously accepted portb console output before the process exits. This includes the host stdout/stderr relay, not just the device's transmit buffer. Draining is bounded to five seconds; an output error or timeout is reported as a failure rather than a successful exit with truncated output. +In RPC-server mode, a drain failure also fails pending `WaitVm` requests and +terminates the server with an error instead of leaving it uninitialized. Snapshot capture still preserves pending console bytes in the snapshot. * `--crash-dump-path `: when the guest triple-faults, write a diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index c854427b38d..77a6a915013 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -3,9 +3,12 @@ //! MicroVM-specific support for the management RPC endpoint. +use super::VmLifecycle; +use super::grpc_error; use crate::cli_args::SerialConfigCli; use crate::serial_io::connect::bind_serial_without_cleanup; use crate::vm_controller::MicrovmController; +use crate::vm_controller::VmControllerEvent; use anyhow::Context; use chipset_resources::microvm::MicrovmPortbHandle; use chipset_resources::microvm::MicrovmShutdownHandle; @@ -1294,6 +1297,43 @@ pub(super) struct ControllerFields { pub(super) microvm: MicrovmController, } +/// Handles controller events emitted by the microVM guest-exit path. +pub(super) fn handle_exit_event( + event: VmControllerEvent, + lifecycle: &mut VmLifecycle, + wait_vm_response: &mut Option<( + mesh::CancelContext, + mesh::OneshotSender>, + )>, +) -> anyhow::Result { + match event { + VmControllerEvent::ExitRequested { code } => { + let reason = format!("guest exited with status {code}"); + tracing::info!(code, "guest halted with process status"); + *lifecycle = VmLifecycle::Halted(reason.clone()); + if code == 0 { + if let Some((_, response)) = wait_vm_response.take() { + response.send(Ok(())); + } + Ok(true) + } else { + if let Some((_, response)) = wait_vm_response.take() { + response.send(Err(grpc_error(anyhow::anyhow!(reason.clone())))); + } + Err(anyhow::anyhow!(reason)) + } + } + VmControllerEvent::ExitFailed { error } => { + *lifecycle = VmLifecycle::Halted(error.clone()); + if let Some((_, response)) = wait_vm_response.take() { + response.send(Err(grpc_error(anyhow::anyhow!(error.clone())))); + } + Err(anyhow::anyhow!(error)) + } + _ => unreachable!("only microVM exit events are delegated"), + } +} + fn openvmm_machine_profile(profile: vmservice::vm_config::MachineProfile) -> MachineProfile { match profile { vmservice::vm_config::MachineProfile::Standard => MachineProfile::Standard, @@ -1329,6 +1369,60 @@ mod tests { use openvmm_defs::microvm::MachineProfile as OpenvmmMachineProfile; use test_with_tracing::test; + #[test] + fn rpc_service_reports_guest_exit_status() { + DefaultPool::run_with(async |driver| { + for (event, expected_error) in [ + (VmControllerEvent::ExitRequested { code: 0 }, None), + ( + VmControllerEvent::ExitRequested { code: 1 }, + Some("guest exited with status 1"), + ), + ( + VmControllerEvent::ExitFailed { + error: "console output drain timed out".to_owned(), + }, + Some("console output drain timed out"), + ), + ] { + let directory = tempfile::tempdir().unwrap(); + let listener = UnixListener::bind(directory.path().join("rpc")).unwrap(); + let (events, event_recv) = mesh::channel(); + let (worker_send, worker_recv) = mesh::channel(); + let (response, received) = mesh::oneshot(); + let mut service = VmService { + driver: driver.clone(), + vm: None, + vm_controller: None, + vm_controller_events: Some(event_recv), + controller_task: None, + wait_vm_response: Some((mesh::CancelContext::new(), response)), + lifecycle: VmLifecycle::Running, + restore_ready_pending: false, + rpc_tasks: Vec::new(), + transport: ResolvedTransport::Auto, + registry: FdRegistry::default(), + }; + events.send(event); + let result = mesh::CancelContext::new() + .with_timeout(Duration::from_secs(5)) + .until_cancelled(service.run(listener, worker_recv)) + .await + .expect("guest exit must stop the RPC service"); + drop(worker_send); + let response = received.await.unwrap(); + if let Some(expected_error) = expected_error { + assert!(result.unwrap_err().to_string().contains(expected_error)); + assert!(response.unwrap_err().message.contains(expected_error)); + } else { + result.unwrap(); + response.unwrap(); + } + assert!(matches!(service.lifecycle, VmLifecycle::Halted(_))); + } + }); + } + #[test] fn ttrpc_standard_virtio_fs_preserves_access_mode() { for read_only in [false, true] { diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index b0ec5ce47b5..e6b6eac84fa 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -417,6 +417,7 @@ impl VmService { } }); + let mut exit_error = None; let quit = loop { // Take the controller events receiver out of self so it can be // polled in the select without borrowing self. @@ -494,9 +495,14 @@ impl VmService { ); break false; } - Action::ControllerEvent(Some(event)) => { - self.handle_controller_event(event); - } + Action::ControllerEvent(Some(event)) => match self.handle_controller_event(event) { + Ok(true) => break true, + Ok(false) => {} + Err(error) => { + exit_error = Some(error); + break true; + } + }, Action::ControllerEvent(None) => {} // handled above Action::WaitVmCancelled(reason) => { tracing::debug!("WaitVm client cancelled"); @@ -528,7 +534,11 @@ impl VmService { let _ = Arc::try_unwrap(vm).ok().expect("no more VM references"); } drop(cancel_send); - server_task.await + server_task.await?; + match exit_error { + Some(error) => Err(error), + None => Ok(()), + } } fn start_rpc( @@ -1534,23 +1544,23 @@ impl VmService { Ok(()) } - fn handle_controller_event(&mut self, event: VmControllerEvent) { - match event { + fn handle_controller_event(&mut self, event: VmControllerEvent) -> anyhow::Result { + Ok(match event { VmControllerEvent::GuestHalt(reason) => { tracing::info!(%reason, "guest halted (via controller)"); self.lifecycle = VmLifecycle::Halted(reason); if let Some((_, response)) = self.wait_vm_response.take() { response.send(Ok(())); } + false } - VmControllerEvent::ExitRequested { code } => { - // The protocol has no `exit` power action, so this should not - // occur in ttrpc/grpc mode; log rather than exiting the server - // out from under its clients. - tracing::warn!(code, "unexpected exit request in server mode"); - } - VmControllerEvent::ExitFailed { error } => { - tracing::error!(error = %error, "guest-requested exit failed in server mode"); + event @ (VmControllerEvent::ExitRequested { .. } + | VmControllerEvent::ExitFailed { .. }) => { + return microvm::handle_exit_event( + event, + &mut self.lifecycle, + &mut self.wait_vm_response, + ); } VmControllerEvent::WorkerStopped { error } => { if let Some(err) = &error { @@ -1571,13 +1581,15 @@ impl VmService { self.vm.take(); self.vm_controller.take(); self.lifecycle = VmLifecycle::Uninitialized; + false } VmControllerEvent::VncWorkerStopped { error } => { if let Some(err) = &error { tracing::error!(error = %err, "VNC worker stopped unexpectedly"); } + false } - } + }) } fn add_pcie_device( From 980a2e7e9c71ad2d79cf0354baa54f94282e495f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:54:29 -0700 Subject: [PATCH 157/304] openvmm_helpers: add a publication and private-restore benchmark Snapshot restore latency is dominated by host-side work that does not need a guest: publishing a snapshot, verifying it on open, and mapping its RAM privately copy-on-write. Add an example that measures these steps in isolation, so changes to them can be compared without booting a VM. `phase2_snapshot_bench` creates a seeded memory source (128 MiB by default) and, for each run, publishes a snapshot with a 64-KiB saved state, then measures: - publication, and the combined restore preparation of reading and verifying the snapshot and mapping its RAM copy-on-write; - the cost of dirtying every page of the private mapping; - a repeated in-process restore after dirtying, which also checks that the snapshot content is unchanged; - a restore preparation in a fresh process, through the example's own `--restore-worker` mode. After the warm-up runs, it prints one `OPENVMM_PHASE2_RESULT=` JSON line with the samples and their median and 95th percentile for each metric. `--warmups`, `--runs`, and `--memory-mib` adjust the run. The example needs `sparse_mmap` as a dev-dependency for the private mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- openvmm/openvmm_helpers/Cargo.toml | 1 + .../examples/phase2_snapshot_bench.rs | 393 ++++++++++++++++++ 2 files changed, 394 insertions(+) create mode 100644 openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs diff --git a/openvmm/openvmm_helpers/Cargo.toml b/openvmm/openvmm_helpers/Cargo.toml index abd3b0447d1..bf31c4a67f8 100644 --- a/openvmm/openvmm_helpers/Cargo.toml +++ b/openvmm/openvmm_helpers/Cargo.toml @@ -40,6 +40,7 @@ workspace = true features = ["Win32_Storage_FileSystem"] [dev-dependencies] +sparse_mmap.workspace = true tempfile.workspace = true [lints] diff --git a/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs b/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs new file mode 100644 index 00000000000..44b1cef5f94 --- /dev/null +++ b/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs @@ -0,0 +1,393 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Measures the host-side snapshot foundations used by microVM phase 2. + +use anyhow::Context; +use openvmm_helpers::snapshot::MANIFEST_VERSION; +use openvmm_helpers::snapshot::SnapshotManifest; +use openvmm_helpers::snapshot::publish::write_snapshot; +use openvmm_helpers::snapshot::restore::read_snapshot_with_memory; +use sparse_mmap::SparseMapping; +use std::io::Seek; +use std::io::SeekFrom; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Stdio; +use std::time::Instant; + +const RESULT_PREFIX: &str = "OPENVMM_PHASE2_RESULT="; +const STATE_SIZE_BYTES: usize = 64 * 1024; + +struct Options { + warmups: usize, + runs: usize, + memory_mib: usize, + restore_worker: Option, +} + +impl Options { + fn parse() -> anyhow::Result { + let mut options = Self { + warmups: 3, + runs: 11, + memory_mib: 128, + restore_worker: None, + }; + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--restore-worker" => { + options.restore_worker = Some(PathBuf::from( + args.next().context("missing value for --restore-worker")?, + )); + continue; + } + "--help" | "-h" => { + println!( + "usage: phase2_snapshot_bench [--warmups N] [--runs N] \ + [--memory-mib N]" + ); + std::process::exit(0); + } + "--warmups" | "--runs" | "--memory-mib" => {} + _ => anyhow::bail!("unknown argument: {arg}"), + } + let value = args + .next() + .with_context(|| format!("missing value for {arg}"))? + .parse::() + .with_context(|| format!("invalid value for {arg}"))?; + anyhow::ensure!(value > 0, "{arg} must be greater than zero"); + match arg.as_str() { + "--warmups" => options.warmups = value, + "--runs" => options.runs = value, + "--memory-mib" => options.memory_mib = value, + _ => unreachable!(), + } + } + Ok(options) + } +} + +#[derive(Default)] +struct Samples { + snapshot_publish_ms: Vec, + restore_prepare_ms: Vec, + snapshot_verify_ms: Vec, + cow_map_ms: Vec, + cow_dirty_all_ms: Vec, + repeat_restore_prepare_ms: Vec, + new_process_restore_prepare_ms: Vec, + repeat_verify_ms: Vec, + repeat_cow_map_ms: Vec, +} + +struct Iteration { + snapshot_publish_ms: f64, + restore_prepare_ms: f64, + snapshot_verify_ms: f64, + cow_map_ms: f64, + cow_dirty_all_ms: f64, + repeat_restore_prepare_ms: f64, + new_process_restore_prepare_ms: f64, + repeat_verify_ms: f64, + repeat_cow_map_ms: f64, +} + +impl Samples { + fn push(&mut self, sample: Iteration) { + self.snapshot_publish_ms.push(sample.snapshot_publish_ms); + self.restore_prepare_ms.push(sample.restore_prepare_ms); + self.snapshot_verify_ms.push(sample.snapshot_verify_ms); + self.cow_map_ms.push(sample.cow_map_ms); + self.cow_dirty_all_ms.push(sample.cow_dirty_all_ms); + self.repeat_restore_prepare_ms + .push(sample.repeat_restore_prepare_ms); + self.new_process_restore_prepare_ms + .push(sample.new_process_restore_prepare_ms); + self.repeat_verify_ms.push(sample.repeat_verify_ms); + self.repeat_cow_map_ms.push(sample.repeat_cow_map_ms); + } +} + +fn elapsed_ms(started: Instant) -> f64 { + started.elapsed().as_secs_f64() * 1000.0 +} + +fn create_memory_source(path: &Path, size: u64) -> anyhow::Result<()> { + let mut file = fs_err::File::create(path).context("failed to create memory source")?; + file.set_len(size).context("failed to size memory source")?; + const INITIALIZE_CHUNK_BYTES: usize = 1024 * 1024; + let chunk = vec![0x6d_u8; INITIALIZE_CHUNK_BYTES]; + let mut remaining = size; + while remaining > 0 { + let count = usize::try_from(remaining.min(INITIALIZE_CHUNK_BYTES as u64)).unwrap(); + file.write_all(&chunk[..count]) + .context("failed to initialize memory source")?; + remaining -= count as u64; + } + file.seek(SeekFrom::Start(0)) + .context("failed to seek in memory source")?; + file.write_all(&[0x5a]) + .context("failed to seed memory source")?; + file.seek(SeekFrom::End(-1)) + .context("failed to seek in memory source")?; + file.write_all(&[0xa5]) + .context("failed to seed end of memory source")?; + file.sync_all().context("failed to flush memory source")?; + Ok(()) +} + +fn map_copy_on_write( + memory_file: std::fs::File, + memory_size: usize, +) -> anyhow::Result { + let mappable = sparse_mmap::new_mappable_from_file_copy_on_write(&memory_file, false) + .context("failed to create copy-on-write memory handle")?; + let mapping = SparseMapping::new(memory_size).context("failed to reserve memory mapping")?; + mapping + .map_file_copy_on_write(0, memory_size, &mappable, 0, true) + .context("failed to map snapshot memory copy-on-write")?; + Ok(mapping) +} + +fn validate_original(mapping: &SparseMapping, memory_size: usize) -> anyhow::Result<()> { + let mut byte = [0_u8; 1]; + mapping.read_at(0, &mut byte)?; + anyhow::ensure!(byte == [0x5a], "snapshot first byte changed"); + mapping.read_at(memory_size - 1, &mut byte)?; + anyhow::ensure!(byte == [0xa5], "snapshot last byte changed"); + Ok(()) +} + +fn run_iteration( + root: &Path, + iteration: usize, + executable: &Path, + memory_path: &Path, + memory_size: usize, + state: &[u8], +) -> anyhow::Result { + let snapshot_path = root.join(format!("snapshot-{iteration}")); + let manifest = SnapshotManifest { + version: MANIFEST_VERSION, + created_at: std::time::SystemTime::now().into(), + openvmm_version: env!("CARGO_PKG_VERSION").to_owned(), + memory_size_bytes: memory_size as u64, + vp_count: 1, + page_size: SparseMapping::page_size() as u32, + architecture: "x86_64".to_owned(), + state_size_bytes: 0, + state_sha256: Vec::new(), + memory_sha256: Vec::new(), + machine_contract: None, + format_magic: openvmm_helpers::snapshot::format::SNAPSHOT_FORMAT_MAGIC.to_vec(), + saved_state_schema_version: openvmm_helpers::snapshot::format::SAVED_STATE_SCHEMA_VERSION, + saved_state_root_type: openvmm_helpers::snapshot::format::SAVED_STATE_ROOT_TYPE.to_owned(), + snapshot_tier: String::new(), + restore_policy: String::new(), + consumed_config_sections: 0, + }; + + let started = Instant::now(); + write_snapshot(&snapshot_path, &manifest, state, memory_path)?; + let snapshot_publish_ms = elapsed_ms(started); + + let restore_started = Instant::now(); + let started = Instant::now(); + let (_, restored_state, memory_file) = + read_snapshot_with_memory(&snapshot_path, memory_size as u64)?; + let snapshot_verify_ms = elapsed_ms(started); + anyhow::ensure!(restored_state == state, "saved state changed"); + + let started = Instant::now(); + let mapping = map_copy_on_write(memory_file, memory_size)?; + let cow_map_ms = elapsed_ms(started); + validate_original(&mapping, memory_size)?; + let restore_prepare_ms = elapsed_ms(restore_started); + + let started = Instant::now(); + mapping.fill_at(0, 0x3c, memory_size)?; + let cow_dirty_all_ms = elapsed_ms(started); + drop(mapping); + + let repeat_restore_started = Instant::now(); + let started = Instant::now(); + let (_, restored_state, memory_file) = + read_snapshot_with_memory(&snapshot_path, memory_size as u64)?; + let repeat_verify_ms = elapsed_ms(started); + anyhow::ensure!( + restored_state == state, + "saved state changed after COW dirtying" + ); + + let started = Instant::now(); + let mapping = map_copy_on_write(memory_file, memory_size)?; + let repeat_cow_map_ms = elapsed_ms(started); + validate_original(&mapping, memory_size)?; + let repeat_restore_prepare_ms = elapsed_ms(repeat_restore_started); + drop(mapping); + + let started = Instant::now(); + let status = Command::new(executable) + .arg("--restore-worker") + .arg(&snapshot_path) + .arg("--memory-mib") + .arg((memory_size / (1024 * 1024)).to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .status() + .context("failed to launch restore-preparation worker")?; + anyhow::ensure!(status.success(), "restore-preparation worker failed"); + let new_process_restore_prepare_ms = elapsed_ms(started); + + fs_err::remove_dir_all(&snapshot_path).context("failed to remove benchmark snapshot")?; + Ok(Iteration { + snapshot_publish_ms, + restore_prepare_ms, + snapshot_verify_ms, + cow_map_ms, + cow_dirty_all_ms, + repeat_restore_prepare_ms, + new_process_restore_prepare_ms, + repeat_verify_ms, + repeat_cow_map_ms, + }) +} + +fn median(samples: &[f64]) -> f64 { + let mut sorted = samples.to_vec(); + sorted.sort_by(f64::total_cmp); + let middle = sorted.len() / 2; + if sorted.len().is_multiple_of(2) { + (sorted[middle - 1] + sorted[middle]) / 2.0 + } else { + sorted[middle] + } +} + +fn nearest_rank_percentile(samples: &[f64], percentile: usize) -> f64 { + assert!(!samples.is_empty()); + assert!((1..=100).contains(&percentile)); + let mut sorted = samples.to_vec(); + sorted.sort_by(f64::total_cmp); + let index = (percentile * sorted.len()).div_ceil(100) - 1; + sorted[index] +} + +fn metric_json(name: &str, samples: &[f64]) -> String { + let values = samples + .iter() + .map(|sample| format!("{sample:.6}")) + .collect::>() + .join(","); + format!( + "\"{name}\":{{\"samples_ms\":[{values}],\"p50_ms\":{:.6},\ + \"p95_ms\":{:.6},\"min_ms\":{:.6},\"max_ms\":{:.6}}}", + median(samples), + nearest_rank_percentile(samples, 95), + samples.iter().copied().fold(f64::INFINITY, f64::min), + samples.iter().copied().fold(f64::NEG_INFINITY, f64::max), + ) +} + +fn print_result(options: &Options, samples: &Samples) { + let metrics = [ + metric_json("snapshot_publish", &samples.snapshot_publish_ms), + metric_json("restore_prepare", &samples.restore_prepare_ms), + metric_json("snapshot_verify", &samples.snapshot_verify_ms), + metric_json("cow_map", &samples.cow_map_ms), + metric_json("cow_dirty_all", &samples.cow_dirty_all_ms), + metric_json("repeat_restore_prepare", &samples.repeat_restore_prepare_ms), + metric_json( + "new_process_restore_prepare", + &samples.new_process_restore_prepare_ms, + ), + metric_json("repeat_verify", &samples.repeat_verify_ms), + metric_json("repeat_cow_map", &samples.repeat_cow_map_ms), + ]; + println!( + "{RESULT_PREFIX}{{\"memory_mib\":{},\"state_bytes\":{},\ + \"artifact_unchanged\":true,\"metrics\":{{{}}}}}", + options.memory_mib, + STATE_SIZE_BYTES, + metrics.join(",") + ); +} + +fn main() -> anyhow::Result<()> { + let options = Options::parse()?; + let memory_size = options + .memory_mib + .checked_mul(1024 * 1024) + .context("memory size overflow")?; + anyhow::ensure!( + memory_size >= SparseMapping::page_size(), + "memory size must be at least one host page" + ); + + if let Some(snapshot_path) = &options.restore_worker { + let (_, _, memory_file) = read_snapshot_with_memory(snapshot_path, memory_size as u64)?; + let mapping = map_copy_on_write(memory_file, memory_size)?; + validate_original(&mapping, memory_size)?; + mapping.write_at(0, &[0x3c])?; + return Ok(()); + } + + let root = tempfile::tempdir().context("failed to create benchmark directory")?; + let executable = std::env::current_exe().context("failed to locate benchmark executable")?; + let memory_path = root.path().join("memory-source.bin"); + create_memory_source(&memory_path, memory_size as u64)?; + let state = vec![0xc3_u8; STATE_SIZE_BYTES]; + + for index in 0..options.warmups { + let sample = run_iteration( + root.path(), + index, + &executable, + &memory_path, + memory_size, + &state, + )?; + println!( + "warmup {}/{}: publish={:.3} ms restore-prep={:.3} ms COW-dirty={:.3} ms", + index + 1, + options.warmups, + sample.snapshot_publish_ms, + sample.restore_prepare_ms, + sample.cow_dirty_all_ms, + ); + } + + let mut samples = Samples::default(); + for index in 0..options.runs { + let sample = run_iteration( + root.path(), + options.warmups + index, + &executable, + &memory_path, + memory_size, + &state, + )?; + println!( + "sample {}/{}: publish={:.3} ms restore-prep={:.3} ms verify={:.3} ms \ + map={:.3} ms dirty={:.3} ms repeat-restore-prep={:.3} ms \ + new-process-restore-prep={:.3} ms", + index + 1, + options.runs, + sample.snapshot_publish_ms, + sample.restore_prepare_ms, + sample.snapshot_verify_ms, + sample.cow_map_ms, + sample.cow_dirty_all_ms, + sample.repeat_restore_prepare_ms, + sample.new_process_restore_prepare_ms, + ); + samples.push(sample); + } + print_result(&options, &samples); + Ok(()) +} From 40380585d56e6572fd23cc46cdf37c7c21e00fc7 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:57:51 -0700 Subject: [PATCH 158/304] ci: restrict privileged, self-hosted, and publishing jobs to microsoft/openvmm This repository is a fork of microsoft/openvmm. Its workflows still target upstream's self-hosted and 1ES runner pools, the Azure test result storage, the dependency-review and mirror-refresh integrations, and the openvmm.dev publishing job, none of which a fork can use. On the fork those jobs queue forever or fail on missing credentials, and a PR cannot get a usable result. Gate them on the repository: - The checkin-gate pipeline assigns runner pools through a helper that adds `github.repository == 'microsoft/openvmm'` to jobs on self-hosted or runner-group pools, together with the existing draft-PR and release-label conditions for each pipeline. Jobs on GitHub-hosted runners keep running on forks. The quick fmt and clippy check moves to a GitHub-hosted Linux runner, so a fork can still bootstrap the remaining jobs, and the release pipeline keeps its release-label gate on every job. - The openvmm.dev publishing job, dependency review, mirror refresh, and Petri result upload only run in microsoft/openvmm. - The generated workflows are regenerated from the pipeline. A flowey test checks that every job of the release PR workflow keeps the release-label condition, using `test_with_tracing` as a new dev-dependency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- .github/workflows/dep-review.yml | 1 + .github/workflows/openvmm-ci.yaml | 60 +++++----- .github/workflows/openvmm-docs-ci.yaml | 2 +- .github/workflows/openvmm-pr-release.yaml | 66 +++++------ .github/workflows/openvmm-pr.yaml | 62 +++++----- .github/workflows/refresh-mirror.yml | 1 + .github/workflows/upload-petri-results.yml | 9 +- Cargo.lock | 1 + flowey/flowey_hvlite/Cargo.toml | 3 + .../flowey_hvlite/src/pipelines/build_docs.rs | 1 + .../src/pipelines/checkin_gates.rs | 109 +++++++++++++----- 11 files changed, 182 insertions(+), 133 deletions(-) diff --git a/.github/workflows/dep-review.yml b/.github/workflows/dep-review.yml index e484fb1391d..32c2d59de6e 100644 --- a/.github/workflows/dep-review.yml +++ b/.github/workflows/dep-review.yml @@ -29,6 +29,7 @@ permissions: jobs: dep-review: + if: github.repository == 'microsoft/openvmm' runs-on: ubuntu-latest steps: - name: Checkout base branch (safe — our own code) diff --git a/.github/workflows/openvmm-ci.yaml b/.github/workflows/openvmm-ci.yaml index a4e2aa0629a..91965ca1839 100644 --- a/.github/workflows/openvmm-ci.yaml +++ b/.github/workflows/openvmm-ci.yaml @@ -362,7 +362,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -854,7 +854,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -1127,7 +1127,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -1511,7 +1511,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -1871,7 +1871,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -2161,7 +2161,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -2540,7 +2540,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -2934,7 +2934,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -3224,7 +3224,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -3579,7 +3579,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -4164,7 +4164,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4464,7 +4464,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4759,7 +4759,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5059,7 +5059,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5360,7 +5360,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5659,7 +5659,7 @@ jobs: - job10 - job3 - job4 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5942,7 +5942,7 @@ jobs: - job10 - job3 - job4 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -6274,7 +6274,7 @@ jobs: - job3 - job4 - job6 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -6596,7 +6596,7 @@ jobs: needs: - job4 - job9 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6871,7 +6871,7 @@ jobs: id-token: write needs: - job10 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6997,7 +6997,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -7186,7 +7186,7 @@ jobs: id-token: write needs: - job10 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -7364,7 +7364,7 @@ jobs: needs: - job7 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7488,7 +7488,7 @@ jobs: needs: - job7 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7998,7 +7998,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -8399,7 +8399,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -8634,7 +8634,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -8954,7 +8954,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -9193,7 +9193,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x @@ -9521,7 +9521,7 @@ jobs: permissions: contents: read id-token: write - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - run: | set -x diff --git a/.github/workflows/openvmm-docs-ci.yaml b/.github/workflows/openvmm-docs-ci.yaml index 3b60e8a4967..4f158f698fb 100644 --- a/.github/workflows/openvmm-docs-ci.yaml +++ b/.github/workflows/openvmm-docs-ci.yaml @@ -531,7 +531,7 @@ jobs: - job0 - job1 - job2 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c diff --git a/.github/workflows/openvmm-pr-release.yaml b/.github/workflows/openvmm-pr-release.yaml index ff68ecbcf0a..b5810bd14ab 100644 --- a/.github/workflows/openvmm-pr-release.yaml +++ b/.github/workflows/openvmm-pr-release.yaml @@ -27,11 +27,7 @@ concurrency: jobs: job0: name: quick check [fmt, clippy x64-linux] - runs-on: - - self-hosted - - 1ES.Pool=openvmm-gh-amd-westus3-v7 - - 1ES.ImageOverride=ubuntu2404-amd64 - - JobId=job0-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + runs-on: ubuntu-latest permissions: contents: read id-token: write @@ -415,7 +411,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -857,7 +853,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1064,7 +1060,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1382,7 +1378,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1700,7 +1696,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -1992,7 +1988,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -2297,7 +2293,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -2649,7 +2645,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -2941,7 +2937,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -3297,7 +3293,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -3841,7 +3837,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4142,7 +4138,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4438,7 +4434,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4739,7 +4735,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5040,7 +5036,7 @@ jobs: - job3 - job4 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5340,7 +5336,7 @@ jobs: - job10 - job3 - job4 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5624,7 +5620,7 @@ jobs: - job10 - job3 - job4 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -5957,7 +5953,7 @@ jobs: - job3 - job4 - job6 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -6280,7 +6276,7 @@ jobs: - job0 - job4 - job9 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6556,7 +6552,7 @@ jobs: needs: - job0 - job10 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6684,7 +6680,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -6874,7 +6870,7 @@ jobs: needs: - job0 - job10 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -7053,7 +7049,7 @@ jobs: - job0 - job7 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7178,7 +7174,7 @@ jobs: - job0 - job7 - job8 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7455,7 +7451,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7814,7 +7810,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -8051,7 +8047,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -8373,7 +8369,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -8614,7 +8610,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - run: | set -x @@ -8944,7 +8940,7 @@ jobs: id-token: write needs: - job0 - if: contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c diff --git a/.github/workflows/openvmm-pr.yaml b/.github/workflows/openvmm-pr.yaml index 67639f64d79..815eefadeee 100644 --- a/.github/workflows/openvmm-pr.yaml +++ b/.github/workflows/openvmm-pr.yaml @@ -26,11 +26,7 @@ concurrency: jobs: job0: name: quick check [fmt, clippy x64-linux] - runs-on: - - self-hosted - - 1ES.Pool=openvmm-gh-amd-westus3-v7 - - 1ES.ImageOverride=ubuntu2404-amd64 - - JobId=job0-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }} + runs-on: ubuntu-latest permissions: contents: read id-token: write @@ -414,7 +410,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -856,7 +852,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1059,7 +1055,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1270,7 +1266,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1584,7 +1580,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -1795,7 +1791,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -2113,7 +2109,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -2405,7 +2401,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -2710,7 +2706,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -3062,7 +3058,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -3498,7 +3494,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -3855,7 +3851,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -4255,7 +4251,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4556,7 +4552,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -4852,7 +4848,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5153,7 +5149,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5454,7 +5450,7 @@ jobs: - job3 - job4 - job8 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -5754,7 +5750,7 @@ jobs: - job10 - job3 - job4 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -6038,7 +6034,7 @@ jobs: - job10 - job3 - job4 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -6371,7 +6367,7 @@ jobs: - job3 - job4 - job6 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -6692,7 +6688,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -6882,7 +6878,7 @@ jobs: - job0 - job4 - job9 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7380,7 +7376,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c @@ -7739,7 +7735,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -7976,7 +7972,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -8298,7 +8294,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -8539,7 +8535,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - run: | set -x @@ -8869,7 +8865,7 @@ jobs: id-token: write needs: - job0 - if: github.event.pull_request.draft == false + if: github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false steps: - name: 🌼📦 Download artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c diff --git a/.github/workflows/refresh-mirror.yml b/.github/workflows/refresh-mirror.yml index d31fd40c0d6..168e6b30506 100644 --- a/.github/workflows/refresh-mirror.yml +++ b/.github/workflows/refresh-mirror.yml @@ -12,6 +12,7 @@ permissions: jobs: QueueBuild: + if: github.repository == 'microsoft/openvmm' runs-on: ubuntu-latest steps: - uses: Azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1 diff --git a/.github/workflows/upload-petri-results.yml b/.github/workflows/upload-petri-results.yml index f86a2ea0e9e..c4106a966b8 100644 --- a/.github/workflows/upload-petri-results.yml +++ b/.github/workflows/upload-petri-results.yml @@ -17,12 +17,15 @@ permissions: jobs: upload-logs: + # Only the upstream repository has access to the Azure test results storage. # Only attempt upload if the workflow run could have actually produced some artifacts if: >- ${{ - github.event.workflow_run.conclusion == 'success' || - github.event.workflow_run.conclusion == 'failure' || - github.event.workflow_run.conclusion == 'timed_out' + github.repository == 'microsoft/openvmm' && ( + github.event.workflow_run.conclusion == 'success' || + github.event.workflow_run.conclusion == 'failure' || + github.event.workflow_run.conclusion == 'timed_out' + ) }} runs-on: ubuntu-latest env: diff --git a/Cargo.lock b/Cargo.lock index a3c394e4ff4..741b1846b2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2350,6 +2350,7 @@ dependencies = [ "serde", "serde_json", "target-lexicon", + "test_with_tracing", ] [[package]] diff --git a/flowey/flowey_hvlite/Cargo.toml b/flowey/flowey_hvlite/Cargo.toml index 5ca34093828..28f27725bf4 100644 --- a/flowey/flowey_hvlite/Cargo.toml +++ b/flowey/flowey_hvlite/Cargo.toml @@ -24,6 +24,9 @@ serde = { workspace = true, features = ["std"] } serde_json.workspace = true target-lexicon = { workspace = true, features = ["serde_support"] } +[dev-dependencies] +test_with_tracing.workspace = true + [lints] workspace = true diff --git a/flowey/flowey_hvlite/src/pipelines/build_docs.rs b/flowey/flowey_hvlite/src/pipelines/build_docs.rs index 8fd4ff0ebbe..98ed28db501 100644 --- a/flowey/flowey_hvlite/src/pipelines/build_docs.rs +++ b/flowey/flowey_hvlite/src/pipelines/build_docs.rs @@ -177,6 +177,7 @@ impl IntoPipeline for BuildDocsCli { let job = pipeline .new_job(FlowPlatform::Linux(FlowPlatformLinuxDistro::Ubuntu), FlowArch::X86_64, "publish openvmm.dev") .gh_set_pool(crate::pipelines_shared::gh_pools::linux_x64_gh()) + .gh_dangerous_override_if("github.repository == 'microsoft/openvmm'") .gh_set_concurrency_group(GhConcurrencyGroup { name: "publish-openvmm.dev".into(), cancel_in_progress: false, diff --git a/flowey/flowey_hvlite/src/pipelines/checkin_gates.rs b/flowey/flowey_hvlite/src/pipelines/checkin_gates.rs index 21f1033bc57..575a5ff8a8a 100644 --- a/flowey/flowey_hvlite/src/pipelines/checkin_gates.rs +++ b/flowey/flowey_hvlite/src/pipelines/checkin_gates.rs @@ -57,6 +57,37 @@ enum PipelineConfig { PrRelease, } +trait CheckinGatesJobExt { + fn gh_set_pool_with_fork_gate(self, pool: GhRunner, config: PipelineConfig) -> Self; +} + +impl CheckinGatesJobExt for PipelineJob<'_> { + fn gh_set_pool_with_fork_gate(self, pool: GhRunner, config: PipelineConfig) -> Self { + let is_upstream_only = + matches!(pool, GhRunner::SelfHosted(_) | GhRunner::RunnerGroup { .. }); + let job = self.gh_set_pool(pool); + + let condition = match (config, is_upstream_only) { + (PipelineConfig::Pr, true) => Some( + "github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false", + ), + (PipelineConfig::Ci, true) => Some("github.repository == 'microsoft/openvmm'"), + (PipelineConfig::PrRelease, true) => Some( + "github.repository == 'microsoft/openvmm' && contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false", + ), + (PipelineConfig::PrRelease, false) => Some( + "contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false", + ), + _ => None, + }; + + match condition { + Some(condition) => job.gh_dangerous_override_if(condition), + None => job, + } + } +} + /// A unified pipeline defining all checkin gates required to land a commit in /// the OpenVMM repo. #[derive(clap::Args)] @@ -163,8 +194,7 @@ impl IntoPipeline for CheckinGatesCli { )?; pipeline.inject_all_jobs_with(move |job| { - let mut job = job - .dep_on(&cfg_common_params) + job.dep_on(&cfg_common_params) .dep_on(|_| flowey_lib_hvlite::_jobs::cfg_versions::Request::Init) .dep_on( |_| flowey_lib_hvlite::_jobs::cfg_hvlite_reposource::Params { @@ -178,16 +208,7 @@ impl IntoPipeline for CheckinGatesCli { .gh_grant_permissions::([( GhPermission::IdToken, GhPermissionValue::Write, - )]); - - // For the release pipeline, only run if the "release-ci-required" label is present and PR is not draft - if matches!(config, PipelineConfig::PrRelease) { - job = job.gh_dangerous_override_if( - "contains(github.event.pull_request.labels.*.name, 'release-ci-required') && github.event.pull_request.draft == false", - ); - } - - job + )]) }); let openhcl_musl_target = |arch: CommonArch| -> Triple { @@ -237,7 +258,8 @@ impl IntoPipeline for CheckinGatesCli { // Quick check gate // - // Combined fmt + clippy on one self-hosted linux machine. + // Combined fmt + clippy on one GitHub-hosted linux machine so that it + // can bootstrap the remaining fork-capable jobs. // Catches the most common failures quickly before fanning out expensive jobs. let quick_check_job = if matches!(config, PipelineConfig::Pr | PipelineConfig::PrRelease) { let job = pipeline @@ -246,7 +268,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "quick check [fmt, clippy x64-linux]", ) - .gh_set_pool(gh_pools::default_linux()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .ado_set_pool(ado_pools::default_linux()) // 1. xtask fmt (linux) .side_effect(|done| flowey_lib_hvlite::_jobs::check_xtask_fmt::Request { @@ -276,7 +298,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "xtask fmt (windows)", ) - .gh_set_pool(gh_pools::windows_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::windows_x64_gh(), config) .ado_set_pool(ado_pools::default_windows()) .side_effect(|done| flowey_lib_hvlite::_jobs::check_xtask_fmt::Request { target: CommonTriple::X86_64_WINDOWS_MSVC, @@ -295,7 +317,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "xtask fmt (linux)", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .ado_set_pool(ado_pools::default_linux()) .side_effect(|done| flowey_lib_hvlite::_jobs::check_xtask_fmt::Request { target: CommonTriple::X86_64_LINUX_GNU, @@ -320,7 +342,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "cargo hack (linux)", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .ado_set_pool(ado_pools::default_linux()) .side_effect(|done| flowey_lib_hvlite::_jobs::check_cargo_hack::Request { profile: CommonProfile::from_release(release), @@ -369,7 +391,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "build artifacts (shared VMM tests) [windows]", ) - .gh_set_pool(gh_pools::default_windows()) + .gh_set_pool_with_fork_gate(gh_pools::default_windows(), config) .ado_set_pool(ado_pools::default_windows()); for (arch, pub_pipette_windows) in shared_win_pipette_artifacts { shared_win_job = shared_win_job.publish(pub_pipette_windows, |pipette| { @@ -477,7 +499,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "build artifacts (shared VMM tests) [linux]", ) - .gh_set_pool(gh_pools::linux_intel_v6_1es()) + .gh_set_pool_with_fork_gate(gh_pools::linux_intel_v6_1es(), config) .ado_set_pool(ado_pools::default_linux()); for ( arch, @@ -637,7 +659,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("build artifacts (not for VMM tests) [{arch_tag}-windows]"), ) - .gh_set_pool(gh_pools::default_windows()) + .gh_set_pool_with_fork_gate(gh_pools::default_windows(), config) .ado_set_pool(ado_pools::default_windows()) .publish(pub_hypestv, |hypestv| { flowey_lib_hvlite::build_hypestv::Request { @@ -713,7 +735,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("build artifacts (for VMM tests) [{arch_tag}-windows]"), ) - .gh_set_pool(gh_pools::default_windows()) + .gh_set_pool_with_fork_gate(gh_pools::default_windows(), config) .ado_set_pool(ado_pools::default_windows()) .publish(pub_openvmm, |openvmm| { flowey_lib_hvlite::build_openvmm::Request { @@ -896,7 +918,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("build artifacts (for VMM tests) [{arch_tag}-linux]"), ) - .gh_set_pool(gh_pools::default_linux()) + .gh_set_pool_with_fork_gate(gh_pools::default_linux(), config) .ado_set_pool(ado_pools::default_linux()) .publish(pub_openvmm, |openvmm| { flowey_lib_hvlite::build_openvmm::Request { @@ -1153,7 +1175,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, build_openhcl_job_tag(arch_tag, mi_secure), ) - .gh_set_pool(gh_pools::default_linux()) + .gh_set_pool_with_fork_gate(gh_pools::default_linux(), config) .ado_set_pool(ado_pools::default_linux()) .dep_on(|ctx| { let publish_baseline_artifact = pub_openhcl_baseline @@ -1208,7 +1230,10 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("verify openhcl binary size [{}]", arch_tag), ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) + .gh_dangerous_override_if( + "github.repository == 'microsoft/openvmm' && github.event.pull_request.draft == false", + ) .side_effect(|done| { flowey_lib_hvlite::_jobs::check_openvmm_hcl_size::Request { target: CommonTriple::Common { @@ -1366,7 +1391,7 @@ impl IntoPipeline for CheckinGatesCli { let mut clippy_unit_test_job = pipeline .new_job(platform, arch, job_name) - .gh_set_pool(gh_pool); + .gh_set_pool_with_fork_gate(gh_pool, config); if let Some(pool) = ado_pool { clippy_unit_test_job = clippy_unit_test_job.ado_set_pool(pool); @@ -1794,7 +1819,7 @@ impl IntoPipeline for CheckinGatesCli { let mut vmm_tests_run_job = pipeline .new_job(platform, arch, format!("run vmm-tests [{label}]")) - .gh_set_pool(gh_pool); + .gh_set_pool_with_fork_gate(gh_pool, config); if let Some(pool) = ado_pool { vmm_tests_run_job = vmm_tests_run_job.ado_set_pool(pool); @@ -1918,7 +1943,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, format!("run vmm-perf [{label}]"), ) - .gh_set_pool(pool) + .gh_set_pool_with_fork_gate(pool, config) .with_timeout_in_minutes(120) .dep_on(|_| flowey_lib_hvlite::_jobs::cfg_versions::Request::Init) .dep_on( @@ -1949,7 +1974,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "test flowey local backend", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .side_effect(|done| { flowey_lib_hvlite::_jobs::test_local_flowey_build_igvm::Request { base_recipe: OpenhclIgvmRecipe::X64, @@ -1969,7 +1994,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "build openvmm [distribution config, x64-linux-gnu]", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .ado_set_pool(ado_pools::default_linux()) .side_effect(|done| { flowey_lib_hvlite::_jobs::check_distro_build_from_checkout::Request { done } @@ -2005,7 +2030,7 @@ impl IntoPipeline for CheckinGatesCli { FlowArch::X86_64, "openvmm checkin gates", ) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) // always run this job, regardless whether or not any previous jobs failed .gh_dangerous_override_if("always() && github.event.pull_request.draft == false") .gh_dangerous_global_env_var("ANY_JOBS_FAILED", "${{ contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'failure') }}") @@ -2033,7 +2058,7 @@ impl IntoPipeline for CheckinGatesCli { GhPermission::Contents, GhPermissionValue::Write, )]) - .gh_set_pool(gh_pools::linux_x64_gh()) + .gh_set_pool_with_fork_gate(gh_pools::linux_x64_gh(), config) .dep_on( |ctx| flowey_lib_hvlite::_jobs::publish_vmgstool_gh_release::Request { vmgstools: vmgstools @@ -2054,3 +2079,25 @@ impl IntoPipeline for CheckinGatesCli { Ok(pipeline) } } + +#[cfg(test)] +mod tests { + use test_with_tracing::test; + + #[test] + fn release_pr_jobs_keep_the_release_label_gate() { + let workflow = include_str!("../../../../.github/workflows/openvmm-pr-release.yaml"); + let conditions: Vec<_> = workflow + .lines() + .filter(|line| line.starts_with(" if: ")) + .collect(); + + assert!(!conditions.is_empty()); + for condition in conditions { + assert!( + condition.contains("release-ci-required"), + "release PR job is missing the release label gate: {condition}" + ); + } + } +} From c94a280d20a0372b6b4450a11bb2aa47c40c3fd0 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 25 Sep 2026 16:59:28 -0700 Subject: [PATCH 159/304] vmservice: keep NVX wire-tag compatibility NVX clients already speak an earlier revision of the microVM management schema, in which some fields took tags that upstream later assigned differently: microVM PVH boot used `VMConfig` field 14, the machine profile used field 15 before upstream gave it to `iommufds`, and the microVM filesystem fields used `VirtioFs` fields 3 and 4, where upstream has `read_only`. Renumber this fork's schema so those clients keep decoding correctly, and document what changes for everyone else. - `VMConfig`: field 14 and the name `pvh_boot` are reserved, `crash_dump_path` moves to field 16, and `machine_profile` moves to field 17. `iommufds` keeps upstream's field 15, so the colliding legacy machine-profile encoding no longer decodes. Upstream's `disable_vmbus` and `disable_hv` flags, which upstream assigns fields 16 and 17, use fields 20 and 21 instead. - `MachineProfile` reserves values 1, 3, and 4 so that no future profile reuses them; `MICROVM` stays 2. - `VirtioFs`: `guest_mount_target` and `read_write` take fields 3 and 4, and the standard-machine `read_only` flag moves to field 5. - `DevicesConfig` reserves field 7 and the name `virtio_blk`. The gRPC reference explains the fork's tags and that clients must generate bindings from this fork's `vmservice.proto`, including clients of the former `MICROVM_V2` source name. The CLI reference notes that `--machine microvm` now selects the contract formerly exposed as `microvm-v2`, whose selector was removed, and that the snapshot ABI and boot layout remain value 2. Tests check the exact wire encoding of the microVM filesystem fields, the separate `read_only` field, the non-conflicting machine-profile tag with the rejection of the legacy encoding, the new crash-dump tag, and the relocated platform flags. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: ef640993-0003-4e49-b1ed-1b8b4776d0ef --- Guide/src/reference/openvmm/management/cli.md | 6 ++ .../src/reference/openvmm/management/grpc.md | 26 +++++-- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 71 +++++++++++++++++++ .../src/vmservice.proto | 23 +++--- 4 files changed, 112 insertions(+), 14 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 4ebf162bef6..6d9dae3fc74 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -74,6 +74,12 @@ describes the source definitions. with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. Guest-requested snapshot capture and new-process restore are available for blockless and fixed-block machines on Linux/KVM, Linux/MSHV, and Windows/WHP. + + ```admonish warning title="microVM migration" + The canonical `microvm` spelling now selects the contract formerly exposed + as `microvm-v2`; the `microvm-v2` selector and the former ABI-v1 behavior are + removed. Snapshot ABI and boot layout remain numeric value 2. + ``` * `--net `: With `--machine microvm`, attach one virtio-net NIC at MMIO `0xd0000000`. KVM and MSHV use IRQ 10; WHP uses IRQ 5. Prefixes `/1` through `/30` are accepted. The first usable subnet address becomes diff --git a/Guide/src/reference/openvmm/management/grpc.md b/Guide/src/reference/openvmm/management/grpc.md index 99163837111..12fecd1e705 100644 --- a/Guide/src/reference/openvmm/management/grpc.md +++ b/Guide/src/reference/openvmm/management/grpc.md @@ -179,6 +179,10 @@ omit the attachment or supply a new one; after `ResumeVM`, the guest explicitly mounts tag `microvm`. The fixed device uses MMIO `0xd0001000`, IRQ 6, one request queue, and no DAX window. +The fork retains `VirtioFs.guest_mount_target` at protobuf field 3 and +`read_write` at field 4 for existing microVM clients. Standard-machine +`read_only` uses field 5, rather than upstream's conflicting field 3; clients +using that flag must generate bindings from this fork's `vmservice.proto`. Standard-machine attachments reject the microVM mount fields, and microVM attachments reject `read_only=true` before opening device resources. Use `read_write` to select a microVM attachment's access mode. @@ -191,12 +195,22 @@ The readiness endpoint is a process-local orchestration attachment and is not part of saved state. Each successful restore publishes one event; validation, attachment, or worker-start failure publishes none. -`VMConfig.MICROVM` is numeric value 2, matching the microVM ABI version, and -accepts exactly 1, 2, 4, or 8 processors. Numeric value 1 is not a valid profile -and is rejected before host resources are opened. RPC construction is currently -blockless; role-bearing sandbox blocks remain CLI-only. Snapshot ABI and boot -layout remain value 2, which identifies the Linux-direct MP-table layout. Value -1 snapshots are unsupported. +`VMConfig.MICROVM` is numeric value 2 and accepts exactly 1, 2, 4, or 8 +processors. Numeric value 1 is reserved and rejected before host resources are +opened. Clients must regenerate bindings because `machine_profile` moved from +field 15 to field 17 when upstream assigned field 15 to `iommufds`; the +colliding legacy encoding is rejected. Clients that used the former +`MICROVM_V2` source name must also update their bindings. RPC construction is +currently blockless; role-bearing sandbox blocks remain CLI-only. Snapshot ABI +and boot layout remain value 2, which identifies the Linux-direct MP-table +layout. Value 1 snapshots are unsupported. + +`VMConfig.pvh_boot` and protobuf field 14 are reserved, upstream `iommufds` +retains field 15, `crash_dump_path` uses field 16, and `machine_profile` uses +field 17. Upstream's `disable_vmbus` and `disable_hv` use fields 20 and 21 +instead of upstream's fields 16 and 17, while the `igvm` boot configuration and +`isolation_config` keep upstream's fields 18 and 19. Generate bindings from this +fork for the combined schema. The API has the same KVM/MSHV/WHP backend, no-block device, artifact integrity, and security restrictions documented under [`--snapshot-destination`]. diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index 77a6a915013..e87f378d6d3 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -1423,6 +1423,77 @@ mod tests { }); } + #[test] + fn ttrpc_virtio_fs_preserves_microvm_wire_fields() { + let encoded = b"\x1a\x06/share\x20\x01"; + let filesystem: vmservice::VirtioFs = mesh::payload::decode(encoded).unwrap(); + assert_eq!(filesystem.guest_mount_target, "/share"); + assert!(filesystem.read_write); + assert!(!filesystem.read_only); + assert_eq!(mesh::payload::encode(filesystem), encoded); + } + + #[test] + fn ttrpc_virtio_fs_read_only_uses_a_distinct_wire_field() { + let filesystem = vmservice::VirtioFs { + read_only: true, + ..Default::default() + }; + assert_eq!(mesh::payload::encode(filesystem), [0x28, 0x01]); + let decoded: vmservice::VirtioFs = mesh::payload::decode(&[0x28, 0x01]).unwrap(); + assert!(decoded.read_only); + assert!(!decoded.read_write); + assert!(decoded.guest_mount_target.is_empty()); + assert!(mesh::payload::decode::(&[0x18, 0x01]).is_err()); + } + + #[test] + fn ttrpc_vm_config_uses_non_conflicting_microvm_wire_fields() { + let encoded = b"\x88\x01\x02"; + let config: vmservice::VmConfig = mesh::payload::decode(encoded).unwrap(); + assert!(config.boot_config.is_none()); + assert_eq!( + config.machine_profile, + vmservice::vm_config::MachineProfile::Microvm as i32 + ); + assert!(config.crash_dump_path.is_none()); + assert_eq!(mesh::payload::encode(config), encoded); + assert!(mesh::payload::decode::(b"\x72\x00\x78\x02").is_err()); + } + + #[test] + fn ttrpc_crash_dump_uses_a_distinct_wire_field() { + let config = vmservice::VmConfig { + crash_dump_path: Some("dump".to_owned()), + ..Default::default() + }; + let encoded = b"\x82\x01\x04dump"; + assert_eq!(mesh::payload::encode(config), encoded); + let decoded: vmservice::VmConfig = mesh::payload::decode(encoded).unwrap(); + assert_eq!(decoded.crash_dump_path.as_deref(), Some("dump")); + assert!(decoded.boot_config.is_none()); + } + + #[test] + fn ttrpc_platform_flags_avoid_fork_wire_fields() { + let config = vmservice::VmConfig { + disable_vmbus: true, + disable_hv: true, + ..Default::default() + }; + let encoded = b"\xa0\x01\x01\xa8\x01\x01"; + assert_eq!(mesh::payload::encode(config), encoded); + let decoded: vmservice::VmConfig = mesh::payload::decode(encoded).unwrap(); + assert!(decoded.disable_vmbus); + assert!(decoded.disable_hv); + assert!(decoded.crash_dump_path.is_none()); + assert_eq!( + decoded.machine_profile, + vmservice::vm_config::MachineProfile::Standard as i32 + ); + assert!(mesh::payload::decode::(b"\x80\x01\x01").is_err()); + } + #[test] fn ttrpc_standard_virtio_fs_preserves_access_mode() { for read_only in [false, true] { diff --git a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto index 5693abb795f..1e4317a8683 100644 --- a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto +++ b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto @@ -209,6 +209,8 @@ message DevicesConfig { repeated WindowsPCIDevice windows_device = 4; repeated VirtioFs virtiofs_config = 5; VirtioConsoleConfig virtio_console = 6; + reserved 7; + reserved "virtio_blk"; } // An endpoint PCIe device function. Attached behind a port in the topology @@ -618,6 +620,7 @@ message VMConfig { STANDARD = 0; // Deterministic SMP-capable microVM ABI. MICROVM = 2; + reserved 1, 3, 4; } // Action for the VMM to take when the guest triggers a power event. @@ -651,6 +654,8 @@ message VMConfig { UEFI uefi = 6; IgvmBoot igvm = 18; } + reserved 14; + reserved "pvh_boot"; WindowsOptions windows_options = 7; // Field 8 was previously map extra_data. reserved 8; @@ -665,28 +670,29 @@ message VMConfig { // Actions to take on guest power events. Each unset action uses its // default. GuestPowerActions guest_power_actions = 12; - // SMBIOS (DMI) identity overrides. Applied to whichever boot type is // selected; note that PCAT boot honors only the system UUID and serial // number. IGVM boot does not support these overrides. SMBIOSConfig smbios_config = 13; - optional string crash_dump_path = 14; + // Tag 14 is retained for microVM PVH boot compatibility. + optional string crash_dump_path = 16; // Host iommufd contexts to create with the VM. Linux only; a non-empty // list on other hosts is an error. Contexts remain available until VM // teardown, including when unused or after their last device is removed, // so AddPcieDevice can reference them. No runtime context add/remove. repeated IommufdConfig iommufds = 15; // Run without VMBus. Incompatible with VMBus devices and HVSocket. - // Upstream assigns field 16, which this fork uses for machine_profile. + // Upstream uses field 16, which this fork assigns to crash_dump_path. bool disable_vmbus = 20; // Do not expose Hyper-V (HV#1) enlightenments. Requires disable_vmbus. // Unsupported for x86_64 UEFI boot. - bool disable_hv = 17; + // Upstream uses field 17, which this fork assigns to machine_profile. + bool disable_hv = 21; // Confidential-computing isolation mode. IsolationConfig isolation_config = 19; // Machine selection is independent from hypervisor selection. - MachineProfile machine_profile = 16; + MachineProfile machine_profile = 17; } // WindowsOptions contains virtual machine configurations that are only present on a Windows host. @@ -942,13 +948,14 @@ message WindowsPCIDevice { message VirtioFs { string tag = 1; string root_path = 2; - bool read_only = 3; // Absolute guest target for the microVM fixed HostFs device. // Must be empty for a standard-machine virtio-fs device. - string guest_mount_target = 4; + string guest_mount_target = 3; // Enable the microVM common read-write contract. False selects read-only. // Must be false for a standard-machine virtio-fs device. - bool read_write = 5; + bool read_write = 4; + // Standard-machine access flag. Tags 3 and 4 retain microVM wire compatibility. + bool read_only = 5; } message VirtioConsoleConfig { From 842f5e3fd16a7657187c6aae8f676659c45cea5b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 28 Sep 2026 12:13:16 -0700 Subject: [PATCH 160/304] virt_mshv: synchronize restored TSCs only on created VPs MSHV creates the BSP when partition memory is finalized but creates each application processor only when its binder is first bound. Restored-TSC synchronization still copied the advanced BSP counter to every VP index below the topology capacity. That holds only while every binder is bound: a restore that instantiates a VP prefix calls HvCallSetVpRegisters for VPs that were never created, and the hypervisor rejects the call with HV_STATUS_INVALID_VP_INDEX, failing the restore. This change belongs in 15313864a ("openvmm: bring a restore-time processor prefix online"). That commit documented that an explicit MSHV restore target instantiates only the VP prefix, while restored-TSC synchronization still targeted the full VP capacity. Create every VP through one helper that records that the hypervisor VP exists, for the BSP at finalization and for an application processor at its first bind, and align only the created application processors to the advanced BSP counter. An uncreated VP is skipped silently, so log the number of aligned application processors and the VP capacity once per restore. Restores that create every VP are unchanged. A unit test checks that only created application processors are selected. On an MSHV host, tests check that the hypervisor rejects synchronizing an uncreated VP with HV_STATUS_INVALID_VP_INDEX, that the created counters still converge, and, on a real partition that binds only a VP prefix, that restore-time alignment covers exactly the bound VPs. That test fails if binding stops recording VP creation or if alignment targets the full capacity again. The snapshot guide now says that partition time is frozen and the instantiated VPs are aligned only when more than one VP is instantiated. See microsoft/nvx#219. Fixes: 15313864adee ("openvmm: bring a restore-time processor prefix online") Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + Guide/src/user_guide/openvmm/snapshots.md | 11 +- vmm_core/virt_mshv/Cargo.toml | 1 + vmm_core/virt_mshv/src/lib.rs | 8 + vmm_core/virt_mshv/src/x86_64/finalize.rs | 5 +- vmm_core/virt_mshv/src/x86_64/mod.rs | 25 ++- vmm_core/virt_mshv/src/x86_64/tsc.rs | 240 +++++++++++++++++++--- 7 files changed, 250 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 741b1846b2d..4b77f06dbe0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9843,6 +9843,7 @@ dependencies = [ "mshv-bindings", "mshv-ioctls", "pal", + "pal_async", "pal_event", "parking_lot", "pci_core", diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 84d6d0dd6ed..bb3119a834c 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -178,11 +178,12 @@ without adding host read/write latency. KVM snapshot resume requires fails restore explicitly instead of falling back to imprecise counter writes. All backends read back the adjusted TSC before resume and reject a discarded or incomplete downtime adjustment. -After restoring counters and advancing snapshot time, MSHV and WHP freeze -partition time and align every VP's TSC to the BSP's advanced counter before -any VP runs. The first VP run thaws time. Setting counters while time is -running would introduce inter-VP skew from host scheduling delays, which can -make Linux reject the TSC clocksource during CPU activation. +When more than one VP is instantiated, MSHV and WHP freeze partition time +after restoring counters and advancing snapshot time, and align every +instantiated VP's TSC to the BSP's advanced counter before any VP runs. The +first VP run thaws time. Setting counters while time is running would +introduce inter-VP skew from host scheduling delays, which can make Linux +reject the TSC clocksource during CPU activation. For restored WHP partitions with multiple VPs, RDTSC, RDTSCP, and `IA32_TSC` reads additionally use one partition-reference-time epoch anchored to the advanced BSP counter. This clock has 100-nanosecond resolution and diff --git a/vmm_core/virt_mshv/Cargo.toml b/vmm_core/virt_mshv/Cargo.toml index c7349c7d672..bcad871a48b 100644 --- a/vmm_core/virt_mshv/Cargo.toml +++ b/vmm_core/virt_mshv/Cargo.toml @@ -38,6 +38,7 @@ tracing.workspace = true zerocopy.workspace = true [dev-dependencies] +pal_async.workspace = true test_with_tracing.workspace = true [build-dependencies] diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 7898b36395f..8785c57b831 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -160,6 +160,8 @@ impl<'a> MshvProtoPartition<'a> { message_queues: MessageQueues::new(), message_queues_pending: AtomicBool::new(false), extint_pending: AtomicBool::new(false), + #[cfg(guest_arch = "x86_64")] + created: AtomicBool::new(false), waker: RwLock::new(None), }) .collect(); @@ -349,6 +351,12 @@ struct MshvVpInner { /// Set when the userspace PIC pulses LINT0 and cleared after ExtINT delivery. #[cfg(guest_arch = "x86_64")] extint_pending: AtomicBool, + /// Set once this VP exists in the hypervisor. The BSP is created when + /// partition memory is finalized and an application processor when its + /// binder is first bound, so a restore-time VP prefix leaves the suffix + /// uncreated. + #[cfg(guest_arch = "x86_64")] + created: AtomicBool, /// Waker for the VP run loop task. Set by the VP thread, used by device /// threads to re-poll the run loop when new messages are enqueued. waker: RwLock>, diff --git a/vmm_core/virt_mshv/src/x86_64/finalize.rs b/vmm_core/virt_mshv/src/x86_64/finalize.rs index 4efda0b1954..70af0d8212c 100644 --- a/vmm_core/virt_mshv/src/x86_64/finalize.rs +++ b/vmm_core/virt_mshv/src/x86_64/finalize.rs @@ -15,6 +15,7 @@ use inspect::Inspect; use mshv_ioctls::VcpuFd; use std::sync::OnceLock; use virt::ProtoPartitionConfig; +use virt::VpIndex; /// Partition state that exists only after memory finalization. pub(crate) struct MshvFinalizedPartition { @@ -109,13 +110,13 @@ impl MshvPartitionInner { finalize_memory_once(&self.finalized, memory_attached, || { let _span = tracing::info_span!("mshv create BSP", vp_index = 0).entered(); let started = std::time::Instant::now(); - let result = self.vmfd.create_vcpu(0); + let result = self.create_vp(VpIndex::BSP); tracing::info!( elapsed_us = started.elapsed().as_micros() as u64, success = result.is_ok(), "MSHV_CREATE_VCPU completed" ); - let bsp_vcpufd = result.map_err(|e| ErrorInner::CreateVcpu(e.into()))?; + let bsp_vcpufd = result?; let caps = self.build_caps(&bsp_vcpufd)?; Ok(MshvFinalizedPartition { bsp_vcpufd, caps }) }) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 3f7d85926ce..153682a95c8 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -762,6 +762,24 @@ impl virt::irqcon::IoApicRouting for MshvPartitionInner { // Processor binding and run loop // --------------------------------------------------------------------------- +impl MshvPartitionInner { + /// Creates the hypervisor VP and records that it exists. Every VP is + /// created here, so restored-TSC synchronization sees exactly the created + /// VPs. + fn create_vp(&self, vp_index: VpIndex) -> Result { + let vcpufd = self + .vmfd + .create_vcpu( + u8::try_from(vp_index.index()).expect("VP count validated at partition creation"), + ) + .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; + self.vp(vp_index) + .created + .store(true, std::sync::atomic::Ordering::Release); + Ok(vcpufd) + } +} + impl virt::BindProcessor for MshvProcessorBinder { type Processor<'a> = MshvProcessor<'a> @@ -777,12 +795,7 @@ impl virt::BindProcessor for MshvProcessorBinder { &finalized.bsp_vcpufd } else { if self.vcpufd.is_none() { - let vcpufd = self - .partition - .vmfd - .create_vcpu(u8::try_from(self.vpindex.index()).expect("validated above")) - .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; - self.vcpufd = Some(vcpufd); + self.vcpufd = Some(self.partition.create_vp(self.vpindex)?); } self.vcpufd.as_ref().unwrap() }; diff --git a/vmm_core/virt_mshv/src/x86_64/tsc.rs b/vmm_core/virt_mshv/src/x86_64/tsc.rs index 179700b96c6..50746fc4d3b 100644 --- a/vmm_core/virt_mshv/src/x86_64/tsc.rs +++ b/vmm_core/virt_mshv/src/x86_64/tsc.rs @@ -88,16 +88,28 @@ impl MshvPartitionInner { Ok(()) } - /// Aligns the restored VP counters to the advanced BSP counter. + /// Aligns the restored counters of the created application processors to + /// the advanced BSP counter. pub(super) fn advance_snapshot_time(&self) -> Result<(), Error> { - if self.vps.len() <= 1 { + let aps = created_aps( + self.vps + .iter() + .map(|vp| vp.created.load(std::sync::atomic::Ordering::Acquire)), + ); + // Uncreated VPs are skipped silently, so record how many are aligned. + tracing::info!( + created_aps = aps.len(), + vp_capacity = self.vps.len(), + "aligning restored AP TSCs to the BSP" + ); + if aps.is_empty() { return Ok(()); } // Per-VP counter writes run at different host times. Freeze before // aligning them to the advanced BSP counter; the first VP run thaws time. self.freeze_time()?; - synchronize_restored_tscs(&self.vmfd, &self.finalized()?.bsp_vcpufd, self.vps.len()) + synchronize_restored_tscs(&self.vmfd, &self.finalized()?.bsp_vcpufd, &aps) } pub(super) fn apic_frequency_hz(&self) -> Result, Error> { @@ -109,7 +121,20 @@ impl MshvPartitionInner { } } -fn synchronize_restored_tscs(vmfd: &VmFd, bsp: &VcpuFd, vp_count: usize) -> Result<(), Error> { +/// Returns the VP indices of the created application processors, given +/// whether each VP of the topology was created. A restore-time VP prefix +/// leaves the suffix VPs uncreated, and the hypervisor rejects register access +/// to a VP that does not exist. +fn created_aps(created: impl IntoIterator) -> Vec { + created + .into_iter() + .enumerate() + .skip(1) + .filter_map(|(vp_index, created)| created.then_some(vp_index as u32)) + .collect() +} + +fn synchronize_restored_tscs(vmfd: &VmFd, bsp: &VcpuFd, aps: &[u32]) -> Result<(), Error> { let mut registers = [HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0_u64))]; bsp.get_hvdef_regs(&mut registers) .map_err(ErrorInner::Register)?; @@ -129,7 +154,7 @@ fn synchronize_restored_tscs(vmfd: &VmFd, bsp: &VcpuFd, vp_count: usize) -> Resu }, register: registers[0], }; - for vp_index in 1..vp_count as u32 { + for &vp_index in aps { input.header.vp_index = vp_index; let mut args = mshv_bindings::mshv_root_hvcall { code: hvdef::HypercallCode::HvCallSetVpRegisters.0, @@ -157,9 +182,30 @@ fn synchronize_restored_tscs(vmfd: &VmFd, bsp: &VcpuFd, vp_count: usize) -> Resu #[cfg(test)] mod tests { use super::*; + use crate::LinuxMshv; use crate::x86_64::partition_create_args; + use guestmem::GuestMemory; + use hvdef::HvError; + use hvdef::HypercallCode; + use hvdef::Vtl; use mshv_ioctls::Mshv; + use pal_async::DefaultDriver; + use pal_async::async_test; + use std::borrow::Borrow; + use std::sync::atomic::Ordering; use test_with_tracing::test; + use virt::BindProcessor; + use virt::Hypervisor; + use virt::Partition; + use virt::PartitionConfig; + use virt::PartitionMemoryMapper; + use virt::ProtoPartition; + use virt::ProtoPartitionConfig; + use vm_topology::memory::MemoryLayout; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + use vmcore::vmtime::VmTime; + use vmcore::vmtime::VmTimeKeeper; #[test] fn versioned_cpu_contract_does_not_expose_tsc_adjust() { @@ -178,39 +224,177 @@ mod tests { } } + #[test] + fn restored_tsc_synchronization_targets_only_created_aps() { + // The BSP is the synchronization source, never a target. + assert!(created_aps([true]).is_empty()); + assert!(created_aps([true, false, false, false]).is_empty()); + assert_eq!(created_aps([true, true, false, false]), [1]); + assert_eq!(created_aps([true, true, true, true]), [1, 2, 3]); + } + + fn create_frozen_partition(vp_count: u8) -> (VmFd, Vec) { + let mshv = Mshv::new().unwrap(); + let vmfd = mshv.create_vm().unwrap(); + vmfd.initialize().unwrap(); + let vps: Vec<_> = (0..vp_count) + .map(|index| vmfd.create_vcpu(index).unwrap()) + .collect(); + vmfd.set_partition_property(HvPartitionPropertyCode::TimeFreeze.0, 1) + .unwrap(); + (vmfd, vps) + } + + fn set_skewed_tscs(vps: &[impl Borrow], tsc: u64) { + for (index, vp) in vps.iter().enumerate() { + vp.borrow() + .set_hvdef_regs(&[HvRegisterAssoc::from(( + HvX64RegisterName::Tsc, + tsc + index as u64 * 100_000, + ))]) + .unwrap(); + } + } + + fn assert_tscs(vps: &[impl Borrow], expected_tsc: u64) { + for (index, vp) in vps.iter().enumerate() { + let mut registers = [HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0_u64))]; + vp.borrow().get_hvdef_regs(&mut registers).unwrap(); + assert_eq!( + registers[0].value.as_u64(), + expected_tsc, + "VP {index} TSC differs with {} VPs", + vps.len() + ); + } + } + #[test] #[ignore = "requires /dev/mshv"] fn restored_tscs_are_identical_while_partition_time_is_frozen() { for vp_count in [1, 2, 4, 8] { - let mshv = Mshv::new().unwrap(); - let vmfd = mshv.create_vm().unwrap(); - vmfd.initialize().unwrap(); - let vps: Vec<_> = (0..vp_count) - .map(|index| vmfd.create_vcpu(index).unwrap()) - .collect(); - vmfd.set_partition_property(HvPartitionPropertyCode::TimeFreeze.0, 1) - .unwrap(); + let (vmfd, vps) = create_frozen_partition(vp_count); let expected_tsc = 1_000_000_u64; - for (index, vp) in vps.iter().enumerate() { - vp.set_hvdef_regs(&[HvRegisterAssoc::from(( - HvX64RegisterName::Tsc, - expected_tsc + index as u64 * 100_000, - ))]) - .unwrap(); + set_skewed_tscs(&vps, expected_tsc); + + let aps = created_aps(std::iter::repeat_n(true, usize::from(vp_count))); + synchronize_restored_tscs(&vmfd, &vps[0], &aps).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + assert_tscs(&vps, expected_tsc); + } + } + + #[test] + #[ignore = "requires /dev/mshv"] + fn restored_tsc_synchronization_skips_uncreated_suffix_vps() { + const VP_CAPACITY: u8 = 8; + for vp_count in [1, 2, 4] { + let (vmfd, vps) = create_frozen_partition(vp_count); + let expected_tsc = 1_000_000_u64; + set_skewed_tscs(&vps, expected_tsc); + + // The hypervisor rejects register access to an uncreated VP. + let uncreated = u32::from(vp_count); + let failure = synchronize_restored_tscs(&vmfd, &vps[0], &[uncreated]).unwrap_err(); + match failure.0 { + ErrorInner::SynchronizeTsc { + vp_index, + error: KernelError::Hypercall { code, error }, + } => { + assert_eq!(vp_index, uncreated); + assert_eq!(code, HypercallCode::HvCallSetVpRegisters); + assert_eq!(error, HvError::InvalidVpIndex); + } + other => panic!("unexpected error: {other:?}"), } - synchronize_restored_tscs(&vmfd, &vps[0], vps.len()).unwrap(); + let aps = created_aps((0..VP_CAPACITY).map(|index| index < vp_count)); + synchronize_restored_tscs(&vmfd, &vps[0], &aps).unwrap(); std::thread::sleep(std::time::Duration::from_millis(10)); - for (index, vp) in vps.iter().enumerate() { - let mut registers = [HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0_u64))]; - vp.get_hvdef_regs(&mut registers).unwrap(); - assert_eq!( - registers[0].value.as_u64(), - expected_tsc, - "VP {index} TSC differs with {vp_count} VPs" - ); + assert_tscs(&vps, expected_tsc); + } + } + + #[async_test] + #[ignore = "requires /dev/mshv"] + async fn restored_tsc_synchronization_aligns_only_bound_vps(driver: DefaultDriver) { + const VP_CAPACITY: u32 = 4; + let processor_topology = TopologyBuilder::new_x86() + .x2apic(X2ApicState::Supported) + .build(VP_CAPACITY) + .unwrap(); + let mem_layout = MemoryLayout::new(0x400000, &[], &[], &[], None).unwrap(); + let vmtime_keeper = VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); + let vmtime = vmtime_keeper.builder().build(&driver).await.unwrap(); + + for vp_count in [1, 2, VP_CAPACITY] { + let guest_memory = GuestMemory::allocate(mem_layout.end_of_ram() as usize); + let mut mshv = LinuxMshv::new().unwrap(); + let (partition, mut binders) = mshv + .new_partition(ProtoPartitionConfig { + processor_topology: &processor_topology, + hv_config: None, + vmtime: &vmtime, + isolation: virt::ProtoPartitionIsolation::None, + nested_virt: false, + user_mode_memory_faults: false, + lazy_memory_registration: false, + versioned_cpu_contract: false, + }) + .unwrap() + .build(PartitionConfig { + mem_layout: &mem_layout, + guest_memory: &guest_memory, + cpuid: &[], + vtl0_alias_map: None, + fault_resolver: None, + }) + .unwrap(); + let ram = guest_memory.inner_buf().unwrap(); + // SAFETY: the guest memory outlives the partition. + unsafe { + partition.memory_mapper(Vtl::Vtl0).map_range( + ram.as_ptr().cast_mut().cast(), + ram.len(), + 0, + true, + true, + ) } + .unwrap(); + partition.finalize_memory().unwrap(); + + // A restore binds only the VPs that it runs, which creates them. + for binder in &mut binders[..vp_count as usize] { + binder.bind().unwrap(); + } + let created: Vec<_> = partition + .inner + .vps + .iter() + .map(|vp| vp.created.load(Ordering::Acquire)) + .collect(); + let expected_created: Vec<_> = (0..VP_CAPACITY).map(|index| index < vp_count).collect(); + assert_eq!(created, expected_created); + + partition.inner.freeze_time().unwrap(); + let bound: Vec<&VcpuFd> = + std::iter::once(&partition.inner.finalized().unwrap().bsp_vcpufd) + .chain( + binders[1..vp_count as usize] + .iter() + .map(|binder| binder.vcpufd.as_ref().unwrap()), + ) + .collect(); + let expected_tsc = 1_000_000_u64; + set_skewed_tscs(&bound, expected_tsc); + + partition.inner.advance_snapshot_time().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + + assert_tscs(&bound, expected_tsc); } } } From b26d19cec72f2188f4b7fc81de8e6d27b7c06bab Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 28 Sep 2026 12:13:23 -0700 Subject: [PATCH 161/304] openvmm_core: instantiate only the requested VP prefix on MSHV restore The CLI --restore-processors option and the TTRPC restore processor count pass an explicit restore-time activation target to the VM worker as restore_vp_count, which is documented to make MSHV create and bind only VPs 0..N-1. Nothing read the field, so every MSHV restore created, restored, and ran the full VP capacity of the snapshot, as KVM and WHP do. The guest still onlined exactly N CPUs, so only the host-side saving was lost. This change belongs in 15313864a ("openvmm: bring a restore-time processor prefix online"). That commit documented that an explicit MSHV restore target instantiates and binds only the VP prefix, while the worker still built the partition unit with the active_vp_count of None that b80b54fc7 ("vmm_core: instantiate an active VP prefix") set. The gRPC reference update belongs in 9354e0960 ("vmservice: create, capture, and restore microVMs over the management RPC"), which added restore_processor_count. Take the target with the other restore inputs and keep it only for MSHV, which creates application processors lazily while binding them. Before any VP is bound, check the target against the topology capacity, discard the binders after the prefix, and pass the prefix to the partition unit, which validates the complete saved VP inventory before restoring only the prefix and rejects saving the reduced runtime. KVM, WHP, and untargeted MSHV restores keep the full capacity. This relies on the previous commit, which limits restored-TSC synchronization to the created VPs. Emit the startup.vp_thread_bind, startup.vp_bind_bsp, and startup.vp_bind_ap_ profile phases that NVX documents, so lifecycle profiling shows which VPs a restore binds. On a bare-metal MSHV host, restoring a capacity-8 snapshot now binds exactly N VPs, and worker construction drops from about 97 ms to about 11 ms for one or two VPs. Unit tests cover the MSHV-only selection, discarding the suffix binders, keeping every binder without a target, and rejecting an out-of-range prefix, a prefix without saved state, and a backend that returns fewer binders than the capacity. The worker parameter documentation and the gRPC reference describe the backend-specific behavior. See microsoft/nvx#219. Fixes: 15313864adee ("openvmm: bring a restore-time processor prefix online") Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/reference/openvmm/management/grpc.md | 4 +- openvmm/openvmm_core/src/worker/dispatch.rs | 57 +++++-- .../src/worker/dispatch/restore.rs | 143 +++++++++++++++++- openvmm/openvmm_defs/src/worker.rs | 3 +- 4 files changed, 190 insertions(+), 17 deletions(-) diff --git a/Guide/src/reference/openvmm/management/grpc.md b/Guide/src/reference/openvmm/management/grpc.md index 12fecd1e705..d1d3dc0998e 100644 --- a/Guide/src/reference/openvmm/management/grpc.md +++ b/Guide/src/reference/openvmm/management/grpc.md @@ -146,7 +146,9 @@ restore flow over both transports: valid only for a snapshot that advertises processor activation, implies fresh entropy and the post-restore gate, and must satisfy the snapshot's boot-online and immutable capacity bounds. `ProcessorConfig.processor_count`, when present, remains an - exact capacity assertion. + exact capacity assertion. On MSHV, a nonzero value instantiates and binds + only that VP prefix, as for `--restore-processors`; MSHV restores without a + target, and all KVM and WHP restores, instantiate the full VP capacity. * `restore_memory_bytes` selects a 128-MiB-aligned total RAM target between the snapshot base and immutable capacity. Zero selects the base. Expansion uses fresh private zeroed backing, implies fresh restore packet delivery and the diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 0918fc70d68..9b731f57751 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1659,7 +1659,7 @@ impl InitializedVm { let Self { partition, - vps, + mut vps, vmtime_keeper, vmtime_source, memory_manager, @@ -1676,6 +1676,12 @@ impl InitializedVm { driver_source, } = self; + let instantiated_vp_count = snapshot_restore.select_instantiated_vps( + &mut vps, + processor_topology.vp_count(), + saved_state.is_some(), + )?; + let mut resolver = ResourceResolver::new(); // Expose the partition reference time source, if available. @@ -3202,7 +3208,7 @@ impl InitializedVm { partition.clone().into_vm_partition(), PartitionUnitParams { processor_topology: &processor_topology, - active_vp_count: None, + active_vp_count: Some(instantiated_vp_count), halt_vps, halt_request_recv, client_notify_send: halt_send, @@ -3217,6 +3223,12 @@ impl InitializedVm { .context("failed to create partition unit")?; // Start the VP backing threads. + assert_eq!( + vps.len(), + vp_runners.len(), + "every instantiated VP needs exactly one binder and one runner" + ); + let vp_thread_bind = openvmm_defs::profile::ProfileSpan::start(); try_join_all(vps.into_iter().zip(vp_runners).enumerate().map( |(vp_index, (mut vp, runner))| { let partition = partition.clone(); @@ -3224,17 +3236,22 @@ impl InitializedVm { let (send, recv) = mesh::oneshot(); thread::Builder::new() .name(format!("vp-{}", vp_index)) - .spawn(move || match vp.bind() { - Ok(mut vp) => { - send.send(Ok(())); - block_on_vp( - partition, - VpIndex::new(vp_index as u32), - vp.run(runner, &chipset), - ) - } - Err(err) => { - send.send(Err(err)); + .spawn(move || { + let vp_bind = openvmm_defs::profile::ProfileSpan::start(); + let bound = vp.bind(); + complete_vp_bind_profile(vp_bind, vp_index); + match bound { + Ok(mut vp) => { + send.send(Ok(())); + block_on_vp( + partition, + VpIndex::new(vp_index as u32), + vp.run(runner, &chipset), + ) + } + Err(err) => { + send.send(Err(err)); + } } }) .unwrap(); @@ -3247,6 +3264,7 @@ impl InitializedVm { }, )) .await?; + vp_thread_bind.complete("startup", "vp_thread_bind", Default::default()); let mut this = LoadedVm { state_units, @@ -3328,6 +3346,19 @@ impl InitializedVm { } } +/// Emits the non-exclusive profile milestone of binding VP `vp_index`. +fn complete_vp_bind_profile(span: openvmm_defs::profile::ProfileSpan, vp_index: usize) { + if vp_index == 0 { + span.complete_milestone("startup", "vp_bind_bsp", Default::default()); + } else if openvmm_defs::profile::enabled() { + span.complete_milestone( + "startup", + &format!("vp_bind_ap_{vp_index}"), + Default::default(), + ); + } +} + impl LoadedVmInner { fn slit_info(&self) -> Option { let num_nodes = self.numa_cfg.nodes.len(); diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index 7e75c4e9958..d9fb889bbae 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -2,8 +2,9 @@ // Licensed under the MIT License. //! Snapshot restore support for the VM worker: the restore inputs taken from -//! the worker parameters, the restore state kept by [`LoadedVm`], the steps -//! around applying the saved state, and the post-restore start sequence +//! the worker parameters, the restore state kept by [`LoadedVm`], the VP +//! prefix instantiated by an explicit restore-time activation target, the +//! steps around applying the saved state, and the post-restore start sequence //! (input gate, readiness event, and VP release). use super::LoadedVm; @@ -11,6 +12,8 @@ use super::clock; use super::clock::RestoreTime; use crate::partition::HvlitePartition; use anyhow::Context; +use hypervisor_resources::HypervisorKind; +use hypervisor_resources::MshvHandle; use membacking::FileMappingMode; use membacking::SharedMemoryBacking; use openvmm_defs::profile::ProfileSpan; @@ -27,6 +30,7 @@ use std::fs::File; use std::future::Future; use std::io::Write as _; use std::time::Duration; +use vm_resource::ResourceId; use vmm_core::partition_unit::StopGuard; /// Snapshot-restore inputs taken from the [`VmWorkerParameters`]. @@ -54,6 +58,13 @@ impl RestoreParameters { parameters.restore_apic_frequency_hz, )?; tracing::debug!(?restore_time, "received snapshot restore time contract"); + let restore_vp_count = parameters.restore_vp_count.take(); + let vp_prefix = restore_vp_prefix(parameters.hypervisor.id(), restore_vp_count); + tracing::debug!( + ?restore_vp_count, + ?vp_prefix, + "received restore-time VP activation target" + ); let cpu_contract = parameters.restore_cpu_contract.take(); let file_mapping_mode = if parameters.shared_memory_copy_on_write { FileMappingMode::CopyOnWrite @@ -63,6 +74,7 @@ impl RestoreParameters { Ok(Self { state: SnapshotRestore { time: restore_time, + vp_prefix, ready_sink, gate_timeout, ..Default::default() @@ -79,11 +91,52 @@ impl RestoreParameters { } } +/// Returns the VP prefix that a restore instantiates, or `None` for the full +/// VP capacity. +/// +/// Only MSHV instantiates an explicit restore-time activation target: it +/// creates application processors lazily while binding them, so discarding +/// the suffix binders avoids creating VPs that the restore will not run. +/// Other backends create their VPs with the partition and keep the full +/// capacity. +fn restore_vp_prefix(hypervisor: &str, restore_vp_count: Option) -> Option { + restore_vp_count.filter(|_| hypervisor == >::ID) +} + +/// Discards the binders after an explicit VP prefix and returns the number +/// of VPs to instantiate. The topology and saved VP inventory keep the full +/// capacity. +fn select_instantiated_vps( + vps: &mut Vec, + vp_capacity: u32, + vp_prefix: Option, + restoring: bool, +) -> anyhow::Result { + anyhow::ensure!( + vps.len() == vp_capacity as usize, + "backend returned {} VP binders for topology capacity {vp_capacity}", + vps.len() + ); + let Some(vp_prefix) = vp_prefix else { + return Ok(vp_capacity); + }; + anyhow::ensure!(restoring, "a restore VP prefix requires saved state"); + anyhow::ensure!( + (1..=vp_capacity).contains(&vp_prefix), + "restore VP prefix {vp_prefix} is outside topology capacity 1..={vp_capacity}" + ); + vps.truncate(vp_prefix as usize); + Ok(vp_prefix) +} + /// Snapshot-restore state of a [`LoadedVm`]. #[derive(Default)] pub(super) struct SnapshotRestore { /// Saved guest-clock contract, checked and applied around the restore. time: Option, + /// VP prefix instantiated for an explicit MSHV restore-time activation + /// target. + vp_prefix: Option, /// Whether the VM was loaded from saved state. restored_from_snapshot: bool, /// Holds the VPs stopped after a restore until the VM first starts. @@ -101,6 +154,17 @@ pub(super) struct SnapshotRestore { } impl SnapshotRestore { + /// Discards the binders after the restore VP prefix, if any, before + /// binding creates their VPs. Returns the number of VPs to instantiate. + pub(super) fn select_instantiated_vps( + &self, + vps: &mut Vec, + vp_capacity: u32, + restoring: bool, + ) -> anyhow::Result { + select_instantiated_vps(vps, vp_capacity, self.vp_prefix, restoring) + } + /// Returns a future that completes when the armed post-restore input gate /// expires, and never completes while the gate is not armed. pub(super) fn gate_expired(&self, driver: &impl Driver) -> impl Future { @@ -260,3 +324,78 @@ impl LoadedVm { } } } + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + fn binders() -> Vec { + (0..8).collect() + } + + #[test] + fn limits_only_explicit_mshv_restores() { + assert_eq!(restore_vp_prefix("mshv", Some(2)), Some(2)); + assert_eq!(restore_vp_prefix("mshv", None), None); + for hypervisor in ["kvm", "whp", "hvf"] { + assert_eq!(restore_vp_prefix(hypervisor, Some(2)), None); + assert_eq!(restore_vp_prefix(hypervisor, None), None); + } + } + + #[test] + fn restore_vp_prefix_discards_suffix_binders() { + for prefix in [1, 2, 4, 8] { + let mut vps = binders(); + assert_eq!( + select_instantiated_vps(&mut vps, 8, Some(prefix), true).unwrap(), + prefix + ); + assert_eq!(vps, (0..prefix).collect::>()); + } + } + + #[test] + fn untargeted_loads_keep_every_binder() { + for restoring in [false, true] { + let mut vps = binders(); + assert_eq!( + select_instantiated_vps(&mut vps, 8, None, restoring).unwrap(), + 8 + ); + assert_eq!(vps, binders()); + } + } + + #[test] + fn rejects_invalid_restore_vp_prefix() { + for prefix in [0, 9] { + let mut vps = binders(); + let error = select_instantiated_vps(&mut vps, 8, Some(prefix), true).unwrap_err(); + assert_eq!( + error.to_string(), + format!("restore VP prefix {prefix} is outside topology capacity 1..=8") + ); + assert_eq!(vps, binders()); + } + + let mut vps = binders(); + let error = select_instantiated_vps(&mut vps, 8, Some(2), false).unwrap_err(); + assert_eq!( + error.to_string(), + "a restore VP prefix requires saved state" + ); + assert_eq!(vps, binders()); + } + + #[test] + fn rejects_a_partial_backend_binder_set() { + let mut vps = (0..4).collect::>(); + let error = select_instantiated_vps(&mut vps, 8, None, true).unwrap_err(); + assert_eq!( + error.to_string(), + "backend returned 4 VP binders for topology capacity 8" + ); + } +} diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index 80a73028411..fffb34e1dcc 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -87,7 +87,8 @@ pub struct VmWorkerParameters { pub restore_ready_sink: Option, /// Timeout for the microVM post-restore input gate, when required. pub restore_gate_timeout: Option, - /// MSHV VP prefix instantiated for an explicit restore-time activation target. + /// Explicit restore-time activation target. MSHV instantiates and binds + /// only VPs `0..N-1`; other backends instantiate the full VP capacity. pub restore_vp_count: Option, /// The VM RPC channel. pub rpc: mesh::Receiver, From 885fde30f334dc18c1042a0acfe6a1b43c9a7f2b Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Mon, 28 Sep 2026 14:28:31 -0700 Subject: [PATCH 162/304] Restore listener endpoint rebinding Allow eligible listener attachments to use a fresh restore-time endpoint while preserving every saved attachment invariant except endpoint identity. Validate replacements before reconstructing obsolete saved endpoints and align machine-contract validation with the saved contract. This behavior was previously merged in commits a8894ba0eb56563747cd13205782e533d8b744d5 and 46b4a7bd7423e0c73e7249cd0b0a78beb455df1f. The modular microVM v2 clean-history baseline c94a280d20a0372b6b4450a11bb2aa47c40c3fd0 should have carried those changes forward. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 11 +- Guide/src/user_guide/openvmm/snapshots.md | 12 + openvmm/openvmm_entry/src/microvm/console.rs | 210 ++++++++++++++++++ openvmm/openvmm_entry/src/microvm/restore.rs | 56 +++++ 4 files changed, 287 insertions(+), 2 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 6d9dae3fc74..9e543f32fec 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -683,7 +683,10 @@ Serial devices can be configured to appear as different devices inside the guest recreate the optional listener on restore. Unix sockets must be beside the snapshot directory. Windows pipes use the `//./pipe/openvmm-microvm-` namespace. TCP ports must be nonzero. - TCP addresses must be loopback addresses. + TCP addresses must be loopback addresses. A restore caller can resupply + `--virtio-console listen=` with `--restore-snapshot` to + replace the saved listener identity while preserving the attachment + contract. Omitting the option recreates the saved listener endpoint. * `connect=PATH` or `connect=tcp:IP:PORT`: require a client connection before vCPUs start. Cold boot and restore use a five-second timeout. The restore command must explicitly resupply the matching client attachment. @@ -750,7 +753,11 @@ Serial devices can be configured to appear as different devices inside the guest reconnect policy. It never records a capability or UID/SID. Restore validates the saved endpoint contract, requires a fresh launcher-provided capability for a live endpoint, and generates a fresh VMM instance ID; saved credentials - and stale capabilities are never reused. + and stale capabilities are never reused. Restore callers resupply + `--microvm-control-console listen=` together with + `--microvm-control-auth-stdin`; the endpoint may change, but the saved stable + attachment ID, attachment kind, backend kind, reconnect policy, required + flag, length, and timeout remain exact. Restore snapshots containing this device through the CLI. The OpenVMM management RPC does not expose control-console restore attachments and diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index bb3119a834c..47d3dfac6df 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -124,6 +124,18 @@ directory, so `file=...` should not be specified in `--memory` (the two options are mutually exclusive). Guest writes use a private copy-on-write mapping and do not modify the snapshot artifact. +Saved virtio-console listener attachments may be rebound to fresh restore-time +listener paths. The replacement must retain the saved stable attachment ID, +attachment kind, backend kind, reconnect policy, required flag, length, and +timeout; only the listener endpoint identity may change. Restore callers use +`--virtio-console listen=` and, for an authenticated control +listener, `--microvm-control-console listen=` with +`--microvm-control-auth-stdin`. OpenVMM validates an approved replacement +without reopening or canonicalizing the captured listener path, so the source +generation's private directory need not survive until restore. Client, +inherited-provider, and disconnected attachments retain their stricter saved +identity requirements. + MicroVM orchestrators can add `--restore-ready-path `. OpenVMM connects to an existing Unix domain socket on Linux or named pipe on Windows and writes `OPENVMM_RESTORE_READY_V1\n` after restore validation, attachment resolution, diff --git a/openvmm/openvmm_entry/src/microvm/console.rs b/openvmm/openvmm_entry/src/microvm/console.rs index 7e2fab20bf3..1a3e0d46e06 100644 --- a/openvmm/openvmm_entry/src/microvm/console.rs +++ b/openvmm/openvmm_entry/src/microvm/console.rs @@ -40,6 +40,22 @@ pub(super) fn microvm_console_attachments_share_endpoint( } } +pub(super) fn microvm_console_listener_replacement_matches( + saved: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, + requested: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, +) -> bool { + matches!( + saved.reconnect_policy.as_str(), + "recreate-listener" | "broker-authenticated-listener" + ) && saved.stable_id == requested.stable_id + && saved.kind == requested.kind + && saved.required == requested.required + && saved.reconnect_policy == requested.reconnect_policy + && saved.identity_kind == requested.identity_kind + && saved.length == requested.length + && saved.reconnect_timeout_ms == requested.reconnect_timeout_ms +} + pub(crate) struct MicrovmConsoleSocketCleanup { #[cfg(unix)] path: PathBuf, @@ -484,6 +500,23 @@ fn microvm_console_attachment_from_snapshot_with_identity( "snapshot requires an explicitly approved restore-time control attachment" ); } + if matches!( + attachment.reconnect_policy.as_str(), + "recreate-listener" | "broker-authenticated-listener" + ) { + if let Some(requested) = requested { + let requested = if control_console { + microvm_control_console_attachment_from_cli(requested)? + } else { + microvm_console_attachment_from_cli(requested)? + }; + anyhow::ensure!( + microvm_console_listener_replacement_matches(attachment, &requested.2), + "restore-time virtio-console listener does not match the snapshot attachment contract" + ); + return Ok(requested); + } + } let config = match ( attachment.reconnect_policy.as_str(), attachment.identity_kind.as_str(), @@ -771,6 +804,21 @@ mod tests { assert!(!microvm_console_attachments_share_endpoint(&left, &right)); } + #[test] + fn listener_replacement_contract_changes_only_endpoint_identity() { + let saved = socket_attachment(Path::new("source.sock")); + let mut requested = saved.clone(); + requested.identity = b"restored.sock".to_vec(); + assert!(microvm_console_listener_replacement_matches( + &saved, &requested + )); + + requested.kind = MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND.to_owned(); + assert!(!microvm_console_listener_replacement_matches( + &saved, &requested + )); + } + #[test] fn client_attachment_is_required_and_bounded() { let requested = SerialConfigCli::ConnectTcp("127.0.0.1:5555".parse().unwrap()); @@ -793,6 +841,168 @@ mod tests { assert_eq!(restored_snapshot, snapshot); } + #[cfg(unix)] + #[test] + fn listener_attachment_accepts_fresh_restore_identity_after_source_cleanup() { + let source_directory = tempfile::tempdir().unwrap(); + let source = SerialConfigCli::Pipe(source_directory.path().join("source.sock")); + let (_, _, snapshot) = microvm_console_attachment_from_cli(&source).unwrap(); + source_directory.close().unwrap(); + let restore_directory = tempfile::tempdir().unwrap(); + let requested = SerialConfigCli::Pipe(restore_directory.path().join("restored.sock")); + + let (restored, resource, restored_attachment) = + microvm_console_attachment_from_snapshot(&snapshot, Some(&requested)).unwrap(); + + let SerialConfigCli::Pipe(restored_path) = restored else { + panic!("restore did not retain a listener"); + }; + assert!(restored_path.ends_with("restored.sock")); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::RecreateListener + ); + assert_ne!(restored_attachment.identity, snapshot.identity); + assert!(microvm_console_listener_replacement_matches( + &snapshot, + &restored_attachment + )); + } + + #[cfg(unix)] + #[test] + fn authenticated_control_listener_accepts_fresh_identity_after_source_cleanup() { + use std::os::unix::fs::PermissionsExt as _; + + let source_directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions( + source_directory.path(), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let source = SerialConfigCli::Pipe(source_directory.path().join("source-control.sock")); + let (_, _, snapshot) = microvm_control_console_attachment_from_cli(&source).unwrap(); + source_directory.close().unwrap(); + let restore_directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions( + restore_directory.path(), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + let requested = + SerialConfigCli::Pipe(restore_directory.path().join("restored-control.sock")); + + let (restored, resource, restored_attachment) = + microvm_console_attachment_from_snapshot_with_identity( + &snapshot, + Some(&requested), + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + true, + ) + .unwrap(); + + let SerialConfigCli::Pipe(restored_path) = restored else { + panic!("restore did not retain a control listener"); + }; + assert!(restored_path.ends_with("restored-control.sock")); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::RecreateListener + ); + assert_eq!( + restored_attachment.reconnect_policy, + "broker-authenticated-listener" + ); + assert_ne!(restored_attachment.identity, snapshot.identity); + assert!(microvm_console_listener_replacement_matches( + &snapshot, + &restored_attachment + )); + } + + #[cfg(windows)] + #[test] + fn named_pipe_listener_accepts_fresh_restore_identity() { + let source = + SerialConfigCli::Pipe(PathBuf::from("//./pipe/openvmm-microvm-source-console")); + let requested = + SerialConfigCli::Pipe(PathBuf::from("//./pipe/openvmm-microvm-restored-console")); + let (_, _, snapshot) = microvm_console_attachment_from_cli(&source).unwrap(); + + let (restored, resource, restored_attachment) = + microvm_console_attachment_from_snapshot(&snapshot, Some(&requested)).unwrap(); + + let SerialConfigCli::Pipe(restored_path) = restored else { + panic!("restore did not retain a named-pipe listener"); + }; + assert_eq!( + restored_path, + PathBuf::from("//./pipe/openvmm-microvm-restored-console") + ); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::RecreateListener + ); + assert_ne!(restored_attachment.identity, snapshot.identity); + assert!(microvm_console_listener_replacement_matches( + &snapshot, + &restored_attachment + )); + } + + #[cfg(windows)] + #[test] + fn authenticated_named_pipe_listener_accepts_fresh_restore_identity() { + let source = + SerialConfigCli::Pipe(PathBuf::from("//./pipe/openvmm-microvm-source-control")); + let requested = + SerialConfigCli::Pipe(PathBuf::from("//./pipe/openvmm-microvm-restored-control")); + let (_, _, snapshot) = microvm_control_console_attachment_from_cli(&source).unwrap(); + + let (restored, resource, restored_attachment) = + microvm_console_attachment_from_snapshot_with_identity( + &snapshot, + Some(&requested), + MICROVM_CONTROL_CONSOLE_STABLE_ID, + MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND, + true, + ) + .unwrap(); + + let SerialConfigCli::Pipe(restored_path) = restored else { + panic!("restore did not retain an authenticated named-pipe listener"); + }; + assert_eq!( + restored_path, + PathBuf::from("//./pipe/openvmm-microvm-restored-control") + ); + assert_eq!( + resource.reconnect_policy, + VirtioConsoleReconnectPolicy::RecreateListener + ); + assert_eq!( + restored_attachment.reconnect_policy, + "broker-authenticated-listener" + ); + assert_ne!(restored_attachment.identity, snapshot.identity); + assert!(microvm_console_listener_replacement_matches( + &snapshot, + &restored_attachment + )); + } + + #[cfg(unix)] + #[test] + fn listener_attachment_rejects_restore_client_substitution() { + let directory = tempfile::tempdir().unwrap(); + let source = SerialConfigCli::Pipe(directory.path().join("source.sock")); + let requested = SerialConfigCli::ConnectPipe(directory.path().join("restored.sock")); + let (_, _, snapshot) = microvm_console_attachment_from_cli(&source).unwrap(); + + assert!(microvm_console_attachment_from_snapshot(&snapshot, Some(&requested)).is_err()); + } + #[test] fn tcp_attachment_rejects_non_loopback_addresses() { assert!( diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 7b415080778..491a70d7a5d 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -3,6 +3,9 @@ //! MicroVM snapshot restore packets and restore-time contract checks. +use super::console::MICROVM_CONSOLE_STABLE_ID; +use super::console::MICROVM_CONTROL_CONSOLE_STABLE_ID; +use super::console::microvm_console_listener_replacement_matches; use super::filesystem::microvm_filesystem_slot_from_snapshot; use crate::Options; use crate::cli_args; @@ -134,6 +137,36 @@ pub(super) fn align_legacy_network_policy_contract( } } +fn align_restore_console_listener_contract( + saved: &SnapshotMachineContract, + expected: &mut SnapshotMachineContract, +) { + align_restore_console_listener_attachments(&saved.attachments, &mut expected.attachments); +} + +fn align_restore_console_listener_attachments( + saved: &[SnapshotAttachment], + expected: &mut [SnapshotAttachment], +) { + for stable_id in [MICROVM_CONSOLE_STABLE_ID, MICROVM_CONTROL_CONSOLE_STABLE_ID] { + let Some(saved_attachment) = saved + .iter() + .find(|attachment| attachment.stable_id == stable_id) + else { + continue; + }; + let Some(expected_attachment) = expected + .iter_mut() + .find(|attachment| attachment.stable_id == stable_id) + else { + continue; + }; + if microvm_console_listener_replacement_matches(saved_attachment, expected_attachment) { + expected_attachment.clone_from(saved_attachment); + } + } +} + /// Restore-time inputs that shape a microVM configuration. #[derive(Default)] pub(crate) struct MicrovmRestore { @@ -388,6 +421,7 @@ pub(crate) fn validate_restore_contract( saved_contract.cpu_contract.clone(), )?; align_legacy_network_policy_contract(saved_contract, &mut expected_contract); + align_restore_console_listener_contract(saved_contract, &mut expected_contract); openvmm_helpers::snapshot::microvm::validate_microvm_machine_contract( manifest, &expected_contract, @@ -411,6 +445,28 @@ mod tests { use openvmm_helpers::snapshot::microvm::SnapshotMemoryExpansionRange; use test_with_tracing::test; + #[test] + fn listener_replacement_alignment_preserves_saved_machine_contract() { + let saved_attachment = SnapshotAttachment { + stable_id: MICROVM_CONSOLE_STABLE_ID.to_owned(), + kind: "virtio-console".to_owned(), + required: false, + reconnect_policy: "recreate-listener".to_owned(), + identity_kind: "unix-socket".to_owned(), + identity: b"source.sock".to_vec(), + length: 0, + reconnect_timeout_ms: 0, + }; + let mut requested_attachment = saved_attachment.clone(); + requested_attachment.identity = b"restored.sock".to_vec(); + let saved = vec![saved_attachment.clone()]; + let mut expected = vec![requested_attachment]; + + align_restore_console_listener_attachments(&saved, &mut expected); + + assert_eq!(expected, vec![saved_attachment]); + } + #[test] fn snapshot_downtime_accepts_supported_elapsed_time() { let capture = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1); From dc61c51897643665f6190815f9e2c007e666518e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 30 Sep 2026 07:52:20 -0700 Subject: [PATCH 163/304] openvmm_entry: compile connect_serial without the RPC features The cargo hack (linux) CI job fails to check openvmm_entry with --no-default-features: error[E0425]: cannot find function `connect_serial` in module `super` --> openvmm/openvmm_entry/src/serial_io/connect.rs:44:38 Upstream df3466790 ("CI: Add a cargo-hack run (#4497)") compiles serial_io::connect_serial only with the ttrpc or grpc feature, because upstream calls it only from the management RPC server in ttrpc/mod.rs. f802e90f2 ("openvmm_entry: add connect= serial backends with bounded timeouts") then added serial_io::connect::connect_serial_with_timeout, which calls connect_serial on a helper thread and does not depend on either RPC feature: it backs the connect=PATH serial backend of the command line and of the microVM virtio console. A build without the RPC features therefore references a function that is not compiled. The default features enable both RPC features, so only the cargo hack job, which checks each crate on its own under every feature combination, builds this configuration. Compile connect_serial unconditionally. Every build of this fork calls it, so it cannot become dead code when the RPC features are disabled. On Linux, checking openvmm_entry with --no-default-features no longer reports E0425. The check still fails on RPC-only items that the next commit gates: the 13 unused re-exports that cargo hack reported, and one unused console function that CI did not report because the resolution error stopped compilation before the dead-code lint ran. Fixes: f802e90f29b4 ("openvmm_entry: add connect= serial backends with bounded timeouts") Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_entry/src/serial_io.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/openvmm/openvmm_entry/src/serial_io.rs b/openvmm/openvmm_entry/src/serial_io.rs index b1e345d3aa0..e820d0808f2 100644 --- a/openvmm/openvmm_entry/src/serial_io.rs +++ b/openvmm/openvmm_entry/src/serial_io.rs @@ -74,7 +74,6 @@ pub fn bind_serial(path: &Path) -> io::Result> { /// /// Unlike [`bind_serial`], which creates a new server, this function connects /// to a pipe or socket that already exists. -#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub fn connect_serial(path: &Path) -> io::Result> { #[cfg(windows)] { From cb6bf80641418e1a8683a2fefc79471edb90c11c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 30 Sep 2026 07:59:36 -0700 Subject: [PATCH 164/304] openvmm_entry: gate RPC-only microVM helpers behind the RPC features After the previous commit, the cargo hack (linux) CI job still fails to check openvmm_entry with --no-default-features. CI denies warnings, and the build reports 13 unused imports, all crate-level re-exports, 11 in microvm/mod.rs and 2 in snapshot_restore/mod.rs, for example: error: unused import: `console::MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND` --> openvmm/openvmm_entry/src/microvm/mod.rs:20:16 error: unused import: `ready::connect_restore_ready_sink` --> openvmm/openvmm_entry/src/snapshot_restore/mod.rs:27:16 and, now that the resolution error no longer stops compilation first, one unused function: error: function `microvm_console_attachment_from_snapshot` is never used --> openvmm/openvmm_entry/src/microvm/console.rs:581:15 9354e0960 ("vmservice: create, capture, and restore microVMs over the management RPC") added these re-exports so that the management RPC server in the ttrpc module can reach helpers that are private to the microvm and snapshot_restore modules. The server is their only user: code inside those modules imports the helpers from their submodules directly. The ttrpc module is compiled only with the ttrpc or grpc feature, so without either feature every one of these re-exports is unused. microvm_console_attachment_from_snapshot is likewise used only by the server and by the console unit tests. 8686010375 ("openvmm_entry: add authenticated microVM control-console endpoints") added it ahead of the server with cfg_attr(not(test), expect(dead_code)), and 9354e0960 dropped that expectation once the server called it. The function is used outside tests only when an RPC feature is enabled. Compile the re-exports only with the ttrpc or grpc feature, like the ttrpc module that uses them, and compile microvm_console_attachment_from_snapshot only for tests or with one of those features, so that the console unit tests still build without them. The helpers behind the re-exports stay unconditional, because the command-line paths of both modules use them without the RPC features. On Linux, with warnings denied as in CI, openvmm_entry now checks cleanly under every combination that cargo hack runs: --all-features, --no-default-features, and --no-default-features with each of default, grpc, and ttrpc. Checking all targets with the default features and a Windows check with --no-default-features are also clean. Fixes: 9354e0960be2 ("vmservice: create, capture, and restore microVMs over the management RPC") Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_entry/src/microvm/console.rs | 1 + openvmm/openvmm_entry/src/microvm/mod.rs | 11 +++++++++++ openvmm/openvmm_entry/src/snapshot_restore/mod.rs | 2 ++ 3 files changed, 14 insertions(+) diff --git a/openvmm/openvmm_entry/src/microvm/console.rs b/openvmm/openvmm_entry/src/microvm/console.rs index 1a3e0d46e06..b77e459a46f 100644 --- a/openvmm/openvmm_entry/src/microvm/console.rs +++ b/openvmm/openvmm_entry/src/microvm/console.rs @@ -578,6 +578,7 @@ fn microvm_console_attachment_from_snapshot_with_identity( Ok(reconstructed) } +#[cfg(any(test, feature = "ttrpc", feature = "grpc"))] pub(crate) fn microvm_console_attachment_from_snapshot( attachment: &openvmm_helpers::snapshot::microvm::SnapshotAttachment, requested: Option<&SerialConfigCli>, diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 2f366acc459..deb53d4c179 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -17,21 +17,32 @@ pub(crate) use config::MicrovmConfigBuilder; pub(crate) use console::MICROVM_CONSOLE_ATTACHMENT_KIND; #[cfg(test)] pub(crate) use console::MICROVM_CONSOLE_STABLE_ID; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use console::MICROVM_CONTROL_CONSOLE_ATTACHMENT_KIND; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use console::MICROVM_CONTROL_CONSOLE_STABLE_ID; pub(crate) use console::MicrovmConsoleSocketCleanup; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use console::microvm_console_attachment_from_cli; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use console::microvm_console_attachment_from_snapshot; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use console::microvm_console_socket_cleanup; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use console::validate_microvm_console_attachment_namespace; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use filesystem::microvm_filesystem_attachment; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use filesystem::microvm_filesystem_from_snapshot; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use filesystem::microvm_filesystem_slot_from_snapshot; pub(crate) use filesystem::validate_microvm_filesystem_private_storage; pub(crate) use launch::MicrovmLaunch; pub(crate) use restore::ExpectedRestoreContract; pub(crate) use restore::MicrovmRestore; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use restore::fresh_microvm_generation_id; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use restore::fresh_microvm_restore_packet; pub(crate) use restore::prepare_restore; pub(crate) use restore::validate_restore_contract; diff --git a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs index ff655796e12..99fc4acb617 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs @@ -23,7 +23,9 @@ mod prepare; mod ready; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use prepare::prepare_snapshot_restore_for_config; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use ready::connect_restore_ready_sink; pub(crate) use ready::restore_ready_sink; From 8f8eca2393a1aa78e2874772b7636bf20d4925d8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 12:24:37 -0700 Subject: [PATCH 165/304] lxutil: add path confinement that never follows symbolic links Add an opt-in LxVolumeOptions::confine_paths mode for callers whose paths come from a client that can also create symbolic links. On Linux, a confined volume opens the parent directory of every path beneath the root with openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS), or with one O_NOFOLLOW open per component where openat2 is unavailable, and applies the operation to the final component through that pinned directory without following it. chmod pins the object with O_PATH and fails with EOPNOTSUPP on a link instead of following it with fchmodat. statfs and the extended attribute calls use the pinned directory rather than an absolute host path. A concurrent rename or a newly created link therefore cannot redirect an operation outside the volume. On Windows, a confined volume always creates WSL-style links, which Windows path resolution never follows, instead of NT symbolic links. The default behavior is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vm/devices/support/fs/lxutil/src/lib.rs | 86 +++- .../support/fs/lxutil/src/unix/confine.rs | 219 ++++++++++ .../fs/lxutil/src/unix/confine_tests.rs | 373 ++++++++++++++++++ vm/devices/support/fs/lxutil/src/unix/mod.rs | 237 +++++++---- vm/devices/support/fs/lxutil/src/unix/util.rs | 126 +++++- .../support/fs/lxutil/src/windows/mod.rs | 7 +- 6 files changed, 961 insertions(+), 87 deletions(-) create mode 100644 vm/devices/support/fs/lxutil/src/unix/confine.rs create mode 100644 vm/devices/support/fs/lxutil/src/unix/confine_tests.rs diff --git a/vm/devices/support/fs/lxutil/src/lib.rs b/vm/devices/support/fs/lxutil/src/lib.rs index 0244a9f394f..c61a7dfd686 100644 --- a/vm/devices/support/fs/lxutil/src/lib.rs +++ b/vm/devices/support/fs/lxutil/src/lib.rs @@ -30,7 +30,8 @@ pub use path::PathExt; /// it has Unix semantics. /// /// N.B.: all methods take relative paths, but do not attempt to make sure the path does not escape -/// the root of the `LxVolume`, and therefore should not be relied upon for security. +/// the root of the `LxVolume`, and therefore should not be relied upon for security unless +/// [`LxVolumeOptions::confine_paths`] is enabled. /// /// Use `PathExt` and `PathBufExt` to write cross-platform code that deals only with Unix-style /// paths. @@ -54,8 +55,8 @@ pub use path::PathExt; /// # Unix /// /// All calls pass through directly to their libc equivalent. Attributes like mode are always -/// enabled if the file system supports them. `LxVolumeOptions` is entirely ignored, as are the -/// `uid` and `gid` fields of `LxCreateOptions`. +/// enabled if the file system supports them. `LxVolumeOptions` other than `confine_paths` is +/// ignored, as are the `uid` and `gid` fields of `LxCreateOptions`. pub struct LxVolume { inner: sys::LxVolume, } @@ -116,7 +117,8 @@ impl LxVolume { /// # Unix /// /// Symlinks are followed for chmod, because the `fchmodat` syscall does not offer a way to not - /// follow symlinks. + /// follow symlinks. With [`LxVolumeOptions::confine_paths`], chmod never follows a symlink and + /// fails with `EOPNOTSUPP` when the path names one. /// /// The `SetAttributes::thread_uid` field is ignored, and the thread's actual capabilities are /// are used. @@ -182,7 +184,8 @@ impl LxVolume { /// # Unix /// /// Symlinks are followed for chmod, because the `fchmodat` syscall does not offer a way to not - /// follow symlinks. + /// follow symlinks. With [`LxVolumeOptions::confine_paths`], chmod never follows a symlink and + /// fails with `EOPNOTSUPP` when the path names one. pub fn chmod(&self, path: impl AsRef, mode: lx::mode_t) -> lx::Result<()> { let mut attr = SetAttributes::default(); attr.mode = Some(mode); @@ -274,7 +277,8 @@ impl LxVolume { /// # Windows /// /// This will attempt to create an NTFS symbolic link, but will fall back to a WSL-style link - /// if this is not possible. + /// if this is not possible. With [`LxVolumeOptions::confine_paths`], it always creates a + /// WSL-style link, which Windows path resolution never follows. pub fn symlink( &self, path: impl AsRef, @@ -687,7 +691,7 @@ impl LxFile { /// /// # Unix /// -/// These options have no effect on Unix platforms. +/// Except for `confine_paths`, these options have no effect on Unix platforms. #[derive(Clone)] pub struct LxVolumeOptions { uid: Option, @@ -705,6 +709,7 @@ pub struct LxVolumeOptions { symlink_root: String, override_xattrs: HashMap>, readonly: bool, + confine_paths: bool, } impl LxVolumeOptions { @@ -726,6 +731,7 @@ impl LxVolumeOptions { symlink_root: "".to_string(), override_xattrs: HashMap::new(), readonly: false, + confine_paths: false, } } @@ -1045,6 +1051,29 @@ impl LxVolumeOptions { pub fn is_readonly(&self) -> bool { self.readonly } + + /// Never follow a symbolic link while resolving a path inside the volume. + /// + /// Use this when the paths come from an untrusted client that can also create symbolic links. + /// + /// # Unix + /// + /// Every operation opens the parent directory of its path strictly beneath the volume root + /// without following any symbolic link, and then applies the operation to the final + /// component without following it. A path that crosses a symbolic link fails with `ELOOP`. + /// Opening or truncating a symbolic link fails with `ELOOP`, and changing its mode fails with + /// `EOPNOTSUPP`. Extended attribute and file system statistics calls use the pinned parent + /// directory instead of an absolute host path. + /// + /// # Windows + /// + /// New symbolic links are always created as WSL-style links. Windows path resolution never + /// follows them, so a link created through the volume can never redirect a later host path + /// lookup. Existing NT symbolic links and junctions are not affected. + pub fn confine_paths(&mut self, enabled: bool) -> &mut Self { + self.confine_paths = enabled; + self + } } impl Default for LxVolumeOptions { @@ -2230,6 +2259,49 @@ mod tests { assert_eq!(err.value(), lx::EEXIST); } + #[test] + #[cfg(windows)] + fn confined_volume_creates_inert_lx_symlinks() { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + + let env = TestEnv::with_options(LxVolumeOptions::new().confine_paths(true)); + env.create_file("dir/file", "data"); + + // Without confinement, targets inside the volume would become NT symlinks whenever the + // process may create them. + for (link, target) in [ + ("file_link", "dir/file"), + ("dir_link", "dir"), + ("outside_link", "../outside"), + ] { + let stat = env + .volume + .symlink_stat(link, target, LxCreateOptions::new(0, 0, 0)) + .unwrap(); + assert_eq!(stat.mode & lx::S_IFMT, lx::S_IFLNK); + assert_eq!(env.volume.read_link(link).unwrap(), target); + + let host = env.root_dir.path().join(link); + let metadata = fs::symlink_metadata(&host).unwrap(); + assert_ne!(metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT, 0); + // A WSL-style link is neither an NT symlink nor something Windows follows. + assert!(fs::read_link(&host).is_err()); + assert!(fs::metadata(&host).is_err()); + } + + assert!( + env.volume + .lstat(Path::from_lx("dir_link/file").unwrap()) + .is_err() + ); + + // Renaming a link keeps its target. + env.volume.rename("file_link", "renamed_link", 0).unwrap(); + assert_eq!(env.volume.read_link("renamed_link").unwrap(), "dir/file"); + } + // This test is disabled in CI, because it requires NTFS support for setting the case sensitive // directory attribute, which is only enabled if the WSL optional component is installed. #[test] diff --git a/vm/devices/support/fs/lxutil/src/unix/confine.rs b/vm/devices/support/fs/lxutil/src/unix/confine.rs new file mode 100644 index 00000000000..9bdade77945 --- /dev/null +++ b/vm/devices/support/fs/lxutil/src/unix/confine.rs @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Resolution of volume-relative paths that never follows a symbolic link. +//! +//! A confined volume opens the parent directory of every path strictly beneath its root, refusing +//! symbolic links in every component, and then applies the requested operation to the final +//! component through that pinned directory. A concurrent rename or symbolic link therefore cannot +//! redirect an operation outside the volume. + +use super::util; +use std::ffi::CStr; +use std::ffi::CString; +use std::ffi::OsStr; +use std::fs::File; +use std::mem::MaybeUninit; +use std::os::unix::prelude::*; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +/// Flags for the `O_PATH` handle of an intermediate directory. +const DIRECTORY_FLAGS: i32 = libc::O_PATH | libc::O_DIRECTORY | libc::O_CLOEXEC; + +/// The version 0 layout of `struct open_how` from `linux/openat2.h`. +#[repr(C)] +struct OpenHow { + flags: u64, + mode: u64, + resolve: u64, +} + +/// How the parent directory of a confined path is opened. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Resolver { + /// One `openat2` call that rejects symbolic links and paths that leave the root. + Openat2, + /// One `openat(O_NOFOLLOW)` call per component, for kernels or sandboxes without `openat2`. + Walk, +} + +impl Resolver { + /// Selects `openat2` when the kernel and any seccomp policy allow it. + pub(crate) fn probe(root: &File) -> Self { + match openat2_directory(root.as_raw_fd(), c".") { + Ok(_) => Self::Openat2, + Err(error) => { + tracing::debug!( + error = error.value(), + "openat2 is unavailable; resolving confined paths one component at a time" + ); + Self::Walk + } + } + } +} + +/// The directory that holds the final component of a confined path. +pub(crate) enum ParentDirectory<'a> { + /// The volume root, for single-component paths and the root itself. + Root(&'a File), + /// An `O_PATH` handle opened without following any symbolic link. + Opened(OwnedFd), +} + +impl AsRawFd for ParentDirectory<'_> { + fn as_raw_fd(&self) -> RawFd { + match self { + Self::Root(root) => root.as_raw_fd(), + Self::Opened(directory) => directory.as_raw_fd(), + } + } +} + +/// A path split into a pinned directory and the name of its final component. +pub(crate) struct AtPath<'a> { + /// The directory that holds `name`. + pub(crate) directory: ParentDirectory<'a>, + /// The final component, or an empty string for the volume root itself. + pub(crate) name: CString, +} + +impl AtPath<'_> { + /// Returns whether this refers to the volume root rather than an entry in a directory. + pub(crate) fn is_root(&self) -> bool { + self.name.is_empty() + } + + /// Returns a `/proc` path that reaches the final component through the pinned directory. + /// + /// Path-based system calls that do not follow a final symbolic link (such as the `l*xattr` + /// family) can use it without resolving any ancestor from the host root again. The returned + /// path is only valid while `self` keeps the directory open. + pub(crate) fn proc_path(&self) -> lx::Result { + let mut path = format!("/proc/self/fd/{}/", self.directory.as_raw_fd()).into_bytes(); + if self.is_root() { + path.push(b'.'); + } else { + path.extend_from_slice(self.name.as_bytes()); + } + util::create_cstr(path) + } +} + +/// Splits a volume-relative `path` and opens its parent directory beneath `root` without following +/// any symbolic link. +/// +/// A path that crosses a symbolic link fails with `ELOOP`. The final component is not inspected. +pub(crate) fn resolve<'a>( + root: &'a File, + resolver: Resolver, + path: &Path, +) -> lx::Result> { + let mut components = Vec::new(); + for component in path.components() { + let Component::Normal(component) = component else { + return Err(lx::Error::EINVAL); + }; + components.push(component); + } + + let Some(name) = components.pop() else { + return Ok(AtPath { + directory: ParentDirectory::Root(root), + name: CString::default(), + }); + }; + + let name = util::create_cstr(name.as_bytes())?; + let Some((first, rest)) = components.split_first() else { + return Ok(AtPath { + directory: ParentDirectory::Root(root), + name, + }); + }; + + let directory = match resolver { + Resolver::Openat2 => { + let parent: PathBuf = components.iter().collect(); + openat2_directory(root.as_raw_fd(), &util::path_to_cstr(&parent)?)? + } + Resolver::Walk => { + let mut directory = open_component(root.as_raw_fd(), first)?; + for component in rest { + directory = open_component(directory.as_raw_fd(), component)?; + } + directory + } + }; + + Ok(AtPath { + directory: ParentDirectory::Opened(directory), + name, + }) +} + +/// Opens `path` beneath `directory` as an `O_PATH` directory handle, rejecting every symbolic +/// link and any escape from `directory`. +fn openat2_directory(directory: RawFd, path: &CStr) -> lx::Result { + let how = OpenHow { + flags: DIRECTORY_FLAGS as u64, + mode: 0, + resolve: libc::RESOLVE_BENEATH | libc::RESOLVE_NO_SYMLINKS, + }; + + // SAFETY: `path` is NUL-terminated and `how` uses the kernel's version 0 layout, whose size + // is passed explicitly. + let result = unsafe { + libc::syscall( + libc::SYS_openat2, + directory, + path.as_ptr(), + std::ptr::from_ref(&how), + size_of::(), + ) + }; + + let fd = util::check_lx_errno(result)?; + let fd = RawFd::try_from(fd).map_err(|_| lx::Error::EIO)?; + // SAFETY: The kernel returned a new descriptor that nothing else owns. + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +/// Opens one directory component without following a symbolic link. +fn open_component(directory: RawFd, name: &OsStr) -> lx::Result { + let name = util::create_cstr(name.as_bytes())?; + + // SAFETY: Calling C API as documented, with no special requirements. + let result = + unsafe { libc::openat(directory, name.as_ptr(), DIRECTORY_FLAGS | libc::O_NOFOLLOW) }; + + match util::check_lx_errno(result) { + // SAFETY: The kernel returned a new descriptor that nothing else owns. + Ok(fd) => Ok(unsafe { OwnedFd::from_raw_fd(fd) }), + // O_NOFOLLOW reports a symbolic link as "not a directory"; report it like openat2 does. + Err(error) if error.value() == lx::ENOTDIR && is_symlink(directory, &name) => { + Err(lx::Error::ELOOP) + } + Err(error) => Err(error), + } +} + +/// Returns whether `name` in `directory` is a symbolic link. +fn is_symlink(directory: RawFd, name: &CStr) -> bool { + let mut stat = MaybeUninit::::uninit(); + + // SAFETY: `stat` is valid for writes and `name` is NUL-terminated. + let result = unsafe { + libc::fstatat( + directory, + name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + + // SAFETY: fstatat initialized `stat` because it succeeded. + result == 0 && unsafe { stat.assume_init() }.st_mode & libc::S_IFMT == libc::S_IFLNK +} diff --git a/vm/devices/support/fs/lxutil/src/unix/confine_tests.rs b/vm/devices/support/fs/lxutil/src/unix/confine_tests.rs new file mode 100644 index 00000000000..62f2500033e --- /dev/null +++ b/vm/devices/support/fs/lxutil/src/unix/confine_tests.rs @@ -0,0 +1,373 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for volumes that resolve paths without following symbolic links. + +use super::LxVolume; +use super::confine::Resolver; +use crate::LxCreateOptions; +use crate::LxVolumeOptions; +use crate::SetAttributes; +use crate::SetTime; +use std::ffi::OsString; +use std::fs; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::fs::symlink; +use std::path::Path; +use std::path::PathBuf; +use tempfile::TempDir; + +/// A volume root and a sibling directory that must stay unreachable from it. +struct Fixture { + _directory: TempDir, + root: PathBuf, + outside: PathBuf, +} + +impl Fixture { + fn new() -> Self { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("root"); + let outside = directory.path().join("outside"); + fs::create_dir(&root).unwrap(); + fs::create_dir(&outside).unwrap(); + let secret = outside.join("secret"); + fs::write(&secret, b"secret").unwrap(); + fs::set_permissions(&secret, fs::Permissions::from_mode(0o644)).unwrap(); + Self { + _directory: directory, + root, + outside, + } + } + + /// Returns a confined volume for every resolver that this kernel supports. + fn volumes(&self) -> Vec { + let mut options = LxVolumeOptions::new(); + options.confine_paths(true); + let mut volumes = Vec::new(); + let probed = LxVolume::new(&self.root, &options).unwrap(); + if probed.resolver == Some(Resolver::Openat2) { + volumes.push(probed); + } + let mut walk = LxVolume::new(&self.root, &options).unwrap(); + walk.resolver = Some(Resolver::Walk); + volumes.push(walk); + volumes + } + + fn assert_outside_unchanged(&self) { + let names = fs::read_dir(&self.outside) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(names, [OsString::from("secret")]); + let secret = self.outside.join("secret"); + assert_eq!(fs::read(&secret).unwrap(), b"secret"); + assert_eq!( + fs::metadata(&secret).unwrap().permissions().mode() & 0o7777, + 0o644 + ); + } +} + +fn errno(result: lx::Result) -> i32 { + match result { + Ok(_) => panic!("operation unexpectedly succeeded"), + Err(error) => error.value(), + } +} + +fn attributes(update: impl FnOnce(&mut SetAttributes)) -> SetAttributes { + let mut attr = SetAttributes::default(); + update(&mut attr); + attr +} + +fn name(value: &[u8]) -> &lx::LxStr { + lx::LxStr::from_bytes(value) +} + +#[test] +fn confined_paths_do_not_traverse_symlinks() { + let fixture = Fixture::new(); + fs::create_dir(fixture.root.join("dir")).unwrap(); + symlink(&fixture.outside, fixture.root.join("absolute")).unwrap(); + symlink("../outside", fixture.root.join("relative")).unwrap(); + symlink("../../outside", fixture.root.join("dir").join("nested")).unwrap(); + + for volume in fixture.volumes() { + for link in ["absolute", "relative", "dir/nested"] { + let link = Path::new(link); + let secret = link.join("secret"); + let created = link.join("created"); + let file = LxCreateOptions::new(0o644, 0, 0); + let fifo = LxCreateOptions::new(lx::S_IFIFO | 0o644, 0, 0); + let directory = LxCreateOptions::new(0o755, 0, 0); + + assert_eq!(errno(volume.lstat(&secret)), lx::ELOOP); + assert_eq!(errno(volume.open(&secret, lx::O_RDONLY, None)), lx::ELOOP); + assert_eq!( + errno(volume.open(&created, lx::O_WRONLY | lx::O_CREAT, Some(file))), + lx::ELOOP + ); + assert_eq!(errno(volume.mkdir(&created, directory)), lx::ELOOP); + let target = name(b"target"); + let link_options = LxCreateOptions::new(0, 0, 0); + assert_eq!( + errno(volume.symlink(&created, target, link_options)), + lx::ELOOP + ); + assert_eq!(errno(volume.mknod(&created, fifo, 0)), lx::ELOOP); + assert_eq!(errno(volume.read_link(&secret)), lx::ELOOP); + assert_eq!(errno(volume.unlink(&secret, 0)), lx::ELOOP); + assert_eq!( + errno(volume.rename(&secret, Path::new("moved"), 0)), + lx::ELOOP + ); + assert_eq!(errno(volume.link(&secret, Path::new("hard"))), lx::ELOOP); + let truncate = attributes(|attr| attr.size = Some(0)); + assert_eq!(errno(volume.set_attr(&secret, truncate)), lx::ELOOP); + let chmod = attributes(|attr| attr.mode = Some(0o600)); + assert_eq!(errno(volume.set_attr(&secret, chmod)), lx::ELOOP); + assert_eq!(errno(volume.stat_fs(&secret)), lx::ELOOP); + let xattr = name(b"user.nvx"); + assert_eq!( + errno(volume.set_xattr(&secret, xattr, b"value", 0)), + lx::ELOOP + ); + assert_eq!(errno(volume.get_xattr(&secret, xattr, None)), lx::ELOOP); + assert_eq!(errno(volume.list_xattr(&secret, None)), lx::ELOOP); + assert_eq!(errno(volume.remove_xattr(&secret, xattr)), lx::ELOOP); + } + assert!(!fixture.root.join("moved").exists()); + assert!(!fixture.root.join("hard").exists()); + } + fixture.assert_outside_unchanged(); +} + +#[test] +fn confined_final_symlink_is_not_followed() { + let fixture = Fixture::new(); + let target = fixture.outside.join("secret"); + symlink(&target, fixture.root.join("final")).unwrap(); + symlink( + fixture.outside.join("missing"), + fixture.root.join("dangling"), + ) + .unwrap(); + let modified = fs::metadata(&target).unwrap().modified().unwrap(); + + for volume in fixture.volumes() { + let link = Path::new("final"); + assert_eq!( + u32::from(volume.lstat(link).unwrap().mode) & lx::S_IFMT, + lx::S_IFLNK + ); + assert_eq!( + volume.read_link(link).unwrap().as_bytes(), + target.as_os_str().as_bytes() + ); + assert_eq!(errno(volume.open(link, lx::O_RDONLY, None)), lx::ELOOP); + assert_eq!( + errno(volume.open(link, lx::O_WRONLY | lx::O_TRUNC, None)), + lx::ELOOP + ); + let file = LxCreateOptions::new(0o644, 0, 0); + assert_eq!( + errno(volume.open( + Path::new("dangling"), + lx::O_WRONLY | lx::O_CREAT, + Some(file) + )), + lx::ELOOP + ); + let truncate = attributes(|attr| attr.size = Some(0)); + assert_eq!(errno(volume.set_attr(link, truncate)), lx::ELOOP); + let chmod = attributes(|attr| attr.mode = Some(0o600)); + assert_eq!(errno(volume.set_attr(link, chmod)), lx::ENOTSUP); + let touch = attributes(|attr| attr.mtime = SetTime::Now); + volume.set_attr(link, touch).unwrap(); + volume.stat_fs(link).unwrap(); + // Linux rejects user attributes on a link; either way, the target is not modified. + let _ = volume.set_xattr(link, name(b"user.nvx"), b"value", 0); + } + + assert!(!fixture.outside.join("missing").exists()); + assert_eq!(fs::metadata(&target).unwrap().modified().unwrap(), modified); + let outside = LxVolume::new(&fixture.outside, &LxVolumeOptions::new()).unwrap(); + assert!( + outside + .get_xattr(Path::new("secret"), name(b"user.nvx"), None) + .is_err() + ); + + let volume = fixture.volumes().remove(0); + volume.unlink(Path::new("final"), 0).unwrap(); + assert!(fs::symlink_metadata(fixture.root.join("final")).is_err()); + fixture.assert_outside_unchanged(); +} + +#[test] +fn confined_symlink_targets_are_verbatim() { + let fixture = Fixture::new(); + let long = vec![b'x'; 1024]; + let targets: [&[u8]; 6] = [ + b"/absolute/host/path", + b"../../escape", + b"dangling/relative", + b"with space", + b"\xff\xfe-not-utf8", + &long, + ]; + + for (volume_index, volume) in fixture.volumes().into_iter().enumerate() { + for (target_index, target) in targets.iter().enumerate() { + let link = PathBuf::from(format!("link-{volume_index}-{target_index}")); + let stat = volume + .symlink_stat(&link, name(target), LxCreateOptions::new(0, 0, 0)) + .unwrap(); + assert_eq!(stat.mode & lx::S_IFMT, lx::S_IFLNK); + assert_eq!(stat.file_size, target.len() as u64); + assert_eq!(volume.read_link(&link).unwrap().as_bytes(), *target); + let host = fs::read_link(fixture.root.join(&link)).unwrap(); + assert_eq!(host.as_os_str().as_bytes(), *target); + } + } +} + +#[test] +fn confined_volume_supports_common_operations() { + let fixture = Fixture::new(); + for (index, volume) in fixture.volumes().into_iter().enumerate() { + let base = PathBuf::from(format!("base-{index}")); + let nested = base.join("nested"); + let file = nested.join("file"); + volume + .mkdir(&base, LxCreateOptions::new(0o755, 0, 0)) + .unwrap(); + let stat = volume + .mkdir_stat(&nested, LxCreateOptions::new(0o755, 0, 0)) + .unwrap(); + assert_eq!(stat.mode & lx::S_IFMT, lx::S_IFDIR); + + let options = LxCreateOptions::new(0o644, 0, 0); + let handle = volume + .open(&file, lx::O_RDWR | lx::O_CREAT | lx::O_EXCL, Some(options)) + .unwrap(); + assert_eq!(handle.pwrite(b"data", 0, 0).unwrap(), 4); + drop(handle); + + let chmod = attributes(|attr| attr.mode = Some(0o600)); + assert_eq!( + volume.set_attr_stat(&file, chmod).unwrap().mode & 0o7777, + 0o600 + ); + let truncate = attributes(|attr| attr.size = Some(2)); + assert_eq!(volume.set_attr_stat(&file, truncate).unwrap().file_size, 2); + + let link = nested.join("link"); + volume + .symlink(&link, name(b"file"), LxCreateOptions::new(0, 0, 0)) + .unwrap(); + assert_eq!(volume.read_link(&link).unwrap().as_bytes(), b"file"); + + let moved = base.join("moved"); + volume.rename(&file, &moved, 0).unwrap(); + let hard = nested.join("hard"); + assert_eq!(volume.link_stat(&moved, &hard).unwrap().link_count, 2); + let handle = volume.open(&hard, lx::O_RDONLY, None).unwrap(); + let mut buffer = [0; 8]; + let size = handle.pread(&mut buffer, 0).unwrap(); + assert_eq!(&buffer[..size], b"da"); + drop(handle); + + volume.stat_fs(&moved).unwrap(); + let xattr = name(b"user.nvx"); + match volume.set_xattr(&moved, xattr, b"value", 0) { + Ok(()) => { + let mut value = [0; 16]; + let size = volume.get_xattr(&moved, xattr, Some(&mut value)).unwrap(); + assert_eq!(&value[..size], b"value"); + assert!(volume.list_xattr(&moved, None).unwrap() > 0); + volume.remove_xattr(&moved, xattr).unwrap(); + } + // Some file systems do not support user extended attributes. + Err(error) => assert_eq!(error.value(), lx::ENOTSUP), + } + + volume.unlink(&hard, 0).unwrap(); + volume.unlink(&link, 0).unwrap(); + volume.unlink(&nested, lx::AT_REMOVEDIR).unwrap(); + volume.unlink(&moved, 0).unwrap(); + volume.unlink(&base, lx::AT_REMOVEDIR).unwrap(); + assert_eq!(fs::read_dir(&fixture.root).unwrap().count(), 0); + } +} + +#[test] +fn confined_root_uses_its_own_descriptor() { + let fixture = Fixture::new(); + let root = Path::new(""); + for volume in fixture.volumes() { + assert_eq!( + u32::from(volume.lstat(root).unwrap().mode) & lx::S_IFMT, + lx::S_IFDIR + ); + let handle = volume + .open(root, lx::O_RDONLY | lx::O_DIRECTORY, None) + .unwrap(); + assert_eq!( + u32::from(handle.fstat().unwrap().mode) & lx::S_IFMT, + lx::S_IFDIR + ); + let touch = attributes(|attr| attr.mtime = SetTime::Now); + volume.set_attr(root, touch).unwrap(); + volume.stat_fs(root).unwrap(); + if let Err(error) = volume.list_xattr(root, None) { + assert_eq!(error.value(), lx::ENOTSUP); + } + } +} + +#[test] +fn swapped_directory_is_not_followed() { + // A client can hold a node for `dir/secret` and, while an operation on it is in flight, + // replace `dir` with a symbolic link to a host directory. + let fixture = Fixture::new(); + fs::create_dir(fixture.root.join("dir")).unwrap(); + fs::write(fixture.root.join("dir").join("secret"), b"inside").unwrap(); + + for volume in fixture.volumes() { + let secret = Path::new("dir/secret"); + volume.lstat(secret).unwrap(); + fs::rename(fixture.root.join("dir"), fixture.root.join("saved")).unwrap(); + symlink(&fixture.outside, fixture.root.join("dir")).unwrap(); + + assert_eq!(errno(volume.lstat(secret)), lx::ELOOP); + assert_eq!(errno(volume.open(secret, lx::O_RDWR, None)), lx::ELOOP); + let chmod = attributes(|attr| attr.mode = Some(0o666)); + assert_eq!(errno(volume.set_attr(secret, chmod)), lx::ELOOP); + assert_eq!(errno(volume.unlink(secret, 0)), lx::ELOOP); + + fs::remove_file(fixture.root.join("dir")).unwrap(); + fs::rename(fixture.root.join("saved"), fixture.root.join("dir")).unwrap(); + } + fixture.assert_outside_unchanged(); +} + +#[test] +fn unconfined_volume_still_follows_intermediate_symlinks() { + let fixture = Fixture::new(); + symlink("../outside", fixture.root.join("relative")).unwrap(); + let volume = LxVolume::new(&fixture.root, &LxVolumeOptions::new()).unwrap(); + assert!(volume.resolver.is_none()); + assert_eq!( + volume + .lstat(Path::new("relative/secret")) + .unwrap() + .file_size, + 6 + ); +} diff --git a/vm/devices/support/fs/lxutil/src/unix/mod.rs b/vm/devices/support/fs/lxutil/src/unix/mod.rs index d94f66a7bed..cf9ebb92658 100644 --- a/vm/devices/support/fs/lxutil/src/unix/mod.rs +++ b/vm/devices/support/fs/lxutil/src/unix/mod.rs @@ -5,10 +5,15 @@ #![expect(unsafe_code)] #![expect(clippy::undocumented_unsafe_blocks)] +mod confine; +#[cfg(test)] +mod confine_tests; pub(crate) mod path; mod util; use crate::SetAttributes; +use confine::AtPath; +use confine::ParentDirectory; use lx::StatEx; use std::ffi; use std::mem; @@ -22,23 +27,44 @@ const STATX_BTIME: u32 = 0x00000800; // See crate::LxVolume for more detailed comments. pub struct LxVolume { root: std::fs::File, + /// Set when paths must be resolved without following symbolic links. + resolver: Option, } impl LxVolume { - pub fn new(root_path: &Path, _options: &super::LxVolumeOptions) -> lx::Result { + pub fn new(root_path: &Path, options: &super::LxVolumeOptions) -> lx::Result { let path = util::path_to_cstr(root_path)?; // SAFETY: Calling C API as documented, with no special requirements. - unsafe { + let root = unsafe { // Open a file descriptor to the root to use with "*at" functions. let fd = util::check_lx_errno(libc::open( path.as_ptr(), libc::O_RDONLY | libc::O_DIRECTORY, ))?; - Ok(Self { - root: std::fs::File::from_raw_fd(fd), - }) + std::fs::File::from_raw_fd(fd) + }; + + let resolver = options + .confine_paths + .then(|| confine::Resolver::probe(&root)); + + Ok(Self { root, resolver }) + } + + /// Splits `path` into the directory and name used by "*at" functions. + /// + /// Without confinement, the directory is the root and the name is the whole relative path, + /// so the kernel follows symbolic links in every component except the last. + fn at_path(&self, path: &Path) -> lx::Result> { + assert!(path.is_relative()); + match self.resolver { + Some(resolver) => confine::resolve(&self.root, resolver, path), + None => Ok(AtPath { + directory: ParentDirectory::Root(&self.root), + name: util::path_to_cstr(path)?, + }), } } @@ -47,16 +73,15 @@ impl LxVolume { } pub fn lstat(&self, path: &Path) -> lx::Result { - assert!(path.is_relative()); - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling syscall as documented, with no special requirements. let statx = unsafe { let mut statx: StatEx = mem::zeroed(); util::check_lx_errno(libc::syscall( libc::SYS_statx as ffi::c_long, - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), libc::AT_SYMLINK_NOFOLLOW | libc::AT_EMPTY_PATH, STATX_BASIC_STATS | STATX_BTIME, &mut statx, @@ -67,11 +92,20 @@ impl LxVolume { } pub fn set_attr(&self, path: &Path, attr: SetAttributes) -> lx::Result<()> { - util::set_attr(&self.root, Some(path), &attr) + if self.resolver.is_none() { + return util::set_attr(&self.root, Some(path), &attr); + } + + let path = self.at_path(path)?; + if path.is_root() { + util::set_attr(&self.root, None, &attr) + } else { + util::set_attr_confined(path.directory.as_raw_fd(), &path.name, &attr) + } } pub fn set_attr_stat(&self, path: &Path, attr: SetAttributes) -> lx::Result { - util::set_attr(&self.root, Some(path), &attr)?; + self.set_attr(path, attr)?; self.lstat(path).map(|x| x.into()) } @@ -83,7 +117,24 @@ impl LxVolume { ) -> lx::Result { assert!(path.is_relative()); - let fd = util::openat(&self.root, path, flags, options)?; + let fd = if self.resolver.is_some() { + // A confined open never follows the final component, and reopens the root through + // its own descriptor rather than its host path. + let path = self.at_path(path)?; + let name = if path.is_root() { + c"." + } else { + path.name.as_c_str() + }; + util::openat_name( + path.directory.as_raw_fd(), + name, + flags | libc::O_NOFOLLOW, + options, + )? + } else { + util::openat(&self.root, path, flags, options)? + }; Ok(LxFile { fd, @@ -92,15 +143,13 @@ impl LxVolume { } pub fn mkdir(&self, path: &Path, options: super::LxCreateOptions) -> lx::Result<()> { - assert!(path.is_relative()); - - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::mkdirat( - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), options.mode, ))?; } @@ -121,17 +170,15 @@ impl LxVolume { target: &lx::LxStr, _: super::LxCreateOptions, ) -> lx::Result<()> { - assert!(path.is_relative()); - - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; let target = util::create_cstr(target.as_bytes())?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::symlinkat( target.as_ptr(), - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), ))?; } @@ -149,16 +196,14 @@ impl LxVolume { } pub fn read_link(&self, path: &Path) -> lx::Result { - assert!(path.is_relative()); - let mut buffer = [0u8; libc::PATH_MAX as usize]; - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling C API as documented, with no special requirements. let size = unsafe { util::check_lx_errno(libc::readlinkat( - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), buffer.as_mut_ptr().cast(), buffer.len(), ))? @@ -169,13 +214,15 @@ impl LxVolume { } pub fn unlink(&self, path: &Path, flags: i32) -> lx::Result<()> { - assert!(path.is_relative()); - - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { - util::check_lx_errno(libc::unlinkat(self.root.as_raw_fd(), path.as_ptr(), flags))?; + util::check_lx_errno(libc::unlinkat( + path.directory.as_raw_fd(), + path.name.as_ptr(), + flags, + ))?; } Ok(()) @@ -187,15 +234,13 @@ impl LxVolume { options: super::LxCreateOptions, device_id: lx::dev_t, ) -> lx::Result<()> { - assert!(path.is_relative()); - - let path = util::path_to_cstr(path)?; + let path = self.at_path(path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::mknodat( - self.root.as_raw_fd(), - path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), options.mode, device_id as u64, ))?; @@ -215,10 +260,8 @@ impl LxVolume { } pub fn rename(&self, path: &Path, new_path: &Path, flags: u32) -> lx::Result<()> { - assert!(path.is_relative()); - assert!(new_path.is_relative()); - let path = util::path_to_cstr(path)?; - let new_path = util::path_to_cstr(new_path)?; + let path = self.at_path(path)?; + let new_path = self.at_path(new_path)?; // renameat2 does not have a wrapper in musl, though it does in glibc. Call it using syscall directly instead. // SAFETY: Our arguments are valid for this syscall. We are passing arguments in the @@ -226,10 +269,10 @@ impl LxVolume { unsafe { util::check_lx_errno(libc::syscall( libc::SYS_renameat2, - self.root.as_raw_fd(), - path.as_ptr(), - self.root.as_raw_fd(), - new_path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), + new_path.directory.as_raw_fd(), + new_path.name.as_ptr(), flags, ) as libc::c_int)?; } @@ -238,18 +281,16 @@ impl LxVolume { } pub fn link(&self, path: &Path, new_path: &Path) -> lx::Result<()> { - assert!(path.is_relative()); - assert!(new_path.is_relative()); - let path = util::path_to_cstr(path)?; - let new_path = util::path_to_cstr(new_path)?; + let path = self.at_path(path)?; + let new_path = self.at_path(new_path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::linkat( - self.root.as_raw_fd(), - path.as_ptr(), - self.root.as_raw_fd(), - new_path.as_ptr(), + path.directory.as_raw_fd(), + path.name.as_ptr(), + new_path.directory.as_raw_fd(), + new_path.name.as_ptr(), 0, ))?; } @@ -264,16 +305,41 @@ impl LxVolume { pub fn stat_fs(&self, path: &Path) -> lx::Result { assert!(path.is_relative()); - let path = self.full_path(path)?; - // SAFETY: Calling C API as documented, with no special requirements. - let stat_fs = unsafe { - let mut stat_fs = mem::zeroed(); - util::check_lx_errno(libc::statfs(path.as_ptr(), &mut stat_fs))?; - stat_fs - }; + let mut stat_fs = mem::MaybeUninit::::uninit(); + if self.resolver.is_some() { + // Inspect the object itself through an O_PATH handle rather than following the path. + let path = self.at_path(path)?; + let object; + let fd = if path.is_root() { + self.root.as_raw_fd() + } else { + object = util::openat_name( + path.directory.as_raw_fd(), + &path.name, + libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC, + None, + )?; + object.as_raw_fd() + }; + + // SAFETY: `stat_fs` is valid for writes and `fd` is open. + unsafe { + util::check_lx_errno(libc::fstatfs(fd, stat_fs.as_mut_ptr()))?; + } + } else { + let path = self.full_path(path)?; + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + util::check_lx_errno(libc::statfs(path.as_ptr(), stat_fs.as_mut_ptr()))?; + } + } - Ok(util::libc_stat_fs_to_lx_stat_fs(stat_fs)) + // SAFETY: statfs and fstatfs initialize the buffer when they succeed. + Ok(util::libc_stat_fs_to_lx_stat_fs(unsafe { + stat_fs.assume_init() + })) } pub fn set_xattr( @@ -283,16 +349,14 @@ impl LxVolume { value: &[u8], flags: i32, ) -> lx::Result<()> { - assert!(path.is_relative()); - // There is no *at version of the xattr APIs. - let path = self.full_path(path)?; + let path = self.xattr_path(path)?; let name = util::create_cstr(name.as_bytes())?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { util::check_lx_errno(libc::lsetxattr( - path.as_ptr(), + path.path.as_ptr(), name.as_ptr(), value.as_ptr().cast::(), value.len(), @@ -309,10 +373,8 @@ impl LxVolume { name: &lx::LxStr, value: Option<&mut [u8]>, ) -> lx::Result { - assert!(path.is_relative()); - // There is no *at version of the xattr APIs. - let path = self.full_path(path)?; + let path = self.xattr_path(path)?; let name = util::create_cstr(name.as_bytes())?; // Set the pointer to NULL if no value buffer is provided, to query the attribute's size. @@ -325,7 +387,7 @@ impl LxVolume { // SAFETY: Calling C API as documented, with no special requirements. let size = unsafe { util::check_lx_errno(libc::lgetxattr( - path.as_ptr(), + path.path.as_ptr(), name.as_ptr(), value_ptr.cast::(), size, @@ -337,10 +399,8 @@ impl LxVolume { } pub fn list_xattr(&self, path: &Path, list: Option<&mut [u8]>) -> lx::Result { - assert!(path.is_relative()); - // There is no *at version of the xattr APIs. - let path = self.full_path(path)?; + let path = self.xattr_path(path)?; // Set the list pointer to NULL if no list buffer was provided, to query the size. let (list_ptr, size) = if let Some(list) = list { @@ -351,7 +411,7 @@ impl LxVolume { // SAFETY: Calling C API as documented, with no special requirements. let size = unsafe { - util::check_lx_errno(libc::llistxattr(path.as_ptr(), list_ptr.cast(), size))? + util::check_lx_errno(libc::llistxattr(path.path.as_ptr(), list_ptr.cast(), size))? }; // Size is guaranteed positive after the check. @@ -359,15 +419,13 @@ impl LxVolume { } pub fn remove_xattr(&self, path: &Path, name: &lx::LxStr) -> lx::Result<()> { - assert!(path.is_relative()); - // There is no *at version of the xattr APIs. - let path = self.full_path(path)?; + let path = self.xattr_path(path)?; let name = util::create_cstr(name.as_bytes())?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { - util::check_lx_errno(libc::lremovexattr(path.as_ptr(), name.as_ptr()))?; + util::check_lx_errno(libc::lremovexattr(path.path.as_ptr(), name.as_ptr()))?; } Ok(()) @@ -378,6 +436,33 @@ impl LxVolume { full_path.push(path); util::path_to_cstr(&full_path) } + + /// Returns a path for the `l*xattr` functions, which act on a final symbolic link itself. + /// + /// A confined volume reaches the object through its pinned parent directory in `/proc`, so no + /// ancestor is resolved from the host root again. + fn xattr_path(&self, path: &Path) -> lx::Result> { + assert!(path.is_relative()); + if self.resolver.is_none() { + return Ok(XattrPath { + path: self.full_path(path)?, + _directory: None, + }); + } + + let path = self.at_path(path)?; + Ok(XattrPath { + path: path.proc_path()?, + _directory: Some(path), + }) + } +} + +/// A host path for an extended attribute call and the directory it depends on. +struct XattrPath<'a> { + path: ffi::CString, + /// Keeps the pinned directory named by `path` open until the call completes. + _directory: Option>, } // Unix implementation of LxFile. diff --git a/vm/devices/support/fs/lxutil/src/unix/util.rs b/vm/devices/support/fs/lxutil/src/unix/util.rs index 546623343e1..1fe525886f7 100644 --- a/vm/devices/support/fs/lxutil/src/unix/util.rs +++ b/vm/devices/support/fs/lxutil/src/unix/util.rs @@ -148,12 +148,21 @@ pub fn openat( return reopen(dirfd, flags); } + openat_name(dirfd.as_raw_fd(), &path_to_cstr(path)?, flags, options) +} + +// Open a name relative to a directory descriptor. +pub fn openat_name( + dirfd: RawFd, + name: &ffi::CStr, + flags: i32, + options: Option, +) -> lx::Result { let mode = options.unwrap_or_default().mode; - let path = path_to_cstr(path)?; // SAFETY: Calling C API as documented, with no special requirements. unsafe { - let fd = check_lx_errno(libc::openat(dirfd.as_raw_fd(), path.as_ptr(), flags, mode))?; + let fd = check_lx_errno(libc::openat(dirfd, name.as_ptr(), flags, mode))?; Ok(std::fs::File::from_raw_fd(fd)) } @@ -296,6 +305,119 @@ pub fn set_attr(fd: &std::fs::File, path: Option<&Path>, attr: &SetAttributes) - } } +/// Apply attributes to `name` in the directory `dirfd` without following a final symbolic link. +/// +/// Changing the mode of a symbolic link fails with `EOPNOTSUPP`, as `lchmod` does on Linux. +/// +/// Linux will remove the set-user-ID and set-group-ID bits as appropriate, so unlike the Windows +/// version this doesn't need to be done explicitly. +pub fn set_attr_confined(dirfd: RawFd, name: &ffi::CStr, attr: &SetAttributes) -> lx::Result<()> { + // Ctime is updated by most of the operations below, so don't explicitly + // update it if not needed. + let mut need_ctime_update = !attr.ctime.is_omit(); + + if let Some(size) = attr.size { + let file = openat_name( + dirfd, + name, + libc::O_WRONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + None, + )?; + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::ftruncate(file.as_raw_fd(), size))?; + } + + need_ctime_update = false; + } + + if let Some(mode) = attr.mode { + // `fchmodat` cannot be told not to follow a symbolic link. Pin the object without + // following it, and change the mode of exactly that object through its /proc link. + let object = openat_name( + dirfd, + name, + libc::O_PATH | libc::O_NOFOLLOW | libc::O_CLOEXEC, + None, + )?; + let mut stat = mem::MaybeUninit::::uninit(); + + // SAFETY: `stat` is valid for writes and `object` is open. + unsafe { + check_lx_errno(libc::fstat(object.as_raw_fd(), stat.as_mut_ptr()))?; + } + + // SAFETY: fstat initialized `stat` because it succeeded. + if unsafe { stat.assume_init() }.st_mode & libc::S_IFMT == libc::S_IFLNK { + return Err(lx::Error::ENOTSUP); + } + + let path = path_to_cstr(&get_proc_fd_path(&object))?; + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::chmod(path.as_ptr(), mode))?; + } + + need_ctime_update = false; + } + + if attr.uid.is_some() || attr.gid.is_some() { + let uid = attr.uid.unwrap_or(lx::UID_INVALID); + let gid = attr.gid.unwrap_or(lx::GID_INVALID); + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::fchownat( + dirfd, + name.as_ptr(), + uid, + gid, + libc::AT_SYMLINK_NOFOLLOW, + ))?; + } + + need_ctime_update = false; + } + + if !attr.atime.is_omit() || !attr.mtime.is_omit() { + let times = [ + set_time_to_timespec(&attr.atime), + set_time_to_timespec(&attr.mtime), + ]; + + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::utimensat( + dirfd, + name.as_ptr(), + times.as_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ))?; + } + + need_ctime_update = false; + } + + // If a ctime update was requested and didn't already happen, perform a no-op operation that + // has a ctime update as a side-effect. + if need_ctime_update { + // SAFETY: Calling C API as documented, with no special requirements. + unsafe { + check_lx_errno(libc::fchownat( + dirfd, + name.as_ptr(), + lx::UID_INVALID, + lx::GID_INVALID, + libc::AT_SYMLINK_NOFOLLOW, + ))?; + } + } + + Ok(()) +} + /// Create a timespec with either omit, now or a value. fn set_time_to_timespec(time: &SetTime) -> libc::timespec { match time { diff --git a/vm/devices/support/fs/lxutil/src/windows/mod.rs b/vm/devices/support/fs/lxutil/src/windows/mod.rs index 082d32d40cc..4842dc8aa6a 100644 --- a/vm/devices/support/fs/lxutil/src/windows/mod.rs +++ b/vm/devices/support/fs/lxutil/src/windows/mod.rs @@ -757,8 +757,11 @@ impl LxVolume { let win_target = Path::from_lx(target); // Determine whether a NT symlink can be created, and if so whether it should be a file or - // directory. - let link_type = if let Ok(win_target) = &win_target { + // directory. A confined volume only creates LX symlinks, which Windows path resolution + // never follows, so a link can never redirect a later lookup outside the volume. + let link_type = if self.state.options.confine_paths { + SymlinkType::Lx + } else if let Ok(win_target) = &win_target { self.determine_symlink_type(path.parent().unwrap(), win_target) } else { SymlinkType::Lx From 2728f33ea9a44d2d0fb956d9f01768ae2100fb62 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 12:24:58 -0700 Subject: [PATCH 166/304] virtiofs: allow symlink creation on read-write microVM shares Build tools such as package managers, virtual environments, and language toolchains need symbolic links in a live workspace, but the microVM profile refused to create them and failed every operation on a link. The microVM volume now uses lxutil path confinement, so the host never follows a link while resolving a guest path, and a read-write share accepts symlink() with the target stored exactly as given. The guest resolves links in its own namespace, so a target that names a host path, a location above the export, or a denied path cannot reach host data outside the policy. A read-only share still returns EROFS. - Validate a link target only against the Linux bounds (ENOENT for an empty target, ENAMETOOLONG beyond 4095 bytes). - Check the inode table before creating a link, so a full table cannot leave an untracked link on the host. - Let operations on an object accept a link as its final component, while lookups and namespace changes still require that no component is a link. Also validate a primary path that has outlived its aliases. - Reject a link that an open or create reaches with ELOOP on every host. - Save and restore symlink inodes. Restore revalidates the link itself and rejects an alias whose ancestor has become a link. - Do not append a trailing separator to the path of a renamed object, which made the host treat it as a directory and follow a link. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/reference/devices/virtio/virtio-fs.md | 46 +- Guide/src/reference/openvmm/management/cli.md | 5 +- vm/devices/virtio/virtiofs/src/inode.rs | 52 +- vm/devices/virtio/virtiofs/src/lib.rs | 6 +- vm/devices/virtio/virtiofs/src/microvm/fs.rs | 26 +- .../virtio/virtiofs/src/microvm/inode.rs | 58 +- .../virtio/virtiofs/src/microvm/limits.rs | 10 +- vm/devices/virtio/virtiofs/src/microvm/mod.rs | 2 + .../virtio/virtiofs/src/microvm/state.rs | 13 +- .../virtiofs/src/microvm/symlink_tests.rs | 624 ++++++++++++++++++ 10 files changed, 786 insertions(+), 56 deletions(-) create mode 100644 vm/devices/virtio/virtiofs/src/microvm/symlink_tests.rs diff --git a/Guide/src/reference/devices/virtio/virtio-fs.md b/Guide/src/reference/devices/virtio/virtio-fs.md index 48e2dc5df97..7d9907a90f7 100644 --- a/Guide/src/reference/devices/virtio/virtio-fs.md +++ b/Guide/src/reference/devices/virtio/virtio-fs.md @@ -49,6 +49,7 @@ guest-visible configuration: | Cache policy | Zero entry and attribute lifetimes | | File policy | Direct I/O | | Maximum write | 1 MiB payload plus protocol headers | +| Symbolic links | `rw`: created with the exact target; `ro`: `EROFS` | For an active cold-boot attachment, the profile adds `virtfs_dir`, `virtfs_tag`, and `virtfs_mode` bootstrap tokens to the kernel command line. @@ -59,6 +60,18 @@ canonicalizes each entry relative to the export and rejects paths outside the root, the root itself, overlapping entries, symlink/reparse components, and nested-mount crossings before opening the device. +A read-write attachment lets the guest create symbolic links, so ordinary +build tools (package managers, virtual environments, and language +toolchains) work in a live share. HostFs stores each target byte for byte and +never follows a link while resolving a host path; the guest kernel resolves +links in its own namespace. A target that names an absolute host path, a +location above the export, or a denied path therefore cannot reach host data +outside the policy. Opening a link object fails with `ELOOP`, and on Linux +changing its mode fails with `EOPNOTSUPP`. On Windows, HostFs always creates +WSL-style links, which Windows path resolution never follows, so Windows tools +see guest-created links as inaccessible files. Host software that later reads +the exported directory must treat guest-created links as untrusted. + `SectionFs`, aggregate roots, alternate tags, PCI transport, DAX, and extra queues are not part of the microVM profile. @@ -78,7 +91,9 @@ Restoring a snapshot captured with an active attachment requires a fresh guest target, and mode. Identity validation remains independent: before any vCPU starts, OpenVMM pins the supplied root and validates its saved root and object identities. Missing, moved, replaced, ambiguous, or no-longer-reopenable -objects fail restore. +objects fail restore. A saved symbolic link is revalidated as the link itself, +without being followed, and an alias whose ancestor has become a link fails +restore. A snapshot captured without `--mount` records the fixed slot as dormant. It may restore without an attachment, or bind a new `--mount` attachment. Because @@ -111,20 +126,33 @@ that would expose guest RAM or snapshot files through virtio-fs. Guest FUSE requests, paths, and saved aliases are untrusted. HostFs rejects absolute and parent-relative aliases, does not follow symbolic links or -Windows reparse points while resolving saved objects, and enforces read-only -mode before invoking a host mutation. +Windows reparse points while resolving guest or saved objects, and enforces +read-only mode before invoking a host mutation. + +On Linux, every host operation opens the parent directory of its path with +`openat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)`, or with one +`O_NOFOLLOW` open per component where `openat2` is unavailable, and applies +the operation to the final component without following it. Extended +attributes and file system statistics use that pinned directory rather than +an absolute host path. Neither a guest-created link nor a concurrent rename +can therefore redirect an operation outside the export. On Windows, the guest +can only create WSL-style links, which are never followed, and HostFs rejects +any ancestor that is a symbolic link or reparse point before each operation. Denied paths are enforced in the server namespace rather than by guest mount layout. Lookup and mutation operations reject denied prefixes, directory enumeration omits their names, and denied root object identities reject -hard-link, junction, and bind-mount aliases. Mounting the same virtio-fs tag at -another guest path does not change the policy. +hard-link, junction, and bind-mount aliases. A guest-created link cannot reach +a denied path because the guest resolves it and every resulting host lookup +applies the same policy. Mounting the same virtio-fs tag at another guest path +does not change the policy. ```admonish warning -The current cross-platform `LxVolume` interface does not provide fully -handle-relative component walking for every operation. Do not allow an -untrusted host process to concurrently replace or rename directories inside -the export; quiesce external namespace mutation during capture and restore. +On Windows, the check for host-created NT symbolic links and junctions in +ancestor components is not handle-relative. Do not let an untrusted host +process create reparse points or replace directories inside a Windows export +while a VM runs. On every host, quiesce external namespace mutation during +capture and restore. ``` Host filesystem behavior differs where Windows cannot represent a POSIX diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 9e543f32fec..a423834e4e0 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -195,7 +195,10 @@ describes the source definitions. behavior, zero entry and attribute cache lifetimes, and no shared-memory window. `--mount` conflicts with `--virtio-fs` and `--virtio-fs-shmem`; those standard-machine options cannot select the - microVM filesystem profile. + microVM filesystem profile. A read-write attachment accepts guest-created + symbolic links, stores their targets exactly, and never follows them on the + host; a read-only attachment rejects them with `EROFS`. On Windows, the + links are WSL-style reparse points. `--mount-deny ` is repeatable and hides an existing file or directory inside the exported root. Paths are canonicalized to diff --git a/vm/devices/virtio/virtiofs/src/inode.rs b/vm/devices/virtio/virtiofs/src/inode.rs index c6056894415..87554fcae47 100644 --- a/vm/devices/virtio/virtiofs/src/inode.rs +++ b/vm/devices/virtio/virtiofs/src/inode.rs @@ -165,7 +165,7 @@ impl VirtioFsInode { } pub(crate) fn object_stat(&self) -> lx::Result { - self.validate_confined()?; + self.validate_confined_object()?; self.volume.lstat(&*self.get_path()) } @@ -245,21 +245,21 @@ impl VirtioFsInode { /// Retrieves the attributes of this inode. pub fn get_attr(&self) -> lx::Result { - self.validate_confined()?; + self.validate_confined_object()?; let stat = self.volume.lstat(&*self.get_path())?; Ok(self.attr_from_stat(&stat)) } /// Retrieves the extended attributes of this inode. pub fn get_statx(&self) -> lx::Result { - self.validate_confined()?; + self.validate_confined_object()?; let statx = self.volume.statx(&*self.get_path())?; Ok(self.statx_from(&statx)) } /// Sets the attributes of this inode. pub fn set_attr(&self, arg: &fuse_setattr_in, request_uid: lx::uid_t) -> lx::Result { - self.validate_confined()?; + self.validate_confined_object()?; let attr = util::fuse_set_attr_to_lxutil(arg, request_uid); // Because FUSE_HANDLE_KILLPRIV is set, set-user-ID and set-group-ID must be cleared @@ -271,9 +271,10 @@ impl VirtioFsInode { /// Opens the inode, creating a file object. pub fn open(self: Arc, flags: u32) -> lx::Result { - self.validate_confined()?; + self.validate_confined_object()?; let flags = (flags as i32) | lx::O_NOFOLLOW; let file = self.volume.open(&*self.get_path(), flags, None)?; + crate::microvm::inode::reject_opened_symlink(&self.volume, &file)?; Ok(VirtioFsFile::new(file, self, flags as u32)) } @@ -292,6 +293,7 @@ impl VirtioFsInode { let flags = (flags as i32) | lx::O_CREAT | lx::O_NOFOLLOW; let file = self.volume.open(&path, flags, Some(options))?; let stat = file.fstat()?.into(); + crate::microvm::inode::reject_symlink_stat(&self.volume, &stat)?; let inode = Self::with_attr(Arc::clone(&self.volume), path, &stat); let attr = inode.attr_from_stat(&stat); Ok((inode, attr, file)) @@ -362,7 +364,7 @@ impl VirtioFsInode { /// Creates a new hard link as a child of this inode. pub fn link(&self, name: &LxStr, target: &VirtioFsInode) -> lx::Result { self.validate_confined()?; - target.validate_confined()?; + target.validate_confined_object()?; if self.volume.id() != target.volume.id() { return Err(lx::Error::EXDEV); } @@ -375,7 +377,7 @@ impl VirtioFsInode { /// Reads the target of the symbolic link, if this inode is a symbolic link. pub fn read_link(&self) -> lx::Result { - self.validate_confined()?; + self.validate_confined_object()?; self.volume.read_link(&*self.get_path()) } @@ -403,7 +405,7 @@ impl VirtioFsInode { /// Gets the attributes of the file system that the inode resides on. pub fn stat_fs(&self) -> lx::Result { - self.validate_confined()?; + self.validate_confined_object()?; let stat_fs = self.volume.stat_fs(&*self.get_path())?; Ok(fuse_kstatfs::new( stat_fs.block_count, @@ -419,26 +421,26 @@ impl VirtioFsInode { /// Gets the value or the size of an extended attribute on this inode. pub fn get_xattr(&self, name: &LxStr, value: Option<&mut [u8]>) -> lx::Result { - self.validate_confined()?; + self.validate_confined_object()?; self.volume.get_xattr(&*self.get_path(), name, value) } /// Sets an extended attribute on this inode. pub fn set_xattr(&self, name: &LxStr, value: &[u8], flags: u32) -> lx::Result<()> { - self.validate_confined()?; + self.validate_confined_object()?; self.volume .set_xattr(&*self.get_path(), name, value, flags as i32) } /// Lists the extended attributes on this inode. pub fn list_xattr(&self, list: Option<&mut [u8]>) -> lx::Result { - self.validate_confined()?; + self.validate_confined_object()?; self.volume.list_xattr(&*self.get_path(), list) } /// Removes an extended attribute from this inode. pub fn remove_xattr(&self, name: &LxStr) -> lx::Result<()> { - self.validate_confined()?; + self.validate_confined_object()?; self.volume.remove_xattr(&*self.get_path(), name) } @@ -475,11 +477,10 @@ impl VirtioFsInode { let replacements: Vec<_> = aliases .iter() .filter_map(|alias| { - alias.strip_prefix(old).ok().map(|suffix| { - let mut replacement = new.to_path_buf(); - replacement.push(suffix); - (alias.clone(), replacement) - }) + alias + .strip_prefix(old) + .ok() + .map(|suffix| (alias.clone(), renamed_path(new, suffix))) }) .collect(); for (old_alias, new_alias) in &replacements { @@ -491,9 +492,7 @@ impl VirtioFsInode { let mut path = self.path.write(); let suffix = path.strip_prefix(old).ok().map(ToOwned::to_owned); if let Some(suffix) = suffix { - let mut replacement = new.to_path_buf(); - replacement.push(&suffix); - *path = replacement; + *path = renamed_path(new, &suffix); } } @@ -537,3 +536,16 @@ impl VirtioFsInode { self.path.read() } } + +/// Returns `suffix` relocated under a renamed prefix `new`. +/// +/// An empty suffix (the renamed object itself) must not be pushed, because +/// pushing an empty path appends a trailing separator, which makes the host +/// resolve the path as a directory (and follow a symbolic link). +pub(crate) fn renamed_path(new: &Path, suffix: &Path) -> PathBuf { + let mut path = new.to_path_buf(); + if !suffix.as_os_str().is_empty() { + path.push(suffix); + } + path +} diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index 747328fca18..dadd3e267d5 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -354,9 +354,11 @@ impl Fuse for VirtioFs { if self.is_synthetic_root(request.node_id()) { return Err(lx::Error::EROFS); } - microvm::fs::check_symlink_allowed(self)?; let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; + microvm::fs::validate_symlink_target(self, target)?; + let path = inode.child_path(name)?; + self.preflight_new_inode_path(&path)?; let (new_inode, attr) = inode.symlink(name, target, request.uid(), request.gid())?; let (_, node_id) = self.insert_inode(new_inode)?; @@ -713,7 +715,7 @@ impl VirtioFs { tracelimit::warn_ratelimited!(node_id, "request for unknown inode"); lx::Error::EINVAL })?; - inode.validate_confined()?; + inode.validate_confined_object()?; Ok(inode) } diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs.rs b/vm/devices/virtio/virtiofs/src/microvm/fs.rs index 1db445af50f..9763f8de28c 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/fs.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/fs.rs @@ -85,12 +85,23 @@ pub(crate) fn configure_session(fs: &VirtioFs, info: &mut SessionInfo) { } } -pub(crate) fn check_symlink_allowed(fs: &VirtioFs) -> lx::Result<()> { - // The generic LxVolume API cannot pin every ancestor while resolving - // a symlink. The microVM profile therefore does not create links that - // could later turn a checked relative lookup into an escape. +/// The longest symbolic link target, in bytes, that Linux accepts. +const MAX_SYMLINK_TARGET_BYTES: usize = 4095; + +/// Validates a guest symbolic link target without rewriting it. +pub(crate) fn validate_symlink_target(fs: &VirtioFs, target: &lx::LxStr) -> lx::Result<()> { + // The microVM volume never follows a symbolic link while resolving a host + // path (see `LxVolumeOptions::confine_paths`), so the guest may store any + // target verbatim. Only the bounds that Linux itself enforces are applied, + // so that every host reports the same errors. if fs.is_microvm() { - return Err(lx::Error::ENOTSUP); + let target = target.as_bytes(); + if target.is_empty() { + return Err(lx::Error::ENOENT); + } + if target.len() > MAX_SYMLINK_TARGET_BYTES { + return Err(lx::Error::ENAMETOOLONG); + } } Ok(()) } @@ -129,7 +140,10 @@ impl VirtioFs { let root_path = root_path.as_ref(); profile.validate_root_path(root_path)?; let mut mount_options = LxVolumeOptions::new(); - mount_options.readonly(profile.is_readonly()).sandbox(true); + mount_options + .readonly(profile.is_readonly()) + .sandbox(true) + .confine_paths(true); let volume = mount_options.new_volume(root_path)?; let denied_identities = profile .denied_paths() diff --git a/vm/devices/virtio/virtiofs/src/microvm/inode.rs b/vm/devices/virtio/virtiofs/src/microvm/inode.rs index b07c77feb68..e8815fce225 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/inode.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/inode.rs @@ -87,27 +87,51 @@ impl VirtioFsInode { Ok(inode) } - /// Checks that a microVM path is relative and has no symlink component. + /// Checks that a microVM inode used as a directory is reached without + /// crossing a symbolic link, including at its final component. /// /// LxVolume intentionally does not promise this property for arbitrary /// callers, so the profile applies a conservative check before each - /// namespace operation. A handle-relative, race-free traversal primitive - /// remains necessary for a complete cross-platform guarantee. + /// namespace operation. On Linux, the confined volume additionally + /// resolves every host path without following a symbolic link, so this + /// check is not the only defense against a concurrent rename. Windows + /// guests can only create WSL-style links, which Windows path resolution + /// never follows. pub(crate) fn validate_confined(&self) -> lx::Result<()> { + self.validate_confined_paths(false) + } + + /// Checks that a microVM inode is reached without crossing a symbolic + /// link. The inode itself may be a symbolic link, which operations on the + /// object never follow. + pub(crate) fn validate_confined_object(&self) -> lx::Result<()> { + self.validate_confined_paths(true) + } + + fn validate_confined_paths(&self, allow_final_link: bool) -> lx::Result<()> { if !self.volume.strict_paths() { return Ok(()); } - for path in self.aliases() { + // Operations use the primary path, which outlives the aliases when the + // object is unlinked, so it must be validated as well. + let mut paths = self.aliases(); + let primary = self.clone_path(); + if !paths.contains(&primary) { + paths.push(primary); + } + for path in paths { validate_relative_path(&path, true)?; self.volume.ensure_path_allowed(&path)?; + let component_count = path.components().count(); let mut prefix = PathBuf::new(); - for component in path.components() { + for (index, component) in path.components().enumerate() { let Component::Normal(component) = component else { return Err(lx::Error::EINVAL); }; prefix.push(component); let stat = self.volume.lstat(&prefix)?; - if stat.mode & lx::S_IFMT == lx::S_IFLNK { + let final_link = allow_final_link && index + 1 == component_count; + if stat.mode & lx::S_IFMT == lx::S_IFLNK && !final_link { return Err(lx::Error::ELOOP); } } @@ -116,6 +140,28 @@ impl VirtioFsInode { } } +/// Rejects a symbolic link opened on a strict volume. +/// +/// Windows opens a link itself when asked not to follow it, so the opened +/// object is checked rather than relying on the host to refuse. +pub(crate) fn reject_opened_symlink( + volume: &VirtioFsVolume, + file: &lxutil::LxFile, +) -> lx::Result<()> { + if volume.strict_paths() { + reject_symlink_stat(volume, &file.fstat()?.into())?; + } + Ok(()) +} + +/// Rejects the attributes of an opened symbolic link on a strict volume. +pub(crate) fn reject_symlink_stat(volume: &VirtioFsVolume, stat: &lx::Stat) -> lx::Result<()> { + if volume.strict_paths() && stat.mode & lx::S_IFMT == lx::S_IFLNK { + return Err(lx::Error::ELOOP); + } + Ok(()) +} + pub(crate) fn child_name_disallowed(volume: &VirtioFsVolume, name: &[u8]) -> bool { volume.strict_paths() && (name.contains(&b'\\') || name.contains(&b':')) } diff --git a/vm/devices/virtio/virtiofs/src/microvm/limits.rs b/vm/devices/virtio/virtiofs/src/microvm/limits.rs index 23d7aa81ce9..49aca0e4383 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/limits.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/limits.rs @@ -12,6 +12,7 @@ use super::state::relative_path_encoded_len; use super::state::validate_relative_path; use crate::InodeMap; use crate::inode::VirtioFsInode; +use crate::inode::renamed_path; use std::collections::BTreeSet; use std::path::Path; use std::sync::Arc; @@ -97,11 +98,10 @@ impl InodeMap { let replacements: Vec<_> = aliases .iter() .filter_map(|alias| { - alias.strip_prefix(old).ok().map(|suffix| { - let mut replacement = new.to_path_buf(); - replacement.push(suffix); - (alias.clone(), replacement) - }) + alias + .strip_prefix(old) + .ok() + .map(|suffix| (alias.clone(), renamed_path(new, suffix))) }) .collect(); for (old_alias, new_alias) in replacements { diff --git a/vm/devices/virtio/virtiofs/src/microvm/mod.rs b/vm/devices/virtio/virtiofs/src/microvm/mod.rs index 1c41b6fcc5f..deed7d94c50 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/mod.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/mod.rs @@ -14,6 +14,8 @@ pub mod profile; pub(crate) mod resolver; pub(crate) mod saved_state; pub(crate) mod state; +#[cfg(test)] +mod symlink_tests; const MAX_FUSE_REQUEST_HEADER_BYTES: usize = 4096; diff --git a/vm/devices/virtio/virtiofs/src/microvm/state.rs b/vm/devices/virtio/virtiofs/src/microvm/state.rs index 6c5568fc865..de885eb418a 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/state.rs @@ -165,8 +165,11 @@ pub(crate) fn validate_reopenable_alias( volume .ensure_path_allowed(path) .map_err(anyhow::Error::from)?; + // The object itself may be a symbolic link, which is reopened without + // being followed, but no ancestor may be one. + let component_count = path.components().count(); let mut prefix = PathBuf::new(); - for component in path.components() { + for (index, component) in path.components().enumerate() { let std::path::Component::Normal(component) = component else { anyhow::bail!("alias contains a non-normal path component"); }; @@ -176,8 +179,8 @@ pub(crate) fn validate_reopenable_alias( .map_err(anyhow::Error::from) .context("alias component cannot be inspected")?; anyhow::ensure!( - stat.mode & lx::S_IFMT != lx::S_IFLNK, - "alias contains a symbolic-link component" + index + 1 == component_count || stat.mode & lx::S_IFMT != lx::S_IFLNK, + "alias crosses a symbolic-link component" ); } volume @@ -344,10 +347,6 @@ pub(crate) fn validate_microvm_state( inode.lookup_count != 0, "saved inode lookup count is invalid" ); - anyhow::ensure!( - inode.object_identity.kind != lx::S_IFLNK, - "saved symlink identities are unsupported by the microVM profile" - ); anyhow::ensure!( !inode.relative_aliases.is_empty() && inode.relative_aliases.len() <= MAX_ALIASES_PER_INODE, diff --git a/vm/devices/virtio/virtiofs/src/microvm/symlink_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/symlink_tests.rs new file mode 100644 index 00000000000..2a6709f42a9 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/symlink_tests.rs @@ -0,0 +1,624 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for symbolic links on microVM shares. + +use super::profile::MICROVM_ATTACHMENT_ID; +use super::profile::MicroVmVirtioFsProfile; +use super::profile::microvm_root_identity; +use super::saved_state::SavedHandle; +use super::saved_state::SavedObjectIdentity; +use super::state::validate_microvm_state; +use crate::VirtioFs; +use fuse::Fuse; +use fuse::Request; +use fuse::Session; +use fuse::SessionState; +use fuse::protocol::FATTR_MODE; +use fuse::protocol::FATTR_SIZE; +use fuse::protocol::FUSE_READLINK; +use fuse::protocol::FUSE_ROOT_ID; +use fuse::protocol::fuse_in_header; +use fuse::protocol::fuse_setattr_in; +use lxutil::LxCreateOptions; +use lxutil::LxVolume; +use lxutil::LxVolumeOptions; +use std::path::Path; +use std::sync::Arc; +use tempfile::tempdir; +use test_with_tracing::test; +use zerocopy::FromZeros; +use zerocopy::IntoBytes; + +fn profile(root_path: &Path, read_only: bool, denied_paths: &[&str]) -> MicroVmVirtioFsProfile { + MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root_path).unwrap(), + read_only, + denied_paths.iter().map(|path| (*path).to_owned()).collect(), + ) + .unwrap() +} + +/// Builds a request header for `node_id`, as the guest kernel would send it. +fn request(node_id: u64) -> Request { + let header = fuse_in_header { + len: size_of::() as u32, + opcode: FUSE_READLINK, + unique: 1, + nodeid: node_id, + uid: 0, + gid: 0, + pid: 0, + padding: 0, + }; + Request::new(header.as_bytes()).unwrap() +} + +fn name(value: &[u8]) -> &lx::LxStr { + lx::LxStr::from_bytes(value) +} + +fn error(result: lx::Result) -> lx::Error { + match result { + Ok(_) => panic!("operation unexpectedly succeeded"), + Err(error) => error, + } +} + +/// Creates a link on the host the way the microVM volume does on every platform. +fn host_symlink(root: &Path, link: &str, target: &str) { + LxVolumeOptions::new() + .confine_paths(true) + .new_volume(root) + .unwrap() + .symlink(link, target, LxCreateOptions::new(0, 0, 0)) + .unwrap(); +} + +fn host_read_link(root: &Path, link: &str) -> Vec { + LxVolume::new(root) + .unwrap() + .read_link(link) + .unwrap() + .as_bytes() + .to_vec() +} + +fn host_exists(path: &Path) -> bool { + std::fs::symlink_metadata(path).is_ok() +} + +fn setattr(valid: u32, update: impl FnOnce(&mut fuse_setattr_in)) -> fuse_setattr_in { + let mut arg = fuse_setattr_in::new_zeroed(); + arg.valid = valid; + update(&mut arg); + arg +} + +/// A share and a sibling host directory that the guest must never reach. +struct Fixture { + _directory: tempfile::TempDir, + root: std::path::PathBuf, + outside: std::path::PathBuf, +} + +impl Fixture { + fn new() -> Self { + let directory = tempdir().unwrap(); + let root = directory.path().join("share"); + let outside = directory.path().join("outside"); + std::fs::create_dir(&root).unwrap(); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("secret"), b"host secret").unwrap(); + Self { + _directory: directory, + root, + outside, + } + } + + fn outside_target(&self) -> Vec { + self.outside + .join("secret") + .to_str() + .unwrap() + .replace('\\', "/") + .into_bytes() + } + + fn assert_outside_unchanged(&self) { + let names = std::fs::read_dir(&self.outside) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(names, ["secret"]); + assert_eq!( + std::fs::read(self.outside.join("secret")).unwrap(), + b"host secret" + ); + } +} + +#[test] +fn rw_symlink_preserves_target_verbatim() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let targets: [&[u8]; 6] = [ + b"/absolute/host/path", + b"../../outside/secret", + b"dangling/relative", + b"with space", + b"\xff\xfe-not-utf8", + b"node_modules/.bin/../tool/bin/cli.js", + ]; + + for (index, target) in targets.iter().enumerate() { + let link = format!("link-{index}"); + let entry = fs + .symlink(&request(FUSE_ROOT_ID), name(link.as_bytes()), name(target)) + .unwrap(); + assert_eq!(entry.attr.mode & lx::S_IFMT, lx::S_IFLNK); + assert_eq!(entry.attr.size, target.len() as u64); + + let node = request(entry.nodeid); + assert_eq!(fs.read_link(&node).unwrap().as_bytes(), *target); + assert_eq!( + fs.get_attr(&node, 0, 0).unwrap().attr.mode & lx::S_IFMT, + lx::S_IFLNK + ); + assert_eq!(host_read_link(&fixture.root, &link), *target); + + let lookup = fs + .lookup(&request(FUSE_ROOT_ID), name(link.as_bytes())) + .unwrap(); + assert_eq!(lookup.nodeid, entry.nodeid); + } + fixture.assert_outside_unchanged(); +} + +#[test] +fn ro_symlink_is_rejected_with_erofs() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, true, &[])).unwrap(); + + assert_eq!( + error(fs.symlink(&request(FUSE_ROOT_ID), name(b"link"), name(b"target"))), + lx::Error::EROFS + ); + assert!(!host_exists(&fixture.root.join("link"))); +} + +#[test] +fn symlink_target_bounds_match_linux() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let root = request(FUSE_ROOT_ID); + + assert_eq!( + error(fs.symlink(&root, name(b"empty"), name(b""))), + lx::Error::ENOENT + ); + assert_eq!( + error(fs.symlink(&root, name(b"long"), name(&[b'x'; 4096]))), + lx::Error::ENAMETOOLONG + ); + assert!(!host_exists(&fixture.root.join("empty"))); + assert!(!host_exists(&fixture.root.join("long"))); +} + +#[test] +fn symlink_respects_inode_limits_before_creating_the_link() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + fs.inner.inodes.write().inodes_by_node_id.next_handle = 0; + + assert_eq!( + error(fs.symlink(&request(FUSE_ROOT_ID), name(b"link"), name(b"target"))), + lx::Error::ENOSPC + ); + assert!(!host_exists(&fixture.root.join("link"))); +} + +#[test] +fn guest_links_cannot_reach_a_denied_path() { + let fixture = Fixture::new(); + std::fs::create_dir(fixture.root.join("allowed")).unwrap(); + std::fs::create_dir(fixture.root.join("secrets")).unwrap(); + std::fs::write(fixture.root.join("secrets").join("token"), b"token").unwrap(); + let fs = + VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &["secrets"])).unwrap(); + let root = request(FUSE_ROOT_ID); + + // A link cannot take the place of the denied path. + std::fs::rename( + fixture.root.join("secrets"), + fixture.root.join("moved-secrets"), + ) + .unwrap(); + assert_eq!( + error(fs.symlink(&root, name(b"secrets"), name(b"allowed"))), + lx::Error::EACCES + ); + assert!(!host_exists(&fixture.root.join("secrets"))); + std::fs::rename( + fixture.root.join("moved-secrets"), + fixture.root.join("secrets"), + ) + .unwrap(); + + // A link may name the denied path, but the guest resolves it, and every + // lookup of the denied path is still refused. + let allowed = fs.lookup(&root, name(b"allowed")).unwrap(); + for (link, target) in [ + (b"alias".as_slice(), b"../secrets".as_slice()), + (b"token".as_slice(), b"../secrets/token".as_slice()), + ] { + let entry = fs + .symlink(&request(allowed.nodeid), name(link), name(target)) + .unwrap(); + assert_eq!( + fs.read_link(&request(entry.nodeid)).unwrap().as_bytes(), + target + ); + // Using the link as a directory never traverses it on the host. + assert_eq!( + error(fs.lookup(&request(entry.nodeid), name(b"token"))), + lx::Error::ELOOP + ); + } + assert_eq!(error(fs.lookup(&root, name(b"secrets"))), lx::Error::EACCES); + assert_eq!( + std::fs::read(fixture.root.join("secrets").join("token")).unwrap(), + b"token" + ); +} + +#[test] +fn symlink_inode_operations_do_not_follow() { + let fixture = Fixture::new(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let root = request(FUSE_ROOT_ID); + let target = fixture.outside_target(); + let entry = fs.symlink(&root, name(b"escape"), name(&target)).unwrap(); + let node = request(entry.nodeid); + + assert_eq!(error(fs.open(&node, lx::O_RDWR as u32)), lx::Error::ELOOP); + assert_eq!(error(fs.lookup(&node, name(b"secret"))), lx::Error::ELOOP); + // Neither truncation nor a mode change may reach the target. Linux + // rejects both; Windows applies them to the inert link file itself. + let truncate = fs.set_attr(&node, &setattr(FATTR_SIZE, |arg| arg.size = 0)); + let chmod = fs.set_attr(&node, &setattr(FATTR_MODE, |arg| arg.mode = 0o600)); + if cfg!(unix) { + assert_eq!(error(truncate), lx::Error::ELOOP); + assert_eq!(error(chmod), lx::Error::ENOTSUP); + } + fs.statfs(&node).unwrap(); + assert_eq!( + fs.get_attr(&node, 0, 0).unwrap().attr.mode & lx::S_IFMT, + lx::S_IFLNK + ); + + // Renaming, hard-linking, and removing the link act on the link itself. + fs.rename(&root, name(b"escape"), FUSE_ROOT_ID, name(b"renamed"), 0) + .unwrap(); + assert_eq!(fs.read_link(&node).unwrap().as_bytes(), target); + let hard = fs.link(&root, name(b"hard"), entry.nodeid).unwrap(); + assert_eq!(hard.nodeid, entry.nodeid); + fs.unlink(&root, name(b"renamed")).unwrap(); + assert_eq!(fs.read_link(&node).unwrap().as_bytes(), target); + fs.unlink(&root, name(b"hard")).unwrap(); + assert!(!host_exists(&fixture.root.join("hard"))); + fixture.assert_outside_unchanged(); +} + +#[test] +fn renamed_nodes_keep_an_exact_path() { + let fixture = Fixture::new(); + std::fs::write(fixture.root.join("file"), b"file").unwrap(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let root = request(FUSE_ROOT_ID); + let file = fs.lookup(&root, name(b"file")).unwrap(); + let link = fs.symlink(&root, name(b"link"), name(b"file")).unwrap(); + + // The guest may use a node again before it looks the new name up. + fs.rename(&root, name(b"file"), FUSE_ROOT_ID, name(b"moved-file"), 0) + .unwrap(); + fs.rename(&root, name(b"link"), FUSE_ROOT_ID, name(b"moved-link"), 0) + .unwrap(); + for (node, path, kind) in [ + (file.nodeid, "moved-file", lx::S_IFREG), + (link.nodeid, "moved-link", lx::S_IFLNK), + ] { + // A trailing separator would make the host resolve the name as a + // directory and follow a link. + let inode = fs.get_inode(node).unwrap(); + assert_eq!(inode.clone_path().as_os_str(), path); + assert!( + inode + .aliases() + .iter() + .all(|alias| alias.as_os_str() == path) + ); + assert_eq!( + fs.get_attr(&request(node), 0, 0).unwrap().attr.mode & lx::S_IFMT, + kind + ); + } + assert_eq!( + fs.read_link(&request(link.nodeid)).unwrap().as_bytes(), + b"file" + ); +} + +#[test] +fn stale_node_replaced_by_a_link_cannot_reach_the_target() { + let fixture = Fixture::new(); + std::fs::write(fixture.root.join("victim"), b"victim").unwrap(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let root = request(FUSE_ROOT_ID); + let victim = fs.lookup(&root, name(b"victim")).unwrap(); + + // The guest keeps the node of an unlinked file and puts a link at its + // former name. + fs.unlink(&root, name(b"victim")).unwrap(); + fs.symlink(&root, name(b"victim"), name(&fixture.outside_target())) + .unwrap(); + + let node = request(victim.nodeid); + let _ = fs.set_attr(&node, &setattr(FATTR_SIZE, |arg| arg.size = 0)); + let _ = fs.set_attr(&node, &setattr(FATTR_MODE, |arg| arg.mode = 0o777)); + let _ = fs.open(&node, lx::O_RDWR as u32); + fixture.assert_outside_unchanged(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(fixture.outside.join("secret")) + .unwrap() + .permissions() + .mode(); + assert_ne!(mode & 0o777, 0o777); + } +} + +#[cfg(unix)] +#[test] +fn replaced_directory_cannot_redirect_a_node_outside_the_share() { + let fixture = Fixture::new(); + std::fs::create_dir(fixture.root.join("dir")).unwrap(); + std::fs::write(fixture.root.join("dir").join("secret"), b"inside").unwrap(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + let directory = fs.lookup(&request(FUSE_ROOT_ID), name(b"dir")).unwrap(); + let secret = fs + .lookup(&request(directory.nodeid), name(b"secret")) + .unwrap(); + + // A concurrent rename can swap the directory for a link to the host + // between any check and the host operation. + std::fs::rename(fixture.root.join("dir"), fixture.root.join("saved")).unwrap(); + std::os::unix::fs::symlink(&fixture.outside, fixture.root.join("dir")).unwrap(); + + assert_eq!( + error(fs.open(&request(secret.nodeid), lx::O_RDWR as u32)), + lx::Error::ELOOP + ); + // The volume itself refuses to traverse the link, so the outcome does not + // depend on the component check winning the race. + let volume = fs.get_inode(FUSE_ROOT_ID).unwrap().volume(); + let path = Path::new("dir/secret"); + assert_eq!(error(volume.open(path, lx::O_RDWR, None)), lx::Error::ELOOP); + let attr = lxutil::SetAttributes { + mode: Some(0o777), + ..Default::default() + }; + assert_eq!(error(volume.set_attr(path, attr)), lx::Error::ELOOP); + assert_eq!(error(volume.unlink(path, 0)), lx::Error::ELOOP); + fixture.assert_outside_unchanged(); +} + +#[test] +fn readdirplus_returns_usable_symlink_nodes() { + let fixture = Fixture::new(); + std::fs::write(fixture.root.join("file"), b"file").unwrap(); + host_symlink(&fixture.root, "link", "file"); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, true, &[])).unwrap(); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + let handle = fs + .insert_file(Arc::clone(&root).open(lx::O_RDONLY as u32).unwrap()) + .unwrap(); + + let entries = fs + .get_file(handle) + .unwrap() + .read_dir(&fs, 0, 4096, true) + .unwrap(); + assert!(!entries.is_empty()); + let link = fs.lookup(&request(FUSE_ROOT_ID), name(b"link")).unwrap(); + assert_eq!(link.attr.mode & lx::S_IFMT, lx::S_IFLNK); + assert_eq!( + fs.read_link(&request(link.nodeid)).unwrap().as_bytes(), + b"file" + ); +} + +#[test] +fn symlink_inode_survives_save_and_restore() { + let fixture = Fixture::new(); + let profile = profile(&fixture.root, false, &[]); + let source = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let entry = source + .symlink( + &request(FUSE_ROOT_ID), + name(b"link"), + name(b"../verbatim/target"), + ) + .unwrap(); + + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let saved = state + .inodes + .iter() + .find(|inode| inode.node_id == entry.nodeid) + .unwrap(); + assert_eq!(saved.object_identity.kind, lx::S_IFLNK); + validate_microvm_state(&state, &profile).unwrap(); + + let destination = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + assert_eq!( + destination + .read_link(&request(entry.nodeid)) + .unwrap() + .as_bytes(), + b"../verbatim/target" + ); +} + +#[test] +fn restore_rejects_a_replaced_symlink() { + let fixture = Fixture::new(); + std::fs::write(fixture.root.join("file"), b"file").unwrap(); + let profile = profile(&fixture.root, false, &[]); + let source = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + source + .symlink(&request(FUSE_ROOT_ID), name(b"link"), name(b"file")) + .unwrap(); + let link_state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let file_state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + + // Keep the original link alive so that its identity cannot be reused. + std::fs::rename(fixture.root.join("link"), fixture.root.join("saved")).unwrap(); + host_symlink(&fixture.root, "link", "file"); + let destination = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + assert!( + destination + .restore_microvm_state(&profile, link_state, &session) + .is_err() + ); + + std::fs::remove_file(fixture.root.join("link")).unwrap(); + std::fs::write(fixture.root.join("link"), b"file").unwrap(); + let destination = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + assert!( + destination + .restore_microvm_state(&profile, file_state, &session) + .is_err() + ); +} + +#[test] +fn restore_rejects_an_alias_that_crosses_a_symlink() { + let fixture = Fixture::new(); + std::fs::create_dir(fixture.root.join("dir")).unwrap(); + std::fs::write(fixture.root.join("dir").join("file"), b"file").unwrap(); + let profile = profile(&fixture.root, false, &[]); + let source = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let directory = source.lookup(&request(FUSE_ROOT_ID), name(b"dir")).unwrap(); + source + .lookup(&request(directory.nodeid), name(b"file")) + .unwrap(); + // Keep only the file's node, whose saved alias names the directory. + source.forget(directory.nodeid, 1); + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + assert!( + state + .inodes + .iter() + .all(|inode| inode.node_id != directory.nodeid) + ); + + // The same file remains reachable, but only through a link. + std::fs::rename(fixture.root.join("dir"), fixture.root.join("saved")).unwrap(); + host_symlink(&fixture.root, "dir", "saved"); + let destination = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + let error = destination + .restore_microvm_state(&profile, state, &session) + .unwrap_err(); + assert!( + format!("{error:#}").contains("alias crosses a symbolic-link component"), + "{error:#}" + ); +} + +#[test] +fn saved_symlink_handle_is_still_rejected() { + let fixture = Fixture::new(); + let profile = profile(&fixture.root, false, &[]); + let source = VirtioFs::new_microvm(&fixture.root, profile.clone()).unwrap(); + let entry = source + .symlink(&request(FUSE_ROOT_ID), name(b"link"), name(b"target")) + .unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + let identity = &state + .inodes + .iter() + .find(|inode| inode.node_id == entry.nodeid) + .unwrap() + .object_identity; + let handle = SavedHandle { + handle_id: state.next_handle_id, + node_id: entry.nodeid, + open_flags: (lx::O_RDONLY | lx::O_NOFOLLOW) as u32, + kind: lx::S_IFLNK, + object_identity: SavedObjectIdentity { + device_id: identity.device_id, + inode_id: identity.inode_id, + kind: identity.kind, + }, + directory_entries: Vec::new(), + directory_snapshot_built: false, + }; + state.next_handle_id += 1; + state.handles.push(handle); + + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[cfg(windows)] +#[test] +fn windows_microvm_symlinks_are_inert_wsl_links() { + use std::os::windows::fs::MetadataExt; + + const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400; + + let fixture = Fixture::new(); + std::fs::create_dir(fixture.root.join("dir")).unwrap(); + std::fs::write(fixture.root.join("dir").join("file"), b"file").unwrap(); + let fs = VirtioFs::new_microvm(&fixture.root, profile(&fixture.root, false, &[])).unwrap(); + + for (link, target) in [ + ("file-link", "dir/file"), + ("dir-link", "dir"), + ("escape", "../outside/secret"), + ] { + fs.symlink( + &request(FUSE_ROOT_ID), + name(link.as_bytes()), + name(target.as_bytes()), + ) + .unwrap(); + let host = fixture.root.join(link); + let metadata = std::fs::symlink_metadata(&host).unwrap(); + assert_ne!(metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT, 0); + assert!(std::fs::read_link(&host).is_err()); + assert!(std::fs::metadata(&host).is_err()); + assert!(std::fs::read(&host).is_err()); + } + fixture.assert_outside_unchanged(); +} From 4355c010e726128c751138e7f74ad969e76a4c19 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 16:37:31 -0700 Subject: [PATCH 167/304] openvmm_entry: flush portb output before a guest-requested snapshot A guest that reports a marker on the portb console and then requests a snapshot through port 0x605 could have the marker missing from the source endpoint. portb buffers guest output and writes it to the endpoint asynchronously, and stopping the device makes only one non-blocking write attempt. Bytes still buffered at quiesce time moved only into the snapshot, and the source process then exited after the commit without draining them, unlike a guest-requested exit. When the endpoint was slow to report write readiness, as Windows sockets can be, the host saw EOF before the marker. This made ttrpc::test_ttrpc_microvm_linux_direct_lifecycle_and_snapshot flaky on WHP (microsoft/nvx#231). Flush the output before quiescing: - The portb output-drain request now carries a mode, `MicrovmPortbDrain::Flush` or `MicrovmPortbDrain::Close`. `Close` keeps the existing exit behavior: write and flush every accepted byte, then close the endpoint. `Flush` writes and flushes the accepted bytes but keeps the endpoint open, because a capture can still roll back and resume the guest. Without a connected endpoint, `Flush` completes at once and the bytes stay buffered, so the snapshot still saves them for the restored VM. - `MicrovmOutputDrain::flush` sends a `Flush` request bounded by a timeout. It does not wait for the host relay, which completes only when the endpoint closes. - After preflight and before `QuiesceForSnapshot`, the controller flushes portb output within the snapshot quiesce timeout and records a `capture/output_flush` profile phase. A failed or timed-out flush logs a warning and capture continues; undelivered bytes stay in the snapshot as before. The CLI and management RPC references describe the flush. Tests cover a flush that waits for the endpoint and its flush, keeps the endpoint open and delivers later output, and leaves no stale output in the saved state; a flush without an endpoint that keeps the bytes for restore; a failed flush that keeps the endpoint for the exit drain; and the controller handle's flush mode, endpoint failure, and deadline, with the exit drain still requesting `Close`. Validated locally on Windows/WHP with a temporary fault injection that delays portb write readiness: the lifecycle-and-snapshot vmm-test failed 3/3 without the flush and passed 3/3 with it. Fixes: 5751cf356390 ("openvmm_entry: capture guest-requested microVM snapshots") Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 12 +- .../src/reference/openvmm/management/grpc.md | 4 +- openvmm/openvmm_entry/src/microvm/output.rs | 78 +++++++- .../src/vm_controller/microvm.rs | 14 ++ vm/devices/chipset/src/microvm.rs | 179 ++++++++++++++++-- vm/devices/chipset_resources/src/microvm.rs | 16 +- 6 files changed, 276 insertions(+), 27 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index a423834e4e0..56a1c5c2b3e 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -221,13 +221,17 @@ describes the source definitions. * `--snapshot-destination `: Publish a microVM snapshot when the guest writes to PMIO port `0x605`. The destination must not exist and its parent must already be a directory. OpenVMM automatically creates file-backed RAM - in that parent when no memory backing file was supplied, quiesces the VM, + in that parent when no memory backing file was supplied, delivers the portb + console output that the guest wrote before its request, quiesces the VM, writes and flushes a sibling staging directory, and atomically renames it to `DIR`. After a successful commit, the source VM terminates without executing the instruction after the snapshot `out`. `--snapshot-quiesce-timeout-ms ` sets the bounded quiesce - timeout and defaults to 5000. A request with no configured destination is + timeout and defaults to 5000. The same bound applies to delivering portb + output before quiescing; output still undelivered when it expires, or + accepted while no portb peer is connected, is saved in the snapshot and + written by the restored VM. A request with no configured destination is ignored and the guest continues. Capture requires 1, 2, 4, or 8 vCPUs, KVM, MSHV, or WHP, and shared file-backed RAM. Sandbox block media must be cached regular raw files with nonzero 512-byte-aligned geometry. An @@ -814,7 +818,9 @@ transmit buffer. Draining is bounded to five seconds; an output error or timeout is reported as a failure rather than a successful exit with truncated output. In RPC-server mode, a drain failure also fails pending `WaitVm` requests and terminates the server with an error instead of leaving it uninitialized. -Snapshot capture still preserves pending console bytes in the snapshot. +A guest-requested snapshot capture instead flushes accepted portb output to its +endpoint before quiescing, without closing it because the capture can still roll +back; see `--snapshot-destination`. * `--crash-dump-path `: when the guest triple-faults, write a WinDbg-compatible `.vmrs` dump of the VM's processor state and guest memory to diff --git a/Guide/src/reference/openvmm/management/grpc.md b/Guide/src/reference/openvmm/management/grpc.md index d1d3dc0998e..957903ea8a2 100644 --- a/Guide/src/reference/openvmm/management/grpc.md +++ b/Guide/src/reference/openvmm/management/grpc.md @@ -125,7 +125,9 @@ restore flow over both transports: * `destination_path` configures guest-requested capture. Supply a microVM configuration, including `DirectBoot` kernel/initrd files, memory, and the processor count. The path must not exist. `quiesce_timeout_ms` defaults to - five seconds when zero. + five seconds when zero. Before quiescing, the source delivers the serial + port 0 (portb) output the guest wrote before its request, within the same + bound, so the endpoint receives it before the source terminates. `memory_capacity_bytes` optionally reserves an immutable, 128-MiB-aligned RAM capacity while keeping the configured base memory as the exact `memory.bin` payload and initial Linux direct e820 map. diff --git a/openvmm/openvmm_entry/src/microvm/output.rs b/openvmm/openvmm_entry/src/microvm/output.rs index 793a359fd43..0f4b79da283 100644 --- a/openvmm/openvmm_entry/src/microvm/output.rs +++ b/openvmm/openvmm_entry/src/microvm/output.rs @@ -1,9 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Drains the microVM portb device and its host output relay before process exit. +//! Delivers microVM portb output to the host before a guest-requested snapshot +//! capture and drains it, with its host output relay, before process exit. use anyhow::Context; +use chipset_resources::microvm::MicrovmPortbDrain; use futures::AsyncRead; use futures::AsyncWriteExt; use futures::executor::block_on; @@ -39,18 +41,35 @@ pub(crate) fn spawn_output( } pub(crate) struct MicrovmOutputDrain { - portb: mesh::Sender>, + portb: mesh::Sender>, output: Option, } impl MicrovmOutputDrain { pub(crate) fn new( output: Option, - ) -> (Self, mesh::Receiver>) { + ) -> (Self, mesh::Receiver>) { let (portb, requests) = mesh::channel(); (Self { portb, output }, requests) } + /// Writes and flushes the output accepted by portb to its endpoint, which + /// stays open, and fails after `timeout`. + /// + /// The host relay is not awaited because it completes only once the + /// endpoint closes. + pub(crate) async fn flush(&self, timeout: Duration) -> anyhow::Result<()> { + CancelContext::new() + .with_timeout(timeout) + .until_cancelled( + self.portb + .call_failable(std::convert::identity, MicrovmPortbDrain::Flush), + ) + .await + .context("microVM console output flush timed out")? + .context("failed to flush the microVM portb endpoint") + } + pub(crate) async fn drain(self) -> anyhow::Result<()> { self.drain_with_timeout(Duration::from_secs(5)).await } @@ -60,7 +79,7 @@ impl MicrovmOutputDrain { .with_timeout(timeout) .until_cancelled(async { self.portb - .call_failable(std::convert::identity, ()) + .call_failable(std::convert::identity, MicrovmPortbDrain::Close) .await .context("failed to drain the microVM portb endpoint")?; if let Some(output) = self.output { @@ -95,13 +114,62 @@ mod tests { let (drain, mut requests) = MicrovmOutputDrain::new(Some(completed)); let mut drain = pin!(drain.drain()); assert!(poll!(&mut drain).is_pending()); - requests.recv().await.unwrap().complete(Ok(())); + let request = requests.recv().await.unwrap(); + assert_eq!(*request.input(), MicrovmPortbDrain::Close); + request.complete(Ok(())); assert!(poll!(&mut drain).is_pending()); complete.send(Ok(())); drain.await.unwrap(); }); } + #[test] + fn flush_keeps_the_endpoint_open_without_waiting_for_the_relay() { + block_on(async { + let (_complete, completed) = oneshot(); + let (drain, mut requests) = MicrovmOutputDrain::new(Some(completed)); + { + let mut flush = pin!(drain.flush(Duration::from_secs(5))); + assert!(poll!(&mut flush).is_pending()); + let request = requests.recv().await.unwrap(); + assert_eq!(*request.input(), MicrovmPortbDrain::Flush); + request.complete(Ok(())); + flush.await.unwrap(); + } + // The same handle still drains and closes portb at process exit. + let mut exit = pin!(drain.drain()); + assert!(poll!(&mut exit).is_pending()); + assert_eq!( + *requests.recv().await.unwrap().input(), + MicrovmPortbDrain::Close + ); + }); + } + + #[test] + fn flush_reports_endpoint_failure() { + block_on(async { + let (drain, mut requests) = MicrovmOutputDrain::new(None); + let mut flush = pin!(drain.flush(Duration::from_secs(5))); + assert!(poll!(&mut flush).is_pending()); + requests + .recv() + .await + .unwrap() + .fail(io::Error::from(io::ErrorKind::BrokenPipe)); + assert!(format!("{:#}", flush.await.unwrap_err()).contains("portb endpoint")); + }); + } + + #[test] + fn flush_has_a_bounded_deadline() { + block_on(async { + let (drain, _requests) = MicrovmOutputDrain::new(None); + let error = drain.flush(Duration::from_millis(10)).await.unwrap_err(); + assert!(format!("{error:#}").contains("timed out")); + }); + } + #[test] fn drain_reports_endpoint_failure() { block_on(async { diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 217eaa4520d..0b9743d8133 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -219,6 +219,20 @@ impl VmController { return self.release_snapshot_boundary_without_capture().await; } + // The source terminates once the snapshot commits, so deliver the + // console output the guest wrote before its request while the endpoint + // is still open. Bytes that miss the bound stay in the snapshot. + if let Some(drain) = &self.microvm.resources.output_drain { + let output_flush = openvmm_defs::profile::ProfileSpan::start(); + match drain.flush(self.microvm.snapshot_quiesce_timeout).await { + Ok(()) => output_flush.complete("capture", "output_flush", Default::default()), + Err(error) => tracelimit::warn_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "failed to flush microVM console output before snapshot; undelivered bytes stay in the snapshot" + ), + } + } + let response = match self .vm_rpc .call( diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index ee9a8dc059b..c7c06636095 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -12,6 +12,7 @@ use chipset_device::io::deferred::DeferredWrite; use chipset_device::io::deferred::defer_write; use chipset_device::pio::PortIoIntercept; use chipset_device::poll_device::PollDevice; +use chipset_resources::microvm::MicrovmPortbDrain; use futures::AsyncRead; use futures::AsyncWrite; use inspect::InspectMut; @@ -76,9 +77,9 @@ pub struct MicrovmPortb { #[inspect(skip)] tx_waker: Option, #[inspect(skip)] - output_drain_requests: Option>>, + output_drain_requests: Option>>, #[inspect(skip)] - output_drain: Option>, + output_drain: Option<(MicrovmPortbDrain, mesh::rpc::FailableRpc<(), ()>)>, } impl MicrovmPortb { @@ -119,15 +120,38 @@ impl MicrovmPortb { } } - /// Installs the process-exit output drain channel. + /// Installs the output drain request channel. + /// + /// A drain request writes and flushes every byte the device has accepted. + /// [`MicrovmPortbDrain::Close`] then closes the endpoint before process + /// exit, while [`MicrovmPortbDrain::Flush`] keeps it open because a + /// snapshot capture may still roll back and resume the guest. pub fn with_output_drain( mut self, - requests: Option>>, + requests: Option>>, ) -> Self { self.output_drain_requests = requests; self } + /// Completes an active flush that has no connected endpoint to write to. + /// + /// The accepted bytes stay buffered, so a snapshot saves them for the + /// restored VM, as it does for output accepted while no peer is connected. + fn complete_flush_without_endpoint(&mut self, cx: &mut Context<'_>) { + if matches!(self.output_drain, Some((MicrovmPortbDrain::Flush, _))) + && let Some((_, request)) = self.output_drain.take() + { + tracing::debug!( + buffered_bytes = self.tx_buffer.len(), + "microVM portb endpoint is not connected; keeping buffered output" + ); + request.complete(Ok(())); + // Poll again to accept the next drain request. + cx.waker().wake_by_ref(); + } + } + fn poll_rx(&mut self, cx: &mut Context<'_>) { let mut buffer = [0; 256]; loop { @@ -208,7 +232,8 @@ impl ChangeDeviceState for MicrovmPortb { } async fn stop(&mut self) { - // Drain every byte the endpoint accepts immediately. Any remaining + // Drain every byte the endpoint accepts immediately. A guest-requested + // snapshot flushes the output before stopping the device. Any remaining // VMM-owned bytes are serialized and retried against the reconstructed // endpoint after restore. let _ = self.poll_tx(&mut Context::from_waker(Waker::noop())); @@ -244,11 +269,13 @@ impl PollDevice for MicrovmPortb { { match requests.poll_recv(cx) { Poll::Ready(Ok(request)) => { + let (drain, request) = request.split(); tracing::debug!( buffered_bytes = self.tx_buffer.len(), + ?drain, "draining microVM portb output" ); - self.output_drain = Some(request); + self.output_drain = Some((drain, request)); } Poll::Ready(Err(_)) => self.output_drain_requests = None, Poll::Pending => {} @@ -262,9 +289,13 @@ impl PollDevice for MicrovmPortb { error = &error as &dyn std::error::Error, "microVM portb backend connection failed" ); + self.complete_flush_without_endpoint(cx); + return; + } + Poll::Pending => { + self.complete_flush_without_endpoint(cx); return; } - Poll::Pending => return, } } if !self.input_gated { @@ -276,14 +307,23 @@ impl PollDevice for MicrovmPortb { Poll::Ready(Ok(())) => Pin::new(&mut self.io).poll_flush(cx), output => output, }; - if let Poll::Ready(result) = output { - // Closing the endpoint publishes EOF only after every accepted - // byte has reached it. The controller also waits for the relay. - self.io = Box::new(Disconnected); - self.output_drain_requests = None; - if let Some(request) = self.output_drain.take() { - request.handle_failable_sync(|()| result); + if let Poll::Ready(result) = output + && let Some((drain, request)) = self.output_drain.take() + { + match drain { + MicrovmPortbDrain::Flush => { + // Poll again to accept the next drain request. + cx.waker().wake_by_ref(); + } + MicrovmPortbDrain::Close => { + // Closing the endpoint publishes EOF only after every + // accepted byte has reached it. The controller also + // waits for the relay. + self.io = Box::new(Disconnected); + self.output_drain_requests = None; + } } + request.handle_failable_sync(|()| result); } } } @@ -754,7 +794,8 @@ mod tests { .with_output_drain(Some(receiver)); let payload = b"OPENVMM-SNAPSHOT-RESTORE-OK\n\0\xff"; assert!(matches!(portb.io_write(DATA_PORT, payload), IoResult::Ok)); - let mut result = Box::pin(requests.call_failable(std::convert::identity, ())); + let mut result = + Box::pin(requests.call_failable(std::convert::identity, MicrovmPortbDrain::Close)); let mut cx = Context::from_waker(Waker::noop()); portb.poll_device(&mut cx); assert!(result.as_mut().now_or_never().is_none()); @@ -788,12 +829,118 @@ mod tests { ) .with_output_drain(Some(receiver)); assert!(matches!(portb.io_write(DATA_PORT, b"marker"), IoResult::Ok)); - let result = requests.call_failable(std::convert::identity, ()); + let result = requests.call_failable(std::convert::identity, MicrovmPortbDrain::Close); portb.poll_device(&mut Context::from_waker(Waker::noop())); assert!(result.now_or_never().unwrap().is_err()); assert!(state.lock().closed); } + #[test] + fn portb_snapshot_flush_delivers_output_and_keeps_endpoint_open() { + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + use std::task::Wake; + + struct FlushWake(AtomicBool); + impl Wake for FlushWake { + fn wake(self: Arc) { + self.0.store(true, Ordering::Relaxed); + } + } + + let wake = Arc::new(FlushWake(AtomicBool::new(false))); + let waker = Waker::from(wake.clone()); + let mut cx = Context::from_waker(&waker); + let state = Arc::new(Mutex::new(OutputState::default())); + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + Vec::new(), + ) + .with_output_drain(Some(receiver)); + // The guest writes a marker just before its snapshot request, while + // the endpoint does not yet accept writes. + let marker = b"OPENVMM-LINUX-MPTABLE-SNAPSHOT-READY"; + assert!(matches!(portb.io_write(DATA_PORT, marker), IoResult::Ok)); + let mut flush = + Box::pin(requests.call_failable(std::convert::identity, MicrovmPortbDrain::Flush)); + portb.poll_device(&mut cx); + assert!(flush.as_mut().now_or_never().is_none()); + + state.lock().writable = true; + portb.poll_device(&mut cx); + assert!(flush.as_mut().now_or_never().is_none()); + assert_eq!(state.lock().bytes, marker); + + state.lock().flushed = true; + wake.0.store(false, Ordering::Relaxed); + portb.poll_device(&mut cx); + flush.now_or_never().unwrap().unwrap(); + assert!(wake.0.load(Ordering::Relaxed)); + assert!(!state.lock().closed); + + // A capture that rolls back keeps delivering guest output. + assert!(matches!(portb.io_write(DATA_PORT, b"+"), IoResult::Ok)); + portb.poll_device(&mut cx); + assert_eq!( + state.lock().bytes, + [marker.as_slice(), b"+".as_slice()].concat() + ); + + // A committed capture saves no stale source output, and the source + // endpoint holds the marker when the terminating process closes it. + futures::executor::block_on(portb.stop()); + assert!(portb.save().unwrap().tx_buffer.is_empty()); + drop(portb); + assert!(state.lock().closed); + } + + #[test] + fn portb_snapshot_flush_without_endpoint_keeps_output_for_restore() { + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()) + .with_output_drain(Some(receiver)); + assert!(matches!(portb.io_write(DATA_PORT, b"marker"), IoResult::Ok)); + let flush = requests.call_failable(std::convert::identity, MicrovmPortbDrain::Flush); + portb.poll_device(&mut Context::from_waker(Waker::noop())); + flush.now_or_never().unwrap().unwrap(); + assert_eq!(portb.save().unwrap().tx_buffer, b"marker"); + } + + #[test] + fn portb_snapshot_flush_failure_keeps_endpoint_for_exit_drain() { + let state = Arc::new(Mutex::new(OutputState { + error: Some(ErrorKind::BrokenPipe), + ..Default::default() + })); + let (requests, receiver) = mesh::channel(); + let mut portb = MicrovmPortb::new( + Box::new(Connected::new(BufferedOutput(state.clone()))), + TEST_GENERATION_ID, + Vec::new(), + ) + .with_output_drain(Some(receiver)); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!(portb.io_write(DATA_PORT, b"marker"), IoResult::Ok)); + let flush = requests.call_failable(std::convert::identity, MicrovmPortbDrain::Flush); + portb.poll_device(&mut cx); + assert!(flush.now_or_never().unwrap().is_err()); + assert!(!state.lock().closed); + assert_eq!(portb.save().unwrap().tx_buffer, b"marker"); + + *state.lock() = OutputState { + writable: true, + flushed: true, + ..Default::default() + }; + let close = requests.call_failable(std::convert::identity, MicrovmPortbDrain::Close); + portb.poll_device(&mut cx); + close.now_or_never().unwrap().unwrap(); + assert_eq!(state.lock().bytes, b"marker"); + assert!(state.lock().closed); + } + #[test] fn portb_output_error_preserves_the_next_write_wakeup() { use std::sync::atomic::AtomicBool; diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs index 004daa15f40..4439ceb1a7e 100644 --- a/vm/devices/chipset_resources/src/microvm.rs +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -33,6 +33,17 @@ pub struct MicrovmSnapshotBoundaryRequest { pub transaction_complete: mesh::rpc::Rpc<(), ()>, } +/// How the microVM portb device completes an output drain request. +#[derive(Clone, Copy, Debug, PartialEq, Eq, MeshPayload)] +pub enum MicrovmPortbDrain { + /// Write and flush the accepted output, keeping the endpoint open, before + /// a snapshot capture that may still roll back. + Flush, + /// Write and flush the accepted output, then close the endpoint so a host + /// relay sees EOF, before the VM process exits. + Close, +} + /// The microVM bidirectional portb console at ports `0xe9` and `0xea`. #[derive(MeshPayload)] pub struct MicrovmPortbHandle { @@ -42,8 +53,9 @@ pub struct MicrovmPortbHandle { pub generation_id: [u8; 16], /// Fresh entropy exposed only through the private restore-input selector. pub restore_entropy: Vec, - /// Requests to drain and close output before terminating the VM process. - pub output_drain: Option>>, + /// Requests to deliver accepted output before a snapshot capture or before + /// terminating the VM process. + pub output_drain: Option>>, } impl ResourceId for MicrovmPortbHandle { From ad38a75c6bb1fee85ab762e1c4335458185aa2f4 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 02:19:25 -0700 Subject: [PATCH 168/304] virt: add the NVX time ABI v1 scaffolding Add the shared types that the time ABI backends, the CPU profiles, and the core restore path build on, with no behavior change: - virt::time_abi: stable failure codes and errors, declared rates, test hooks, the TSC rate policy and LAPIC tick rule, downtime selection and the restore TSC target, host time samples and identities, the identity CPUID leaves and CPU time bits with a capabilities view that keeps hv1 and kvm_clock false, the identity MSR handler, and the TimeAbiBackend trait. - ProtoPartitionConfig::time_abi, always None for now, and Partition::time_abi, None by default and forwarded by the microVM partition trait. - An empty virt::x86::cpu_profile module for the CPU profile model. - openvmm_defs::time_abi: the manifest time contract and CPU profile record, and the capture and restore payloads. - chipset_resources::microvm_time: the restore packet v4 and time sample codec, with golden vectors. The contract is specified in microsoft/nvx doc/design/time-abi.md. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 3 + openvmm/openvmm_core/src/partition/microvm.rs | 10 + openvmm/openvmm_core/src/worker/dispatch.rs | 2 + openvmm/openvmm_defs/src/lib.rs | 1 + openvmm/openvmm_defs/src/time_abi.rs | 110 ++++ tmk/tmk_vmm/src/host_vmm.rs | 2 + vm/devices/chipset_resources/Cargo.toml | 1 + vm/devices/chipset_resources/src/lib.rs | 1 + .../chipset_resources/src/microvm_time.rs | 510 ++++++++++++++++++ vmm_core/virt/Cargo.toml | 12 + vmm_core/virt/src/generic.rs | 12 + vmm_core/virt/src/lib.rs | 1 + vmm_core/virt/src/time_abi/backend.rs | 119 ++++ vmm_core/virt/src/time_abi/downtime.rs | 289 ++++++++++ vmm_core/virt/src/time_abi/host.rs | 378 +++++++++++++ vmm_core/virt/src/time_abi/identity.rs | 421 +++++++++++++++ vmm_core/virt/src/time_abi/mod.rs | 465 ++++++++++++++++ vmm_core/virt/src/time_abi/msr.rs | 273 ++++++++++ vmm_core/virt/src/time_abi/rate.rs | 277 ++++++++++ vmm_core/virt/src/x86/cpu_profile/mod.rs | 4 + vmm_core/virt/src/x86/mod.rs | 1 + vmm_core/virt_mshv/src/x86_64/tsc.rs | 1 + 22 files changed, 2893 insertions(+) create mode 100644 openvmm/openvmm_defs/src/time_abi.rs create mode 100644 vm/devices/chipset_resources/src/microvm_time.rs create mode 100644 vmm_core/virt/src/time_abi/backend.rs create mode 100644 vmm_core/virt/src/time_abi/downtime.rs create mode 100644 vmm_core/virt/src/time_abi/host.rs create mode 100644 vmm_core/virt/src/time_abi/identity.rs create mode 100644 vmm_core/virt/src/time_abi/mod.rs create mode 100644 vmm_core/virt/src/time_abi/msr.rs create mode 100644 vmm_core/virt/src/time_abi/rate.rs create mode 100644 vmm_core/virt/src/x86/cpu_profile/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 4b77f06dbe0..66a62dc6c20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -727,6 +727,7 @@ dependencies = [ "local_clock", "memory_range", "mesh", + "thiserror 2.0.16", "vm_resource", ] @@ -9750,6 +9751,7 @@ dependencies = [ "guestmem", "hvdef", "inspect", + "libc", "memory_range", "mesh_protobuf", "pal_event", @@ -9762,6 +9764,7 @@ dependencies = [ "tracing", "vm_topology", "vmcore", + "windows-sys 0.61.0", "x86defs", "zerocopy", ] diff --git a/openvmm/openvmm_core/src/partition/microvm.rs b/openvmm/openvmm_core/src/partition/microvm.rs index fb730dfec77..9b67644dda1 100644 --- a/openvmm/openvmm_core/src/partition/microvm.rs +++ b/openvmm/openvmm_core/src/partition/microvm.rs @@ -26,6 +26,11 @@ pub trait MicrovmPartition: Send + Sync { /// Returns the LAPIC interrupt clock frequency when available. fn apic_frequency_hz(&self) -> anyhow::Result>; + + /// Returns the NVX time ABI primitives, if the partition was built with a + /// time ABI configuration. + #[cfg(guest_arch = "x86_64")] + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend>; } impl MicrovmPartition for T @@ -54,4 +59,9 @@ where fn apic_frequency_hz(&self) -> anyhow::Result> { Ok(Partition::apic_frequency_hz(self)?) } + + #[cfg(guest_arch = "x86_64")] + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { + Partition::time_abi(self) + } } diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 9b731f57751..91375e1e96a 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1269,6 +1269,8 @@ impl InitializedVm { user_mode_memory_faults, lazy_memory_registration, versioned_cpu_contract: microvm::uses_versioned_cpu_contract(cfg.machine_profile), + #[cfg(guest_arch = "x86_64")] + time_abi: None, }) .context("failed to create the prototype partition")?; partition_prototype.complete("startup", "partition_prototype", Default::default()); diff --git a/openvmm/openvmm_defs/src/lib.rs b/openvmm/openvmm_defs/src/lib.rs index b9f34868d33..9e71205aa56 100644 --- a/openvmm/openvmm_defs/src/lib.rs +++ b/openvmm/openvmm_defs/src/lib.rs @@ -11,4 +11,5 @@ pub mod entrypoint; pub mod microvm; pub mod profile; pub mod rpc; +pub mod time_abi; pub mod worker; diff --git a/openvmm/openvmm_defs/src/time_abi.rs b/openvmm/openvmm_defs/src/time_abi.rs new file mode 100644 index 00000000000..d39be059868 --- /dev/null +++ b/openvmm/openvmm_defs/src/time_abi.rs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! NVX time ABI payloads shared by the controller and the VM worker, including +//! the snapshot manifest's time contract and CPU profile record. + +use chipset_resources::microvm_time::RestoreTimeRecord; +use mesh::MeshPayload; +use mesh::payload::Protobuf; +use virt::time_abi::HostIdentity; +use virt::time_abi::TimeAbiTestHooks; + +/// The manifest's time contract, recorded at the capture anchor. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotTimeContract { + /// The time ABI version: 1. + #[mesh(1)] + pub time_abi_version: u32, + /// `F_s`, the declared TSC rate of the snapshot lineage, in Hz. + #[mesh(2)] + pub tsc_frequency_hz: u64, + /// The TSC rate tolerance: 250. + #[mesh(3)] + pub tsc_tolerance_ppm: u32, + /// `L_s`, the LAPIC timer rate, in Hz. + #[mesh(4)] + pub apic_frequency_hz: u64, + /// `T_c`, VP 0's TSC at the capture anchor. + #[mesh(5)] + pub capture_tsc: u64, + /// Host UTC at the capture anchor, in nanoseconds since the Unix epoch. + #[mesh(6)] + pub capture_utc_ns: u64, + /// Host monotonic time at the capture anchor, in nanoseconds. + #[mesh(7)] + pub capture_monotonic_ns: u64, + /// The host monotonic clock: `linux-boottime` or + /// `windows-interrupt-time`. + #[mesh(8)] + pub host_clock: String, + /// The 16-byte host identity. + #[mesh(9)] + pub host_id: Vec, + /// The 16-byte host boot identity. + #[mesh(10)] + pub host_boot_id: Vec, + /// The generation counter of the captured VM process. + #[mesh(11)] + pub capture_generation: u32, +} + +/// The manifest's CPU profile record. +#[derive(Clone, Debug, PartialEq, Eq, Protobuf)] +#[mesh(package = "openvmm.snapshot")] +pub struct SnapshotCpuProfile { + /// The profile ID. + #[mesh(1)] + pub id: String, + /// The 32-byte profile digest. + #[mesh(2)] + pub sha256: Vec, + /// The canonical profile encoding. + #[mesh(3)] + pub profile: Vec, + /// The canonical encoding of the effective guest CPUID. + #[mesh(4)] + pub effective_cpuid: Vec, + /// The 32-byte digest of `effective_cpuid`. + #[mesh(5)] + pub effective_cpuid_sha256: Vec, + /// CPUID.1:EAX of the capture host, for diagnostics. + #[mesh(6)] + pub capture_cpu_signature: u32, +} + +/// The time ABI records the worker returns at capture. +#[derive(Debug, MeshPayload)] +pub struct TimeCapture { + /// The time contract. + pub time: SnapshotTimeContract, + /// The CPU profile record. + pub cpu_profile: SnapshotCpuProfile, +} + +/// The time ABI inputs of a restoring worker, validated by the controller. +#[derive(Debug, MeshPayload)] +pub struct RestoreTimeInput { + /// The snapshot's time contract. + pub contract: SnapshotTimeContract, + /// The snapshot's CPU profile record. + pub cpu_profile: SnapshotCpuProfile, + /// The destination host identity used by the controller's downtime + /// preflight. + pub destination: HostIdentity, + /// Receives the sealed time fields of the restore packet. + pub restore_record: mesh::OneshotSender, +} + +/// Time ABI parameters of every microVM worker. +#[derive(Debug, MeshPayload)] +pub struct TimeAbiParameters { + /// The CPU profile: a pinned profile ID or `auto`. On restore it must + /// match the snapshot's profile. + pub cpu_profile: String, + /// The generation counter of this VM process. + pub generation: u32, + /// The active test hooks. + pub hooks: TimeAbiTestHooks, +} diff --git a/tmk/tmk_vmm/src/host_vmm.rs b/tmk/tmk_vmm/src/host_vmm.rs index 226f169790b..240bc9f7bbb 100644 --- a/tmk/tmk_vmm/src/host_vmm.rs +++ b/tmk/tmk_vmm/src/host_vmm.rs @@ -50,6 +50,8 @@ impl RunContext<'_> { user_mode_memory_faults: true, lazy_memory_registration: false, versioned_cpu_contract: false, + #[cfg(guest_arch = "x86_64")] + time_abi: None, }) .context("failed to create proto partition")?; diff --git a/vm/devices/chipset_resources/Cargo.toml b/vm/devices/chipset_resources/Cargo.toml index 7659952adb0..f7c69627fba 100644 --- a/vm/devices/chipset_resources/Cargo.toml +++ b/vm/devices/chipset_resources/Cargo.toml @@ -14,6 +14,7 @@ vm_resource.workspace = true inspect.workspace = true memory_range.workspace = true mesh.workspace = true +thiserror.workspace = true arbitrary = { workspace = true, optional = true, features = ["derive"] } diff --git a/vm/devices/chipset_resources/src/lib.rs b/vm/devices/chipset_resources/src/lib.rs index 34d7009135e..bb18967b804 100644 --- a/vm/devices/chipset_resources/src/lib.rs +++ b/vm/devices/chipset_resources/src/lib.rs @@ -227,6 +227,7 @@ pub mod pit { } pub mod microvm; +pub mod microvm_time; pub mod battery { //! Resource definitions for the battery device diff --git a/vm/devices/chipset_resources/src/microvm_time.rs b/vm/devices/chipset_resources/src/microvm_time.rs new file mode 100644 index 00000000000..ae195e2ecdb --- /dev/null +++ b/vm/devices/chipset_resources/src/microvm_time.rs @@ -0,0 +1,510 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The microVM restore packet version 4 and the time sample of the NVX time +//! ABI v1, as the guest reads them through the portb device. +//! +//! Every byte the guest reads is a port exit, so the records are compact and +//! readable four bytes at a time. + +use mesh::MeshPayload; +use thiserror::Error; + +/// The restore packet magic. +pub const RESTORE_PACKET_MAGIC: [u8; 3] = *b"OVR"; +/// The restore packet version. +pub const RESTORE_PACKET_VERSION: u8 = 4; +/// The length of the restore packet header. +pub const RESTORE_PACKET_HEADER_LEN: usize = 32; +/// The length of one memory range record. +pub const RESTORE_RANGE_LEN: usize = 16; +/// The length of the restore entropy. +pub const RESTORE_ENTROPY_LEN: usize = 64; +/// The most memory ranges a packet can carry. +pub const MAX_RESTORE_RANGES: usize = u8::MAX as usize; +/// The online VP targets a packet can carry; 0 means no processor target. +pub const VALID_ONLINE_VP_COUNTS: [u8; 5] = [0, 1, 2, 4, 8]; +/// The largest rate deviation magnitude: 250 ppm scaled by 2^16. +pub const MAX_RATE_DEVIATION: i32 = 250 * 65_536; + +/// `D` came from the UTC delta. +pub const FLAG_DOWNTIME_UTC: u8 = 1 << 0; +/// An explicit RAM target was requested. +pub const FLAG_MEMORY_TARGET: u8 = 1 << 1; +/// Host input is gated until the guest acknowledges through port `0x605`. +pub const FLAG_ACK_REQUIRED: u8 = 1 << 2; +/// A test hook altered time ABI behavior. +pub const FLAG_TEST_HOOKS: u8 = 1 << 3; +const RESTORE_PACKET_FLAGS: u8 = + FLAG_DOWNTIME_UTC | FLAG_MEMORY_TARGET | FLAG_ACK_REQUIRED | FLAG_TEST_HOOKS; + +/// The portb selector of the restore packet. +pub const RESTORE_PACKET_SELECT: u8 = 0xa5; +/// The portb selector of the generation ID. +pub const GENERATION_ID_SELECT: u8 = 0xa6; +/// The portb selector that latches a time sample. +pub const TIME_SAMPLE_SELECT: u8 = 0xa7; +/// The port of the time-sample window. +pub const TIME_WINDOW_PORT: u16 = 0xeb; +/// The portb status bit advertising the time-sample window. +pub const STATUS_TIME_SAMPLE_AVAILABLE: u8 = 1 << 6; +/// The time sample version. +pub const TIME_SAMPLE_VERSION: u8 = 1; +/// The length of a time sample. +pub const TIME_SAMPLE_LEN: usize = 16; + +/// A restore packet or time sample is malformed. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum RestorePacketError { + /// The record is shorter than its header. + #[error("record is {0} bytes, shorter than its header")] + Truncated(usize), + /// The magic is wrong. + #[error("bad restore packet magic")] + Magic, + /// The version is not supported. + #[error("unsupported version {0}")] + Version(u8), + /// Reserved flag bits or bytes are set. + #[error("reserved bits are set")] + Reserved, + /// The online VP target is not 0, 1, 2, 4, or 8. + #[error("invalid online VP target {0}")] + OnlineVpCount(u8), + /// Memory ranges without a memory target, or too many ranges. + #[error("invalid memory range count {0}")] + MemoryRanges(usize), + /// The generation counter of a restore is zero. + #[error("restore generation is zero")] + Generation, + /// The rate deviation is beyond the tolerance. + #[error("rate deviation {0} is beyond the tolerance")] + RateDeviation(i32), + /// The record length does not match its counts. + #[error("record is {actual} bytes, expected {expected}")] + Length { + /// The expected length. + expected: usize, + /// The actual length. + actual: usize, + }, +} + +/// A guest RAM range in the restore packet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, MeshPayload)] +pub struct RestoreMemoryRange { + /// The first guest physical address. + pub gpa_start: u64, + /// The length in bytes. + pub length: u64, +} + +/// The restore packet fields the controller knows before the worker starts. +#[derive(Debug, Clone, PartialEq, Eq, MeshPayload)] +pub struct RestorePacketBase { + /// The online VP target: 0 for none, else 1, 2, 4, or 8. + pub online_vp_count: u8, + /// Whether an explicit RAM target was requested. + pub memory_target: bool, + /// Whether the guest must acknowledge through port `0x605`. + pub ack_required: bool, + /// The generation counter `g` of the restored process, at least 1. + pub generation: u32, + /// The RAM ranges to online, only with a memory target. + pub ranges: Vec, + /// The restore entropy; its first 16 bytes are the generation ID. + pub entropy: [u8; RESTORE_ENTROPY_LEN], +} + +impl RestorePacketBase { + /// Checks the fields the packet encoding constrains. + pub fn validate(&self) -> Result<(), RestorePacketError> { + if !VALID_ONLINE_VP_COUNTS.contains(&self.online_vp_count) { + return Err(RestorePacketError::OnlineVpCount(self.online_vp_count)); + } + if self.ranges.len() > MAX_RESTORE_RANGES + || (!self.memory_target && !self.ranges.is_empty()) + { + return Err(RestorePacketError::MemoryRanges(self.ranges.len())); + } + if self.generation == 0 { + return Err(RestorePacketError::Generation); + } + Ok(()) + } + + /// Returns the encoded length of the packet. + pub fn encoded_len(&self) -> usize { + RESTORE_PACKET_HEADER_LEN + RESTORE_RANGE_LEN * self.ranges.len() + RESTORE_ENTROPY_LEN + } +} + +/// The time fields of the restore packet, sealed by the worker before the +/// first restored VP runs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, MeshPayload)] +pub struct RestoreTimeRecord { + /// The downtime `D`, in nanoseconds. + pub downtime_ns: u64, + /// Whether `D` came from the UTC delta. + pub downtime_utc: bool, + /// `(F_d - F_s) / F_s` in ppm scaled by 2^16. + pub rate_deviation: i32, + /// Whether a test hook is active. + pub test_hooks: bool, +} + +/// A complete restore packet version 4. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RestorePacketV4 { + /// The controller's fields. + pub base: RestorePacketBase, + /// The worker's sealed time fields. + pub time: RestoreTimeRecord, + /// Host UTC when the guest first selected the packet, in nanoseconds + /// since the Unix epoch. + pub utc_ns: u64, +} + +impl RestorePacketV4 { + /// Encodes the packet. + pub fn encode(&self) -> Result, RestorePacketError> { + self.base.validate()?; + if self.time.rate_deviation.unsigned_abs() > MAX_RATE_DEVIATION.unsigned_abs() { + return Err(RestorePacketError::RateDeviation(self.time.rate_deviation)); + } + let mut flags = 0; + for (set, flag) in [ + (self.time.downtime_utc, FLAG_DOWNTIME_UTC), + (self.base.memory_target, FLAG_MEMORY_TARGET), + (self.base.ack_required, FLAG_ACK_REQUIRED), + (self.time.test_hooks, FLAG_TEST_HOOKS), + ] { + if set { + flags |= flag; + } + } + let mut bytes = Vec::with_capacity(self.base.encoded_len()); + bytes.extend_from_slice(&RESTORE_PACKET_MAGIC); + bytes.extend_from_slice(&[ + RESTORE_PACKET_VERSION, + flags, + self.base.online_vp_count, + self.base.ranges.len() as u8, + 0, + ]); + bytes.extend_from_slice(&self.base.generation.to_le_bytes()); + bytes.extend_from_slice(&self.time.rate_deviation.to_le_bytes()); + bytes.extend_from_slice(&self.time.downtime_ns.to_le_bytes()); + bytes.extend_from_slice(&self.utc_ns.to_le_bytes()); + for range in &self.base.ranges { + bytes.extend_from_slice(&range.gpa_start.to_le_bytes()); + bytes.extend_from_slice(&range.length.to_le_bytes()); + } + bytes.extend_from_slice(&self.base.entropy); + debug_assert_eq!(bytes.len(), self.base.encoded_len()); + Ok(bytes) + } + + /// Decodes and validates a packet. + pub fn decode(bytes: &[u8]) -> Result { + let header: &[u8; RESTORE_PACKET_HEADER_LEN] = bytes + .get(..RESTORE_PACKET_HEADER_LEN) + .and_then(|header| header.try_into().ok()) + .ok_or(RestorePacketError::Truncated(bytes.len()))?; + if header[..3] != RESTORE_PACKET_MAGIC { + return Err(RestorePacketError::Magic); + } + if header[3] != RESTORE_PACKET_VERSION { + return Err(RestorePacketError::Version(header[3])); + } + let flags = header[4]; + if flags & !RESTORE_PACKET_FLAGS != 0 || header[7] != 0 { + return Err(RestorePacketError::Reserved); + } + let range_count = usize::from(header[6]); + let expected = + RESTORE_PACKET_HEADER_LEN + RESTORE_RANGE_LEN * range_count + RESTORE_ENTROPY_LEN; + if bytes.len() != expected { + return Err(RestorePacketError::Length { + expected, + actual: bytes.len(), + }); + } + let u32_at = + |offset: usize| u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()); + let u64_at = + |offset: usize| u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()); + let ranges = (0..range_count) + .map(|i| { + let offset = RESTORE_PACKET_HEADER_LEN + RESTORE_RANGE_LEN * i; + RestoreMemoryRange { + gpa_start: u64_at(offset), + length: u64_at(offset + 8), + } + }) + .collect(); + let entropy_offset = expected - RESTORE_ENTROPY_LEN; + let packet = Self { + base: RestorePacketBase { + online_vp_count: header[5], + memory_target: flags & FLAG_MEMORY_TARGET != 0, + ack_required: flags & FLAG_ACK_REQUIRED != 0, + generation: u32_at(8), + ranges, + entropy: bytes[entropy_offset..].try_into().unwrap(), + }, + time: RestoreTimeRecord { + downtime_ns: u64_at(16), + downtime_utc: flags & FLAG_DOWNTIME_UTC != 0, + rate_deviation: u32_at(12) as i32, + test_hooks: flags & FLAG_TEST_HOOKS != 0, + }, + utc_ns: u64_at(24), + }; + packet.base.validate()?; + if packet.time.rate_deviation.unsigned_abs() > MAX_RATE_DEVIATION.unsigned_abs() { + return Err(RestorePacketError::RateDeviation( + packet.time.rate_deviation, + )); + } + Ok(packet) + } +} + +/// A time sample, latched by selector [`TIME_SAMPLE_SELECT`] and read from +/// port [`TIME_WINDOW_PORT`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TimeSample { + /// Whether a test hook is active. + pub test_hooks: bool, + /// The generation counter `g` of this VM process. + pub generation: u32, + /// Host UTC at the latch, in nanoseconds since the Unix epoch. + pub utc_ns: u64, +} + +impl TimeSample { + /// Encodes the sample. + pub fn encode(&self) -> [u8; TIME_SAMPLE_LEN] { + let mut bytes = [0; TIME_SAMPLE_LEN]; + bytes[0] = TIME_SAMPLE_VERSION; + bytes[1] = if self.test_hooks { FLAG_TEST_HOOKS } else { 0 }; + bytes[4..8].copy_from_slice(&self.generation.to_le_bytes()); + bytes[8..16].copy_from_slice(&self.utc_ns.to_le_bytes()); + bytes + } + + /// Decodes and validates a sample. + pub fn decode(bytes: &[u8]) -> Result { + let bytes: &[u8; TIME_SAMPLE_LEN] = + bytes.try_into().map_err(|_| RestorePacketError::Length { + expected: TIME_SAMPLE_LEN, + actual: bytes.len(), + })?; + if bytes[0] != TIME_SAMPLE_VERSION { + return Err(RestorePacketError::Version(bytes[0])); + } + if bytes[1] & !FLAG_TEST_HOOKS != 0 || bytes[2..4] != [0, 0] { + return Err(RestorePacketError::Reserved); + } + Ok(Self { + test_hooks: bytes[1] & FLAG_TEST_HOOKS != 0, + generation: u32::from_le_bytes(bytes[4..8].try_into().unwrap()), + utc_ns: u64::from_le_bytes(bytes[8..16].try_into().unwrap()), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entropy() -> [u8; RESTORE_ENTROPY_LEN] { + std::array::from_fn(|i| i as u8) + } + + fn packet_without_ranges() -> RestorePacketV4 { + RestorePacketV4 { + base: RestorePacketBase { + online_vp_count: 2, + memory_target: false, + ack_required: true, + generation: 7, + ranges: Vec::new(), + entropy: entropy(), + }, + time: RestoreTimeRecord { + downtime_ns: 0x0102_0304_0506_0708, + downtime_utc: true, + rate_deviation: -2, + test_hooks: false, + }, + utc_ns: 0x1122_3344_5566_7788, + } + } + + fn packet_with_ranges() -> RestorePacketV4 { + RestorePacketV4 { + base: RestorePacketBase { + online_vp_count: 0, + memory_target: true, + ack_required: false, + generation: 1, + ranges: vec![ + RestoreMemoryRange { + gpa_start: 0x1_0000_0000, + length: 0x800_0000, + }, + RestoreMemoryRange { + gpa_start: 0x2_0000_0000, + length: 0x1000_0000, + }, + ], + entropy: entropy(), + }, + time: RestoreTimeRecord { + downtime_ns: 30_000_000_000, + downtime_utc: false, + rate_deviation: MAX_RATE_DEVIATION, + test_hooks: true, + }, + utc_ns: 1_700_000_000_000_000_000, + } + } + + #[test] + fn golden_packet_without_ranges() { + let mut expected = vec![ + b'O', b'V', b'R', 4, 0x05, 2, 0, 0, // header + 7, 0, 0, 0, // generation + 0xfe, 0xff, 0xff, 0xff, // rate deviation + 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // downtime + 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // UTC + ]; + expected.extend(entropy()); + let packet = packet_without_ranges(); + let bytes = packet.encode().unwrap(); + assert_eq!(bytes.len(), 96); + assert_eq!(bytes, expected); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap(), packet); + } + + #[test] + fn golden_packet_with_ranges() { + let mut expected = vec![ + b'O', b'V', b'R', 4, 0x0a, 0, 2, 0, // header + 1, 0, 0, 0, // generation + 0x00, 0x00, 0xfa, 0x00, // rate deviation 250 * 2^16 + 0x00, 0xac, 0x23, 0xfc, 0x06, 0x00, 0x00, 0x00, // downtime 30 s + 0x00, 0x00, 0x2a, 0x36, 0xfe, 0x9c, 0x97, 0x17, // UTC + 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, // range 0 start + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, // range 0 length + 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, // range 1 start + 0x00, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, // range 1 length + ]; + expected.extend(entropy()); + let packet = packet_with_ranges(); + let bytes = packet.encode().unwrap(); + assert_eq!(bytes.len(), 128); + assert_eq!(bytes, expected); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap(), packet); + } + + #[test] + fn decode_rejects_malformed_packets() { + let good = packet_with_ranges().encode().unwrap(); + let mutate = |offset: usize, value: u8| { + let mut bytes = good.clone(); + bytes[offset] = value; + RestorePacketV4::decode(&bytes).unwrap_err() + }; + assert_eq!(mutate(0, b'X'), RestorePacketError::Magic); + assert_eq!(mutate(3, 3), RestorePacketError::Version(3)); + assert_eq!(mutate(4, 0x1a), RestorePacketError::Reserved); + assert_eq!(mutate(7, 1), RestorePacketError::Reserved); + assert_eq!(mutate(5, 3), RestorePacketError::OnlineVpCount(3)); + assert_eq!(mutate(4, 0x08), RestorePacketError::MemoryRanges(2)); + assert!(matches!(mutate(6, 3), RestorePacketError::Length { .. })); + assert_eq!(mutate(8, 0), RestorePacketError::Generation); + assert!(matches!( + mutate(14, 0xfb), + RestorePacketError::RateDeviation(_) + )); + assert_eq!( + RestorePacketV4::decode(&good[..31]).unwrap_err(), + RestorePacketError::Truncated(31) + ); + assert!(matches!( + RestorePacketV4::decode(&good[..good.len() - 1]).unwrap_err(), + RestorePacketError::Length { .. } + )); + } + + #[test] + fn encode_rejects_invalid_fields() { + let mut packet = packet_without_ranges(); + packet.base.online_vp_count = 3; + assert_eq!( + packet.encode().unwrap_err(), + RestorePacketError::OnlineVpCount(3) + ); + + let mut packet = packet_with_ranges(); + packet.base.memory_target = false; + assert_eq!( + packet.encode().unwrap_err(), + RestorePacketError::MemoryRanges(2) + ); + + let mut packet = packet_without_ranges(); + packet.base.generation = 0; + assert_eq!(packet.encode().unwrap_err(), RestorePacketError::Generation); + + let mut packet = packet_without_ranges(); + packet.time.rate_deviation = -MAX_RATE_DEVIATION - 1; + assert!(matches!( + packet.encode().unwrap_err(), + RestorePacketError::RateDeviation(_) + )); + } + + #[test] + fn golden_time_sample() { + let sample = TimeSample { + test_hooks: true, + generation: 3, + utc_ns: 0x0102_0304_0506_0708, + }; + let bytes = sample.encode(); + assert_eq!( + bytes, + [ + 1, 0x08, 0, 0, 3, 0, 0, 0, 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01 + ] + ); + assert_eq!(TimeSample::decode(&bytes).unwrap(), sample); + + let mut bad = bytes; + bad[0] = 2; + assert_eq!( + TimeSample::decode(&bad).unwrap_err(), + RestorePacketError::Version(2) + ); + let mut bad = bytes; + bad[1] = 0x01; + assert_eq!( + TimeSample::decode(&bad).unwrap_err(), + RestorePacketError::Reserved + ); + let mut bad = bytes; + bad[3] = 1; + assert_eq!( + TimeSample::decode(&bad).unwrap_err(), + RestorePacketError::Reserved + ); + assert!(matches!( + TimeSample::decode(&bytes[..15]).unwrap_err(), + RestorePacketError::Length { .. } + )); + } +} diff --git a/vmm_core/virt/Cargo.toml b/vmm_core/virt/Cargo.toml index 7368544fb6d..992a89db7cc 100644 --- a/vmm_core/virt/Cargo.toml +++ b/vmm_core/virt/Cargo.toml @@ -27,6 +27,18 @@ slab.workspace = true thiserror.workspace = true tracing.workspace = true zerocopy.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +libc.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_System_Registry", + "Win32_System_SystemInformation", + "Win32_System_WindowsProgramming", +] } + [dev-dependencies] test_with_tracing.workspace = true diff --git a/vmm_core/virt/src/generic.rs b/vmm_core/virt/src/generic.rs index 2f777844b3e..8ce86d8117b 100644 --- a/vmm_core/virt/src/generic.rs +++ b/vmm_core/virt/src/generic.rs @@ -364,6 +364,11 @@ pub struct ProtoPartitionConfig<'a> { pub lazy_memory_registration: bool, /// Build the reproducible CPU/clock contract used by a versioned machine profile. pub versioned_cpu_contract: bool, + /// The NVX time ABI configuration. A backend that implements the time ABI + /// returns `Some` from [`Partition::time_abi`] for such a partition; the + /// worker rejects one that does not. + #[cfg(guest_arch = "x86_64")] + pub time_abi: Option, } /// Partition creation configuration. @@ -563,6 +568,13 @@ pub trait Partition: 'static + Hv1 + Inspect + Send + Sync { Ok(None) } + /// Returns the NVX time ABI primitives, if the partition was built with + /// [`ProtoPartitionConfig::time_abi`]. + #[cfg(guest_arch = "x86_64")] + fn time_abi(&self) -> Option<&dyn crate::time_abi::TimeAbiBackend> { + None + } + /// Returns a trait object for initial page imports during the initial start /// flow. fn supports_initial_page_acceptance( diff --git a/vmm_core/virt/src/lib.rs b/vmm_core/virt/src/lib.rs index 81d2d623d04..148fb6295c7 100644 --- a/vmm_core/virt/src/lib.rs +++ b/vmm_core/virt/src/lib.rs @@ -11,6 +11,7 @@ pub mod irqcon; pub use vmcore::irqfd; pub mod state; pub mod synic; +pub mod time_abi; pub mod x86; pub use arch::*; diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs new file mode 100644 index 00000000000..662ee411288 --- /dev/null +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The time ABI primitives a backend provides. + +use super::HostTimeSample; +use super::TimeAbiError; +use crate::CpuidLeaf; +use vm_topology::processor::VpIndex; + +/// Time ABI primitives of a backend partition, returned by +/// [`Partition::time_abi`](crate::Partition::time_abi) when the partition +/// was built with [`TimeAbiConfig`](super::TimeAbiConfig). +/// +/// Every method is called with all VPs stopped. No method falls back to +/// another mechanism: a backend that cannot meet an obligation returns the +/// failure code the specification names. +pub trait TimeAbiBackend: Send + Sync { + /// Returns the native guest TSC rate `F_d`, in Hz. The guest TSC is never + /// scaled. Fails with `E_TSC_RATE_UNAVAILABLE`. + fn native_tsc_hz(&self) -> Result; + + /// Returns the LAPIC timer rate, in Hz: the backend constant. Fails with + /// `E_LAPIC_RATE_UNAVAILABLE`. + fn lapic_hz(&self) -> Result; + + /// Verifies, once after the partition is built, that identity routing is + /// installed, that the synchronized TSC set is available, and that the + /// guest TSC is not scaled. Fails with `E_IDENTITY_ROUTING`, + /// `E_TSC_SYNC_UNSUPPORTED`, or `E_TSC_SCALING_ACTIVE`. + fn preflight(&self) -> Result; + + /// Returns the CPUID the backend programmed for VP 0, for the + /// effective-CPUID record and check. Fails with `E_CPU_SURFACE`. + fn effective_cpuid(&self) -> Result, TimeAbiError>; + + /// Takes the capture anchor: VP 0's TSC paired with + /// [`sample_host_time`](super::host::sample_host_time). Fails with + /// `E_TSC_ANCHOR` if the pair cannot be formed within the bound. + fn capture_anchor(&self) -> Result; + + /// Performs the synchronized TSC set. + /// + /// The backend takes the restore anchor, one host instant, and calls + /// `target` with the host sample of that instant. The orchestrator + /// selects the downtime there and returns the TSC target, or fails. The + /// backend then makes every instantiated VP hold that target as of the + /// anchor and verifies it by read-back before returning + /// (`E_TSC_SYNC_READBACK`). Creating a VP afterwards is an error + /// (`E_VP_LATE_CREATION`). + fn set_synchronized_tsc( + &self, + target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result; + + /// Releases partition time immediately before the restored VPs first run. + /// + /// A backend whose synchronized set leaves partition time frozen resumes + /// it here, unless the first VP run resumes it. The default does + /// nothing. + fn release_time(&self) -> Result<(), TimeAbiError> { + Ok(()) + } +} + +/// How a backend delivers the identity MSR range to [`super::TimeAbiMsrs`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IdentityMsrRoute { + /// Every access in the range exits to OpenVMM. + ExitToVmm, + /// The hypervisor serves the range natively with values verified equal + /// to the time ABI's (TBD(mshv)). + NativeVerified, +} + +/// How a backend performs the synchronized TSC set. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TscSyncMethod { + /// One TSC offset for every VP, computed from one host TSC read (KVM). + CommonOffset, + /// Partition time is frozen, the target is written to every VP and read + /// back while frozen, and time resumes at release (MSHV, WHP). + FrozenWrite, +} + +/// The result of [`TimeAbiBackend::preflight`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BackendPreflight { + /// The identity MSR route. + pub msr_route: IdentityMsrRoute, + /// The synchronized TSC set method. + pub sync: TscSyncMethod, +} + +/// The capture anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TscAnchor { + /// `T_c`, VP 0's TSC. + pub tsc: u64, + /// The host sample paired with `tsc`. + pub sample: HostTimeSample, + /// The pairing uncertainty: the most the host instant of `sample` can + /// differ from the instant `tsc` was read, in nanoseconds. + pub pairing_ns: u64, +} + +/// The result of [`TimeAbiBackend::set_synchronized_tsc`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TscSetReport { + /// The TSC target every VP holds as of the anchor. + pub target: u64, + /// The host sample of the restore anchor. + pub sample: HostTimeSample, + /// The read-back evidence, one entry per instantiated VP: the TSC value, + /// or the offset for [`TscSyncMethod::CommonOffset`]. + pub readback: Vec<(VpIndex, u64)>, + /// The method used. + pub method: TscSyncMethod, +} diff --git a/vmm_core/virt/src/time_abi/downtime.rs b/vmm_core/virt/src/time_abi/downtime.rs new file mode 100644 index 00000000000..ba6e9ed5096 --- /dev/null +++ b/vmm_core/virt/src/time_abi/downtime.rs @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Downtime selection and the restore TSC target. + +use super::HostIdentity; +use super::HostTimeSample; +use super::TimeAbiCode; +use super::TimeAbiError; +use super::TimeAbiTestHooks; + +/// The longest accepted downtime: 30 days. +pub const MAX_DOWNTIME_NS: u64 = 30 * 24 * 60 * 60 * 1_000_000_000; +/// The smallest disagreement between the UTC delta and a monotonic downtime +/// that is reported as a host wall-clock step. +pub const HOST_WALL_CLOCK_STEP_NS: u64 = 1_000_000_000; + +/// What capture recorded at the capture anchor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CaptureTimeRecord { + /// `T_c`, the guest TSC of VP 0. + pub tsc: u64, + /// The host clocks. + pub sample: HostTimeSample, + /// The host and boot identities. + pub identity: HostIdentity, +} + +/// The source of a downtime measurement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DowntimeSource { + /// Host monotonic time on the same host boot. + HostMonotonic, + /// The host UTC delta. + Utc, +} + +/// A selected downtime `D`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Downtime { + /// `D`, in nanoseconds. + pub nanos: u64, + /// The source `D` came from. + pub source: DowntimeSource, + /// On the monotonic path, the UTC delta minus the monotonic delta when + /// it exceeds [`HOST_WALL_CLOCK_STEP_NS`] in magnitude: the host + /// wall clock was stepped. It is logged and does not change `D`. + pub host_wall_clock_step_ns: Option, +} + +/// Selects the downtime source and measures `D` from the capture record to +/// the destination sample `now`. +/// +/// Host monotonic time is used iff the host identity, boot identity, and +/// clock kind all match and no test hook forces UTC; otherwise the UTC delta +/// is used. Test hooks apply as specified: `utc_offset_ms` to the destination +/// UTC reading and `downtime_add_s` to `D` before the bounds check. +pub fn select_downtime( + capture: &CaptureTimeRecord, + destination: &HostIdentity, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks, +) -> Result { + let utc_now = i128::from(now.utc_ns) + i128::from(hooks.utc_offset_ms) * 1_000_000; + let utc_delta = utc_now - i128::from(capture.sample.utc_ns); + let same_boot = capture.identity.host_id == destination.host_id + && capture.identity.boot_id == destination.boot_id + && capture.identity.clock == destination.clock + && !hooks.boot_id_mismatch; + + let (source, measured, host_wall_clock_step_ns) = if same_boot && !hooks.force_utc_downtime { + let delta = i128::from(now.monotonic_ns) - i128::from(capture.sample.monotonic_ns); + let step = utc_delta - delta; + let step = (step.unsigned_abs() > u128::from(HOST_WALL_CLOCK_STEP_NS)) + .then(|| i64::try_from(step).unwrap_or(if step < 0 { i64::MIN } else { i64::MAX })); + (DowntimeSource::HostMonotonic, delta, step) + } else { + (DowntimeSource::Utc, utc_delta, None) + }; + + let downtime = measured + i128::from(hooks.downtime_add_s) * 1_000_000_000; + if downtime < 0 { + return Err(TimeAbiError::new( + TimeAbiCode::DowntimeNegative, + format!("downtime from the {source:?} source is negative: {downtime} ns"), + )); + } + if downtime > i128::from(MAX_DOWNTIME_NS) { + return Err(TimeAbiError::new( + TimeAbiCode::DowntimeExcessive, + format!( + "downtime from the {source:?} source is {downtime} ns, above {MAX_DOWNTIME_NS} ns" + ), + )); + } + Ok(Downtime { + nanos: downtime as u64, + source, + host_wall_clock_step_ns, + }) +} + +/// Returns the restore TSC target `T_c + floor(D * F_s / 1_000_000_000)`. +pub fn tsc_target(capture_tsc: u64, downtime_ns: u64, tsc_hz: u64) -> Result { + let cycles = u128::from(downtime_ns) * u128::from(tsc_hz) / 1_000_000_000; + u64::try_from(u128::from(capture_tsc) + cycles).map_err(|_| { + TimeAbiError::new( + TimeAbiCode::TscTargetOverflow, + format!( + "TSC target from {capture_tsc} plus {downtime_ns} ns at {tsc_hz} Hz exceeds 64 bits" + ), + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time_abi::HostClockKind; + + const SECOND: u64 = 1_000_000_000; + + fn identity(host: u8, boot: u8) -> HostIdentity { + HostIdentity { + host_id: [host; 16], + boot_id: [boot; 16], + clock: HostClockKind::LinuxBoottime, + } + } + + fn capture() -> CaptureTimeRecord { + CaptureTimeRecord { + tsc: 1_000, + sample: HostTimeSample { + utc_ns: 1_700_000_000 * SECOND, + monotonic_ns: 100 * SECOND, + }, + identity: identity(1, 1), + } + } + + fn after(seconds_utc: u64, seconds_monotonic: u64) -> HostTimeSample { + let capture = capture(); + HostTimeSample { + utc_ns: capture.sample.utc_ns + seconds_utc * SECOND, + monotonic_ns: capture.sample.monotonic_ns + seconds_monotonic * SECOND, + } + } + + fn select( + destination: &HostIdentity, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks, + ) -> Result { + select_downtime(&capture(), destination, now, hooks) + } + + #[test] + fn same_boot_uses_host_monotonic_time() { + let downtime = select(&identity(1, 1), &after(7, 5), &Default::default()).unwrap(); + assert_eq!( + downtime, + Downtime { + nanos: 5 * SECOND, + source: DowntimeSource::HostMonotonic, + host_wall_clock_step_ns: Some(2 * SECOND as i64), + } + ); + let downtime = select(&identity(1, 1), &after(6, 5), &Default::default()).unwrap(); + assert_eq!(downtime.host_wall_clock_step_ns, None); + } + + #[test] + fn any_identity_difference_uses_utc() { + let mut other_clock = identity(1, 1); + other_clock.clock = HostClockKind::WindowsInterruptTime; + for destination in [identity(2, 1), identity(1, 2), other_clock] { + let downtime = select(&destination, &after(7, 0), &Default::default()).unwrap(); + assert_eq!(downtime.source, DowntimeSource::Utc); + assert_eq!(downtime.nanos, 7 * SECOND); + assert_eq!(downtime.host_wall_clock_step_ns, None); + } + } + + #[test] + fn hooks_force_utc() { + for hooks in [ + TimeAbiTestHooks { + force_utc_downtime: true, + ..Default::default() + }, + TimeAbiTestHooks { + boot_id_mismatch: true, + ..Default::default() + }, + ] { + let downtime = select(&identity(1, 1), &after(7, 5), &hooks).unwrap(); + assert_eq!(downtime.source, DowntimeSource::Utc); + assert_eq!(downtime.nanos, 7 * SECOND); + } + } + + #[test] + fn bounds() { + let max_seconds = MAX_DOWNTIME_NS / SECOND; + let downtime = + select(&identity(2, 2), &after(max_seconds, 0), &Default::default()).unwrap(); + assert_eq!(downtime.nanos, MAX_DOWNTIME_NS); + assert_eq!( + select( + &identity(2, 2), + &after(max_seconds + 1, 0), + &Default::default() + ) + .unwrap_err() + .code, + TimeAbiCode::DowntimeExcessive + ); + let hooks = TimeAbiTestHooks { + downtime_add_s: 2_592_001, + ..Default::default() + }; + assert_eq!( + select(&identity(1, 1), &after(0, 0), &hooks) + .unwrap_err() + .code, + TimeAbiCode::DowntimeExcessive + ); + } + + #[test] + fn negative_downtime_is_rejected() { + let mut now = after(0, 0); + now.utc_ns -= 1; + assert_eq!( + select(&identity(2, 2), &now, &Default::default()) + .unwrap_err() + .code, + TimeAbiCode::DowntimeNegative + ); + let mut now = after(0, 0); + now.monotonic_ns -= 1; + assert_eq!( + select(&identity(1, 1), &now, &Default::default()) + .unwrap_err() + .code, + TimeAbiCode::DowntimeNegative + ); + let hooks = TimeAbiTestHooks { + force_utc_downtime: true, + utc_offset_ms: -10_000, + ..Default::default() + }; + assert_eq!( + select(&identity(1, 1), &after(5, 5), &hooks) + .unwrap_err() + .code, + TimeAbiCode::DowntimeNegative + ); + } + + #[test] + fn utc_offset_hook_applies_to_utc() { + let hooks = TimeAbiTestHooks { + utc_offset_ms: 1_500, + ..Default::default() + }; + let downtime = select(&identity(2, 2), &after(5, 0), &hooks).unwrap(); + assert_eq!(downtime.nanos, 6_500_000_000); + } + + #[test] + fn target() { + assert_eq!( + tsc_target(1_000, SECOND, 2_000_000_000).unwrap(), + 2_000_001_000 + ); + // Floors partial cycles: 1 ns at 2.5 GHz is 2.5 cycles. + assert_eq!(tsc_target(0, 1, 2_500_000_000).unwrap(), 2); + assert_eq!( + tsc_target(u64::MAX - 1, 1, 2_000_000_000).unwrap_err().code, + TimeAbiCode::TscTargetOverflow + ); + assert_eq!( + tsc_target(u64::MAX - 2, 1, 2_000_000_000).unwrap(), + u64::MAX + ); + } +} diff --git a/vmm_core/virt/src/time_abi/host.rs b/vmm_core/virt/src/time_abi/host.rs new file mode 100644 index 00000000000..a6f990a3a10 --- /dev/null +++ b/vmm_core/virt/src/time_abi/host.rs @@ -0,0 +1,378 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host time samples and host identities. + +// UNSAFETY: Calling host clock and Windows registry APIs. +#![cfg_attr(any(target_os = "linux", windows), expect(unsafe_code))] + +use super::TimeAbiCode; +use super::TimeAbiError; +use mesh_protobuf::Protobuf; + +/// The host monotonic clock a sample was taken from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Protobuf)] +#[mesh(package = "virt.time_abi")] +pub enum HostClockKind { + /// Linux `CLOCK_BOOTTIME`. + #[mesh(1)] + LinuxBoottime, + /// Windows `QueryInterruptTimePrecise`, in nanoseconds. + #[mesh(2)] + WindowsInterruptTime, +} + +impl HostClockKind { + /// Returns the manifest spelling of the clock kind. + pub const fn as_str(self) -> &'static str { + match self { + Self::LinuxBoottime => "linux-boottime", + Self::WindowsInterruptTime => "windows-interrupt-time", + } + } + + /// Parses the manifest spelling of a clock kind. + pub fn from_manifest(value: &str) -> Option { + match value { + "linux-boottime" => Some(Self::LinuxBoottime), + "windows-interrupt-time" => Some(Self::WindowsInterruptTime), + _ => None, + } + } +} + +/// The identity of a host and of its current boot. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Protobuf)] +#[mesh(package = "virt.time_abi")] +pub struct HostIdentity { + /// The host identity: `/etc/machine-id` on Linux, `MachineGuid` on + /// Windows. + #[mesh(1)] + pub host_id: [u8; 16], + /// The boot identity: `/proc/sys/kernel/random/boot_id` on Linux, + /// `PrefetchParameters\BootId` (zero-extended) on Windows. + #[mesh(2)] + pub boot_id: [u8; 16], + /// The host monotonic clock. + #[mesh(3)] + pub clock: HostClockKind, +} + +/// A pair of host clock readings taken back to back. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct HostTimeSample { + /// Host UTC, in nanoseconds since the Unix epoch. + pub utc_ns: u64, + /// Host monotonic time, in nanoseconds, from the clock of + /// [`HostIdentity::clock`]. + pub monotonic_ns: u64, +} + +/// Samples host UTC and host monotonic time back to back. +/// +/// The monotonic clock is read on both sides of the UTC read and the midpoint +/// is returned, so the pair is consistent to within half the read time. +pub fn sample_host_time() -> Result { + let monotonic_before = sys::monotonic_ns()?; + let utc_ns = sys::utc_ns()?; + let monotonic_after = sys::monotonic_ns()?; + if monotonic_after < monotonic_before { + return Err(host_error("the host monotonic clock went backwards")); + } + Ok(HostTimeSample { + utc_ns, + monotonic_ns: monotonic_before + (monotonic_after - monotonic_before) / 2, + }) +} + +/// Returns the identity of this host and of its current boot. +pub fn host_identity() -> Result { + sys::host_identity() +} + +fn host_error(message: impl Into) -> TimeAbiError { + TimeAbiError::new(TimeAbiCode::HostIdentity, message) +} + +/// Parses 32 hexadecimal digits, ignoring dashes and a trailing newline, as +/// in `/etc/machine-id`, the Linux boot ID, and the Windows machine GUID. +fn parse_hex_id(text: &str) -> Option<[u8; 16]> { + let digits: Vec = text + .trim() + .trim_start_matches('{') + .trim_end_matches('}') + .bytes() + .filter(|&b| b != b'-') + .collect(); + if digits.len() != 32 { + return None; + } + let mut id = [0; 16]; + for (i, byte) in id.iter_mut().enumerate() { + let pair = std::str::from_utf8(&digits[2 * i..2 * i + 2]).ok()?; + *byte = u8::from_str_radix(pair, 16).ok()?; + } + Some(id) +} + +/// Returns a 16-byte identity holding `value` little-endian, zero-extended. +#[cfg_attr(not(any(windows, test)), expect(dead_code))] +fn zero_extended_id(value: u32) -> [u8; 16] { + let mut id = [0; 16]; + id[..4].copy_from_slice(&value.to_le_bytes()); + id +} + +#[cfg(target_os = "linux")] +mod sys { + use super::HostClockKind; + use super::HostIdentity; + use super::host_error; + use super::parse_hex_id; + use crate::time_abi::TimeAbiError; + + fn clock_ns(clock: libc::clockid_t, name: &str) -> Result { + let mut ts = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + // SAFETY: `ts` is a valid, writable timespec for the duration of the + // call. + let r = unsafe { libc::clock_gettime(clock, &mut ts) }; + if r != 0 { + return Err(host_error(format!( + "cannot read {name}: {}", + std::io::Error::last_os_error() + ))); + } + let secs = + u64::try_from(ts.tv_sec).map_err(|_| host_error(format!("{name} is negative")))?; + let nanos = + u64::try_from(ts.tv_nsec).map_err(|_| host_error(format!("{name} is malformed")))?; + secs.checked_mul(1_000_000_000) + .and_then(|ns| ns.checked_add(nanos)) + .ok_or_else(|| host_error(format!("{name} overflows"))) + } + + pub(super) fn utc_ns() -> Result { + clock_ns(libc::CLOCK_REALTIME, "CLOCK_REALTIME") + } + + pub(super) fn monotonic_ns() -> Result { + clock_ns(libc::CLOCK_BOOTTIME, "CLOCK_BOOTTIME") + } + + fn read_id(path: &str) -> Result<[u8; 16], TimeAbiError> { + let text = std::fs::read_to_string(path) + .map_err(|err| host_error(format!("cannot read {path}: {err}")))?; + parse_hex_id(&text).ok_or_else(|| host_error(format!("{path} is malformed"))) + } + + pub(super) fn host_identity() -> Result { + Ok(HostIdentity { + host_id: read_id("/etc/machine-id")?, + boot_id: read_id("/proc/sys/kernel/random/boot_id")?, + clock: HostClockKind::LinuxBoottime, + }) + } +} + +#[cfg(windows)] +mod sys { + use super::HostClockKind; + use super::HostIdentity; + use super::host_error; + use super::parse_hex_id; + use super::zero_extended_id; + use crate::time_abi::TimeAbiError; + use windows_sys::Win32::Foundation::ERROR_SUCCESS; + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE; + use windows_sys::Win32::System::Registry::RRF_RT_REG_DWORD; + use windows_sys::Win32::System::Registry::RRF_RT_REG_SZ; + use windows_sys::Win32::System::Registry::RegGetValueW; + use windows_sys::Win32::System::SystemInformation::GetSystemTimePreciseAsFileTime; + use windows_sys::Win32::System::WindowsProgramming::QueryInterruptTimePrecise; + + /// 100 ns intervals from 1601-01-01 to 1970-01-01. + const FILETIME_UNIX_EPOCH: u64 = 116_444_736_000_000_000; + + pub(super) fn utc_ns() -> Result { + let mut time = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + // SAFETY: `time` is a valid, writable FILETIME for the duration of + // the call. + unsafe { GetSystemTimePreciseAsFileTime(&mut time) }; + let intervals = (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime); + intervals + .checked_sub(FILETIME_UNIX_EPOCH) + .and_then(|intervals| intervals.checked_mul(100)) + .ok_or_else(|| host_error("host UTC is outside the supported range")) + } + + pub(super) fn monotonic_ns() -> Result { + let mut intervals = 0; + // SAFETY: `intervals` is a valid, writable u64 for the duration of + // the call. + unsafe { QueryInterruptTimePrecise(&mut intervals) }; + intervals + .checked_mul(100) + .ok_or_else(|| host_error("host interrupt time overflows")) + } + + fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() + } + + fn read_value( + key: &str, + value: &str, + flags: u32, + data: &mut [u8], + ) -> Result { + let key_wide = wide(key); + let value_wide = wide(value); + let mut len = data.len() as u32; + // SAFETY: the key and value names are NUL-terminated UTF-16 strings, + // and `data` is writable for `len` bytes, all for the duration of the + // call. + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + key_wide.as_ptr(), + value_wide.as_ptr(), + flags, + std::ptr::null_mut(), + data.as_mut_ptr().cast(), + &mut len, + ) + }; + if status != ERROR_SUCCESS { + return Err(host_error(format!( + "cannot read registry value HKLM\\{key}\\{value}: {}", + std::io::Error::from_raw_os_error(status as i32) + ))); + } + Ok(len as usize) + } + + fn machine_guid() -> Result<[u8; 16], TimeAbiError> { + const KEY: &str = r"SOFTWARE\Microsoft\Cryptography"; + let mut data = [0_u8; 128]; + let len = read_value(KEY, "MachineGuid", RRF_RT_REG_SZ, &mut data)?; + let units: Vec = (0..len.min(data.len()) / 2) + .map(|i| u16::from_le_bytes([data[2 * i], data[2 * i + 1]])) + .take_while(|&unit| unit != 0) + .collect(); + let text = String::from_utf16_lossy(&units); + parse_hex_id(&text).ok_or_else(|| host_error("MachineGuid is malformed")) + } + + fn boot_id() -> Result<[u8; 16], TimeAbiError> { + const KEY: &str = r"SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PrefetchParameters"; + let mut data = [0_u8; 4]; + let len = read_value(KEY, "BootId", RRF_RT_REG_DWORD, &mut data)?; + if len != data.len() { + return Err(host_error("BootId is malformed")); + } + Ok(zero_extended_id(u32::from_le_bytes(data))) + } + + pub(super) fn host_identity() -> Result { + Ok(HostIdentity { + host_id: machine_guid()?, + boot_id: boot_id()?, + clock: HostClockKind::WindowsInterruptTime, + }) + } +} + +#[cfg(not(any(target_os = "linux", windows)))] +mod sys { + use super::HostIdentity; + use super::host_error; + use crate::time_abi::TimeAbiError; + + pub(super) fn utc_ns() -> Result { + Err(host_error( + "host time sampling is not supported on this host", + )) + } + + pub(super) fn monotonic_ns() -> Result { + Err(host_error( + "host time sampling is not supported on this host", + )) + } + + pub(super) fn host_identity() -> Result { + Err(host_error("host identity is not supported on this host")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_ids() { + assert_eq!( + parse_hex_id("0123456789abcdef0123456789ABCDEF\n"), + Some([ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, + 0xcd, 0xef + ]) + ); + assert_eq!( + parse_hex_id("01234567-89ab-cdef-0123-456789abcdef"), + parse_hex_id("0123456789abcdef0123456789abcdef") + ); + assert_eq!( + parse_hex_id("{01234567-89ab-cdef-0123-456789abcdef}"), + parse_hex_id("0123456789abcdef0123456789abcdef") + ); + assert_eq!(parse_hex_id("0123"), None); + assert_eq!(parse_hex_id("0123456789abcdef0123456789abcdeg"), None); + assert_eq!(parse_hex_id("0123456789abcdef0123456789abcdef00"), None); + } + + #[test] + fn zero_extension() { + assert_eq!( + zero_extended_id(6), + [6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] + ); + } + + #[test] + fn clock_kind_spelling() { + for kind in [ + HostClockKind::LinuxBoottime, + HostClockKind::WindowsInterruptTime, + ] { + assert_eq!(HostClockKind::from_manifest(kind.as_str()), Some(kind)); + } + assert_eq!(HostClockKind::from_manifest("utc"), None); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn host_time_samples_advance() { + let first = sample_host_time().unwrap(); + let second = sample_host_time().unwrap(); + // After 2020-01-01. + assert!(first.utc_ns > 1_577_836_800_000_000_000); + assert!(second.monotonic_ns >= first.monotonic_ns); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + #[ignore = "reads the host identity, which containers may lack"] + fn host_identity_is_available() { + let first = host_identity().unwrap(); + assert_ne!(first.host_id, [0; 16]); + assert_eq!(host_identity().unwrap(), first); + } +} diff --git a/vmm_core/virt/src/time_abi/identity.rs b/vmm_core/virt/src/time_abi/identity.rs new file mode 100644 index 00000000000..ad26fd921f0 --- /dev/null +++ b/vmm_core/virt/src/time_abi/identity.rs @@ -0,0 +1,421 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The hypervisor identity CPUID leaves and the CPU time bits. + +use super::TimeAbiCode; +use super::TimeAbiError; +use crate::CpuidLeaf; +use crate::CpuidLeafSet; +use std::ops::RangeInclusive; + +/// The identity leaves the time ABI defines. Every other leaf in this range +/// reads as zero or as the vendor's out-of-range result. +pub const IDENTITY_CPUID_RANGE: RangeInclusive = 0x4000_0000..=0x4000_00ff; +/// Every leaf a hypervisor may use, including the bases at `0x400xxx00` +/// that carry KVM, Xen, or VMware signatures. +pub const HYPERVISOR_CPUID_RANGE: RangeInclusive = 0x4000_0000..=0x4fff_ffff; +/// The highest identity leaf. +pub const IDENTITY_MAX_LEAF: u32 = 0x4000_0005; +/// `"Microsoft Hv"` in EBX, ECX, and EDX of leaf `0x40000000`. +pub const VENDOR_SIGNATURE: [u32; 3] = [0x7263_694d, 0x666f_736f, 0x7648_2074]; +/// `"Hv#1"` in EAX of leaf `0x40000001`. +pub const INTERFACE_SIGNATURE: u32 = 0x3123_7648; +/// `"NVX"` in EAX of leaf `0x40000002`. +pub const NVX_BUILD_SIGNATURE: u32 = 0x0058_564e; +/// Time ABI 1.0 in EBX of leaf `0x40000002`. +pub const TIME_ABI_VERSION_EBX: u32 = 0x0001_0000; +/// `AccessHypercallMsrs`, `AccessVpIndex`, `AccessFrequencyRegs`, and +/// `AccessTscInvariantControls` in EAX of leaf `0x40000003`. +pub const PRIVILEGES_EAX: u32 = 0x0000_8860; +/// `FrequencyRegsAvailable` in EDX of leaf `0x40000003`. +pub const FEATURES_EDX: u32 = 0x0000_0100; +/// "Never notify" spinlock retries in EBX of leaf `0x40000004`. +pub const SPINLOCK_RETRIES_EBX: u32 = 0xffff_ffff; +/// The invariant-TSC bit in EDX of leaf `0x80000007`. +pub const INVARIANT_TSC_EDX: u32 = 1 << 8; + +const LEAF_VERSION_AND_FEATURES: u32 = 0x1; +const LEAF_POWER_MANAGEMENT: u32 = 0x6; +const LEAF_EXTENDED_FEATURES: u32 = 0x7; +const LEAF_PERFORMANCE_MONITORING: u32 = 0xa; +const LEAF_CORE_CRYSTAL_CLOCK: u32 = 0x15; +const LEAF_PROCESSOR_FREQUENCY: u32 = 0x16; +const LEAF_EXTENDED_MAX: u32 = 0x8000_0000; +const LEAF_EXTENDED_FEATURES_1: u32 = 0x8000_0001; +const LEAF_ADVANCED_POWER_MANAGEMENT: u32 = 0x8000_0007; + +const ECX1_PDCM: u32 = 1 << 15; +const ECX1_TSC_DEADLINE: u32 = 1 << 24; +const ECX1_HYPERVISOR: u32 = 1 << 31; +const EDX1_TSC: u32 = 1 << 4; +const EBX7_TSC_ADJUST: u32 = 1 << 1; +const EAX6_ARAT: u32 = 1 << 2; +const EDX80000001_RDTSCP: u32 = 1 << 27; + +/// Returns the six identity leaves `0x40000000..=0x40000005` for a VM with +/// `vp_count` VPs. Every VP sees the same values. +pub fn identity_cpuid_leaves(vp_count: u32) -> [CpuidLeaf; 6] { + let [ebx, ecx, edx] = VENDOR_SIGNATURE; + [ + CpuidLeaf::new(0x4000_0000, [IDENTITY_MAX_LEAF, ebx, ecx, edx]), + CpuidLeaf::new(0x4000_0001, [INTERFACE_SIGNATURE, 0, 0, 0]), + CpuidLeaf::new( + 0x4000_0002, + [NVX_BUILD_SIGNATURE, TIME_ABI_VERSION_EBX, 0, 0], + ), + CpuidLeaf::new(0x4000_0003, [PRIVILEGES_EAX, 0, 0, FEATURES_EDX]), + CpuidLeaf::new(0x4000_0004, [0, SPINLOCK_RETRIES_EBX, 0, 0]), + CpuidLeaf::new(0x4000_0005, [vp_count, vp_count, 0, 0]), + ] +} + +/// Returns CPUID results that enforce the CPU time bits over another CPUID +/// source. Bits outside the time bits keep that source's values. +/// +/// `invariant_tsc` sets CPUID `0x80000007` EDX to the invariant-TSC bit, or to +/// zero when the backend must not expose it (TBD(whp)). +pub fn time_bits_cpuid_leaves(invariant_tsc: bool) -> Vec { + vec![ + CpuidLeaf::new(LEAF_VERSION_AND_FEATURES, [0, 0, ECX1_HYPERVISOR, EDX1_TSC]).masked([ + 0, + 0, + ECX1_HYPERVISOR | ECX1_TSC_DEADLINE | ECX1_PDCM, + EDX1_TSC, + ]), + CpuidLeaf::new(LEAF_POWER_MANAGEMENT, [EAX6_ARAT, 0, 0, 0]), + CpuidLeaf::new(LEAF_EXTENDED_FEATURES, [0; 4]) + .indexed(0) + .masked([0, EBX7_TSC_ADJUST, 0, 0]), + CpuidLeaf::new(LEAF_PERFORMANCE_MONITORING, [0; 4]), + CpuidLeaf::new(LEAF_CORE_CRYSTAL_CLOCK, [0; 4]), + CpuidLeaf::new(LEAF_PROCESSOR_FREQUENCY, [0; 4]), + CpuidLeaf::new(LEAF_EXTENDED_FEATURES_1, [0, 0, 0, EDX80000001_RDTSCP]).masked([ + 0, + 0, + 0, + EDX80000001_RDTSCP, + ]), + CpuidLeaf::new( + LEAF_ADVANCED_POWER_MANAGEMENT, + [0, 0, 0, if invariant_tsc { INVARIANT_TSC_EDX } else { 0 }], + ), + ] +} + +/// Returns the time ABI CPUID results a backend applies over its own CPUID +/// until CPU profiles land: the identity leaves and the CPU time bits. +/// +/// The caller must also ensure that no other leaf in +/// [`HYPERVISOR_CPUID_RANGE`] is exposed. +pub fn time_abi_cpuid(vp_count: u32, invariant_tsc: bool) -> CpuidLeafSet { + let mut leaves = time_bits_cpuid_leaves(invariant_tsc); + leaves.extend(identity_cpuid_leaves(vp_count)); + CpuidLeafSet::new(leaves) +} + +/// Wraps a CPUID lookup so that every leaf in [`HYPERVISOR_CPUID_RANGE`] +/// reads as zero. +/// +/// Backends pass this view to +/// [`X86PartitionCapabilities::from_cpuid`](crate::x86::X86PartitionCapabilities::from_cpuid), +/// so the identity leaves never make `hv1` or `kvm_clock` true: the time ABI +/// exposes neither the synthetic MSR, SynIC, and reference-time state that +/// `hv1` implies nor the KVM clock. +pub fn capabilities_cpuid<'a>( + cpuid: &'a mut dyn FnMut(u32, u32) -> [u32; 4], +) -> impl FnMut(u32, u32) -> [u32; 4] + 'a { + move |leaf, subleaf| { + if HYPERVISOR_CPUID_RANGE.contains(&leaf) { + [0; 4] + } else { + cpuid(leaf, subleaf) + } + } +} + +/// Checks the identity leaves of an effective CPUID: the six leaves exactly +/// and no hypervisor signature at any base `0x40000100..=0x4000ff00`. +pub fn check_identity( + cpuid: &mut dyn FnMut(u32, u32) -> [u32; 4], + vp_count: u32, +) -> Result<(), TimeAbiError> { + for leaf in identity_cpuid_leaves(vp_count) { + let actual = cpuid(leaf.function, 0); + if actual != leaf.result { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!( + "CPUID {:#x} is {actual:#x?}, expected {:#x?}", + leaf.function, leaf.result + ), + )); + } + } + for base in (0x4000_0100..=0x4000_ff00).step_by(0x100) { + let [_, ebx, ecx, edx] = cpuid(base, 0); + if [ebx, ecx, edx] != [0; 3] { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!("CPUID {base:#x} carries a hypervisor signature"), + )); + } + } + Ok(()) +} + +/// Checks the CPU time bits of an effective CPUID. +/// +/// With `invariant_tsc`, CPUID `0x80000007` EDX must be exactly the +/// invariant-TSC bit; without it, EDX may also be zero (TBD(whp)). +pub fn check_time_bits( + cpuid: &mut dyn FnMut(u32, u32) -> [u32; 4], + invariant_tsc: bool, +) -> Result<(), TimeAbiError> { + fn violation(what: &str) -> TimeAbiError { + TimeAbiError::new( + TimeAbiCode::ProfileTimeBits, + format!("CPU time bits violated: {what}"), + ) + } + + let max_basic = cpuid(0, 0)[0]; + if max_basic < LEAF_VERSION_AND_FEATURES { + return Err(violation("CPUID leaf 0x1 is missing")); + } + let [_, _, ecx, edx] = cpuid(LEAF_VERSION_AND_FEATURES, 0); + if ecx & ECX1_HYPERVISOR == 0 { + return Err(violation("CPUID.1:ECX[31] hypervisor present is clear")); + } + if ecx & ECX1_TSC_DEADLINE != 0 { + return Err(violation("CPUID.1:ECX[24] TSC-deadline timer is set")); + } + if ecx & ECX1_PDCM != 0 { + return Err(violation("CPUID.1:ECX[15] PDCM is set")); + } + if edx & EDX1_TSC == 0 { + return Err(violation("CPUID.1:EDX[4] TSC is clear")); + } + if max_basic >= LEAF_POWER_MANAGEMENT && cpuid(LEAF_POWER_MANAGEMENT, 0) != [EAX6_ARAT, 0, 0, 0] + { + return Err(violation("CPUID.6 is not ARAT only")); + } + if max_basic >= LEAF_EXTENDED_FEATURES + && cpuid(LEAF_EXTENDED_FEATURES, 0)[1] & EBX7_TSC_ADJUST != 0 + { + return Err(violation("CPUID.7.0:EBX[1] IA32_TSC_ADJUST is set")); + } + for leaf in [ + LEAF_PERFORMANCE_MONITORING, + LEAF_CORE_CRYSTAL_CLOCK, + LEAF_PROCESSOR_FREQUENCY, + ] { + if max_basic >= leaf && cpuid(leaf, 0) != [0; 4] { + return Err(violation(&format!("CPUID {leaf:#x} is not zero"))); + } + } + let max_extended = cpuid(LEAF_EXTENDED_MAX, 0)[0]; + if max_extended < LEAF_ADVANCED_POWER_MANAGEMENT { + return Err(violation("CPUID leaf 0x80000007 is missing")); + } + if cpuid(LEAF_EXTENDED_FEATURES_1, 0)[3] & EDX80000001_RDTSCP == 0 { + return Err(violation("CPUID.80000001:EDX[27] RDTSCP is clear")); + } + let apm = cpuid(LEAF_ADVANCED_POWER_MANAGEMENT, 0); + let apm_valid = apm == [0, 0, 0, INVARIANT_TSC_EDX] || (!invariant_tsc && apm == [0; 4]); + if !apm_valid { + return Err(violation("CPUID.80000007 is not invariant TSC only")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::x86::X86PartitionCapabilities; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::ApicMode; + + /// A host CPUID with every time bit wrong and a KVM signature. + fn host_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + match (leaf, subleaf) { + (0, _) => [0x1f, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + (1, _) => [0x0005_0657, 0, 0x7ffa_fbff | ECX1_TSC_DEADLINE, 0xbfeb_fbff], + (6, _) => [0x77, 2, 9, 0], + (7, 0) => [0, 0xd19f_4fbb, 0, 0], + (0xa, _) => [0x0740_4f04, 0, 0, 0x603], + (0x15, _) => [2, 0xd4, 0x017d_7840, 0], + (0x16, _) => [0x0bb8, 0x0e74, 0x64, 0], + (0x4000_0000, _) => [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d], + (0x4000_0001, _) => [0x0100_8efb, 0, 0, 0], + (0x8000_0000, _) => [0x8000_0008, 0, 0, 0], + (0x8000_0001, _) => [0, 0, 0x121, 0x2c10_0800], + (0x8000_0007, _) => [0, 0, 0, 0x100], + _ => [0; 4], + } + } + + fn effective(leaf: u32, subleaf: u32, overlay: &CpuidLeafSet) -> [u32; 4] { + let base = if HYPERVISOR_CPUID_RANGE.contains(&leaf) { + [0; 4] + } else { + host_cpuid(leaf, subleaf) + }; + overlay.result(leaf, subleaf, &base) + } + + #[test] + fn identity_leaves_match_the_specification() { + let leaves = identity_cpuid_leaves(4); + let results: Vec<_> = leaves + .iter() + .map(|leaf| (leaf.function, leaf.result)) + .collect(); + assert_eq!( + results, + [ + ( + 0x4000_0000, + [0x4000_0005, 0x7263_694d, 0x666f_736f, 0x7648_2074] + ), + (0x4000_0001, [0x3123_7648, 0, 0, 0]), + (0x4000_0002, [0x0058_564e, 0x0001_0000, 0, 0]), + (0x4000_0003, [0x0000_8860, 0, 0, 0x0000_0100]), + (0x4000_0004, [0, 0xffff_ffff, 0, 0]), + (0x4000_0005, [4, 4, 0, 0]), + ] + ); + let vendor: Vec = VENDOR_SIGNATURE + .iter() + .flat_map(|word| word.to_le_bytes()) + .collect(); + assert_eq!(vendor, b"Microsoft Hv"); + assert_eq!(&INTERFACE_SIGNATURE.to_le_bytes(), b"Hv#1"); + assert_eq!(&NVX_BUILD_SIGNATURE.to_le_bytes(), b"NVX\0"); + } + + #[test] + fn overlay_enforces_identity_and_time_bits() { + let overlay = time_abi_cpuid(8, true); + let mut cpuid = |leaf, subleaf| effective(leaf, subleaf, &overlay); + check_time_bits(&mut cpuid, true).unwrap(); + check_identity(&mut cpuid, 8).unwrap(); + // Bits outside the time bits keep the host's values. + assert_eq!( + cpuid(1, 0)[2], + (0x7ffa_fbff & !(ECX1_TSC_DEADLINE | ECX1_PDCM)) | ECX1_HYPERVISOR + ); + assert_eq!(cpuid(7, 0)[1], 0xd19f_4fbb & !EBX7_TSC_ADJUST); + assert_eq!(cpuid(0x8000_0001, 0)[3], 0x2c10_0800 | EDX80000001_RDTSCP); + assert_eq!(cpuid(0x15, 0), [0; 4]); + } + + #[test] + fn invariant_tsc_may_be_clear_only_when_allowed() { + let overlay = time_abi_cpuid(1, false); + let mut cpuid = |leaf, subleaf| effective(leaf, subleaf, &overlay); + check_time_bits(&mut cpuid, false).unwrap(); + assert_eq!( + check_time_bits(&mut cpuid, true).unwrap_err().code, + TimeAbiCode::ProfileTimeBits + ); + } + + #[test] + fn time_bit_violations_are_rejected() { + let mut host = host_cpuid; + assert_eq!( + check_time_bits(&mut host, true).unwrap_err().code, + TimeAbiCode::ProfileTimeBits + ); + let overlay = time_abi_cpuid(1, true); + for (leaf, subleaf, register, bit) in [ + (1, 0, 2, ECX1_TSC_DEADLINE), + (1, 0, 2, ECX1_PDCM), + (6, 0, 1, 1), + (7, 0, 1, EBX7_TSC_ADJUST), + (0xa, 0, 0, 1), + (0x15, 0, 2, 1), + (0x8000_0007, 0, 3, 1), + ] { + let mut cpuid = |l, s| { + let mut result = effective(l, s, &overlay); + if (l, s) == (leaf, subleaf) { + result[register] |= bit; + } + result + }; + assert_eq!( + check_time_bits(&mut cpuid, true).unwrap_err().code, + TimeAbiCode::ProfileTimeBits, + "{leaf:#x}" + ); + } + for (leaf, register, bit) in [ + (1, 2, ECX1_HYPERVISOR), + (1, 3, EDX1_TSC), + (0x8000_0001, 3, EDX80000001_RDTSCP), + ] { + let mut cpuid = |l, s| { + let mut result = effective(l, s, &overlay); + if l == leaf { + result[register] &= !bit; + } + result + }; + assert_eq!( + check_time_bits(&mut cpuid, true).unwrap_err().code, + TimeAbiCode::ProfileTimeBits, + "{leaf:#x}" + ); + } + } + + #[test] + fn identity_check_rejects_other_signatures() { + let overlay = time_abi_cpuid(2, true); + let mut cpuid = |leaf, subleaf| { + if leaf == 0x4000_0100 { + [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d] + } else { + effective(leaf, subleaf, &overlay) + } + }; + assert_eq!( + check_identity(&mut cpuid, 2).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + let mut cpuid = |leaf, subleaf| effective(leaf, subleaf, &overlay); + assert_eq!( + check_identity(&mut cpuid, 3).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + } + + #[test] + fn capabilities_ignore_the_identity() { + let topology = TopologyBuilder::new_x86().build(1).unwrap(); + let x2apic = topology.apic_mode() != ApicMode::XApic; + let overlay = time_abi_cpuid(1, true); + let mut cpuid = |leaf, subleaf| { + let mut result = effective(leaf, subleaf, &overlay); + if leaf == 1 { + // Match the topology's APIC mode and drop XSAVE, which the + // fake host does not describe. + result[2] &= !((1 << 21) | (1 << 26)); + if x2apic { + result[2] |= 1 << 21; + } + } + result + }; + let unmasked = X86PartitionCapabilities::from_cpuid(&topology, &mut cpuid).unwrap(); + assert!(unmasked.hv1); + let masked = + X86PartitionCapabilities::from_cpuid(&topology, &mut capabilities_cpuid(&mut cpuid)) + .unwrap(); + assert!(!masked.hv1); + assert!(!masked.kvm_clock); + assert!(!masked.tsc_deadline); + } +} diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs new file mode 100644 index 00000000000..4ade89956ae --- /dev/null +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -0,0 +1,465 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! NVX time ABI v1. +//! +//! The guest-visible time contract of the microVM: a minimal Hyper-V +//! frequency identity, the identity MSRs, the TSC rate policy, downtime +//! selection, and the backend primitives that snapshot restore needs. The +//! contract is specified in the NVX repository, `doc/design/time-abi.md`. + +#[cfg(guest_arch = "x86_64")] +pub mod backend; +pub mod downtime; +pub mod host; +#[cfg(guest_arch = "x86_64")] +pub mod identity; +#[cfg(guest_arch = "x86_64")] +pub mod msr; +pub mod rate; + +#[cfg(guest_arch = "x86_64")] +pub use backend::BackendPreflight; +#[cfg(guest_arch = "x86_64")] +pub use backend::IdentityMsrRoute; +#[cfg(guest_arch = "x86_64")] +pub use backend::TimeAbiBackend; +#[cfg(guest_arch = "x86_64")] +pub use backend::TscAnchor; +#[cfg(guest_arch = "x86_64")] +pub use backend::TscSetReport; +#[cfg(guest_arch = "x86_64")] +pub use backend::TscSyncMethod; +pub use downtime::CaptureTimeRecord; +pub use downtime::Downtime; +pub use downtime::DowntimeSource; +pub use host::HostClockKind; +pub use host::HostIdentity; +pub use host::HostTimeSample; +#[cfg(guest_arch = "x86_64")] +pub use msr::TimeAbiMsrs; +pub use rate::RateCheck; + +use mesh_protobuf::Protobuf; +use std::fmt; + +/// The time ABI version this OpenVMM implements. +pub const TIME_ABI_VERSION: u32 = 1; + +macro_rules! time_abi_codes { + ($($(#[doc = $doc:literal])* $variant:ident => $name:literal,)*) => { + /// A stable failure code of the time ABI. + /// + /// The spelling returned by [`TimeAbiCode::as_str`] is part of the + /// ABI: tests and orchestrators match it. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] + pub enum TimeAbiCode { + $($(#[doc = $doc])* $variant,)* + } + + impl TimeAbiCode { + /// Every code, in specification order. + pub const ALL: &[TimeAbiCode] = &[$(TimeAbiCode::$variant,)*]; + + /// Returns the stable name of the code, for example + /// `E_TSC_RATE_TOLERANCE`. + pub const fn as_str(self) -> &'static str { + match self { + $(TimeAbiCode::$variant => $name,)* + } + } + } + }; +} + +time_abi_codes! { + /// The manifest version is not the supported one. + SnapshotVersion => "E_SNAPSHOT_VERSION", + /// The time contract is missing or malformed. + ManifestTime => "E_MANIFEST_TIME", + /// The snapshot was taken on another backend. + BackendMismatch => "E_BACKEND_MISMATCH", + /// The CPU profile is not pinned in this OpenVMM. + ProfileUnknown => "E_PROFILE_UNKNOWN", + /// A CPU profile or effective-CPUID digest does not verify. + ProfileDigest => "E_PROFILE_DIGEST", + /// `--cpu-profile auto` maps the host to no profile. + ProfileHostUnknown => "E_PROFILE_HOST_UNKNOWN", + /// The CPU profile violates the CPU time bits. + ProfileTimeBits => "E_PROFILE_TIME_BITS", + /// The host CPU generation is not in the profile. + CpuGeneration => "E_CPU_GENERATION", + /// The backend lacks a feature, limit, or value of the profile. + ProfileUnsupported => "E_PROFILE_UNSUPPORTED", + /// The recomputed effective CPUID differs from the recorded one. + CpuSurface => "E_CPU_SURFACE", + /// The backend cannot deliver the identity CPUID or MSRs. + IdentityRouting => "E_IDENTITY_ROUTING", + /// The backend lacks the synchronized TSC set. + TscSyncUnsupported => "E_TSC_SYNC_UNSUPPORTED", + /// The guest TSC would be scaled. + TscScalingActive => "E_TSC_SCALING_ACTIVE", + /// The backend cannot report its native TSC rate. + TscRateUnavailable => "E_TSC_RATE_UNAVAILABLE", + /// A TSC rate is outside the plausible range. + TscRateImplausible => "E_TSC_RATE_IMPLAUSIBLE", + /// The destination TSC rate is outside the tolerance. + TscRateTolerance => "E_TSC_RATE_TOLERANCE", + /// The backend cannot report its LAPIC timer rate. + LapicRateUnavailable => "E_LAPIC_RATE_UNAVAILABLE", + /// The LAPIC timer rate is not the backend constant or the saved rate. + LapicRateMismatch => "E_LAPIC_RATE_MISMATCH", + /// The host identity, boot identity, or clocks are unavailable. + HostIdentity => "E_HOST_IDENTITY", + /// The downtime is negative. + DowntimeNegative => "E_DOWNTIME_NEGATIVE", + /// The downtime exceeds the maximum. + DowntimeExcessive => "E_DOWNTIME_EXCESSIVE", + /// The capture anchor is unavailable or not paired closely enough. + TscAnchor => "E_TSC_ANCHOR", + /// The restore TSC target exceeds 64 bits. + TscTargetOverflow => "E_TSC_TARGET_OVERFLOW", + /// A VP does not hold the synchronized TSC value. + TscSyncReadback => "E_TSC_SYNC_READBACK", + /// A VP was instantiated after the synchronized TSC set. + VpLateCreation => "E_VP_LATE_CREATION", + /// A periodic LAPIC timer is armed. + LapicPeriodic => "E_LAPIC_PERIODIC", + /// TSC-deadline LAPIC timer mode or state is present. + LapicTscDeadline => "E_LAPIC_TSC_DEADLINE", + /// PIT channel 0 is counting in a periodic mode. + PitActive => "E_PIT_ACTIVE", + /// The generation counter would overflow. + GenerationExhausted => "E_GENERATION_EXHAUSTED", + /// A clock token is present in the kernel command line. + CmdlineClockToken => "E_CMDLINE_CLOCK_TOKEN", + /// A test hook is malformed or unknown. + TestHook => "E_TEST_HOOK", +} + +impl fmt::Display for TimeAbiCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A time ABI failure. +/// +/// It displays as `[E_CODE] message`, so the code survives conversion to a +/// string across the worker boundary and error wrapping. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TimeAbiError { + /// The stable code. + pub code: TimeAbiCode, + /// A human-readable description with the relevant values. + pub message: String, +} + +impl TimeAbiError { + /// Returns a new error. + pub fn new(code: TimeAbiCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +impl fmt::Display for TimeAbiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}", self.code, self.message) + } +} + +impl std::error::Error for TimeAbiError {} + +/// The rates the guest reads from MSRs `0x40000022` and `0x40000023`. +/// +/// They are declared once per VM process: a cold boot declares the native TSC +/// rate and a restore declares the snapshot's rates. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Protobuf)] +#[mesh(package = "virt.time_abi")] +pub struct DeclaredRates { + /// The declared TSC rate `F`, in Hz. + #[mesh(1)] + pub tsc_hz: u64, + /// The LAPIC timer rate `L`, in Hz. + #[mesh(2)] + pub apic_hz: u64, +} + +impl DeclaredRates { + /// Returns validated rates: a plausible TSC rate and a LAPIC rate that is + /// one of the backend constants. + pub fn new(tsc_hz: u64, apic_hz: u64) -> Result { + rate::check_plausible_tsc_hz(tsc_hz)?; + if apic_hz != rate::LAPIC_HZ_KVM && apic_hz != rate::LAPIC_HZ_HYPERV { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateMismatch, + format!("LAPIC timer rate {apic_hz} Hz is not a backend constant"), + )); + } + Ok(Self { tsc_hz, apic_hz }) + } +} + +/// Test hooks selected with the hidden `--x-time-abi-test-hook` option. +/// +/// Every active hook is logged and sets the `TEST_HOOKS` flag in the restore +/// packet and in every time sample. +#[derive(Debug, Clone, Default, PartialEq, Eq, Protobuf)] +#[mesh(package = "virt.time_abi")] +pub struct TimeAbiTestHooks { + /// Select the UTC downtime source even on the same host boot. + #[mesh(1)] + pub force_utc_downtime: bool, + /// Treat the destination boot identity as different. + #[mesh(2)] + pub boot_id_mismatch: bool, + /// Perturb the measured destination TSC rate by this many ppm. + #[mesh(3)] + pub dest_rate_offset_ppm: i32, + /// Add this many seconds to the measured downtime. + #[mesh(4)] + pub downtime_add_s: u64, + /// Add this many milliseconds to every destination UTC reading. + #[mesh(5)] + pub utc_offset_ms: i64, + /// Delay the handling of time-sample selections by this many + /// microseconds. + #[mesh(6)] + pub sample_delay_us: u32, +} + +/// The largest accepted `dest-rate-offset-ppm` magnitude. +pub const MAX_HOOK_RATE_OFFSET_PPM: i32 = 10_000; +/// The largest accepted `downtime-add-s` value: ten years. +pub const MAX_HOOK_DOWNTIME_ADD_S: u64 = 10 * 365 * 24 * 60 * 60; +/// The largest accepted `utc-offset-ms` magnitude: ten years. +pub const MAX_HOOK_UTC_OFFSET_MS: i64 = 10 * 365 * 24 * 60 * 60 * 1000; +/// The largest accepted `sample-delay-us` value. +pub const MAX_HOOK_SAMPLE_DELAY_US: u32 = 100_000; + +impl TimeAbiTestHooks { + /// Parses hook values such as `force-utc-downtime` or + /// `dest-rate-offset-ppm=-200`. A hook may appear at most once. + pub fn parse>(values: &[S]) -> Result { + fn invalid(value: &str, reason: &str) -> TimeAbiError { + TimeAbiError::new( + TimeAbiCode::TestHook, + format!("invalid time ABI test hook '{value}': {reason}"), + ) + } + + fn number( + value: &str, + argument: Option<&str>, + min: T, + max: T, + ) -> Result { + let argument = argument.ok_or_else(|| invalid(value, "missing value"))?; + let parsed: T = argument + .parse() + .map_err(|_| invalid(value, "malformed number"))?; + if parsed < min || parsed > max { + return Err(invalid(value, "value out of range")); + } + Ok(parsed) + } + + let mut hooks = Self::default(); + let mut seen = Vec::new(); + for value in values { + let value = value.as_ref(); + let (name, argument) = match value.split_once('=') { + Some((name, argument)) => (name, Some(argument)), + None => (value, None), + }; + if seen.contains(&name) { + return Err(invalid(value, "repeated hook")); + } + seen.push(name); + match name { + "force-utc-downtime" | "boot-id-mismatch" if argument.is_some() => { + return Err(invalid(value, "the hook takes no value")); + } + "force-utc-downtime" => hooks.force_utc_downtime = true, + "boot-id-mismatch" => hooks.boot_id_mismatch = true, + "dest-rate-offset-ppm" => { + hooks.dest_rate_offset_ppm = number( + value, + argument, + -MAX_HOOK_RATE_OFFSET_PPM, + MAX_HOOK_RATE_OFFSET_PPM, + )?; + } + "downtime-add-s" => { + hooks.downtime_add_s = number(value, argument, 0, MAX_HOOK_DOWNTIME_ADD_S)?; + } + "utc-offset-ms" => { + hooks.utc_offset_ms = number( + value, + argument, + -MAX_HOOK_UTC_OFFSET_MS, + MAX_HOOK_UTC_OFFSET_MS, + )?; + } + "sample-delay-us" => { + hooks.sample_delay_us = number(value, argument, 0, MAX_HOOK_SAMPLE_DELAY_US)?; + } + _ => return Err(invalid(value, "unknown hook")), + } + } + Ok(hooks) + } + + /// Returns whether any hook alters time ABI behavior. + pub fn active(&self) -> bool { + *self != Self::default() + } +} + +/// Time ABI configuration of a partition, set in +/// [`ProtoPartitionConfig::time_abi`](crate::ProtoPartitionConfig::time_abi). +/// +/// A backend that receives it must program `cpuid` after every other CPUID +/// source, remove every hypervisor-range leaf of its own, route the identity +/// MSR range to `msrs`, derive its partition capabilities through +/// [`identity::capabilities_cpuid`], and implement [`TimeAbiBackend`]. +#[cfg(guest_arch = "x86_64")] +#[derive(Debug, Clone)] +pub struct TimeAbiConfig { + /// CPUID results the backend programs on every VP. Until CPU profiles + /// land this is [`identity::time_abi_cpuid`]: the identity leaves and the + /// CPU time bits, applied over the backend's own CPUID. + pub cpuid: std::sync::Arc, + /// The identity MSR handler, shared with the worker's `time-abi` state + /// unit. + pub msrs: std::sync::Arc, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_match_the_specification() { + let names: Vec<_> = TimeAbiCode::ALL.iter().map(|code| code.as_str()).collect(); + assert_eq!( + names, + [ + "E_SNAPSHOT_VERSION", + "E_MANIFEST_TIME", + "E_BACKEND_MISMATCH", + "E_PROFILE_UNKNOWN", + "E_PROFILE_DIGEST", + "E_PROFILE_HOST_UNKNOWN", + "E_PROFILE_TIME_BITS", + "E_CPU_GENERATION", + "E_PROFILE_UNSUPPORTED", + "E_CPU_SURFACE", + "E_IDENTITY_ROUTING", + "E_TSC_SYNC_UNSUPPORTED", + "E_TSC_SCALING_ACTIVE", + "E_TSC_RATE_UNAVAILABLE", + "E_TSC_RATE_IMPLAUSIBLE", + "E_TSC_RATE_TOLERANCE", + "E_LAPIC_RATE_UNAVAILABLE", + "E_LAPIC_RATE_MISMATCH", + "E_HOST_IDENTITY", + "E_DOWNTIME_NEGATIVE", + "E_DOWNTIME_EXCESSIVE", + "E_TSC_ANCHOR", + "E_TSC_TARGET_OVERFLOW", + "E_TSC_SYNC_READBACK", + "E_VP_LATE_CREATION", + "E_LAPIC_PERIODIC", + "E_LAPIC_TSC_DEADLINE", + "E_PIT_ACTIVE", + "E_GENERATION_EXHAUSTED", + "E_CMDLINE_CLOCK_TOKEN", + "E_TEST_HOOK", + ] + ); + } + + #[test] + fn error_display_leads_with_the_code() { + let error = TimeAbiError::new(TimeAbiCode::TscRateTolerance, "rate too far"); + assert_eq!(error.to_string(), "[E_TSC_RATE_TOLERANCE] rate too far"); + let wrapped = anyhow::Error::new(error).context("restore failed"); + assert!(format!("{wrapped:#}").contains("[E_TSC_RATE_TOLERANCE] rate too far")); + } + + #[test] + fn declared_rates_are_validated() { + DeclaredRates::new(2_000_000_000, rate::LAPIC_HZ_KVM).unwrap(); + DeclaredRates::new(2_000_000_000, rate::LAPIC_HZ_HYPERV).unwrap(); + assert_eq!( + DeclaredRates::new(2_000_000_000, 100_000_000) + .unwrap_err() + .code, + TimeAbiCode::LapicRateMismatch + ); + assert_eq!( + DeclaredRates::new(100, rate::LAPIC_HZ_KVM) + .unwrap_err() + .code, + TimeAbiCode::TscRateImplausible + ); + } + + #[test] + fn test_hooks_parse() { + let hooks = TimeAbiTestHooks::parse(&[ + "force-utc-downtime", + "boot-id-mismatch", + "dest-rate-offset-ppm=-200", + "downtime-add-s=2592001", + "utc-offset-ms=-5000", + "sample-delay-us=3000", + ]) + .unwrap(); + assert_eq!( + hooks, + TimeAbiTestHooks { + force_utc_downtime: true, + boot_id_mismatch: true, + dest_rate_offset_ppm: -200, + downtime_add_s: 2_592_001, + utc_offset_ms: -5000, + sample_delay_us: 3000, + } + ); + assert!(hooks.active()); + assert!(!TimeAbiTestHooks::parse::<&str>(&[]).unwrap().active()); + } + + #[test] + fn test_hooks_reject_malformed_values() { + for value in [ + "unknown", + "force-utc-downtime=1", + "dest-rate-offset-ppm", + "dest-rate-offset-ppm=", + "dest-rate-offset-ppm=x", + "dest-rate-offset-ppm=10001", + "downtime-add-s=-1", + "downtime-add-s=315360001", + "utc-offset-ms=315360000001", + "sample-delay-us=100001", + ] { + assert_eq!( + TimeAbiTestHooks::parse(&[value]).unwrap_err().code, + TimeAbiCode::TestHook, + "{value}" + ); + } + assert_eq!( + TimeAbiTestHooks::parse(&["downtime-add-s=1", "downtime-add-s=2"]) + .unwrap_err() + .code, + TimeAbiCode::TestHook + ); + } +} diff --git a/vmm_core/virt/src/time_abi/msr.rs b/vmm_core/virt/src/time_abi/msr.rs new file mode 100644 index 00000000000..99cce01d9d8 --- /dev/null +++ b/vmm_core/virt/src/time_abi/msr.rs @@ -0,0 +1,273 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The identity MSR handler. +//! +//! OpenVMM serves the whole identity MSR range itself on every backend. Each +//! backend routes accesses in [`IDENTITY_MSR_RANGE`] to one shared +//! [`TimeAbiMsrs`] before any other MSR handling. + +use super::DeclaredRates; +use crate::x86::MsrError; +use mesh_protobuf::Protobuf; +use std::ops::RangeInclusive; +use std::sync::OnceLock; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use thiserror::Error; +use vm_topology::processor::VpIndex; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SavedStateRoot; + +/// The identity MSR range. Every MSR in it that the table below does not +/// define raises #GP. +pub const IDENTITY_MSR_RANGE: RangeInclusive = 0x4000_0000..=0x4000_01ff; +/// `HV_X64_MSR_VP_INDEX`: the VP index, read only. +pub const MSR_VP_INDEX: u32 = 0x4000_0002; +/// `HV_X64_MSR_TSC_FREQUENCY`: the declared TSC rate, read only. +pub const MSR_TSC_FREQUENCY: u32 = 0x4000_0022; +/// `HV_X64_MSR_APIC_FREQUENCY`: the LAPIC timer rate, read only. +pub const MSR_APIC_FREQUENCY: u32 = 0x4000_0023; +/// `HV_X64_MSR_TSC_INVARIANT_CONTROL`: 0 or 1, partition-wide guest state. +pub const MSR_TSC_INVARIANT_CONTROL: u32 = 0x4000_0118; + +/// The rates were declared before, with different values. +#[derive(Debug, Error)] +#[error("time ABI rates are already declared as {0:?}")] +pub struct AlreadyDeclared(pub DeclaredRates); + +/// The identity MSR handler of one partition. +/// +/// It is shared between the backend's MSR exit path and the worker's +/// `time-abi` state unit. +#[derive(Debug, Default)] +pub struct TimeAbiMsrs { + rates: OnceLock, + tsc_invariant_control: AtomicU64, +} + +/// Saved state of the identity MSRs. +#[derive(Debug, Clone, PartialEq, Eq, Protobuf, SavedStateRoot)] +#[mesh(package = "virt.time_abi")] +pub struct TimeAbiSavedState { + /// `HV_X64_MSR_TSC_INVARIANT_CONTROL`. + #[mesh(1)] + pub tsc_invariant_control: u64, +} + +impl TimeAbiMsrs { + /// Returns a handler with no declared rates. + pub fn new() -> Self { + Self::default() + } + + /// Declares the rates. The worker calls this once, before any VP runs; + /// declaring the same rates again is accepted. + pub fn declare(&self, rates: DeclaredRates) -> Result<(), AlreadyDeclared> { + let declared = *self.rates.get_or_init(|| rates); + if declared != rates { + return Err(AlreadyDeclared(declared)); + } + Ok(()) + } + + /// Returns the declared rates, if any. + pub fn declared(&self) -> Option { + self.rates.get().copied() + } + + /// Handles a read of `msr` by `vp`. + /// + /// Returns `None` if `msr` is outside [`IDENTITY_MSR_RANGE`], so the + /// backend handles it as before. `Some(Err(_))` means the backend must + /// inject #GP; in-range MSRs never fall through to other handlers. + pub fn read(&self, vp: VpIndex, msr: u32) -> Option> { + if !IDENTITY_MSR_RANGE.contains(&msr) { + return None; + } + let value = match msr { + MSR_VP_INDEX => Ok(vp.index().into()), + MSR_TSC_FREQUENCY | MSR_APIC_FREQUENCY => match self.declared() { + Some(rates) if msr == MSR_TSC_FREQUENCY => Ok(rates.tsc_hz), + Some(rates) => Ok(rates.apic_hz), + None => { + tracelimit::warn_ratelimited!( + vp = vp.index(), + msr, + "identity frequency MSR read before the rates were declared" + ); + Err(MsrError::InvalidAccess) + } + }, + MSR_TSC_INVARIANT_CONTROL => Ok(self.tsc_invariant_control()), + _ => Err(MsrError::InvalidAccess), + }; + Some(value) + } + + /// Handles a write of `value` to `msr` by `vp`, with the same contract as + /// [`Self::read`]. + pub fn write(&self, vp: VpIndex, msr: u32, value: u64) -> Option> { + let _ = vp; + if !IDENTITY_MSR_RANGE.contains(&msr) { + return None; + } + let result = match (msr, value) { + (MSR_TSC_INVARIANT_CONTROL, 0 | 1) => { + self.tsc_invariant_control.store(value, Ordering::Relaxed); + Ok(()) + } + _ => Err(MsrError::InvalidAccess), + }; + Some(result) + } + + /// Returns `HV_X64_MSR_TSC_INVARIANT_CONTROL`. + pub fn tsc_invariant_control(&self) -> u64 { + self.tsc_invariant_control.load(Ordering::Relaxed) + } + + /// Resets the guest state to its power-on value. The declared rates are + /// kept: they belong to the VM process. + pub fn reset(&self) { + self.tsc_invariant_control.store(0, Ordering::Relaxed); + } + + /// Saves the guest state. + pub fn save(&self) -> TimeAbiSavedState { + TimeAbiSavedState { + tsc_invariant_control: self.tsc_invariant_control(), + } + } + + /// Restores the guest state. + pub fn restore(&self, state: TimeAbiSavedState) -> Result<(), RestoreError> { + let TimeAbiSavedState { + tsc_invariant_control, + } = state; + if tsc_invariant_control > 1 { + return Err(RestoreError::InvalidSavedState(anyhow::anyhow!( + "HV_X64_MSR_TSC_INVARIANT_CONTROL value {tsc_invariant_control} is not 0 or 1" + ))); + } + self.tsc_invariant_control + .store(tsc_invariant_control, Ordering::Relaxed); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time_abi::rate::LAPIC_HZ_HYPERV; + + const RATES: DeclaredRates = DeclaredRates { + tsc_hz: 2_100_000_000, + apic_hz: LAPIC_HZ_HYPERV, + }; + + fn gp(result: Option>) -> bool { + matches!(result, Some(Err(MsrError::InvalidAccess))) + } + + #[test] + fn reads() { + let msrs = TimeAbiMsrs::new(); + let vp = VpIndex::new(3); + assert!(gp(msrs.read(vp, MSR_TSC_FREQUENCY))); + assert!(gp(msrs.read(vp, MSR_APIC_FREQUENCY))); + msrs.declare(RATES).unwrap(); + assert_eq!(msrs.read(vp, MSR_VP_INDEX).unwrap().unwrap(), 3); + assert_eq!( + msrs.read(vp, MSR_TSC_FREQUENCY).unwrap().unwrap(), + 2_100_000_000 + ); + assert_eq!( + msrs.read(vp, MSR_APIC_FREQUENCY).unwrap().unwrap(), + 200_000_000 + ); + assert_eq!( + msrs.read(vp, MSR_TSC_INVARIANT_CONTROL).unwrap().unwrap(), + 0 + ); + // Hyper-V MSRs the time ABI does not define raise #GP. + for msr in [ + 0x4000_0000, + 0x4000_0001, + 0x4000_0020, + 0x4000_0021, + 0x4000_0073, + 0x4000_01ff, + ] { + assert!(gp(msrs.read(vp, msr)), "{msr:#x}"); + } + for msr in [0x3fff_ffff, 0x4000_0200, 0x10] { + assert!(msrs.read(vp, msr).is_none(), "{msr:#x}"); + } + } + + #[test] + fn writes() { + let msrs = TimeAbiMsrs::new(); + let vp = VpIndex::BSP; + msrs.write(vp, MSR_TSC_INVARIANT_CONTROL, 1) + .unwrap() + .unwrap(); + assert_eq!(msrs.tsc_invariant_control(), 1); + msrs.write(vp, MSR_TSC_INVARIANT_CONTROL, 0) + .unwrap() + .unwrap(); + assert_eq!(msrs.tsc_invariant_control(), 0); + assert!(gp(msrs.write(vp, MSR_TSC_INVARIANT_CONTROL, 2))); + for msr in [ + MSR_VP_INDEX, + MSR_TSC_FREQUENCY, + MSR_APIC_FREQUENCY, + 0x4000_0001, + ] { + assert!(gp(msrs.write(vp, msr, 0)), "{msr:#x}"); + } + assert!(msrs.write(vp, 0x4000_0200, 0).is_none()); + } + + #[test] + fn declare_once() { + let msrs = TimeAbiMsrs::new(); + assert_eq!(msrs.declared(), None); + msrs.declare(RATES).unwrap(); + msrs.declare(RATES).unwrap(); + let other = DeclaredRates { + tsc_hz: 2_000_000_000, + ..RATES + }; + assert_eq!(msrs.declare(other).unwrap_err().0, RATES); + assert_eq!(msrs.declared(), Some(RATES)); + } + + #[test] + fn save_restore_reset() { + let msrs = TimeAbiMsrs::new(); + msrs.write(VpIndex::BSP, MSR_TSC_INVARIANT_CONTROL, 1) + .unwrap() + .unwrap(); + let saved = msrs.save(); + assert_eq!(saved.tsc_invariant_control, 1); + + let restored = TimeAbiMsrs::new(); + restored.restore(saved).unwrap(); + assert_eq!(restored.tsc_invariant_control(), 1); + assert!( + restored + .restore(TimeAbiSavedState { + tsc_invariant_control: 2, + }) + .is_err() + ); + assert_eq!(restored.tsc_invariant_control(), 1); + + restored.declare(RATES).unwrap(); + restored.reset(); + assert_eq!(restored.tsc_invariant_control(), 0); + assert_eq!(restored.declared(), Some(RATES)); + } +} diff --git a/vmm_core/virt/src/time_abi/rate.rs b/vmm_core/virt/src/time_abi/rate.rs new file mode 100644 index 00000000000..698246c7eeb --- /dev/null +++ b/vmm_core/virt/src/time_abi/rate.rs @@ -0,0 +1,277 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The TSC rate policy and the LAPIC rate rule. + +use super::TimeAbiCode; +use super::TimeAbiError; + +/// The accepted deviation of the destination TSC rate from the declared one. +pub const TSC_TOLERANCE_PPM: u32 = 250; +/// The lowest plausible TSC rate. +pub const MIN_TSC_HZ: u64 = 500_000_000; +/// The highest plausible TSC rate. +pub const MAX_TSC_HZ: u64 = 10_000_000_000; +/// The LAPIC timer rate on KVM. +pub const LAPIC_HZ_KVM: u64 = 1_000_000_000; +/// The LAPIC timer rate on MSHV and WHP. +pub const LAPIC_HZ_HYPERV: u64 = 200_000_000; +/// The largest LAPIC divide-configuration value. +pub const MAX_LAPIC_DIVIDE: u32 = 128; + +/// Checks that `hz` is a plausible TSC rate. +pub fn check_plausible_tsc_hz(hz: u64) -> Result<(), TimeAbiError> { + if !(MIN_TSC_HZ..=MAX_TSC_HZ).contains(&hz) { + return Err(TimeAbiError::new( + TimeAbiCode::TscRateImplausible, + format!("TSC rate {hz} Hz is outside {MIN_TSC_HZ} to {MAX_TSC_HZ} Hz"), + )); + } + Ok(()) +} + +/// Returns the LAPIC timer rate of the backend with hypervisor ID +/// `hypervisor` (`kvm`, `mshv`, or `whp`). +pub fn backend_lapic_hz(hypervisor: &str) -> Result { + match hypervisor { + "kvm" => Ok(LAPIC_HZ_KVM), + "mshv" | "whp" => Ok(LAPIC_HZ_HYPERV), + _ => Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!("backend '{hypervisor}' has no time ABI LAPIC rate"), + )), + } +} + +/// Checks a backend's reported LAPIC rate against its constant and, on +/// restore, against the saved rate. +pub fn check_lapic_hz( + hypervisor: &str, + actual: u64, + saved: Option, +) -> Result<(), TimeAbiError> { + let expected = backend_lapic_hz(hypervisor)?; + if actual != expected { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateMismatch, + format!( + "backend '{hypervisor}' reports LAPIC rate {actual} Hz, expected {expected} Hz" + ), + )); + } + if let Some(saved) = saved { + if saved != actual { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateMismatch, + format!( + "snapshot LAPIC rate {saved} Hz differs from the destination's {actual} Hz" + ), + )); + } + } + Ok(()) +} + +/// The outcome of an accepted TSC rate check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateCheck { + /// The declared rate `F_s`, in Hz. + pub declared_hz: u64, + /// The destination's native rate `F_d`, in Hz. + pub native_hz: u64, + /// `(F_d - F_s) / F_s` in parts per billion, rounded to nearest, for logs. + pub deviation_ppb: i64, + /// The restore packet's rate deviation: `(F_d - F_s) / F_s` in ppm + /// scaled by 2^16, rounded to nearest. + pub rate_deviation: i32, +} + +/// Applies the rate policy: accepts iff +/// `|F_d - F_s| * 1_000_000 <= TSC_TOLERANCE_PPM * F_s`, boundary included. +pub fn check_tsc_rate(declared_hz: u64, native_hz: u64) -> Result { + check_plausible_tsc_hz(declared_hz)?; + check_plausible_tsc_hz(native_hz)?; + let declared = i128::from(declared_hz); + let difference = i128::from(native_hz) - declared; + if difference.abs() * 1_000_000 > i128::from(TSC_TOLERANCE_PPM) * declared { + return Err(TimeAbiError::new( + TimeAbiCode::TscRateTolerance, + format!( + "destination TSC rate {native_hz} Hz deviates from the declared {declared_hz} Hz by {} ppb, beyond {TSC_TOLERANCE_PPM} ppm", + div_round_nearest(difference * 1_000_000_000, declared) + ), + )); + } + // Both values are bounded by the tolerance check above. + let deviation_ppb = div_round_nearest(difference * 1_000_000_000, declared) as i64; + let rate_deviation = div_round_nearest(difference * 1_000_000 * 65_536, declared) as i32; + Ok(RateCheck { + declared_hz, + native_hz, + deviation_ppb, + rate_deviation, + }) +} + +/// Applies the `dest-rate-offset-ppm` test hook to a measured native rate. +pub fn apply_rate_offset(native_hz: u64, offset_ppm: i32) -> Result { + let native = i128::from(native_hz); + let offset = div_round_nearest(native * i128::from(offset_ppm), 1_000_000); + u64::try_from(native + offset).map_err(|_| { + TimeAbiError::new( + TimeAbiCode::TestHook, + format!("rate offset {offset_ppm} ppm cannot apply to {native_hz} Hz"), + ) + }) +} + +/// Returns the LAPIC ticks of a counting-mode one-shot timer over +/// `downtime_ns`: `floor(D * L / 1_000_000_000 / divide)`. +/// +/// `divide` is the divide-configuration value, a power of two from 1 to +/// [`MAX_LAPIC_DIVIDE`]; other values return `None`. +pub fn lapic_ticks(downtime_ns: u64, apic_hz: u64, divide: u32) -> Option { + if !divide.is_power_of_two() || divide > MAX_LAPIC_DIVIDE { + return None; + } + let ticks = + u128::from(downtime_ns) * u128::from(apic_hz) / (1_000_000_000 * u128::from(divide)); + u64::try_from(ticks).ok() +} + +/// Divides with rounding to nearest, ties away from zero. `den` must be +/// positive. +fn div_round_nearest(num: i128, den: i128) -> i128 { + debug_assert!(den > 0); + let magnitude = (num.abs() + den / 2) / den; + if num < 0 { -magnitude } else { magnitude } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plausibility_bounds_are_inclusive() { + check_plausible_tsc_hz(MIN_TSC_HZ).unwrap(); + check_plausible_tsc_hz(MAX_TSC_HZ).unwrap(); + for hz in [0, MIN_TSC_HZ - 1, MAX_TSC_HZ + 1] { + assert_eq!( + check_plausible_tsc_hz(hz).unwrap_err().code, + TimeAbiCode::TscRateImplausible + ); + } + } + + #[test] + fn tolerance_boundary_is_accepted() { + let declared = 2_000_000_000; + // 250 ppm of 2 GHz is exactly 500 kHz. + let fast = check_tsc_rate(declared, declared + 500_000).unwrap(); + assert_eq!(fast.rate_deviation, 250 * 65_536); + assert_eq!(fast.deviation_ppb, 250_000); + let slow = check_tsc_rate(declared, declared - 500_000).unwrap(); + assert_eq!(slow.rate_deviation, -250 * 65_536); + for native in [declared + 500_001, declared - 500_001] { + assert_eq!( + check_tsc_rate(declared, native).unwrap_err().code, + TimeAbiCode::TscRateTolerance + ); + } + } + + #[test] + fn rate_deviation_rounds_to_nearest() { + let declared = 2_100_000_000; + let same = check_tsc_rate(declared, declared).unwrap(); + assert_eq!((same.rate_deviation, same.deviation_ppb), (0, 0)); + // 1 Hz of 2.1 GHz is 31.2076... in units of 2^-16 ppm. + assert_eq!( + check_tsc_rate(declared, declared + 1) + .unwrap() + .rate_deviation, + 31 + ); + assert_eq!( + check_tsc_rate(declared, declared - 1) + .unwrap() + .rate_deviation, + -31 + ); + // 200 ppm of 2.1 GHz. + let hooked = check_tsc_rate(declared, declared + 420_000).unwrap(); + assert_eq!(hooked.rate_deviation, 200 * 65_536); + } + + #[test] + fn rate_check_rejects_implausible_rates() { + assert_eq!( + check_tsc_rate(1_000, 1_000).unwrap_err().code, + TimeAbiCode::TscRateImplausible + ); + } + + #[test] + fn rate_offset_hook() { + assert_eq!( + apply_rate_offset(2_000_000_000, 251).unwrap(), + 2_000_502_000 + ); + assert_eq!( + apply_rate_offset(2_000_000_000, -200).unwrap(), + 1_999_600_000 + ); + assert_eq!(apply_rate_offset(2_000_000_000, 0).unwrap(), 2_000_000_000); + let rejected = apply_rate_offset(2_000_000_000, 251).unwrap(); + assert_eq!( + check_tsc_rate(2_000_000_000, rejected).unwrap_err().code, + TimeAbiCode::TscRateTolerance + ); + } + + #[test] + fn lapic_rates() { + assert_eq!(backend_lapic_hz("kvm").unwrap(), LAPIC_HZ_KVM); + assert_eq!(backend_lapic_hz("mshv").unwrap(), LAPIC_HZ_HYPERV); + assert_eq!(backend_lapic_hz("whp").unwrap(), LAPIC_HZ_HYPERV); + assert_eq!( + backend_lapic_hz("hvf").unwrap_err().code, + TimeAbiCode::LapicRateUnavailable + ); + check_lapic_hz("kvm", LAPIC_HZ_KVM, None).unwrap(); + check_lapic_hz("whp", LAPIC_HZ_HYPERV, Some(LAPIC_HZ_HYPERV)).unwrap(); + assert_eq!( + check_lapic_hz("whp", LAPIC_HZ_KVM, None).unwrap_err().code, + TimeAbiCode::LapicRateMismatch + ); + assert_eq!( + check_lapic_hz("mshv", LAPIC_HZ_HYPERV, Some(LAPIC_HZ_KVM)) + .unwrap_err() + .code, + TimeAbiCode::LapicRateMismatch + ); + } + + #[test] + fn lapic_tick_rule() { + // One second at 200 MHz with divide 1 and 16. + assert_eq!( + lapic_ticks(1_000_000_000, LAPIC_HZ_HYPERV, 1), + Some(200_000_000) + ); + assert_eq!( + lapic_ticks(1_000_000_000, LAPIC_HZ_HYPERV, 16), + Some(12_500_000) + ); + // Floors partial ticks. + assert_eq!(lapic_ticks(4, LAPIC_HZ_HYPERV, 1), Some(0)); + assert_eq!(lapic_ticks(5, LAPIC_HZ_HYPERV, 1), Some(1)); + assert_eq!(lapic_ticks(1_999, LAPIC_HZ_KVM, 2), Some(999)); + // Thirty days at 1 GHz. + let thirty_days = 30 * 24 * 60 * 60 * 1_000_000_000; + assert_eq!(lapic_ticks(thirty_days, LAPIC_HZ_KVM, 1), Some(thirty_days)); + assert_eq!(lapic_ticks(1, LAPIC_HZ_KVM, 3), None); + assert_eq!(lapic_ticks(1, LAPIC_HZ_KVM, 256), None); + assert_eq!(lapic_ticks(1, LAPIC_HZ_KVM, 0), None); + } +} diff --git a/vmm_core/virt/src/x86/cpu_profile/mod.rs b/vmm_core/virt/src/x86/cpu_profile/mod.rs new file mode 100644 index 00000000000..95032d3a85b --- /dev/null +++ b/vmm_core/virt/src/x86/cpu_profile/mod.rs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPU profiles: the pinned, versioned guest CPUID surfaces of the time ABI. diff --git a/vmm_core/virt/src/x86/mod.rs b/vmm_core/virt/src/x86/mod.rs index 1f1152c2223..209f5684b0d 100644 --- a/vmm_core/virt/src/x86/mod.rs +++ b/vmm_core/virt/src/x86/mod.rs @@ -5,6 +5,7 @@ pub mod apic_software_device; pub mod cpu_contract; +pub mod cpu_profile; pub mod snp; pub mod topology; pub mod tsc; diff --git a/vmm_core/virt_mshv/src/x86_64/tsc.rs b/vmm_core/virt_mshv/src/x86_64/tsc.rs index 50746fc4d3b..701a8be0323 100644 --- a/vmm_core/virt_mshv/src/x86_64/tsc.rs +++ b/vmm_core/virt_mshv/src/x86_64/tsc.rs @@ -342,6 +342,7 @@ mod tests { user_mode_memory_faults: false, lazy_memory_registration: false, versioned_cpu_contract: false, + time_abi: None, }) .unwrap() .build(PartitionConfig { From 3f516be3c84104fb28303cb4f62f21d0d5e9a877 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 02:48:36 -0700 Subject: [PATCH 169/304] virt: program explicit zero time ABI identity leaves The KVM spike showed that KVM answers an unlisted hypervisor leaf with the highest basic leaf instead of zeros, and its CPUID table cannot list the whole 0x40000000..=0x400000ff range. Add explicit zero results for 0x40000006..=0x4000000f and 0x40000080..=0x40000082, which Linux and Hyper-V-aware software probe, to the time ABI CPUID overlay and to the identity check, so these leaves read zero on every backend. Also remove the empty virt::x86::cpu_profile module: CPU profiles live in their own cpu_profile crate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt/src/time_abi/identity.rs | 60 ++++++++++++++++++++++-- vmm_core/virt/src/time_abi/mod.rs | 5 +- vmm_core/virt/src/x86/cpu_profile/mod.rs | 4 -- vmm_core/virt/src/x86/mod.rs | 1 - 4 files changed, 59 insertions(+), 11 deletions(-) delete mode 100644 vmm_core/virt/src/x86/cpu_profile/mod.rs diff --git a/vmm_core/virt/src/time_abi/identity.rs b/vmm_core/virt/src/time_abi/identity.rs index ad26fd921f0..9ae1583a784 100644 --- a/vmm_core/virt/src/time_abi/identity.rs +++ b/vmm_core/virt/src/time_abi/identity.rs @@ -17,6 +17,11 @@ pub const IDENTITY_CPUID_RANGE: RangeInclusive = 0x4000_0000..=0x4000_00ff; pub const HYPERVISOR_CPUID_RANGE: RangeInclusive = 0x4000_0000..=0x4fff_ffff; /// The highest identity leaf. pub const IDENTITY_MAX_LEAF: u32 = 0x4000_0005; +/// Leaves beyond the identity that every backend programs as zero, because +/// Linux and Hyper-V-aware software probe them and KVM cannot list the whole +/// range. +pub const IDENTITY_ZERO_LEAVES: [RangeInclusive; 2] = + [0x4000_0006..=0x4000_000f, 0x4000_0080..=0x4000_0082]; /// `"Microsoft Hv"` in EBX, ECX, and EDX of leaf `0x40000000`. pub const VENDOR_SIGNATURE: [u32; 3] = [0x7263_694d, 0x666f_736f, 0x7648_2074]; /// `"Hv#1"` in EAX of leaf `0x40000001`. @@ -70,6 +75,14 @@ pub fn identity_cpuid_leaves(vp_count: u32) -> [CpuidLeaf; 6] { ] } +/// Returns the explicit zero leaves of [`IDENTITY_ZERO_LEAVES`]. +pub fn identity_zero_cpuid_leaves() -> impl Iterator { + IDENTITY_ZERO_LEAVES + .into_iter() + .flatten() + .map(|leaf| CpuidLeaf::new(leaf, [0; 4])) +} + /// Returns CPUID results that enforce the CPU time bits over another CPUID /// source. Bits outside the time bits keep that source's values. /// @@ -104,13 +117,15 @@ pub fn time_bits_cpuid_leaves(invariant_tsc: bool) -> Vec { } /// Returns the time ABI CPUID results a backend applies over its own CPUID -/// until CPU profiles land: the identity leaves and the CPU time bits. +/// until CPU profiles land: the identity leaves, the explicit zero leaves, +/// and the CPU time bits. /// /// The caller must also ensure that no other leaf in /// [`HYPERVISOR_CPUID_RANGE`] is exposed. pub fn time_abi_cpuid(vp_count: u32, invariant_tsc: bool) -> CpuidLeafSet { let mut leaves = time_bits_cpuid_leaves(invariant_tsc); leaves.extend(identity_cpuid_leaves(vp_count)); + leaves.extend(identity_zero_cpuid_leaves()); CpuidLeafSet::new(leaves) } @@ -134,13 +149,18 @@ pub fn capabilities_cpuid<'a>( } } -/// Checks the identity leaves of an effective CPUID: the six leaves exactly -/// and no hypervisor signature at any base `0x40000100..=0x4000ff00`. +/// Checks the identity leaves of an effective CPUID: the six leaves exactly, +/// the explicit zero leaves, and no hypervisor signature at any base +/// `0x40000100..=0x4000ff00`. `cpuid` returns zeros for leaves absent from +/// the CPUID table being checked. pub fn check_identity( cpuid: &mut dyn FnMut(u32, u32) -> [u32; 4], vp_count: u32, ) -> Result<(), TimeAbiError> { - for leaf in identity_cpuid_leaves(vp_count) { + let expected = identity_cpuid_leaves(vp_count) + .into_iter() + .chain(identity_zero_cpuid_leaves()); + for leaf in expected { let actual = cpuid(leaf.function, 0); if actual != leaf.result { return Err(TimeAbiError::new( @@ -390,6 +410,38 @@ mod tests { check_identity(&mut cpuid, 3).unwrap_err().code, TimeAbiCode::IdentityRouting ); + // A "VS#1" interface signature, which the explicit zero leaves rule + // out. + let mut cpuid = |leaf, subleaf| { + if leaf == 0x4000_0081 { + [0x3123_5356, 0, 0, 0] + } else { + effective(leaf, subleaf, &overlay) + } + }; + assert_eq!( + check_identity(&mut cpuid, 2).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + } + + #[test] + fn overlay_programs_explicit_zero_leaves() { + let overlay = time_abi_cpuid(1, true); + let listed: Vec = overlay + .leaves() + .iter() + .map(|leaf| leaf.function) + .filter(|function| HYPERVISOR_CPUID_RANGE.contains(function)) + .collect(); + let mut expected: Vec = (0x4000_0000..=0x4000_000f).collect(); + expected.extend(0x4000_0080..=0x4000_0082); + assert_eq!(listed, expected); + for leaf in overlay.leaves() { + if (0x4000_0006..=0x4000_0082).contains(&leaf.function) { + assert_eq!((leaf.result, leaf.mask), ([0; 4], [!0; 4])); + } + } } #[test] diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index 4ade89956ae..01a9630bbfe 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -330,8 +330,9 @@ impl TimeAbiTestHooks { #[derive(Debug, Clone)] pub struct TimeAbiConfig { /// CPUID results the backend programs on every VP. Until CPU profiles - /// land this is [`identity::time_abi_cpuid`]: the identity leaves and the - /// CPU time bits, applied over the backend's own CPUID. + /// land this is [`identity::time_abi_cpuid`]: the identity leaves, the + /// explicit zero leaves, and the CPU time bits, applied over the + /// backend's own CPUID. pub cpuid: std::sync::Arc, /// The identity MSR handler, shared with the worker's `time-abi` state /// unit. diff --git a/vmm_core/virt/src/x86/cpu_profile/mod.rs b/vmm_core/virt/src/x86/cpu_profile/mod.rs deleted file mode 100644 index 95032d3a85b..00000000000 --- a/vmm_core/virt/src/x86/cpu_profile/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! CPU profiles: the pinned, versioned guest CPUID surfaces of the time ABI. diff --git a/vmm_core/virt/src/x86/mod.rs b/vmm_core/virt/src/x86/mod.rs index 209f5684b0d..1f1152c2223 100644 --- a/vmm_core/virt/src/x86/mod.rs +++ b/vmm_core/virt/src/x86/mod.rs @@ -5,7 +5,6 @@ pub mod apic_software_device; pub mod cpu_contract; -pub mod cpu_profile; pub mod snp; pub mod topology; pub mod tsc; From 491f9bec6312f6095610ddcdd29e5f6de8300d15 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 02:46:08 -0700 Subject: [PATCH 170/304] kvm: add MSR filter and vCPU attribute helpers Add the KVM primitives that the NVX time ABI backend needs: - Partition::set_msr_filter (KVM_X86_SET_MSR_FILTER) with MsrFilterRange, and Partition::enable_msr_exits to select the user-space MSR exit reasons (enable_unknown_msr_exits now uses it). - Processor::has_tsc_offset_attr (KVM_HAS_DEVICE_ATTR for KVM_VCPU_TSC_OFFSET, Linux 5.16 and later). - Processor::enable_cap for per-vCPU capabilities such as KVM_CAP_ENFORCE_PV_FEATURE_CPUID. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vm/kvm/src/lib.rs | 188 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 187 insertions(+), 1 deletion(-) diff --git a/vm/kvm/src/lib.rs b/vm/kvm/src/lib.rs index 1230dc5553e..fcf735ff5bb 100644 --- a/vm/kvm/src/lib.rs +++ b/vm/kvm/src/lib.rs @@ -133,6 +133,10 @@ mod ioctl { ioctl_write_ptr!(kvm_set_device_attr, KVMIO, 0xe1, kvm_device_attr); #[cfg(target_arch = "x86_64")] ioctl_write_ptr!(kvm_get_device_attr, KVMIO, 0xe2, kvm_device_attr); + #[cfg(target_arch = "x86_64")] + ioctl_write_ptr!(kvm_has_device_attr, KVMIO, 0xe3, kvm_device_attr); + #[cfg(target_arch = "x86_64")] + ioctl_write_ptr!(kvm_x86_set_msr_filter, KVMIO, 0xc6, kvm_msr_filter); ioctl_readwrite!(kvm_create_guest_memfd, KVMIO, 0xd4, kvm_create_guest_memfd); #[cfg(target_arch = "aarch64")] ioctl_readwrite_bad!( @@ -356,6 +360,12 @@ pub enum Error { requested: usize, failed_msr: u32, }, + #[cfg(target_arch = "x86_64")] + #[error("SetMsrFilter")] + SetMsrFilter(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("invalid MSR filter: {0}")] + InvalidMsrFilter(&'static str), #[error("SetupMce")] SetupMce(#[source] nix::Error), #[error("GetMceCapSupported")] @@ -625,6 +635,57 @@ struct Cpuid { entries: [kvm_cpuid_entry2; 256], } +/// One range of an MSR filter installed with [`Partition::set_msr_filter`]. +#[cfg(target_arch = "x86_64")] +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MsrFilterRange { + /// The first MSR of the range. + pub base: u32, + /// The number of MSRs in the range. + pub nmsrs: u32, + /// Whether the range applies to reads. + pub read: bool, + /// Whether the range applies to writes. + pub write: bool, + /// One bit per MSR, LSB first: a set bit lets KVM handle the access, a + /// clear bit denies it. A denied access raises #GP in the guest or, with + /// `KVM_MSR_EXIT_REASON_FILTER` enabled, exits to user space. Holds + /// `nmsrs.div_ceil(8)` bytes. + pub bitmap: Vec, +} + +#[cfg(target_arch = "x86_64")] +impl MsrFilterRange { + /// Returns a range that denies reads and writes of every MSR in `msrs`. + pub fn deny(msrs: std::ops::RangeInclusive) -> Self { + let nmsrs = msrs.end() - msrs.start() + 1; + Self { + base: *msrs.start(), + nmsrs, + read: true, + write: true, + bitmap: vec![0; nmsrs.div_ceil(8) as usize], + } + } + + fn validate(&self) -> Result<()> { + if self.nmsrs == 0 { + return Err(Error::InvalidMsrFilter("empty range")); + } + if !self.read && !self.write { + return Err(Error::InvalidMsrFilter("range applies to no access")); + } + if self.bitmap.len() != self.nmsrs.div_ceil(8) as usize + || self.bitmap.len() > KVM_MSR_FILTER_MAX_BITMAP_SIZE as usize + { + return Err(Error::InvalidMsrFilter( + "bitmap size does not match the range", + )); + } + Ok(()) + } +} + #[derive(Debug)] pub struct Partition { vm: File, @@ -831,6 +892,12 @@ impl Partition { } pub fn enable_unknown_msr_exits(&self) -> Result<()> { + self.enable_msr_exits(KVM_MSR_EXIT_REASON_UNKNOWN) + } + + /// Enables user-space MSR exits (`KVM_CAP_X86_USER_SPACE_MSR`) for the + /// `KVM_MSR_EXIT_REASON_*` bits in `reasons`, replacing the previous set. + pub fn enable_msr_exits(&self, reasons: u32) -> Result<()> { // SAFETY: Calling IOCTL as documented, with no special requirements. // TODO: We are not checking KVM_CAP_ENABLE_CAP_VM first. unsafe { @@ -838,7 +905,7 @@ impl Partition { self.vm.as_raw_fd(), &kvm_enable_cap { cap: KVM_CAP_X86_USER_SPACE_MSR, - args: [KVM_MSR_EXIT_REASON_UNKNOWN.into(), 0, 0, 0], + args: [reasons.into(), 0, 0, 0], ..Default::default() }, ) @@ -847,6 +914,47 @@ impl Partition { Ok(()) } + /// Installs an MSR filter (`KVM_X86_SET_MSR_FILTER`), replacing the + /// previous one. KVM checks `ranges` in order and uses the first range + /// that covers the MSR and the access type; accesses no range covers are + /// allowed if `default_allow`, and denied otherwise. + /// + /// The filter applies to guest accesses only: host-initiated + /// `KVM_GET_MSRS` and `KVM_SET_MSRS` bypass it. KVM never filters the + /// x2APIC MSRs. + #[cfg(target_arch = "x86_64")] + pub fn set_msr_filter(&self, default_allow: bool, ranges: &[MsrFilterRange]) -> Result<()> { + if ranges.len() > KVM_MSR_FILTER_MAX_RANGES as usize { + return Err(Error::InvalidMsrFilter("too many ranges")); + } + let mut filter = kvm_msr_filter { + flags: if default_allow { + KVM_MSR_FILTER_DEFAULT_ALLOW + } else { + KVM_MSR_FILTER_DEFAULT_DENY + }, + ..Default::default() + }; + for (slot, range) in filter.ranges.iter_mut().zip(ranges) { + range.validate()?; + *slot = kvm_msr_filter_range { + flags: if range.read { KVM_MSR_FILTER_READ } else { 0 } + | if range.write { KVM_MSR_FILTER_WRITE } else { 0 }, + nmsrs: range.nmsrs, + base: range.base, + // KVM only reads the bitmap, and copies it during the ioctl. + bitmap: range.bitmap.as_ptr().cast_mut(), + }; + } + // SAFETY: `filter` and the bitmaps it points to, which `ranges` owns, + // are valid for the duration of the ioctl. + unsafe { + ioctl::kvm_x86_set_msr_filter(self.vm.as_raw_fd(), &filter) + .map_err(Error::SetMsrFilter)?; + } + Ok(()) + } + /// Set the VCPU index of the BSP. This must be called before any VCPUs are /// created. #[cfg(target_arch = "x86_64")] @@ -1344,6 +1452,38 @@ impl<'a> Processor<'a> { Ok(()) } + /// Returns whether the vCPU supports the `KVM_VCPU_TSC_OFFSET` attribute + /// (`KVM_HAS_DEVICE_ATTR`, Linux 5.16 and later). + #[cfg(target_arch = "x86_64")] + pub fn has_tsc_offset_attr(&self) -> bool { + let attr = kvm_device_attr { + group: KVM_VCPU_TSC_CTRL, + attr: u64::from(KVM_VCPU_TSC_OFFSET), + addr: 0, + flags: 0, + }; + // SAFETY: KVM_HAS_DEVICE_ATTR does not dereference `attr.addr`. + unsafe { ioctl::kvm_has_device_attr(self.get().vcpu.as_raw_fd(), &attr) }.is_ok() + } + + /// Enables the vCPU capability `cap` with `args` (`KVM_ENABLE_CAP` on the + /// vCPU). `name` identifies the capability in errors. + pub fn enable_cap(&self, name: &'static str, cap: u32, args: [u64; 4]) -> Result<()> { + // SAFETY: Calling IOCTL as documented, with no special requirements. + unsafe { + ioctl::kvm_enable_cap( + self.get().vcpu.as_raw_fd(), + &kvm_enable_cap { + cap, + args, + ..Default::default() + }, + ) + .map_err(|err| Error::EnableCap(name, err))?; + } + Ok(()) + } + pub fn enable_synic(&self) -> Result<()> { // TODO: We are not checking KVM_CAP_ENABLE_CAP_VM first. // TODO: We are not calling KVM_CHECK_EXTENSION first. @@ -2336,4 +2476,50 @@ mod tests { assert_eq!(update.type_, KVM_SEV_SNP_PAGE_TYPE_ZERO_UAPI); assert_eq!(update.flags, 0); } + + #[test] + fn msr_filter_deny_range_covers_every_msr() { + let range = MsrFilterRange::deny(0x4000_0000..=0x4000_01ff); + assert_eq!(range.base, 0x4000_0000); + assert_eq!(range.nmsrs, 0x200); + assert!(range.read && range.write); + assert_eq!(range.bitmap, vec![0; 0x40]); + range.validate().unwrap(); + + let single = MsrFilterRange::deny(0x3b..=0x3b); + assert_eq!((single.base, single.nmsrs), (0x3b, 1)); + assert_eq!(single.bitmap, vec![0]); + single.validate().unwrap(); + } + + #[test] + fn msr_filter_rejects_malformed_ranges() { + let valid = MsrFilterRange::deny(0x10..=0x1f); + for range in [ + MsrFilterRange { + nmsrs: 0, + bitmap: Vec::new(), + ..valid.clone() + }, + MsrFilterRange { + read: false, + write: false, + ..valid.clone() + }, + MsrFilterRange { + bitmap: vec![0; 3], + ..valid.clone() + }, + MsrFilterRange { + nmsrs: (KVM_MSR_FILTER_MAX_BITMAP_SIZE + 1) * 8, + bitmap: vec![0; KVM_MSR_FILTER_MAX_BITMAP_SIZE as usize + 1], + ..valid.clone() + }, + ] { + assert!( + matches!(range.validate(), Err(Error::InvalidMsrFilter(_))), + "{range:?}" + ); + } + } } From 8fe01830d34b13e886dc1cc639edb1ffafacd754 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 02:46:44 -0700 Subject: [PATCH 171/304] virt_kvm: implement the NVX time ABI backend When ProtoPartitionConfig::time_abi is set (--x-time-abi-v1), the KVM partition implements the time ABI's KVM backend obligations and returns itself from Partition::time_abi: - CPUID: the time ABI CPUID applies after every other source and every KVM hypervisor-range leaf is removed; no frequency leaves are added. Capabilities come from the CPUID with the hypervisor range masked, and KVM_CAP_ENFORCE_PV_FEATURE_CPUID makes KVM's paravirtual MSRs #GP. - Identity MSRs: an MSR filter denies 0x40000000..=0x400001ff and IA32_TSC_ADJUST, and filtered accesses exit to TimeAbiMsrs (or #GP for IA32_TSC_ADJUST, which KVM serves even with its CPUID bit clear). - HV_X64_MSR_TSC_INVARIANT_CONTROL is mirrored into KVM with host-initiated writes after each guest write and before a VP runs, so KVM (6.3+) stops hiding invariant TSC, also after restore and reset. - TimeAbiBackend: native rate from KVM_GET_TSC_KHZ, 1 GHz LAPIC, preflight (offset attribute, equal rates, unscaled TSC), effective CPUID, a capture anchor that pairs VP 0's offset with a host TSC bracket around the host time sample, and a synchronized set that writes one KVM_VCPU_TSC_OFFSET to every vCPU and reads it back. The set supersedes the saved IA32_TSC values, which Linux 6.6 discards for snapshots taken in the guest's first second. The legacy path is unchanged while time_abi is None; with it set, the legacy KVM_SET_TSC_KHZ and KVM_SET_CLOCK entry points fail. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 109 +- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 1051 +++++++++++++++++ vmm_core/virt_kvm/src/lib.rs | 14 + 3 files changed, 1166 insertions(+), 8 deletions(-) create mode 100644 vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index b4d76935bab..bffa164413b 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -8,10 +8,12 @@ mod cpu_contract; mod regs; pub(crate) mod snp; +mod time_abi; mod tsc; mod vm_state; mod vp_state; +pub(crate) use time_abi::KvmTimeAbi; pub(crate) use vp_state::seg_reg; pub(crate) use vp_state::table_reg; @@ -80,6 +82,7 @@ use virt::irqcon::DeliveryMode; use virt::irqcon::IoApicRouting; use virt::irqcon::MsiRequest; use virt::state::StateElement; +use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use virt::vm::AccessVmState; use virt::x86::HardwareBreakpoint; use virt::x86::max_physical_address_size_from_cpuid; @@ -168,6 +171,14 @@ impl virt::Hypervisor for Kvm { } let nested_virt = config.nested_virt; + if config.time_abi.is_some() { + if config.hv_config.is_some() { + return Err(KvmError::TimeAbiUnsupported("Hyper-V enlightenments")); + } + if !matches!(config.isolation.isolation_type(), virt::IsolationType::None) { + return Err(KvmError::TimeAbiUnsupported("isolation")); + } + } let supported_cpuid = self.kvm.supported_cpuid()?; // KVM's in-kernel LAPIC only exposes the CMCI LVT register (APIC @@ -434,6 +445,9 @@ impl virt::Hypervisor for Kvm { vm.enable_split_irqchip(virt::irqcon::IRQ_LINES as u32)?; vm.enable_x2apic_api()?; vm.enable_unknown_msr_exits()?; + if config.time_abi.is_some() { + time_abi::install_identity_msr_filter(&vm)?; + } if let Some(sev) = &sev { vm.sev_snp_init( @@ -505,7 +519,10 @@ impl ProtoPartition for KvmProtoPartition<'_> { cpuid.extend(config.cpuid); cpuid.extend(topology_leaves); cpuid.extend(psfd_fixup); - let cpuid = CpuidLeafSet::new(cpuid); + let cpuid = match &self.config.time_abi { + Some(time_abi) => time_abi::compose_cpuid(cpuid, &time_abi.cpuid), + None => CpuidLeafSet::new(cpuid), + }; let bsp_apic_id = self.config.processor_topology.vp_arch(VpIndex::BSP).apic_id; if bsp_apic_id != 0 { @@ -524,12 +541,39 @@ impl ProtoPartition for KvmProtoPartition<'_> { self.vm.add_vp(vp_info.apic_id)?; } - let cpuid = tsc::add_frequency_leaves(&self.vm, bsp_apic_id, cpuid)?; - let mut caps = virt::PartitionCapabilities::from_cpuid( - self.config.processor_topology, - &mut |function, index| cpuid.result(function, index, &[0; 4]), - ) + let cpuid = match &self.config.time_abi { + None => tsc::add_frequency_leaves(&self.vm, bsp_apic_id, cpuid)?, + Some(_) => cpuid, + }; + let mut caps = if self.config.time_abi.is_some() { + // The identity leaves must not make the partition model Hyper-V + // or the KVM clock. + virt::PartitionCapabilities::from_cpuid( + self.config.processor_topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut |function, index| { + cpuid.result(function, index, &[0; 4]) + }), + ) + } else { + virt::PartitionCapabilities::from_cpuid( + self.config.processor_topology, + &mut |function, index| cpuid.result(function, index, &[0; 4]), + ) + } .map_err(KvmError::Capabilities)?; + let time_abi = match &self.config.time_abi { + Some(config) => { + time_abi::check_capabilities(&caps)?; + let vcpus: Vec<_> = self + .config + .processor_topology + .vps_arch() + .map(|vp_info| vp_info.apic_id) + .collect(); + Some(KvmTimeAbi::new(&self.vm, &vcpus, config.msrs.clone())?) + } + None => None, + }; caps.can_freeze_time = false; caps.nested_virt = self.nested_virt; @@ -607,6 +651,7 @@ impl ProtoPartition for KvmProtoPartition<'_> { reserved_vps_per_socket: self.config.processor_topology.reserved_vps_per_socket(), mce_cmci_supported: x86defs::McgCap::from(self.supported_mce_cap).cmci_p(), synic_ports: Default::default(), + time_abi, }); let partition = KvmPartition { @@ -784,6 +829,11 @@ impl Partition for KvmPartition { } fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), Self::Error> { + if self.inner.time_abi.is_some() { + return Err(KvmError::TimeAbiUnsupported( + "TSC scaling (KVM_SET_TSC_KHZ)", + )); + } self.inner.set_tsc_frequency_hz(frequency_hz) } @@ -792,9 +842,21 @@ impl Partition for KvmPartition { } fn advance_snapshot_time(&self, duration: Duration) -> Result<(), Self::Error> { + if self.inner.time_abi.is_some() { + return Err(KvmError::TimeAbiUnsupported( + "the KVM clock (KVM_SET_CLOCK)", + )); + } self.inner.advance_snapshot_time(duration) } + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { + self.inner + .time_abi + .is_some() + .then_some(&*self.inner as &dyn virt::time_abi::TimeAbiBackend) + } + fn supports_reset(&self) -> Option<&dyn ResetPartition> { // TODO: Support resetting SNP launch state and rebuilding the protected // guest before advertising reset support. @@ -1608,6 +1670,12 @@ impl<'p> Processor for KvmProcessor<'p> { stop: StopVp<'_>, dev: &impl CpuIo, ) -> Result { + if let Some(time_abi) = &self.partition.time_abi { + // A restore or a reset may have changed the guest-visible value. + time_abi + .sync_invariant_control(&self.kvm) + .map_err(|err| dev.fatal_error(KvmRunVpError::TimeAbi(err).into()))?; + } loop { self.inner.needs_yield.maybe_yield().await; stop.check()?; @@ -1691,7 +1759,17 @@ impl<'p> Processor for KvmProcessor<'p> { dev.read_mmio(self.vpindex, address, data).await } kvm::Exit::MsrRead { index, data, error } => { - if MYSTERY_MSRS.contains(&index) { + if let Some(result) = self + .partition + .time_abi + .as_ref() + .and_then(|time_abi| time_abi.read_msr(self.vpindex, index)) + { + match result { + Ok(value) => *data = value, + Err(_) => *error = 1, + } + } else if MYSTERY_MSRS.contains(&index) { tracelimit::warn_ratelimited!(index, "stubbed out mystery MSR read"); *data = 0; } else { @@ -1700,7 +1778,22 @@ impl<'p> Processor for KvmProcessor<'p> { } } kvm::Exit::MsrWrite { index, data, error } => { - if MYSTERY_MSRS.contains(&index) { + if let Some(time_abi) = &self.partition.time_abi + && let Some(result) = time_abi.write_msr(self.vpindex, index, data) + { + match result { + Ok(()) if index == MSR_TSC_INVARIANT_CONTROL => { + // KVM hides invariant TSC until its own + // copy is set; mirror it before the guest + // runs on. + time_abi.sync_invariant_control(&self.kvm).map_err(|err| { + dev.fatal_error(KvmRunVpError::TimeAbi(err).into()) + })?; + } + Ok(()) => {} + Err(_) => *error = 1, + } + } else if MYSTERY_MSRS.contains(&index) { tracelimit::warn_ratelimited!(index, "stubbed out mystery MSR write"); } else { tracelimit::error_ratelimited!(index, data, "unrecognized msr write"); diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs new file mode 100644 index 00000000000..2a3e4140a2d --- /dev/null +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -0,0 +1,1051 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The NVX time ABI v1 on KVM. +//! +//! Active only for partitions built with +//! [`ProtoPartitionConfig::time_abi`](virt::ProtoPartitionConfig::time_abi). +//! It implements the KVM column of "Backend obligations" in NVX +//! `doc/design/time-abi.md`: +//! +//! - **CPUID.** The time ABI CPUID applies after every other CPUID source and +//! no KVM `0x4xxxxxxx` leaf remains, so the guest sees the Hyper-V frequency +//! identity instead of KVM's signature. Without a KVM signature the guest +//! has no KVM paravirtual feature, and `KVM_CAP_ENFORCE_PV_FEATURE_CPUID` +//! makes their MSRs (kvmclock and the others) raise #GP. The partition +//! capabilities come from the CPUID with the hypervisor range masked, so +//! neither `hv1` nor `kvm_clock` is present. +//! - **Identity MSRs.** An MSR filter denies `0x40000000..=0x400001ff` and +//! `IA32_TSC_ADJUST`, and `KVM_MSR_EXIT_REASON_FILTER` delivers every guest +//! access to OpenVMM: [`TimeAbiMsrs`] serves the identity range, and +//! `IA32_TSC_ADJUST` raises #GP (KVM serves it even with its CPUID bit +//! clear). KVM checks the filter before its in-kernel Hyper-V MSRs, so those +//! never see a guest access. +//! - **Invariant TSC.** With `"Hv#1"` and `AccessTscInvariantControls` in +//! CPUID, KVM (Linux 6.3 and later) hides the invariant-TSC bit of CPUID +//! `0x80000007` until its own copy of `HV_X64_MSR_TSC_INVARIANT_CONTROL` is +//! set. The filter keeps the guest's writes away from KVM, so the backend +//! mirrors the guest-visible value into KVM with host-initiated writes, +//! which bypass the filter: after every guest write, and before a VP runs +//! after the value changed otherwise (restore and reset). +//! - **TSC.** The guest TSC is the host TSC plus a per-vCPU offset and is +//! never scaled: the backend never calls `KVM_SET_TSC_KHZ`, and `F_d` is +//! VP 0's `KVM_GET_TSC_KHZ`. The synchronized set writes one +//! `KVM_VCPU_TSC_OFFSET` (Linux 5.16 and later) to every vCPU, computed from +//! one host TSC read, and reads every offset back. +//! - **LAPIC.** The in-kernel LAPIC timer counts KVM's fixed 1 GHz bus clock; +//! `KVM_CAP_X86_APIC_BUS_CYCLES_NS` is never set. +//! +//! **Restore and the saved per-VP TSC.** VP state restore still writes each +//! VP's saved `IA32_TSC`. KVM passes host-initiated `IA32_TSC` writes through +//! its TSC synchronization heuristic: a value within about one second of the +//! TSC that KVM expects is treated as a synchronization attempt, and KVM +//! keeps its current offset instead. Linux 6.6 applies this to the first +//! write after vCPU creation as well, so it discards the saved TSC of any +//! snapshot taken within about a second of the guest's boot; Linux 6.7 and +//! later honor the first user-space write. The synchronized set overwrites +//! every vCPU's offset before any VP runs, so what KVM made of the saved +//! values never reaches the guest, on either kernel. KVM computes the LAPIC +//! timer's TSC deadline when the LAPIC state is set, so the restore +//! orchestrator re-applies the LAPIC state after the synchronized set. + +use super::KvmPartitionInner; +use crate::KvmError; +use parking_lot::Mutex; +use std::sync::Arc; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; +use virt::VpIndex; +use virt::time_abi::BackendPreflight; +use virt::time_abi::HostTimeSample; +use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::TimeAbiBackend; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiMsrs; +use virt::time_abi::TscAnchor; +use virt::time_abi::TscSetReport; +use virt::time_abi::TscSyncMethod; +use virt::time_abi::host::sample_host_time; +use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; +use virt::time_abi::msr::IDENTITY_MSR_RANGE; +use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; +use virt::time_abi::rate::LAPIC_HZ_KVM; +use virt::x86::MsrError; + +/// `IA32_TSC_ADJUST`, which the time ABI hides. +const MSR_IA32_TSC_ADJUST: u32 = 0x3b; + +/// The most a host time sample may be from the TSC read it is paired with +/// (spec: "Capture anchor"). +const MAX_ANCHOR_PAIRING_NS: u64 = 10_000; + +/// Anchor attempts before giving up: an attempt only fails when the thread +/// is interrupted between the two TSC reads around the host sample. +const ANCHOR_ATTEMPTS: usize = 16; + +/// Returns the MSR filter ranges of the time ABI: deny every guest access to +/// the identity range and to `IA32_TSC_ADJUST`, so they exit to OpenVMM. +pub(crate) fn identity_msr_filter() -> [kvm::MsrFilterRange; 2] { + [ + kvm::MsrFilterRange::deny(IDENTITY_MSR_RANGE), + kvm::MsrFilterRange::deny(MSR_IA32_TSC_ADJUST..=MSR_IA32_TSC_ADJUST), + ] +} + +/// Routes the time ABI's MSRs to user space: user-space exits for unknown +/// and filtered MSRs, and the [`identity_msr_filter`] (every other MSR keeps +/// its in-kernel handling). +pub(crate) fn install_identity_msr_filter(vm: &kvm::Partition) -> Result<(), TimeAbiError> { + for (name, cap) in [ + ( + "KVM_CAP_X86_USER_SPACE_MSR", + kvm::KVM_CAP_X86_USER_SPACE_MSR, + ), + ("KVM_CAP_X86_MSR_FILTER", kvm::KVM_CAP_X86_MSR_FILTER), + ] { + if !vm.check_extension(cap).is_ok_and(|value| value > 0) { + return Err(routing_error(format!( + "KVM lacks {name} (Linux 5.10 or later)" + ))); + } + } + vm.enable_msr_exits(kvm::KVM_MSR_EXIT_REASON_UNKNOWN | kvm::KVM_MSR_EXIT_REASON_FILTER) + .map_err(|err| routing_error(format!("cannot enable user-space MSR exits: {err:#}")))?; + vm.set_msr_filter(true, &identity_msr_filter()) + .map_err(|err| routing_error(format!("cannot install the MSR filter: {err:#}")))?; + Ok(()) +} + +/// Composes the partition CPUID: `base` (every other CPUID source, in +/// order) without its hypervisor-range leaves, then the time ABI CPUID, so +/// that the time ABI leaves override every other source. +pub(crate) fn compose_cpuid(base: Vec, time_abi: &CpuidLeafSet) -> CpuidLeafSet { + let mut leaves: Vec<_> = base + .into_iter() + .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) + .collect(); + leaves.extend(time_abi.leaves().iter().copied()); + CpuidLeafSet::new(leaves) +} + +/// Converts the KVM CPUID entries programmed for a VP to CPUID leaves. +pub(crate) fn cpuid_leaves(entries: &[kvm::kvm_cpuid_entry2]) -> Vec { + entries + .iter() + .map(|entry| { + let leaf = CpuidLeaf::new(entry.function, [entry.eax, entry.ebx, entry.ecx, entry.edx]); + if entry.flags & kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX != 0 { + leaf.indexed(entry.index) + } else { + leaf + } + }) + .collect() +} + +fn routing_error(message: impl Into) -> TimeAbiError { + TimeAbiError::new(TimeAbiCode::IdentityRouting, message) +} + +/// Two host TSC reads around another host operation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct HostTscBracket { + pub start: u64, + pub end: u64, +} + +/// Waits until every earlier instruction has completed locally, so that a +/// TSC read is not reordered with its neighbors. +fn lfence() { + // SAFETY: LFENCE only orders instruction execution. + unsafe { core::arch::asm!("lfence", options(nostack, preserves_flags)) }; +} + +impl HostTscBracket { + /// Runs `f` between two host TSC reads. + pub(crate) fn around(f: impl FnOnce() -> T) -> (Self, T) { + lfence(); + let start = safe_intrinsics::rdtsc(); + lfence(); + let value = f(); + lfence(); + let end = safe_intrinsics::rdtsc(); + (Self { start, end }, value) + } + + /// The host TSC at the middle of the bracket, and the most the instant of + /// the bracketed operation can be from it, in cycles. `None` if the TSC + /// went backwards. + pub(crate) fn midpoint(&self) -> Option<(u64, u64)> { + let width = self.end.checked_sub(self.start)?; + Some((self.start + width / 2, width.div_ceil(2))) + } +} + +/// Converts `cycles` at `tsc_hz` to nanoseconds, rounding up. +fn cycles_to_ns_ceil(cycles: u64, tsc_hz: u64) -> u64 { + let ns = (u128::from(cycles) * 1_000_000_000).div_ceil(u128::from(tsc_hz.max(1))); + u64::try_from(ns).unwrap_or(u64::MAX) +} + +/// Pairs a host time sample with the bracket of host TSC reads around it. +/// +/// Returns the host TSC at the middle of the bracket and the pairing +/// uncertainty, half the bracket, in nanoseconds at `tsc_hz`. `None` if the +/// TSC went backwards. +pub(crate) fn pair_host_sample(bracket: HostTscBracket, tsc_hz: u64) -> Option<(u64, u64)> { + let (host_tsc, half_width) = bracket.midpoint()?; + Some((host_tsc, cycles_to_ns_ceil(half_width, tsc_hz))) +} + +/// A host time sample paired with the host TSC. +#[derive(Debug, Clone, Copy)] +struct PairedSample { + host_tsc: u64, + sample: HostTimeSample, + pairing_ns: u64, +} + +/// Takes a host time sample paired with the host TSC: the first of +/// [`ANCHOR_ATTEMPTS`] attempts within [`MAX_ANCHOR_PAIRING_NS`], or else the +/// tightest one. +fn paired_host_sample(tsc_hz: u64) -> Result { + let mut best: Option = None; + for _ in 0..ANCHOR_ATTEMPTS { + let (bracket, sample) = HostTscBracket::around(sample_host_time); + let sample = sample?; + let Some((host_tsc, pairing_ns)) = pair_host_sample(bracket, tsc_hz) else { + continue; + }; + let paired = PairedSample { + host_tsc, + sample, + pairing_ns, + }; + if pairing_ns <= MAX_ANCHOR_PAIRING_NS { + return Ok(paired); + } + if best.is_none_or(|best| pairing_ns < best.pairing_ns) { + best = Some(paired); + } + } + best.ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::TscAnchor, + "the host TSC went backwards around every host time sample", + ) + }) +} + +/// Returns whether the guest TSC read from a vCPU between the host TSC reads +/// of `bracket` is the host TSC plus `offset`, as it is without TSC scaling. +pub(crate) fn tsc_is_unscaled(guest_tsc: u64, offset: u64, bracket: HostTscBracket) -> bool { + let host_tsc = guest_tsc.wrapping_sub(offset); + (bracket.start..=bracket.end).contains(&host_tsc) +} + +/// Checks the synchronized set's read-back: every vCPU's offset must be the +/// common offset. +pub(crate) fn check_offset_readback( + offset: u64, + readback: &[(VpIndex, u64)], +) -> Result<(), TimeAbiError> { + let mismatched: Vec<_> = readback + .iter() + .filter(|(_, value)| *value != offset) + .map(|(vp, value)| format!("VP {}: {value:#x}", vp.index())) + .collect(); + if !mismatched.is_empty() { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!( + "KVM_VCPU_TSC_OFFSET read back differs from the common offset {offset:#x}: {}", + mismatched.join(", ") + ), + )); + } + Ok(()) +} + +/// The time ABI state of a KVM partition. +#[derive(Debug)] +pub(crate) struct KvmTimeAbi { + msrs: Arc, + /// The value of KVM's own `HV_X64_MSR_TSC_INVARIANT_CONTROL`, or `None` + /// if this KVM does not implement it (Linux before 6.3, or built without + /// Hyper-V emulation), in which case KVM does not hide invariant TSC + /// either. + kvm_invariant_control: Option>, +} + +impl KvmTimeAbi { + /// Sets up the vCPUs of a new partition for the time ABI: KVM's + /// paravirtual MSRs raise #GP, and KVM's copy of + /// `HV_X64_MSR_TSC_INVARIANT_CONTROL` is probed. + pub(crate) fn new( + vm: &kvm::Partition, + vcpus: &[u32], + msrs: Arc, + ) -> Result { + for &vcpu in vcpus { + vm.vp(vcpu) + .enable_cap( + "enforce_pv_feature_cpuid", + kvm::KVM_CAP_ENFORCE_PV_FEATURE_CPUID, + [1, 0, 0, 0], + ) + .map_err(|err| { + routing_error(format!( + "cannot hide KVM's paravirtual MSRs on vCPU {vcpu}: {err:#}" + )) + })?; + } + // Writing KVM's power-on value tells whether KVM implements the MSR. + // `vcpus[0]` is VP 0. + let kvm_invariant_control = + match vm.vp(vcpus[0]).set_msrs(&[(MSR_TSC_INVARIANT_CONTROL, 0)]) { + Ok(()) => Some(Mutex::new(0)), + Err(kvm::Error::IncompleteMsrs { .. }) => { + tracing::info!( + "KVM lacks HV_X64_MSR_TSC_INVARIANT_CONTROL, so it does not hide \ + invariant TSC" + ); + None + } + Err(err) => { + return Err(routing_error(format!( + "cannot write KVM's HV_X64_MSR_TSC_INVARIANT_CONTROL: {err:#}" + ))); + } + }; + Ok(Self { + msrs, + kvm_invariant_control, + }) + } + + /// Handles a guest read of `msr` that exited to user space. `None` means + /// the MSR is not the time ABI's. + pub(crate) fn read_msr(&self, vp: VpIndex, msr: u32) -> Option> { + if msr == MSR_IA32_TSC_ADJUST { + tracelimit::info_ratelimited!(vp = vp.index(), "IA32_TSC_ADJUST read raises #GP"); + return Some(Err(MsrError::InvalidAccess)); + } + self.msrs.read(vp, msr) + } + + /// Handles a guest write of `value` to `msr` that exited to user space. + /// `None` means the MSR is not the time ABI's. + /// + /// After an accepted `HV_X64_MSR_TSC_INVARIANT_CONTROL` write, the caller + /// mirrors it with [`Self::sync_invariant_control`] before resuming the + /// guest. + pub(crate) fn write_msr( + &self, + vp: VpIndex, + msr: u32, + value: u64, + ) -> Option> { + if msr == MSR_IA32_TSC_ADJUST { + tracelimit::info_ratelimited!( + vp = vp.index(), + value, + "IA32_TSC_ADJUST write raises #GP" + ); + return Some(Err(MsrError::InvalidAccess)); + } + self.msrs.write(vp, msr, value) + } + + /// Makes KVM's copy of `HV_X64_MSR_TSC_INVARIANT_CONTROL` equal the + /// guest-visible value, with a host-initiated write through `vp` when it + /// differs. + pub(crate) fn sync_invariant_control( + &self, + vp: &kvm::Processor<'_>, + ) -> Result<(), TimeAbiError> { + let Some(kvm_value) = &self.kvm_invariant_control else { + return Ok(()); + }; + // Hold the lock while writing so that concurrent callers apply the + // latest guest-visible value last. + let mut kvm_value = kvm_value.lock(); + let value = self.msrs.tsc_invariant_control(); + if *kvm_value != value { + vp.set_msrs(&[(MSR_TSC_INVARIANT_CONTROL, value)]) + .map_err(|err| { + routing_error(format!( + "cannot mirror HV_X64_MSR_TSC_INVARIANT_CONTROL = {value} into KVM: {err:#}" + )) + })?; + tracing::debug!(value, "mirrored HV_X64_MSR_TSC_INVARIANT_CONTROL into KVM"); + *kvm_value = value; + } + Ok(()) + } +} + +impl KvmPartitionInner { + fn time_abi_vps(&self) -> impl Iterator)> { + self.vps.iter().map(|vp| { + ( + vp.vp_info().base.vp_index, + self.kvm.vp(vp.vp_info().apic_id), + ) + }) + } +} + +impl TimeAbiBackend for KvmPartitionInner { + fn native_tsc_hz(&self) -> Result { + self.vp_kvm(VpIndex::BSP).tsc_frequency_hz().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!("KVM_GET_TSC_KHZ failed on VP 0: {err:#}"), + ) + }) + } + + fn lapic_hz(&self) -> Result { + // The partition never sets the bus cycle, so it is KVM's default, + // which KVM reports for the capability. Kernels before 6.11 lack the + // capability (0) and have the same fixed 1 ns cycle. + match self + .kvm + .check_extension(kvm::KVM_CAP_X86_APIC_BUS_CYCLES_NS) + { + Ok(0 | 1) => Ok(LAPIC_HZ_KVM), + Ok(ns) => Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!("KVM's default APIC bus cycle is {ns} ns, not 1 ns"), + )), + Err(err) => Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!("cannot query KVM_CAP_X86_APIC_BUS_CYCLES_NS: {err:#}"), + )), + } + } + + fn preflight(&self) -> Result { + // Partition creation fails with E_IDENTITY_ROUTING unless the MSR + // filter and the user-space MSR exits are installed, so a built + // partition always routes the identity MSRs to OpenVMM. + let native_khz = self.native_tsc_hz()? / 1000; + for (vp_index, vp) in self.time_abi_vps() { + if !vp.has_tsc_offset_attr() { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!( + "VP {} lacks KVM_VCPU_TSC_OFFSET (Linux 5.16 or later)", + vp_index.index() + ), + )); + } + let scaling = |message: String| { + TimeAbiError::new( + TimeAbiCode::TscScalingActive, + format!("VP {}: {message}", vp_index.index()), + ) + }; + let khz = vp.tsc_frequency_hz().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!("KVM_GET_TSC_KHZ failed on VP {}: {err:#}", vp_index.index()), + ) + })? / 1000; + if khz != native_khz { + return Err(scaling(format!( + "the TSC runs at {khz} kHz, VP 0 at {native_khz} kHz" + ))); + } + // Without scaling, the guest TSC is the host TSC plus the offset. + // KVM scales only outside its tolerance (250 ppm by default), far + // more than the bracket width at any host TSC value. + let offset = vp + .tsc_offset() + .map_err(|err| scaling(format!("cannot read KVM_VCPU_TSC_OFFSET: {err:#}")))?; + let mut guest_tsc = [0]; + let (bracket, result) = + HostTscBracket::around(|| vp.get_msrs(&[x86defs::X86X_MSR_TSC], &mut guest_tsc)); + result.map_err(|err| scaling(format!("cannot read IA32_TSC: {err:#}")))?; + if !tsc_is_unscaled(guest_tsc[0], offset, bracket) { + return Err(scaling(format!( + "IA32_TSC {:#x} is not the host TSC in [{:#x}, {:#x}] plus the offset {offset:#x}", + guest_tsc[0], bracket.start, bracket.end + ))); + } + } + tracing::info!( + native_khz, + vps = self.vps.len(), + "time ABI KVM preflight: identity MSRs exit to OpenVMM, common TSC offset, no scaling" + ); + Ok(BackendPreflight { + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::CommonOffset, + }) + } + + fn effective_cpuid(&self) -> Result, TimeAbiError> { + Ok(cpuid_leaves(&self.bsp_cpuid)) + } + + fn capture_anchor(&self) -> Result { + let tsc_hz = self.native_tsc_hz()?; + // With every VP stopped, VP 0's TSC is the host TSC plus its offset + // (preflight verified that it is not scaled). Reading the offset once + // and pairing the host TSC with the host sample is much tighter than + // pairing the sample with a KVM_GET_MSRS round trip. + let offset = self.vp_kvm(VpIndex::BSP).tsc_offset().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!("cannot read VP 0's TSC offset: {err:#}"), + ) + })?; + let paired = paired_host_sample(tsc_hz)?; + if paired.pairing_ns > MAX_ANCHOR_PAIRING_NS { + return Err(TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!( + "cannot pair VP 0's TSC with a host time sample within \ + {MAX_ANCHOR_PAIRING_NS} ns: the best of {ANCHOR_ATTEMPTS} attempts is \ + {} ns", + paired.pairing_ns + ), + )); + } + let tsc = paired.host_tsc.wrapping_add(offset); + tracing::debug!( + tsc, + sample = ?paired.sample, + pairing_ns = paired.pairing_ns, + "time ABI capture anchor" + ); + Ok(TscAnchor { + tsc, + sample: paired.sample, + pairing_ns: paired.pairing_ns, + }) + } + + fn set_synchronized_tsc( + &self, + target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result { + let readback_error = + |message: String| TimeAbiError::new(TimeAbiCode::TscSyncReadback, message); + let tsc_hz = self.native_tsc_hz()?; + // The offsets the restore left, logged to show what KVM made of the + // saved IA32_TSC values (see the module documentation). + let previous = self + .time_abi_vps() + .map(|(vp_index, vp)| { + vp.tsc_offset().map_err(|err| { + readback_error(format!( + "cannot read VP {}'s TSC offset: {err:#}", + vp_index.index() + )) + }) + }) + .collect::, _>>()?; + + // The restore anchor. Every guest TSC equals the target at the host + // instant of `host_tsc` and runs from there. + let paired = paired_host_sample(tsc_hz)?; + if paired.pairing_ns > MAX_ANCHOR_PAIRING_NS { + tracelimit::warn_ratelimited!( + pairing_ns = paired.pairing_ns, + "time ABI restore anchor is paired loosely" + ); + } + let host_tsc = paired.host_tsc; + let target_tsc = target(&paired.sample)?; + let offset = target_tsc.wrapping_sub(host_tsc); + for (vp_index, vp) in self.time_abi_vps() { + vp.set_tsc_offset(offset).map_err(|err| { + readback_error(format!( + "cannot set VP {}'s TSC offset to {offset:#x}: {err:#}", + vp_index.index() + )) + })?; + } + let readback = self + .time_abi_vps() + .map(|(vp_index, vp)| { + vp.tsc_offset() + .map(|value| (vp_index, value)) + .map_err(|err| { + readback_error(format!( + "cannot read back VP {}'s TSC offset: {err:#}", + vp_index.index() + )) + }) + }) + .collect::, _>>()?; + check_offset_readback(offset, &readback)?; + tracing::info!( + target_tsc, + host_tsc, + offset = format_args!("{offset:#x}"), + pairing_ns = paired.pairing_ns, + vps = readback.len(), + previous_minus_target_cycles = ?previous + .iter() + .map(|previous| host_tsc.wrapping_add(*previous).wrapping_sub(target_tsc) as i64) + .collect::>(), + "time ABI synchronized TSC set" + ); + Ok(TscSetReport { + target: target_tsc, + sample: paired.sample, + readback, + method: TscSyncMethod::CommonOffset, + }) + } +} + +/// Reports the time ABI capabilities that the partition does not have. +pub(crate) fn check_capabilities(caps: &virt::PartitionCapabilities) -> Result<(), KvmError> { + if caps.hv1 || caps.kvm_clock { + return Err(KvmError::TimeAbi(routing_error(format!( + "partition capabilities expose hv1 ({}) or the KVM clock ({})", + caps.hv1, caps.kvm_clock + )))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use virt::time_abi::DeclaredRates; + use virt::time_abi::identity::time_abi_cpuid; + use virt::time_abi::msr::MSR_APIC_FREQUENCY; + use virt::time_abi::msr::MSR_TSC_FREQUENCY; + use virt::time_abi::msr::MSR_VP_INDEX; + + #[test] + fn filter_denies_exactly_the_owned_msrs() { + let filter = identity_msr_filter(); + let owned = [ + (0x4000_0000, 0x4000_01ff), + (MSR_IA32_TSC_ADJUST, MSR_IA32_TSC_ADJUST), + ]; + for (range, (first, last)) in filter.iter().zip(owned) { + assert_eq!(range.base, first); + assert_eq!(range.base + range.nmsrs - 1, last); + assert!(range.read && range.write); + assert!(range.bitmap.iter().all(|&byte| byte == 0)); + } + assert_eq!(filter[0].nmsrs, 0x200); + assert_eq!(filter[1].nmsrs, 1); + } + + fn kvm_like_base() -> Vec { + vec![ + CpuidLeaf::new(0, [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + CpuidLeaf::new( + 1, + [ + 0x0005_0654, + 0x0010_0800, + 0xfffa_3203 | (1 << 24), + 0x0f8b_fbff, + ], + ), + CpuidLeaf::new(6, [0x4, 0, 0, 0]), + CpuidLeaf::new(7, [0, 0xd19f_4fbb | (1 << 1), 0, 0]).indexed(0), + CpuidLeaf::new(0xa, [0x0740_4f04, 0, 0, 0x603]), + CpuidLeaf::new(0x15, [2, 0xd4, 0x017d_7840, 0]), + // KVM's signature and features, and a stray leaf at another + // hypervisor base. + CpuidLeaf::new(0x4000_0000, [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + CpuidLeaf::new(0x4000_0001, [0x0100_8efb, 0, 0, 0]), + CpuidLeaf::new(0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + CpuidLeaf::new(0x8000_0000, [0x8000_0008, 0, 0, 0]), + CpuidLeaf::new(0x8000_0001, [0, 0, 0x121, 0x2c10_0800]), + CpuidLeaf::new(0x8000_0007, [0, 0, 0, 0x100]), + ] + } + + #[test] + fn composed_cpuid_carries_the_identity_and_time_bits() { + let config = time_abi_cpuid(4, true); + let cpuid = compose_cpuid(kvm_like_base(), &config); + let mut lookup = |leaf, subleaf| cpuid.result(leaf, subleaf, &[0; 4]); + virt::time_abi::identity::check_identity(&mut lookup, 4).unwrap(); + virt::time_abi::identity::check_time_bits(&mut lookup, true).unwrap(); + // The hypervisor range holds exactly the time ABI's leaves: no KVM + // signature survives at any base. + let hypervisor_leaves = |set: &CpuidLeafSet| -> Vec { + set.leaves() + .iter() + .map(|leaf| leaf.function) + .filter(|function| HYPERVISOR_CPUID_RANGE.contains(function)) + .collect() + }; + assert_eq!(hypervisor_leaves(&cpuid), hypervisor_leaves(&config)); + assert_eq!(cpuid.result(0x4000_0100, 0, &[0; 4]), [0; 4]); + // The time bits are cleared or set; the other bits keep KVM's values. + assert_eq!( + cpuid.result(1, 0, &[0; 4])[2], + (0xfffa_3203 & !((1 << 24) | (1 << 15))) | (1 << 31) + ); + assert_eq!(cpuid.result(7, 0, &[0; 4])[1], 0xd19f_4fbb & !(1 << 1)); + } + + #[test] + fn composed_cpuid_drops_hypervisor_leaves_of_every_source() { + let mut base = kvm_like_base(); + // A later override of a hypervisor leaf does not survive either. + base.push(CpuidLeaf::new(0x4000_0003, [!0; 4])); + let cpuid = compose_cpuid(base, &time_abi_cpuid(1, true)); + assert_eq!(cpuid.result(0x4000_0003, 0, &[0; 4]), [0x8860, 0, 0, 0x100]); + } + + #[test] + fn capabilities_of_the_composed_cpuid_exclude_hv1_and_kvm_clock() { + let topology = vm_topology::processor::TopologyBuilder::new_x86() + .build(2) + .unwrap(); + let cpuid = compose_cpuid(kvm_like_base(), &time_abi_cpuid(2, true)); + let mut lookup = |leaf, subleaf| { + let mut result = cpuid.result(leaf, subleaf, &[0; 4]); + if leaf == 1 { + // Match the topology's APIC mode and drop XSAVE, which the + // fake CPUID does not describe. + result[2] &= !((1 << 21) | (1 << 26) | (1 << 27)); + if topology.apic_mode() != vm_topology::processor::x86::ApicMode::XApic { + result[2] |= 1 << 21; + } + } + result + }; + let caps = virt::PartitionCapabilities::from_cpuid( + &topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut lookup), + ) + .unwrap(); + check_capabilities(&caps).unwrap(); + let unmasked = virt::PartitionCapabilities::from_cpuid(&topology, &mut lookup).unwrap(); + assert!(check_capabilities(&unmasked).is_err()); + } + + #[test] + fn cpuid_leaves_keep_significant_indices() { + let entries = [ + kvm::kvm_cpuid_entry2 { + function: 7, + index: 0, + flags: kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX, + ebx: 0x1234, + ..Default::default() + }, + kvm::kvm_cpuid_entry2 { + function: 0x4000_0001, + index: 0, + flags: 0, + eax: 0x3123_7648, + ..Default::default() + }, + ]; + let leaves = cpuid_leaves(&entries); + assert_eq!( + (leaves[0].function, leaves[0].index, leaves[0].result), + (7, Some(0), [0, 0x1234, 0, 0]) + ); + assert_eq!( + (leaves[1].function, leaves[1].index, leaves[1].result), + (0x4000_0001, None, [0x3123_7648, 0, 0, 0]) + ); + } + + #[test] + fn host_sample_pairing_uses_the_bracket_midpoint() { + // 2 GHz: 2 cycles per ns. + let hz = 2_000_000_000; + let bracket = HostTscBracket { + start: 1_000, + end: 1_401, + }; + // Midpoint 1200, half width 201 cycles rounds up to 101 ns. + assert_eq!(pair_host_sample(bracket, hz), Some((1_200, 101))); + // A 10 us half width is at the anchor bound. + let at_bound = HostTscBracket { + start: 0, + end: 40_000, + }; + assert_eq!( + pair_host_sample(at_bound, hz), + Some((20_000, MAX_ANCHOR_PAIRING_NS)) + ); + let beyond = HostTscBracket { + start: 0, + end: 40_002, + }; + assert_eq!( + pair_host_sample(beyond, hz), + Some((20_001, MAX_ANCHOR_PAIRING_NS + 1)) + ); + // A TSC that went backwards cannot be paired. + let backwards = HostTscBracket { start: 10, end: 9 }; + assert_eq!(pair_host_sample(backwards, hz), None); + // Near the top of the TSC range. + let high = HostTscBracket { + start: u64::MAX - 100, + end: u64::MAX, + }; + assert_eq!(pair_host_sample(high, hz), Some((u64::MAX - 50, 25))); + } + + #[test] + fn unscaled_tsc_is_host_plus_offset() { + let bracket = HostTscBracket { + start: 5_000, + end: 6_000, + }; + // Offsets wrap: a guest TSC below the host TSC has an offset above + // 2^63. + for offset in [0, 1 << 40, 0u64.wrapping_sub(4_000)] { + assert!(tsc_is_unscaled( + 5_500u64.wrapping_add(offset), + offset, + bracket + )); + assert!(tsc_is_unscaled( + 5_000u64.wrapping_add(offset), + offset, + bracket + )); + assert!(tsc_is_unscaled( + 6_000u64.wrapping_add(offset), + offset, + bracket + )); + assert!(!tsc_is_unscaled( + 4_999u64.wrapping_add(offset), + offset, + bracket + )); + assert!(!tsc_is_unscaled( + 6_001u64.wrapping_add(offset), + offset, + bracket + )); + } + // 250 ppm of scaling at a host TSC of 10^12 is far outside the + // bracket. + let host = 1_000_000_000_000u64; + let bracket = HostTscBracket { + start: host, + end: host + 20_000, + }; + let scaled = host + host / 4_000; + assert!(!tsc_is_unscaled(scaled, 0, bracket)); + } + + #[test] + fn common_offset_reaches_the_target_at_the_anchor() { + for (target, host_tsc) in [ + (5_000_000u64, 1_000u64), + (1_000, 5_000_000), + (u64::MAX, 0), + (0, u64::MAX), + ] { + let offset = target.wrapping_sub(host_tsc); + assert_eq!(host_tsc.wrapping_add(offset), target); + } + } + + #[test] + fn offset_readback_requires_every_vp() { + let vps = |values: &[u64]| -> Vec<(VpIndex, u64)> { + values + .iter() + .enumerate() + .map(|(index, &value)| (VpIndex::new(index as u32), value)) + .collect() + }; + check_offset_readback(7, &vps(&[7, 7, 7, 7])).unwrap(); + check_offset_readback(7, &[]).unwrap(); + let error = check_offset_readback(7, &vps(&[7, 8, 7, 9])).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscSyncReadback); + assert!(error.message.contains("VP 1: 0x8"), "{}", error.message); + assert!(error.message.contains("VP 3: 0x9"), "{}", error.message); + } + + fn time_abi(msrs: Arc) -> KvmTimeAbi { + KvmTimeAbi { + msrs, + kvm_invariant_control: None, + } + } + + /// Maps an MSR exit result to `Some(Ok(value))`, `Some(Err(()))` for #GP, + /// or `None` when the time ABI does not handle the MSR. + fn outcome(result: Option>) -> Option> { + result.map(|result| { + result.map_err(|err| assert!(matches!(err, MsrError::InvalidAccess), "{err:?}")) + }) + } + + #[test] + fn msr_exits_serve_the_identity_and_hide_tsc_adjust() { + let msrs = Arc::new(TimeAbiMsrs::new()); + msrs.declare(DeclaredRates { + tsc_hz: 2_194_804_000, + apic_hz: LAPIC_HZ_KVM, + }) + .unwrap(); + let time_abi = time_abi(msrs.clone()); + let vp = VpIndex::new(2); + let read = |msr| outcome(time_abi.read_msr(vp, msr)); + let write = |msr, value| outcome(time_abi.write_msr(vp, msr, value)); + assert_eq!(read(MSR_VP_INDEX), Some(Ok(2))); + assert_eq!(read(MSR_TSC_FREQUENCY), Some(Ok(2_194_804_000))); + assert_eq!(read(MSR_APIC_FREQUENCY), Some(Ok(1_000_000_000))); + // Hyper-V MSRs that KVM would emulate raise #GP. + for msr in [ + 0x4000_0000, + 0x4000_0001, + 0x4000_0020, + 0x4000_0021, + 0x4000_00b0, + ] { + assert_eq!(read(msr), Some(Err(())), "{msr:#x}"); + assert_eq!(write(msr, 1), Some(Err(())), "{msr:#x}"); + } + assert_eq!(read(MSR_IA32_TSC_ADJUST), Some(Err(()))); + assert_eq!(write(MSR_IA32_TSC_ADJUST, 0), Some(Err(()))); + // Other unknown MSRs keep their existing handling. + assert_eq!(read(0x88), None); + assert_eq!(write(0x4000_0200, 0), None); + // The invariant TSC control is guest state in the shared handler. + assert_eq!(write(MSR_TSC_INVARIANT_CONTROL, 2), Some(Err(()))); + assert_eq!(write(MSR_TSC_INVARIANT_CONTROL, 1), Some(Ok(()))); + assert_eq!(msrs.tsc_invariant_control(), 1); + assert_eq!(read(MSR_TSC_INVARIANT_CONTROL), Some(Ok(1))); + } + + #[test] + fn cycle_conversion_rounds_up() { + assert_eq!(cycles_to_ns_ceil(0, 2_000_000_000), 0); + assert_eq!(cycles_to_ns_ceil(1, 2_000_000_000), 1); + // One millisecond at the prometheus32 rate, and a hair over a + // microsecond. + assert_eq!(cycles_to_ns_ceil(2_194_804, 2_194_804_000), 1_000_000); + assert_eq!(cycles_to_ns_ceil(2_195, 2_194_804_000), 1_001); + assert_eq!(cycles_to_ns_ceil(u64::MAX, 1), u64::MAX); + } +} + +/// Tests against the host's KVM. +#[cfg(test)] +mod kvm_tests { + use super::*; + use test_with_tracing::test; + + fn partition(vp_count: u32) -> kvm::Partition { + let kvm = kvm::Kvm::new().unwrap(); + let mut vm = kvm.new_vm(kvm::VmType::Default).unwrap(); + for vcpu in 0..vp_count { + vm.add_vp(vcpu).unwrap(); + } + vm + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn identity_msr_filter_installs() { + let vm = partition(1); + install_identity_msr_filter(&vm).unwrap(); + // Host-initiated accesses bypass the filter. + let mut value = [0]; + vm.vp(0) + .get_msrs(&[x86defs::X86X_MSR_TSC], &mut value) + .unwrap(); + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn common_offset_is_exact_on_every_vcpu() { + for vp_count in [1, 2, 4, 8] { + let vm = partition(vp_count); + let hz = vm.vp(0).tsc_frequency_hz().unwrap(); + // Desynchronize the vCPUs first, as a restore's IA32_TSC writes + // may. + for vcpu in 0..vp_count { + vm.vp(vcpu) + .set_tsc_offset(u64::from(vcpu) * 1_000_003) + .unwrap(); + } + let (bracket, ()) = HostTscBracket::around(|| ()); + let target = hz * 3600; + let offset = target.wrapping_sub(bracket.end); + for vcpu in 0..vp_count { + vm.vp(vcpu).set_tsc_offset(offset).unwrap(); + } + for vcpu in 0..vp_count { + let vp = vm.vp(vcpu); + assert!(vp.has_tsc_offset_attr()); + assert_eq!( + vp.tsc_offset().unwrap(), + offset, + "vCPU {vcpu} of {vp_count}" + ); + let mut guest = [0]; + let (bracket, result) = + HostTscBracket::around(|| vp.get_msrs(&[x86defs::X86X_MSR_TSC], &mut guest)); + result.unwrap(); + assert!( + tsc_is_unscaled(guest[0], offset, bracket), + "vCPU {vcpu} of {vp_count}: {:#x} {bracket:x?}", + guest[0] + ); + } + } + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn host_samples_pair_within_the_anchor_bound() { + let vm = partition(1); + let hz = vm.vp(0).tsc_frequency_hz().unwrap(); + let mut worst = 0; + for _ in 0..1000 { + worst = worst.max(paired_host_sample(hz).unwrap().pairing_ns); + } + tracing::info!(worst, "worst host sample pairing, ns"); + assert!(worst <= MAX_ANCHOR_PAIRING_NS); + } + + #[test] + #[ignore = "requires /dev/kvm"] + fn invariant_control_mirror_reaches_kvm() { + let vm = partition(2); + let msrs = Arc::new(TimeAbiMsrs::new()); + let time_abi = KvmTimeAbi::new(&vm, &[0, 1], msrs.clone()).unwrap(); + if time_abi.kvm_invariant_control.is_none() { + tracing::info!("KVM does not implement HV_X64_MSR_TSC_INVARIANT_CONTROL"); + return; + } + msrs.write(VpIndex::BSP, MSR_TSC_INVARIANT_CONTROL, 1) + .unwrap() + .unwrap(); + time_abi.sync_invariant_control(&vm.vp(1)).unwrap(); + let mut value = [0]; + // The control is partition-wide: every vCPU reads the mirrored value. + vm.vp(0) + .get_msrs(&[MSR_TSC_INVARIANT_CONTROL], &mut value) + .unwrap(); + assert_eq!(value[0], 1); + // A reset returns it to 0, which a host write may set in KVM. + msrs.reset(); + time_abi.sync_invariant_control(&vm.vp(0)).unwrap(); + vm.vp(1) + .get_msrs(&[MSR_TSC_INVARIANT_CONTROL], &mut value) + .unwrap(); + assert_eq!(value[0], 0); + } +} diff --git a/vmm_core/virt_kvm/src/lib.rs b/vmm_core/virt_kvm/src/lib.rs index 88f09728950..be1f36aea51 100644 --- a/vmm_core/virt_kvm/src/lib.rs +++ b/vmm_core/virt_kvm/src/lib.rs @@ -96,6 +96,12 @@ pub enum KvmError { #[cfg(guest_arch = "x86_64")] #[error(transparent)] TscFrequencyCpuid(#[from] virt::x86::tsc::TscFrequencyCpuidError), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TimeAbi(#[from] virt::time_abi::TimeAbiError), + #[cfg(guest_arch = "x86_64")] + #[error("the time ABI does not support {0}")] + TimeAbiUnsupported(&'static str), } #[derive(Inspect)] @@ -166,6 +172,11 @@ struct KvmPartitionInner { gic_nr_irqs: u32, #[cfg(guest_arch = "x86_64")] synic_ports: virt::synic::SynicPortMap, + /// The NVX time ABI state, for a partition built with a time ABI + /// configuration. + #[cfg(guest_arch = "x86_64")] + #[inspect(skip)] + time_abi: Option, } // TODO: Chunk this up into smaller types. @@ -194,6 +205,9 @@ enum KvmRunVpError { #[cfg(guest_arch = "x86_64")] #[error("failed to inject an extint interrupt")] ExtintInterrupt(#[source] kvm::Error), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TimeAbi(virt::time_abi::TimeAbiError), } pub struct KvmProcessorBinder { From b591be93430f8aa7c5a50bbefac37d143684dae5 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 02:55:08 -0700 Subject: [PATCH 172/304] virt_mshv: implement the NVX time ABI backend Implement virt::time_abi::TimeAbiBackend for MSHV partitions built with ProtoPartitionConfig::time_abi. The backend stays inert until the worker passes a configuration, and every legacy path is unchanged. A time ABI partition: - sets no synthetic processor features, and installs MSR-index intercepts for VP_INDEX, TSC_FREQUENCY, APIC_FREQUENCY, and TSC_INVARIANT_CONTROL, whose exits the shared TimeAbiMsrs handler serves. With synthetic features, the hypervisor serves the first three natively, never delivers their intercepts, and reports this host's TSC rate instead of the declared one; - registers the configured CPUID over its own and drops its own hypervisor-range leaves. Without synthetic features, the hypervisor reports no hypervisor leaf of its own, which preflight checks; - keeps the invariant TSC and hides the TSC-deadline timer, TSC_ADJUST, and APERF/MPERF in processor feature bank 1; and - derives its capabilities with the hypervisor CPUID range masked, so hv1 and kvm_clock stay false. The backend reads F_d from ProcessorClockFrequency and L from ApicFrequency. Preflight checks the identity CPUID that VP 0 reads, and that partition time can be frozen. The capture anchor pairs VP 0's TSC with the midpoint of the tightest of up to 64 bracketing host samples. The synchronized TSC set seals the VP set, freezes partition time, takes the restore anchor, writes the target to every created VP, and reads it back. The first VP run thaws time, and creating a VP afterwards fails with E_VP_LATE_CREATION. Ignored hardware tests exercise every primitive on /dev/mshv and report whether the feature banks can expose BHI_CTRL. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/lib.rs | 6 + vmm_core/virt_mshv/src/x86_64/finalize.rs | 34 +- vmm_core/virt_mshv/src/x86_64/mod.rs | 55 +- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 1481 +++++++++++++++++++++ 4 files changed, 1561 insertions(+), 15 deletions(-) create mode 100644 vmm_core/virt_mshv/src/x86_64/time_abi.rs diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 8785c57b831..6abb8b1b9b6 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -333,6 +333,9 @@ struct MshvPartitionInner { /// Set to `true` when partition time is frozen (e.g. during reset). /// The first VP to enter `run_vp` after a freeze will thaw time. time_frozen: Mutex, + /// The NVX time ABI state, for a partition built with it. + #[cfg(guest_arch = "x86_64")] + time_abi: Option, /// aarch64 GIC MSI controller config, used to decode PCIe MSIs into SPI /// assertions via a v2m frame. #[cfg(guest_arch = "aarch64")] @@ -861,6 +864,9 @@ enum ErrorInner { #[cfg(guest_arch = "x86_64")] #[error("failed to create virtual device")] NewDevice(#[source] virt::x86::apic_software_device::DeviceIdInUse), + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TimeAbi(#[from] virt::time_abi::TimeAbiError), } /// Equivalent to [`MshvError`] but has a much better error message. diff --git a/vmm_core/virt_mshv/src/x86_64/finalize.rs b/vmm_core/virt_mshv/src/x86_64/finalize.rs index 70af0d8212c..ff7ec131f8e 100644 --- a/vmm_core/virt_mshv/src/x86_64/finalize.rs +++ b/vmm_core/virt_mshv/src/x86_64/finalize.rs @@ -124,16 +124,23 @@ impl MshvPartitionInner { fn build_caps(&self, bsp: &VcpuFd) -> Result { let mut cpuid_error = None; - let cpuid_caps = virt::PartitionCapabilities::from_cpuid( - &self.config.processor_topology, - &mut |function, index| { - bsp.get_cpuid_values(function, index, 0, 0) - .unwrap_or_else(|error| { - cpuid_error.get_or_insert(error); - [0; 4] - }) - }, - ); + let mut cpuid = |function, index| { + bsp.get_cpuid_values(function, index, 0, 0) + .unwrap_or_else(|error| { + cpuid_error.get_or_insert(error); + [0; 4] + }) + }; + // The time ABI identity must not make the partition look like an hv1 + // or KVM-clock guest, so its capabilities ignore the hypervisor range. + let cpuid_caps = if self.time_abi.is_some() { + virt::PartitionCapabilities::from_cpuid( + &self.config.processor_topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut cpuid), + ) + } else { + virt::PartitionCapabilities::from_cpuid(&self.config.processor_topology, &mut cpuid) + }; let mut caps = match (cpuid_caps, cpuid_error) { (Ok(caps), None) => caps, (result, error) => { @@ -158,6 +165,13 @@ impl MshvPartitionInner { caps.xsaves_state_bv_broken = true; // Ordinary state access does not freeze the partition clock. caps.can_freeze_time = false; + if self.time_abi.is_some() && (caps.hv1 || caps.kvm_clock) { + return Err(virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::IdentityRouting, + "the time ABI partition capabilities include hv1 or the KVM clock", + ) + .into()); + } Ok(caps) } } diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 153682a95c8..37afd4b5060 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -5,6 +5,7 @@ mod extint; pub(crate) mod finalize; +pub(crate) mod time_abi; mod tsc; mod vm_state; mod vp_state; @@ -119,6 +120,9 @@ impl virt::Hypervisor for LinuxMshv { }; let isolation = config.isolation.isolation_type(); validate_snp_cpuid_offload_config(isolation, self.snp_disable_cpuid_offload)?; + if config.time_abi.is_some() { + time_abi::validate_partition(isolation, config.hv_config.is_some())?; + } let snp = isolation == virt::IsolationType::Snp; let x2apic = matches!( config.processor_topology.apic_mode(), @@ -130,13 +134,18 @@ impl virt::Hypervisor for LinuxMshv { x2apic, config.processor_topology.smt_enabled(), )?; - let create_args = tsc::with_features1(create_args, config.versioned_cpu_contract); + let create_args = if config.time_abi.is_some() { + time_abi::with_feature_banks(create_args) + } else { + tsc::with_features1(create_args, config.versioned_cpu_contract) + }; let vmfd = create_vm_with_retry(&self.mshv, &create_args)?; // Set synthetic processor features before initialization when the // guest interface is configured. SNP partitions require the smaller - // early-property feature set accepted by the hypervisor. + // early-property feature set accepted by the hypervisor. A time ABI + // partition has none: OpenVMM serves its identity MSRs. if config.hv_config.is_some() || snp { let synthetic_features = if snp { snp_synthetic_features() @@ -158,6 +167,10 @@ impl virt::Hypervisor for LinuxMshv { vmfd.initialize() .map_err(|e| ErrorInner::CreateVMInitFailed(e.into()))?; + if config.time_abi.is_some() { + time_abi::route_identity_msrs(&vmfd)?; + } + if snp { let snp_policy = igvm_snp_config.as_ref().map_or_else( || { @@ -482,10 +495,13 @@ impl ProtoPartition for MshvProtoPartition<'_> { cpuid.extend(snp_hv_cpuid_overrides(native_max_leaf)); } } - let cpuid = tsc::add_cpuid_leaves(&self.vmfd, cpuid)?; - let cpuid = virt::CpuidLeafSet::new(cpuid); + let cpuid = match &self.config.time_abi { + Some(time_abi) => time_abi::partition_cpuid(cpuid, &time_abi.cpuid), + None => virt::CpuidLeafSet::new(tsc::add_cpuid_leaves(&self.vmfd, cpuid)?), + }; // Apply CPUID overrides partition-wide. + let started = std::time::Instant::now(); for leaf in cpuid.leaves().iter() { let input = hvdef::hypercall::RegisterInterceptResultCpuid { partition_id: 0, @@ -522,6 +538,16 @@ impl ProtoPartition for MshvProtoPartition<'_> { .hvcall(&mut args) .map_err(|e| ErrorInner::RegisterCpuid(e.into()))?; } + tracing::info!( + leaves = cpuid.leaves().len(), + elapsed_us = started.elapsed().as_micros() as u64, + "registered MSHV CPUID results" + ); + let time_abi = self + .config + .time_abi + .as_ref() + .map(|config| time_abi::MshvTimeAbi::new(config, &cpuid)); let apic_id_map = self .config @@ -558,6 +584,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { isolation, // SNP partition creation set TimeFreeze=1 before this object was built. time_frozen: Mutex::new(time_frozen), + time_abi, }); inner.add_snp_vmsa_mapping()?; @@ -611,6 +638,10 @@ impl virt::Partition for MshvPartition { self.inner.apic_frequency_hz() } + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { + self.inner.time_abi.as_ref().map(|_| self as _) + } + fn supports_initial_page_acceptance( &self, ) -> Option<&dyn virt::AcceptInitialPages> { @@ -767,6 +798,9 @@ impl MshvPartitionInner { /// created here, so restored-TSC synchronization sees exactly the created /// VPs. fn create_vp(&self, vp_index: VpIndex) -> Result { + if let Some(time_abi) = &self.time_abi { + time_abi.check_vp_creation(vp_index)?; + } let vcpufd = self .vmfd .create_vcpu( @@ -775,7 +809,10 @@ impl MshvPartitionInner { .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; self.vp(vp_index) .created - .store(true, std::sync::atomic::Ordering::Release); + .store(true, std::sync::atomic::Ordering::SeqCst); + if let Some(time_abi) = &self.time_abi { + time_abi.check_vp_creation(vp_index)?; + } Ok(vcpufd) } } @@ -920,6 +957,14 @@ impl MshvProcessor<'_> { HvMessageType::HvMessageTypeX64InterruptionDeliverable => { self.handle_interrupt_deliverable(exit, dev); } + // Only a time ABI partition installs MSR intercepts. + HvMessageType::HvMessageTypeMsrIntercept => { + let partition = self.partition; + let Some(time_abi) = &partition.time_abi else { + panic!("Unhandled vcpu exit code {:?}", exit.header.typ); + }; + self.handle_time_abi_msr_intercept(time_abi, exit); + } exit_type => { panic!("Unhandled vcpu exit code {exit_type:?}"); } diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs new file mode 100644 index 00000000000..d6713ed783f --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -0,0 +1,1481 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The NVX time ABI on MSHV. +//! +//! A partition built with a [`TimeAbiConfig`] shows the guest the time ABI's +//! minimal Hyper-V identity, served by OpenVMM rather than by the hypervisor: +//! +//! - The partition has no synthetic processor features, so the hypervisor +//! reports no hypervisor CPUID leaves and serves no synthetic MSR itself. +//! With them, it would serve `HV_X64_MSR_VP_INDEX` and the frequency MSRs +//! natively and never deliver their intercepts, and +//! `HV_X64_MSR_TSC_FREQUENCY` would report this host's rate instead of the +//! declared one. +//! - MSR-index intercepts route reads of the four identity MSRs and writes of +//! `HV_X64_MSR_TSC_INVARIANT_CONTROL` to [`TimeAbiMsrs`]. The hypervisor +//! raises #GP itself for writes to the three read-only identity MSRs, even +//! when they are intercepted, and for every other MSR of the identity range. +//! - CPUID intercept results apply the configured CPUID over the hypervisor's +//! own. The hypervisor reports no hypervisor leaf of its own, which +//! preflight verifies at sentinel leaves. +//! - The processor feature banks keep the invariant TSC and hide the +//! TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. +//! - The synchronized TSC set freezes partition time, writes the target to +//! every created VP, and reads it back. Partition time thaws when a VP first +//! runs, so the guest resumes exactly at the target. +//! +//! These semantics were verified on hypervisor builds 10.0.26100.30000 (bare +//! metal) and 10.0.26100.9444 (Azure nested), where they are identical. + +use crate::KernelError; +use crate::MshvPartition; +use crate::MshvPartitionInner; +use crate::MshvProcessor; +use crate::VcpuFdExt; +use hvdef::HvInterceptAccessType; +use hvdef::HvMessage; +use hvdef::HvPartitionPropertyCode; +use hvdef::HvX64RegisterName; +use hvdef::hypercall::HV_INTERCEPT_ACCESS_MASK_READ_WRITE; +use hvdef::hypercall::HvRegisterAssoc; +use inspect::Inspect; +use mshv_ioctls::VcpuFd; +use mshv_ioctls::VmFd; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; +use virt::VpIndex; +use virt::time_abi::BackendPreflight; +use virt::time_abi::HostTimeSample; +use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::TimeAbiBackend; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiConfig; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiMsrs; +use virt::time_abi::TscAnchor; +use virt::time_abi::TscSetReport; +use virt::time_abi::TscSyncMethod; +use virt::time_abi::host::sample_host_time; +use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; +use virt::time_abi::identity::IDENTITY_CPUID_RANGE; +use virt::time_abi::identity::IDENTITY_MAX_LEAF; +use virt::time_abi::msr::MSR_APIC_FREQUENCY; +use virt::time_abi::msr::MSR_TSC_FREQUENCY; +use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; +use virt::time_abi::msr::MSR_VP_INDEX; + +/// The identity MSRs that OpenVMM serves. The hypervisor raises #GP for +/// every other MSR of the identity range. +pub(crate) const ROUTED_MSRS: [u32; 4] = [ + MSR_VP_INDEX, + MSR_TSC_FREQUENCY, + MSR_APIC_FREQUENCY, + MSR_TSC_INVARIANT_CONTROL, +]; + +/// The most a capture anchor's host sample may differ from the instant VP 0's +/// TSC was read, in nanoseconds. +pub(crate) const MAX_ANCHOR_PAIRING_NS: u64 = 10_000; + +/// How many bracketed TSC reads a capture anchor tries before giving up. +const ANCHOR_ATTEMPTS: usize = 64; + +/// A capture anchor pairing this tight ends the search early. +const ANCHOR_TARGET_PAIRING_NS: u64 = 1_000; + +/// Leaves read from VP 0 for the effective CPUID besides the programmed ones: +/// the maximum basic and extended leaves. +const EFFECTIVE_CPUID_MAX_LEAVES: [u32; 2] = [0, 0x8000_0000]; + +/// CPUID leaves of the identity range that preflight reads from VP 0 to check +/// that the zero leaves took effect: the first one past the identity, the +/// Hyper-V isolation leaf `0x40000081` (where `"VS#1"` would appear), and the +/// last one, plus three hypervisor signature bases. +const PREFLIGHT_SENTINEL_LEAVES: [u32; 6] = [ + 0x4000_0006, + 0x4000_0081, + 0x4000_00ff, + 0x4000_0100, + 0x4000_0200, + 0x4000_ff00, +]; + +/// Time ABI state of an MSHV partition. +#[derive(Inspect)] +pub(crate) struct MshvTimeAbi { + /// The identity MSR handler, shared with the worker. + #[inspect( + rename = "tsc_invariant_control", + with = "|x| x.tsc_invariant_control()" + )] + msrs: Arc, + /// The CPUID results registered with the hypervisor. + #[inspect(skip)] + registered_cpuid: Vec, + /// Set by the synchronized TSC set. VP creation fails afterwards. + #[inspect(with = "|x| x.load(Ordering::Relaxed)")] + vp_set_sealed: AtomicBool, +} + +impl MshvTimeAbi { + pub(super) fn new(config: &TimeAbiConfig, registered_cpuid: &CpuidLeafSet) -> Self { + Self { + msrs: config.msrs.clone(), + registered_cpuid: registered_cpuid.leaves().to_vec(), + vp_set_sealed: AtomicBool::new(false), + } + } + + /// Fails with `E_VP_LATE_CREATION` once the synchronized TSC set has run. + /// + /// VP creation checks this both before and after it marks a VP created, + /// and the synchronized set seals before it collects the created VPs, so a + /// VP is either written by the set or fails creation. + pub(super) fn check_vp_creation(&self, vp_index: VpIndex) -> Result<(), TimeAbiError> { + if self.vp_set_sealed.load(Ordering::SeqCst) { + return Err(TimeAbiError::new( + TimeAbiCode::VpLateCreation, + format!( + "VP {} was instantiated after the synchronized TSC set", + vp_index.index() + ), + )); + } + Ok(()) + } +} + +/// Fails unless the partition can carry the time ABI: it must not be isolated +/// and must not expose the Hyper-V guest interface, whose synthetic processor +/// features would serve the identity MSRs natively. +pub(super) fn validate_partition( + isolation: virt::IsolationType, + hv_configured: bool, +) -> Result<(), TimeAbiError> { + if isolation != virt::IsolationType::None { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!("the MSHV time ABI does not support {isolation:?} isolation"), + )); + } + if hv_configured { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + "the MSHV time ABI cannot be combined with the Hyper-V guest interface", + )); + } + Ok(()) +} + +/// Returns processor feature bank 1 for a time ABI partition: `supported`, +/// with the invariant TSC, and without the TSC-deadline timer, +/// `IA32_TSC_ADJUST`, or APERF/MPERF. +pub(super) fn processor_features1( + supported: hvdef::HvX64PartitionProcessorFeatures1, +) -> hvdef::HvX64PartitionProcessorFeatures1 { + supported + .with_tsc_invariant_support(true) + .with_tsc_deadline_tmr_support(false) + .with_tsc_adjust_support(false) + .with_a_count_m_count_support(false) +} + +/// Applies the time ABI's processor feature bank 1 to the partition creation +/// arguments, whose banks hold the disabled features. +pub(super) fn with_feature_banks( + mut args: mshv_bindings::mshv_create_partition_v2, +) -> mshv_bindings::mshv_create_partition_v2 { + let mut banks = args.pt_cpu_fbanks; + banks[1] = !u64::from(processor_features1(super::supported_processor_features1())); + args.pt_cpu_fbanks = banks; + args +} + +/// Returns the MSR-index intercept that routes reads and writes of `msr` to +/// OpenVMM. +fn msr_index_intercept(msr: u32) -> mshv_bindings::mshv_install_intercept { + mshv_bindings::mshv_install_intercept { + access_type_mask: HV_INTERCEPT_ACCESS_MASK_READ_WRITE, + intercept_type: mshv_bindings::hv_intercept_type_HV_INTERCEPT_TYPE_X64_MSR_INDEX, + intercept_parameter: mshv_bindings::hv_intercept_parameters { + as_uint64: u64::from(msr), + }, + } +} + +/// Routes the identity MSRs to OpenVMM. Fails with `E_IDENTITY_ROUTING`, +/// naming the MSR whose intercept the hypervisor refused. +pub(super) fn route_identity_msrs(vmfd: &VmFd) -> Result<(), TimeAbiError> { + install_msr_intercepts(vmfd, &ROUTED_MSRS) +} + +fn install_msr_intercepts(vmfd: &VmFd, msrs: &[u32]) -> Result<(), TimeAbiError> { + for &msr in msrs { + vmfd.install_intercept(msr_index_intercept(msr)) + .map_err(|error| { + TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!( + "cannot intercept MSR {msr:#x}: {}", + error_chain(&KernelError::from(error)) + ), + ) + })?; + } + Ok(()) +} + +/// Returns the CPUID results to register for a time ABI partition. +/// +/// The hypervisor-range leaves of `own`, the backend's other CPUID results, +/// are dropped, and `config` is applied last, over everything else. Without +/// synthetic processor features, the hypervisor itself reports every +/// hypervisor-range leaf as zero, so the identity range holds exactly the +/// configured leaves (the identity and the explicit zero leaves) and zeros. +pub(super) fn partition_cpuid(own: Vec, config: &CpuidLeafSet) -> CpuidLeafSet { + let mut leaves: Vec = own + .into_iter() + .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) + .collect(); + leaves.extend(config.leaves().iter().copied()); + CpuidLeafSet::new(leaves) +} + +/// Returns whether `leaf` is an explicit zero leaf past the identity, which +/// the effective CPUID reports as registered instead of reading it back. +/// Preflight reads such leaves back at its sentinels. +fn is_zero_fill(leaf: &CpuidLeaf) -> bool { + leaf.function > IDENTITY_MAX_LEAF + && IDENTITY_CPUID_RANGE.contains(&leaf.function) + && leaf.result == [0; 4] + && leaf.mask == [!0; 4] +} + +/// An intercepted access to an identity MSR. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum MsrAccess { + Read, + Write(u64), +} + +/// How an intercepted identity MSR access completes. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum MsrOutcome { + /// The read returns this value. + Read(u64), + /// The write takes effect. + Written, + /// The access raises #GP. + Fault, +} + +/// Serves an intercepted access through the identity MSR handler. +fn serve_identity_msr(msrs: &TimeAbiMsrs, vp: VpIndex, msr: u32, access: MsrAccess) -> MsrOutcome { + let outcome = match access { + MsrAccess::Read => msrs.read(vp, msr).map(|r| r.map(MsrOutcome::Read)), + MsrAccess::Write(value) => msrs + .write(vp, msr, value) + .map(|r| r.map(|()| MsrOutcome::Written)), + }; + match outcome { + Some(Ok(outcome)) => outcome, + Some(Err(_)) => MsrOutcome::Fault, + None => { + // Only identity MSRs are intercepted, so this is a hypervisor + // defect; the access still faults as an unknown MSR would. + tracelimit::error_ratelimited!( + vp = vp.index(), + msr, + "MSR intercept outside the time ABI identity range" + ); + MsrOutcome::Fault + } + } +} + +/// Returns the pending-event register value that raises #GP(0). +fn gp_fault_event() -> u128 { + hvdef::HvX64PendingExceptionEvent::new() + .with_event_pending(true) + .with_event_type(hvdef::HV_X64_PENDING_EVENT_EXCEPTION) + .with_vector(x86defs::Exception::GENERAL_PROTECTION_FAULT.0.into()) + .with_deliver_error_code(true) + .with_error_code(0) + .into() +} + +impl MshvProcessor<'_> { + /// Completes an intercepted identity MSR access, or raises #GP. + pub(super) fn handle_time_abi_msr_intercept( + &mut self, + time_abi: &MshvTimeAbi, + message: &HvMessage, + ) { + let info = message.as_message::(); + let access = if info.header.intercept_access_type == HvInterceptAccessType::WRITE { + MsrAccess::Write((info.rdx << 32) | (info.rax & 0xffff_ffff)) + } else { + MsrAccess::Read + }; + let outcome = serve_identity_msr(&time_abi.msrs, self.vpindex, info.msr_number, access); + tracing::trace!( + vp = self.vpindex.index(), + msr = info.msr_number, + ?access, + ?outcome, + "time ABI identity MSR access" + ); + let next_rip = info.header.rip + u64::from(info.header.instruction_len()); + match outcome { + MsrOutcome::Read(value) => { + let rp = self.runner.reg_page(); + rp.gp_registers[x86emu::Gp::RAX as usize] = value & 0xffff_ffff; + rp.gp_registers[x86emu::Gp::RDX as usize] = value >> 32; + rp.rip = next_rip; + rp.dirty.set_general_purpose(true); + rp.dirty.set_instruction_pointer(true); + } + MsrOutcome::Written => { + let rp = self.runner.reg_page(); + rp.rip = next_rip; + rp.dirty.set_instruction_pointer(true); + } + MsrOutcome::Fault => { + // The faulting instruction does not retire, so RIP stays. + self.runner + .vcpufd + .set_hvdef_regs(&[HvRegisterAssoc::from(( + HvX64RegisterName::PendingEvent0, + gp_fault_event(), + ))]) + .expect("failed to inject #GP for an identity MSR access"); + } + } + } +} + +/// Returns the VPs that exist in the hypervisor, given whether each VP of the +/// topology was created. A restore-time VP prefix leaves the suffix uncreated. +fn created_vps(created: impl IntoIterator) -> Vec { + created + .into_iter() + .enumerate() + .filter_map(|(index, created)| created.then_some(VpIndex::new(index as u32))) + .collect() +} + +/// Checks that every VP read back `target`. Fails with +/// `E_TSC_SYNC_READBACK`, naming the first VP that differs. +fn check_readback(target: u64, readback: &[(VpIndex, u64)]) -> Result<(), TimeAbiError> { + if let Some((vp, value)) = readback.iter().find(|(_, value)| *value != target) { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!( + "VP {} holds TSC {value:#x} instead of the synchronized {target:#x}", + vp.index() + ), + )); + } + Ok(()) +} + +/// One bracketed read of VP 0's TSC: host samples taken immediately before +/// and after the read. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct AnchorCandidate { + tsc: u64, + before: HostTimeSample, + after: HostTimeSample, +} + +impl AnchorCandidate { + /// The host monotonic time the read took, in nanoseconds. + fn width_ns(&self) -> u64 { + self.after + .monotonic_ns + .saturating_sub(self.before.monotonic_ns) + } + + /// Pairs the TSC with the midpoint of the bracket, which differs from the + /// read instant by at most half the bracket. UTC is derived from the first + /// sample and the monotonic midpoint, so a UTC step inside the bracket + /// does not skew it. + fn anchor(&self) -> TscAnchor { + let half = self.width_ns() / 2; + TscAnchor { + tsc: self.tsc, + sample: HostTimeSample { + utc_ns: self.before.utc_ns + half, + monotonic_ns: self.before.monotonic_ns + half, + }, + pairing_ns: self.width_ns().div_ceil(2), + } + } +} + +/// Chooses the tightest candidate. Fails with `E_TSC_ANCHOR` if none pairs +/// within [`MAX_ANCHOR_PAIRING_NS`]. +fn select_anchor(candidates: &[AnchorCandidate]) -> Result { + let best = candidates + .iter() + .min_by_key(|candidate| candidate.width_ns()) + .ok_or_else(|| TimeAbiError::new(TimeAbiCode::TscAnchor, "no TSC read was attempted"))?; + let anchor = best.anchor(); + if anchor.pairing_ns > MAX_ANCHOR_PAIRING_NS { + return Err(TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!( + "the tightest of {} VP 0 TSC reads pairs within {} ns, above the {MAX_ANCHOR_PAIRING_NS} ns bound", + candidates.len(), + anchor.pairing_ns + ), + )); + } + Ok(anchor) +} + +/// Formats an error with its sources, for failure messages. +fn error_chain(error: &dyn std::error::Error) -> String { + let mut text = error.to_string(); + let mut source = error.source(); + while let Some(error) = source { + text.push_str(": "); + text.push_str(&error.to_string()); + source = error.source(); + } + text +} + +/// The input of `HvCallGetVpRegisters` and `HvCallSetVpRegisters` for one +/// register of another VP. +#[repr(C)] +struct VpRegisterInput { + header: hvdef::hypercall::GetSetVpRegisters, + register: HvRegisterAssoc, +} + +fn vp_register_input(vp: VpIndex, register: HvRegisterAssoc) -> VpRegisterInput { + VpRegisterInput { + header: hvdef::hypercall::GetSetVpRegisters { + partition_id: 0, + vp_index: vp.index(), + target_vtl: hvdef::hypercall::HvInputVtl::CURRENT_VTL, + rsvd: [0; 3], + }, + register, + } +} + +/// Writes the TSC of a created VP through `HvCallSetVpRegisters`. +fn set_vp_tsc(vmfd: &VmFd, vp: VpIndex, tsc: u64) -> Result<(), KernelError> { + let input = vp_register_input(vp, HvRegisterAssoc::from((HvX64RegisterName::Tsc, tsc))); + let mut args = mshv_bindings::mshv_root_hvcall { + code: hvdef::HypercallCode::HvCallSetVpRegisters.0, + in_sz: size_of::() as u16, + in_ptr: std::ptr::addr_of!(input) as u64, + reps: 1, + ..Default::default() + }; + vmfd.hvcall(&mut args)?; + if args.reps != 1 { + return Err(KernelError::Kernel(std::io::Error::from_raw_os_error( + libc::EINTR, + ))); + } + Ok(()) +} + +/// Reads the TSC of a created VP through `HvCallGetVpRegisters`. +fn get_vp_tsc(vmfd: &VmFd, vp: VpIndex) -> Result { + // The input is the header and one register name; the name occupies the + // first four bytes of the register association. + let input = vp_register_input(vp, HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0u64))); + let mut output = [0u64; 2]; + let mut args = mshv_bindings::mshv_root_hvcall { + code: hvdef::HypercallCode::HvCallGetVpRegisters.0, + in_sz: (size_of::() + size_of::()) as u16, + in_ptr: std::ptr::addr_of!(input) as u64, + out_sz: size_of_val(&output) as u16, + out_ptr: output.as_mut_ptr() as u64, + reps: 1, + ..Default::default() + }; + vmfd.hvcall(&mut args)?; + if args.reps != 1 { + return Err(KernelError::Kernel(std::io::Error::from_raw_os_error( + libc::EINTR, + ))); + } + Ok(output[0]) +} + +/// Reads one CPUID leaf as VP 0 sees it. +fn vp_cpuid(bsp: &VcpuFd, function: u32, index: u32) -> Result<[u32; 4], KernelError> { + Ok(bsp.get_cpuid_values(function, index, 0, 0)?) +} + +impl MshvPartitionInner { + fn time_abi_state(&self) -> Result<&MshvTimeAbi, TimeAbiError> { + self.time_abi.as_ref().ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + "the partition was built without the time ABI", + ) + }) + } + + fn time_abi_bsp(&self, code: TimeAbiCode) -> Result<&VcpuFd, TimeAbiError> { + self.finalized() + .map(|finalized| &finalized.bsp_vcpufd) + .map_err(|error| TimeAbiError::new(code, format!("VP 0 is unavailable: {error}"))) + } + + /// Checks that partition time can be frozen, leaving it as it was: a + /// frozen partition stays frozen until a VP runs. + fn probe_time_freeze(&self) -> Result<(), KernelError> { + let frozen = self.time_frozen.lock(); + self.vmfd + .set_partition_property(HvPartitionPropertyCode::TimeFreeze.0, 1)?; + if !*frozen { + self.vmfd + .set_partition_property(HvPartitionPropertyCode::TimeFreeze.0, 0)?; + } + Ok(()) + } + + /// Checks that VP 0 sees the identity leaves as registered, and zero at + /// the sentinel leaves. + fn check_identity_cpuid(&self, bsp: &VcpuFd) -> Result<(), TimeAbiError> { + let state = self.time_abi_state()?; + let identity = state + .registered_cpuid + .iter() + .filter(|leaf| { + leaf.function <= IDENTITY_MAX_LEAF && IDENTITY_CPUID_RANGE.contains(&leaf.function) + }) + .copied(); + let sentinels = PREFLIGHT_SENTINEL_LEAVES + .into_iter() + .map(|function| CpuidLeaf::new(function, [0; 4])); + for leaf in identity.chain(sentinels) { + let function = leaf.function; + let actual = vp_cpuid(bsp, function, 0).map_err(|error| { + TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!( + "cannot read CPUID {function:#x} of VP 0: {}", + error_chain(&error) + ), + ) + })?; + let mut expected = actual; + leaf.apply(&mut expected); + if actual != expected { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!( + "VP 0 reads CPUID {function:#x} as {actual:#x?}, expected {expected:#x?}" + ), + )); + } + } + Ok(()) + } +} + +impl TimeAbiBackend for MshvPartition { + fn native_tsc_hz(&self) -> Result { + let hz = self + .inner + .vmfd + .get_partition_property(HvPartitionPropertyCode::ProcessorClockFrequency.0) + .map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!( + "cannot read the ProcessorClockFrequency partition property: {}", + error_chain(&KernelError::from(error)) + ), + ) + })?; + if hz == 0 { + return Err(TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + "the ProcessorClockFrequency partition property is zero", + )); + } + Ok(hz) + } + + fn lapic_hz(&self) -> Result { + let hz = self + .inner + .vmfd + .get_partition_property(HvPartitionPropertyCode::ApicFrequency.0) + .map_err(|error| { + TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!( + "cannot read the ApicFrequency partition property: {}", + error_chain(&KernelError::from(error)) + ), + ) + })?; + if hz == 0 { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + "the ApicFrequency partition property is zero", + )); + } + Ok(hz) + } + + fn preflight(&self) -> Result { + // The MSR intercepts were installed at partition creation, which + // fails with E_IDENTITY_ROUTING otherwise. Check that the CPUID + // results took effect. + let bsp = self.inner.time_abi_bsp(TimeAbiCode::IdentityRouting)?; + self.inner.check_identity_cpuid(bsp)?; + + self.inner.probe_time_freeze().map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot freeze partition time: {}", error_chain(&error)), + ) + })?; + + // The guest TSC is the host TSC plus an offset: OpenVMM never sets a + // TSC frequency or multiplier for an MSHV partition. + Ok(BackendPreflight { + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::FrozenWrite, + }) + } + + fn effective_cpuid(&self) -> Result, TimeAbiError> { + let state = self.inner.time_abi_state()?; + let bsp = self.inner.time_abi_bsp(TimeAbiCode::CpuSurface)?; + let read = |function: u32, index: Option| { + let result = vp_cpuid(bsp, function, index.unwrap_or(0)).map_err(|error| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "cannot read CPUID {function:#x}/{:#x} of VP 0: {}", + index.unwrap_or(0), + error_chain(&error) + ), + ) + })?; + let leaf = CpuidLeaf::new(function, result); + Ok(match index { + Some(index) => leaf.indexed(index), + None => leaf, + }) + }; + let mut leaves = EFFECTIVE_CPUID_MAX_LEAVES + .into_iter() + .map(|function| read(function, None)) + .collect::, TimeAbiError>>()?; + for leaf in &state.registered_cpuid { + leaves.push(if is_zero_fill(leaf) { + *leaf + } else { + read(leaf.function, leaf.index)? + }); + } + Ok(CpuidLeafSet::new(leaves).into_leaves()) + } + + fn capture_anchor(&self) -> Result { + let mut candidates = Vec::with_capacity(ANCHOR_ATTEMPTS); + for _ in 0..ANCHOR_ATTEMPTS { + let before = sample_host_time()?; + let tsc = get_vp_tsc(&self.inner.vmfd, VpIndex::BSP).map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!("cannot read the TSC of VP 0: {}", error_chain(&error)), + ) + })?; + let after = sample_host_time()?; + let candidate = AnchorCandidate { tsc, before, after }; + candidates.push(candidate); + if candidate.width_ns() <= 2 * ANCHOR_TARGET_PAIRING_NS { + break; + } + } + let anchor = select_anchor(&candidates)?; + tracing::info!( + tsc = anchor.tsc, + pairing_ns = anchor.pairing_ns, + attempts = candidates.len(), + "time ABI: capture anchor" + ); + Ok(anchor) + } + + fn set_synchronized_tsc( + &self, + target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result { + let started = std::time::Instant::now(); + let state = self.inner.time_abi_state()?; + // Seal before collecting the created VPs; see + // `MshvTimeAbi::check_vp_creation`. + state.vp_set_sealed.store(true, Ordering::SeqCst); + let created = created_vps( + self.inner + .vps + .iter() + .map(|vp| vp.created.load(Ordering::SeqCst)), + ); + if created.first() != Some(&VpIndex::BSP) { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + "VP 0 does not exist", + )); + } + + // Partition time stays frozen until a VP first runs, so every VP + // holds the target from the anchor until the guest resumes. + self.inner.freeze_time().map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot freeze partition time: {}", error_chain(&error)), + ) + })?; + let frozen = started.elapsed(); + + let sample = sample_host_time()?; + let target = target(&sample)?; + + let vmfd = &self.inner.vmfd; + let failed = |vp: VpIndex, what: &str, error: KernelError| { + TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!( + "cannot {what} the TSC of VP {}: {}", + vp.index(), + error_chain(&error) + ), + ) + }; + for &vp in &created { + set_vp_tsc(vmfd, vp, target).map_err(|error| failed(vp, "write", error))?; + } + let written = started.elapsed(); + let readback = created + .iter() + .map(|&vp| { + get_vp_tsc(vmfd, vp) + .map(|value| (vp, value)) + .map_err(|error| failed(vp, "read back", error)) + }) + .collect::, _>>()?; + let result = check_readback(target, &readback); + tracing::info!( + created_vps = created.len(), + vp_capacity = self.inner.vps.len(), + target, + readback_equal = result.is_ok(), + freeze_us = frozen.as_micros() as u64, + write_us = (written - frozen).as_micros() as u64, + total_us = started.elapsed().as_micros() as u64, + "time ABI: synchronized TSC set while partition time is frozen" + ); + result?; + Ok(TscSetReport { + target, + sample, + readback, + method: TscSyncMethod::FrozenWrite, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use virt::time_abi::DeclaredRates; + use virt::time_abi::identity::time_abi_cpuid; + + const VP_COUNT: u32 = 8; + + fn declared_msrs() -> TimeAbiMsrs { + let msrs = TimeAbiMsrs::new(); + msrs.declare(DeclaredRates::new(2_194_844_582, 200_000_000).unwrap()) + .unwrap(); + msrs + } + + #[test] + fn identity_msr_reads_follow_the_abi_table() { + let msrs = declared_msrs(); + let read = |vp, msr| serve_identity_msr(&msrs, VpIndex::new(vp), msr, MsrAccess::Read); + assert_eq!(read(3, MSR_VP_INDEX), MsrOutcome::Read(3)); + assert_eq!(read(0, MSR_TSC_FREQUENCY), MsrOutcome::Read(2_194_844_582)); + assert_eq!(read(5, MSR_APIC_FREQUENCY), MsrOutcome::Read(200_000_000)); + assert_eq!(read(1, MSR_TSC_INVARIANT_CONTROL), MsrOutcome::Read(0)); + } + + #[test] + fn frequency_reads_fault_before_the_rates_are_declared() { + let msrs = TimeAbiMsrs::new(); + for msr in [MSR_TSC_FREQUENCY, MSR_APIC_FREQUENCY] { + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Read), + MsrOutcome::Fault + ); + } + assert_eq!( + serve_identity_msr(&msrs, VpIndex::new(2), MSR_VP_INDEX, MsrAccess::Read), + MsrOutcome::Read(2) + ); + } + + #[test] + fn tsc_invariant_control_accepts_only_zero_and_one() { + let msrs = declared_msrs(); + let vp = VpIndex::new(1); + let read = || serve_identity_msr(&msrs, vp, MSR_TSC_INVARIANT_CONTROL, MsrAccess::Read); + let write = |value| { + serve_identity_msr( + &msrs, + vp, + MSR_TSC_INVARIANT_CONTROL, + MsrAccess::Write(value), + ) + }; + for value in [1, 0, 1] { + assert_eq!(write(value), MsrOutcome::Written); + assert_eq!(read(), MsrOutcome::Read(value)); + } + for value in [2, 3, (1 << 63) | 1, u64::MAX] { + assert_eq!(write(value), MsrOutcome::Fault); + assert_eq!(read(), MsrOutcome::Read(1)); + } + } + + #[test] + fn read_only_and_undefined_identity_msrs_fault() { + let msrs = declared_msrs(); + for msr in [MSR_VP_INDEX, MSR_TSC_FREQUENCY, MSR_APIC_FREQUENCY] { + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Write(0)), + MsrOutcome::Fault + ); + } + for msr in [ + 0x4000_0000, + 0x4000_0001, + 0x4000_0020, + 0x4000_0021, + 0x4000_01ff, + ] { + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Read), + MsrOutcome::Fault + ); + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Write(1)), + MsrOutcome::Fault + ); + } + } + + #[test] + fn msrs_outside_the_identity_range_fault() { + let msrs = declared_msrs(); + for msr in [0x10, 0x3fff_ffff, 0x4000_0200] { + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Read), + MsrOutcome::Fault + ); + } + } + + #[test] + fn routed_msrs_are_the_served_identity_msrs() { + assert_eq!( + ROUTED_MSRS, + [0x4000_0002, 0x4000_0022, 0x4000_0023, 0x4000_0118] + ); + for msr in ROUTED_MSRS { + let intercept = msr_index_intercept(msr); + assert_eq!( + intercept.access_type_mask, + HV_INTERCEPT_ACCESS_MASK_READ_WRITE + ); + assert_eq!( + intercept.intercept_type, + mshv_bindings::hv_intercept_type_HV_INTERCEPT_TYPE_X64_MSR_INDEX + ); + // SAFETY: every view of this C union is a plain integer. + let parameter = unsafe { intercept.intercept_parameter.as_uint64 }; + assert_eq!(parameter, u64::from(msr)); + } + } + + #[test] + fn gp_fault_event_raises_gp_with_error_code_zero() { + let event = hvdef::HvX64PendingExceptionEvent::from(gp_fault_event()); + assert!(event.event_pending()); + assert_eq!(event.event_type(), hvdef::HV_X64_PENDING_EVENT_EXCEPTION); + assert_eq!(event.vector(), 13); + assert!(event.deliver_error_code()); + assert_eq!(event.error_code(), 0); + } + + #[test] + fn partition_cpuid_registers_the_configured_identity_verbatim() { + let own = vec![ + CpuidLeaf::new(0x4000_0000, [0x4000_0006, 1, 2, 3]), + CpuidLeaf::new(0x4000_0081, [0x3123_5356, 0, 0, 0]), + CpuidLeaf::new(0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + CpuidLeaf::new(0xd, [7, 0x340, 0x340, 0]).indexed(0), + ]; + let config = time_abi_cpuid(VP_COUNT, true); + let cpuid = partition_cpuid(own, &config); + let result = |function| cpuid.result(function, 0, &[0; 4]); + + assert_eq!( + result(0x4000_0000), + [0x4000_0005, 0x7263_694d, 0x666f_736f, 0x7648_2074] + ); + assert_eq!(result(0x4000_0001), [0x3123_7648, 0, 0, 0]); + assert_eq!(result(0x4000_0002), [0x0058_564e, 0x0001_0000, 0, 0]); + assert_eq!(result(0x4000_0003), [0x8860, 0, 0, 0x100]); + assert_eq!(result(0x4000_0004), [0, 0xffff_ffff, 0, 0]); + assert_eq!(result(0x4000_0005), [VP_COUNT, VP_COUNT, 0, 0]); + // The backend's own hypervisor-range leaves are dropped: every + // registered hypervisor-range leaf is a configured one, verbatim. + let hypervisor: Vec<_> = cpuid + .leaves() + .iter() + .filter(|leaf| HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) + .collect(); + let configured: Vec<_> = config + .leaves() + .iter() + .filter(|leaf| HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) + .collect(); + assert_eq!(hypervisor.len(), configured.len()); + for (registered, configured) in hypervisor.iter().zip(&configured) { + assert_eq!( + ( + registered.function, + registered.index, + registered.result, + registered.mask + ), + ( + configured.function, + configured.index, + configured.result, + configured.mask + ) + ); + } + assert_eq!(result(0x4000_0081), [0; 4]); + assert_eq!(result(0x4000_0100), [0; 4]); + // Other leaves are kept. + assert_eq!(cpuid.result(0xd, 0, &[0; 4]), [7, 0x340, 0x340, 0]); + } + + #[test] + fn partition_cpuid_applies_the_time_bits_last() { + let own = vec![ + CpuidLeaf::new( + 1, + [ + 0x0005_0657, + 0x0080_0800, + 0x7ffa_fbff | (1 << 24), + 0xbfeb_fbff, + ], + ), + CpuidLeaf::new(6, [0x77, 2, 9, 0]), + CpuidLeaf::new(0x15, [2, 0xd4, 0x017d_7840, 0]), + ]; + let cpuid = partition_cpuid(own, &time_abi_cpuid(VP_COUNT, true)); + let [eax, ebx, ecx, edx] = cpuid.result(1, 0, &[0; 4]); + assert_eq!((eax, ebx), (0x0005_0657, 0x0080_0800)); + assert_eq!(ecx & (1 << 31), 1 << 31, "hypervisor present"); + assert_eq!(ecx & (1 << 24), 0, "TSC-deadline timer"); + assert_eq!(ecx & (1 << 15), 0, "PDCM"); + assert_eq!(edx, 0xbfeb_fbff); + assert_eq!(cpuid.result(6, 0, &[!0; 4]), [4, 0, 0, 0]); + assert_eq!(cpuid.result(0x15, 0, &[!0; 4]), [0; 4]); + assert_eq!(cpuid.result(0x8000_0007, 0, &[!0; 4]), [0, 0, 0, 0x100]); + } + + #[test] + fn zero_fill_leaves_are_full_zero_leaves_past_the_identity() { + for function in [0x4000_0006, 0x4000_0081, 0x4000_00ff] { + assert!(is_zero_fill(&CpuidLeaf::new(function, [0; 4]))); + } + // Identity leaves, nonzero or partial leaves, and zeroed time-bit + // leaves outside the range are read back from VP 0. + assert!(!is_zero_fill(&CpuidLeaf::new(0x4000_0004, [0; 4]))); + assert!(!is_zero_fill(&CpuidLeaf::new(0x4000_0006, [1, 0, 0, 0]))); + assert!(!is_zero_fill(&CpuidLeaf::new(0x4000_0100, [0; 4]))); + assert!(!is_zero_fill(&CpuidLeaf::new(0x15, [0; 4]))); + assert!(!is_zero_fill( + &CpuidLeaf::new(0x4000_0006, [0; 4]).masked([1, 0, 0, 0]) + )); + } + + #[test] + fn time_abi_bank1_keeps_invariant_tsc_and_hides_the_other_time_features() { + let supported = super::super::supported_processor_features1(); + assert!(supported.tsc_deadline_tmr_support()); + assert!(supported.tsc_adjust_support()); + assert!(supported.a_count_m_count_support()); + let features = processor_features1(supported); + assert!(features.tsc_invariant_support()); + assert!(!features.tsc_deadline_tmr_support()); + assert!(!features.tsc_adjust_support()); + assert!(!features.a_count_m_count_support()); + // Nothing else changes. + let time_bits = hvdef::HvX64PartitionProcessorFeatures1::new() + .with_tsc_invariant_support(true) + .with_tsc_deadline_tmr_support(true) + .with_tsc_adjust_support(true) + .with_a_count_m_count_support(true); + let others = !u64::from(time_bits); + assert_eq!(u64::from(features) & others, u64::from(supported) & others); + + let args = with_feature_banks( + super::super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false) + .unwrap(), + ); + let banks = args.pt_cpu_fbanks; + assert_eq!(banks[1], !u64::from(features)); + assert_eq!( + banks[0], + !u64::from(super::super::supported_processor_features()) + ); + } + + #[test] + fn time_abi_rejects_isolation_and_the_hyperv_interface() { + validate_partition(virt::IsolationType::None, false).unwrap(); + for (isolation, hv) in [ + (virt::IsolationType::Snp, false), + (virt::IsolationType::None, true), + ] { + assert_eq!( + validate_partition(isolation, hv).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + } + } + + #[test] + fn created_vps_skip_an_uncreated_suffix() { + let vps = |created: &[bool]| -> Vec { + created_vps(created.iter().copied()) + .into_iter() + .map(|vp| vp.index()) + .collect() + }; + assert_eq!(vps(&[true]), [0]); + assert_eq!( + vps(&[true, true, true, true, false, false, false, false]), + [0, 1, 2, 3] + ); + assert_eq!(vps(&[false, false]), Vec::::new()); + } + + #[test] + fn readback_must_equal_the_target_on_every_vp() { + let target = 0x1234_5678_9abc; + let readback: Vec<_> = (0..4).map(|vp| (VpIndex::new(vp), target)).collect(); + check_readback(target, &readback).unwrap(); + check_readback(target, &[]).unwrap(); + + let mut skewed = readback.clone(); + skewed[2].1 += 1; + let error = check_readback(target, &skewed).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscSyncReadback); + assert!(error.message.contains("VP 2"), "{error}"); + } + + #[test] + fn late_vp_creation_fails_after_the_synchronized_set() { + let config = TimeAbiConfig { + cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), + msrs: Arc::new(TimeAbiMsrs::new()), + }; + let state = MshvTimeAbi::new(&config, &partition_cpuid(Vec::new(), &config.cpuid)); + state.check_vp_creation(VpIndex::new(4)).unwrap(); + state.vp_set_sealed.store(true, Ordering::SeqCst); + let error = state.check_vp_creation(VpIndex::new(4)).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::VpLateCreation); + assert_eq!( + error.to_string(), + "[E_VP_LATE_CREATION] VP 4 was instantiated after the synchronized TSC set" + ); + } + + fn sample(utc_ns: u64, monotonic_ns: u64) -> HostTimeSample { + HostTimeSample { + utc_ns, + monotonic_ns, + } + } + + #[test] + fn capture_anchor_pairs_with_the_bracket_midpoint() { + let candidate = AnchorCandidate { + tsc: 42, + before: sample(1_000_000, 500), + after: sample(1_003_001, 3_501), + }; + assert_eq!(candidate.width_ns(), 3_001); + assert_eq!( + candidate.anchor(), + TscAnchor { + tsc: 42, + sample: sample(1_001_500, 2_000), + pairing_ns: 1_501, + } + ); + } + + #[test] + fn capture_anchor_ignores_a_utc_step_inside_the_bracket() { + let candidate = AnchorCandidate { + tsc: 7, + before: sample(5_000_000, 100), + after: sample(1_000, 2_100), + }; + assert_eq!(candidate.anchor().sample, sample(5_001_000, 1_100)); + } + + #[test] + fn capture_anchor_selects_the_tightest_bracket_within_the_bound() { + let candidate = |tsc, width| AnchorCandidate { + tsc, + before: sample(0, 0), + after: sample(width, width), + }; + let anchor = select_anchor(&[ + candidate(1, 30_000), + candidate(2, 8_000), + candidate(3, 12_000), + ]) + .unwrap(); + assert_eq!((anchor.tsc, anchor.pairing_ns), (2, 4_000)); + + // The bound applies to the pairing: half the bracket. + assert_eq!( + select_anchor(&[candidate(4, 20_000)]).unwrap().pairing_ns, + 10_000 + ); + let error = select_anchor(&[candidate(5, 20_001), candidate(6, 50_000)]).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscAnchor); + assert_eq!(select_anchor(&[]).unwrap_err().code, TimeAbiCode::TscAnchor); + } + + #[test] + fn error_chain_includes_sources() { + let error = KernelError::Hypercall { + code: hvdef::HypercallCode::HvCallInstallIntercept, + error: hvdef::HvError::AccessDenied, + }; + let text = error_chain(&error); + assert!(text.starts_with("hypercall"), "{text}"); + assert!(text.contains(": "), "{text}"); + } +} + +/// Hardware tests. Run them on an MSHV root partition with +/// `cargo test -p virt_mshv -- --ignored --nocapture --test-threads=1 time_abi::hw`. +#[cfg(test)] +mod hw { + use super::*; + use crate::LinuxMshv; + use guestmem::GuestMemory; + use hvdef::Vtl; + use pal_async::DefaultDriver; + use pal_async::async_test; + use virt::BindProcessor; + use virt::Hypervisor; + use virt::Partition; + use virt::PartitionConfig; + use virt::PartitionMemoryMapper; + use virt::ProtoPartition; + use virt::ProtoPartitionConfig; + use virt::time_abi::identity::check_identity; + use virt::time_abi::identity::check_time_bits; + use virt::time_abi::identity::time_abi_cpuid; + use virt::time_abi::rate::LAPIC_HZ_HYPERV; + use virt::time_abi::rate::check_plausible_tsc_hz; + use vm_topology::memory::MemoryLayout; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + use vmcore::vmtime::VmTime; + use vmcore::vmtime::VmTimeKeeper; + + const VP_CAPACITY: u32 = 4; + + fn percentile(sorted: &[u64], percent: usize) -> u64 { + sorted[(sorted.len() - 1) * percent / 100] + } + + /// Builds a time ABI partition, binds the first `created` VPs, and runs + /// every backend primitive against the hypervisor. + #[async_test] + #[ignore = "requires /dev/mshv"] + async fn time_abi_backend_on_hardware(driver: DefaultDriver) { + let processor_topology = TopologyBuilder::new_x86() + .x2apic(X2ApicState::Supported) + .build(VP_CAPACITY) + .unwrap(); + let mem_layout = MemoryLayout::new(0x400000, &[], &[], &[], None).unwrap(); + let vmtime_keeper = VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); + let vmtime = vmtime_keeper.builder().build(&driver).await.unwrap(); + + for created in [1, 2, VP_CAPACITY] { + let guest_memory = GuestMemory::allocate(mem_layout.end_of_ram() as usize); + let mut mshv = LinuxMshv::new().unwrap(); + let started = std::time::Instant::now(); + let (partition, mut binders) = mshv + .new_partition(ProtoPartitionConfig { + processor_topology: &processor_topology, + hv_config: None, + vmtime: &vmtime, + isolation: virt::ProtoPartitionIsolation::None, + nested_virt: false, + user_mode_memory_faults: false, + lazy_memory_registration: false, + versioned_cpu_contract: false, + time_abi: Some(TimeAbiConfig { + cpuid: Arc::new(time_abi_cpuid(VP_CAPACITY, true)), + msrs: Arc::new(TimeAbiMsrs::new()), + }), + }) + .unwrap() + .build(PartitionConfig { + mem_layout: &mem_layout, + guest_memory: &guest_memory, + cpuid: &[], + vtl0_alias_map: None, + fault_resolver: None, + }) + .unwrap(); + let build_us = started.elapsed().as_micros(); + let ram = guest_memory.inner_buf().unwrap(); + // SAFETY: the guest memory outlives the partition. + unsafe { + partition.memory_mapper(Vtl::Vtl0).map_range( + ram.as_ptr().cast_mut().cast(), + ram.len(), + 0, + true, + true, + ) + } + .unwrap(); + partition.finalize_memory().unwrap(); + for binder in &mut binders[..created as usize] { + binder.bind().unwrap(); + } + + assert!(!partition.caps().hv1); + assert!(!partition.caps().kvm_clock); + let backend = partition.time_abi().expect("a time ABI partition"); + + let started = std::time::Instant::now(); + let preflight = backend.preflight().unwrap(); + let preflight_us = started.elapsed().as_micros(); + assert_eq!( + preflight, + BackendPreflight { + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::FrozenWrite, + } + ); + let tsc_hz = backend.native_tsc_hz().unwrap(); + check_plausible_tsc_hz(tsc_hz).unwrap(); + assert_eq!(backend.lapic_hz().unwrap(), LAPIC_HZ_HYPERV); + + let started = std::time::Instant::now(); + let effective = CpuidLeafSet::new(backend.effective_cpuid().unwrap()); + let effective_us = started.elapsed().as_micros(); + let mut lookup = |leaf, subleaf| effective.result(leaf, subleaf, &[0; 4]); + check_time_bits(&mut lookup, true).unwrap(); + check_identity(&mut lookup, VP_CAPACITY).unwrap(); + + let mut pairings: Vec = (0..200) + .map(|_| backend.capture_anchor().unwrap().pairing_ns) + .collect(); + pairings.sort_unstable(); + let first = backend.capture_anchor().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(200)); + let second = backend.capture_anchor().unwrap(); + let elapsed_ns = second.sample.monotonic_ns - first.sample.monotonic_ns; + let measured_hz = + u128::from(second.tsc - first.tsc) * 1_000_000_000 / u128::from(elapsed_ns); + let deviation_ppm = (measured_hz as f64 - tsc_hz as f64) / tsc_hz as f64 * 1_000_000.0; + assert!( + deviation_ppm.abs() < 200.0, + "anchors measure {measured_hz} Hz against {tsc_hz} Hz" + ); + + let target = 1_000_000_000_000; + let mut anchor = None; + let started = std::time::Instant::now(); + let report = backend + .set_synchronized_tsc(&mut |sample| { + anchor = Some(*sample); + Ok(target) + }) + .unwrap(); + let set_us = started.elapsed().as_micros(); + assert_eq!(report.target, target); + assert_eq!(Some(report.sample), anchor); + assert_eq!(report.method, TscSyncMethod::FrozenWrite); + let expected: Vec<_> = (0..created).map(|vp| (VpIndex::new(vp), target)).collect(); + assert_eq!(report.readback, expected); + + // Time stays frozen until a VP runs. + std::thread::sleep(std::time::Duration::from_millis(10)); + for vp in 0..created { + assert_eq!( + get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp)).unwrap(), + target + ); + } + if created < VP_CAPACITY { + let error = binders[created as usize].bind().err().unwrap(); + assert!( + error.to_string().starts_with("[E_VP_LATE_CREATION]"), + "{error}" + ); + } + + // A failing target aborts the set. + let error = backend + .set_synchronized_tsc(&mut |_| { + Err(TimeAbiError::new(TimeAbiCode::DowntimeNegative, "test")) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::DowntimeNegative); + + // After the thaw, every VP advances from the target together. + partition.inner.thaw_time().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + let after: Vec = (0..created) + .map(|vp| get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp)).unwrap()) + .collect(); + assert!(after.iter().all(|&tsc| tsc > target + tsc_hz / 1000)); + + println!( + "time ABI backend: created {created}/{VP_CAPACITY} tsc_hz={tsc_hz} \ + build_us={build_us} preflight_us={preflight_us} effective_cpuid_us={effective_us} \ + ({} leaves) set_us={set_us} anchor_pairing_ns min={} p50={} p99={} max={} \ + anchor_rate_deviation_ppm={deviation_ppm:.3} thawed={after:?}", + effective.leaves().len(), + pairings[0], + percentile(&pairings, 50), + percentile(&pairings, 99), + pairings[pairings.len() - 1], + ); + } + } + + /// The negative path of identity routing: the hypervisor refuses an + /// MSR-index intercept for `IA32_ARCH_CAPABILITIES` (AccessDenied), which + /// must surface as `E_IDENTITY_ROUTING` naming the MSR. + #[test] + #[ignore = "requires /dev/mshv"] + fn refused_msr_intercept_fails_identity_routing() { + let args = with_feature_banks( + super::super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false) + .unwrap(), + ); + let mshv = mshv_ioctls::Mshv::new().unwrap(); + let vmfd = crate::create_vm_with_retry(&mshv, &args).unwrap(); + vmfd.initialize().unwrap(); + route_identity_msrs(&vmfd).unwrap(); + let error = install_msr_intercepts(&vmfd, &[0x10a]).unwrap_err(); + println!("refused intercept: {error}"); + assert_eq!(error.code, TimeAbiCode::IdentityRouting); + assert!(error.message.contains("MSR 0x10a"), "{error}"); + } + + /// Reports whether the processor feature banks can expose `BHI_CTRL` + /// (CPUID.(EAX=7,ECX=2):EDX[4]) to a guest: Linux treats a guest with it + /// as not vulnerable to indirect target selection. + #[test] + #[ignore = "requires /dev/mshv"] + fn bhi_ctrl_exposure() { + let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").unwrap(); + let line = |prefix: &str| { + cpuinfo + .lines() + .find(|line| line.starts_with(prefix)) + .unwrap_or_default() + }; + let has = |line: &str, token: &str| line.split_whitespace().any(|word| word == token); + println!( + "host: {}; root flags bhi_ctrl={} bugs its={}", + line("model name"), + has(line("flags"), "bhi_ctrl"), + has(line("bugs"), "its") + ); + let mshv = mshv_ioctls::Mshv::new().unwrap(); + match mshv.get_host_partition_property(HvPartitionPropertyCode::ProcessorFeatures1.0) { + Ok(bank1) => { + let bank1 = hvdef::HvX64PartitionProcessorFeatures1::from(bank1); + println!( + "host ProcessorFeatures1={:#018x}: bhi_dis_support={} bhi_no_support={}", + u64::from(bank1), + bank1.bhi_dis_support(), + bank1.bhi_no_support() + ); + } + Err(error) => println!("host ProcessorFeatures1 unavailable: {error}"), + } + let supported = super::super::supported_processor_features1(); + for (name, features1) in [ + ("default", supported), + ("time-abi", processor_features1(supported)), + ( + "bhi_dis", + processor_features1(supported).with_bhi_dis_support(true), + ), + ( + "bhi_dis+bhi_no", + processor_features1(supported) + .with_bhi_dis_support(true) + .with_bhi_no_support(true), + ), + ] { + let mut args = super::super::partition_create_args( + &virt::ProtoPartitionIsolation::None, + false, + false, + ) + .unwrap(); + let mut banks = args.pt_cpu_fbanks; + banks[1] = !u64::from(features1); + args.pt_cpu_fbanks = banks; + let vmfd = crate::create_vm_with_retry(&mshv, &args).unwrap(); + vmfd.initialize().unwrap(); + let vp = vmfd.create_vcpu(0).unwrap(); + let leaf7 = vp.get_cpuid_values(7, 0, 0, 0).unwrap(); + let leaf7_2 = vp.get_cpuid_values(7, 2, 0, 0).unwrap(); + println!( + "banks={name}: CPUID.7.0={leaf7:#010x?} CPUID.7.2={leaf7_2:#010x?} \ + BHI_CTRL={}", + leaf7[0] >= 2 && leaf7_2[3] & (1 << 4) != 0 + ); + } + } +} From fc5293f86493853d7ebcdba5a0e345831947c035 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 03:21:20 -0700 Subject: [PATCH 173/304] openvmm_core: add the time ABI platform behind --x-time-abi-v1 Add the hidden --x-time-abi-v1 development switch, which selects the NVX time ABI v1 for a microVM cold boot. It exists only on the integration branch: the flip commit removes it with the old path. Under the switch the worker passes the time ABI CPUID and the identity MSR handler to the backend instead of the versioned CPU contract, requires the backend to implement TimeAbiBackend (E_TSC_SYNC_UNSUPPORTED otherwise), applies the rate policy and declares the native TSC rate and the backend LAPIC rate, registers the time-abi state unit that saves HV_X64_MSR_TSC_INVARIANT_CONTROL, and rejects kernel clock tokens instead of injecting them. Snapshot capture and restore under the switch are rejected until the time ABI restore path lands. Also add virt::time_abi::negotiate_rates, which applies the rate policy to a backend for cold boot and restore, and a command-line check for the forbidden clock tokens that follows the kernel's parsing rules. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_core/src/worker/dispatch.rs | 65 ++++- .../src/worker/dispatch/microvm.rs | 13 + .../src/worker/dispatch/time_abi.rs | 136 ++++++++++ openvmm/openvmm_defs/src/microvm.rs | 7 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 5 + openvmm/openvmm_entry/src/microvm/config.rs | 19 ++ vmm_core/virt/src/time_abi/backend.rs | 249 ++++++++++++++++++ vmm_core/virt/src/time_abi/mod.rs | 92 +++++++ vmm_core/virt/src/time_abi/msr.rs | 12 + 9 files changed, 595 insertions(+), 3 deletions(-) create mode 100644 openvmm/openvmm_core/src/worker/dispatch/time_abi.rs diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 91375e1e96a..68ac4a65bdb 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -13,6 +13,7 @@ mod pcie_wiring; mod restore; mod smmu_wiring; mod snapshot_rpc; +mod time_abi; use crate::emuplat; use crate::partition::BindHvliteVp; @@ -347,7 +348,8 @@ impl Worker for VmWorker { let microvm_params = microvm::MicrovmParameters::take(&mut parameters)?; let (device_thread, device_driver) = new_device_thread(); - let manifest = Manifest::from_config(parameters.cfg); + let mut manifest = Manifest::from_config(parameters.cfg); + manifest.microvm.hypervisor_id = parameters.hypervisor.id().to_owned(); let hypervisor = block_on(ResourceResolver::new().resolve(parameters.hypervisor, ())) .context("failed to resolve hypervisor backend")?; @@ -491,6 +493,9 @@ pub(crate) struct InitializedVm { processor_topology: ProcessorTopology, igvm_file: Option, driver_source: VmTaskDriverSource, + /// The identity MSR handler, when the partition uses the time ABI. + #[cfg(guest_arch = "x86_64")] + time_abi_msrs: Option>, } trait ExtractTopologyConfig { @@ -914,6 +919,9 @@ struct LoadedVmInner { partition: Arc, chipset_devices: ChipsetDevices, _vmtime: SpawnedUnit, + /// The time ABI state unit, when the partition uses the time ABI. + #[cfg(guest_arch = "x86_64")] + _time_abi_unit: Option>, memory_manager: GuestMemoryManager, gm: GuestMemory, vtl0_hvsock_relay: Option, @@ -1256,6 +1264,20 @@ impl InitializedVm { microvm::uses_lazy_memory_registration(&cfg, shared_memory.as_ref()); let user_mode_memory_faults = !lazy_memory_registration; + #[cfg(guest_arch = "x86_64")] + let (time_abi_msrs, time_abi_config) = + if cfg.machine_profile == MachineProfile::Microvm && cfg.microvm.time_abi.is_some() { + let (msrs, config) = time_abi::partition_config(processor_topology.vp_count()); + (Some(msrs), Some(config)) + } else { + (None, None) + }; + #[cfg(guest_arch = "x86_64")] + let versioned_cpu_contract = + microvm::uses_versioned_cpu_contract(cfg.machine_profile) && time_abi_config.is_none(); + #[cfg(not(guest_arch = "x86_64"))] + let versioned_cpu_contract = microvm::uses_versioned_cpu_contract(cfg.machine_profile); + let partition_prototype = openvmm_defs::profile::ProfileSpan::start(); let proto = hypervisor .new_partition(virt::ProtoPartitionConfig { @@ -1268,9 +1290,9 @@ impl InitializedVm { device_assignment_msi_iova_range, user_mode_memory_faults, lazy_memory_registration, - versioned_cpu_contract: microvm::uses_versioned_cpu_contract(cfg.machine_profile), + versioned_cpu_contract, #[cfg(guest_arch = "x86_64")] - time_abi: None, + time_abi: time_abi_config, }) .context("failed to create the prototype partition")?; partition_prototype.complete("startup", "partition_prototype", Default::default()); @@ -1644,6 +1666,8 @@ impl InitializedVm { processor_topology, igvm_file, driver_source, + #[cfg(guest_arch = "x86_64")] + time_abi_msrs, }) } @@ -1676,6 +1700,8 @@ impl InitializedVm { processor_topology, igvm_file, driver_source, + #[cfg(guest_arch = "x86_64")] + time_abi_msrs, } = self; let instantiated_vp_count = snapshot_restore.select_instantiated_vps( @@ -1909,6 +1935,37 @@ impl InitializedVm { }) .unwrap(); + #[cfg(guest_arch = "x86_64")] + let time_abi_unit = match time_abi_msrs { + Some(msrs) => { + let parameters = cfg + .microvm + .time_abi + .as_ref() + .context("time ABI parameters are missing")?; + anyhow::ensure!( + saved_state.is_none(), + "time ABI v1 snapshot restore is not implemented yet" + ); + time_abi::declare_rates( + partition.as_ref(), + &msrs, + &cfg.microvm.hypervisor_id, + None, + ¶meters.hooks, + )?; + Some( + state_units + .add(time_abi::TIME_ABI_UNIT) + .spawn(driver_source.simple(), |recv| { + state_unit::run_unit(time_abi::TimeAbiUnit(msrs), recv) + }) + .unwrap(), + ) + } + None => None, + }; + let mut input_distributor = InputDistributor::new(cfg.input); resolver.add_async_resolver::( input_distributor.client().clone(), @@ -3280,6 +3337,8 @@ impl InitializedVm { partition, chipset_devices: devices, _vmtime: vmtime, + #[cfg(guest_arch = "x86_64")] + _time_abi_unit: time_abi_unit, memory_manager, gm, vtl0_hvsock_relay, diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 310bcc6c5be..9bbf9adc060 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -49,6 +49,11 @@ pub(super) struct MicrovmManifest { pub(super) memory_capacity: Option, pub(super) snapshot_memory_ranges: Vec, pub(super) restore_memory_ranges: Vec, + /// The NVX time ABI parameters, when the time ABI is selected. + pub(super) time_abi: Option, + /// The hypervisor backend ID (`kvm`, `mshv`, or `whp`), set by the + /// worker. + pub(super) hypervisor_id: String, } impl From for MicrovmManifest { @@ -61,12 +66,15 @@ impl From for MicrovmManifest { memory_capacity, snapshot_memory_ranges, restore_memory_ranges, + time_abi, } = config; Self { sandbox_blocks, memory_capacity, snapshot_memory_ranges, restore_memory_ranges, + time_abi, + hypervisor_id: String::new(), } } } @@ -739,6 +747,11 @@ fn prepare_cold_boot_command_line( let Some(cmdline) = cmdline else { anyhow::bail!("microVM has no supported cold-boot command line"); }; + if cfg.microvm.time_abi.is_some() { + // The time ABI declares both rates through the identity MSRs. + virt::time_abi::check_command_line_clock_tokens(cmdline)?; + return Ok(()); + } match partition .tsc_frequency_hz() .context("failed to query the backend guest TSC frequency")? diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs new file mode 100644 index 00000000000..25ce5758a8d --- /dev/null +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! NVX time ABI v1 in the VM worker, selected by the hidden `--x-time-abi-v1` +//! development switch: the partition configuration, the backend preflight +//! and rate policy, and the `time-abi` state unit. + +#![cfg(guest_arch = "x86_64")] + +use crate::partition::HvlitePartition; +use inspect::Inspect; +use inspect::InspectMut; +use state_unit::StateUnit; +use std::sync::Arc; +use virt::time_abi::DeclaredRates; +use virt::time_abi::NegotiatedRates; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiConfig; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiMsrs; +use virt::time_abi::TimeAbiTestHooks; +use vmcore::save_restore::RestoreError; +use vmcore::save_restore::SaveError; +use vmcore::save_restore::SavedStateBlob; + +/// The name of the time ABI state unit. +pub(super) const TIME_ABI_UNIT: &str = "time-abi"; + +/// Returns the identity MSR handler and the time ABI configuration of a new +/// partition with `vp_count` VPs. +pub(super) fn partition_config(vp_count: u32) -> (Arc, TimeAbiConfig) { + let msrs = Arc::new(TimeAbiMsrs::new()); + let config = TimeAbiConfig { + cpuid: Arc::new(virt::time_abi::identity::time_abi_cpuid(vp_count, true)), + msrs: msrs.clone(), + }; + (msrs, config) +} + +/// Runs the backend preflight and the rate policy, and declares the rates +/// to the guest: restore steps 8 and 9 of the specification when `saved` +/// holds the snapshot's rates, or their cold-boot equivalent. +pub(super) fn declare_rates( + partition: &dyn HvlitePartition, + msrs: &TimeAbiMsrs, + hypervisor: &str, + saved: Option, + hooks: &TimeAbiTestHooks, +) -> anyhow::Result { + let backend = partition.time_abi().ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("the {hypervisor} backend does not implement the time ABI"), + ) + })?; + let preflight = backend.preflight()?; + let rates = virt::time_abi::negotiate_rates(backend, hypervisor, saved, hooks)?; + msrs.declare(rates.declared)?; + tracing::info!( + hypervisor, + msr_route = ?preflight.msr_route, + sync = ?preflight.sync, + native_tsc_hz = rates.native_tsc_hz, + tsc_hz = rates.declared.tsc_hz, + apic_hz = rates.declared.apic_hz, + deviation_ppb = ?rates.check.map(|check| check.deviation_ppb), + "time ABI rates declared" + ); + Ok(rates) +} + +/// The `time-abi` state unit. It saves and restores +/// `HV_X64_MSR_TSC_INVARIANT_CONTROL`, which is partition-wide guest state. +pub(super) struct TimeAbiUnit(pub Arc); + +impl InspectMut for TimeAbiUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + self.0.inspect(req); + } +} + +impl StateUnit for TimeAbiUnit { + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn stop(&mut self) {} + + async fn reset(&mut self) -> anyhow::Result<()> { + self.0.reset(); + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(Some(SavedStateBlob::new(self.0.save()))) + } + + async fn restore(&mut self, state: SavedStateBlob) -> Result<(), RestoreError> { + self.0.restore(state.parse()?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; + use vm_topology::processor::VpIndex; + + #[test] + fn unit_saves_restores_and_resets_the_invariant_control() { + futures::executor::block_on(async { + let (msrs, config) = partition_config(4); + assert!(Arc::ptr_eq(&msrs, &config.msrs)); + msrs.write(VpIndex::BSP, MSR_TSC_INVARIANT_CONTROL, 1) + .unwrap() + .unwrap(); + let mut unit = TimeAbiUnit(msrs.clone()); + let saved = unit.save().await.unwrap().unwrap(); + + let (restored, _) = partition_config(4); + let mut restored_unit = TimeAbiUnit(restored.clone()); + restored_unit.restore(saved).await.unwrap(); + assert_eq!(restored.tsc_invariant_control(), 1); + + restored_unit.reset().await.unwrap(); + assert_eq!(restored.tsc_invariant_control(), 0); + }); + } + + #[test] + fn partition_cpuid_carries_the_identity() { + let (_, config) = partition_config(2); + let mut cpuid = |leaf, subleaf| config.cpuid.result(leaf, subleaf, &[0; 4]); + virt::time_abi::identity::check_identity(&mut cpuid, 2).unwrap(); + } +} diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 274aebaed3d..5d4b5fcd32c 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -1061,6 +1061,9 @@ pub struct MicrovmConfig { pub snapshot_memory_ranges: Vec, /// Fresh private GPA ranges selected for this restore launch. pub restore_memory_ranges: Vec, + /// Selects the NVX time ABI v1. Set by the hidden `--x-time-abi-v1` + /// development switch until the time ABI becomes the only microVM path. + pub time_abi: Option, } fn validate_machine_load_mode( @@ -1159,6 +1162,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> && config.microvm.restore_memory_ranges.is_empty(), "microVM memory expansion configuration requires the microVM profile" ); + anyhow::ensure!( + config.microvm.time_abi.is_none(), + "the NVX time ABI requires the microVM profile" + ); return Ok(()); }; diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 7f0e4932f94..a75ce29b7cf 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -358,6 +358,11 @@ pub struct MicrovmCli { hide = true )] pub microvm_control_auth_timeout_ms: u64, + + /// use the NVX time ABI v1 (development switch; removed when the time ABI + /// becomes the only microVM path) + #[clap(long = "x-time-abi-v1", hide = true)] + pub x_time_abi_v1: bool, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index e104ae8ed9d..2fb69ec87e5 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -680,6 +680,25 @@ impl<'a> MicrovmConfigBuilder<'a> { memory_range::MemoryRange::new(range.gpa_start..range.gpa_start + range.length) }) .collect(); + if opt.microvm.x_time_abi_v1 { + anyhow::ensure!( + cfg.machine_profile == MachineProfile::Microvm, + "--x-time-abi-v1 requires the microVM machine profile" + ); + anyhow::ensure!( + opt.restore_snapshot.is_none(), + "time ABI v1 snapshot restore is not implemented yet" + ); + anyhow::ensure!( + opt.microvm.snapshot_destination.is_none(), + "time ABI v1 snapshot capture is not implemented yet" + ); + cfg.microvm.time_abi = Some(openvmm_defs::time_abi::TimeAbiParameters { + cpu_profile: "auto".to_owned(), + generation: 0, + hooks: Default::default(), + }); + } let requested_hypervisor = opt .hypervisor diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index 662ee411288..b37ad9fc928 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -3,8 +3,12 @@ //! The time ABI primitives a backend provides. +use super::DeclaredRates; use super::HostTimeSample; +use super::RateCheck; use super::TimeAbiError; +use super::TimeAbiTestHooks; +use super::rate; use crate::CpuidLeaf; use vm_topology::processor::VpIndex; @@ -117,3 +121,248 @@ pub struct TscSetReport { /// The method used. pub method: TscSyncMethod, } + +/// The outcome of [`negotiate_rates`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NegotiatedRates { + /// The rates to declare to the guest. + pub declared: DeclaredRates, + /// The backend's native TSC rate `F_d`, after the rate test hook. + pub native_tsc_hz: u64, + /// On restore, the accepted rate check against the snapshot's rate. + pub check: Option, +} + +/// Applies the rate policy to a backend: restore step 9 of the +/// specification, and its cold-boot equivalent when `saved` is `None`. +/// +/// A cold boot declares the native TSC rate. A restore declares the saved +/// rates after checking the native rate, perturbed by the +/// `dest-rate-offset-ppm` hook, against the tolerance, and the LAPIC rate +/// against the saved one. `hypervisor` is the backend ID (`kvm`, `mshv`, or +/// `whp`). +pub fn negotiate_rates( + backend: &dyn TimeAbiBackend, + hypervisor: &str, + saved: Option, + hooks: &TimeAbiTestHooks, +) -> Result { + let native_tsc_hz = backend.native_tsc_hz()?; + let lapic_hz = backend.lapic_hz()?; + match saved { + None => { + rate::check_lapic_hz(hypervisor, lapic_hz, None)?; + Ok(NegotiatedRates { + declared: DeclaredRates::new(native_tsc_hz, lapic_hz)?, + native_tsc_hz, + check: None, + }) + } + Some(saved) => { + let native_tsc_hz = rate::apply_rate_offset(native_tsc_hz, hooks.dest_rate_offset_ppm)?; + let check = rate::check_tsc_rate(saved.tsc_hz, native_tsc_hz)?; + rate::check_lapic_hz(hypervisor, lapic_hz, Some(saved.apic_hz))?; + Ok(NegotiatedRates { + declared: DeclaredRates::new(saved.tsc_hz, saved.apic_hz)?, + native_tsc_hz, + check: Some(check), + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time_abi::TimeAbiCode; + use crate::time_abi::rate::LAPIC_HZ_HYPERV; + use crate::time_abi::rate::LAPIC_HZ_KVM; + + struct FakeBackend { + tsc_hz: Result, + lapic_hz: u64, + } + + impl TimeAbiBackend for FakeBackend { + fn native_tsc_hz(&self) -> Result { + self.tsc_hz.clone() + } + + fn lapic_hz(&self) -> Result { + Ok(self.lapic_hz) + } + + fn preflight(&self) -> Result { + unreachable!() + } + + fn effective_cpuid(&self) -> Result, TimeAbiError> { + unreachable!() + } + + fn capture_anchor(&self) -> Result { + unreachable!() + } + + fn set_synchronized_tsc( + &self, + _target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result { + unreachable!() + } + } + + fn backend(tsc_hz: u64, lapic_hz: u64) -> FakeBackend { + FakeBackend { + tsc_hz: Ok(tsc_hz), + lapic_hz, + } + } + + const NO_HOOKS: TimeAbiTestHooks = TimeAbiTestHooks { + force_utc_downtime: false, + boot_id_mismatch: false, + dest_rate_offset_ppm: 0, + downtime_add_s: 0, + utc_offset_ms: 0, + sample_delay_us: 0, + }; + + #[test] + fn cold_boot_declares_the_native_rate() { + let rates = negotiate_rates( + &backend(2_194_804_000, LAPIC_HZ_KVM), + "kvm", + None, + &NO_HOOKS, + ) + .unwrap(); + assert_eq!( + rates, + NegotiatedRates { + declared: DeclaredRates { + tsc_hz: 2_194_804_000, + apic_hz: LAPIC_HZ_KVM, + }, + native_tsc_hz: 2_194_804_000, + check: None, + } + ); + assert_eq!( + negotiate_rates( + &backend(2_194_804_000, LAPIC_HZ_HYPERV), + "kvm", + None, + &NO_HOOKS + ) + .unwrap_err() + .code, + TimeAbiCode::LapicRateMismatch + ); + assert_eq!( + negotiate_rates(&backend(1_000, LAPIC_HZ_HYPERV), "whp", None, &NO_HOOKS) + .unwrap_err() + .code, + TimeAbiCode::TscRateImplausible + ); + let unavailable = FakeBackend { + tsc_hz: Err(TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + "no rate", + )), + lapic_hz: LAPIC_HZ_HYPERV, + }; + assert_eq!( + negotiate_rates(&unavailable, "mshv", None, &NO_HOOKS) + .unwrap_err() + .code, + TimeAbiCode::TscRateUnavailable + ); + } + + #[test] + fn restore_declares_the_saved_rates() { + let saved = DeclaredRates { + tsc_hz: 2_000_000_000, + apic_hz: LAPIC_HZ_HYPERV, + }; + let rates = negotiate_rates( + &backend(2_000_400_000, LAPIC_HZ_HYPERV), + "mshv", + Some(saved), + &NO_HOOKS, + ) + .unwrap(); + assert_eq!(rates.declared, saved); + assert_eq!(rates.native_tsc_hz, 2_000_400_000); + assert_eq!(rates.check.unwrap().rate_deviation, 200 * 65_536); + + assert_eq!( + negotiate_rates( + &backend(2_000_600_000, LAPIC_HZ_HYPERV), + "mshv", + Some(saved), + &NO_HOOKS + ) + .unwrap_err() + .code, + TimeAbiCode::TscRateTolerance + ); + assert_eq!( + negotiate_rates( + &backend(2_000_000_000, LAPIC_HZ_KVM), + "kvm", + Some(saved), + &NO_HOOKS + ) + .unwrap_err() + .code, + TimeAbiCode::LapicRateMismatch + ); + } + + #[test] + fn restore_applies_the_rate_hook() { + let saved = DeclaredRates { + tsc_hz: 2_000_000_000, + apic_hz: LAPIC_HZ_HYPERV, + }; + let hooks = TimeAbiTestHooks { + dest_rate_offset_ppm: -200, + ..NO_HOOKS + }; + let rates = negotiate_rates( + &backend(2_000_000_000, LAPIC_HZ_HYPERV), + "whp", + Some(saved), + &hooks, + ) + .unwrap(); + assert_eq!(rates.native_tsc_hz, 1_999_600_000); + assert_eq!(rates.check.unwrap().rate_deviation, -200 * 65_536); + let hooks = TimeAbiTestHooks { + dest_rate_offset_ppm: 251, + ..NO_HOOKS + }; + assert_eq!( + negotiate_rates( + &backend(2_000_000_000, LAPIC_HZ_HYPERV), + "whp", + Some(saved), + &hooks + ) + .unwrap_err() + .code, + TimeAbiCode::TscRateTolerance + ); + // The hook never changes a cold boot's declared rate. + let rates = negotiate_rates( + &backend(2_000_000_000, LAPIC_HZ_HYPERV), + "whp", + None, + &hooks, + ) + .unwrap(); + assert_eq!(rates.declared.tsc_hz, 2_000_000_000); + } +} diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index 01a9630bbfe..3efd12f5383 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -23,6 +23,8 @@ pub use backend::BackendPreflight; #[cfg(guest_arch = "x86_64")] pub use backend::IdentityMsrRoute; #[cfg(guest_arch = "x86_64")] +pub use backend::NegotiatedRates; +#[cfg(guest_arch = "x86_64")] pub use backend::TimeAbiBackend; #[cfg(guest_arch = "x86_64")] pub use backend::TscAnchor; @@ -30,6 +32,8 @@ pub use backend::TscAnchor; pub use backend::TscSetReport; #[cfg(guest_arch = "x86_64")] pub use backend::TscSyncMethod; +#[cfg(guest_arch = "x86_64")] +pub use backend::negotiate_rates; pub use downtime::CaptureTimeRecord; pub use downtime::Downtime; pub use downtime::DowntimeSource; @@ -319,6 +323,62 @@ impl TimeAbiTestHooks { } } +/// Kernel parameters that configure guest clocks and that the time ABI +/// forbids. +pub const CLOCK_TOKENS: [&str; 2] = ["tsc_early_khz", "lapic_timer_hz"]; + +/// Rejects a kernel command line that sets a [`CLOCK_TOKENS`] parameter +/// (`E_CMDLINE_CLOCK_TOKEN`). +/// +/// It follows the kernel's parsing: parameters are separated by unquoted +/// whitespace, `-` and `_` are equivalent in names, a parameter may be +/// quoted as a whole, and parsing stops at the `--` delimiter, after which +/// arguments belong to init. +pub fn check_command_line_clock_tokens(cmdline: &str) -> Result<(), TimeAbiError> { + fn normalized(name: &str) -> impl Iterator + '_ { + name.bytes().map(|b| if b == b'-' { b'_' } else { b }) + } + + let mut tokens = Vec::new(); + let mut start = None; + let mut in_quote = false; + for (offset, c) in cmdline.char_indices() { + if c == '"' { + in_quote = !in_quote; + } + if c.is_ascii_whitespace() && !in_quote { + if let Some(start) = start.take() { + tokens.push(&cmdline[start..offset]); + } + } else if start.is_none() { + start = Some(offset); + } + } + if let Some(start) = start { + tokens.push(&cmdline[start..]); + } + + for token in tokens { + let unquoted = token + .strip_prefix('"') + .map_or(token, |rest| rest.strip_suffix('"').unwrap_or(rest)); + if unquoted == "--" { + break; + } + let name = unquoted.split_once('=').map_or(unquoted, |(name, _)| name); + if CLOCK_TOKENS + .iter() + .any(|clock| normalized(name).eq(normalized(clock))) + { + return Err(TimeAbiError::new( + TimeAbiCode::CmdlineClockToken, + format!("kernel command line sets the clock parameter '{token}'"), + )); + } + } + Ok(()) +} + /// Time ABI configuration of a partition, set in /// [`ProtoPartitionConfig::time_abi`](crate::ProtoPartitionConfig::time_abi). /// @@ -410,6 +470,38 @@ mod tests { ); } + #[test] + fn clock_tokens_are_rejected() { + for cmdline in [ + "console=hvc0 tsc_early_khz=2500000", + "lapic_timer_hz=200000000 console=hvc0", + "console=hvc0 tsc-early-khz=1", + "console=hvc0 lapic-timer_hz", + r#"console=hvc0 "tsc_early_khz=1""#, + "console=hvc0\ttsc_early_khz=1\n", + ] { + assert_eq!( + check_command_line_clock_tokens(cmdline).unwrap_err().code, + TimeAbiCode::CmdlineClockToken, + "{cmdline}" + ); + } + } + + #[test] + fn clock_token_check_follows_kernel_parsing() { + for cmdline in [ + "", + "console=hvc0 clocksource=tsc", + "console=hvc0 -- tsc_early_khz=1", + r#"console=hvc0 "--" lapic_timer_hz=1"#, + r#"console=hvc0 note="tsc_early_khz=1 lapic_timer_hz=1""#, + "xtsc_early_khz=1 tsc_early_khzx=1", + ] { + check_command_line_clock_tokens(cmdline).unwrap(); + } + } + #[test] fn test_hooks_parse() { let hooks = TimeAbiTestHooks::parse(&[ diff --git a/vmm_core/virt/src/time_abi/msr.rs b/vmm_core/virt/src/time_abi/msr.rs index 99cce01d9d8..81668693354 100644 --- a/vmm_core/virt/src/time_abi/msr.rs +++ b/vmm_core/virt/src/time_abi/msr.rs @@ -9,6 +9,7 @@ use super::DeclaredRates; use crate::x86::MsrError; +use inspect::Inspect; use mesh_protobuf::Protobuf; use std::ops::RangeInclusive; use std::sync::OnceLock; @@ -156,6 +157,17 @@ impl TimeAbiMsrs { } } +impl Inspect for TimeAbiMsrs { + fn inspect(&self, req: inspect::Request<'_>) { + let mut resp = req.respond(); + if let Some(rates) = self.declared() { + resp.field("tsc_hz", rates.tsc_hz) + .field("apic_hz", rates.apic_hz); + } + resp.field("tsc_invariant_control", self.tsc_invariant_control()); + } +} + #[cfg(test)] mod tests { use super::*; From c9e6b590e1423caf857ea16f7f908ef78f05a9ef Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 03:34:31 -0700 Subject: [PATCH 174/304] openvmm_helpers: add snapshot manifest version 6 for the time ABI Manifest version 6 (format magic OPENVMM_SNAPSHOT_V6) carries the NVX time ABI records in the machine contract: the time contract (field 31) and the CPU profile record (field 32). It leaves the retired clock fields 11 to 15, 17, and 20 empty and forbids kernel clock tokens. A version 6 contract is validated by the time ABI rules instead of exact clock equality with the destination. Until the time ABI becomes the only microVM path, versions 2 to 5 keep their existing rules and cannot carry the new records, and check_time_abi_manifest_version pairs version 6 with --x-time-abi-v1 (E_SNAPSHOT_VERSION otherwise). Nothing writes version 6 yet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + openvmm/openvmm_helpers/Cargo.toml | 1 + openvmm/openvmm_helpers/src/snapshot.rs | 1 + .../openvmm_helpers/src/snapshot/format.rs | 33 +- .../openvmm_helpers/src/snapshot/microvm.rs | 82 ++-- openvmm/openvmm_helpers/src/snapshot/time.rs | 447 ++++++++++++++++++ 6 files changed, 534 insertions(+), 31 deletions(-) create mode 100644 openvmm/openvmm_helpers/src/snapshot/time.rs diff --git a/Cargo.lock b/Cargo.lock index 66a62dc6c20..988d0891d68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6162,6 +6162,7 @@ dependencies = [ "tempfile", "thiserror 2.0.16", "tracing", + "virt", "vm_resource", "windows-sys 0.61.0", ] diff --git a/openvmm/openvmm_helpers/Cargo.toml b/openvmm/openvmm_helpers/Cargo.toml index bf31c4a67f8..c61db1f97c4 100644 --- a/openvmm/openvmm_helpers/Cargo.toml +++ b/openvmm/openvmm_helpers/Cargo.toml @@ -15,6 +15,7 @@ hypervisor_resources.workspace = true net_backend_resources.workspace = true openvmm_defs.workspace = true pal.workspace = true +virt.workspace = true vm_resource.workspace = true mesh.workspace = true diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index 33b5d34d511..0537c3bc451 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -11,6 +11,7 @@ pub mod fs; pub mod microvm; pub mod publish; pub mod restore; +pub mod time; pub use publish::write_snapshot; diff --git a/openvmm/openvmm_helpers/src/snapshot/format.rs b/openvmm/openvmm_helpers/src/snapshot/format.rs index 0d720936afa..ff2ad035a26 100644 --- a/openvmm/openvmm_helpers/src/snapshot/format.rs +++ b/openvmm/openvmm_helpers/src/snapshot/format.rs @@ -13,6 +13,10 @@ use sha2::Digest; /// Magic identifying the OpenVMM snapshot manifest format. pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V5\0"; +/// Manifest version of snapshots that carry the NVX time ABI records. +pub const TIME_ABI_MANIFEST_VERSION: u32 = 6; +/// Magic identifying a time ABI snapshot manifest. +pub const TIME_ABI_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V6\0"; const VERSION_4_MANIFEST_VERSION: u32 = 4; const VERSION_4_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V4\0"; pub(super) const PREVIOUS_MANIFEST_VERSION: u32 = 3; @@ -113,8 +117,9 @@ pub(super) fn validate_manifest_header(manifest: &SnapshotManifest) -> anyhow::R PREVIOUS_MANIFEST_VERSION => PREVIOUS_SNAPSHOT_FORMAT_MAGIC, VERSION_4_MANIFEST_VERSION => VERSION_4_SNAPSHOT_FORMAT_MAGIC, MANIFEST_VERSION => SNAPSHOT_FORMAT_MAGIC, + TIME_ABI_MANIFEST_VERSION => TIME_ABI_SNAPSHOT_FORMAT_MAGIC, version => anyhow::bail!( - "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {MANIFEST_VERSION})" + "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {TIME_ABI_MANIFEST_VERSION})" ), }; anyhow::ensure!( @@ -161,7 +166,10 @@ pub(super) fn validate_manifest_version(manifest: &SnapshotManifest) -> anyhow:: "snapshot manifest version {LEGACY_MANIFEST_VERSION} cannot contain microVM sandbox blocks" ); } - PREVIOUS_MANIFEST_VERSION | VERSION_4_MANIFEST_VERSION | MANIFEST_VERSION => { + PREVIOUS_MANIFEST_VERSION + | VERSION_4_MANIFEST_VERSION + | MANIFEST_VERSION + | TIME_ABI_MANIFEST_VERSION => { anyhow::ensure!( manifest.state_sha256.is_empty() && manifest.memory_sha256.is_empty(), "snapshot manifest version {} must not contain legacy artifact digests", @@ -173,14 +181,31 @@ pub(super) fn validate_manifest_version(manifest: &SnapshotManifest) -> anyhow:: "snapshot manifest version {PREVIOUS_MANIFEST_VERSION} cannot contain microVM sandbox blocks" ); } - if manifest.version == MANIFEST_VERSION { + if manifest.version >= MANIFEST_VERSION { microvm::validate_snapshot_tier(manifest)?; } } version => anyhow::bail!( - "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {MANIFEST_VERSION})" + "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {TIME_ABI_MANIFEST_VERSION})" ), } + let contract = manifest.machine_contract.as_ref(); + if manifest.version == TIME_ABI_MANIFEST_VERSION { + let contract = contract.ok_or_else(|| { + virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::ManifestTime, + "a version 6 manifest requires a microVM machine contract", + ) + })?; + super::time::validate_time_abi_contract(contract)?; + } else { + anyhow::ensure!( + contract + .is_none_or(|contract| contract.time.is_none() && contract.cpu_profile.is_none()), + "snapshot manifest version {} cannot carry time ABI records", + manifest.version, + ); + } Ok(()) } diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 5e60802d0ae..7a48a470ee2 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -444,6 +444,13 @@ pub struct SnapshotMachineContract { /// Canonical capacity ranges absent from the captured boot memory map. #[mesh(30)] pub memory_expansion_ranges: Vec, + /// The NVX time ABI time contract; required in manifest version 6, which + /// leaves fields 11 to 15, 17, and 20 empty. + #[mesh(31)] + pub time: Option, + /// The NVX time ABI CPU profile record; required in manifest version 6. + #[mesh(32)] + pub cpu_profile: Option, } impl SnapshotMachineContract { @@ -1127,6 +1134,8 @@ pub fn microvm_machine_contract( memory_capacity_bytes, memory_block_size_bytes, memory_expansion_ranges, + time: None, + cpu_profile: None, }; contract.set_effective_command_line(effective_command_line); contract.set_cpu_compatibility_contract(cpu_contract); @@ -1136,7 +1145,7 @@ pub fn microvm_machine_contract( /// Returns whether restore must hold external device input until guest repair completes. pub fn requires_post_restore_gate(manifest: &SnapshotManifest) -> bool { - manifest.version == MANIFEST_VERSION + manifest.version >= MANIFEST_VERSION && manifest.machine_contract.as_ref().is_some_and(|contract| { matches!( contract.microvm_abi_version, @@ -1229,7 +1238,7 @@ pub fn validate_microvm_machine_contract( "snapshot virtio interrupt contract doesn't match the requested machine" ); anyhow::ensure!( - contract.clock_policy == expected.clock_policy, + contract.clock_policy == expected.clock_policy || contract.time.is_some(), "snapshot clock policy doesn't match the requested machine" ); anyhow::ensure!( @@ -1291,6 +1300,11 @@ pub fn validate_microvm_machine_contract( }, "snapshot sandbox block topology or identity doesn't match the requested machine" ); + if contract.time.is_some() { + // The time ABI replaces exact clock equality with the rate policy and + // the CPU profile checks of restore preflight. + return Ok(()); + } anyhow::ensure!( contract.tsc_frequency_hz == expected.tsc_frequency_hz && contract.tsc_tolerance_ppm == expected.tsc_tolerance_ppm, @@ -1307,6 +1321,35 @@ pub fn validate_microvm_machine_contract( Ok(()) } +/// Validates the clock fields of a machine contract before manifest version 6. +fn validate_clock_contract(contract: &SnapshotMachineContract) -> anyhow::Result<()> { + anyhow::ensure!( + contract.tsc_frequency_hz != 0, + "snapshot TSC frequency must be nonzero" + ); + if let Some(apic_frequency_hz) = contract.apic_frequency_hz { + anyhow::ensure!( + apic_frequency_hz != 0, + "snapshot APIC frequency must be nonzero" + ); + } + anyhow::ensure!( + !contract.cpu_contract.is_empty() && contract.cpu_contract.len() <= MAX_CPU_CONTRACT_BYTES, + "snapshot CPU contract size is invalid" + ); + validate_sha256(&contract.cpu_contract_sha256, "CPU contract")?; + verify_digest( + &contract.cpu_contract, + &contract.cpu_contract_sha256, + "CPU contract", + )?; + let _: std::time::SystemTime = contract + .capture_wall_clock + .try_into() + .context("snapshot capture wall clock is invalid")?; + Ok(()) +} + pub(super) fn validate_machine_contract_shape( contract: &SnapshotMachineContract, memory_size: u64, @@ -1321,8 +1364,9 @@ pub(super) fn validate_machine_contract_shape( == openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, "snapshot microVM shared-status interrupt contract is invalid" ); + let time_abi = contract.time.is_some() || contract.cpu_profile.is_some(); anyhow::ensure!( - contract.clock_policy == ADVANCE_BY_HOST_DOWNTIME, + contract.clock_policy == ADVANCE_BY_HOST_DOWNTIME || time_abi, "snapshot clock policy '{}' is unsupported", contract.clock_policy ); @@ -1343,30 +1387,11 @@ pub(super) fn validate_machine_contract_shape( &contract.effective_command_line_sha256, "effective command line", )?; - anyhow::ensure!( - contract.tsc_frequency_hz != 0, - "snapshot TSC frequency must be nonzero" - ); - if let Some(apic_frequency_hz) = contract.apic_frequency_hz { - anyhow::ensure!( - apic_frequency_hz != 0, - "snapshot APIC frequency must be nonzero" - ); + if time_abi { + super::time::validate_time_abi_contract(contract)?; + } else { + validate_clock_contract(contract)?; } - anyhow::ensure!( - !contract.cpu_contract.is_empty() && contract.cpu_contract.len() <= MAX_CPU_CONTRACT_BYTES, - "snapshot CPU contract size is invalid" - ); - validate_sha256(&contract.cpu_contract_sha256, "CPU contract")?; - verify_digest( - &contract.cpu_contract, - &contract.cpu_contract_sha256, - "CPU contract", - )?; - let _: std::time::SystemTime = contract - .capture_wall_clock - .try_into() - .context("snapshot capture wall clock is invalid")?; anyhow::ensure!( !contract.memory_ranges.is_empty() && contract.memory_ranges.len() <= MAX_MEMORY_RANGES, @@ -1842,8 +1867,9 @@ pub(super) fn validate_snapshot_tier(manifest: &SnapshotManifest) -> anyhow::Res .split_ascii_whitespace() .filter(|token| token.starts_with("tsc_early_khz=")) .collect::>(); + // A time ABI contract forbids the token instead. anyhow::ensure!( - tsc_frequency_tokens == [expected_tsc_frequency.as_str()], + contract.time.is_some() || tsc_frequency_tokens == [expected_tsc_frequency.as_str()], "platform snapshot command line TSC frequency does not match its machine contract" ); let control_tty_count = contract @@ -2049,6 +2075,8 @@ pub(super) fn test_machine_contract() -> SnapshotMachineContract { memory_capacity_bytes: 0, memory_block_size_bytes: 0, memory_expansion_ranges: Vec::new(), + time: None, + cpu_profile: None, }; contract.set_effective_command_line("console=hvc0".to_owned()); contract.set_cpu_compatibility_contract(vec![1, 2, 3]); diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs new file mode 100644 index 00000000000..f65992dd20d --- /dev/null +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -0,0 +1,447 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The NVX time ABI records of a snapshot manifest: the time contract and the +//! CPU profile record of manifest version 6. + +use super::SnapshotManifest; +use super::format::TIME_ABI_MANIFEST_VERSION; +use super::format::TIME_ABI_SNAPSHOT_FORMAT_MAGIC; +use super::microvm::SnapshotMachineContract; +use mesh::payload::Timestamp; +use openvmm_defs::time_abi::SnapshotCpuProfile; +use openvmm_defs::time_abi::SnapshotTimeContract; +use sha2::Digest; +use virt::time_abi::CaptureTimeRecord; +use virt::time_abi::HostClockKind; +use virt::time_abi::HostIdentity; +use virt::time_abi::HostTimeSample; +use virt::time_abi::TIME_ABI_VERSION; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; +use virt::time_abi::rate; + +/// The value of the retired capture wall clock in a version 6 manifest. +const NO_TIMESTAMP: Timestamp = Timestamp { + seconds: 0, + nanos: 0, +}; + +/// The largest canonical CPU profile encoding a manifest may carry. +pub const MAX_CPU_PROFILE_BYTES: usize = 1024 * 1024; +/// The largest canonical effective-CPUID encoding a manifest may carry. +pub const MAX_EFFECTIVE_CPUID_BYTES: usize = 64 * 1024; + +fn manifest_error(message: impl Into) -> TimeAbiError { + TimeAbiError::new(TimeAbiCode::ManifestTime, message) +} + +fn identity(bytes: &[u8], description: &str) -> Result<[u8; 16], TimeAbiError> { + bytes + .try_into() + .map_err(|_| manifest_error(format!("{description} is {} bytes, not 16", bytes.len()))) +} + +/// Validates a time contract (restore step 2 of the specification). +pub fn validate_time_contract(contract: &SnapshotTimeContract) -> Result<(), TimeAbiError> { + let SnapshotTimeContract { + time_abi_version, + tsc_frequency_hz, + tsc_tolerance_ppm, + apic_frequency_hz, + capture_tsc: _, + capture_utc_ns: _, + capture_monotonic_ns: _, + host_clock, + host_id, + host_boot_id, + capture_generation, + } = contract; + if *time_abi_version != TIME_ABI_VERSION { + return Err(manifest_error(format!( + "time ABI version {time_abi_version} is not {TIME_ABI_VERSION}" + ))); + } + if *tsc_tolerance_ppm != rate::TSC_TOLERANCE_PPM { + return Err(manifest_error(format!( + "TSC tolerance {tsc_tolerance_ppm} ppm is not {}", + rate::TSC_TOLERANCE_PPM + ))); + } + rate::check_plausible_tsc_hz(*tsc_frequency_hz) + .map_err(|err| manifest_error(format!("snapshot {}", err.message)))?; + if ![rate::LAPIC_HZ_KVM, rate::LAPIC_HZ_HYPERV].contains(apic_frequency_hz) { + return Err(manifest_error(format!( + "LAPIC rate {apic_frequency_hz} Hz is not a backend constant" + ))); + } + if HostClockKind::from_manifest(host_clock).is_none() { + return Err(manifest_error(format!( + "host clock '{host_clock}' is unknown" + ))); + } + identity(host_id, "host identity")?; + identity(host_boot_id, "host boot identity")?; + if *capture_generation == u32::MAX { + return Err(TimeAbiError::new( + TimeAbiCode::GenerationExhausted, + "the snapshot's generation counter cannot be incremented", + )); + } + Ok(()) +} + +fn verify_sha256(bytes: &[u8], digest: &[u8], description: &str) -> Result<(), TimeAbiError> { + if digest.len() != 32 { + return Err(manifest_error(format!( + "{description} digest is {} bytes, not 32", + digest.len() + ))); + } + if sha2::Sha256::digest(bytes).as_slice() != digest { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileDigest, + format!("{description} digest does not verify"), + )); + } + Ok(()) +} + +/// Validates a CPU profile record: its shape and both digests. +pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), TimeAbiError> { + let SnapshotCpuProfile { + id, + sha256, + profile, + effective_cpuid, + effective_cpuid_sha256, + capture_cpu_signature: _, + } = record; + let id_valid = !id.is_empty() + && id.len() <= 64 + && id + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + if !id_valid { + return Err(manifest_error(format!( + "CPU profile ID '{id}' is malformed" + ))); + } + if profile.len() > MAX_CPU_PROFILE_BYTES { + return Err(manifest_error("CPU profile exceeds 1 MiB")); + } + verify_sha256(profile, sha256, "CPU profile")?; + if effective_cpuid.is_empty() || effective_cpuid.len() > MAX_EFFECTIVE_CPUID_BYTES { + return Err(manifest_error("effective CPUID record size is invalid")); + } + verify_sha256(effective_cpuid, effective_cpuid_sha256, "effective CPUID") +} + +/// Returns the capture record of a validated time contract. +pub fn capture_record(contract: &SnapshotTimeContract) -> Result { + validate_time_contract(contract)?; + Ok(CaptureTimeRecord { + tsc: contract.capture_tsc, + sample: HostTimeSample { + utc_ns: contract.capture_utc_ns, + monotonic_ns: contract.capture_monotonic_ns, + }, + identity: HostIdentity { + host_id: identity(&contract.host_id, "host identity")?, + boot_id: identity(&contract.host_boot_id, "host boot identity")?, + clock: HostClockKind::from_manifest(&contract.host_clock) + .ok_or_else(|| manifest_error("host clock is unknown"))?, + }, + }) +} + +/// Validates the time ABI part of a version 6 machine contract: both +/// records are present and valid, every retired clock field is empty, and +/// the command line sets no clock parameter. +pub fn validate_time_abi_contract(contract: &SnapshotMachineContract) -> Result<(), TimeAbiError> { + let time = contract + .time + .as_ref() + .ok_or_else(|| manifest_error("the time contract is missing"))?; + let cpu_profile = contract + .cpu_profile + .as_ref() + .ok_or_else(|| manifest_error("the CPU profile record is missing"))?; + validate_time_contract(time)?; + validate_cpu_profile_record(cpu_profile)?; + let retired_fields_empty = contract.capture_wall_clock == NO_TIMESTAMP + && contract.tsc_frequency_hz == 0 + && contract.tsc_tolerance_ppm == 0 + && contract.cpu_contract.is_empty() + && contract.cpu_contract_sha256.is_empty() + && contract.clock_policy.is_empty() + && contract.apic_frequency_hz.is_none(); + if !retired_fields_empty { + return Err(manifest_error( + "a version 6 machine contract carries a retired clock field", + )); + } + virt::time_abi::check_command_line_clock_tokens(&contract.effective_command_line) +} + +/// Makes `manifest` a version 6 manifest carrying the time ABI records, and +/// clears the retired clock fields of its machine contract. +pub fn set_time_abi_records( + manifest: &mut SnapshotManifest, + time: SnapshotTimeContract, + cpu_profile: SnapshotCpuProfile, +) -> anyhow::Result<()> { + let contract = manifest + .machine_contract + .as_mut() + .ok_or_else(|| manifest_error("the time ABI requires a microVM machine contract"))?; + contract.capture_wall_clock = NO_TIMESTAMP; + contract.tsc_frequency_hz = 0; + contract.tsc_tolerance_ppm = 0; + contract.cpu_contract.clear(); + contract.cpu_contract_sha256.clear(); + contract.clock_policy.clear(); + contract.apic_frequency_hz = None; + contract.time = Some(time); + contract.cpu_profile = Some(cpu_profile); + manifest.version = TIME_ABI_MANIFEST_VERSION; + manifest.format_magic = TIME_ABI_SNAPSHOT_FORMAT_MAGIC.to_vec(); + Ok(()) +} + +/// Requires the manifest version that matches the selected restore path: +/// version 6 with the time ABI, and a version before 6 without it. +pub fn check_time_abi_manifest_version( + manifest: &SnapshotManifest, + time_abi: bool, +) -> Result<(), TimeAbiError> { + let is_time_abi = manifest.version == TIME_ABI_MANIFEST_VERSION; + match (time_abi, is_time_abi) { + (true, false) => Err(TimeAbiError::new( + TimeAbiCode::SnapshotVersion, + format!( + "snapshot manifest version {} predates the time ABI; recapture the snapshot", + manifest.version + ), + )), + (false, true) => Err(TimeAbiError::new( + TimeAbiCode::SnapshotVersion, + "a time ABI snapshot requires --x-time-abi-v1", + )), + _ => Ok(()), + } +} + +#[cfg(test)] +pub(super) mod tests { + use super::*; + + pub(in crate::snapshot) fn test_time_contract() -> SnapshotTimeContract { + SnapshotTimeContract { + time_abi_version: 1, + tsc_frequency_hz: 2_100_000_000, + tsc_tolerance_ppm: 250, + apic_frequency_hz: rate::LAPIC_HZ_HYPERV, + capture_tsc: 12_345_678, + capture_utc_ns: 1_700_000_000_000_000_000, + capture_monotonic_ns: 123_000_000_000, + host_clock: "linux-boottime".to_owned(), + host_id: vec![1; 16], + host_boot_id: vec![2; 16], + capture_generation: 3, + } + } + + pub(in crate::snapshot) fn test_cpu_profile() -> SnapshotCpuProfile { + let profile = b"profile".to_vec(); + let effective_cpuid = vec![0; 24]; + SnapshotCpuProfile { + id: "intel-skylake-sp-v1".to_owned(), + sha256: sha2::Sha256::digest(&profile).to_vec(), + profile, + effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), + effective_cpuid, + capture_cpu_signature: 0x0005_0657, + } + } + + fn code(result: Result<(), TimeAbiError>) -> TimeAbiCode { + result.unwrap_err().code + } + + #[test] + fn time_contract_rules() { + validate_time_contract(&test_time_contract()).unwrap(); + let mutations: [(fn(&mut SnapshotTimeContract), TimeAbiCode); 8] = [ + (|c| c.time_abi_version = 2, TimeAbiCode::ManifestTime), + (|c| c.tsc_tolerance_ppm = 251, TimeAbiCode::ManifestTime), + (|c| c.tsc_frequency_hz = 100, TimeAbiCode::ManifestTime), + ( + |c| c.apic_frequency_hz = 100_000_000, + TimeAbiCode::ManifestTime, + ), + ( + |c| c.host_clock = "utc".to_owned(), + TimeAbiCode::ManifestTime, + ), + ( + |c| { + c.host_id.pop(); + }, + TimeAbiCode::ManifestTime, + ), + (|c| c.host_boot_id.push(0), TimeAbiCode::ManifestTime), + ( + |c| c.capture_generation = u32::MAX, + TimeAbiCode::GenerationExhausted, + ), + ]; + for (mutate, expected) in mutations { + let mut contract = test_time_contract(); + mutate(&mut contract); + assert_eq!( + code(validate_time_contract(&contract)), + expected, + "{contract:?}" + ); + } + } + + #[test] + fn cpu_profile_record_rules() { + validate_cpu_profile_record(&test_cpu_profile()).unwrap(); + let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 7] = [ + (|r| r.id.clear(), TimeAbiCode::ManifestTime), + (|r| r.id = "Intel_SKX".to_owned(), TimeAbiCode::ManifestTime), + (|r| r.profile.push(0), TimeAbiCode::ProfileDigest), + (|r| r.sha256.truncate(31), TimeAbiCode::ManifestTime), + (|r| r.effective_cpuid[0] = 1, TimeAbiCode::ProfileDigest), + (|r| r.effective_cpuid.clear(), TimeAbiCode::ManifestTime), + ( + |r| r.profile = vec![0; MAX_CPU_PROFILE_BYTES + 1], + TimeAbiCode::ManifestTime, + ), + ]; + for (mutate, expected) in mutations { + let mut record = test_cpu_profile(); + mutate(&mut record); + assert_eq!( + code(validate_cpu_profile_record(&record)), + expected, + "{}", + record.id + ); + } + } + + #[test] + fn capture_record_from_contract() { + let record = capture_record(&test_time_contract()).unwrap(); + assert_eq!(record.tsc, 12_345_678); + assert_eq!(record.sample.utc_ns, 1_700_000_000_000_000_000); + assert_eq!(record.sample.monotonic_ns, 123_000_000_000); + assert_eq!(record.identity.host_id, [1; 16]); + assert_eq!(record.identity.boot_id, [2; 16]); + assert_eq!(record.identity.clock, HostClockKind::LinuxBoottime); + } + + fn version_6_manifest() -> SnapshotManifest { + let mut manifest = crate::snapshot::tests::test_manifest(); + manifest.machine_contract = Some(crate::snapshot::microvm::test_machine_contract()); + set_time_abi_records(&mut manifest, test_time_contract(), test_cpu_profile()).unwrap(); + manifest + } + + fn validate(manifest: &SnapshotManifest) -> anyhow::Result<()> { + crate::snapshot::format::validate_manifest_header(manifest)?; + crate::snapshot::format::validate_manifest_version(manifest)?; + crate::snapshot::microvm::validate_microvm_machine_contract( + manifest, + &crate::snapshot::microvm::test_machine_contract(), + ) + } + + #[test] + fn version_6_manifest_is_accepted() { + let manifest = version_6_manifest(); + assert_eq!(manifest.version, 6); + assert_eq!(manifest.format_magic, b"OPENVMM_SNAPSHOT_V6\0"); + // The destination's pre-v6 clock fields are not compared. + validate(&manifest).unwrap(); + let contract = manifest.machine_contract.as_ref().unwrap(); + assert_eq!(contract.tsc_frequency_hz, 0); + assert!(contract.cpu_contract.is_empty() && contract.clock_policy.is_empty()); + } + + #[test] + fn version_6_manifest_requires_records() { + let mut manifest = version_6_manifest(); + manifest.machine_contract.as_mut().unwrap().time = None; + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); + + let mut manifest = version_6_manifest(); + manifest.machine_contract.as_mut().unwrap().cpu_profile = None; + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); + + let mut manifest = version_6_manifest(); + manifest.machine_contract = None; + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); + } + + #[test] + fn version_6_manifest_rejects_retired_fields_and_clock_tokens() { + let mut manifest = version_6_manifest(); + manifest.machine_contract.as_mut().unwrap().tsc_frequency_hz = 1; + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("retired clock field"), "{err}"); + + let mut manifest = version_6_manifest(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line("console=hvc0 tsc_early_khz=2100000".to_owned()); + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_CMDLINE_CLOCK_TOKEN]"), "{err}"); + + let mut manifest = version_6_manifest(); + manifest + .machine_contract + .as_mut() + .unwrap() + .time + .as_mut() + .unwrap() + .tsc_tolerance_ppm = 100; + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); + } + + #[test] + fn earlier_versions_cannot_carry_records() { + let mut manifest = version_6_manifest(); + manifest.version = crate::snapshot::MANIFEST_VERSION; + manifest.format_magic = crate::snapshot::format::SNAPSHOT_FORMAT_MAGIC.to_vec(); + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("cannot carry time ABI records"), "{err}"); + } + + #[test] + fn manifest_version_must_match_the_restore_path() { + let version_6 = version_6_manifest(); + let version_5 = crate::snapshot::tests::test_manifest(); + check_time_abi_manifest_version(&version_6, true).unwrap(); + check_time_abi_manifest_version(&version_5, false).unwrap(); + for (manifest, time_abi) in [(&version_5, true), (&version_6, false)] { + assert_eq!( + check_time_abi_manifest_version(manifest, time_abi) + .unwrap_err() + .code, + TimeAbiCode::SnapshotVersion + ); + } + } +} From 41962b930b733de68e3626dbe2add25322f1ac02 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 03:54:11 -0700 Subject: [PATCH 175/304] chipset: serve restore packet v4 and time samples on portb Under the NVX time ABI, the microVM portb device serves restore packet version 4 instead of the entropy packets, and a time-sample window: - Selector 0xa5 encodes the packet at its first selection, latching host UTC then; the worker seals the time fields (downtime, its source, the rate deviation, and the test-hook flag) through a oneshot before the first restored VP runs. Status bits 2 to 4 follow the packet. - Selector 0xa7 latches a 16-byte time sample (generation and host UTC) into a window read at the new port 0xeb; status bit 6 advertises it. The window is not saved state. - Selected records and the window accept 1-, 2-, and 4-byte reads, so the guest reads a packet in a quarter of the port exits. Console reads stay one byte. Without the time ABI the device is unchanged. The CLI path enables the time ABI configuration under --x-time-abi-v1 (cold boot only for now); the ttrpc path and petri keep the legacy packets until the flip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_entry/src/microvm/config.rs | 9 + openvmm/openvmm_entry/src/ttrpc/microvm.rs | 1 + petri/src/vm/openvmm/microvm.rs | 1 + vm/devices/chipset/src/microvm.rs | 440 +++++++++++++++++++- vm/devices/chipset/src/microvm/resolver.rs | 10 +- vm/devices/chipset_resources/src/microvm.rs | 27 ++ 6 files changed, 479 insertions(+), 9 deletions(-) diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 2fb69ec87e5..1eeed96e796 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -411,6 +411,15 @@ impl<'a> MicrovmConfigBuilder<'a> { generation_id, restore_entropy, output_drain: Some(output_drain), + time_abi: opt.microvm.x_time_abi_v1.then(|| { + chipset_resources::microvm::MicrovmPortbTimeAbi { + generation: 0, + utc_offset_ms: 0, + sample_delay_us: 0, + test_hooks: false, + restore: None, + } + }), } .into_resource(), }); diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index e87f378d6d3..9f245783300 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -751,6 +751,7 @@ impl CreateVm { generation_id, restore_entropy, output_drain, + time_abi: None, } .into_resource(), }, diff --git a/petri/src/vm/openvmm/microvm.rs b/petri/src/vm/openvmm/microvm.rs index 81c8f4d8557..f708dfd474b 100644 --- a/petri/src/vm/openvmm/microvm.rs +++ b/petri/src/vm/openvmm/microvm.rs @@ -151,6 +151,7 @@ pub(super) fn attach_chipset_devices( generation_id: [0x5a; 16], restore_entropy: Vec::new(), output_drain: None, + time_abi: None, } .into_resource(), }, diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index c7c06636095..6038afa18b1 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -13,6 +13,11 @@ use chipset_device::io::deferred::defer_write; use chipset_device::pio::PortIoIntercept; use chipset_device::poll_device::PollDevice; use chipset_resources::microvm::MicrovmPortbDrain; +use chipset_resources::microvm::MicrovmPortbTimeAbi; +use chipset_resources::microvm_time::RestorePacketBase; +use chipset_resources::microvm_time::RestorePacketV4; +use chipset_resources::microvm_time::RestoreTimeRecord; +use chipset_resources::microvm_time::TimeSample; use futures::AsyncRead; use futures::AsyncWrite; use inspect::InspectMut; @@ -21,6 +26,7 @@ use power_resources::PowerRequestClient; use serial_core::SerialIo; use serial_core::disconnected::Disconnected; use std::collections::VecDeque; +use std::future::Future; use std::io; use std::io::ErrorKind; use std::ops::RangeInclusive; @@ -28,6 +34,7 @@ use std::pin::Pin; use std::task::Context; use std::task::Poll; use std::task::Waker; +use std::time::SystemTime; use vmcore::device_state::ChangeDeviceState; use vmcore::save_restore::NoSavedState; use vmcore::save_restore::RestoreError; @@ -37,10 +44,12 @@ use vmcore::save_restore::SavedStateRoot; const DATA_PORT: u16 = 0xe9; const STATUS_PORT: u16 = 0xea; +const TIME_WINDOW_PORT: u16 = chipset_resources::microvm_time::TIME_WINDOW_PORT; const SHUTDOWN_PORT: u16 = 0x604; const SNAPSHOT_PORT: u16 = 0x605; const RESTORE_ENTROPY_SELECT: u8 = 0xa5; const GENERATION_ID_SELECT: u8 = 0xa6; +const TIME_SAMPLE_SELECT: u8 = chipset_resources::microvm_time::TIME_SAMPLE_SELECT; const GENERATION_ID_SIZE: usize = 16; const RESTORE_PROCESSOR_TARGET_PACKET_HEADER: &[u8] = b"OPENVMM_ENTROPY_V2\0"; const RESTORE_MEMORY_TARGET_PACKET_HEADER: &[u8] = b"OPENVMM_ENTROPY_V3\0"; @@ -50,8 +59,67 @@ const STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE: u8 = 1 << 2; const STATUS_RESTORE_MEMORY_TARGET_AVAILABLE: u8 = 1 << 3; const STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE: u8 = 1 << 4; const STATUS_GENERATION_ID_AVAILABLE: u8 = 1 << 5; +const STATUS_TIME_SAMPLE_AVAILABLE: u8 = + chipset_resources::microvm_time::STATUS_TIME_SAMPLE_AVAILABLE; const BUFFER_MAX: usize = 1024 * 1024; +/// The restore packet version 4 of a restored VM process. +enum RestorePacketState { + /// No packet, or the packet was consumed. + None, + /// Waiting for the worker to seal the time fields. + Pending(RestorePacketBase, mesh::OneshotReceiver), + /// Sealed and not yet selected; host UTC is latched at the first + /// selection. + Sealed(RestorePacketBase, RestoreTimeRecord), + /// Encoded into the restore-packet queue. + Encoded, +} + +/// NVX time ABI v1 state of the portb device. +struct PortbTimeAbi { + generation: u32, + utc_offset_ns: i128, + sample_delay: std::time::Duration, + test_hooks: bool, + restore: RestorePacketState, +} + +impl PortbTimeAbi { + /// Returns host UTC in nanoseconds, after the test hooks' delay and + /// offset. + fn latch_utc_ns(&self) -> u64 { + if !self.sample_delay.is_zero() { + std::thread::sleep(self.sample_delay); + } + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map_or(0, |since_epoch| since_epoch.as_nanos() as i128); + (now + self.utc_offset_ns).clamp(0, u64::MAX.into()) as u64 + } + + /// Returns whether a sealed packet waits for its first selection, + /// receiving the sealed time fields if they arrived. + fn poll_sealed(&mut self) -> bool { + self.restore = match std::mem::replace(&mut self.restore, RestorePacketState::None) { + RestorePacketState::Pending(base, mut time) => { + match Pin::new(&mut time).poll(&mut Context::from_waker(Waker::noop())) { + Poll::Ready(Ok(record)) => RestorePacketState::Sealed(base, record), + Poll::Ready(Err(_)) => { + tracelimit::error_ratelimited!( + "microVM restore packet time fields were never sealed" + ); + RestorePacketState::None + } + Poll::Pending => RestorePacketState::Pending(base, time), + } + } + state => state, + }; + matches!(self.restore, RestorePacketState::Sealed(..)) + } +} + /// Raw bidirectional microVM portb console. #[derive(InspectMut)] pub struct MicrovmPortb { @@ -73,6 +141,10 @@ pub struct MicrovmPortb { restore_memory_expansion_available: bool, input_gated: bool, #[inspect(skip)] + time_abi: Option, + #[inspect(with = "VecDeque::len")] + time_window: VecDeque, + #[inspect(skip)] rx_waker: Option, #[inspect(skip)] tx_waker: Option, @@ -113,6 +185,8 @@ impl MicrovmPortb { restore_memory_target_available, restore_memory_expansion_available, input_gated: false, + time_abi: None, + time_window: VecDeque::new(), rx_waker: None, tx_waker: None, output_drain_requests: None, @@ -120,6 +194,102 @@ impl MicrovmPortb { } } + /// Selects the NVX time ABI v1: restore packet version 4 instead of the + /// entropy packets, and the time-sample window at port `0xeb`. + pub fn with_time_abi(mut self, config: MicrovmPortbTimeAbi) -> Self { + let MicrovmPortbTimeAbi { + generation, + utc_offset_ms, + sample_delay_us, + test_hooks, + restore, + } = config; + if !self.restore_entropy.is_empty() { + tracing::warn!("ignoring microVM entropy packet under the time ABI"); + self.restore_entropy.clear(); + } + self.io_region = ("microvm-portb", DATA_PORT..=TIME_WINDOW_PORT); + self.restore_processor_target_available = false; + self.restore_memory_target_available = false; + self.restore_memory_expansion_available = false; + let restore = match restore { + Some(source) => { + self.restore_processor_target_available = source.base.online_vp_count != 0; + self.restore_memory_target_available = source.base.memory_target; + self.restore_memory_expansion_available = !source.base.ranges.is_empty(); + RestorePacketState::Pending(source.base, source.time) + } + None => RestorePacketState::None, + }; + self.time_abi = Some(PortbTimeAbi { + generation, + utc_offset_ns: i128::from(utc_offset_ms) * 1_000_000, + sample_delay: std::time::Duration::from_micros(sample_delay_us.into()), + test_hooks, + restore, + }); + self + } + + /// Handles selector `0xa5` under the time ABI: encodes the sealed packet + /// with host UTC at its first selection, then selects it. + fn select_restore_packet_v4(&mut self) { + let Some(time_abi) = &mut self.time_abi else { + return; + }; + if time_abi.poll_sealed() { + let RestorePacketState::Sealed(base, time) = + std::mem::replace(&mut time_abi.restore, RestorePacketState::Encoded) + else { + return; + }; + let packet = RestorePacketV4 { + base, + time, + utc_ns: time_abi.latch_utc_ns(), + }; + match packet.encode() { + Ok(bytes) => self.restore_entropy = bytes.into(), + Err(error) => { + tracelimit::error_ratelimited!( + error = &error as &dyn std::error::Error, + "microVM restore packet is invalid" + ); + self.clear_restore_packet(); + return; + } + } + } + if !self.restore_entropy.is_empty() { + self.generation_id_read_index = None; + self.restore_entropy_selected = true; + } + } + + /// Handles selector `0xa7`: latches a fresh time sample into the window. + fn latch_time_sample(&mut self) { + let Some(time_abi) = &self.time_abi else { + return; + }; + let sample = TimeSample { + test_hooks: time_abi.test_hooks, + generation: time_abi.generation, + utc_ns: time_abi.latch_utc_ns(), + }; + self.time_window = sample.encode().into(); + } + + fn clear_restore_packet(&mut self) { + self.restore_entropy.clear(); + self.restore_entropy_selected = false; + self.restore_processor_target_available = false; + self.restore_memory_target_available = false; + self.restore_memory_expansion_available = false; + if let Some(time_abi) = &mut self.time_abi { + time_abi.restore = RestorePacketState::None; + } + } + /// Installs the output drain request channel. /// /// A drain request writes and flushes every byte the device has accepted. @@ -249,6 +419,10 @@ impl ChangeDeviceState for MicrovmPortb { self.restore_memory_target_available = false; self.restore_memory_expansion_available = false; self.input_gated = false; + self.time_window.clear(); + if let Some(time_abi) = &mut self.time_abi { + time_abi.restore = RestorePacketState::None; + } } } @@ -335,14 +509,31 @@ impl PortIoIntercept for MicrovmPortb { return IoResult::Err(IoError::InvalidAccessSize); } data.fill(0); + // The time ABI reads selected records 1, 2, or 4 bytes at a time; + // console input stays one byte per read. + let record_len = if self.time_abi.is_some() { + data.len() + } else { + 1 + }; match io_port { DATA_PORT => { - if let Some(index) = self.generation_id_read_index { - data[0] = self.generation_id[index]; - self.generation_id_read_index = - (index + 1 < self.generation_id.len()).then_some(index + 1); + if self.generation_id_read_index.is_some() { + for byte in &mut data[..record_len] { + let Some(index) = self.generation_id_read_index else { + break; + }; + *byte = self.generation_id[index]; + self.generation_id_read_index = + (index + 1 < self.generation_id.len()).then_some(index + 1); + } } else if self.restore_entropy_selected { - data[0] = self.restore_entropy.pop_front().unwrap_or(0); + for byte in &mut data[..record_len] { + let Some(value) = self.restore_entropy.pop_front() else { + break; + }; + *byte = value; + } if self.restore_entropy.is_empty() { self.restore_entropy_selected = false; self.restore_processor_target_available = false; @@ -364,7 +555,22 @@ impl PortIoIntercept for MicrovmPortb { } else { 0 }; - if !self.restore_entropy.is_empty() { + let sealed_packet = self + .time_abi + .as_mut() + .is_some_and(|time_abi| time_abi.poll_sealed()); + if self + .time_abi + .as_ref() + .is_some_and(|time_abi| matches!(time_abi.restore, RestorePacketState::None)) + && self.restore_entropy.is_empty() + { + // An unsealed time ABI packet was dropped: no targets. + self.restore_processor_target_available = false; + self.restore_memory_target_available = false; + self.restore_memory_expansion_available = false; + } + if !self.restore_entropy.is_empty() || sealed_packet { data[0] |= STATUS_RESTORE_PACKET_AVAILABLE; } if self.restore_processor_target_available { @@ -377,6 +583,17 @@ impl PortIoIntercept for MicrovmPortb { data[0] |= STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE; } data[0] |= STATUS_GENERATION_ID_AVAILABLE; + if self.time_abi.is_some() { + data[0] |= STATUS_TIME_SAMPLE_AVAILABLE; + } + } + TIME_WINDOW_PORT if self.time_abi.is_some() => { + for byte in data.iter_mut() { + let Some(value) = self.time_window.pop_front() else { + break; + }; + *byte = value; + } } _ => return IoResult::Err(IoError::InvalidRegister), } @@ -397,6 +614,9 @@ impl PortIoIntercept for MicrovmPortb { self.wake_tx(); } STATUS_PORT => match data.first() { + Some(&RESTORE_ENTROPY_SELECT) if self.time_abi.is_some() => { + self.select_restore_packet_v4(); + } Some(&RESTORE_ENTROPY_SELECT) if !self.restore_entropy.is_empty() => { self.generation_id_read_index = None; self.restore_entropy_selected = true; @@ -405,8 +625,12 @@ impl PortIoIntercept for MicrovmPortb { self.restore_entropy_selected = false; self.generation_id_read_index = Some(0); } + Some(&TIME_SAMPLE_SELECT) if self.time_abi.is_some() => { + self.latch_time_sample(); + } _ => {} }, + TIME_WINDOW_PORT if self.time_abi.is_some() => {} _ => return IoResult::Err(IoError::InvalidRegister), } IoResult::Ok @@ -441,6 +665,7 @@ impl SaveRestore for MicrovmPortb { self.tx_buffer = state.tx_buffer.into(); self.generation_id_read_index = None; self.restore_entropy_selected = false; + self.time_window.clear(); Ok(()) } } @@ -1305,6 +1530,209 @@ mod tests { assert_eq!(portb.rx_buffer, [0x5a]); } + fn time_abi_portb( + restore: Option, + ) -> MicrovmPortb { + MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()).with_time_abi( + MicrovmPortbTimeAbi { + generation: 7, + utc_offset_ms: 0, + sample_delay_us: 0, + test_hooks: true, + restore, + }, + ) + } + + fn read_status(portb: &mut MicrovmPortb) -> u8 { + let mut data = [0]; + assert!(matches!( + portb.io_read(STATUS_PORT, &mut data), + IoResult::Ok + )); + data[0] + } + + fn read_record(portb: &mut MicrovmPortb, port: u16, len: usize) -> Vec { + let mut bytes = Vec::new(); + while bytes.len() < len { + let mut data = [0; 4]; + assert!(matches!(portb.io_read(port, &mut data), IoResult::Ok)); + bytes.extend(data); + } + bytes.truncate(len); + bytes + } + + fn now_ns() -> u64 { + SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64 + } + + #[test] + fn time_abi_portb_cold_boot_offers_time_samples() { + let mut portb = time_abi_portb(None); + assert_eq!(portb.get_static_regions()[0].1, 0xe9..=0xeb); + assert_eq!( + read_status(&mut portb), + STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE + ); + // No packet on a cold boot: selecting it reads console input. + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), + IoResult::Ok + )); + assert!(!portb.restore_entropy_selected); + + // The window is empty until a sample is latched. + assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); + let before = now_ns(); + assert!(matches!( + portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), + IoResult::Ok + )); + let after = now_ns(); + let sample = TimeSample::decode(&read_record(&mut portb, TIME_WINDOW_PORT, 16)).unwrap(); + assert_eq!((sample.generation, sample.test_hooks), (7, true)); + assert!((before..=after).contains(&sample.utc_ns)); + // Reads past the end are zero, and selecting a sample keeps the + // generation-ID selection. + assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); + assert!(matches!( + portb.io_write(STATUS_PORT, &[GENERATION_ID_SELECT]), + IoResult::Ok + )); + assert!(matches!( + portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), + IoResult::Ok + )); + assert_eq!(read_record(&mut portb, DATA_PORT, 16), TEST_GENERATION_ID); + } + + #[test] + fn time_abi_portb_serves_restore_packet_v4() { + let base = RestorePacketBase { + online_vp_count: 2, + memory_target: false, + ack_required: true, + generation: 7, + ranges: Vec::new(), + entropy: [0x3c; 64], + }; + let time = RestoreTimeRecord { + downtime_ns: 1_000_000, + downtime_utc: true, + rate_deviation: -5, + test_hooks: true, + }; + let (send, recv) = mesh::oneshot(); + let mut portb = time_abi_portb(Some( + chipset_resources::microvm::MicrovmRestorePacketSource { + base: base.clone(), + time: recv, + }, + )); + // Unsealed: only the targets are advertised. + assert_eq!( + read_status(&mut portb), + STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE + | STATUS_TIME_SAMPLE_AVAILABLE + ); + send.send(time); + assert_eq!( + read_status(&mut portb), + STATUS_RESTORE_PACKET_AVAILABLE + | STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE + | STATUS_TIME_SAMPLE_AVAILABLE + ); + + let before = now_ns(); + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), + IoResult::Ok + )); + let after = now_ns(); + let bytes = read_record(&mut portb, DATA_PORT, base.encoded_len()); + let packet = RestorePacketV4::decode(&bytes).unwrap(); + assert_eq!((packet.base, packet.time), (base, time)); + assert!((before..=after).contains(&packet.utc_ns)); + + // The packet is consumed once read. + assert_eq!( + read_status(&mut portb), + STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE + ); + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), + IoResult::Ok + )); + assert!(!portb.restore_entropy_selected); + } + + #[test] + fn time_abi_portb_drops_an_unsealed_packet() { + let (send, recv) = mesh::oneshot::(); + let mut portb = time_abi_portb(Some( + chipset_resources::microvm::MicrovmRestorePacketSource { + base: RestorePacketBase { + online_vp_count: 2, + memory_target: false, + ack_required: false, + generation: 1, + ranges: Vec::new(), + entropy: [0; 64], + }, + time: recv, + }, + )); + drop(send); + assert_eq!( + read_status(&mut portb), + STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE + ); + } + + #[test] + fn time_abi_portb_window_is_not_saved() { + let mut portb = time_abi_portb(None); + assert!(matches!( + portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), + IoResult::Ok + )); + let state = portb.save().unwrap(); + portb.restore(state).unwrap(); + assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); + + assert!(matches!( + portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), + IoResult::Ok + )); + futures::executor::block_on(portb.reset()); + assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); + } + + #[test] + fn legacy_portb_keeps_single_byte_records_and_two_ports() { + let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()); + assert_eq!(portb.get_static_regions()[0].1, 0xe9..=0xea); + assert!(matches!( + portb.io_write(STATUS_PORT, &[GENERATION_ID_SELECT]), + IoResult::Ok + )); + let mut data = [0xff; 4]; + assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); + assert_eq!(data, [TEST_GENERATION_ID[0], 0, 0, 0]); + assert!(matches!( + portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), + IoResult::Ok + )); + assert!(portb.time_window.is_empty()); + } + #[test] fn portb_input_gate_blocks_rx_but_not_tx() { let mut portb = MicrovmPortb::new( diff --git a/vm/devices/chipset/src/microvm/resolver.rs b/vm/devices/chipset/src/microvm/resolver.rs index e23db3832a8..ef0f74a8d5a 100644 --- a/vm/devices/chipset/src/microvm/resolver.rs +++ b/vm/devices/chipset/src/microvm/resolver.rs @@ -60,13 +60,17 @@ impl AsyncResolveResource for Micro ) .await .map_err(ResolveMicrovmPortbError::ResolveBackend)?; - Ok(MicrovmPortb::new( + let portb = MicrovmPortb::new( io.0.into_io(), resource.generation_id, resource.restore_entropy, ) - .with_output_drain(resource.output_drain) - .into()) + .with_output_drain(resource.output_drain); + let portb = match resource.time_abi { + Some(time_abi) => portb.with_time_abi(time_abi), + None => portb, + }; + Ok(portb.into()) } } diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs index 4439ceb1a7e..71fb7419b4e 100644 --- a/vm/devices/chipset_resources/src/microvm.rs +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -56,6 +56,33 @@ pub struct MicrovmPortbHandle { /// Requests to deliver accepted output before a snapshot capture or before /// terminating the VM process. pub output_drain: Option>>, + /// NVX time ABI v1 configuration: restore packet version 4 and the time + /// samples at port `0xeb`. `restore_entropy` must be empty when set. + pub time_abi: Option, +} + +/// NVX time ABI v1 configuration of the portb device. +#[derive(MeshPayload)] +pub struct MicrovmPortbTimeAbi { + /// The generation counter of this VM process. + pub generation: u32, + /// Test hook: milliseconds added to every host UTC reading. + pub utc_offset_ms: i64, + /// Test hook: microseconds of delay before latching a UTC reading. + pub sample_delay_us: u32, + /// Whether a test hook is active. + pub test_hooks: bool, + /// The restore packet of a restored VM process. + pub restore: Option, +} + +/// The parts of a restore packet version 4. +#[derive(MeshPayload)] +pub struct MicrovmRestorePacketSource { + /// The fields the controller knows before the worker starts. + pub base: crate::microvm_time::RestorePacketBase, + /// The time fields the worker seals before the first restored VP runs. + pub time: mesh::OneshotReceiver, } impl ResourceId for MicrovmPortbHandle { From e082b19a12ce173284e1f03af7baad0609556465 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 04:10:58 -0700 Subject: [PATCH 176/304] virt: export the time ABI capture-anchor bound The KVM and MSHV backends each defined the 10 us capture-anchor bound privately. Export it as MAX_ANCHOR_PAIRING_NS and define pairing_ns as half the host-time bracket around the TSC read, as both backends already compute it. The restore anchor is logged beyond the bound but not rejected, because a pairing error shifts every VP's TSC alike. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt/src/time_abi/backend.rs | 9 +++++++-- vmm_core/virt/src/time_abi/mod.rs | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index b37ad9fc928..2bf9831fb8b 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -96,6 +96,11 @@ pub struct BackendPreflight { pub sync: TscSyncMethod, } +/// The largest accepted capture-anchor pairing, in nanoseconds; a capture +/// anchor beyond it fails with `E_TSC_ANCHOR`. The restore anchor is only +/// logged beyond it. +pub const MAX_ANCHOR_PAIRING_NS: u64 = 10_000; + /// The capture anchor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TscAnchor { @@ -103,8 +108,8 @@ pub struct TscAnchor { pub tsc: u64, /// The host sample paired with `tsc`. pub sample: HostTimeSample, - /// The pairing uncertainty: the most the host instant of `sample` can - /// differ from the instant `tsc` was read, in nanoseconds. + /// The pairing uncertainty: half the width of the host-time bracket + /// around the TSC read, in nanoseconds. pub pairing_ns: u64, } diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index 3efd12f5383..ab590e43703 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -23,6 +23,8 @@ pub use backend::BackendPreflight; #[cfg(guest_arch = "x86_64")] pub use backend::IdentityMsrRoute; #[cfg(guest_arch = "x86_64")] +pub use backend::MAX_ANCHOR_PAIRING_NS; +#[cfg(guest_arch = "x86_64")] pub use backend::NegotiatedRates; #[cfg(guest_arch = "x86_64")] pub use backend::TimeAbiBackend; From 8175eb30b9ef2e1078e56342c22c7e2da1beabd6 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 04:10:58 -0700 Subject: [PATCH 177/304] openvmm_helpers: accept dotted CPU profile IDs CPU profile IDs are ...v, with [a-z0-9-] components, as the profiles design settled. Accept them in the snapshot's CPU profile record. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_helpers/src/snapshot/time.rs | 21 +++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index f65992dd20d..7cd6b81445d 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -119,9 +119,12 @@ pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), Ti } = record; let id_valid = !id.is_empty() && id.len() <= 64 - && id - .bytes() - .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-'); + && id.split('.').all(|component| { + !component.is_empty() + && component + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + }); if !id_valid { return Err(manifest_error(format!( "CPU profile ID '{id}' is malformed" @@ -256,7 +259,7 @@ pub(super) mod tests { let profile = b"profile".to_vec(); let effective_cpuid = vec![0; 24]; SnapshotCpuProfile { - id: "intel-skylake-sp-v1".to_owned(), + id: "intel.skylake-sp.kvm.v1".to_owned(), sha256: sha2::Sha256::digest(&profile).to_vec(), profile, effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), @@ -310,9 +313,17 @@ pub(super) mod tests { #[test] fn cpu_profile_record_rules() { validate_cpu_profile_record(&test_cpu_profile()).unwrap(); - let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 7] = [ + let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 9] = [ (|r| r.id.clear(), TimeAbiCode::ManifestTime), (|r| r.id = "Intel_SKX".to_owned(), TimeAbiCode::ManifestTime), + ( + |r| r.id = "intel..kvm.v1".to_owned(), + TimeAbiCode::ManifestTime, + ), + ( + |r| r.id = "intel.skylake-sp.kvm.v1.".to_owned(), + TimeAbiCode::ManifestTime, + ), (|r| r.profile.push(0), TimeAbiCode::ProfileDigest), (|r| r.sha256.truncate(31), TimeAbiCode::ManifestTime), (|r| r.effective_cpuid[0] = 1, TimeAbiCode::ProfileDigest), From 28c72d544412521fe0bac46622f83228aa0ae0a6 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 04:57:18 -0700 Subject: [PATCH 178/304] virt_mshv: use the shared capture-anchor bound The time ABI now exports MAX_ANCHOR_PAIRING_NS, the capture-anchor bound every backend applies. Use it instead of the MSHV copy, so the bound has one definition, and test the boundary relative to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index d6713ed783f..8a3ad11c61c 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -51,6 +51,7 @@ use virt::VpIndex; use virt::time_abi::BackendPreflight; use virt::time_abi::HostTimeSample; use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::MAX_ANCHOR_PAIRING_NS; use virt::time_abi::TimeAbiBackend; use virt::time_abi::TimeAbiCode; use virt::time_abi::TimeAbiConfig; @@ -77,10 +78,6 @@ pub(crate) const ROUTED_MSRS: [u32; 4] = [ MSR_TSC_INVARIANT_CONTROL, ]; -/// The most a capture anchor's host sample may differ from the instant VP 0's -/// TSC was read, in nanoseconds. -pub(crate) const MAX_ANCHOR_PAIRING_NS: u64 = 10_000; - /// How many bracketed TSC reads a capture anchor tries before giving up. const ANCHOR_ATTEMPTS: usize = 64; @@ -1172,11 +1169,13 @@ mod tests { assert_eq!((anchor.tsc, anchor.pairing_ns), (2, 4_000)); // The bound applies to the pairing: half the bracket. + let widest = 2 * MAX_ANCHOR_PAIRING_NS; assert_eq!( - select_anchor(&[candidate(4, 20_000)]).unwrap().pairing_ns, - 10_000 + select_anchor(&[candidate(4, widest)]).unwrap().pairing_ns, + MAX_ANCHOR_PAIRING_NS ); - let error = select_anchor(&[candidate(5, 20_001), candidate(6, 50_000)]).unwrap_err(); + let error = + select_anchor(&[candidate(5, widest + 1), candidate(6, 5 * widest)]).unwrap_err(); assert_eq!(error.code, TimeAbiCode::TscAnchor); assert_eq!(select_anchor(&[]).unwrap_err().code, TimeAbiCode::TscAnchor); } From 676be8dc0316d9c527eaa29f8b2a06c1fafe6ff8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 03:53:35 -0700 Subject: [PATCH 179/304] virt_kvm: fail loose time ABI anchors and hide IA32_TSC_DEADLINE Both anchors now sample the host clock again, up to 16 times, until the host time sample pairs with the host TSC within core's MAX_ANCHOR_PAIRING_NS (10 us), and otherwise fail with E_TSC_ANCHOR. The restore anchor used to take its tightest pairing with a warning; per the orchestrator's decision, a loose pairing is never accepted silently. IA32_TSC_DEADLINE joins IA32_TSC_ADJUST in the MSR filter and raises #GP, so every backend gives the guest the same view: with the TSC-deadline CPUID bit clear, KVM would otherwise read it as 0 and ignore writes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 198 ++++++++++++------ 1 file changed, 131 insertions(+), 67 deletions(-) diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index 2a3e4140a2d..d977c1b7887 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -15,12 +15,14 @@ //! makes their MSRs (kvmclock and the others) raise #GP. The partition //! capabilities come from the CPUID with the hypervisor range masked, so //! neither `hv1` nor `kvm_clock` is present. -//! - **Identity MSRs.** An MSR filter denies `0x40000000..=0x400001ff` and -//! `IA32_TSC_ADJUST`, and `KVM_MSR_EXIT_REASON_FILTER` delivers every guest -//! access to OpenVMM: [`TimeAbiMsrs`] serves the identity range, and -//! `IA32_TSC_ADJUST` raises #GP (KVM serves it even with its CPUID bit -//! clear). KVM checks the filter before its in-kernel Hyper-V MSRs, so those -//! never see a guest access. +//! - **Identity MSRs.** An MSR filter denies `0x40000000..=0x400001ff`, +//! `IA32_TSC_ADJUST`, and `IA32_TSC_DEADLINE`, and +//! `KVM_MSR_EXIT_REASON_FILTER` delivers every guest access to OpenVMM: +//! [`TimeAbiMsrs`] serves the identity range, and the two hidden TSC MSRs +//! raise #GP (with their CPUID bits clear, KVM would still serve +//! `IA32_TSC_ADJUST`, and would read `IA32_TSC_DEADLINE` as 0 and ignore +//! writes). KVM checks the filter before its in-kernel Hyper-V MSRs, so +//! those never see a guest access. //! - **Invariant TSC.** With `"Hv#1"` and `AccessTscInvariantControls` in //! CPUID, KVM (Linux 6.3 and later) hides the invariant-TSC bit of CPUID //! `0x80000007` until its own copy of `HV_X64_MSR_TSC_INVARIANT_CONTROL` is @@ -59,6 +61,7 @@ use virt::VpIndex; use virt::time_abi::BackendPreflight; use virt::time_abi::HostTimeSample; use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::MAX_ANCHOR_PAIRING_NS; use virt::time_abi::TimeAbiBackend; use virt::time_abi::TimeAbiCode; use virt::time_abi::TimeAbiError; @@ -76,20 +79,24 @@ use virt::x86::MsrError; /// `IA32_TSC_ADJUST`, which the time ABI hides. const MSR_IA32_TSC_ADJUST: u32 = 0x3b; -/// The most a host time sample may be from the TSC read it is paired with -/// (spec: "Capture anchor"). -const MAX_ANCHOR_PAIRING_NS: u64 = 10_000; +/// `IA32_TSC_DEADLINE`, which the time ABI hides. +const MSR_IA32_TSC_DEADLINE: u32 = 0x6e0; -/// Anchor attempts before giving up: an attempt only fails when the thread -/// is interrupted between the two TSC reads around the host sample. +/// The MSRs outside the identity range that raise #GP. +const HIDDEN_TSC_MSRS: [u32; 2] = [MSR_IA32_TSC_ADJUST, MSR_IA32_TSC_DEADLINE]; + +/// Pairing attempts before an anchor fails: an attempt only misses +/// [`MAX_ANCHOR_PAIRING_NS`] when the thread is interrupted between the two +/// TSC reads around the host sample. const ANCHOR_ATTEMPTS: usize = 16; /// Returns the MSR filter ranges of the time ABI: deny every guest access to -/// the identity range and to `IA32_TSC_ADJUST`, so they exit to OpenVMM. -pub(crate) fn identity_msr_filter() -> [kvm::MsrFilterRange; 2] { +/// the identity range and to the hidden TSC MSRs, so they exit to OpenVMM. +pub(crate) fn identity_msr_filter() -> [kvm::MsrFilterRange; 3] { [ kvm::MsrFilterRange::deny(IDENTITY_MSR_RANGE), kvm::MsrFilterRange::deny(MSR_IA32_TSC_ADJUST..=MSR_IA32_TSC_ADJUST), + kvm::MsrFilterRange::deny(MSR_IA32_TSC_DEADLINE..=MSR_IA32_TSC_DEADLINE), ] } @@ -207,35 +214,45 @@ struct PairedSample { pairing_ns: u64, } -/// Takes a host time sample paired with the host TSC: the first of -/// [`ANCHOR_ATTEMPTS`] attempts within [`MAX_ANCHOR_PAIRING_NS`], or else the -/// tightest one. -fn paired_host_sample(tsc_hz: u64) -> Result { - let mut best: Option = None; +/// Takes the `anchor` ("capture anchor" or "restore anchor"): a host time +/// sample paired with the host TSC within [`MAX_ANCHOR_PAIRING_NS`]. +fn paired_host_sample(tsc_hz: u64, anchor: &str) -> Result { + pair_within_bound(tsc_hz, anchor, || HostTscBracket::around(sample_host_time)) +} + +/// Samples with `sample` until a pair is within [`MAX_ANCHOR_PAIRING_NS`], +/// at most [`ANCHOR_ATTEMPTS`] times, then fails with `E_TSC_ANCHOR`. +fn pair_within_bound( + tsc_hz: u64, + anchor: &str, + mut sample: impl FnMut() -> (HostTscBracket, Result), +) -> Result { + let mut tightest_ns: Option = None; for _ in 0..ANCHOR_ATTEMPTS { - let (bracket, sample) = HostTscBracket::around(sample_host_time); - let sample = sample?; + let (bracket, host) = sample(); + let host = host?; let Some((host_tsc, pairing_ns)) = pair_host_sample(bracket, tsc_hz) else { continue; }; - let paired = PairedSample { - host_tsc, - sample, - pairing_ns, - }; if pairing_ns <= MAX_ANCHOR_PAIRING_NS { - return Ok(paired); - } - if best.is_none_or(|best| pairing_ns < best.pairing_ns) { - best = Some(paired); + return Ok(PairedSample { + host_tsc, + sample: host, + pairing_ns, + }); } + tightest_ns = Some(tightest_ns.map_or(pairing_ns, |ns| ns.min(pairing_ns))); } - best.ok_or_else(|| { - TimeAbiError::new( - TimeAbiCode::TscAnchor, - "the host TSC went backwards around every host time sample", - ) - }) + Err(TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!( + "cannot pair the {anchor} with a host time sample within {MAX_ANCHOR_PAIRING_NS} ns \ + in {ANCHOR_ATTEMPTS} attempts (tightest pairing: {})", + tightest_ns.map_or("none, the host TSC went backwards".into(), |ns| format!( + "{ns} ns" + )) + ), + )) } /// Returns whether the guest TSC read from a vCPU between the host TSC reads @@ -328,8 +345,8 @@ impl KvmTimeAbi { /// Handles a guest read of `msr` that exited to user space. `None` means /// the MSR is not the time ABI's. pub(crate) fn read_msr(&self, vp: VpIndex, msr: u32) -> Option> { - if msr == MSR_IA32_TSC_ADJUST { - tracelimit::info_ratelimited!(vp = vp.index(), "IA32_TSC_ADJUST read raises #GP"); + if HIDDEN_TSC_MSRS.contains(&msr) { + tracelimit::info_ratelimited!(vp = vp.index(), msr, "hidden TSC MSR read raises #GP"); return Some(Err(MsrError::InvalidAccess)); } self.msrs.read(vp, msr) @@ -347,11 +364,12 @@ impl KvmTimeAbi { msr: u32, value: u64, ) -> Option> { - if msr == MSR_IA32_TSC_ADJUST { + if HIDDEN_TSC_MSRS.contains(&msr) { tracelimit::info_ratelimited!( vp = vp.index(), + msr, value, - "IA32_TSC_ADJUST write raises #GP" + "hidden TSC MSR write raises #GP" ); return Some(Err(MsrError::InvalidAccess)); } @@ -460,8 +478,9 @@ impl TimeAbiBackend for KvmPartitionInner { ))); } // Without scaling, the guest TSC is the host TSC plus the offset. - // KVM scales only outside its tolerance (250 ppm by default), far - // more than the bracket width at any host TSC value. + // KVM scales only outside its tolerance (250 ppm by default), + // which moves the value far outside the bracket once the host + // has been up for about a second. let offset = vp .tsc_offset() .map_err(|err| scaling(format!("cannot read KVM_VCPU_TSC_OFFSET: {err:#}")))?; @@ -503,18 +522,7 @@ impl TimeAbiBackend for KvmPartitionInner { format!("cannot read VP 0's TSC offset: {err:#}"), ) })?; - let paired = paired_host_sample(tsc_hz)?; - if paired.pairing_ns > MAX_ANCHOR_PAIRING_NS { - return Err(TimeAbiError::new( - TimeAbiCode::TscAnchor, - format!( - "cannot pair VP 0's TSC with a host time sample within \ - {MAX_ANCHOR_PAIRING_NS} ns: the best of {ANCHOR_ATTEMPTS} attempts is \ - {} ns", - paired.pairing_ns - ), - )); - } + let paired = paired_host_sample(tsc_hz, "capture anchor")?; let tsc = paired.host_tsc.wrapping_add(offset); tracing::debug!( tsc, @@ -552,13 +560,7 @@ impl TimeAbiBackend for KvmPartitionInner { // The restore anchor. Every guest TSC equals the target at the host // instant of `host_tsc` and runs from there. - let paired = paired_host_sample(tsc_hz)?; - if paired.pairing_ns > MAX_ANCHOR_PAIRING_NS { - tracelimit::warn_ratelimited!( - pairing_ns = paired.pairing_ns, - "time ABI restore anchor is paired loosely" - ); - } + let paired = paired_host_sample(tsc_hz, "restore anchor")?; let host_tsc = paired.host_tsc; let target_tsc = target(&paired.sample)?; let offset = target_tsc.wrapping_sub(host_tsc); @@ -628,18 +630,14 @@ mod tests { #[test] fn filter_denies_exactly_the_owned_msrs() { let filter = identity_msr_filter(); - let owned = [ - (0x4000_0000, 0x4000_01ff), - (MSR_IA32_TSC_ADJUST, MSR_IA32_TSC_ADJUST), - ]; + let owned = [(0x4000_0000, 0x4000_01ff), (0x3b, 0x3b), (0x6e0, 0x6e0)]; + assert_eq!(filter.len(), owned.len()); for (range, (first, last)) in filter.iter().zip(owned) { assert_eq!(range.base, first); assert_eq!(range.base + range.nmsrs - 1, last); assert!(range.read && range.write); assert!(range.bitmap.iter().all(|&byte| byte == 0)); } - assert_eq!(filter[0].nmsrs, 0x200); - assert_eq!(filter[1].nmsrs, 1); } fn kvm_like_base() -> Vec { @@ -799,6 +797,70 @@ mod tests { assert_eq!(pair_host_sample(high, hz), Some((u64::MAX - 50, 25))); } + fn host_sample(ns: u64) -> HostTimeSample { + HostTimeSample { + utc_ns: ns, + monotonic_ns: ns, + } + } + + #[test] + fn anchors_sample_again_until_paired_within_the_bound() { + // 1 GHz: one cycle per ns. Two wide brackets, then a tight one. + let mut brackets = [(0, 30_000), (0, 25_000), (1_000, 1_100)].into_iter(); + let paired = pair_within_bound(1_000_000_000, "capture anchor", || { + let (start, end) = brackets.next().unwrap(); + (HostTscBracket { start, end }, Ok(host_sample(start))) + }) + .unwrap(); + assert_eq!((paired.host_tsc, paired.pairing_ns), (1_050, 50)); + assert_eq!(paired.sample, host_sample(1_000)); + } + + #[test] + fn anchors_fail_after_bounded_attempts() { + let mut attempts = 0; + let error = pair_within_bound(1_000_000_000, "restore anchor", || { + attempts += 1; + let end = 20_000 + attempts as u64; + (HostTscBracket { start: 0, end }, Ok(host_sample(0))) + }) + .unwrap_err(); + assert_eq!(attempts, ANCHOR_ATTEMPTS); + assert_eq!(error.code, TimeAbiCode::TscAnchor); + assert!( + error.message.contains("restore anchor"), + "{}", + error.message + ); + assert!( + error.message.contains("tightest pairing: 10001 ns"), + "{}", + error.message + ); + + let error = pair_within_bound(1_000_000_000, "capture anchor", || { + (HostTscBracket { start: 10, end: 9 }, Ok(host_sample(0))) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscAnchor); + assert!( + error.message.contains("went backwards"), + "{}", + error.message + ); + + // A host clock failure ends the anchor at once, with its own code. + let error = pair_within_bound(1_000_000_000, "capture anchor", || { + ( + HostTscBracket { start: 0, end: 1 }, + Err(TimeAbiError::new(TimeAbiCode::HostIdentity, "no clock")), + ) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::HostIdentity); + } + #[test] fn unscaled_tsc_is_host_plus_offset() { let bracket = HostTscBracket { @@ -918,6 +980,8 @@ mod tests { } assert_eq!(read(MSR_IA32_TSC_ADJUST), Some(Err(()))); assert_eq!(write(MSR_IA32_TSC_ADJUST, 0), Some(Err(()))); + assert_eq!(read(MSR_IA32_TSC_DEADLINE), Some(Err(()))); + assert_eq!(write(MSR_IA32_TSC_DEADLINE, 1), Some(Err(()))); // Other unknown MSRs keep their existing handling. assert_eq!(read(0x88), None); assert_eq!(write(0x4000_0200, 0), None); @@ -1014,7 +1078,7 @@ mod kvm_tests { let hz = vm.vp(0).tsc_frequency_hz().unwrap(); let mut worst = 0; for _ in 0..1000 { - worst = worst.max(paired_host_sample(hz).unwrap().pairing_ns); + worst = worst.max(paired_host_sample(hz, "test anchor").unwrap().pairing_ns); } tracing::info!(worst, "worst host sample pairing, ns"); assert!(worst <= MAX_ANCHOR_PAIRING_NS); From 16b0708dc82faaf3abf34cec8fa875407abe6df9 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 03:39:26 -0700 Subject: [PATCH 180/304] kvm, whp: add the capability queries the CPU fingerprint needs The CPU fingerprint reports what each backend can expose to a guest without running one. Add the KVM system ioctls it needs: the API version, the MSR index and feature-MSR lists, feature MSR values, the guest XSAVE permissions, and the VM's TSC rate. Add raw, non-panicking WHP queries for every processor feature bank, so the fingerprint can record what each bank reports instead of failing on hosts that lack one. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vm/kvm/src/lib.rs | 137 ++++++++++++++++++++++++++++++++++++++++++++++ vm/whp/src/lib.rs | 15 +++++ 2 files changed, 152 insertions(+) diff --git a/vm/kvm/src/lib.rs b/vm/kvm/src/lib.rs index fcf735ff5bb..6d80e94f1d8 100644 --- a/vm/kvm/src/lib.rs +++ b/vm/kvm/src/lib.rs @@ -39,12 +39,17 @@ mod ioctl { const KVMIO: u8 = 0xae; + ioctl_write_int_bad!(kvm_get_api_version, request_code_none!(KVMIO, 0x0)); ioctl_write_int_bad!(kvm_create_vm, request_code_none!(KVMIO, 0x1)); + #[cfg(target_arch = "x86_64")] + ioctl_readwrite!(kvm_get_msr_index_list, KVMIO, 0x02, kvm_msr_list); ioctl_write_int_bad!(kvm_check_extension, request_code_none!(KVMIO, 0x03)); ioctl_write_int_bad!(kvm_get_vcpu_mmap_size, request_code_none!(KVMIO, 0x04)); #[cfg(target_arch = "x86_64")] ioctl_readwrite!(kvm_get_supported_cpuid, KVMIO, 0x05, kvm_cpuid2); #[cfg(target_arch = "x86_64")] + ioctl_readwrite!(kvm_get_msr_feature_index_list, KVMIO, 0x0a, kvm_msr_list); + #[cfg(target_arch = "x86_64")] ioctl_readwrite!(kvm_get_supported_hv_cpuid, KVMIO, 0xc1, kvm_cpuid2); ioctl_write_int_bad!(kvm_create_vcpu, request_code_none!(KVMIO, 0x41)); ioctl_write_ptr!( @@ -402,6 +407,20 @@ pub enum Error { GetClock(#[source] nix::Error), #[error("SetClock")] SetClock(#[source] nix::Error), + #[error("GetApiVersion")] + GetApiVersion(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetMsrIndexList")] + GetMsrIndexList(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetMsrFeatureIndexList")] + GetMsrFeatureIndexList(#[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("GetFeatureMsr({0:#x})")] + GetFeatureMsr(u32, #[source] nix::Error), + #[cfg(target_arch = "x86_64")] + #[error("KVM did not report the MSR-based feature {0:#x}")] + FeatureMsrNotReported(u32), } type Result = std::result::Result; @@ -526,6 +545,111 @@ impl Kvm { unsafe { ioctl::kvm_check_extension(self.as_fd().as_raw_fd(), extension as i32) } } + /// Returns the KVM API version (`KVM_GET_API_VERSION`), which is 12 for + /// every stable KVM. + pub fn api_version(&self) -> Result { + // SAFETY: Calling IOCTL as documented, with no special requirements. + unsafe { ioctl::kvm_get_api_version(self.as_fd().as_raw_fd(), 0) } + .map_err(Error::GetApiVersion) + } + + /// Returns the MSRs that KVM can save and restore for a vCPU + /// (`KVM_GET_MSR_INDEX_LIST`). + #[cfg(target_arch = "x86_64")] + pub fn msr_index_list(&self) -> Result> { + self.msr_list(ioctl::kvm_get_msr_index_list) + .map_err(Error::GetMsrIndexList) + } + + /// Returns the MSR-based features that KVM reports + /// (`KVM_GET_MSR_FEATURE_INDEX_LIST`). + #[cfg(target_arch = "x86_64")] + pub fn msr_feature_index_list(&self) -> Result> { + self.msr_list(ioctl::kvm_get_msr_feature_index_list) + .map_err(Error::GetMsrFeatureIndexList) + } + + #[cfg(target_arch = "x86_64")] + fn msr_list( + &self, + get: unsafe fn(libc::c_int, *mut kvm_msr_list) -> nix::Result, + ) -> nix::Result> { + const MAX_MSR_LIST_ENTRIES: usize = 1024; + + #[repr(C)] + struct MsrList { + header: kvm_msr_list, + indices: [u32; MAX_MSR_LIST_ENTRIES], + } + + let mut list = MsrList { + header: kvm_msr_list { + nmsrs: MAX_MSR_LIST_ENTRIES as u32, + ..Default::default() + }, + indices: [0; MAX_MSR_LIST_ENTRIES], + }; + // SAFETY: The header is immediately followed by space for `nmsrs` + // indices, as the ioctl requires. + unsafe { + get(self.as_fd().as_raw_fd(), &mut list.header)?; + } + let count = (list.header.nmsrs as usize).min(MAX_MSR_LIST_ENTRIES); + Ok(list.indices[..count].to_vec()) + } + + /// Returns the value that KVM reports for the MSR-based feature `index` + /// (`KVM_GET_MSRS` on the system file descriptor). + #[cfg(target_arch = "x86_64")] + pub fn feature_msr(&self, index: u32) -> Result { + #[repr(C)] + struct Msrs { + header: kvm_msrs, + entries: [kvm_msr_entry; 1], + } + + let mut msrs = Msrs { + header: kvm_msrs { + nmsrs: 1, + ..Default::default() + }, + entries: [kvm_msr_entry { + index, + reserved: 0, + data: 0, + }], + }; + // SAFETY: The header is immediately followed by `nmsrs` entries, as + // the ioctl requires. + let read = unsafe { ioctl::kvm_get_msrs(self.as_fd().as_raw_fd(), &mut msrs.header) } + .map_err(|err| Error::GetFeatureMsr(index, err))?; + if read != 1 { + return Err(Error::FeatureMsrNotReported(index)); + } + Ok(msrs.entries[0].data) + } + + /// Returns the XSAVE features that KVM supports for guests + /// (`KVM_X86_XCOMP_GUEST_SUPP`), including dynamically enabled features + /// that a VMM must request permission for before KVM reports them in + /// [`Self::supported_cpuid`]. + #[cfg(target_arch = "x86_64")] + pub fn xsave_guest_supported(&self) -> Result { + let mut value = 0u64; + let attr = kvm_device_attr { + group: KVM_X86_GRP_SYSTEM, + attr: u64::from(KVM_X86_XCOMP_GUEST_SUPP), + addr: std::ptr::from_mut(&mut value) as u64, + flags: 0, + }; + // SAFETY: `attr.addr` points to `value` for the duration of the ioctl. + unsafe { + ioctl::kvm_get_device_attr(self.as_fd().as_raw_fd(), &attr) + .map_err(Error::GetDeviceAttr)?; + } + Ok(value) + } + pub fn new_vm(&self, vm_type: VmType) -> Result { let raw_vm_type = self.raw_vm_type(vm_type)?; self.new_vm_with_type(raw_vm_type) @@ -729,6 +853,19 @@ impl Partition { unsafe { ioctl::kvm_check_extension(self.vm.as_raw_fd(), extension as i32) } } + /// Returns the TSC frequency, in Hz, that new vCPUs of this VM get + /// (`KVM_GET_TSC_KHZ` on the VM, which requires `KVM_CAP_VM_TSC_CONTROL`). + #[cfg(target_arch = "x86_64")] + pub fn tsc_frequency_hz(&self) -> Result { + // SAFETY: Calling the documented VM ioctl with no pointer argument. + let khz = unsafe { ioctl::kvm_get_tsc_khz(self.vm.as_raw_fd(), 0) } + .map_err(Error::GetTscFrequency)?; + if khz <= 0 { + return Err(Error::GetTscFrequency(nix::errno::Errno::EINVAL)); + } + Ok(khz as u64 * 1000) + } + #[cfg(target_arch = "aarch64")] pub fn arm_rmi_populate(&self, populate: &mut KvmArmRmiPopulate) -> Result<()> { // SAFETY: `populate` points to a valid KVM_ARM_RMI_POPULATE argument for diff --git a/vm/whp/src/lib.rs b/vm/whp/src/lib.rs index 66749535985..ad2321c5209 100644 --- a/vm/whp/src/lib.rs +++ b/vm/whp/src/lib.rs @@ -117,6 +117,21 @@ pub mod capabilities { Err(err) => Err(err), } } + /// The available processor features of the first bank, from the query + /// that predates feature banks. + pub fn processor_features_bank0() -> Result { + get(abi::WHvCapabilityCodeProcessorFeatures) + } + /// The available processor feature banks, without a fallback to the + /// query that predates them. + pub fn processor_features_banks() -> Result { + get(abi::WHvCapabilityCodeProcessorFeaturesBanks) + } + /// The available synthetic processor feature banks, whatever their count. + pub fn synthetic_processor_features_banks() + -> Result { + get(abi::WHvCapabilityCodeSyntheticProcessorFeaturesBanks) + } /// Processor frequency capping capabilities. pub fn processor_frequency_cap() -> Result { get(abi::WHvCapabilityCodeProcessorFrequencyCap) From 3e84aaa8e32b43b8dca89476eda2c4ce6a2c57c4 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 03:41:01 -0700 Subject: [PATCH 181/304] cpu_profile: fingerprint the supported CPU surface of each backend The NVX time ABI pins the guest CPU surface with per-generation CPU profiles intersected from host fingerprints, following the workflow of Firecracker's cpu-template-helper. Add the cpu_profile crate with the fingerprint document: a canonical, deterministic JSON encoding with digests over the guest-relevant surface and over the whole document; CPUID enumeration and the normalization of fields that describe the executing processor or runtime state rather than the surface; the XSAVE layout; and the host's CPU identity (vendor, signature, brand, microcode, and the host OS's view of the TSC). The crate does not depend on virt, so virt can depend on it. Each backend reports its supported guest CPU surface and time capabilities, creating at most one transient probe partition that never runs: - KVM: KVM_GET_SUPPORTED_CPUID, the feature MSRs including ARCH_CAPABILITIES, and the TSC and LAPIC rates and TSC controls of a probe VM without vCPUs. - MSHV: the CPUID of a probe partition that enables every processor feature and XSAVE feature of the host partition, the host's feature banks, and probes of TSC writes, partition time freezing, and MSR-index intercepts. Time freezing is decided by the write alone, because some hypervisor builds reject reading the property back. - WHP: the CPUID of a probe partition with every processor and XSAVE feature that WHP reports as available, because WHP's default features omit some of them, such as the speculation controls and PSFD; the capability banks; and probes of TSC writes, time suspension, and frequency virtualization. Each backend module has an ignored hardware test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 16 + Cargo.toml | 1 + vmm_core/cpu_profile/Cargo.toml | 25 + vmm_core/cpu_profile/src/canonical.rs | 152 +++++ vmm_core/cpu_profile/src/cpuid.rs | 548 +++++++++++++++ vmm_core/cpu_profile/src/fingerprint.rs | 623 ++++++++++++++++++ vmm_core/cpu_profile/src/hex.rs | 166 +++++ vmm_core/cpu_profile/src/host/linux.rs | 100 +++ vmm_core/cpu_profile/src/host/mod.rs | 353 ++++++++++ vmm_core/cpu_profile/src/host/windows.rs | 159 +++++ vmm_core/cpu_profile/src/lib.rs | 26 + vmm_core/virt_kvm/Cargo.toml | 1 + .../virt_kvm/src/arch/x86_64/fingerprint.rs | 202 ++++++ vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 1 + vmm_core/virt_mshv/Cargo.toml | 1 + vmm_core/virt_mshv/src/lib.rs | 6 + vmm_core/virt_mshv/src/x86_64/fingerprint.rs | 235 +++++++ vmm_core/virt_mshv/src/x86_64/mod.rs | 1 + vmm_core/virt_whp/Cargo.toml | 1 + vmm_core/virt_whp/src/fingerprint.rs | 344 ++++++++++ vmm_core/virt_whp/src/lib.rs | 4 + 21 files changed, 2965 insertions(+) create mode 100644 vmm_core/cpu_profile/Cargo.toml create mode 100644 vmm_core/cpu_profile/src/canonical.rs create mode 100644 vmm_core/cpu_profile/src/cpuid.rs create mode 100644 vmm_core/cpu_profile/src/fingerprint.rs create mode 100644 vmm_core/cpu_profile/src/hex.rs create mode 100644 vmm_core/cpu_profile/src/host/linux.rs create mode 100644 vmm_core/cpu_profile/src/host/mod.rs create mode 100644 vmm_core/cpu_profile/src/host/windows.rs create mode 100644 vmm_core/cpu_profile/src/lib.rs create mode 100644 vmm_core/virt_kvm/src/arch/x86_64/fingerprint.rs create mode 100644 vmm_core/virt_mshv/src/x86_64/fingerprint.rs create mode 100644 vmm_core/virt_whp/src/fingerprint.rs diff --git a/Cargo.lock b/Cargo.lock index 988d0891d68..fd5e78b2077 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -995,6 +995,19 @@ dependencies = [ "syn 2.0.106", ] +[[package]] +name = "cpu_profile" +version = "0.0.0" +dependencies = [ + "safe_intrinsics", + "serde", + "serde_json", + "sha2", + "test_with_tracing", + "thiserror 2.0.16", + "windows-sys 0.61.0", +] + [[package]] name = "cpubits" version = "0.1.0" @@ -9803,6 +9816,7 @@ dependencies = [ "bitfield-struct 0.11.0", "build_rs_guest_arch", "cfg-if", + "cpu_profile", "guestmem", "hv1_emulator", "hv1_hypercall", @@ -9835,6 +9849,7 @@ dependencies = [ "aarch64defs", "anyhow", "build_rs_guest_arch", + "cpu_profile", "fs-err", "guestmem", "headervec", @@ -9990,6 +10005,7 @@ dependencies = [ "arrayvec", "build_rs_guest_arch", "chipset_device", + "cpu_profile", "guestmem", "hv1_emulator", "hv1_hypercall", diff --git a/Cargo.toml b/Cargo.toml index 2030eeb1eec..c206e7aa4b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -450,6 +450,7 @@ vm_topology = { path = "vm/vmcore/vm_topology" } vmcore = { path = "vm/vmcore" } # vmm_core +cpu_profile = { path = "vmm_core/cpu_profile" } hyperv_dump = { path = "vmm_core/hyperv_dump" } state_unit = { path = "vmm_core/state_unit" } virt = { path = "vmm_core/virt" } diff --git a/vmm_core/cpu_profile/Cargo.toml b/vmm_core/cpu_profile/Cargo.toml new file mode 100644 index 00000000000..59086add4d6 --- /dev/null +++ b/vmm_core/cpu_profile/Cargo.toml @@ -0,0 +1,25 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[package] +name = "cpu_profile" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +safe_intrinsics.workspace = true + +serde = { workspace = true, features = ["derive", "std"] } +serde_json = { workspace = true, features = ["std"] } +sha2.workspace = true +thiserror.workspace = true + +[target.'cfg(windows)'.dependencies.windows-sys] +workspace = true +features = ["Win32_Foundation", "Win32_System_Registry"] + +[dev-dependencies] +test_with_tracing.workspace = true + +[lints] +workspace = true diff --git a/vmm_core/cpu_profile/src/canonical.rs b/vmm_core/cpu_profile/src/canonical.rs new file mode 100644 index 00000000000..c01b6d6f7bb --- /dev/null +++ b/vmm_core/cpu_profile/src/canonical.rs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Canonical JSON and its SHA-256 digests. +//! +//! The canonical form sorts object keys by their UTF-8 bytes and keeps array +//! order, so the producer is responsible for ordering arrays. The compact form +//! has no insignificant whitespace and is the input of every digest. The +//! pretty form indents by two spaces and is what fingerprints are written as. +//! Both are independent of how `serde_json` orders map keys. + +use serde_json::Value; +use sha2::Digest; + +/// The prefix of every digest string. +pub(crate) const DIGEST_PREFIX: &str = "sha256:"; + +/// Returns the compact canonical encoding of `value`. +pub(crate) fn to_compact(value: &Value) -> String { + let mut out = String::new(); + write_value(&mut out, value, None, 0); + out +} + +/// Returns the pretty canonical encoding of `value`, with a final newline. +pub(crate) fn to_pretty(value: &Value) -> String { + let mut out = String::new(); + write_value(&mut out, value, Some(2), 0); + out.push('\n'); + out +} + +/// Returns `sha256:` and the hex SHA-256 of the compact canonical encoding of +/// `value`. +pub(crate) fn digest(value: &Value) -> String { + let hash = sha2::Sha256::digest(to_compact(value).as_bytes()); + let mut out = String::with_capacity(DIGEST_PREFIX.len() + 64); + out.push_str(DIGEST_PREFIX); + for byte in hash.iter() { + out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap()); + out.push(char::from_digit(u32::from(byte & 0xf), 16).unwrap()); + } + out +} + +fn write_value(out: &mut String, value: &Value, indent: Option, depth: usize) { + match value { + Value::Null => out.push_str("null"), + Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }), + Value::Number(value) => out.push_str(&value.to_string()), + Value::String(value) => write_string(out, value), + Value::Array(items) => { + if items.is_empty() { + out.push_str("[]"); + return; + } + out.push('['); + for (i, item) in items.iter().enumerate() { + if i != 0 { + out.push(','); + } + write_newline(out, indent, depth + 1); + write_value(out, item, indent, depth + 1); + } + write_newline(out, indent, depth); + out.push(']'); + } + Value::Object(map) => { + if map.is_empty() { + out.push_str("{}"); + return; + } + let mut entries = map.iter().collect::>(); + entries.sort_unstable_by(|(left, _), (right, _)| left.as_bytes().cmp(right.as_bytes())); + out.push('{'); + for (i, (key, item)) in entries.into_iter().enumerate() { + if i != 0 { + out.push(','); + } + write_newline(out, indent, depth + 1); + write_string(out, key); + out.push(':'); + if indent.is_some() { + out.push(' '); + } + write_value(out, item, indent, depth + 1); + } + write_newline(out, indent, depth); + out.push('}'); + } + } +} + +fn write_newline(out: &mut String, indent: Option, depth: usize) { + if let Some(indent) = indent { + out.push('\n'); + out.extend(std::iter::repeat_n(' ', indent * depth)); + } +} + +fn write_string(out: &mut String, value: &str) { + // serde_json escapes deterministically, and serializing a string cannot + // fail. + out.push_str(&serde_json::to_string(value).expect("string serialization is infallible")); +} + +#[cfg(test)] +mod tests { + use super::digest; + use super::to_compact; + use super::to_pretty; + use serde_json::json; + use test_with_tracing::test; + + #[test] + fn sorts_object_keys_and_keeps_array_order() { + let value = json!({"b": [3, 1, {"z": null, "a": true}], "a": "x", "B": {}}); + assert_eq!( + to_compact(&value), + r#"{"B":{},"a":"x","b":[3,1,{"a":true,"z":null}]}"# + ); + } + + #[test] + fn pretty_form_is_indented_and_terminated() { + let value = json!({"b": [1, 2], "a": {"c": "d"}, "e": [], "f": {}}); + assert_eq!( + to_pretty(&value), + "{\n \"a\": {\n \"c\": \"d\"\n },\n \"b\": [\n 1,\n 2\n ],\n \"e\": [],\n \"f\": {}\n}\n" + ); + } + + #[test] + fn escapes_strings() { + let value = json!({"k\"ey": "line\nbreak\u{1}"}); + assert_eq!(to_compact(&value), r#"{"k\"ey":"line\nbreak\u0001"}"#); + } + + #[test] + fn digest_is_sha256_of_compact_form() { + // SHA-256 of `{}`. + assert_eq!( + digest(&json!({})), + "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a" + ); + // Key order does not change the digest. + assert_eq!( + digest(&json!({"a": 1, "b": 2})), + digest(&serde_json::from_str(r#"{"b": 2, "a": 1}"#).unwrap()) + ); + } +} diff --git a/vmm_core/cpu_profile/src/cpuid.rs b/vmm_core/cpu_profile/src/cpuid.rs new file mode 100644 index 00000000000..162aa80b1e9 --- /dev/null +++ b/vmm_core/cpu_profile/src/cpuid.rs @@ -0,0 +1,548 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPUID tables: entries, subleaf enumeration, the XSAVE layout they +//! describe, and the normalization of fields that differ between processors +//! and virtual processor states rather than between CPU surfaces. + +use crate::Hex32; +use serde::Deserialize; +use serde::Serialize; + +/// The first leaf of the hypervisor range. +pub const HYPERVISOR_LEAF_BASE: u32 = 0x4000_0000; +/// The first leaf of the extended range. +pub const EXTENDED_LEAF_BASE: u32 = 0x8000_0000; + +/// The most leaves enumerated in each of the basic, hypervisor, and extended +/// ranges, which bounds the enumeration of a hypervisor reporting garbage. +const MAX_LEAVES_PER_RANGE: u32 = 0x100; +/// The most subleaves enumerated for one leaf. +const MAX_SUBLEAVES: u32 = 64; + +/// The size of the legacy region and the header of an XSAVE area. +const XSAVE_LEGACY_AND_HEADER_SIZE: u32 = 512 + 64; + +/// `CPUID.1:EBX[31:24]`, the initial APIC ID of the executing processor. +const LEAF1_EBX_INITIAL_APIC_ID: u32 = 0xff00_0000; +/// `CPUID.1:ECX[27]`, OSXSAVE, which mirrors CR4.OSXSAVE. +const LEAF1_ECX_OSXSAVE: u32 = 1 << 27; +/// `CPUID.(7,0):ECX[4]`, OSPKE, which mirrors CR4.PKE. +const LEAF7_ECX_OSPKE: u32 = 1 << 4; + +/// One CPUID leaf, or one subleaf of an indexed leaf, with its full register +/// values. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpuidEntry { + /// The leaf, the input value of `EAX`. + pub leaf: Hex32, + /// The subleaf, the input value of `ECX`, or `None` for a leaf whose + /// output does not depend on `ECX`. + pub subleaf: Option, + /// The output value of `EAX`. + pub eax: Hex32, + /// The output value of `EBX`. + pub ebx: Hex32, + /// The output value of `ECX`. + pub ecx: Hex32, + /// The output value of `EDX`. + pub edx: Hex32, +} + +impl CpuidEntry { + /// Returns an entry for `leaf` and `subleaf` with the output registers + /// `[eax, ebx, ecx, edx]`. + pub fn new(leaf: u32, subleaf: Option, registers: [u32; 4]) -> Self { + let [eax, ebx, ecx, edx] = registers; + Self { + leaf: Hex32(leaf), + subleaf: subleaf.map(Hex32), + eax: Hex32(eax), + ebx: Hex32(ebx), + ecx: Hex32(ecx), + edx: Hex32(edx), + } + } + + /// Returns the output registers as `[eax, ebx, ecx, edx]`. + pub fn registers(&self) -> [u32; 4] { + [self.eax.0, self.ebx.0, self.ecx.0, self.edx.0] + } + + /// Returns the sort key of the entry: the leaf, then the subleaf, with a + /// subleaf-independent entry first. + pub fn key(&self) -> (u32, Option) { + (self.leaf.0, self.subleaf.map(|subleaf| subleaf.0)) + } +} + +/// Returns whether the output of `leaf` depends on the subleaf in `ECX`. +/// +/// These are the leaves for which KVM sets `KVM_CPUID_FLAG_SIGNIFCANT_INDEX`, +/// so tables enumerated here and tables from `KVM_GET_SUPPORTED_CPUID` index +/// the same leaves. +pub fn is_indexed_leaf(leaf: u32) -> bool { + matches!( + leaf, + 0x4 | 0x7 + | 0xb + | 0xd + | 0xf + | 0x10 + | 0x12 + | 0x14 + | 0x17 + | 0x18 + | 0x1d + | 0x1e + | 0x1f + | 0x24 + | 0x8000_001d + ) +} + +/// Returns the output registers that `entries` define for `leaf` and +/// `subleaf`, if any. A subleaf-independent entry matches every subleaf. +pub fn lookup(entries: &[CpuidEntry], leaf: u32, subleaf: u32) -> Option<[u32; 4]> { + entries + .iter() + .find(|entry| { + entry.leaf.0 == leaf + && entry + .subleaf + .is_none_or(|entry_subleaf| entry_subleaf.0 == subleaf) + }) + .map(CpuidEntry::registers) +} + +/// Returns whether bit `bit` of register `register` (0 for `EAX` through 3 +/// for `EDX`) is set for `leaf` and `subleaf`. +pub fn has_bit(entries: &[CpuidEntry], leaf: u32, subleaf: u32, register: usize, bit: u32) -> bool { + lookup(entries, leaf, subleaf).is_some_and(|registers| registers[register] & (1 << bit) != 0) +} + +/// Enumerates the CPUID table reported by `query(leaf, subleaf)`. +/// +/// This walks the basic, hypervisor, and extended ranges up to the maximum +/// leaf that each range reports, and the subleaves of every indexed leaf as +/// the architecture enumerates them: until an invalid level or cache type, +/// up to the maximum subleaf reported in subleaf 0, or, for XSAVE, one +/// subleaf per supported state component. The hypervisor range is walked +/// only when it reports a plausible maximum leaf; otherwise only its first +/// leaf is recorded. The result is sorted. +pub fn enumerate( + mut query: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + let mut entries = Vec::new(); + for base in [0, HYPERVISOR_LEAF_BASE, EXTENDED_LEAF_BASE] { + let max = query(base, 0)?[0]; + let last = if (base..base + MAX_LEAVES_PER_RANGE).contains(&max) { + max + } else { + base + }; + for leaf in base..=last { + enumerate_leaf(&mut query, leaf, &mut entries)?; + } + } + entries.sort_by_key(CpuidEntry::key); + Ok(entries) +} + +fn enumerate_leaf( + query: &mut impl FnMut(u32, u32) -> Result<[u32; 4], E>, + leaf: u32, + entries: &mut Vec, +) -> Result<(), E> { + if !is_indexed_leaf(leaf) { + entries.push(CpuidEntry::new(leaf, None, query(leaf, 0)?)); + return Ok(()); + } + + let mut subleaf_query = |subleaf: u32| -> Result<[u32; 4], E> { + let registers = query(leaf, subleaf)?; + entries.push(CpuidEntry::new(leaf, Some(subleaf), registers)); + Ok(registers) + }; + let first = subleaf_query(0)?; + match leaf { + // Deterministic cache parameters, up to and including the first + // subleaf with a null cache type. + 0x4 | 0x8000_001d => { + let mut registers = first; + for subleaf in 1..MAX_SUBLEAVES { + if registers[0] & 0x1f == 0 { + break; + } + registers = subleaf_query(subleaf)?; + } + } + // Extended topology, up to and including the first subleaf with an + // invalid level type. + 0xb | 0x1f => { + let mut registers = first; + for subleaf in 1..MAX_SUBLEAVES { + if (registers[2] >> 8) & 0xff == 0 { + break; + } + registers = subleaf_query(subleaf)?; + } + } + // Subleaf 0 reports the maximum subleaf in EAX. + 0x7 | 0x14 | 0x17 | 0x18 | 0x1d | 0x24 => { + for subleaf in 1..=first[0].min(MAX_SUBLEAVES - 1) { + subleaf_query(subleaf)?; + } + } + // XSAVE: the two fixed subleaves, then one per supported state + // component. + 0xd => { + let second = subleaf_query(1)?; + let components = xsave_mask(first[0], first[3]) | xsave_mask(second[2], second[3]); + for subleaf in 2..63 { + if components & (1 << subleaf) != 0 { + subleaf_query(subleaf)?; + } + } + } + // Resource director technology monitoring and allocation. + 0xf => { + subleaf_query(1)?; + } + 0x10 => { + for subleaf in 1..=3 { + subleaf_query(subleaf)?; + } + } + // SGX: the two fixed subleaves, then the EPC sections up to and + // including the first invalid one. + 0x12 => { + subleaf_query(1)?; + for subleaf in 2..MAX_SUBLEAVES { + if subleaf_query(subleaf)?[0] & 0xf == 0 { + break; + } + } + } + // TMUL information has a single subleaf. + _ => {} + } + Ok(()) +} + +/// Normalizes a CPUID table so that tables from different runs, hosts, and +/// backends compare equal when they offer guests the same CPU. +/// +/// This sorts the entries, drops duplicate keys (keeping the first), and +/// rewrites the fields that describe the executing processor or the current +/// virtual processor state rather than the CPU surface: +/// +/// - The initial APIC ID, `CPUID.1:EBX[31:24]`, and the x2APIC IDs of the +/// extended topology leaves 0xb and 0x1f in EDX, are zeroed. A backend +/// reports the ID of whichever processor executed the query, and the VMM +/// sets each virtual processor's own ID. +/// - The AMD extended APIC ID, CPUID.0x8000001e:EAX, is zeroed for the same +/// reason. +/// - OSXSAVE, `CPUID.1:ECX[27]`, and OSPKE, `CPUID.(7,0):ECX[4]`, are cleared. +/// They mirror control register bits, which are clear at reset. +/// - The XSAVE area sizes of the enabled features, CPUID.(0xd,0):EBX and +/// CPUID.(0xd,1):EBX, are set to the standard size of every supported user +/// state component and to the compacted size of every supported user and +/// supervisor state component. They otherwise depend on the current XCR0 +/// and IA32_XSS. These are the values that `KVM_GET_SUPPORTED_CPUID` +/// reports. +pub fn normalize(entries: &mut Vec) { + entries.sort_by_key(CpuidEntry::key); + entries.dedup_by_key(|entry| entry.key()); + let (xcr0, xss) = xsave_supported(entries); + let components = xsave_components(entries); + let standard_size = xsave_standard_size(&components, xcr0); + let compacted_size = xsave_compacted_size(&components, xcr0 | xss); + for entry in entries.iter_mut() { + match entry.key() { + (0x1, _) => { + entry.ebx.0 &= !LEAF1_EBX_INITIAL_APIC_ID; + entry.ecx.0 &= !LEAF1_ECX_OSXSAVE; + } + (0x7, Some(0)) => entry.ecx.0 &= !LEAF7_ECX_OSPKE, + (0xb | 0x1f, _) => entry.edx.0 = 0, + (0xd, Some(0)) => entry.ebx.0 = standard_size, + (0xd, Some(1)) => entry.ebx.0 = compacted_size, + (0x8000_001e, _) => entry.eax.0 = 0, + _ => {} + } + } +} + +fn xsave_mask(low: u32, high: u32) -> u64 { + u64::from(low) | (u64::from(high) << 32) +} + +/// One XSAVE state component, from CPUID.(0xd,n) for component `n >= 2`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct XsaveComponent { + /// The component number, which is its bit in XCR0 or IA32_XSS. + pub index: u32, + /// The size of the component in bytes, from EAX. + pub size: u32, + /// The offset of the component in the standard format, from EBX. It is + /// zero for supervisor components, which only the compacted format holds. + pub offset: u32, + /// The component is a supervisor state, enabled in IA32_XSS (`ECX[0]`). + pub supervisor: bool, + /// The component is 64-byte aligned in the compacted format (`ECX[1]`). + pub align64: bool, + /// The component supports extended feature disable (`ECX[2]`). + pub xfd: bool, +} + +/// Returns the supported XCR0 and IA32_XSS bits, from CPUID.(0xd,0):EDX:EAX +/// and CPUID.(0xd,1):EDX:ECX. +pub fn xsave_supported(entries: &[CpuidEntry]) -> (u64, u64) { + let first = lookup(entries, 0xd, 0).unwrap_or_default(); + let second = lookup(entries, 0xd, 1).unwrap_or_default(); + ( + xsave_mask(first[0], first[3]), + xsave_mask(second[2], second[3]), + ) +} + +/// Returns the supported XSAVE state components beyond x87 and SSE, ordered +/// by component number. +pub fn xsave_components(entries: &[CpuidEntry]) -> Vec { + let (xcr0, xss) = xsave_supported(entries); + (2..63) + .filter(|index| (xcr0 | xss) & (1 << index) != 0) + .filter_map(|index| { + let [size, offset, flags, _] = lookup(entries, 0xd, index)?; + Some(XsaveComponent { + index, + size, + offset, + supervisor: flags & 1 != 0, + align64: flags & 2 != 0, + xfd: flags & 4 != 0, + }) + }) + .collect() +} + +/// Returns the size of a standard-format XSAVE area holding the user state +/// components in `xcr0`. +pub fn xsave_standard_size(components: &[XsaveComponent], xcr0: u64) -> u32 { + components + .iter() + .filter(|component| xcr0 & (1 << component.index) != 0 && !component.supervisor) + .map(|component| component.offset.saturating_add(component.size)) + .fold(XSAVE_LEGACY_AND_HEADER_SIZE, u32::max) +} + +/// Returns the size of a compacted-format XSAVE area holding the state +/// components in `mask`. +pub fn xsave_compacted_size(components: &[XsaveComponent], mask: u64) -> u32 { + components + .iter() + .filter(|component| mask & (1 << component.index) != 0) + .fold(XSAVE_LEGACY_AND_HEADER_SIZE, |size, component| { + let start = if component.align64 { + size.checked_next_multiple_of(64).unwrap_or(u32::MAX) + } else { + size + }; + start.saturating_add(component.size) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use test_with_tracing::test; + + /// A small Skylake-like CPUID table, keyed by leaf and subleaf. + fn table() -> BTreeMap<(u32, u32), [u32; 4]> { + BTreeMap::from([ + ((0x0, 0), [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + ( + (0x1, 0), + [0x0005_0654, 0x2a20_0800, 0xfffa_3203, 0x0f8b_fbff], + ), + ((0x4, 0), [0x0c00_4121, 0x01c0_003f, 0x3f, 0]), + ((0x4, 1), [0x0c00_4122, 0x00c0_003f, 0x3f, 0]), + ((0x4, 2), [0x0c00_4143, 0x03c0_003f, 0x3ff, 0]), + ( + (0x7, 0), + [0, 0xd19f_27eb, 0x8 | LEAF7_ECX_OSPKE, 0xbc00_0400], + ), + ((0xb, 0), [1, 2, 0x100, 0x2a]), + ((0xb, 1), [5, 0x14, 0x201, 0x2a]), + // XCR0: x87, SSE, AVX (2), opmask/ZMM (5-7). XSS: PT (8). + ((0xd, 0), [0xe7, 0x340, 0xa88, 0]), + ((0xd, 1), [0xf, 0x3c0, 0x100, 0]), + ((0xd, 2), [0x100, 0x240, 0, 0]), + ((0xd, 5), [0x40, 0x440, 0, 0]), + ((0xd, 6), [0x200, 0x480, 0, 0]), + ((0xd, 7), [0x400, 0x680, 0, 0]), + ((0xd, 8), [0x80, 0, 1, 0]), + ((0x40000000, 0), [0, 0, 0, 0]), + ((0x8000_0000, 0), [0x8000_0001, 0, 0, 0]), + ((0x8000_0001, 0), [0, 0, 0x121, 0x2c10_0800]), + ]) + } + + fn query( + table: &BTreeMap<(u32, u32), [u32; 4]>, + ) -> impl FnMut(u32, u32) -> Result<[u32; 4], ()> { + |leaf, subleaf| Ok(table.get(&(leaf, subleaf)).copied().unwrap_or_default()) + } + + #[test] + fn enumerates_ranges_and_subleaves() { + let table = table(); + let entries = enumerate(query(&table)).unwrap(); + let keys = entries.iter().map(CpuidEntry::key).collect::>(); + + // Basic leaves 0 through 0x16, with cache subleaves up to and + // including the null entry (subleaf 3). + assert!(keys.contains(&(0x16, None))); + assert!(!keys.contains(&(0x17, Some(0)))); + for subleaf in 0..=3 { + assert!(keys.contains(&(0x4, Some(subleaf))), "subleaf {subleaf}"); + } + assert!(!keys.contains(&(0x4, Some(4)))); + // Leaf 7 reports no subleaves beyond 0. + assert!(keys.contains(&(0x7, Some(0)))); + assert!(!keys.contains(&(0x7, Some(1)))); + // Topology up to and including the invalid level (subleaf 2). + assert!(keys.contains(&(0xb, Some(2)))); + assert!(!keys.contains(&(0xb, Some(3)))); + // XSAVE: subleaves 0, 1, and one per supported component. + let xsave = keys + .iter() + .filter(|(leaf, _)| *leaf == 0xd) + .map(|(_, subleaf)| subleaf.unwrap()) + .collect::>(); + assert_eq!(xsave, [0, 1, 2, 5, 6, 7, 8]); + // An implausible hypervisor maximum leaf records only the first leaf. + assert!(keys.contains(&(0x4000_0000, None))); + assert!(!keys.contains(&(0x4000_0001, None))); + // Extended leaves. + assert_eq!(keys.last(), Some(&(0x8000_0001, None))); + // Sorted. + assert!(keys.windows(2).all(|pair| pair[0] < pair[1])); + } + + #[test] + fn enumeration_is_bounded() { + // A hypervisor that reports endless valid subleaves and leaves. + let entries = enumerate(|leaf, _| -> Result<_, ()> { + Ok(match leaf { + 0 => [0xff, 0, 0, 0], + 0x4000_0000 => [0x4000_00ff, 0, 0, 0], + 0x8000_0000 => [0x8000_00ff, 0, 0, 0], + _ => [!0, !0, !0, !0], + }) + }) + .unwrap(); + assert!(entries.len() < 3 * 0x100 + 16 * 64 * 2); + } + + #[test] + fn enumeration_propagates_errors() { + let result = enumerate(|leaf, _| { + if leaf == 2 { + Err("boom") + } else { + Ok([2, 0, 0, 0]) + } + }); + assert_eq!(result, Err("boom")); + } + + #[test] + fn normalizes_processor_and_state_fields() { + let table = table(); + let mut entries = enumerate(query(&table)).unwrap(); + let mut duplicated = entries.clone(); + duplicated.reverse(); + duplicated.push(CpuidEntry::new(0x1, None, [0; 4])); + normalize(&mut entries); + normalize(&mut duplicated); + + let [_, ebx, ecx, _] = lookup(&entries, 1, 0).unwrap(); + assert_eq!(ebx, 0x0020_0800); + assert_eq!(ecx & LEAF1_ECX_OSXSAVE, 0); + assert_eq!(lookup(&entries, 7, 0).unwrap()[2], 0x8); + assert_eq!(lookup(&entries, 0xb, 0).unwrap()[3], 0); + assert_eq!(lookup(&entries, 0xb, 1).unwrap()[3], 0); + // Standard size: ZMM_Hi256 (7) ends at 0x680 + 0x400. + assert_eq!(lookup(&entries, 0xd, 0).unwrap()[1], 0xa80); + // Compacted: 576 + AVX 0x100 + 0x40 + 0x200 + 0x400 + PT 0x80. + assert_eq!( + lookup(&entries, 0xd, 1).unwrap()[1], + 576 + 0x100 + 0x40 + 0x200 + 0x400 + 0x80 + ); + // Normalization is independent of the input order and idempotent. + assert_eq!(entries, duplicated); + let again = { + let mut again = entries.clone(); + normalize(&mut again); + again + }; + assert_eq!(entries, again); + } + + #[test] + fn computes_xsave_layout() { + let table = table(); + let entries = enumerate(query(&table)).unwrap(); + assert_eq!(xsave_supported(&entries), (0xe7, 0x100)); + let components = xsave_components(&entries); + assert_eq!( + components.iter().map(|c| c.index).collect::>(), + [2, 5, 6, 7, 8] + ); + assert!(components[4].supervisor); + assert_eq!(xsave_standard_size(&components, 0xe7), 0xa80); + // The supervisor component never counts towards the standard size. + assert_eq!(xsave_standard_size(&components, 0x1e7), 0xa80); + assert_eq!(xsave_standard_size(&components, 0x3), 576); + + let aligned = [ + XsaveComponent { + index: 2, + size: 10, + offset: 576, + supervisor: false, + align64: false, + xfd: false, + }, + XsaveComponent { + index: 3, + size: 8, + offset: 0, + supervisor: false, + align64: true, + xfd: false, + }, + ]; + assert_eq!(xsave_compacted_size(&aligned, 0xc), 640 + 8); + assert_eq!(xsave_compacted_size(&aligned, 0x4), 586); + } + + #[test] + fn lookup_matches_subleaf_independent_entries() { + let entries = [ + CpuidEntry::new(0x1, None, [1, 2, 3, 4]), + CpuidEntry::new(0x7, Some(0), [5, 6, 7, 8]), + ]; + assert_eq!(lookup(&entries, 0x1, 9), Some([1, 2, 3, 4])); + assert_eq!(lookup(&entries, 0x7, 0), Some([5, 6, 7, 8])); + assert_eq!(lookup(&entries, 0x7, 1), None); + assert!(has_bit(&entries, 0x7, 0, 1, 1)); + assert!(!has_bit(&entries, 0x7, 0, 1, 0)); + assert!(!has_bit(&entries, 0x8, 0, 1, 1)); + } +} diff --git a/vmm_core/cpu_profile/src/fingerprint.rs b/vmm_core/cpu_profile/src/fingerprint.rs new file mode 100644 index 00000000000..1d5355a69c1 --- /dev/null +++ b/vmm_core/cpu_profile/src/fingerprint.rs @@ -0,0 +1,623 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU fingerprint document. +//! +//! A [`CpuFingerprint`] combines the [`HostIdentity`] of the host with the +//! [`BackendFingerprint`] of one hypervisor backend: the guest CPU surface it +//! supports and its time capabilities. It carries two digests: +//! +//! - `surface_digest` covers only the guest CPU surface: the CPUID table, the +//! XSAVE layout, the feature MSRs, and the feature banks. Hosts that offer +//! guests the same CPU through the same backend share it, regardless of +//! their TSC rates, software versions, or capability values. +//! - `digest` covers the whole document except itself. +//! +//! Both are SHA-256 over the compact canonical JSON encoding (object keys +//! sorted, no whitespace). The document itself is written as pretty +//! canonical JSON, so the same host, backend, and tool always produce the +//! same bytes. + +use crate::Hex32; +use crate::Hex64; +use crate::canonical; +use crate::cpuid; +use crate::cpuid::CpuidEntry; +use crate::cpuid::XsaveComponent; +use crate::host::HostIdentity; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use thiserror::Error; + +/// The schema of the fingerprint documents this crate reads and writes. +pub const SCHEMA: &str = "openvmm-cpu-fingerprint/v1"; + +/// The index of `IA32_ARCH_CAPABILITIES`. +pub const IA32_ARCH_CAPABILITIES: u32 = 0x10a; + +/// An invalid fingerprint document. +#[derive(Debug, Error)] +pub enum FingerprintError { + /// The document is not valid JSON or does not match the schema's shape. + #[error("malformed CPU fingerprint")] + Json(#[source] serde_json::Error), + /// The document declares another schema. + #[error("unsupported CPU fingerprint schema {0:?}, expected {SCHEMA:?}")] + Schema(String), + /// A recorded digest does not match the content. + #[error("CPU fingerprint {field} mismatch: recorded {recorded}, computed {computed}")] + Digest { + /// The digest field. + field: &'static str, + /// The digest the document records. + recorded: String, + /// The digest of the document's content. + computed: String, + }, +} + +/// The tool that produced a fingerprint. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolIdentity { + /// The tool name, such as `openvmm`. + pub name: String, + /// The tool version, including its source revision when known. + pub version: String, +} + +/// A host CPU fingerprint for one hypervisor backend. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpuFingerprint { + /// The document schema, [`SCHEMA`]. + pub schema: String, + /// The tool that produced the fingerprint. + pub tool: ToolIdentity, + /// The host CPU, OS, and hypervisor. + pub host: HostIdentity, + /// The guest CPU surface and time capabilities of the backend. + pub backend: BackendFingerprint, + /// The digest of the guest CPU surface, see + /// [`BackendFingerprint::surface_digest`]. + pub surface_digest: String, + /// The digest of the whole document except this field. + pub digest: String, +} + +impl CpuFingerprint { + /// Assembles a fingerprint and computes its digests. + pub fn new(tool: ToolIdentity, host: HostIdentity, backend: BackendFingerprint) -> Self { + let mut this = Self { + schema: SCHEMA.to_owned(), + tool, + host, + backend, + surface_digest: String::new(), + digest: String::new(), + }; + this.surface_digest = this.backend.surface_digest(); + this.digest = this.content_digest(); + this + } + + /// Returns the fingerprint as pretty canonical JSON, with a final + /// newline. + pub fn to_json(&self) -> String { + canonical::to_pretty(&to_value(self)) + } + + /// Parses a fingerprint and verifies its schema and digests. + pub fn from_json(json: &str) -> Result { + let value: serde_json::Value = + serde_json::from_str(json).map_err(FingerprintError::Json)?; + let schema = value.get("schema").and_then(|schema| schema.as_str()); + if schema != Some(SCHEMA) { + return Err(FingerprintError::Schema( + schema.unwrap_or_default().to_owned(), + )); + } + let this: Self = serde_json::from_value(value).map_err(FingerprintError::Json)?; + this.verify()?; + Ok(this) + } + + /// Verifies that the recorded digests match the content. + pub fn verify(&self) -> Result<(), FingerprintError> { + let check = |field, recorded: &str, computed: String| { + if recorded == computed { + Ok(()) + } else { + Err(FingerprintError::Digest { + field, + recorded: recorded.to_owned(), + computed, + }) + } + }; + check( + "surface_digest", + &self.surface_digest, + self.backend.surface_digest(), + )?; + check("digest", &self.digest, self.content_digest()) + } + + fn content_digest(&self) -> String { + let mut value = to_value(self); + value + .as_object_mut() + .expect("a fingerprint serializes as an object") + .remove("digest"); + canonical::digest(&value) + } +} + +/// The guest CPU surface and time capabilities that one backend supports on +/// one host. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BackendFingerprint { + /// The backend: `kvm`, `mshv`, or `whp`. + pub name: String, + /// How the backend reported its CPUID table. + pub method: String, + /// The supported CPUID table, normalized by [`cpuid::normalize`]. + pub cpuid: Vec, + /// The XSAVE features and layout that the CPUID table describes. + pub xsave: XsaveInfo, + /// The feature MSRs and the MSRs the backend supports. + pub msrs: MsrInfo, + /// The time capabilities of the backend. + pub time: TimeCapabilities, + /// Processor feature banks, which together with the CPUID table define + /// the guest CPU surface of hypervisors configured through banks. + pub feature_banks: BTreeMap, + /// Other raw capability and property values reported by the backend. + pub values: BTreeMap, + /// Other CPUID tables the backend reports, such as the Hyper-V + /// enlightenments it can emulate. + pub extra_cpuid: BTreeMap>, + /// Backend queries that failed, with their errors. + pub unavailable: BTreeMap, +} + +impl BackendFingerprint { + /// Returns a backend fingerprint for the CPUID table `cpuid`, obtained + /// through `method`. + /// + /// The table is normalized, and the XSAVE information and the CPUID time + /// bits are derived from it. + pub fn new(name: &str, method: &str, mut cpuid: Vec) -> Self { + cpuid::normalize(&mut cpuid); + let xsave = XsaveInfo::from_cpuid(&cpuid); + let time = TimeCapabilities::from_cpuid(&cpuid); + Self { + name: name.to_owned(), + method: method.to_owned(), + cpuid, + xsave, + msrs: MsrInfo::default(), + time, + feature_banks: BTreeMap::new(), + values: BTreeMap::new(), + extra_cpuid: BTreeMap::new(), + unavailable: BTreeMap::new(), + } + } + + /// Records a raw capability or property value. + pub fn set_value(&mut self, name: impl Into, value: u64) { + self.values.insert(name.into(), Hex64(value)); + } + + /// Records a processor feature bank. + pub fn set_feature_bank(&mut self, name: impl Into, value: u64) { + self.feature_banks.insert(name.into(), Hex64(value)); + } + + /// Records another CPUID table, normalized. + pub fn set_extra_cpuid(&mut self, name: impl Into, mut entries: Vec) { + cpuid::normalize(&mut entries); + self.extra_cpuid.insert(name.into(), entries); + } + + /// Records that the query `name` failed with `error`. + pub fn set_unavailable( + &mut self, + name: impl Into, + error: &(dyn std::error::Error + 'static), + ) { + self.unavailable.insert(name.into(), error_chain(error)); + } + + /// Records the outcome of the query `name`: its value as + /// [`values`](Self::values) when it succeeded, and its error as + /// [`unavailable`](Self::unavailable) when it failed. + pub fn record( + &mut self, + name: &str, + result: Result, + ) -> Option { + match result { + Ok(value) => { + self.set_value(name, value); + Some(value) + } + Err(error) => { + self.set_unavailable(name, &error); + None + } + } + } + + /// Records the feature MSRs and their values, sorted by index, and + /// derives [`MsrInfo::arch_capabilities`] from them. + pub fn set_feature_msrs(&mut self, msrs: impl IntoIterator) { + let mut msrs = msrs + .into_iter() + .map(|(index, value)| MsrEntry { + index: Hex32(index), + value: Hex64(value), + }) + .collect::>(); + msrs.sort_by_key(|msr| msr.index); + msrs.dedup_by_key(|msr| msr.index); + self.msrs.arch_capabilities = msrs + .iter() + .find(|msr| msr.index.0 == IA32_ARCH_CAPABILITIES) + .map(|msr| msr.value); + self.msrs.feature_msrs = msrs; + } + + /// Records the MSRs the backend supports, sorted. + pub fn set_supported_msrs(&mut self, msrs: impl IntoIterator) { + let mut msrs = msrs.into_iter().map(Hex32).collect::>(); + msrs.sort_unstable(); + msrs.dedup(); + self.msrs.supported = msrs; + } + + /// Returns the digest of the guest CPU surface: the backend name, the + /// CPUID table, the XSAVE information, the feature MSRs, and the feature + /// banks. + pub fn surface_digest(&self) -> String { + #[derive(Serialize)] + struct Surface<'a> { + backend: &'a str, + cpuid: &'a [CpuidEntry], + xsave: &'a XsaveInfo, + feature_msrs: &'a [MsrEntry], + arch_capabilities: Option, + feature_banks: &'a BTreeMap, + } + + canonical::digest(&to_value(&Surface { + backend: &self.name, + cpuid: &self.cpuid, + xsave: &self.xsave, + feature_msrs: &self.msrs.feature_msrs, + arch_capabilities: self.msrs.arch_capabilities, + feature_banks: &self.feature_banks, + })) + } +} + +/// The XSAVE features and layout of a CPUID table. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct XsaveInfo { + /// The supported XCR0 bits, CPUID.(0xd,0):EDX:EAX. + pub xcr0_supported: Hex64, + /// The supported IA32_XSS bits, CPUID.(0xd,1):EDX:ECX. + pub xss_supported: Hex64, + /// The XSAVE instruction extensions, CPUID.(0xd,1):EAX: XSAVEOPT, + /// XSAVEC, XGETBV with ECX=1, XSAVES, and XFD. + pub extensions: Hex32, + /// The size of a standard-format area for every supported user state + /// component as the CPU reports it, CPUID.(0xd,0):ECX. + pub max_standard_size: u32, + /// The size of a standard-format area for every supported user state + /// component, computed from the component layout. + pub standard_size: u32, + /// The size of a compacted-format area for every supported state + /// component, computed from the component layout. + pub compacted_size: u32, + /// The supported state components beyond x87 and SSE. + pub components: Vec, +} + +impl XsaveInfo { + /// Derives the XSAVE information from a CPUID table. + pub fn from_cpuid(entries: &[CpuidEntry]) -> Self { + let (xcr0, xss) = cpuid::xsave_supported(entries); + let components = cpuid::xsave_components(entries); + let first = cpuid::lookup(entries, 0xd, 0).unwrap_or_default(); + let second = cpuid::lookup(entries, 0xd, 1).unwrap_or_default(); + Self { + xcr0_supported: Hex64(xcr0), + xss_supported: Hex64(xss), + extensions: Hex32(second[0]), + max_standard_size: first[2], + standard_size: cpuid::xsave_standard_size(&components, xcr0), + compacted_size: cpuid::xsave_compacted_size(&components, xcr0 | xss), + components, + } + } +} + +/// An MSR index and value. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MsrEntry { + /// The MSR index. + pub index: Hex32, + /// The MSR value. + pub value: Hex64, +} + +/// The MSRs of a backend. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MsrInfo { + /// The feature MSRs and the values the backend reports for guests, such + /// as the KVM MSR-based features, sorted by index. + pub feature_msrs: Vec, + /// The MSRs the backend can save and restore for a virtual processor, + /// sorted. + pub supported: Vec, + /// The `IA32_ARCH_CAPABILITIES` value exposed to guests, when the backend + /// reports one. + pub arch_capabilities: Option, +} + +/// The time capabilities of a backend. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TimeCapabilities { + /// The guest TSC rate, in Hz, that the backend reports. + pub tsc_frequency_hz: Option, + /// The local APIC timer rate, in Hz, that the backend reports. + pub lapic_timer_frequency_hz: Option, + /// The backend can expose an invariant TSC, `CPUID.0x80000007:EDX[8]`. + pub invariant_tsc: bool, + /// The backend can expose the TSC-deadline timer, `CPUID.1:ECX[24]`. + pub tsc_deadline: bool, + /// The backend can expose `IA32_TSC_ADJUST`, `CPUID.(7,0):EBX[1]`. + pub tsc_adjust: bool, + /// The backend can expose RDTSCP, `CPUID.0x80000001:EDX[27]`. + pub rdtscp: bool, + /// The backend can expose an always-running APIC timer, `CPUID.6:EAX[2]`. + pub arat: bool, + /// The backend can set each virtual processor's TSC (or TSC offset) + /// without trapping RDTSC. + pub tsc_offset_control: Option, + /// The backend can run a guest at a TSC rate other than the host's. + pub tsc_scaling: Option, + /// The backend can freeze partition time while it changes VP state. + pub time_freeze: Option, + /// The MSR interception mechanisms the backend offers, by name. + pub msr_intercepts: BTreeMap, +} + +impl TimeCapabilities { + /// Returns the capabilities that a CPUID table determines; the backend + /// fills in the others. + pub fn from_cpuid(entries: &[CpuidEntry]) -> Self { + Self { + invariant_tsc: cpuid::has_bit(entries, 0x8000_0007, 0, 3, 8), + tsc_deadline: cpuid::has_bit(entries, 0x1, 0, 2, 24), + tsc_adjust: cpuid::has_bit(entries, 0x7, 0, 1, 1), + rdtscp: cpuid::has_bit(entries, 0x8000_0001, 0, 3, 27), + arat: cpuid::has_bit(entries, 0x6, 0, 0, 2), + ..Default::default() + } + } +} + +fn to_value(value: &T) -> serde_json::Value { + // Every type here has string map keys and infallible field + // serialization. + serde_json::to_value(value).expect("fingerprint serialization is infallible") +} + +fn error_chain(error: &(dyn std::error::Error + 'static)) -> String { + let mut message = error.to_string(); + let mut source = error.source(); + while let Some(error) = source { + message.push_str(": "); + message.push_str(&error.to_string()); + source = error.source(); + } + message +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::host::HostCpu; + use crate::host::HostOs; + use test_with_tracing::test; + + fn host() -> HostIdentity { + HostIdentity { + cpu: HostCpu { + vendor: "GenuineIntel".to_owned(), + signature: Hex32(0x0005_0654), + family: 6, + model: 85, + stepping: 4, + brand: "Intel(R) Xeon(R) Silver 4114 CPU @ 2.20GHz".to_owned(), + microcode: vec!["0x2007006".to_owned()], + invariant_tsc: true, + tsc_deadline: true, + tsc_adjust: true, + }, + os: HostOs { + kind: "linux".to_owned(), + release: Some("7.0.0-30-generic".to_owned()), + version: None, + cpu_flags: vec!["constant_tsc".to_owned(), "nonstop_tsc".to_owned()], + clocksource: Some("tsc".to_owned()), + available_clocksources: vec!["hpet".to_owned(), "tsc".to_owned()], + }, + hypervisor: None, + } + } + + fn backend(cpuid_order_reversed: bool) -> BackendFingerprint { + let mut cpuid = vec![ + CpuidEntry::new(0x0, None, [0xd, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + // The initial APIC ID differs between runs. + CpuidEntry::new( + 0x1, + None, + [ + 0x0005_0654, + if cpuid_order_reversed { + 0x1f00_0800 + } else { + 0x0300_0800 + }, + 1 << 24, + 0, + ], + ), + CpuidEntry::new(0x7, Some(0), [0, 0x2, 0, 0]), + CpuidEntry::new(0xd, Some(0), [0x7, 0x240, 0x340, 0]), + CpuidEntry::new(0xd, Some(1), [0x1, 0x240, 0, 0]), + CpuidEntry::new(0xd, Some(2), [0x100, 0x240, 0, 0]), + CpuidEntry::new(0x8000_0000, None, [0x8000_0007, 0, 0, 0]), + CpuidEntry::new(0x8000_0007, None, [0, 0, 0, 1 << 8]), + ]; + if cpuid_order_reversed { + cpuid.reverse(); + } + let mut backend = BackendFingerprint::new("kvm", "KVM_GET_SUPPORTED_CPUID", cpuid); + backend.set_feature_msrs([ + (IA32_ARCH_CAPABILITIES, 0x2b), + (0x8b, 0x0200_7006_0000_0000), + ]); + backend.set_supported_msrs([0x3b, 0x10, 0x3b]); + backend.set_value("kvm.api_version", 12); + backend.time.tsc_frequency_hz = Some(2_194_843_000); + backend + .time + .msr_intercepts + .insert("KVM_CAP_X86_MSR_FILTER".to_owned(), true); + backend + } + + fn fingerprint() -> CpuFingerprint { + CpuFingerprint::new( + ToolIdentity { + name: "openvmm".to_owned(), + version: "0.2.0".to_owned(), + }, + host(), + backend(false), + ) + } + + #[test] + fn derives_backend_sections_from_cpuid() { + let backend = backend(false); + assert_eq!(backend.xsave.xcr0_supported, Hex64(0x7)); + assert_eq!(backend.xsave.standard_size, 0x340); + assert_eq!(backend.xsave.compacted_size, 576 + 0x100); + assert_eq!(backend.xsave.max_standard_size, 0x340); + assert!(backend.time.invariant_tsc); + assert!(backend.time.tsc_deadline); + assert!(backend.time.tsc_adjust); + assert!(!backend.time.rdtscp); + assert_eq!(backend.msrs.arch_capabilities, Some(Hex64(0x2b))); + assert_eq!(backend.msrs.feature_msrs[0].index, Hex32(0x8b)); + assert_eq!(backend.msrs.supported, [Hex32(0x10), Hex32(0x3b)]); + } + + #[test] + fn output_is_deterministic() { + let left = CpuFingerprint::new(fingerprint().tool, host(), backend(false)); + let right = CpuFingerprint::new(fingerprint().tool, host(), backend(true)); + assert_eq!(left.to_json(), right.to_json()); + assert_eq!(left.surface_digest, right.surface_digest); + assert!(left.digest.starts_with("sha256:")); + assert_eq!(left.digest.len(), "sha256:".len() + 64); + } + + #[test] + fn round_trips_and_verifies() { + let fingerprint = fingerprint(); + let json = fingerprint.to_json(); + let parsed = CpuFingerprint::from_json(&json).unwrap(); + assert_eq!(parsed, fingerprint); + assert_eq!(parsed.to_json(), json); + } + + #[test] + fn surface_digest_ignores_rates_and_versions() { + let mut other = backend(false); + other.time.tsc_frequency_hz = Some(2_194_844_000); + other.set_value("kvm.api_version", 13); + let mut other_host = host(); + other_host.os.release = Some("6.6.150".to_owned()); + let other = CpuFingerprint::new(fingerprint().tool, other_host, other); + let fingerprint = fingerprint(); + assert_eq!(other.surface_digest, fingerprint.surface_digest); + assert_ne!(other.digest, fingerprint.digest); + + let mut changed = backend(false); + changed.set_feature_bank("bank0", 1); + assert_ne!(changed.surface_digest(), backend(false).surface_digest()); + } + + #[test] + fn rejects_tampering_and_other_schemas() { + let json = fingerprint().to_json(); + + let tampered = json.replace("\"0x000000000000002b\"", "\"0x000000000000002f\""); + assert_ne!(tampered, json); + assert!(matches!( + CpuFingerprint::from_json(&tampered), + Err(FingerprintError::Digest { + field: "surface_digest", + .. + }) + )); + + let tampered = json.replace("7.0.0-30-generic", "7.0.0-31-generic"); + assert!(matches!( + CpuFingerprint::from_json(&tampered), + Err(FingerprintError::Digest { + field: "digest", + .. + }) + )); + + let other_schema = json.replace(SCHEMA, "openvmm-cpu-fingerprint/v2"); + assert!(matches!( + CpuFingerprint::from_json(&other_schema), + Err(FingerprintError::Schema(schema)) if schema == "openvmm-cpu-fingerprint/v2" + )); + assert!(matches!( + CpuFingerprint::from_json("[]"), + Err(FingerprintError::Schema(_)) + )); + let unknown_field = json.replacen('{', "{\"extra\": 1,", 1); + assert!(matches!( + CpuFingerprint::from_json(&unknown_field), + Err(FingerprintError::Json(_)) + )); + } + + #[test] + fn records_failed_queries() { + let mut backend = backend(false); + let error = std::io::Error::other("not supported"); + assert_eq!(backend.record("a", Ok::<_, std::io::Error>(5)), Some(5)); + assert_eq!(backend.record("b", Err::(error)), None); + assert_eq!(backend.values["a"], Hex64(5)); + assert_eq!(backend.unavailable["b"], "not supported"); + } +} diff --git a/vmm_core/cpu_profile/src/hex.rs b/vmm_core/cpu_profile/src/hex.rs new file mode 100644 index 00000000000..0c7e4378c52 --- /dev/null +++ b/vmm_core/cpu_profile/src/hex.rs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Fixed-width hexadecimal JSON encodings for register values. +//! +//! Register values and bit masks are encoded as `0x`-prefixed strings with a +//! fixed number of digits. Unlike JSON numbers, these round-trip 64-bit +//! values through tools that use double-precision numbers, and they keep +//! fingerprints readable and line-diffable. + +use serde::Deserialize; +use serde::Deserializer; +use serde::Serialize; +use serde::Serializer; +use std::fmt; +use thiserror::Error; + +/// A malformed hexadecimal register value. +#[derive(Debug, Error)] +#[error("invalid hexadecimal value {value:?}: expected 0x followed by up to {digits} hex digits")] +pub struct ParseHexError { + value: String, + digits: usize, +} + +fn parse_hex(value: &str, digits: usize) -> Result { + let error = || ParseHexError { + value: value.to_owned(), + digits, + }; + let hex = value.strip_prefix("0x").ok_or_else(error)?; + if hex.is_empty() || hex.len() > digits || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(error()); + } + u64::from_str_radix(hex, 16).map_err(|_| error()) +} + +/// A 32-bit register value, encoded as `0x` and eight hex digits. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Hex32(pub u32); + +impl Hex32 { + /// Parses `0x` followed by one to eight hex digits. + pub fn parse(value: &str) -> Result { + // At most eight digits were accepted, so the value fits. + Ok(Self(parse_hex(value, 8)? as u32)) + } +} + +impl fmt::Display for Hex32 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:#010x}", self.0) + } +} + +impl From for Hex32 { + fn from(value: u32) -> Self { + Self(value) + } +} + +impl From for u32 { + fn from(value: Hex32) -> Self { + value.0 + } +} + +impl Serialize for Hex32 { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Hex32 { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +/// A 64-bit register value, encoded as `0x` and sixteen hex digits. +#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Hex64(pub u64); + +impl Hex64 { + /// Parses `0x` followed by one to sixteen hex digits. + pub fn parse(value: &str) -> Result { + Ok(Self(parse_hex(value, 16)?)) + } +} + +impl fmt::Display for Hex64 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:#018x}", self.0) + } +} + +impl From for Hex64 { + fn from(value: u64) -> Self { + Self(value) + } +} + +impl From for u64 { + fn from(value: Hex64) -> Self { + value.0 + } +} + +impl Serialize for Hex64 { + fn serialize(&self, serializer: S) -> Result { + serializer.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for Hex64 { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + Self::parse(&value).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::Hex32; + use super::Hex64; + use test_with_tracing::test; + + #[test] + fn formats_fixed_width() { + assert_eq!(Hex32(0x1f).to_string(), "0x0000001f"); + assert_eq!(Hex32(u32::MAX).to_string(), "0xffffffff"); + assert_eq!(Hex64(0x10a).to_string(), "0x000000000000010a"); + assert_eq!(Hex64(u64::MAX).to_string(), "0xffffffffffffffff"); + } + + #[test] + fn round_trips_through_json() { + for value in [0, 1, 0x8000_0000, u32::MAX] { + let json = serde_json::to_string(&Hex32(value)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), Hex32(value)); + } + for value in [0, 1 << 53 | 1, u64::MAX] { + let json = serde_json::to_string(&Hex64(value)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), Hex64(value)); + } + } + + #[test] + fn rejects_malformed_values() { + for value in [ + "", + "0x", + "1f", + "0X1f", + "0x1g", + "0x123456789", + "-0x1", + "0x 1", + ] { + assert!(Hex32::parse(value).is_err(), "{value:?}"); + } + assert!(Hex64::parse("0x00000000000000001").is_err()); + assert!(serde_json::from_str::("31").is_err()); + } +} diff --git a/vmm_core/cpu_profile/src/host/linux.rs b/vmm_core/cpu_profile/src/host/linux.rs new file mode 100644 index 00000000000..7eed65f6c6a --- /dev/null +++ b/vmm_core/cpu_profile/src/host/linux.rs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Linux host OS information from procfs and sysfs. + +use super::HostOs; +use super::OsInfo; +use std::collections::BTreeSet; + +const CLOCKSOURCE_DIR: &str = "/sys/devices/system/clocksource/clocksource0"; + +fn read_trimmed(path: &str) -> Option { + std::fs::read_to_string(path) + .ok() + .map(|text| text.trim().to_owned()) + .filter(|text| !text.is_empty()) +} + +pub(super) fn os_info() -> OsInfo { + let cpuinfo = std::fs::read_to_string("/proc/cpuinfo").unwrap_or_default(); + let (cpu_flags, microcode) = parse_cpuinfo(&cpuinfo); + let mut available_clocksources = + read_trimmed(&format!("{CLOCKSOURCE_DIR}/available_clocksource")) + .map(|text| { + text.split_whitespace() + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(); + available_clocksources.sort(); + OsInfo { + os: Some(HostOs { + kind: std::env::consts::OS.to_owned(), + release: read_trimmed("/proc/sys/kernel/osrelease"), + version: read_trimmed("/proc/sys/kernel/version"), + cpu_flags, + clocksource: read_trimmed(&format!("{CLOCKSOURCE_DIR}/current_clocksource")), + available_clocksources, + }), + microcode, + } +} + +/// Returns the sorted CPU flags of the first processor in `/proc/cpuinfo`, +/// and the distinct microcode revisions of all processors. +fn parse_cpuinfo(cpuinfo: &str) -> (Vec, Vec) { + let mut flags = None; + let mut microcode = BTreeSet::new(); + for line in cpuinfo.lines() { + let Some((key, value)) = line.split_once(':') else { + continue; + }; + match key.trim() { + "flags" if flags.is_none() => { + flags = Some( + value + .split_whitespace() + .map(str::to_owned) + .collect::>(), + ); + } + "microcode" => { + microcode.insert(value.trim().to_ascii_lowercase()); + } + _ => {} + } + } + ( + flags.unwrap_or_default().into_iter().collect(), + microcode.into_iter().collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::parse_cpuinfo; + use test_with_tracing::test; + + #[test] + fn parses_flags_and_microcode() { + let cpuinfo = "\ +processor\t: 0 +vendor_id\t: GenuineIntel +microcode\t: 0x2007006 +flags\t\t: fpu tsc nonstop_tsc constant_tsc tsc +bugs\t\t: spectre_v1 + +processor\t: 1 +microcode\t: 0x2007006 +flags\t\t: fpu tsc +"; + let (flags, microcode) = parse_cpuinfo(cpuinfo); + assert_eq!(flags, ["constant_tsc", "fpu", "nonstop_tsc", "tsc"]); + assert_eq!(microcode, ["0x2007006"]); + + let (flags, microcode) = parse_cpuinfo("microcode : 0xFFFFFFFF\nmicrocode : 0x1\n"); + assert!(flags.is_empty()); + assert_eq!(microcode, ["0x1", "0xffffffff"]); + } +} diff --git a/vmm_core/cpu_profile/src/host/mod.rs b/vmm_core/cpu_profile/src/host/mod.rs new file mode 100644 index 00000000000..25cde77796e --- /dev/null +++ b/vmm_core/cpu_profile/src/host/mod.rs @@ -0,0 +1,353 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The identity of the host: its CPU, its OS, and the hypervisor that the +//! host OS itself runs under, if any. +//! +//! The host CPU identity comes from the CPUID instruction as the host OS sees +//! it, which on a root partition or a nested host is itself virtualized. Only +//! fields that do not depend on the executing processor are recorded. + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(windows)] +mod windows; + +use crate::Hex32; +use serde::Deserialize; +use serde::Serialize; +use thiserror::Error; + +/// A failure to identify the host. +#[derive(Debug, Error)] +pub enum HostIdentityError { + /// The host is not an x86-64 machine. + #[error("host CPU identification requires an x86-64 host")] + UnsupportedArchitecture, +} + +/// The identity of the host. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostIdentity { + /// The host CPU. + pub cpu: HostCpu, + /// The host OS. + pub os: HostOs, + /// The hypervisor that the host OS runs under: the hypervisor of a root + /// partition, or the outer hypervisor of a nested host. + pub hypervisor: Option, +} + +/// The host CPU. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostCpu { + /// The vendor string of CPUID leaf 0, such as `GenuineIntel`. + pub vendor: String, + /// The processor signature, CPUID.1:EAX. + pub signature: Hex32, + /// The display family decoded from the signature. + pub family: u32, + /// The display model decoded from the signature. + pub model: u32, + /// The stepping decoded from the signature. + pub stepping: u32, + /// The brand string of CPUID leaves 0x80000002 through 0x80000004. + pub brand: String, + /// The microcode revisions that the host OS reports, in lowercase hex. + /// More than one value means that the processors disagree. It is empty + /// when the OS does not report a revision. + pub microcode: Vec, + /// The host OS sees an invariant TSC, `CPUID.0x80000007:EDX[8]`. + pub invariant_tsc: bool, + /// The host OS sees the TSC-deadline timer, `CPUID.1:ECX[24]`. + pub tsc_deadline: bool, + /// The host OS sees `IA32_TSC_ADJUST`, `CPUID.(7,0):EBX[1]`. + pub tsc_adjust: bool, +} + +/// The host OS. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostOs { + /// The OS family, such as `linux` or `windows`. + pub kind: String, + /// The OS release: the kernel release on Linux, and + /// `major.minor.build.revision` on Windows. + pub release: Option, + /// The OS version: the kernel version string on Linux, and the product + /// name and display version on Windows. + pub version: Option, + /// The CPU flags that the Linux kernel reports for its first processor, + /// sorted. + pub cpu_flags: Vec, + /// The current Linux clocksource. + pub clocksource: Option, + /// The available Linux clocksources, sorted. + pub available_clocksources: Vec, +} + +/// The hypervisor that the host OS runs under. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HostHypervisor { + /// The vendor signature of CPUID leaf 0x40000000, such as `Microsoft Hv`. + pub vendor: String, + /// The maximum hypervisor leaf, CPUID.0x40000000:EAX. + pub max_leaf: Hex32, + /// The interface signature of CPUID leaf 0x40000001, such as `Hv#1`. + pub interface: String, + /// For the `Hv#1` interface, the hypervisor version of CPUID leaf + /// 0x40000002 as `major.minor.build.service`. + pub version: Option, +} + +/// The information that the host OS reports about itself and the CPU. +#[derive(Default)] +struct OsInfo { + os: Option, + microcode: Vec, +} + +impl HostIdentity { + /// Identifies the host this process runs on. + pub fn collect() -> Result { + let mut cpuid = host_cpuid()?; + let info = os_info(); + let (cpu, hypervisor) = identify_cpu(&mut cpuid, info.microcode); + let os = info.os.unwrap_or_else(|| HostOs { + kind: std::env::consts::OS.to_owned(), + release: None, + version: None, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }); + Ok(Self { + cpu, + os, + hypervisor, + }) + } +} + +// The host CPU is identified with the CPUID instruction of the host, which +// exists only on x86-64 hosts. +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(target_arch = "x86_64")] +fn host_cpuid() -> Result [u32; 4], HostIdentityError> { + Ok(|leaf, subleaf| { + let result = safe_intrinsics::cpuid(leaf, subleaf); + [result.eax, result.ebx, result.ecx, result.edx] + }) +} + +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(not(target_arch = "x86_64"))] +fn host_cpuid() -> Result [u32; 4], HostIdentityError> { + Err(HostIdentityError::UnsupportedArchitecture) +} + +#[cfg(target_os = "linux")] +fn os_info() -> OsInfo { + linux::os_info() +} + +#[cfg(windows)] +fn os_info() -> OsInfo { + windows::os_info() +} + +#[cfg(not(any(target_os = "linux", windows)))] +fn os_info() -> OsInfo { + OsInfo::default() +} + +fn signature_string(registers: &[u32]) -> String { + let bytes = registers + .iter() + .flat_map(|register| register.to_le_bytes()) + .collect::>(); + String::from_utf8_lossy(&bytes) + .trim_end_matches('\0') + .trim() + .to_owned() +} + +/// Identifies the CPU, and the hypervisor the host runs under, from the +/// CPUID results of `cpuid`. +fn identify_cpu( + cpuid: &mut dyn FnMut(u32, u32) -> [u32; 4], + microcode: Vec, +) -> (HostCpu, Option) { + let [max_basic, ebx, ecx, edx] = cpuid(0, 0); + let vendor = signature_string(&[ebx, edx, ecx]); + let [signature, _, features_ecx, _] = if max_basic >= 1 { cpuid(1, 0) } else { [0; 4] }; + let extended_features_ebx = if max_basic >= 7 { cpuid(7, 0)[1] } else { 0 }; + let max_extended = cpuid(0x8000_0000, 0)[0]; + let brand = if max_extended >= 0x8000_0004 { + signature_string( + &(0x8000_0002..=0x8000_0004) + .flat_map(|leaf| cpuid(leaf, 0)) + .collect::>(), + ) + } else { + String::new() + }; + let invariant_tsc = max_extended >= 0x8000_0007 && cpuid(0x8000_0007, 0)[3] & (1 << 8) != 0; + + let stepping = signature & 0xf; + let base_model = (signature >> 4) & 0xf; + let base_family = (signature >> 8) & 0xf; + let extended_model = (signature >> 16) & 0xf; + let extended_family = (signature >> 20) & 0xff; + let family = if base_family == 0xf { + base_family + extended_family + } else { + base_family + }; + // Intel extends the model for families 6 and 15; AMD only for family 15. + let extends_model = base_family == 0xf || (base_family == 0x6 && vendor == "GenuineIntel"); + let model = if extends_model { + (extended_model << 4) | base_model + } else { + base_model + }; + + let cpu = HostCpu { + vendor, + signature: Hex32(signature), + family, + model, + stepping, + brand, + microcode, + invariant_tsc, + tsc_deadline: features_ecx & (1 << 24) != 0, + tsc_adjust: extended_features_ebx & (1 << 1) != 0, + }; + + let hypervisor = (features_ecx & (1 << 31) != 0).then(|| { + let [max_leaf, ebx, ecx, edx] = cpuid(0x4000_0000, 0); + let interface = if max_leaf >= 0x4000_0001 { + signature_string(&[cpuid(0x4000_0001, 0)[0]]) + } else { + String::new() + }; + let version = (interface == "Hv#1" && max_leaf >= 0x4000_0002).then(|| { + let [build, major_minor, _, service] = cpuid(0x4000_0002, 0); + format!( + "{}.{}.{}.{}", + major_minor >> 16, + major_minor & 0xffff, + build, + service & 0xff_ffff + ) + }); + HostHypervisor { + vendor: signature_string(&[ebx, ecx, edx]), + max_leaf: Hex32(max_leaf), + interface, + version, + } + }); + + (cpu, hypervisor) +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + fn word(text: &[u8; 4]) -> u32 { + u32::from_le_bytes(*text) + } + + /// CPUID of a Skylake-SP root partition under Hyper-V. + fn skylake_root(leaf: u32, _subleaf: u32) -> [u32; 4] { + let brand = b"Intel(R) Xeon(R) Silver 4114 CPU @ 2.20GHz\0\0\0\0\0\0"; + let brand_word = |i: usize| u32::from_le_bytes(brand[i * 4..i * 4 + 4].try_into().unwrap()); + match leaf { + 0 => [0x16, word(b"Genu"), word(b"ntel"), word(b"ineI")], + 1 => [ + 0x0005_0654, + 0x0b20_0800, + 0xfefa_3203 | (1 << 31), + 0x1f8b_fbff, + ], + 7 => [0, 0x009c_6fbb, 0, 0], + 0x4000_0000 => [0x4000_000c, word(b"Micr"), word(b"osof"), word(b"t Hv")], + 0x4000_0001 => [word(b"Hv#1"), 0, 0, 0], + 0x4000_0002 => [26100, 0x000a_0000, 0, 0x0100_1234], + 0x8000_0000 => [0x8000_0008, 0, 0, 0], + 0x8000_0002..=0x8000_0004 => { + let base = (leaf - 0x8000_0002) as usize * 4; + [ + brand_word(base), + brand_word(base + 1), + brand_word(base + 2), + brand_word(base + 3), + ] + } + 0x8000_0007 => [0, 0, 0, 1 << 8], + _ => [0; 4], + } + } + + #[test] + fn identifies_intel_root_partition() { + let (cpu, hypervisor) = identify_cpu(&mut skylake_root, vec!["0x2007006".to_owned()]); + assert_eq!(cpu.vendor, "GenuineIntel"); + assert_eq!((cpu.family, cpu.model, cpu.stepping), (6, 85, 4)); + assert_eq!(cpu.brand, "Intel(R) Xeon(R) Silver 4114 CPU @ 2.20GHz"); + assert_eq!(cpu.microcode, ["0x2007006"]); + assert!(cpu.invariant_tsc); + assert!(!cpu.tsc_deadline); + assert!(cpu.tsc_adjust); + + let hypervisor = hypervisor.unwrap(); + assert_eq!(hypervisor.vendor, "Microsoft Hv"); + assert_eq!(hypervisor.interface, "Hv#1"); + assert_eq!(hypervisor.max_leaf, Hex32(0x4000_000c)); + assert_eq!(hypervisor.version.as_deref(), Some("10.0.26100.4660")); + } + + #[test] + fn decodes_extended_families_and_models() { + let mut emerald_rapids = |leaf: u32, _: u32| -> [u32; 4] { + match leaf { + 0 => [1, word(b"Genu"), word(b"ntel"), word(b"ineI")], + 1 => [0x000c_06f2, 0, 0, 0], + _ => [0; 4], + } + }; + let (cpu, hypervisor) = identify_cpu(&mut emerald_rapids, Vec::new()); + assert_eq!((cpu.family, cpu.model, cpu.stepping), (6, 207, 2)); + assert!(hypervisor.is_none()); + assert!(cpu.brand.is_empty()); + + let mut zen4 = |leaf: u32, _: u32| -> [u32; 4] { + match leaf { + 0 => [1, word(b"Auth"), word(b"cAMD"), word(b"enti")], + 1 => [0x00a1_0f11, 0, 0, 0], + _ => [0; 4], + } + }; + let (cpu, _) = identify_cpu(&mut zen4, Vec::new()); + assert_eq!(cpu.vendor, "AuthenticAMD"); + assert_eq!((cpu.family, cpu.model, cpu.stepping), (0x19, 0x11, 1)); + } + + #[test] + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + fn collects_this_host() { + let identity = HostIdentity::collect().unwrap(); + assert!(!identity.cpu.vendor.is_empty()); + assert_eq!(identity.os.kind, std::env::consts::OS); + // Collection is deterministic. + assert_eq!(HostIdentity::collect().unwrap(), identity); + } +} diff --git a/vmm_core/cpu_profile/src/host/windows.rs b/vmm_core/cpu_profile/src/host/windows.rs new file mode 100644 index 00000000000..376cf3fcd89 --- /dev/null +++ b/vmm_core/cpu_profile/src/host/windows.rs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Windows host OS information from the registry. + +// UNSAFETY: Calling the Win32 registry API. +#![expect(unsafe_code)] + +use super::HostOs; +use super::OsInfo; +use windows_sys::Win32::Foundation::ERROR_SUCCESS; +use windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE; +use windows_sys::Win32::System::Registry::RRF_RT_REG_BINARY; +use windows_sys::Win32::System::Registry::RRF_RT_REG_DWORD; +use windows_sys::Win32::System::Registry::RRF_RT_REG_SZ; +use windows_sys::Win32::System::Registry::RegGetValueW; + +const CURRENT_VERSION_KEY: &str = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion"; +const PROCESSOR0_KEY: &str = r"HARDWARE\DESCRIPTION\System\CentralProcessor\0"; + +fn wide(text: &str) -> Vec { + text.encode_utf16().chain(Some(0)).collect() +} + +/// Reads the value `value` of the `HKEY_LOCAL_MACHINE` subkey `subkey`, if it +/// exists and has a type that `flags` allows. +fn read_value(subkey: &str, value: &str, flags: u32) -> Option> { + let subkey = wide(subkey); + let value = wide(value); + let mut len = 0_u32; + // SAFETY: The key and value names are NUL-terminated UTF-16 strings that + // outlive the call, and a null data pointer queries only the size. + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + flags, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut len, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + let mut data = vec![0_u8; len as usize]; + // SAFETY: `data` is writable for `len` bytes, and the names are as + // above. + let status = unsafe { + RegGetValueW( + HKEY_LOCAL_MACHINE, + subkey.as_ptr(), + value.as_ptr(), + flags, + std::ptr::null_mut(), + data.as_mut_ptr().cast(), + &mut len, + ) + }; + if status != ERROR_SUCCESS { + return None; + } + data.truncate(len as usize); + Some(data) +} + +fn read_string(subkey: &str, value: &str) -> Option { + let data = read_value(subkey, value, RRF_RT_REG_SZ)?; + let wide = data + .as_chunks::<2>() + .0 + .iter() + .map(|&pair| u16::from_le_bytes(pair)) + .take_while(|&unit| unit != 0) + .collect::>(); + let text = String::from_utf16_lossy(&wide).trim().to_owned(); + (!text.is_empty()).then_some(text) +} + +fn read_dword(subkey: &str, value: &str) -> Option { + let data = read_value(subkey, value, RRF_RT_REG_DWORD)?; + Some(u32::from_le_bytes(data.get(..4)?.try_into().ok()?)) +} + +pub(super) fn os_info() -> OsInfo { + let major = read_dword(CURRENT_VERSION_KEY, "CurrentMajorVersionNumber"); + let minor = read_dword(CURRENT_VERSION_KEY, "CurrentMinorVersionNumber"); + let build = read_string(CURRENT_VERSION_KEY, "CurrentBuildNumber"); + let revision = read_dword(CURRENT_VERSION_KEY, "UBR"); + let release = match (major, minor, build) { + (Some(major), Some(minor), Some(build)) => Some(match revision { + Some(revision) => format!("{major}.{minor}.{build}.{revision}"), + None => format!("{major}.{minor}.{build}"), + }), + _ => None, + }; + let version = match ( + read_string(CURRENT_VERSION_KEY, "ProductName"), + read_string(CURRENT_VERSION_KEY, "DisplayVersion"), + ) { + (Some(product), Some(display)) => Some(format!("{product} {display}")), + (product, display) => product.or(display), + }; + let microcode = read_value(PROCESSOR0_KEY, "Update Revision", RRF_RT_REG_BINARY) + .and_then(|data| microcode_revision(&data)) + .into_iter() + .collect(); + OsInfo { + os: Some(HostOs { + kind: std::env::consts::OS.to_owned(), + release, + version, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }), + microcode, + } +} + +/// Decodes the processor's `Update Revision` registry value. Older Windows +/// releases store the 64-bit microcode signature MSR, with the Intel revision +/// in the high half and the AMD revision in the low half; newer releases +/// store the 32-bit revision itself. +fn microcode_revision(data: &[u8]) -> Option { + let revision = match data.len() { + 4 => u64::from(u32::from_le_bytes(data.try_into().ok()?)), + 8 => { + let signature = u64::from_le_bytes(data.try_into().ok()?); + match signature >> 32 { + 0 => signature & 0xffff_ffff, + high => high, + } + } + _ => return None, + }; + Some(format!("{revision:#x}")) +} + +#[cfg(test)] +mod tests { + use super::microcode_revision; + use test_with_tracing::test; + + #[test] + fn decodes_microcode_revision() { + let intel = 0x0200_7006_0000_0000_u64.to_le_bytes(); + assert_eq!(microcode_revision(&intel).as_deref(), Some("0x2007006")); + let amd = 0x0a10_1148_u64.to_le_bytes(); + assert_eq!(microcode_revision(&amd).as_deref(), Some("0xa101148")); + assert_eq!( + microcode_revision(&[0x3b, 0x04, 0, 0]).as_deref(), + Some("0x43b") + ); + assert_eq!(microcode_revision(&[1, 2, 3]), None); + assert_eq!(microcode_revision(&[0; 9]), None); + } +} diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs new file mode 100644 index 00000000000..eabaf33e659 --- /dev/null +++ b/vmm_core/cpu_profile/src/lib.rs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! x86 CPU fingerprints, the input to pinned guest CPU profiles. +//! +//! A [`CpuFingerprint`](fingerprint::CpuFingerprint) records the guest CPU +//! surface that one hypervisor backend supports on one host: every CPUID leaf +//! and subleaf that a guest could be given, the XSAVE features and layout, the +//! feature MSRs, and the time capabilities that the NVX time ABI depends on, +//! together with the identity of the host CPU and OS. +//! +//! Fingerprints are emitted as deterministic, canonical JSON with SHA-256 +//! digests, so that the fingerprints of a fleet of hosts can be compared and +//! intersected into per-generation CPU profiles. This follows the dump, strip, +//! and verify workflow of Firecracker's `cpu-template-helper`. + +pub mod cpuid; +pub mod fingerprint; +pub mod host; + +mod canonical; +mod hex; + +pub use hex::Hex32; +pub use hex::Hex64; +pub use hex::ParseHexError; diff --git a/vmm_core/virt_kvm/Cargo.toml b/vmm_core/virt_kvm/Cargo.toml index 691447bcf80..28d2745f77c 100644 --- a/vmm_core/virt_kvm/Cargo.toml +++ b/vmm_core/virt_kvm/Cargo.toml @@ -9,6 +9,7 @@ rust-version.workspace = true [target.'cfg(target_os = "linux")'.dependencies] virt.workspace = true +cpu_profile.workspace = true hv1_emulator.workspace = true hv1_hypercall.workspace = true hvdef.workspace = true diff --git a/vmm_core/virt_kvm/src/arch/x86_64/fingerprint.rs b/vmm_core/virt_kvm/src/arch/x86_64/fingerprint.rs new file mode 100644 index 00000000000..211187a22f7 --- /dev/null +++ b/vmm_core/virt_kvm/src/arch/x86_64/fingerprint.rs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU fingerprint of the KVM backend: the guest CPU surface and time +//! capabilities that KVM supports on this host. + +use super::Kvm; +use crate::KvmError; +use cpu_profile::cpuid::CpuidEntry; +use cpu_profile::fingerprint::BackendFingerprint; +use kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX; +use std::collections::BTreeMap; + +const METHOD: &str = "KVM_GET_SUPPORTED_CPUID, without permission for dynamically enabled \ + XSAVE features"; + +/// KVM's APIC bus cycle before `KVM_CAP_X86_APIC_BUS_CYCLES_NS` made it +/// configurable. +const DEFAULT_APIC_BUS_CYCLE_NS: u64 = 1; + +/// The KVM capabilities recorded in the fingerprint. +const CAPABILITIES: &[(&str, u32)] = &[ + ("KVM_CAP_ADJUST_CLOCK", kvm::KVM_CAP_ADJUST_CLOCK), + ("KVM_CAP_EXT_CPUID", kvm::KVM_CAP_EXT_CPUID), + ("KVM_CAP_GET_MSR_FEATURES", kvm::KVM_CAP_GET_MSR_FEATURES), + ("KVM_CAP_GET_TSC_KHZ", kvm::KVM_CAP_GET_TSC_KHZ), + ("KVM_CAP_HYPERV", kvm::KVM_CAP_HYPERV), + ( + "KVM_CAP_HYPERV_ENFORCE_CPUID", + kvm::KVM_CAP_HYPERV_ENFORCE_CPUID, + ), + ("KVM_CAP_HYPERV_SYNIC2", kvm::KVM_CAP_HYPERV_SYNIC2), + ("KVM_CAP_HYPERV_TIME", kvm::KVM_CAP_HYPERV_TIME), + ("KVM_CAP_KVMCLOCK_CTRL", kvm::KVM_CAP_KVMCLOCK_CTRL), + ("KVM_CAP_NESTED_STATE", kvm::KVM_CAP_NESTED_STATE), + ("KVM_CAP_PMU_CAPABILITY", kvm::KVM_CAP_PMU_CAPABILITY), + ("KVM_CAP_SPLIT_IRQCHIP", kvm::KVM_CAP_SPLIT_IRQCHIP), + ("KVM_CAP_SYS_ATTRIBUTES", kvm::KVM_CAP_SYS_ATTRIBUTES), + ("KVM_CAP_TSC_CONTROL", kvm::KVM_CAP_TSC_CONTROL), + ( + "KVM_CAP_TSC_DEADLINE_TIMER", + kvm::KVM_CAP_TSC_DEADLINE_TIMER, + ), + ("KVM_CAP_VCPU_ATTRIBUTES", kvm::KVM_CAP_VCPU_ATTRIBUTES), + ("KVM_CAP_VM_TSC_CONTROL", kvm::KVM_CAP_VM_TSC_CONTROL), + ("KVM_CAP_X2APIC_API", kvm::KVM_CAP_X2APIC_API), + ( + "KVM_CAP_X86_APIC_BUS_CYCLES_NS", + kvm::KVM_CAP_X86_APIC_BUS_CYCLES_NS, + ), + ("KVM_CAP_X86_DISABLE_EXITS", kvm::KVM_CAP_X86_DISABLE_EXITS), + ("KVM_CAP_X86_MSR_FILTER", kvm::KVM_CAP_X86_MSR_FILTER), + ("KVM_CAP_X86_NOTIFY_VMEXIT", kvm::KVM_CAP_X86_NOTIFY_VMEXIT), + ( + "KVM_CAP_X86_USER_SPACE_MSR", + kvm::KVM_CAP_X86_USER_SPACE_MSR, + ), + ("KVM_CAP_XCRS", kvm::KVM_CAP_XCRS), + ("KVM_CAP_XSAVE", kvm::KVM_CAP_XSAVE), + ("KVM_CAP_XSAVE2", kvm::KVM_CAP_XSAVE2), +]; + +/// The capabilities through which KVM routes guest MSR accesses to the VMM. +const MSR_INTERCEPT_CAPABILITIES: &[&str] = + &["KVM_CAP_X86_USER_SPACE_MSR", "KVM_CAP_X86_MSR_FILTER"]; + +fn cpuid_entry(entry: &kvm::kvm_cpuid_entry2) -> CpuidEntry { + CpuidEntry::new( + entry.function, + (entry.flags & KVM_CPUID_FLAG_SIGNIFCANT_INDEX != 0).then_some(entry.index), + [entry.eax, entry.ebx, entry.ecx, entry.edx], + ) +} + +impl Kvm { + /// Returns the guest CPU surface and time capabilities that KVM supports + /// on this host. + /// + /// The CPUID table is `KVM_GET_SUPPORTED_CPUID`. Like OpenVMM's own + /// partitions, the fingerprint does not request permission for + /// dynamically enabled XSAVE features such as AMX tile data, so KVM omits + /// them; `kvm.xcomp_guest_supp` records what KVM could support with + /// permission. The feature MSRs are KVM's MSR-based features. The TSC + /// rate and the APIC bus cycle come from a transient probe VM without + /// vCPUs. TSC offset control is `KVM_CAP_VCPU_ATTRIBUTES`, which x86 KVM + /// reports together with the `KVM_VCPU_TSC_OFFSET` attribute (Linux + /// 5.16 and later). + pub fn cpu_fingerprint(&self) -> Result { + let cpuid = self + .kvm + .supported_cpuid()? + .iter() + .map(cpuid_entry) + .collect(); + let mut fingerprint = BackendFingerprint::new("kvm", METHOD, cpuid); + + fingerprint.record( + "kvm.api_version", + self.kvm.api_version().map(|version| version as u64), + ); + let mut capabilities = BTreeMap::new(); + for &(name, capability) in CAPABILITIES { + if let Some(value) = fingerprint.record( + &format!("kvm.cap.{name}"), + self.kvm + .check_extension(capability) + .map(|value| value as u64), + ) { + capabilities.insert(name, value); + } + } + let has_capability = |name| capabilities.get(name).is_some_and(|&value| value != 0); + + match self.kvm.msr_feature_index_list() { + Ok(indices) => { + let mut msrs = Vec::with_capacity(indices.len()); + for index in indices { + match self.kvm.feature_msr(index) { + Ok(value) => msrs.push((index, value)), + Err(error) => fingerprint + .set_unavailable(format!("kvm.feature_msr.{index:#010x}"), &error), + } + } + fingerprint.set_feature_msrs(msrs); + } + Err(error) => fingerprint.set_unavailable("kvm.msr_feature_index_list", &error), + } + match self.kvm.msr_index_list() { + Ok(indices) => fingerprint.set_supported_msrs(indices), + Err(error) => fingerprint.set_unavailable("kvm.msr_index_list", &error), + } + fingerprint.record("kvm.xcomp_guest_supp", self.kvm.xsave_guest_supported()); + fingerprint.record("kvm.mce_cap_supported", self.kvm.supported_mce_cap()); + match self.kvm.supported_hv_cpuid() { + Ok(entries) => fingerprint.set_extra_cpuid( + "kvm.supported_hv_cpuid", + entries.iter().map(cpuid_entry).collect(), + ), + Err(error) => fingerprint.set_unavailable("kvm.supported_hv_cpuid", &error), + } + + let vm = self.kvm.new_vm(kvm::VmType::Default)?; + let tsc_frequency_hz = fingerprint.record("kvm.vm.tsc_frequency_hz", vm.tsc_frequency_hz()); + let apic_bus_cycle_ns = fingerprint.record( + "kvm.vm.cap.KVM_CAP_X86_APIC_BUS_CYCLES_NS", + vm.check_extension(kvm::KVM_CAP_X86_APIC_BUS_CYCLES_NS) + .map(|value| value as u64), + ); + drop(vm); + + let time = &mut fingerprint.time; + time.tsc_frequency_hz = tsc_frequency_hz; + // Zero means that the APIC bus cycle is not configurable and has its + // default length. + time.lapic_timer_frequency_hz = Some( + 1_000_000_000 + / apic_bus_cycle_ns + .filter(|&ns| ns != 0) + .unwrap_or(DEFAULT_APIC_BUS_CYCLE_NS), + ); + // KVM emulates the TSC-deadline timer, but kernels before 6.14 do not + // report it in the supported CPUID. + time.tsc_deadline |= has_capability("KVM_CAP_TSC_DEADLINE_TIMER"); + time.tsc_offset_control = Some(has_capability("KVM_CAP_VCPU_ATTRIBUTES")); + time.tsc_scaling = Some(has_capability("KVM_CAP_TSC_CONTROL")); + for &name in MSR_INTERCEPT_CAPABILITIES { + time.msr_intercepts + .insert(name.to_owned(), has_capability(name)); + } + Ok(fingerprint) + } +} + +#[cfg(test)] +mod tests { + use super::Kvm; + use cpu_profile::Hex32; + use cpu_profile::cpuid; + use test_with_tracing::test; + + #[test] + #[ignore = "requires /dev/kvm"] + fn fingerprint_reports_the_supported_surface() { + let kvm = Kvm::new().unwrap(); + let fingerprint = kvm.cpu_fingerprint().unwrap(); + tracing::info!(unavailable = ?fingerprint.unavailable, "KVM fingerprint"); + assert_eq!(fingerprint, kvm.cpu_fingerprint().unwrap()); + assert!(cpuid::lookup(&fingerprint.cpuid, 0, 0).is_some()); + assert_eq!(fingerprint.xsave.xcr0_supported.0 & 0x3, 0x3); + assert!(fingerprint.time.tsc_frequency_hz.is_some()); + assert_eq!( + fingerprint.time.lapic_timer_frequency_hz, + Some(1_000_000_000) + ); + assert!( + fingerprint + .msrs + .supported + .contains(&Hex32(x86defs::X86X_MSR_TSC)) + ); + } +} diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index bffa164413b..e023d6d436c 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -6,6 +6,7 @@ #![cfg(all(target_os = "linux", guest_arch = "x86_64"))] mod cpu_contract; +mod fingerprint; mod regs; pub(crate) mod snp; mod time_abi; diff --git a/vmm_core/virt_mshv/Cargo.toml b/vmm_core/virt_mshv/Cargo.toml index bcad871a48b..80724fb5814 100644 --- a/vmm_core/virt_mshv/Cargo.toml +++ b/vmm_core/virt_mshv/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [target.'cfg(target_os = "linux")'.dependencies] aarch64defs.workspace = true +cpu_profile.workspace = true headervec.workspace = true hv1_emulator.workspace = true hv1_hypercall.workspace = true diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 6abb8b1b9b6..3ce2b05adee 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -867,6 +867,12 @@ enum ErrorInner { #[cfg(guest_arch = "x86_64")] #[error(transparent)] TimeAbi(#[from] virt::time_abi::TimeAbiError), + #[cfg(guest_arch = "x86_64")] + #[error("failed to get host partition property {0:?}")] + GetHostPartitionProperty(HvPartitionPropertyCode, #[source] KernelError), + #[cfg(guest_arch = "x86_64")] + #[error("failed to query the CPUID values of the fingerprint probe partition")] + FingerprintCpuid(#[source] KernelError), } /// Equivalent to [`MshvError`] but has a much better error message. diff --git a/vmm_core/virt_mshv/src/x86_64/fingerprint.rs b/vmm_core/virt_mshv/src/x86_64/fingerprint.rs new file mode 100644 index 00000000000..a98ad86e3ca --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/fingerprint.rs @@ -0,0 +1,235 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU fingerprint of the MSHV backend: the guest CPU surface and time +//! capabilities that the Microsoft hypervisor supports on this host. + +use crate::Error; +use crate::ErrorInner; +use crate::KernelError; +use crate::LinuxMshv; +use crate::VcpuFdExt; +use crate::create_vm_with_retry; +use cpu_profile::fingerprint::BackendFingerprint; +use hvdef::HvPartitionPropertyCode; +use hvdef::HvX64RegisterName; +use hvdef::hypercall::HvRegisterAssoc; +use mshv_ioctls::VcpuFd; +use mshv_ioctls::VmFd; + +const METHOD: &str = "HvCallGetVpCpuidValues on a probe partition that enables every processor \ + and XSAVE feature of the host partition"; + +/// The host partition properties that the probe partition enables, which +/// together with its CPUID define the guest CPU surface. +const FEATURE_BANKS: [HvPartitionPropertyCode; 3] = [ + HvPartitionPropertyCode::ProcessorFeatures0, + HvPartitionPropertyCode::ProcessorFeatures1, + HvPartitionPropertyCode::ProcessorXsaveFeatures, +]; + +/// Other host partition properties recorded in the fingerprint. +const HOST_PROPERTIES: &[HvPartitionPropertyCode] = &[ + HvPartitionPropertyCode::ProcessorVendor, + HvPartitionPropertyCode::ProcessorClockFrequency, + HvPartitionPropertyCode::ProcessorCLFlushSize, + HvPartitionPropertyCode::PhysicalAddressWidth, + HvPartitionPropertyCode::XsaveStates, + HvPartitionPropertyCode::MaxXsaveDataSize, + HvPartitionPropertyCode::FeatureBankCount, + HvPartitionPropertyCode::RootProcessorFeatures0, + HvPartitionPropertyCode::RootProcessorFeatures1, + HvPartitionPropertyCode::RootProcessorXsaveFeatures, + HvPartitionPropertyCode::RootSyntheticProcFeatures, + HvPartitionPropertyCode::PrivilegeFlags, + HvPartitionPropertyCode::SyntheticProcFeatures, +]; + +/// Properties of the probe partition recorded in the fingerprint. +const PROBE_PROPERTIES: &[HvPartitionPropertyCode] = &[ + HvPartitionPropertyCode::ProcessorFeatures0, + HvPartitionPropertyCode::ProcessorFeatures1, + HvPartitionPropertyCode::ProcessorXsaveFeatures, + HvPartitionPropertyCode::ProcessorClockFrequency, + HvPartitionPropertyCode::ApicFrequency, + HvPartitionPropertyCode::PhysicalAddressWidth, + HvPartitionPropertyCode::XsaveStates, + HvPartitionPropertyCode::MaxXsaveDataSize, + HvPartitionPropertyCode::UnimplementedMsrAction, +]; + +/// The TSC invariant control MSR of the Hyper-V interface, whose intercept +/// probes the MSR-intercept capability. +const HV_X64_MSR_TSC_INVARIANT_CONTROL: u32 = 0x4000_0118; + +/// The TSC value written to the probe virtual processor. A probe partition +/// starts near zero, so a read-back between this value and twice it shows +/// that the write took effect. +const PROBE_TSC: u64 = 1 << 40; + +impl LinuxMshv { + /// Returns the guest CPU surface and time capabilities that the + /// hypervisor supports on this host. + /// + /// The CPUID table is what the virtual processor of a transient probe + /// partition reports when the partition enables every processor feature + /// bank and XSAVE feature of the host partition. The probe partition has + /// no memory, never runs, and is destroyed before this returns. It also + /// probes per-VP TSC writes, partition time freezing, and MSR-index + /// intercepts. + pub fn cpu_fingerprint(&self) -> Result { + let host_property = |code: HvPartitionPropertyCode| { + self.mshv + .get_host_partition_property(code.0) + .map_err(KernelError::from) + }; + let mut banks = [0; FEATURE_BANKS.len()]; + for (bank, code) in banks.iter_mut().zip(FEATURE_BANKS) { + *bank = host_property(code) + .map_err(|error| ErrorInner::GetHostPartitionProperty(code, error))?; + } + let [features0, features1, xsave_features] = banks; + + let args = mshv_bindings::mshv_create_partition_v2 { + pt_flags: 1 << mshv_bindings::MSHV_PT_BIT_LAPIC + | 1 << mshv_bindings::MSHV_PT_BIT_X2APIC + | 1 << mshv_bindings::MSHV_PT_BIT_GPA_SUPER_PAGES + | 1 << mshv_bindings::MSHV_PT_BIT_CPU_AND_XSAVE_FEATURES, + pt_isolation: mshv_bindings::MSHV_PT_ISOLATION_NONE as u64, + pt_num_cpu_fbanks: mshv_bindings::MSHV_NUM_CPU_FEATURES_BANKS as u16, + // The banks hold the features to disable. + pt_cpu_fbanks: [!features0, !features1], + pt_disabled_xsave: !xsave_features, + ..Default::default() + }; + let vmfd = create_vm_with_retry(&self.mshv, &args)?; + vmfd.initialize() + .map_err(|e| ErrorInner::CreateVMInitFailed(e.into()))?; + let vp = vmfd + .create_vcpu(0) + .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; + + let cpuid = + cpu_profile::cpuid::enumerate(|leaf, subleaf| vp.get_cpuid_values(leaf, subleaf, 0, 0)) + .map_err(|e| ErrorInner::FingerprintCpuid(e.into()))?; + + let mut fingerprint = BackendFingerprint::new("mshv", METHOD, cpuid); + for (code, value) in FEATURE_BANKS.into_iter().zip(banks) { + fingerprint.set_feature_bank(format!("mshv.host.{code:?}"), value); + } + for &code in HOST_PROPERTIES { + fingerprint.record(&format!("mshv.host.{code:?}"), host_property(code)); + } + for &code in PROBE_PROPERTIES { + fingerprint.record( + &format!("mshv.probe.{code:?}"), + vmfd.get_partition_property(code.0) + .map_err(KernelError::from), + ); + } + + let probe_value = |name: &str| fingerprint.values.get(name).map(|value| value.0); + let tsc_frequency_hz = probe_value("mshv.probe.ProcessorClockFrequency"); + let lapic_timer_frequency_hz = probe_value("mshv.probe.ApicFrequency"); + let tsc_offset_control = probe_tsc_write(&vp, &mut fingerprint); + let time_freeze = probe_time_freeze(&vmfd, &mut fingerprint); + let msr_index_intercept = probe_msr_index_intercept(&vmfd, &mut fingerprint); + + let time = &mut fingerprint.time; + time.tsc_frequency_hz = tsc_frequency_hz; + time.lapic_timer_frequency_hz = lapic_timer_frequency_hz; + time.tsc_offset_control = Some(tsc_offset_control); + time.time_freeze = Some(time_freeze); + // The hypervisor offers no TSC scaling control to the root. + time.tsc_scaling = None; + time.msr_intercepts + .insert("X64MsrIndex".to_owned(), msr_index_intercept); + + drop(vp); + drop(vmfd); + Ok(fingerprint) + } +} + +/// Writes the TSC of the probe virtual processor and reads it back, and +/// returns whether the write took effect. +fn probe_tsc_write(vp: &VcpuFd, fingerprint: &mut BackendFingerprint) -> bool { + let result = (|| { + vp.set_hvdef_regs(&[HvRegisterAssoc::from((HvX64RegisterName::Tsc, PROBE_TSC))])?; + let mut registers = [HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0_u64))]; + vp.get_hvdef_regs(&mut registers)?; + Ok::<_, KernelError>(registers[0].value.as_u64()) + })(); + match result { + Ok(tsc) => tsc.wrapping_sub(PROBE_TSC) < PROBE_TSC, + Err(error) => { + fingerprint.set_unavailable("mshv.probe.tsc_write", &error); + false + } + } +} + +/// Freezes and thaws the probe partition's time, and returns whether the +/// hypervisor accepted the freeze. Reading the property back is optional: +/// some hypervisors accept `TimeFreeze` writes but not reads. +fn probe_time_freeze(vmfd: &VmFd, fingerprint: &mut BackendFingerprint) -> bool { + let code = HvPartitionPropertyCode::TimeFreeze.0; + if let Err(error) = vmfd.set_partition_property(code, 1) { + fingerprint.set_unavailable("mshv.probe.TimeFreeze.freeze", &KernelError::from(error)); + return false; + } + match vmfd.get_partition_property(code) { + Ok(value) => fingerprint.set_value("mshv.probe.TimeFreeze.frozen", value), + Err(error) => { + fingerprint.set_unavailable("mshv.probe.TimeFreeze.read", &KernelError::from(error)) + } + } + if let Err(error) = vmfd.set_partition_property(code, 0) { + fingerprint.set_unavailable("mshv.probe.TimeFreeze.thaw", &KernelError::from(error)); + } + true +} + +/// Installs an MSR-index intercept on the probe partition, and returns +/// whether the hypervisor accepted it. +fn probe_msr_index_intercept(vmfd: &VmFd, fingerprint: &mut BackendFingerprint) -> bool { + let result = vmfd.install_intercept(mshv_bindings::mshv_install_intercept { + access_type_mask: mshv_bindings::HV_INTERCEPT_ACCESS_MASK_READ + | mshv_bindings::HV_INTERCEPT_ACCESS_MASK_WRITE, + intercept_type: mshv_bindings::hv_intercept_type_HV_INTERCEPT_TYPE_X64_MSR_INDEX, + intercept_parameter: mshv_bindings::hv_intercept_parameters { + msr_index: HV_X64_MSR_TSC_INVARIANT_CONTROL, + }, + }); + match result { + Ok(()) => true, + Err(error) => { + fingerprint.set_unavailable( + "mshv.probe.install_intercept.X64MsrIndex", + &KernelError::from(error), + ); + false + } + } +} + +#[cfg(test)] +mod tests { + use crate::LinuxMshv; + use cpu_profile::cpuid; + use test_with_tracing::test; + + #[test] + #[ignore = "requires /dev/mshv"] + fn fingerprint_reports_the_supported_surface() { + let mshv = LinuxMshv::new().unwrap(); + let fingerprint = mshv.cpu_fingerprint().unwrap(); + tracing::info!(unavailable = ?fingerprint.unavailable, "MSHV fingerprint"); + assert_eq!(fingerprint, mshv.cpu_fingerprint().unwrap()); + assert!(cpuid::lookup(&fingerprint.cpuid, 0, 0).is_some()); + assert_eq!(fingerprint.xsave.xcr0_supported.0 & 0x3, 0x3); + assert_eq!(fingerprint.feature_banks.len(), super::FEATURE_BANKS.len()); + assert!(fingerprint.time.tsc_frequency_hz.is_some()); + assert!(fingerprint.time.lapic_timer_frequency_hz.is_some()); + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 37afd4b5060..735296c4d96 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -5,6 +5,7 @@ mod extint; pub(crate) mod finalize; +mod fingerprint; pub(crate) mod time_abi; mod tsc; mod vm_state; diff --git a/vmm_core/virt_whp/Cargo.toml b/vmm_core/virt_whp/Cargo.toml index 912cc6568ee..f125b564f97 100644 --- a/vmm_core/virt_whp/Cargo.toml +++ b/vmm_core/virt_whp/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [target.'cfg(windows)'.dependencies] chipset_device.workspace = true +cpu_profile.workspace = true hv1_emulator.workspace = true hv1_hypercall.workspace = true hv1_structs.workspace = true diff --git a/vmm_core/virt_whp/src/fingerprint.rs b/vmm_core/virt_whp/src/fingerprint.rs new file mode 100644 index 00000000000..8c89c193c9d --- /dev/null +++ b/vmm_core/virt_whp/src/fingerprint.rs @@ -0,0 +1,344 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU fingerprint of the WHP backend: the guest CPU surface and time +//! capabilities that the Windows Hypervisor Platform supports on this host. + +use crate::Error; +use crate::WhpResultExt; +use cpu_profile::cpuid::CpuidEntry; +use cpu_profile::fingerprint::BackendFingerprint; +use whp::abi::WHV_X64_MSR_EXIT_BITMAP; + +const METHOD: &str = "WHvGetVirtualProcessorCpuidOutput on a probe partition with every processor \ + and XSAVE feature that WHP reports as available, and an in-hypervisor x2APIC"; + +/// The TSC value written to the probe virtual processor. A probe partition +/// starts near zero, so a read-back between this value and twice it shows +/// that the write took effect. +const PROBE_TSC: u64 = 1 << 40; + +/// The MSR exits that WHP can deliver, by `WHV_X64_MSR_EXIT_BITMAP` name. +const MSR_EXITS: &[(&str, WHV_X64_MSR_EXIT_BITMAP)] = &[ + ("UnhandledMsrs", WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs), + ("TscMsrWrite", WHV_X64_MSR_EXIT_BITMAP::TscMsrWrite), + ("TscMsrRead", WHV_X64_MSR_EXIT_BITMAP::TscMsrRead), + ( + "ApicBaseMsrWrite", + WHV_X64_MSR_EXIT_BITMAP::ApicBaseMsrWrite, + ), + ( + "MiscEnableMsrRead", + WHV_X64_MSR_EXIT_BITMAP::MiscEnableMsrRead, + ), + ( + "McUpdatePatchLevelMsrRead", + WHV_X64_MSR_EXIT_BITMAP::McUpdatePatchLevelMsrRead, + ), +]; + +/// Returns the guest CPU surface and time capabilities that WHP supports on +/// this host. +/// +/// The CPUID table is what the virtual processor of a transient probe +/// partition reports. The probe partition enables every processor and XSAVE +/// feature that WHP reports as available, because WHP's default processor +/// features, which OpenVMM's partitions use today, omit some of them, such +/// as the speculation controls of `CPUID.(7,0):EDX` and PSFD. If WHP rejects +/// the available features, the probe partition falls back to the default +/// features and the fingerprint records the error. The probe partition uses +/// the in-hypervisor x2APIC, has no memory, never runs, and is deleted +/// before this returns. The fingerprint also records the processor +/// capabilities (0x1000 through 0x1009), probes per-VP TSC writes and +/// partition time suspension on the probe partition, and probes TSC +/// frequency virtualization on a second partition object that is never set +/// up. +pub fn cpu_fingerprint() -> Result { + let available = whp::capabilities::processor_features() + .ok() + .zip(whp::capabilities::processor_xsave_features().ok()); + let mut rejected_features = None; + let mut partition = None; + if let Some(features) = available { + match probe_partition(Some(features)) { + Ok(probe) => partition = Some(probe), + Err(error) => rejected_features = Some(error), + } + } + let partition = match partition { + Some(partition) => partition, + None => probe_partition(None)?, + }; + let cpuid = probe_cpuid(&partition)?; + + let mut fingerprint = BackendFingerprint::new("whp", METHOD, cpuid); + if let Some(error) = rejected_features { + fingerprint.set_unavailable("whp.probe.ProcessorFeatures", &error); + } + record_capabilities(&mut fingerprint); + let vp = partition.vp(0); + + let tsc_frequency_hz = fingerprint.record( + "whp.probe.ProcessorClockFrequency", + partition.tsc_frequency(), + ); + let lapic_timer_frequency_hz = fingerprint.record( + "whp.probe.InterruptClockFrequency", + partition.apic_frequency(), + ); + fingerprint.record( + "whp.probe.PhysicalAddressWidth", + partition.physical_address_width().map(u64::from), + ); + + let tsc_write = (|| { + vp.set_register(whp::Register64::Tsc, PROBE_TSC)?; + vp.get_register(whp::Register64::Tsc) + })(); + let tsc_offset_control = match tsc_write { + Ok(tsc) => tsc.wrapping_sub(PROBE_TSC) < PROBE_TSC, + Err(error) => { + fingerprint.set_unavailable("whp.probe.tsc_write", &error); + false + } + }; + let time_freeze = match partition + .suspend_time() + .and_then(|()| partition.resume_time()) + { + Ok(()) => true, + Err(error) => { + fingerprint.set_unavailable("whp.probe.suspend_time", &error); + false + } + }; + drop(partition); + + let tsc_scaling = + tsc_frequency_hz.map(|frequency_hz| probe_tsc_scaling(frequency_hz, &mut fingerprint)); + let msr_exits = fingerprint + .values + .get("whp.capability.X64MsrExitBitmap") + .map(|bitmap| bitmap.0); + let exits = fingerprint + .values + .get("whp.capability.ExtendedVmExits") + .map(|exits| exits.0); + + let time = &mut fingerprint.time; + time.tsc_frequency_hz = tsc_frequency_hz; + time.lapic_timer_frequency_hz = lapic_timer_frequency_hz; + time.tsc_offset_control = Some(tsc_offset_control); + time.time_freeze = Some(time_freeze); + time.tsc_scaling = tsc_scaling; + if let Some(exits) = exits { + time.msr_intercepts.insert( + "X64MsrExit".to_owned(), + whp::abi::WHV_EXTENDED_VM_EXITS(exits) + .is_set(whp::abi::WHV_EXTENDED_VM_EXITS::X64MsrExit), + ); + } + if let Some(msr_exits) = msr_exits { + for &(name, bit) in MSR_EXITS { + time.msr_intercepts.insert( + name.to_owned(), + WHV_X64_MSR_EXIT_BITMAP(msr_exits).is_set(bit), + ); + } + } + Ok(fingerprint) +} + +/// Creates a probe partition with one virtual processor and the +/// in-hypervisor x2APIC. `features` are the processor and XSAVE features to +/// enable, or `None` for WHP's default processor features. +fn probe_partition( + features: Option<( + whp::ProcessorFeatures, + whp::abi::WHV_PROCESSOR_XSAVE_FEATURES, + )>, +) -> Result { + let mut config = + whp::PartitionConfig::new().for_op("create the fingerprint probe partition")?; + config + .set_property(whp::PartitionProperty::ProcessorCount(1)) + .for_op("set the probe partition processor count")?; + config + .set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeX2Apic, + )) + .for_op("set the probe partition APIC emulation mode")?; + if let Some((processor, xsave)) = features { + config + .set_property(whp::PartitionProperty::ProcessorFeatures(processor)) + .for_op("set the probe partition processor features")?; + config + .set_property(whp::PartitionProperty::ProcessorXsaveFeatures(xsave)) + .for_op("set the probe partition XSAVE features")?; + } + let partition = config + .create() + .for_op("set up the fingerprint probe partition")?; + partition + .create_vp(0) + .create() + .for_op("create the probe virtual processor")?; + Ok(partition) +} + +/// Returns the CPUID table that the virtual processor of the probe +/// partition reports. +fn probe_cpuid(partition: &whp::Partition) -> Result, Error> { + let vp = partition.vp(0); + cpu_profile::cpuid::enumerate(|leaf, subleaf| { + vp.get_cpuid_output(leaf, subleaf) + .map(|output| [output.Eax, output.Ebx, output.Ecx, output.Edx]) + }) + .for_op("query the probe virtual processor CPUID") +} + +/// Records the WHP capabilities: the processor feature banks as feature +/// banks, and the others as values. +fn record_capabilities(fingerprint: &mut BackendFingerprint) { + use whp::capabilities; + + match capabilities::processor_features_banks() { + Ok(banks) => { + fingerprint.set_value( + "whp.capability.ProcessorFeaturesBanks.count", + banks.BanksCount.into(), + ); + for (index, bank) in banks.Banks.iter().enumerate() { + fingerprint.set_feature_bank( + format!("whp.capability.ProcessorFeaturesBanks.bank{index}"), + *bank, + ); + } + } + Err(error) => fingerprint.set_unavailable("whp.capability.ProcessorFeaturesBanks", &error), + } + match capabilities::processor_features_bank0() { + Ok(features) => { + fingerprint.set_feature_bank("whp.capability.ProcessorFeatures", features.0) + } + Err(error) => fingerprint.set_unavailable("whp.capability.ProcessorFeatures", &error), + } + match capabilities::processor_xsave_features() { + Ok(features) => { + fingerprint.set_feature_bank("whp.capability.ProcessorXsaveFeatures", features.0) + } + Err(error) => fingerprint.set_unavailable("whp.capability.ProcessorXsaveFeatures", &error), + } + match capabilities::synthetic_processor_features_banks() { + Ok(banks) => { + fingerprint.set_value( + "whp.capability.SyntheticProcessorFeaturesBanks.count", + banks.BanksCount.into(), + ); + for (index, bank) in banks.Banks.iter().enumerate() { + fingerprint.set_value( + format!("whp.capability.SyntheticProcessorFeaturesBanks.bank{index}"), + *bank, + ); + } + } + Err(error) => { + fingerprint.set_unavailable("whp.capability.SyntheticProcessorFeaturesBanks", &error) + } + } + match capabilities::processor_frequency_cap() { + Ok(cap) => { + for (name, value) in [ + ("Flags", cap.Flags), + ("HighestFrequencyMhz", cap.HighestFrequencyMhz), + ("NominalFrequencyMhz", cap.NominalFrequencyMhz), + ("LowestFrequencyMhz", cap.LowestFrequencyMhz), + ("FrequencyStepMhz", cap.FrequencyStepMhz), + ] { + fingerprint.set_value( + format!("whp.capability.ProcessorFrequencyCap.{name}"), + value.into(), + ); + } + } + Err(error) => fingerprint.set_unavailable("whp.capability.ProcessorFrequencyCap", &error), + } + fingerprint.record( + "whp.capability.PerfmonFeatures", + capabilities::perfmon_features().map(|features| features.0), + ); + fingerprint.record( + "whp.capability.Features", + capabilities::features().map(|features| features.0), + ); + fingerprint.record( + "whp.capability.ExtendedVmExits", + capabilities::extended_vm_exits().map(|exits| exits.0), + ); + fingerprint.record( + "whp.capability.X64MsrExitBitmap", + capabilities::x64_msr_exit_bitmap().map(|bitmap| bitmap.0), + ); + fingerprint.record( + "whp.capability.ProcessorVendor", + capabilities::processor_vendor().map(|vendor| vendor.0.into()), + ); + fingerprint.record( + "whp.capability.ProcessorClFlushSize", + capabilities::processor_cl_flush_size().map(u64::from), + ); + fingerprint.record( + "whp.capability.ProcessorClockFrequency", + capabilities::processor_clock_frequency(), + ); + fingerprint.record( + "whp.capability.InterruptClockFrequency", + capabilities::interrupt_clock_frequency(), + ); +} + +/// Asks WHP to virtualize half of the host TSC frequency for a partition +/// that is never set up, and returns whether WHP accepted it. +fn probe_tsc_scaling(host_frequency_hz: u64, fingerprint: &mut BackendFingerprint) -> bool { + let result = whp::PartitionConfig::new().and_then(|mut config| { + config + .set_property(whp::PartitionProperty::ProcessorCount(1))? + .set_property(whp::PartitionProperty::ProcessorClockFrequency( + host_frequency_hz / 2, + ))?; + Ok(()) + }); + match result { + Ok(()) => true, + Err(error) => { + fingerprint.set_unavailable("whp.probe.ProcessorClockFrequency.set", &error); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::cpu_fingerprint; + use cpu_profile::cpuid; + use test_with_tracing::test; + + #[test] + #[ignore = "requires WHP"] + fn fingerprint_reports_the_supported_surface() { + let fingerprint = cpu_fingerprint().unwrap(); + tracing::info!(unavailable = ?fingerprint.unavailable, "WHP fingerprint"); + assert_eq!(fingerprint, cpu_fingerprint().unwrap()); + assert!(cpuid::lookup(&fingerprint.cpuid, 0, 0).is_some()); + assert_eq!(fingerprint.xsave.xcr0_supported.0 & 0x3, 0x3); + assert!(fingerprint.time.tsc_frequency_hz.is_some()); + assert!(fingerprint.time.lapic_timer_frequency_hz.is_some()); + assert_eq!(fingerprint.time.tsc_offset_control, Some(true)); + // WHP accepts every feature that it reports as available. + assert!( + !fingerprint + .unavailable + .contains_key("whp.probe.ProcessorFeatures") + ); + } +} diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 34c166f5a31..32c8421c14f 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -14,6 +14,8 @@ mod apic; mod cpu_contract; pub mod device; mod emu; +#[cfg(guest_arch = "x86_64")] +mod fingerprint; mod hypercalls; mod memory; mod regs; @@ -81,6 +83,8 @@ use x86defs::cpuid::Vendor; #[cfg(guest_arch = "aarch64")] pub use aarch64::WHP_PMU_GSIV; +#[cfg(guest_arch = "x86_64")] +pub use fingerprint::cpu_fingerprint; #[derive(Debug)] pub struct Whp { From 630b06f8e639192d57d183e2d3b2dc1f1002158d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 03:43:41 -0700 Subject: [PATCH 182/304] hypervisor_resources, openvmm_hypervisors: let a probe fingerprint its backend Add HypervisorProbe::cpu_fingerprint, which reports the backend's supported CPU surface and time capabilities without a guest. It is unsupported by default; KVM, MSHV, and WHP implement it, and none of them takes parameters yet. openvmm_helpers::hypervisor::cpu_fingerprint fingerprints the selected or auto-detected backend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 4 +++ openvmm/hypervisor_resources/Cargo.toml | 1 + openvmm/hypervisor_resources/src/lib.rs | 21 +++++++++++++ openvmm/openvmm_helpers/Cargo.toml | 1 + openvmm/openvmm_helpers/src/hypervisor.rs | 38 +++++++++++++++++++++++ openvmm/openvmm_hypervisors/Cargo.toml | 4 ++- openvmm/openvmm_hypervisors/src/kvm.rs | 11 +++++++ openvmm/openvmm_hypervisors/src/lib.rs | 30 ++++++++++++++++++ openvmm/openvmm_hypervisors/src/mshv.rs | 12 +++++++ openvmm/openvmm_hypervisors/src/whp.rs | 10 ++++++ 10 files changed, 131 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index fd5e78b2077..fae9ee540ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3774,6 +3774,7 @@ name = "hypervisor_resources" version = "0.0.0" dependencies = [ "anyhow", + "cpu_profile", "linkme", "mesh", "vm_resource", @@ -6157,6 +6158,7 @@ name = "openvmm_helpers" version = "0.0.0" dependencies = [ "anyhow", + "cpu_profile", "disk_backend_resources", "disk_vhd1", "disk_vhdmp", @@ -6186,9 +6188,11 @@ version = "0.0.0" dependencies = [ "anyhow", "build_rs_guest_arch", + "cpu_profile", "fs-err", "hypervisor_resources", "test_with_tracing", + "virt_kvm", "virt_mshv", "virt_whp", "vm_resource", diff --git a/openvmm/hypervisor_resources/Cargo.toml b/openvmm/hypervisor_resources/Cargo.toml index 332c036db2f..aaad8f491b2 100644 --- a/openvmm/hypervisor_resources/Cargo.toml +++ b/openvmm/hypervisor_resources/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] anyhow.workspace = true +cpu_profile.workspace = true linkme.workspace = true mesh.workspace = true vm_resource.workspace = true diff --git a/openvmm/hypervisor_resources/src/lib.rs b/openvmm/hypervisor_resources/src/lib.rs index 9714cd033c5..673068fc478 100644 --- a/openvmm/hypervisor_resources/src/lib.rs +++ b/openvmm/hypervisor_resources/src/lib.rs @@ -117,6 +117,27 @@ pub trait HypervisorProbe: Send + Sync + 'static { /// passed as `(key, "true")`. Backends should return an error for /// unrecognized keys. fn new_resource(&self, params: &[(&str, &str)]) -> anyhow::Result>; + + /// Returns the guest CPU surface that this backend supports on this host, + /// for a host CPU fingerprint. + /// + /// This needs no guest. A backend may create a transient probe partition + /// but must not leave any state behind. `params` are the backend + /// parameters, as for [`new_resource`](Self::new_resource); backends + /// reject the ones that do not apply to fingerprinting. + /// + /// The default implementation reports that the backend does not support + /// fingerprinting. + fn cpu_fingerprint( + &self, + params: &[(&str, &str)], + ) -> anyhow::Result { + let _ = params; + anyhow::bail!( + "the {} hypervisor backend does not support CPU fingerprinting", + self.name() + ) + } } /// Private module for linkme infrastructure. diff --git a/openvmm/openvmm_helpers/Cargo.toml b/openvmm/openvmm_helpers/Cargo.toml index c61db1f97c4..43615f4d69f 100644 --- a/openvmm/openvmm_helpers/Cargo.toml +++ b/openvmm/openvmm_helpers/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +cpu_profile.workspace = true disk_backend_resources.workspace = true disk_vhd1.workspace = true disklayer_vhdx.workspace = true diff --git a/openvmm/openvmm_helpers/src/hypervisor.rs b/openvmm/openvmm_helpers/src/hypervisor.rs index 0cb66f5a314..318f53104f3 100644 --- a/openvmm/openvmm_helpers/src/hypervisor.rs +++ b/openvmm/openvmm_helpers/src/hypervisor.rs @@ -4,6 +4,7 @@ //! Hypervisor resource construction and auto-detection for OpenVMM entry //! points. +use cpu_profile::fingerprint::BackendFingerprint; use hypervisor_resources::HypervisorKind; use vm_resource::Resource; @@ -56,3 +57,40 @@ pub fn hypervisor_resource(spec: &str) -> anyhow::Result) -> anyhow::Result { + match spec { + Some(spec) => { + let (name, params) = parse_hypervisor_spec(spec)?; + let probe = hypervisor_resources::probe_by_name(name) + .ok_or_else(|| anyhow::anyhow!("unknown hypervisor: {name}"))?; + probe.cpu_fingerprint(¶ms) + } + None => { + for probe in hypervisor_resources::probes() { + if probe.try_new_resource()?.is_some() { + return probe.cpu_fingerprint(&[]); + } + } + anyhow::bail!("no hypervisor available"); + } + } +} + +#[cfg(test)] +mod tests { + use super::cpu_fingerprint; + + #[test] + fn fingerprint_rejects_unknown_hypervisor() { + let error = cpu_fingerprint(Some("nosuch")).unwrap_err(); + assert_eq!(error.to_string(), "unknown hypervisor: nosuch"); + let error = cpu_fingerprint(Some(":x")).unwrap_err(); + assert_eq!(error.to_string(), "empty hypervisor name in spec: :x"); + } +} diff --git a/openvmm/openvmm_hypervisors/Cargo.toml b/openvmm/openvmm_hypervisors/Cargo.toml index 0dc63b0dbb2..687807d3b38 100644 --- a/openvmm/openvmm_hypervisors/Cargo.toml +++ b/openvmm/openvmm_hypervisors/Cargo.toml @@ -11,7 +11,7 @@ rust-version.workspace = true virt_mshv = ["dep:virt_mshv"] # Enable building with KVM support. -virt_kvm = [] +virt_kvm = ["dep:virt_kvm"] # Enable building with WHP support. virt_whp = ["dep:virt_whp"] @@ -21,11 +21,13 @@ virt_hvf = [] [dependencies] anyhow.workspace = true +cpu_profile.workspace = true fs-err.workspace = true hypervisor_resources.workspace = true vm_resource.workspace = true [target.'cfg(target_os = "linux")'.dependencies] +virt_kvm = { workspace = true, optional = true } virt_mshv = { workspace = true, optional = true } [target.'cfg(windows)'.dependencies] diff --git a/openvmm/openvmm_hypervisors/src/kvm.rs b/openvmm/openvmm_hypervisors/src/kvm.rs index 2a1c65f8eda..829bb2061a8 100644 --- a/openvmm/openvmm_hypervisors/src/kvm.rs +++ b/openvmm/openvmm_hypervisors/src/kvm.rs @@ -35,6 +35,17 @@ impl hypervisor_resources::HypervisorProbe for KvmProbe { let kvm = open_kvm().context("KVM is not available")?; Ok(KvmHandle { kvm: kvm.into() }.into_resource()) } + + #[cfg(guest_arch = "x86_64")] + fn cpu_fingerprint( + &self, + params: &[(&str, &str)], + ) -> anyhow::Result { + crate::reject_fingerprint_params("kvm", params)?; + let kvm = open_kvm().context("KVM is not available")?; + let kvm = virt_kvm::Kvm::from_kvm(kvm.into())?; + Ok(kvm.cpu_fingerprint()?) + } } fn open_kvm() -> std::io::Result { diff --git a/openvmm/openvmm_hypervisors/src/lib.rs b/openvmm/openvmm_hypervisors/src/lib.rs index e724a9ef4d8..61819772b5c 100644 --- a/openvmm/openvmm_hypervisors/src/lib.rs +++ b/openvmm/openvmm_hypervisors/src/lib.rs @@ -40,3 +40,33 @@ pub(crate) fn parse_bool_param(key: &str, val: &str) -> anyhow::Result { _ => anyhow::bail!("invalid boolean value for {key}: {val}"), } } + +/// Rejects backend parameters for CPU fingerprinting, which none of them +/// affect. +#[expect(clippy::allow_attributes, reason = "lots of conditions")] +#[allow(dead_code)] +pub(crate) fn reject_fingerprint_params( + backend: &str, + params: &[(&str, &str)], +) -> anyhow::Result<()> { + if let Some(&(key, _)) = params.first() { + anyhow::bail!("{backend} parameter {key} does not apply to CPU fingerprinting"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::reject_fingerprint_params; + use test_with_tracing::test; + + #[test] + fn fingerprinting_rejects_backend_parameters() { + reject_fingerprint_params("whp", &[]).unwrap(); + let error = reject_fingerprint_params("whp", &[("user_mode_apic", "true")]).unwrap_err(); + assert_eq!( + error.to_string(), + "whp parameter user_mode_apic does not apply to CPU fingerprinting" + ); + } +} diff --git a/openvmm/openvmm_hypervisors/src/mshv.rs b/openvmm/openvmm_hypervisors/src/mshv.rs index 50608eaf53e..b1638893f50 100644 --- a/openvmm/openvmm_hypervisors/src/mshv.rs +++ b/openvmm/openvmm_hypervisors/src/mshv.rs @@ -55,6 +55,18 @@ impl hypervisor_resources::HypervisorProbe for MshvProbe { snp_disable_cpuid_offload, })) } + + #[cfg(guest_arch = "x86_64")] + fn cpu_fingerprint( + &self, + params: &[(&str, &str)], + ) -> anyhow::Result { + crate::reject_fingerprint_params("mshv", params)?; + anyhow::ensure!(virt_mshv::is_available()?, "MSHV is not available"); + let mshv = + virt_mshv::LinuxMshv::from(std::fs::File::from(fs_err::File::open("/dev/mshv")?)); + Ok(mshv.cpu_fingerprint()?) + } } #[cfg(test)] diff --git a/openvmm/openvmm_hypervisors/src/whp.rs b/openvmm/openvmm_hypervisors/src/whp.rs index 8b9c1ef20b2..cb0dfca0db8 100644 --- a/openvmm/openvmm_hypervisors/src/whp.rs +++ b/openvmm/openvmm_hypervisors/src/whp.rs @@ -46,4 +46,14 @@ impl hypervisor_resources::HypervisorProbe for WhpProbe { anyhow::ensure!(virt_whp::is_available()?, "WHP is not available"); Ok(Resource::new(handle)) } + + #[cfg(guest_arch = "x86_64")] + fn cpu_fingerprint( + &self, + params: &[(&str, &str)], + ) -> anyhow::Result { + crate::reject_fingerprint_params("whp", params)?; + anyhow::ensure!(virt_whp::is_available()?, "WHP is not available"); + Ok(virt_whp::cpu_fingerprint()?) + } } From 608f9fa1c770c4fc58a1703d8fcb40056e0c078f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 04:22:10 -0700 Subject: [PATCH 183/304] cpu_profile: pin CPU profiles, and select, verify, and restore them The NVX time ABI gives every backend of a CPU generation the same pinned guest CPU surface. Add the profile model and the three initial profiles: - CpuProfile lists every CPUID leaf and subleaf the guest can observe in the basic and extended ranges, with exact values and a mask of the pinned bits; the XSAVE features and layout; the guest physical address width; and pinned MSR values. It leaves to OpenVMM the topology and APIC-mode fields, which the VM configuration defines, and the runtime state the processor maintains. Its canonical encoding is compact canonical JSON, its digest is that encoding's SHA-256, and decoding accepts only the canonical bytes. - CpuProfile::effective_cpuid combines a profile with the VM's topology leaves and the time ABI's identity leaves into the VM's complete effective CPUID, with a canonical encoding, digest, and comparison for the snapshot record. - verify_support checks a backend's supported CPU surface against a profile: feature bits, numeric limits, the exact XSAVE layout, the address width, and that every pinned MSR value can be presented. The time ABI's policy bits (hypervisor, ARAT, and the invariant TSC) are exempt; host qualification backs the invariant TSC instead. - Selection by ID or by the host CPU's generation, the generation check, and the restore checks fail with the time ABI specification's codes. - The derive module intersects host fingerprints of every backend into a generation's profile under a fixed policy. IA32_ARCH_CAPABILITIES is pinned through the bits the Hyper-V processor feature banks derive (hv_banks), because MSHV and WHP cannot intercept it. The pinned intel.skylake-sp.v1, intel.icelake-sp.v1, and intel.emeraldrapids.v1 profiles are derived from the fingerprints of three bare-metal hosts, one per backend, and fifteen Azure hosts; their provenance records the fingerprints' surface digests. A test builds each profile's effective CPUID with OpenVMM's topology code and the time ABI's identity leaves, and checks the CPU time bits and the identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 3 + vmm_core/cpu_profile/Cargo.toml | 5 + vmm_core/cpu_profile/build.rs | 8 + .../cpu_profile/examples/check_fingerprint.rs | 37 + .../cpu_profile/examples/derive_profile.rs | 54 ++ .../profiles/intel.emeraldrapids.v1.json | 820 ++++++++++++++++ .../profiles/intel.icelake-sp.v1.json | 843 +++++++++++++++++ .../profiles/intel.skylake-sp.v1.json | 779 +++++++++++++++ vmm_core/cpu_profile/src/canonical.rs | 19 +- vmm_core/cpu_profile/src/catalog.rs | 399 ++++++++ vmm_core/cpu_profile/src/check.rs | 173 ++++ vmm_core/cpu_profile/src/derive.rs | 893 ++++++++++++++++++ vmm_core/cpu_profile/src/effective.rs | 591 ++++++++++++ vmm_core/cpu_profile/src/error.rs | 110 +++ vmm_core/cpu_profile/src/host/mod.rs | 20 +- vmm_core/cpu_profile/src/hv_banks.rs | 179 ++++ vmm_core/cpu_profile/src/lib.rs | 85 +- vmm_core/cpu_profile/src/profile.rs | 874 +++++++++++++++++ vmm_core/cpu_profile/src/signature.rs | 163 ++++ vmm_core/cpu_profile/src/surface.rs | 441 +++++++++ vmm_core/cpu_profile/src/test_support.rs | 90 ++ 21 files changed, 6556 insertions(+), 30 deletions(-) create mode 100644 vmm_core/cpu_profile/build.rs create mode 100644 vmm_core/cpu_profile/examples/check_fingerprint.rs create mode 100644 vmm_core/cpu_profile/examples/derive_profile.rs create mode 100644 vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json create mode 100644 vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json create mode 100644 vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json create mode 100644 vmm_core/cpu_profile/src/catalog.rs create mode 100644 vmm_core/cpu_profile/src/check.rs create mode 100644 vmm_core/cpu_profile/src/derive.rs create mode 100644 vmm_core/cpu_profile/src/effective.rs create mode 100644 vmm_core/cpu_profile/src/error.rs create mode 100644 vmm_core/cpu_profile/src/hv_banks.rs create mode 100644 vmm_core/cpu_profile/src/profile.rs create mode 100644 vmm_core/cpu_profile/src/signature.rs create mode 100644 vmm_core/cpu_profile/src/surface.rs create mode 100644 vmm_core/cpu_profile/src/test_support.rs diff --git a/Cargo.lock b/Cargo.lock index fae9ee540ac..abbc11a93d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -999,12 +999,15 @@ dependencies = [ name = "cpu_profile" version = "0.0.0" dependencies = [ + "build_rs_guest_arch", "safe_intrinsics", "serde", "serde_json", "sha2", "test_with_tracing", "thiserror 2.0.16", + "virt", + "vm_topology", "windows-sys 0.61.0", ] diff --git a/vmm_core/cpu_profile/Cargo.toml b/vmm_core/cpu_profile/Cargo.toml index 59086add4d6..b179378cece 100644 --- a/vmm_core/cpu_profile/Cargo.toml +++ b/vmm_core/cpu_profile/Cargo.toml @@ -20,6 +20,11 @@ features = ["Win32_Foundation", "Win32_System_Registry"] [dev-dependencies] test_with_tracing.workspace = true +virt.workspace = true +vm_topology.workspace = true + +[build-dependencies] +build_rs_guest_arch.workspace = true [lints] workspace = true diff --git a/vmm_core/cpu_profile/build.rs b/vmm_core/cpu_profile/build.rs new file mode 100644 index 00000000000..9a6ae219443 --- /dev/null +++ b/vmm_core/cpu_profile/build.rs @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#![expect(missing_docs)] + +fn main() { + build_rs_guest_arch::emit_guest_arch() +} diff --git a/vmm_core/cpu_profile/examples/check_fingerprint.rs b/vmm_core/cpu_profile/examples/check_fingerprint.rs new file mode 100644 index 00000000000..0f85af1bb6a --- /dev/null +++ b/vmm_core/cpu_profile/examples/check_fingerprint.rs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Checks host fingerprints against the pinned profile of their generation. +//! +//! ```text +//! cargo run -p cpu_profile --example check_fingerprint -- ... +//! ``` +//! +//! Prints one `NVX-CPU-PROFILE:` line per fingerprint, and fails if any +//! check fails. + +use cpu_profile::fingerprint::CpuFingerprint; +use std::process::ExitCode; + +fn main() -> ExitCode { + let mut status = ExitCode::SUCCESS; + for path in std::env::args().skip(1) { + let fingerprint = std::fs::read_to_string(&path) + .map_err(|error| error.to_string()) + .and_then(|json| CpuFingerprint::from_json(&json).map_err(|error| error.to_string())); + match fingerprint { + Ok(fingerprint) => { + let check = cpu_profile::check_fingerprint(&fingerprint); + println!("{path}: {}", check.summary_line(&fingerprint)); + if check.result.is_err() { + status = ExitCode::FAILURE; + } + } + Err(error) => { + println!("{path}: error: {error}"); + status = ExitCode::FAILURE; + } + } + } + status +} diff --git a/vmm_core/cpu_profile/examples/derive_profile.rs b/vmm_core/cpu_profile/examples/derive_profile.rs new file mode 100644 index 00000000000..8a1c5782f1f --- /dev/null +++ b/vmm_core/cpu_profile/examples/derive_profile.rs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Derives a CPU profile from host fingerprints. +//! +//! ```text +//! cargo run -p cpu_profile --example derive_profile -- ... +//! ``` +//! +//! Writes the profile, as pretty canonical JSON, to standard output, and its +//! ID and digest to standard error. Pin it under +//! `vmm_core/cpu_profile/profiles/.json` with its digest in the catalog. + +use cpu_profile::derive; +use cpu_profile::fingerprint::CpuFingerprint; +use std::process::ExitCode; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let usage = "usage: derive_profile ..."; + let name = args.next().ok_or(usage)?; + let revision = args.next().ok_or(usage)?.parse::()?; + let generation = derive::known_generation(&name).ok_or_else(|| { + format!( + "unknown generation {name:?}; known: {}", + derive::KNOWN_GENERATIONS + .iter() + .map(|generation| generation.name) + .collect::>() + .join(", ") + ) + })?; + let fingerprints = args + .map(|path| { + let json = + std::fs::read_to_string(&path).map_err(|error| format!("{path}: {error}"))?; + CpuFingerprint::from_json(&json).map_err(|error| format!("{path}: {error}")) + }) + .collect::, _>>()?; + let profile = derive::derive_profile(generation, revision, &fingerprints)?; + print!("{}", profile.to_pretty_json()); + eprintln!("{} {}", profile.id(), profile.digest_string()); + Ok(()) +} diff --git a/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json b/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json new file mode 100644 index 00000000000..24c030d7054 --- /dev/null +++ b/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json @@ -0,0 +1,820 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001c", + "0x756e6547", + "0x6c65746e", + "0x49656e69" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x000c06f2", + "0x00000800", + "0xf6da3203", + "0x0f8bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00feff01", + "0x000000f0", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x02c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x03c0003f", + "0x000007ff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x04c0003f", + "0x00033fff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0xf1bf2fb9", + "0x00415f46", + "0x20000010" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x000000e7", + "0x00000000", + "0x00000a80", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000005", + "value": [ + "0x00000040", + "0x00000440", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000006", + "value": [ + "0x00000200", + "0x00000480", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000007", + "value": [ + "0x00000400", + "0x00000680", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000008", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000121", + "0x2c100800" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x45544e49", + "0x2952284c", + "0x4f455820", + "0x2952284e" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x414c5020", + "0x554e4954", + "0x3538204d", + "0x00433337" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x08007040", + "0x00000000" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00003930", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "Intel Xeon Scalable, fifth generation (Emerald Rapids), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 6, + "model": 207, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "emeraldrapids" + }, + "id": "intel.emeraldrapids.v1", + "msrs": [ + { + "index": "0x0000010a", + "mask": "0x400000000c12e1bb", + "value": "0x0000000000000021" + } + ], + "physical_address_width": 48, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "mshv", + "hosts": 4, + "surface_digests": [ + "sha256:2c5bfaecc6f6ea9fc4ee8c152291264ecbe4ab1e56a57393bb8f5039a0aecfe1" + ] + }, + { + "backend": "whp", + "hosts": 2, + "surface_digests": [ + "sha256:257b3a4f975154c05e1d781fa8000bbe525be97f4889f5d700ef2df16ffe7510" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "GenuineIntel", + "xcr0": "0x00000000000000e7", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 5, + "offset": 1088, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 6, + "offset": 1152, + "size": 512, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 7, + "offset": 1664, + "size": 1024, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json b/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json new file mode 100644 index 00000000000..83eca941d5e --- /dev/null +++ b/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json @@ -0,0 +1,843 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001b", + "0x756e6547", + "0x6c65746e", + "0x49656e69" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x000606a6", + "0x00000800", + "0xf6da3203", + "0x0f8bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00feff01", + "0x000000f0", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x02c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x04c0003f", + "0x000003ff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x02c0003f", + "0x0000ffff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000002", + "0xf1bf2fb9", + "0x00415f46", + "0x20000010" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000001" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x000000e7", + "0x00000000", + "0x00000a80", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000005", + "value": [ + "0x00000040", + "0x00000440", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000006", + "value": [ + "0x00000200", + "0x00000480", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000007", + "value": [ + "0x00000400", + "0x00000680", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000008", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000121", + "0x2c100800" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x65746e49", + "0x2952286c", + "0x6f655820", + "0x2952286e" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x616c5020", + "0x756e6974", + "0x3338206d", + "0x20433037" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x20555043", + "0x2e322040", + "0x48473038", + "0x0000007a" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x01006040", + "0x00000000" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000392e", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "Intel Xeon Scalable, third generation (Ice Lake-SP), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 6, + "model": 106, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "icelake-sp" + }, + "id": "intel.icelake-sp.v1", + "msrs": [ + { + "index": "0x0000010a", + "mask": "0x400000000c12e1bb", + "value": "0x0000000008000121" + } + ], + "physical_address_width": 46, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "kvm", + "hosts": 5, + "surface_digests": [ + "sha256:6286956acbefac80fa56363b3b1d5933567e6e1e3a9b15788ddc832e994e772f" + ] + }, + { + "backend": "mshv", + "hosts": 1, + "surface_digests": [ + "sha256:270942e3223b3b9ea22d62250659e51248a0de98d412aef3b1ba09ac01ac7379" + ] + }, + { + "backend": "whp", + "hosts": 3, + "surface_digests": [ + "sha256:ffc115da4f1554c9bb84f2ff8034bd64eeb8844cb7a8daeb8e1da8867fa2ef1e" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "GenuineIntel", + "xcr0": "0x00000000000000e7", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 5, + "offset": 1088, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 6, + "offset": 1152, + "size": 512, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 7, + "offset": 1664, + "size": 1024, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json b/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json new file mode 100644 index 00000000000..8c97fc3f233 --- /dev/null +++ b/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json @@ -0,0 +1,779 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000016", + "0x756e6547", + "0x6c65746e", + "0x49656e69" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00050654", + "0x00000800", + "0xf6da3203", + "0x0f8bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x76036301", + "0x00f0b5ff", + "0x00000000", + "0x00c30000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x03c0003f", + "0x000003ff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x0280003f", + "0x00004fff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0xd19f6fb9", + "0x00000000", + "0xac000400" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x000000ff", + "0x00000000", + "0x00000a80", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000040", + "0x000003c0", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000040", + "0x00000400", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000005", + "value": [ + "0x00000040", + "0x00000440", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000006", + "value": [ + "0x00000200", + "0x00000480", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000007", + "value": [ + "0x00000400", + "0x00000680", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000008", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000121", + "0x2c100800" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x65746e49", + "0x2952286c", + "0x6f655820", + "0x2952286e" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x6c695320", + "0x20726576", + "0x34313134", + "0x55504320" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x32204020", + "0x4730322e", + "0x00007a48", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x01006040", + "0x00000000" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000302e", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "Intel Xeon Scalable, first generation (Skylake-SP), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 6, + "model": 85, + "steppings": [ + 0, + 4 + ] + } + ], + "name": "skylake-sp" + }, + "id": "intel.skylake-sp.v1", + "msrs": [ + { + "index": "0x0000010a", + "mask": "0x400000000c12e1bb", + "value": "0x0000000000000000" + } + ], + "physical_address_width": 46, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "kvm", + "hosts": 1, + "surface_digests": [ + "sha256:862ab0052435bb3c005481854c4aa92c6185ca3f8c6b280bd760d18927a29b9c" + ] + }, + { + "backend": "mshv", + "hosts": 1, + "surface_digests": [ + "sha256:75f96c74049a61d5cccca3f83a8dadbf8975cb41bc53a69e5ddcee0511714e82" + ] + }, + { + "backend": "whp", + "hosts": 1, + "surface_digests": [ + "sha256:d67f24b8d101181f10b59022682b4945a7014c77dd095a138cc168adae2e5e2b" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "GenuineIntel", + "xcr0": "0x00000000000000ff", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 3, + "offset": 960, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 4, + "offset": 1024, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 5, + "offset": 1088, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 6, + "offset": 1152, + "size": 512, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 7, + "offset": 1664, + "size": 1024, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/src/canonical.rs b/vmm_core/cpu_profile/src/canonical.rs index c01b6d6f7bb..464453489da 100644 --- a/vmm_core/cpu_profile/src/canonical.rs +++ b/vmm_core/cpu_profile/src/canonical.rs @@ -33,10 +33,19 @@ pub(crate) fn to_pretty(value: &Value) -> String { /// Returns `sha256:` and the hex SHA-256 of the compact canonical encoding of /// `value`. pub(crate) fn digest(value: &Value) -> String { - let hash = sha2::Sha256::digest(to_compact(value).as_bytes()); + format_digest(&sha256(to_compact(value).as_bytes())) +} + +/// Returns the SHA-256 of `bytes`. +pub(crate) fn sha256(bytes: &[u8]) -> [u8; 32] { + sha2::Sha256::digest(bytes).into() +} + +/// Returns `sha256:` and the lowercase hex of `hash`. +pub(crate) fn format_digest(hash: &[u8; 32]) -> String { let mut out = String::with_capacity(DIGEST_PREFIX.len() + 64); out.push_str(DIGEST_PREFIX); - for byte in hash.iter() { + for byte in hash { out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap()); out.push(char::from_digit(u32::from(byte & 0xf), 16).unwrap()); } @@ -149,4 +158,10 @@ mod tests { digest(&serde_json::from_str(r#"{"b": 2, "a": 1}"#).unwrap()) ); } + + #[test] + fn digest_strings_are_prefixed_lowercase_hex() { + let text = super::format_digest(&super::sha256(b"{}")); + assert_eq!(text, digest(&json!({}))); + } } diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs new file mode 100644 index 00000000000..4f826c03200 --- /dev/null +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -0,0 +1,399 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU profiles pinned in this OpenVMM, profile selection, and the +//! profile checks of cold boot and restore. + +use crate::canonical; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::profile::CpuProfile; +use crate::signature::HostCpuSignature; +use std::sync::OnceLock; + +/// The `--cpu-profile` value that selects the profile of the host's +/// generation. +pub const AUTO: &str = "auto"; + +/// A pinned profile: its pretty canonical JSON and its golden digest. +struct Pinned { + json: &'static str, + digest: &'static str, +} + +/// The pinned profiles. Released profiles are immutable: a change is a new +/// revision with a new ID, and a test checks every golden digest. +const PINNED: &[Pinned] = &[ + Pinned { + json: include_str!("../profiles/intel.skylake-sp.v1.json"), + digest: "sha256:e1c8e7965ec04693b2e3bd97eb93da55a2dc518118e48b563241772190d4b0a8", + }, + Pinned { + json: include_str!("../profiles/intel.icelake-sp.v1.json"), + digest: "sha256:5dbabb30bef86017f5d0858c2f3c056d345bfd33698e2f69877150a9cc66f812", + }, + Pinned { + json: include_str!("../profiles/intel.emeraldrapids.v1.json"), + digest: "sha256:84106dd216e6dc858c1e8f7813eff29b0ee61ab7968b676c74efb268f9ed700f", + }, +]; + +/// Returns every pinned profile. +pub fn pinned_profiles() -> &'static [CpuProfile] { + static CATALOG: OnceLock> = OnceLock::new(); + CATALOG.get_or_init(|| { + PINNED + .iter() + .map(|pinned| { + let profile = CpuProfile::from_pretty_json(pinned.json) + .unwrap_or_else(|error| panic!("invalid pinned CPU profile: {error}")); + assert_eq!( + profile.digest_string(), + pinned.digest, + "pinned CPU profile {} changed, but released profiles are immutable", + profile.id() + ); + profile + }) + .collect() + }) +} + +/// Returns the pinned profile `id`. +pub fn pinned(id: &str) -> Option<&'static CpuProfile> { + pinned_profiles().iter().find(|profile| profile.id() == id) +} + +/// Returns the name of the generation of the host CPU, if a pinned profile +/// serves it. +pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { + pinned_profiles() + .iter() + .find(|profile| profile.generation().contains(profile.vendor(), host)) + .map(|profile| profile.generation().name.as_str()) +} + +/// Selects the profile of the host CPU's generation: its latest pinned +/// revision. +/// +/// Fails with `E_PROFILE_HOST_UNKNOWN` when no pinned profile serves the +/// host; there is no host CPUID passthrough. +pub fn select_auto(host: &HostCpuSignature) -> Result<&'static CpuProfile, ProfileError> { + pinned_profiles() + .iter() + .filter(|profile| profile.generation().contains(profile.vendor(), host)) + .max_by_key(|profile| revision(profile.id())) + .ok_or_else(|| { + ProfileError::new( + ProfileErrorCode::ProfileHostUnknown, + format!( + "no pinned CPU profile serves the host CPU, {host}; pinned profiles: {}", + pinned_ids() + ), + ) + }) +} + +/// Selects the profile for `--cpu-profile spec`, [`AUTO`] or a pinned ID, +/// and checks that the host CPU is in its generation. +/// +/// Fails with `E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNKNOWN` for an ID that is +/// not pinned, or `E_CPU_GENERATION`. +pub fn select(spec: &str, host: &HostCpuSignature) -> Result<&'static CpuProfile, ProfileError> { + if spec == AUTO { + return select_auto(host); + } + let profile = pinned(spec).ok_or_else(|| unknown(spec))?; + check_generation(profile, host)?; + Ok(profile) +} + +/// Checks that the host CPU is in the profile's generation +/// (`E_CPU_GENERATION`). +pub fn check_generation(profile: &CpuProfile, host: &HostCpuSignature) -> Result<(), ProfileError> { + if profile.generation().contains(profile.vendor(), host) { + Ok(()) + } else { + Err(ProfileError::new( + ProfileErrorCode::CpuGeneration, + format!( + "the host CPU, {host}, is not in generation {} of CPU profile {}", + profile.generation().name, + profile.id() + ), + )) + } +} + +/// Verifies the CPU profile record of a snapshot: the embedded canonical +/// profile matches its recorded SHA-256 and recorded ID, and decodes +/// (`E_PROFILE_DIGEST`). +pub fn verify_profile_record( + id: &str, + sha256: &[u8], + embedded: &[u8], +) -> Result { + if canonical::sha256(embedded).as_slice() != sha256 { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!("the embedded CPU profile {id} does not match its recorded digest"), + )); + } + let profile = CpuProfile::decode(embedded)?; + if profile.id() != id { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!( + "the embedded CPU profile is {}, but the record names {id}", + profile.id() + ), + )); + } + Ok(profile) +} + +/// Returns the pinned profile that a snapshot recorded by ID and SHA-256. +/// +/// Fails with `E_PROFILE_UNKNOWN` when this OpenVMM does not pin the ID, and +/// with `E_PROFILE_DIGEST` when its pinned profile has another digest. +pub fn pinned_for_restore(id: &str, sha256: &[u8]) -> Result<&'static CpuProfile, ProfileError> { + let profile = pinned(id).ok_or_else(|| unknown(id))?; + if profile.digest().as_slice() != sha256 { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!( + "the snapshot's CPU profile {id} differs from the pinned profile of the same ID ({})", + profile.digest_string() + ), + )); + } + Ok(profile) +} + +/// Runs the profile checks of a restore: [`verify_profile_record`], +/// [`pinned_for_restore`], and [`check_generation`] for the destination +/// host. +pub fn restore_profile( + id: &str, + sha256: &[u8], + embedded: &[u8], + host: &HostCpuSignature, +) -> Result<&'static CpuProfile, ProfileError> { + verify_profile_record(id, sha256, embedded)?; + let profile = pinned_for_restore(id, sha256)?; + check_generation(profile, host)?; + Ok(profile) +} + +fn unknown(id: &str) -> ProfileError { + ProfileError::new( + ProfileErrorCode::ProfileUnknown, + format!( + "CPU profile {id:?} is not pinned in this OpenVMM; pinned profiles: {}", + pinned_ids() + ), + ) +} + +fn pinned_ids() -> String { + pinned_profiles() + .iter() + .map(|profile| profile.id()) + .collect::>() + .join(", ") +} + +/// Returns the revision of a valid profile ID. +fn revision(id: &str) -> u32 { + id.rsplit_once(".v") + .and_then(|(_, revision)| revision.parse().ok()) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive; + use crate::test_support::profile; + use test_with_tracing::test; + + const SKYLAKE: u32 = 0x0005_0654; + const CASCADE_LAKE: u32 = 0x0005_0657; + const ICELAKE: u32 = 0x0006_06a6; + const EMERALDRAPIDS: u32 = 0x000c_06f2; + const ALDER_LAKE: u32 = 0x0009_06a3; + + fn intel(signature: u32) -> HostCpuSignature { + HostCpuSignature::new(*b"GenuineIntel", signature) + } + + fn code(result: Result) -> ProfileErrorCode { + result.unwrap_err().code + } + + #[test] + fn pins_the_three_generations_with_their_golden_digests() { + let pinned = pinned_profiles() + .iter() + .map(|profile| (profile.id(), profile.digest_string())) + .collect::>(); + let golden = PINNED + .iter() + .zip([ + "intel.skylake-sp.v1", + "intel.icelake-sp.v1", + "intel.emeraldrapids.v1", + ]) + .map(|(pinned, id)| (id, pinned.digest.to_owned())) + .collect::>(); + assert_eq!(pinned, golden); + } + + #[test] + fn generations_are_known_and_disjoint() { + for (i, profile) in pinned_profiles().iter().enumerate() { + let generation = profile.generation(); + let known = derive::known_generation(&generation.name).unwrap(); + assert_eq!(profile.vendor(), known.vendor); + assert_eq!( + generation + .cpus + .iter() + .map(|cpu| (cpu.family, cpu.model, cpu.steppings)) + .collect::>(), + known.cpus + ); + for other in &pinned_profiles()[i + 1..] { + assert_ne!(other.id(), profile.id()); + if other.generation().name != generation.name { + for cpu in &generation.cpus { + assert!(!other.generation().cpus.iter().any(|other| { + (other.family, other.model) == (cpu.family, cpu.model) + && other.steppings[0] <= cpu.steppings[1] + && cpu.steppings[0] <= other.steppings[1] + })); + } + } + } + } + } + + #[test] + fn auto_selects_the_host_generation_or_fails() { + for (signature, id, generation) in [ + (SKYLAKE, "intel.skylake-sp.v1", "skylake-sp"), + (ICELAKE, "intel.icelake-sp.v1", "icelake-sp"), + (EMERALDRAPIDS, "intel.emeraldrapids.v1", "emeraldrapids"), + ] { + assert_eq!(select_auto(&intel(signature)).unwrap().id(), id); + assert_eq!(select(AUTO, &intel(signature)).unwrap().id(), id); + assert_eq!(generation_of(&intel(signature)), Some(generation)); + } + for host in [ + intel(CASCADE_LAKE), + intel(ALDER_LAKE), + HostCpuSignature::new(*b"AuthenticAMD", 0x00a1_0f11), + ] { + assert_eq!( + code(select_auto(&host)), + ProfileErrorCode::ProfileHostUnknown + ); + assert_eq!(generation_of(&host), None); + } + let error = select_auto(&intel(CASCADE_LAKE)).unwrap_err(); + assert!( + error.to_string().starts_with("[E_PROFILE_HOST_UNKNOWN] "), + "{error}" + ); + assert!(error.message.contains("model 85 stepping 7"), "{error}"); + } + + #[test] + fn explicit_selection_checks_the_id_and_the_generation() { + let host = intel(ICELAKE); + assert_eq!( + select("intel.icelake-sp.v1", &host).unwrap().id(), + "intel.icelake-sp.v1" + ); + assert_eq!( + code(select("intel.skylake-sp.v1", &host)), + ProfileErrorCode::CpuGeneration + ); + assert_eq!( + code(select("intel.icelake-sp.v2", &host)), + ProfileErrorCode::ProfileUnknown + ); + assert_eq!(code(select("", &host)), ProfileErrorCode::ProfileUnknown); + } + + #[test] + fn restore_checks_the_record_the_pinned_profile_and_the_host() { + let profile = profile("intel.icelake-sp.v1"); + let bytes = profile.encode(); + let digest = profile.digest(); + let host = intel(ICELAKE); + assert_eq!( + restore_profile(profile.id(), &digest, &bytes, &host).unwrap(), + profile + ); + + // A corrupted record. + let mut corrupted = bytes.clone(); + corrupted[10] ^= 1; + assert_eq!( + code(restore_profile(profile.id(), &digest, &corrupted, &host)), + ProfileErrorCode::ProfileDigest + ); + assert_eq!( + code(restore_profile(profile.id(), &digest[..31], &bytes, &host)), + ProfileErrorCode::ProfileDigest + ); + assert_eq!( + code(restore_profile( + "intel.skylake-sp.v1", + &digest, + &bytes, + &host + )), + ProfileErrorCode::ProfileDigest + ); + + // A profile this OpenVMM does not pin. + let mut unknown = profile.clone(); + unknown.id = "intel.icelake-sp.v9".to_owned(); + let unknown_bytes = unknown.encode(); + assert_eq!( + code(restore_profile( + unknown.id(), + &unknown.digest(), + &unknown_bytes, + &host + )), + ProfileErrorCode::ProfileUnknown + ); + + // A pinned ID whose content differs. + let mut changed = profile.clone(); + changed.description.push('!'); + assert_eq!( + code(restore_profile( + changed.id(), + &changed.digest(), + &changed.encode(), + &host + )), + ProfileErrorCode::ProfileDigest + ); + + // Another generation on the destination. + assert_eq!( + code(restore_profile( + profile.id(), + &digest, + &bytes, + &intel(EMERALDRAPIDS) + )), + ProfileErrorCode::CpuGeneration + ); + } +} diff --git a/vmm_core/cpu_profile/src/check.rs b/vmm_core/cpu_profile/src/check.rs new file mode 100644 index 00000000000..e270dece106 --- /dev/null +++ b/vmm_core/cpu_profile/src/check.rs @@ -0,0 +1,173 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Checking a host fingerprint against the profile of its generation, the +//! CPU part of host qualification. + +use crate::catalog; +use crate::error::ProfileError; +use crate::fingerprint::CpuFingerprint; +use crate::profile::CpuProfile; +use crate::signature::HostCpuSignature; +use crate::surface::HostCpuSurface; +use crate::surface::verify_support; + +/// The prefix of the summary line of a fingerprint check. +pub const SUMMARY_PREFIX: &str = "NVX-CPU-PROFILE:"; + +/// The outcome of checking a fingerprint against the profile that `auto` +/// selects for its host. +#[derive(Debug)] +pub struct FingerprintCheck { + /// The host CPU. + pub host: HostCpuSignature, + /// The profile that `auto` selects, if any. + pub profile: Option<&'static CpuProfile>, + /// Whether the backend supports the profile, or why not. + pub result: Result<(), ProfileError>, +} + +impl FingerprintCheck { + /// Returns the one-line summary of the check, for logs and CI: + /// + /// ```text + /// NVX-CPU-PROFILE: status=pass backend=kvm generation=icelake-sp profile=intel.icelake-sp.v1 profile_digest=sha256:… surface_digest=sha256:… host_invariant_tsc=yes + /// NVX-CPU-PROFILE: status=fail backend=whp generation=none profile=none surface_digest=sha256:… host_invariant_tsc=yes code=E_PROFILE_HOST_UNKNOWN detail="…" + /// ``` + /// + /// `host_invariant_tsc` reports the host OS's view, which host + /// qualification evaluates; it does not affect `status`. + pub fn summary_line(&self, fingerprint: &CpuFingerprint) -> String { + let mut line = format!( + "{SUMMARY_PREFIX} status={} backend={} generation={} profile={}", + if self.result.is_ok() { "pass" } else { "fail" }, + fingerprint.backend.name, + self.profile + .map_or("none", |profile| profile.generation().name.as_str()), + self.profile.map_or("none", |profile| profile.id()), + ); + if let Some(profile) = self.profile { + line.push_str(&format!(" profile_digest={}", profile.digest_string())); + } + line.push_str(&format!( + " surface_digest={} host_invariant_tsc={}", + fingerprint.surface_digest, + if fingerprint.host.cpu.invariant_tsc { + "yes" + } else { + "no" + } + )); + if let Err(error) = &self.result { + line.push_str(&format!(" code={} detail={:?}", error.code, error.message)); + } + line + } +} + +/// Checks a host fingerprint: selects the profile of the host's generation +/// (`E_PROFILE_HOST_UNKNOWN`) and verifies that the backend supports it +/// (`E_PROFILE_UNSUPPORTED`). +pub fn check_fingerprint(fingerprint: &CpuFingerprint) -> FingerprintCheck { + let cpu = &fingerprint.host.cpu; + let mut vendor = [0; 12]; + if cpu.vendor.len() == vendor.len() { + vendor.copy_from_slice(cpu.vendor.as_bytes()); + } + let host = HostCpuSignature::new(vendor, cpu.signature.0); + match catalog::select_auto(&host) { + Ok(profile) => FingerprintCheck { + host, + profile: Some(profile), + result: verify_support( + profile, + &HostCpuSurface::from_fingerprint(&fingerprint.backend), + ), + }, + Err(error) => FingerprintCheck { + host, + profile: None, + result: Err(error), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Hex32; + use crate::ProfileErrorCode; + use crate::cpuid::CpuidEntry; + use crate::test_support::fingerprint; + use crate::test_support::fingerprint_with; + use crate::test_support::profile; + use crate::test_support::profile_entries; + use test_with_tracing::test; + + #[test] + fn passes_a_host_that_supports_its_generation() { + let profile = profile("intel.icelake-sp.v1"); + let fingerprint = fingerprint(profile, "kvm"); + let check = check_fingerprint(&fingerprint); + check.result.as_ref().unwrap(); + assert_eq!( + check.profile.map(CpuProfile::id), + Some("intel.icelake-sp.v1") + ); + assert_eq!( + check.summary_line(&fingerprint), + format!( + "NVX-CPU-PROFILE: status=pass backend=kvm generation=icelake-sp \ + profile=intel.icelake-sp.v1 profile_digest={} surface_digest={} \ + host_invariant_tsc=yes", + profile.digest_string(), + fingerprint.surface_digest + ) + ); + } + + #[test] + fn fails_unknown_hosts_and_unsupported_profiles() { + let profile = profile("intel.icelake-sp.v1"); + let mut alder_lake = fingerprint(profile, "whp"); + alder_lake.host.cpu.signature = Hex32(0x0009_06a3); + alder_lake.host.cpu.invariant_tsc = false; + let check = check_fingerprint(&alder_lake); + assert_eq!( + check.result.as_ref().unwrap_err().code, + ProfileErrorCode::ProfileHostUnknown + ); + let line = check.summary_line(&alder_lake); + assert!( + line.starts_with("NVX-CPU-PROFILE: status=fail backend=whp generation=none profile=none surface_digest="), + "{line}" + ); + assert!( + line.contains( + " host_invariant_tsc=no code=E_PROFILE_HOST_UNKNOWN detail=\"no pinned CPU profile" + ), + "{line}" + ); + + let mut entries = profile_entries(profile); + let entry = entries + .iter_mut() + .find(|entry| entry.key() == (7, Some(0))) + .unwrap(); + let [eax, ebx, ecx, edx] = entry.registers(); + *entry = CpuidEntry::new(7, Some(0), [eax, ebx & !(1 << 16), ecx, edx]); + let no_avx512 = fingerprint_with(profile, "kvm", entries); + let check = check_fingerprint(&no_avx512); + assert_eq!( + check.result.as_ref().unwrap_err().code, + ProfileErrorCode::ProfileUnsupported + ); + assert!( + check + .summary_line(&no_avx512) + .contains("code=E_PROFILE_UNSUPPORTED detail=\"the backend does not support CPU profile intel.icelake-sp.v1: CPUID 0x7.0 EBX bit 16 is not supported\""), + "{}", + check.summary_line(&no_avx512) + ); + } +} diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs new file mode 100644 index 00000000000..606fad0261d --- /dev/null +++ b/vmm_core/cpu_profile/src/derive.rs @@ -0,0 +1,893 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Deriving a CPU profile from host fingerprints. +//! +//! A profile serves one CPU generation on every backend, so it is the +//! intersection of what every backend supports on every fingerprinted host +//! of the generation, under a fixed policy: +//! +//! 1. Within each backend, and then across backends, each CPUID register is +//! combined by its class: feature flags by intersection, numeric limits +//! by minimum, and descriptive values (signature, cache, TLB) by majority, +//! one vote per host within a backend and one per backend across them. A +//! tie is an error. +//! 2. Only the leaves of [`DATA_LEAVES`] keep data; every other leaf in range +//! is zero. The time ABI's CPU time bits are applied, and features a VM +//! must not see (virtualization, power management, performance +//! monitoring, debug, SGX, RDT, PT, CET, key locker) are cleared. +//! 3. The XSAVE features are the intersection, within [`ALLOWED_XCR0`], and +//! the XSAVE leaf is rebuilt from them; every host must agree on the +//! layout. A feature whose XSAVE state is not enabled is cleared. +//! 4. The brand string is the hosts' common brand. +//! 5. `IA32_ARCH_CAPABILITIES` is pinned in [`ARCH_CAPABILITIES_PINNED_MASK`] +//! to what every backend can present: KVM's value, and for MSHV and WHP +//! the bits their processor feature banks derive. `ITS_NO` is pinned +//! clear because the Hyper-V backends cannot present it. +//! 6. The bits OpenVMM sets per VM are zero and unmasked. + +use crate::Hex32; +use crate::Hex64; +use crate::cpuid; +use crate::cpuid::EXTENDED_LEAF_BASE; +use crate::cpuid::XsaveComponent; +use crate::fingerprint::CpuFingerprint; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::hv_banks; +use crate::profile::CpuProfile; +use crate::profile::CpuidLeafValue; +use crate::profile::Generation; +use crate::profile::GenerationCpu; +use crate::profile::PinnedMsr; +use crate::profile::Provenance; +use crate::profile::ProvenanceSource; +use crate::profile::VM_OWNED_LEAVES; +use crate::profile::describe_leaf; +use crate::profile::pinned_mask; +use crate::signature::HostCpuSignature; +use crate::surface::RegisterClass; +use crate::surface::field_value; +use crate::surface::register_class; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use thiserror::Error; + +/// The version of the derivation policy, recorded in each profile's +/// provenance. +pub const POLICY: &str = "openvmm-cpu-profile-policy/v1"; + +/// The leaves that keep data; every other leaf in range is zero. +pub const DATA_LEAVES: [u32; 16] = [ + 0x0, + 0x1, + 0x2, + 0x4, + 0x6, + 0x7, + 0xd, + 0x18, + EXTENDED_LEAF_BASE, + EXTENDED_LEAF_BASE + 1, + EXTENDED_LEAF_BASE + 2, + EXTENDED_LEAF_BASE + 3, + EXTENDED_LEAF_BASE + 4, + EXTENDED_LEAF_BASE + 6, + EXTENDED_LEAF_BASE + 7, + EXTENDED_LEAF_BASE + 8, +]; + +/// The XCR0 bits a profile may enable: x87, SSE, AVX, MPX, AVX-512, and +/// PKRU. AMX needs dynamic XSAVE permissions that no backend grants yet. +pub const ALLOWED_XCR0: u64 = 0x2ff; + +/// The IA32_XSS bits a profile may enable: none yet. +pub const ALLOWED_XSS: u64 = 0; + +/// The XSAVE extensions of `CPUID.(0xd,1):EAX` a profile may enable: +/// XSAVEOPT, XSAVEC, XGETBV with ECX=1, and XSAVES. XFD only matters with +/// AMX. +pub const ALLOWED_XSAVE_EXTENSIONS: u32 = 0xf; + +/// `IA32_ARCH_CAPABILITIES.ITS_NO`. +pub const ARCH_CAPABILITIES_ITS_NO: u64 = 1 << 62; + +/// The `IA32_ARCH_CAPABILITIES` bits that profiles pin: those the Hyper-V +/// processor feature banks derive, and `ITS_NO`. +pub const ARCH_CAPABILITIES_PINNED_MASK: u64 = + hv_banks::ARCH_CAPABILITIES_BANK_MASK | ARCH_CAPABILITIES_ITS_NO; + +/// A CPU generation that profiles are derived for. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct KnownGeneration { + /// The CPUID vendor string. + pub vendor: &'static str, + /// The vendor as profile IDs spell it. + pub vendor_short: &'static str, + /// The generation name, as logs and reports spell it. + pub name: &'static str, + /// The CPUs of the generation: family, model, and inclusive stepping + /// range. + pub cpus: &'static [(u32, u32, [u32; 2])], + /// A human-readable name. + pub description: &'static str, +} + +impl KnownGeneration { + fn generation(&self) -> Generation { + Generation { + name: self.name.to_owned(), + cpus: self + .cpus + .iter() + .map(|&(family, model, steppings)| GenerationCpu { + family, + model, + steppings, + }) + .collect(), + } + } +} + +/// The generations profiles exist for. +pub const KNOWN_GENERATIONS: [KnownGeneration; 3] = [ + KnownGeneration { + vendor: "GenuineIntel", + vendor_short: "intel", + name: "skylake-sp", + // Steppings 5 to 7 are Cascade Lake, and 10 and 11 Cooper Lake. + cpus: &[(6, 85, [0, 4])], + description: "Intel Xeon Scalable, first generation (Skylake-SP)", + }, + KnownGeneration { + vendor: "GenuineIntel", + vendor_short: "intel", + name: "icelake-sp", + cpus: &[(6, 106, [0, 15])], + description: "Intel Xeon Scalable, third generation (Ice Lake-SP)", + }, + KnownGeneration { + vendor: "GenuineIntel", + vendor_short: "intel", + name: "emeraldrapids", + cpus: &[(6, 207, [0, 15])], + description: "Intel Xeon Scalable, fifth generation (Emerald Rapids)", + }, +]; + +/// Returns the known generation named `name`. +pub fn known_generation(name: &str) -> Option<&'static KnownGeneration> { + KNOWN_GENERATIONS + .iter() + .find(|generation| generation.name == name) +} + +/// A failure to derive a profile. +#[derive(Debug, Error)] +pub enum DeriveError { + /// No fingerprint was given. + #[error("no fingerprints")] + NoFingerprints, + /// A fingerprint's host is not in the generation. + #[error("the {backend} fingerprint {digest} is of {host}, not generation {generation}")] + WrongGeneration { + /// The fingerprint's backend. + backend: String, + /// The fingerprint's digest. + digest: String, + /// The host CPU. + host: String, + /// The generation. + generation: String, + }, + /// The values of a descriptive register tie. + #[error("no majority for {what}: {values}")] + Tie { + /// The register. + what: String, + /// The tied values and their votes. + values: String, + }, + /// The hosts report different brand strings. + #[error("the hosts report different brand strings: {0:?}")] + Brand(Vec), + /// The hosts disagree on the layout of an XSAVE component. + #[error("the hosts disagree on the layout of XSAVE component {0}")] + XsaveLayout(u32), + /// A feature the time ABI requires is missing. + #[error("not every backend supports {0}, which the time ABI requires")] + TimeFeature(&'static str), + /// The derived profile is invalid. + #[error("the derived profile is invalid: {0}")] + Invalid(String), +} + +/// A CPUID table: the four registers of each leaf and subleaf. +type Table = BTreeMap<(u32, Option), [u32; 4]>; + +/// Derives revision `revision` of the profile of `generation` from the +/// `fingerprints` of its hosts, on any backends. +pub fn derive_profile( + generation: &KnownGeneration, + revision: u32, + fingerprints: &[CpuFingerprint], +) -> Result { + if fingerprints.is_empty() { + return Err(DeriveError::NoFingerprints); + } + let profile_generation = generation.generation(); + for fingerprint in fingerprints { + let cpu = &fingerprint.host.cpu; + let mut vendor = [0; 12]; + let host_vendor = cpu.vendor.as_bytes(); + if host_vendor.len() == vendor.len() { + vendor.copy_from_slice(host_vendor); + } + let host = HostCpuSignature::new(vendor, cpu.signature.0); + if !profile_generation.contains(generation.vendor, &host) { + return Err(DeriveError::WrongGeneration { + backend: fingerprint.backend.name.clone(), + digest: fingerprint.digest.clone(), + host: host.to_string(), + generation: generation.name.to_owned(), + }); + } + } + + let mut backends = BTreeMap::<&str, Vec<&CpuFingerprint>>::new(); + for fingerprint in fingerprints { + backends + .entry(&fingerprint.backend.name) + .or_default() + .push(fingerprint); + } + let backend_tables = backends + .iter() + .map(|(backend, members)| { + let tables = members + .iter() + .map(|member| table(&member.backend.cpuid)) + .collect::>(); + combine(&tables, &format!("{backend} hosts")) + }) + .collect::, _>>()?; + let combined = combine(&backend_tables, "backends")?; + + let mut leaves = apply_policy(&combined)?; + + // The brand string. + let brands = fingerprints + .iter() + .map(|fingerprint| fingerprint.host.cpu.brand.clone()) + .collect::>(); + if brands.len() != 1 { + return Err(DeriveError::Brand(brands.into_iter().collect())); + } + let brand = brand_leaves(brands.first().expect("one brand")); + for (leaf, value) in (EXTENDED_LEAF_BASE + 2..).zip(brand) { + if let Some(entry) = leaves.get_mut(&(leaf, None)) { + *entry = value; + } + } + + // XSAVE. + let xcr0 = fingerprints.iter().fold(ALLOWED_XCR0, |xcr0, fingerprint| { + xcr0 & fingerprint.backend.xsave.xcr0_supported.0 + }); + let xss = fingerprints.iter().fold(ALLOWED_XSS, |xss, fingerprint| { + xss & fingerprint.backend.xsave.xss_supported.0 + }); + let mut components = Vec::::new(); + for index in (2..63).filter(|index| (xcr0 | xss) & (1 << index) != 0) { + let mut layouts = fingerprints.iter().map(|fingerprint| { + fingerprint + .backend + .xsave + .components + .iter() + .find(|component| component.index == index) + }); + let first = layouts.next().flatten(); + match first { + Some(component) if layouts.all(|layout| layout == Some(component)) => { + components.push(component.clone()); + } + _ => return Err(DeriveError::XsaveLayout(index)), + } + } + let extensions = fingerprints + .iter() + .fold(ALLOWED_XSAVE_EXTENSIONS, |extensions, fingerprint| { + extensions & fingerprint.backend.xsave.extensions.0 + }); + leaves.retain(|&(leaf, _), _| leaf != 0xd); + leaves.insert( + (0xd, Some(0)), + [ + xcr0 as u32, + 0, + cpuid::xsave_standard_size(&components, xcr0), + (xcr0 >> 32) as u32, + ], + ); + leaves.insert( + (0xd, Some(1)), + [extensions, 0, xss as u32, (xss >> 32) as u32], + ); + for component in &components { + let flags = u32::from(component.supervisor) + | u32::from(component.align64) << 1 + | u32::from(component.xfd) << 2; + leaves.insert( + (0xd, Some(component.index)), + [component.size, component.offset, flags, 0], + ); + } + clear_stateless_features(&mut leaves, xcr0); + + // IA32_ARCH_CAPABILITIES. + let mut msrs = Vec::new(); + if leaves.get(&(0x7, Some(0))).copied().unwrap_or_default()[3] & 1 << 29 != 0 { + let value = fingerprints + .iter() + .map(|fingerprint| { + let backend = &fingerprint.backend; + match backend.msrs.arch_capabilities { + Some(value) => value.0, + None => hv_banks::fingerprint_banks(backend) + .map_or(0, hv_banks::arch_capabilities_from_banks), + } + }) + .fold(ARCH_CAPABILITIES_PINNED_MASK, |value, presentable| { + value & presentable + }) + & !ARCH_CAPABILITIES_ITS_NO; + msrs.push(PinnedMsr { + index: Hex32(IA32_ARCH_CAPABILITIES), + value: Hex64(value), + mask: Hex64(ARCH_CAPABILITIES_PINNED_MASK), + }); + } + + // Unmask the bits OpenVMM sets per VM. + let leaves = leaves + .into_iter() + .map(|((leaf, subleaf), value)| { + let mask = pinned_mask(leaf, subleaf, value); + let value = [0, 1, 2, 3].map(|register| value[register] & mask[register]); + CpuidLeafValue::new(leaf, subleaf, value, mask) + }) + .collect::>(); + let physical_address_width = combined + .get(&(EXTENDED_LEAF_BASE + 8, None)) + .copied() + .unwrap_or_default()[0] as u8; + + let provenance = Provenance { + method: format!( + "derived by cpu_profile::derive under {POLICY}: per-register consensus within each \ + backend, then across backends, of the hosts' supported CPU surfaces" + ), + sources: backends + .iter() + .map(|(backend, members)| ProvenanceSource { + backend: (*backend).to_owned(), + hosts: members.len() as u32, + surface_digests: members + .iter() + .map(|member| member.surface_digest.clone()) + .collect::>() + .into_iter() + .collect(), + }) + .collect(), + }; + CpuProfile::from_parts( + format!( + "{}.{}.v{revision}", + generation.vendor_short, generation.name + ), + format!( + "{}, on every backend; NVX time ABI v1", + generation.description + ), + generation.vendor.to_owned(), + profile_generation, + leaves, + xcr0, + xss, + physical_address_width, + msrs, + provenance, + ) + .map_err(DeriveError::Invalid) +} + +/// Returns the table of a fingerprint's CPUID for combining: without the +/// hypervisor range, the XSAVE leaf (rebuilt from the fingerprints' XSAVE +/// information), and the brand string (the hosts' common brand), and with +/// the bits OpenVMM sets per VM cleared, so hosts are compared only on what a +/// profile pins. +fn table(entries: &[cpuid::CpuidEntry]) -> Table { + entries + .iter() + .filter(|entry| { + let leaf = entry.leaf.0; + !(cpuid::HYPERVISOR_LEAF_BASE..EXTENDED_LEAF_BASE).contains(&leaf) + && leaf != 0xd + && !(EXTENDED_LEAF_BASE + 2..=EXTENDED_LEAF_BASE + 4).contains(&leaf) + }) + .map(|entry| { + let (leaf, subleaf) = entry.key(); + let value = entry.registers(); + let mask = pinned_mask(leaf, subleaf, value); + ((leaf, subleaf), [0, 1, 2, 3].map(|register| value[register] & mask[register])) + }) + .collect() +} + +/// Combines `tables` register by register; see the module documentation. +fn combine(tables: &[Table], voters: &str) -> Result { + let keys = tables + .iter() + .flat_map(|table| table.keys().copied()) + .collect::>(); + let mut combined = Table::new(); + for (leaf, subleaf) in keys { + let mut registers = [0; 4]; + for (register, out) in registers.iter_mut().enumerate() { + let values = tables + .iter() + .map(|table| table.get(&(leaf, subleaf)).copied().unwrap_or_default()[register]) + .collect::>(); + *out = match register_class(leaf, subleaf.unwrap_or(0), register) { + RegisterClass::Features => values.iter().fold(!0, |bits, value| bits & value), + RegisterClass::Limits(fields) => fields.iter().fold(0, |out, &(shift, width)| { + let minimum = values + .iter() + .map(|&value| field_value(value, shift, width)) + .min() + .unwrap_or(0); + out | minimum << shift + }), + RegisterClass::Informational => majority(&values).ok_or_else(|| { + let mut votes = BTreeMap::::new(); + for &value in &values { + *votes.entry(value).or_default() += 1; + } + DeriveError::Tie { + what: format!( + "{} {} among the {voters}", + describe_leaf(leaf, subleaf), + ["EAX", "EBX", "ECX", "EDX"][register] + ), + values: votes + .iter() + .map(|(value, count)| format!("{value:#x} ({count})")) + .collect::>() + .join(", "), + } + })?, + }; + } + combined.insert((leaf, subleaf), registers); + } + Ok(combined) +} + +/// Returns the value with the most votes, or `None` on a tie. +fn majority(values: &[u32]) -> Option { + let mut votes = BTreeMap::::new(); + for &value in values { + *votes.entry(value).or_default() += 1; + } + let best = votes.values().copied().max()?; + let mut winners = votes.iter().filter(|&(_, &count)| count == best); + let (&winner, _) = winners.next()?; + winners.next().is_none().then_some(winner) +} + +/// Lays out the profile's leaves from the combined table: the dense leaf and +/// subleaf set, the [`DATA_LEAVES`] allowlist, the CPU time bits, and the +/// features a VM must not see. The XSAVE leaf and the brand are filled in +/// later. +fn apply_policy(combined: &Table) -> Result { + let get = |leaf: u32, subleaf: Option| { + combined.get(&(leaf, subleaf)).copied().unwrap_or_default() + }; + let max_basic = get(0, None)[0]; + let max_extended = get(EXTENDED_LEAF_BASE, None)[0]; + let mut leaves = Table::new(); + let all_leaves = (0..=max_basic).chain(EXTENDED_LEAF_BASE..=max_extended); + for leaf in all_leaves.filter(|leaf| !VM_OWNED_LEAVES.contains(leaf)) { + let data = DATA_LEAVES.contains(&leaf); + if !cpuid::is_indexed_leaf(leaf) { + leaves.insert((leaf, None), if data { get(leaf, None) } else { [0; 4] }); + continue; + } + let last = match leaf { + _ if !data => 0, + 0x4 => (0..0x40) + .find(|&subleaf| get(leaf, Some(subleaf))[0] & 0x1f == 0) + .unwrap_or(0x3f), + // Rebuilt from the XSAVE features later. + 0xd => continue, + _ => get(leaf, Some(0))[0].min(0x3f), + }; + for subleaf in 0..=last { + let value = if data { + get(leaf, Some(subleaf)) + } else { + [0; 4] + }; + leaves.insert((leaf, Some(subleaf)), value); + } + } + + let edit = + |leaves: &mut Table, key: (u32, Option), register: usize, set: u32, clear: u32| { + if let Some(value) = leaves.get_mut(&key) { + value[register] = (value[register] | set) & !clear; + } + }; + let require = + |leaves: &Table, key: (u32, Option), register: usize, bit: u32, what: &'static str| { + if leaves + .get(&key) + .is_none_or(|value| value[register] & bit == 0) + { + Err(DeriveError::TimeFeature(what)) + } else { + Ok(()) + } + }; + // CPUID.1: the hypervisor bit (time ABI); no VMX, SMX, EST, TM2, DTES64, + // MONITOR, DS-CPL, SDBG, xTPR, PDCM (time ABI, no PMU), DCA, or + // TSC-deadline (time ABI); no DS, ACPI, TM, or PBE. + edit( + &mut leaves, + (0x1, None), + 2, + 1 << 31, + 1 << 2 + | 1 << 3 + | 1 << 4 + | 1 << 5 + | 1 << 6 + | 1 << 7 + | 1 << 8 + | 1 << 11 + | 1 << 14 + | 1 << 15 + | 1 << 18 + | 1 << 24, + ); + edit( + &mut leaves, + (0x1, None), + 3, + 0, + 1 << 21 | 1 << 22 | 1 << 29 | 1 << 31, + ); + require(&leaves, (0x1, None), 3, 1 << 4, "the TSC")?; + // CPUID.6: ARAT only (time ABI). + leaves.insert((0x6, None), [1 << 2, 0, 0, 0]); + // CPUID.(7,0): no TSC_ADJUST (time ABI), SGX, RDT, or Intel PT; no CET, + // key locker, or SGX launch control; no hybrid, PCONFIG, or arch LBR. + edit( + &mut leaves, + (0x7, Some(0)), + 1, + 0, + 1 << 1 | 1 << 2 | 1 << 12 | 1 << 15 | 1 << 25, + ); + edit( + &mut leaves, + (0x7, Some(0)), + 2, + 0, + 1 << 7 | 1 << 23 | 1 << 30, + ); + edit( + &mut leaves, + (0x7, Some(0)), + 3, + 0, + 1 << 15 | 1 << 18 | 1 << 19 | 1 << 20, + ); + // CPUID.0x80000001: RDTSCP (time ABI). + require( + &leaves, + (EXTENDED_LEAF_BASE + 1, None), + 3, + 1 << 27, + "RDTSCP", + )?; + // CPUID.0x80000007: the invariant TSC only (time ABI). + leaves.insert((EXTENDED_LEAF_BASE + 7, None), [0, 0, 0, 1 << 8]); + // CPUID.0x80000008: the address widths only; no GuestPhysBits, which only + // newer KVM reports, and no AMD core count. + edit( + &mut leaves, + (EXTENDED_LEAF_BASE + 8, None), + 0, + 0, + 0xffff_0000, + ); + edit(&mut leaves, (EXTENDED_LEAF_BASE + 8, None), 2, 0, !0); + edit(&mut leaves, (EXTENDED_LEAF_BASE + 8, None), 3, 0, !0); + Ok(leaves) +} + +/// Clears the features whose XSAVE state `xcr0` does not enable. +fn clear_stateless_features(leaves: &mut Table, xcr0: u64) { + const AVX: u64 = 1 << 2; + const MPX: u64 = 3 << 3; + const AVX512: u64 = 7 << 5; + const PKRU: u64 = 1 << 9; + let enabled = |state: u64| xcr0 & state == state; + if let Some(value) = leaves.get_mut(&(0x1, None)) { + if !enabled(AVX) { + // AVX, FMA, and F16C. + value[2] &= !(1 << 28 | 1 << 12 | 1 << 29); + } + } + if let Some(value) = leaves.get_mut(&(0x7, Some(0))) { + if !enabled(AVX) { + // AVX2, VAES, and VPCLMULQDQ. + value[1] &= !(1 << 5); + value[2] &= !(1 << 9 | 1 << 10); + } + if !enabled(MPX) { + value[1] &= !(1 << 14); + } + if !enabled(AVX512) { + // AVX512F, DQ, IFMA, PF, ER, CD, BW, and VL; VBMI, VBMI2, VNNI, + // BITALG, and VPOPCNTDQ; 4VNNIW, 4FMAPS, VP2INTERSECT, and FP16. + value[1] &= + !(1 << 16 | 1 << 17 | 1 << 21 | 1 << 26 | 1 << 27 | 1 << 28 | 1 << 30 | 1 << 31); + value[2] &= !(1 << 1 | 1 << 6 | 1 << 11 | 1 << 12 | 1 << 14); + value[3] &= !(1 << 2 | 1 << 3 | 1 << 8 | 1 << 23); + } + if !enabled(PKRU) { + // PKU. + value[2] &= !(1 << 3); + } + } + if let Some(value) = leaves.get_mut(&(0x7, Some(1))) { + if !enabled(AVX) { + // AVX-VNNI. + value[0] &= !(1 << 4); + } + if !enabled(AVX512) { + // AVX512-BF16. + value[0] &= !(1 << 5); + } + } +} + +/// Returns the CPUID brand string leaves `0x80000002..=0x80000004` for +/// `brand`, padded with zeros. +fn brand_leaves(brand: &str) -> [[u32; 4]; 3] { + let mut bytes = [0u8; 48]; + let brand = brand.as_bytes(); + let len = brand.len().min(47); + bytes[..len].copy_from_slice(&brand[..len]); + let mut leaves = [[0; 4]; 3]; + for (i, word) in bytes.as_chunks::<4>().0.iter().enumerate() { + leaves[i / 4][i % 4] = u32::from_le_bytes(*word); + } + leaves +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cpuid::CpuidEntry; + use crate::test_support::fingerprint; + use crate::test_support::fingerprint_with; + use crate::test_support::profile; + use crate::test_support::profile_entries; + use test_with_tracing::test; + + const ICELAKE: &str = "intel.icelake-sp.v1"; + + fn generation(name: &str) -> &'static KnownGeneration { + known_generation(name).unwrap() + } + + /// Returns the fingerprint of a `backend` host of `profile`'s CPU whose + /// CPUID is the profile's, changed by `edit`. + fn edited( + profile: &CpuProfile, + backend: &str, + edit: impl FnOnce(&mut Vec), + ) -> CpuFingerprint { + let mut entries = profile_entries(profile); + edit(&mut entries); + fingerprint_with(profile, backend, entries) + } + + /// Changes register `register` of `leaf` and `subleaf`. + fn set( + entries: &mut [CpuidEntry], + leaf: u32, + subleaf: Option, + register: usize, + f: impl FnOnce(u32) -> u32, + ) { + let entry = entries + .iter_mut() + .find(|entry| entry.key() == (leaf, subleaf)) + .unwrap(); + let mut registers = entry.registers(); + registers[register] = f(registers[register]); + *entry = CpuidEntry::new(leaf, subleaf, registers); + } + + #[test] + fn rederiving_a_pinned_profile_reproduces_it() { + for pinned in crate::pinned_profiles() { + let known = generation(&pinned.generation().name); + let derived = derive_profile( + known, + 1, + &[fingerprint(pinned, "kvm"), fingerprint(pinned, "whp")], + ) + .unwrap(); + assert_eq!(derived.id(), pinned.id()); + assert_eq!(derived.description(), pinned.description()); + assert_eq!(derived.generation(), pinned.generation()); + assert_eq!(derived.cpuid(), pinned.cpuid()); + assert_eq!(derived.xcr0(), pinned.xcr0()); + assert_eq!(derived.xss(), pinned.xss()); + assert_eq!(derived.xsave_components(), pinned.xsave_components()); + assert_eq!( + derived.physical_address_width(), + pinned.physical_address_width() + ); + assert_eq!(derived.msrs(), pinned.msrs()); + assert_eq!( + derived + .provenance() + .sources + .iter() + .map(|source| (source.backend.as_str(), source.hosts)) + .collect::>(), + [("kvm", 1), ("whp", 1)] + ); + } + } + + #[test] + fn votes_only_on_pinned_bits() { + // KVM reports the host's topology fields and no brand string; two + // backends must still agree. + let pinned = profile(ICELAKE); + let kvm = edited(pinned, "kvm", |entries| { + set(entries, 1, None, 1, |ebx| ebx | 8 << 16); + set(entries, 4, Some(0), 0, |eax| eax | 0xc000_0000); + for leaf in 0x8000_0002..=0x8000_0004 { + set(entries, leaf, None, 0, |_| 0); + } + }); + let derived = derive_profile(generation("icelake-sp"), 1, &[kvm, fingerprint(pinned, "whp")]).unwrap(); + assert_eq!(derived.cpuid(), pinned.cpuid()); + } + + #[test] + fn intersects_features_and_votes_on_descriptions() { + let pinned = profile(ICELAKE); + let fingerprints = [ + // PKU, and another TLB descriptor. + edited(pinned, "kvm", |entries| { + set(entries, 7, Some(0), 2, |ecx| ecx | 1 << 3); + set(entries, 2, None, 1, |ebx| ebx + 1); + }), + // No AVX512-VBMI. + edited(pinned, "mshv", |entries| { + set(entries, 7, Some(0), 2, |ecx| ecx & !(1 << 1)); + }), + fingerprint(pinned, "whp"), + ]; + let derived = derive_profile(generation("icelake-sp"), 2, &fingerprints).unwrap(); + assert_eq!(derived.id(), "intel.icelake-sp.v2"); + assert_eq!(derived.lookup(7, 0)[2], pinned.lookup(7, 0)[2] & !(1 << 1)); + assert_eq!(derived.lookup(2, 0), pinned.lookup(2, 0)); + + // Two backends that disagree have no majority. + let tie = derive_profile(generation("icelake-sp"), 2, &fingerprints[..2]).unwrap_err(); + assert!( + matches!(&tie, DeriveError::Tie { what, .. } if what == "CPUID 0x2 EBX among the backends"), + "{tie}" + ); + } + + #[test] + fn pins_the_arch_capabilities_every_backend_can_present() { + let pinned = profile(ICELAKE); + let mut kvm = fingerprint(pinned, "kvm"); + kvm.backend.msrs.arch_capabilities = Some(Hex64(0x4000_0000_0c00_016d)); + let mut whp = fingerprint(pinned, "whp"); + whp.backend.msrs.arch_capabilities = None; + whp.backend.set_feature_bank( + "whp.capability.ProcessorFeaturesBanks.bank0", + 0x2e0a_8bff_e7f7_859f, + ); + whp.backend.set_feature_bank( + "whp.capability.ProcessorFeaturesBanks.bank1", + 0x0001_000e_0000_00f1, + ); + let derived = derive_profile(generation("icelake-sp"), 1, &[kvm.clone(), whp]).unwrap(); + assert_eq!( + derived.msr(IA32_ARCH_CAPABILITIES), + Some((0x0800_0121, ARCH_CAPABILITIES_PINNED_MASK)) + ); + // ITS_NO stays clear even where every backend presents it. + let derived = derive_profile(generation("icelake-sp"), 1, &[kvm]).unwrap(); + assert_eq!( + derived.msr(IA32_ARCH_CAPABILITIES), + Some((0x0c00_0129, ARCH_CAPABILITIES_PINNED_MASK)) + ); + } + + #[test] + fn clears_features_whose_xsave_state_is_disabled() { + let pinned = profile(ICELAKE); + // No AVX-512 state. + let fingerprint = edited(pinned, "kvm", |entries| { + set(entries, 0xd, Some(0), 0, |eax| eax & !0xe0); + entries.retain(|entry| !matches!(entry.key(), (0xd, Some(5..=7)))); + }); + let derived = derive_profile(generation("icelake-sp"), 1, &[fingerprint]).unwrap(); + assert_eq!(derived.xcr0(), 0x7); + assert_eq!(derived.lookup(7, 0)[1] & (1 << 16 | 1 << 31), 0); + assert_eq!(derived.lookup(7, 0)[2] & (1 << 1 | 1 << 14), 0); + assert_eq!(derived.lookup(0xd, 0)[2], 832); + assert_eq!(derived.xsave_components().len(), 1); + } + + #[test] + fn rejects_inconsistent_inputs() { + let pinned = profile(ICELAKE); + assert!(matches!( + derive_profile(generation("icelake-sp"), 1, &[]), + Err(DeriveError::NoFingerprints) + )); + assert!(matches!( + derive_profile(generation("skylake-sp"), 1, &[fingerprint(pinned, "kvm")]), + Err(DeriveError::WrongGeneration { .. }) + )); + + let mut renamed = fingerprint(pinned, "whp"); + renamed.host.cpu.brand.push('!'); + assert!(matches!( + derive_profile( + generation("icelake-sp"), + 1, + &[fingerprint(pinned, "kvm"), renamed] + ), + Err(DeriveError::Brand(_)) + )); + + let moved = edited(pinned, "whp", |entries| { + set(entries, 0xd, Some(7), 1, |offset| offset + 64); + }); + assert!(matches!( + derive_profile( + generation("icelake-sp"), + 1, + &[fingerprint(pinned, "kvm"), moved] + ), + Err(DeriveError::XsaveLayout(7)) + )); + + let no_rdtscp = edited(pinned, "whp", |entries| { + set(entries, 0x8000_0001, None, 3, |edx| edx & !(1 << 27)); + }); + assert!(matches!( + derive_profile(generation("icelake-sp"), 1, &[no_rdtscp]), + Err(DeriveError::TimeFeature("RDTSCP")) + )); + } +} diff --git a/vmm_core/cpu_profile/src/effective.rs b/vmm_core/cpu_profile/src/effective.rs new file mode 100644 index 00000000000..f596a89a45f --- /dev/null +++ b/vmm_core/cpu_profile/src/effective.rs @@ -0,0 +1,591 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The effective guest CPUID of a VM: its profile's pinned values, the fields +//! its topology and APIC mode define, and the hypervisor identity leaves. + +use crate::canonical; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::profile::CpuProfile; +use crate::profile::CpuidLeafValue; +use crate::profile::VM_OWNED_LEAVES; +use crate::profile::describe_leaf; +use crate::profile::find; +use crate::profile::vm_owned_bits; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; + +/// The schema of the effective-CPUID records this crate reads and writes. +pub const EFFECTIVE_CPUID_SCHEMA: &str = "openvmm-effective-cpuid/v1"; + +/// The leaves a hypervisor may use, which the time ABI's identity owns. +const HYPERVISOR_LEAVES: std::ops::RangeInclusive = 0x4000_0000..=0x4fff_ffff; + +/// The register names, for messages. +const REGISTERS: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"]; + +/// One CPUID result and the mask of the bits it defines. +/// +/// The fields match `virt::CpuidLeaf` one for one, so converting between the +/// two is field for field. `index` is `None` for a result that applies to +/// every subleaf. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CpuidResult { + /// The leaf, the input value of `EAX`. + pub function: u32, + /// The subleaf, the input value of `ECX`, or `None` for every subleaf. + pub index: Option, + /// The output values of `EAX`, `EBX`, `ECX`, and `EDX`. + pub result: [u32; 4], + /// The bits of each output register that `result` defines. + pub mask: [u32; 4], +} + +impl CpuidResult { + /// Returns a result for every subleaf of `function` that defines every + /// bit. + pub const fn new(function: u32, result: [u32; 4]) -> Self { + Self { + function, + index: None, + result, + mask: [!0; 4], + } + } + + /// Returns the result for subleaf `index` only. + pub const fn indexed(self, index: u32) -> Self { + Self { + index: Some(index), + ..self + } + } + + /// Returns the result defining only the bits in `mask`. + pub const fn masked(self, mask: [u32; 4]) -> Self { + Self { mask, ..self } + } +} + +/// Returns the CPUID result that reports the x2APIC as the VM's APIC mode +/// decides: `CPUID.1:ECX[21]`, set unless the APIC is xAPIC-only. +pub const fn x2apic_cpuid(x2apic: bool) -> CpuidResult { + const X2APIC: u32 = 1 << 21; + CpuidResult::new(0x1, [0, 0, if x2apic { X2APIC } else { 0 }, 0]).masked([0, 0, X2APIC, 0]) +} + +/// The effective guest CPUID of a VM. +/// +/// It defines every bit of every leaf and subleaf the guest can observe, +/// except the runtime state that the processor maintains (OSXSAVE, OSPKE, and +/// the XSAVE sizes of the enabled features). Leaves it does not list read as +/// zero. The per-VP fields carry the BSP's APIC identity; backends set each +/// VP's own. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EffectiveCpuid { + entries: Vec, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveCpuidDocument { + schema: String, + cpuid: Vec, +} + +impl EffectiveCpuid { + /// Returns the results, sorted by leaf and subleaf. + pub fn results(&self) -> impl Iterator + '_ { + self.entries.iter().map(|entry| CpuidResult { + function: entry.leaf.0, + index: entry.subleaf.map(|subleaf| subleaf.0), + result: entry.values(), + mask: entry.masks(), + }) + } + + /// Returns the output of CPUID for `leaf` and `subleaf`, with zeros in + /// the runtime-owned bits, or zeros for a leaf the table does not list. + pub fn lookup(&self, leaf: u32, subleaf: u32) -> [u32; 4] { + find(&self.entries, leaf, subleaf).map_or([0; 4], CpuidLeafValue::values) + } + + /// Returns the canonical encoding: compact canonical JSON. + pub fn encode(&self) -> Vec { + let document = EffectiveCpuidDocument { + schema: EFFECTIVE_CPUID_SCHEMA.to_owned(), + cpuid: self.entries.clone(), + }; + let value = + serde_json::to_value(document).expect("effective CPUID serialization is infallible"); + canonical::to_compact(&value).into_bytes() + } + + /// Returns the SHA-256 of [`Self::encode`]. + pub fn digest(&self) -> [u8; 32] { + canonical::sha256(&self.encode()) + } + + /// Decodes an effective CPUID from its canonical encoding. + /// + /// Bytes that are not exactly a canonical encoding fail with + /// `E_PROFILE_DIGEST`. + pub fn decode(bytes: &[u8]) -> Result { + let invalid = |message: &str| { + ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!("invalid effective CPUID record: {message}"), + ) + }; + let document: EffectiveCpuidDocument = serde_json::from_slice(bytes) + .map_err(|error| invalid(&format!("malformed: {error}")))?; + if document.schema != EFFECTIVE_CPUID_SCHEMA { + return Err(invalid(&format!( + "unsupported schema {:?}", + document.schema + ))); + } + if document + .cpuid + .windows(2) + .any(|pair| pair[0].key() >= pair[1].key()) + { + return Err(invalid("entries are not strictly sorted")); + } + if document.cpuid.iter().any(|entry| { + entry + .values() + .iter() + .zip(entry.masks()) + .any(|(value, mask)| value & !mask != 0) + }) { + return Err(invalid("an entry sets bits outside its mask")); + } + let this = Self { + entries: document.cpuid, + }; + if this.encode() != bytes { + return Err(invalid("not in canonical form")); + } + Ok(this) + } + + /// Decodes a recorded effective CPUID and verifies it against its + /// recorded SHA-256 (`E_PROFILE_DIGEST`). + pub fn decode_verified(bytes: &[u8], sha256: &[u8]) -> Result { + if canonical::sha256(bytes).as_slice() != sha256 { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + "the effective CPUID record does not match its digest", + )); + } + Self::decode(bytes) + } + + /// Checks that this effective CPUID, recomputed on the destination, + /// equals the `recorded` one (`E_CPU_SURFACE`, naming the first + /// difference). + pub fn check_matches(&self, recorded: &Self) -> Result<(), ProfileError> { + let mut ours = self.entries.iter().peekable(); + let mut theirs = recorded.entries.iter().peekable(); + loop { + let difference = match (ours.peek(), theirs.peek()) { + (None, None) => return Ok(()), + (Some(entry), None) => { + format!("{} exists only on the destination", describe(entry)) + } + (None, Some(entry)) => format!("{} exists only in the snapshot", describe(entry)), + (Some(a), Some(b)) if a.key() < b.key() => { + format!("{} exists only on the destination", describe(a)) + } + (Some(a), Some(b)) if a.key() > b.key() => { + format!("{} exists only in the snapshot", describe(b)) + } + (Some(a), Some(b)) if a != b => format!( + "{} is {:#010x?} under mask {:#010x?} on the destination, but {:#010x?} under mask {:#010x?} in the snapshot", + describe(a), + a.values(), + a.masks(), + b.values(), + b.masks() + ), + _ => { + ours.next(); + theirs.next(); + continue; + } + }; + return Err(ProfileError::new( + ProfileErrorCode::CpuSurface, + format!("the effective CPUID differs from the snapshot's: {difference}"), + )); + } + } +} + +fn describe(entry: &CpuidLeafValue) -> String { + describe_leaf(entry.leaf.0, entry.subleaf.map(|subleaf| subleaf.0)) +} + +impl CpuProfile { + /// Builds the effective guest CPUID of a VM that uses this profile. + /// + /// `vm` holds the results that the VM configuration defines: the topology + /// leaves and fields (`virt::x86::topology::topology_cpuid`, with the + /// profile's [`CpuProfile::lookup`] as its CPUID source) and the APIC + /// mode ([`x2apic_cpuid`]). They may set only the bits that + /// [`vm_owned_bits`] assigns to the VM, and together they must set all of + /// them. A result for every subleaf (`index` of `None`) applies to each of + /// the profile's subleaves that has VM-owned bits. `identity` holds the + /// time ABI's hypervisor identity leaves, which must lie in + /// `0x40000000..=0x4fffffff`. Later results override earlier ones. + /// + /// Fails with `E_CPU_SURFACE` when the inputs do not complete the profile + /// exactly. + pub fn effective_cpuid( + &self, + vm: &[CpuidResult], + identity: &[CpuidResult], + ) -> Result { + let error = |message: String| { + ProfileError::new( + ProfileErrorCode::CpuSurface, + format!( + "cannot build the effective CPUID of profile {}: {message}", + self.id() + ), + ) + }; + let mut entries = self + .cpuid() + .iter() + .map(|entry| (entry.key(), (entry.values(), entry.masks()))) + .collect::>(); + let max_basic = self.lookup(0, 0)[0]; + + for leaf in vm.iter().filter(|leaf| leaf.mask != [0; 4]) { + let what = describe_leaf(leaf.function, leaf.index); + if VM_OWNED_LEAVES.contains(&leaf.function) { + if leaf.function > max_basic { + return Err(error(format!("{what} is beyond the maximum basic leaf"))); + } + if leaf.index.is_none() { + return Err(error(format!("{what} has no subleaf"))); + } + merge( + entries + .entry((leaf.function, leaf.index)) + .or_insert(([0; 4], [0; 4])), + leaf, + ); + continue; + } + let mut applied = false; + for (&(function, index), slot) in entries.range_mut((leaf.function, None)..) { + if function != leaf.function { + break; + } + if leaf.index.is_some() && index.is_some() && index != leaf.index { + continue; + } + let owned = vm_owned_bits(function, slot.0); + if leaf.index.is_none() && owned == [0; 4] { + // A result for every subleaf skips subleaves without VM + // fields, such as the null cache type that ends leaf 4. + continue; + } + for (register, (bits, owned)) in leaf.mask.iter().zip(owned).enumerate() { + if bits & !owned != 0 { + return Err(error(format!( + "{what} sets {} bits {:#x}, which the profile pins", + REGISTERS[register], + bits & !owned + ))); + } + } + merge(slot, leaf); + applied = true; + } + if !applied { + return Err(error(format!( + "{what} matches no profile leaf with VM fields" + ))); + } + } + + for leaf in identity { + let what = describe_leaf(leaf.function, leaf.index); + if !HYPERVISOR_LEAVES.contains(&leaf.function) { + return Err(error(format!( + "identity {what} is outside the hypervisor range" + ))); + } + merge( + entries + .entry((leaf.function, leaf.index)) + .or_insert(([0; 4], [0; 4])), + leaf, + ); + } + + for &leaf in &VM_OWNED_LEAVES { + if leaf <= max_basic && !entries.contains_key(&(leaf, Some(0))) { + return Err(error(format!( + "the VM did not define {}", + describe_leaf(leaf, Some(0)) + ))); + } + } + for (&(function, index), (value, mask)) in &entries { + if HYPERVISOR_LEAVES.contains(&function) { + continue; + } + let owned = vm_owned_bits(function, *value); + for (register, (owned, mask)) in owned.iter().zip(mask).enumerate() { + if owned & !mask != 0 { + return Err(error(format!( + "the VM did not define {} {} bits {:#x}", + describe_leaf(function, index), + REGISTERS[register], + owned & !mask + ))); + } + } + } + + Ok(EffectiveCpuid { + entries: entries + .into_iter() + .map(|((function, index), (value, mask))| { + CpuidLeafValue::new(function, index, value, mask) + }) + .collect(), + }) + } +} + +fn merge(slot: &mut ([u32; 4], [u32; 4]), leaf: &CpuidResult) { + let (value, mask) = slot; + for register in 0..4 { + value[register] = (value[register] & !leaf.mask[register]) + | (leaf.result[register] & leaf.mask[register]); + mask[register] |= leaf.mask[register]; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::profile; + use test_with_tracing::test; + + const ICELAKE: &str = "intel.icelake-sp.v1"; + + /// Returns hand-built VM leaves for `profile`: the fields OpenVMM's + /// topology code sets for one socket of `vp_count` VPs, and an xAPIC. + fn vm_leaves(profile: &CpuProfile, vp_count: u32) -> Vec { + let mut leaves = vec![ + CpuidResult::new(1, [0, vp_count << 16, 0, 0]).masked([0, 0xffff_0000, 0, 0]), + CpuidResult::new(4, [(vp_count - 1) << 26, 0, 0, 0]).masked([0xffff_c000, 0, 0, 0]), + x2apic_cpuid(false), + ]; + let max_basic = profile.lookup(0, 0)[0]; + for leaf in VM_OWNED_LEAVES + .into_iter() + .filter(|&leaf| leaf <= max_basic) + { + leaves.push(CpuidResult::new(leaf, [0, 1, 0x100, 0]).indexed(0)); + leaves.push(CpuidResult::new(leaf, [0, vp_count, 0x201, 0]).indexed(1)); + } + leaves + } + + fn identity() -> Vec { + vec![CpuidResult::new( + 0x4000_0000, + [0x4000_0005, 0x7263_694d, 0x666f_736f, 0x7648_2074], + )] + } + + fn error_message(result: Result) -> String { + let error = result.unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuSurface, "{error}"); + error.message + } + + #[test] + fn completes_the_profile_with_vm_fields_and_identity() { + let profile = profile(ICELAKE); + let effective = profile + .effective_cpuid(&vm_leaves(profile, 4), &identity()) + .unwrap(); + // The profile's pinned bits and the VM's fields. + let leaf1 = effective.lookup(1, 0); + assert_eq!(leaf1[0], profile.lookup(1, 0)[0]); + assert_eq!(leaf1[1] >> 16, 4); + assert_eq!(leaf1[2] & (1 << 21), 0); + // Every subleaf of leaf 4 that describes a cache, but not the null + // cache type that ends it. + assert_eq!(effective.lookup(4, 0)[0] >> 26, 3); + assert_eq!(effective.lookup(4, 4), [0; 4]); + assert_eq!(effective.lookup(0xb, 1)[1], 4); + assert_eq!(effective.lookup(0x4000_0000, 0)[0], 0x4000_0005); + // Only the runtime state is left undefined. + let undefined = effective + .results() + .filter(|result| result.mask != [!0; 4]) + .map(|result| (result.function, result.index, result.mask)) + .collect::>(); + assert_eq!( + undefined, + [ + (1, None, [!0, !0, !(1 << 27), !0]), + (7, Some(0), [!0, !0, !(1 << 4), !0]), + (0xd, Some(0), [!0, 0, !0, !0]), + (0xd, Some(1), [!0, 0, !0, !0]), + ] + ); + } + + #[test] + fn rejects_inputs_that_do_not_complete_the_profile_exactly() { + let profile = profile(ICELAKE); + let mut leaves = vm_leaves(profile, 2); + leaves.push(CpuidResult::new(1, [0, 0, 1, 0]).masked([0, 0, 1, 0])); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("CPUID 0x1 sets ECX bits 0x1, which the profile pins") + ); + + let mut leaves = vm_leaves(profile, 2); + leaves.retain(|leaf| *leaf != x2apic_cpuid(false)); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("the VM did not define CPUID 0x1 ECX bits 0x200000") + ); + + let leaves = vm_leaves(profile, 2) + .into_iter() + .filter(|leaf| leaf.function != 0xb) + .collect::>(); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("the VM did not define CPUID 0xb.0") + ); + + let leaves = vm_leaves(profile, 2); + assert!( + error_message(profile.effective_cpuid(&leaves, &[CpuidResult::new(3, [0; 4])])) + .contains("outside the hypervisor range") + ); + let mut leaves = vm_leaves(profile, 2); + leaves.push(CpuidResult::new(0x1f, [0; 4]).indexed(0)); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("beyond the maximum basic leaf") + ); + } + + #[test] + fn records_round_trip_and_compare() { + let profile = profile(ICELAKE); + let effective = profile + .effective_cpuid(&vm_leaves(profile, 2), &identity()) + .unwrap(); + let bytes = effective.encode(); + assert_eq!(EffectiveCpuid::decode(&bytes).unwrap(), effective); + assert_eq!( + EffectiveCpuid::decode_verified(&bytes, &effective.digest()).unwrap(), + effective + ); + assert_eq!( + EffectiveCpuid::decode_verified(&bytes, &[0; 32]) + .unwrap_err() + .code, + ProfileErrorCode::ProfileDigest + ); + let mut spaced = bytes.clone(); + spaced.insert(1, b' '); + assert_eq!( + EffectiveCpuid::decode(&spaced).unwrap_err().code, + ProfileErrorCode::ProfileDigest + ); + + effective.check_matches(&effective).unwrap(); + let other = profile + .effective_cpuid(&vm_leaves(profile, 4), &identity()) + .unwrap(); + let error = other.check_matches(&effective).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuSurface); + assert!(error.message.contains("CPUID 0x1 is"), "{error}"); + let without_identity = profile + .effective_cpuid(&vm_leaves(profile, 2), &[]) + .unwrap(); + let error = without_identity.check_matches(&effective).unwrap_err(); + assert!( + error + .message + .contains("CPUID 0x40000000 exists only in the snapshot"), + "{error}" + ); + } + + /// The contract with core: OpenVMM's topology leaves and the time ABI's + /// identity leaves complete every pinned profile into an effective CPUID + /// that satisfies the CPU time bits and the identity. + #[cfg(guest_arch = "x86_64")] + #[test] + fn pinned_profiles_satisfy_the_time_abi_with_openvmm_topologies() { + use virt::time_abi::identity; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + + let result = |leaf: &virt::CpuidLeaf| CpuidResult { + function: leaf.function, + index: leaf.index, + result: leaf.result, + mask: leaf.mask, + }; + for profile in crate::pinned_profiles() { + for vp_count in [1, 2, 8] { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(vp_count) + .x2apic(X2ApicState::Unsupported) + .build(vp_count) + .unwrap(); + let mut topology_leaves = Vec::new(); + virt::x86::topology::topology_cpuid( + &topology, + &|leaf, subleaf| profile.lookup(leaf, subleaf), + &mut topology_leaves, + ) + .unwrap(); + let mut vm = topology_leaves.iter().map(result).collect::>(); + vm.push(x2apic_cpuid(false)); + let identity_leaves = identity::identity_cpuid_leaves(vp_count) + .iter() + .map(result) + .chain(identity::identity_zero_cpuid_leaves().map(|leaf| result(&leaf))) + .collect::>(); + let effective = profile.effective_cpuid(&vm, &identity_leaves).unwrap(); + + identity::check_time_bits( + &mut |leaf, subleaf| effective.lookup(leaf, subleaf), + true, + ) + .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); + identity::check_identity( + &mut |leaf, subleaf| effective.lookup(leaf, subleaf), + vp_count, + ) + .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); + assert_eq!( + EffectiveCpuid::decode(&effective.encode()).unwrap(), + effective + ); + } + } + } +} diff --git a/vmm_core/cpu_profile/src/error.rs b/vmm_core/cpu_profile/src/error.rs new file mode 100644 index 00000000000..2f9a21f7faa --- /dev/null +++ b/vmm_core/cpu_profile/src/error.rs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPU profile failures, with the stable codes of the time ABI specification. + +use std::fmt; + +/// A stable failure code from the "Failure codes" table of the NVX time ABI +/// specification. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProfileErrorCode { + /// `E_PROFILE_UNKNOWN`: the profile ID is not pinned in this OpenVMM. + ProfileUnknown, + /// `E_PROFILE_DIGEST`: a profile or effective-CPUID record does not + /// verify against its digest, does not decode, or disagrees with the + /// pinned profile of the same ID. + ProfileDigest, + /// `E_PROFILE_HOST_UNKNOWN`: automatic selection maps the host CPU to no + /// pinned profile. + ProfileHostUnknown, + /// `E_CPU_GENERATION`: the host CPU is not in the profile's generation. + CpuGeneration, + /// `E_PROFILE_UNSUPPORTED`: the backend lacks a feature, limit, XSAVE + /// layout, or MSR value of the profile. + ProfileUnsupported, + /// `E_CPU_SURFACE`: an effective CPUID differs from the recorded one, or + /// the VM's CPUID inputs cannot complete the profile. + CpuSurface, +} + +impl ProfileErrorCode { + /// Every code. + pub const ALL: [Self; 6] = [ + Self::ProfileUnknown, + Self::ProfileDigest, + Self::ProfileHostUnknown, + Self::CpuGeneration, + Self::ProfileUnsupported, + Self::CpuSurface, + ]; + + /// Returns the code as the specification spells it, such as + /// `E_PROFILE_UNKNOWN`. + pub const fn as_str(self) -> &'static str { + match self { + Self::ProfileUnknown => "E_PROFILE_UNKNOWN", + Self::ProfileDigest => "E_PROFILE_DIGEST", + Self::ProfileHostUnknown => "E_PROFILE_HOST_UNKNOWN", + Self::CpuGeneration => "E_CPU_GENERATION", + Self::ProfileUnsupported => "E_PROFILE_UNSUPPORTED", + Self::CpuSurface => "E_CPU_SURFACE", + } + } +} + +impl fmt::Display for ProfileErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +/// A CPU profile failure. +/// +/// It displays as `[E_CODE] message`, the format of the time ABI's errors, so +/// the code survives when the error crosses a process boundary as a string. +#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] +#[error("[{code}] {message}")] +pub struct ProfileError { + /// The stable code. + pub code: ProfileErrorCode, + /// What failed. + pub message: String, +} + +impl ProfileError { + /// Returns an error with `code` and `message`. + pub fn new(code: ProfileErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +#[cfg(test)] +mod tests { + use super::ProfileError; + use super::ProfileErrorCode; + use test_with_tracing::test; + + #[test] + fn displays_the_bracketed_code_first() { + let error = ProfileError::new(ProfileErrorCode::CpuGeneration, "host is 6/85/7"); + assert_eq!(error.to_string(), "[E_CPU_GENERATION] host is 6/85/7"); + } + + #[test] + fn codes_are_distinct_and_spelled_like_the_specification() { + let mut codes = ProfileErrorCode::ALL.map(ProfileErrorCode::as_str).to_vec(); + assert!(codes.iter().all(|code| { + code.starts_with("E_") + && code + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte == b'_') + })); + codes.sort_unstable(); + codes.dedup(); + assert_eq!(codes.len(), ProfileErrorCode::ALL.len()); + } +} diff --git a/vmm_core/cpu_profile/src/host/mod.rs b/vmm_core/cpu_profile/src/host/mod.rs index 25cde77796e..a132b700300 100644 --- a/vmm_core/cpu_profile/src/host/mod.rs +++ b/vmm_core/cpu_profile/src/host/mod.rs @@ -196,24 +196,8 @@ fn identify_cpu( String::new() }; let invariant_tsc = max_extended >= 0x8000_0007 && cpuid(0x8000_0007, 0)[3] & (1 << 8) != 0; - - let stepping = signature & 0xf; - let base_model = (signature >> 4) & 0xf; - let base_family = (signature >> 8) & 0xf; - let extended_model = (signature >> 16) & 0xf; - let extended_family = (signature >> 20) & 0xff; - let family = if base_family == 0xf { - base_family + extended_family - } else { - base_family - }; - // Intel extends the model for families 6 and 15; AMD only for family 15. - let extends_model = base_family == 0xf || (base_family == 0x6 && vendor == "GenuineIntel"); - let model = if extends_model { - (extended_model << 4) | base_model - } else { - base_model - }; + let (family, model, stepping) = + crate::signature::decode_signature(vendor.as_bytes(), signature); let cpu = HostCpu { vendor, diff --git a/vmm_core/cpu_profile/src/hv_banks.rs b/vmm_core/cpu_profile/src/hv_banks.rs new file mode 100644 index 00000000000..d74799be02b --- /dev/null +++ b/vmm_core/cpu_profile/src/hv_banks.rs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! How the Hyper-V backends, MSHV and WHP, present `IA32_ARCH_CAPABILITIES`. +//! +//! Neither backend lets the VMM intercept `IA32_ARCH_CAPABILITIES`: the +//! hypervisor answers it, deriving the immunity and capability bits from the +//! partition's processor feature banks. A profile therefore pins those bits +//! through the banks. The bank layout is the hypervisor's +//! `HV_PARTITION_PROCESSOR_FEATURES`, which WHP's `WHV_PROCESSOR_FEATURES` +//! and `WHV_PROCESSOR_FEATURES1` share. + +use crate::fingerprint::BackendFingerprint; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::profile::CpuProfile; +use crate::surface::SupportedMsr; + +/// The `IA32_ARCH_CAPABILITIES` bits that Hyper-V derives from the processor +/// feature banks, as (MSR bit, bank, bank bit). +pub const ARCH_CAPABILITIES_BANK_BITS: [(u32, usize, u32); 14] = [ + // RDCL_NO: RdclNo. + (0, 0, 51), + // IBRS_ALL: IbrsAllSupport. + (1, 0, 52), + // SKIP_L1DFL_VMENTRY: SkipL1df (reserved in WHP). + (3, 0, 53), + // SSB_NO: SsbNo. + (4, 0, 54), + // MDS_NO: MdsNoSupport. + (5, 0, 59), + // TSX_CTRL: TsxCtrlSupport. + (7, 0, 62), + // TAA_NO: TaaNoSupport. + (8, 0, 61), + // SBDR_SSDP_NO: SbdrSsdpNoSupport. + (13, 1, 25), + // FBSDP_NO: FbsdpNoSupport. + (14, 1, 26), + // PSDP_NO: PsdpNoSupport. + (15, 1, 27), + // FB_CLEAR: FbClearSupport. + (17, 1, 28), + // BHI_NO: BhiNoSupport. + (20, 1, 43), + // GDS_NO: GdsNoSupport. + (26, 1, 39), + // RFDS_NO: RfdsNoSupport. + (27, 1, 48), +]; + +/// The `IA32_ARCH_CAPABILITIES` bits that the banks control. +pub const ARCH_CAPABILITIES_BANK_MASK: u64 = { + let mut mask = 0; + let mut i = 0; + while i < ARCH_CAPABILITIES_BANK_BITS.len() { + mask |= 1 << ARCH_CAPABILITIES_BANK_BITS[i].0; + i += 1; + } + mask +}; + +/// Returns the `IA32_ARCH_CAPABILITIES` bits that a partition with processor +/// feature `banks` presents. +pub fn arch_capabilities_from_banks(banks: [u64; 2]) -> u64 { + ARCH_CAPABILITIES_BANK_BITS + .iter() + .filter(|&&(_, bank, bank_bit)| banks[bank] & (1 << bank_bit) != 0) + .fold(0, |value, &(bit, ..)| value | 1 << bit) +} + +/// Returns what a Hyper-V backend whose host offers processor feature +/// `banks` can present in `IA32_ARCH_CAPABILITIES`. +/// +/// Bits the banks do not derive are reported neither supported nor +/// controllable, so a profile can pin them only to zero. +pub fn arch_capabilities_msr(banks: [u64; 2]) -> SupportedMsr { + SupportedMsr { + index: IA32_ARCH_CAPABILITIES, + supported: arch_capabilities_from_banks(banks), + controllable: ARCH_CAPABILITIES_BANK_MASK, + } +} + +/// Clears from `banks` every bank bit whose `IA32_ARCH_CAPABILITIES` bit +/// `profile` pins clear, so the hypervisor presents the profile's value. +/// +/// The backend must also verify the profile (see +/// [`verify_support`](crate::verify_support)), which ensures the banks +/// already have every bit the profile pins set. +pub fn restrict_banks_to_profile(profile: &CpuProfile, banks: &mut [u64; 2]) { + let Some((value, mask)) = profile.msr(IA32_ARCH_CAPABILITIES) else { + return; + }; + for &(bit, bank, bank_bit) in &ARCH_CAPABILITIES_BANK_BITS { + if mask & !value & (1 << bit) != 0 { + banks[bank] &= !(1 << bank_bit); + } + } +} + +/// Returns the processor feature banks 0 and 1 a Hyper-V fingerprint +/// recorded, if any. +pub(crate) fn fingerprint_banks(backend: &BackendFingerprint) -> Option<[u64; 2]> { + let bank = |name: &str| backend.feature_banks.get(name).map(|value| value.0); + match backend.name.as_str() { + "mshv" => Some([ + bank("mshv.host.ProcessorFeatures0")?, + bank("mshv.host.ProcessorFeatures1").unwrap_or(0), + ]), + "whp" => Some( + match ( + bank("whp.capability.ProcessorFeaturesBanks.bank0"), + bank("whp.capability.ProcessorFeaturesBanks.bank1"), + ) { + (Some(bank0), bank1) => [bank0, bank1.unwrap_or(0)], + (None, _) => [bank("whp.capability.ProcessorFeatures")?, 0], + }, + ), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use test_with_tracing::test; + + #[test] + fn derives_arch_capabilities_from_azure_banks() { + // Ice Lake on Azure WHP: RdclNo, MdsNo, TaaNo; RfdsNo in bank 1. + let icelake_whp = [0x2e0a_8bff_e7f7_859f, 0x0001_000e_0000_00f1]; + assert_eq!(arch_capabilities_from_banks(icelake_whp), 0x0800_0121); + // Emerald Rapids on Azure MSHV: RdclNo, SkipL1df, and MdsNo. + let emeraldrapids_mshv = [0x0e2a_8bff_fff7_859f, 0x0000_000c_0000_0051]; + assert_eq!(arch_capabilities_from_banks(emeraldrapids_mshv), 0x29); + // WHP reserves SkipL1df. + let emeraldrapids_whp = [0x0e0a_8bff_e7f7_859f, 0x0000_000c_0000_0051]; + assert_eq!(arch_capabilities_from_banks(emeraldrapids_whp), 0x21); + } + + #[test] + fn the_mask_covers_every_mapped_bit_once() { + assert_eq!( + ARCH_CAPABILITIES_BANK_MASK.count_ones() as usize, + ARCH_CAPABILITIES_BANK_BITS.len() + ); + assert_eq!( + arch_capabilities_from_banks([!0, !0]), + ARCH_CAPABILITIES_BANK_MASK + ); + let msr = arch_capabilities_msr([0, 0]); + assert_eq!( + (msr.supported, msr.controllable), + (0, ARCH_CAPABILITIES_BANK_MASK) + ); + } + + #[test] + fn restricts_the_banks_to_the_profile() { + let icelake = crate::test_support::profile("intel.icelake-sp.v1"); + let mut banks = [!0, !0]; + restrict_banks_to_profile(icelake, &mut banks); + assert_eq!(arch_capabilities_from_banks(banks), 0x0800_0121); + // Bits that derive no IA32_ARCH_CAPABILITIES bit are untouched. + let mapped = |bank| { + ARCH_CAPABILITIES_BANK_BITS + .iter() + .filter(|&&(_, b, _)| b == bank) + .fold(0u64, |mask, &(.., bit)| mask | 1 << bit) + }; + assert_eq!(banks[0] | mapped(0), !0); + assert_eq!(banks[1] | mapped(1), !0); + + let skylake = crate::test_support::profile("intel.skylake-sp.v1"); + let mut banks = [!0, !0]; + restrict_banks_to_profile(skylake, &mut banks); + assert_eq!(arch_capabilities_from_banks(banks), 0); + } +} diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index eabaf33e659..cb740c25df6 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -1,26 +1,91 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! x86 CPU fingerprints, the input to pinned guest CPU profiles. +//! x86 CPU profiles for the NVX time ABI, and the host CPU fingerprints they +//! are derived from. //! -//! A [`CpuFingerprint`](fingerprint::CpuFingerprint) records the guest CPU -//! surface that one hypervisor backend supports on one host: every CPUID leaf -//! and subleaf that a guest could be given, the XSAVE features and layout, the -//! feature MSRs, and the time capabilities that the NVX time ABI depends on, -//! together with the identity of the host CPU and OS. +//! A [`CpuProfile`] is the complete guest-visible CPU surface of one CPU +//! generation, shared by every hypervisor backend: every CPUID leaf a guest +//! can observe, the XSAVE features and layout, the guest physical address +//! width, and pinned MSR values. Profiles are pinned in this crate +//! ([`pinned`]), immutable once released, and identified by ID and SHA-256 +//! digest. A VM's effective CPUID ([`CpuProfile::effective_cpuid`]) is a pure +//! function of its profile, its topology, and the time ABI's identity +//! leaves. //! -//! Fingerprints are emitted as deterministic, canonical JSON with SHA-256 -//! digests, so that the fingerprints of a fleet of hosts can be compared and -//! intersected into per-generation CPU profiles. This follows the dump, strip, -//! and verify workflow of Firecracker's `cpu-template-helper`. +//! - **Selection:** [`select`] maps `--cpu-profile ` and the host +//! CPU ([`HostCpuSignature`]) to a pinned profile. +//! - **Verification:** each backend reports the CPU surface it supports +//! ([`HostCpuSurface`]), and [`verify_support`] checks that it covers the +//! profile. +//! - **Restore:** [`restore_profile`] checks a snapshot's profile record, +//! and [`EffectiveCpuid::check_matches`] its effective CPUID. +//! +//! Failures carry the stable codes of the time ABI specification +//! ([`ProfileError`]). +//! +//! A [`CpuFingerprint`](fingerprint::CpuFingerprint) records the CPU surface +//! that one backend supports on one host, with the host's identity, as +//! deterministic canonical JSON with SHA-256 digests. The +//! [`derive`](mod@derive) module intersects the fingerprints of a +//! generation's hosts into its profile, following the dump, strip, and verify +//! workflow of Firecracker's `cpu-template-helper`. pub mod cpuid; +pub mod derive; pub mod fingerprint; pub mod host; +pub mod hv_banks; mod canonical; +mod catalog; +mod check; +mod effective; +mod error; mod hex; +mod profile; +mod signature; +mod surface; +#[cfg(test)] +mod test_support; +pub use catalog::AUTO; +pub use catalog::check_generation; +pub use catalog::generation_of; +pub use catalog::pinned; +pub use catalog::pinned_for_restore; +pub use catalog::pinned_profiles; +pub use catalog::restore_profile; +pub use catalog::select; +pub use catalog::select_auto; +pub use catalog::verify_profile_record; +pub use check::FingerprintCheck; +pub use check::SUMMARY_PREFIX; +pub use check::check_fingerprint; +pub use effective::CpuidResult; +pub use effective::EFFECTIVE_CPUID_SCHEMA; +pub use effective::EffectiveCpuid; +pub use effective::x2apic_cpuid; +pub use error::ProfileError; +pub use error::ProfileErrorCode; pub use hex::Hex32; pub use hex::Hex64; pub use hex::ParseHexError; +pub use profile::CpuProfile; +pub use profile::CpuidLeafValue; +pub use profile::Generation; +pub use profile::GenerationCpu; +pub use profile::PinnedMsr; +pub use profile::Provenance; +pub use profile::ProvenanceSource; +pub use profile::SCHEMA as PROFILE_SCHEMA; +pub use profile::VM_OWNED_LEAVES; +pub use profile::pinned_mask; +pub use profile::runtime_owned_bits; +pub use profile::vm_owned_bits; +pub use signature::HostCpuSignature; +pub use surface::HostCpuSurface; +pub use surface::SupportedMsr; +pub use surface::TIME_POLICY_BITS; +pub use surface::support_violations; +pub use surface::verify_support; diff --git a/vmm_core/cpu_profile/src/profile.rs b/vmm_core/cpu_profile/src/profile.rs new file mode 100644 index 00000000000..ef3cf7276af --- /dev/null +++ b/vmm_core/cpu_profile/src/profile.rs @@ -0,0 +1,874 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Pinned CPU profiles: the complete guest-visible CPU surface of one CPU +//! generation. +//! +//! A [`CpuProfile`] lists every CPUID leaf and subleaf a guest can observe in +//! the basic and extended ranges, with exact values and a mask of the bits it +//! pins; the XSAVE features and layout; the guest physical address width; and +//! pinned MSR values. The bits it does not pin are the ones OpenVMM sets per +//! VM, which [`vm_owned_bits`] and [`runtime_owned_bits`] define, so a VM's +//! effective CPUID is a pure function of the profile, its topology, and the +//! time ABI's identity leaves. +//! +//! The canonical encoding is compact canonical JSON (object keys sorted by +//! their UTF-8 bytes, no whitespace), and the profile digest is its SHA-256. +//! Pinned profiles are stored as pretty canonical JSON. Decoding accepts only +//! the canonical bytes, so a profile has exactly one encoding and one digest. + +use crate::Hex32; +use crate::Hex64; +use crate::canonical; +use crate::cpuid; +use crate::cpuid::CpuidEntry; +use crate::cpuid::EXTENDED_LEAF_BASE; +use crate::cpuid::XsaveComponent; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::signature::HostCpuSignature; +use crate::signature::decode_signature; +use serde::Deserialize; +use serde::Serialize; + +/// The schema of the profile documents this crate reads and writes. +pub const SCHEMA: &str = "openvmm-cpu-profile/v1"; + +/// The leaves that OpenVMM synthesizes entirely from the VM's topology, which +/// profiles therefore omit. +pub const VM_OWNED_LEAVES: [u32; 2] = [0xb, 0x1f]; + +/// The leaves whose subleaf 0 reports the highest subleaf in EAX. +const MAX_SUBLEAF_IN_EAX_LEAVES: [u32; 6] = [0x7, 0x14, 0x17, 0x18, 0x1d, 0x24]; + +/// `CPUID.(7,0):EDX[29]`, the presence of `IA32_ARCH_CAPABILITIES`. +const LEAF7_EDX_ARCH_CAPABILITIES: u32 = 1 << 29; + +/// The CPU generation that a profile serves. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Generation { + /// The generation name used in logs and reports, such as `icelake-sp`. + pub name: String, + /// The CPU models of the generation. + pub cpus: Vec, +} + +impl Generation { + /// Returns whether the host CPU, of the profile's `vendor`, belongs to + /// the generation. + pub fn contains(&self, vendor: &str, host: &HostCpuSignature) -> bool { + host.vendor().as_slice() == vendor.as_bytes() + && self.cpus.iter().any(|cpu| { + let (family, model, stepping) = + decode_signature(vendor.as_bytes(), host.signature()); + cpu.family == family + && cpu.model == model + && (cpu.steppings[0]..=cpu.steppings[1]).contains(&stepping) + }) + } +} + +/// A CPU model of a generation: a display family and model, and the range of +/// steppings it covers. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GenerationCpu { + /// The display family. + pub family: u32, + /// The display model. + pub model: u32, + /// The first and last stepping, inclusive. Some model numbers span + /// generations: family 6 model 85 is Skylake-SP up to stepping 4, Cascade + /// Lake at steppings 5 to 7, and Cooper Lake at steppings 10 and 11. + pub steppings: [u32; 2], +} + +/// One CPUID leaf, or one subleaf of an indexed leaf, with its values and the +/// mask of the bits they define. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpuidLeafValue { + /// The leaf, the input value of `EAX`. + pub leaf: Hex32, + /// The subleaf, the input value of `ECX`, or `None` for a leaf whose + /// output does not depend on `ECX`. + pub subleaf: Option, + /// The output values of `EAX`, `EBX`, `ECX`, and `EDX`. Bits outside the + /// mask are zero. + pub value: [Hex32; 4], + /// The bits of each output register that the values define. + pub mask: [Hex32; 4], +} + +impl CpuidLeafValue { + pub(crate) fn new(leaf: u32, subleaf: Option, value: [u32; 4], mask: [u32; 4]) -> Self { + Self { + leaf: Hex32(leaf), + subleaf: subleaf.map(Hex32), + value: value.map(Hex32), + mask: mask.map(Hex32), + } + } + + /// Returns the sort key: the leaf, then the subleaf, with a + /// subleaf-independent entry first. + pub fn key(&self) -> (u32, Option) { + (self.leaf.0, self.subleaf.map(|subleaf| subleaf.0)) + } + + /// Returns the output values. + pub fn values(&self) -> [u32; 4] { + self.value.map(|value| value.0) + } + + /// Returns the masks. + pub fn masks(&self) -> [u32; 4] { + self.mask.map(|mask| mask.0) + } +} + +/// An MSR value that a profile pins: the guest reads `value` in the bits of +/// `mask`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PinnedMsr { + /// The MSR index. + pub index: Hex32, + /// The pinned value. Bits outside the mask are zero. + pub value: Hex64, + /// The bits the profile pins. The backend defines the others. + pub mask: Hex64, +} + +/// Where a profile came from. Informative; part of the digest like every +/// other field. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Provenance { + /// How the profile was derived. + pub method: String, + /// The host fingerprints it was derived from, by backend. + pub sources: Vec, +} + +/// The fingerprints of one backend that a profile was derived from. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProvenanceSource { + /// The backend: `kvm`, `mshv`, or `whp`. + pub backend: String, + /// The number of host fingerprints. + pub hosts: u32, + /// The distinct surface digests of those fingerprints, sorted. + pub surface_digests: Vec, +} + +/// A CPU profile: the complete guest-visible CPU surface of one CPU +/// generation, shared by every backend. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CpuProfile { + pub(crate) schema: String, + pub(crate) id: String, + pub(crate) description: String, + pub(crate) vendor: String, + pub(crate) generation: Generation, + pub(crate) cpuid: Vec, + pub(crate) xcr0: Hex64, + pub(crate) xss: Hex64, + pub(crate) xsave_components: Vec, + pub(crate) physical_address_width: u32, + pub(crate) msrs: Vec, + pub(crate) provenance: Provenance, +} + +impl CpuProfile { + /// Returns the profile ID, `..v`, such as + /// `intel.icelake-sp.v1`. + pub fn id(&self) -> &str { + &self.id + } + + /// Returns the human-readable description. + pub fn description(&self) -> &str { + &self.description + } + + /// Returns the 12-character CPUID vendor string, such as `GenuineIntel`. + pub fn vendor(&self) -> &str { + &self.vendor + } + + /// Returns the CPU generation the profile serves. + pub fn generation(&self) -> &Generation { + &self.generation + } + + /// Returns the CPUID leaves, sorted by leaf and subleaf. + pub fn cpuid(&self) -> &[CpuidLeafValue] { + &self.cpuid + } + + /// Returns the output of CPUID for `leaf` and `subleaf`: the profile + /// values, which are zero in the bits OpenVMM sets per VM, or zeros for a + /// leaf or subleaf the profile does not list. + pub fn lookup(&self, leaf: u32, subleaf: u32) -> [u32; 4] { + find(&self.cpuid, leaf, subleaf).map_or([0; 4], CpuidLeafValue::values) + } + + /// Returns the XCR0 bits the guest may enable. + pub fn xcr0(&self) -> u64 { + self.xcr0.0 + } + + /// Returns the IA32_XSS bits the guest may enable. + pub fn xss(&self) -> u64 { + self.xss.0 + } + + /// Returns the layout of every enabled XSAVE state component beyond x87 + /// and SSE. + pub fn xsave_components(&self) -> &[XsaveComponent] { + &self.xsave_components + } + + /// Returns the guest physical address width in bits. + pub fn physical_address_width(&self) -> u8 { + self.physical_address_width as u8 + } + + /// Returns the pinned MSR values, sorted by index. + pub fn msrs(&self) -> &[PinnedMsr] { + &self.msrs + } + + /// Returns the pinned value and mask of MSR `index`, if the profile pins + /// it. + pub fn msr(&self, index: u32) -> Option<(u64, u64)> { + self.msrs + .iter() + .find(|msr| msr.index.0 == index) + .map(|msr| (msr.value.0, msr.mask.0)) + } + + /// Returns where the profile came from. + pub fn provenance(&self) -> &Provenance { + &self.provenance + } + + /// Returns the canonical encoding: compact canonical JSON. + pub fn encode(&self) -> Vec { + canonical::to_compact(&to_value(self)).into_bytes() + } + + /// Returns the profile as pretty canonical JSON, with a final newline: + /// the form in which profiles are pinned. + pub fn to_pretty_json(&self) -> String { + canonical::to_pretty(&to_value(self)) + } + + /// Returns the profile digest: the SHA-256 of [`Self::encode`]. + pub fn digest(&self) -> [u8; 32] { + canonical::sha256(&self.encode()) + } + + /// Returns the profile digest as `sha256:`. + pub fn digest_string(&self) -> String { + canonical::format_digest(&self.digest()) + } + + /// Decodes a profile from its canonical encoding and validates it. + /// + /// Bytes that are not exactly the canonical encoding of a valid profile + /// fail with `E_PROFILE_DIGEST`: they cannot carry a verifiable digest. + pub fn decode(bytes: &[u8]) -> Result { + let text = std::str::from_utf8(bytes) + .map_err(|_| invalid("the encoding is not UTF-8".to_owned()))?; + let this = Self::parse(text)?; + if this.encode() != bytes { + return Err(invalid(format!( + "profile {} is not in its canonical encoding", + this.id + ))); + } + Ok(this) + } + + /// Parses a profile from pretty canonical JSON, the pinned form, and + /// validates it. + pub fn from_pretty_json(text: &str) -> Result { + let this = Self::parse(text)?; + if this.to_pretty_json() != text { + return Err(invalid(format!( + "profile {} is not in pretty canonical JSON", + this.id + ))); + } + Ok(this) + } + + fn parse(text: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(text) + .map_err(|error| invalid(format!("malformed profile: {error}")))?; + let schema = value.get("schema").and_then(|schema| schema.as_str()); + if schema != Some(SCHEMA) { + return Err(invalid(format!( + "unsupported profile schema {:?}, expected {SCHEMA:?}", + schema.unwrap_or_default() + ))); + } + let this: Self = serde_json::from_value(value) + .map_err(|error| invalid(format!("malformed profile: {error}")))?; + this.validate() + .map_err(|message| invalid(format!("profile {:?} is invalid: {message}", this.id)))?; + Ok(this) + } + + /// Assembles a profile from its parts and validates it. + #[expect(clippy::too_many_arguments)] + pub(crate) fn from_parts( + id: String, + description: String, + vendor: String, + generation: Generation, + mut leaves: Vec, + xcr0: u64, + xss: u64, + physical_address_width: u8, + msrs: Vec, + provenance: Provenance, + ) -> Result { + leaves.sort_by_key(CpuidLeafValue::key); + let entries = leaves + .iter() + .map(|entry| CpuidEntry::new(entry.leaf.0, entry.subleaf.map(|s| s.0), entry.values())) + .collect::>(); + let this = Self { + schema: SCHEMA.to_owned(), + id, + description, + vendor, + generation, + xsave_components: cpuid::xsave_components(&entries), + cpuid: leaves, + xcr0: Hex64(xcr0), + xss: Hex64(xss), + physical_address_width: physical_address_width.into(), + msrs, + provenance, + }; + this.validate()?; + Ok(this) + } + + /// Checks every structural invariant of a profile; see the module + /// documentation and [`SCHEMA`]. + fn validate(&self) -> Result<(), String> { + if self.schema != SCHEMA { + return Err(format!("schema {:?} is not {SCHEMA:?}", self.schema)); + } + self.validate_identity()?; + self.validate_cpuid()?; + self.validate_xsave()?; + self.validate_msrs()?; + let width = self.lookup(EXTENDED_LEAF_BASE + 8, 0)[0] & 0xff; + if self.physical_address_width != width { + return Err(format!( + "physical address width {} differs from CPUID 0x80000008 EAX[7:0] {width}", + self.physical_address_width + )); + } + Ok(()) + } + + fn validate_identity(&self) -> Result<(), String> { + // Only Intel profiles exist; AMD profiles wait for an AMD host, and + // their topology fields differ. + let vendor_short = match self.vendor.as_str() { + "GenuineIntel" => "intel", + vendor => return Err(format!("unsupported vendor {vendor:?}")), + }; + let name = &self.generation.name; + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') + { + return Err(format!("invalid generation name {name:?}")); + } + let revision = self + .id + .strip_prefix(vendor_short) + .and_then(|rest| rest.strip_prefix('.')) + .and_then(|rest| rest.strip_prefix(name.as_str())) + .and_then(|rest| rest.strip_prefix(".v")) + .ok_or_else(|| format!("ID {:?} is not {vendor_short}.{name}.v", self.id))?; + if revision.is_empty() + || revision.starts_with('0') + || !revision.bytes().all(|byte| byte.is_ascii_digit()) + { + return Err(format!("ID {:?} has an invalid revision", self.id)); + } + if self.generation.cpus.is_empty() { + return Err("the generation lists no CPU".to_owned()); + } + for cpu in &self.generation.cpus { + let [first, last] = cpu.steppings; + if first > last || last > 0xf { + return Err(format!( + "invalid stepping range {first}..={last} for family {} model {}", + cpu.family, cpu.model + )); + } + } + let [_, ebx, ecx, edx] = self.lookup(0, 0); + let vendor = crate::signature::vendor_bytes(ebx, edx, ecx); + if vendor.as_slice() != self.vendor.as_bytes() { + return Err(format!( + "CPUID leaf 0 reports vendor {:?}, not {:?}", + String::from_utf8_lossy(&vendor), + self.vendor + )); + } + let signature = HostCpuSignature::new(vendor, self.lookup(1, 0)[0]); + if !self.generation.contains(&self.vendor, &signature) { + return Err(format!( + "CPUID.1:EAX {:#x} is not in generation {name}", + signature.signature() + )); + } + Ok(()) + } + + fn validate_cpuid(&self) -> Result<(), String> { + for pair in self.cpuid.windows(2) { + if pair[0].key() >= pair[1].key() { + return Err(format!( + "CPUID entries are not strictly sorted at {}", + describe(&pair[1]) + )); + } + } + let max_basic = self.lookup(0, 0)[0]; + let max_extended = self.lookup(EXTENDED_LEAF_BASE, 0)[0]; + if !(1..cpuid::HYPERVISOR_LEAF_BASE).contains(&max_basic) { + return Err(format!("invalid maximum basic leaf {max_basic:#x}")); + } + if !(EXTENDED_LEAF_BASE..EXTENDED_LEAF_BASE + 0x100).contains(&max_extended) { + return Err(format!("invalid maximum extended leaf {max_extended:#x}")); + } + for entry in &self.cpuid { + let leaf = entry.leaf.0; + let in_range = leaf <= max_basic || (EXTENDED_LEAF_BASE..=max_extended).contains(&leaf); + if !in_range || VM_OWNED_LEAVES.contains(&leaf) { + return Err(format!( + "{} is outside the profile's leaves", + describe(entry) + )); + } + if entry.subleaf.is_some() != cpuid::is_indexed_leaf(leaf) { + return Err(format!( + "{} has the wrong subleaf indexing", + describe(entry) + )); + } + let value = entry.values(); + let expected_mask = pinned_mask(leaf, entry.subleaf.map(|s| s.0), value); + if entry.masks() != expected_mask { + return Err(format!( + "{} has mask {:#x?}, expected {expected_mask:#x?}", + describe(entry), + entry.masks() + )); + } + if value + .iter() + .zip(expected_mask) + .any(|(value, mask)| value & !mask != 0) + { + return Err(format!("{} sets bits outside its mask", describe(entry))); + } + } + let leaves = (0..=max_basic).chain(EXTENDED_LEAF_BASE..=max_extended); + for leaf in leaves.filter(|leaf| !VM_OWNED_LEAVES.contains(leaf)) { + let subleaves = self + .cpuid + .iter() + .filter(|entry| entry.leaf.0 == leaf) + .map(|entry| entry.subleaf.map(|s| s.0)) + .collect::>(); + let expected = self.expected_subleaves(leaf); + if subleaves != expected { + return Err(format!( + "CPUID {leaf:#x} lists subleaves {subleaves:?}, expected {expected:?}" + )); + } + } + Ok(()) + } + + /// Returns the subleaves a dense profile lists for `leaf`: `[None]` for a + /// leaf that is not indexed, and otherwise the subleaves the architecture + /// enumerates from the profile's own values. + fn expected_subleaves(&self, leaf: u32) -> Vec> { + if !cpuid::is_indexed_leaf(leaf) { + return vec![None]; + } + let last = match leaf { + // Up to and including the first subleaf with a null cache type. + 0x4 => (0..0x40) + .find(|&subleaf| self.lookup(leaf, subleaf)[0] & 0x1f == 0) + .unwrap_or(0x3f), + 0xd => { + let components = (self.xcr0.0 | self.xss.0) & !3; + return [0, 1] + .into_iter() + .chain((2..63).filter(|index| components & (1 << index) != 0)) + .map(Some) + .collect(); + } + leaf if MAX_SUBLEAF_IN_EAX_LEAVES.contains(&leaf) => self.lookup(leaf, 0)[0].min(0x3f), + _ => 0, + }; + (0..=last).map(Some).collect() + } + + fn validate_xsave(&self) -> Result<(), String> { + let xcr0 = self.xcr0.0; + let xss = self.xss.0; + if xcr0 & 3 != 3 { + return Err(format!("XCR0 {xcr0:#x} lacks x87 or SSE")); + } + if xcr0 & xss != 0 { + return Err(format!("XCR0 {xcr0:#x} and XSS {xss:#x} overlap")); + } + let entries = self + .cpuid + .iter() + .map(|entry| CpuidEntry::new(entry.leaf.0, entry.subleaf.map(|s| s.0), entry.values())) + .collect::>(); + if cpuid::xsave_supported(&entries) != (xcr0, xss) { + return Err("XCR0 and XSS differ from CPUID leaf 0xd".to_owned()); + } + let components = cpuid::xsave_components(&entries); + if components != self.xsave_components { + return Err("the XSAVE components differ from CPUID leaf 0xd".to_owned()); + } + if components.len() != ((xcr0 | xss) & !3).count_ones() as usize { + return Err("an enabled XSAVE component has no CPUID leaf 0xd subleaf".to_owned()); + } + if components + .iter() + .any(|component| component.supervisor != (xss & (1 << component.index) != 0)) + { + return Err("an XSAVE component's supervisor flag disagrees with XSS".to_owned()); + } + let standard_size = cpuid::xsave_standard_size(&components, xcr0); + if self.lookup(0xd, 0)[2] != standard_size { + return Err(format!( + "CPUID.(0xd,0):ECX is not the standard XSAVE size {standard_size}" + )); + } + Ok(()) + } + + fn validate_msrs(&self) -> Result<(), String> { + for pair in self.msrs.windows(2) { + if pair[0].index.0 >= pair[1].index.0 { + return Err("pinned MSRs are not strictly sorted".to_owned()); + } + } + for msr in &self.msrs { + if msr.mask.0 == 0 || msr.value.0 & !msr.mask.0 != 0 { + return Err(format!( + "MSR {:#x} pins {:#x} under mask {:#x}", + msr.index.0, msr.value.0, msr.mask.0 + )); + } + if msr.index.0 == IA32_ARCH_CAPABILITIES + && self.lookup(7, 0)[3] & LEAF7_EDX_ARCH_CAPABILITIES == 0 + { + return Err("IA32_ARCH_CAPABILITIES is pinned but not enumerated".to_owned()); + } + } + Ok(()) + } +} + +/// Returns the entry of `entries` that CPUID `leaf` and `subleaf` read, if any. +pub(crate) fn find(entries: &[CpuidLeafValue], leaf: u32, subleaf: u32) -> Option<&CpuidLeafValue> { + entries.iter().find(|entry| { + entry.leaf.0 == leaf + && entry + .subleaf + .is_none_or(|entry_subleaf| entry_subleaf.0 == subleaf) + }) +} + +/// Returns the bits of CPUID `leaf` that OpenVMM sets from the VM +/// configuration, given the leaf's output `value`: +/// +/// - `CPUID.1:EBX[31:16]`, the logical processor count and initial APIC ID, +/// and `CPUID.1:ECX[21]`, x2APIC, which the APIC mode decides; +/// - `CPUID.4:EAX[31:14]`, the core and cache-sharing counts, in every +/// subleaf that describes a cache; and +/// - every bit of the topology leaves in [`VM_OWNED_LEAVES`]. +pub fn vm_owned_bits(leaf: u32, value: [u32; 4]) -> [u32; 4] { + match leaf { + 0x1 => [0, 0xffff_0000, 1 << 21, 0], + 0x4 if value[0] & 0x1f != 0 => [0xffff_c000, 0, 0, 0], + leaf if VM_OWNED_LEAVES.contains(&leaf) => [!0; 4], + _ => [0; 4], + } +} + +/// Returns the bits of CPUID `leaf` and `subleaf` that reflect runtime state +/// rather than the CPU surface: OSXSAVE and OSPKE, which mirror control +/// register bits, and the XSAVE area sizes for the currently enabled XCR0 and +/// IA32_XSS. +pub fn runtime_owned_bits(leaf: u32, subleaf: Option) -> [u32; 4] { + match (leaf, subleaf) { + (0x1, _) => [0, 0, 1 << 27, 0], + (0x7, Some(0)) => [0, 0, 1 << 4, 0], + (0xd, Some(0 | 1)) => [0, !0, 0, 0], + _ => [0; 4], + } +} + +/// Returns the bits of CPUID `leaf` and `subleaf`, with output `value`, that +/// a profile pins: every bit that is neither VM-owned nor runtime-owned. +pub fn pinned_mask(leaf: u32, subleaf: Option, value: [u32; 4]) -> [u32; 4] { + let vm = vm_owned_bits(leaf, value); + let runtime = runtime_owned_bits(leaf, subleaf); + [0, 1, 2, 3].map(|register| !(vm[register] | runtime[register])) +} + +pub(crate) fn describe(entry: &CpuidLeafValue) -> String { + describe_leaf(entry.leaf.0, entry.subleaf.map(|s| s.0)) +} + +pub(crate) fn describe_leaf(leaf: u32, subleaf: Option) -> String { + match subleaf { + Some(subleaf) => format!("CPUID {leaf:#x}.{subleaf}"), + None => format!("CPUID {leaf:#x}"), + } +} + +fn invalid(message: String) -> ProfileError { + ProfileError::new(ProfileErrorCode::ProfileDigest, message) +} + +fn to_value(value: &T) -> serde_json::Value { + // Every field serializes infallibly with string map keys. + serde_json::to_value(value).expect("profile serialization is infallible") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::profile; + use serde_json::Value; + use serde_json::json; + use test_with_tracing::test; + + const ICELAKE: &str = "intel.icelake-sp.v1"; + + /// Decodes `profile` after `edit` changes its JSON form. + fn decode_edited(edit: impl FnOnce(&mut Value)) -> Result { + let mut value = to_value(profile(ICELAKE)); + edit(&mut value); + CpuProfile::decode(canonical::to_compact(&value).as_bytes()) + } + + /// Returns the CPUID entry of `leaf` and `subleaf` in a profile's JSON. + fn entry(value: &mut Value, leaf: u32, subleaf: Option) -> &mut Value { + value["cpuid"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|entry| { + entry["leaf"] == json!(format!("{leaf:#010x}")) + && entry["subleaf"] + == subleaf.map_or(Value::Null, |s| json!(format!("{s:#010x}"))) + }) + .unwrap() + } + + fn assert_invalid(result: Result, expected: &str) { + let error = result.unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileDigest, "{error}"); + assert!(error.message.contains(expected), "{error}"); + } + + #[test] + fn pinned_profiles_round_trip_through_their_canonical_encoding() { + for profile in crate::pinned_profiles() { + let encoded = profile.encode(); + let decoded = CpuProfile::decode(&encoded).unwrap(); + assert_eq!(&decoded, profile); + assert_eq!(decoded.digest(), canonical::sha256(&encoded)); + assert_eq!( + CpuProfile::from_pretty_json(&profile.to_pretty_json()).unwrap(), + decoded + ); + } + } + + #[test] + fn decoding_rejects_every_non_canonical_form() { + let profile = profile(ICELAKE); + let mut spaced = profile.encode(); + spaced.insert(1, b' '); + assert_invalid(CpuProfile::decode(&spaced), "canonical"); + assert_invalid( + CpuProfile::decode(profile.to_pretty_json().as_bytes()), + "canonical", + ); + assert_invalid( + CpuProfile::from_pretty_json(&String::from_utf8(profile.encode()).unwrap()), + "pretty canonical", + ); + assert_invalid(CpuProfile::decode(b"\xff"), "UTF-8"); + assert_invalid(CpuProfile::decode(b"{}"), "schema"); + assert_invalid( + decode_edited(|value| value["extra"] = json!(1)), + "unknown field", + ); + } + + #[test] + fn validation_rejects_malformed_identities() { + assert_invalid( + decode_edited(|value| value["id"] = json!("intel.icelake-sp.v01")), + "invalid revision", + ); + assert_invalid( + decode_edited(|value| value["id"] = json!("intel.skylake-sp.v1")), + "is not intel.icelake-sp.v", + ); + assert_invalid( + decode_edited(|value| value["vendor"] = json!("AuthenticAMD")), + "unsupported vendor", + ); + assert_invalid( + decode_edited(|value| value["generation"]["cpus"][0]["model"] = json!(85)), + "is not in generation icelake-sp", + ); + assert_invalid( + decode_edited(|value| value["generation"]["cpus"][0]["steppings"] = json!([4, 3])), + "invalid stepping range", + ); + } + + #[test] + fn validation_rejects_malformed_cpuid_tables() { + // A VM-owned field that the profile pins. + assert_invalid( + decode_edited(|value| entry(value, 1, None)["mask"][1] = json!("0xffffffff")), + "has mask", + ); + // A value outside the mask. + assert_invalid( + decode_edited(|value| entry(value, 1, None)["value"][1] = json!("0x00010800")), + "outside its mask", + ); + // A missing leaf. + assert_invalid( + decode_edited(|value| { + value["cpuid"] + .as_array_mut() + .unwrap() + .retain(|entry| entry["leaf"] != json!("0x00000003")); + }), + "CPUID 0x3 lists subleaves []", + ); + // A hypervisor leaf, which belongs to the time ABI's identity. + assert_invalid( + decode_edited(|value| { + let cpuid = value["cpuid"].as_array_mut().unwrap(); + let position = cpuid + .iter() + .position(|entry| entry["leaf"].as_str().unwrap() >= "0x80000000") + .unwrap(); + cpuid.insert( + position, + json!({ + "leaf": "0x40000000", + "subleaf": null, + "value": ["0x00000000", "0x00000000", "0x00000000", "0x00000000"], + "mask": ["0xffffffff", "0xffffffff", "0xffffffff", "0xffffffff"], + }), + ); + }), + "outside the profile's leaves", + ); + // A subleaf beyond leaf 7's maximum. + assert_invalid( + decode_edited(|value| entry(value, 7, Some(0))["value"][0] = json!("0x00000001")), + "CPUID 0x7 lists subleaves", + ); + } + + #[test] + fn validation_rejects_inconsistent_xsave_and_msrs() { + // XCR0 enables components that leaf 0xd does not describe. + assert_invalid( + decode_edited(|value| value["xcr0"] = json!("0x00000000000000ff")), + "CPUID 0xd lists subleaves", + ); + // Leaf 0xd disagrees with XCR0. + assert_invalid( + decode_edited(|value| entry(value, 0xd, Some(0))["value"][0] = json!("0x00000067")), + "differ from CPUID leaf 0xd", + ); + assert_invalid( + decode_edited(|value| value["xsave_components"][0]["offset"] = json!(512)), + "XSAVE components differ", + ); + assert_invalid( + decode_edited(|value| value["physical_address_width"] = json!(52)), + "physical address width", + ); + assert_invalid( + decode_edited(|value| value["msrs"][0]["value"] = json!("0x8000000000000000")), + "under mask", + ); + assert_invalid( + decode_edited(|value| entry(value, 7, Some(0))["value"][3] = json!("0x00000010")), + "not enumerated", + ); + } + + #[test] + fn masks_leave_the_vm_and_runtime_fields_to_openvmm() { + assert_eq!( + pinned_mask(1, None, [0; 4]), + [!0, 0x0000_ffff, !(1 << 21 | 1 << 27), !0] + ); + assert_eq!( + pinned_mask(4, Some(0), [0x121, 0, 0, 0]), + [0x3fff, !0, !0, !0] + ); + // The null cache type that ends leaf 4 has no VM fields. + assert_eq!(pinned_mask(4, Some(4), [0; 4]), [!0; 4]); + assert_eq!(pinned_mask(7, Some(0), [0; 4]), [!0, !0, !(1 << 4), !0]); + assert_eq!(pinned_mask(0xd, Some(1), [0; 4]), [!0, 0, !0, !0]); + assert_eq!(pinned_mask(0xd, Some(2), [0; 4]), [!0; 4]); + assert_eq!(pinned_mask(0xb, Some(0), [0; 4]), [0; 4]); + } + + #[test] + fn generations_match_by_vendor_family_model_and_stepping() { + let skylake = profile("intel.skylake-sp.v1").generation(); + let intel = |signature| HostCpuSignature::new(*b"GenuineIntel", signature); + assert!(skylake.contains("GenuineIntel", &intel(0x0005_0654))); + // Cascade Lake shares family 6 model 85. + assert!(!skylake.contains("GenuineIntel", &intel(0x0005_0657))); + assert!(!skylake.contains( + "GenuineIntel", + &HostCpuSignature::new(*b"AuthenticAMD", 0x0005_0654) + )); + } +} diff --git a/vmm_core/cpu_profile/src/signature.rs b/vmm_core/cpu_profile/src/signature.rs new file mode 100644 index 00000000000..8ac1bf0bb13 --- /dev/null +++ b/vmm_core/cpu_profile/src/signature.rs @@ -0,0 +1,163 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The identity of a host CPU that profile selection and generation checks +//! use. + +use std::fmt; + +/// The vendor and signature of a host CPU: the vendor string of CPUID leaf 0 +/// and `CPUID.1:EAX`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HostCpuSignature { + vendor: [u8; 12], + signature: u32, +} + +impl HostCpuSignature { + /// Returns the signature of a CPU with the 12-byte `vendor` string and + /// `CPUID.1:EAX` `signature`. + pub const fn new(vendor: [u8; 12], signature: u32) -> Self { + Self { vendor, signature } + } + + /// Returns the signature from the outputs of CPUID leaves 0 and 1, as + /// `[eax, ebx, ecx, edx]`. + pub fn from_cpuid(leaf0: [u32; 4], leaf1: [u32; 4]) -> Self { + Self::new(vendor_bytes(leaf0[1], leaf0[3], leaf0[2]), leaf1[0]) + } + + /// Returns the signature of the CPU this process runs on. + // The host CPU is identified with the CPUID instruction of the host, + // which exists only on x86-64 hosts. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + pub fn current() -> Self { + let cpuid = |leaf| { + let result = safe_intrinsics::cpuid(leaf, 0); + [result.eax, result.ebx, result.ecx, result.edx] + }; + Self::from_cpuid(cpuid(0), cpuid(1)) + } + + /// Returns the 12-byte vendor string, such as `GenuineIntel`. + pub fn vendor(&self) -> [u8; 12] { + self.vendor + } + + /// Returns the vendor string, with any non-UTF-8 bytes replaced. + pub fn vendor_str(&self) -> String { + String::from_utf8_lossy(&self.vendor).into_owned() + } + + /// Returns `CPUID.1:EAX`. + pub fn signature(&self) -> u32 { + self.signature + } + + /// Returns the display family. + pub fn family(&self) -> u32 { + decode_signature(&self.vendor, self.signature).0 + } + + /// Returns the display model. + pub fn model(&self) -> u32 { + decode_signature(&self.vendor, self.signature).1 + } + + /// Returns the stepping. + pub fn stepping(&self) -> u32 { + decode_signature(&self.vendor, self.signature).2 + } +} + +impl fmt::Display for HostCpuSignature { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (family, model, stepping) = decode_signature(&self.vendor, self.signature); + write!( + f, + "{} family {family} model {model} stepping {stepping} (CPUID.1:EAX {:#010x})", + self.vendor_str(), + self.signature + ) + } +} + +/// Returns the 12-byte vendor string from CPUID leaf 0's EBX, EDX, and ECX. +pub(crate) fn vendor_bytes(ebx: u32, edx: u32, ecx: u32) -> [u8; 12] { + let mut vendor = [0; 12]; + for (chunk, register) in vendor + .as_chunks_mut::<4>() + .0 + .iter_mut() + .zip([ebx, edx, ecx]) + { + *chunk = register.to_le_bytes(); + } + vendor +} + +/// Returns the display family, display model, and stepping of the CPUID.1:EAX +/// `signature` of a CPU from `vendor`. +pub(crate) fn decode_signature(vendor: &[u8], signature: u32) -> (u32, u32, u32) { + let stepping = signature & 0xf; + let base_model = (signature >> 4) & 0xf; + let base_family = (signature >> 8) & 0xf; + let extended_model = (signature >> 16) & 0xf; + let extended_family = (signature >> 20) & 0xff; + let family = if base_family == 0xf { + base_family + extended_family + } else { + base_family + }; + // Intel extends the model for families 6 and 15; AMD only for family 15. + let extends_model = base_family == 0xf || (base_family == 0x6 && vendor == b"GenuineIntel"); + let model = if extends_model { + (extended_model << 4) | base_model + } else { + base_model + }; + (family, model, stepping) +} + +#[cfg(test)] +mod tests { + use super::HostCpuSignature; + use test_with_tracing::test; + + const INTEL_LEAF0: [u32; 4] = [0x1b, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]; + + #[test] + fn decodes_the_vendor_and_signature() { + let host = HostCpuSignature::from_cpuid(INTEL_LEAF0, [0x0006_06a6, 0, 0, 0]); + assert_eq!(&host.vendor(), b"GenuineIntel"); + assert_eq!(host.vendor_str(), "GenuineIntel"); + assert_eq!((host.family(), host.model(), host.stepping()), (6, 106, 6)); + assert_eq!( + host.to_string(), + "GenuineIntel family 6 model 106 stepping 6 (CPUID.1:EAX 0x000606a6)" + ); + } + + #[test] + fn decodes_extended_families_and_amd_models() { + let intel = HostCpuSignature::new(*b"GenuineIntel", 0x000c_06f2); + assert_eq!( + (intel.family(), intel.model(), intel.stepping()), + (6, 207, 2) + ); + let amd = HostCpuSignature::new(*b"AuthenticAMD", 0x00a1_0f11); + assert_eq!((amd.family(), amd.model(), amd.stepping()), (0x19, 0x11, 1)); + // AMD does not extend the model of family 6. + let old_amd = HostCpuSignature::new(*b"AuthenticAMD", 0x0001_0661); + assert_eq!((old_amd.family(), old_amd.model()), (6, 6)); + } + + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + #[test] + fn identifies_this_host() { + let host = HostCpuSignature::current(); + assert!(host.family() != 0, "{host}"); + } +} diff --git a/vmm_core/cpu_profile/src/surface.rs b/vmm_core/cpu_profile/src/surface.rs new file mode 100644 index 00000000000..5ea8721c6b1 --- /dev/null +++ b/vmm_core/cpu_profile/src/surface.rs @@ -0,0 +1,441 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! What a backend supports, and the check that it supports a profile. + +use crate::cpuid; +use crate::cpuid::CpuidEntry; +use crate::cpuid::EXTENDED_LEAF_BASE; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::fingerprint::BackendFingerprint; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::hv_banks; +use crate::profile::CpuProfile; +use crate::profile::describe; + +/// The register names, for messages. +const REGISTERS: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"]; + +/// The CPU surface a backend supports on this host, which it reports through +/// `supported_cpu_surface()` and which the CPU fingerprint records. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostCpuSurface { + /// Every CPUID leaf and subleaf the backend can expose, with every + /// feature it can expose set, normalized with [`cpuid::normalize`]. KVM + /// reports `KVM_GET_SUPPORTED_CPUID`; MSHV and WHP report a partition with + /// every available processor and XSAVE feature enabled. + pub cpuid: Vec, + /// The widest guest physical address the backend supports, in bits. + pub physical_address_width: u8, + /// The MSR values the backend can present to a guest. + pub msrs: Vec, +} + +/// The values of one MSR that a backend can present to a guest. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SupportedMsr { + /// The MSR index. + pub index: u32, + /// The bits the backend can present set. + pub supported: u64, + /// The bits the backend can present clear, even where `supported` has + /// them. KVM controls every bit; MSHV and WHP control the bits that their + /// processor feature banks derive. + pub controllable: u64, +} + +impl HostCpuSurface { + /// Returns the surface that a CPU fingerprint recorded. + /// + /// `IA32_ARCH_CAPABILITIES` comes from the KVM feature MSR, or, for the + /// Hyper-V backends, from the processor feature banks. + pub fn from_fingerprint(backend: &BackendFingerprint) -> Self { + let width = cpuid::lookup(&backend.cpuid, EXTENDED_LEAF_BASE + 8, 0).unwrap_or_default()[0]; + let mut msrs = Vec::new(); + if let Some(value) = backend.msrs.arch_capabilities { + msrs.push(SupportedMsr { + index: IA32_ARCH_CAPABILITIES, + supported: value.0, + controllable: !0, + }); + } else if let Some(banks) = hv_banks::fingerprint_banks(backend) { + msrs.push(hv_banks::arch_capabilities_msr(banks)); + } + Self { + cpuid: backend.cpuid.clone(), + physical_address_width: (width & 0xff) as u8, + msrs, + } + } + + /// Returns the supported value of `IA32_ARCH_CAPABILITIES` or another + /// MSR. + pub fn msr(&self, index: u32) -> Option<&SupportedMsr> { + self.msrs.iter().find(|msr| msr.index == index) + } +} + +/// The CPUID time bits that the time ABI sets by policy on every backend, +/// whatever the backend reports: the hypervisor bit, ARAT (a virtual LAPIC +/// timer never stops), and the invariant TSC (backed by host qualification +/// instead). Support checks skip them. +pub const TIME_POLICY_BITS: [(u32, u32, usize, u32); 3] = [ + (0x1, 0, 2, 1 << 31), + (0x6, 0, 0, 1 << 2), + (EXTENDED_LEAF_BASE + 7, 0, 3, 1 << 8), +]; + +/// How a support check compares one CPUID register, and how derivation +/// combines it across hosts. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RegisterClass { + /// Feature flags: every pinned set bit must be supported. + Features, + /// Numeric limits in the given bit fields (shift, width): the profile's + /// may not exceed the backend's. + Limits(&'static [(u32, u32)]), + /// Values that do not constrain the host, or that other checks cover: + /// the vendor and signature (generation check), descriptive cache, TLB, + /// and brand information, and the XSAVE leaf (layout check). + Informational, +} + +pub(crate) fn register_class(leaf: u32, subleaf: u32, register: usize) -> RegisterClass { + const ALL: &[(u32, u32)] = &[(0, 32)]; + const ADDRESS_WIDTHS: &[(u32, u32)] = &[(0, 8), (8, 8)]; + match (leaf, subleaf, register) { + (0x0, _, 0) | (0x8000_0000, _, 0) | (0x7, 0, 0) => RegisterClass::Limits(ALL), + (0x8000_0008, _, 0) => RegisterClass::Limits(ADDRESS_WIDTHS), + (0x0 | 0x2 | 0x3 | 0x4 | 0xd | 0x18, _, _) + | (0x1, _, 0 | 1) + | (0x8000_0002..=0x8000_0006, _, _) + | (0x8000_0008, _, 2 | 3) => RegisterClass::Informational, + _ => RegisterClass::Features, + } +} + +/// Checks that the backend described by `surface` supports `profile`: every +/// feature bit the profile sets except the [`TIME_POLICY_BITS`], every +/// numeric limit, the exact XSAVE layout of every enabled component, the +/// guest physical address width, and every pinned MSR value. +/// +/// Fails with `E_PROFILE_UNSUPPORTED`, naming the first missing leaf, +/// register, and bits. +pub fn verify_support(profile: &CpuProfile, surface: &HostCpuSurface) -> Result<(), ProfileError> { + let violations = support_violations(profile, surface); + match violations.split_first() { + None => Ok(()), + Some((first, rest)) => { + let more = if rest.is_empty() { + String::new() + } else { + format!(" (and {} more)", rest.len()) + }; + Err(ProfileError::new( + ProfileErrorCode::ProfileUnsupported, + format!( + "the backend does not support CPU profile {}: {first}{more}", + profile.id() + ), + )) + } + } +} + +/// Returns every way in which `surface` falls short of `profile`, in the +/// order [`verify_support`] reports them. +pub fn support_violations(profile: &CpuProfile, surface: &HostCpuSurface) -> Vec { + let mut violations = Vec::new(); + for entry in profile.cpuid() { + let leaf = entry.leaf.0; + let subleaf = entry.subleaf.map_or(0, |subleaf| subleaf.0); + let host = cpuid::lookup(&surface.cpuid, leaf, subleaf).unwrap_or_default(); + for (register, (value, mask)) in entry.values().iter().zip(entry.masks()).enumerate() { + let pinned = value & mask; + match register_class(leaf, subleaf, register) { + RegisterClass::Informational => {} + RegisterClass::Features => { + let exempt = TIME_POLICY_BITS + .iter() + .filter(|&&(l, s, r, _)| (l, s, r) == (leaf, subleaf, register)) + .fold(0, |bits, &(.., bit)| bits | bit); + let missing = pinned & !exempt & !host[register]; + if missing != 0 { + violations.push(format!( + "{} {} {} not supported", + describe(entry), + REGISTERS[register], + bits_phrase(missing.into()) + )); + } + } + RegisterClass::Limits(fields) => { + for &(shift, width) in fields { + let field = |value: u32| field_value(value, shift, width); + if field(pinned) > field(host[register]) { + violations.push(format!( + "{} {}[{}:{shift}] is {:#x}, above the supported {:#x}", + describe(entry), + REGISTERS[register], + shift + width - 1, + field(pinned), + field(host[register]) + )); + } + } + } + } + } + } + + let (host_xcr0, host_xss) = cpuid::xsave_supported(&surface.cpuid); + let missing_xcr0 = profile.xcr0() & !host_xcr0; + if missing_xcr0 != 0 { + violations.push(format!("XCR0 bits {missing_xcr0:#x} are not supported")); + } + let missing_xss = profile.xss() & !host_xss; + if missing_xss != 0 { + violations.push(format!("IA32_XSS bits {missing_xss:#x} are not supported")); + } + let host_components = cpuid::xsave_components(&surface.cpuid); + for component in profile.xsave_components() { + match host_components + .iter() + .find(|host| host.index == component.index) + { + Some(host) if host == component => {} + Some(host) => violations.push(format!( + "XSAVE component {} is {} bytes at offset {} (flags supervisor={} align64={} xfd={}), but the backend's is {} bytes at offset {} (flags supervisor={} align64={} xfd={})", + component.index, + component.size, + component.offset, + component.supervisor, + component.align64, + component.xfd, + host.size, + host.offset, + host.supervisor, + host.align64, + host.xfd + )), + None => violations.push(format!( + "XSAVE component {} has no layout", + component.index + )), + } + } + + if profile.physical_address_width() > surface.physical_address_width { + violations.push(format!( + "the guest physical address width {} exceeds the supported {}", + profile.physical_address_width(), + surface.physical_address_width + )); + } + + for msr in profile.msrs() { + let index = msr.index.0; + let host = surface.msr(index).copied().unwrap_or(SupportedMsr { + index, + supported: 0, + controllable: 0, + }); + let unsupported = msr.value.0 & !host.supported; + if unsupported != 0 { + violations.push(format!( + "MSR {index:#x} {} not supported", + bits_phrase(unsupported) + )); + } + let uncontrollable = !msr.value.0 & msr.mask.0 & host.supported & !host.controllable; + if uncontrollable != 0 { + violations.push(format!( + "MSR {index:#x} {} pinned clear but cannot be cleared", + bits_phrase(uncontrollable) + )); + } + } + violations +} + +/// Returns the `width`-bit field at `shift` of `value`. +pub(crate) fn field_value(value: u32, shift: u32, width: u32) -> u32 { + (value >> shift) & u32::MAX.checked_shr(32 - width).unwrap_or(0) +} + +/// Returns `bit 16 is` or `bits 3, 7 are`. +fn bits_phrase(bits: u64) -> String { + let list = (0..64) + .filter(|bit| bits & (1 << bit) != 0) + .map(|bit| bit.to_string()) + .collect::>(); + if list.len() == 1 { + format!("bit {} is", list[0]) + } else { + format!("bits {} are", list.join(", ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::fingerprint; + use crate::test_support::profile; + use test_with_tracing::test; + + const ICELAKE: &str = "intel.icelake-sp.v1"; + + /// Returns the surface of a KVM host that supports exactly `profile`. + fn surface(profile: &CpuProfile) -> HostCpuSurface { + HostCpuSurface::from_fingerprint(&fingerprint(profile, "kvm").backend) + } + + /// Changes the register of `leaf` and `subleaf` in `surface`. + fn edit( + surface: &mut HostCpuSurface, + leaf: u32, + subleaf: u32, + register: usize, + f: impl FnOnce(u32) -> u32, + ) { + let entry = surface + .cpuid + .iter_mut() + .find(|entry| entry.leaf.0 == leaf && entry.subleaf.is_none_or(|s| s.0 == subleaf)) + .unwrap(); + let mut registers = entry.registers(); + registers[register] = f(registers[register]); + *entry = CpuidEntry::new(leaf, entry.subleaf.map(|s| s.0), registers); + } + + fn unsupported(profile: &CpuProfile, surface: &HostCpuSurface) -> String { + let error = verify_support(profile, surface).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported, "{error}"); + error.message + } + + #[test] + fn every_pinned_profile_supports_itself() { + for profile in crate::pinned_profiles() { + verify_support(profile, &surface(profile)).unwrap(); + } + } + + #[test] + fn the_time_policy_bits_are_exempt() { + let profile = profile(ICELAKE); + let mut surface = surface(profile); + for (leaf, subleaf, register, bit) in TIME_POLICY_BITS { + edit(&mut surface, leaf, subleaf, register, |value| value & !bit); + } + verify_support(profile, &surface).unwrap(); + } + + #[test] + fn names_missing_features_and_limits() { + let profile = profile(ICELAKE); + let mut surface = surface(profile); + // AVX512F and PSFD. + edit(&mut surface, 7, 0, 1, |ebx| ebx & !(1 << 16)); + edit(&mut surface, 7, 2, 3, |edx| edx & !1); + let message = unsupported(profile, &surface); + assert!( + message.ends_with( + "intel.icelake-sp.v1: CPUID 0x7.0 EBX bit 16 is not supported (and 1 more)" + ), + "{message}" + ); + + let mut surface = self::surface(profile); + edit(&mut surface, 0x8000_0008, 0, 0, |eax| (eax & !0xff) | 39); + surface.physical_address_width = 39; + let violations = support_violations(profile, &surface); + assert_eq!( + violations, + [ + "CPUID 0x80000008 EAX[7:0] is 0x2e, above the supported 0x27", + "the guest physical address width 46 exceeds the supported 39", + ] + ); + + let mut surface = self::surface(profile); + edit(&mut surface, 7, 0, 0, |_| 0); + assert!( + unsupported(profile, &surface) + .contains("CPUID 0x7.0 EAX[31:0] is 0x2, above the supported 0x0") + ); + } + + #[test] + fn requires_the_exact_xsave_layout() { + let profile = profile(ICELAKE); + let mut surface = surface(profile); + edit(&mut surface, 0xd, 7, 1, |offset| offset + 64); + assert!( + unsupported(profile, &surface) + .contains("XSAVE component 7 is 1024 bytes at offset 1664") + ); + let mut surface = self::surface(profile); + edit(&mut surface, 0xd, 0, 0, |eax| eax & !(1 << 7)); + let violations = support_violations(profile, &surface); + assert!( + violations.contains(&"XCR0 bits 0x80 are not supported".to_owned()), + "{violations:?}" + ); + } + + #[test] + fn checks_that_pinned_msr_values_can_be_presented() { + let profile = profile(ICELAKE); + let (value, mask) = profile.msr(IA32_ARCH_CAPABILITIES).unwrap(); + assert_eq!((value, mask), (0x0800_0121, 0x4000_0000_0c12_e1bb)); + + // KVM controls every bit, so it can present the value while + // supporting more. + let mut surface = surface(profile); + surface.msrs[0].supported |= 1 << 62 | 1 << 26; + verify_support(profile, &surface).unwrap(); + + // A Hyper-V backend cannot clear what its banks do not derive. + surface.msrs[0].controllable = hv_banks::ARCH_CAPABILITIES_BANK_MASK; + assert!( + unsupported(profile, &surface) + .contains("MSR 0x10a bit 62 is pinned clear but cannot be cleared") + ); + + // A missing immunity bit. + let mut surface = self::surface(profile); + surface.msrs[0].supported = value & !(1 << 27); + assert!(unsupported(profile, &surface).contains("MSR 0x10a bit 27 is not supported")); + surface.msrs.clear(); + assert!( + unsupported(profile, &surface).contains("MSR 0x10a bits 0, 5, 8, 27 are not supported") + ); + } + + #[test] + fn reads_the_hyper_v_arch_capabilities_from_the_banks() { + let profile = profile(ICELAKE); + let mut fingerprint = fingerprint(profile, "whp"); + fingerprint.backend.msrs.arch_capabilities = None; + fingerprint.backend.set_feature_bank( + "whp.capability.ProcessorFeaturesBanks.bank0", + 0x2e0a_8bff_e7f7_859f, + ); + fingerprint.backend.set_feature_bank( + "whp.capability.ProcessorFeaturesBanks.bank1", + 0x0001_000e_0000_00f1, + ); + let surface = HostCpuSurface::from_fingerprint(&fingerprint.backend); + assert_eq!( + surface.msr(IA32_ARCH_CAPABILITIES).copied(), + Some(SupportedMsr { + index: IA32_ARCH_CAPABILITIES, + supported: 0x0800_0121, + controllable: hv_banks::ARCH_CAPABILITIES_BANK_MASK, + }) + ); + verify_support(profile, &surface).unwrap(); + } +} diff --git a/vmm_core/cpu_profile/src/test_support.rs b/vmm_core/cpu_profile/src/test_support.rs new file mode 100644 index 00000000000..a73ffc7def2 --- /dev/null +++ b/vmm_core/cpu_profile/src/test_support.rs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Fixtures shared by the unit tests. + +use crate::CpuProfile; +use crate::Hex32; +use crate::Hex64; +use crate::cpuid::CpuidEntry; +use crate::fingerprint::BackendFingerprint; +use crate::fingerprint::CpuFingerprint; +use crate::fingerprint::IA32_ARCH_CAPABILITIES; +use crate::fingerprint::ToolIdentity; +use crate::host::HostCpu; +use crate::host::HostIdentity; +use crate::host::HostOs; +use crate::signature::decode_signature; + +/// Returns the pinned profile `id`. +pub(crate) fn profile(id: &str) -> &'static CpuProfile { + crate::pinned(id).unwrap_or_else(|| panic!("profile {id} is not pinned")) +} + +/// Returns a profile's CPUID values as fingerprint entries. +pub(crate) fn profile_entries(profile: &CpuProfile) -> Vec { + profile + .cpuid() + .iter() + .map(|entry| CpuidEntry::new(entry.leaf.0, entry.subleaf.map(|s| s.0), entry.values())) + .collect() +} + +/// Returns the fingerprint of a host whose `backend` supports exactly +/// `profile`: its CPUID values and, as a KVM host would report it, its pinned +/// `IA32_ARCH_CAPABILITIES` value. +pub(crate) fn fingerprint(profile: &CpuProfile, backend: &str) -> CpuFingerprint { + fingerprint_with(profile, backend, profile_entries(profile)) +} + +/// Returns the fingerprint of a host whose `backend` reports `cpuid`, of +/// `profile`'s CPU. +pub(crate) fn fingerprint_with( + profile: &CpuProfile, + backend: &str, + cpuid: Vec, +) -> CpuFingerprint { + let signature = profile.lookup(1, 0)[0]; + let (family, model, stepping) = decode_signature(profile.vendor().as_bytes(), signature); + let brand = (0x8000_0002..=0x8000_0004) + .flat_map(|leaf| profile.lookup(leaf, 0)) + .flat_map(u32::to_le_bytes) + .collect::>(); + let host = HostIdentity { + cpu: HostCpu { + vendor: profile.vendor().to_owned(), + signature: Hex32(signature), + family, + model, + stepping, + brand: String::from_utf8_lossy(&brand) + .trim_end_matches('\0') + .to_owned(), + microcode: Vec::new(), + invariant_tsc: true, + tsc_deadline: true, + tsc_adjust: true, + }, + os: HostOs { + kind: "linux".to_owned(), + release: None, + version: None, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }, + hypervisor: None, + }; + let mut backend = BackendFingerprint::new(backend, "test", cpuid); + backend.msrs.arch_capabilities = profile + .msr(IA32_ARCH_CAPABILITIES) + .map(|(value, _)| Hex64(value)); + CpuFingerprint::new( + ToolIdentity { + name: "test".to_owned(), + version: "0".to_owned(), + }, + host, + backend, + ) +} From 299426bfa3ee26f27c42f7befea060b92ca4da26 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 05:25:08 -0700 Subject: [PATCH 184/304] openvmm: add the time ABI restore preflight behind --x-time-abi-v1 Run restore steps 1 to 9 of the NVX time ABI for a version 6 snapshot under the development switch. The controller requires the manifest version that matches the switch, validates the time contract and CPU profile record, requires the snapshot's backend, checks the interim CPU profile and host CPU signature, preflights the downtime against the host's identity and clocks, and gives restore packet version 4 its controller fields and generation counter. The worker resolves the CPU profile, compares the effective CPUID with the recorded one (E_CPU_SURFACE), declares the snapshot's rates after the rate policy, and then stops with an explicit error until the restore clock lands. Add the hidden --cpu-profile, --x-time-abi-test-hook, and --x-time-abi-verify options. Verification mode builds the partition, runs the preflight, prints one NVX-TIME-ABI-VERIFY line from the worker's time-abi inspect report, and exits without running the guest. Add virt::time_abi::surface with the canonical effective-CPUID encoding, its coded comparison, and the interim CPU profile used until CPU profiles land, and a machine-contract builder that takes the clock records of either manifest generation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 2 + openvmm/openvmm_core/src/worker/dispatch.rs | 41 ++- .../src/worker/dispatch/restore.rs | 8 + .../src/worker/dispatch/time_abi.rs | 130 +++++++- openvmm/openvmm_defs/src/worker.rs | 3 + openvmm/openvmm_entry/Cargo.toml | 1 + openvmm/openvmm_entry/src/cli_args/microvm.rs | 30 ++ openvmm/openvmm_entry/src/lib.rs | 12 +- openvmm/openvmm_entry/src/microvm/config.rs | 85 +++-- openvmm/openvmm_entry/src/microvm/launch.rs | 8 + openvmm/openvmm_entry/src/microvm/mod.rs | 8 + openvmm/openvmm_entry/src/microvm/restore.rs | 303 ++++++++++++++++-- openvmm/openvmm_entry/src/microvm/verify.rs | 177 ++++++++++ .../openvmm_entry/src/snapshot_restore/mod.rs | 32 +- .../src/snapshot_restore/prepare.rs | 17 +- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 11 +- openvmm/openvmm_entry/src/ttrpc/mod.rs | 1 + .../openvmm_helpers/src/snapshot/microvm.rs | 119 ++++++- openvmm/openvmm_helpers/src/snapshot/time.rs | 276 +++++++++++++++- petri/src/worker.rs | 1 + vmm_core/virt/Cargo.toml | 3 + vmm_core/virt/src/time_abi/mod.rs | 1 + vmm_core/virt/src/time_abi/surface.rs | 281 ++++++++++++++++ 23 files changed, 1453 insertions(+), 97 deletions(-) create mode 100644 openvmm/openvmm_entry/src/microvm/verify.rs create mode 100644 vmm_core/virt/src/time_abi/surface.rs diff --git a/Cargo.lock b/Cargo.lock index abbc11a93d5..a25f34c29ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6089,6 +6089,7 @@ dependencies = [ "unix_socket", "vfio_assigned_device_resources", "video_core", + "virt", "virt_whp", "virtio_resources", "vm_manifest_builder", @@ -9778,6 +9779,7 @@ dependencies = [ "pal_event", "parking_lot", "pci_core", + "safe_intrinsics", "slab", "test_with_tracing", "thiserror 2.0.16", diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 68ac4a65bdb..c50e61f621a 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1943,27 +1943,54 @@ impl InitializedVm { .time_abi .as_ref() .context("time ABI parameters are missing")?; + let hypervisor = &cfg.microvm.hypervisor_id; + let restore = snapshot_restore.time_abi.as_ref(); anyhow::ensure!( - saved_state.is_none(), - "time ABI v1 snapshot restore is not implemented yet" + saved_state.is_some() == restore.is_some(), + "a time ABI restore requires both the saved state and the time ABI restore inputs" ); - time_abi::declare_rates( + let cpu_profile = virt::time_abi::surface::resolve_cpu_profile( + ¶meters.cpu_profile, + hypervisor, + )?; + if let Some(input) = restore { + time_abi::check_cpu_surface( + partition.as_ref(), + hypervisor, + &input.cpu_profile, + )?; + } + let report = time_abi::declare_rates( partition.as_ref(), &msrs, - &cfg.microvm.hypervisor_id, - None, + hypervisor, + cpu_profile, + restore.map(|input| virt::time_abi::DeclaredRates { + tsc_hz: input.contract.tsc_frequency_hz, + apic_hz: input.contract.apic_frequency_hz, + }), ¶meters.hooks, )?; + anyhow::ensure!( + restore.is_none(), + "time ABI v1 snapshot restore is not implemented yet: the preflight passed, but the restore clock is missing" + ); Some( state_units .add(time_abi::TIME_ABI_UNIT) .spawn(driver_source.simple(), |recv| { - state_unit::run_unit(time_abi::TimeAbiUnit(msrs), recv) + state_unit::run_unit(time_abi::TimeAbiUnit { msrs, report }, recv) }) .unwrap(), ) } - None => None, + None => { + anyhow::ensure!( + snapshot_restore.time_abi.is_none(), + "time ABI restore inputs require a time ABI partition" + ); + None + } }; let mut input_distributor = InputDistributor::new(cfg.input); diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index d9fb889bbae..e0094bac8a2 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -66,6 +66,11 @@ impl RestoreParameters { "received restore-time VP activation target" ); let cpu_contract = parameters.restore_cpu_contract.take(); + let time_abi = parameters.restore_time.take(); + anyhow::ensure!( + time_abi.is_none() || (restore_time.is_none() && cpu_contract.is_none()), + "a restore carries either the time ABI inputs or the legacy clock contract" + ); let file_mapping_mode = if parameters.shared_memory_copy_on_write { FileMappingMode::CopyOnWrite } else { @@ -74,6 +79,7 @@ impl RestoreParameters { Ok(Self { state: SnapshotRestore { time: restore_time, + time_abi, vp_prefix, ready_sink, gate_timeout, @@ -134,6 +140,8 @@ fn select_instantiated_vps( pub(super) struct SnapshotRestore { /// Saved guest-clock contract, checked and applied around the restore. time: Option, + /// The time ABI inputs of a restore, validated by the controller. + pub(super) time_abi: Option, /// VP prefix instantiated for an explicit MSHV restore-time activation /// target. vp_prefix: Option, diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 25ce5758a8d..d34a7632ebe 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -10,15 +10,19 @@ use crate::partition::HvlitePartition; use inspect::Inspect; use inspect::InspectMut; +use openvmm_defs::time_abi::SnapshotCpuProfile; use state_unit::StateUnit; use std::sync::Arc; use virt::time_abi::DeclaredRates; -use virt::time_abi::NegotiatedRates; +use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::RateCheck; +use virt::time_abi::TimeAbiBackend; use virt::time_abi::TimeAbiCode; use virt::time_abi::TimeAbiConfig; use virt::time_abi::TimeAbiError; use virt::time_abi::TimeAbiMsrs; use virt::time_abi::TimeAbiTestHooks; +use virt::time_abi::TscSyncMethod; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; use vmcore::save_restore::SavedStateBlob; @@ -37,6 +41,41 @@ pub(super) fn partition_config(vp_count: u32) -> (Arc, TimeAbiConfi (msrs, config) } +/// Returns the partition's time ABI backend (`E_TSC_SYNC_UNSUPPORTED` if the +/// backend does not implement the time ABI). +fn backend<'a>( + partition: &'a dyn HvlitePartition, + hypervisor: &str, +) -> Result<&'a dyn TimeAbiBackend, TimeAbiError> { + partition.time_abi().ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("the {hypervisor} backend does not implement the time ABI"), + ) + }) +} + +/// What the time ABI preflight established for a VM process, exposed through +/// inspect at `time-abi` together with the declared rates. +#[derive(Debug, Clone, Inspect)] +pub(super) struct TimeAbiReport { + /// The backend. + pub hypervisor: String, + /// The CPU profile. + pub cpu_profile: String, + /// The identity MSR route. + #[inspect(debug)] + pub msr_route: IdentityMsrRoute, + /// The synchronized TSC set method. + #[inspect(debug)] + pub sync: TscSyncMethod, + /// The backend's native TSC rate `F_d`, after the rate test hook. + pub native_tsc_hz: u64, + /// On restore, the accepted rate check against the snapshot's rate. + #[inspect(debug)] + pub rate_check: Option, +} + /// Runs the backend preflight and the rate policy, and declares the rates /// to the guest: restore steps 8 and 9 of the specification when `saved` /// holds the snapshot's rates, or their cold-boot equivalent. @@ -44,20 +83,17 @@ pub(super) fn declare_rates( partition: &dyn HvlitePartition, msrs: &TimeAbiMsrs, hypervisor: &str, + cpu_profile: String, saved: Option, hooks: &TimeAbiTestHooks, -) -> anyhow::Result { - let backend = partition.time_abi().ok_or_else(|| { - TimeAbiError::new( - TimeAbiCode::TscSyncUnsupported, - format!("the {hypervisor} backend does not implement the time ABI"), - ) - })?; +) -> anyhow::Result { + let backend = backend(partition, hypervisor)?; let preflight = backend.preflight()?; let rates = virt::time_abi::negotiate_rates(backend, hypervisor, saved, hooks)?; msrs.declare(rates.declared)?; tracing::info!( hypervisor, + cpu_profile, msr_route = ?preflight.msr_route, sync = ?preflight.sync, native_tsc_hz = rates.native_tsc_hz, @@ -66,16 +102,40 @@ pub(super) fn declare_rates( deviation_ppb = ?rates.check.map(|check| check.deviation_ppb), "time ABI rates declared" ); - Ok(rates) + Ok(TimeAbiReport { + hypervisor: hypervisor.to_owned(), + cpu_profile, + msr_route: preflight.msr_route, + sync: preflight.sync, + native_tsc_hz: rates.native_tsc_hz, + rate_check: rates.check, + }) +} + +/// Compares the effective CPUID the backend programmed with the snapshot's +/// record (`E_CPU_SURFACE`): the effective-CPUID check of restore step 8 of +/// the specification. +pub(super) fn check_cpu_surface( + partition: &dyn HvlitePartition, + hypervisor: &str, + record: &SnapshotCpuProfile, +) -> anyhow::Result<()> { + let effective = backend(partition, hypervisor)?.effective_cpuid()?; + virt::time_abi::surface::check_effective_cpuid(&effective, &record.effective_cpuid)?; + Ok(()) } /// The `time-abi` state unit. It saves and restores -/// `HV_X64_MSR_TSC_INVARIANT_CONTROL`, which is partition-wide guest state. -pub(super) struct TimeAbiUnit(pub Arc); +/// `HV_X64_MSR_TSC_INVARIANT_CONTROL`, which is partition-wide guest state, +/// and inspects the declared rates and the preflight report. +pub(super) struct TimeAbiUnit { + pub msrs: Arc, + pub report: TimeAbiReport, +} impl InspectMut for TimeAbiUnit { fn inspect_mut(&mut self, req: inspect::Request<'_>) { - self.0.inspect(req); + req.respond().merge(&*self.msrs).merge(&self.report); } } @@ -87,16 +147,16 @@ impl StateUnit for TimeAbiUnit { async fn stop(&mut self) {} async fn reset(&mut self) -> anyhow::Result<()> { - self.0.reset(); + self.msrs.reset(); Ok(()) } async fn save(&mut self) -> Result, SaveError> { - Ok(Some(SavedStateBlob::new(self.0.save()))) + Ok(Some(SavedStateBlob::new(self.msrs.save()))) } async fn restore(&mut self, state: SavedStateBlob) -> Result<(), RestoreError> { - self.0.restore(state.parse()?) + self.msrs.restore(state.parse()?) } } @@ -106,6 +166,20 @@ mod tests { use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use vm_topology::processor::VpIndex; + fn test_unit(msrs: Arc) -> TimeAbiUnit { + TimeAbiUnit { + msrs, + report: TimeAbiReport { + hypervisor: "kvm".to_owned(), + cpu_profile: "interim.host.kvm.v1".to_owned(), + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::CommonOffset, + native_tsc_hz: 2_100_000_000, + rate_check: None, + }, + } + } + #[test] fn unit_saves_restores_and_resets_the_invariant_control() { futures::executor::block_on(async { @@ -114,11 +188,11 @@ mod tests { msrs.write(VpIndex::BSP, MSR_TSC_INVARIANT_CONTROL, 1) .unwrap() .unwrap(); - let mut unit = TimeAbiUnit(msrs.clone()); + let mut unit = test_unit(msrs.clone()); let saved = unit.save().await.unwrap().unwrap(); let (restored, _) = partition_config(4); - let mut restored_unit = TimeAbiUnit(restored.clone()); + let mut restored_unit = test_unit(restored.clone()); restored_unit.restore(saved).await.unwrap(); assert_eq!(restored.tsc_invariant_control(), 1); @@ -127,6 +201,28 @@ mod tests { }); } + #[test] + fn unit_inspects_the_report() { + let (msrs, _) = partition_config(1); + let mut unit = test_unit(msrs); + let mut inspection = inspect::inspect("", &mut unit); + futures::executor::block_on(inspection.resolve()); + let inspect::Node::Dir(entries) = inspection.results() else { + panic!("the unit inspects as a directory"); + }; + let names: Vec<_> = entries.iter().map(|entry| entry.name.as_str()).collect(); + for name in [ + "tsc_invariant_control", + "hypervisor", + "cpu_profile", + "msr_route", + "sync", + "native_tsc_hz", + ] { + assert!(names.contains(&name), "{names:?}"); + } + } + #[test] fn partition_cpuid_carries_the_identity() { let (_, config) = partition_config(2); diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index fffb34e1dcc..c385e577cd8 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -83,6 +83,9 @@ pub struct VmWorkerParameters { pub restore_apic_frequency_hz: Option, /// Saved canonical CPU contract required by restore. pub restore_cpu_contract: Option>, + /// The time ABI inputs of a restored microVM, validated by the + /// controller. They replace the four fields above under the time ABI. + pub restore_time: Option, /// Single-use process-local sink for the restore readiness event. pub restore_ready_sink: Option, /// Timeout for the microVM post-restore input gate, when required. diff --git a/openvmm/openvmm_entry/Cargo.toml b/openvmm/openvmm_entry/Cargo.toml index 6175ddcaf78..0efbb8ca587 100644 --- a/openvmm/openvmm_entry/Cargo.toml +++ b/openvmm/openvmm_entry/Cargo.toml @@ -60,6 +60,7 @@ vmgs_format.workspace = true vmgs_resources.workspace = true vm_manifest_builder.workspace = true vm_resource.workspace = true +virt.workspace = true vtl2_settings_proto.workspace = true pcie_remote_resources.workspace = true diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index a75ce29b7cf..4b281224db4 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -363,6 +363,36 @@ pub struct MicrovmCli { /// becomes the only microVM path) #[clap(long = "x-time-abi-v1", hide = true)] pub x_time_abi_v1: bool, + + /// microVM CPU profile: a pinned profile ID, or `auto` (the default) to + /// select the host's profile; a restore must name the snapshot's profile + #[clap( + long = "cpu-profile", + value_name = "ID", + hide = true, + requires = "x_time_abi_v1" + )] + pub cpu_profile: Option, + + /// time ABI test hook (repeatable; testing only) + #[clap( + long = "x-time-abi-test-hook", + value_name = "HOOK", + hide = true, + requires = "x_time_abi_v1" + )] + pub x_time_abi_test_hook: Vec, + + /// build the partition and run the time ABI preflight without running + /// the guest, print one `NVX-TIME-ABI-VERIFY:` line, and exit with status + /// 0 or 1 (host qualification) + #[clap( + long = "x-time-abi-verify", + hide = true, + requires = "x_time_abi_v1", + conflicts_with_all = ["restore_snapshot", "snapshot_destination"] + )] + pub x_time_abi_verify: bool, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 227dbac3b5c..56c3e43cd05 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -2970,7 +2970,7 @@ async fn run_control_inner( let vm_host = mesh.make_host("vm", opt.log_file.clone()).await?; let (shared_memory, saved_state) = if opt.restore_snapshot.is_some() { - let (fd, state_msg) = restore.prepare(&opt, µvm, &source_hypervisor)?; + let (fd, state_msg) = restore.prepare(&opt, &mut microvm, &source_hypervisor)?; (Some(fd), Some(state_msg)) } else if let Some(shared_memory) = microvm.capture_shared_memory()? { (Some(shared_memory), None) @@ -3003,6 +3003,7 @@ async fn run_control_inner( restore_tsc_frequency_hz: restore.tsc_frequency_hz, restore_apic_frequency_hz: restore.apic_frequency_hz, restore_cpu_contract: restore.cpu_contract, + restore_time: restore.time, restore_ready_sink, restore_gate_timeout: microvm.restore_gate_timeout(&opt), restore_vp_count: opt.microvm.restore_processors, @@ -3010,11 +3011,14 @@ async fn run_control_inner( notify: notify_send, }; let worker_launch = openvmm_defs::profile::ProfileSpan::start(); - vm_host + let launched = vm_host .launch_worker(VM_WORKER, params) .await - .context("failed to launch vm worker") - .inspect(|_| snapshot_restore::worker_launched(worker_launch))? + .context("failed to launch vm worker"); + if opt.microvm.x_time_abi_verify { + return microvm::report_time_abi_verification(launched, &source_hypervisor).await; + } + launched.inspect(|_| snapshot_restore::worker_launched(worker_launch))? }; if opt.restore_snapshot.is_some() { diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 1eeed96e796..5b2fdf26033 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -394,16 +394,41 @@ impl<'a> MicrovmConfigBuilder<'a> { let opt = self.opt; let (drain, output_drain) = MicrovmOutputDrain::new(Some(output_completion)); self.resources.output_drain = Some(drain); - let (generation_id, restore_entropy) = - if opt.microvm.restore_entropy || self.restore.memory_target_requested { - fresh_microvm_restore_packet( - opt.microvm.restore_processors, - self.restore.memory_target_requested, - &self.restore.memory_ranges, - )? - } else { - (fresh_microvm_generation_id()?, Vec::new()) - }; + let mut time_abi_restore = None; + let (generation_id, restore_entropy) = if opt.microvm.x_time_abi_v1 + && self.restore.time_abi_records().is_some() + { + let (generation_id, base) = self + .restore + .time_abi_restore_packet(opt.microvm.restore_processors)?; + let (send, recv) = mesh::oneshot(); + self.resources.restore_time_record = Some(send); + time_abi_restore = + Some(chipset_resources::microvm::MicrovmRestorePacketSource { base, time: recv }); + (generation_id, Vec::new()) + } else if opt.microvm.restore_entropy || self.restore.memory_target_requested { + fresh_microvm_restore_packet( + opt.microvm.restore_processors, + self.restore.memory_target_requested, + &self.restore.memory_ranges, + )? + } else { + (fresh_microvm_generation_id()?, Vec::new()) + }; + let time_abi = if opt.microvm.x_time_abi_v1 { + let hooks = time_abi_test_hooks(opt)?; + Some(chipset_resources::microvm::MicrovmPortbTimeAbi { + generation: time_abi_restore + .as_ref() + .map_or(0, |restore| restore.base.generation), + utc_offset_ms: hooks.utc_offset_ms, + sample_delay_us: hooks.sample_delay_us, + test_hooks: hooks.active(), + restore: time_abi_restore, + }) + } else { + None + }; chipset_devices.push(ChipsetDeviceHandle { name: MicrovmPortbHandle::ID.to_owned(), resource: MicrovmPortbHandle { @@ -411,15 +436,7 @@ impl<'a> MicrovmConfigBuilder<'a> { generation_id, restore_entropy, output_drain: Some(output_drain), - time_abi: opt.microvm.x_time_abi_v1.then(|| { - chipset_resources::microvm::MicrovmPortbTimeAbi { - generation: 0, - utc_offset_ms: 0, - sample_delay_us: 0, - test_hooks: false, - restore: None, - } - }), + time_abi, } .into_resource(), }); @@ -694,18 +711,27 @@ impl<'a> MicrovmConfigBuilder<'a> { cfg.machine_profile == MachineProfile::Microvm, "--x-time-abi-v1 requires the microVM machine profile" ); - anyhow::ensure!( - opt.restore_snapshot.is_none(), - "time ABI v1 snapshot restore is not implemented yet" - ); anyhow::ensure!( opt.microvm.snapshot_destination.is_none(), "time ABI v1 snapshot capture is not implemented yet" ); + let hooks = time_abi_test_hooks(opt)?; + if hooks.active() { + tracing::warn!(?hooks, "time ABI test hooks are active"); + } + let requested_profile = opt.microvm.cpu_profile.as_deref().unwrap_or("auto"); + let (cpu_profile, generation) = match self.restore.time_abi_records() { + // The controller's restore preflight checks the requested + // profile against the snapshot's. + Some((time, record)) => { + (record.id.clone(), time.capture_generation.saturating_add(1)) + } + None => (requested_profile.to_owned(), 0), + }; cfg.microvm.time_abi = Some(openvmm_defs::time_abi::TimeAbiParameters { - cpu_profile: "auto".to_owned(), - generation: 0, - hooks: Default::default(), + cpu_profile, + generation, + hooks, }); } @@ -809,6 +835,13 @@ fn setup_host_console( }) } +/// Returns the time ABI test hooks selected on the command line. +fn time_abi_test_hooks(opt: &Options) -> anyhow::Result { + Ok(virt::time_abi::TimeAbiTestHooks::parse( + &opt.microvm.x_time_abi_test_hook, + )?) +} + fn build_effective_microvm_command_line( user_args: &[String], processor_count: u32, diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 551ca74802f..c0d41fa4424 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -170,6 +170,14 @@ impl MicrovmLaunch { .then_some(Duration::from_millis(opt.microvm.restore_gate_timeout_ms)) } + /// Takes the sender that seals the time fields of a time ABI restore + /// packet, for the restoring worker. + pub(crate) fn take_restore_time_record( + &mut self, + ) -> Option> { + self.resources.restore_time_record.take() + } + /// Creates the channels that carry guest-requested snapshot boundaries from /// the VM worker to the VM controller. pub(crate) fn snapshot_channels( diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index deb53d4c179..1d282423f7e 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -11,6 +11,7 @@ mod network; pub(crate) mod output; pub(crate) mod report; mod restore; +mod verify; pub(crate) use config::MicrovmConfigBuilder; #[cfg(test)] @@ -40,12 +41,15 @@ pub(crate) use filesystem::validate_microvm_filesystem_private_storage; pub(crate) use launch::MicrovmLaunch; pub(crate) use restore::ExpectedRestoreContract; pub(crate) use restore::MicrovmRestore; +pub(crate) use restore::RestoreClock; +pub(crate) use restore::TimeAbiRestoreOptions; #[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use restore::fresh_microvm_generation_id; #[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use restore::fresh_microvm_restore_packet; pub(crate) use restore::prepare_restore; pub(crate) use restore::validate_restore_contract; +pub(crate) use verify::report_time_abi_verification; use crate::storage_builder::microvm::MicrovmSandboxBlockSource; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; @@ -80,4 +84,8 @@ pub(crate) struct MicrovmResources { pub(crate) filesystem_attachment: Option, /// Canonical host path of the live filesystem root. pub(crate) filesystem_root_path: Option, + /// Under the time ABI, seals the time fields of the restore packet; the + /// restoring worker takes it. + pub(crate) restore_time_record: + Option>, } diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 491a70d7a5d..2eb077b8be8 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -12,11 +12,19 @@ use crate::cli_args; use crate::cli_args::DiskCliKind; use crate::cli_args::microvm::MachineProfileCli; use anyhow::Context; +use chipset_resources::microvm_time::RESTORE_ENTROPY_LEN; +use chipset_resources::microvm_time::RestoreMemoryRange; +use chipset_resources::microvm_time::RestorePacketBase; +use chipset_resources::microvm_time::RestoreTimeRecord; use net_backend_resources::egress::EgressPolicy; use openvmm_defs::config::DeviceVtl; use openvmm_defs::microvm::MicrovmFilesystemConfig; use openvmm_defs::microvm::MicrovmNetworkConfig; +use openvmm_defs::time_abi::RestoreTimeInput; +use openvmm_defs::time_abi::SnapshotCpuProfile; +use openvmm_defs::time_abi::SnapshotTimeContract; use openvmm_helpers::snapshot::SnapshotManifest; +use openvmm_helpers::snapshot::microvm::MicrovmClockContract; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use openvmm_helpers::snapshot::microvm::SnapshotMachineContract; use openvmm_helpers::snapshot::microvm::SnapshotMemoryExpansionRange; @@ -24,6 +32,8 @@ use openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock; use openvmm_helpers::snapshot::restore::OpenedSnapshot; use std::path::Path; use std::time::Duration; +use virt::time_abi::HostIdentity; +use virt::time_abi::TimeAbiTestHooks; const MAX_SNAPSHOT_DOWNTIME: Duration = Duration::from_secs(30 * 24 * 60 * 60); @@ -182,6 +192,63 @@ pub(crate) struct MicrovmRestore { pub(crate) private_scratch_dir: Option, } +impl MicrovmRestore { + /// Returns the time ABI records of the snapshot being restored: its time + /// contract and CPU profile record. + pub(crate) fn time_abi_records(&self) -> Option<(&SnapshotTimeContract, &SnapshotCpuProfile)> { + let contract = self.machine_contract.as_ref()?; + contract.time.as_ref().zip(contract.cpu_profile.as_ref()) + } + + /// Returns the controller's fields of restore packet version 4 for a time + /// ABI restore, and the generation ID they imply: restore step 7 of the + /// specification. The generation counter is the snapshot's plus one. + pub(crate) fn time_abi_restore_packet( + &self, + restore_online_vp_count: Option, + ) -> anyhow::Result<([u8; 16], RestorePacketBase)> { + let (time, _) = self + .time_abi_records() + .context("time ABI restore requires the snapshot's time contract")?; + let generation = time.capture_generation.checked_add(1).ok_or_else(|| { + virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::GenerationExhausted, + "the snapshot's generation counter cannot be incremented", + ) + })?; + anyhow::ensure!( + self.memory_target_requested || self.memory_ranges.is_empty(), + "restore memory ranges require an explicit memory target" + ); + let generation_id_create = openvmm_defs::profile::ProfileSpan::start(); + let mut entropy = [0_u8; RESTORE_ENTROPY_LEN]; + getrandom::fill(&mut entropy).context("failed to generate restore entropy")?; + let base = RestorePacketBase { + online_vp_count: restore_online_vp_count + .map(u8::try_from) + .transpose() + .context("restore-online VP count does not fit in u8")? + .unwrap_or(0), + memory_target: self.memory_target_requested, + ack_required: self.gate_required, + generation, + ranges: self + .memory_ranges + .iter() + .map(|range| RestoreMemoryRange { + gpa_start: range.gpa_start, + length: range.length, + }) + .collect(), + entropy, + }; + base.validate() + .context("restore packet version 4 is invalid")?; + generation_id_create.complete("restore", "generation_id_create", Default::default()); + Ok((microvm_generation_id(&entropy), base)) + } +} + /// Validates a snapshot restore against the microVM profile and prepares the /// restore-time inputs of the microVM configuration. /// @@ -224,6 +291,13 @@ pub(crate) fn prepare_restore( "snapshot machine profile does not match the requested microVM machine" ); openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; + openvmm_helpers::snapshot::time::check_time_abi_manifest_version( + manifest, + opt.microvm.x_time_abi_v1, + )?; + if let Some(time) = &contract.time { + openvmm_helpers::snapshot::time::validate_time_contract(time)?; + } if let Some(restore_processors) = opt.microvm.restore_processors { openvmm_helpers::snapshot::microvm::validate_restore_online_vp_count( manifest, @@ -328,7 +402,11 @@ pub(crate) fn prepare_restore( } else { None }; - if restore_gate_required || opt.microvm.restore_processors.is_some() { + // A time ABI restore always exposes a restore packet with fresh entropy. + if restore_gate_required + || opt.microvm.restore_processors.is_some() + || (opt.microvm.x_time_abi_v1 && restore_machine_contract.is_some()) + { opt.microvm.restore_entropy = true; } if restore_machine_contract.is_some() @@ -373,8 +451,54 @@ pub(crate) type ExpectedRestoreContract<'a> = ( /// microVM: host downtime, TSC and APIC frequencies, and the CPU contract. pub(crate) type RestoreTime = (Duration, u64, Option, Vec); +/// The time ABI options of a microVM restore. +#[derive(Clone, Copy)] +pub(crate) struct TimeAbiRestoreOptions<'a> { + /// The requested CPU profile: `auto` or a profile ID. + pub(crate) cpu_profile: &'a str, + /// The active test hooks. + pub(crate) hooks: &'a TimeAbiTestHooks, +} + +/// The clock inputs of a restored microVM's worker. +pub(crate) enum RestoreClock { + /// The clock fields of manifest versions before 6. + Legacy(RestoreTime), + /// The time ABI records of a version 6 manifest, after the controller's + /// time ABI preflight. + TimeAbi(Box), +} + +/// A time ABI restore validated by the controller: restore steps 1 to 6 of +/// the specification. +pub(crate) struct TimeAbiRestore { + contract: SnapshotTimeContract, + cpu_profile: SnapshotCpuProfile, + destination: HostIdentity, +} + +impl TimeAbiRestore { + /// Returns the worker's restore input. The worker seals the time fields + /// of the restore packet through `restore_record`. + pub(crate) fn into_input( + self, + restore_record: mesh::OneshotSender, + ) -> RestoreTimeInput { + RestoreTimeInput { + contract: self.contract, + cpu_profile: self.cpu_profile, + destination: self.destination, + restore_record, + } + } +} + /// Validates the authoritative machine contract of a microVM snapshot against /// the restore-time configuration. +/// +/// With `time_abi`, the manifest must be version 6, and the time ABI +/// preflight checks its records, the backend, the CPU profile, and the +/// downtime against this host before the rest of the contract is compared. pub(crate) fn validate_restore_contract( manifest: &SnapshotManifest, expected_memory_size: u64, @@ -388,11 +512,16 @@ pub(crate) fn validate_restore_contract( control_console_attachment, sandbox_blocks, ): ExpectedRestoreContract<'_>, -) -> anyhow::Result { + time_abi: Option>, +) -> anyhow::Result { let saved_contract = manifest .machine_contract .as_ref() .context("microVM snapshot is missing its authoritative machine contract")?; + openvmm_helpers::snapshot::time::check_time_abi_manifest_version(manifest, time_abi.is_some())?; + let time_abi_destination = time_abi + .map(|options| preflight_time_abi_restore(saved_contract, expected_hypervisor, options)) + .transpose()?; let network = network.map(|(config, policy, attachment)| (config, policy, attachment.clone())); let filesystem_slot = microvm_filesystem_slot_from_snapshot(saved_contract)?; let filesystem = saved_contract @@ -400,43 +529,100 @@ pub(crate) fn validate_restore_contract( .as_ref() .and(filesystem) .map(|(config, root_path, attachment)| (config, root_path, attachment.clone())); - let mut expected_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( - expected_hypervisor, - openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, - effective_command_line.to_owned(), - network, - filesystem_slot, - filesystem, - console_attachment.cloned(), - control_console_attachment.cloned(), - sandbox_blocks, - expected_vp_count, - expected_memory_size, - (saved_contract.memory_expansion_version != 0) - .then_some(saved_contract.memory_capacity_bytes), - saved_contract.state_unit_names.clone(), - saved_contract.capture_wall_clock, - saved_contract.tsc_frequency_hz, - saved_contract.apic_frequency_hz, - saved_contract.cpu_contract.clone(), - )?; + let clock = match (&saved_contract.time, &saved_contract.cpu_profile) { + (Some(time), Some(cpu_profile)) => MicrovmClockContract::TimeAbi { + time: time.clone(), + cpu_profile: cpu_profile.clone(), + }, + _ => MicrovmClockContract::Legacy { + capture_wall_clock: saved_contract.capture_wall_clock, + tsc_frequency_hz: saved_contract.tsc_frequency_hz, + apic_frequency_hz: saved_contract.apic_frequency_hz, + cpu_contract: saved_contract.cpu_contract.clone(), + }, + }; + let mut expected_contract = + openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_clock( + expected_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + effective_command_line.to_owned(), + network, + filesystem_slot, + filesystem, + console_attachment.cloned(), + control_console_attachment.cloned(), + sandbox_blocks, + expected_vp_count, + expected_memory_size, + (saved_contract.memory_expansion_version != 0) + .then_some(saved_contract.memory_capacity_bytes), + saved_contract.state_unit_names.clone(), + clock, + )?; align_legacy_network_policy_contract(saved_contract, &mut expected_contract); align_restore_console_listener_contract(saved_contract, &mut expected_contract); openvmm_helpers::snapshot::microvm::validate_microvm_machine_contract( manifest, &expected_contract, )?; + if let Some(destination) = time_abi_destination { + let (Some(contract), Some(cpu_profile)) = ( + saved_contract.time.clone(), + saved_contract.cpu_profile.clone(), + ) else { + unreachable!("the time ABI preflight requires both records"); + }; + return Ok(RestoreClock::TimeAbi(Box::new(TimeAbiRestore { + contract, + cpu_profile, + destination, + }))); + } let capture_time: std::time::SystemTime = saved_contract .capture_wall_clock .try_into() .context("snapshot capture wall clock is invalid")?; let downtime = calculate_snapshot_downtime(capture_time, std::time::SystemTime::now())?; - Ok(( + Ok(RestoreClock::Legacy(( downtime, saved_contract.tsc_frequency_hz, saved_contract.apic_frequency_hz, saved_contract.cpu_contract.clone(), - )) + ))) +} + +/// Runs the controller's time ABI preflight (restore steps 2 to 5 of the +/// specification) against this host, and returns the host's identity. +fn preflight_time_abi_restore( + contract: &SnapshotMachineContract, + hypervisor: &str, + options: TimeAbiRestoreOptions<'_>, +) -> anyhow::Result { + let destination = virt::time_abi::host::host_identity()?; + let now = virt::time_abi::host::sample_host_time()?; + let preflight = openvmm_helpers::snapshot::time::preflight_time_abi_restore( + contract, + hypervisor, + options.cpu_profile, + virt::time_abi::surface::host_cpu_signature(), + &destination, + &now, + options.hooks, + )?; + if let Some(step_ns) = preflight.downtime.host_wall_clock_step_ns { + tracing::warn!( + step_ns, + "host wall clock was stepped since capture; the downtime uses host monotonic time" + ); + } + tracing::info!( + downtime_ns = preflight.downtime.nanos, + source = ?preflight.downtime.source, + generation = preflight.generation, + cpu_profile = preflight.cpu_profile, + "time ABI restore preflight passed" + ); + Ok(destination) } #[cfg(test)] @@ -568,4 +754,73 @@ mod tests { assert!(microvm_restore_packet(&entropy, None, false, &ranges[..1]).is_err()); } + + fn time_abi_restore(capture_generation: u32) -> MicrovmRestore { + let mut contract: SnapshotMachineContract = mesh::payload::decode(&[]).unwrap(); + contract.time = Some(SnapshotTimeContract { + time_abi_version: 1, + tsc_frequency_hz: 2_100_000_000, + tsc_tolerance_ppm: 250, + apic_frequency_hz: 200_000_000, + capture_tsc: 1, + capture_utc_ns: 2, + capture_monotonic_ns: 3, + host_clock: "linux-boottime".to_owned(), + host_id: vec![1; 16], + host_boot_id: vec![2; 16], + capture_generation, + }); + contract.cpu_profile = Some(SnapshotCpuProfile { + id: "interim.host.kvm.v1".to_owned(), + sha256: Vec::new(), + profile: Vec::new(), + effective_cpuid: Vec::new(), + effective_cpuid_sha256: Vec::new(), + capture_cpu_signature: 0, + }); + MicrovmRestore { + machine_contract: Some(contract), + gate_required: true, + memory_target_requested: true, + memory_ranges: vec![SnapshotMemoryExpansionRange { + gpa_start: 0x1_0000_0000, + length: 0x4000_0000, + }], + private_scratch_dir: None, + } + } + + #[test] + fn time_abi_restore_packet_carries_the_controller_fields() { + let restore = time_abi_restore(6); + let (generation_id, base) = restore.time_abi_restore_packet(Some(4)).unwrap(); + assert_eq!(base.generation, 7); + assert_eq!(base.online_vp_count, 4); + assert!(base.memory_target && base.ack_required); + assert_eq!( + base.ranges, + [RestoreMemoryRange { + gpa_start: 0x1_0000_0000, + length: 0x4000_0000, + }] + ); + assert_eq!(generation_id, microvm_generation_id(&base.entropy)); + let (_, other) = restore.time_abi_restore_packet(None).unwrap(); + assert_eq!(other.online_vp_count, 0); + assert_ne!(other.entropy, base.entropy); + + assert!(restore.time_abi_restore_packet(Some(3)).is_err()); + let err = time_abi_restore(u32::MAX) + .time_abi_restore_packet(None) + .unwrap_err(); + assert!( + format!("{err:#}").contains("[E_GENERATION_EXHAUSTED]"), + "{err:#}" + ); + assert!( + MicrovmRestore::default() + .time_abi_restore_packet(None) + .is_err() + ); + } } diff --git a/openvmm/openvmm_entry/src/microvm/verify.rs b/openvmm/openvmm_entry/src/microvm/verify.rs new file mode 100644 index 00000000000..e403eb7d38f --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/verify.rs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The time ABI verification mode for host qualification +//! (`--x-time-abi-verify`): the VM worker builds the microVM partition and +//! runs the time ABI preflight, and the controller prints one +//! `NVX-TIME-ABI-VERIFY:` line and exits without running the guest. + +use anyhow::Context; +use inspect::Inspect; +use mesh::CancelContext; +use mesh_worker::WorkerHandle; +use std::time::Duration; + +/// The prefix of the verification line. +const PREFIX: &str = "NVX-TIME-ABI-VERIFY:"; +/// The longest failure detail the verification line carries. +const MAX_DETAIL_LEN: usize = 400; +/// How long the controller waits for the worker's time ABI report. +const REPORT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Reports the outcome of a verification-mode worker launch: prints the +/// verification line and returns the process exit status, 0 when the time +/// ABI preflight passed and 1 when it failed. +pub(crate) async fn report_time_abi_verification( + launched: anyhow::Result, + hypervisor: &str, +) -> anyhow::Result { + let mut worker = match launched { + Ok(worker) => worker, + Err(err) => { + tracing::error!(error = format!("{err:#}"), "time ABI verification failed"); + println!("{}", failure_line(hypervisor, &err)); + return Ok(1); + } + }; + let mut inspection = inspect::InspectionBuilder::new("time-abi") + .depth(Some(1)) + .inspect(inspect::adhoc(|req| worker.inspect(req))); + let _ = CancelContext::new() + .with_timeout(REPORT_TIMEOUT) + .until_cancelled(inspection.resolve()) + .await; + let line = success_line(hypervisor, &inspection.results())?; + println!("{line}"); + worker.stop(); + worker + .join() + .await + .context("VM worker failed after time ABI verification")?; + Ok(0) +} + +/// Formats the line of a passed verification from the worker's `time-abi` +/// inspect node. +fn success_line(hypervisor: &str, node: &inspect::Node) -> anyhow::Result { + let inspect::Node::Dir(entries) = node else { + anyhow::bail!("the VM worker returned no time ABI report: {node:?}"); + }; + let field = |name: &str| -> anyhow::Result { + let entry = entries + .iter() + .find(|entry| entry.name == name) + .with_context(|| format!("the time ABI report lacks '{name}'"))?; + match &entry.node { + inspect::Node::Value(value) => Ok(match &value.kind { + inspect::ValueKind::String(text) => text.clone(), + _ => value.to_string(), + }), + node => anyhow::bail!("the time ABI report field '{name}' is {node:?}"), + } + }; + Ok(format!( + "{PREFIX} v=1 status=ok backend={hypervisor} cpu_profile={} tsc_hz={} native_tsc_hz={} lapic_hz={} msr_route={} sync={}", + field("cpu_profile")?, + field("tsc_hz")?, + field("native_tsc_hz")?, + field("apic_hz")?, + field("msr_route")?, + field("sync")?, + )) +} + +/// Formats the line of a failed verification: the stable time ABI code in +/// the error chain, or `none`, and the escaped error chain. +fn failure_line(hypervisor: &str, err: &anyhow::Error) -> String { + let detail = format!("{err:#}"); + let code = time_abi_code(&detail).unwrap_or("none"); + let mut escaped = String::new(); + for c in detail.chars() { + if escaped.len() >= MAX_DETAIL_LEN { + escaped.push_str("..."); + break; + } + match c { + '"' | '\\' => { + escaped.push('\\'); + escaped.push(c); + } + c if c.is_control() => escaped.push(' '), + c => escaped.push(c), + } + } + format!("{PREFIX} v=1 status=fail backend={hypervisor} code={code} detail=\"{escaped}\"") +} + +/// Returns the first bracketed time ABI code, such as `E_TSC_SYNC_UNSUPPORTED`, +/// in an error chain. +fn time_abi_code(detail: &str) -> Option<&str> { + let mut rest = detail; + while let Some(start) = rest.find("[E_") { + let candidate = &rest[start + 1..]; + if let Some(end) = candidate.find(']') { + let code = &candidate[..end]; + if code + .bytes() + .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_') + { + return Some(code); + } + } + rest = candidate; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(name: &str, value: impl Into) -> inspect::Entry { + inspect::Entry { + name: name.to_owned(), + node: inspect::Node::Value(value.into()), + sensitivity: Default::default(), + } + } + + #[test] + fn success_line_reports_the_preflight() { + let node = inspect::Node::Dir(vec![ + entry("tsc_hz", 2_100_000_000_u64), + entry("apic_hz", 200_000_000_u64), + entry("tsc_invariant_control", 0_u64), + entry("hypervisor", "mshv".to_owned()), + entry("cpu_profile", "interim.host.mshv.v1".to_owned()), + entry("msr_route", "ExitToVmm".to_owned()), + entry("sync", "FrozenWrite".to_owned()), + entry("native_tsc_hz", 2_100_000_000_u64), + ]); + assert_eq!( + success_line("mshv", &node).unwrap(), + "NVX-TIME-ABI-VERIFY: v=1 status=ok backend=mshv cpu_profile=interim.host.mshv.v1 tsc_hz=2100000000 native_tsc_hz=2100000000 lapic_hz=200000000 msr_route=ExitToVmm sync=FrozenWrite" + ); + let inspect::Node::Dir(mut entries) = node else { + unreachable!() + }; + entries.retain(|entry| entry.name != "sync"); + let err = success_line("mshv", &inspect::Node::Dir(entries)).unwrap_err(); + assert!(err.to_string().contains("'sync'"), "{err}"); + assert!(success_line("mshv", &inspect::Node::Unevaluated).is_err()); + } + + #[test] + fn failure_line_carries_the_code_and_escaped_detail() { + let err = anyhow::anyhow!("[E_TSC_SYNC_UNSUPPORTED] no \"sync\"\nset") + .context("failed to launch vm worker"); + assert_eq!( + failure_line("kvm", &err), + "NVX-TIME-ABI-VERIFY: v=1 status=fail backend=kvm code=E_TSC_SYNC_UNSUPPORTED detail=\"failed to launch vm worker: [E_TSC_SYNC_UNSUPPORTED] no \\\"sync\\\" set\"" + ); + let err = anyhow::anyhow!("kvm device unavailable [E_lower] x"); + assert!(failure_line("kvm", &err).contains(" code=none ")); + let long = anyhow::anyhow!("{}", "x".repeat(1000)); + assert!(failure_line("kvm", &long).ends_with("...\"")); + } +} diff --git a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs index 99fc4acb617..c3563884632 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs @@ -31,6 +31,7 @@ pub(crate) use ready::restore_ready_sink; use crate::Options; use crate::microvm::MicrovmLaunch; +use crate::microvm::RestoreClock; use anyhow::Context; use mesh::payload::message::ProtobufMessage; use openvmm_defs::profile::ProfileSpan; @@ -62,6 +63,9 @@ pub(crate) struct WorkerRestore { pub(crate) apic_frequency_hz: Option, /// Saved canonical CPU contract of a restored microVM. pub(crate) cpu_contract: Option>, + /// The time ABI inputs of a restored microVM, validated by the + /// controller's restore preflight. + pub(crate) time: Option, } impl SnapshotRestore { @@ -111,7 +115,7 @@ impl SnapshotRestore { pub(crate) fn prepare( &mut self, opt: &Options, - microvm: &MicrovmLaunch, + microvm: &mut MicrovmLaunch, expected_hypervisor: &str, ) -> anyhow::Result<(SharedMemoryFd, ProtobufMessage)> { let prepared = prepare::prepare_snapshot_restore( @@ -124,13 +128,25 @@ impl SnapshotRestore { )?; self.worker.shared_memory_copy_on_write = true; self.worker.guards = Some(prepared.guards); - if let Some((downtime, tsc_frequency_hz, apic_frequency_hz, cpu_contract)) = - prepared.restore_time - { - self.worker.downtime = Some(downtime); - self.worker.tsc_frequency_hz = Some(tsc_frequency_hz); - self.worker.apic_frequency_hz = apic_frequency_hz; - self.worker.cpu_contract = Some(cpu_contract); + match prepared.restore_time { + Some(RestoreClock::Legacy(( + downtime, + tsc_frequency_hz, + apic_frequency_hz, + cpu_contract, + ))) => { + self.worker.downtime = Some(downtime); + self.worker.tsc_frequency_hz = Some(tsc_frequency_hz); + self.worker.apic_frequency_hz = apic_frequency_hz; + self.worker.cpu_contract = Some(cpu_contract); + } + Some(RestoreClock::TimeAbi(time)) => { + let restore_record = microvm + .take_restore_time_record() + .context("time ABI restore is missing its restore packet")?; + self.worker.time = Some(time.into_input(restore_record)); + } + None => {} } Ok((prepared.shared_memory, prepared.saved_state)) } diff --git a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs index 70b81ed8059..5d03e078fac 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs @@ -7,7 +7,6 @@ use crate::Options; use crate::microvm; use anyhow::Context; -use std::time::Duration; /// An opened snapshot generation, validated against the VM configuration and /// prepared for the VM worker. @@ -15,7 +14,7 @@ pub(crate) struct PreparedSnapshotRestore { pub(crate) shared_memory: openvmm_defs::worker::SharedMemoryFd, pub(crate) guards: openvmm_defs::worker::SnapshotRestoreGuards, pub(crate) saved_state: mesh::payload::message::ProtobufMessage, - pub(crate) restore_time: Option<(Duration, u64, Option, Vec)>, + pub(crate) restore_time: Option, } /// Validate an opened snapshot generation against the current VM config. @@ -29,21 +28,34 @@ pub(super) fn prepare_snapshot_restore( let base_memory_size = snapshot.manifest().memory_size_bytes; let expected_microvm_contract = microvm.expected_restore_contract(opt, snapshot.manifest(), expected_hypervisor)?; + let hooks = virt::time_abi::TimeAbiTestHooks::parse(&opt.microvm.x_time_abi_test_hook)?; + let time_abi = opt + .microvm + .x_time_abi_v1 + .then(|| microvm::TimeAbiRestoreOptions { + cpu_profile: opt.microvm.cpu_profile.as_deref().unwrap_or("auto"), + hooks: &hooks, + }); prepare_snapshot_restore_for_config( snapshot, base_memory_size, opt.memory_size(), opt.processors, expected_microvm_contract, + time_abi, ) } +/// Validates an opened snapshot generation against a VM configuration and +/// prepares it for the VM worker. `time_abi` selects the time ABI restore +/// path of a microVM. pub(crate) fn prepare_snapshot_restore_for_config( snapshot: openvmm_helpers::snapshot::restore::OpenedSnapshot, expected_memory_size: u64, selected_memory_size: u64, expected_vp_count: u32, expected_microvm_contract: Option>, + time_abi: Option>, ) -> anyhow::Result { let artifact_prepare = openvmm_defs::profile::ProfileSpan::start(); let manifest = snapshot.manifest(); @@ -62,6 +74,7 @@ pub(crate) fn prepare_snapshot_restore_for_config( expected_memory_size, expected_vp_count, contract, + time_abi, ) }) .transpose()?; diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index 9f245783300..3c1ffcf03a5 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -499,10 +499,17 @@ impl CreateVm { None, restore.machine_contract.microvm_sandbox_blocks.clone(), )), + None, )?; - let restore_time = prepared + let restore_time = match prepared .restore_time - .context("microVM snapshot is missing its restore-time contract")?; + .context("microVM snapshot is missing its restore-time contract")? + { + crate::microvm::RestoreClock::Legacy(restore_time) => restore_time, + crate::microvm::RestoreClock::TimeAbi(_) => { + anyhow::bail!("ttrpc restore does not support time ABI snapshots yet") + } + }; if !self.restore_entropy && self.restore_online_vp_count.is_none() && !restore.restore_memory_target_requested diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index e6b6eac84fa..d3acf3651b5 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1383,6 +1383,7 @@ impl VmService { restore_cpu_contract: worker_fields .restore_time .map(|(_, _, _, cpu_contract)| cpu_contract), + restore_time: None, restore_ready_sink, restore_gate_timeout: worker_fields.restore_gate_timeout, restore_vp_count: worker_fields.restore_vp_count, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 7a48a470ee2..d7da399512c 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -650,7 +650,8 @@ fn memory_expansion_prefix( Ok(prefix) } -/// Builds the authoritative microVM machine contract. +/// Builds the authoritative microVM machine contract with the clock fields of +/// manifest versions 2 through 5. pub fn microvm_machine_contract( source_hypervisor: &str, boot_layout_version: u32, @@ -677,6 +678,79 @@ pub fn microvm_machine_contract( tsc_frequency_hz: u64, apic_frequency_hz: Option, cpu_contract: Vec, +) -> anyhow::Result { + microvm_machine_contract_with_clock( + source_hypervisor, + boot_layout_version, + effective_command_line, + network, + filesystem_slot, + filesystem, + console_attachment, + control_console_attachment, + sandbox_blocks, + processor_count, + memory_size, + memory_capacity, + state_unit_names, + MicrovmClockContract::Legacy { + capture_wall_clock, + tsc_frequency_hz, + apic_frequency_hz, + cpu_contract, + }, + ) +} + +/// The clock records of a microVM machine contract. +#[derive(Clone, Debug)] +pub enum MicrovmClockContract { + /// The clock fields of manifest versions 2 through 5. + Legacy { + /// Host UTC at capture. + capture_wall_clock: Timestamp, + /// The effective guest TSC frequency. + tsc_frequency_hz: u64, + /// The guest LAPIC timer frequency. + apic_frequency_hz: Option, + /// The canonical CPU compatibility contract. + cpu_contract: Vec, + }, + /// The NVX time ABI records of manifest version 6, which leave the + /// retired clock fields empty. + TimeAbi { + /// The time contract. + time: openvmm_defs::time_abi::SnapshotTimeContract, + /// The CPU profile record. + cpu_profile: openvmm_defs::time_abi::SnapshotCpuProfile, + }, +} + +/// Builds the authoritative microVM machine contract with the given clock +/// records. +pub fn microvm_machine_contract_with_clock( + source_hypervisor: &str, + boot_layout_version: u32, + effective_command_line: String, + network: Option<( + &openvmm_defs::microvm::MicrovmNetworkConfig, + &net_backend_resources::egress::EgressPolicy, + SnapshotAttachment, + )>, + filesystem_slot: bool, + filesystem: Option<( + &openvmm_defs::microvm::MicrovmFilesystemConfig, + &Path, + SnapshotAttachment, + )>, + console_attachment: Option, + control_console_attachment: Option, + sandbox_blocks: Vec, + processor_count: u32, + memory_size: u64, + memory_capacity: Option, + state_unit_names: Vec, + clock: MicrovmClockContract, ) -> anyhow::Result { anyhow::ensure!( matches!(source_hypervisor, "kvm" | "mshv" | "whp"), @@ -1099,6 +1173,39 @@ pub fn microvm_machine_contract( attachments.push(attachment); } + let ( + capture_wall_clock, + tsc_frequency_hz, + apic_frequency_hz, + clock_policy, + cpu_contract, + time, + cpu_profile, + ) = match clock { + MicrovmClockContract::Legacy { + capture_wall_clock, + tsc_frequency_hz, + apic_frequency_hz, + cpu_contract, + } => ( + capture_wall_clock, + tsc_frequency_hz, + apic_frequency_hz, + ADVANCE_BY_HOST_DOWNTIME.to_owned(), + Some(cpu_contract), + None, + None, + ), + MicrovmClockContract::TimeAbi { time, cpu_profile } => ( + super::time::NO_TIMESTAMP, + 0, + None, + String::new(), + None, + Some(time), + Some(cpu_profile), + ), + }; let mut contract = SnapshotMachineContract { machine_profile: "microvm".to_owned(), microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, @@ -1116,7 +1223,7 @@ pub fn microvm_machine_contract( cpu_contract: Vec::new(), cpu_contract_sha256: Vec::new(), boot_layout_version, - clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), + clock_policy, microvm_network, microvm_filesystem, apic_frequency_hz, @@ -1134,11 +1241,13 @@ pub fn microvm_machine_contract( memory_capacity_bytes, memory_block_size_bytes, memory_expansion_ranges, - time: None, - cpu_profile: None, + time, + cpu_profile, }; contract.set_effective_command_line(effective_command_line); - contract.set_cpu_compatibility_contract(cpu_contract); + if let Some(cpu_contract) = cpu_contract { + contract.set_cpu_compatibility_contract(cpu_contract); + } validate_machine_contract_shape(&contract, memory_size, processor_count)?; Ok(contract) } diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index 7cd6b81445d..bbe072de462 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -13,16 +13,20 @@ use openvmm_defs::time_abi::SnapshotCpuProfile; use openvmm_defs::time_abi::SnapshotTimeContract; use sha2::Digest; use virt::time_abi::CaptureTimeRecord; +use virt::time_abi::Downtime; use virt::time_abi::HostClockKind; use virt::time_abi::HostIdentity; use virt::time_abi::HostTimeSample; use virt::time_abi::TIME_ABI_VERSION; use virt::time_abi::TimeAbiCode; use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiTestHooks; +use virt::time_abi::downtime::select_downtime; use virt::time_abi::rate; +use virt::time_abi::surface; /// The value of the retired capture wall clock in a version 6 manifest. -const NO_TIMESTAMP: Timestamp = Timestamp { +pub(super) const NO_TIMESTAMP: Timestamp = Timestamp { seconds: 0, nanos: 0, }; @@ -134,7 +138,12 @@ pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), Ti return Err(manifest_error("CPU profile exceeds 1 MiB")); } verify_sha256(profile, sha256, "CPU profile")?; - if effective_cpuid.is_empty() || effective_cpuid.len() > MAX_EFFECTIVE_CPUID_BYTES { + if effective_cpuid.is_empty() + || effective_cpuid.len() > MAX_EFFECTIVE_CPUID_BYTES + || !effective_cpuid + .len() + .is_multiple_of(surface::ENCODED_CPUID_LEAF_LEN) + { return Err(manifest_error("effective CPUID record size is invalid")); } verify_sha256(effective_cpuid, effective_cpuid_sha256, "effective CPUID") @@ -235,6 +244,93 @@ pub fn check_time_abi_manifest_version( } } +/// The controller's view of a time ABI restore after restore steps 2 to 5 of +/// the specification. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TimeAbiRestorePreflight { + /// The capture record. + pub capture: CaptureTimeRecord, + /// The preflight downtime. The worker measures `D` again at the restore + /// anchor. + pub downtime: Downtime, + /// The generation counter `g` of the restored process. + pub generation: u32, + /// The CPU profile the restored VM uses: the snapshot's. + pub cpu_profile: String, +} + +/// Runs restore steps 2 to 5 of the specification on a version 6 machine +/// contract: the time ABI records, the backend, the CPU profile and host CPU +/// generation, and the downtime preflight against the destination host's +/// identity and clocks. +/// +/// Until CPU profiles land, the snapshot's profile must be the interim +/// profile of `hypervisor` and the host must have the capture host's CPU +/// signature; the worker compares the effective CPUID. +pub fn preflight_time_abi_restore( + contract: &SnapshotMachineContract, + hypervisor: &str, + requested_cpu_profile: &str, + host_cpu_signature: Option, + destination: &HostIdentity, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks, +) -> Result { + validate_time_abi_contract(contract)?; + let (Some(time), Some(record)) = (&contract.time, &contract.cpu_profile) else { + unreachable!("a validated time ABI contract carries both records"); + }; + if contract.source_hypervisor != hypervisor { + return Err(TimeAbiError::new( + TimeAbiCode::BackendMismatch, + format!( + "the snapshot was captured on the {} backend and cannot be restored on {hypervisor}", + contract.source_hypervisor + ), + )); + } + let cpu_profile = surface::resolve_cpu_profile(&record.id, hypervisor)?; + if requested_cpu_profile != "auto" && requested_cpu_profile != record.id { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileUnknown, + format!( + "--cpu-profile {requested_cpu_profile} does not name the snapshot's profile '{}'", + record.id + ), + )); + } + if !record.profile.is_empty() { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileDigest, + format!( + "the snapshot's '{}' profile document is not the pinned one", + record.id + ), + )); + } + if host_cpu_signature != Some(record.capture_cpu_signature) { + let host = host_cpu_signature.map_or_else( + || "unavailable".to_owned(), + |signature| format!("{signature:#010x}"), + ); + return Err(TimeAbiError::new( + TimeAbiCode::CpuGeneration, + format!( + "the host CPU signature {host} is not the capture host's {:#010x}", + record.capture_cpu_signature + ), + )); + } + let capture = capture_record(time)?; + let downtime = select_downtime(&capture, destination, now, hooks)?; + Ok(TimeAbiRestorePreflight { + capture, + downtime, + generation: time.capture_generation + 1, + cpu_profile, + }) +} + #[cfg(test)] pub(super) mod tests { use super::*; @@ -455,4 +551,180 @@ pub(super) mod tests { ); } } + + const SIGNATURE: u32 = 0x0005_0657; + + /// A version 6 machine contract captured on WHP with the interim profile, + /// and a destination sample 5 s after its capture anchor on the same host + /// boot. + fn interim_restore() -> (SnapshotMachineContract, HostIdentity, HostTimeSample) { + let effective_cpuid = vec![7; 48]; + let record = SnapshotCpuProfile { + id: "interim.host.whp.v1".to_owned(), + sha256: sha2::Sha256::digest(b"").to_vec(), + profile: Vec::new(), + effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), + effective_cpuid, + capture_cpu_signature: SIGNATURE, + }; + let mut manifest = crate::snapshot::tests::test_manifest(); + manifest.machine_contract = Some(crate::snapshot::microvm::test_machine_contract()); + set_time_abi_records(&mut manifest, test_time_contract(), record).unwrap(); + let capture = capture_record(&test_time_contract()).unwrap(); + let now = HostTimeSample { + utc_ns: capture.sample.utc_ns + 5_000_000_000, + monotonic_ns: capture.sample.monotonic_ns + 5_000_000_000, + }; + (manifest.machine_contract.unwrap(), capture.identity, now) + } + + fn preflight( + contract: &SnapshotMachineContract, + hypervisor: &str, + requested: &str, + signature: Option, + destination: &HostIdentity, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks, + ) -> Result { + preflight_time_abi_restore( + contract, + hypervisor, + requested, + signature, + destination, + now, + hooks, + ) + } + + #[test] + fn restore_preflight_selects_the_downtime_and_generation() { + let (contract, destination, now) = interim_restore(); + let hooks = TimeAbiTestHooks::default(); + for requested in ["auto", "interim.host.whp.v1"] { + let result = preflight( + &contract, + "whp", + requested, + Some(SIGNATURE), + &destination, + &now, + &hooks, + ) + .unwrap(); + assert_eq!(result.generation, 4); + assert_eq!(result.cpu_profile, "interim.host.whp.v1"); + assert_eq!(result.downtime.nanos, 5_000_000_000); + assert_eq!( + result.downtime.source, + virt::time_abi::DowntimeSource::HostMonotonic + ); + } + + let hooks = TimeAbiTestHooks { + force_utc_downtime: true, + ..Default::default() + }; + let result = preflight( + &contract, + "whp", + "auto", + Some(SIGNATURE), + &destination, + &now, + &hooks, + ) + .unwrap(); + assert_eq!(result.downtime.source, virt::time_abi::DowntimeSource::Utc); + } + + #[test] + fn restore_preflight_failures_have_stable_codes() { + let (contract, destination, now) = interim_restore(); + let hooks = TimeAbiTestHooks::default(); + let fail = |contract: &SnapshotMachineContract, + hypervisor: &str, + requested: &str, + signature: Option, + now: &HostTimeSample, + hooks: &TimeAbiTestHooks| { + preflight( + contract, + hypervisor, + requested, + signature, + &destination, + now, + hooks, + ) + .unwrap_err() + .code + }; + + assert_eq!( + fail(&contract, "kvm", "auto", Some(SIGNATURE), &now, &hooks), + TimeAbiCode::BackendMismatch + ); + assert_eq!( + fail( + &contract, + "whp", + "intel.icelake-sp.whp.v1", + Some(SIGNATURE), + &now, + &hooks + ), + TimeAbiCode::ProfileUnknown + ); + assert_eq!( + fail(&contract, "whp", "auto", Some(SIGNATURE + 1), &now, &hooks), + TimeAbiCode::CpuGeneration + ); + assert_eq!( + fail(&contract, "whp", "auto", None, &now, &hooks), + TimeAbiCode::CpuGeneration + ); + + let mut pinned = contract.clone(); + let record = pinned.cpu_profile.as_mut().unwrap(); + record.id = "intel.icelake-sp.whp.v1".to_owned(); + assert_eq!( + fail(&pinned, "whp", "auto", Some(SIGNATURE), &now, &hooks), + TimeAbiCode::ProfileUnknown + ); + + let mut document = contract.clone(); + let record = document.cpu_profile.as_mut().unwrap(); + record.profile = b"profile".to_vec(); + record.sha256 = sha2::Sha256::digest(&record.profile).to_vec(); + assert_eq!( + fail(&document, "whp", "auto", Some(SIGNATURE), &now, &hooks), + TimeAbiCode::ProfileDigest + ); + + let mut exhausted = contract.clone(); + exhausted.time.as_mut().unwrap().capture_generation = u32::MAX; + assert_eq!( + fail(&exhausted, "whp", "auto", Some(SIGNATURE), &now, &hooks), + TimeAbiCode::GenerationExhausted + ); + + let rollback = HostTimeSample { + utc_ns: now.utc_ns, + monotonic_ns: now.monotonic_ns - 10_000_000_000, + }; + assert_eq!( + fail(&contract, "whp", "auto", Some(SIGNATURE), &rollback, &hooks), + TimeAbiCode::DowntimeNegative + ); + let excessive = TimeAbiTestHooks { + downtime_add_s: 30 * 24 * 60 * 60, + ..Default::default() + }; + assert_eq!( + fail(&contract, "whp", "auto", Some(SIGNATURE), &now, &excessive), + TimeAbiCode::DowntimeExcessive + ); + } } diff --git a/petri/src/worker.rs b/petri/src/worker.rs index e6a415b60cd..45031929502 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -48,6 +48,7 @@ impl Worker { restore_tsc_frequency_hz: None, restore_apic_frequency_hz: None, restore_cpu_contract: None, + restore_time: None, restore_ready_sink: None, restore_gate_timeout: None, restore_vp_count: None, diff --git a/vmm_core/virt/Cargo.toml b/vmm_core/virt/Cargo.toml index 992a89db7cc..262bd8cfdb2 100644 --- a/vmm_core/virt/Cargo.toml +++ b/vmm_core/virt/Cargo.toml @@ -31,6 +31,9 @@ zerocopy.workspace = true [target.'cfg(target_os = "linux")'.dependencies] libc.workspace = true +[target.'cfg(target_arch = "x86_64")'.dependencies] +safe_intrinsics.workspace = true + [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = [ "Win32_Foundation", diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index ab590e43703..a49490a7f1c 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -17,6 +17,7 @@ pub mod identity; #[cfg(guest_arch = "x86_64")] pub mod msr; pub mod rate; +pub mod surface; #[cfg(guest_arch = "x86_64")] pub use backend::BackendPreflight; diff --git a/vmm_core/virt/src/time_abi/surface.rs b/vmm_core/virt/src/time_abi/surface.rs new file mode 100644 index 00000000000..b0c252f4434 --- /dev/null +++ b/vmm_core/virt/src/time_abi/surface.rs @@ -0,0 +1,281 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The effective CPU surface record of a snapshot: the canonical CPUID +//! encoding and its comparison, and the interim CPU profile used until CPU +//! profiles land. + +use super::TimeAbiCode; +use super::TimeAbiError; +use crate::CpuidLeaf; + +/// The length of one leaf in the canonical CPUID encoding. +pub const ENCODED_CPUID_LEAF_LEN: usize = 24; +/// The most differing leaves an `E_CPU_SURFACE` failure names. +const MAX_REPORTED_CPUID_DIFFERENCES: usize = 16; + +/// Returns the canonical encoding of an effective CPUID table, as recorded in +/// a snapshot's CPU profile record. +/// +/// Leaves are sorted by function and subleaf, a leaf without a subleaf sorts +/// first, and duplicates keep the last entry. Each leaf is 24 bytes, all +/// little-endian `u32`s: the function, the subleaf (`0xffffffff` for any), +/// and EAX, EBX, ECX, and EDX. The values are the effective results, so the +/// masks are not encoded. +pub fn encode_cpuid(leaves: &[CpuidLeaf]) -> Vec { + let mut entries: Vec<(u32, Option, [u32; 4])> = leaves + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result)) + .collect(); + entries.sort_by_key(|&(function, index, _)| (function, index)); + let mut deduplicated: Vec<(u32, Option, [u32; 4])> = Vec::with_capacity(entries.len()); + for entry in entries { + match deduplicated.last_mut() { + Some(last) if (last.0, last.1) == (entry.0, entry.1) => *last = entry, + _ => deduplicated.push(entry), + } + } + let mut bytes = Vec::with_capacity(deduplicated.len() * ENCODED_CPUID_LEAF_LEN); + for (function, index, result) in deduplicated { + bytes.extend_from_slice(&function.to_le_bytes()); + bytes.extend_from_slice(&index.unwrap_or(u32::MAX).to_le_bytes()); + for register in result { + bytes.extend_from_slice(®ister.to_le_bytes()); + } + } + bytes +} + +/// Decodes a canonical CPUID encoding into `((function, subleaf), result)` +/// pairs, or returns `None` if its length is not a multiple of a leaf. +fn decode_cpuid(bytes: &[u8]) -> Option> { + let (leaves, rest) = bytes.as_chunks::(); + if !rest.is_empty() { + return None; + } + let word = |leaf: &[u8; ENCODED_CPUID_LEAF_LEN], n: usize| { + u32::from_le_bytes(leaf[n * 4..n * 4 + 4].try_into().unwrap()) + }; + Some( + leaves + .iter() + .map(|leaf| { + ( + (word(leaf, 0), word(leaf, 1)), + [word(leaf, 2), word(leaf, 3), word(leaf, 4), word(leaf, 5)], + ) + }) + .collect(), + ) +} + +/// Compares the effective CPUID a backend programmed with the canonical +/// encoding a snapshot recorded (`E_CPU_SURFACE`), naming the differing +/// leaves and registers. +pub fn check_effective_cpuid(effective: &[CpuidLeaf], recorded: &[u8]) -> Result<(), TimeAbiError> { + let encoded = encode_cpuid(effective); + if encoded == recorded { + return Ok(()); + } + let recorded = decode_cpuid(recorded).ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + "the snapshot's effective CPUID record is malformed", + ) + })?; + let recorded: std::collections::BTreeMap<_, _> = recorded.into_iter().collect(); + let effective: std::collections::BTreeMap<_, _> = decode_cpuid(&encoded) + .expect("canonical encoding is whole leaves") + .into_iter() + .collect(); + let mut differences = Vec::new(); + for key in recorded + .keys() + .chain(effective.keys().filter(|key| !recorded.contains_key(key))) + { + let (old, new) = (recorded.get(key), effective.get(key)); + if old == new { + continue; + } + let leaf = if key.1 == u32::MAX { + format!("{:#010x}", key.0) + } else { + format!("{:#010x}/{}", key.0, key.1) + }; + differences.push(match (old, new) { + (Some(old), Some(new)) => { + let registers: Vec = ["eax", "ebx", "ecx", "edx"] + .iter() + .zip(old.iter().zip(new)) + .filter(|(_, (old, new))| old != new) + .map(|(name, (old, new))| { + format!( + "{name} {old:#010x} -> {new:#010x} (bits {:#010x})", + old ^ new + ) + }) + .collect(); + format!("{leaf} {}", registers.join(", ")) + } + (Some(_), None) => format!("{leaf} missing"), + (None, _) => format!("{leaf} added"), + }); + } + let count = differences.len(); + differences.truncate(MAX_REPORTED_CPUID_DIFFERENCES); + Err(TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "the effective CPUID differs from the snapshot's in {count} leaves (snapshot -> destination): {}", + differences.join("; ") + ), + )) +} + +/// Returns the CPU profile ID recorded until CPU profiles land: the backend's +/// own CPUID with the time ABI applied. Such a snapshot restores only on the +/// same backend and host CPU signature, with an identical effective CPUID. +pub fn interim_cpu_profile_id(hypervisor: &str) -> String { + format!("interim.host.{hypervisor}.v1") +} + +/// Resolves a requested CPU profile (`auto` or a profile ID) on `hypervisor` +/// to the profile ID a VM uses (`E_PROFILE_UNKNOWN`). Until CPU profiles land, +/// only the interim profile exists. +pub fn resolve_cpu_profile(requested: &str, hypervisor: &str) -> Result { + let interim = interim_cpu_profile_id(hypervisor); + if requested == "auto" || requested == interim { + Ok(interim) + } else { + Err(TimeAbiError::new( + TimeAbiCode::ProfileUnknown, + format!( + "CPU profile '{requested}' is not pinned in this OpenVMM; the {hypervisor} backend offers '{interim}'" + ), + )) + } +} + +/// Returns the host's display signature, CPUID.1:EAX, as the VMM's host OS +/// sees it, or `None` on a host that is not x86-64. +pub fn host_cpu_signature() -> Option { + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + { + Some(safe_intrinsics::cpuid(1, 0).eax) + } + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cpuid_encoding_is_canonical() { + let leaves = [ + CpuidLeaf::new(7, [1, 2, 3, 4]).indexed(1), + CpuidLeaf::new(1, [5, 6, 7, 8]), + CpuidLeaf::new(7, [9, 10, 11, 12]).indexed(0), + CpuidLeaf::new(1, [13, 14, 15, 16]), + ]; + let bytes = encode_cpuid(&leaves); + assert_eq!(bytes.len(), 3 * 24); + let words: Vec = bytes + .chunks(4) + .map(|word| u32::from_le_bytes(word.try_into().unwrap())) + .collect(); + assert_eq!( + words, + [ + 1, + u32::MAX, + 13, + 14, + 15, + 16, + 7, + 0, + 9, + 10, + 11, + 12, + 7, + 1, + 1, + 2, + 3, + 4, + ] + ); + let mut reversed = leaves; + reversed.swap(0, 2); + reversed.swap(1, 3); + assert_ne!(encode_cpuid(&reversed), bytes); + assert_eq!(encode_cpuid(&[]), Vec::::new()); + } + + #[test] + fn effective_cpuid_differences_are_named() { + let recorded = encode_cpuid(&[ + CpuidLeaf::new(1, [1, 2, 3, 4]), + CpuidLeaf::new(7, [0; 4]).indexed(0), + ]); + check_effective_cpuid( + &[ + CpuidLeaf::new(7, [0; 4]).indexed(0), + CpuidLeaf::new(1, [1, 2, 3, 4]), + ], + &recorded, + ) + .unwrap(); + + let err = check_effective_cpuid( + &[ + CpuidLeaf::new(1, [1, 2, 7, 4]), + CpuidLeaf::new(0xd, [1; 4]).indexed(1), + ], + &recorded, + ) + .unwrap_err(); + assert_eq!(err.code, TimeAbiCode::CpuSurface); + for expected in [ + "in 3 leaves", + "0x00000001 ecx 0x00000003 -> 0x00000007 (bits 0x00000004)", + "0x00000007/0 missing", + "0x0000000d/1 added", + ] { + assert!(err.message.contains(expected), "{err}"); + } + + let malformed = check_effective_cpuid(&[], &[0; 23]).unwrap_err(); + assert_eq!(malformed.code, TimeAbiCode::CpuSurface); + assert!(malformed.message.contains("malformed"), "{malformed}"); + } + + #[test] + fn interim_cpu_profile_resolution() { + assert_eq!( + resolve_cpu_profile("auto", "kvm").unwrap(), + "interim.host.kvm.v1" + ); + assert_eq!( + resolve_cpu_profile("interim.host.mshv.v1", "mshv").unwrap(), + "interim.host.mshv.v1" + ); + for (requested, hypervisor) in [ + ("interim.host.kvm.v1", "mshv"), + ("intel.icelake-sp.kvm.v1", "kvm"), + ] { + assert_eq!( + resolve_cpu_profile(requested, hypervisor).unwrap_err().code, + TimeAbiCode::ProfileUnknown + ); + } + assert_ne!(host_cpu_signature(), Some(0)); + } +} From 1b06cb580798f7afb34bda9e9b76a68284c156dc Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 05:25:50 -0700 Subject: [PATCH 185/304] virt_mshv: record the effective CPUID of VP 0 at reset The time ABI records the effective CPUID at capture and requires the restore to recompute the same table (E_CPU_SURFACE). MSHV answers CPUID queries with the VP's current state folded in: the get-VP-CPUID-values call applies the VP's XCR0 and XSS, and the OSXSAVE bit follows the VP's CR4. Reading VP 0 at capture, after the guest enabled XSAVE, and again at restore preflight, with VP 0 at reset, would then disagree on every restore. Read the effective CPUID once, the first time it is needed, and keep it. Preflight needs it before any VP runs, on cold boot and on restore alike, so the record is always VP 0's view at reset. Preflight checks the identity leaves against it and reads only the sentinel leaves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 167 +++++++++++++++------- 1 file changed, 112 insertions(+), 55 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 8a3ad11c61c..82b1ea5be29 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -113,6 +113,12 @@ pub(crate) struct MshvTimeAbi { /// The CPUID results registered with the hypervisor. #[inspect(skip)] registered_cpuid: Vec, + /// The effective CPUID: VP 0's view of the registered leaves at reset, + /// read once before any VP runs. Later reads would fold in VP state (the + /// hypervisor applies the VP's XCR0, XSS, and control registers), so the + /// capture and the restore of one snapshot would disagree. + #[inspect(skip)] + effective_cpuid: std::sync::OnceLock>, /// Set by the synchronized TSC set. VP creation fails afterwards. #[inspect(with = "|x| x.load(Ordering::Relaxed)")] vp_set_sealed: AtomicBool, @@ -123,6 +129,7 @@ impl MshvTimeAbi { Self { msrs: config.msrs.clone(), registered_cpuid: registered_cpuid.leaves().to_vec(), + effective_cpuid: std::sync::OnceLock::new(), vp_set_sealed: AtomicBool::new(false), } } @@ -544,22 +551,63 @@ impl MshvPartitionInner { Ok(()) } - /// Checks that VP 0 sees the identity leaves as registered, and zero at - /// the sentinel leaves. - fn check_identity_cpuid(&self, bsp: &VcpuFd) -> Result<(), TimeAbiError> { + /// Returns the effective CPUID, reading it from VP 0 the first time. The + /// first read happens in preflight, before any VP runs, on both cold boot + /// and restore, so it is VP 0's view at reset. + fn time_abi_effective_cpuid(&self) -> Result<&[CpuidLeaf], TimeAbiError> { let state = self.time_abi_state()?; - let identity = state - .registered_cpuid - .iter() - .filter(|leaf| { - leaf.function <= IDENTITY_MAX_LEAF && IDENTITY_CPUID_RANGE.contains(&leaf.function) + if let Some(leaves) = state.effective_cpuid.get() { + return Ok(leaves); + } + let bsp = self.time_abi_bsp(TimeAbiCode::CpuSurface)?; + let read = |function: u32, index: Option| { + let result = vp_cpuid(bsp, function, index.unwrap_or(0)).map_err(|error| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "cannot read CPUID {function:#x}/{:#x} of VP 0: {}", + index.unwrap_or(0), + error_chain(&error) + ), + ) + })?; + let leaf = CpuidLeaf::new(function, result); + Ok(match index { + Some(index) => leaf.indexed(index), + None => leaf, }) - .copied(); - let sentinels = PREFLIGHT_SENTINEL_LEAVES + }; + let mut leaves = EFFECTIVE_CPUID_MAX_LEAVES .into_iter() - .map(|function| CpuidLeaf::new(function, [0; 4])); - for leaf in identity.chain(sentinels) { - let function = leaf.function; + .map(|function| read(function, None)) + .collect::, TimeAbiError>>()?; + for leaf in &state.registered_cpuid { + leaves.push(if is_zero_fill(leaf) { + *leaf + } else { + read(leaf.function, leaf.index)? + }); + } + // A concurrent first read stores identical values. + let _ = state + .effective_cpuid + .set(CpuidLeafSet::new(leaves).into_leaves()); + Ok(state.effective_cpuid.get().expect("set above")) + } + + /// Checks that the effective CPUID holds the identity leaves as + /// registered, and that VP 0 reads zero at the sentinel leaves. + fn check_identity_cpuid(&self, bsp: &VcpuFd) -> Result<(), TimeAbiError> { + let state = self.time_abi_state()?; + let effective = CpuidLeafSet::new(self.time_abi_effective_cpuid()?.to_vec()); + let identity = state.registered_cpuid.iter().filter(|leaf| { + leaf.function <= IDENTITY_MAX_LEAF && IDENTITY_CPUID_RANGE.contains(&leaf.function) + }); + for leaf in identity { + let actual = effective.result(leaf.function, leaf.index.unwrap_or(0), &[0; 4]); + check_leaf(leaf, actual)?; + } + for function in PREFLIGHT_SENTINEL_LEAVES { let actual = vp_cpuid(bsp, function, 0).map_err(|error| { TimeAbiError::new( TimeAbiCode::IdentityRouting, @@ -569,21 +617,29 @@ impl MshvPartitionInner { ), ) })?; - let mut expected = actual; - leaf.apply(&mut expected); - if actual != expected { - return Err(TimeAbiError::new( - TimeAbiCode::IdentityRouting, - format!( - "VP 0 reads CPUID {function:#x} as {actual:#x?}, expected {expected:#x?}" - ), - )); - } + check_leaf(&CpuidLeaf::new(function, [0; 4]), actual)?; } Ok(()) } } +/// Fails with `E_IDENTITY_ROUTING` unless VP 0's `actual` result agrees with +/// `leaf` under its mask. +fn check_leaf(leaf: &CpuidLeaf, actual: [u32; 4]) -> Result<(), TimeAbiError> { + let mut expected = actual; + leaf.apply(&mut expected); + if actual != expected { + return Err(TimeAbiError::new( + TimeAbiCode::IdentityRouting, + format!( + "VP 0 reads CPUID {:#x} as {actual:#x?}, expected {expected:#x?}", + leaf.function + ), + )); + } + Ok(()) +} + impl TimeAbiBackend for MshvPartition { fn native_tsc_hz(&self) -> Result { let hz = self @@ -654,37 +710,7 @@ impl TimeAbiBackend for MshvPartition { } fn effective_cpuid(&self) -> Result, TimeAbiError> { - let state = self.inner.time_abi_state()?; - let bsp = self.inner.time_abi_bsp(TimeAbiCode::CpuSurface)?; - let read = |function: u32, index: Option| { - let result = vp_cpuid(bsp, function, index.unwrap_or(0)).map_err(|error| { - TimeAbiError::new( - TimeAbiCode::CpuSurface, - format!( - "cannot read CPUID {function:#x}/{:#x} of VP 0: {}", - index.unwrap_or(0), - error_chain(&error) - ), - ) - })?; - let leaf = CpuidLeaf::new(function, result); - Ok(match index { - Some(index) => leaf.indexed(index), - None => leaf, - }) - }; - let mut leaves = EFFECTIVE_CPUID_MAX_LEAVES - .into_iter() - .map(|function| read(function, None)) - .collect::, TimeAbiError>>()?; - for leaf in &state.registered_cpuid { - leaves.push(if is_zero_fill(leaf) { - *leaf - } else { - read(leaf.function, leaf.index)? - }); - } - Ok(CpuidLeafSet::new(leaves).into_leaves()) + Ok(self.inner.time_abi_effective_cpuid()?.to_vec()) } fn capture_anchor(&self) -> Result { @@ -1310,6 +1336,37 @@ mod hw { check_time_bits(&mut lookup, true).unwrap(); check_identity(&mut lookup, VP_CAPACITY).unwrap(); + // The hypervisor folds VP state into CPUID reads, so the effective + // CPUID must stay VP 0's view at reset, whatever the guest does. + let bsp = &partition.inner.finalized().unwrap().bsp_vcpufd; + let live_before = bsp.get_cpuid_values(1, 0, 0, 0).unwrap(); + let mut cr4 = [HvRegisterAssoc::from((HvX64RegisterName::Cr4, 0u64))]; + bsp.get_hvdef_regs(&mut cr4).unwrap(); + let reset_cr4 = cr4[0].value.as_u64(); + let osxsave = bsp.set_hvdef_regs(&[HvRegisterAssoc::from(( + HvX64RegisterName::Cr4, + reset_cr4 | (1 << 18), + ))]); + let live_after = bsp.get_cpuid_values(1, 0, 0, 0).unwrap(); + let key = |leaves: &[CpuidLeaf]| -> Vec<(u32, Option, [u32; 4])> { + leaves + .iter() + .map(|l| (l.function, l.index, l.result)) + .collect() + }; + assert_eq!( + key(&backend.effective_cpuid().unwrap()), + key(effective.leaves()) + ); + bsp.set_hvdef_regs(&[HvRegisterAssoc::from((HvX64RegisterName::Cr4, reset_cr4))]) + .unwrap(); + let osxsave = format!( + "cr4_osxsave_write={} live_cpuid1_ecx={:#x}->{:#x}", + osxsave.is_ok(), + live_before[2], + live_after[2] + ); + let mut pairings: Vec = (0..200) .map(|_| backend.capture_anchor().unwrap().pairing_ns) .collect(); @@ -1378,7 +1435,7 @@ mod hw { "time ABI backend: created {created}/{VP_CAPACITY} tsc_hz={tsc_hz} \ build_us={build_us} preflight_us={preflight_us} effective_cpuid_us={effective_us} \ ({} leaves) set_us={set_us} anchor_pairing_ns min={} p50={} p99={} max={} \ - anchor_rate_deviation_ppm={deviation_ppm:.3} thawed={after:?}", + anchor_rate_deviation_ppm={deviation_ppm:.3} {osxsave} thawed={after:?}", effective.leaves().len(), pairings[0], percentile(&pairings, 50), From 4567a3f2599e061043161f915099e44396d583f0 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 05:42:08 -0700 Subject: [PATCH 186/304] cpu_profile: report every violation, and reject ambiguous generations The time ABI spec requires boot and restore verification to report every violation at once, naming each leaf, subleaf, register, and bit. Make verify_support list every unsupported feature instead of the first one and a count, and make EffectiveCpuid::check_matches name every difference between the recomputed and the recorded effective CPUID. The spec also fails a host that profiles of more than one generation cover with E_PROFILE_HOST_UNKNOWN. The pinned generations are disjoint, and a test keeps them so, but select_auto now enforces the rule itself instead of taking the latest revision across generations, and generation_of follows select_auto, so the two cannot disagree. Also apply rustfmt to two lines of the profile derivation that the voting fix left unformatted. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/cpu_profile/src/catalog.rs | 82 ++++++++++++++++++++-- vmm_core/cpu_profile/src/derive.rs | 12 +++- vmm_core/cpu_profile/src/effective.rs | 99 ++++++++++++++++++--------- vmm_core/cpu_profile/src/surface.rs | 40 +++++------ 4 files changed, 169 insertions(+), 64 deletions(-) diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index 4f826c03200..eb91a5cc2d8 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -65,11 +65,10 @@ pub fn pinned(id: &str) -> Option<&'static CpuProfile> { } /// Returns the name of the generation of the host CPU, if a pinned profile -/// serves it. +/// serves it: the generation of the profile [`select_auto`] selects. pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { - pinned_profiles() - .iter() - .find(|profile| profile.generation().contains(profile.vendor(), host)) + select_auto(host) + .ok() .map(|profile| profile.generation().name.as_str()) } @@ -77,18 +76,52 @@ pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { /// revision. /// /// Fails with `E_PROFILE_HOST_UNKNOWN` when no pinned profile serves the -/// host; there is no host CPUID passthrough. +/// host, or when profiles of more than one generation do; there is no host +/// CPUID passthrough. pub fn select_auto(host: &HostCpuSignature) -> Result<&'static CpuProfile, ProfileError> { - pinned_profiles() + select_auto_in(pinned_profiles(), host) +} + +fn select_auto_in<'a>( + profiles: &'a [CpuProfile], + host: &HostCpuSignature, +) -> Result<&'a CpuProfile, ProfileError> { + let ids = || { + profiles + .iter() + .map(|profile| profile.id()) + .collect::>() + .join(", ") + }; + let matches = profiles .iter() .filter(|profile| profile.generation().contains(profile.vendor(), host)) + .collect::>(); + let mut generations = matches + .iter() + .map(|profile| profile.generation().name.as_str()) + .collect::>(); + generations.sort_unstable(); + generations.dedup(); + if generations.len() > 1 { + return Err(ProfileError::new( + ProfileErrorCode::ProfileHostUnknown, + format!( + "the host CPU, {host}, is in more than one CPU generation ({}); pinned profiles: {}", + generations.join(", "), + ids() + ), + )); + } + matches + .into_iter() .max_by_key(|profile| revision(profile.id())) .ok_or_else(|| { ProfileError::new( ProfileErrorCode::ProfileHostUnknown, format!( "no pinned CPU profile serves the host CPU, {host}; pinned profiles: {}", - pinned_ids() + ids() ), ) }) @@ -308,6 +341,41 @@ mod tests { assert!(error.message.contains("model 85 stepping 7"), "{error}"); } + #[test] + fn auto_takes_the_latest_revision_and_rejects_ambiguous_generations() { + let json = profile("intel.skylake-sp.v1").to_pretty_json(); + let variant = + |from: &str, to: &str| CpuProfile::from_pretty_json(&json.replace(from, to)).unwrap(); + let host = intel(SKYLAKE); + let revisions = [ + variant("intel.skylake-sp.v1", "intel.skylake-sp.v2"), + variant("intel.skylake-sp.v1", "intel.skylake-sp.v10"), + CpuProfile::from_pretty_json(&json).unwrap(), + ]; + assert_eq!( + select_auto_in(&revisions, &host).unwrap().id(), + "intel.skylake-sp.v10" + ); + + // Two generations that both cover the host. + let ambiguous = [ + CpuProfile::from_pretty_json(&json).unwrap(), + variant("skylake-sp", "skylake-x"), + ]; + let error = select_auto_in(&ambiguous, &host).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown); + assert!( + error + .message + .contains("is in more than one CPU generation (skylake-sp, skylake-x)"), + "{error}" + ); + assert_eq!( + code(select_auto_in(&ambiguous, &intel(ICELAKE))), + ProfileErrorCode::ProfileHostUnknown + ); + } + #[test] fn explicit_selection_checks_the_id_and_the_generation() { let host = intel(ICELAKE); diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs index 606fad0261d..07ab70ecc71 100644 --- a/vmm_core/cpu_profile/src/derive.rs +++ b/vmm_core/cpu_profile/src/derive.rs @@ -421,7 +421,10 @@ fn table(entries: &[cpuid::CpuidEntry]) -> Table { let (leaf, subleaf) = entry.key(); let value = entry.registers(); let mask = pinned_mask(leaf, subleaf, value); - ((leaf, subleaf), [0, 1, 2, 3].map(|register| value[register] & mask[register])) + ( + (leaf, subleaf), + [0, 1, 2, 3].map(|register| value[register] & mask[register]), + ) }) .collect() } @@ -771,7 +774,12 @@ mod tests { set(entries, leaf, None, 0, |_| 0); } }); - let derived = derive_profile(generation("icelake-sp"), 1, &[kvm, fingerprint(pinned, "whp")]).unwrap(); + let derived = derive_profile( + generation("icelake-sp"), + 1, + &[kvm, fingerprint(pinned, "whp")], + ) + .unwrap(); assert_eq!(derived.cpuid(), pinned.cpuid()); } diff --git a/vmm_core/cpu_profile/src/effective.rs b/vmm_core/cpu_profile/src/effective.rs index f596a89a45f..f9b34d240ab 100644 --- a/vmm_core/cpu_profile/src/effective.rs +++ b/vmm_core/cpu_profile/src/effective.rs @@ -15,6 +15,7 @@ use crate::profile::find; use crate::profile::vm_owned_bits; use serde::Deserialize; use serde::Serialize; +use std::cmp::Ordering; use std::collections::BTreeMap; /// The schema of the effective-CPUID records this crate reads and writes. @@ -185,42 +186,65 @@ impl EffectiveCpuid { } /// Checks that this effective CPUID, recomputed on the destination, - /// equals the `recorded` one (`E_CPU_SURFACE`, naming the first - /// difference). + /// equals the `recorded` one (`E_CPU_SURFACE`, naming every difference). pub fn check_matches(&self, recorded: &Self) -> Result<(), ProfileError> { - let mut ours = self.entries.iter().peekable(); - let mut theirs = recorded.entries.iter().peekable(); + let differences = self.differences(recorded); + if differences.is_empty() { + return Ok(()); + } + Err(ProfileError::new( + ProfileErrorCode::CpuSurface, + format!( + "the effective CPUID differs from the snapshot's: {}", + differences.join("; ") + ), + )) + } + + /// Lists every entry that differs between this effective CPUID and the + /// `recorded` one; both are sorted by leaf and subleaf. + fn differences(&self, recorded: &Self) -> Vec { + let (ours, theirs) = (&self.entries, &recorded.entries); + let (mut i, mut j) = (0, 0); + let mut differences = Vec::new(); loop { - let difference = match (ours.peek(), theirs.peek()) { - (None, None) => return Ok(()), - (Some(entry), None) => { - format!("{} exists only on the destination", describe(entry)) - } - (None, Some(entry)) => format!("{} exists only in the snapshot", describe(entry)), - (Some(a), Some(b)) if a.key() < b.key() => { - format!("{} exists only on the destination", describe(a)) + let order = match (ours.get(i), theirs.get(j)) { + (None, None) => return differences, + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (Some(a), Some(b)) => a.key().cmp(&b.key()), + }; + match order { + Ordering::Less => { + differences.push(format!( + "{} exists only on the destination", + describe(&ours[i]) + )); + i += 1; } - (Some(a), Some(b)) if a.key() > b.key() => { - format!("{} exists only in the snapshot", describe(b)) + Ordering::Greater => { + differences.push(format!( + "{} exists only in the snapshot", + describe(&theirs[j]) + )); + j += 1; } - (Some(a), Some(b)) if a != b => format!( - "{} is {:#010x?} under mask {:#010x?} on the destination, but {:#010x?} under mask {:#010x?} in the snapshot", - describe(a), - a.values(), - a.masks(), - b.values(), - b.masks() - ), - _ => { - ours.next(); - theirs.next(); - continue; + Ordering::Equal => { + let (a, b) = (&ours[i], &theirs[j]); + if a != b { + differences.push(format!( + "{} is {:#010x?} under mask {:#010x?} on the destination, but {:#010x?} under mask {:#010x?} in the snapshot", + describe(a), + a.values(), + a.masks(), + b.values(), + b.masks() + )); + } + i += 1; + j += 1; } - }; - return Err(ProfileError::new( - ProfileErrorCode::CpuSurface, - format!("the effective CPUID differs from the snapshot's: {difference}"), - )); + } } } } @@ -519,7 +543,11 @@ mod tests { .unwrap(); let error = other.check_matches(&effective).unwrap_err(); assert_eq!(error.code, ProfileErrorCode::CpuSurface); - assert!(error.message.contains("CPUID 0x1 is"), "{error}"); + // Every difference is named: the logical processor counts of leaves + // 1, 4, and 0xB. + for difference in ["CPUID 0x1 is", "CPUID 0x4.0 is", "CPUID 0xb.1 is"] { + assert!(error.message.contains(difference), "{error}"); + } let without_identity = profile .effective_cpuid(&vm_leaves(profile, 2), &[]) .unwrap(); @@ -530,6 +558,13 @@ mod tests { .contains("CPUID 0x40000000 exists only in the snapshot"), "{error}" ); + let error = effective.check_matches(&without_identity).unwrap_err(); + assert!( + error + .message + .ends_with(": CPUID 0x40000000 exists only on the destination"), + "{error}" + ); } /// The contract with core: OpenVMM's topology leaves and the time ABI's diff --git a/vmm_core/cpu_profile/src/surface.rs b/vmm_core/cpu_profile/src/surface.rs index 5ea8721c6b1..67f9ecc3050 100644 --- a/vmm_core/cpu_profile/src/surface.rs +++ b/vmm_core/cpu_profile/src/surface.rs @@ -120,27 +120,22 @@ pub(crate) fn register_class(leaf: u32, subleaf: u32, register: usize) -> Regist /// numeric limit, the exact XSAVE layout of every enabled component, the /// guest physical address width, and every pinned MSR value. /// -/// Fails with `E_PROFILE_UNSUPPORTED`, naming the first missing leaf, -/// register, and bits. +/// Fails with `E_PROFILE_UNSUPPORTED`, naming every violation at once: each +/// missing leaf, subleaf, register, and bit, as [`support_violations`] lists +/// them. pub fn verify_support(profile: &CpuProfile, surface: &HostCpuSurface) -> Result<(), ProfileError> { let violations = support_violations(profile, surface); - match violations.split_first() { - None => Ok(()), - Some((first, rest)) => { - let more = if rest.is_empty() { - String::new() - } else { - format!(" (and {} more)", rest.len()) - }; - Err(ProfileError::new( - ProfileErrorCode::ProfileUnsupported, - format!( - "the backend does not support CPU profile {}: {first}{more}", - profile.id() - ), - )) - } + if violations.is_empty() { + return Ok(()); } + Err(ProfileError::new( + ProfileErrorCode::ProfileUnsupported, + format!( + "the backend does not support CPU profile {}: {}", + profile.id(), + violations.join("; ") + ), + )) } /// Returns every way in which `surface` falls short of `profile`, in the @@ -340,11 +335,10 @@ mod tests { edit(&mut surface, 7, 0, 1, |ebx| ebx & !(1 << 16)); edit(&mut surface, 7, 2, 3, |edx| edx & !1); let message = unsupported(profile, &surface); - assert!( - message.ends_with( - "intel.icelake-sp.v1: CPUID 0x7.0 EBX bit 16 is not supported (and 1 more)" - ), - "{message}" + assert_eq!( + message, + "the backend does not support CPU profile intel.icelake-sp.v1: \ + CPUID 0x7.0 EBX bit 16 is not supported; CPUID 0x7.2 EDX bit 0 is not supported" ); let mut surface = self::surface(profile); From 7d16b4bb9c9e90f45b4e5c5c6f4a8e1e6d25cdac Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 06:35:26 -0700 Subject: [PATCH 187/304] openvmm: capture and restore the time ABI clocks behind --x-time-abi-v1 Implement NVX time ABI capture and restore steps 10 to 17 under the development switch. Capture checks that no VP has an armed periodic or TSC-deadline LAPIC timer, takes the backend's capture anchor and the host identities, and returns the time contract and CPU profile record with the declared rates and the generation counter; the controller writes a version 6 manifest from them. A strict PIT, selected by the microVM entry under the switch, refuses to save or restore channel 0 counting periodically (E_PIT_ACTIVE). Restore checks the LAPIC timers, performs the backend's synchronized TSC set at the downtime selected at the restore anchor, advances every one-shot LAPIC timer by the tick rule and sets every LAPIC again, advances VM time and the RTC, seals the time fields of restore packet version 4, and releases partition time just before the readiness event and the VP release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + openvmm/openvmm_core/Cargo.toml | 2 + openvmm/openvmm_core/src/worker/dispatch.rs | 35 ++-- .../src/worker/dispatch/microvm.rs | 39 +++++ .../src/worker/dispatch/restore.rs | 27 +++ .../src/worker/dispatch/time_abi.rs | 165 +++++++++++++++++- openvmm/openvmm_defs/src/rpc.rs | 3 + openvmm/openvmm_defs/src/time_abi.rs | 38 ++++ openvmm/openvmm_entry/src/microvm/config.rs | 12 +- .../src/vm_controller/microvm.rs | 60 ++++--- .../openvmm_helpers/src/snapshot/publish.rs | 6 +- openvmm/openvmm_helpers/src/snapshot/time.rs | 14 +- vm/devices/chipset/src/pit/mod.rs | 90 ++++++++++ vm/devices/chipset/src/pit/resolver.rs | 10 +- vm/devices/chipset_resources/src/lib.rs | 8 +- vmm_core/src/partition_unit/snapshot.rs | 47 +++++ vmm_core/src/partition_unit/vp_set.rs | 32 ++++ .../src/partition_unit/vp_set/time_abi.rs | 106 +++++++++++ vmm_core/virt/src/time_abi/surface.rs | 52 +++++- vmm_core/virt/src/x86/vp/snapshot.rs | 155 ++++++++++++++++ vmm_core/vm_manifest_builder/src/lib.rs | 2 +- 21 files changed, 840 insertions(+), 64 deletions(-) create mode 100644 vmm_core/src/partition_unit/vp_set/time_abi.rs diff --git a/Cargo.lock b/Cargo.lock index a25f34c29ac..de02132950d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5942,6 +5942,7 @@ dependencies = [ "scsi_core", "scsidisk", "serial_16550_resources", + "sha2", "smmu", "sparse_mmap", "state_unit", diff --git a/openvmm/openvmm_core/Cargo.toml b/openvmm/openvmm_core/Cargo.toml index 3db3916e043..f0b2b0a7469 100644 --- a/openvmm/openvmm_core/Cargo.toml +++ b/openvmm/openvmm_core/Cargo.toml @@ -11,6 +11,7 @@ gdb = ["vmm_core/gdb"] [dev-dependencies] chipset.workspace = true +inspect = { workspace = true, features = ["initiate"] } test_with_tracing.workspace = true [dependencies] @@ -105,6 +106,7 @@ futures.workspace = true futures-concurrency.workspace = true getrandom.workspace = true parking_lot.workspace = true +sha2.workspace = true thiserror.workspace = true tracelimit.workspace = true tracing.workspace = true diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index c50e61f621a..ac4d00b9d9b 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -919,9 +919,9 @@ struct LoadedVmInner { partition: Arc, chipset_devices: ChipsetDevices, _vmtime: SpawnedUnit, - /// The time ABI state unit, when the partition uses the time ABI. + /// The time ABI state, when the partition uses the time ABI. #[cfg(guest_arch = "x86_64")] - _time_abi_unit: Option>, + time_abi: Option, memory_manager: GuestMemoryManager, gm: GuestMemory, vtl0_hvsock_relay: Option, @@ -1936,7 +1936,7 @@ impl InitializedVm { .unwrap(); #[cfg(guest_arch = "x86_64")] - let time_abi_unit = match time_abi_msrs { + let time_abi_state = match time_abi_msrs { Some(msrs) => { let parameters = cfg .microvm @@ -1971,18 +1971,23 @@ impl InitializedVm { }), ¶meters.hooks, )?; - anyhow::ensure!( - restore.is_none(), - "time ABI v1 snapshot restore is not implemented yet: the preflight passed, but the restore clock is missing" - ); - Some( - state_units - .add(time_abi::TIME_ABI_UNIT) - .spawn(driver_source.simple(), |recv| { + let unit = state_units + .add(time_abi::TIME_ABI_UNIT) + .spawn(driver_source.simple(), { + let msrs = msrs.clone(); + let report = report.clone(); + move |recv| { state_unit::run_unit(time_abi::TimeAbiUnit { msrs, report }, recv) - }) - .unwrap(), - ) + } + }) + .unwrap(); + Some(time_abi::TimeAbiState { + _unit: unit, + msrs, + report, + generation: parameters.generation, + hooks: parameters.hooks.clone(), + }) } None => { anyhow::ensure!( @@ -3365,7 +3370,7 @@ impl InitializedVm { chipset_devices: devices, _vmtime: vmtime, #[cfg(guest_arch = "x86_64")] - _time_abi_unit: time_abi_unit, + time_abi: time_abi_state, memory_manager, gm, vtl0_hvsock_relay, diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 9bbf9adc060..96a00b99bfa 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -344,6 +344,29 @@ impl LoadedVm { quiesce.complete("capture", "quiesce", Default::default()); self.running = false; + // Time ABI capture steps 1 and 2: the LAPIC timers, then the capture + // anchor and records. The PIT checks itself when saved. + #[cfg(guest_arch = "x86_64")] + let time = match &self.inner.time_abi { + Some(state) => { + let rollback_safe = |error: anyhow::Error| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + }; + self.inner + .partition_unit + .check_one_shot_timers() + .await + .map_err(rollback_safe)?; + Some( + super::time_abi::capture_records(self.inner.partition.as_ref(), state) + .map_err(rollback_safe)?, + ) + } + None => None, + }; + #[cfg(not(guest_arch = "x86_64"))] + let time = None; + let save_state = openvmm_defs::profile::ProfileSpan::start(); let saved_state = self.save().await.map_err(|error| { openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) @@ -372,6 +395,21 @@ impl LoadedVm { )); } }; + if let Some(time) = time { + return Ok(openvmm_defs::rpc::SnapshotSaveResponse { + state_unit_names: saved_state.inventory.clone(), + saved_state: ProtobufMessage::new(saved_state), + effective_command_line, + tsc_frequency_hz: 0, + apic_frequency_hz: 0, + capture_wall_clock: mesh::payload::Timestamp { + seconds: 0, + nanos: 0, + }, + cpu_contract: Vec::new(), + time: Some(time), + }); + } let tsc_frequency_hz = self .inner .partition @@ -409,6 +447,7 @@ impl LoadedVm { apic_frequency_hz, capture_wall_clock, cpu_contract: mesh::payload::encode(self.inner.partition.cpu_compatibility_contract()), + time: None, }) } diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index e0094bac8a2..fb9e4b47db4 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -142,6 +142,9 @@ pub(super) struct SnapshotRestore { time: Option, /// The time ABI inputs of a restore, validated by the controller. pub(super) time_abi: Option, + /// Whether partition time must be released before the restored VPs + /// first run (time ABI restore step 17). + time_abi_release: bool, /// VP prefix instantiated for an explicit MSHV restore-time activation /// target. vp_prefix: Option, @@ -263,6 +266,13 @@ impl LoadedVm { saved_state_restore: ProfileSpan, ) -> anyhow::Result<()> { saved_state_restore.complete("restore", "saved_state_restore", Default::default()); + #[cfg(guest_arch = "x86_64")] + if let Some(input) = self.snapshot_restore.time_abi.take() { + let clock_restore = ProfileSpan::start(); + self.time_abi_restore(input).await?; + self.snapshot_restore.time_abi_release = true; + clock_restore.complete("restore", "time_abi_clock", Default::default()); + } self.advance_restored_clock(self.snapshot_restore.time) .await?; self.snapshot_restore.start_guard = @@ -298,6 +308,23 @@ impl LoadedVm { }, ); } + // Release partition time just before the readiness event and the VP + // release, so a failure never publishes readiness. + #[cfg(guest_arch = "x86_64")] + if std::mem::take(&mut self.snapshot_restore.time_abi_release) { + let released = self + .inner + .time_abi + .as_ref() + .context("a time ABI restore requires a time ABI partition") + .and_then(|state| { + super::time_abi::release_time(self.inner.partition.as_ref(), state) + }); + if let Err(error) = released { + self.state_units.stop().await; + return Err(error.context("failed to release restored partition time")); + } + } if let Some(mut sink) = self.snapshot_restore.ready_sink.take() { let signal_result = sink.write_all(RESTORE_READY_EVENT_V1).and_then(|()| { #[cfg(windows)] diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index d34a7632ebe..e17218b979d 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -3,19 +3,31 @@ //! NVX time ABI v1 in the VM worker, selected by the hidden `--x-time-abi-v1` //! development switch: the partition configuration, the backend preflight -//! and rate policy, and the `time-abi` state unit. +//! and rate policy, the `time-abi` state unit, the capture records, and the +//! restore clock. #![cfg(guest_arch = "x86_64")] +use super::LoadedVm; use crate::partition::HvlitePartition; +use anyhow::Context as _; +use chipset_resources::microvm_time::RestoreTimeRecord; use inspect::Inspect; use inspect::InspectMut; +use openvmm_defs::time_abi::RestoreTimeInput; use openvmm_defs::time_abi::SnapshotCpuProfile; +use openvmm_defs::time_abi::SnapshotTimeContract; +use openvmm_defs::time_abi::TimeCapture; +use sha2::Digest as _; +use state_unit::SpawnedUnit; use state_unit::StateUnit; use std::sync::Arc; +use std::time::Duration; use virt::time_abi::DeclaredRates; +use virt::time_abi::DowntimeSource; use virt::time_abi::IdentityMsrRoute; use virt::time_abi::RateCheck; +use virt::time_abi::TIME_ABI_VERSION; use virt::time_abi::TimeAbiBackend; use virt::time_abi::TimeAbiCode; use virt::time_abi::TimeAbiConfig; @@ -30,6 +42,20 @@ use vmcore::save_restore::SavedStateBlob; /// The name of the time ABI state unit. pub(super) const TIME_ABI_UNIT: &str = "time-abi"; +/// The time ABI state of a loaded VM. +pub(super) struct TimeAbiState { + /// The `time-abi` state unit. + pub _unit: SpawnedUnit, + /// The identity MSR handler, holding the declared rates. + pub msrs: Arc, + /// The preflight report. + pub report: TimeAbiReport, + /// The generation counter of this VM process. + pub generation: u32, + /// The active test hooks. + pub hooks: TimeAbiTestHooks, +} + /// Returns the identity MSR handler and the time ABI configuration of a new /// partition with `vp_count` VPs. pub(super) fn partition_config(vp_count: u32) -> (Arc, TimeAbiConfig) { @@ -125,6 +151,143 @@ pub(super) fn check_cpu_surface( Ok(()) } +/// Takes the time ABI records of a capture: capture steps 2 and 3 of the +/// specification, the capture anchor and host identities, then the declared +/// rates, the CPU profile record, and the generation counter. The caller has +/// stopped every VP and checked the LAPIC timers (step 1). +pub(super) fn capture_records( + partition: &dyn HvlitePartition, + state: &TimeAbiState, +) -> anyhow::Result { + let backend = backend(partition, &state.report.hypervisor)?; + let rates = state + .msrs + .declared() + .context("the time ABI rates are not declared")?; + let anchor = backend.capture_anchor()?; + let identity = virt::time_abi::host::host_identity()?; + let effective_cpuid = virt::time_abi::surface::encode_cpuid(&backend.effective_cpuid()?); + tracing::info!( + tsc = anchor.tsc, + utc_ns = anchor.sample.utc_ns, + pairing_ns = anchor.pairing_ns, + generation = state.generation, + "time ABI capture anchor" + ); + Ok(TimeCapture { + time: SnapshotTimeContract { + time_abi_version: TIME_ABI_VERSION, + tsc_frequency_hz: rates.tsc_hz, + tsc_tolerance_ppm: virt::time_abi::rate::TSC_TOLERANCE_PPM, + apic_frequency_hz: rates.apic_hz, + capture_tsc: anchor.tsc, + capture_utc_ns: anchor.sample.utc_ns, + capture_monotonic_ns: anchor.sample.monotonic_ns, + host_clock: identity.clock.as_str().to_owned(), + host_id: identity.host_id.to_vec(), + host_boot_id: identity.boot_id.to_vec(), + capture_generation: state.generation, + }, + cpu_profile: SnapshotCpuProfile { + id: state.report.cpu_profile.clone(), + // The interim profile has an empty document. + sha256: sha2::Sha256::digest(b"").to_vec(), + profile: Vec::new(), + effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), + effective_cpuid, + capture_cpu_signature: virt::time_abi::surface::host_cpu_signature().unwrap_or(0), + }, + }) +} + +/// Releases partition time immediately before the restored VPs first run: +/// restore step 17 of the specification. +pub(super) fn release_time( + partition: &dyn HvlitePartition, + state: &TimeAbiState, +) -> anyhow::Result<()> { + backend(partition, &state.report.hypervisor)?.release_time()?; + Ok(()) +} + +impl LoadedVm { + /// Restores the guest clocks: restore steps 11 to 16 of the + /// specification, after the saved state is restored and before any + /// restored VP runs. + /// + /// It checks the LAPIC timers, performs the synchronized TSC set at the + /// downtime selected at the restore anchor, advances and sets every + /// LAPIC, advances VM time and the RTC, and seals the time fields of the + /// restore packet. The PIT checks itself when restored. + pub(super) async fn time_abi_restore(&mut self, input: RestoreTimeInput) -> anyhow::Result<()> { + let state = self + .inner + .time_abi + .as_ref() + .context("a time ABI restore requires a time ABI partition")?; + let hypervisor = state.report.hypervisor.clone(); + let hooks = state.hooks.clone(); + let rate_deviation = state + .report + .rate_check + .map_or(0, |check| check.rate_deviation); + let capture = input.contract.capture_record()?; + let tsc_hz = input.contract.tsc_frequency_hz; + let apic_hz = input.contract.apic_frequency_hz; + + self.inner.partition_unit.check_one_shot_timers().await?; + + let partition = self.inner.partition.clone(); + let mut downtime = None; + let set = + backend(partition.as_ref(), &hypervisor)?.set_synchronized_tsc(&mut |sample| { + let selected = virt::time_abi::downtime::select_downtime( + &capture, + &input.destination, + sample, + &hooks, + )?; + let target = + virt::time_abi::downtime::tsc_target(capture.tsc, selected.nanos, tsc_hz)?; + downtime = Some(selected); + Ok(target) + })?; + let downtime = + downtime.context("the backend set the TSC without selecting the downtime")?; + if let Some(step_ns) = downtime.host_wall_clock_step_ns { + tracing::warn!( + step_ns, + "host wall clock was stepped since capture; the downtime uses host monotonic time" + ); + } + tracing::info!( + downtime_ns = downtime.nanos, + source = ?downtime.source, + target = set.target, + method = ?set.method, + vps = set.readback.len(), + "time ABI synchronized TSC set" + ); + + self.inner + .partition_unit + .advance_lapic_timers(downtime.nanos, apic_hz) + .await?; + self.state_units + .advance_time(Duration::from_nanos(downtime.nanos)) + .await + .context("failed to advance restored VM time")?; + + input.restore_record.send(RestoreTimeRecord { + downtime_ns: downtime.nanos, + downtime_utc: downtime.source == DowntimeSource::Utc, + rate_deviation, + test_hooks: hooks.active(), + }); + Ok(()) + } +} + /// The `time-abi` state unit. It saves and restores /// `HV_X64_MSR_TSC_INVARIANT_CONTROL`, which is partition-wide guest state, /// and inspects the declared rates and the preflight report. diff --git a/openvmm/openvmm_defs/src/rpc.rs b/openvmm/openvmm_defs/src/rpc.rs index 52eecf26c11..d0490efe6cd 100644 --- a/openvmm/openvmm_defs/src/rpc.rs +++ b/openvmm/openvmm_defs/src/rpc.rs @@ -77,6 +77,9 @@ pub struct SnapshotSaveResponse { pub capture_wall_clock: mesh::payload::Timestamp, /// Canonical effective CPU compatibility contract. pub cpu_contract: Vec, + /// The NVX time ABI records of the capture. With them, the four clock + /// fields above are empty and the manifest is version 6. + pub time: Option, } /// Failure classification for a bounded snapshot quiesce/save operation. diff --git a/openvmm/openvmm_defs/src/time_abi.rs b/openvmm/openvmm_defs/src/time_abi.rs index d39be059868..dc86b72aead 100644 --- a/openvmm/openvmm_defs/src/time_abi.rs +++ b/openvmm/openvmm_defs/src/time_abi.rs @@ -7,7 +7,12 @@ use chipset_resources::microvm_time::RestoreTimeRecord; use mesh::MeshPayload; use mesh::payload::Protobuf; +use virt::time_abi::CaptureTimeRecord; +use virt::time_abi::HostClockKind; use virt::time_abi::HostIdentity; +use virt::time_abi::HostTimeSample; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; use virt::time_abi::TimeAbiTestHooks; /// The manifest's time contract, recorded at the capture anchor. @@ -50,6 +55,39 @@ pub struct SnapshotTimeContract { pub capture_generation: u32, } +impl SnapshotTimeContract { + /// Returns the capture record: the capture anchor and the capture host's + /// identities (`E_MANIFEST_TIME` if an identity or the clock is + /// malformed). + pub fn capture_record(&self) -> Result { + let identity = |bytes: &[u8], description: &str| { + <[u8; 16]>::try_from(bytes).map_err(|_| { + TimeAbiError::new( + TimeAbiCode::ManifestTime, + format!("{description} is {} bytes, not 16", bytes.len()), + ) + }) + }; + Ok(CaptureTimeRecord { + tsc: self.capture_tsc, + sample: HostTimeSample { + utc_ns: self.capture_utc_ns, + monotonic_ns: self.capture_monotonic_ns, + }, + identity: HostIdentity { + host_id: identity(&self.host_id, "host identity")?, + boot_id: identity(&self.host_boot_id, "host boot identity")?, + clock: HostClockKind::from_manifest(&self.host_clock).ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ManifestTime, + format!("host clock '{}' is unknown", self.host_clock), + ) + })?, + }, + }) + } +} + /// The manifest's CPU profile record. #[derive(Clone, Debug, PartialEq, Eq, Protobuf)] #[mesh(package = "openvmm.snapshot")] diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 5b2fdf26033..1cf87d2bd16 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -417,6 +417,14 @@ impl<'a> MicrovmConfigBuilder<'a> { }; let time_abi = if opt.microvm.x_time_abi_v1 { let hooks = time_abi_test_hooks(opt)?; + // The time ABI forbids a periodic PIT channel 0 at capture and + // restore (`E_PIT_ACTIVE`). + for device in chipset_devices.iter_mut() { + if device.name == chipset_resources::pit::PitDeviceHandle::ID { + device.resource = + chipset_resources::pit::PitDeviceHandle { time_abi: true }.into_resource(); + } + } Some(chipset_resources::microvm::MicrovmPortbTimeAbi { generation: time_abi_restore .as_ref() @@ -711,10 +719,6 @@ impl<'a> MicrovmConfigBuilder<'a> { cfg.machine_profile == MachineProfile::Microvm, "--x-time-abi-v1 requires the microVM machine profile" ); - anyhow::ensure!( - opt.microvm.snapshot_destination.is_none(), - "time ABI v1 snapshot capture is not implemented yet" - ); let hooks = time_abi_test_hooks(opt)?; if hooks.active() { tracing::warn!(?hooks, "time ABI test hooks are active"); diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 0b9743d8133..4832a85c997 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -296,27 +296,45 @@ impl VmController { block.identity.clear(); } } - let machine_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( - &self.microvm.source_hypervisor, - openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, - command_line, - network, - self.microvm.filesystem_slot, - filesystem, - self.microvm.resources.console_attachment.clone(), - self.microvm.resources.control_console_attachment.clone(), - blocks, - self.processors, - self.memory, - self.microvm.memory_capacity, - response.state_unit_names, - response.capture_wall_clock, - response.tsc_frequency_hz, - Some(response.apic_frequency_hz), - response.cpu_contract, - )?; + let (clock, version, format_magic) = match response.time { + Some(time) => ( + openvmm_helpers::snapshot::microvm::MicrovmClockContract::TimeAbi { + time: time.time, + cpu_profile: time.cpu_profile, + }, + openvmm_helpers::snapshot::format::TIME_ABI_MANIFEST_VERSION, + openvmm_helpers::snapshot::format::TIME_ABI_SNAPSHOT_FORMAT_MAGIC, + ), + None => ( + openvmm_helpers::snapshot::microvm::MicrovmClockContract::Legacy { + capture_wall_clock: response.capture_wall_clock, + tsc_frequency_hz: response.tsc_frequency_hz, + apic_frequency_hz: Some(response.apic_frequency_hz), + cpu_contract: response.cpu_contract, + }, + openvmm_helpers::snapshot::MANIFEST_VERSION, + openvmm_helpers::snapshot::format::SNAPSHOT_FORMAT_MAGIC, + ), + }; + let machine_contract = + openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_clock( + &self.microvm.source_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + network, + self.microvm.filesystem_slot, + filesystem, + self.microvm.resources.console_attachment.clone(), + self.microvm.resources.control_console_attachment.clone(), + blocks, + self.processors, + self.memory, + self.microvm.memory_capacity, + response.state_unit_names, + clock, + )?; let manifest = openvmm_helpers::snapshot::SnapshotManifest { - version: openvmm_helpers::snapshot::MANIFEST_VERSION, + version, created_at: std::time::SystemTime::now().into(), openvmm_version: env!("CARGO_PKG_VERSION").to_owned(), memory_size_bytes: self.memory, @@ -327,7 +345,7 @@ impl VmController { state_sha256: Vec::new(), memory_sha256: Vec::new(), machine_contract: Some(machine_contract), - format_magic: openvmm_helpers::snapshot::format::SNAPSHOT_FORMAT_MAGIC.to_vec(), + format_magic: format_magic.to_vec(), saved_state_schema_version: openvmm_helpers::snapshot::format::SAVED_STATE_SCHEMA_VERSION, saved_state_root_type: openvmm_helpers::snapshot::format::SAVED_STATE_ROOT_TYPE diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs index d4d3ad9e422..02b7c0248fb 100644 --- a/openvmm/openvmm_helpers/src/snapshot/publish.rs +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -14,6 +14,7 @@ use super::format::MAX_SAVED_STATE_SIZE_BYTES; use super::format::MEMORY_FILE_NAME; use super::format::SCRATCH_FILE_NAME; use super::format::STATE_FILE_NAME; +use super::format::TIME_ABI_MANIFEST_VERSION; use super::format::validate_manifest_header; use super::format::validate_manifest_version; use super::fs::OpenedSnapshotDirectory; @@ -226,11 +227,12 @@ fn stage_snapshot( manifest.vp_count, )?; } - if manifest.version != MANIFEST_VERSION { + if manifest.version != MANIFEST_VERSION && manifest.version != TIME_ABI_MANIFEST_VERSION { return Err(anyhow::anyhow!( - "snapshot manifest version {} is not supported for writing (expected {})", + "snapshot manifest version {} is not supported for writing (expected {} or {})", manifest.version, MANIFEST_VERSION, + TIME_ABI_MANIFEST_VERSION, ) .into()); } diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index bbe072de462..91c696937f2 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -152,19 +152,7 @@ pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), Ti /// Returns the capture record of a validated time contract. pub fn capture_record(contract: &SnapshotTimeContract) -> Result { validate_time_contract(contract)?; - Ok(CaptureTimeRecord { - tsc: contract.capture_tsc, - sample: HostTimeSample { - utc_ns: contract.capture_utc_ns, - monotonic_ns: contract.capture_monotonic_ns, - }, - identity: HostIdentity { - host_id: identity(&contract.host_id, "host identity")?, - boot_id: identity(&contract.host_boot_id, "host boot identity")?, - clock: HostClockKind::from_manifest(&contract.host_clock) - .ok_or_else(|| manifest_error("host clock is unknown"))?, - }, - }) + contract.capture_record() } /// Validates the time ABI part of a version 6 machine contract: both diff --git a/vm/devices/chipset/src/pit/mod.rs b/vm/devices/chipset/src/pit/mod.rs index a8e8d7b378e..6ee008aa123 100644 --- a/vm/devices/chipset/src/pit/mod.rs +++ b/vm/devices/chipset/src/pit/mod.rs @@ -545,10 +545,21 @@ pub struct PitDevice { // Runtime book-keeping dram_refresh: bool, // just jitters back and forth + // Static configuration + time_abi: bool, + // Volatile state last: VmTime, } +/// Channel 0 counts in a periodic mode, which the NVX time ABI forbids at +/// snapshot capture and restore. +#[derive(Debug, Error)] +#[error("[E_PIT_ACTIVE] PIT channel 0 is counting in periodic mode {mode:?}")] +struct PitActiveError { + mode: Mode, +} + impl PitDevice { pub fn new(interrupt: LineInterrupt, vmtime: VmTimeAccess) -> Self { PitDevice { @@ -562,9 +573,30 @@ impl PitDevice { last: vmtime.now(), vmtime, dram_refresh: false, + time_abi: false, } } + /// Applies the NVX time ABI: saving or restoring the device while + /// channel 0 counts in a periodic mode fails with `E_PIT_ACTIVE`. + pub fn with_time_abi(mut self) -> Self { + self.time_abi = true; + self + } + + /// Fails if the time ABI applies and channel 0 counts in a periodic mode. + fn check_time_abi(&self) -> Result<(), PitActiveError> { + let state = &self.timers[0].state; + let mode = state.op_mode(); + if self.time_abi + && matches!(mode, Mode::RateGenerator | Mode::SquareWave) + && state.state != CountState::Inactive + { + return Err(PitActiveError { mode }); + } + Ok(()) + } + fn evaluate(&mut self, now: VmTime) { // Accumulate an integer number of ticks. // @@ -796,10 +828,13 @@ mod save_restore { type SavedState = state::SavedState; fn save(&mut self) -> Result { + self.check_time_abi() + .map_err(|err| SaveError::Other(err.into()))?; let Self { vmtime: _, timers, dram_refresh: _, + time_abi: _, last, } = self; @@ -891,6 +926,8 @@ mod save_restore { PitDeviceRestoreError::InvalidLastTick.into(), )); } + self.check_time_abi() + .map_err(|err| RestoreError::InvalidSavedState(err.into()))?; Ok(()) } @@ -1087,4 +1124,57 @@ mod tests { assert_eq!(*target.0.lock(), [false, true]); assert!(timer.state.out); } + + #[test] + fn time_abi_rejects_periodic_channel0_at_save_and_restore() { + use super::PitDevice; + use vmcore::save_restore::SaveRestore; + use vmcore::vmtime::VmTime; + + let mut pool = pal_async::DefaultPool::new(); + let driver = pool.driver(); + let keeper = vmcore::vmtime::VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); + let source = pool.run_until(keeper.builder().build(&driver)).unwrap(); + let device = |time_abi| { + let device = PitDevice::new(LineInterrupt::detached(), source.access("pit")); + if time_abi { + device.with_time_abi() + } else { + device + } + }; + + // Linux stops channel 0 by programming mode 0, which is accepted. + let mut stopped = device(true); + set_timer(&mut stopped.timers[0], Mode::TerminalCount, 0, false); + let saved = stopped.save().unwrap(); + + for mode in [Mode::RateGenerator, Mode::SquareWave] { + let mut periodic = device(true); + set_timer(&mut periodic.timers[0], mode, 100, false); + periodic.timers[0].evaluate(1); + let Err(err) = periodic.save() else { + panic!("a periodic channel 0 saved under the time ABI"); + }; + assert!(format!("{err:#}").contains("[E_PIT_ACTIVE]"), "{err:#}"); + + // Without the time ABI the same state saves, and restoring it + // into a time ABI device fails. + let mut legacy = device(false); + set_timer(&mut legacy.timers[0], mode, 100, false); + legacy.timers[0].evaluate(1); + let saved_periodic = legacy.save().unwrap(); + let err = device(true).restore(saved_periodic).unwrap_err(); + let mut chain = err.to_string(); + let mut source = std::error::Error::source(&err); + while let Some(error) = source { + chain = format!("{chain}: {error}"); + source = error.source(); + } + assert!(chain.contains("[E_PIT_ACTIVE]"), "{chain}"); + } + + device(true).restore(saved).unwrap(); + drop(keeper); + } } diff --git a/vm/devices/chipset/src/pit/resolver.rs b/vm/devices/chipset/src/pit/resolver.rs index f43ee07c6c3..061771c0528 100644 --- a/vm/devices/chipset/src/pit/resolver.rs +++ b/vm/devices/chipset/src/pit/resolver.rs @@ -26,11 +26,17 @@ impl ResolveResource for PitResolver { fn resolve( &self, - _resource: PitDeviceHandle, + resource: PitDeviceHandle, input: ResolveChipsetDeviceHandleParams<'_>, ) -> Result { let interrupt = input.configure.new_line(IRQ_LINE_SET, "timer0", 2); // hard-coded IRQ lines, as per x86 spec let vmtime = input.vmtime.access("pit"); - Ok(PitDevice::new(interrupt, vmtime).into()) + let device = PitDevice::new(interrupt, vmtime); + Ok(if resource.time_abi { + device.with_time_abi() + } else { + device + } + .into()) } } diff --git a/vm/devices/chipset_resources/src/lib.rs b/vm/devices/chipset_resources/src/lib.rs index bb18967b804..9b836062a71 100644 --- a/vm/devices/chipset_resources/src/lib.rs +++ b/vm/devices/chipset_resources/src/lib.rs @@ -218,8 +218,12 @@ pub mod pit { use vm_resource::kind::ChipsetDeviceHandleKind; /// A handle to a PIT (Intel 8253/8254 Programmable Interval Timer) device. - #[derive(MeshPayload)] - pub struct PitDeviceHandle; + #[derive(MeshPayload, Default)] + pub struct PitDeviceHandle { + /// Applies the NVX time ABI: saving or restoring the PIT while + /// channel 0 counts in a periodic mode fails with `E_PIT_ACTIVE`. + pub time_abi: bool, + } impl ResourceId for PitDeviceHandle { const ID: &'static str = "pit"; diff --git a/vmm_core/src/partition_unit/snapshot.rs b/vmm_core/src/partition_unit/snapshot.rs index 6dfeca9e777..cc8739330f8 100644 --- a/vmm_core/src/partition_unit/snapshot.rs +++ b/vmm_core/src/partition_unit/snapshot.rs @@ -19,6 +19,10 @@ pub(super) enum SnapshotRequest { StopVpsAtIoBoundary(FailableRpc<(mesh::OneshotSender<()>, mesh::OneshotReceiver<()>), ()>), #[cfg(guest_arch = "x86_64")] AdvanceTsc(FailableRpc<(std::time::Duration, u64, Option), ()>), + #[cfg(guest_arch = "x86_64")] + CheckTimers(FailableRpc<(), ()>), + #[cfg(guest_arch = "x86_64")] + AdvanceLapic(FailableRpc<(u64, u64), ()>), } /// Returns the number of VPs to instantiate, validated against the topology's @@ -70,6 +74,37 @@ impl PartitionUnit { .await?; Ok(()) } + + /// Checks the LAPIC timer of every stopped vCPU for the NVX time ABI + /// (`E_LAPIC_PERIODIC`, `E_LAPIC_TSC_DEADLINE`). + #[cfg(guest_arch = "x86_64")] + pub async fn check_one_shot_timers(&mut self) -> anyhow::Result<()> { + self.req_send + .call_failable( + |rpc| PartitionRequest::Snapshot(SnapshotRequest::CheckTimers(rpc)), + (), + ) + .await?; + Ok(()) + } + + /// Advances the one-shot LAPIC timer of every stopped vCPU by + /// `downtime_ns` at `apic_hz` and sets every vCPU's LAPIC state again, + /// for the NVX time ABI. + #[cfg(guest_arch = "x86_64")] + pub async fn advance_lapic_timers( + &mut self, + downtime_ns: u64, + apic_hz: u64, + ) -> anyhow::Result<()> { + self.req_send + .call_failable( + |rpc| PartitionRequest::Snapshot(SnapshotRequest::AdvanceLapic(rpc)), + (downtime_ns, apic_hz), + ) + .await?; + Ok(()) + } } impl PartitionUnitRunner { @@ -96,6 +131,18 @@ impl PartitionUnitRunner { }) .await } + #[cfg(guest_arch = "x86_64")] + SnapshotRequest::CheckTimers(rpc) => { + rpc.handle_failable(async |()| self.vp_set.check_one_shot_timers().await) + .await + } + #[cfg(guest_arch = "x86_64")] + SnapshotRequest::AdvanceLapic(rpc) => { + rpc.handle_failable(async |(downtime_ns, apic_hz)| { + self.vp_set.advance_lapic_timers(downtime_ns, apic_hz).await + }) + .await + } } } } diff --git a/vmm_core/src/partition_unit/vp_set.rs b/vmm_core/src/partition_unit/vp_set.rs index 888bb687348..3bb95f56828 100644 --- a/vmm_core/src/partition_unit/vp_set.rs +++ b/vmm_core/src/partition_unit/vp_set.rs @@ -6,6 +6,8 @@ mod boundary; mod prefix; #[cfg(guest_arch = "x86_64")] +mod time_abi; +#[cfg(guest_arch = "x86_64")] mod tsc; use super::HaltReason; @@ -87,6 +89,14 @@ trait ControlVp: ProtobufSaveRestore { #[cfg(guest_arch = "x86_64")] fn advance_tsc(&mut self, advance: tsc::TscAdvance) -> anyhow::Result<()>; + /// Checks the stopped vCPU's LAPIC timer for the time ABI. + #[cfg(guest_arch = "x86_64")] + fn check_one_shot_timer(&mut self) -> anyhow::Result<()>; + + /// Advances the stopped vCPU's one-shot LAPIC timer for the time ABI. + #[cfg(guest_arch = "x86_64")] + fn advance_lapic_timer(&mut self, advance: time_abi::LapicAdvance) -> anyhow::Result<()>; + #[cfg(feature = "gdb")] fn debug(&mut self) -> &mut dyn DebugVp; @@ -189,6 +199,16 @@ where advance.apply(self.vp) } + #[cfg(guest_arch = "x86_64")] + fn check_one_shot_timer(&mut self) -> anyhow::Result<()> { + time_abi::check_one_shot_timer(self.vp) + } + + #[cfg(guest_arch = "x86_64")] + fn advance_lapic_timer(&mut self, advance: time_abi::LapicAdvance) -> anyhow::Result<()> { + advance.apply(self.vp) + } + fn inspect_vp( &mut self, vtl_guest_memory: &[Option; NUM_VTLS], @@ -1126,6 +1146,10 @@ enum StateEvent { Scrub(mesh::rpc::FailableRpc), #[cfg(guest_arch = "x86_64")] AdvanceTsc(mesh::rpc::FailableRpc), + #[cfg(guest_arch = "x86_64")] + CheckTimers(mesh::rpc::FailableRpc<(), ()>), + #[cfg(guest_arch = "x86_64")] + AdvanceLapic(mesh::rpc::FailableRpc), #[cfg(feature = "dump")] GetDumpVpState(Rpc>), #[cfg(feature = "gdb")] @@ -1374,6 +1398,14 @@ impl RunnerInner { StateEvent::Scrub(rpc) => rpc.handle_failable_sync(|vtl| vp.scrub(vtl)), #[cfg(guest_arch = "x86_64")] StateEvent::AdvanceTsc(rpc) => rpc.handle_failable_sync(|tsc| vp.advance_tsc(tsc)), + #[cfg(guest_arch = "x86_64")] + StateEvent::CheckTimers(rpc) => { + rpc.handle_failable_sync(|()| vp.check_one_shot_timer()) + } + #[cfg(guest_arch = "x86_64")] + StateEvent::AdvanceLapic(rpc) => { + rpc.handle_failable_sync(|advance| vp.advance_lapic_timer(advance)) + } #[cfg(feature = "dump")] StateEvent::GetDumpVpState(rpc) => rpc.handle_sync(|vtl| vp.get_dump_vp_state(vtl)), #[cfg(feature = "gdb")] diff --git a/vmm_core/src/partition_unit/vp_set/time_abi.rs b/vmm_core/src/partition_unit/vp_set/time_abi.rs new file mode 100644 index 00000000000..cfd9f9a1323 --- /dev/null +++ b/vmm_core/src/partition_unit/vp_set/time_abi.rs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! LAPIC timer checks and advancement for an NVX time ABI snapshot. + +use super::StateEvent; +use super::VpEvent; +use super::VpSet; +use anyhow::Context as _; +use futures::future::TryJoinAll; +use hvdef::Vtl; +use mesh::rpc::RpcSend; +use virt::Processor; +use virt::vp::AccessVpState; + +/// A request to advance the LAPIC timer of a stopped VP by the restore +/// downtime. +#[derive(Debug, Copy, Clone)] +pub(super) struct LapicAdvance { + downtime_ns: u64, + apic_hz: u64, +} + +/// Checks the LAPIC timer of the stopped VP `vp` (`E_LAPIC_PERIODIC`, +/// `E_LAPIC_TSC_DEADLINE`). +pub(super) fn check_one_shot_timer(vp: &mut impl Processor) -> anyhow::Result<()> { + let apic = vp + .access_state(Vtl::Vtl0) + .apic() + .context("failed to read stopped LAPIC")?; + apic.check_one_shot_timer()?; + Ok(()) +} + +impl LapicAdvance { + /// Advances the one-shot LAPIC timer of the stopped VP `vp` and sets its + /// LAPIC state again, even when no timer is armed: KVM derives its timer + /// deadline from the guest TSC when the LAPIC state is set. + pub(super) fn apply(self, vp: &mut impl Processor) -> anyhow::Result<()> { + let mut access = vp.access_state(Vtl::Vtl0); + let mut apic = access.apic().context("failed to read stopped LAPIC")?; + apic.advance_one_shot_timer(self.downtime_ns, self.apic_hz)?; + access + .set_apic(&apic) + .context("failed to set the restored LAPIC")?; + access + .commit() + .context("failed to commit the restored LAPIC")?; + Ok(()) + } +} + +impl VpSet { + /// Checks the LAPIC timer of every stopped VP: restore step 11 and + /// capture step 1 of the time ABI. + pub async fn check_one_shot_timers(&mut self) -> anyhow::Result<()> { + anyhow::ensure!( + !self.started, + "vCPUs must be stopped before checking LAPIC timers" + ); + self.vps + .iter() + .enumerate() + .map(|(index, vp)| async move { + vp.send + .call_failable(|rpc| VpEvent::State(StateEvent::CheckTimers(rpc)), ()) + .await + .with_context(|| format!("vp{index} LAPIC timer")) + }) + .collect::>() + .await?; + Ok(()) + } + + /// Advances the one-shot LAPIC timer of every stopped VP by `downtime_ns` + /// at `apic_hz` and sets every VP's LAPIC state again: restore step 14 of + /// the time ABI, after the synchronized TSC set. + pub async fn advance_lapic_timers( + &mut self, + downtime_ns: u64, + apic_hz: u64, + ) -> anyhow::Result<()> { + anyhow::ensure!( + !self.started, + "vCPUs must be stopped before advancing LAPIC timers" + ); + self.vps + .iter() + .enumerate() + .map(|(index, vp)| async move { + vp.send + .call_failable( + |rpc| VpEvent::State(StateEvent::AdvanceLapic(rpc)), + LapicAdvance { + downtime_ns, + apic_hz, + }, + ) + .await + .with_context(|| format!("vp{index} LAPIC timer advance")) + }) + .collect::>() + .await?; + Ok(()) + } +} diff --git a/vmm_core/virt/src/time_abi/surface.rs b/vmm_core/virt/src/time_abi/surface.rs index b0c252f4434..d8c0ee0a748 100644 --- a/vmm_core/virt/src/time_abi/surface.rs +++ b/vmm_core/virt/src/time_abi/surface.rs @@ -14,18 +14,37 @@ pub const ENCODED_CPUID_LEAF_LEN: usize = 24; /// The most differing leaves an `E_CPU_SURFACE` failure names. const MAX_REPORTED_CPUID_DIFFERENCES: usize = 16; +/// Returns the bits of a CPUID result that mirror VP runtime state rather +/// than the CPU surface, which the canonical encoding clears: OSXSAVE +/// (`CPUID.1:ECX[27]`), OSPKE (`CPUID.(7,0):ECX[4]`), and the XSAVE area sizes +/// of the enabled features (`CPUID.(0xD,0):EBX` and `CPUID.(0xD,1):EBX`). They +/// are the runtime-owned bits of the CPU profile format. +fn runtime_owned_bits(function: u32, index: Option) -> [u32; 4] { + match (function, index) { + (0x1, _) => [0, 0, 1 << 27, 0], + (0x7, Some(0) | None) => [0, 0, 1 << 4, 0], + (0xd, Some(0 | 1)) => [0, !0, 0, 0], + _ => [0; 4], + } +} + /// Returns the canonical encoding of an effective CPUID table, as recorded in /// a snapshot's CPU profile record. /// /// Leaves are sorted by function and subleaf, a leaf without a subleaf sorts /// first, and duplicates keep the last entry. Each leaf is 24 bytes, all /// little-endian `u32`s: the function, the subleaf (`0xffffffff` for any), -/// and EAX, EBX, ECX, and EDX. The values are the effective results, so the -/// masks are not encoded. +/// and EAX, EBX, ECX, and EDX. The values are the effective results with the +/// runtime-owned bits cleared, so a VP that has enabled XSAVE encodes like a +/// fresh one; the masks are not encoded. pub fn encode_cpuid(leaves: &[CpuidLeaf]) -> Vec { let mut entries: Vec<(u32, Option, [u32; 4])> = leaves .iter() - .map(|leaf| (leaf.function, leaf.index, leaf.result)) + .map(|leaf| { + let runtime = runtime_owned_bits(leaf.function, leaf.index); + let result = [0, 1, 2, 3].map(|register| leaf.result[register] & !runtime[register]); + (leaf.function, leaf.index, result) + }) .collect(); entries.sort_by_key(|&(function, index, _)| (function, index)); let mut deduplicated: Vec<(u32, Option, [u32; 4])> = Vec::with_capacity(entries.len()); @@ -219,6 +238,33 @@ mod tests { assert_eq!(encode_cpuid(&[]), Vec::::new()); } + #[test] + fn cpuid_encoding_clears_runtime_owned_bits() { + let fresh = [ + CpuidLeaf::new(1, [0x506e3, 0x800, 0x76da_3203, 0x1f8b_fbff]), + CpuidLeaf::new(7, [0, 0x029c_6fbf, 0, 0]).indexed(0), + CpuidLeaf::new(0xd, [7, 0x240, 0x340, 0]).indexed(0), + CpuidLeaf::new(0xd, [0xf, 0x240, 0, 0]).indexed(1), + ]; + let mut running = fresh; + running[0].result[2] |= 1 << 27; + running[1].result[2] |= 1 << 4; + running[2].result[1] = 0x340; + running[3].result[1] = 0x3c0; + assert_eq!(encode_cpuid(&running), encode_cpuid(&fresh)); + check_effective_cpuid(&running, &encode_cpuid(&fresh)).unwrap(); + + // Every other bit is part of the surface. + let mut changed = fresh; + changed[0].result[2] ^= 1 << 26; + assert_eq!( + check_effective_cpuid(&changed, &encode_cpuid(&fresh)) + .unwrap_err() + .code, + TimeAbiCode::CpuSurface + ); + } + #[test] fn effective_cpuid_differences_are_named() { let recorded = encode_cpuid(&[ diff --git a/vmm_core/virt/src/x86/vp/snapshot.rs b/vmm_core/virt/src/x86/vp/snapshot.rs index 3724841cfdb..db790ecb87d 100644 --- a/vmm_core/virt/src/x86/vp/snapshot.rs +++ b/vmm_core/virt/src/x86/vp/snapshot.rs @@ -8,6 +8,9 @@ use super::Apic; use super::ApicRegisters; use crate::state::HvRegisterState; use crate::state::StateElement; +use crate::time_abi::TimeAbiCode; +use crate::time_abi::TimeAbiError; +use crate::time_abi::rate; use crate::x86::X86PartitionCapabilities; use anyhow::Context as _; use hvdef::HvRegisterValue; @@ -105,6 +108,65 @@ impl Apic { self.registers = *registers.as_array(); Ok(()) } + + /// Checks the LAPIC timer against the NVX time ABI: an armed periodic + /// timer fails with `E_LAPIC_PERIODIC`, and TSC-deadline (or reserved) + /// timer mode with `E_LAPIC_TSC_DEADLINE`. + pub fn check_one_shot_timer(&self) -> Result<(), TimeAbiError> { + let registers = ApicRegisters::from_array(self.registers); + match (registers.lvt_timer >> 17) & 0x3 { + 1 if registers.timer_icr != 0 => Err(TimeAbiError::new( + TimeAbiCode::LapicPeriodic, + format!( + "the LAPIC timer is periodic with initial count {:#x}", + registers.timer_icr + ), + )), + mode @ (2 | 3) => Err(TimeAbiError::new( + TimeAbiCode::LapicTscDeadline, + format!("the LAPIC timer is in mode {mode}, not one-shot"), + )), + _ => Ok(()), + } + } + + /// Advances a counting-mode one-shot LAPIC timer over a downtime of + /// `downtime_ns` at `apic_hz`, by the time ABI's tick rule: if the ticks + /// reach the current count, the count becomes 0 and the timer interrupt + /// is queued unless the LVT is masked; otherwise the count decreases by + /// the ticks. Fails like [`Self::check_one_shot_timer`]. + pub fn advance_one_shot_timer( + &mut self, + downtime_ns: u64, + apic_hz: u64, + ) -> Result<(), TimeAbiError> { + self.check_one_shot_timer()?; + let mut registers = ApicRegisters::from_array(self.registers); + if registers.timer_ccr == 0 { + return Ok(()); + } + let divide = match registers.timer_dcr & 0xb { + 0xb => 1, + 0 => 2, + 1 => 4, + 2 => 8, + 3 => 16, + 8 => 32, + 9 => 64, + 0xa => 128, + _ => unreachable!(), + }; + let ticks = rate::lapic_ticks(downtime_ns, apic_hz, divide) + .expect("the divide configuration is a power of two up to 128"); + if ticks >= u64::from(registers.timer_ccr) { + registers.timer_ccr = 0; + self.queue_timer_interrupt(&mut registers); + } else { + registers.timer_ccr -= ticks as u32; + } + self.registers = *registers.as_array(); + Ok(()) + } } /// IA32_TSC_DEADLINE state. @@ -288,4 +350,97 @@ mod tests { assert_eq!(deadline, 1_500); assert!(!timer_pending(&apic)); } + + fn with_divide(apic: Apic, dcr: u32) -> Apic { + let mut registers = ApicRegisters::from_array(apic.registers); + registers.timer_dcr = dcr; + Apic { + registers: *registers.as_array(), + ..apic + } + } + + #[test] + fn time_abi_one_shot_timer_counts_down_by_the_tick_rule() { + // D = 1 ms at L = 200 MHz is 200,000 ticks before division. + for (dcr, divide) in [ + (0xb, 1), + (0, 2), + (1, 4), + (2, 8), + (3, 16), + (8, 32), + (9, 64), + (0xa, 128), + ] { + let mut timer = with_divide(apic(TIMER_VECTOR, 1_000_000, 1_000_000), dcr); + timer + .advance_one_shot_timer(1_000_000, 200_000_000) + .unwrap(); + assert_eq!(timer.registers().timer_ccr, 1_000_000 - 200_000 / divide); + assert!(!timer_pending(&timer)); + } + + // floor(999 ns * 1 GHz / 1e9) = 999 ticks. + let mut timer = apic(TIMER_VECTOR, 1_000, 1_000); + timer.advance_one_shot_timer(999, 1_000_000_000).unwrap(); + assert_eq!(timer.registers().timer_ccr, 1); + assert!(!timer_pending(&timer)); + } + + #[test] + fn time_abi_one_shot_timer_expires_once() { + let mut timer = apic(TIMER_VECTOR, 100, 50); + timer.advance_one_shot_timer(50, 1_000_000_000).unwrap(); + assert_eq!(timer.registers().timer_ccr, 0); + assert!(timer_pending(&timer)); + // An expired timer stays expired; nothing else is queued. + timer + .advance_one_shot_timer(1_000_000_000, 1_000_000_000) + .unwrap(); + assert_eq!(timer.registers().timer_ccr, 0); + + let mut masked = apic(TIMER_VECTOR | TIMER_MASKED, 100, 50); + masked + .advance_one_shot_timer(1_000_000_000, 200_000_000) + .unwrap(); + assert_eq!(masked.registers().timer_ccr, 0); + assert!(!timer_pending(&masked)); + + let mut idle = apic(TIMER_VECTOR, 0, 0); + idle.advance_one_shot_timer(1_000_000_000, 200_000_000) + .unwrap(); + assert!(!timer_pending(&idle)); + } + + #[test] + fn time_abi_rejects_periodic_and_deadline_timers() { + let periodic = apic(TIMER_VECTOR | TIMER_PERIODIC, 100, 25); + assert_eq!( + periodic.check_one_shot_timer().unwrap_err().code, + TimeAbiCode::LapicPeriodic + ); + let mut advanced = apic(TIMER_VECTOR | TIMER_PERIODIC, 100, 25); + assert_eq!( + advanced + .advance_one_shot_timer(1_000, 200_000_000) + .unwrap_err() + .code, + TimeAbiCode::LapicPeriodic + ); + // A periodic LVT with no initial count is not armed. + apic(TIMER_VECTOR | TIMER_PERIODIC, 0, 0) + .check_one_shot_timer() + .unwrap(); + + for lvt in [TIMER_TSC_DEADLINE, 3 << 17] { + assert_eq!( + apic(TIMER_VECTOR | lvt, 0, 0) + .check_one_shot_timer() + .unwrap_err() + .code, + TimeAbiCode::LapicTscDeadline + ); + } + } } diff --git a/vmm_core/vm_manifest_builder/src/lib.rs b/vmm_core/vm_manifest_builder/src/lib.rs index 86b815a3f0d..f420e14bfb3 100644 --- a/vmm_core/vm_manifest_builder/src/lib.rs +++ b/vmm_core/vm_manifest_builder/src/lib.rs @@ -736,7 +736,7 @@ impl VmChipsetResult { fn attach_pit(&mut self) -> &mut Self { self.chipset_devices.push(ChipsetDeviceHandle { name: PitDeviceHandle::ID.to_owned(), - resource: PitDeviceHandle.into_resource(), + resource: PitDeviceHandle::default().into_resource(), }); self.capabilities.with_pit = true; self From f12a8c14de2442670c03e9418bd8e9444f299794 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 07:57:58 -0700 Subject: [PATCH 188/304] openvmm: profile the time ABI guest resume and repair phases With OPENVMM_STARTUP_PROFILE set, a time ABI restore now emits two exclusive phases after the restored VPs are released: - restore/guest_resume, from the VP release to the guest's first selection of the restore packet (portb selector 0xa5); - restore/guest_repair, for a gated restore, from that selection to the guest's 0x605 acknowledgement. The portb device reports the first selection through a oneshot that the controller creates only when profiling is enabled. The worker's run loop waits for it together with the post-restore gate expiry, and an acknowledgement first handles a selection that the loop has not handled yet, so the phases keep the guest's order. The restore/guest_repair_gate milestone is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_core/src/worker/dispatch.rs | 12 +- .../src/worker/dispatch/microvm.rs | 5 + .../src/worker/dispatch/restore.rs | 169 +++++++++++++++++- openvmm/openvmm_defs/src/time_abi.rs | 4 + openvmm/openvmm_entry/src/microvm/config.rs | 36 ++-- openvmm/openvmm_entry/src/microvm/launch.rs | 6 + openvmm/openvmm_entry/src/microvm/mod.rs | 25 +++ openvmm/openvmm_entry/src/microvm/restore.rs | 5 +- .../openvmm_entry/src/snapshot_restore/mod.rs | 3 +- vm/devices/chipset/src/microvm.rs | 16 ++ vm/devices/chipset_resources/src/microvm.rs | 3 + 11 files changed, 248 insertions(+), 36 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index ac4d00b9d9b..6ae60fc3a4b 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -3964,7 +3964,7 @@ impl LoadedVm { VmRpc(Result), Halt(Result), SnapshotBoundary(microvm::SnapshotBoundaryEvent), - RestoreGateTimeout, + Restore(restore::RestoreEvent), } // Start a task to handle state unit inspections by filtering the worker @@ -3993,10 +3993,7 @@ impl LoadedVm { let b = worker_rpc.recv().map(Event::WorkerRpc); let c = self.inner.halt_recv.recv().map(Event::Halt); let d = self.snapshot_boundary.recv().map(Event::SnapshotBoundary); - let e = self - .snapshot_restore - .gate_expired(driver) - .map(|()| Event::RestoreGateTimeout); + let e = self.snapshot_restore.next_event(driver).map(Event::Restore); (a, b, c, d, e).race().await }; @@ -4449,10 +4446,13 @@ impl LoadedVm { break; } } - Event::RestoreGateTimeout => { + Event::Restore(restore::RestoreEvent::GateExpired) => { self.handle_restore_gate_timeout().await; break; } + Event::Restore(restore::RestoreEvent::PacketSelected) => { + self.snapshot_restore.restore_packet_selected(); + } } } diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 96a00b99bfa..f28fda39e84 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -219,6 +219,11 @@ impl LoadedVm { return true; }; + if self.snapshot_restore.input_gated { + // A boundary under the armed post-restore input gate is the + // guest's acknowledgement of the restore. + self.snapshot_restore.restore_acknowledged(); + } if !self.snapshot_restore.input_gated { let input_gate = openvmm_defs::profile::ProfileSpan::start(); if let Err(error) = self diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index fb9e4b47db4..b57f67fdbc9 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -12,6 +12,8 @@ use super::clock; use super::clock::RestoreTime; use crate::partition::HvlitePartition; use anyhow::Context; +use futures::FutureExt; +use futures_concurrency::future::Race; use hypervisor_resources::HypervisorKind; use hypervisor_resources::MshvHandle; use membacking::FileMappingMode; @@ -162,6 +164,23 @@ pub(super) struct SnapshotRestore { pub(super) gate_profile: Option, /// Whether host input is gated by the post-restore input gate. pub(super) input_gated: bool, + /// With profiling enabled, notified when the guest first selects the + /// time ABI restore packet. + packet_selected: Option>, + /// Profile span from the release of the restored VPs to the guest's + /// first selection of the restore packet. + resume_profile: Option, + /// Profile span from the guest's first selection of the restore packet to + /// its acknowledgement of a gated restore. + repair_profile: Option, +} + +/// A post-restore event handled by the VM worker's run loop. +pub(super) enum RestoreEvent { + /// The armed post-restore input gate expired. + GateExpired, + /// The guest first selected the time ABI restore packet. + PacketSelected, } impl SnapshotRestore { @@ -176,19 +195,70 @@ impl SnapshotRestore { select_instantiated_vps(vps, vp_capacity, self.vp_prefix, restoring) } - /// Returns a future that completes when the armed post-restore input gate - /// expires, and never completes while the gate is not armed. - pub(super) fn gate_expired(&self, driver: &impl Driver) -> impl Future { + /// Returns a future that completes at the next post-restore event: the + /// expiry of the armed input gate, or the guest's first selection of the + /// restore packet. It never completes while neither is pending. + pub(super) fn next_event( + &mut self, + driver: &impl Driver, + ) -> impl Future { let deadline = self.gate_deadline; + let packet_selected = &mut self.packet_selected; async move { - match deadline { - Some(deadline) => { - PolledTimer::new(driver).sleep_until(deadline).await; + let gate_expired = async { + match deadline { + Some(deadline) => { + PolledTimer::new(driver).sleep_until(deadline).await; + } + None => std::future::pending().await, } - None => std::future::pending().await, + RestoreEvent::GateExpired + }; + let selected = async { + if let Some(selected) = packet_selected.as_mut() { + let result = selected.await; + *packet_selected = None; + if result.is_ok() { + return RestoreEvent::PacketSelected; + } + } + std::future::pending().await + }; + (gate_expired, selected).race().await + } + } + + /// Ends the `restore/guest_resume` profile phase at the guest's first + /// selection of the restore packet, and starts the `restore/guest_repair` + /// phase of a gated restore. + pub(super) fn restore_packet_selected(&mut self) { + if let Some(resume) = self.resume_profile.take() { + resume.complete("restore", "guest_resume", Default::default()); + if self.gate_profile.is_some() { + self.repair_profile = Some(ProfileSpan::start()); } } } + + /// Ends the `restore/guest_repair` profile phase when the guest + /// acknowledges a gated restore. + pub(super) fn restore_acknowledged(&mut self) { + // The guest selects the packet before it acknowledges the restore, so + // handle a selection that the run loop has not handled yet first. + if let Some(result) = self + .packet_selected + .as_mut() + .and_then(|selected| selected.now_or_never()) + { + self.packet_selected = None; + if result.is_ok() { + self.restore_packet_selected(); + } + } + if let Some(repair) = self.repair_profile.take() { + repair.complete("restore", "guest_repair", Default::default()); + } + } } #[cfg(guest_arch = "x86_64")] @@ -267,7 +337,8 @@ impl LoadedVm { ) -> anyhow::Result<()> { saved_state_restore.complete("restore", "saved_state_restore", Default::default()); #[cfg(guest_arch = "x86_64")] - if let Some(input) = self.snapshot_restore.time_abi.take() { + if let Some(mut input) = self.snapshot_restore.time_abi.take() { + self.snapshot_restore.packet_selected = input.packet_selected.take(); let clock_restore = ProfileSpan::start(); self.time_abi_restore(input).await?; self.snapshot_restore.time_abi_release = true; @@ -345,6 +416,9 @@ impl LoadedVm { self.snapshot_restore.gate_deadline = Some(Instant::now().saturating_add(timeout)); self.snapshot_restore.gate_profile = Some(ProfileSpan::start()); } + if self.snapshot_restore.packet_selected.is_some() { + self.snapshot_restore.resume_profile = Some(ProfileSpan::start()); + } self.snapshot_restore.start_guard.take(); Ok(()) } @@ -433,4 +507,83 @@ mod tests { "backend returned 4 VP binders for topology capacity 8" ); } + + #[test] + fn restore_events_report_the_first_packet_selection() { + pal_async::DefaultPool::run_with(async |driver| { + let (send, recv) = mesh::oneshot(); + let mut restore = SnapshotRestore { + packet_selected: Some(recv), + ..Default::default() + }; + assert!(restore.next_event(&driver).now_or_never().is_none()); + send.send(()); + assert!(matches!( + restore.next_event(&driver).await, + RestoreEvent::PacketSelected + )); + assert!(restore.packet_selected.is_none()); + assert!(restore.next_event(&driver).now_or_never().is_none()); + }); + } + + #[test] + fn restore_events_ignore_a_dropped_packet_notifier() { + pal_async::DefaultPool::run_with(async |driver| { + let (send, recv) = mesh::oneshot::<()>(); + let mut restore = SnapshotRestore { + packet_selected: Some(recv), + ..Default::default() + }; + drop(send); + assert!(restore.next_event(&driver).now_or_never().is_none()); + assert!(restore.packet_selected.is_none()); + }); + } + + #[test] + fn restore_events_report_the_gate_expiry() { + pal_async::DefaultPool::run_with(async |driver| { + let mut restore = SnapshotRestore { + gate_deadline: Some(Instant::now()), + ..Default::default() + }; + assert!(matches!( + restore.next_event(&driver).await, + RestoreEvent::GateExpired + )); + }); + } + + #[test] + fn packet_selection_starts_the_repair_phase_of_a_gated_restore() { + for gated in [false, true] { + let mut restore = SnapshotRestore { + resume_profile: Some(ProfileSpan::start()), + gate_profile: gated.then(ProfileSpan::start), + ..Default::default() + }; + restore.restore_packet_selected(); + assert!(restore.resume_profile.is_none()); + assert_eq!(restore.repair_profile.is_some(), gated); + restore.restore_acknowledged(); + assert!(restore.repair_profile.is_none()); + } + } + + #[test] + fn acknowledgement_handles_an_unhandled_packet_selection_first() { + let (send, recv) = mesh::oneshot(); + let mut restore = SnapshotRestore { + packet_selected: Some(recv), + resume_profile: Some(ProfileSpan::start()), + gate_profile: Some(ProfileSpan::start()), + ..Default::default() + }; + send.send(()); + restore.restore_acknowledged(); + assert!(restore.packet_selected.is_none()); + assert!(restore.resume_profile.is_none()); + assert!(restore.repair_profile.is_none()); + } } diff --git a/openvmm/openvmm_defs/src/time_abi.rs b/openvmm/openvmm_defs/src/time_abi.rs index dc86b72aead..745f2c7e2a1 100644 --- a/openvmm/openvmm_defs/src/time_abi.rs +++ b/openvmm/openvmm_defs/src/time_abi.rs @@ -133,6 +133,10 @@ pub struct RestoreTimeInput { pub destination: HostIdentity, /// Receives the sealed time fields of the restore packet. pub restore_record: mesh::OneshotSender, + /// With profiling enabled, notified when the guest first selects the + /// restore packet; it divides the guest's resume from its repair in the + /// restore profile. + pub packet_selected: Option>, } /// Time ABI parameters of every microVM worker. diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 1cf87d2bd16..fb684df80a9 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -395,26 +395,22 @@ impl<'a> MicrovmConfigBuilder<'a> { let (drain, output_drain) = MicrovmOutputDrain::new(Some(output_completion)); self.resources.output_drain = Some(drain); let mut time_abi_restore = None; - let (generation_id, restore_entropy) = if opt.microvm.x_time_abi_v1 - && self.restore.time_abi_records().is_some() - { - let (generation_id, base) = self - .restore - .time_abi_restore_packet(opt.microvm.restore_processors)?; - let (send, recv) = mesh::oneshot(); - self.resources.restore_time_record = Some(send); - time_abi_restore = - Some(chipset_resources::microvm::MicrovmRestorePacketSource { base, time: recv }); - (generation_id, Vec::new()) - } else if opt.microvm.restore_entropy || self.restore.memory_target_requested { - fresh_microvm_restore_packet( - opt.microvm.restore_processors, - self.restore.memory_target_requested, - &self.restore.memory_ranges, - )? - } else { - (fresh_microvm_generation_id()?, Vec::new()) - }; + let (generation_id, restore_entropy) = + if opt.microvm.x_time_abi_v1 && self.restore.time_abi_records().is_some() { + let (generation_id, base) = self + .restore + .time_abi_restore_packet(opt.microvm.restore_processors)?; + time_abi_restore = Some(self.resources.time_abi_restore_packet(base)); + (generation_id, Vec::new()) + } else if opt.microvm.restore_entropy || self.restore.memory_target_requested { + fresh_microvm_restore_packet( + opt.microvm.restore_processors, + self.restore.memory_target_requested, + &self.restore.memory_ranges, + )? + } else { + (fresh_microvm_generation_id()?, Vec::new()) + }; let time_abi = if opt.microvm.x_time_abi_v1 { let hooks = time_abi_test_hooks(opt)?; // The time ABI forbids a periodic PIT channel 0 at capture and diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index c0d41fa4424..0746456f517 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -178,6 +178,12 @@ impl MicrovmLaunch { self.resources.restore_time_record.take() } + /// Takes the receiver notified when the guest first selects a time ABI + /// restore packet, for the restoring worker's profile. + pub(crate) fn take_restore_packet_selected(&mut self) -> Option> { + self.resources.restore_packet_selected.take() + } + /// Creates the channels that carry guest-requested snapshot boundaries from /// the VM worker to the VM controller. pub(crate) fn snapshot_channels( diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 1d282423f7e..e6d5ad76735 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -88,4 +88,29 @@ pub(crate) struct MicrovmResources { /// restoring worker takes it. pub(crate) restore_time_record: Option>, + /// Under the time ABI with profiling enabled, notified when the guest + /// first selects the restore packet; the restoring worker takes it. + pub(crate) restore_packet_selected: Option>, +} + +impl MicrovmResources { + /// Returns the portb source of a time ABI restore packet with `base`, and + /// keeps the restoring worker's ends of its channels. + fn time_abi_restore_packet( + &mut self, + base: chipset_resources::microvm_time::RestorePacketBase, + ) -> chipset_resources::microvm::MicrovmRestorePacketSource { + let (record, time) = mesh::oneshot(); + self.restore_time_record = Some(record); + let selected = openvmm_defs::profile::enabled().then(|| { + let (selected, recv) = mesh::oneshot(); + self.restore_packet_selected = Some(recv); + selected + }); + chipset_resources::microvm::MicrovmRestorePacketSource { + base, + time, + selected, + } + } } diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 2eb077b8be8..b8601800cb4 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -479,16 +479,19 @@ pub(crate) struct TimeAbiRestore { impl TimeAbiRestore { /// Returns the worker's restore input. The worker seals the time fields - /// of the restore packet through `restore_record`. + /// of the restore packet through `restore_record`, and `packet_selected`, + /// when present, reports the guest's first selection of the packet. pub(crate) fn into_input( self, restore_record: mesh::OneshotSender, + packet_selected: Option>, ) -> RestoreTimeInput { RestoreTimeInput { contract: self.contract, cpu_profile: self.cpu_profile, destination: self.destination, restore_record, + packet_selected, } } } diff --git a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs index c3563884632..2dc1345c37a 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs @@ -144,7 +144,8 @@ impl SnapshotRestore { let restore_record = microvm .take_restore_time_record() .context("time ABI restore is missing its restore packet")?; - self.worker.time = Some(time.into_input(restore_record)); + let packet_selected = microvm.take_restore_packet_selected(); + self.worker.time = Some(time.into_input(restore_record, packet_selected)); } None => {} } diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index 6038afa18b1..5584a6660b4 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -83,6 +83,8 @@ struct PortbTimeAbi { sample_delay: std::time::Duration, test_hooks: bool, restore: RestorePacketState, + /// Notified at the guest's first selection of the restore packet. + packet_selected: Option>, } impl PortbTimeAbi { @@ -212,11 +214,13 @@ impl MicrovmPortb { self.restore_processor_target_available = false; self.restore_memory_target_available = false; self.restore_memory_expansion_available = false; + let mut packet_selected = None; let restore = match restore { Some(source) => { self.restore_processor_target_available = source.base.online_vp_count != 0; self.restore_memory_target_available = source.base.memory_target; self.restore_memory_expansion_available = !source.base.ranges.is_empty(); + packet_selected = source.selected; RestorePacketState::Pending(source.base, source.time) } None => RestorePacketState::None, @@ -227,6 +231,7 @@ impl MicrovmPortb { sample_delay: std::time::Duration::from_micros(sample_delay_us.into()), test_hooks, restore, + packet_selected, }); self } @@ -243,6 +248,9 @@ impl MicrovmPortb { else { return; }; + if let Some(selected) = time_abi.packet_selected.take() { + selected.send(()); + } let packet = RestorePacketV4 { base, time, @@ -1628,10 +1636,12 @@ mod tests { test_hooks: true, }; let (send, recv) = mesh::oneshot(); + let (selected_send, mut selected) = mesh::oneshot(); let mut portb = time_abi_portb(Some( chipset_resources::microvm::MicrovmRestorePacketSource { base: base.clone(), time: recv, + selected: Some(selected_send), }, )); // Unsealed: only the targets are advertised. @@ -1649,6 +1659,10 @@ mod tests { | STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE ); + assert!( + (&mut selected).now_or_never().is_none(), + "selection is notified only at the first selection" + ); let before = now_ns(); assert!(matches!( @@ -1656,6 +1670,7 @@ mod tests { IoResult::Ok )); let after = now_ns(); + assert!(matches!(selected.now_or_never(), Some(Ok(())))); let bytes = read_record(&mut portb, DATA_PORT, base.encoded_len()); let packet = RestorePacketV4::decode(&bytes).unwrap(); assert_eq!((packet.base, packet.time), (base, time)); @@ -1687,6 +1702,7 @@ mod tests { entropy: [0; 64], }, time: recv, + selected: None, }, )); drop(send); diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs index 71fb7419b4e..c092d9a6f6c 100644 --- a/vm/devices/chipset_resources/src/microvm.rs +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -83,6 +83,9 @@ pub struct MicrovmRestorePacketSource { pub base: crate::microvm_time::RestorePacketBase, /// The time fields the worker seals before the first restored VP runs. pub time: mesh::OneshotReceiver, + /// Notified when the guest first selects the packet, which ends the + /// guest-resume phase of the restore profile. + pub selected: Option>, } impl ResourceId for MicrovmPortbHandle { From 416f23e26dff51bea4dbe6ac59d97769fab5c23d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 08:29:34 -0700 Subject: [PATCH 189/304] virt: document bounded re-sampling for both time ABI anchors Per the orchestrator's decision, a backend samples the capture anchor, and a restore anchor that pairs a TSC read with host time, a bounded number of times, keeps the tightest pair, and fails with E_TSC_ANCHOR when none is within MAX_ANCHOR_PAIRING_NS. The restore anchor is no longer only logged beyond the bound. The bound itself is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt/src/time_abi/backend.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index 2bf9831fb8b..5de28ec4fdf 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -96,9 +96,10 @@ pub struct BackendPreflight { pub sync: TscSyncMethod, } -/// The largest accepted capture-anchor pairing, in nanoseconds; a capture -/// anchor beyond it fails with `E_TSC_ANCHOR`. The restore anchor is only -/// logged beyond it. +/// The largest accepted anchor pairing, in nanoseconds. A backend samples the +/// capture anchor, and a restore anchor that pairs a TSC read with host time, +/// a bounded number of times and keeps the tightest pair; if none is within +/// this bound, the capture or restore fails with `E_TSC_ANCHOR`. pub const MAX_ANCHOR_PAIRING_NS: u64 = 10_000; /// The capture anchor. From c26e03b7e5fd52f0511d551b02e0f459f8f9dbe1 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 08:29:35 -0700 Subject: [PATCH 190/304] openvmm: drop saved per-VP TSC values in time ABI restores The time ABI's synchronized TSC set (restore step 12) supersedes every VP's saved TSC, yet restoring the saved VP state still wrote each VP's TSC through set_tsc first. KVM cannot tell such a write from a reset. A time ABI restore now asks the partition unit to omit saved TSC values: PartitionUnit::omit_saved_tsc makes the runner strip the tsc element from each VP's saved state (VpSavedState::clear_tsc) before the VPs restore it, so no backend applies a per-VP TSC write before the synchronized set. Other restores are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_core/src/worker/dispatch.rs | 2 +- .../src/worker/dispatch/restore.rs | 9 ++++-- vmm_core/src/partition_unit.rs | 24 +++++++++++---- vmm_core/src/partition_unit/snapshot.rs | 30 +++++++++++++++++++ vmm_core/virt/src/x86/vp.rs | 30 +++++++++++++++++++ 5 files changed, 86 insertions(+), 9 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 6ae60fc3a4b..fdb43cefc7d 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -3421,7 +3421,7 @@ impl InitializedVm { }; if let Some(saved_state) = saved_state { - let saved_state_restore = this.begin_snapshot_restore(&saved_state)?; + let saved_state_restore = this.begin_snapshot_restore(&saved_state).await?; this.restore(saved_state) .await .context("loadedvm restore failed")?; diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index b57f67fdbc9..8047b71372e 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -313,8 +313,9 @@ pub(super) fn validate_inventory( impl LoadedVm { /// Prepares to restore `saved_state` into the loaded VM: checks the saved /// time contract against the saved state and the destination clocks, and - /// starts the profile span of the restore. - pub(super) fn begin_snapshot_restore( + /// starts the profile span of the restore. A time ABI restore drops the + /// VPs' saved TSC values, which its synchronized TSC set supersedes. + pub(super) async fn begin_snapshot_restore( &mut self, #[cfg_attr(not(guest_arch = "x86_64"), expect(unused_variables))] saved_state: &SavedState, ) -> anyhow::Result { @@ -325,6 +326,10 @@ impl LoadedVm { clock::validate_snapshot_restore_partition_presence(saved_state, restore_time)?; self.validate_restore_clock(restore_time)?; + #[cfg(guest_arch = "x86_64")] + if self.snapshot_restore.time_abi.is_some() { + self.inner.partition_unit.omit_saved_tsc().await; + } Ok(ProfileSpan::start()) } diff --git a/vmm_core/src/partition_unit.rs b/vmm_core/src/partition_unit.rs index 28b4a1da2fa..6368cd39e32 100644 --- a/vmm_core/src/partition_unit.rs +++ b/vmm_core/src/partition_unit.rs @@ -91,6 +91,9 @@ struct PartitionUnitRunner { req_recv: Receiver, topology: ProcessorTopology, initial_regs: Option>, + /// Whether restores drop the VPs' saved TSC values (NVX time ABI). + #[cfg(guest_arch = "x86_64")] + omit_saved_tsc: bool, #[cfg(feature = "gdb")] debugger_state: debug::DebuggerState, @@ -233,6 +236,8 @@ impl PartitionUnit { req_recv, topology: params.processor_topology.clone(), initial_regs: None, + #[cfg(guest_arch = "x86_64")] + omit_saved_tsc: false, #[cfg(feature = "gdb")] debugger_state: debug::DebuggerState::new( params.vtl_guest_memory[0] @@ -682,12 +687,19 @@ mod save_restore { pub async fn restore(&mut self, state: state::Partition) -> Result<(), RestoreError> { let state::Partition { partition, vps } = state; self.partition.restore(partition)?; - self.vp_set - .restore( - vps.into_iter() - .map(|state::Vp { vp_index, data }| (VpIndex::new(vp_index), data)), - ) - .await?; + let vps = vps + .into_iter() + .map(|state::Vp { vp_index, data }| { + #[cfg(guest_arch = "x86_64")] + let data = if self.omit_saved_tsc { + super::snapshot::without_saved_tsc(data)? + } else { + data + }; + Ok((VpIndex::new(vp_index), data)) + }) + .collect::, RestoreError>>()?; + self.vp_set.restore(vps).await?; Ok(()) } } diff --git a/vmm_core/src/partition_unit/snapshot.rs b/vmm_core/src/partition_unit/snapshot.rs index cc8739330f8..301abcf4523 100644 --- a/vmm_core/src/partition_unit/snapshot.rs +++ b/vmm_core/src/partition_unit/snapshot.rs @@ -23,6 +23,18 @@ pub(super) enum SnapshotRequest { CheckTimers(FailableRpc<(), ()>), #[cfg(guest_arch = "x86_64")] AdvanceLapic(FailableRpc<(u64, u64), ()>), + #[cfg(guest_arch = "x86_64")] + OmitSavedTsc(mesh::rpc::Rpc<(), ()>), +} + +/// Returns `data`, a VP's saved state, without its saved TSC. +#[cfg(guest_arch = "x86_64")] +pub(super) fn without_saved_tsc( + data: vmcore::save_restore::SavedStateBlob, +) -> Result { + let mut state: virt::vp::VpSavedState = data.parse()?; + state.clear_tsc(); + Ok(vmcore::save_restore::SavedStateBlob::new(state)) } /// Returns the number of VPs to instantiate, validated against the topology's @@ -105,6 +117,20 @@ impl PartitionUnit { .await?; Ok(()) } + + /// Makes every later restore drop the VPs' saved TSC values, which the + /// NVX time ABI's synchronized TSC set supersedes, so no backend applies + /// a per-VP TSC write before it. + #[cfg(guest_arch = "x86_64")] + pub async fn omit_saved_tsc(&mut self) { + self.req_send + .call( + |rpc| PartitionRequest::Snapshot(SnapshotRequest::OmitSavedTsc(rpc)), + (), + ) + .await + .unwrap(); + } } impl PartitionUnitRunner { @@ -143,6 +169,10 @@ impl PartitionUnitRunner { }) .await } + #[cfg(guest_arch = "x86_64")] + SnapshotRequest::OmitSavedTsc(rpc) => rpc.handle_sync(|()| { + self.omit_saved_tsc = true; + }), } } } diff --git a/vmm_core/virt/src/x86/vp.rs b/vmm_core/virt/src/x86/vp.rs index 79968ba184f..f725b93bd95 100644 --- a/vmm_core/virt/src/x86/vp.rs +++ b/vmm_core/virt/src/x86/vp.rs @@ -2010,6 +2010,14 @@ state_trait! { (200, "nested_state", nested_state, set_nested_state, NestedState), } +impl VpSavedState { + /// Removes the saved TSC, so restoring this state leaves the VP's TSC to + /// the NVX time ABI's synchronized TSC set. + pub fn clear_tsc(&mut self) { + self.tsc = None; + } +} + /// Resets register state for an x86 INIT via the APIC. pub fn x86_init(access: &mut T, vp_info: &X86VpInfo) -> Result<(), T::Error> { // Reset core register and debug register state, but preserve a few bits of cr0. @@ -2040,3 +2048,25 @@ pub fn x86_init(access: &mut T, vp_info: &X86VpInfo) -> Result Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clear_tsc_keeps_the_other_elements() { + let mut state = VpSavedState { + tsc: Some(Tsc { value: 7 }), + tsc_aux: Some(TscAux { value: 3 }), + ..Default::default() + }; + state.clear_tsc(); + assert_eq!( + state, + VpSavedState { + tsc_aux: Some(TscAux { value: 3 }), + ..Default::default() + } + ); + } +} From d1a50858441a8856b520b6cf0f7786cd1ff77e94 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 05:39:11 -0700 Subject: [PATCH 191/304] virt_whp: implement the NVX time ABI backend Implement virt::time_abi::TimeAbiBackend for WHP partitions built with ProtoPartitionConfig::time_abi. The backend stays inert until the worker passes a configuration, and every legacy path is unchanged. A time ABI partition: - rejects the Hyper-V guest interface, isolation, nested virtualization, and the user-mode APIC, so WHP sets no synthetic processor features and reports no hypervisor CPUID leaf or synthetic MSR of its own; - sets both processor feature banks explicitly to everything WHP offers minus the TSC-deadline timer, TSC_ADJUST, and APERF/MPERF. WHP's default banks omit SPEC_CTRL, STIBP, SSBD, and PSFD where the host offers them, so they are never used; - exits the topology leaves and every time ABI CPUID leaf except the explicit zero leaves, which WHP answers with zeros natively, and applies the time ABI CPUID after every other source. The build checks that every programmed result exits and that no other result shadows the time ABI's; - routes unhandled MSRs to OpenVMM with the offloaded APIC and serves the identity range from TimeAbiMsrs ahead of every other MSR handler. IA32_TSC_ADJUST, IA32_TSC_DEADLINE, and the legacy L2-cache MSRs that the backend stubs for the PCAT BIOS raise #GP, as on MSHV; - never sets ProcessorClockFrequency, so the guest TSC runs at the host rate, and derives its capabilities with the hypervisor range masked, so hv1 and kvm_clock stay false. F_d is WHvCapabilityCodeProcessorClockFrequency and L is the partition's InterruptClockFrequency (200 MHz on every measured host). Preflight checks the exits, samples the hypervisor range natively, checks that the banks hide the timer features, checks the effective identity and time bits, probes time suspension, and compares the partition TSC rate with the host rate. The effective CPUID is VP 0's view of every programmed leaf (the topology, time ABI, and identity leaves) and of the maximum leaves, with guest CR4, XCR0, and XSS state cleared, computed once. The capture anchor pairs VP 0's TSC with the midpoint of the tightest of up to 64 bracketing host samples. The synchronized TSC set suspends partition time, writes the target to every VP, reads it back while frozen, and resumes partition time before any VP runs. The legacy restored-TSC path is refused on a time ABI partition. Ignored hardware tests exercise the configuration, the native zero leaves, time suspension, the frozen write, and the anchor bracket on WHP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vm/whp/src/abi/x64.rs | 2 +- vm/whp/src/lib.rs | 12 + vmm_core/virt_whp/src/lib.rs | 106 +- vmm_core/virt_whp/src/time_abi.rs | 1698 +++++++++++++++++++++++++++++ vmm_core/virt_whp/src/vp.rs | 6 + 5 files changed, 1813 insertions(+), 11 deletions(-) create mode 100644 vmm_core/virt_whp/src/time_abi.rs diff --git a/vm/whp/src/abi/x64.rs b/vm/whp/src/abi/x64.rs index d50797d3fcd..156adc01398 100644 --- a/vm/whp/src/abi/x64.rs +++ b/vm/whp/src/abi/x64.rs @@ -342,7 +342,7 @@ impl WHV_PROCESSOR_FEATURES { impl WHV_PROCESSOR_FEATURES1 { pub const ACountMCountSupport: Self = Self(1 << 0); - pub const Reserved1: Self = Self(1 << 1); + pub const TscInvariantSupport: Self = Self(1 << 1); pub const ClZeroSupport: Self = Self(1 << 2); pub const RdpruSupport: Self = Self(1 << 3); diff --git a/vm/whp/src/lib.rs b/vm/whp/src/lib.rs index ad2321c5209..1052d22c827 100644 --- a/vm/whp/src/lib.rs +++ b/vm/whp/src/lib.rs @@ -251,6 +251,10 @@ pub enum PartitionProperty<'a> { AllowDeviceAssignment(bool), DisableSmt(bool), ProcessorFeatures(ProcessorFeatures), + /// Processor features banks 0 and 1, always set through the banks + /// property. Unlike [`Self::ProcessorFeatures`], a zero bank 1 is set + /// explicitly instead of keeping WHP's default. + ProcessorFeaturesBanks(ProcessorFeatures), ProcessorClFlushSize(u8), #[cfg(target_arch = "x86_64")] CpuidExitList(&'a [u32]), @@ -444,6 +448,14 @@ impl Partition { set(partition_prop::ProcessorFeaturesBanks, &banks) } } + PartitionProperty::ProcessorFeaturesBanks(val) => { + banks = abi::WHV_PROCESSOR_FEATURES_BANKS { + BanksCount: 2, + Reserved0: 0, + Banks: [val.bank0.0, val.bank1.0], + }; + set(partition_prop::ProcessorFeaturesBanks, &banks) + } PartitionProperty::ProcessorClFlushSize(val) => { set(partition_prop::ProcessorClFlushSize, val) } diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 32c8421c14f..5f17bec22d3 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -21,6 +21,8 @@ mod memory; mod regs; mod synic; #[cfg(guest_arch = "x86_64")] +mod time_abi; +#[cfg(guest_arch = "x86_64")] mod tsc; mod vm_state; mod vp; @@ -136,6 +138,9 @@ struct WhpPartitionInner { #[cfg(guest_arch = "x86_64")] #[inspect(flatten)] clock: tsc::PartitionClock, + /// The NVX time ABI state, for a partition built with it. + #[cfg(guest_arch = "x86_64")] + time_abi: Option, vtl0_alias_map_offset: Option, monitor_page: MonitorPage, hvstate: Hv1State, @@ -168,6 +173,12 @@ struct VtlPartition { lapic: LocalApicKind, hypervisor_enlightened: bool, + + /// The NVX time ABI state this partition was configured with, until the + /// partition build moves it to [`WhpPartitionInner`]. + #[cfg(guest_arch = "x86_64")] + #[inspect(skip)] + time_abi: Option, } impl VtlPartition { @@ -640,6 +651,11 @@ impl virt::Partition for WhpPartition { #[cfg(guest_arch = "x86_64")] fn advance_snapshot_time(&self, _duration: std::time::Duration) -> Result<(), Self::Error> { + if self.inner.time_abi.is_some() { + // The time ABI's synchronized TSC set replaces the restored-TSC + // clock. + return Err(Error::NotWithTimeAbi("legacy snapshot time advance")); + } self.inner.advance_snapshot_time() } @@ -648,6 +664,11 @@ impl virt::Partition for WhpPartition { self.inner.apic_frequency_hz() } + #[cfg(guest_arch = "x86_64")] + fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { + self.inner.time_abi.as_ref().map(|_| &*self.inner as _) + } + fn supports_reset(&self) -> Option<&dyn virt::ResetPartition> { if whp::capabilities::reset_partition() { Some(self) @@ -886,6 +907,12 @@ pub enum Error { #[cfg(guest_arch = "x86_64")] #[error("timestamp intercept arrived without a restored TSC clock")] UnexpectedTimestampExit, + #[cfg(guest_arch = "x86_64")] + #[error(transparent)] + TimeAbi(#[from] virt::time_abi::TimeAbiError), + #[cfg(guest_arch = "x86_64")] + #[error("{0} is not available on a time ABI partition")] + NotWithTimeAbi(&'static str), } trait WhpResultExt { @@ -962,6 +989,16 @@ impl virt::Hypervisor for Whp { } } + #[cfg(guest_arch = "x86_64")] + if config.time_abi.is_some() { + time_abi::validate_partition( + isolation, + config.hv_config.is_some(), + nested_virt, + user_mode_apic, + )?; + } + let vtl0 = VtlPartition::new( &config, Vtl::Vtl0, @@ -1159,6 +1196,12 @@ impl WhpPartitionInner { #[cfg(guest_arch = "x86_64")] let tsc_frequency = vtl0.whp.tsc_frequency().for_op("get tsc frequency")?; + #[cfg(guest_arch = "x86_64")] + let (vtl0, time_abi) = { + let mut vtl0 = vtl0; + let time_abi = vtl0.time_abi.take(); + (vtl0, time_abi) + }; // FUTURE: register cpuid results with the hypervisor, and register // appropriate per-VP results where necessary (or tell the hypervisor @@ -1240,8 +1283,15 @@ impl WhpPartitionInner { ) .map_err(Error::TopologyCpuid)?; - let cpuid = tsc::add_frequency_leaves(cpuid, tsc_frequency, &vtl0)?; - virt::CpuidLeafSet::new(cpuid) + match &time_abi { + // The time ABI declares the TSC rate through its identity + // MSRs; leaf 0x15 stays zero. + Some(time_abi) => time_abi.partition_cpuid(cpuid)?, + None => { + let cpuid = tsc::add_frequency_leaves(cpuid, tsc_frequency, &vtl0)?; + virt::CpuidLeafSet::new(cpuid) + } + } }; let mut vtl0_alias_map_offset = None; @@ -1304,15 +1354,34 @@ impl WhpPartitionInner { #[cfg(guest_arch = "x86_64")] let caps = { - let mut caps = virt::x86::X86PartitionCapabilities::from_cpuid( - proto_config.processor_topology, - &mut |function, index| cpuid.result(function, index, &vtl0.cpuid(function, index)), - ) - .map_err(Error::Capabilities)?; + let mut lookup = + |function, index| cpuid.result(function, index, &vtl0.cpuid(function, index)); + let caps = if time_abi.is_some() { + // The time ABI identity must not make the partition look like + // an hv1 or KVM-clock guest, so its capabilities ignore the + // hypervisor range. + virt::x86::X86PartitionCapabilities::from_cpuid( + proto_config.processor_topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut lookup), + ) + } else { + virt::x86::X86PartitionCapabilities::from_cpuid( + proto_config.processor_topology, + &mut lookup, + ) + }; + let mut caps = caps.map_err(Error::Capabilities)?; caps.can_freeze_time = true; caps.xsaves_state_bv_broken = true; caps.dr6_tsx_broken = true; caps.nested_virt = nested_virt; + if time_abi.is_some() && (caps.hv1 || caps.kvm_clock) { + return Err(virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::IdentityRouting, + "the time ABI partition capabilities include hv1 or the KVM clock", + ) + .into()); + } caps }; let vendor = match whp::capabilities::processor_vendor().for_op("get processor vendor")? { @@ -1376,6 +1445,8 @@ impl WhpPartitionInner { cpuid_topology: cpu_contract::CpuidTopology::new(proto_config.processor_topology), #[cfg(guest_arch = "x86_64")] clock: tsc::PartitionClock::new(tsc_frequency), + #[cfg(guest_arch = "x86_64")] + time_abi, vtl0_alias_map_offset, monitor_page: MonitorPage::new(), hvstate, @@ -1535,9 +1606,22 @@ impl VtlPartition { .for_op("set processor count")?; #[cfg(guest_arch = "x86_64")] - if config.versioned_cpu_contract { - cpu_contract::configure_versioned_contract(&mut whp_config, &mut extended_exits)?; - } + let time_abi = match &config.time_abi { + Some(time_abi) => Some(time_abi::WhpTimeAbi::configure( + time_abi, + &mut whp_config, + &mut extended_exits, + )?), + None => { + if config.versioned_cpu_contract { + cpu_contract::configure_versioned_contract( + &mut whp_config, + &mut extended_exits, + )?; + } + None + } + }; #[cfg(guest_arch = "x86_64")] if nested_virt { @@ -1837,6 +1921,8 @@ impl VtlPartition { mapper, lapic, hypervisor_enlightened, + #[cfg(guest_arch = "x86_64")] + time_abi, }) } diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs new file mode 100644 index 00000000000..b7e737327dd --- /dev/null +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -0,0 +1,1698 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The NVX time ABI v1 on WHP. +//! +//! Active only for partitions built with +//! [`ProtoPartitionConfig::time_abi`](virt::ProtoPartitionConfig::time_abi). +//! It implements the WHP column of "Backend obligations" in NVX +//! `doc/design/time-abi.md`: +//! +//! - **Partition.** No Hyper-V guest interface (so no synthetic processor +//! features), the in-hypervisor (offloaded) APIC, no isolation, no VTL2, +//! and no nested virtualization. Without synthetic features WHP reports no +//! hypervisor CPUID leaf and serves no synthetic MSR of its own. +//! - **Processor features.** Both feature banks are set explicitly to every +//! feature WHP offers, without the TSC-deadline timer, `IA32_TSC_ADJUST`, +//! or APERF/MPERF. WHP's default banks omit speculation controls that the +//! host offers (SPEC_CTRL, STIBP, and SSBD on Skylake-SP, PSFD on Ice Lake), +//! so they are never used. The CPU profiles will supply the banks. +//! - **CPUID.** The topology leaves and every leaf of the time ABI CPUID exit +//! to OpenVMM, which answers from the partition's CPUID results; the time +//! ABI CPUID applies after every other source. The explicit zero leaves of +//! the identity range do not exit: WHP answers the whole hypervisor range +//! with zeros natively, which preflight samples. Each exit-list entry costs +//! about 25 µs of partition setup, and each native CPUID read about 18 µs. +//! - **Invariant TSC.** The time ABI CPUID sets bit 8 of CPUID `0x80000007` +//! EDX on every host. Azure's WHP offers no `TscInvariantSupport` to its L1 +//! partitions, so there the bit comes from the CPUID result alone; host +//! qualification measures the invariance. +//! - **Identity MSRs.** `UnhandledMsrs` exits deliver every access to an MSR +//! that WHP does not implement, which without synthetic features includes +//! the whole identity range, to [`TimeAbiMsrs`] before any other MSR +//! handling. With their feature bits clear, WHP does not implement +//! `IA32_TSC_ADJUST` and `IA32_TSC_DEADLINE` either; they raise #GP, and +//! so do the legacy L2-cache MSRs that the backend stubs for the PCAT BIOS. +//! - **Rates.** The guest TSC runs at the host rate: OpenVMM never sets the +//! `ProcessorClockFrequency` partition property. The offloaded APIC timer +//! runs at WHP's fixed 200 MHz (WHP cannot set `InterruptClockFrequency`). +//! - **Synchronized TSC set.** Partition time is suspended, the target is +//! written to every VP and read back while frozen, and partition time +//! resumes explicitly before any VP runs, so the guest TSC runs from the +//! restore anchor. Every VP exists from partition creation, so no VP can be +//! created after the set. +//! +//! These semantics were measured on Windows Server 2025 bare metal (Xeon Gold +//! 6138, Skylake-SP) and on Azure runners (Xeon Platinum 8370C and 8573C). + +use crate::WhpPartitionInner; +use crate::WhpProcessor; +use crate::WhpResultExt; +use inspect::Inspect; +use std::sync::Arc; +use std::sync::OnceLock; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; +use virt::IsolationType; +use virt::VpIndex; +use virt::time_abi::BackendPreflight; +use virt::time_abi::HostTimeSample; +use virt::time_abi::IdentityMsrRoute; +use virt::time_abi::MAX_ANCHOR_PAIRING_NS; +use virt::time_abi::TimeAbiBackend; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiConfig; +use virt::time_abi::TimeAbiError; +use virt::time_abi::TimeAbiMsrs; +use virt::time_abi::TscAnchor; +use virt::time_abi::TscSetReport; +use virt::time_abi::TscSyncMethod; +use virt::time_abi::host::sample_host_time; +use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; +use virt::time_abi::identity::IDENTITY_CPUID_RANGE; +use virt::time_abi::identity::IDENTITY_MAX_LEAF; +use whp::abi::WHV_EXTENDED_VM_EXITS; +use whp::abi::WHV_PROCESSOR_FEATURES1; +use whp::abi::WHV_X64_MSR_EXIT_BITMAP; +use x86defs::cpuid::CpuidFunction; + +/// `IA32_TSC_ADJUST`, which the time ABI hides. +const MSR_IA32_TSC_ADJUST: u32 = 0x3b; + +/// `IA32_TSC_DEADLINE`, which the time ABI hides. +const MSR_IA32_TSC_DEADLINE: u32 = x86defs::X86X_MSR_TSC_DEADLINE; + +/// The legacy P6 L2-cache MSRs that the backend stubs for Windows booting +/// from the PCAT BIOS (`MYSTERY_MSRS` in `vp.rs`). No CPU profile pins them +/// and a microVM never probes them, so they raise #GP on a time ABI +/// partition, as on MSHV. +const LEGACY_L2_CACHE_MSRS: [u32; 9] = [0x88, 0x89, 0x8a, 0x116, 0x118, 0x119, 0x11a, 0x11b, 0x11e]; + +/// Leaves whose results OpenVMM computes from the topology on every vendor +/// (see [`virt::x86::topology::topology_cpuid`]) and adjusts per VP. +const TOPOLOGY_CPUID_EXITS: [u32; 3] = [ + CpuidFunction::VersionAndFeatures.0, + CpuidFunction::ExtendedTopologyEnumeration.0, + CpuidFunction::V2ExtendedTopologyEnumeration.0, +]; + +/// Topology leaves OpenVMM computes only on Intel-compatible hosts. +const INTEL_TOPOLOGY_CPUID_EXITS: [u32; 1] = [CpuidFunction::CacheParameters.0]; + +/// Topology leaves OpenVMM computes only on AMD-compatible hosts. +const AMD_TOPOLOGY_CPUID_EXITS: [u32; 2] = [ + CpuidFunction::ExtendedAddressSpaceSizes.0, + CpuidFunction::ProcessorTopologyDefinition.0, +]; + +/// The feature-bank bits the time ABI clears: the TSC-deadline timer, +/// `IA32_TSC_ADJUST`, and APERF/MPERF. +const HIDDEN_FEATURES1: WHV_PROCESSOR_FEATURES1 = WHV_PROCESSOR_FEATURES1( + WHV_PROCESSOR_FEATURES1::TscDeadlineTmrSupport.0 + | WHV_PROCESSOR_FEATURES1::TscAdjustSupport.0 + | WHV_PROCESSOR_FEATURES1::ACountMCountSupport.0, +); + +/// Leaves of the hypervisor range that preflight reads natively, to check +/// that WHP reports nothing there: the first leaf past the identity, the +/// Hyper-V virtualization stack interface leaf (where `"VS#1"` would +/// appear), the last identity leaf, and three hypervisor signature bases. +/// The explicit zero leaves among them stand for the rest, which WHP answers +/// the same way, and the guest's boot check reads them all. +const PREFLIGHT_SENTINEL_LEAVES: [u32; 6] = [ + 0x4000_0006, + 0x4000_0081, + 0x4000_00ff, + 0x4000_0100, + 0x4000_0200, + 0x4000_ff00, +]; + +/// Leaves the effective CPUID reports besides the time ABI CPUID: the +/// maximum basic and extended leaves. +const EFFECTIVE_CPUID_MAX_LEAVES: [u32; 2] = [ + CpuidFunction::VendorAndMaxFunction.0, + CpuidFunction::ExtendedMaxFunction.0, +]; + +/// `OSXSAVE` in CPUID `0x1` ECX: guest CR4 state, not configuration. +const ECX1_OSXSAVE: u32 = 1 << 27; +/// `OSPKE` in CPUID `0x7.0` ECX: guest CR4 state, not configuration. +const ECX7_OSPKE: u32 = 1 << 4; + +/// How many bracketed reads of VP 0's TSC a capture anchor takes at most. A +/// nested register read takes 17 to 25 µs and only an occasional one pairs +/// within the bound, so the search tries as many as MSHV does; capture +/// latency is not on the restore path. +const ANCHOR_ATTEMPTS: usize = 64; + +/// A capture anchor paired this tightly ends the search early. A WHP +/// register read takes 9.5 to 11 µs on bare metal and 17 to 25 µs nested. +const ANCHOR_TARGET_PAIRING_NS: u64 = 6_000; + +/// Time ABI state of a WHP partition. +#[derive(Inspect)] +pub(crate) struct WhpTimeAbi { + /// The identity MSR handler, shared with the worker. + #[inspect( + rename = "tsc_invariant_control", + with = "|x| x.tsc_invariant_control()" + )] + msrs: Arc, + /// The time ABI CPUID. + #[inspect(skip)] + cpuid: Arc, + /// The CPUID exit list. + #[inspect(with = "|x| format!(\"{x:#x?}\")")] + cpuid_exits: Vec, + /// Processor feature bank 0. + #[inspect(hex)] + features_bank0: u64, + /// Processor feature bank 1. + #[inspect(hex)] + features_bank1: u64, + /// The effective CPUID record, computed once: it depends only on the + /// configuration, because it excludes guest state. + #[inspect(skip)] + effective: OnceLock>, +} + +impl WhpTimeAbi { + /// Programs a new WHP partition for the time ABI: the CPUID exits, the + /// processor feature banks, and the unhandled-MSR exits. The partition + /// must have passed [`validate_partition`]. + pub(crate) fn configure( + config: &TimeAbiConfig, + whp_config: &mut whp::PartitionConfig, + extended_exits: &mut WHV_EXTENDED_VM_EXITS, + ) -> Result { + let amd = match whp::capabilities::processor_vendor() { + Ok(whp::abi::WHvProcessorVendorIntel) => false, + Ok(whp::abi::WHvProcessorVendorAmd | whp::abi::WHvProcessorVendorHygon) => true, + Ok(_) => return Err(routing_error("the processor vendor is not x86")), + Err(err) => { + return Err(routing_error(format!( + "cannot query the processor vendor: {err}" + ))); + } + }; + + let available_exits = whp::capabilities::extended_vm_exits() + .map_err(|err| routing_error(format!("cannot query the extended VM exits: {err}")))?; + let exits = WHV_EXTENDED_VM_EXITS::X64CpuidExit | WHV_EXTENDED_VM_EXITS::X64MsrExit; + if available_exits.0 & exits.0 != exits.0 { + return Err(routing_error(format!( + "WHP lacks CPUID or MSR exits (extended VM exits {:#x})", + available_exits.0 + ))); + } + *extended_exits |= exits; + + let cpuid_exits = cpuid_exits(amd, &config.cpuid); + whp_config + .set_property(whp::PartitionProperty::CpuidExitList(&cpuid_exits)) + .map_err(|err| routing_error(format!("cannot set the CPUID exit list: {err}")))?; + + let available = whp::capabilities::processor_features().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot query the processor features: {err}"), + ) + })?; + let features = processor_features(available); + whp_config + .set_property(whp::PartitionProperty::ProcessorFeaturesBanks(features)) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "cannot set processor feature banks {:#x} and {:#x}: {err}", + features.bank0.0, features.bank1.0 + ), + ) + })?; + + let msr_exits = whp::capabilities::x64_msr_exit_bitmap() + .map_err(|err| routing_error(format!("cannot query the MSR exits: {err}")))?; + if !msr_exits.is_set(WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs) { + return Err(routing_error(format!( + "WHP lacks unhandled-MSR exits (MSR exit bitmap {:#x})", + msr_exits.0 + ))); + } + whp_config + .set_property(whp::PartitionProperty::X64MsrExitBitmap( + WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs, + )) + .map_err(|err| routing_error(format!("cannot enable unhandled-MSR exits: {err}")))?; + + tracing::info!( + cpuid_exits = cpuid_exits.len(), + available_bank0 = format_args!("{:#x}", available.bank0.0), + available_bank1 = format_args!("{:#x}", available.bank1.0), + bank0 = format_args!("{:#x}", features.bank0.0), + bank1 = format_args!("{:#x}", features.bank1.0), + tsc_invariant_feature = features + .bank1 + .is_set(WHV_PROCESSOR_FEATURES1::TscInvariantSupport), + "time ABI: WHP partition configured" + ); + + Ok(Self { + msrs: config.msrs.clone(), + cpuid: config.cpuid.clone(), + cpuid_exits, + features_bank0: features.bank0.0, + features_bank1: features.bank1.0, + effective: OnceLock::new(), + }) + } + + /// Returns the CPUID results of the partition (see [`partition_cpuid`]), + /// after checking that every result reaches the guest + /// ([`check_cpuid_delivery`]). + pub(crate) fn partition_cpuid( + &self, + own: Vec, + ) -> Result { + let cpuid = partition_cpuid(own, &self.cpuid)?; + check_cpuid_delivery(&cpuid, &self.cpuid_exits)?; + Ok(cpuid) + } +} + +fn routing_error(message: impl Into) -> TimeAbiError { + TimeAbiError::new(TimeAbiCode::IdentityRouting, message) +} + +/// Fails unless the partition can carry the time ABI: no isolation, no +/// Hyper-V guest interface (whose synthetic processor features would serve +/// the identity CPUID and MSRs natively, and which VTL2 requires), no nested +/// virtualization, and the offloaded APIC. +pub(crate) fn validate_partition( + isolation: IsolationType, + hv_configured: bool, + nested_virt: bool, + user_mode_apic: bool, +) -> Result<(), TimeAbiError> { + if isolation != IsolationType::None { + return Err(routing_error(format!( + "the WHP time ABI does not support {isolation:?} isolation" + ))); + } + if hv_configured { + return Err(routing_error( + "the WHP time ABI cannot be combined with the Hyper-V guest interface", + )); + } + if nested_virt { + return Err(routing_error( + "the WHP time ABI cannot be combined with nested virtualization", + )); + } + if user_mode_apic { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + "the WHP time ABI requires the offloaded APIC, not the user-mode APIC", + )); + } + Ok(()) +} + +/// Returns the CPUID exit list of a time ABI partition: the topology leaves +/// of the host's vendor and every leaf of the time ABI CPUID except the +/// explicit zero leaves, sorted and unique. +pub(crate) fn cpuid_exits(amd: bool, config: &CpuidLeafSet) -> Vec { + let mut exits = TOPOLOGY_CPUID_EXITS.to_vec(); + if amd { + exits.extend(AMD_TOPOLOGY_CPUID_EXITS); + } else { + exits.extend(INTEL_TOPOLOGY_CPUID_EXITS); + } + exits.extend( + config + .leaves() + .iter() + .filter(|leaf| !is_zero_fill(leaf)) + .map(|leaf| leaf.function), + ); + exits.sort_unstable(); + exits.dedup(); + exits +} + +/// Returns the processor features of a time ABI partition: `available` +/// without the TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. The +/// invariant TSC stays where WHP offers it. +pub(crate) fn processor_features(available: whp::ProcessorFeatures) -> whp::ProcessorFeatures { + let mut features = available; + features.bank1 = WHV_PROCESSOR_FEATURES1(features.bank1.0 & !HIDDEN_FEATURES1.0); + features +} + +/// Returns whether `leaf` is an explicit zero leaf past the identity, which +/// WHP answers with zeros natively when it does not exit. +fn is_zero_fill(leaf: &CpuidLeaf) -> bool { + leaf.function > IDENTITY_MAX_LEAF + && IDENTITY_CPUID_RANGE.contains(&leaf.function) + && leaf.index.is_none() + && leaf.result == [0; 4] + && leaf.mask == [!0; 4] +} + +/// Returns the CPUID results of a time ABI partition: `own`, the backend's +/// other results, without their hypervisor-range leaves, then `config`, so +/// that the time ABI CPUID overrides every other source. +/// +/// Fails with `E_IDENTITY_ROUTING` if another result would shadow part of +/// `config`: [`CpuidLeafSet::result`] applies only the first matching leaf, +/// so a subleaf-less leaf would hide a subleaf of `config`. +pub(crate) fn partition_cpuid( + own: Vec, + config: &CpuidLeafSet, +) -> Result { + let mut leaves: Vec = own + .into_iter() + .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) + .collect(); + leaves.extend(config.leaves().iter().copied()); + let cpuid = CpuidLeafSet::new(leaves); + for leaf in config.leaves() { + let index = leaf.index.unwrap_or(0); + for base in [[0; 4], [!0; 4]] { + let result = cpuid.result(leaf.function, index, &base); + let applied = result + .iter() + .zip(leaf.result) + .zip(leaf.mask) + .all(|((actual, expected), mask)| actual & mask == expected & mask); + if !applied { + return Err(routing_error(format!( + "another CPUID result shadows time ABI leaf {:#x}/{index:#x}", + leaf.function + ))); + } + } + } + Ok(cpuid) +} + +/// Checks that every leaf of `cpuid` reaches the guest: each leaf exits, +/// except the explicit zero leaves, which WHP answers natively. Fails with +/// `E_IDENTITY_ROUTING`, naming the first leaf that would not. +pub(crate) fn check_cpuid_delivery( + cpuid: &CpuidLeafSet, + exits: &[u32], +) -> Result<(), TimeAbiError> { + if let Some(leaf) = cpuid + .leaves() + .iter() + .find(|leaf| !is_zero_fill(leaf) && exits.binary_search(&leaf.function).is_err()) + { + return Err(routing_error(format!( + "CPUID leaf {:#x} has a programmed result but does not exit", + leaf.function + ))); + } + Ok(()) +} + +/// Clears the bits of a CPUID result that reflect guest state rather than +/// configuration: `OSXSAVE` and `OSPKE` follow the VP's CR4, and the XSAVE +/// area sizes in `0xD.0` and `0xD.1` EBX follow its XCR0 and XSS. +fn normalize_cpuid(function: u32, index: u32, mut result: [u32; 4]) -> [u32; 4] { + match CpuidFunction(function) { + CpuidFunction::VersionAndFeatures => result[2] &= !ECX1_OSXSAVE, + CpuidFunction::ExtendedFeatures if index == 0 => result[2] &= !ECX7_OSPKE, + CpuidFunction::ExtendedStateEnumeration if index <= 1 => result[1] = 0, + _ => {} + } + result +} + +/// Returns the effective CPUID record: the results VP 0 sees, from `vp0`, +/// for the maximum basic and extended leaves and for every leaf the +/// partition programs, `cpuid` (the topology, profile, and identity leaves), +/// with guest-state bits cleared. The explicit zero leaves are reported as +/// programmed; preflight samples them natively. +pub(crate) fn effective_cpuid( + cpuid: &CpuidLeafSet, + mut vp0: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + let mut leaves = Vec::with_capacity(EFFECTIVE_CPUID_MAX_LEAVES.len() + cpuid.leaves().len()); + for function in EFFECTIVE_CPUID_MAX_LEAVES { + leaves.push(CpuidLeaf::new(function, vp0(function, 0)?)); + } + for leaf in cpuid.leaves() { + let index = leaf.index.unwrap_or(0); + let result = if is_zero_fill(leaf) { + leaf.result + } else { + normalize_cpuid(leaf.function, index, vp0(leaf.function, index)?) + }; + let effective = CpuidLeaf::new(leaf.function, result); + leaves.push(match leaf.index { + Some(index) => effective.indexed(index), + None => effective, + }); + } + Ok(CpuidLeafSet::new(leaves).into_leaves()) +} + +/// Checks that WHP answers the [`PREFLIGHT_SENTINEL_LEAVES`] with zeros, +/// reading each with `native`. Fails with `E_IDENTITY_ROUTING`. +pub(crate) fn check_native_sentinels( + mut native: impl FnMut(u32) -> Result<[u32; 4], E>, +) -> Result<(), TimeAbiError> { + for function in PREFLIGHT_SENTINEL_LEAVES { + let result = native(function) + .map_err(|err| routing_error(format!("cannot read CPUID {function:#x}: {err}")))?; + if result != [0; 4] { + return Err(routing_error(format!( + "WHP reports CPUID {function:#x} as {result:#x?} instead of zeros" + ))); + } + } + Ok(()) +} + +/// Checks that the processor feature banks took effect: WHP's own CPUID +/// results, before the time ABI CPUID applies, must offer neither the +/// TSC-deadline timer, `IA32_TSC_ADJUST`, nor APERF/MPERF, or the guest +/// could use their MSRs. `native` reads WHP's result for a leaf and subleaf. +/// Fails with `E_PROFILE_UNSUPPORTED`. +pub(crate) fn check_feature_banks( + mut native: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result<(), TimeAbiError> { + const CHECKS: [(u32, usize, u32, &str); 3] = [ + (0x1, 2, 1 << 24, "the TSC-deadline timer"), + (0x6, 2, 1 << 0, "APERF/MPERF"), + (0x7, 1, 1 << 1, "IA32_TSC_ADJUST"), + ]; + for (function, register, bit, feature) in CHECKS { + let result = native(function, 0).map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot read CPUID {function:#x}: {err}"), + ) + })?; + if result[register] & bit != 0 { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("the processor feature banks still offer {feature}"), + )); + } + } + Ok(()) +} + +/// Checks that the guest TSC runs at the host rate: the partition's +/// `ProcessorClockFrequency` must equal WHP's capability. Fails with +/// `E_TSC_SCALING_ACTIVE`. +pub(crate) fn check_unscaled(partition_hz: u64, capability_hz: u64) -> Result<(), TimeAbiError> { + if partition_hz != capability_hz { + return Err(TimeAbiError::new( + TimeAbiCode::TscScalingActive, + format!( + "the partition TSC runs at {partition_hz} Hz, but the host TSC runs at {capability_hz} Hz" + ), + )); + } + Ok(()) +} + +/// An access to an MSR that exited to OpenVMM. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) enum MsrAccess { + Read, + Write(u64), +} + +/// How a time ABI MSR access completes. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) enum MsrOutcome { + /// The read returns this value. + Read(u64), + /// The write takes effect. + Written, + /// The access raises #GP. + Fault, +} + +/// Serves an MSR access that the time ABI owns: the identity range through +/// `msrs`, and the MSRs the time ABI hides, which raise #GP. Returns `None` +/// for every other MSR, which the backend handles as before. +pub(crate) fn serve_msr( + msrs: &TimeAbiMsrs, + vp: VpIndex, + msr: u32, + access: MsrAccess, +) -> Option { + if matches!(msr, MSR_IA32_TSC_ADJUST | MSR_IA32_TSC_DEADLINE) { + tracelimit::info_ratelimited!( + vp = vp.index(), + msr, + ?access, + "time ABI: hidden timer MSR access raises #GP" + ); + return Some(MsrOutcome::Fault); + } + if LEGACY_L2_CACHE_MSRS.contains(&msr) { + return Some(MsrOutcome::Fault); + } + let outcome = match access { + MsrAccess::Read => msrs.read(vp, msr)?.map(MsrOutcome::Read), + MsrAccess::Write(value) => msrs.write(vp, msr, value)?.map(|()| MsrOutcome::Written), + }; + Some(outcome.unwrap_or(MsrOutcome::Fault)) +} + +/// One bracketed read of VP 0's TSC: host samples taken immediately before +/// and after the read. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct AnchorCandidate { + tsc: u64, + before: HostTimeSample, + after: HostTimeSample, +} + +impl AnchorCandidate { + /// The host monotonic time the read took, in nanoseconds. + fn width_ns(&self) -> u64 { + self.after + .monotonic_ns + .saturating_sub(self.before.monotonic_ns) + } + + /// Pairs the TSC with the midpoint of the bracket, which differs from the + /// read instant by at most half the bracket. UTC is derived from the + /// first sample and the monotonic midpoint, so a UTC step inside the + /// bracket does not skew it. + fn anchor(&self) -> TscAnchor { + let half = self.width_ns() / 2; + TscAnchor { + tsc: self.tsc, + sample: HostTimeSample { + utc_ns: self.before.utc_ns + half, + monotonic_ns: self.before.monotonic_ns + half, + }, + pairing_ns: self.width_ns().div_ceil(2), + } + } +} + +/// Chooses the tightest candidate. Fails with `E_TSC_ANCHOR` if none pairs +/// within `bound_ns`. +fn select_anchor(candidates: &[AnchorCandidate], bound_ns: u64) -> Result { + let best = candidates + .iter() + .min_by_key(|candidate| candidate.width_ns()) + .ok_or_else(|| TimeAbiError::new(TimeAbiCode::TscAnchor, "no TSC read was attempted"))?; + let anchor = best.anchor(); + if anchor.pairing_ns > bound_ns { + return Err(TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!( + "the tightest of {} VP 0 TSC reads pairs within {} ns, above the {bound_ns} ns bound", + candidates.len(), + anchor.pairing_ns + ), + )); + } + Ok(anchor) +} + +/// Checks that every VP read back `target`. Fails with +/// `E_TSC_SYNC_READBACK`, naming the first VP that differs. +fn check_readback(target: u64, readback: &[(VpIndex, u64)]) -> Result<(), TimeAbiError> { + if let Some((vp, value)) = readback.iter().find(|(_, value)| *value != target) { + return Err(TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!( + "VP {} holds TSC {value:#x} instead of the synchronized {target:#x}", + vp.index() + ), + )); + } + Ok(()) +} + +/// WHP's own CPUID results for VP 0, read on demand and kept for the rest +/// of one check: each read costs about 18 µs. +struct NativeCpuid<'a> { + partition: &'a whp::Partition, + results: Vec<((u32, u32), [u32; 4])>, +} + +impl<'a> NativeCpuid<'a> { + fn new(partition: &'a whp::Partition) -> Self { + Self { + partition, + results: Vec::new(), + } + } + + fn get(&mut self, function: u32, index: u32) -> Result<[u32; 4], whp::WHvError> { + if let Some((_, result)) = self + .results + .iter() + .find(|(key, _)| *key == (function, index)) + { + return Ok(*result); + } + let output = self.partition.vp(0).get_cpuid_output(function, index)?; + let result = [output.Eax, output.Ebx, output.Ecx, output.Edx]; + self.results.push(((function, index), result)); + Ok(result) + } +} + +/// Returns the result VP 0 sees for a leaf: the partition's CPUID results +/// over WHP's own. Every leaf with a programmed result exits (see +/// [`check_cpuid_delivery`]), the exit handler applies the partition's +/// results over WHP's own, and VP 0 carries the BSP identity that the +/// topology results already hold. WHP's result is read only if a bit of it +/// shows through. +fn vp0_cpuid( + cpuid: &CpuidLeafSet, + native: &mut NativeCpuid<'_>, + function: u32, + index: u32, +) -> Result<[u32; 4], whp::WHvError> { + let programmed = cpuid + .leaves() + .iter() + .find(|leaf| leaf.matches(function, index)); + let base = match programmed { + Some(leaf) if leaf.mask == [!0; 4] => [0; 4], + _ => native.get(function, index)?, + }; + Ok(cpuid.result(function, index, &base)) +} + +impl WhpPartitionInner { + fn time_abi_state(&self) -> Result<&WhpTimeAbi, TimeAbiError> { + self.time_abi.as_ref().ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + "the partition was built without the time ABI", + ) + }) + } + + /// Returns the effective CPUID record, computing it on first use. + fn effective_record( + &self, + state: &WhpTimeAbi, + native: &mut NativeCpuid<'_>, + ) -> Result, TimeAbiError> { + if let Some(record) = state.effective.get() { + return Ok(record.clone()); + } + let record = effective_cpuid(&self.cpuid, |function, index| { + vp0_cpuid(&self.cpuid, native, function, index).map_err(|err| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!("cannot read CPUID {function:#x}/{index:#x} of VP 0: {err}"), + ) + }) + })?; + Ok(state.effective.get_or_init(|| record).clone()) + } + + fn time_abi_vps(&self) -> impl Iterator + '_ { + self.vps.iter().map(|vp| vp.vp_info.base.vp_index) + } +} + +impl TimeAbiBackend for WhpPartitionInner { + fn native_tsc_hz(&self) -> Result { + let hz = whp::capabilities::processor_clock_frequency().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!("cannot query WHvCapabilityCodeProcessorClockFrequency: {err}"), + ) + })?; + if hz == 0 { + return Err(TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + "WHvCapabilityCodeProcessorClockFrequency is zero", + )); + } + Ok(hz) + } + + fn lapic_hz(&self) -> Result { + let hz = self.vtl0.whp.apic_frequency().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + format!("cannot read the InterruptClockFrequency partition property: {err}"), + ) + })?; + if hz == 0 { + return Err(TimeAbiError::new( + TimeAbiCode::LapicRateUnavailable, + "the InterruptClockFrequency partition property is zero", + )); + } + Ok(hz) + } + + fn preflight(&self) -> Result { + let started = std::time::Instant::now(); + let state = self.time_abi_state()?; + + // Partition creation set up the exits, and the build checked that + // every programmed CPUID result exits. Check that the exits are in + // place and that WHP answers the leaves that do not exit with zeros. + let exits = + self.vtl0.whp.extended_vm_exits().map_err(|err| { + routing_error(format!("cannot read the extended VM exits: {err}")) + })?; + let required = WHV_EXTENDED_VM_EXITS::X64CpuidExit | WHV_EXTENDED_VM_EXITS::X64MsrExit; + let msr_exits = self + .vtl0 + .whp + .x64_msr_exit_bitmap() + .map_err(|err| routing_error(format!("cannot read the MSR exit bitmap: {err}")))?; + if exits.0 & required.0 != required.0 + || !msr_exits.is_set(WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs) + { + return Err(routing_error(format!( + "the partition lacks CPUID, MSR, or unhandled-MSR exits (extended VM exits {:#x}, MSR exits {:#x})", + exits.0, msr_exits.0 + ))); + } + let mut native = NativeCpuid::new(&self.vtl0.whp); + check_native_sentinels(|function| native.get(function, 0))?; + check_feature_banks(|function, index| native.get(function, index))?; + + // The guest-visible identity and time bits. + let effective = CpuidLeafSet::new(self.effective_record(state, &mut native)?); + let mut lookup = |function, index| effective.result(function, index, &[0; 4]); + virt::time_abi::identity::check_identity(&mut lookup, self.vps.len() as u32)?; + let invariant_tsc = + state + .cpuid + .result(CpuidFunction::ExtendedPowerManagement.0, 0, &[0; 4])[3] + & virt::time_abi::identity::INVARIANT_TSC_EDX + != 0; + virt::time_abi::identity::check_time_bits(&mut lookup, invariant_tsc)?; + + // The synchronized set suspends partition time. A partition whose + // VPs never ran keeps its time frozen until the first VP run, so the + // probe leaves the partition as it was. + self.vtl0.whp.suspend_time().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot suspend partition time: {err}"), + ) + })?; + + let partition_hz = self.vtl0.whp.tsc_frequency().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscRateUnavailable, + format!("cannot read the ProcessorClockFrequency partition property: {err}"), + ) + })?; + check_unscaled(partition_hz, self.native_tsc_hz()?)?; + + tracing::info!( + tsc_hz = partition_hz, + invariant_tsc, + tsc_invariant_feature = WHV_PROCESSOR_FEATURES1(state.features_bank1) + .is_set(WHV_PROCESSOR_FEATURES1::TscInvariantSupport), + cpuid_reads = native.results.len(), + elapsed_us = started.elapsed().as_micros() as u64, + "time ABI: WHP preflight" + ); + Ok(BackendPreflight { + msr_route: IdentityMsrRoute::ExitToVmm, + sync: TscSyncMethod::FrozenWrite, + }) + } + + fn effective_cpuid(&self) -> Result, TimeAbiError> { + let state = self.time_abi_state()?; + self.effective_record(state, &mut NativeCpuid::new(&self.vtl0.whp)) + } + + fn capture_anchor(&self) -> Result { + self.time_abi_state()?; + let bsp = self.vtl0.whp.vp(VpIndex::BSP.index()); + let mut candidates = Vec::with_capacity(ANCHOR_ATTEMPTS); + for _ in 0..ANCHOR_ATTEMPTS { + let before = sample_host_time()?; + let tsc = bsp.get_register(whp::Register64::Tsc).map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscAnchor, + format!("cannot read the TSC of VP 0: {err}"), + ) + })?; + let after = sample_host_time()?; + let candidate = AnchorCandidate { tsc, before, after }; + candidates.push(candidate); + if candidate.anchor().pairing_ns <= ANCHOR_TARGET_PAIRING_NS { + break; + } + } + let anchor = select_anchor(&candidates, MAX_ANCHOR_PAIRING_NS)?; + tracing::info!( + tsc = anchor.tsc, + pairing_ns = anchor.pairing_ns, + attempts = candidates.len(), + "time ABI: capture anchor" + ); + Ok(anchor) + } + + fn set_synchronized_tsc( + &self, + target: &mut dyn FnMut(&HostTimeSample) -> Result, + ) -> Result { + let started = std::time::Instant::now(); + self.time_abi_state()?; + let partition = &self.vtl0.whp; + + // While partition time is suspended, every VP's TSC holds its last + // value, so the frozen read-back is exact. Writes while time runs + // would skew the VPs by the 10 to 25 µs each register write takes. + partition.suspend_time().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot suspend partition time: {err}"), + ) + })?; + let suspended = started.elapsed(); + + let sample = sample_host_time()?; + let target = target(&sample)?; + + let failed = |vp: VpIndex, what: &str, err: whp::WHvError| { + TimeAbiError::new( + TimeAbiCode::TscSyncReadback, + format!("cannot {what} the TSC of VP {}: {err}", vp.index()), + ) + }; + for vp in self.time_abi_vps() { + partition + .vp(vp.index()) + .set_register(whp::Register64::Tsc, target) + .map_err(|err| failed(vp, "write", err))?; + } + let written = started.elapsed(); + let readback = self + .time_abi_vps() + .map(|vp| { + partition + .vp(vp.index()) + .get_register(whp::Register64::Tsc) + .map(|value| (vp, value)) + .map_err(|err| failed(vp, "read back", err)) + }) + .collect::, _>>()?; + let read = started.elapsed(); + let result = check_readback(target, &readback); + + // Resume before any VP runs, so the guest TSC runs from the restore + // anchor rather than from the first VP run. A failed set leaves time + // suspended: the restore is rejected and no VP runs. + if result.is_ok() { + partition.resume_time().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot resume partition time: {err}"), + ) + })?; + } + tracing::info!( + vps = readback.len(), + target, + readback_equal = result.is_ok(), + suspend_us = suspended.as_micros() as u64, + write_us = (written - suspended).as_micros() as u64, + readback_us = (read - written).as_micros() as u64, + total_us = started.elapsed().as_micros() as u64, + "time ABI: synchronized TSC set with partition time suspended" + ); + result?; + Ok(TscSetReport { + target, + sample, + readback, + method: TscSyncMethod::FrozenWrite, + }) + } +} + +impl WhpProcessor<'_> { + /// Serves an MSR exit that the time ABI owns (see [`serve_msr`]), ahead + /// of every other MSR handler. Returns `false` on a partition without the + /// time ABI and for every other MSR. + pub(crate) fn handle_time_abi_msr( + &self, + info: &whp::abi::WHV_X64_MSR_ACCESS_CONTEXT, + exit: whp::Exit<'_>, + ) -> Result { + let Some(time_abi) = &self.vp.partition.time_abi else { + return Ok(false); + }; + let access = if info.AccessInfo.IsWrite() { + MsrAccess::Write((info.Rax & 0xffff_ffff) | (info.Rdx << 32)) + } else { + MsrAccess::Read + }; + let Some(outcome) = serve_msr(&time_abi.msrs, self.vp.index, info.MsrNumber, access) else { + return Ok(false); + }; + tracing::trace!( + vp = self.vp.index.index(), + msr = info.MsrNumber, + ?access, + ?outcome, + "time ABI MSR access" + ); + // RDMSR and WRMSR are two bytes long. A faulting access does not + // retire, so RIP stays. + let rip = exit.vp_context.Rip.wrapping_add(2); + match outcome { + MsrOutcome::Read(value) => whp::set_registers!( + self.current_whp(), + [ + (whp::Register64::Rax, value & 0xffff_ffff), + (whp::Register64::Rdx, value >> 32), + (whp::Register64::Rip, rip), + ] + ) + .for_op("complete a time ABI MSR read")?, + MsrOutcome::Written => self + .current_whp() + .set_register(whp::Register64::Rip, rip) + .for_op("complete a time ABI MSR write")?, + MsrOutcome::Fault => { + let event = hvdef::HvX64PendingExceptionEvent::new() + .with_event_pending(true) + .with_event_type(hvdef::HV_X64_PENDING_EVENT_EXCEPTION) + .with_deliver_error_code(true) + .with_vector(x86defs::Exception::GENERAL_PROTECTION_FAULT.0.into()); + self.current_whp() + .set_register(whp::Register128::PendingEvent, event.into()) + .for_op("inject #GP for a time ABI MSR access")?; + } + } + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use virt::time_abi::DeclaredRates; + use virt::time_abi::identity::check_identity; + use virt::time_abi::identity::check_time_bits; + use virt::time_abi::identity::time_abi_cpuid; + use virt::time_abi::msr::MSR_APIC_FREQUENCY; + use virt::time_abi::msr::MSR_TSC_FREQUENCY; + use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; + use virt::time_abi::msr::MSR_VP_INDEX; + use vm_topology::processor::TopologyBuilder; + + const VP_COUNT: u32 = 8; + + /// WHP's own results on an Intel host without synthetic features: no + /// hypervisor leaves, and every time bit wrong. + fn whp_cpuid(function: u32, index: u32) -> [u32; 4] { + match (function, index) { + (0, _) => [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + (1, _) => [ + 0x0005_0654, + 0x0010_0800, + 0x7ffa_fbff | (1 << 24) | ECX1_OSXSAVE, + 0xbfeb_fbff, + ], + (6, _) => [0x77, 2, 9, 0], + (7, 0) => [0, 0xd19f_4fbb, ECX7_OSPKE, 0], + (0xa, _) => [0x0740_4f04, 0, 0, 0x603], + (0x15, _) => [2, 0xd4, 0x017d_7840, 0], + (0x16, _) => [0x0bb8, 0x0e74, 0x64, 0], + (0x8000_0000, _) => [0x8000_0008, 0, 0, 0], + (0x8000_0001, _) => [0, 0, 0x121, 0x2c10_0800], + _ => [0; 4], + } + } + + /// Leaves the backend contributes before the time ABI CPUID: the + /// topology in leaf 1 and a stale hypervisor leaf. + fn own_leaves() -> Vec { + vec![ + CpuidLeaf::new(1, [0, 0x0008_0000, 0, 0]).masked([0, 0x00ff_0000, 0, 0]), + CpuidLeaf::new(4, [0x1c00_4121, 0, 0, 0]).indexed(0), + CpuidLeaf::new(0x4000_0000, [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + CpuidLeaf::new(0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + ] + } + + fn key(leaf: &CpuidLeaf) -> (u32, Option, [u32; 4], [u32; 4]) { + (leaf.function, leaf.index, leaf.result, leaf.mask) + } + + fn declared_msrs() -> TimeAbiMsrs { + let msrs = TimeAbiMsrs::new(); + msrs.declare(DeclaredRates::new(2_194_843_000, 200_000_000).unwrap()) + .unwrap(); + msrs + } + + #[test] + fn partition_validation_rejects_unsupported_configurations() { + validate_partition(IsolationType::None, false, false, false).unwrap(); + for (isolation, hv, nested, user_mode_apic, code) in [ + ( + IsolationType::Vbs, + false, + false, + false, + TimeAbiCode::IdentityRouting, + ), + ( + IsolationType::None, + true, + false, + false, + TimeAbiCode::IdentityRouting, + ), + ( + IsolationType::None, + false, + true, + false, + TimeAbiCode::IdentityRouting, + ), + ( + IsolationType::None, + false, + false, + true, + TimeAbiCode::LapicRateUnavailable, + ), + ] { + assert_eq!( + validate_partition(isolation, hv, nested, user_mode_apic) + .unwrap_err() + .code, + code + ); + } + } + + #[test] + fn exit_list_covers_the_topology_and_the_time_abi_cpuid() { + let config = time_abi_cpuid(VP_COUNT, true); + let mut expected = vec![1, 4, 6, 7, 0xa, 0xb, 0x15, 0x16, 0x1f]; + expected.extend(0x4000_0000..=0x4000_0005); + expected.extend([0x8000_0001, 0x8000_0007]); + assert_eq!(cpuid_exits(false, &config), expected); + + let amd = cpuid_exits(true, &config); + assert!(!amd.contains(&4)); + assert!(amd.contains(&0x8000_0008) && amd.contains(&0x8000_001e)); + assert!(amd.windows(2).all(|pair| pair[0] < pair[1])); + // WHP answers the explicit zero leaves natively. + assert!( + !amd.iter() + .any(|leaf| (0x4000_0006..=0x4000_00ff).contains(leaf)) + ); + } + + #[test] + fn feature_banks_hide_exactly_the_time_bits() { + let mut available = whp::ProcessorFeatures::default(); + available.bank0 = whp::abi::WHV_PROCESSOR_FEATURES(0x1001_f9ff_e7f7_859f); + available.bank1 = WHV_PROCESSOR_FEATURES1(!0); + let features = processor_features(available); + assert_eq!(features.bank0, available.bank0); + assert_eq!(features.bank1.0, !((1 << 0) | (1 << 17) | (1 << 18))); + assert!( + features + .bank1 + .is_set(WHV_PROCESSOR_FEATURES1::TscInvariantSupport) + ); + // Azure's 8573C runners offer no invariant TSC; the banks add none. + available.bank1 = WHV_PROCESSOR_FEATURES1(0xc_0000_0051); + assert_eq!(processor_features(available).bank1.0, 0xc_0000_0050); + } + + #[test] + fn partition_cpuid_puts_the_time_abi_last() { + let config = time_abi_cpuid(VP_COUNT, true); + let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); + let mut effective = + |function, index| cpuid.result(function, index, &whp_cpuid(function, index)); + check_identity(&mut effective, VP_COUNT).unwrap(); + check_time_bits(&mut effective, true).unwrap(); + // The topology survives in the bits the time ABI does not own. + assert_eq!(effective(1, 0)[1] & 0x00ff_0000, 0x0008_0000); + assert_eq!(effective(4, 0)[0], 0x1c00_4121); + // No hypervisor leaf of another source remains. + assert_eq!(effective(0x4000_0100, 0), [0; 4]); + assert!( + cpuid + .leaves() + .iter() + .filter(|leaf| HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) + .all(|leaf| config.leaves().iter().any(|c| key(c) == key(leaf))) + ); + } + + #[test] + fn partition_cpuid_rejects_shadowed_subleaves() { + let mut own = own_leaves(); + // A subleaf-less leaf 7 would hide the time ABI's leaf 7.0. + own.push(CpuidLeaf::new(7, [0, 1 << 1, 0, 0]).masked([0, 1 << 1, 0, 0])); + assert_eq!( + partition_cpuid(own, &time_abi_cpuid(VP_COUNT, true)) + .unwrap_err() + .code, + TimeAbiCode::IdentityRouting + ); + } + + #[test] + fn every_programmed_leaf_must_exit() { + let config = time_abi_cpuid(VP_COUNT, true); + let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); + let exits = cpuid_exits(false, &config); + check_cpuid_delivery(&cpuid, &exits).unwrap(); + let without: Vec = exits + .iter() + .copied() + .filter(|&leaf| leaf != 0x8000_0007) + .collect(); + let error = check_cpuid_delivery(&cpuid, &without).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::IdentityRouting); + assert!(error.message.contains("0x80000007"), "{error}"); + } + + #[test] + fn effective_cpuid_reports_every_programmed_leaf() { + let config = time_abi_cpuid(VP_COUNT, true); + let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); + let vp0 = |function, index| { + Ok::<_, std::convert::Infallible>(cpuid.result( + function, + index, + &whp_cpuid(function, index), + )) + }; + let record = effective_cpuid(&cpuid, vp0).unwrap(); + let mut keys: Vec<(u32, Option)> = cpuid + .leaves() + .iter() + .map(|leaf| (leaf.function, leaf.index)) + .chain([(0, None), (0x8000_0000, None)]) + .collect(); + keys.sort(); + assert_eq!( + record + .iter() + .map(|leaf| (leaf.function, leaf.index)) + .collect::>(), + keys + ); + assert!(record.iter().all(|leaf| leaf.mask == [!0; 4])); + + let record = CpuidLeafSet::new(record); + let mut lookup = |function, index| record.result(function, index, &[0; 4]); + check_identity(&mut lookup, VP_COUNT).unwrap(); + check_time_bits(&mut lookup, true).unwrap(); + // Guest CR4 state does not reach the record. + assert_eq!(lookup(1, 0)[2] & ECX1_OSXSAVE, 0); + assert_eq!(lookup(7, 0)[2] & ECX7_OSPKE, 0); + assert_eq!(lookup(0, 0), whp_cpuid(0, 0)); + // The topology leaves are part of the record. + assert_eq!(lookup(4, 0)[0], 0x1c00_4121); + assert_eq!(lookup(1, 0)[1] & 0x00ff_0000, 0x0008_0000); + } + + #[test] + fn effective_cpuid_ignores_guest_cr4_state() { + let config = time_abi_cpuid(VP_COUNT, true); + let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); + let record = |cr4_bits: bool| { + effective_cpuid(&cpuid, |function, index| { + let mut result = cpuid.result(function, index, &whp_cpuid(function, index)); + match (function, cr4_bits) { + (1, false) => result[2] &= !ECX1_OSXSAVE, + (7, false) => result[2] &= !ECX7_OSPKE, + _ => {} + } + Ok::<_, std::convert::Infallible>(result) + }) + .unwrap() + .iter() + .map(key) + .collect::>() + }; + assert_eq!(record(true), record(false)); + } + + #[test] + fn normalization_clears_only_runtime_state() { + let all = [!0; 4]; + assert_eq!(normalize_cpuid(1, 0, all), [!0, !0, !ECX1_OSXSAVE, !0]); + assert_eq!(normalize_cpuid(7, 0, all), [!0, !0, !ECX7_OSPKE, !0]); + assert_eq!(normalize_cpuid(7, 1, all), all); + assert_eq!(normalize_cpuid(0xd, 0, all), [!0, 0, !0, !0]); + assert_eq!(normalize_cpuid(0xd, 1, all), [!0, 0, !0, !0]); + assert_eq!(normalize_cpuid(0xd, 2, all), all); + assert_eq!(normalize_cpuid(0x8000_0001, 0, all), all); + } + + #[test] + fn effective_cpuid_reports_read_failures() { + let config = time_abi_cpuid(VP_COUNT, true); + let error = effective_cpuid(&config, |function, _| { + if function == 0x8000_0007 { + Err(function) + } else { + Ok(whp_cpuid(function, 0)) + } + }) + .unwrap_err(); + assert_eq!(error, 0x8000_0007); + } + + #[test] + fn native_sentinels_must_read_zero() { + let mut read = Vec::new(); + check_native_sentinels(|function| { + read.push(function); + Ok::<_, String>(whp_cpuid(function, 0)) + }) + .unwrap(); + assert_eq!(read, PREFLIGHT_SENTINEL_LEAVES); + let config = time_abi_cpuid(VP_COUNT, true); + for leaf in [0x4000_0006, 0x4000_0081] { + assert!( + config + .leaves() + .iter() + .any(|c| c.function == leaf && is_zero_fill(c)) + ); + } + + for (leaf, result) in [ + (0x4000_0081, [0x3123_5356, 0, 0, 0]), + (0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + ] { + let error = check_native_sentinels(|function| { + Ok::<_, String>(if function == leaf { result } else { [0; 4] }) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::IdentityRouting); + } + assert_eq!( + check_native_sentinels(|_| Err("no VP")).unwrap_err().code, + TimeAbiCode::IdentityRouting + ); + } + + #[test] + fn feature_banks_must_hide_the_timer_msrs() { + assert_eq!( + check_feature_banks(|function, index| Ok::<_, String>(whp_cpuid(function, index))) + .unwrap_err() + .code, + TimeAbiCode::ProfileUnsupported + ); + let hidden = |function, index| { + let mut result = whp_cpuid(function, index); + match function { + 1 => result[2] &= !(1 << 24), + 6 => result[2] &= !1, + 7 => result[1] &= !(1 << 1), + _ => {} + } + Ok::<_, String>(result) + }; + check_feature_banks(hidden).unwrap(); + for (leaf, register, bit) in [(1, 2, 1 << 24), (6, 2, 1), (7, 1, 1 << 1)] { + let error = check_feature_banks(|function, index| { + let mut result = hidden(function, index)?; + if function == leaf { + result[register] |= bit; + } + Ok::<_, String>(result) + }) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnsupported, "{leaf:#x}"); + } + } + + #[test] + fn scaling_is_detected() { + check_unscaled(2_194_843_000, 2_194_843_000).unwrap(); + assert_eq!( + check_unscaled(1_000_000_000, 2_194_843_000) + .unwrap_err() + .code, + TimeAbiCode::TscScalingActive + ); + } + + #[test] + fn msr_exits_serve_the_identity_and_hide_the_timer_msrs() { + let msrs = declared_msrs(); + let read = |vp, msr| serve_msr(&msrs, VpIndex::new(vp), msr, MsrAccess::Read); + let write = |msr, value| serve_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Write(value)); + assert_eq!(read(5, MSR_VP_INDEX), Some(MsrOutcome::Read(5))); + assert_eq!( + read(0, MSR_TSC_FREQUENCY), + Some(MsrOutcome::Read(2_194_843_000)) + ); + assert_eq!( + read(1, MSR_APIC_FREQUENCY), + Some(MsrOutcome::Read(200_000_000)) + ); + assert_eq!( + read(2, MSR_TSC_INVARIANT_CONTROL), + Some(MsrOutcome::Read(0)) + ); + assert_eq!( + write(MSR_TSC_INVARIANT_CONTROL, 1), + Some(MsrOutcome::Written) + ); + assert_eq!( + read(7, MSR_TSC_INVARIANT_CONTROL), + Some(MsrOutcome::Read(1)) + ); + assert_eq!( + write(MSR_TSC_INVARIANT_CONTROL, 0), + Some(MsrOutcome::Written) + ); + assert_eq!(write(MSR_TSC_INVARIANT_CONTROL, 2), Some(MsrOutcome::Fault)); + for msr in [MSR_VP_INDEX, MSR_TSC_FREQUENCY, MSR_APIC_FREQUENCY] { + assert_eq!(write(msr, 0), Some(MsrOutcome::Fault), "{msr:#x}"); + } + // Undefined identity MSRs, such as the hypercall MSRs, fault. + for msr in [ + 0x4000_0000, + 0x4000_0001, + 0x4000_0020, + 0x4000_0073, + 0x4000_01ff, + ] { + assert_eq!(read(0, msr), Some(MsrOutcome::Fault), "{msr:#x}"); + assert_eq!(write(msr, 1), Some(MsrOutcome::Fault), "{msr:#x}"); + } + for msr in [MSR_IA32_TSC_ADJUST, MSR_IA32_TSC_DEADLINE] { + assert_eq!(read(0, msr), Some(MsrOutcome::Fault), "{msr:#x}"); + assert_eq!(write(msr, 0), Some(MsrOutcome::Fault), "{msr:#x}"); + } + // The legacy L2-cache MSRs that the backend otherwise stubs fault. + for msr in [0x88, 0x89, 0x8a, 0x116, 0x118, 0x119, 0x11a, 0x11b, 0x11e] { + assert_eq!(read(0, msr), Some(MsrOutcome::Fault), "{msr:#x}"); + assert_eq!(write(msr, 0), Some(MsrOutcome::Fault), "{msr:#x}"); + } + // Every other MSR keeps its existing handling. + for msr in [0x10, 0x1b, 0x3a, 0x6df, 0x6e1, 0x3fff_ffff, 0x4000_0200] { + assert_eq!(read(0, msr), None, "{msr:#x}"); + assert_eq!(write(msr, 0), None, "{msr:#x}"); + } + } + + #[test] + fn frequency_reads_fault_before_the_rates_are_declared() { + let msrs = TimeAbiMsrs::new(); + for msr in [MSR_TSC_FREQUENCY, MSR_APIC_FREQUENCY] { + assert_eq!( + serve_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Read), + Some(MsrOutcome::Fault) + ); + } + } + + fn sample(monotonic_ns: u64) -> HostTimeSample { + HostTimeSample { + utc_ns: 1_800_000_000_000_000_000 + monotonic_ns, + monotonic_ns, + } + } + + #[test] + fn anchors_pair_with_the_tightest_bracket_midpoint() { + let candidates = [ + AnchorCandidate { + tsc: 100, + before: sample(1_000), + after: sample(22_000), + }, + AnchorCandidate { + tsc: 200, + before: sample(50_000), + after: sample(59_001), + }, + AnchorCandidate { + tsc: 300, + before: sample(90_000), + after: sample(108_000), + }, + ]; + let anchor = select_anchor(&candidates, MAX_ANCHOR_PAIRING_NS).unwrap(); + assert_eq!(anchor.tsc, 200); + assert_eq!(anchor.sample, sample(54_500)); + assert_eq!(anchor.pairing_ns, 4_501); + + let error = select_anchor(&candidates[..1], 10_000).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscAnchor); + assert!(error.message.contains("10500 ns"), "{error}"); + assert_eq!( + select_anchor(&[], MAX_ANCHOR_PAIRING_NS).unwrap_err().code, + TimeAbiCode::TscAnchor + ); + } + + #[test] + fn readback_requires_every_vp() { + let readback: Vec<_> = (0..4).map(|vp| (VpIndex::new(vp), 0x1234)).collect(); + check_readback(0x1234, &readback).unwrap(); + let mut skewed = readback; + skewed[2].1 = 0x1235; + let error = check_readback(0x1234, &skewed).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::TscSyncReadback); + assert!(error.message.contains("VP 2"), "{error}"); + } + + #[test] + fn capabilities_exclude_hv1_and_the_kvm_clock() { + let topology = TopologyBuilder::new_x86().build(1).unwrap(); + let config = time_abi_cpuid(1, true); + let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); + let x2apic = topology.apic_mode() != vm_topology::processor::x86::ApicMode::XApic; + let mut lookup = |function, index| { + let mut result = cpuid.result(function, index, &whp_cpuid(function, index)); + if function == 1 { + // Match the topology's APIC mode and drop XSAVE, which the + // fake host does not describe. + result[2] &= !((1 << 21) | (1 << 26) | ECX1_OSXSAVE); + if x2apic { + result[2] |= 1 << 21; + } + } + result + }; + let caps = virt::x86::X86PartitionCapabilities::from_cpuid( + &topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut lookup), + ) + .unwrap(); + assert!(!caps.hv1); + assert!(!caps.kvm_clock); + assert!(!caps.tsc_deadline); + } +} + +/// Tests against WHP itself. Run them with `--ignored` on a host with the +/// Windows Hypervisor Platform. +#[cfg(test)] +mod whp_tests { + use super::*; + use virt::time_abi::identity::check_identity; + use virt::time_abi::identity::check_time_bits; + use virt::time_abi::identity::time_abi_cpuid; + use virt::time_abi::rate::LAPIC_HZ_HYPERV; + + /// Returns a time ABI partition with `vp_count` VPs, configured as + /// `VtlPartition::new` configures one, and its time ABI state. + fn partition(vp_count: u32) -> (whp::Partition, WhpTimeAbi) { + let config = TimeAbiConfig { + cpuid: Arc::new(time_abi_cpuid(vp_count, true)), + msrs: Arc::new(TimeAbiMsrs::new()), + }; + let mut whp_config = whp::PartitionConfig::new().unwrap(); + whp_config + .set_property(whp::PartitionProperty::ProcessorCount(vp_count)) + .unwrap(); + let mut exits = WHV_EXTENDED_VM_EXITS(0); + let time_abi = WhpTimeAbi::configure(&config, &mut whp_config, &mut exits).unwrap(); + whp_config + .set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeXApic, + )) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::ExtendedVmExits(exits)) + .unwrap(); + let partition = whp_config.create().unwrap(); + for vp in 0..vp_count { + partition.create_vp(vp).create().unwrap(); + } + (partition, time_abi) + } + + fn native( + partition: &whp::Partition, + function: u32, + index: u32, + ) -> Result<[u32; 4], whp::WHvError> { + let output = partition.vp(0).get_cpuid_output(function, index)?; + Ok([output.Eax, output.Ebx, output.Ecx, output.Edx]) + } + + fn tsc(partition: &whp::Partition, vp: u32) -> u64 { + partition.vp(vp).get_register(whp::Register64::Tsc).unwrap() + } + + #[test] + #[ignore = "requires WHP"] + fn partition_meets_the_static_obligations() { + let (partition, time_abi) = partition(4); + let started = std::time::Instant::now(); + check_native_sentinels(|function| native(&partition, function, 0)).unwrap(); + check_feature_banks(|function, index| native(&partition, function, index)).unwrap(); + println!("native checks: {} us", started.elapsed().as_micros()); + // WHP answers the whole identity range past the exits, and every + // hypervisor signature base, with zeros. + let bases = (0x4000_0100..=0x4000_ff00).step_by(0x100); + for function in (IDENTITY_MAX_LEAF + 1..=0x4000_00ff).chain(bases) { + assert_eq!( + native(&partition, function, 0).unwrap(), + [0; 4], + "{function:#x}" + ); + } + check_unscaled( + partition.tsc_frequency().unwrap(), + whp::capabilities::processor_clock_frequency().unwrap(), + ) + .unwrap(); + assert_eq!(partition.apic_frequency().unwrap(), LAPIC_HZ_HYPERV); + let exits = partition.extended_vm_exits().unwrap(); + assert!(exits.is_set(WHV_EXTENDED_VM_EXITS::X64CpuidExit)); + assert!(exits.is_set(WHV_EXTENDED_VM_EXITS::X64MsrExit)); + assert!( + partition + .x64_msr_exit_bitmap() + .unwrap() + .is_set(WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs) + ); + + let cpuid = time_abi.partition_cpuid(Vec::new()).unwrap(); + let record = effective_cpuid(&cpuid, |function, index| { + native(&partition, function, index).map(|result| cpuid.result(function, index, &result)) + }) + .unwrap(); + let record = CpuidLeafSet::new(record); + let mut lookup = |function, index| record.result(function, index, &[0; 4]); + check_identity(&mut lookup, 4).unwrap(); + check_time_bits(&mut lookup, true).unwrap(); + println!( + "tsc_hz {} bank1 {:#x} native 0x80000007 {:#x?}", + partition.tsc_frequency().unwrap(), + time_abi.features_bank1, + native(&partition, 0x8000_0007, 0).unwrap() + ); + } + + #[test] + #[ignore = "requires WHP"] + fn time_suspension_is_idempotent() { + // The preflight probe and the synchronized set both suspend time, and + // a fresh partition's time is frozen until a VP first runs. + let (partition, _) = partition(2); + partition.suspend_time().unwrap(); + partition.suspend_time().unwrap(); + let frozen = tsc(&partition, 0); + std::thread::sleep(std::time::Duration::from_millis(20)); + assert_eq!(tsc(&partition, 0), frozen); + partition.resume_time().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(20)); + assert!(tsc(&partition, 0) > frozen); + println!("second resume: {:?}", partition.resume_time()); + } + + #[test] + #[ignore = "requires WHP"] + fn synchronized_set_is_exact_while_suspended() { + for vp_count in [1, 2, 4, 8] { + let (partition, _) = partition(vp_count); + let hz = partition.tsc_frequency().unwrap(); + partition.resume_time().unwrap(); + for vp in 0..vp_count { + partition + .vp(vp) + .set_register(whp::Register64::Tsc, 1_000_000 * u64::from(vp + 1)) + .unwrap(); + } + + let started = std::time::Instant::now(); + partition.suspend_time().unwrap(); + let target = 0x1234_5678_9abc; + for vp in 0..vp_count { + partition + .vp(vp) + .set_register(whp::Register64::Tsc, target) + .unwrap(); + } + let readback: Vec<_> = (0..vp_count) + .map(|vp| (VpIndex::new(vp), tsc(&partition, vp))) + .collect(); + check_readback(target, &readback).unwrap(); + let elapsed = started.elapsed(); + std::thread::sleep(std::time::Duration::from_millis(10)); + for vp in 0..vp_count { + assert_eq!(tsc(&partition, vp), target, "VP {vp} moved while suspended"); + } + partition.resume_time().unwrap(); + std::thread::sleep(std::time::Duration::from_millis(20)); + // Time runs again without any VP run, from the target. + let advanced = tsc(&partition, 0) - target; + assert!(advanced >= hz / 100 && advanced < hz, "advanced {advanced}"); + println!( + "{vp_count} VPs: suspend, write, and read back in {} us", + elapsed.as_micros() + ); + } + } + + #[test] + #[ignore = "requires WHP"] + fn capture_anchor_pairs_within_the_bound() { + let (partition, _) = partition(1); + partition.resume_time().unwrap(); + let bsp = partition.vp(0); + let candidates: Vec<_> = (0..ANCHOR_ATTEMPTS) + .map(|_| { + let before = sample_host_time().unwrap(); + let tsc = bsp.get_register(whp::Register64::Tsc).unwrap(); + let after = sample_host_time().unwrap(); + AnchorCandidate { tsc, before, after } + }) + .collect(); + let mut widths: Vec = candidates.iter().map(|c| c.width_ns()).collect(); + widths.sort_unstable(); + println!("bracket widths (ns): {widths:?}"); + let anchor = select_anchor(&candidates, MAX_ANCHOR_PAIRING_NS).unwrap(); + println!("pairing {} ns", anchor.pairing_ns); + } +} diff --git a/vmm_core/virt_whp/src/vp.rs b/vmm_core/virt_whp/src/vp.rs index 2698fd5f9aa..7ecd59881c7 100644 --- a/vmm_core/virt_whp/src/vp.rs +++ b/vmm_core/virt_whp/src/vp.rs @@ -1238,6 +1238,12 @@ mod x86 { info: &whp::abi::WHV_X64_MSR_ACCESS_CONTEXT, exit: whp::Exit<'_>, ) -> Result<(), VpHaltReason> { + if self + .handle_time_abi_msr(info, exit) + .map_err(|error| dev.fatal_error(error.into()))? + { + return Ok(()); + } if self.handle_restored_tsc_msr_exit(dev, info, exit)? { return Ok(()); } From 91a556e3621032c43659130e2fc17a53aad2dc1b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 09:10:02 -0700 Subject: [PATCH 192/304] virt_kvm: document that time ABI restores write no saved TSC Core now omits every VP's saved TSC in a time ABI restore (ea570db7e), so no host-initiated IA32_TSC write reaches KVM's TSC synchronization heuristic before the synchronized set: each vCPU keeps its creation-time offset until the set. Update the module documentation and the comment on the pre-set offsets the synchronized set logs. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index d977c1b7887..d11151fe88e 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -38,18 +38,18 @@ //! - **LAPIC.** The in-kernel LAPIC timer counts KVM's fixed 1 GHz bus clock; //! `KVM_CAP_X86_APIC_BUS_CYCLES_NS` is never set. //! -//! **Restore and the saved per-VP TSC.** VP state restore still writes each -//! VP's saved `IA32_TSC`. KVM passes host-initiated `IA32_TSC` writes through -//! its TSC synchronization heuristic: a value within about one second of the -//! TSC that KVM expects is treated as a synchronization attempt, and KVM -//! keeps its current offset instead. Linux 6.6 applies this to the first -//! write after vCPU creation as well, so it discards the saved TSC of any -//! snapshot taken within about a second of the guest's boot; Linux 6.7 and -//! later honor the first user-space write. The synchronized set overwrites -//! every vCPU's offset before any VP runs, so what KVM made of the saved -//! values never reaches the guest, on either kernel. KVM computes the LAPIC -//! timer's TSC deadline when the LAPIC state is set, so the restore -//! orchestrator re-applies the LAPIC state after the synchronized set. +//! **Restore and the saved per-VP TSC.** A time ABI restore omits every VP's +//! saved TSC (core strips the `tsc` element before VP state restore), so no +//! host-initiated `IA32_TSC` write reaches KVM's TSC synchronization +//! heuristic. That heuristic treats a write within about a second of the TSC +//! KVM expects as a synchronization attempt and keeps its own offset; Linux +//! 6.6 applies it to the first write after vCPU creation too, which would +//! discard the saved TSC of a snapshot taken within about a second of the +//! guest's boot. Each vCPU instead keeps its creation-time offset until the +//! synchronized set writes the common offset, before any VP runs, on every +//! kernel. KVM computes the LAPIC timer's TSC deadline when the LAPIC state is +//! set, so the restore orchestrator re-applies the LAPIC state after the +//! synchronized set. use super::KvmPartitionInner; use crate::KvmError; @@ -544,8 +544,8 @@ impl TimeAbiBackend for KvmPartitionInner { let readback_error = |message: String| TimeAbiError::new(TimeAbiCode::TscSyncReadback, message); let tsc_hz = self.native_tsc_hz()?; - // The offsets the restore left, logged to show what KVM made of the - // saved IA32_TSC values (see the module documentation). + // The offsets before the set, logged for diagnosis: each vCPU's + // creation-time offset, since a time ABI restore writes no saved TSC. let previous = self .time_abi_vps() .map(|(vp_index, vp)| { From 84bd27ba8dd524650fa552c006156f4b87085c2c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 09:14:06 -0700 Subject: [PATCH 193/304] virt_mshv: resume partition time right after the TSC read-back The time ABI synchronized TSC set froze partition time, wrote the target to every created VP, read it back, and left time frozen until OpenVMM's first VP run. The guest TSC then held the target through the rest of the restore (LAPIC advance, VM time, packet seal, state unit start), so every MSHV restore lost about 1 ms of guest time: CLOCK_MONOTONIC lagged the host by 0.9 to 1.6 ms, and the guest's restore repair stepped CLOCK_REALTIME forward by about 1.1 ms, against KVM's 0.03 to 0.44 ms. Resume partition time as soon as the read-back matches, so guest time runs from the restore anchor as it does on KVM (running TSC) and WHP (explicit resume). A failed set leaves time frozen for teardown. The set's log line gains a `thawed` field. The hardware test now checks that time runs right after the set with every VP advancing together, that a failed set leaves time frozen, and that a later thaw resumes every VP together. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 57 +++++++++++++++++------ 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 82b1ea5be29..5e92f83ae94 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -22,8 +22,9 @@ //! - The processor feature banks keep the invariant TSC and hide the //! TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. //! - The synchronized TSC set freezes partition time, writes the target to -//! every created VP, and reads it back. Partition time thaws when a VP first -//! runs, so the guest resumes exactly at the target. +//! every created VP, reads it back, and resumes partition time right after a +//! good read-back. Guest time therefore runs from the restore anchor, as on +//! the other backends, instead of waiting for the first VP run. //! //! These semantics were verified on hypervisor builds 10.0.26100.30000 (bare //! metal) and 10.0.26100.9444 (Azure nested), where they are identical. @@ -762,8 +763,8 @@ impl TimeAbiBackend for MshvPartition { )); } - // Partition time stays frozen until a VP first runs, so every VP - // holds the target from the anchor until the guest resumes. + // Freeze partition time so that every VP holds the target from the + // anchor until the read-back, then resume it right away. self.inner.freeze_time().map_err(|error| { TimeAbiError::new( TimeAbiCode::TscSyncUnsupported, @@ -799,11 +800,23 @@ impl TimeAbiBackend for MshvPartition { }) .collect::, _>>()?; let result = check_readback(target, &readback); + // A failed set leaves time frozen; the restore fails and tears the + // partition down. + let thawed = result.is_ok(); + if thawed { + self.inner.thaw_time().map_err(|error| { + TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + format!("cannot resume partition time: {}", error_chain(&error)), + ) + })?; + } tracing::info!( created_vps = created.len(), vp_capacity = self.inner.vps.len(), target, readback_equal = result.is_ok(), + thawed, freeze_us = frozen.as_micros() as u64, write_us = (written - frozen).as_micros() as u64, total_us = started.elapsed().as_micros() as u64, @@ -1399,14 +1412,19 @@ mod hw { let expected: Vec<_> = (0..created).map(|vp| (VpIndex::new(vp), target)).collect(); assert_eq!(report.readback, expected); - // Time stays frozen until a VP runs. + // Time resumes right after the read-back, and every VP advances + // from the target together. + assert!(!*partition.inner.time_frozen.lock()); std::thread::sleep(std::time::Duration::from_millis(10)); - for vp in 0..created { - assert_eq!( - get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp)).unwrap(), - target - ); - } + let running: Vec = (0..created) + .map(|vp| get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp)).unwrap()) + .collect(); + assert!( + running.iter().all(|&tsc| tsc > target + tsc_hz / 200), + "{running:?}" + ); + let spread = running.iter().max().unwrap() - running.iter().min().unwrap(); + assert!(spread < tsc_hz / 1000, "{running:?}"); if created < VP_CAPACITY { let error = binders[created as usize].bind().err().unwrap(); assert!( @@ -1415,21 +1433,32 @@ mod hw { ); } - // A failing target aborts the set. + // A failing target aborts the set and leaves time frozen. let error = backend .set_synchronized_tsc(&mut |_| { Err(TimeAbiError::new(TimeAbiCode::DowntimeNegative, "test")) }) .unwrap_err(); assert_eq!(error.code, TimeAbiCode::DowntimeNegative); + assert!(*partition.inner.time_frozen.lock()); + let frozen_at: Vec = (0..created) + .map(|vp| get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp)).unwrap()) + .collect(); + std::thread::sleep(std::time::Duration::from_millis(10)); + for (vp, &tsc) in frozen_at.iter().enumerate() { + assert_eq!( + get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp as u32)).unwrap(), + tsc + ); + } - // After the thaw, every VP advances from the target together. + // A later thaw (the first VP run) resumes every VP together. partition.inner.thaw_time().unwrap(); std::thread::sleep(std::time::Duration::from_millis(10)); let after: Vec = (0..created) .map(|vp| get_vp_tsc(&partition.inner.vmfd, VpIndex::new(vp)).unwrap()) .collect(); - assert!(after.iter().all(|&tsc| tsc > target + tsc_hz / 1000)); + assert!(after.iter().all(|&tsc| tsc > running[0] + tsc_hz / 200)); println!( "time ABI backend: created {created}/{VP_CAPACITY} tsc_hz={tsc_hz} \ From 0d97e8714691364587d882da6e3c07006999ce9e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 09:30:08 -0700 Subject: [PATCH 194/304] openvmm_helpers: test a time ABI platform snapshot without clock tokens The MSHV spike found that platform-tier snapshots required a tsc_early_khz token. Under the time ABI the validation already skips that requirement, and the token is rejected (E_CMDLINE_CLOCK_TOKEN). Pin both with a version 6 platform-tier manifest: it validates without the token and fails with it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openvmm_helpers/src/snapshot/microvm.rs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index d7da399512c..5d272bd8ddb 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -3541,6 +3541,36 @@ mod tests { validate_manifest_version(&manifest).unwrap(); } + #[test] + fn platform_time_abi_snapshot_carries_no_clock_token() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + make_platform_snapshot(&mut manifest); + crate::snapshot::time::set_time_abi_records( + &mut manifest, + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .unwrap(); + let contract = manifest.machine_contract.as_mut().unwrap(); + contract.set_effective_command_line( + contract + .effective_command_line + .replace(" tsc_early_khz=1000000", ""), + ); + validate_manifest_version(&manifest).unwrap(); + + let contract = manifest.machine_contract.as_mut().unwrap(); + contract.set_effective_command_line(format!( + "{} tsc_early_khz=2100000", + contract.effective_command_line + )); + let err = validate_manifest_version(&manifest) + .unwrap_err() + .to_string(); + assert!(err.contains("[E_CMDLINE_CLOCK_TOKEN]"), "{err}"); + } + #[test] fn platform_snapshot_rejects_invalid_control_tokens() { let scratch = vec![0x5a; 512]; From 1c3b58233ae835243972be36171857e69c4b170f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 09:40:03 -0700 Subject: [PATCH 195/304] virt: raise the time ABI anchor bound to 100 us Apply the orchestrator's decision: MAX_ANCHOR_PAIRING_NS becomes 100 us, with bounded re-sampling on both anchors (ec71e536c). The 10 us bound rejected 7 of 9 nested WHP captures on the 8573C runners, where one bracketed VP 0 TSC register read takes 17 to 25 us; with 100 us that matrix passes 9/9. A pairing error shifts every VP's restored TSC alike, by at most the pairing. The KVM unit test that pinned a fixed 10 us bracket is now relative to the constant; it is a mechanical change in a virt_kvm test only. MSHV's and WHP's tests are already independent of the value. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt/src/time_abi/backend.rs | 2 +- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index 5de28ec4fdf..24efe54906d 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -100,7 +100,7 @@ pub struct BackendPreflight { /// capture anchor, and a restore anchor that pairs a TSC read with host time, /// a bounded number of times and keeps the tightest pair; if none is within /// this bound, the capture or restore fails with `E_TSC_ANCHOR`. -pub const MAX_ANCHOR_PAIRING_NS: u64 = 10_000; +pub const MAX_ANCHOR_PAIRING_NS: u64 = 100_000; /// The capture anchor. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index d11151fe88e..b3e26cbb1ce 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -769,22 +769,23 @@ mod tests { }; // Midpoint 1200, half width 201 cycles rounds up to 101 ns. assert_eq!(pair_host_sample(bracket, hz), Some((1_200, 101))); - // A 10 us half width is at the anchor bound. + // A half width of MAX_ANCHOR_PAIRING_NS (2 cycles per ns) is at the + // anchor bound. let at_bound = HostTscBracket { start: 0, - end: 40_000, + end: 4 * MAX_ANCHOR_PAIRING_NS, }; assert_eq!( pair_host_sample(at_bound, hz), - Some((20_000, MAX_ANCHOR_PAIRING_NS)) + Some((2 * MAX_ANCHOR_PAIRING_NS, MAX_ANCHOR_PAIRING_NS)) ); let beyond = HostTscBracket { start: 0, - end: 40_002, + end: 4 * MAX_ANCHOR_PAIRING_NS + 2, }; assert_eq!( pair_host_sample(beyond, hz), - Some((20_001, MAX_ANCHOR_PAIRING_NS + 1)) + Some((2 * MAX_ANCHOR_PAIRING_NS + 1, MAX_ANCHOR_PAIRING_NS + 1)) ); // A TSC that went backwards cannot be paired. let backwards = HostTscBracket { start: 10, end: 9 }; @@ -806,8 +807,10 @@ mod tests { #[test] fn anchors_sample_again_until_paired_within_the_bound() { - // 1 GHz: one cycle per ns. Two wide brackets, then a tight one. - let mut brackets = [(0, 30_000), (0, 25_000), (1_000, 1_100)].into_iter(); + // 1 GHz: one cycle per ns. Two brackets wider than the bound, then a + // tight one. + let wide = 2 * MAX_ANCHOR_PAIRING_NS; + let mut brackets = [(0, wide + 10_000), (0, wide + 5_000), (1_000, 1_100)].into_iter(); let paired = pair_within_bound(1_000_000_000, "capture anchor", || { let (start, end) = brackets.next().unwrap(); (HostTscBracket { start, end }, Ok(host_sample(start))) @@ -822,7 +825,7 @@ mod tests { let mut attempts = 0; let error = pair_within_bound(1_000_000_000, "restore anchor", || { attempts += 1; - let end = 20_000 + attempts as u64; + let end = 2 * MAX_ANCHOR_PAIRING_NS + attempts as u64; (HostTscBracket { start: 0, end }, Ok(host_sample(0))) }) .unwrap_err(); @@ -833,11 +836,8 @@ mod tests { "{}", error.message ); - assert!( - error.message.contains("tightest pairing: 10001 ns"), - "{}", - error.message - ); + let tightest = format!("tightest pairing: {} ns", MAX_ANCHOR_PAIRING_NS + 1); + assert!(error.message.contains(&tightest), "{}", error.message); let error = pair_within_bound(1_000_000_000, "capture anchor", || { (HostTscBracket { start: 10, end: 9 }, Ok(host_sample(0))) From 55e4439cb8f3eae391ee38e658780f874dea0ec4 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 04:30:32 -0700 Subject: [PATCH 196/304] openvmm_entry: add the --cpu-fingerprint tool Add --cpu-fingerprint , which writes a CPU fingerprint of the host for the backend selected by --hypervisor, or to stdout for "-", and exits without starting a VM. It then checks the fingerprint against the pinned CPU profile of the host's generation, prints one NVX-CPU-PROFILE: line to stderr, and fails with E_PROFILE_HOST_UNKNOWN or E_PROFILE_UNSUPPORTED when no profile serves the host or the backend does not support it, so host qualification can run the check before a CI job. The fingerprint is written first, so hosts of new generations can still be fingerprinted to derive their profiles. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + Guide/src/reference/openvmm/management/cli.md | 41 ++++++++++++++ openvmm/openvmm_entry/Cargo.toml | 1 + openvmm/openvmm_entry/src/cli_args.rs | 51 ++++++++++++++++++ openvmm/openvmm_entry/src/cli_args/microvm.rs | 2 +- openvmm/openvmm_entry/src/cpu_fingerprint.rs | 53 +++++++++++++++++++ openvmm/openvmm_entry/src/lib.rs | 6 +++ 7 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 openvmm/openvmm_entry/src/cpu_fingerprint.rs diff --git a/Cargo.lock b/Cargo.lock index de02132950d..0c85947e5d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6021,6 +6021,7 @@ dependencies = [ "clap", "clap_dyn_complete", "console_relay", + "cpu_profile", "crossterm", "crypto", "cxl_spec", diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 56a1c5c2b3e..35147ec9135 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -462,6 +462,47 @@ guest state and protect the directory with host access controls. --hypervisor whp:user_mode_apic,no_enlightenments --hypervisor kvm ``` +* `--cpu-fingerprint `: Write a CPU fingerprint of the host for the + backend selected by `--hypervisor` (or the auto-detected one) to `PATH`, or + to standard output for `-`, and exit without starting a VM. Backend + parameters are rejected. + + The fingerprint records the guest CPU surface that the backend supports on + this host: every CPUID leaf and subleaf, the XSAVE features and layout, the + feature MSRs, and the processor feature banks. It also records the time + capabilities (TSC and LAPIC timer rates, invariant TSC, TSC-deadline, + `TSC_ADJUST`, TSC offset control and scaling, time freezing, and MSR + interception) and the host CPU, OS, and hypervisor identity. KVM reports its + surface through `KVM_GET_SUPPORTED_CPUID` and its MSR-based features; MSHV + and WHP report the CPUID of a transient probe partition that is destroyed + before OpenVMM exits. No guest runs. + + The output is deterministic, sorted JSON (schema + `openvmm-cpu-fingerprint/v1`). CPUID fields that depend on the executing + processor or on the virtual processor state, such as APIC IDs and the + enabled XSAVE size, are normalized. `surface_digest` is the SHA-256 of the + guest CPU surface, so hosts that offer guests the same CPU share it, and + `digest` covers the whole document. + + OpenVMM then checks the fingerprint against the pinned CPU profile of the + host's generation (the profile that `--cpu-profile auto` would select) and + prints one line to standard error, for example: + + ```text + NVX-CPU-PROFILE: status=pass backend=kvm generation=icelake-sp profile=intel.icelake-sp.v1 profile_digest=sha256:… surface_digest=sha256:… host_invariant_tsc=yes + ``` + + If no pinned profile serves the host, or the backend does not support every + feature, limit, XSAVE layout, and MSR value of the profile, the line has + `status=fail`, `code`, and `detail`, and OpenVMM exits with status 1 and the + same code (`E_PROFILE_HOST_UNKNOWN` or `E_PROFILE_UNSUPPORTED`). The + fingerprint is still written, so hosts of new generations can be + fingerprinted to derive their profiles. + + ```bash + openvmm --hypervisor kvm --cpu-fingerprint host.json + openvmm --hypervisor whp --cpu-fingerprint - + ``` * `--isolation `: Enable a confidential or isolated VM mode. Supported modes include `vbs` and, for `x86_64` guests on KVM or MSHV, `snp`. diff --git a/openvmm/openvmm_entry/Cargo.toml b/openvmm/openvmm_entry/Cargo.toml index 0efbb8ca587..12d10a90670 100644 --- a/openvmm/openvmm_entry/Cargo.toml +++ b/openvmm/openvmm_entry/Cargo.toml @@ -15,6 +15,7 @@ ttrpc = [] [dependencies] chipset_resources.workspace = true chipset_device_worker_defs.workspace = true +cpu_profile.workspace = true crypto.workspace = true cxl_spec.workspace = true debug_worker_defs.workspace = true diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index 7d43cab94e9..83415fcfe5c 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -1175,6 +1175,35 @@ flags: #[clap(long)] pub write_saved_state_proto: Option, + /// write a CPU fingerprint of this host for the hypervisor selected by + /// `--hypervisor` to the specified path (`-` for stdout), and exit + #[clap( + long, + value_name = "PATH", + long_help = r#"Write a CPU fingerprint of this host to PATH ("-" for stdout), and exit. + +The fingerprint records the guest CPU surface that the backend selected by +--hypervisor supports on this host (or the first available backend): every +CPUID leaf and subleaf, the XSAVE features and layout, the feature MSRs, and +the time capabilities (TSC and LAPIC timer rates, invariant TSC, TSC-deadline, +TSC_ADJUST, TSC offset control and scaling, and MSR interception). It also +records the host CPU, OS, and hypervisor identity. + +The output is deterministic, sorted JSON with SHA-256 digests. It needs no +guest; the backend may create, and then destroys, a transient probe partition. + +After writing the fingerprint, OpenVMM checks it against the pinned CPU profile +of the host's generation and prints one "NVX-CPU-PROFILE:" line to stderr. It +exits with status 1 and the failure code (E_PROFILE_HOST_UNKNOWN or +E_PROFILE_UNSUPPORTED) if no profile serves the host or the backend does not +support it. + +Examples: + --hypervisor kvm --cpu-fingerprint host.json + --hypervisor whp --cpu-fingerprint -"# + )] + pub cpu_fingerprint: Option, + /// specify the IMC hive file for booting Windows #[clap(long)] pub imc: Option, @@ -5823,6 +5852,28 @@ mod tests { assert_eq!(opt.pidfile, Some(PathBuf::from("/tmp/test.pid"))); } + #[test] + fn test_cpu_fingerprint_option() { + let opt = Options::try_parse_from(["openvmm"]).unwrap(); + assert_eq!(opt.cpu_fingerprint, None); + + let opt = Options::try_parse_from([ + "openvmm", + "--hypervisor", + "kvm", + "--cpu-fingerprint", + "host.json", + ]) + .unwrap(); + assert_eq!(opt.cpu_fingerprint, Some(PathBuf::from("host.json"))); + assert_eq!(opt.hypervisor.as_deref(), Some("kvm")); + + let opt = Options::try_parse_from(["openvmm", "--cpu-fingerprint", "-"]).unwrap(); + assert_eq!(opt.cpu_fingerprint, Some(PathBuf::from("-"))); + + assert!(Options::try_parse_from(["openvmm", "--cpu-fingerprint"]).is_err()); + } + #[test] fn test_tpm_version_option() { let opt = Options::try_parse_from(["openvmm"]).unwrap(); diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 4b281224db4..eed6f28f3dc 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -346,7 +346,7 @@ pub struct MicrovmCli { long, hide = true, requires("microvm_control_console"), - conflicts_with_all = ["rpc", "ttrpc", "grpc", "relay_console_path", "write_saved_state_proto", "paused"] + conflicts_with_all = ["rpc", "ttrpc", "grpc", "relay_console_path", "write_saved_state_proto", "cpu_fingerprint", "paused"] )] pub microvm_control_auth_stdin: bool, diff --git a/openvmm/openvmm_entry/src/cpu_fingerprint.rs b/openvmm/openvmm_entry/src/cpu_fingerprint.rs new file mode 100644 index 00000000000..432f36fcbd8 --- /dev/null +++ b/openvmm/openvmm_entry/src/cpu_fingerprint.rs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Writes and checks the host CPU fingerprint for `--cpu-fingerprint`. + +use anyhow::Context; +use cpu_profile::fingerprint::CpuFingerprint; +use cpu_profile::fingerprint::ToolIdentity; +use cpu_profile::host::HostIdentity; +use std::io::Write; +use std::path::Path; + +/// Fingerprints this host for the backend selected by `hypervisor` (or the +/// first available one), writes the fingerprint to `path`, or to stdout for +/// `-`, and checks it against the CPU profile of the host's generation. +/// +/// The check prints one `NVX-CPU-PROFILE:` line to stderr and fails with its +/// code (`E_PROFILE_HOST_UNKNOWN` or `E_PROFILE_UNSUPPORTED`) after the +/// fingerprint is written, so hosts of new generations can still be +/// fingerprinted. +pub(crate) fn write(path: &Path, hypervisor: Option<&str>) -> anyhow::Result<()> { + let backend = openvmm_helpers::hypervisor::cpu_fingerprint(hypervisor) + .context("failed to fingerprint the hypervisor backend")?; + let host = HostIdentity::collect().context("failed to identify the host")?; + let fingerprint = CpuFingerprint::new( + ToolIdentity { + name: "openvmm".to_owned(), + version: openvmm_build_info::get().version().to_owned(), + }, + host, + backend, + ); + let json = fingerprint.to_json(); + if path == Path::new("-") { + let mut stdout = std::io::stdout().lock(); + stdout + .write_all(json.as_bytes()) + .and_then(|()| stdout.flush()) + .context("failed to write the CPU fingerprint to stdout")?; + } else { + fs_err::write(path, json).context("failed to write the CPU fingerprint")?; + } + tracing::info!( + backend = fingerprint.backend.name, + surface_digest = fingerprint.surface_digest, + digest = fingerprint.digest, + "wrote CPU fingerprint" + ); + + let check = cpu_profile::check_fingerprint(&fingerprint); + eprintln!("{}", check.summary_line(&fingerprint)); + Ok(check.result?) +} diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 56c3e43cd05..d1bdc0f3726 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -8,6 +8,7 @@ #![forbid(unsafe_code)] mod cli_args; +mod cpu_fingerprint; mod crash_dump; mod kvp; mod meshworker; @@ -2742,6 +2743,11 @@ fn do_main(pidfile_guard: &mut Option) -> anyhow::Result return Ok(0); } + if let Some(path) = &opt.cpu_fingerprint { + cpu_fingerprint::write(path, opt.hypervisor.as_deref())?; + return Ok(0); + } + if let Some(ref path) = opt.pidfile { *pidfile_guard = Some(pidfile::Pidfile::new(path).context("failed to create pidfile")?); } From 8122b97375377df2d57f9b720c1dc498c62aa4ec Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 10:41:45 -0700 Subject: [PATCH 197/304] virt_whp: derive time ABI processor features from a CPU profile Add the WHP side of the CPU profile API (profiles-report "API for backends"): profile_features() starts from the processor feature banks and XSAVE features WHP offers, clears every bit whose CPUID feature the profile clears, and applies hv_banks::restrict_banks_to_profile so the bank-derived IA32_ARCH_CAPABILITIES matches the profile's pinned value. A profile feature WHP lacks fails with E_PROFILE_UNSUPPORTED, except the time policy bits, which the time ABI CPUID supplies. WhpTimeAbi::configure takes the profile and programs the derived banks and XSAVE features. Partitions pass None until TimeAbiConfig carries the profile, so behavior is unchanged. The table maps each bank and XSAVE bit to its CPUID feature. Hardware tests check it by clearing one bit at a time, check that a partition configured from the host's profile presents the profile's CPUID, check that CpuidResultList2 results reach the guest CPUID, and time a supported-surface probe partition. They pass on prometheus28 (intel.skylake-sp.v1), the 8370C runners (intel.icelake-sp.v1), and the 8573C runners (intel.emeraldrapids.v1); only the hypervisor bit, ARAT, and (on Azure) the invariant TSC need the CPUID results. vm/whp: make WHV_X64_CPUID_RESULT2's fields public so the backend can build CpuidResultList2 entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vm/whp/src/abi/x64.rs | 12 +- vmm_core/virt_whp/src/lib.rs | 5 + vmm_core/virt_whp/src/profile_features.rs | 917 ++++++++++++++++++++++ vmm_core/virt_whp/src/time_abi.rs | 127 ++- 4 files changed, 1050 insertions(+), 11 deletions(-) create mode 100644 vmm_core/virt_whp/src/profile_features.rs diff --git a/vm/whp/src/abi/x64.rs b/vm/whp/src/abi/x64.rs index 156adc01398..1ce7c64bac6 100644 --- a/vm/whp/src/abi/x64.rs +++ b/vm/whp/src/abi/x64.rs @@ -747,12 +747,12 @@ pub const WHvX64CpuidResult2FlagVpSpecific: WHV_X64_CPUID_RESULT2_FLAGS = #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct WHV_X64_CPUID_RESULT2 { - Function: u32, - Index: u32, - VpIndex: u32, - Flags: WHV_X64_CPUID_RESULT2_FLAGS, - Output: WHV_CPUID_OUTPUT, - Mask: WHV_CPUID_OUTPUT, + pub Function: u32, + pub Index: u32, + pub VpIndex: u32, + pub Flags: WHV_X64_CPUID_RESULT2_FLAGS, + pub Output: WHV_CPUID_OUTPUT, + pub Mask: WHV_CPUID_OUTPUT, } #[repr(C)] diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 5f17bec22d3..db1fd0e618f 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -18,6 +18,8 @@ mod emu; mod fingerprint; mod hypercalls; mod memory; +#[cfg(guest_arch = "x86_64")] +mod profile_features; mod regs; mod synic; #[cfg(guest_arch = "x86_64")] @@ -1609,6 +1611,9 @@ impl VtlPartition { let time_abi = match &config.time_abi { Some(time_abi) => Some(time_abi::WhpTimeAbi::configure( time_abi, + // The features derive from the CPU profile once + // `TimeAbiConfig` carries it. + None, &mut whp_config, &mut extended_exits, )?), diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs new file mode 100644 index 00000000000..c4c060ec169 --- /dev/null +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -0,0 +1,917 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! WHP processor features derived from a CPU profile. +//! +//! A WHP partition presents a CPU feature only when the feature's processor +//! feature bank bit (or XSAVE feature bit) is set, and some of those bits also +//! decide which MSRs the guest may use (for example `IA32_SPEC_CTRL`). +//! [`profile_features`] starts from the features WHP offers, clears every bit +//! whose CPUID feature the profile clears, and restricts the +//! `IA32_ARCH_CAPABILITIES` bits that the banks derive to the profile's pinned +//! value. The partition then behaves as the profile's CPUID describes. +//! +//! Bits without a CPUID feature, such as the nested-paging details, keep WHP's +//! capability. Features that no bit controls (for example PKU) and the time +//! policy bits a host lacks (ARAT and invariant TSC on Azure) reach the guest +//! through the CPUID results instead. +//! +//! The hardware test `features_control_the_mapped_cpuid_bits` checks the table +//! against what WHP presents on a host. + +use cpu_profile::CpuProfile; +use cpu_profile::TIME_POLICY_BITS; +use cpu_profile::hv_banks; +use hvdef::HvX64PartitionProcessorFeatures as Bank0; +use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; +use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; + +/// `IA32_ARCH_CAPABILITIES`. +const MSR_ARCH_CAPABILITIES: u32 = 0x10a; + +const EAX: usize = 0; +const EBX: usize = 1; +const ECX: usize = 2; +const EDX: usize = 3; + +/// The extended feature leaf. +const X1: u32 = 0x8000_0001; +/// The advanced power management leaf. +const X7: u32 = 0x8000_0007; +/// The AMD extended feature identifiers leaf. +const X8: u32 = 0x8000_0008; + +/// WHP processor features: feature banks 0 and 1, and the XSAVE features. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub(crate) struct WhpFeatures { + pub banks: [u64; 2], + pub xsave: u64, +} + +impl WhpFeatures { + fn word_mut(&mut self, bank: Bank) -> &mut u64 { + match bank { + Bank::Features0 => &mut self.banks[0], + Bank::Features1 => &mut self.banks[1], + Bank::Xsave => &mut self.xsave, + } + } + + #[cfg(test)] + fn word(&self, bank: Bank) -> u64 { + match bank { + Bank::Features0 => self.banks[0], + Bank::Features1 => self.banks[1], + Bank::Xsave => self.xsave, + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum Bank { + Features0, + Features1, + Xsave, +} + +#[cfg(test)] +impl Bank { + fn name(self) -> &'static str { + match self { + Bank::Features0 => "bank0", + Bank::Features1 => "bank1", + Bank::Xsave => "xsave", + } + } +} + +/// A CPUID feature bit: leaf, subleaf, register (EAX through EDX), and bit. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +struct CpuidBit { + leaf: u32, + subleaf: u32, + register: usize, + bit: u32, +} + +impl std::fmt::Display for CpuidBit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let register = ["eax", "ebx", "ecx", "edx"][self.register]; + write!( + f, + "{:#x}.{}:{}[{}]", + self.leaf, self.subleaf, register, self.bit + ) + } +} + +const fn cpuid(leaf: u32, subleaf: u32, register: usize, bit: u32) -> CpuidBit { + CpuidBit { + leaf, + subleaf, + register, + bit, + } +} + +/// A WHP feature bit and the CPUID feature bit it controls. +struct Feature { + name: &'static str, + bank: Bank, + mask: u64, + cpuid: CpuidBit, +} + +impl Feature { + fn name(&self) -> &'static str { + self.name.trim_start_matches("with_") + } + + fn is_time_policy(&self) -> bool { + let bit = self.cpuid; + TIME_POLICY_BITS.contains(&(bit.leaf, bit.subleaf, bit.register, 1 << bit.bit)) + } +} + +macro_rules! feature { + ($bank:ident, $ty:ident, $with:ident, $leaf:expr, $subleaf:expr, $register:expr, $bit:expr) => { + Feature { + name: stringify!($with), + bank: Bank::$bank, + mask: $ty::new().$with(true).into_bits(), + cpuid: cpuid($leaf, $subleaf, $register, $bit), + } + }; +} + +macro_rules! bank0 { + ($with:ident, $($cpuid:expr),+) => { + feature!(Features0, Bank0, $with, $($cpuid),+) + }; +} + +macro_rules! bank1 { + ($with:ident, $($cpuid:expr),+) => { + feature!(Features1, Bank1, $with, $($cpuid),+) + }; +} + +macro_rules! xsave { + ($with:ident, $($cpuid:expr),+) => { + feature!(Xsave, XsaveBank, $with, $($cpuid),+) + }; +} + +/// The WHP feature bits with a CPUID feature, as leaf, subleaf, register, and +/// bit. The leaves follow the Intel SDM and the AMD APM. The bits that the +/// banks derive into `IA32_ARCH_CAPABILITIES` are [`hv_banks`]'s. +const FEATURES: &[Feature] = &[ + // Bank 0. + bank0!(with_sse3_support, 1, 0, ECX, 0), + bank0!(with_lahf_sahf_support, X1, 0, ECX, 0), + bank0!(with_ssse3_support, 1, 0, ECX, 9), + bank0!(with_sse4_1_support, 1, 0, ECX, 19), + bank0!(with_sse4_2_support, 1, 0, ECX, 20), + bank0!(with_sse4a_support, X1, 0, ECX, 6), + bank0!(with_xop_support, X1, 0, ECX, 11), + bank0!(with_pop_cnt_support, 1, 0, ECX, 23), + bank0!(with_cmpxchg16b_support, 1, 0, ECX, 13), + bank0!(with_altmovcr8_support, X1, 0, ECX, 4), + bank0!(with_lzcnt_support, X1, 0, ECX, 5), + bank0!(with_mis_align_sse_support, X1, 0, ECX, 7), + bank0!(with_mmx_ext_support, X1, 0, EDX, 22), + bank0!(with_amd3d_now_support, X1, 0, EDX, 31), + bank0!(with_extended_amd3d_now_support, X1, 0, EDX, 30), + bank0!(with_page_1gb_support, X1, 0, EDX, 26), + bank0!(with_aes_support, 1, 0, ECX, 25), + bank0!(with_pclmulqdq_support, 1, 0, ECX, 1), + bank0!(with_pcid_support, 1, 0, ECX, 17), + bank0!(with_fma4_support, X1, 0, ECX, 16), + bank0!(with_f16c_support, 1, 0, ECX, 29), + bank0!(with_rd_rand_support, 1, 0, ECX, 30), + bank0!(with_rd_wr_fs_gs_support, 7, 0, EBX, 0), + bank0!(with_smep_support, 7, 0, EBX, 7), + bank0!(with_enhanced_fast_string_support, 7, 0, EBX, 9), + bank0!(with_bmi1_support, 7, 0, EBX, 3), + bank0!(with_bmi2_support, 7, 0, EBX, 8), + bank0!(with_movbe_support, 1, 0, ECX, 22), + bank0!(with_dep_x87_fpu_save_support, 7, 0, EBX, 13), + bank0!(with_rd_seed_support, 7, 0, EBX, 18), + bank0!(with_adx_support, 7, 0, EBX, 19), + bank0!(with_intel_prefetch_support, X1, 0, ECX, 8), + bank0!(with_smap_support, 7, 0, EBX, 20), + bank0!(with_hle_support, 7, 0, EBX, 4), + bank0!(with_rtm_support, 7, 0, EBX, 11), + bank0!(with_rdtscp_support, X1, 0, EDX, 27), + bank0!(with_clflushopt_support, 7, 0, EBX, 23), + bank0!(with_clwb_support, 7, 0, EBX, 24), + bank0!(with_sha_support, 7, 0, EBX, 29), + bank0!(with_x87_pointers_saved_support, X8, 0, EBX, 2), + bank0!(with_invpcid_support, 7, 0, EBX, 10), + bank0!(with_ibrs_support, 7, 0, EDX, 26), + bank0!(with_stibp_support, 7, 0, EDX, 27), + // CPUID enumerates IBPB with IBRS; WHP derives the bit from IBRS. + bank0!(with_ibpb_support, 7, 0, EDX, 26), + bank0!(with_mdd_support, 7, 0, EDX, 31), + bank0!(with_fast_short_rep_mov_support, 7, 0, EDX, 4), + bank0!(with_l1d_cache_flush_support, 7, 0, EDX, 28), + bank0!(with_rd_pid_support, 7, 0, ECX, 22), + bank0!(with_umip_support, 7, 0, ECX, 2), + bank0!(with_mb_clear_support, 7, 0, EDX, 10), + // Bank 1. + bank1!(with_a_count_m_count_support, 6, 0, ECX, 0), + bank1!(with_tsc_invariant_support, X7, 0, EDX, 8), + bank1!(with_cl_zero_support, X8, 0, EBX, 0), + bank1!(with_rdpru_support, X8, 0, EBX, 4), + bank1!(with_la57_support, 7, 0, ECX, 16), + bank1!(with_nested_virt_support, 1, 0, ECX, 5), + bank1!(with_psfd_support, 7, 2, EDX, 0), + bank1!(with_cet_ss_support, 7, 0, ECX, 7), + bank1!(with_cet_ibt_support, 7, 0, EDX, 20), + bank1!(with_enqcmd_support, 7, 0, ECX, 29), + bank1!(with_umwait_tpause_support, 7, 0, ECX, 5), + bank1!(with_movdiri_support, 7, 0, ECX, 27), + bank1!(with_movdir64b_support, 7, 0, ECX, 28), + bank1!(with_cldemote_support, 7, 0, ECX, 25), + bank1!(with_serialize_support, 7, 0, EDX, 14), + bank1!(with_tsc_deadline_tmr_support, 1, 0, ECX, 24), + bank1!(with_tsc_adjust_support, 7, 0, EBX, 1), + bank1!(with_fz_l_rep_movsb, 7, 1, EAX, 10), + bank1!(with_fs_rep_stosb, 7, 1, EAX, 11), + bank1!(with_fs_rep_cmpsb, 7, 1, EAX, 12), + bank1!(with_tsx_ld_trk_support, 7, 0, EDX, 16), + bank1!(with_cmpccxadd_support, 7, 1, EAX, 7), + bank1!(with_bhi_dis_support, 7, 2, EDX, 4), + bank1!(with_prefetch_i_support, 7, 1, EDX, 14), + bank1!(with_sha512_support, 7, 1, EAX, 0), + bank1!(with_sm3_support, 7, 1, EAX, 1), + bank1!(with_sm4_support, 7, 1, EAX, 2), + bank1!(with_lass_support, 7, 1, EAX, 6), + // XSAVE features. + xsave!(with_xsave_support, 1, 0, ECX, 26), + xsave!(with_xsaveopt_support, 0xd, 1, EAX, 0), + xsave!(with_avx_support, 1, 0, ECX, 28), + xsave!(with_avx2_support, 7, 0, EBX, 5), + xsave!(with_fma_support, 1, 0, ECX, 12), + xsave!(with_mpx_support, 7, 0, EBX, 14), + xsave!(with_avx512_support, 7, 0, EBX, 16), + xsave!(with_avx512_dq_support, 7, 0, EBX, 17), + xsave!(with_avx512_cd_support, 7, 0, EBX, 28), + xsave!(with_avx512_bw_support, 7, 0, EBX, 30), + xsave!(with_avx512_vl_support, 7, 0, EBX, 31), + xsave!(with_xsave_comp_support, 0xd, 1, EAX, 1), + xsave!(with_xsave_supervisor_support, 0xd, 1, EAX, 3), + xsave!(with_xcr1_support, 0xd, 1, EAX, 2), + xsave!(with_avx512_bitalg_support, 7, 0, ECX, 12), + xsave!(with_avx512_ifma_support, 7, 0, EBX, 21), + xsave!(with_avx512_vbmi_support, 7, 0, ECX, 1), + xsave!(with_avx512_vbmi2_support, 7, 0, ECX, 6), + xsave!(with_avx512_vnni_support, 7, 0, ECX, 11), + xsave!(with_gfni_support, 7, 0, ECX, 8), + xsave!(with_vaes_support, 7, 0, ECX, 9), + xsave!(with_avx512_vpopcntdq_support, 7, 0, ECX, 14), + xsave!(with_vpclmulqdq_support, 7, 0, ECX, 10), + xsave!(with_avx512_bf16_support, 7, 1, EAX, 5), + xsave!(with_avx512_vp2_intersect_support, 7, 0, EDX, 8), + xsave!(with_avx512_fp16_support, 7, 0, EDX, 23), + xsave!(with_xfd_support, 0xd, 1, EAX, 4), + xsave!(with_amx_tile_support, 7, 0, EDX, 24), + xsave!(with_amx_bf16_support, 7, 0, EDX, 22), + xsave!(with_amx_int8_support, 7, 0, EDX, 25), + xsave!(with_avx_vnni_support, 7, 1, EAX, 4), + xsave!(with_avx_ifma_support, 7, 1, EAX, 23), + xsave!(with_avx_ne_convert_support, 7, 1, EDX, 5), + xsave!(with_avx_vnni_int8_support, 7, 1, EDX, 4), + xsave!(with_avx_vnni_int16_support, 7, 1, EDX, 10), + // CPUID enumerates AVX10 once; leaf 0x24 reports its vector lengths. + xsave!(with_avx10_1_256_support, 7, 1, EDX, 19), + xsave!(with_avx10_1_512_support, 7, 1, EDX, 19), + xsave!(with_amx_fp16_support, 7, 1, EAX, 21), +]; + +/// How a profile pins a CPUID feature bit. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum Pinned { + Set, + Clear, + Unpinned, +} + +/// Returns how `profile` pins `bit`. A leaf the profile does not list reads +/// zero, pinned. +fn pinned(profile: &CpuProfile, bit: CpuidBit) -> Pinned { + let (value, mask) = profile + .cpuid() + .iter() + .find(|entry| { + let (leaf, subleaf) = entry.key(); + leaf == bit.leaf && subleaf.unwrap_or(0) == bit.subleaf + }) + .map_or((0, !0), |entry| { + (entry.values()[bit.register], entry.masks()[bit.register]) + }); + let flag = 1 << bit.bit; + if mask & flag == 0 { + Pinned::Unpinned + } else if value & flag != 0 { + Pinned::Set + } else { + Pinned::Clear + } +} + +/// Returns the WHP features for `profile`, given the features WHP offers. +/// +/// Every feature bit whose CPUID feature the profile clears is cleared, and +/// the `IA32_ARCH_CAPABILITIES` bits the banks derive are restricted to the +/// profile's pinned value. Fails with `E_PROFILE_UNSUPPORTED` when the profile +/// sets a CPUID feature whose bit WHP does not offer (except the time policy +/// bits, which the CPUID results supply), or when the banks cannot present +/// the profile's `IA32_ARCH_CAPABILITIES`. +pub(crate) fn profile_features( + profile: &CpuProfile, + available: WhpFeatures, +) -> Result { + let mut features = available; + let mut missing = Vec::new(); + for feature in FEATURES { + let word = features.word_mut(feature.bank); + match pinned(profile, feature.cpuid) { + Pinned::Clear => *word &= !feature.mask, + Pinned::Set if *word & feature.mask == 0 && !feature.is_time_policy() => { + missing.push(feature.name().to_owned()); + } + Pinned::Set | Pinned::Unpinned => {} + } + } + hv_banks::restrict_banks_to_profile(profile, &mut features.banks); + if let Some((value, mask)) = profile.msr(MSR_ARCH_CAPABILITIES) { + let presented = hv_banks::arch_capabilities_from_banks(features.banks); + if presented & mask != value & mask { + missing.push(format!( + "IA32_ARCH_CAPABILITIES {:#x} (the banks present {:#x} under mask {mask:#x})", + value & mask, + presented & mask + )); + } + } + if !missing.is_empty() { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "WHP cannot present CPU profile {}: it lacks {}", + profile.id(), + missing.join(", ") + ), + )); + } + Ok(features) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Feature capabilities that WHP reported on the fleet's hosts (CPU + /// fingerprints): prometheus28 (Skylake-SP, bare metal), the 8370C + /// runners (Ice Lake, nested), and the 8573C runners (Emerald Rapids, + /// nested). + const PROMETHEUS28: WhpFeatures = WhpFeatures { + banks: [0x1001_f9ff_e7f7_859f, 0x0000_000f_1086_0063], + xsave: 0x3fff, + }; + const AZURE_8370C: WhpFeatures = WhpFeatures { + banks: [0x2e0a_8bff_e7f7_859f, 0x0001_000e_0000_00f1], + xsave: 0x7f_ffdf, + }; + const AZURE_8573C: WhpFeatures = WhpFeatures { + banks: [0x0e0a_8bff_e7f7_859f, 0x0000_000c_0000_0051], + xsave: 0x7f_ffdf, + }; + + const HOSTS: [(&str, WhpFeatures); 3] = [ + ("intel.skylake-sp.v1", PROMETHEUS28), + ("intel.icelake-sp.v1", AZURE_8370C), + ("intel.emeraldrapids.v1", AZURE_8573C), + ]; + + fn profile(id: &str) -> &'static CpuProfile { + cpu_profile::pinned(id).unwrap_or_else(|| panic!("profile {id} is not pinned")) + } + + fn has(features: &WhpFeatures, bank: Bank, mask: u64) -> bool { + features.word(bank) & mask != 0 + } + + #[test] + fn every_feature_is_one_distinct_bit() { + let mut seen = Vec::new(); + for feature in FEATURES { + assert_eq!(feature.mask.count_ones(), 1, "{}", feature.name()); + assert!( + !seen.contains(&(feature.bank, feature.mask)), + "{} repeats a bit", + feature.name() + ); + seen.push((feature.bank, feature.mask)); + } + } + + #[test] + fn pinned_profiles_derive_on_their_hosts() { + for (id, available) in HOSTS { + let profile = profile(id); + let features = profile_features(profile, available).unwrap(); + for bank in [Bank::Features0, Bank::Features1, Bank::Xsave] { + assert_eq!( + features.word(bank) & !available.word(bank), + 0, + "{id}: {} gained bits", + bank.name() + ); + } + // Every mapped bit follows the profile's CPUID. + for feature in FEATURES { + let offered = has(&available, feature.bank, feature.mask); + let kept = has(&features, feature.bank, feature.mask); + match pinned(profile, feature.cpuid) { + Pinned::Clear => assert!(!kept, "{id}: {}", feature.name()), + Pinned::Set | Pinned::Unpinned => { + assert_eq!(kept, offered, "{id}: {}", feature.name()) + } + } + } + // The time policy: no TSC-deadline, TSC_ADJUST, or APERF/MPERF. + for mask in [ + Bank1::new().with_tsc_deadline_tmr_support(true).into_bits(), + Bank1::new().with_tsc_adjust_support(true).into_bits(), + Bank1::new().with_a_count_m_count_support(true).into_bits(), + ] { + assert!(!has(&features, Bank::Features1, mask), "{id}: {mask:#x}"); + } + let (value, mask) = profile.msr(MSR_ARCH_CAPABILITIES).unwrap(); + assert_eq!( + hv_banks::arch_capabilities_from_banks(features.banks) & mask, + value & mask, + "{id}" + ); + } + } + + #[test] + fn skylake_keeps_the_speculation_controls() { + // The Skylake-SP profile pins SPEC_CTRL, STIBP, SSBD, and MD_CLEAR + // (CPUID 7.0 EDX 0xac000400), which WHP's default features omit. The + // Azure profiles pin none of them (EDX 0x20000010), as Azure's WHP + // offers none. + let features = profile_features(profile("intel.skylake-sp.v1"), PROMETHEUS28).unwrap(); + for mask in [ + Bank0::new().with_ibrs_support(true).into_bits(), + Bank0::new().with_ibpb_support(true).into_bits(), + Bank0::new().with_stibp_support(true).into_bits(), + Bank0::new().with_mdd_support(true).into_bits(), + Bank0::new().with_mb_clear_support(true).into_bits(), + ] { + assert!(has(&features, Bank::Features0, mask), "{mask:#x}"); + } + } + + #[test] + fn invariant_tsc_follows_the_host_and_the_cpuid_results_supply_it() { + let invariant = Bank1::new().with_tsc_invariant_support(true).into_bits(); + let skylake = profile_features(profile("intel.skylake-sp.v1"), PROMETHEUS28).unwrap(); + assert!(has(&skylake, Bank::Features1, invariant)); + // Azure's WHP offers no invariant TSC; the profile still pins it, and + // the derivation leaves it to the CPUID results. + let icelake = profile_features(profile("intel.icelake-sp.v1"), AZURE_8370C).unwrap(); + assert!(!has(&icelake, Bank::Features1, invariant)); + } + + #[test] + fn a_missing_feature_is_unsupported() { + let mut available = PROMETHEUS28; + available.xsave &= !XsaveBank::new().with_avx512_support(true).into_bits(); + available.banks[0] &= !Bank0::new().with_ibrs_support(true).into_bits(); + let error = profile_features(profile("intel.skylake-sp.v1"), available).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); + assert!(error.message.contains("avx512_support"), "{error}"); + assert!(error.message.contains("ibrs_support"), "{error}"); + } + + #[test] + fn a_newer_profile_is_unsupported_on_an_older_host() { + let error = profile_features(profile("intel.emeraldrapids.v1"), PROMETHEUS28).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); + } + + #[test] + fn unlisted_leaves_read_zero_and_unmasked_bits_are_unpinned() { + let skylake = profile("intel.skylake-sp.v1"); + // Skylake-SP lists no leaf 7.1, so its features are pinned clear. + assert_eq!(pinned(skylake, cpuid(7, 1, EAX, 5)), Pinned::Clear); + // OSXSAVE is guest state: the profile leaves it unpinned. + assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 27)), Pinned::Unpinned); + assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 26)), Pinned::Set); + } +} + +#[cfg(test)] +mod whp_tests { + //! Tests that need WHP. Run them on a host with + //! `cargo test -p virt_whp -- --ignored`. + + use super::*; + use cpu_profile::HostCpuSignature; + use whp::abi::WHV_PROCESSOR_FEATURES; + use whp::abi::WHV_PROCESSOR_FEATURES1; + use whp::abi::WHV_PROCESSOR_XSAVE_FEATURES; + + /// The leaves whose feature bits the WHP features control. + const FEATURE_LEAVES: [(u32, u32); 14] = [ + (1, 0), + (6, 0), + (7, 0), + (7, 1), + (7, 2), + (0xd, 0), + (0xd, 1), + (0x14, 0), + (0x19, 0), + (0x24, 0), + (X1, 0), + (X7, 0), + (X8, 0), + (0x8000_0021, 0), + ]; + + fn available() -> WhpFeatures { + let banks = whp::capabilities::processor_features().unwrap(); + let xsave = whp::capabilities::processor_xsave_features().unwrap(); + WhpFeatures { + banks: [banks.bank0.0, banks.bank1.0], + xsave: xsave.0, + } + } + + /// Creates a partition with `vp_count` VPs, `features`, and the CPUID + /// `results`. + fn probe_partition_with( + features: WhpFeatures, + vp_count: u32, + results: &[whp::abi::WHV_X64_CPUID_RESULT2], + ) -> Result { + let mut config = whp::PartitionConfig::new()?; + config.set_property(whp::PartitionProperty::ProcessorCount(vp_count))?; + config.set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeXApic, + ))?; + let mut banks = whp::capabilities::processor_features()?; + banks.bank0 = WHV_PROCESSOR_FEATURES(features.banks[0]); + banks.bank1 = WHV_PROCESSOR_FEATURES1(features.banks[1]); + config.set_property(whp::PartitionProperty::ProcessorFeaturesBanks(banks))?; + config.set_property(whp::PartitionProperty::ProcessorXsaveFeatures( + WHV_PROCESSOR_XSAVE_FEATURES(features.xsave), + ))?; + if !results.is_empty() { + config.set_property(whp::PartitionProperty::CpuidResultList2(results))?; + } + let partition = config.create()?; + for vp in 0..vp_count { + partition.create_vp(vp).create()?; + } + Ok(partition) + } + + /// Creates a one-VP partition with `features`. + fn probe_partition(features: WhpFeatures) -> Result { + probe_partition_with(features, 1, &[]) + } + + fn read_vp(partition: &whp::Partition, vp: u32, leaf: u32, subleaf: u32) -> [u32; 4] { + let output = partition.vp(vp).get_cpuid_output(leaf, subleaf).unwrap(); + [output.Eax, output.Ebx, output.Ecx, output.Edx] + } + + fn read(partition: &whp::Partition, leaf: u32, subleaf: u32) -> [u32; 4] { + read_vp(partition, 0, leaf, subleaf) + } + + fn feature_cpuid(partition: &whp::Partition) -> Vec<[u32; 4]> { + FEATURE_LEAVES + .iter() + .map(|&(leaf, subleaf)| read(partition, leaf, subleaf)) + .collect() + } + + /// The CPUID bits set in `base` and clear in `probe`. + fn removed(base: &[[u32; 4]], probe: &[[u32; 4]]) -> Vec { + let mut bits = Vec::new(); + for (index, &(leaf, subleaf)) in FEATURE_LEAVES.iter().enumerate() { + for register in 0..4 { + // Leaf 0xD.0 reports XSAVE sizes and components, and leaf 7.0 + // EAX the last subleaf; neither holds features. + if (leaf, subleaf) == (0xd, 0) || (leaf, subleaf, register) == (7, 0, EAX) { + continue; + } + let lost = base[index][register] & !probe[index][register]; + for bit in 0..32 { + if lost & (1 << bit) != 0 { + bits.push(cpuid(leaf, subleaf, register, bit)); + } + } + } + } + bits + } + + /// Whether another feature maps the same CPUID bit (as IBRS and IBPB + /// do): WHP may derive the bit from either. + fn shared(feature: &Feature) -> bool { + FEATURES + .iter() + .any(|other| !std::ptr::eq(other, feature) && other.cpuid == feature.cpuid) + } + + fn list(bits: &[CpuidBit]) -> String { + if bits.is_empty() { + return "none".to_owned(); + } + bits.iter() + .map(|bit| bit.to_string()) + .collect::>() + .join(" ") + } + + /// Clears each feature bit WHP offers, one at a time, and checks that the + /// CPUID bit the table maps to it disappears. Prints what every bit + /// controls on this host. + #[test] + #[ignore = "requires WHP"] + fn features_control_the_mapped_cpuid_bits() { + let available = available(); + let base = feature_cpuid(&probe_partition(available).unwrap()); + println!( + "available: bank0 {:#x} bank1 {:#x} xsave {:#x}", + available.banks[0], available.banks[1], available.xsave + ); + let mut failures = Vec::new(); + for bank in [Bank::Features0, Bank::Features1, Bank::Xsave] { + let word = available.word(bank); + for bit in (0..64).filter(|bit| word & (1 << bit) != 0) { + let mask = 1u64 << bit; + let feature = FEATURES + .iter() + .find(|feature| feature.bank == bank && feature.mask == mask); + let name = feature.map_or("(unmapped)", |feature| feature.name()); + let mut features = available; + *features.word_mut(bank) &= !mask; + let probe = match probe_partition(features) { + Ok(partition) => feature_cpuid(&partition), + Err(err) => { + println!("{} bit {bit} {name}: rejected: {err}", bank.name()); + continue; + } + }; + let removed = removed(&base, &probe); + let mut verdict = ""; + if let Some(feature) = feature { + let index = FEATURE_LEAVES + .iter() + .position(|&key| key == (feature.cpuid.leaf, feature.cpuid.subleaf)) + .unwrap(); + let offered = + base[index][feature.cpuid.register] & (1 << feature.cpuid.bit) != 0; + if offered && !shared(feature) && !removed.contains(&feature.cpuid) { + verdict = " MISMATCH"; + failures.push(format!( + "{} bit {bit} {name}: expected {} removed {}", + bank.name(), + feature.cpuid, + list(&removed) + )); + } + } + println!( + "{} bit {bit} {name}: removes {}{verdict}", + bank.name(), + list(&removed) + ); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Derives the features for this host's profile and reports the profile + /// bits that the features alone do not present (the CPUID results must + /// supply them). Fails if a mapped feature bit disagrees. + #[test] + #[ignore = "requires WHP"] + fn host_profile_features_present_the_profile() { + let profile = match cpu_profile::select_auto(&HostCpuSignature::current()) { + Ok(profile) => profile, + Err(err) => { + println!("skipped: no profile for this host: {err}"); + return; + } + }; + let available = available(); + let features = profile_features(profile, available).unwrap(); + println!( + "{}: bank0 {:#x} -> {:#x}, bank1 {:#x} -> {:#x}, xsave {:#x} -> {:#x}", + profile.id(), + available.banks[0], + features.banks[0], + available.banks[1], + features.banks[1], + available.xsave, + features.xsave + ); + let partition = probe_partition(features).unwrap(); + let mut failures = Vec::new(); + for entry in profile.cpuid() { + let (leaf, subleaf) = entry.key(); + let subleaf = subleaf.unwrap_or(0); + let actual = read(&partition, leaf, subleaf); + for register in 0..4 { + let mask = entry.masks()[register]; + let differs = (actual[register] ^ entry.values()[register]) & mask; + if differs == 0 { + continue; + } + let bits: Vec<_> = (0..32) + .filter(|bit| differs & (1 << bit) != 0) + .map(|bit| cpuid(leaf, subleaf, register, bit)) + .collect(); + let mapped: Vec<_> = bits + .iter() + .filter(|&&bit| { + FEATURES + .iter() + .any(|feature| feature.cpuid == bit && !feature.is_time_policy()) + }) + .copied() + .collect(); + println!( + "{leaf:#x}.{subleaf} {}: actual {:#010x} profile {:#010x} mask {mask:#010x} ({})", + ["eax", "ebx", "ecx", "edx"][register], + actual[register], + entry.values()[register], + list(&bits) + ); + if !mapped.is_empty() { + failures.push(list(&mapped)); + } + } + } + assert!(failures.is_empty(), "mapped bits differ: {failures:?}"); + } + + fn output(registers: [u32; 4]) -> whp::abi::WHV_CPUID_OUTPUT { + whp::abi::WHV_CPUID_OUTPUT { + Eax: registers[0], + Ebx: registers[1], + Ecx: registers[2], + Edx: registers[3], + } + } + + fn result2( + leaf: u32, + subleaf: Option, + vp: Option, + value: [u32; 4], + mask: [u32; 4], + ) -> whp::abi::WHV_X64_CPUID_RESULT2 { + let mut flags = whp::abi::WHV_X64_CPUID_RESULT2_FLAGS(0); + if subleaf.is_some() { + flags |= whp::abi::WHvX64CpuidResult2FlagSubleafSpecific; + } + if vp.is_some() { + flags |= whp::abi::WHvX64CpuidResult2FlagVpSpecific; + } + whp::abi::WHV_X64_CPUID_RESULT2 { + Function: leaf, + Index: subleaf.unwrap_or(0), + VpIndex: vp.unwrap_or(0), + Flags: flags, + Output: output(value), + Mask: output(mask), + } + } + + /// Programs CPUID results with `CpuidResultList2` (a whole descriptive + /// leaf, a masked feature bit the banks set, a masked bit, and a subleaf + /// before setup; per-VP values after the VPs exist) and checks what + /// `WHvGetVirtualProcessorCpuidOutput` returns. + #[test] + #[ignore = "requires WHP"] + fn cpuid_result_list2_reaches_the_cpuid_output() { + let available = available(); + let native = probe_partition(available).unwrap(); + let native_7 = read(&native, 7, 0); + let native_x7 = read(&native, X7, 0); + let native_xsave1 = read(&native, 0xd, 1); + drop(native); + assert_ne!(native_7[EBX] & 1, 0, "the host lacks FSGSBASE"); + + let descriptors = [0x7603_6301, 0x00f0_b5ff, 0, 0x00c3_0000]; + let results = [ + result2(2, None, None, descriptors, [!0; 4]), + result2(7, Some(0), None, [0; 4], [0, 1, 0, 0]), + result2(X7, None, None, [0, 0, 0, 1 << 8], [0, 0, 0, 1 << 8]), + result2(0xd, Some(1), None, [0x5, 0, 0, 0], [0xf, 0, 0, 0]), + ]; + let partition = match probe_partition_with(available, 2, &results) { + Ok(partition) => partition, + Err(err) => panic!("WHP rejects CpuidResultList2: {err}"), + }; + let mut expected_7 = native_7; + expected_7[EBX] &= !1; + let mut expected_x7 = native_x7; + expected_x7[EDX] |= 1 << 8; + let mut expected_xsave1 = native_xsave1; + expected_xsave1[EAX] = (native_xsave1[EAX] & !0xf) | 0x5; + let mut checks = vec![ + ("leaf 2 (whole leaf)", read(&partition, 2, 0), descriptors), + ( + "leaf 7.0 (FSGSBASE masked clear)", + read(&partition, 7, 0), + expected_7, + ), + ( + "leaf 0x80000007 (invariant TSC masked set)", + read(&partition, X7, 0), + expected_x7, + ), + ( + "leaf 0xd.1 (subleaf, masked)", + read(&partition, 0xd, 1), + expected_xsave1, + ), + ("leaf 2 on VP 1", read_vp(&partition, 1, 2, 0), descriptors), + ]; + let per_vp = [ + result2(0x16, None, Some(0), [100, 200, 300, 0], [!0; 4]), + result2(0x16, None, Some(1), [101, 201, 301, 1], [!0; 4]), + ]; + let mut all = results.to_vec(); + all.extend_from_slice(&per_vp); + match partition.set_property(whp::PartitionProperty::CpuidResultList2(&all)) { + Ok(()) => { + checks.push(( + "leaf 0x16 on VP 0", + read_vp(&partition, 0, 0x16, 0), + [100, 200, 300, 0], + )); + checks.push(( + "leaf 0x16 on VP 1", + read_vp(&partition, 1, 0x16, 0), + [101, 201, 301, 1], + )); + } + Err(err) => println!("per-VP results after setup: rejected: {err}"), + } + let mut failures = Vec::new(); + for (name, actual, expected) in checks { + let verdict = if actual == expected { "ok" } else { "DIFFERS" }; + println!("{name}: {actual:08x?} expected {expected:08x?} {verdict}"); + if actual != expected { + failures.push(name); + } + } + assert!(failures.is_empty(), "{failures:?}"); + } + + /// Times what a WHP `supported_cpu_surface()` costs with a probe + /// partition: creating a one-VP partition with every feature WHP offers, + /// reading its whole CPUID, and tearing it down. + #[test] + #[ignore = "requires WHP"] + fn supported_surface_probe_cost() { + for round in 0..5 { + let start = std::time::Instant::now(); + let partition = probe_partition(available()).unwrap(); + let created = start.elapsed(); + let mut reads = 0; + let entries = cpu_profile::cpuid::enumerate(|leaf, subleaf| { + reads += 1; + partition + .vp(0) + .get_cpuid_output(leaf, subleaf) + .map(|output| [output.Eax, output.Ebx, output.Ecx, output.Edx]) + }) + .unwrap(); + let enumerated = start.elapsed(); + drop(partition); + let total = start.elapsed(); + println!( + "round {round}: create {created:?}, {reads} reads ({} entries) {:?}, teardown {:?}, total {total:?}", + entries.len(), + enumerated - created, + total - enumerated + ); + } + } +} diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index b7e737327dd..b56dca0c0dd 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -48,6 +48,9 @@ use crate::WhpPartitionInner; use crate::WhpProcessor; use crate::WhpResultExt; +use crate::profile_features::WhpFeatures; +use crate::profile_features::profile_features; +use cpu_profile::CpuProfile; use inspect::Inspect; use std::sync::Arc; use std::sync::OnceLock; @@ -72,7 +75,9 @@ use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_MAX_LEAF; use whp::abi::WHV_EXTENDED_VM_EXITS; +use whp::abi::WHV_PROCESSOR_FEATURES; use whp::abi::WHV_PROCESSOR_FEATURES1; +use whp::abi::WHV_PROCESSOR_XSAVE_FEATURES; use whp::abi::WHV_X64_MSR_EXIT_BITMAP; use x86defs::cpuid::CpuidFunction; @@ -171,6 +176,11 @@ pub(crate) struct WhpTimeAbi { /// Processor feature bank 1. #[inspect(hex)] features_bank1: u64, + /// The XSAVE features, when a CPU profile set them. + #[inspect(hex)] + features_xsave: Option, + /// The CPU profile the features derive from, if any. + cpu_profile: Option, /// The effective CPUID record, computed once: it depends only on the /// configuration, because it excludes guest state. #[inspect(skip)] @@ -179,10 +189,15 @@ pub(crate) struct WhpTimeAbi { impl WhpTimeAbi { /// Programs a new WHP partition for the time ABI: the CPUID exits, the - /// processor feature banks, and the unhandled-MSR exits. The partition - /// must have passed [`validate_partition`]. + /// processor features, and the unhandled-MSR exits. The partition must + /// have passed [`validate_partition`]. + /// + /// With a CPU `profile`, the processor feature banks and the XSAVE + /// features derive from it ([`profile_features`]); without one, the banks + /// are WHP's capability without the hidden time features. pub(crate) fn configure( config: &TimeAbiConfig, + profile: Option<&CpuProfile>, whp_config: &mut whp::PartitionConfig, extended_exits: &mut WHV_EXTENDED_VM_EXITS, ) -> Result { @@ -219,7 +234,29 @@ impl WhpTimeAbi { format!("cannot query the processor features: {err}"), ) })?; - let features = processor_features(available); + let (features, xsave) = match profile { + Some(profile) => { + let available_xsave = + whp::capabilities::processor_xsave_features().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot query the XSAVE features: {err}"), + ) + })?; + let derived = profile_features( + profile, + WhpFeatures { + banks: [available.bank0.0, available.bank1.0], + xsave: available_xsave.0, + }, + )?; + let mut features = available; + features.bank0 = WHV_PROCESSOR_FEATURES(derived.banks[0]); + features.bank1 = WHV_PROCESSOR_FEATURES1(derived.banks[1]); + (processor_features(features), Some(derived.xsave)) + } + None => (processor_features(available), None), + }; whp_config .set_property(whp::PartitionProperty::ProcessorFeaturesBanks(features)) .map_err(|err| { @@ -231,6 +268,18 @@ impl WhpTimeAbi { ), ) })?; + if let Some(xsave) = xsave { + whp_config + .set_property(whp::PartitionProperty::ProcessorXsaveFeatures( + WHV_PROCESSOR_XSAVE_FEATURES(xsave), + )) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot set XSAVE features {xsave:#x}: {err}"), + ) + })?; + } let msr_exits = whp::capabilities::x64_msr_exit_bitmap() .map_err(|err| routing_error(format!("cannot query the MSR exits: {err}")))?; @@ -248,10 +297,12 @@ impl WhpTimeAbi { tracing::info!( cpuid_exits = cpuid_exits.len(), + cpu_profile = profile.map(|profile| profile.id()), available_bank0 = format_args!("{:#x}", available.bank0.0), available_bank1 = format_args!("{:#x}", available.bank1.0), bank0 = format_args!("{:#x}", features.bank0.0), bank1 = format_args!("{:#x}", features.bank1.0), + xsave = xsave.map(|xsave| format!("{xsave:#x}")), tsc_invariant_feature = features .bank1 .is_set(WHV_PROCESSOR_FEATURES1::TscInvariantSupport), @@ -264,6 +315,8 @@ impl WhpTimeAbi { cpuid_exits, features_bank0: features.bank0.0, features_bank1: features.bank1.0, + features_xsave: xsave, + cpu_profile: profile.map(|profile| profile.id().to_owned()), effective: OnceLock::new(), }) } @@ -1127,7 +1180,7 @@ mod tests { #[test] fn feature_banks_hide_exactly_the_time_bits() { let mut available = whp::ProcessorFeatures::default(); - available.bank0 = whp::abi::WHV_PROCESSOR_FEATURES(0x1001_f9ff_e7f7_859f); + available.bank0 = WHV_PROCESSOR_FEATURES(0x1001_f9ff_e7f7_859f); available.bank1 = WHV_PROCESSOR_FEATURES1(!0); let features = processor_features(available); assert_eq!(features.bank0, available.bank0); @@ -1525,6 +1578,12 @@ mod whp_tests { /// Returns a time ABI partition with `vp_count` VPs, configured as /// `VtlPartition::new` configures one, and its time ABI state. fn partition(vp_count: u32) -> (whp::Partition, WhpTimeAbi) { + partition_with(vp_count, None) + } + + /// Returns a time ABI partition with `vp_count` VPs whose features derive + /// from `profile`, if any. + fn partition_with(vp_count: u32, profile: Option<&CpuProfile>) -> (whp::Partition, WhpTimeAbi) { let config = TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(vp_count, true)), msrs: Arc::new(TimeAbiMsrs::new()), @@ -1534,7 +1593,8 @@ mod whp_tests { .set_property(whp::PartitionProperty::ProcessorCount(vp_count)) .unwrap(); let mut exits = WHV_EXTENDED_VM_EXITS(0); - let time_abi = WhpTimeAbi::configure(&config, &mut whp_config, &mut exits).unwrap(); + let time_abi = + WhpTimeAbi::configure(&config, profile, &mut whp_config, &mut exits).unwrap(); whp_config .set_property(whp::PartitionProperty::LocalApicEmulationMode( whp::abi::WHvX64LocalApicEmulationModeXApic, @@ -1614,6 +1674,63 @@ mod whp_tests { ); } + /// Configures a partition from this host's CPU profile, as partitions + /// will once `TimeAbiConfig` carries the profile, and checks the static + /// obligations and that the guest CPUID equals the profile's wherever the + /// profile pins it. + #[test] + #[ignore = "requires WHP"] + fn host_profile_configures_the_partition() { + let profile = match cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) { + Ok(profile) => profile, + Err(err) => { + println!("skipped: no profile for this host: {err}"); + return; + } + }; + let (partition, time_abi) = partition_with(4, Some(profile)); + assert_eq!(time_abi.cpu_profile.as_deref(), Some(profile.id())); + assert!(time_abi.features_xsave.is_some()); + check_native_sentinels(|function| native(&partition, function, 0)).unwrap(); + check_feature_banks(|function, index| native(&partition, function, index)).unwrap(); + + let cpuid = time_abi.partition_cpuid(Vec::new()).unwrap(); + let record = effective_cpuid(&cpuid, |function, index| { + native(&partition, function, index).map(|result| cpuid.result(function, index, &result)) + }) + .unwrap(); + let record = CpuidLeafSet::new(record); + let mut lookup = |function, index| record.result(function, index, &[0; 4]); + check_identity(&mut lookup, 4).unwrap(); + check_time_bits(&mut lookup, true).unwrap(); + + // What the guest sees: the time ABI CPUID over WHP's results. + let mut differences = Vec::new(); + for entry in profile.cpuid() { + let (function, index) = entry.key(); + let index = index.unwrap_or(0); + let native = native(&partition, function, index).unwrap(); + let guest = cpuid.result(function, index, &native); + let pinned = entry.values().into_iter().zip(entry.masks()); + for (register, (actual, (value, mask))) in guest.into_iter().zip(pinned).enumerate() { + let differs = (actual ^ value) & mask; + if differs != 0 { + differences.push(format!( + "{function:#x}.{index} register {register}: guest {actual:#010x} profile {value:#010x} differing bits {differs:#010x}", + )); + } + } + } + println!( + "{}: bank0 {:#x} bank1 {:#x} xsave {:#x?}", + profile.id(), + time_abi.features_bank0, + time_abi.features_bank1, + time_abi.features_xsave + ); + assert!(differences.is_empty(), "{differences:#?}"); + } + #[test] #[ignore = "requires WHP"] fn time_suspension_is_idempotent() { From 2fc7f0336a87a606cb765dc316245b602af322fa Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 10:30:39 -0700 Subject: [PATCH 198/304] cpu_profile: pin the generic brand of each generation The time ABI spec settles the profile brand string as generic per generation: "Intel(R) Xeon(R) Processor ()" with the generation's display name, zero-padded and without a frequency. Every host of a generation then presents the same brand whatever its SKU, and deriving a profile no longer needs the source hosts to agree on one. Give each known generation its brand, take the brand leaves from it instead of from the hosts, and drop the error for hosts whose brands differ. Regenerate the three v1 profiles from the same fleet fingerprints: only CPUID 0x80000002..=0x80000004 change, and with them the golden digests. Nothing records these profiles yet: the time ABI has not shipped, and core still uses its interim profile, so no snapshot refers to the old digests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../profiles/intel.emeraldrapids.v1.json | 22 +++--- .../profiles/intel.icelake-sp.v1.json | 16 ++-- .../profiles/intel.skylake-sp.v1.json | 14 ++-- vmm_core/cpu_profile/src/catalog.rs | 6 +- vmm_core/cpu_profile/src/derive.rs | 77 ++++++++++++------- 5 files changed, 80 insertions(+), 55 deletions(-) diff --git a/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json b/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json index 24c030d7054..7a58a9a81e2 100644 --- a/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json +++ b/vmm_core/cpu_profile/profiles/intel.emeraldrapids.v1.json @@ -634,10 +634,10 @@ ], "subleaf": null, "value": [ - "0x45544e49", - "0x2952284c", - "0x4f455820", - "0x2952284e" + "0x65746e49", + "0x2952286c", + "0x6f655820", + "0x2952286e" ] }, { @@ -650,10 +650,10 @@ ], "subleaf": null, "value": [ - "0x414c5020", - "0x554e4954", - "0x3538204d", - "0x00433337" + "0x6f725020", + "0x73736563", + "0x2820726f", + "0x72656d45" ] }, { @@ -666,9 +666,9 @@ ], "subleaf": null, "value": [ - "0x00000000", - "0x00000000", - "0x00000000", + "0x20646c61", + "0x69706152", + "0x00297364", "0x00000000" ] }, diff --git a/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json b/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json index 83eca941d5e..495ffba7da6 100644 --- a/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json +++ b/vmm_core/cpu_profile/profiles/intel.icelake-sp.v1.json @@ -666,10 +666,10 @@ ], "subleaf": null, "value": [ - "0x616c5020", - "0x756e6974", - "0x3338206d", - "0x20433037" + "0x6f725020", + "0x73736563", + "0x2820726f", + "0x20656349" ] }, { @@ -682,10 +682,10 @@ ], "subleaf": null, "value": [ - "0x20555043", - "0x2e322040", - "0x48473038", - "0x0000007a" + "0x656b614c", + "0x2950532d", + "0x00000000", + "0x00000000" ] }, { diff --git a/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json b/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json index 8c97fc3f233..353fa43cf72 100644 --- a/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json +++ b/vmm_core/cpu_profile/profiles/intel.skylake-sp.v1.json @@ -586,10 +586,10 @@ ], "subleaf": null, "value": [ - "0x6c695320", - "0x20726576", - "0x34313134", - "0x55504320" + "0x6f725020", + "0x73736563", + "0x2820726f", + "0x6c796b53" ] }, { @@ -602,9 +602,9 @@ ], "subleaf": null, "value": [ - "0x32204020", - "0x4730322e", - "0x00007a48", + "0x2d656b61", + "0x00295053", + "0x00000000", "0x00000000" ] }, diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index eb91a5cc2d8..6b803350eed 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -26,15 +26,15 @@ struct Pinned { const PINNED: &[Pinned] = &[ Pinned { json: include_str!("../profiles/intel.skylake-sp.v1.json"), - digest: "sha256:e1c8e7965ec04693b2e3bd97eb93da55a2dc518118e48b563241772190d4b0a8", + digest: "sha256:b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492", }, Pinned { json: include_str!("../profiles/intel.icelake-sp.v1.json"), - digest: "sha256:5dbabb30bef86017f5d0858c2f3c056d345bfd33698e2f69877150a9cc66f812", + digest: "sha256:a35f3bb9bc30337b8b76af4939d5914f878ac7dc2a2d6d9d6bf4d8278b91f301", }, Pinned { json: include_str!("../profiles/intel.emeraldrapids.v1.json"), - digest: "sha256:84106dd216e6dc858c1e8f7813eff29b0ee61ab7968b676c74efb268f9ed700f", + digest: "sha256:73c084783f26df29871c72200ea34470e4afec6187bf96de6ac051dbaed6727d", }, ]; diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs index 07ab70ecc71..da1daf8d07f 100644 --- a/vmm_core/cpu_profile/src/derive.rs +++ b/vmm_core/cpu_profile/src/derive.rs @@ -19,7 +19,10 @@ //! 3. The XSAVE features are the intersection, within [`ALLOWED_XCR0`], and //! the XSAVE leaf is rebuilt from them; every host must agree on the //! layout. A feature whose XSAVE state is not enabled is cleared. -//! 4. The brand string is the hosts' common brand. +//! 4. The brand string is generic per generation, +//! `Intel(R) Xeon(R) Processor ()` without a frequency +//! ([`KnownGeneration::brand`]), so every host of a generation presents +//! it whatever its SKU, and the hosts' brands are not compared. //! 5. `IA32_ARCH_CAPABILITIES` is pinned in [`ARCH_CAPABILITIES_PINNED_MASK`] //! to what every backend can present: KVM's value, and for MSHV and WHP //! the bits their processor feature banks derive. `ITS_NO` is pinned @@ -110,6 +113,9 @@ pub struct KnownGeneration { pub cpus: &'static [(u32, u32, [u32; 2])], /// A human-readable name. pub description: &'static str, + /// The brand string of every profile of the generation: generic, with + /// the generation's display name and without a SKU or frequency. + pub brand: &'static str, } impl KnownGeneration { @@ -138,6 +144,7 @@ pub const KNOWN_GENERATIONS: [KnownGeneration; 3] = [ // Steppings 5 to 7 are Cascade Lake, and 10 and 11 Cooper Lake. cpus: &[(6, 85, [0, 4])], description: "Intel Xeon Scalable, first generation (Skylake-SP)", + brand: "Intel(R) Xeon(R) Processor (Skylake-SP)", }, KnownGeneration { vendor: "GenuineIntel", @@ -145,6 +152,7 @@ pub const KNOWN_GENERATIONS: [KnownGeneration; 3] = [ name: "icelake-sp", cpus: &[(6, 106, [0, 15])], description: "Intel Xeon Scalable, third generation (Ice Lake-SP)", + brand: "Intel(R) Xeon(R) Processor (Ice Lake-SP)", }, KnownGeneration { vendor: "GenuineIntel", @@ -152,6 +160,7 @@ pub const KNOWN_GENERATIONS: [KnownGeneration; 3] = [ name: "emeraldrapids", cpus: &[(6, 207, [0, 15])], description: "Intel Xeon Scalable, fifth generation (Emerald Rapids)", + brand: "Intel(R) Xeon(R) Processor (Emerald Rapids)", }, ]; @@ -188,9 +197,6 @@ pub enum DeriveError { /// The tied values and their votes. values: String, }, - /// The hosts report different brand strings. - #[error("the hosts report different brand strings: {0:?}")] - Brand(Vec), /// The hosts disagree on the layout of an XSAVE component. #[error("the hosts disagree on the layout of XSAVE component {0}")] XsaveLayout(u32), @@ -255,16 +261,8 @@ pub fn derive_profile( let mut leaves = apply_policy(&combined)?; - // The brand string. - let brands = fingerprints - .iter() - .map(|fingerprint| fingerprint.host.cpu.brand.clone()) - .collect::>(); - if brands.len() != 1 { - return Err(DeriveError::Brand(brands.into_iter().collect())); - } - let brand = brand_leaves(brands.first().expect("one brand")); - for (leaf, value) in (EXTENDED_LEAF_BASE + 2..).zip(brand) { + // The brand string: generic per generation. + for (leaf, value) in (EXTENDED_LEAF_BASE + 2..).zip(brand_leaves(generation.brand)) { if let Some(entry) = leaves.get_mut(&(leaf, None)) { *entry = value; } @@ -405,7 +403,7 @@ pub fn derive_profile( /// Returns the table of a fingerprint's CPUID for combining: without the /// hypervisor range, the XSAVE leaf (rebuilt from the fingerprints' XSAVE -/// information), and the brand string (the hosts' common brand), and with +/// information), and the brand string (generic per generation), and with /// the bits OpenVMM sets per VM cleared, so hosts are compared only on what a /// profile pins. fn table(entries: &[cpuid::CpuidEntry]) -> Table { @@ -783,6 +781,44 @@ mod tests { assert_eq!(derived.cpuid(), pinned.cpuid()); } + /// Returns the brand string of `profile`. + fn brand(profile: &CpuProfile) -> String { + let bytes = (0x8000_0002..=0x8000_0004) + .flat_map(|leaf| profile.lookup(leaf, 0)) + .flat_map(u32::to_le_bytes) + .collect::>(); + let end = bytes.iter().position(|&byte| byte == 0).unwrap(); + String::from_utf8(bytes[..end].to_vec()).unwrap() + } + + #[test] + fn the_brand_is_generic_per_generation() { + for known in &KNOWN_GENERATIONS { + // The brand string holds 47 bytes and a terminator. + assert!(known.brand.len() < 48, "{}", known.brand); + } + for pinned in crate::pinned_profiles() { + assert_eq!(brand(pinned), generation(&pinned.generation().name).brand); + } + + // A host of another SKU derives the same brand. + let pinned = profile(ICELAKE); + let mut other_sku = edited(pinned, "whp", |entries| { + for leaf in 0x8000_0002..=0x8000_0004 { + set(entries, leaf, None, 0, |eax| eax ^ 0x0101_0101); + } + }); + other_sku.host.cpu.brand = "Intel(R) Xeon(R) Gold 6338 CPU @ 2.00GHz".to_owned(); + let derived = derive_profile( + generation("icelake-sp"), + 1, + &[fingerprint(pinned, "kvm"), other_sku], + ) + .unwrap(); + assert_eq!(brand(&derived), "Intel(R) Xeon(R) Processor (Ice Lake-SP)"); + assert_eq!(derived.cpuid(), pinned.cpuid()); + } + #[test] fn intersects_features_and_votes_on_descriptions() { let pinned = profile(ICELAKE); @@ -867,17 +903,6 @@ mod tests { Err(DeriveError::WrongGeneration { .. }) )); - let mut renamed = fingerprint(pinned, "whp"); - renamed.host.cpu.brand.push('!'); - assert!(matches!( - derive_profile( - generation("icelake-sp"), - 1, - &[fingerprint(pinned, "kvm"), renamed] - ), - Err(DeriveError::Brand(_)) - )); - let moved = edited(pinned, "whp", |entries| { set(entries, 0xd, Some(7), 1, |offset| offset + 64); }); From 7449e1abea21fe25c2a35dfa17ef03f6ad07659c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 10:32:12 -0700 Subject: [PATCH 199/304] openvmm_entry: lead the fatal error message with the time ABI code A time ABI failure, such as a partition-creation error from a backend, reaches the process exit message wrapped in several context layers, so its stable [E_*] code appears only in the "Caused by" list. Move the first code in the chain to the front of the message ("fatal error: [E_CODE] ..."), so the harness and the job summary can match the start of the line. Messages that already start with the code, or carry none, are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_entry/src/lib.rs | 2 +- openvmm/openvmm_entry/src/microvm/mod.rs | 1 + openvmm/openvmm_entry/src/microvm/verify.rs | 32 +++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index d1bdc0f3726..1e3e2e5c761 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -167,7 +167,7 @@ pub fn openvmm_main() { let exit_code = match do_main(&mut pidfile_guard) { Ok(code) => code, Err(err) => { - eprintln!("fatal error: {:?}", err); + eprintln!("{}", microvm::fatal_error_message(&err)); 1 } }; diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index e6d5ad76735..5ce9db91163 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -49,6 +49,7 @@ pub(crate) use restore::fresh_microvm_generation_id; pub(crate) use restore::fresh_microvm_restore_packet; pub(crate) use restore::prepare_restore; pub(crate) use restore::validate_restore_contract; +pub(crate) use verify::fatal_error_message; pub(crate) use verify::report_time_abi_verification; use crate::storage_builder::microvm::MicrovmSandboxBlockSource; diff --git a/openvmm/openvmm_entry/src/microvm/verify.rs b/openvmm/openvmm_entry/src/microvm/verify.rs index e403eb7d38f..381888fd181 100644 --- a/openvmm/openvmm_entry/src/microvm/verify.rs +++ b/openvmm/openvmm_entry/src/microvm/verify.rs @@ -104,6 +104,19 @@ fn failure_line(hypervisor: &str, err: &anyhow::Error) -> String { format!("{PREFIX} v=1 status=fail backend={hypervisor} code={code} detail=\"{escaped}\"") } +/// Formats the process's fatal-error message. A time ABI failure leads with +/// its stable code wherever the error chain carries it, so callers can match +/// the start of the message. +pub(crate) fn fatal_error_message(err: &anyhow::Error) -> String { + let chain = format!("{err:#}"); + match time_abi_code(&chain) { + Some(code) if !chain.starts_with(&format!("[{code}]")) => { + format!("fatal error: [{code}] {err:?}") + } + _ => format!("fatal error: {err:?}"), + } +} + /// Returns the first bracketed time ABI code, such as `E_TSC_SYNC_UNSUPPORTED`, /// in an error chain. fn time_abi_code(detail: &str) -> Option<&str> { @@ -174,4 +187,23 @@ mod tests { let long = anyhow::anyhow!("{}", "x".repeat(1000)); assert!(failure_line("kvm", &long).ends_with("...\"")); } + + #[test] + fn fatal_error_message_leads_with_the_time_abi_code() { + let err = anyhow::anyhow!("[E_TSC_SYNC_UNSUPPORTED] no synchronized set") + .context("failed to create the partition") + .context("failed to launch vm worker"); + let message = fatal_error_message(&err); + assert!( + message.starts_with("fatal error: [E_TSC_SYNC_UNSUPPORTED] failed to launch vm worker"), + "{message}" + ); + + let err = anyhow::anyhow!("[E_TEST_HOOK] unknown hook"); + let message = fatal_error_message(&err); + assert_eq!(message, "fatal error: [E_TEST_HOOK] unknown hook"); + + let err = anyhow::anyhow!("no code here"); + assert_eq!(fatal_error_message(&err), "fatal error: no code here"); + } } From f3df8ec3956a0e28efc889da15930a36cde5b292 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 10:54:42 -0700 Subject: [PATCH 200/304] virt: document the time ABI backend call order State the order the backends rely on in the TimeAbiBackend docs: core calls preflight before any VP runs and before any VP state is restored (MSHV briefly freezes partition time and reads VP 0's reset state); core's first effective_cpuid or preflight call comes before any VP runs, and later calls must not depend on guest state; and the synchronized TSC set runs once per restore, after every instantiated VP is bound and restored and before any restored VP runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt/src/time_abi/backend.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index 24efe54906d..bde51743a9d 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -32,10 +32,21 @@ pub trait TimeAbiBackend: Send + Sync { /// installed, that the synchronized TSC set is available, and that the /// guest TSC is not scaled. Fails with `E_IDENTITY_ROUTING`, /// `E_TSC_SYNC_UNSUPPORTED`, or `E_TSC_SCALING_ACTIVE`. + /// + /// Core calls it on cold boot and restore before any VP runs and before + /// any VP state is restored, so it may briefly stop partition time (MSHV + /// probes `TimeFreeze`) and read VP 0's reset state. fn preflight(&self) -> Result; /// Returns the CPUID the backend programmed for VP 0, for the /// effective-CPUID record and check. Fails with `E_CPU_SURFACE`. + /// + /// Core calls it with all VPs stopped: on restore, before any VP runs, for + /// the check, and at capture, after the guest has run, for the record, so + /// the result must not depend on guest state. Core's first call to this + /// method or to [`Self::preflight`] comes before any VP runs, so a + /// backend that can only read VP 0's live view reads it then and keeps + /// it. fn effective_cpuid(&self) -> Result, TimeAbiError>; /// Takes the capture anchor: VP 0's TSC paired with @@ -52,6 +63,10 @@ pub trait TimeAbiBackend: Send + Sync { /// anchor and verifies it by read-back before returning /// (`E_TSC_SYNC_READBACK`). Creating a VP afterwards is an error /// (`E_VP_LATE_CREATION`). + /// + /// Core calls it once per restore, after every instantiated VP is bound + /// (which creates it on MSHV) and its saved state is restored, and before + /// any restored VP runs. A cold boot never calls it. fn set_synchronized_tsc( &self, target: &mut dyn FnMut(&HostTimeSample) -> Result, From 2f04a139e91b8eeb82ff1fdecac49692258c0812 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 10:22:59 -0700 Subject: [PATCH 201/304] virt_kvm: raise #GP for the legacy L2-cache MSRs under the time ABI The time ABI spec makes the legacy P6 L2-cache MSRs (0x88..=0x8a, 0x116, 0x118..=0x11b, and 0x11e) raise #GP on every backend, as MSHV's hypervisor already does: no CPU profile pins them, and a microVM boots its kernel directly and never probes them. The KVM backend stubbed them for the PCAT BIOS in time ABI partitions too (reads 0, writes ignored), and KVM itself serves reads of 0x11e (MSR_IA32_BBL_CR_CTL3). Deny all nine in the time ABI's MSR filter, so every guest access exits to OpenVMM, and raise #GP for them with the hidden TSC MSRs. A test ties the list to the legacy stubs. Partitions without the time ABI are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 78 +++++++++++++++---- 1 file changed, 62 insertions(+), 16 deletions(-) diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index b3e26cbb1ce..6c44fd561db 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -16,13 +16,14 @@ //! capabilities come from the CPUID with the hypervisor range masked, so //! neither `hv1` nor `kvm_clock` is present. //! - **Identity MSRs.** An MSR filter denies `0x40000000..=0x400001ff`, -//! `IA32_TSC_ADJUST`, and `IA32_TSC_DEADLINE`, and -//! `KVM_MSR_EXIT_REASON_FILTER` delivers every guest access to OpenVMM: -//! [`TimeAbiMsrs`] serves the identity range, and the two hidden TSC MSRs -//! raise #GP (with their CPUID bits clear, KVM would still serve -//! `IA32_TSC_ADJUST`, and would read `IA32_TSC_DEADLINE` as 0 and ignore -//! writes). KVM checks the filter before its in-kernel Hyper-V MSRs, so -//! those never see a guest access. +//! `IA32_TSC_ADJUST`, `IA32_TSC_DEADLINE`, and the legacy P6 L2-cache MSRs, +//! and `KVM_MSR_EXIT_REASON_FILTER` delivers every guest access to OpenVMM: +//! [`TimeAbiMsrs`] serves the identity range, and the other MSRs raise #GP +//! (with their CPUID bits clear, KVM would still serve `IA32_TSC_ADJUST`, +//! and would read `IA32_TSC_DEADLINE` as 0 and ignore writes; KVM serves +//! reads of `MSR_IA32_BBL_CR_CTL3`, and OpenVMM stubs the other L2-cache +//! MSRs for the PCAT BIOS). KVM checks the filter before its in-kernel +//! Hyper-V MSRs, so those never see a guest access. //! - **Invariant TSC.** With `"Hv#1"` and `AccessTscInvariantControls` in //! CPUID, KVM (Linux 6.3 and later) hides the invariant-TSC bit of CPUID //! `0x80000007` until its own copy of `HV_X64_MSR_TSC_INVARIANT_CONTROL` is @@ -54,6 +55,7 @@ use super::KvmPartitionInner; use crate::KvmError; use parking_lot::Mutex; +use std::ops::RangeInclusive; use std::sync::Arc; use virt::CpuidLeaf; use virt::CpuidLeafSet; @@ -82,21 +84,44 @@ const MSR_IA32_TSC_ADJUST: u32 = 0x3b; /// `IA32_TSC_DEADLINE`, which the time ABI hides. const MSR_IA32_TSC_DEADLINE: u32 = 0x6e0; -/// The MSRs outside the identity range that raise #GP. +/// The TSC MSRs that the time ABI hides; they raise #GP. const HIDDEN_TSC_MSRS: [u32; 2] = [MSR_IA32_TSC_ADJUST, MSR_IA32_TSC_DEADLINE]; +/// The legacy P6 L2-cache MSRs that the backend stubs for Windows booting +/// from the PCAT BIOS (`MYSTERY_MSRS` in the parent module). No CPU profile +/// pins them and a microVM never probes them, so they raise #GP on a time +/// ABI partition, as on MSHV. KVM itself serves reads of `0x11e` +/// (`MSR_IA32_BBL_CR_CTL3`), so the filter denies them all. +const LEGACY_L2_CACHE_MSRS: [RangeInclusive; 4] = + [0x88..=0x8a, 0x116..=0x116, 0x118..=0x11b, 0x11e..=0x11e]; + +/// Whether a guest access to `msr` raises #GP on a time ABI partition, +/// outside the identity range. +fn is_hidden_msr(msr: u32) -> bool { + HIDDEN_TSC_MSRS.contains(&msr) + || LEGACY_L2_CACHE_MSRS + .iter() + .any(|range| range.contains(&msr)) +} + /// Pairing attempts before an anchor fails: an attempt only misses /// [`MAX_ANCHOR_PAIRING_NS`] when the thread is interrupted between the two /// TSC reads around the host sample. const ANCHOR_ATTEMPTS: usize = 16; /// Returns the MSR filter ranges of the time ABI: deny every guest access to -/// the identity range and to the hidden TSC MSRs, so they exit to OpenVMM. -pub(crate) fn identity_msr_filter() -> [kvm::MsrFilterRange; 3] { +/// the identity range, the hidden TSC MSRs, and the legacy L2-cache MSRs, so +/// they exit to OpenVMM. +pub(crate) fn identity_msr_filter() -> [kvm::MsrFilterRange; 7] { + let [l2_a, l2_b, l2_c, l2_d] = LEGACY_L2_CACHE_MSRS; [ kvm::MsrFilterRange::deny(IDENTITY_MSR_RANGE), kvm::MsrFilterRange::deny(MSR_IA32_TSC_ADJUST..=MSR_IA32_TSC_ADJUST), kvm::MsrFilterRange::deny(MSR_IA32_TSC_DEADLINE..=MSR_IA32_TSC_DEADLINE), + kvm::MsrFilterRange::deny(l2_a), + kvm::MsrFilterRange::deny(l2_b), + kvm::MsrFilterRange::deny(l2_c), + kvm::MsrFilterRange::deny(l2_d), ] } @@ -345,8 +370,8 @@ impl KvmTimeAbi { /// Handles a guest read of `msr` that exited to user space. `None` means /// the MSR is not the time ABI's. pub(crate) fn read_msr(&self, vp: VpIndex, msr: u32) -> Option> { - if HIDDEN_TSC_MSRS.contains(&msr) { - tracelimit::info_ratelimited!(vp = vp.index(), msr, "hidden TSC MSR read raises #GP"); + if is_hidden_msr(msr) { + tracelimit::info_ratelimited!(vp = vp.index(), msr, "hidden MSR read raises #GP"); return Some(Err(MsrError::InvalidAccess)); } self.msrs.read(vp, msr) @@ -364,12 +389,12 @@ impl KvmTimeAbi { msr: u32, value: u64, ) -> Option> { - if HIDDEN_TSC_MSRS.contains(&msr) { + if is_hidden_msr(msr) { tracelimit::info_ratelimited!( vp = vp.index(), msr, value, - "hidden TSC MSR write raises #GP" + "hidden MSR write raises #GP" ); return Some(Err(MsrError::InvalidAccess)); } @@ -630,7 +655,15 @@ mod tests { #[test] fn filter_denies_exactly_the_owned_msrs() { let filter = identity_msr_filter(); - let owned = [(0x4000_0000, 0x4000_01ff), (0x3b, 0x3b), (0x6e0, 0x6e0)]; + let owned = [ + (0x4000_0000, 0x4000_01ff), + (0x3b, 0x3b), + (0x6e0, 0x6e0), + (0x88, 0x8a), + (0x116, 0x116), + (0x118, 0x11b), + (0x11e, 0x11e), + ]; assert_eq!(filter.len(), owned.len()); for (range, (first, last)) in filter.iter().zip(owned) { assert_eq!(range.base, first); @@ -640,6 +673,12 @@ mod tests { } } + #[test] + fn legacy_l2_cache_msrs_are_the_legacy_stubs() { + let hidden: Vec = LEGACY_L2_CACHE_MSRS.into_iter().flatten().collect(); + assert_eq!(hidden, super::super::MYSTERY_MSRS); + } + fn kvm_like_base() -> Vec { vec![ CpuidLeaf::new(0, [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), @@ -982,8 +1021,15 @@ mod tests { assert_eq!(write(MSR_IA32_TSC_ADJUST, 0), Some(Err(()))); assert_eq!(read(MSR_IA32_TSC_DEADLINE), Some(Err(()))); assert_eq!(write(MSR_IA32_TSC_DEADLINE, 1), Some(Err(()))); + // So do the legacy L2-cache MSRs that the legacy path stubs. + for msr in [0x88, 0x89, 0x8a, 0x116, 0x118, 0x119, 0x11a, 0x11b, 0x11e] { + assert_eq!(read(msr), Some(Err(())), "{msr:#x}"); + assert_eq!(write(msr, 0), Some(Err(())), "{msr:#x}"); + } // Other unknown MSRs keep their existing handling. - assert_eq!(read(0x88), None); + for msr in [0x87, 0x8b, 0x117, 0x11c, 0x11f] { + assert_eq!(read(msr), None, "{msr:#x}"); + } assert_eq!(write(0x4000_0200, 0), None); // The invariant TSC control is guest state in the shared handler. assert_eq!(write(MSR_TSC_INVARIANT_CONTROL, 2), Some(Err(()))); From ff13091e9b127c7d865fe5bbbd40a510e6861c55 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 11:20:28 -0700 Subject: [PATCH 202/304] openvmm: carry the selected CPU profile in the time ABI configuration First step of the CPU profile integration. The worker now selects the partition's CPU profile before it creates the partition, and backends can derive their processor features from it: - `auto` or a pinned ID selects a pinned profile whose generation contains the host CPU (E_PROFILE_HOST_UNKNOWN, E_PROFILE_UNKNOWN, E_CPU_GENERATION); a restore uses the profile the snapshot recorded. TimeAbiConfig::cpu_profile carries the ID, which cpu_profile::pinned resolves. - Capture records the pinned profile's canonical document and digest. - The restore preflight verifies a pinned profile record with cpu_profile::restore_profile (E_PROFILE_DIGEST, E_PROFILE_UNKNOWN, E_CPU_GENERATION), keeping each failure's code. - Interim profiles remain for snapshots that recorded one and when --cpu-profile names one; `auto` no longer selects them. TimeAbiConfig::cpuid and the effective-CPUID record are unchanged, so no guest-visible CPUID changes until a backend derives its features from the profile. The virt_mshv and virt_whp test literals gain the new field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + openvmm/openvmm_core/Cargo.toml | 1 + openvmm/openvmm_core/src/worker/dispatch.rs | 43 +++-- .../src/worker/dispatch/time_abi.rs | 119 +++++++++++- openvmm/openvmm_entry/src/microvm/restore.rs | 2 +- openvmm/openvmm_helpers/src/snapshot/time.rs | 176 ++++++++++++++++-- vmm_core/virt/src/time_abi/mod.rs | 29 ++- vmm_core/virt/src/time_abi/surface.rs | 60 +++--- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 2 + vmm_core/virt_whp/src/time_abi.rs | 1 + 10 files changed, 358 insertions(+), 76 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c85947e5d0..bc3351079b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5895,6 +5895,7 @@ dependencies = [ "chipset_legacy", "chipset_resources", "closeable_mutex", + "cpu_profile", "crc32fast", "cxl_spec", "debug_ptr", diff --git a/openvmm/openvmm_core/Cargo.toml b/openvmm/openvmm_core/Cargo.toml index f0b2b0a7469..33e8a02d69d 100644 --- a/openvmm/openvmm_core/Cargo.toml +++ b/openvmm/openvmm_core/Cargo.toml @@ -45,6 +45,7 @@ igvm = { workspace = true, features = ["corim"] } igvm_defs.workspace = true loader.workspace = true virt.workspace = true +cpu_profile.workspace = true vm_loader.workspace = true vmgs.workspace = true vmgs_broker.workspace = true diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index fdb43cefc7d..4b60fb8ed47 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -493,9 +493,9 @@ pub(crate) struct InitializedVm { processor_topology: ProcessorTopology, igvm_file: Option, driver_source: VmTaskDriverSource, - /// The identity MSR handler, when the partition uses the time ABI. + /// The time ABI of the partition, when it uses the time ABI. #[cfg(guest_arch = "x86_64")] - time_abi_msrs: Option>, + time_abi_partition: Option, } trait ExtractTopologyConfig { @@ -1265,13 +1265,21 @@ impl InitializedVm { let user_mode_memory_faults = !lazy_memory_registration; #[cfg(guest_arch = "x86_64")] - let (time_abi_msrs, time_abi_config) = - if cfg.machine_profile == MachineProfile::Microvm && cfg.microvm.time_abi.is_some() { - let (msrs, config) = time_abi::partition_config(processor_topology.vp_count()); - (Some(msrs), Some(config)) - } else { - (None, None) - }; + let (time_abi_partition, time_abi_config) = match &cfg.microvm.time_abi { + Some(parameters) if cfg.machine_profile == MachineProfile::Microvm => { + let cpu_profile = time_abi::select_cpu_profile( + ¶meters.cpu_profile, + &cfg.microvm.hypervisor_id, + )?; + let (msrs, config) = + time_abi::partition_config(processor_topology.vp_count(), cpu_profile.clone()); + ( + Some(time_abi::PartitionTimeAbi { msrs, cpu_profile }), + Some(config), + ) + } + _ => (None, None), + }; #[cfg(guest_arch = "x86_64")] let versioned_cpu_contract = microvm::uses_versioned_cpu_contract(cfg.machine_profile) && time_abi_config.is_none(); @@ -1667,7 +1675,7 @@ impl InitializedVm { igvm_file, driver_source, #[cfg(guest_arch = "x86_64")] - time_abi_msrs, + time_abi_partition, }) } @@ -1701,7 +1709,7 @@ impl InitializedVm { igvm_file, driver_source, #[cfg(guest_arch = "x86_64")] - time_abi_msrs, + time_abi_partition, } = self; let instantiated_vp_count = snapshot_restore.select_instantiated_vps( @@ -1936,8 +1944,8 @@ impl InitializedVm { .unwrap(); #[cfg(guest_arch = "x86_64")] - let time_abi_state = match time_abi_msrs { - Some(msrs) => { + let time_abi_state = match time_abi_partition { + Some(time_abi::PartitionTimeAbi { msrs, cpu_profile }) => { let parameters = cfg .microvm .time_abi @@ -1949,11 +1957,12 @@ impl InitializedVm { saved_state.is_some() == restore.is_some(), "a time ABI restore requires both the saved state and the time ABI restore inputs" ); - let cpu_profile = virt::time_abi::surface::resolve_cpu_profile( - ¶meters.cpu_profile, - hypervisor, - )?; if let Some(input) = restore { + anyhow::ensure!( + input.cpu_profile.id == cpu_profile, + "the partition's CPU profile '{cpu_profile}' is not the snapshot's '{}'", + input.cpu_profile.id + ); time_abi::check_cpu_surface( partition.as_ref(), hypervisor, diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index e17218b979d..ffd0ad21577 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -56,17 +56,65 @@ pub(super) struct TimeAbiState { pub hooks: TimeAbiTestHooks, } +/// The time ABI of a new partition, set up before the partition is created. +pub(super) struct PartitionTimeAbi { + /// The identity MSR handler. + pub msrs: Arc, + /// The ID of the partition's CPU profile. + pub cpu_profile: String, +} + /// Returns the identity MSR handler and the time ABI configuration of a new -/// partition with `vp_count` VPs. -pub(super) fn partition_config(vp_count: u32) -> (Arc, TimeAbiConfig) { +/// partition with `vp_count` VPs and the CPU profile `cpu_profile`. +pub(super) fn partition_config( + vp_count: u32, + cpu_profile: String, +) -> (Arc, TimeAbiConfig) { let msrs = Arc::new(TimeAbiMsrs::new()); let config = TimeAbiConfig { cpuid: Arc::new(virt::time_abi::identity::time_abi_cpuid(vp_count, true)), msrs: msrs.clone(), + cpu_profile, }; (msrs, config) } +/// Selects the CPU profile of a new partition before it is created, and +/// returns its ID. +/// +/// `requested` is `--cpu-profile` on cold boot (`auto` or an ID), or the +/// profile a snapshot recorded on restore. `auto` and a pinned ID select a +/// pinned profile whose generation contains the host CPU +/// (`E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNKNOWN`, `E_CPU_GENERATION`). An +/// interim ID must be the backend's own (`E_PROFILE_UNKNOWN`). +pub(super) fn select_cpu_profile(requested: &str, hypervisor: &str) -> anyhow::Result { + if virt::time_abi::surface::is_interim_cpu_profile(requested) { + virt::time_abi::surface::check_interim_cpu_profile(requested, hypervisor)?; + return Ok(requested.to_owned()); + } + let profile = cpu_profile::select(requested, &host_cpu()?)?; + Ok(profile.id().to_owned()) +} + +/// Returns the vendor and signature of the host CPU (`E_CPU_GENERATION` if +/// the host is not x86-64). +fn host_cpu() -> Result { + // The host CPU is identified with the host's CPUID instruction. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + { + Ok(cpu_profile::HostCpuSignature::current()) + } + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + { + Err(TimeAbiError::new( + TimeAbiCode::CpuGeneration, + "the host CPU cannot be identified on this architecture", + )) + } +} + /// Returns the partition's time ABI backend (`E_TSC_SYNC_UNSUPPORTED` if the /// backend does not implement the time ABI). fn backend<'a>( @@ -167,6 +215,15 @@ pub(super) fn capture_records( let anchor = backend.capture_anchor()?; let identity = virt::time_abi::host::host_identity()?; let effective_cpuid = virt::time_abi::surface::encode_cpuid(&backend.effective_cpuid()?); + // A pinned profile is recorded with its canonical document; an interim + // profile has none. + let (profile_sha256, profile) = match cpu_profile::pinned(&state.report.cpu_profile) { + Some(profile) => { + let encoded = profile.encode(); + (sha2::Sha256::digest(&encoded).to_vec(), encoded) + } + None => (sha2::Sha256::digest(b"").to_vec(), Vec::new()), + }; tracing::info!( tsc = anchor.tsc, utc_ns = anchor.sample.utc_ns, @@ -190,9 +247,8 @@ pub(super) fn capture_records( }, cpu_profile: SnapshotCpuProfile { id: state.report.cpu_profile.clone(), - // The interim profile has an empty document. - sha256: sha2::Sha256::digest(b"").to_vec(), - profile: Vec::new(), + sha256: profile_sha256, + profile, effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), effective_cpuid, capture_cpu_signature: virt::time_abi::surface::host_cpu_signature().unwrap_or(0), @@ -329,6 +385,8 @@ mod tests { use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use vm_topology::processor::VpIndex; + const TEST_PROFILE: &str = "intel.icelake-sp.v1"; + fn test_unit(msrs: Arc) -> TimeAbiUnit { TimeAbiUnit { msrs, @@ -346,7 +404,7 @@ mod tests { #[test] fn unit_saves_restores_and_resets_the_invariant_control() { futures::executor::block_on(async { - let (msrs, config) = partition_config(4); + let (msrs, config) = partition_config(4, TEST_PROFILE.to_owned()); assert!(Arc::ptr_eq(&msrs, &config.msrs)); msrs.write(VpIndex::BSP, MSR_TSC_INVARIANT_CONTROL, 1) .unwrap() @@ -354,7 +412,7 @@ mod tests { let mut unit = test_unit(msrs.clone()); let saved = unit.save().await.unwrap().unwrap(); - let (restored, _) = partition_config(4); + let (restored, _) = partition_config(4, TEST_PROFILE.to_owned()); let mut restored_unit = test_unit(restored.clone()); restored_unit.restore(saved).await.unwrap(); assert_eq!(restored.tsc_invariant_control(), 1); @@ -366,7 +424,7 @@ mod tests { #[test] fn unit_inspects_the_report() { - let (msrs, _) = partition_config(1); + let (msrs, _) = partition_config(1, TEST_PROFILE.to_owned()); let mut unit = test_unit(msrs); let mut inspection = inspect::inspect("", &mut unit); futures::executor::block_on(inspection.resolve()); @@ -388,8 +446,51 @@ mod tests { #[test] fn partition_cpuid_carries_the_identity() { - let (_, config) = partition_config(2); + let (_, config) = partition_config(2, TEST_PROFILE.to_owned()); + assert_eq!(config.cpu_profile, TEST_PROFILE); let mut cpuid = |leaf, subleaf| config.cpuid.result(leaf, subleaf, &[0; 4]); virt::time_abi::identity::check_identity(&mut cpuid, 2).unwrap(); } + + fn code(result: anyhow::Result) -> String { + let message = format!("{:#}", result.unwrap_err()); + message + .strip_prefix('[') + .and_then(|rest| rest.split_once(']')) + .map_or(message.clone(), |(code, _)| code.to_owned()) + } + + #[test] + fn cpu_profile_selection() { + assert_eq!( + select_cpu_profile("interim.host.kvm.v1", "kvm").unwrap(), + "interim.host.kvm.v1" + ); + assert_eq!( + code(select_cpu_profile("interim.host.kvm.v1", "mshv")), + "E_PROFILE_UNKNOWN" + ); + assert_eq!( + code(select_cpu_profile("intel.cascadelake.v1", "kvm")), + "E_PROFILE_UNKNOWN" + ); + + // `auto` selects the pinned profile of the host's generation, if any, + // and a pinned ID selects its profile only in its generation. + let host = host_cpu().unwrap(); + match select_cpu_profile("auto", "whp") { + Ok(id) => assert!( + cpu_profile::check_generation(cpu_profile::pinned(&id).unwrap(), &host).is_ok() + ), + Err(err) => assert_eq!(code(Err(err)), "E_PROFILE_HOST_UNKNOWN"), + } + for profile in cpu_profile::pinned_profiles() { + let selected = select_cpu_profile(profile.id(), "kvm"); + if cpu_profile::check_generation(profile, &host).is_ok() { + assert_eq!(selected.unwrap(), profile.id()); + } else { + assert_eq!(code(selected), "E_CPU_GENERATION"); + } + } + } } diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index b8601800cb4..f36cf3e2a47 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -607,7 +607,7 @@ fn preflight_time_abi_restore( contract, hypervisor, options.cpu_profile, - virt::time_abi::surface::host_cpu_signature(), + openvmm_helpers::snapshot::time::host_cpu(), &destination, &now, options.hooks, diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index 91c696937f2..8e6467ea794 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -252,14 +252,16 @@ pub struct TimeAbiRestorePreflight { /// generation, and the downtime preflight against the destination host's /// identity and clocks. /// -/// Until CPU profiles land, the snapshot's profile must be the interim -/// profile of `hypervisor` and the host must have the capture host's CPU -/// signature; the worker compares the effective CPUID. +/// A snapshot that recorded a pinned profile restores where this OpenVMM pins +/// the same profile and `host` is in its generation. One that recorded an +/// interim profile restores only on the backend that recorded it and on a +/// host with the capture host's CPU signature. In both cases the worker +/// compares the effective CPUID. pub fn preflight_time_abi_restore( contract: &SnapshotMachineContract, hypervisor: &str, requested_cpu_profile: &str, - host_cpu_signature: Option, + host: Option, destination: &HostIdentity, now: &HostTimeSample, hooks: &TimeAbiTestHooks, @@ -277,8 +279,7 @@ pub fn preflight_time_abi_restore( ), )); } - let cpu_profile = surface::resolve_cpu_profile(&record.id, hypervisor)?; - if requested_cpu_profile != "auto" && requested_cpu_profile != record.id { + if requested_cpu_profile != cpu_profile::AUTO && requested_cpu_profile != record.id { return Err(TimeAbiError::new( TimeAbiCode::ProfileUnknown, format!( @@ -287,17 +288,49 @@ pub fn preflight_time_abi_restore( ), )); } + if surface::is_interim_cpu_profile(&record.id) { + check_interim_cpu_profile_restore(record, hypervisor, host)?; + } else { + let host = host.ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::CpuGeneration, + "the host CPU cannot be identified", + ) + })?; + cpu_profile::restore_profile(&record.id, &record.sha256, &record.profile, &host) + .map_err(profile_error)?; + } + let capture = capture_record(time)?; + let downtime = select_downtime(&capture, destination, now, hooks)?; + Ok(TimeAbiRestorePreflight { + capture, + downtime, + generation: time.capture_generation + 1, + cpu_profile: record.id.clone(), + }) +} + +/// Checks the restore of a snapshot that recorded an interim CPU profile: the +/// profile is the backend's, the record embeds no profile document, and the +/// host has the capture host's CPU signature. +fn check_interim_cpu_profile_restore( + record: &SnapshotCpuProfile, + hypervisor: &str, + host: Option, +) -> Result<(), TimeAbiError> { + surface::check_interim_cpu_profile(&record.id, hypervisor)?; if !record.profile.is_empty() { return Err(TimeAbiError::new( TimeAbiCode::ProfileDigest, format!( - "the snapshot's '{}' profile document is not the pinned one", + "the snapshot's interim profile '{}' embeds a profile document", record.id ), )); } - if host_cpu_signature != Some(record.capture_cpu_signature) { - let host = host_cpu_signature.map_or_else( + let signature = host.map(|host| host.signature()); + if signature != Some(record.capture_cpu_signature) { + let host = signature.map_or_else( || "unavailable".to_owned(), |signature| format!("{signature:#010x}"), ); @@ -309,14 +342,30 @@ pub fn preflight_time_abi_restore( ), )); } - let capture = capture_record(time)?; - let downtime = select_downtime(&capture, destination, now, hooks)?; - Ok(TimeAbiRestorePreflight { - capture, - downtime, - generation: time.capture_generation + 1, - cpu_profile, - }) + Ok(()) +} + +/// Converts a CPU profile failure to the time ABI failure with the same code. +pub fn profile_error(error: cpu_profile::ProfileError) -> TimeAbiError { + let code = TimeAbiCode::from_name(error.code.as_str()) + .expect("every CPU profile code is a time ABI code"); + TimeAbiError::new(code, error.message) +} + +/// Returns the vendor and signature of the host CPU, or `None` on a host that +/// is not x86-64. +pub fn host_cpu() -> Option { + // The host CPU is identified with the host's CPUID instruction. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + { + Some(cpu_profile::HostCpuSignature::current()) + } + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + { + None + } } #[cfg(test)] @@ -579,7 +628,8 @@ pub(super) mod tests { contract, hypervisor, requested, - signature, + signature + .map(|signature| cpu_profile::HostCpuSignature::new(*b"GenuineIntel", signature)), destination, now, hooks, @@ -674,12 +724,20 @@ pub(super) mod tests { TimeAbiCode::CpuGeneration ); + let mut other_interim = contract.clone(); + other_interim.cpu_profile.as_mut().unwrap().id = "interim.host.kvm.v1".to_owned(); + assert_eq!( + fail(&other_interim, "whp", "auto", Some(SIGNATURE), &now, &hooks), + TimeAbiCode::ProfileUnknown + ); + + // A pinned ID without its profile document does not verify. let mut pinned = contract.clone(); let record = pinned.cpu_profile.as_mut().unwrap(); - record.id = "intel.icelake-sp.whp.v1".to_owned(); + record.id = "intel.icelake-sp.v1".to_owned(); assert_eq!( fail(&pinned, "whp", "auto", Some(SIGNATURE), &now, &hooks), - TimeAbiCode::ProfileUnknown + TimeAbiCode::ProfileDigest ); let mut document = contract.clone(); @@ -715,4 +773,82 @@ pub(super) mod tests { TimeAbiCode::DowntimeExcessive ); } + + /// A version 6 machine contract like [`interim_restore`]'s, recorded with + /// the pinned profile `id`. + fn pinned_restore(id: &str) -> (SnapshotMachineContract, HostIdentity, HostTimeSample) { + let (mut contract, destination, now) = interim_restore(); + let profile = cpu_profile::pinned(id).unwrap(); + let record = contract.cpu_profile.as_mut().unwrap(); + record.id = id.to_owned(); + record.profile = profile.encode(); + record.sha256 = profile.digest().to_vec(); + (contract, destination, now) + } + + #[test] + fn restore_preflight_checks_a_pinned_profile() { + const ICELAKE: &str = "intel.icelake-sp.v1"; + let icelake_host = cpu_profile::HostCpuSignature::new(*b"GenuineIntel", 0x0006_06a6); + let skylake_host = cpu_profile::HostCpuSignature::new(*b"GenuineIntel", 0x0005_0654); + cpu_profile::check_generation(cpu_profile::pinned(ICELAKE).unwrap(), &icelake_host) + .unwrap(); + let (contract, destination, now) = pinned_restore(ICELAKE); + let hooks = TimeAbiTestHooks::default(); + let run = |contract: &SnapshotMachineContract, requested: &str, host| { + preflight_time_abi_restore(contract, "whp", requested, host, &destination, &now, &hooks) + }; + let code = |result: Result| result.unwrap_err().code; + + for requested in ["auto", ICELAKE] { + let result = run(&contract, requested, Some(icelake_host)).unwrap(); + assert_eq!(result.cpu_profile, ICELAKE); + assert_eq!(result.generation, 4); + } + assert_eq!( + code(run(&contract, "auto", Some(skylake_host))), + TimeAbiCode::CpuGeneration + ); + assert_eq!( + code(run(&contract, "auto", None)), + TimeAbiCode::CpuGeneration + ); + assert_eq!( + code(run(&contract, "intel.skylake-sp.v1", Some(icelake_host))), + TimeAbiCode::ProfileUnknown + ); + + // A valid profile document that this OpenVMM does not pin. + let mut unknown = contract.clone(); + let record = unknown.cpu_profile.as_mut().unwrap(); + record.id = "intel.icelake-sp.v9".to_owned(); + record.profile = String::from_utf8(record.profile.clone()) + .unwrap() + .replace("\"intel.icelake-sp.v1\"", "\"intel.icelake-sp.v9\"") + .into_bytes(); + record.sha256 = sha2::Sha256::digest(&record.profile).to_vec(); + assert_eq!( + code(run(&unknown, "auto", Some(icelake_host))), + TimeAbiCode::ProfileUnknown + ); + + // The embedded document is another profile than the record names. + let mut swapped = contract.clone(); + let record = swapped.cpu_profile.as_mut().unwrap(); + record.profile = cpu_profile::pinned("intel.skylake-sp.v1").unwrap().encode(); + record.sha256 = sha2::Sha256::digest(&record.profile).to_vec(); + assert_eq!( + code(run(&swapped, "auto", Some(icelake_host))), + TimeAbiCode::ProfileDigest + ); + } + + #[test] + fn profile_errors_keep_their_codes() { + for code in cpu_profile::ProfileErrorCode::ALL { + let error = profile_error(cpu_profile::ProfileError::new(code, "message")); + assert_eq!(error.code.as_str(), code.as_str()); + assert_eq!(error.message, "message"); + } + } } diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index a49490a7f1c..9b073d5bef8 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -75,6 +75,11 @@ macro_rules! time_abi_codes { $(TimeAbiCode::$variant => $name,)* } } + + /// Returns the code with the stable name `name`, if any. + pub fn from_name(name: &str) -> Option { + Self::ALL.iter().copied().find(|code| code.as_str() == name) + } } }; } @@ -392,14 +397,21 @@ pub fn check_command_line_clock_tokens(cmdline: &str) -> Result<(), TimeAbiError #[cfg(guest_arch = "x86_64")] #[derive(Debug, Clone)] pub struct TimeAbiConfig { - /// CPUID results the backend programs on every VP. Until CPU profiles - /// land this is [`identity::time_abi_cpuid`]: the identity leaves, the - /// explicit zero leaves, and the CPU time bits, applied over the - /// backend's own CPUID. + /// CPUID results the backend programs on every VP. Until the profile's + /// effective CPUID replaces it, this is [`identity::time_abi_cpuid`]: the + /// identity leaves, the explicit zero leaves, and the CPU time bits, + /// applied over the backend's own CPUID. pub cpuid: std::sync::Arc, /// The identity MSR handler, shared with the worker's `time-abi` state /// unit. pub msrs: std::sync::Arc, + /// The ID of the partition's CPU profile, selected or restored before the + /// partition is created. A pinned ID names the profile that + /// `cpu_profile::pinned` returns, from which a backend derives its + /// processor features. An interim ID ([`surface::is_interim_cpu_profile`]) + /// names no profile: the backend presents its own CPU features, as before + /// CPU profiles. + pub cpu_profile: String, } #[cfg(test)] @@ -447,6 +459,15 @@ mod tests { ); } + #[test] + fn codes_parse_from_their_names() { + for &code in TimeAbiCode::ALL { + assert_eq!(TimeAbiCode::from_name(code.as_str()), Some(code)); + } + assert_eq!(TimeAbiCode::from_name("E_NOT_A_CODE"), None); + assert_eq!(TimeAbiCode::from_name("e_test_hook"), None); + } + #[test] fn error_display_leads_with_the_code() { let error = TimeAbiError::new(TimeAbiCode::TscRateTolerance, "rate too far"); diff --git a/vmm_core/virt/src/time_abi/surface.rs b/vmm_core/virt/src/time_abi/surface.rs index d8c0ee0a748..bb36cee5d61 100644 --- a/vmm_core/virt/src/time_abi/surface.rs +++ b/vmm_core/virt/src/time_abi/surface.rs @@ -2,8 +2,8 @@ // Licensed under the MIT License. //! The effective CPU surface record of a snapshot: the canonical CPUID -//! encoding and its comparison, and the interim CPU profile used until CPU -//! profiles land. +//! encoding and its comparison, and the interim CPU profiles, which name the +//! backends' own CPU features. use super::TimeAbiCode; use super::TimeAbiError; @@ -151,25 +151,36 @@ pub fn check_effective_cpuid(effective: &[CpuidLeaf], recorded: &[u8]) -> Result )) } -/// Returns the CPU profile ID recorded until CPU profiles land: the backend's -/// own CPUID with the time ABI applied. Such a snapshot restores only on the -/// same backend and host CPU signature, with an identical effective CPUID. +/// Returns the ID of the interim CPU profile of `hypervisor`: the backend's +/// own CPU features with the time ABI applied. A snapshot that records it +/// restores only on the same backend and host CPU signature, with an +/// identical effective CPUID. pub fn interim_cpu_profile_id(hypervisor: &str) -> String { - format!("interim.host.{hypervisor}.v1") + format!("{INTERIM_CPU_PROFILE_PREFIX}{hypervisor}.v1") } -/// Resolves a requested CPU profile (`auto` or a profile ID) on `hypervisor` -/// to the profile ID a VM uses (`E_PROFILE_UNKNOWN`). Until CPU profiles land, -/// only the interim profile exists. -pub fn resolve_cpu_profile(requested: &str, hypervisor: &str) -> Result { +/// The prefix of every interim CPU profile ID. +const INTERIM_CPU_PROFILE_PREFIX: &str = "interim.host."; + +/// Returns whether `id` names an interim CPU profile rather than a pinned +/// one. A VM uses an interim profile only when `--cpu-profile` requests it by +/// ID or when it restores a snapshot that recorded one; `auto` selects a +/// pinned profile. +pub fn is_interim_cpu_profile(id: &str) -> bool { + id.starts_with(INTERIM_CPU_PROFILE_PREFIX) +} + +/// Checks that the interim CPU profile `id` is the one of `hypervisor` +/// (`E_PROFILE_UNKNOWN`). +pub fn check_interim_cpu_profile(id: &str, hypervisor: &str) -> Result<(), TimeAbiError> { let interim = interim_cpu_profile_id(hypervisor); - if requested == "auto" || requested == interim { - Ok(interim) + if id == interim { + Ok(()) } else { Err(TimeAbiError::new( TimeAbiCode::ProfileUnknown, format!( - "CPU profile '{requested}' is not pinned in this OpenVMM; the {hypervisor} backend offers '{interim}'" + "CPU profile '{id}' is not the interim profile of the {hypervisor} backend, '{interim}'" ), )) } @@ -304,21 +315,20 @@ mod tests { } #[test] - fn interim_cpu_profile_resolution() { - assert_eq!( - resolve_cpu_profile("auto", "kvm").unwrap(), - "interim.host.kvm.v1" - ); - assert_eq!( - resolve_cpu_profile("interim.host.mshv.v1", "mshv").unwrap(), - "interim.host.mshv.v1" - ); - for (requested, hypervisor) in [ + fn interim_cpu_profiles() { + assert_eq!(interim_cpu_profile_id("kvm"), "interim.host.kvm.v1"); + assert!(is_interim_cpu_profile("interim.host.mshv.v1")); + for id in ["auto", "intel.icelake-sp.v1", "", "interim"] { + assert!(!is_interim_cpu_profile(id), "{id}"); + } + check_interim_cpu_profile("interim.host.mshv.v1", "mshv").unwrap(); + for (id, hypervisor) in [ ("interim.host.kvm.v1", "mshv"), - ("intel.icelake-sp.kvm.v1", "kvm"), + ("interim.host.kvm.v2", "kvm"), + ("intel.icelake-sp.v1", "kvm"), ] { assert_eq!( - resolve_cpu_profile(requested, hypervisor).unwrap_err().code, + check_interim_cpu_profile(id, hypervisor).unwrap_err().code, TimeAbiCode::ProfileUnknown ); } diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 5e92f83ae94..86e8418998b 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -1145,6 +1145,7 @@ mod tests { let config = TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: String::new(), }; let state = MshvTimeAbi::new(&config, &partition_cpuid(Vec::new(), &config.cpuid)); state.check_vp_creation(VpIndex::new(4)).unwrap(); @@ -1295,6 +1296,7 @@ mod hw { time_abi: Some(TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(VP_CAPACITY, true)), msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: String::new(), }), }) .unwrap() diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index b56dca0c0dd..35d6462207a 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -1587,6 +1587,7 @@ mod whp_tests { let config = TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(vp_count, true)), msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: String::new(), }; let mut whp_config = whp::PartitionConfig::new().unwrap(); whp_config From 79b3d1e98e5bdede1e6d092c55277b24f04d018b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 11:53:58 -0700 Subject: [PATCH 203/304] virt_whp: derive processor features from the partition's CPU profile Core carries the selected CPU profile in TimeAbiConfig::cpu_profile (fa64e3b37). WhpTimeAbi::configure now resolves it. A pinned profile's processor feature banks and XSAVE features derive with profile_features, which 41e1c9cdc validated on prometheus28 and the 8370C and 8573C runners. An interim profile keeps WHP's capability without the hidden time features, as before. An ID that is neither interim nor pinned fails with E_PROFILE_UNKNOWN. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/lib.rs | 3 -- vmm_core/virt_whp/src/time_abi.rs | 49 ++++++++++++++++++++++++++----- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index db1fd0e618f..b5f8cb5991f 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -1611,9 +1611,6 @@ impl VtlPartition { let time_abi = match &config.time_abi { Some(time_abi) => Some(time_abi::WhpTimeAbi::configure( time_abi, - // The features derive from the CPU profile once - // `TimeAbiConfig` carries it. - None, &mut whp_config, &mut extended_exits, )?), diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 35d6462207a..8c5bee342be 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -74,6 +74,7 @@ use virt::time_abi::host::sample_host_time; use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_MAX_LEAF; +use virt::time_abi::surface::is_interim_cpu_profile; use whp::abi::WHV_EXTENDED_VM_EXITS; use whp::abi::WHV_PROCESSOR_FEATURES; use whp::abi::WHV_PROCESSOR_FEATURES1; @@ -192,15 +193,16 @@ impl WhpTimeAbi { /// processor features, and the unhandled-MSR exits. The partition must /// have passed [`validate_partition`]. /// - /// With a CPU `profile`, the processor feature banks and the XSAVE - /// features derive from it ([`profile_features`]); without one, the banks - /// are WHP's capability without the hidden time features. + /// With a pinned CPU profile (`config.cpu_profile`), the processor feature + /// banks and the XSAVE features derive from it ([`profile_features`]); + /// with an interim profile, the banks are WHP's capability without the + /// hidden time features. pub(crate) fn configure( config: &TimeAbiConfig, - profile: Option<&CpuProfile>, whp_config: &mut whp::PartitionConfig, extended_exits: &mut WHV_EXTENDED_VM_EXITS, ) -> Result { + let profile = pinned_profile(&config.cpu_profile)?; let amd = match whp::capabilities::processor_vendor() { Ok(whp::abi::WHvProcessorVendorIntel) => false, Ok(whp::abi::WHvProcessorVendorAmd | whp::abi::WHvProcessorVendorHygon) => true, @@ -338,6 +340,22 @@ fn routing_error(message: impl Into) -> TimeAbiError { TimeAbiError::new(TimeAbiCode::IdentityRouting, message) } +/// Returns the pinned CPU profile that `id` names, or `None` for an interim +/// profile, which keeps WHP's own CPU features. Core selects and verifies the +/// profile before the partition exists, so an unknown ID is an internal +/// error (`E_PROFILE_UNKNOWN`). +fn pinned_profile(id: &str) -> Result, TimeAbiError> { + if is_interim_cpu_profile(id) { + return Ok(None); + } + cpu_profile::pinned(id).map(Some).ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ProfileUnknown, + format!("CPU profile {id:?} is not pinned in this OpenVMM"), + ) + }) +} + /// Fails unless the partition can carry the time ABI: no isolation, no /// Hyper-V guest interface (whose synthetic processor features would serve /// the identity CPUID and MSRs natively, and which VTL2 requires), no nested @@ -1072,6 +1090,21 @@ mod tests { const VP_COUNT: u32 = 8; + #[test] + fn cpu_profile_ids_resolve_to_pinned_profiles() { + let interim = virt::time_abi::surface::interim_cpu_profile_id("whp"); + assert!(pinned_profile(&interim).unwrap().is_none()); + let skylake = pinned_profile("intel.skylake-sp.v1").unwrap().unwrap(); + assert_eq!(skylake.id(), "intel.skylake-sp.v1"); + for id in ["intel.skylake-sp.whp.v1", ""] { + assert_eq!( + pinned_profile(id).unwrap_err().code, + TimeAbiCode::ProfileUnknown, + "{id:?}" + ); + } + } + /// WHP's own results on an Intel host without synthetic features: no /// hypervisor leaves, and every time bit wrong. fn whp_cpuid(function: u32, index: u32) -> [u32; 4] { @@ -1587,15 +1620,17 @@ mod whp_tests { let config = TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(vp_count, true)), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: String::new(), + cpu_profile: profile.map_or_else( + || virt::time_abi::surface::interim_cpu_profile_id("whp"), + |profile| profile.id().to_owned(), + ), }; let mut whp_config = whp::PartitionConfig::new().unwrap(); whp_config .set_property(whp::PartitionProperty::ProcessorCount(vp_count)) .unwrap(); let mut exits = WHV_EXTENDED_VM_EXITS(0); - let time_abi = - WhpTimeAbi::configure(&config, profile, &mut whp_config, &mut exits).unwrap(); + let time_abi = WhpTimeAbi::configure(&config, &mut whp_config, &mut exits).unwrap(); whp_config .set_property(whp::PartitionProperty::LocalApicEmulationMode( whp::abi::WHvX64LocalApicEmulationModeXApic, From 17b81a8413a1c0577f6706c7ade59e18432cfc7b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 12:39:23 -0700 Subject: [PATCH 204/304] virt_whp: program the CPU profile's CPUID leaves with CpuidResultList2 With a pinned CPU profile, the time ABI partition now programs every profile leaf except the VM-owned topology leaves as a CpuidResultList2 entry with the profile's mask. The pinned bits (features, cache descriptors, the brand string) come from the profile; runtime state such as OSXSAVE and the VM-owned topology fields still come from WHP and OpenVMM. The profiles' generic brand strings (0cd06bafd) differ from every host's native brand, so WHP must program them; the same holds for the descriptive leaves on hosts of other SKUs. A hardware test runs CPUID in a real-mode guest. A leaf in both the exit list and CpuidResultList2 exits with the programmed result as its default, which OpenVMM's exit handler starts from, so the time ABI's exiting leaves present the profile too. A leaf without an exit returns the programmed result directly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/profile_features.rs | 261 +++++++++++++++++++++- vmm_core/virt_whp/src/time_abi.rs | 19 ++ 2 files changed, 270 insertions(+), 10 deletions(-) diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index c4c060ec169..2c7ca44b011 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -12,9 +12,10 @@ //! value. The partition then behaves as the profile's CPUID describes. //! //! Bits without a CPUID feature, such as the nested-paging details, keep WHP's -//! capability. Features that no bit controls (for example PKU) and the time -//! policy bits a host lacks (ARAT and invariant TSC on Azure) reach the guest -//! through the CPUID results instead. +//! capability. Features that no bit controls (for example PKU), the +//! descriptive leaves (caches, the brand string), and the time policy bits a +//! host lacks (ARAT and invariant TSC on Azure) reach the guest through the +//! CPUID results ([`profile_cpuid_results`] and the time ABI CPUID). //! //! The hardware test `features_control_the_mapped_cpuid_bits` checks the table //! against what WHP presents on a host. @@ -27,6 +28,10 @@ use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; use virt::time_abi::TimeAbiCode; use virt::time_abi::TimeAbiError; +use whp::abi::WHV_CPUID_OUTPUT; +use whp::abi::WHV_X64_CPUID_RESULT2; +use whp::abi::WHV_X64_CPUID_RESULT2_FLAGS; +use whp::abi::WHvX64CpuidResult2FlagSubleafSpecific; /// `IA32_ARCH_CAPABILITIES`. const MSR_ARCH_CAPABILITIES: u32 = 0x10a; @@ -370,6 +375,48 @@ pub(crate) fn profile_features( Ok(features) } +/// Returns the CPUID results that present `profile`'s pinned bits: one +/// `CpuidResultList2` entry per profile leaf, with the profile's mask, so the +/// pinned bits (features, descriptors, the brand string) come from the +/// profile and the rest (runtime state such as OSXSAVE, and the VM-owned +/// topology fields) from WHP and OpenVMM. The VM-owned topology leaves are +/// left to OpenVMM's CPUID exits. +/// +/// WHP returns these results for leaves without an exit, and as the default +/// result of a CPUID exit, on which OpenVMM applies the time ABI CPUID. +pub(crate) fn profile_cpuid_results(profile: &CpuProfile) -> Vec { + profile + .cpuid() + .iter() + .filter_map(|entry| { + let (leaf, subleaf) = entry.key(); + let mask = entry.masks(); + if cpu_profile::VM_OWNED_LEAVES.contains(&leaf) || mask == [0; 4] { + return None; + } + let values = entry.values(); + let output = |registers: [u32; 4]| WHV_CPUID_OUTPUT { + Eax: registers[EAX], + Ebx: registers[EBX], + Ecx: registers[ECX], + Edx: registers[EDX], + }; + Some(WHV_X64_CPUID_RESULT2 { + Function: leaf, + Index: subleaf.unwrap_or(0), + VpIndex: 0, + Flags: if subleaf.is_some() { + WHvX64CpuidResult2FlagSubleafSpecific + } else { + WHV_X64_CPUID_RESULT2_FLAGS(0) + }, + Output: output(std::array::from_fn(|i| values[i] & mask[i])), + Mask: output(mask), + }) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -515,6 +562,81 @@ mod tests { assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 27)), Pinned::Unpinned); assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 26)), Pinned::Set); } + + #[test] + fn profile_results_present_every_pinned_leaf() { + for (id, _) in HOSTS { + let profile = profile(id); + let results = profile_cpuid_results(profile); + let pinned_entries = profile + .cpuid() + .iter() + .filter(|entry| { + !cpu_profile::VM_OWNED_LEAVES.contains(&entry.key().0) + && entry.masks() != [0; 4] + }) + .count(); + assert_eq!(results.len(), pinned_entries, "{id}"); + for result in &results { + assert!(!cpu_profile::VM_OWNED_LEAVES.contains(&result.Function)); + let entry = profile + .cpuid() + .iter() + .find(|entry| { + let (leaf, subleaf) = entry.key(); + leaf == result.Function + && subleaf.unwrap_or(0) == result.Index + && subleaf.is_some() + == result.Flags.is_set(WHvX64CpuidResult2FlagSubleafSpecific) + }) + .unwrap(); + let output = [ + result.Output.Eax, + result.Output.Ebx, + result.Output.Ecx, + result.Output.Edx, + ]; + let mask = [ + result.Mask.Eax, + result.Mask.Ebx, + result.Mask.Ecx, + result.Mask.Edx, + ]; + assert_eq!(mask, entry.masks(), "{id} {:#x}", result.Function); + for register in 0..4 { + assert_eq!( + output[register], + entry.values()[register] & mask[register], + "{id} {:#x}", + result.Function + ); + } + } + // The brand string is the profile's generic one, not the host's. + let brand: String = [0x8000_0002, 0x8000_0003, 0x8000_0004] + .iter() + .flat_map(|&leaf| { + let result = results.iter().find(|r| r.Function == leaf).unwrap(); + [ + result.Output.Eax, + result.Output.Ebx, + result.Output.Ecx, + result.Output.Edx, + ] + }) + .flat_map(u32::to_le_bytes) + .take_while(|&byte| byte != 0) + .map(char::from) + .collect(); + assert!( + brand.starts_with("Intel(R) Xeon(R) Processor ("), + "{id}: {brand}" + ); + // Runtime state stays with the hypervisor: OSXSAVE is unpinned. + let leaf1 = results.iter().find(|r| r.Function == 1).unwrap(); + assert_eq!(leaf1.Mask.Ecx & (1 << 27), 0, "{id}"); + } + } } #[cfg(test)] @@ -560,7 +682,7 @@ mod whp_tests { fn probe_partition_with( features: WhpFeatures, vp_count: u32, - results: &[whp::abi::WHV_X64_CPUID_RESULT2], + results: &[WHV_X64_CPUID_RESULT2], ) -> Result { let mut config = whp::PartitionConfig::new()?; config.set_property(whp::PartitionProperty::ProcessorCount(vp_count))?; @@ -768,8 +890,8 @@ mod whp_tests { assert!(failures.is_empty(), "mapped bits differ: {failures:?}"); } - fn output(registers: [u32; 4]) -> whp::abi::WHV_CPUID_OUTPUT { - whp::abi::WHV_CPUID_OUTPUT { + fn output(registers: [u32; 4]) -> WHV_CPUID_OUTPUT { + WHV_CPUID_OUTPUT { Eax: registers[0], Ebx: registers[1], Ecx: registers[2], @@ -783,15 +905,15 @@ mod whp_tests { vp: Option, value: [u32; 4], mask: [u32; 4], - ) -> whp::abi::WHV_X64_CPUID_RESULT2 { - let mut flags = whp::abi::WHV_X64_CPUID_RESULT2_FLAGS(0); + ) -> WHV_X64_CPUID_RESULT2 { + let mut flags = WHV_X64_CPUID_RESULT2_FLAGS(0); if subleaf.is_some() { - flags |= whp::abi::WHvX64CpuidResult2FlagSubleafSpecific; + flags |= WHvX64CpuidResult2FlagSubleafSpecific; } if vp.is_some() { flags |= whp::abi::WHvX64CpuidResult2FlagVpSpecific; } - whp::abi::WHV_X64_CPUID_RESULT2 { + WHV_X64_CPUID_RESULT2 { Function: leaf, Index: subleaf.unwrap_or(0), VpIndex: vp.unwrap_or(0), @@ -914,4 +1036,123 @@ mod whp_tests { ); } } + + /// Runs `cpuid; hlt` in real mode at the reset vector and checks how + /// `CpuidResultList2` reaches the guest: a leaf that also exits reports + /// the programmed result as the exit's default result, which OpenVMM's + /// exit handler starts from, and a leaf that does not exit returns it + /// directly. The time ABI exits some profile leaves (1, 4, 6, 7, ...), so + /// their profile values depend on the former. + #[test] + #[ignore = "requires WHP"] + fn cpuid_result_list2_feeds_exit_defaults() { + const EXIT_LEAF: u32 = 0x16; + const PLAIN_LEAF: u32 = 0x8000_0002; + let exit_value = [0x1111_0001, 0x2222_0002, 0x3333_0003, 0x4444_0004]; + let plain_value = [0x4141_4141, 0x4242_4242, 0x4343_4343, 0x4444_4444]; + let results = [ + result2(EXIT_LEAF, None, None, exit_value, [!0; 4]), + result2(PLAIN_LEAF, None, None, plain_value, [!0; 4]), + ]; + let mut config = whp::PartitionConfig::new().unwrap(); + config + .set_property(whp::PartitionProperty::ProcessorCount(1)) + .unwrap(); + config + .set_property(whp::PartitionProperty::ExtendedVmExits( + whp::abi::WHV_EXTENDED_VM_EXITS::X64CpuidExit, + )) + .unwrap(); + config + .set_property(whp::PartitionProperty::CpuidExitList(&[EXIT_LEAF])) + .unwrap(); + config + .set_property(whp::PartitionProperty::CpuidResultList2(&results)) + .unwrap(); + let partition = config.create().unwrap(); + partition.create_vp(0).create().unwrap(); + + // At 0xffff0: cpuid; hlt; jmp back to the cpuid. + let layout = std::alloc::Layout::from_size_align(4096, 4096).unwrap(); + // SAFETY: the layout has a nonzero size. + let page = unsafe { std::alloc::alloc_zeroed(layout) }; + assert!(!page.is_null()); + let code = [0x0f, 0xa2, 0xf4, 0xeb, 0xfb]; + // SAFETY: `page` is a fresh allocation of 4096 bytes. + unsafe { std::ptr::copy_nonoverlapping(code.as_ptr(), page.add(0xff0), code.len()) }; + let rwx = whp::abi::WHV_MAP_GPA_RANGE_FLAGS( + whp::abi::WHvMapGpaRangeFlagRead.0 + | whp::abi::WHvMapGpaRangeFlagWrite.0 + | whp::abi::WHvMapGpaRangeFlagExecute.0, + ); + // SAFETY: `page` stays allocated until after the partition is dropped. + unsafe { partition.map_range(None, page, 4096, 0xff000, rwx) }.unwrap(); + + let vp = partition.vp(0); + let registers = |vp: &whp::Processor<'_>| { + [ + whp::Register64::Rax, + whp::Register64::Rbx, + whp::Register64::Rcx, + whp::Register64::Rdx, + ] + .map(|register| vp.get_register(register).unwrap() as u32) + }; + vp.set_register(whp::Register64::Rax, EXIT_LEAF.into()) + .unwrap(); + vp.set_register(whp::Register64::Rcx, 0).unwrap(); + let mut runner = vp.runner(); + + // The exiting leaf: its default result is the programmed one. + let (default, next_rip) = match runner.run().unwrap() { + whp::Exit { + vp_context, + reason: whp::ExitReason::Cpuid(info), + } => ( + [ + info.DefaultResultRax as u32, + info.DefaultResultRbx as u32, + info.DefaultResultRcx as u32, + info.DefaultResultRdx as u32, + ], + vp_context.Rip + u64::from(vp_context.InstructionLength()), + ), + exit => panic!("expected a CPUID exit: {exit:#x?}"), + }; + println!("exit leaf {EXIT_LEAF:#x}: default result {default:08x?}"); + // Complete the CPUID with the default result and continue to the HLT. + for (register, value) in [ + whp::Register64::Rax, + whp::Register64::Rbx, + whp::Register64::Rcx, + whp::Register64::Rdx, + ] + .into_iter() + .zip(default) + { + vp.set_register(register, value.into()).unwrap(); + } + vp.set_register(whp::Register64::Rip, next_rip).unwrap(); + match runner.run().unwrap().reason { + whp::ExitReason::Halt => {} + reason => panic!("expected a halt: {reason:#x?}"), + } + + // The plain leaf: the guest gets the programmed result directly. + vp.set_register(whp::Register64::Rax, PLAIN_LEAF.into()) + .unwrap(); + vp.set_register(whp::Register64::Rcx, 0).unwrap(); + match runner.run().unwrap().reason { + whp::ExitReason::Halt => {} + reason => panic!("expected a halt: {reason:#x?}"), + } + let plain = registers(&vp); + println!("plain leaf {PLAIN_LEAF:#x}: guest result {plain:08x?}"); + + drop(partition); + // SAFETY: the partition that mapped the page is gone. + unsafe { std::alloc::dealloc(page, layout) }; + assert_eq!(default, exit_value, "the exit's default result"); + assert_eq!(plain, plain_value, "the guest's result"); + } } diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 8c5bee342be..118d210429b 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -49,6 +49,7 @@ use crate::WhpPartitionInner; use crate::WhpProcessor; use crate::WhpResultExt; use crate::profile_features::WhpFeatures; +use crate::profile_features::profile_cpuid_results; use crate::profile_features::profile_features; use cpu_profile::CpuProfile; use inspect::Inspect; @@ -182,6 +183,8 @@ pub(crate) struct WhpTimeAbi { features_xsave: Option, /// The CPU profile the features derive from, if any. cpu_profile: Option, + /// How many `CpuidResultList2` entries present the CPU profile's leaves. + cpuid_results: usize, /// The effective CPUID record, computed once: it depends only on the /// configuration, because it excludes guest state. #[inspect(skip)] @@ -282,6 +285,20 @@ impl WhpTimeAbi { ) })?; } + let cpuid_results = profile.map(profile_cpuid_results).unwrap_or_default(); + if !cpuid_results.is_empty() { + whp_config + .set_property(whp::PartitionProperty::CpuidResultList2(&cpuid_results)) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "cannot program the {} CPUID results of the CPU profile: {err}", + cpuid_results.len() + ), + ) + })?; + } let msr_exits = whp::capabilities::x64_msr_exit_bitmap() .map_err(|err| routing_error(format!("cannot query the MSR exits: {err}")))?; @@ -299,6 +316,7 @@ impl WhpTimeAbi { tracing::info!( cpuid_exits = cpuid_exits.len(), + cpuid_results = cpuid_results.len(), cpu_profile = profile.map(|profile| profile.id()), available_bank0 = format_args!("{:#x}", available.bank0.0), available_bank1 = format_args!("{:#x}", available.bank1.0), @@ -319,6 +337,7 @@ impl WhpTimeAbi { features_bank1: features.bank1.0, features_xsave: xsave, cpu_profile: profile.map(|profile| profile.id().to_owned()), + cpuid_results: cpuid_results.len(), effective: OnceLock::new(), }) } From bf17190c26f8df18457c17509dff4782ec2030b5 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 10:34:34 -0700 Subject: [PATCH 205/304] virt_mshv: wake halted VPs for vectors written into the LAPIC IRR When the VMM writes a VP's LAPIC state through the state page, the hypervisor doesn't re-evaluate that VP's interrupts. A VP restored halted with a deliverable vector pending in IRR therefore stays halted until some other interrupt reaches it. Restores write such vectors: the saved LAPIC state can hold them, and the restore clock (the time ABI's step 14, and the legacy TscAdvance) queues the vector of every one-shot timer that expired during the downtime and zeroes its count. On prometheus30, a VP inspect 2 s after the restore marker found halted VPs with Linux's timer vector 0xec pending, PPR 0, and a zero count, on the legacy path and under the time ABI alike. Each such CPU's timers stall until an IPI happens to wake it. When that CPU owns the hvc console poller's (khvcd) timeout, console input stops while output still works: the intermittent post-restore console hang seen in the restore matrix. After writing the LAPIC state, re-assert its highest-priority pending edge-triggered vector through the hypervisor's interrupt path. For a vector that is already pending this is idempotent, and it wakes the VP when the vector is deliverable; the hypervisor still applies the PPR and IF rules. Level-triggered vectors are left to their IOAPIC, and vectors 0 to 15 (whose IRR bits the Hyper-V state page reuses) are ignored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/lib.rs | 7 ++ vmm_core/virt_mshv/src/x86_64/vp_state.rs | 113 ++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 3ce2b05adee..5f9abcc3f1f 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -841,6 +841,13 @@ enum ErrorInner { error: KernelError, ty: u8, }, + #[cfg(guest_arch = "x86_64")] + #[error("failed to re-assert pending LAPIC vector {vector:#x}")] + AssertPendingInterrupt { + #[source] + error: KernelError, + vector: u8, + }, #[error("failed to reset state")] ResetState(#[source] Box>), #[error("install intercept failed")] diff --git a/vmm_core/virt_mshv/src/x86_64/vp_state.rs b/vmm_core/virt_mshv/src/x86_64/vp_state.rs index 027f01206fe..c5898bc4a10 100644 --- a/vmm_core/virt_mshv/src/x86_64/vp_state.rs +++ b/vmm_core/virt_mshv/src/x86_64/vp_state.rs @@ -11,6 +11,7 @@ use mshv_bindings::MSHV_VP_STATE_SIEFP; use mshv_bindings::MSHV_VP_STATE_SIMP; use mshv_bindings::MSHV_VP_STATE_SYNTHETIC_TIMERS; use mshv_bindings::mshv_get_set_vp_state; +use mshv_ioctls::InterruptRequest; use std::ptr::NonNull; use std::sync::OnceLock; use virt::state::HvRegisterState; @@ -117,6 +118,51 @@ impl MshvProcessor<'_> { let hv_state: hvdef::HvX64InterruptControllerState = (*lapic).into(); self.set_state(mshv_bindings::MSHV_VP_STATE_LAPIC, hv_state.as_bytes()) } + + /// Re-asserts the highest-priority pending edge-triggered vector of a + /// LAPIC state just written through the state page. + /// + /// The hypervisor doesn't re-evaluate a halted VP's interrupts when the + /// VMM writes its IRR through the LAPIC state page, as a snapshot restore + /// does, or the restore clock when it queues the vector of an expired + /// one-shot timer. Such a VP stays halted until some other interrupt + /// arrives, and so do the timers it owns: on prometheus30, 2 s after a + /// restore, VPs were still halted with the timer vector pending. Asserting + /// a vector that is already pending through the interrupt path is + /// idempotent, and wakes the VP when the vector is deliverable: the + /// hypervisor still applies the PPR and IF rules. + fn assert_pending_interrupt(&self, lapic: &ApicRegisters) -> Result<(), Error> { + let Some(vector) = highest_pending_edge_vector(lapic) else { + return Ok(()); + }; + self.partition + .vmfd + .request_virtual_interrupt(&InterruptRequest { + interrupt_type: hvdef::HvInterruptType::HvX64InterruptTypeFixed.0, + apic_id: self.inner.vp_info.apic_id.into(), + vector: vector.into(), + level_triggered: false, + logical_destination_mode: false, + long_mode: false, + }) + .map_err(|error| ErrorInner::AssertPendingInterrupt { + error: error.into(), + vector, + })?; + Ok(()) + } +} + +/// Returns the highest-priority vector pending in `lapic`'s IRR that is +/// edge-triggered (TMR clear). Vectors 0 to 15 are skipped: they aren't valid +/// fixed interrupts, and the Hyper-V state page reuses their IRR bits (for +/// example for the NMI pending bit). Level-triggered vectors are left to +/// their IOAPIC, whose EOI handling an edge assertion would bypass. +fn highest_pending_edge_vector(lapic: &ApicRegisters) -> Option { + (16..=255u8).rev().find(|&vector| { + let (word, bit) = (usize::from(vector / 32), vector % 32); + lapic.irr[word] >> bit & 1 == 1 && lapic.tmr[word] >> bit & 1 == 0 + }) } struct PageAlignedBuffer { @@ -270,6 +316,9 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { let mut lapic = *value.registers(); lapic.set_hv_apic_nmi_pending(nmi_pending); self.set_lapic(&lapic)?; + if self.partition.isolation.snp().is_none() { + self.assert_pending_interrupt(&lapic)?; + } Ok(()) } @@ -422,3 +471,67 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { Err(ErrorInner::NotSupported.into()) } } + +#[cfg(test)] +mod tests { + use super::highest_pending_edge_vector; + use virt::vp::ApicRegisters; + use zerocopy::FromZeros; + + fn pending(irr: &[u8], level: &[u8]) -> ApicRegisters { + let mut lapic = ApicRegisters::new_zeroed(); + for &vector in irr { + lapic.irr[usize::from(vector / 32)] |= 1 << (vector % 32); + } + for &vector in level { + lapic.tmr[usize::from(vector / 32)] |= 1 << (vector % 32); + } + lapic + } + + #[test] + fn nothing_pending_asserts_nothing() { + assert_eq!(highest_pending_edge_vector(&pending(&[], &[])), None); + } + + #[test] + fn the_expired_timer_vector_is_asserted() { + // Linux's LOCAL_TIMER_VECTOR, as queued by the restore clock. + assert_eq!( + highest_pending_edge_vector(&pending(&[0xec], &[])), + Some(0xec) + ); + } + + #[test] + fn the_highest_edge_vector_wins() { + assert_eq!( + highest_pending_edge_vector(&pending(&[0x30, 0xec, 0x41], &[])), + Some(0xec) + ); + assert_eq!( + highest_pending_edge_vector(&pending(&[0xff, 0x10], &[])), + Some(0xff) + ); + } + + #[test] + fn level_triggered_vectors_are_left_to_their_ioapic() { + assert_eq!( + highest_pending_edge_vector(&pending(&[0x41], &[0x41])), + None + ); + assert_eq!( + highest_pending_edge_vector(&pending(&[0x41, 0x30], &[0x41])), + Some(0x30) + ); + } + + #[test] + fn reserved_vectors_and_the_nmi_pending_bit_are_ignored() { + let mut lapic = pending(&[], &[]); + lapic.set_hv_apic_nmi_pending(true); + lapic.irr[0] |= 0xffff; + assert_eq!(highest_pending_edge_vector(&lapic), None); + } +} From 8af9ba5c3be4fccf37e2a5ce8a923e172e0b112e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 11:09:38 -0700 Subject: [PATCH 206/304] virt_mshv: re-assert a pending LAPIC vector when the VP next runs The re-assertion in set_apic wakes a halted VP for a vector written into its IRR, but a later partition time freeze can still leave the VP halted: the legacy restore clock writes the LAPIC (TscAdvance), then freezes partition time and rewrites the AP TSCs before the first VP run thaws it. In a probe soak, one legacy restore in twelve still had a VP halted with the timer vector pending 2 s after the restore marker, while no time ABI restore did. Mark a VP whose LAPIC state was written, and assert its highest pending edge-triggered vector again at the start of its next run, after partition time thaws and right before the VP enters the hypervisor. Like the first assertion, this is idempotent for a pending vector. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/lib.rs | 21 +++++++++++++++++++++ vmm_core/virt_mshv/src/x86_64/vp_state.rs | 13 +++++++++++++ 2 files changed, 34 insertions(+) diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 5f9abcc3f1f..86d3be124be 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -162,6 +162,8 @@ impl<'a> MshvProtoPartition<'a> { extint_pending: AtomicBool::new(false), #[cfg(guest_arch = "x86_64")] created: AtomicBool::new(false), + #[cfg(guest_arch = "x86_64")] + lapic_written: AtomicBool::new(false), waker: RwLock::new(None), }) .collect(); @@ -360,6 +362,11 @@ struct MshvVpInner { /// uncreated. #[cfg(guest_arch = "x86_64")] created: AtomicBool, + /// Set when the VMM writes this VP's LAPIC state. The next VP run + /// consumes it and re-asserts the highest pending edge-triggered vector + /// (see `MshvProcessor::assert_pending_interrupt`). + #[cfg(guest_arch = "x86_64")] + lapic_written: AtomicBool, /// Waker for the VP run loop task. Set by the VP thread, used by device /// threads to re-poll the run loop when new messages are enqueued. waker: RwLock>, @@ -643,6 +650,20 @@ impl virt::Processor for MshvProcessor<'_> { .thaw_time() .expect("failed to thaw partition time"); + // A LAPIC state written since the last run may hold a pending vector + // that the hypervisor hasn't delivered, and partition time may have + // been frozen and thawed since then (the legacy restore clock does). + // Assert it again now that the VP is about to run. + #[cfg(guest_arch = "x86_64")] + if vpinner.lapic_written.swap(false, Ordering::AcqRel) { + if let Err(err) = self.reassert_pending_interrupt() { + tracelimit::warn_ratelimited!( + error = &err as &dyn std::error::Error, + "failed to re-assert a pending LAPIC vector" + ); + } + } + // Ensure any messages present from a state restore are flushed on // the first loop iteration. if vpinner.message_queues.pending_sints() != 0 { diff --git a/vmm_core/virt_mshv/src/x86_64/vp_state.rs b/vmm_core/virt_mshv/src/x86_64/vp_state.rs index c5898bc4a10..de3c000538d 100644 --- a/vmm_core/virt_mshv/src/x86_64/vp_state.rs +++ b/vmm_core/virt_mshv/src/x86_64/vp_state.rs @@ -119,6 +119,16 @@ impl MshvProcessor<'_> { self.set_state(mshv_bindings::MSHV_VP_STATE_LAPIC, hv_state.as_bytes()) } + /// Re-asserts the highest pending edge-triggered vector of the VP's + /// current LAPIC state; see [`Self::assert_pending_interrupt`]. + pub(crate) fn reassert_pending_interrupt(&self) -> Result<(), Error> { + if self.partition.isolation.snp().is_some() { + return Ok(()); + } + let lapic = self.get_lapic()?; + self.assert_pending_interrupt(&lapic) + } + /// Re-asserts the highest-priority pending edge-triggered vector of a /// LAPIC state just written through the state page. /// @@ -318,6 +328,9 @@ impl AccessVpState for &'_ mut MshvProcessor<'_> { self.set_lapic(&lapic)?; if self.partition.isolation.snp().is_none() { self.assert_pending_interrupt(&lapic)?; + self.inner + .lapic_written + .store(true, std::sync::atomic::Ordering::Release); } Ok(()) From f0bd3dd4f5051eed07c193795087fce159b4070b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 12:58:40 -0700 Subject: [PATCH 207/304] openvmm: program the CPU profile's complete effective CPUID under the time ABI Second step of the CPU profile integration: - The worker builds the partition's effective CPUID from the pinned profile, OpenVMM's topology leaves, the APIC mode, and the identity and explicit zero leaves (CpuProfile::effective_cpuid), and passes it as TimeAbiConfig::cpuid. The per-VP APIC identity bits are unmasked (virt::x86::topology::per_vp_cpuid_bits); backends set each VP's own. - Capture records the effective CPUID in the crate's canonical encoding (openvmm-effective-cpuid/v1). Restore recomputes it and compares it with the record (E_CPU_SURFACE); the record must verify and decode (E_PROFILE_DIGEST). - On cold boot and restore, before any VP runs, VP 0's CPUID as the backend reports it must equal the effective CPUID under its masks (E_CPU_SURFACE). - TimeAbiBackend::supported_cpu_surface (default None) lets a backend report a cheap CPU surface, which core then checks against the profile at partition creation (E_PROFILE_UNSUPPORTED). Until every backend reports one, --x-time-abi-verify checks support from the backend's CPU fingerprint. - Core no longer selects or restores interim profiles: auto and explicit IDs select pinned profiles only, and a snapshot that recorded an interim profile is rejected. virt keeps is_interim_cpu_profile and interim_cpu_profile_id until WHP drops its interim branch. - topology_cpuid indexes its leaf 4 results, since each describes one cache. An unindexed result applied to every subleaf: on KVM, which takes the first matching entry, it replaced every cache descriptor, and on WHP it shadowed the profile's per-subleaf leaf 4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_core/src/worker/dispatch.rs | 44 +- .../src/worker/dispatch/time_abi.rs | 416 +++++++++++++++--- openvmm/openvmm_entry/src/microvm/restore.rs | 2 +- openvmm/openvmm_entry/src/microvm/verify.rs | 70 ++- openvmm/openvmm_helpers/src/snapshot/time.rs | 312 ++++++------- vmm_core/virt/src/time_abi/backend.rs | 29 +- vmm_core/virt/src/time_abi/mod.rs | 23 +- vmm_core/virt/src/time_abi/surface.rs | 331 ++------------ vmm_core/virt/src/x86/topology.rs | 136 +++++- 9 files changed, 755 insertions(+), 608 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 4b60fb8ed47..2db9300a85c 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1267,14 +1267,16 @@ impl InitializedVm { #[cfg(guest_arch = "x86_64")] let (time_abi_partition, time_abi_config) = match &cfg.microvm.time_abi { Some(parameters) if cfg.machine_profile == MachineProfile::Microvm => { - let cpu_profile = time_abi::select_cpu_profile( - ¶meters.cpu_profile, - &cfg.microvm.hypervisor_id, - )?; - let (msrs, config) = - time_abi::partition_config(processor_topology.vp_count(), cpu_profile.clone()); + let profile = time_abi::select_cpu_profile(¶meters.cpu_profile)?; + let effective_cpuid = + Arc::new(time_abi::effective_cpuid(profile, &processor_topology)?); + let (msrs, config) = time_abi::partition_config(profile, &effective_cpuid); ( - Some(time_abi::PartitionTimeAbi { msrs, cpu_profile }), + Some(time_abi::PartitionTimeAbi { + msrs, + profile, + effective_cpuid, + }), Some(config), ) } @@ -1945,7 +1947,11 @@ impl InitializedVm { #[cfg(guest_arch = "x86_64")] let time_abi_state = match time_abi_partition { - Some(time_abi::PartitionTimeAbi { msrs, cpu_profile }) => { + Some(time_abi::PartitionTimeAbi { + msrs, + profile, + effective_cpuid, + }) => { let parameters = cfg .microvm .time_abi @@ -1959,21 +1965,25 @@ impl InitializedVm { ); if let Some(input) = restore { anyhow::ensure!( - input.cpu_profile.id == cpu_profile, - "the partition's CPU profile '{cpu_profile}' is not the snapshot's '{}'", + input.cpu_profile.id == profile.id(), + "the partition's CPU profile '{}' is not the snapshot's '{}'", + profile.id(), input.cpu_profile.id ); - time_abi::check_cpu_surface( - partition.as_ref(), - hypervisor, - &input.cpu_profile, - )?; + time_abi::check_recorded_cpuid(&effective_cpuid, &input.cpu_profile)?; } + time_abi::check_presented_cpuid( + partition.as_ref(), + hypervisor, + profile, + &effective_cpuid, + )?; + time_abi::check_profile_support(partition.as_ref(), hypervisor, profile)?; let report = time_abi::declare_rates( partition.as_ref(), &msrs, hypervisor, - cpu_profile, + profile.id().to_owned(), restore.map(|input| virt::time_abi::DeclaredRates { tsc_hz: input.contract.tsc_frequency_hz, apic_hz: input.contract.apic_frequency_hz, @@ -1993,6 +2003,8 @@ impl InitializedVm { Some(time_abi::TimeAbiState { _unit: unit, msrs, + profile, + effective_cpuid, report, generation: parameters.generation, hooks: parameters.hooks.clone(), diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index ffd0ad21577..998299db537 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -12,6 +12,8 @@ use super::LoadedVm; use crate::partition::HvlitePartition; use anyhow::Context as _; use chipset_resources::microvm_time::RestoreTimeRecord; +use cpu_profile::CpuProfile; +use cpu_profile::EffectiveCpuid; use inspect::Inspect; use inspect::InspectMut; use openvmm_defs::time_abi::RestoreTimeInput; @@ -23,6 +25,8 @@ use state_unit::SpawnedUnit; use state_unit::StateUnit; use std::sync::Arc; use std::time::Duration; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; use virt::time_abi::DeclaredRates; use virt::time_abi::DowntimeSource; use virt::time_abi::IdentityMsrRoute; @@ -35,6 +39,9 @@ use virt::time_abi::TimeAbiError; use virt::time_abi::TimeAbiMsrs; use virt::time_abi::TimeAbiTestHooks; use virt::time_abi::TscSyncMethod; +use virt::time_abi::surface::SupportedCpuSurface; +use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::x86::ApicMode; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; use vmcore::save_restore::SavedStateBlob; @@ -42,12 +49,19 @@ use vmcore::save_restore::SavedStateBlob; /// The name of the time ABI state unit. pub(super) const TIME_ABI_UNIT: &str = "time-abi"; +/// The most differences an `E_CPU_SURFACE` read-back failure names. +const MAX_REPORTED_CPUID_DIFFERENCES: usize = 16; + /// The time ABI state of a loaded VM. pub(super) struct TimeAbiState { /// The `time-abi` state unit. pub _unit: SpawnedUnit, /// The identity MSR handler, holding the declared rates. pub msrs: Arc, + /// The CPU profile. + pub profile: &'static CpuProfile, + /// The effective CPUID of the partition. + pub effective_cpuid: Arc, /// The preflight report. pub report: TimeAbiReport, /// The generation counter of this VM process. @@ -60,40 +74,97 @@ pub(super) struct TimeAbiState { pub(super) struct PartitionTimeAbi { /// The identity MSR handler. pub msrs: Arc, - /// The ID of the partition's CPU profile. - pub cpu_profile: String, + /// The CPU profile. + pub profile: &'static CpuProfile, + /// The effective CPUID of the partition. + pub effective_cpuid: Arc, } /// Returns the identity MSR handler and the time ABI configuration of a new -/// partition with `vp_count` VPs and the CPU profile `cpu_profile`. +/// partition with the CPU profile `profile` and its effective CPUID. pub(super) fn partition_config( - vp_count: u32, - cpu_profile: String, + profile: &CpuProfile, + effective_cpuid: &EffectiveCpuid, ) -> (Arc, TimeAbiConfig) { let msrs = Arc::new(TimeAbiMsrs::new()); let config = TimeAbiConfig { - cpuid: Arc::new(virt::time_abi::identity::time_abi_cpuid(vp_count, true)), + cpuid: Arc::new(backend_cpuid(effective_cpuid)), msrs: msrs.clone(), - cpu_profile, + cpu_profile: profile.id().to_owned(), }; (msrs, config) } -/// Selects the CPU profile of a new partition before it is created, and -/// returns its ID. +/// Selects the CPU profile of a new partition before it is created. /// /// `requested` is `--cpu-profile` on cold boot (`auto` or an ID), or the -/// profile a snapshot recorded on restore. `auto` and a pinned ID select a -/// pinned profile whose generation contains the host CPU -/// (`E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNKNOWN`, `E_CPU_GENERATION`). An -/// interim ID must be the backend's own (`E_PROFILE_UNKNOWN`). -pub(super) fn select_cpu_profile(requested: &str, hypervisor: &str) -> anyhow::Result { - if virt::time_abi::surface::is_interim_cpu_profile(requested) { - virt::time_abi::surface::check_interim_cpu_profile(requested, hypervisor)?; - return Ok(requested.to_owned()); - } - let profile = cpu_profile::select(requested, &host_cpu()?)?; - Ok(profile.id().to_owned()) +/// profile a snapshot recorded on restore. It selects a pinned profile whose +/// generation contains the host CPU (`E_PROFILE_HOST_UNKNOWN`, +/// `E_PROFILE_UNKNOWN`, `E_CPU_GENERATION`). +pub(super) fn select_cpu_profile(requested: &str) -> anyhow::Result<&'static CpuProfile> { + Ok(cpu_profile::select(requested, &host_cpu()?)?) +} + +/// Builds the effective CPUID of a partition with `profile` and `topology`: +/// the profile completed with OpenVMM's topology leaves, the APIC mode, and +/// the time ABI's identity and explicit zero leaves. Fails with +/// `E_CPU_SURFACE` if they do not complete the profile exactly. +pub(super) fn effective_cpuid( + profile: &CpuProfile, + topology: &ProcessorTopology, +) -> anyhow::Result { + let result = |leaf: &CpuidLeaf| cpu_profile::CpuidResult { + function: leaf.function, + index: leaf.index, + result: leaf.result, + mask: leaf.mask, + }; + let mut topology_leaves = Vec::new(); + virt::x86::topology::topology_cpuid( + topology, + &|leaf, subleaf| profile.lookup(leaf, subleaf), + &mut topology_leaves, + ) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "cannot build the topology CPUID of CPU profile {}: {err}", + profile.id() + ), + ) + })?; + let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); + vm.push(cpu_profile::x2apic_cpuid(!matches!( + topology.apic_mode(), + ApicMode::XApic + ))); + let identity: Vec<_> = virt::time_abi::identity::identity_cpuid_leaves(topology.vp_count()) + .iter() + .map(result) + .chain(virt::time_abi::identity::identity_zero_cpuid_leaves().map(|leaf| result(&leaf))) + .collect(); + Ok(profile.effective_cpuid(&vm, &identity)?) +} + +/// Returns the CPUID results a backend programs for `effective`: every +/// result of the effective CPUID, with the per-VP APIC identity bits +/// unmasked, so that the backend sets each VP's own. +fn backend_cpuid(effective: &EffectiveCpuid) -> CpuidLeafSet { + CpuidLeafSet::new( + effective + .results() + .map(|result| { + let per_vp = virt::x86::topology::per_vp_cpuid_bits(result.function); + CpuidLeaf { + function: result.function, + index: result.index, + result: result.result, + mask: [0, 1, 2, 3].map(|register| result.mask[register] & !per_vp[register]), + } + }) + .collect(), + ) } /// Returns the vendor and signature of the host CPU (`E_CPU_GENERATION` if @@ -186,19 +257,126 @@ pub(super) fn declare_rates( }) } -/// Compares the effective CPUID the backend programmed with the snapshot's -/// record (`E_CPU_SURFACE`): the effective-CPUID check of restore step 8 of -/// the specification. -pub(super) fn check_cpu_surface( - partition: &dyn HvlitePartition, - hypervisor: &str, +/// Compares the effective CPUID recomputed for this partition with the one +/// the snapshot recorded: restore step 8 of the specification +/// (`E_PROFILE_DIGEST` if the record does not verify, `E_CPU_SURFACE` if the +/// two differ). +pub(super) fn check_recorded_cpuid( + effective_cpuid: &EffectiveCpuid, record: &SnapshotCpuProfile, ) -> anyhow::Result<()> { - let effective = backend(partition, hypervisor)?.effective_cpuid()?; - virt::time_abi::surface::check_effective_cpuid(&effective, &record.effective_cpuid)?; + let recorded = + EffectiveCpuid::decode_verified(&record.effective_cpuid, &record.effective_cpuid_sha256)?; + effective_cpuid.check_matches(&recorded)?; Ok(()) } +/// Checks that VP 0 observes the effective CPUID, on cold boot and restore +/// before any VP runs (`E_CPU_SURFACE`): the backend's report, looked up as +/// the guest's CPUID instruction finds it, equals every governed leaf under +/// its masks, and a leaf without a subleaf is read at subleaf 0. +pub(super) fn check_presented_cpuid( + partition: &dyn HvlitePartition, + hypervisor: &str, + profile: &CpuProfile, + effective_cpuid: &EffectiveCpuid, +) -> anyhow::Result<()> { + let presented = CpuidLeafSet::new(backend(partition, hypervisor)?.effective_cpuid()?); + let differences = cpuid_differences(&presented, effective_cpuid); + if differences.is_empty() { + return Ok(()); + } + let count = differences.len(); + let shown = &differences[..count.min(MAX_REPORTED_CPUID_DIFFERENCES)]; + Err(TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "VP 0 does not observe the effective CPUID of CPU profile {} in {count} results: {}", + profile.id(), + shown.join("; ") + ), + ) + .into()) +} + +/// Lists every result of `effective` that `presented` does not match under +/// the result's masks. +fn cpuid_differences(presented: &CpuidLeafSet, effective: &EffectiveCpuid) -> Vec { + const REGISTERS: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"]; + effective + .results() + .filter_map(|expected| { + let subleaf = expected.index.unwrap_or(0); + let actual = presented.result(expected.function, subleaf, &[0; 4]); + let registers: Vec = (0..4) + .filter(|®ister| { + (actual[register] ^ expected.result[register]) & expected.mask[register] != 0 + }) + .map(|register| { + format!( + "{} is {:#010x}, not {:#010x} under mask {:#010x}", + REGISTERS[register], + actual[register], + expected.result[register], + expected.mask[register] + ) + }) + .collect(); + (!registers.is_empty()).then(|| { + let leaf = match expected.index { + Some(index) => format!("CPUID {:#x}.{index}", expected.function), + None => format!("CPUID {:#x}", expected.function), + }; + format!("{leaf} {}", registers.join(", ")) + }) + }) + .collect() +} + +/// Checks that the backend supports the CPU profile (`E_PROFILE_UNSUPPORTED`) +/// when it reports its CPU surface, and returns whether it did. Until every +/// backend reports one, verification mode checks support from the backend's +/// CPU fingerprint instead. +pub(super) fn check_profile_support( + partition: &dyn HvlitePartition, + hypervisor: &str, + profile: &CpuProfile, +) -> anyhow::Result { + let Some(surface) = backend(partition, hypervisor)?.supported_cpu_surface()? else { + tracing::info!( + hypervisor, + cpu_profile = profile.id(), + "the backend reports no CPU surface; profile support is checked in verification mode" + ); + return Ok(false); + }; + cpu_profile::verify_support(profile, &host_cpu_surface(&surface))?; + Ok(true) +} + +/// Converts a backend's CPU surface to the profile crate's form. +fn host_cpu_surface(surface: &SupportedCpuSurface) -> cpu_profile::HostCpuSurface { + let mut cpuid = surface + .cpuid + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::cpuid::normalize(&mut cpuid); + cpu_profile::HostCpuSurface { + cpuid, + physical_address_width: surface.physical_address_width, + msrs: surface + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + } +} + /// Takes the time ABI records of a capture: capture steps 2 and 3 of the /// specification, the capture anchor and host identities, then the declared /// rates, the CPU profile record, and the generation counter. The caller has @@ -214,16 +392,8 @@ pub(super) fn capture_records( .context("the time ABI rates are not declared")?; let anchor = backend.capture_anchor()?; let identity = virt::time_abi::host::host_identity()?; - let effective_cpuid = virt::time_abi::surface::encode_cpuid(&backend.effective_cpuid()?); - // A pinned profile is recorded with its canonical document; an interim - // profile has none. - let (profile_sha256, profile) = match cpu_profile::pinned(&state.report.cpu_profile) { - Some(profile) => { - let encoded = profile.encode(); - (sha2::Sha256::digest(&encoded).to_vec(), encoded) - } - None => (sha2::Sha256::digest(b"").to_vec(), Vec::new()), - }; + let profile = state.profile.encode(); + let effective_cpuid = state.effective_cpuid.encode(); tracing::info!( tsc = anchor.tsc, utc_ns = anchor.sample.utc_ns, @@ -246,8 +416,8 @@ pub(super) fn capture_records( capture_generation: state.generation, }, cpu_profile: SnapshotCpuProfile { - id: state.report.cpu_profile.clone(), - sha256: profile_sha256, + id: state.profile.id().to_owned(), + sha256: sha2::Sha256::digest(&profile).to_vec(), profile, effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), effective_cpuid, @@ -383,16 +553,40 @@ impl StateUnit for TimeAbiUnit { mod tests { use super::*; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; + use vm_topology::processor::TopologyBuilder; use vm_topology::processor::VpIndex; + use vm_topology::processor::x86::X2ApicState; const TEST_PROFILE: &str = "intel.icelake-sp.v1"; + fn test_profile() -> &'static CpuProfile { + cpu_profile::pinned(TEST_PROFILE).unwrap() + } + + fn topology(vp_count: u32, x2apic: X2ApicState) -> ProcessorTopology { + TopologyBuilder::new_x86() + .vps_per_socket(vp_count) + .x2apic(x2apic) + .build(vp_count) + .unwrap() + } + + /// Returns the MSR handler, the configuration, and the effective CPUID of + /// a partition with `vp_count` VPs and the test profile. + fn test_config(vp_count: u32) -> (Arc, TimeAbiConfig, EffectiveCpuid) { + let profile = test_profile(); + let effective = + effective_cpuid(profile, &topology(vp_count, X2ApicState::Supported)).unwrap(); + let (msrs, config) = partition_config(profile, &effective); + (msrs, config, effective) + } + fn test_unit(msrs: Arc) -> TimeAbiUnit { TimeAbiUnit { msrs, report: TimeAbiReport { hypervisor: "kvm".to_owned(), - cpu_profile: "interim.host.kvm.v1".to_owned(), + cpu_profile: TEST_PROFILE.to_owned(), msr_route: IdentityMsrRoute::ExitToVmm, sync: TscSyncMethod::CommonOffset, native_tsc_hz: 2_100_000_000, @@ -401,10 +595,18 @@ mod tests { } } + fn code(result: anyhow::Result) -> String { + let message = format!("{:#}", result.unwrap_err()); + message + .strip_prefix('[') + .and_then(|rest| rest.split_once(']')) + .map_or(message.clone(), |(code, _)| code.to_owned()) + } + #[test] fn unit_saves_restores_and_resets_the_invariant_control() { futures::executor::block_on(async { - let (msrs, config) = partition_config(4, TEST_PROFILE.to_owned()); + let (msrs, config, _) = test_config(4); assert!(Arc::ptr_eq(&msrs, &config.msrs)); msrs.write(VpIndex::BSP, MSR_TSC_INVARIANT_CONTROL, 1) .unwrap() @@ -412,7 +614,7 @@ mod tests { let mut unit = test_unit(msrs.clone()); let saved = unit.save().await.unwrap().unwrap(); - let (restored, _) = partition_config(4, TEST_PROFILE.to_owned()); + let (restored, _, _) = test_config(4); let mut restored_unit = test_unit(restored.clone()); restored_unit.restore(saved).await.unwrap(); assert_eq!(restored.tsc_invariant_control(), 1); @@ -424,7 +626,7 @@ mod tests { #[test] fn unit_inspects_the_report() { - let (msrs, _) = partition_config(1, TEST_PROFILE.to_owned()); + let (msrs, _, _) = test_config(1); let mut unit = test_unit(msrs); let mut inspection = inspect::inspect("", &mut unit); futures::executor::block_on(inspection.resolve()); @@ -444,50 +646,118 @@ mod tests { } } + /// Every pinned profile completes, for every topology, into a partition + /// CPUID that carries the whole effective CPUID, the identity, and the + /// time bits, with only the per-VP bits left to the backend. #[test] - fn partition_cpuid_carries_the_identity() { - let (_, config) = partition_config(2, TEST_PROFILE.to_owned()); - assert_eq!(config.cpu_profile, TEST_PROFILE); - let mut cpuid = |leaf, subleaf| config.cpuid.result(leaf, subleaf, &[0; 4]); - virt::time_abi::identity::check_identity(&mut cpuid, 2).unwrap(); - } - - fn code(result: anyhow::Result) -> String { - let message = format!("{:#}", result.unwrap_err()); - message - .strip_prefix('[') - .and_then(|rest| rest.split_once(']')) - .map_or(message.clone(), |(code, _)| code.to_owned()) + fn partition_cpuid_is_the_complete_profile() { + for profile in cpu_profile::pinned_profiles() { + for vp_count in [1, 2, 8] { + for x2apic in [X2ApicState::Unsupported, X2ApicState::Supported] { + let effective = effective_cpuid(profile, &topology(vp_count, x2apic)).unwrap(); + let (_, config) = partition_config(profile, &effective); + assert_eq!(config.cpu_profile, profile.id()); + + let mut cpuid = |leaf, subleaf| config.cpuid.result(leaf, subleaf, &[0; 4]); + virt::time_abi::identity::check_identity(&mut cpuid, vp_count).unwrap(); + virt::time_abi::identity::check_time_bits(&mut cpuid, true).unwrap(); + assert!( + cpuid_differences(&config.cpuid, &effective).is_empty(), + "{} at {vp_count} VPs", + profile.id() + ); + let x2apic_bit = effective.lookup(1, 0)[2] & (1 << 21) != 0; + assert_eq!(x2apic_bit, x2apic == X2ApicState::Supported); + + for leaf in config.cpuid.leaves() { + let per_vp = virt::x86::topology::per_vp_cpuid_bits(leaf.function); + for (mask, bits) in leaf.mask.iter().zip(per_vp) { + assert_eq!(mask & bits, 0, "{leaf:?}"); + } + } + } + } + } } #[test] - fn cpu_profile_selection() { - assert_eq!( - select_cpu_profile("interim.host.kvm.v1", "kvm").unwrap(), - "interim.host.kvm.v1" + fn presented_cpuid_must_match_under_the_masks() { + let (_, config, effective) = test_config(2); + let presented = config.cpuid.leaves().to_vec(); + assert!(cpuid_differences(&CpuidLeafSet::new(presented.clone()), &effective).is_empty()); + + // A feature bit the profile sets (AVX2) that VP 0 does not see. + let mut changed = presented.clone(); + changed.push( + CpuidLeaf::new(7, [0; 4]) + .indexed(0) + .masked([0, 1 << 5, 0, 0]), ); - assert_eq!( - code(select_cpu_profile("interim.host.kvm.v1", "mshv")), - "E_PROFILE_UNKNOWN" + let differences = cpuid_differences(&CpuidLeafSet::new(changed), &effective); + assert_eq!(differences.len(), 1, "{differences:?}"); + assert!( + differences[0].starts_with("CPUID 0x7.0 EBX is"), + "{differences:?}" ); + + // Runtime-owned bits, such as OSXSAVE, are not part of the profile. + let mut running = presented.clone(); + running.push(CpuidLeaf::new(1, [0, 0, 1 << 27, 0]).masked([0, 0, 1 << 27, 0])); + assert!(cpuid_differences(&CpuidLeafSet::new(running), &effective).is_empty()); + + // A leaf VP 0 does not report reads as zeros. + let without_brand: Vec<_> = presented + .iter() + .copied() + .filter(|leaf| leaf.function != 0x8000_0002) + .collect(); + let differences = cpuid_differences(&CpuidLeafSet::new(without_brand), &effective); + assert_eq!(differences.len(), 1, "{differences:?}"); + assert!(differences[0].starts_with("CPUID 0x80000002 ")); + } + + #[test] + fn recorded_cpuid_verifies_and_must_match() { + let (_, _, effective) = test_config(2); + let profile = test_profile().encode(); + let record = SnapshotCpuProfile { + id: TEST_PROFILE.to_owned(), + sha256: sha2::Sha256::digest(&profile).to_vec(), + profile, + effective_cpuid: effective.encode(), + effective_cpuid_sha256: effective.digest().to_vec(), + capture_cpu_signature: 0, + }; + check_recorded_cpuid(&effective, &record).unwrap(); + + let (_, _, other) = test_config(4); + assert_eq!(code(check_recorded_cpuid(&other, &record)), "E_CPU_SURFACE"); + + let mut tampered = record.clone(); + tampered.effective_cpuid_sha256[0] ^= 1; assert_eq!( - code(select_cpu_profile("intel.cascadelake.v1", "kvm")), - "E_PROFILE_UNKNOWN" + code(check_recorded_cpuid(&effective, &tampered)), + "E_PROFILE_DIGEST" ); + } + + #[test] + fn cpu_profile_selection() { + for requested in ["interim.host.kvm.v1", "intel.cascadelake.v1"] { + assert_eq!(code(select_cpu_profile(requested)), "E_PROFILE_UNKNOWN"); + } // `auto` selects the pinned profile of the host's generation, if any, // and a pinned ID selects its profile only in its generation. let host = host_cpu().unwrap(); - match select_cpu_profile("auto", "whp") { - Ok(id) => assert!( - cpu_profile::check_generation(cpu_profile::pinned(&id).unwrap(), &host).is_ok() - ), - Err(err) => assert_eq!(code(Err(err)), "E_PROFILE_HOST_UNKNOWN"), + match select_cpu_profile("auto") { + Ok(profile) => assert!(cpu_profile::check_generation(profile, &host).is_ok()), + Err(err) => assert_eq!(code::<()>(Err(err)), "E_PROFILE_HOST_UNKNOWN"), } for profile in cpu_profile::pinned_profiles() { - let selected = select_cpu_profile(profile.id(), "kvm"); + let selected = select_cpu_profile(profile.id()); if cpu_profile::check_generation(profile, &host).is_ok() { - assert_eq!(selected.unwrap(), profile.id()); + assert_eq!(selected.unwrap().id(), profile.id()); } else { assert_eq!(code(selected), "E_CPU_GENERATION"); } diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index f36cf3e2a47..4e351e20622 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -774,7 +774,7 @@ mod tests { capture_generation, }); contract.cpu_profile = Some(SnapshotCpuProfile { - id: "interim.host.kvm.v1".to_owned(), + id: "intel.skylake-sp.v1".to_owned(), sha256: Vec::new(), profile: Vec::new(), effective_cpuid: Vec::new(), diff --git a/openvmm/openvmm_entry/src/microvm/verify.rs b/openvmm/openvmm_entry/src/microvm/verify.rs index 381888fd181..5fb7ac9f2e1 100644 --- a/openvmm/openvmm_entry/src/microvm/verify.rs +++ b/openvmm/openvmm_entry/src/microvm/verify.rs @@ -41,35 +41,65 @@ pub(crate) async fn report_time_abi_verification( .with_timeout(REPORT_TIMEOUT) .until_cancelled(inspection.resolve()) .await; - let line = success_line(hypervisor, &inspection.results())?; - println!("{line}"); + let report = inspection.results(); + let line = success_line(hypervisor, &report)?; worker.stop(); worker .join() .await .context("VM worker failed after time ABI verification")?; + // The worker checks profile support only where the backend reports a + // cheap CPU surface; verification mode always checks it, from the + // backend's CPU fingerprint, once the worker's partition is gone. + if let Err(err) = report_field(&report, "cpu_profile") + .and_then(|cpu_profile| check_profile_support(hypervisor, &cpu_profile)) + { + tracing::error!(error = format!("{err:#}"), "time ABI verification failed"); + println!("{}", failure_line(hypervisor, &err)); + return Ok(1); + } + println!("{line}"); Ok(0) } -/// Formats the line of a passed verification from the worker's `time-abi` -/// inspect node. -fn success_line(hypervisor: &str, node: &inspect::Node) -> anyhow::Result { +/// Checks that the backend supports the CPU profile `id`, from the backend's +/// CPU fingerprint (`E_PROFILE_UNSUPPORTED`). Fingerprinting creates a probe +/// partition, which only verification mode can afford. +fn check_profile_support(hypervisor: &str, id: &str) -> anyhow::Result<()> { + let profile = cpu_profile::pinned(id) + .with_context(|| format!("the CPU profile '{id}' is not pinned in this OpenVMM"))?; + let fingerprint = openvmm_helpers::hypervisor::cpu_fingerprint(Some(hypervisor)) + .context("failed to fingerprint the hypervisor backend")?; + cpu_profile::verify_support( + profile, + &cpu_profile::HostCpuSurface::from_fingerprint(&fingerprint), + )?; + Ok(()) +} + +/// Returns the string value of `name` in the worker's `time-abi` inspect +/// node. +fn report_field(node: &inspect::Node, name: &str) -> anyhow::Result { let inspect::Node::Dir(entries) = node else { anyhow::bail!("the VM worker returned no time ABI report: {node:?}"); }; - let field = |name: &str| -> anyhow::Result { - let entry = entries - .iter() - .find(|entry| entry.name == name) - .with_context(|| format!("the time ABI report lacks '{name}'"))?; - match &entry.node { - inspect::Node::Value(value) => Ok(match &value.kind { - inspect::ValueKind::String(text) => text.clone(), - _ => value.to_string(), - }), - node => anyhow::bail!("the time ABI report field '{name}' is {node:?}"), - } - }; + let entry = entries + .iter() + .find(|entry| entry.name == name) + .with_context(|| format!("the time ABI report lacks '{name}'"))?; + match &entry.node { + inspect::Node::Value(value) => Ok(match &value.kind { + inspect::ValueKind::String(text) => text.clone(), + _ => value.to_string(), + }), + node => anyhow::bail!("the time ABI report field '{name}' is {node:?}"), + } +} + +/// Formats the line of a passed verification from the worker's `time-abi` +/// inspect node. +fn success_line(hypervisor: &str, node: &inspect::Node) -> anyhow::Result { + let field = |name: &str| report_field(node, name); Ok(format!( "{PREFIX} v=1 status=ok backend={hypervisor} cpu_profile={} tsc_hz={} native_tsc_hz={} lapic_hz={} msr_route={} sync={}", field("cpu_profile")?, @@ -156,14 +186,14 @@ mod tests { entry("apic_hz", 200_000_000_u64), entry("tsc_invariant_control", 0_u64), entry("hypervisor", "mshv".to_owned()), - entry("cpu_profile", "interim.host.mshv.v1".to_owned()), + entry("cpu_profile", "intel.skylake-sp.v1".to_owned()), entry("msr_route", "ExitToVmm".to_owned()), entry("sync", "FrozenWrite".to_owned()), entry("native_tsc_hz", 2_100_000_000_u64), ]); assert_eq!( success_line("mshv", &node).unwrap(), - "NVX-TIME-ABI-VERIFY: v=1 status=ok backend=mshv cpu_profile=interim.host.mshv.v1 tsc_hz=2100000000 native_tsc_hz=2100000000 lapic_hz=200000000 msr_route=ExitToVmm sync=FrozenWrite" + "NVX-TIME-ABI-VERIFY: v=1 status=ok backend=mshv cpu_profile=intel.skylake-sp.v1 tsc_hz=2100000000 native_tsc_hz=2100000000 lapic_hz=200000000 msr_route=ExitToVmm sync=FrozenWrite" ); let inspect::Node::Dir(mut entries) = node else { unreachable!() diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index 8e6467ea794..47d7f0eddee 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -23,7 +23,6 @@ use virt::time_abi::TimeAbiError; use virt::time_abi::TimeAbiTestHooks; use virt::time_abi::downtime::select_downtime; use virt::time_abi::rate; -use virt::time_abi::surface; /// The value of the retired capture wall clock in a version 6 manifest. pub(super) const NO_TIMESTAMP: Timestamp = Timestamp { @@ -111,7 +110,10 @@ fn verify_sha256(bytes: &[u8], digest: &[u8], description: &str) -> Result<(), T Ok(()) } -/// Validates a CPU profile record: its shape and both digests. +/// Validates a CPU profile record: its shape, both digests, and both canonical +/// encodings. The embedded profile must be the one the record names +/// (`E_PROFILE_DIGEST`); whether this OpenVMM pins it is the restore +/// preflight's check. pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), TimeAbiError> { let SnapshotCpuProfile { id, @@ -134,19 +136,17 @@ pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), Ti "CPU profile ID '{id}' is malformed" ))); } - if profile.len() > MAX_CPU_PROFILE_BYTES { - return Err(manifest_error("CPU profile exceeds 1 MiB")); + if profile.is_empty() || profile.len() > MAX_CPU_PROFILE_BYTES { + return Err(manifest_error("CPU profile record size is invalid")); } verify_sha256(profile, sha256, "CPU profile")?; - if effective_cpuid.is_empty() - || effective_cpuid.len() > MAX_EFFECTIVE_CPUID_BYTES - || !effective_cpuid - .len() - .is_multiple_of(surface::ENCODED_CPUID_LEAF_LEN) - { + if effective_cpuid.is_empty() || effective_cpuid.len() > MAX_EFFECTIVE_CPUID_BYTES { return Err(manifest_error("effective CPUID record size is invalid")); } - verify_sha256(effective_cpuid, effective_cpuid_sha256, "effective CPUID") + verify_sha256(effective_cpuid, effective_cpuid_sha256, "effective CPUID")?; + cpu_profile::verify_profile_record(id, sha256, profile).map_err(profile_error)?; + cpu_profile::EffectiveCpuid::decode(effective_cpuid).map_err(profile_error)?; + Ok(()) } /// Returns the capture record of a validated time contract. @@ -252,11 +252,9 @@ pub struct TimeAbiRestorePreflight { /// generation, and the downtime preflight against the destination host's /// identity and clocks. /// -/// A snapshot that recorded a pinned profile restores where this OpenVMM pins -/// the same profile and `host` is in its generation. One that recorded an -/// interim profile restores only on the backend that recorded it and on a -/// host with the capture host's CPU signature. In both cases the worker -/// compares the effective CPUID. +/// The snapshot's profile must be pinned in this OpenVMM with the same +/// digest, and `host` must be in its generation. The worker recomputes the +/// effective CPUID and compares it with the record. pub fn preflight_time_abi_restore( contract: &SnapshotMachineContract, hypervisor: &str, @@ -288,18 +286,14 @@ pub fn preflight_time_abi_restore( ), )); } - if surface::is_interim_cpu_profile(&record.id) { - check_interim_cpu_profile_restore(record, hypervisor, host)?; - } else { - let host = host.ok_or_else(|| { - TimeAbiError::new( - TimeAbiCode::CpuGeneration, - "the host CPU cannot be identified", - ) - })?; - cpu_profile::restore_profile(&record.id, &record.sha256, &record.profile, &host) - .map_err(profile_error)?; - } + let host = host.ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::CpuGeneration, + "the host CPU cannot be identified", + ) + })?; + cpu_profile::restore_profile(&record.id, &record.sha256, &record.profile, &host) + .map_err(profile_error)?; let capture = capture_record(time)?; let downtime = select_downtime(&capture, destination, now, hooks)?; Ok(TimeAbiRestorePreflight { @@ -310,41 +304,6 @@ pub fn preflight_time_abi_restore( }) } -/// Checks the restore of a snapshot that recorded an interim CPU profile: the -/// profile is the backend's, the record embeds no profile document, and the -/// host has the capture host's CPU signature. -fn check_interim_cpu_profile_restore( - record: &SnapshotCpuProfile, - hypervisor: &str, - host: Option, -) -> Result<(), TimeAbiError> { - surface::check_interim_cpu_profile(&record.id, hypervisor)?; - if !record.profile.is_empty() { - return Err(TimeAbiError::new( - TimeAbiCode::ProfileDigest, - format!( - "the snapshot's interim profile '{}' embeds a profile document", - record.id - ), - )); - } - let signature = host.map(|host| host.signature()); - if signature != Some(record.capture_cpu_signature) { - let host = signature.map_or_else( - || "unavailable".to_owned(), - |signature| format!("{signature:#010x}"), - ); - return Err(TimeAbiError::new( - TimeAbiCode::CpuGeneration, - format!( - "the host CPU signature {host} is not the capture host's {:#010x}", - record.capture_cpu_signature - ), - )); - } - Ok(()) -} - /// Converts a CPU profile failure to the time ABI failure with the same code. pub fn profile_error(error: cpu_profile::ProfileError) -> TimeAbiError { let code = TimeAbiCode::from_name(error.code.as_str()) @@ -388,16 +347,46 @@ pub(super) mod tests { } } + /// The CPU profile of the test records. + pub(in crate::snapshot) const TEST_PROFILE: &str = "intel.icelake-sp.v1"; + /// A host CPU signature in the test profile's generation. + const TEST_SIGNATURE: u32 = 0x0006_06a6; + + /// Returns the effective CPUID of a partition with `vp_count` VPs and + /// `profile`: the topology fields OpenVMM sets for one socket, an xAPIC, + /// and no identity leaves. + fn test_effective_cpuid( + profile: &cpu_profile::CpuProfile, + vp_count: u32, + ) -> cpu_profile::EffectiveCpuid { + use cpu_profile::CpuidResult; + let mut vm = vec![ + CpuidResult::new(1, [0, vp_count << 16, 0, 0]).masked([0, 0xffff_0000, 0, 0]), + CpuidResult::new(4, [(vp_count - 1) << 26, 0, 0, 0]).masked([0xffff_c000, 0, 0, 0]), + cpu_profile::x2apic_cpuid(false), + ]; + let max_basic = profile.lookup(0, 0)[0]; + for leaf in cpu_profile::VM_OWNED_LEAVES + .into_iter() + .filter(|&leaf| leaf <= max_basic) + { + vm.push(CpuidResult::new(leaf, [0, 1, 0x100, 0]).indexed(0)); + vm.push(CpuidResult::new(leaf, [0, vp_count, 0x201, 0]).indexed(1)); + } + profile.effective_cpuid(&vm, &[]).unwrap() + } + pub(in crate::snapshot) fn test_cpu_profile() -> SnapshotCpuProfile { - let profile = b"profile".to_vec(); - let effective_cpuid = vec![0; 24]; + let pinned = cpu_profile::pinned(TEST_PROFILE).unwrap(); + let profile = pinned.encode(); + let effective_cpuid = test_effective_cpuid(pinned, 2).encode(); SnapshotCpuProfile { - id: "intel.skylake-sp.kvm.v1".to_owned(), + id: TEST_PROFILE.to_owned(), sha256: sha2::Sha256::digest(&profile).to_vec(), profile, effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), effective_cpuid, - capture_cpu_signature: 0x0005_0657, + capture_cpu_signature: TEST_SIGNATURE, } } @@ -446,7 +435,7 @@ pub(super) mod tests { #[test] fn cpu_profile_record_rules() { validate_cpu_profile_record(&test_cpu_profile()).unwrap(); - let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 9] = [ + let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 12] = [ (|r| r.id.clear(), TimeAbiCode::ManifestTime), (|r| r.id = "Intel_SKX".to_owned(), TimeAbiCode::ManifestTime), ( @@ -457,9 +446,30 @@ pub(super) mod tests { |r| r.id = "intel.skylake-sp.kvm.v1.".to_owned(), TimeAbiCode::ManifestTime, ), + // The embedded profile is not the one the record names. + ( + |r| r.id = "intel.skylake-sp.v1".to_owned(), + TimeAbiCode::ProfileDigest, + ), (|r| r.profile.push(0), TimeAbiCode::ProfileDigest), + ( + |r| { + r.profile.clear(); + r.sha256 = sha2::Sha256::digest(&r.profile).to_vec(); + }, + TimeAbiCode::ManifestTime, + ), (|r| r.sha256.truncate(31), TimeAbiCode::ManifestTime), (|r| r.effective_cpuid[0] = 1, TimeAbiCode::ProfileDigest), + // A digest that verifies over bytes that are not the canonical + // encoding. + ( + |r| { + r.effective_cpuid.insert(1, b' '); + r.effective_cpuid_sha256 = sha2::Sha256::digest(&r.effective_cpuid).to_vec(); + }, + TimeAbiCode::ProfileDigest, + ), (|r| r.effective_cpuid.clear(), TimeAbiCode::ManifestTime), ( |r| r.profile = vec![0; MAX_CPU_PROFILE_BYTES + 1], @@ -589,24 +599,13 @@ pub(super) mod tests { } } - const SIGNATURE: u32 = 0x0005_0657; - - /// A version 6 machine contract captured on WHP with the interim profile, + /// A version 6 machine contract captured on WHP with the test profile, /// and a destination sample 5 s after its capture anchor on the same host /// boot. - fn interim_restore() -> (SnapshotMachineContract, HostIdentity, HostTimeSample) { - let effective_cpuid = vec![7; 48]; - let record = SnapshotCpuProfile { - id: "interim.host.whp.v1".to_owned(), - sha256: sha2::Sha256::digest(b"").to_vec(), - profile: Vec::new(), - effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), - effective_cpuid, - capture_cpu_signature: SIGNATURE, - }; + fn test_restore() -> (SnapshotMachineContract, HostIdentity, HostTimeSample) { let mut manifest = crate::snapshot::tests::test_manifest(); manifest.machine_contract = Some(crate::snapshot::microvm::test_machine_contract()); - set_time_abi_records(&mut manifest, test_time_contract(), record).unwrap(); + set_time_abi_records(&mut manifest, test_time_contract(), test_cpu_profile()).unwrap(); let capture = capture_record(&test_time_contract()).unwrap(); let now = HostTimeSample { utc_ns: capture.sample.utc_ns + 5_000_000_000, @@ -638,21 +637,21 @@ pub(super) mod tests { #[test] fn restore_preflight_selects_the_downtime_and_generation() { - let (contract, destination, now) = interim_restore(); + let (contract, destination, now) = test_restore(); let hooks = TimeAbiTestHooks::default(); - for requested in ["auto", "interim.host.whp.v1"] { + for requested in ["auto", TEST_PROFILE] { let result = preflight( &contract, "whp", requested, - Some(SIGNATURE), + Some(TEST_SIGNATURE), &destination, &now, &hooks, ) .unwrap(); assert_eq!(result.generation, 4); - assert_eq!(result.cpu_profile, "interim.host.whp.v1"); + assert_eq!(result.cpu_profile, TEST_PROFILE); assert_eq!(result.downtime.nanos, 5_000_000_000); assert_eq!( result.downtime.source, @@ -668,7 +667,7 @@ pub(super) mod tests { &contract, "whp", "auto", - Some(SIGNATURE), + Some(TEST_SIGNATURE), &destination, &now, &hooks, @@ -679,7 +678,7 @@ pub(super) mod tests { #[test] fn restore_preflight_failures_have_stable_codes() { - let (contract, destination, now) = interim_restore(); + let (contract, destination, now) = test_restore(); let hooks = TimeAbiTestHooks::default(); let fail = |contract: &SnapshotMachineContract, hypervisor: &str, @@ -699,24 +698,19 @@ pub(super) mod tests { .unwrap_err() .code }; + let host = Some(TEST_SIGNATURE); assert_eq!( - fail(&contract, "kvm", "auto", Some(SIGNATURE), &now, &hooks), + fail(&contract, "kvm", "auto", host, &now, &hooks), TimeAbiCode::BackendMismatch ); assert_eq!( - fail( - &contract, - "whp", - "intel.icelake-sp.whp.v1", - Some(SIGNATURE), - &now, - &hooks - ), + fail(&contract, "whp", "intel.skylake-sp.v1", host, &now, &hooks), TimeAbiCode::ProfileUnknown ); + // A Skylake-SP host, and a host whose CPU cannot be identified. assert_eq!( - fail(&contract, "whp", "auto", Some(SIGNATURE + 1), &now, &hooks), + fail(&contract, "whp", "auto", Some(0x0005_0654), &now, &hooks), TimeAbiCode::CpuGeneration ); assert_eq!( @@ -724,35 +718,47 @@ pub(super) mod tests { TimeAbiCode::CpuGeneration ); - let mut other_interim = contract.clone(); - other_interim.cpu_profile.as_mut().unwrap().id = "interim.host.kvm.v1".to_owned(); + // A snapshot that recorded an interim profile, before CPU profiles. + let mut interim = contract.clone(); + let record = interim.cpu_profile.as_mut().unwrap(); + record.id = "interim.host.whp.v1".to_owned(); + record.profile = Vec::new(); + record.sha256 = sha2::Sha256::digest(b"").to_vec(); + record.effective_cpuid = vec![7; 48]; + record.effective_cpuid_sha256 = sha2::Sha256::digest(&record.effective_cpuid).to_vec(); assert_eq!( - fail(&other_interim, "whp", "auto", Some(SIGNATURE), &now, &hooks), - TimeAbiCode::ProfileUnknown + fail(&interim, "whp", "auto", host, &now, &hooks), + TimeAbiCode::ManifestTime ); - // A pinned ID without its profile document does not verify. - let mut pinned = contract.clone(); - let record = pinned.cpu_profile.as_mut().unwrap(); - record.id = "intel.icelake-sp.v1".to_owned(); + // A valid profile document that this OpenVMM does not pin. + let mut unknown = contract.clone(); + let record = unknown.cpu_profile.as_mut().unwrap(); + record.id = "intel.icelake-sp.v9".to_owned(); + record.profile = String::from_utf8(record.profile.clone()) + .unwrap() + .replace("\"intel.icelake-sp.v1\"", "\"intel.icelake-sp.v9\"") + .into_bytes(); + record.sha256 = sha2::Sha256::digest(&record.profile).to_vec(); assert_eq!( - fail(&pinned, "whp", "auto", Some(SIGNATURE), &now, &hooks), - TimeAbiCode::ProfileDigest + fail(&unknown, "whp", "auto", host, &now, &hooks), + TimeAbiCode::ProfileUnknown ); - let mut document = contract.clone(); - let record = document.cpu_profile.as_mut().unwrap(); - record.profile = b"profile".to_vec(); + // The embedded document is another profile than the record names. + let mut swapped = contract.clone(); + let record = swapped.cpu_profile.as_mut().unwrap(); + record.profile = cpu_profile::pinned("intel.skylake-sp.v1").unwrap().encode(); record.sha256 = sha2::Sha256::digest(&record.profile).to_vec(); assert_eq!( - fail(&document, "whp", "auto", Some(SIGNATURE), &now, &hooks), + fail(&swapped, "whp", "auto", host, &now, &hooks), TimeAbiCode::ProfileDigest ); let mut exhausted = contract.clone(); exhausted.time.as_mut().unwrap().capture_generation = u32::MAX; assert_eq!( - fail(&exhausted, "whp", "auto", Some(SIGNATURE), &now, &hooks), + fail(&exhausted, "whp", "auto", host, &now, &hooks), TimeAbiCode::GenerationExhausted ); @@ -761,7 +767,7 @@ pub(super) mod tests { monotonic_ns: now.monotonic_ns - 10_000_000_000, }; assert_eq!( - fail(&contract, "whp", "auto", Some(SIGNATURE), &rollback, &hooks), + fail(&contract, "whp", "auto", host, &rollback, &hooks), TimeAbiCode::DowntimeNegative ); let excessive = TimeAbiTestHooks { @@ -769,80 +775,24 @@ pub(super) mod tests { ..Default::default() }; assert_eq!( - fail(&contract, "whp", "auto", Some(SIGNATURE), &now, &excessive), + fail(&contract, "whp", "auto", host, &now, &excessive), TimeAbiCode::DowntimeExcessive ); } - /// A version 6 machine contract like [`interim_restore`]'s, recorded with - /// the pinned profile `id`. - fn pinned_restore(id: &str) -> (SnapshotMachineContract, HostIdentity, HostTimeSample) { - let (mut contract, destination, now) = interim_restore(); - let profile = cpu_profile::pinned(id).unwrap(); - let record = contract.cpu_profile.as_mut().unwrap(); - record.id = id.to_owned(); - record.profile = profile.encode(); - record.sha256 = profile.digest().to_vec(); - (contract, destination, now) - } - #[test] - fn restore_preflight_checks_a_pinned_profile() { - const ICELAKE: &str = "intel.icelake-sp.v1"; - let icelake_host = cpu_profile::HostCpuSignature::new(*b"GenuineIntel", 0x0006_06a6); - let skylake_host = cpu_profile::HostCpuSignature::new(*b"GenuineIntel", 0x0005_0654); - cpu_profile::check_generation(cpu_profile::pinned(ICELAKE).unwrap(), &icelake_host) - .unwrap(); - let (contract, destination, now) = pinned_restore(ICELAKE); - let hooks = TimeAbiTestHooks::default(); - let run = |contract: &SnapshotMachineContract, requested: &str, host| { - preflight_time_abi_restore(contract, "whp", requested, host, &destination, &now, &hooks) - }; - let code = |result: Result| result.unwrap_err().code; - - for requested in ["auto", ICELAKE] { - let result = run(&contract, requested, Some(icelake_host)).unwrap(); - assert_eq!(result.cpu_profile, ICELAKE); - assert_eq!(result.generation, 4); + fn effective_cpuid_records_fit_the_manifest() { + for profile in cpu_profile::pinned_profiles() { + let encoded = test_effective_cpuid(profile, 8).encode(); + assert!( + encoded.len() < MAX_EFFECTIVE_CPUID_BYTES / 2, + "{}: {} bytes", + profile.id(), + encoded.len() + ); + assert!(profile.encode().len() < MAX_CPU_PROFILE_BYTES / 16); } - assert_eq!( - code(run(&contract, "auto", Some(skylake_host))), - TimeAbiCode::CpuGeneration - ); - assert_eq!( - code(run(&contract, "auto", None)), - TimeAbiCode::CpuGeneration - ); - assert_eq!( - code(run(&contract, "intel.skylake-sp.v1", Some(icelake_host))), - TimeAbiCode::ProfileUnknown - ); - - // A valid profile document that this OpenVMM does not pin. - let mut unknown = contract.clone(); - let record = unknown.cpu_profile.as_mut().unwrap(); - record.id = "intel.icelake-sp.v9".to_owned(); - record.profile = String::from_utf8(record.profile.clone()) - .unwrap() - .replace("\"intel.icelake-sp.v1\"", "\"intel.icelake-sp.v9\"") - .into_bytes(); - record.sha256 = sha2::Sha256::digest(&record.profile).to_vec(); - assert_eq!( - code(run(&unknown, "auto", Some(icelake_host))), - TimeAbiCode::ProfileUnknown - ); - - // The embedded document is another profile than the record names. - let mut swapped = contract.clone(); - let record = swapped.cpu_profile.as_mut().unwrap(); - record.profile = cpu_profile::pinned("intel.skylake-sp.v1").unwrap().encode(); - record.sha256 = sha2::Sha256::digest(&record.profile).to_vec(); - assert_eq!( - code(run(&swapped, "auto", Some(icelake_host))), - TimeAbiCode::ProfileDigest - ); } - #[test] fn profile_errors_keep_their_codes() { for code in cpu_profile::ProfileErrorCode::ALL { diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index bde51743a9d..6930d9ea231 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -38,17 +38,34 @@ pub trait TimeAbiBackend: Send + Sync { /// probes `TimeFreeze`) and read VP 0's reset state. fn preflight(&self) -> Result; - /// Returns the CPUID the backend programmed for VP 0, for the - /// effective-CPUID record and check. Fails with `E_CPU_SURFACE`. + /// Returns the CPUID that VP 0 observes for the leaves of + /// [`TimeAbiConfig::cpuid`](super::TimeAbiConfig::cpuid), or a superset, + /// with VP 0's own APIC identity in the per-VP fields. Core compares it + /// with the CPU profile's effective CPUID under its masks + /// (`E_CPU_SURFACE`). Fails with `E_CPU_SURFACE`. /// - /// Core calls it with all VPs stopped: on restore, before any VP runs, for - /// the check, and at capture, after the guest has run, for the record, so - /// the result must not depend on guest state. Core's first call to this - /// method or to [`Self::preflight`] comes before any VP runs, so a + /// Core calls it with all VPs stopped, before any VP runs and before any + /// VP state is restored, on cold boot and restore. Core's first call to + /// this method or to [`Self::preflight`] comes before any VP runs, so a /// backend that can only read VP 0's live view reads it then and keeps /// it. fn effective_cpuid(&self) -> Result, TimeAbiError>; + /// Returns the CPU surface the backend supports on this host, for the CPU + /// profile's support check (`E_PROFILE_UNSUPPORTED`) at partition + /// creation. It must be cheap: capability queries and the host's CPUID, + /// never a probe partition. + /// + /// The default returns `None`; core then checks support only in + /// verification mode (`--x-time-abi-verify`), from the backend's CPU + /// fingerprint. Every backend implements it before the time ABI becomes + /// the only microVM path. + fn supported_cpu_surface( + &self, + ) -> Result, TimeAbiError> { + Ok(None) + } + /// Takes the capture anchor: VP 0's TSC paired with /// [`sample_host_time`](super::host::sample_host_time). Fails with /// `E_TSC_ANCHOR` if the pair cannot be formed within the bound. diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index 9b073d5bef8..83685e593e5 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -391,26 +391,27 @@ pub fn check_command_line_clock_tokens(cmdline: &str) -> Result<(), TimeAbiError /// [`ProtoPartitionConfig::time_abi`](crate::ProtoPartitionConfig::time_abi). /// /// A backend that receives it must program `cpuid` after every other CPUID -/// source, remove every hypervisor-range leaf of its own, route the identity -/// MSR range to `msrs`, derive its partition capabilities through +/// source, remove every hypervisor-range leaf of its own, derive its +/// processor features from the CPU profile, route the identity MSR range to +/// `msrs`, derive its partition capabilities through /// [`identity::capabilities_cpuid`], and implement [`TimeAbiBackend`]. #[cfg(guest_arch = "x86_64")] #[derive(Debug, Clone)] pub struct TimeAbiConfig { - /// CPUID results the backend programs on every VP. Until the profile's - /// effective CPUID replaces it, this is [`identity::time_abi_cpuid`]: the - /// identity leaves, the explicit zero leaves, and the CPU time bits, - /// applied over the backend's own CPUID. + /// The complete CPUID of the partition, which the backend programs on + /// every VP: the effective CPUID of the CPU profile (every leaf and + /// subleaf of the profile, the topology leaves, and the identity and + /// explicit zero leaves). Bits it leaves unmasked are not the profile's: + /// the runtime-owned bits, and the per-VP fields (the initial APIC ID in + /// leaf 1, the x2APIC IDs in leaves 0xB and 0x1F, and the IDs in leaf + /// 0x8000001E), which the backend sets for each VP. pub cpuid: std::sync::Arc, /// The identity MSR handler, shared with the worker's `time-abi` state /// unit. pub msrs: std::sync::Arc, /// The ID of the partition's CPU profile, selected or restored before the - /// partition is created. A pinned ID names the profile that - /// `cpu_profile::pinned` returns, from which a backend derives its - /// processor features. An interim ID ([`surface::is_interim_cpu_profile`]) - /// names no profile: the backend presents its own CPU features, as before - /// CPU profiles. + /// partition is created: `cpu_profile::pinned` returns the profile, from + /// which a backend derives its processor features. pub cpu_profile: String, } diff --git a/vmm_core/virt/src/time_abi/surface.rs b/vmm_core/virt/src/time_abi/surface.rs index bb36cee5d61..e90ecb0a35f 100644 --- a/vmm_core/virt/src/time_abi/surface.rs +++ b/vmm_core/virt/src/time_abi/surface.rs @@ -1,191 +1,27 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! The effective CPU surface record of a snapshot: the canonical CPUID -//! encoding and its comparison, and the interim CPU profiles, which name the -//! backends' own CPU features. +//! Host CPU facts for the CPU profile record of a snapshot, the CPU surface a +//! backend supports, and the interim CPU profile IDs that backends still +//! recognize. -use super::TimeAbiCode; -use super::TimeAbiError; -use crate::CpuidLeaf; - -/// The length of one leaf in the canonical CPUID encoding. -pub const ENCODED_CPUID_LEAF_LEN: usize = 24; -/// The most differing leaves an `E_CPU_SURFACE` failure names. -const MAX_REPORTED_CPUID_DIFFERENCES: usize = 16; - -/// Returns the bits of a CPUID result that mirror VP runtime state rather -/// than the CPU surface, which the canonical encoding clears: OSXSAVE -/// (`CPUID.1:ECX[27]`), OSPKE (`CPUID.(7,0):ECX[4]`), and the XSAVE area sizes -/// of the enabled features (`CPUID.(0xD,0):EBX` and `CPUID.(0xD,1):EBX`). They -/// are the runtime-owned bits of the CPU profile format. -fn runtime_owned_bits(function: u32, index: Option) -> [u32; 4] { - match (function, index) { - (0x1, _) => [0, 0, 1 << 27, 0], - (0x7, Some(0) | None) => [0, 0, 1 << 4, 0], - (0xd, Some(0 | 1)) => [0, !0, 0, 0], - _ => [0; 4], - } -} - -/// Returns the canonical encoding of an effective CPUID table, as recorded in -/// a snapshot's CPU profile record. -/// -/// Leaves are sorted by function and subleaf, a leaf without a subleaf sorts -/// first, and duplicates keep the last entry. Each leaf is 24 bytes, all -/// little-endian `u32`s: the function, the subleaf (`0xffffffff` for any), -/// and EAX, EBX, ECX, and EDX. The values are the effective results with the -/// runtime-owned bits cleared, so a VP that has enabled XSAVE encodes like a -/// fresh one; the masks are not encoded. -pub fn encode_cpuid(leaves: &[CpuidLeaf]) -> Vec { - let mut entries: Vec<(u32, Option, [u32; 4])> = leaves - .iter() - .map(|leaf| { - let runtime = runtime_owned_bits(leaf.function, leaf.index); - let result = [0, 1, 2, 3].map(|register| leaf.result[register] & !runtime[register]); - (leaf.function, leaf.index, result) - }) - .collect(); - entries.sort_by_key(|&(function, index, _)| (function, index)); - let mut deduplicated: Vec<(u32, Option, [u32; 4])> = Vec::with_capacity(entries.len()); - for entry in entries { - match deduplicated.last_mut() { - Some(last) if (last.0, last.1) == (entry.0, entry.1) => *last = entry, - _ => deduplicated.push(entry), - } - } - let mut bytes = Vec::with_capacity(deduplicated.len() * ENCODED_CPUID_LEAF_LEN); - for (function, index, result) in deduplicated { - bytes.extend_from_slice(&function.to_le_bytes()); - bytes.extend_from_slice(&index.unwrap_or(u32::MAX).to_le_bytes()); - for register in result { - bytes.extend_from_slice(®ister.to_le_bytes()); - } - } - bytes -} - -/// Decodes a canonical CPUID encoding into `((function, subleaf), result)` -/// pairs, or returns `None` if its length is not a multiple of a leaf. -fn decode_cpuid(bytes: &[u8]) -> Option> { - let (leaves, rest) = bytes.as_chunks::(); - if !rest.is_empty() { - return None; - } - let word = |leaf: &[u8; ENCODED_CPUID_LEAF_LEN], n: usize| { - u32::from_le_bytes(leaf[n * 4..n * 4 + 4].try_into().unwrap()) - }; - Some( - leaves - .iter() - .map(|leaf| { - ( - (word(leaf, 0), word(leaf, 1)), - [word(leaf, 2), word(leaf, 3), word(leaf, 4), word(leaf, 5)], - ) - }) - .collect(), - ) -} - -/// Compares the effective CPUID a backend programmed with the canonical -/// encoding a snapshot recorded (`E_CPU_SURFACE`), naming the differing -/// leaves and registers. -pub fn check_effective_cpuid(effective: &[CpuidLeaf], recorded: &[u8]) -> Result<(), TimeAbiError> { - let encoded = encode_cpuid(effective); - if encoded == recorded { - return Ok(()); - } - let recorded = decode_cpuid(recorded).ok_or_else(|| { - TimeAbiError::new( - TimeAbiCode::CpuSurface, - "the snapshot's effective CPUID record is malformed", - ) - })?; - let recorded: std::collections::BTreeMap<_, _> = recorded.into_iter().collect(); - let effective: std::collections::BTreeMap<_, _> = decode_cpuid(&encoded) - .expect("canonical encoding is whole leaves") - .into_iter() - .collect(); - let mut differences = Vec::new(); - for key in recorded - .keys() - .chain(effective.keys().filter(|key| !recorded.contains_key(key))) - { - let (old, new) = (recorded.get(key), effective.get(key)); - if old == new { - continue; - } - let leaf = if key.1 == u32::MAX { - format!("{:#010x}", key.0) - } else { - format!("{:#010x}/{}", key.0, key.1) - }; - differences.push(match (old, new) { - (Some(old), Some(new)) => { - let registers: Vec = ["eax", "ebx", "ecx", "edx"] - .iter() - .zip(old.iter().zip(new)) - .filter(|(_, (old, new))| old != new) - .map(|(name, (old, new))| { - format!( - "{name} {old:#010x} -> {new:#010x} (bits {:#010x})", - old ^ new - ) - }) - .collect(); - format!("{leaf} {}", registers.join(", ")) - } - (Some(_), None) => format!("{leaf} missing"), - (None, _) => format!("{leaf} added"), - }); - } - let count = differences.len(); - differences.truncate(MAX_REPORTED_CPUID_DIFFERENCES); - Err(TimeAbiError::new( - TimeAbiCode::CpuSurface, - format!( - "the effective CPUID differs from the snapshot's in {count} leaves (snapshot -> destination): {}", - differences.join("; ") - ), - )) -} +/// The prefix of every interim CPU profile ID. +const INTERIM_CPU_PROFILE_PREFIX: &str = "interim.host."; -/// Returns the ID of the interim CPU profile of `hypervisor`: the backend's -/// own CPU features with the time ABI applied. A snapshot that records it -/// restores only on the same backend and host CPU signature, with an -/// identical effective CPUID. +/// Returns the ID of the interim CPU profile of `hypervisor`, which named the +/// backend's own CPU features before CPU profiles. Core no longer selects or +/// restores interim profiles; backends that still recognize the ID drop it +/// together with this function. pub fn interim_cpu_profile_id(hypervisor: &str) -> String { format!("{INTERIM_CPU_PROFILE_PREFIX}{hypervisor}.v1") } -/// The prefix of every interim CPU profile ID. -const INTERIM_CPU_PROFILE_PREFIX: &str = "interim.host."; - /// Returns whether `id` names an interim CPU profile rather than a pinned -/// one. A VM uses an interim profile only when `--cpu-profile` requests it by -/// ID or when it restores a snapshot that recorded one; `auto` selects a -/// pinned profile. +/// one. See [`interim_cpu_profile_id`]. pub fn is_interim_cpu_profile(id: &str) -> bool { id.starts_with(INTERIM_CPU_PROFILE_PREFIX) } -/// Checks that the interim CPU profile `id` is the one of `hypervisor` -/// (`E_PROFILE_UNKNOWN`). -pub fn check_interim_cpu_profile(id: &str, hypervisor: &str) -> Result<(), TimeAbiError> { - let interim = interim_cpu_profile_id(hypervisor); - if id == interim { - Ok(()) - } else { - Err(TimeAbiError::new( - TimeAbiCode::ProfileUnknown, - format!( - "CPU profile '{id}' is not the interim profile of the {hypervisor} backend, '{interim}'" - ), - )) - } -} - /// Returns the host's display signature, CPUID.1:EAX, as the VMM's host OS /// sees it, or `None` on a host that is not x86-64. pub fn host_cpu_signature() -> Option { @@ -201,119 +37,37 @@ pub fn host_cpu_signature() -> Option { } } +/// The CPU surface a backend supports on this host, as +/// [`TimeAbiBackend::supported_cpu_surface`](super::TimeAbiBackend::supported_cpu_surface) +/// reports it: what `cpu_profile::HostCpuSurface` holds, in this crate's +/// types. +#[derive(Debug, Clone, Default)] +pub struct SupportedCpuSurface { + /// Every CPUID leaf and subleaf the backend can expose, with every + /// feature it can expose set. Masks are ignored. + pub cpuid: Vec, + /// The widest guest physical address the backend supports, in bits. + pub physical_address_width: u8, + /// The MSR values the backend can present to a guest. + pub msrs: Vec, +} + +/// The values of one MSR that a backend can present to a guest. +#[derive(Debug, Clone, Copy)] +pub struct SupportedMsrValue { + /// The MSR index. + pub index: u32, + /// The bits the backend can present set. + pub supported: u64, + /// The bits the backend can present clear, even where `supported` has + /// them. + pub controllable: u64, +} + #[cfg(test)] mod tests { use super::*; - #[test] - fn cpuid_encoding_is_canonical() { - let leaves = [ - CpuidLeaf::new(7, [1, 2, 3, 4]).indexed(1), - CpuidLeaf::new(1, [5, 6, 7, 8]), - CpuidLeaf::new(7, [9, 10, 11, 12]).indexed(0), - CpuidLeaf::new(1, [13, 14, 15, 16]), - ]; - let bytes = encode_cpuid(&leaves); - assert_eq!(bytes.len(), 3 * 24); - let words: Vec = bytes - .chunks(4) - .map(|word| u32::from_le_bytes(word.try_into().unwrap())) - .collect(); - assert_eq!( - words, - [ - 1, - u32::MAX, - 13, - 14, - 15, - 16, - 7, - 0, - 9, - 10, - 11, - 12, - 7, - 1, - 1, - 2, - 3, - 4, - ] - ); - let mut reversed = leaves; - reversed.swap(0, 2); - reversed.swap(1, 3); - assert_ne!(encode_cpuid(&reversed), bytes); - assert_eq!(encode_cpuid(&[]), Vec::::new()); - } - - #[test] - fn cpuid_encoding_clears_runtime_owned_bits() { - let fresh = [ - CpuidLeaf::new(1, [0x506e3, 0x800, 0x76da_3203, 0x1f8b_fbff]), - CpuidLeaf::new(7, [0, 0x029c_6fbf, 0, 0]).indexed(0), - CpuidLeaf::new(0xd, [7, 0x240, 0x340, 0]).indexed(0), - CpuidLeaf::new(0xd, [0xf, 0x240, 0, 0]).indexed(1), - ]; - let mut running = fresh; - running[0].result[2] |= 1 << 27; - running[1].result[2] |= 1 << 4; - running[2].result[1] = 0x340; - running[3].result[1] = 0x3c0; - assert_eq!(encode_cpuid(&running), encode_cpuid(&fresh)); - check_effective_cpuid(&running, &encode_cpuid(&fresh)).unwrap(); - - // Every other bit is part of the surface. - let mut changed = fresh; - changed[0].result[2] ^= 1 << 26; - assert_eq!( - check_effective_cpuid(&changed, &encode_cpuid(&fresh)) - .unwrap_err() - .code, - TimeAbiCode::CpuSurface - ); - } - - #[test] - fn effective_cpuid_differences_are_named() { - let recorded = encode_cpuid(&[ - CpuidLeaf::new(1, [1, 2, 3, 4]), - CpuidLeaf::new(7, [0; 4]).indexed(0), - ]); - check_effective_cpuid( - &[ - CpuidLeaf::new(7, [0; 4]).indexed(0), - CpuidLeaf::new(1, [1, 2, 3, 4]), - ], - &recorded, - ) - .unwrap(); - - let err = check_effective_cpuid( - &[ - CpuidLeaf::new(1, [1, 2, 7, 4]), - CpuidLeaf::new(0xd, [1; 4]).indexed(1), - ], - &recorded, - ) - .unwrap_err(); - assert_eq!(err.code, TimeAbiCode::CpuSurface); - for expected in [ - "in 3 leaves", - "0x00000001 ecx 0x00000003 -> 0x00000007 (bits 0x00000004)", - "0x00000007/0 missing", - "0x0000000d/1 added", - ] { - assert!(err.message.contains(expected), "{err}"); - } - - let malformed = check_effective_cpuid(&[], &[0; 23]).unwrap_err(); - assert_eq!(malformed.code, TimeAbiCode::CpuSurface); - assert!(malformed.message.contains("malformed"), "{malformed}"); - } - #[test] fn interim_cpu_profiles() { assert_eq!(interim_cpu_profile_id("kvm"), "interim.host.kvm.v1"); @@ -321,17 +75,6 @@ mod tests { for id in ["auto", "intel.icelake-sp.v1", "", "interim"] { assert!(!is_interim_cpu_profile(id), "{id}"); } - check_interim_cpu_profile("interim.host.mshv.v1", "mshv").unwrap(); - for (id, hypervisor) in [ - ("interim.host.kvm.v1", "mshv"), - ("interim.host.kvm.v2", "kvm"), - ("intel.icelake-sp.v1", "kvm"), - ] { - assert_eq!( - check_interim_cpu_profile(id, hypervisor).unwrap_err().code, - TimeAbiCode::ProfileUnknown - ); - } assert_ne!(host_cpu_signature(), Some(0)); } } diff --git a/vmm_core/virt/src/x86/topology.rs b/vmm_core/virt/src/x86/topology.rs index 0edd393e16e..ad4b05ae668 100644 --- a/vmm_core/virt/src/x86/topology.rs +++ b/vmm_core/virt/src/x86/topology.rs @@ -15,6 +15,7 @@ use x86defs::cpuid::ExtendedAddressSpaceSizesEcx; use x86defs::cpuid::ExtendedTopologyEax; use x86defs::cpuid::ExtendedTopologyEbx; use x86defs::cpuid::ExtendedTopologyEcx; +use x86defs::cpuid::ProcessorTopologyDefinitionEax; use x86defs::cpuid::ProcessorTopologyDefinitionEbx; use x86defs::cpuid::ProcessorTopologyDefinitionEcx; use x86defs::cpuid::TopologyLevelType; @@ -30,6 +31,39 @@ pub type CpuidFn<'a> = &'a dyn Fn(u32, u32) -> [u32; 4]; #[error("unknown processor vendor {0}")] pub struct UnknownVendor(Vendor); +/// Returns the bits of a CPUID result that hold a VP's own APIC identity, +/// which backends set for each VP: the initial APIC ID in leaf 01h, the +/// x2APIC ID in leaves 0Bh and 1Fh, and the extended APIC, compute unit, and +/// node IDs in leaf 8000001Eh. The partition-wide results that +/// [`topology_cpuid`] builds carry the BSP's identity in these bits. +pub fn per_vp_cpuid_bits(function: u32) -> [u32; 4] { + match CpuidFunction(function) { + CpuidFunction::VersionAndFeatures => [ + 0, + VersionAndFeaturesEbx::new() + .with_initial_apic_id(0xff) + .into(), + 0, + 0, + ], + CpuidFunction::ExtendedTopologyEnumeration + | CpuidFunction::V2ExtendedTopologyEnumeration => [0, 0, 0, !0], + CpuidFunction::ProcessorTopologyDefinition => [ + ProcessorTopologyDefinitionEax::new() + .with_extended_apic_id(!0) + .into(), + ProcessorTopologyDefinitionEbx::new() + .with_compute_unit_id(!0) + .into(), + ProcessorTopologyDefinitionEcx::new() + .with_node_id(!0) + .into(), + 0, + ], + _ => [0; 4], + } +} + /// Adds appropriately masked leaves for reporting processor topology. /// /// This includes some bits of leaves 01h and 04h, plus all of leaves 0Bh and @@ -137,13 +171,14 @@ fn cache_parameters_cpuid( .with_cores_per_socket_minus_one(0x3f) .with_threads_sharing_cache_minus_one(0xfff); + // Each subleaf describes one cache, so the result applies to subleaf + // `i` only. An unindexed result would apply to every subleaf, and a + // backend that looks up the first matching result, such as KVM, + // would then report it for every cache. leaves.push( - CpuidLeaf::new(CpuidFunction::CacheParameters.0, [eax.into(), 0, 0, 0]).masked([ - eax_mask.into(), - 0, - 0, - 0, - ]), + CpuidLeaf::new(CpuidFunction::CacheParameters.0, [eax.into(), 0, 0, 0]) + .indexed(i) + .masked([eax_mask.into(), 0, 0, 0]), ) } } @@ -241,3 +276,92 @@ fn amd_processor_topology_definition_cpuid( .masked([0, ebx_mask.into(), ecx_mask.into(), 0]), ); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::CpuidLeafSet; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + + /// An Intel CPU with maximum basic leaf 0x1f and four caches in leaf 4: + /// L1 data, L1 instruction, L2, and L3. + fn intel_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + match (leaf, subleaf) { + (0, _) => [ + 0x1f, + u32::from_le_bytes(*b"Genu"), + u32::from_le_bytes(*b"ntel"), + u32::from_le_bytes(*b"ineI"), + ], + (4, 0) => [0x21, 0x1c0_003f, 0x3f, 0], + (4, 1) => [0x22, 0x1c0_003f, 0x3f, 0], + (4, 2) => [0x43, 0x3c0_003f, 0x3ff, 0], + (4, 3) => [0x63, 0x3c0_003f, 0xbfff, 4], + _ => [0; 4], + } + } + + #[test] + fn cache_results_apply_to_their_own_subleaf() { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(4) + .x2apic(X2ApicState::Supported) + .build(4) + .unwrap(); + let mut leaves = Vec::new(); + topology_cpuid(&topology, &intel_cpuid, &mut leaves).unwrap(); + + let caches: Vec<_> = leaves + .iter() + .filter(|leaf| leaf.function == CpuidFunction::CacheParameters.0) + .map(|leaf| leaf.index) + .collect(); + assert_eq!(caches, [Some(0), Some(1), Some(2), Some(3)]); + + // Over each cache's own descriptor, only the topology fields change: + // 4 cores per socket. + let set = CpuidLeafSet::new(leaves); + for subleaf in 0..4 { + let native = intel_cpuid(4, subleaf); + let result = set.result(4, subleaf, &native); + assert_eq!(result[1..], native[1..], "subleaf {subleaf}"); + assert_eq!(result[0] & 0x3fff, native[0] & 0x3fff, "subleaf {subleaf}"); + assert_eq!( + CacheParametersEax::from(result[0]).cores_per_socket_minus_one(), + 3 + ); + } + // The null cache that ends the enumeration keeps its native value. + assert_eq!(set.result(4, 4, &[0; 4]), [0; 4]); + } + + #[test] + fn per_vp_bits_cover_the_bsp_identity() { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(2) + .x2apic(X2ApicState::Supported) + .build(2) + .unwrap(); + let mut leaves = Vec::new(); + topology_cpuid(&topology, &intel_cpuid, &mut leaves).unwrap(); + for leaf in &leaves { + let per_vp = per_vp_cpuid_bits(leaf.function); + let expected = match CpuidFunction(leaf.function) { + CpuidFunction::VersionAndFeatures => [0, 0xff00_0000, 0, 0], + CpuidFunction::ExtendedTopologyEnumeration + | CpuidFunction::V2ExtendedTopologyEnumeration => [0, 0, 0, !0], + _ => [0; 4], + }; + assert_eq!(per_vp, expected, "{leaf:?}"); + for (mask, bits) in leaf.mask.iter().zip(per_vp) { + assert_eq!(mask & bits, bits); + } + } + assert_ne!( + per_vp_cpuid_bits(CpuidFunction::ProcessorTopologyDefinition.0), + [0; 4] + ); + assert_eq!(per_vp_cpuid_bits(7), [0; 4]); + } +} From 3bea89b79ec1cdd088d372d74399cbf4857fbb0a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 13:38:44 -0700 Subject: [PATCH 208/304] virt_kvm: program the CPU profile under the time ABI Third step of the CPU profile integration, on KVM (flip criterion c): - With the time ABI, the partition's CPUID is TimeAbiConfig::cpuid verbatim: the profile's complete effective CPUID, which core builds. KVM's supported CPUID, the configured leaves, OpenVMM's topology leaves, and the PSFD fixup no longer fill gaps, so no leaf or bit the profile omits reaches the guest. bind still sets each vCPU's APIC identity. The guest physical address width is the profile's. - Every vCPU presents the profile's IA32_ARCH_CAPABILITIES: a host-initiated write after KVM_SET_CPUID2 of (value & mask) | (supported & !mask), where supported is KVM's feature MSR. On Skylake this clears ITS_NO, RFDS_NO, and SKIP_L1DFL_VMENTRY, which KVM presents natively. A profile whose CPUID does not enumerate the MSR keeps KVM's default (the guest's reads raise #GP). - supported_cpu_surface reports what KVM supports, without a VM: the KVM_GET_SUPPORTED_CPUID query the partition already makes, its physical address width, and the IA32_ARCH_CAPABILITIES feature MSR (every bit controllable). AMD PSFD without SPEC_CTRL access is cleared in the surface, so a profile that sets it fails E_PROFILE_UNSUPPORTED instead of being stripped silently. - An unknown profile ID fails with E_PROFILE_UNKNOWN; a write KVM refuses fails with E_PROFILE_UNSUPPORTED. Partitions without the time ABI are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 100 +++-- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 369 +++++++++++++++--- 2 files changed, 397 insertions(+), 72 deletions(-) diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index e023d6d436c..97293533e92 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -181,6 +181,28 @@ impl virt::Hypervisor for Kvm { } } let supported_cpuid = self.kvm.supported_cpuid()?; + // The CPU surface for the profile's support check: this supported + // CPUID and KVM's IA32_ARCH_CAPABILITIES. + let cpu_surface = match &config.time_abi { + Some(_) => { + let arch_capabilities = self + .kvm + .feature_msr(time_abi::MSR_IA32_ARCH_CAPABILITIES) + .map_err(|err| { + virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::ProfileUnsupported, + format!( + "cannot read KVM's IA32_ARCH_CAPABILITIES feature MSR: {err:#}" + ), + ) + })?; + Some(time_abi::supported_surface( + &supported_cpuid, + arch_capabilities, + )) + } + None => None, + }; // KVM's in-kernel LAPIC only exposes the CMCI LVT register (APIC // offset 0x2F0) when the guest's IA32_MCG_CAP advertises MCG_CMCI_P. @@ -465,6 +487,7 @@ impl virt::Hypervisor for Kvm { cpuid: cpuid_entries, nested_virt, supported_mce_cap, + cpu_surface, }) } } @@ -480,6 +503,8 @@ pub struct KvmProtoPartition<'a> { /// MCE capability bits (`IA32_MCG_CAP`) the host allows setting, from /// `KVM_X86_GET_MCE_CAP_SUPPORTED`. supported_mce_cap: u64, + /// The CPU surface KVM supports, for the time ABI's CPU profile. + cpu_surface: Option, } impl ProtoPartition for KvmProtoPartition<'_> { @@ -488,7 +513,15 @@ impl ProtoPartition for KvmProtoPartition<'_> { type ProcessorBinder = KvmProcessorBinder; fn max_physical_address_size(&self) -> u8 { - max_physical_address_size_from_cpuid(&|eax, ecx| self.cpuid.result(eax, ecx, &[0; 4])) + match &self.config.time_abi { + // The guest sees the CPU profile's width. + Some(time_abi) => max_physical_address_size_from_cpuid(&|eax, ecx| { + time_abi.cpuid.result(eax, ecx, &[0; 4]) + }), + None => max_physical_address_size_from_cpuid(&|eax, ecx| { + self.cpuid.result(eax, ecx, &[0; 4]) + }), + } } fn build( @@ -501,28 +534,31 @@ impl ProtoPartition for KvmProtoPartition<'_> { return Err(SnpError::InvalidVmsaGpa(config.bsp.gpa).into()); } - // Build topology leaves using the base cpuid before consuming it. - let mut topology_leaves = Vec::new(); - virt::x86::topology::topology_cpuid( - self.config.processor_topology, - &|eax, ecx| self.cpuid.result(eax, ecx, &[0; 4]), - &mut topology_leaves, - ) - .map_err(KvmError::TopologyCpuid)?; - - // Work around a KVM bug where PSFD is advertised in guest CPUID - // but the SPEC_CTRL MSR is not accessible. Check the KVM-reported - // CPUID (before user overrides) since that determines what KVM - // will allow. - let psfd_fixup = strip_psfd_leaf(&self.cpuid); - - let mut cpuid = self.cpuid.into_leaves(); - cpuid.extend(config.cpuid); - cpuid.extend(topology_leaves); - cpuid.extend(psfd_fixup); let cpuid = match &self.config.time_abi { - Some(time_abi) => time_abi::compose_cpuid(cpuid, &time_abi.cpuid), - None => CpuidLeafSet::new(cpuid), + Some(time_abi) => time_abi::partition_cpuid(&time_abi.cpuid), + None => { + // Build topology leaves using the base cpuid before consuming + // it. + let mut topology_leaves = Vec::new(); + virt::x86::topology::topology_cpuid( + self.config.processor_topology, + &|eax, ecx| self.cpuid.result(eax, ecx, &[0; 4]), + &mut topology_leaves, + ) + .map_err(KvmError::TopologyCpuid)?; + + // Work around a KVM bug where PSFD is advertised in guest + // CPUID but the SPEC_CTRL MSR is not accessible. Check the + // KVM-reported CPUID (before user overrides) since that + // determines what KVM will allow. + let psfd_fixup = strip_psfd_leaf(&self.cpuid); + + let mut cpuid = self.cpuid.into_leaves(); + cpuid.extend(config.cpuid); + cpuid.extend(topology_leaves); + cpuid.extend(psfd_fixup); + CpuidLeafSet::new(cpuid) + } }; let bsp_apic_id = self.config.processor_topology.vp_arch(VpIndex::BSP).apic_id; @@ -571,7 +607,22 @@ impl ProtoPartition for KvmProtoPartition<'_> { .vps_arch() .map(|vp_info| vp_info.apic_id) .collect(); - Some(KvmTimeAbi::new(&self.vm, &vcpus, config.msrs.clone())?) + let surface = self.cpu_surface.take().unwrap_or_default(); + let profile = time_abi::pinned_profile(&config.cpu_profile)?; + let supported = surface + .msrs + .iter() + .find(|msr| msr.index == time_abi::MSR_IA32_ARCH_CAPABILITIES) + .map_or(0, |msr| msr.supported); + let arch_capabilities = + time_abi::profile_arch_capabilities(profile, supported, &cpuid); + Some(KvmTimeAbi::new( + &self.vm, + &vcpus, + config.msrs.clone(), + surface, + arch_capabilities, + )?) } None => None, }; @@ -1099,6 +1150,9 @@ impl virt::BindProcessor for KvmProcessorBinder { .collect::>(); kvm.set_cpuid(&cpuid_entries)?; + if let Some(time_abi) = &self.partition.time_abi { + time_abi.set_arch_capabilities(&kvm)?; + } let mut vp = KvmProcessor { partition: &self.partition, diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index 6c44fd561db..b9e945526cb 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -8,13 +8,18 @@ //! It implements the KVM column of "Backend obligations" in NVX //! `doc/design/time-abi.md`: //! -//! - **CPUID.** The time ABI CPUID applies after every other CPUID source and -//! no KVM `0x4xxxxxxx` leaf remains, so the guest sees the Hyper-V frequency -//! identity instead of KVM's signature. Without a KVM signature the guest -//! has no KVM paravirtual feature, and `KVM_CAP_ENFORCE_PV_FEATURE_CPUID` -//! makes their MSRs (kvmclock and the others) raise #GP. The partition -//! capabilities come from the CPUID with the hypervisor range masked, so -//! neither `hv1` nor `kvm_clock` is present. +//! - **CPUID and the CPU profile.** The partition's CPUID is the CPU +//! profile's complete effective CPUID, programmed verbatim with +//! `KVM_SET_CPUID2` (each vCPU gets its own APIC identity), so no KVM leaf +//! remains and the guest sees the Hyper-V frequency identity instead of +//! KVM's signature. Without a KVM signature the guest has no KVM +//! paravirtual feature, and `KVM_CAP_ENFORCE_PV_FEATURE_CPUID` makes their +//! MSRs (kvmclock and the others) raise #GP. Every vCPU presents the +//! profile's `IA32_ARCH_CAPABILITIES` in the bits the profile pins, and +//! KVM's own value in the others. KVM's supported CPUID and +//! `IA32_ARCH_CAPABILITIES` feature MSR form the surface for the profile's +//! support check. The partition capabilities come from the CPUID with the +//! hypervisor range masked, so neither `hv1` nor `kvm_clock` is present. //! - **Identity MSRs.** An MSR filter denies `0x40000000..=0x400001ff`, //! `IA32_TSC_ADJUST`, `IA32_TSC_DEADLINE`, and the legacy P6 L2-cache MSRs, //! and `KVM_MSR_EXIT_REASON_FILTER` delivers every guest access to OpenVMM: @@ -54,6 +59,7 @@ use super::KvmPartitionInner; use crate::KvmError; +use cpu_profile::CpuProfile; use parking_lot::Mutex; use std::ops::RangeInclusive; use std::sync::Arc; @@ -72,11 +78,16 @@ use virt::time_abi::TscAnchor; use virt::time_abi::TscSetReport; use virt::time_abi::TscSyncMethod; use virt::time_abi::host::sample_host_time; -use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; use virt::time_abi::msr::IDENTITY_MSR_RANGE; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use virt::time_abi::rate::LAPIC_HZ_KVM; +use virt::time_abi::surface::SupportedCpuSurface; +use virt::time_abi::surface::SupportedMsrValue; use virt::x86::MsrError; +use x86defs::cpuid::CpuidFunction; + +/// `IA32_ARCH_CAPABILITIES`, which the CPU profile pins. +pub(crate) const MSR_IA32_ARCH_CAPABILITIES: u32 = 0x10a; /// `IA32_TSC_ADJUST`, which the time ABI hides. const MSR_IA32_TSC_ADJUST: u32 = 0x3b; @@ -149,16 +160,79 @@ pub(crate) fn install_identity_msr_filter(vm: &kvm::Partition) -> Result<(), Tim Ok(()) } -/// Composes the partition CPUID: `base` (every other CPUID source, in -/// order) without its hypervisor-range leaves, then the time ABI CPUID, so -/// that the time ABI leaves override every other source. -pub(crate) fn compose_cpuid(base: Vec, time_abi: &CpuidLeafSet) -> CpuidLeafSet { - let mut leaves: Vec<_> = base - .into_iter() - .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) - .collect(); - leaves.extend(time_abi.leaves().iter().copied()); - CpuidLeafSet::new(leaves) +/// Returns the partition CPUID: the time ABI CPUID verbatim. It is the CPU +/// profile's complete effective CPUID (the profile's leaves, OpenVMM's +/// topology leaves and APIC mode, and the identity and explicit zero leaves), +/// so neither KVM's supported CPUID nor any other source adds a leaf or a +/// bit. `bind` sets each vCPU's own APIC identity in the per-VP fields. +pub(crate) fn partition_cpuid(time_abi: &CpuidLeafSet) -> CpuidLeafSet { + CpuidLeafSet::new(time_abi.leaves().to_vec()) +} + +/// Returns the CPU surface KVM supports on this host, for the CPU profile's +/// support check (`E_PROFILE_UNSUPPORTED`): `supported` is +/// `KVM_GET_SUPPORTED_CPUID` as the partition queries it (without permission +/// for dynamically enabled XSAVE features), the physical address width is +/// the one it reports, and `arch_capabilities` is KVM's +/// `IA32_ARCH_CAPABILITIES` feature MSR, any bit of which the VMM may present +/// clear. AMD's PSFD is cleared where KVM advertises it without the +/// SPEC_CTRL access that a guest needs to use it (see `strip_psfd_leaf`), so +/// a profile that sets it fails the check instead of losing it silently. +pub(crate) fn supported_surface( + supported: &[kvm::kvm_cpuid_entry2], + arch_capabilities: u64, +) -> SupportedCpuSurface { + let mut cpuid = cpuid_leaves(supported); + if let Some(psfd_fixup) = super::strip_psfd_leaf(&CpuidLeafSet::new(cpuid.clone())) { + for leaf in cpuid + .iter_mut() + .filter(|leaf| leaf.function == psfd_fixup.function) + { + psfd_fixup.apply(&mut leaf.result); + } + } + let physical_address_width = cpuid + .iter() + .find(|leaf| leaf.function == CpuidFunction::ExtendedAddressSpaceSizes.0) + .map_or(0, |leaf| leaf.result[0] as u8); + SupportedCpuSurface { + cpuid, + physical_address_width, + msrs: vec![SupportedMsrValue { + index: MSR_IA32_ARCH_CAPABILITIES, + supported: arch_capabilities, + controllable: !0, + }], + } +} + +/// Returns the `IA32_ARCH_CAPABILITIES` value that every vCPU presents under +/// `profile`: the profile's pinned bits, and KVM's own value (`supported`) in +/// the bits it leaves to the backend. `None` when the profile pins no value +/// or its CPUID (`cpuid`) does not enumerate the MSR, which then raises #GP +/// and keeps KVM's default. +pub(crate) fn profile_arch_capabilities( + profile: &CpuProfile, + supported: u64, + cpuid: &CpuidLeafSet, +) -> Option { + let (value, mask) = profile.msr(MSR_IA32_ARCH_CAPABILITIES)?; + let leaf7_edx = cpuid.result(CpuidFunction::ExtendedFeatures.0, 0, &[0; 4])[3]; + let enumerated = + x86defs::cpuid::ExtendedFeatureSubleaf0Edx::from(leaf7_edx).arch_capabilities(); + enumerated.then_some((value & mask) | (supported & !mask)) +} + +/// Returns the pinned CPU profile that `id` names. Core selects or restores +/// it before the partition exists, so an unknown ID is an internal error +/// (`E_PROFILE_UNKNOWN`). +pub(crate) fn pinned_profile(id: &str) -> Result<&'static CpuProfile, TimeAbiError> { + cpu_profile::pinned(id).ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ProfileUnknown, + format!("CPU profile {id:?} is not pinned in this OpenVMM"), + ) + }) } /// Converts the KVM CPUID entries programmed for a VP to CPUID leaves. @@ -319,16 +393,24 @@ pub(crate) struct KvmTimeAbi { /// Hyper-V emulation), in which case KVM does not hide invariant TSC /// either. kvm_invariant_control: Option>, + /// The CPU surface KVM supports, taken when the partition was created. + surface: SupportedCpuSurface, + /// The `IA32_ARCH_CAPABILITIES` value of every vCPU, from the CPU + /// profile ([`profile_arch_capabilities`]). + arch_capabilities: Option, } impl KvmTimeAbi { /// Sets up the vCPUs of a new partition for the time ABI: KVM's /// paravirtual MSRs raise #GP, and KVM's copy of - /// `HV_X64_MSR_TSC_INVARIANT_CONTROL` is probed. + /// `HV_X64_MSR_TSC_INVARIANT_CONTROL` is probed. `surface` and + /// `arch_capabilities` come from the partition's CPU profile. pub(crate) fn new( vm: &kvm::Partition, vcpus: &[u32], msrs: Arc, + surface: SupportedCpuSurface, + arch_capabilities: Option, ) -> Result { for &vcpu in vcpus { vm.vp(vcpu) @@ -364,9 +446,34 @@ impl KvmTimeAbi { Ok(Self { msrs, kvm_invariant_control, + surface, + arch_capabilities, }) } + /// Presents the CPU profile's `IA32_ARCH_CAPABILITIES` on `vp`, with a + /// host-initiated write after its CPUID is set: KVM accepts the MSR only + /// for a vCPU whose CPUID enumerates it, and keeps the value across + /// resets. + pub(crate) fn set_arch_capabilities( + &self, + vp: &kvm::Processor<'_>, + ) -> Result<(), TimeAbiError> { + let Some(value) = self.arch_capabilities else { + return Ok(()); + }; + vp.set_msrs(&[(MSR_IA32_ARCH_CAPABILITIES, value)]) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "KVM cannot present the CPU profile's IA32_ARCH_CAPABILITIES {value:#x}: \ + {err:#}" + ), + ) + }) + } + /// Handles a guest read of `msr` that exited to user space. `None` means /// the MSR is not the time ABI's. pub(crate) fn read_msr(&self, vp: VpIndex, msr: u32) -> Option> { @@ -535,6 +642,13 @@ impl TimeAbiBackend for KvmPartitionInner { Ok(cpuid_leaves(&self.bsp_cpuid)) } + fn supported_cpu_surface(&self) -> Result, TimeAbiError> { + Ok(self + .time_abi + .as_ref() + .map(|time_abi| time_abi.surface.clone())) + } + fn capture_anchor(&self) -> Result { let tsc_hz = self.native_tsc_hz()?; // With every VP stopped, VP 0's TSC is the host TSC plus its offset @@ -647,6 +761,7 @@ pub(crate) fn check_capabilities(caps: &virt::PartitionCapabilities) -> Result<( mod tests { use super::*; use virt::time_abi::DeclaredRates; + use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; use virt::time_abi::identity::time_abi_cpuid; use virt::time_abi::msr::MSR_APIC_FREQUENCY; use virt::time_abi::msr::MSR_TSC_FREQUENCY; @@ -679,6 +794,112 @@ mod tests { assert_eq!(hidden, super::super::MYSTERY_MSRS); } + fn kvm_entry(function: u32, index: Option, result: [u32; 4]) -> kvm::kvm_cpuid_entry2 { + kvm::kvm_cpuid_entry2 { + function, + index: index.unwrap_or(0), + flags: if index.is_some() { + kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX + } else { + 0 + }, + eax: result[0], + ebx: result[1], + ecx: result[2], + edx: result[3], + ..Default::default() + } + } + + #[test] + fn supported_surface_is_kvms_cpuid_width_and_arch_capabilities() { + let supported = [ + kvm_entry(0, None, [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + kvm_entry(7, Some(0), [0, 0xd19f_4fbb, 0, 0xbc00_0400]), + // A 46-bit physical address width. + kvm_entry(0x8000_0008, None, [0x302e, 0, 0, 0]), + ]; + let surface = supported_surface(&supported, 0x4000_0000_0c00_00eb); + let leaves: Vec<_> = surface + .cpuid + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result)) + .collect(); + assert_eq!( + leaves, + [ + (0, None, [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + (7, Some(0), [0, 0xd19f_4fbb, 0, 0xbc00_0400]), + (0x8000_0008, None, [0x302e, 0, 0, 0]), + ] + ); + assert_eq!(surface.physical_address_width, 46); + let msrs: Vec<_> = surface + .msrs + .iter() + .map(|msr| (msr.index, msr.supported, msr.controllable)) + .collect(); + assert_eq!(msrs, [(0x10a, 0x4000_0000_0c00_00eb, !0)]); + } + + #[test] + fn supported_surface_clears_psfd_without_spec_ctrl() { + // AMD's PSFD (0x80000008 EBX[28]) without IBRS, STIBP, or SSBD: KVM + // would not let the guest write SPEC_CTRL to use it. + let psfd = 1 << 28; + let lone = [kvm_entry(0x8000_0008, None, [0x30, psfd, 0, 0])]; + assert_eq!(supported_surface(&lone, 0).cpuid[0].result[1], 0); + // With SSBD (bit 24), SPEC_CTRL is the guest's, and PSFD stays. + let ssbd = 1 << 24; + let with_ssbd = [kvm_entry(0x8000_0008, None, [0x30, psfd | ssbd, 0, 0])]; + assert_eq!( + supported_surface(&with_ssbd, 0).cpuid[0].result[1], + psfd | ssbd + ); + } + + #[test] + fn arch_capabilities_follow_the_profile() { + // KVM 7.0 on a Skylake-SP host presents ITS_NO (bit 62), RFDS_NO + // (27), SKIP_L1DFL_VMENTRY (3), and IF_PSCHANGE_MC_NO (6). + let supported = (1 << 62) | (1 << 27) | (1 << 3) | (1 << 6); + let enumerated = CpuidLeafSet::new(vec![CpuidLeaf::new(7, [0, 0, 0, 1 << 29]).indexed(0)]); + // The Skylake profile pins its whole mask clear, ITS_NO included; + // outside it, KVM's own value stays. + let skylake = pinned_profile("intel.skylake-sp.v1").unwrap(); + assert_eq!( + profile_arch_capabilities(skylake, supported, &enumerated), + Some(1 << 6) + ); + // The Ice Lake profile pins RDCL_NO, MDS_NO, TAA_NO, and RFDS_NO set. + let icelake = pinned_profile("intel.icelake-sp.v1").unwrap(); + assert_eq!( + profile_arch_capabilities(icelake, supported, &enumerated), + Some(0x0800_0121 | (1 << 6)) + ); + // Without the CPUID enumeration the guest's reads raise #GP, and KVM + // would refuse the write: nothing is presented. + assert_eq!( + profile_arch_capabilities(skylake, supported, &CpuidLeafSet::new(Vec::new())), + None + ); + } + + #[test] + fn only_pinned_profiles_resolve() { + assert_eq!( + pinned_profile("intel.skylake-sp.v1").unwrap().id(), + "intel.skylake-sp.v1" + ); + for id in ["interim.host.kvm.v1", "intel.skylake-sp.kvm.v1", ""] { + let error = pinned_profile(id).unwrap_err(); + assert!( + matches!(error.code, TimeAbiCode::ProfileUnknown), + "{id}: {error:?}" + ); + } + } + fn kvm_like_base() -> Vec { vec![ CpuidLeaf::new(0, [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), @@ -706,47 +927,44 @@ mod tests { ] } + /// An effective CPUID shaped like a CPU profile's: the CPU's leaves + /// without a hypervisor leaf, and the time ABI's identity, zero, and time + /// leaves. + fn effective_like(vp_count: u32) -> CpuidLeafSet { + let mut leaves: Vec<_> = kvm_like_base() + .into_iter() + .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) + .collect(); + leaves.extend(time_abi_cpuid(vp_count, true).leaves().iter().copied()); + CpuidLeafSet::new(leaves) + } + #[test] - fn composed_cpuid_carries_the_identity_and_time_bits() { - let config = time_abi_cpuid(4, true); - let cpuid = compose_cpuid(kvm_like_base(), &config); - let mut lookup = |leaf, subleaf| cpuid.result(leaf, subleaf, &[0; 4]); - virt::time_abi::identity::check_identity(&mut lookup, 4).unwrap(); - virt::time_abi::identity::check_time_bits(&mut lookup, true).unwrap(); - // The hypervisor range holds exactly the time ABI's leaves: no KVM - // signature survives at any base. - let hypervisor_leaves = |set: &CpuidLeafSet| -> Vec { + fn partition_cpuid_is_the_time_abi_cpuid_verbatim() { + let time_abi = effective_like(4); + let cpuid = partition_cpuid(&time_abi); + let leaves = |set: &CpuidLeafSet| -> Vec<_> { set.leaves() .iter() - .map(|leaf| leaf.function) - .filter(|function| HYPERVISOR_CPUID_RANGE.contains(function)) + .map(|leaf| (leaf.function, leaf.index, leaf.result, leaf.mask)) .collect() }; - assert_eq!(hypervisor_leaves(&cpuid), hypervisor_leaves(&config)); + assert_eq!(leaves(&cpuid), leaves(&time_abi)); + let mut lookup = |leaf, subleaf| cpuid.result(leaf, subleaf, &[0; 4]); + virt::time_abi::identity::check_identity(&mut lookup, 4).unwrap(); + virt::time_abi::identity::check_time_bits(&mut lookup, true).unwrap(); + // No KVM signature at any base, and a leaf the table does not list + // reads zero. assert_eq!(cpuid.result(0x4000_0100, 0, &[0; 4]), [0; 4]); - // The time bits are cleared or set; the other bits keep KVM's values. - assert_eq!( - cpuid.result(1, 0, &[0; 4])[2], - (0xfffa_3203 & !((1 << 24) | (1 << 15))) | (1 << 31) - ); - assert_eq!(cpuid.result(7, 0, &[0; 4])[1], 0xd19f_4fbb & !(1 << 1)); + assert_eq!(cpuid.result(0x8000_001f, 0, &[0; 4]), [0; 4]); } #[test] - fn composed_cpuid_drops_hypervisor_leaves_of_every_source() { - let mut base = kvm_like_base(); - // A later override of a hypervisor leaf does not survive either. - base.push(CpuidLeaf::new(0x4000_0003, [!0; 4])); - let cpuid = compose_cpuid(base, &time_abi_cpuid(1, true)); - assert_eq!(cpuid.result(0x4000_0003, 0, &[0; 4]), [0x8860, 0, 0, 0x100]); - } - - #[test] - fn capabilities_of_the_composed_cpuid_exclude_hv1_and_kvm_clock() { + fn capabilities_of_the_partition_cpuid_exclude_hv1_and_kvm_clock() { let topology = vm_topology::processor::TopologyBuilder::new_x86() .build(2) .unwrap(); - let cpuid = compose_cpuid(kvm_like_base(), &time_abi_cpuid(2, true)); + let cpuid = partition_cpuid(&effective_like(2)); let mut lookup = |leaf, subleaf| { let mut result = cpuid.result(leaf, subleaf, &[0; 4]); if leaf == 1 { @@ -980,6 +1198,8 @@ mod tests { KvmTimeAbi { msrs, kvm_invariant_control: None, + surface: SupportedCpuSurface::default(), + arch_capabilities: None, } } @@ -1135,7 +1355,14 @@ mod kvm_tests { fn invariant_control_mirror_reaches_kvm() { let vm = partition(2); let msrs = Arc::new(TimeAbiMsrs::new()); - let time_abi = KvmTimeAbi::new(&vm, &[0, 1], msrs.clone()).unwrap(); + let time_abi = KvmTimeAbi::new( + &vm, + &[0, 1], + msrs.clone(), + SupportedCpuSurface::default(), + None, + ) + .unwrap(); if time_abi.kvm_invariant_control.is_none() { tracing::info!("KVM does not implement HV_X64_MSR_TSC_INVARIANT_CONTROL"); return; @@ -1158,4 +1385,48 @@ mod kvm_tests { .unwrap(); assert_eq!(value[0], 0); } + + #[test] + #[ignore = "requires /dev/kvm"] + fn profile_arch_capabilities_reach_the_vcpu() { + let kvm = kvm::Kvm::new().unwrap(); + let surface = supported_surface( + &kvm.supported_cpuid().unwrap(), + kvm.feature_msr(MSR_IA32_ARCH_CAPABILITIES).unwrap(), + ); + assert!(surface.physical_address_width >= 36); + let supported = surface.msrs[0].supported; + tracing::info!( + supported = format_args!("{supported:#x}"), + "KVM's ARCH_CAPABILITIES" + ); + let vm = partition(1); + // KVM accepts the MSR once the vCPU's CPUID enumerates it. + let enumerated = CpuidLeafSet::new(vec![CpuidLeaf::new(7, [0, 0, 0, 1 << 29]).indexed(0)]); + vm.vp(0) + .set_cpuid(&[kvm::kvm_cpuid_entry2 { + function: 7, + flags: kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX, + edx: 1 << 29, + ..Default::default() + }]) + .unwrap(); + let profile = pinned_profile("intel.skylake-sp.v1").unwrap(); + let value = profile_arch_capabilities(profile, supported, &enumerated).unwrap(); + let time_abi = KvmTimeAbi::new( + &vm, + &[0], + Arc::new(TimeAbiMsrs::new()), + surface, + Some(value), + ) + .unwrap(); + time_abi.set_arch_capabilities(&vm.vp(0)).unwrap(); + let mut read = [0]; + vm.vp(0) + .get_msrs(&[MSR_IA32_ARCH_CAPABILITIES], &mut read) + .unwrap(); + assert_eq!(read[0], value); + assert_eq!(read[0] & (1 << 62), 0, "ITS_NO is pinned clear"); + } } From 1fd109bcaa8ac5afe0c48f8e17f900ce3b36ffd9 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 14:21:18 -0700 Subject: [PATCH 209/304] cpu_profile: share the Hyper-V feature table and profile features MSHV and WHP use the same processor feature banks, and both must derive a partition's features from its CPU profile. Move WHP's table, which maps each bank and XSAVE feature bit to the CPUID feature bit it controls, and its derivation into cpu_profile::hv_banks, so the two backends share one copy: - HV_FEATURES, HvFeature, HvFeatureWord, HvFeatures, and CpuidBit; - pinned(), how a profile pins a CPUID bit; - profile_features(), which clears the bits of the features a profile clears, restricts the IA32_ARCH_CAPABILITIES bits to the profile, and fails with E_PROFILE_UNSUPPORTED naming every shortfall; - mapped_mask(), the bits a profile decides, so a backend keeps its own policy for the others; - restrict_cpuid_to_features(), which clears the CPUID bits of missing features, so MSHV can report its supported surface from the host's CPUID and feature banks without a probe partition. The table keeps hvdef's field names, so cpu_profile now depends on hvdef. The unit tests move with the logic and also run against the MSHV host banks the fleet's fingerprints recorded. WHP's hardware tests stay in virt_whp, which switches to this module in its own commit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 + vmm_core/cpu_profile/Cargo.toml | 1 + vmm_core/cpu_profile/src/hv_banks.rs | 740 ++++++++++++++++++++++++++- 3 files changed, 735 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc3351079b0..61cbb8d9304 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1000,6 +1000,7 @@ name = "cpu_profile" version = "0.0.0" dependencies = [ "build_rs_guest_arch", + "hvdef", "safe_intrinsics", "serde", "serde_json", diff --git a/vmm_core/cpu_profile/Cargo.toml b/vmm_core/cpu_profile/Cargo.toml index b179378cece..3b24b58aa71 100644 --- a/vmm_core/cpu_profile/Cargo.toml +++ b/vmm_core/cpu_profile/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +hvdef.workspace = true safe_intrinsics.workspace = true serde = { workspace = true, features = ["derive", "std"] } diff --git a/vmm_core/cpu_profile/src/hv_banks.rs b/vmm_core/cpu_profile/src/hv_banks.rs index d74799be02b..980066dc6f7 100644 --- a/vmm_core/cpu_profile/src/hv_banks.rs +++ b/vmm_core/cpu_profile/src/hv_banks.rs @@ -1,19 +1,56 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! How the Hyper-V backends, MSHV and WHP, present `IA32_ARCH_CAPABILITIES`. +//! How the Hyper-V backends, MSHV and WHP, present a CPU profile through a +//! partition's processor features. +//! +//! A Hyper-V partition presents a CPU feature only when the feature's +//! processor feature bank bit, or XSAVE feature bit, is set, and some of +//! those bits also decide which MSRs the guest may use (for example +//! `IA32_SPEC_CTRL`). [`HV_FEATURES`] maps each such bit to the CPUID feature +//! bit it controls. [`profile_features`] starts from the features the +//! hypervisor offers and clears every bit whose CPUID feature the profile +//! clears. The partition then behaves as the profile's CPUID describes. //! //! Neither backend lets the VMM intercept `IA32_ARCH_CAPABILITIES`: the //! hypervisor answers it, deriving the immunity and capability bits from the -//! partition's processor feature banks. A profile therefore pins those bits -//! through the banks. The bank layout is the hypervisor's -//! `HV_PARTITION_PROCESSOR_FEATURES`, which WHP's `WHV_PROCESSOR_FEATURES` -//! and `WHV_PROCESSOR_FEATURES1` share. +//! banks ([`ARCH_CAPABILITIES_BANK_BITS`]). A profile therefore pins those +//! bits through the banks too ([`restrict_banks_to_profile`]). +//! +//! The layout is the hypervisor's `HV_PARTITION_PROCESSOR_FEATURES` and +//! `HV_PARTITION_PROCESSOR_XSAVE_FEATURES`, which MSHV uses directly and WHP's +//! `WHV_PROCESSOR_FEATURES`, `WHV_PROCESSOR_FEATURES1`, and +//! `WHV_PROCESSOR_XSAVE_FEATURES` share. +//! +//! Bits without a CPUID feature, such as the nested-paging details, are the +//! backend's to decide. Features that no bit controls (for example PKU), the +//! descriptive leaves (caches, the brand string), and the time policy bits a +//! host lacks reach the guest through the backend's CPUID results. +use crate::cpuid::CpuidEntry; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; use crate::fingerprint::BackendFingerprint; use crate::fingerprint::IA32_ARCH_CAPABILITIES; use crate::profile::CpuProfile; use crate::surface::SupportedMsr; +use crate::surface::TIME_POLICY_BITS; +use hvdef::HvX64PartitionProcessorFeatures as Bank0; +use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; +use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; +use std::fmt; + +const EAX: usize = 0; +const EBX: usize = 1; +const ECX: usize = 2; +const EDX: usize = 3; + +/// The extended feature leaf. +const X1: u32 = 0x8000_0001; +/// The advanced power management leaf. +const X7: u32 = 0x8000_0007; +/// The extended feature identifiers leaf. +const X8: u32 = 0x8000_0008; /// The `IA32_ARCH_CAPABILITIES` bits that Hyper-V derives from the processor /// feature banks, as (MSR bit, bank, bank bit). @@ -98,6 +135,413 @@ pub fn restrict_banks_to_profile(profile: &CpuProfile, banks: &mut [u64; 2]) { } } +/// A processor feature word of a Hyper-V partition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HvFeatureWord { + /// Processor feature bank 0. + Bank0, + /// Processor feature bank 1. + Bank1, + /// The XSAVE features. + Xsave, +} + +impl HvFeatureWord { + /// Every word. + pub const ALL: [Self; 3] = [Self::Bank0, Self::Bank1, Self::Xsave]; + + /// Returns the word's name, as messages spell it. + pub fn name(self) -> &'static str { + match self { + Self::Bank0 => "bank0", + Self::Bank1 => "bank1", + Self::Xsave => "xsave", + } + } +} + +/// A partition's processor features: feature banks 0 and 1, and the XSAVE +/// features. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HvFeatures { + /// Processor feature banks 0 and 1. + pub banks: [u64; 2], + /// The XSAVE features. + pub xsave: u64, +} + +impl HvFeatures { + /// Returns `word`. + pub fn word(&self, word: HvFeatureWord) -> u64 { + match word { + HvFeatureWord::Bank0 => self.banks[0], + HvFeatureWord::Bank1 => self.banks[1], + HvFeatureWord::Xsave => self.xsave, + } + } + + /// Returns `word`, to change it. + pub fn word_mut(&mut self, word: HvFeatureWord) -> &mut u64 { + match word { + HvFeatureWord::Bank0 => &mut self.banks[0], + HvFeatureWord::Bank1 => &mut self.banks[1], + HvFeatureWord::Xsave => &mut self.xsave, + } + } +} + +/// A CPUID feature bit: leaf, subleaf, register (0 for EAX through 3 for +/// EDX), and bit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CpuidBit { + /// The leaf. + pub leaf: u32, + /// The subleaf, 0 for a leaf without subleaves. + pub subleaf: u32, + /// The register: 0 for EAX, 1 for EBX, 2 for ECX, and 3 for EDX. + pub register: usize, + /// The bit. + pub bit: u32, +} + +impl CpuidBit { + /// Returns the CPUID bit `bit` of `register` in `leaf` and `subleaf`. + pub const fn new(leaf: u32, subleaf: u32, register: usize, bit: u32) -> Self { + Self { + leaf, + subleaf, + register, + bit, + } + } +} + +impl fmt::Display for CpuidBit { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let register = ["eax", "ebx", "ecx", "edx"][self.register]; + write!( + f, + "{:#x}.{}:{}[{}]", + self.leaf, self.subleaf, register, self.bit + ) + } +} + +/// A processor feature bit and the CPUID feature bit it controls. +#[derive(Debug)] +pub struct HvFeature { + setter: &'static str, + /// The word that holds the bit. + pub word: HvFeatureWord, + /// The bit, as a mask of [`Self::word`]. + pub mask: u64, + /// The CPUID feature bit that a partition presents only with this bit. + pub cpuid: CpuidBit, +} + +impl HvFeature { + /// Returns the feature's name: hvdef's field name, such as + /// `sse3_support`. + pub fn name(&self) -> &'static str { + self.setter.trim_start_matches("with_") + } + + /// Returns whether the CPUID bit is one of the time policy bits, which + /// the time ABI's CPUID supplies whatever the features. + pub fn is_time_policy(&self) -> bool { + let bit = self.cpuid; + TIME_POLICY_BITS.contains(&(bit.leaf, bit.subleaf, bit.register, 1 << bit.bit)) + } +} + +macro_rules! feature { + ($word:ident, $ty:ident, $with:ident, $leaf:expr, $subleaf:expr, $register:expr, $bit:expr) => { + HvFeature { + setter: stringify!($with), + word: HvFeatureWord::$word, + mask: $ty::new().$with(true).into_bits(), + cpuid: CpuidBit::new($leaf, $subleaf, $register, $bit), + } + }; +} + +macro_rules! bank0 { + ($with:ident, $($cpuid:expr),+) => { + feature!(Bank0, Bank0, $with, $($cpuid),+) + }; +} + +macro_rules! bank1 { + ($with:ident, $($cpuid:expr),+) => { + feature!(Bank1, Bank1, $with, $($cpuid),+) + }; +} + +macro_rules! xsave { + ($with:ident, $($cpuid:expr),+) => { + feature!(Xsave, XsaveBank, $with, $($cpuid),+) + }; +} + +/// The processor feature bits with a CPUID feature, as leaf, subleaf, +/// register, and bit. The leaves follow the Intel SDM and the AMD APM. The +/// bits that the banks derive into `IA32_ARCH_CAPABILITIES` are +/// [`ARCH_CAPABILITIES_BANK_BITS`]. +/// +/// WHP's hardware test `features_control_the_mapped_cpuid_bits` checks the +/// table against what WHP presents on a host. +pub const HV_FEATURES: &[HvFeature] = &[ + // Bank 0. + bank0!(with_sse3_support, 1, 0, ECX, 0), + bank0!(with_lahf_sahf_support, X1, 0, ECX, 0), + bank0!(with_ssse3_support, 1, 0, ECX, 9), + bank0!(with_sse4_1_support, 1, 0, ECX, 19), + bank0!(with_sse4_2_support, 1, 0, ECX, 20), + bank0!(with_sse4a_support, X1, 0, ECX, 6), + bank0!(with_xop_support, X1, 0, ECX, 11), + bank0!(with_pop_cnt_support, 1, 0, ECX, 23), + bank0!(with_cmpxchg16b_support, 1, 0, ECX, 13), + bank0!(with_altmovcr8_support, X1, 0, ECX, 4), + bank0!(with_lzcnt_support, X1, 0, ECX, 5), + bank0!(with_mis_align_sse_support, X1, 0, ECX, 7), + bank0!(with_mmx_ext_support, X1, 0, EDX, 22), + bank0!(with_amd3d_now_support, X1, 0, EDX, 31), + bank0!(with_extended_amd3d_now_support, X1, 0, EDX, 30), + bank0!(with_page_1gb_support, X1, 0, EDX, 26), + bank0!(with_aes_support, 1, 0, ECX, 25), + bank0!(with_pclmulqdq_support, 1, 0, ECX, 1), + bank0!(with_pcid_support, 1, 0, ECX, 17), + bank0!(with_fma4_support, X1, 0, ECX, 16), + bank0!(with_f16c_support, 1, 0, ECX, 29), + bank0!(with_rd_rand_support, 1, 0, ECX, 30), + bank0!(with_rd_wr_fs_gs_support, 7, 0, EBX, 0), + bank0!(with_smep_support, 7, 0, EBX, 7), + bank0!(with_enhanced_fast_string_support, 7, 0, EBX, 9), + bank0!(with_bmi1_support, 7, 0, EBX, 3), + bank0!(with_bmi2_support, 7, 0, EBX, 8), + bank0!(with_movbe_support, 1, 0, ECX, 22), + bank0!(with_dep_x87_fpu_save_support, 7, 0, EBX, 13), + bank0!(with_rd_seed_support, 7, 0, EBX, 18), + bank0!(with_adx_support, 7, 0, EBX, 19), + bank0!(with_intel_prefetch_support, X1, 0, ECX, 8), + bank0!(with_smap_support, 7, 0, EBX, 20), + bank0!(with_hle_support, 7, 0, EBX, 4), + bank0!(with_rtm_support, 7, 0, EBX, 11), + bank0!(with_rdtscp_support, X1, 0, EDX, 27), + bank0!(with_clflushopt_support, 7, 0, EBX, 23), + bank0!(with_clwb_support, 7, 0, EBX, 24), + bank0!(with_sha_support, 7, 0, EBX, 29), + bank0!(with_x87_pointers_saved_support, X8, 0, EBX, 2), + bank0!(with_invpcid_support, 7, 0, EBX, 10), + bank0!(with_ibrs_support, 7, 0, EDX, 26), + bank0!(with_stibp_support, 7, 0, EDX, 27), + // CPUID enumerates IBPB with IBRS; WHP derives the bit from IBRS. + bank0!(with_ibpb_support, 7, 0, EDX, 26), + bank0!(with_mdd_support, 7, 0, EDX, 31), + bank0!(with_fast_short_rep_mov_support, 7, 0, EDX, 4), + bank0!(with_l1d_cache_flush_support, 7, 0, EDX, 28), + bank0!(with_rd_pid_support, 7, 0, ECX, 22), + bank0!(with_umip_support, 7, 0, ECX, 2), + bank0!(with_mb_clear_support, 7, 0, EDX, 10), + // Bank 1. + bank1!(with_a_count_m_count_support, 6, 0, ECX, 0), + bank1!(with_tsc_invariant_support, X7, 0, EDX, 8), + bank1!(with_cl_zero_support, X8, 0, EBX, 0), + bank1!(with_rdpru_support, X8, 0, EBX, 4), + bank1!(with_la57_support, 7, 0, ECX, 16), + bank1!(with_nested_virt_support, 1, 0, ECX, 5), + bank1!(with_psfd_support, 7, 2, EDX, 0), + bank1!(with_cet_ss_support, 7, 0, ECX, 7), + bank1!(with_cet_ibt_support, 7, 0, EDX, 20), + bank1!(with_enqcmd_support, 7, 0, ECX, 29), + bank1!(with_umwait_tpause_support, 7, 0, ECX, 5), + bank1!(with_movdiri_support, 7, 0, ECX, 27), + bank1!(with_movdir64b_support, 7, 0, ECX, 28), + bank1!(with_cldemote_support, 7, 0, ECX, 25), + bank1!(with_serialize_support, 7, 0, EDX, 14), + bank1!(with_tsc_deadline_tmr_support, 1, 0, ECX, 24), + bank1!(with_tsc_adjust_support, 7, 0, EBX, 1), + bank1!(with_fz_l_rep_movsb, 7, 1, EAX, 10), + bank1!(with_fs_rep_stosb, 7, 1, EAX, 11), + bank1!(with_fs_rep_cmpsb, 7, 1, EAX, 12), + bank1!(with_tsx_ld_trk_support, 7, 0, EDX, 16), + bank1!(with_cmpccxadd_support, 7, 1, EAX, 7), + bank1!(with_bhi_dis_support, 7, 2, EDX, 4), + bank1!(with_prefetch_i_support, 7, 1, EDX, 14), + bank1!(with_sha512_support, 7, 1, EAX, 0), + bank1!(with_sm3_support, 7, 1, EAX, 1), + bank1!(with_sm4_support, 7, 1, EAX, 2), + bank1!(with_lass_support, 7, 1, EAX, 6), + // XSAVE features. + xsave!(with_xsave_support, 1, 0, ECX, 26), + xsave!(with_xsaveopt_support, 0xd, 1, EAX, 0), + xsave!(with_avx_support, 1, 0, ECX, 28), + xsave!(with_avx2_support, 7, 0, EBX, 5), + xsave!(with_fma_support, 1, 0, ECX, 12), + xsave!(with_mpx_support, 7, 0, EBX, 14), + xsave!(with_avx512_support, 7, 0, EBX, 16), + xsave!(with_avx512_dq_support, 7, 0, EBX, 17), + xsave!(with_avx512_cd_support, 7, 0, EBX, 28), + xsave!(with_avx512_bw_support, 7, 0, EBX, 30), + xsave!(with_avx512_vl_support, 7, 0, EBX, 31), + xsave!(with_xsave_comp_support, 0xd, 1, EAX, 1), + xsave!(with_xsave_supervisor_support, 0xd, 1, EAX, 3), + xsave!(with_xcr1_support, 0xd, 1, EAX, 2), + xsave!(with_avx512_bitalg_support, 7, 0, ECX, 12), + xsave!(with_avx512_ifma_support, 7, 0, EBX, 21), + xsave!(with_avx512_vbmi_support, 7, 0, ECX, 1), + xsave!(with_avx512_vbmi2_support, 7, 0, ECX, 6), + xsave!(with_avx512_vnni_support, 7, 0, ECX, 11), + xsave!(with_gfni_support, 7, 0, ECX, 8), + xsave!(with_vaes_support, 7, 0, ECX, 9), + xsave!(with_avx512_vpopcntdq_support, 7, 0, ECX, 14), + xsave!(with_vpclmulqdq_support, 7, 0, ECX, 10), + xsave!(with_avx512_bf16_support, 7, 1, EAX, 5), + xsave!(with_avx512_vp2_intersect_support, 7, 0, EDX, 8), + xsave!(with_avx512_fp16_support, 7, 0, EDX, 23), + xsave!(with_xfd_support, 0xd, 1, EAX, 4), + xsave!(with_amx_tile_support, 7, 0, EDX, 24), + xsave!(with_amx_bf16_support, 7, 0, EDX, 22), + xsave!(with_amx_int8_support, 7, 0, EDX, 25), + xsave!(with_avx_vnni_support, 7, 1, EAX, 4), + xsave!(with_avx_ifma_support, 7, 1, EAX, 23), + xsave!(with_avx_ne_convert_support, 7, 1, EDX, 5), + xsave!(with_avx_vnni_int8_support, 7, 1, EDX, 4), + xsave!(with_avx_vnni_int16_support, 7, 1, EDX, 10), + // CPUID enumerates AVX10 once; leaf 0x24 reports its vector lengths. + xsave!(with_avx10_1_256_support, 7, 1, EDX, 19), + xsave!(with_avx10_1_512_support, 7, 1, EDX, 19), + xsave!(with_amx_fp16_support, 7, 1, EAX, 21), +]; + +/// Returns the bits of `word` that a profile decides: those of +/// [`HV_FEATURES`], and for the banks those of +/// [`ARCH_CAPABILITIES_BANK_BITS`]. The backend decides the other bits. +pub fn mapped_mask(word: HvFeatureWord) -> u64 { + let features = HV_FEATURES + .iter() + .filter(|feature| feature.word == word) + .fold(0, |mask, feature| mask | feature.mask); + let bank = match word { + HvFeatureWord::Bank0 => 0, + HvFeatureWord::Bank1 => 1, + HvFeatureWord::Xsave => return features, + }; + ARCH_CAPABILITIES_BANK_BITS + .iter() + .filter(|&&(_, b, _)| b == bank) + .fold(features, |mask, &(.., bit)| mask | 1 << bit) +} + +/// How a profile pins a CPUID bit. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pinned { + /// The profile pins the bit set. + Set, + /// The profile pins the bit clear. + Clear, + /// The bit is VM-owned or runtime-owned: the profile leaves it unpinned. + Unpinned, +} + +/// Returns how `profile` pins `bit`. A leaf or subleaf the profile does not +/// list reads zero, pinned. +pub fn pinned(profile: &CpuProfile, bit: CpuidBit) -> Pinned { + let (value, mask) = profile + .cpuid() + .iter() + .find(|entry| { + let (leaf, subleaf) = entry.key(); + leaf == bit.leaf && subleaf.unwrap_or(0) == bit.subleaf + }) + .map_or((0, !0), |entry| { + (entry.values()[bit.register], entry.masks()[bit.register]) + }); + let flag = 1 << bit.bit; + if mask & flag == 0 { + Pinned::Unpinned + } else if value & flag != 0 { + Pinned::Set + } else { + Pinned::Clear + } +} + +/// Returns the processor features of a partition that presents `profile`, +/// given the features the hypervisor offers. +/// +/// Every feature bit whose CPUID feature the profile clears is cleared, and +/// the `IA32_ARCH_CAPABILITIES` bits that the banks derive are restricted to +/// the profile's pinned value ([`restrict_banks_to_profile`]). Bits that +/// [`mapped_mask`] excludes are left as offered. +/// +/// Fails with `E_PROFILE_UNSUPPORTED`, naming every shortfall, when the +/// profile sets a CPUID feature whose bit the hypervisor does not offer +/// (except the time policy bits, which the time ABI's CPUID supplies), or +/// when the banks cannot present the profile's `IA32_ARCH_CAPABILITIES`. +pub fn profile_features( + profile: &CpuProfile, + available: HvFeatures, +) -> Result { + let mut features = available; + let mut missing = Vec::new(); + for feature in HV_FEATURES { + let word = features.word_mut(feature.word); + match pinned(profile, feature.cpuid) { + Pinned::Clear => *word &= !feature.mask, + Pinned::Set if *word & feature.mask == 0 && !feature.is_time_policy() => { + missing.push(feature.name().to_owned()); + } + Pinned::Set | Pinned::Unpinned => {} + } + } + restrict_banks_to_profile(profile, &mut features.banks); + if let Some((value, mask)) = profile.msr(IA32_ARCH_CAPABILITIES) { + let presented = arch_capabilities_from_banks(features.banks); + if presented & mask != value & mask { + missing.push(format!( + "IA32_ARCH_CAPABILITIES {:#x} (the banks present {:#x} under mask {mask:#x})", + value & mask, + presented & mask + )); + } + } + if !missing.is_empty() { + return Err(ProfileError::new( + ProfileErrorCode::ProfileUnsupported, + format!( + "the hypervisor's processor features cannot present CPU profile {}: they lack {}", + profile.id(), + missing.join(", ") + ), + )); + } + Ok(features) +} + +/// Clears from `cpuid` every CPUID bit that [`HV_FEATURES`] maps to a feature +/// `available` lacks, so a host's CPUID and its features describe what a +/// partition can present, without a probe partition. +/// +/// A CPUID bit that several features control (IBRS and IBPB both control +/// `SPEC_CTRL`) is cleared when any of them is missing. Bits the table does +/// not map are left alone. +pub fn restrict_cpuid_to_features(cpuid: &mut [CpuidEntry], available: HvFeatures) { + for feature in HV_FEATURES { + if available.word(feature.word) & feature.mask != 0 { + continue; + } + let bit = feature.cpuid; + for entry in cpuid.iter_mut().filter(|entry| { + entry.leaf.0 == bit.leaf && entry.subleaf.map_or(0, |subleaf| subleaf.0) == bit.subleaf + }) { + let mut registers = entry.registers(); + registers[bit.register] &= !(1 << bit.bit); + *entry = CpuidEntry::new(bit.leaf, entry.subleaf.map(|subleaf| subleaf.0), registers); + } + } +} + /// Returns the processor feature banks 0 and 1 a Hyper-V fingerprint /// recorded, if any. pub(crate) fn fingerprint_banks(backend: &BackendFingerprint) -> Option<[u64; 2]> { @@ -123,6 +567,8 @@ pub(crate) fn fingerprint_banks(backend: &BackendFingerprint) -> Option<[u64; 2] #[cfg(test)] mod tests { use super::*; + use crate::test_support::profile; + use crate::test_support::profile_entries; use test_with_tracing::test; #[test] @@ -157,7 +603,7 @@ mod tests { #[test] fn restricts_the_banks_to_the_profile() { - let icelake = crate::test_support::profile("intel.icelake-sp.v1"); + let icelake = profile("intel.icelake-sp.v1"); let mut banks = [!0, !0]; restrict_banks_to_profile(icelake, &mut banks); assert_eq!(arch_capabilities_from_banks(banks), 0x0800_0121); @@ -171,9 +617,289 @@ mod tests { assert_eq!(banks[0] | mapped(0), !0); assert_eq!(banks[1] | mapped(1), !0); - let skylake = crate::test_support::profile("intel.skylake-sp.v1"); + let skylake = profile("intel.skylake-sp.v1"); let mut banks = [!0, !0]; restrict_banks_to_profile(skylake, &mut banks); assert_eq!(arch_capabilities_from_banks(banks), 0); } + + /// The processor features the hypervisors offered on the fleet's hosts + /// (CPU fingerprints): WHP's capability and MSHV's host partition + /// properties on the bare-metal Skylake-SP hosts and on the Ice Lake-SP + /// (8370C) and Emerald Rapids (8573C) Azure runners. + const HOSTS: [(&str, &str, HvFeatures); 6] = [ + ( + "intel.skylake-sp.v1", + "whp prometheus28", + HvFeatures { + banks: [0x1001_f9ff_e7f7_859f, 0x0000_000f_1086_0063], + xsave: 0x3fff, + }, + ), + ( + "intel.icelake-sp.v1", + "whp 8370C", + HvFeatures { + banks: [0x2e0a_8bff_e7f7_859f, 0x0001_000e_0000_00f1], + xsave: 0x7f_ffdf, + }, + ), + ( + "intel.emeraldrapids.v1", + "whp 8573C", + HvFeatures { + banks: [0x0e0a_8bff_e7f7_859f, 0x0000_000c_0000_0051], + xsave: 0x7f_ffdf, + }, + ), + ( + "intel.skylake-sp.v1", + "mshv prometheus30", + HvFeatures { + banks: [0x1005_f9ff_fff7_859f, 0x0000_008f_1086_0063], + xsave: 0x3fff, + }, + ), + ( + "intel.icelake-sp.v1", + "mshv 8370C", + HvFeatures { + banks: [0x2e2a_8bff_fff7_859f, 0x0001_00ae_0000_00f1], + xsave: 0x7f_ffdf, + }, + ), + ( + "intel.emeraldrapids.v1", + "mshv 8573C", + HvFeatures { + banks: [0x0e2a_8bff_fff7_859f, 0x0000_000c_0000_0051], + xsave: 0x7f_ffdf, + }, + ), + ]; + + /// The features prometheus28's WHP offers. + const PROMETHEUS28: HvFeatures = HOSTS[0].2; + + fn mask(word: HvFeatureWord, name: &str) -> u64 { + HV_FEATURES + .iter() + .find(|feature| feature.word == word && feature.name() == name) + .unwrap_or_else(|| panic!("no feature {name} in {}", word.name())) + .mask + } + + fn has(features: &HvFeatures, word: HvFeatureWord, name: &str) -> bool { + features.word(word) & mask(word, name) != 0 + } + + #[test] + fn every_feature_is_one_distinct_bit() { + let mut seen = Vec::new(); + for feature in HV_FEATURES { + assert_eq!(feature.mask.count_ones(), 1, "{}", feature.name()); + assert!( + !seen.contains(&(feature.word, feature.mask)), + "{} repeats a bit", + feature.name() + ); + seen.push((feature.word, feature.mask)); + } + // No feature bit is also an IA32_ARCH_CAPABILITIES bit. + for &(_, bank, bit) in &ARCH_CAPABILITIES_BANK_BITS { + assert!( + !seen.contains(&(HvFeatureWord::ALL[bank], 1 << bit)), + "bank{bank} bit {bit}" + ); + } + } + + #[test] + fn the_mapped_mask_covers_the_features_and_arch_capabilities() { + for word in HvFeatureWord::ALL { + let features = HV_FEATURES + .iter() + .filter(|feature| feature.word == word) + .fold(0, |mask, feature| mask | feature.mask); + let arch_capabilities = ARCH_CAPABILITIES_BANK_BITS + .iter() + .filter(|&&(_, bank, _)| HvFeatureWord::ALL[bank] == word) + .fold(0u64, |mask, &(.., bit)| mask | 1 << bit); + assert_eq!( + mapped_mask(word), + features | arch_capabilities, + "{}", + word.name() + ); + } + } + + #[test] + fn pinned_profiles_derive_on_their_hosts() { + for (id, host, available) in HOSTS { + let profile = profile(id); + let features = + profile_features(profile, available).unwrap_or_else(|err| panic!("{host}: {err}")); + for word in HvFeatureWord::ALL { + assert_eq!( + features.word(word) & !available.word(word), + 0, + "{host}: {} gained bits", + word.name() + ); + assert_eq!( + features.word(word) & !mapped_mask(word), + available.word(word) & !mapped_mask(word), + "{host}: {} changed unmapped bits", + word.name() + ); + } + // Every mapped bit follows the profile's CPUID. + for feature in HV_FEATURES { + let offered = available.word(feature.word) & feature.mask != 0; + let kept = features.word(feature.word) & feature.mask != 0; + match pinned(profile, feature.cpuid) { + Pinned::Clear => assert!(!kept, "{host}: {}", feature.name()), + Pinned::Set | Pinned::Unpinned => { + assert_eq!(kept, offered, "{host}: {}", feature.name()) + } + } + } + // The time policy: no TSC-deadline, TSC_ADJUST, or APERF/MPERF. + for name in [ + "tsc_deadline_tmr_support", + "tsc_adjust_support", + "a_count_m_count_support", + ] { + assert!( + !has(&features, HvFeatureWord::Bank1, name), + "{host}: {name}" + ); + } + let (value, msr_mask) = profile.msr(IA32_ARCH_CAPABILITIES).unwrap(); + assert_eq!( + arch_capabilities_from_banks(features.banks) & msr_mask, + value & msr_mask, + "{host}" + ); + } + } + + #[test] + fn skylake_keeps_the_speculation_controls() { + // The Skylake-SP profile pins SPEC_CTRL, STIBP, SSBD, and MD_CLEAR + // (CPUID 7.0 EDX 0xac000400), which WHP's default features omit. + for (id, host, available) in HOSTS { + if id != "intel.skylake-sp.v1" { + continue; + } + let features = profile_features(profile(id), available).unwrap(); + for name in [ + "ibrs_support", + "ibpb_support", + "stibp_support", + "mdd_support", + "mb_clear_support", + ] { + assert!(has(&features, HvFeatureWord::Bank0, name), "{host}: {name}"); + } + } + } + + #[test] + fn invariant_tsc_follows_the_host() { + let skylake = profile_features(profile("intel.skylake-sp.v1"), PROMETHEUS28).unwrap(); + assert!(has(&skylake, HvFeatureWord::Bank1, "tsc_invariant_support")); + // Azure's WHP offers no invariant TSC. The profile still pins it, and + // the time ABI's CPUID supplies it. + let icelake = profile_features(profile("intel.icelake-sp.v1"), HOSTS[1].2).unwrap(); + assert!(!has( + &icelake, + HvFeatureWord::Bank1, + "tsc_invariant_support" + )); + } + + #[test] + fn a_missing_feature_is_unsupported() { + let mut available = PROMETHEUS28; + available.xsave &= !mask(HvFeatureWord::Xsave, "avx512_support"); + available.banks[0] &= !mask(HvFeatureWord::Bank0, "ibrs_support"); + let error = profile_features(profile("intel.skylake-sp.v1"), available).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + assert!(error.message.contains("avx512_support"), "{error}"); + assert!(error.message.contains("ibrs_support"), "{error}"); + + // A newer generation's profile on an older host. + let error = profile_features(profile("intel.emeraldrapids.v1"), PROMETHEUS28).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + } + + #[test] + fn unlisted_leaves_read_zero_and_unmasked_bits_are_unpinned() { + let skylake = profile("intel.skylake-sp.v1"); + // Skylake-SP lists no leaf 7.1, so its features are pinned clear. + assert_eq!(pinned(skylake, CpuidBit::new(7, 1, EAX, 5)), Pinned::Clear); + // OSXSAVE is runtime state: the profile leaves it unpinned. + assert_eq!( + pinned(skylake, CpuidBit::new(1, 0, ECX, 27)), + Pinned::Unpinned + ); + assert_eq!(pinned(skylake, CpuidBit::new(1, 0, ECX, 26)), Pinned::Set); + assert_eq!(CpuidBit::new(7, 0, EDX, 26).to_string(), "0x7.0:edx[26]"); + } + + #[test] + fn restricts_cpuid_to_the_features() { + // Each host's features present its profile, except time policy bits + // that the time ABI's CPUID supplies. + for (id, host, available) in HOSTS { + let original = profile_entries(profile(id)); + let mut cpuid = original.clone(); + restrict_cpuid_to_features(&mut cpuid, available); + for (entry, before) in cpuid.iter().zip(&original) { + let (leaf, subleaf) = (entry.leaf.0, entry.subleaf.map_or(0, |s| s.0)); + for register in 0..4 { + let lost = before.registers()[register] & !entry.registers()[register]; + for bit in (0..32).filter(|bit| lost & (1 << bit) != 0) { + assert!( + TIME_POLICY_BITS.contains(&(leaf, subleaf, register, 1 << bit)), + "{host}: lost {}", + CpuidBit::new(leaf, subleaf, register, bit) + ); + } + } + } + } + + // A missing feature clears its CPUID bit, and nothing else. + let skylake = profile_entries(profile("intel.skylake-sp.v1")); + let mut available = PROMETHEUS28; + available.xsave &= !mask(HvFeatureWord::Xsave, "avx512_support"); + available.banks[0] &= !mask(HvFeatureWord::Bank0, "ibpb_support"); + let mut cpuid = skylake.clone(); + restrict_cpuid_to_features(&mut cpuid, available); + let leaf7 = |cpuid: &[CpuidEntry]| { + cpuid + .iter() + .find(|entry| entry.leaf.0 == 7 && entry.subleaf.map_or(0, |s| s.0) == 0) + .unwrap() + .registers() + }; + let (before, after) = (leaf7(&skylake), leaf7(&cpuid)); + // AVX512F goes, AVX512DQ stays. + assert_eq!(after[EBX], before[EBX] & !(1 << 16)); + // IBPB missing clears SPEC_CTRL, which IBRS and IBPB share. + assert_eq!(after[EDX], before[EDX] & !(1 << 26)); + assert_eq!( + (after[EAX], after[ECX]), + (before[EAX], before[ECX]), + "other registers" + ); + for (entry, before) in cpuid.iter().zip(&skylake) { + if entry.leaf.0 != 7 { + assert_eq!(entry, before); + } + } + } } From c250fba25d3ba364d5dbb525d76c34a47bce371a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 14:37:27 -0700 Subject: [PATCH 210/304] virt: resume partition time inside the time ABI synchronized TSC set Every backend now resumes partition time inside set_synchronized_tsc, right after a successful read-back: MSHV since edae5f77d, and WHP with its frozen write. Guest time therefore runs from the restore anchor on every backend, and the release hook that core called before the restored VPs first ran is a no-op everywhere. Remove TimeAbiBackend::release_time, core's call at restore step 17, and its flag. State the rule in the set_synchronized_tsc and FrozenWrite docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/worker/dispatch/restore.rs | 21 ------------------- .../src/worker/dispatch/time_abi.rs | 10 --------- vmm_core/virt/src/time_abi/backend.rs | 17 +++++++-------- 3 files changed, 7 insertions(+), 41 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index 8047b71372e..3be613333f0 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -144,9 +144,6 @@ pub(super) struct SnapshotRestore { time: Option, /// The time ABI inputs of a restore, validated by the controller. pub(super) time_abi: Option, - /// Whether partition time must be released before the restored VPs - /// first run (time ABI restore step 17). - time_abi_release: bool, /// VP prefix instantiated for an explicit MSHV restore-time activation /// target. vp_prefix: Option, @@ -346,7 +343,6 @@ impl LoadedVm { self.snapshot_restore.packet_selected = input.packet_selected.take(); let clock_restore = ProfileSpan::start(); self.time_abi_restore(input).await?; - self.snapshot_restore.time_abi_release = true; clock_restore.complete("restore", "time_abi_clock", Default::default()); } self.advance_restored_clock(self.snapshot_restore.time) @@ -384,23 +380,6 @@ impl LoadedVm { }, ); } - // Release partition time just before the readiness event and the VP - // release, so a failure never publishes readiness. - #[cfg(guest_arch = "x86_64")] - if std::mem::take(&mut self.snapshot_restore.time_abi_release) { - let released = self - .inner - .time_abi - .as_ref() - .context("a time ABI restore requires a time ABI partition") - .and_then(|state| { - super::time_abi::release_time(self.inner.partition.as_ref(), state) - }); - if let Err(error) = released { - self.state_units.stop().await; - return Err(error.context("failed to release restored partition time")); - } - } if let Some(mut sink) = self.snapshot_restore.ready_sink.take() { let signal_result = sink.write_all(RESTORE_READY_EVENT_V1).and_then(|()| { #[cfg(windows)] diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 998299db537..f1c84326d87 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -426,16 +426,6 @@ pub(super) fn capture_records( }) } -/// Releases partition time immediately before the restored VPs first run: -/// restore step 17 of the specification. -pub(super) fn release_time( - partition: &dyn HvlitePartition, - state: &TimeAbiState, -) -> anyhow::Result<()> { - backend(partition, &state.report.hypervisor)?.release_time()?; - Ok(()) -} - impl LoadedVm { /// Restores the guest clocks: restore steps 11 to 16 of the /// specification, after the saved state is restored and before any diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index 6930d9ea231..27e337b55e8 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -81,6 +81,11 @@ pub trait TimeAbiBackend: Send + Sync { /// (`E_TSC_SYNC_READBACK`). Creating a VP afterwards is an error /// (`E_VP_LATE_CREATION`). /// + /// Guest time runs from the anchor: a backend that stops partition time + /// for the set resumes it right after a successful read-back, before + /// returning, never at the first VP run. A failed set may leave it + /// stopped; the restore then fails. + /// /// Core calls it once per restore, after every instantiated VP is bound /// (which creates it on MSHV) and its saved state is restored, and before /// any restored VP runs. A cold boot never calls it. @@ -88,15 +93,6 @@ pub trait TimeAbiBackend: Send + Sync { &self, target: &mut dyn FnMut(&HostTimeSample) -> Result, ) -> Result; - - /// Releases partition time immediately before the restored VPs first run. - /// - /// A backend whose synchronized set leaves partition time frozen resumes - /// it here, unless the first VP run resumes it. The default does - /// nothing. - fn release_time(&self) -> Result<(), TimeAbiError> { - Ok(()) - } } /// How a backend delivers the identity MSR range to [`super::TimeAbiMsrs`]. @@ -115,7 +111,8 @@ pub enum TscSyncMethod { /// One TSC offset for every VP, computed from one host TSC read (KVM). CommonOffset, /// Partition time is frozen, the target is written to every VP and read - /// back while frozen, and time resumes at release (MSHV, WHP). + /// back while frozen, and time resumes right after the read-back (MSHV, + /// WHP). FrozenWrite, } From 7fc996d6d87b1b584ec5b949b805277f148e7c84 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 15:09:58 -0700 Subject: [PATCH 211/304] cpu_profile: record the Hyper-V feature constraints MSHV enforces The mshv agent checked the shared feature table on MSHV hardware: on a Skylake-SP host and on the 8573C runners, each offered bank bit, cleared alone, removes exactly the CPUID bit the table maps it to. It also found feature sets the hypervisor refuses: mb_clear cleared on its own, rdtscp cleared on its own on Azure, or an XSAVE prerequisite cleared while what depends on it stays set. A profile that cleared one of those where a host offers it would fail partition creation with the hypervisor's error rather than E_PROFILE_UNSUPPORTED. Document the refused sets, add a test that the pinned profiles never produce them on the fleet's recorded hosts, and note what the host's own CPUID view means for restrict_cpuid_to_features: MSHV's root hides TSC_ADJUST, which the surface then under-reports, and root-only bits the table does not map stay set. Note too that nested_virt_support alone does not expose VMX on MSHV, and that LA57 also sets the linear address width. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/cpu_profile/src/hv_banks.rs | 52 ++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/vmm_core/cpu_profile/src/hv_banks.rs b/vmm_core/cpu_profile/src/hv_banks.rs index 980066dc6f7..9d014804c67 100644 --- a/vmm_core/cpu_profile/src/hv_banks.rs +++ b/vmm_core/cpu_profile/src/hv_banks.rs @@ -26,6 +26,16 @@ //! backend's to decide. Features that no bit controls (for example PKU), the //! descriptive leaves (caches, the brand string), and the time policy bits a //! host lacks reach the guest through the backend's CPUID results. +//! +//! The hypervisor refuses some feature sets. MSHV fails to create a partition +//! with `mb_clear_support` cleared on its own on a Skylake-SP host, with +//! `rdtscp_support` cleared on its own on Azure, or with an XSAVE prerequisite +//! (`xsave_support`, `avx_support`, `avx512_support`, +//! `xsave_comp_support`) cleared while what depends on it stays set. No pinned +//! profile clears these where the fleet's hosts offer them, and a test keeps +//! it so. A profile that did would fail partition creation with the +//! hypervisor's error rather than `E_PROFILE_UNSUPPORTED`. The time ABI needs +//! RDTSCP, so no profile clears it. use crate::cpuid::CpuidEntry; use crate::error::ProfileError; @@ -348,7 +358,9 @@ pub const HV_FEATURES: &[HvFeature] = &[ bank1!(with_tsc_invariant_support, X7, 0, EDX, 8), bank1!(with_cl_zero_support, X8, 0, EBX, 0), bank1!(with_rdpru_support, X8, 0, EBX, 4), + // Also sets the linear address width, 0x80000008 EAX[15:8]. bank1!(with_la57_support, 7, 0, ECX, 16), + // MSHV does not expose VMX through this bit alone. bank1!(with_nested_virt_support, 1, 0, ECX, 5), bank1!(with_psfd_support, 7, 2, EDX, 0), bank1!(with_cet_ss_support, 7, 0, ECX, 7), @@ -526,6 +538,14 @@ pub fn profile_features( /// A CPUID bit that several features control (IBRS and IBPB both control /// `SPEC_CTRL`) is cleared when any of them is missing. Bits the table does /// not map are left alone. +/// +/// The function only clears, so the host's own view bounds the result. +/// MSHV's root CPUID hides `TSC_ADJUST` (`7.0:EBX[1]`), which a partition with +/// `tsc_adjust_support` presents, so that bit is under-reported; every profile +/// pins it clear. Root-only bits the table does not map, such as MONITOR, DS, +/// TM, the PMU, PT, and the topology fields, stay set. The derivation policy +/// clears them in every profile, so [`verify_support`](crate::verify_support) +/// never relies on them. pub fn restrict_cpuid_to_features(cpuid: &mut [CpuidEntry], available: HvFeatures) { for feature in HV_FEATURES { if available.word(feature.word) & feature.mask != 0 { @@ -820,6 +840,38 @@ mod tests { )); } + #[test] + fn pinned_profiles_keep_what_the_hypervisor_requires() { + // MSHV refuses a partition with mb_clear or rdtscp cleared on its own, + // or with an XSAVE prerequisite cleared while a feature that depends + // on it stays set (see the module documentation). + for (id, host, available) in HOSTS { + let features = profile_features(profile(id), available).unwrap(); + for name in ["mb_clear_support", "rdtscp_support"] { + if has(&available, HvFeatureWord::Bank0, name) { + assert!( + has(&features, HvFeatureWord::Bank0, name), + "{host}: {id} clears {name}" + ); + } + } + let kept = |name: &str| has(&features, HvFeatureWord::Xsave, name); + for feature in HV_FEATURES { + let name = feature.name(); + if feature.word != HvFeatureWord::Xsave || features.xsave & feature.mask == 0 { + continue; + } + assert!(kept("xsave_support"), "{host}: {name} without xsave"); + if name.starts_with("avx") && name != "avx_support" { + assert!(kept("avx_support"), "{host}: {name} without avx"); + } + if name.starts_with("avx512_") { + assert!(kept("avx512_support"), "{host}: {name} without avx512"); + } + } + } + } + #[test] fn a_missing_feature_is_unsupported() { let mut available = PROMETHEUS28; From a86055ba7ef3cc12312a637a851934275ca87e98 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 14:26:51 -0700 Subject: [PATCH 212/304] virt_mshv: derive time ABI processor features from the CPU profile A time ABI partition now enables the processor features of its CPU profile, within what the host partition offers: cpu_profile::hv_banks' profile_features decides every bit that has a CPUID feature or derives an IA32_ARCH_CAPABILITIES bit, and the other bits keep OpenVMM's legacy policy. The time ABI's policy (invariant TSC kept; TSC-deadline, TSC_ADJUST, and APERF/MPERF hidden) applies on top. A profile that the host cannot present fails partition creation with E_PROFILE_UNSUPPORTED, and an unpinned profile ID with E_PROFILE_UNKNOWN. supported_cpu_surface() reports the CPU surface without a probe partition: the host partition's processor features, the host's CPUID restricted to them, the guest physical address width, and IA32_ARCH_CAPABILITIES from the banks. Core then checks profile support at every boot. Hardware tests check the shared feature table, the surface, and the derived features against MSHV. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/lib.rs | 6 + vmm_core/virt_mshv/src/x86_64/mod.rs | 29 +- .../virt_mshv/src/x86_64/profile_features.rs | 722 ++++++++++++++++++ vmm_core/virt_mshv/src/x86_64/time_abi.rs | 176 ++++- 4 files changed, 903 insertions(+), 30 deletions(-) create mode 100644 vmm_core/virt_mshv/src/x86_64/profile_features.rs diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index 86d3be124be..d201a77736f 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -209,6 +209,8 @@ impl<'a> MshvProtoPartition<'a> { config, #[cfg(guest_arch = "x86_64")] isolation: arch::MshvProtoPartitionIsolation::None, + #[cfg(guest_arch = "x86_64")] + time_abi_host: None, vmfd, vps, #[cfg(guest_arch = "aarch64")] @@ -231,6 +233,10 @@ pub struct MshvProtoPartition<'a> { config: ProtoPartitionConfig<'a>, #[cfg(guest_arch = "x86_64")] isolation: arch::MshvProtoPartitionIsolation, + /// The processor features the host partition offered, read when the + /// partition carries the time ABI. + #[cfg(guest_arch = "x86_64")] + time_abi_host: Option, vmfd: VmFd, vps: Vec, #[cfg(guest_arch = "aarch64")] diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 735296c4d96..7e3855e1104 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -6,6 +6,7 @@ mod extint; pub(crate) mod finalize; mod fingerprint; +mod profile_features; pub(crate) mod time_abi; mod tsc; mod vm_state; @@ -135,10 +136,16 @@ impl virt::Hypervisor for LinuxMshv { x2apic, config.processor_topology.smt_enabled(), )?; - let create_args = if config.time_abi.is_some() { - time_abi::with_feature_banks(create_args) - } else { - tsc::with_features1(create_args, config.versioned_cpu_contract) + let (create_args, time_abi_host) = match &config.time_abi { + Some(time_abi) => { + let host = profile_features::host_features(&self.mshv)?; + let features = time_abi::partition_features(&time_abi.cpu_profile, host)?; + (time_abi::with_features(create_args, features), Some(host)) + } + None => ( + tsc::with_features1(create_args, config.versioned_cpu_contract), + None, + ), }; let vmfd = create_vm_with_retry(&self.mshv, &create_args)?; @@ -232,6 +239,7 @@ impl virt::Hypervisor for LinuxMshv { }; let mut proto = MshvProtoPartition::new(config, vmfd)?; proto.isolation = isolation; + proto.time_abi_host = time_abi_host; Ok(proto) } } @@ -544,11 +552,14 @@ impl ProtoPartition for MshvProtoPartition<'_> { elapsed_us = started.elapsed().as_micros() as u64, "registered MSHV CPUID results" ); - let time_abi = self - .config - .time_abi - .as_ref() - .map(|config| time_abi::MshvTimeAbi::new(config, &cpuid)); + let time_abi = self.config.time_abi.as_ref().map(|config| { + time_abi::MshvTimeAbi::new( + config, + &cpuid, + self.time_abi_host + .expect("a time ABI partition reads the host's features"), + ) + }); let apic_id_map = self .config diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs new file mode 100644 index 00000000000..a2802167342 --- /dev/null +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -0,0 +1,722 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! MSHV processor features derived from a CPU profile, and the CPU surface +//! that MSHV supports on this host. +//! +//! A partition presents a CPU feature only when its processor feature bank +//! bit (or XSAVE feature bit) is set, and some bits also decide which MSRs the +//! guest may use. [`time_abi_features`] starts from the features the host +//! partition offers, and [`hv_banks::profile_features`] makes every bit that +//! the profile decides ([`hv_banks::mapped_mask`]) follow the profile. The +//! other bits, such as the nested-paging and VMX details, keep OpenVMM's +//! legacy policy: what the host offers of OpenVMM's supported lists. The +//! CPUID intercept results then present the profile's leaves verbatim, +//! including the descriptive leaves and the time policy bits a host lacks. +//! +//! [`supported_cpu_surface`] reports what MSHV supports on this host without a +//! probe partition: the host partition's processor features, the host's CPUID +//! restricted to them ([`hv_banks::restrict_cpuid_to_features`]), and the +//! guest physical address width. +//! +//! The ignored hardware tests (`profile_features::hw`) check the shared +//! feature table, the surface, and the derived features against MSHV. + +use crate::KernelError; +use cpu_profile::CpuProfile; +use cpu_profile::cpuid::CpuidEntry; +use cpu_profile::cpuid::EXTENDED_LEAF_BASE; +use cpu_profile::cpuid::HYPERVISOR_LEAF_BASE; +use cpu_profile::hv_banks; +use cpu_profile::hv_banks::HvFeatureWord; +use cpu_profile::hv_banks::HvFeatures; +use hvdef::HvPartitionPropertyCode; +use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; +use mshv_ioctls::Mshv; +use virt::CpuidLeaf; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; +use virt::time_abi::surface::SupportedCpuSurface; +use virt::time_abi::surface::SupportedMsrValue; + +/// Returns the features of OpenVMM's supported lists, which every MSHV +/// partition without the time ABI enables. +pub(crate) fn legacy_features() -> HvFeatures { + HvFeatures { + banks: [ + super::supported_processor_features().into_bits(), + super::supported_processor_features1().into_bits(), + ], + xsave: super::supported_xsave_features().into_bits(), + } +} + +/// Reads the features that the host partition offers its child partitions. +/// Fails with `E_PROFILE_UNSUPPORTED`. +pub(crate) fn host_features(mshv: &Mshv) -> Result { + let property = |code: HvPartitionPropertyCode| { + mshv.get_host_partition_property(code.0).map_err(|error| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "cannot read the host partition's {code:?}: {}", + super::time_abi::error_chain(&KernelError::from(error)) + ), + ) + }) + }; + Ok(HvFeatures { + banks: [ + property(HvPartitionPropertyCode::ProcessorFeatures0)?, + property(HvPartitionPropertyCode::ProcessorFeatures1)?, + ], + xsave: property(HvPartitionPropertyCode::ProcessorXsaveFeatures)?, + }) +} + +/// Returns the processor features of a time ABI partition with CPU profile +/// `profile` on a host that offers `host`. +/// +/// The bits the profile decides start from what the host offers; the other +/// bits keep OpenVMM's legacy policy. On top, the time ABI's policy keeps the +/// invariant TSC and hides the TSC-deadline timer, `IA32_TSC_ADJUST`, and +/// APERF/MPERF, whatever the profile says. Fails with `E_PROFILE_UNSUPPORTED`. +pub(crate) fn time_abi_features( + profile: &CpuProfile, + host: HvFeatures, +) -> Result { + let legacy = legacy_features(); + let mut start = host; + for word in HvFeatureWord::ALL { + *start.word_mut(word) &= legacy.word(word) | hv_banks::mapped_mask(word); + } + let mut features = hv_banks::profile_features(profile, start) + .map_err(|error| TimeAbiError::new(TimeAbiCode::ProfileUnsupported, error.message))?; + features.banks[1] = + super::time_abi::processor_features1(Bank1::from_bits(features.banks[1])).into_bits(); + Ok(features) +} + +/// Returns the CPUID result of the host processor for `leaf` and `subleaf`. +// The host CPU is identified with the CPUID instruction of the host, which +// exists only on x86-64 hosts. +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(target_arch = "x86_64")] +fn host_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + let result = std::arch::x86_64::__cpuid_count(leaf, subleaf); + [result.eax, result.ebx, result.ecx, result.edx] +} + +// xtask-fmt allow-target-arch cpu-intrinsic +#[cfg(not(target_arch = "x86_64"))] +fn host_cpuid(_leaf: u32, _subleaf: u32) -> [u32; 4] { + [0; 4] +} + +/// Returns the CPU surface that MSHV supports on this host, given the +/// features the host partition offers and the guest physical address width. +/// +/// This is what a partition that enables every offered feature presents, +/// approximated without creating one: the host's CPUID without the mapped +/// features `host` lacks. `IA32_ARCH_CAPABILITIES` follows the banks. +pub(crate) fn supported_cpu_surface( + host: HvFeatures, + physical_address_width: u8, +) -> SupportedCpuSurface { + surface_from_host_cpuid( + cpu_profile::cpuid::enumerate(|leaf, subleaf| { + Ok::<_, std::convert::Infallible>(host_cpuid(leaf, subleaf)) + }) + .unwrap_or_else(|never| match never {}), + host, + physical_address_width, + ) +} + +fn surface_from_host_cpuid( + mut cpuid: Vec, + host: HvFeatures, + physical_address_width: u8, +) -> SupportedCpuSurface { + // The hypervisor range is the host's own Hyper-V interface, not the + // guest's, whose identity the time ABI defines. + cpuid.retain(|entry| !(HYPERVISOR_LEAF_BASE..EXTENDED_LEAF_BASE).contains(&entry.leaf.0)); + hv_banks::restrict_cpuid_to_features(&mut cpuid, host); + let msr = hv_banks::arch_capabilities_msr(host.banks); + SupportedCpuSurface { + cpuid: cpuid + .iter() + .map(|entry| { + let leaf = CpuidLeaf::new(entry.leaf.0, entry.registers()); + match entry.subleaf { + Some(subleaf) => leaf.indexed(subleaf.0), + None => leaf, + } + }) + .collect(), + physical_address_width, + msrs: vec![SupportedMsrValue { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }], + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cpu_profile::hv_banks::HV_FEATURES; + use cpu_profile::hv_banks::Pinned; + use hvdef::HvX64PartitionProcessorFeatures as Bank0; + use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; + + /// `IA32_ARCH_CAPABILITIES`. + const MSR_ARCH_CAPABILITIES: u32 = 0x10a; + + /// Features that the host partition offered on the fleet's MSHV hosts + /// (CPU fingerprints): prometheus30 (Skylake-SP, bare metal) and the 8573C + /// runners (Emerald Rapids, nested). + const PROMETHEUS30: HvFeatures = HvFeatures { + banks: [0x1005_f9ff_fff7_859f, 0x0000_008f_1086_0063], + xsave: 0x3fff, + }; + const AZURE_8573C: HvFeatures = HvFeatures { + banks: [0x0e2a_8bff_fff7_859f, 0x0000_000c_0000_0051], + xsave: 0x7f_ffdf, + }; + + const HOSTS: [(&str, HvFeatures); 2] = [ + ("intel.skylake-sp.v1", PROMETHEUS30), + ("intel.emeraldrapids.v1", AZURE_8573C), + ]; + + fn profile(id: &str) -> &'static CpuProfile { + cpu_profile::pinned(id).unwrap_or_else(|| panic!("profile {id} is not pinned")) + } + + fn has(features: &HvFeatures, word: HvFeatureWord, mask: u64) -> bool { + features.word(word) & mask != 0 + } + + fn bank1(f: fn(Bank1) -> Bank1) -> u64 { + f(Bank1::new()).into_bits() + } + + #[test] + fn pinned_profiles_derive_on_their_hosts() { + let legacy = legacy_features(); + let invariant = bank1(|b| b.with_tsc_invariant_support(true)); + for (id, host) in HOSTS { + let profile = profile(id); + let features = time_abi_features(profile, host).unwrap(); + for word in HvFeatureWord::ALL { + // Only the forced invariant TSC may exceed what the host offers. + let allowed = if word == HvFeatureWord::Bank1 { + invariant + } else { + 0 + }; + let gained = features.word(word) & !host.word(word); + assert_eq!(gained & !allowed, 0, "{id}: {} gained bits", word.name()); + // The bits the profile does not decide keep the legacy policy. + let unmapped = !hv_banks::mapped_mask(word) & !allowed; + assert_eq!( + features.word(word) & unmapped, + host.word(word) & legacy.word(word) & unmapped, + "{id}: {} unmapped bits", + word.name() + ); + } + // Every mapped bit follows the profile. + for feature in HV_FEATURES + .iter() + .filter(|feature| !feature.is_time_policy()) + { + let offered = has(&host, feature.word, feature.mask); + let kept = has(&features, feature.word, feature.mask); + match hv_banks::pinned(profile, feature.cpuid) { + Pinned::Clear => assert!(!kept, "{id}: {}", feature.name()), + Pinned::Set => assert!(kept, "{id}: {}", feature.name()), + Pinned::Unpinned => assert_eq!(kept, offered, "{id}: {}", feature.name()), + } + } + // The time policy. + assert!(has(&features, HvFeatureWord::Bank1, invariant), "{id}"); + for mask in [ + bank1(|b| b.with_tsc_deadline_tmr_support(true)), + bank1(|b| b.with_tsc_adjust_support(true)), + bank1(|b| b.with_a_count_m_count_support(true)), + ] { + assert!( + !has(&features, HvFeatureWord::Bank1, mask), + "{id}: {mask:#x}" + ); + } + let (value, mask) = profile.msr(MSR_ARCH_CAPABILITIES).unwrap(); + assert_eq!( + hv_banks::arch_capabilities_from_banks(features.banks) & mask, + value & mask, + "{id}" + ); + } + } + + #[test] + fn bits_the_profile_does_not_decide_keep_the_legacy_policy() { + let features = time_abi_features(profile("intel.skylake-sp.v1"), PROMETHEUS30).unwrap(); + // prometheus30 offers the deprecated HLE and RTM bits, which no CPUID + // feature maps and OpenVMM never listed: they stay off. + for mask in [ + Bank0::new().with_hle_support_deprecated(true).into_bits(), + Bank0::new().with_rtm_support_deprecated(true).into_bits(), + ] { + assert!(has(&PROMETHEUS30, HvFeatureWord::Bank0, mask), "{mask:#x}"); + assert!(!has(&features, HvFeatureWord::Bank0, mask), "{mask:#x}"); + } + // Unrestricted guest support is listed and offered: it stays on. + let unrestricted = Bank0::new() + .with_unrestricted_guest_support(true) + .into_bits(); + assert!(has(&features, HvFeatureWord::Bank0, unrestricted)); + } + + #[test] + fn skylake_presents_no_arch_capabilities() { + // The Skylake-SP profile pins every bank-derived bit clear, including + // RDCL_NO, IBRS_ALL, and TSX_CTRL, which OpenVMM's lists enable. + let features = time_abi_features(profile("intel.skylake-sp.v1"), PROMETHEUS30).unwrap(); + assert_eq!(hv_banks::arch_capabilities_from_banks(features.banks), 0); + } + + #[test] + fn a_missing_feature_is_unsupported() { + let mut host = PROMETHEUS30; + host.xsave &= !XsaveBank::new().with_avx512_support(true).into_bits(); + host.banks[0] &= !Bank0::new().with_ibrs_support(true).into_bits(); + let error = time_abi_features(profile("intel.skylake-sp.v1"), host).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); + assert!(error.message.contains("avx512_support"), "{error}"); + assert!(error.message.contains("ibrs_support"), "{error}"); + let error = time_abi_features(profile("intel.emeraldrapids.v1"), PROMETHEUS30).unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); + } + + #[test] + fn the_time_policy_bits_need_no_host_support() { + // Azure's MSHV offers no invariant TSC; the profile pins it, the CPUID + // results supply it, and the bank bit is forced as before. + let invariant = bank1(|b| b.with_tsc_invariant_support(true)); + assert!(!has(&AZURE_8573C, HvFeatureWord::Bank1, invariant)); + let features = time_abi_features(profile("intel.emeraldrapids.v1"), AZURE_8573C).unwrap(); + assert!(has(&features, HvFeatureWord::Bank1, invariant)); + } + + #[test] + fn the_surface_follows_the_banks() { + let stibp = Bank0::new().with_stibp_support(true).into_bits(); + let host = HvFeatures { + banks: [stibp, 0], + xsave: 0, + }; + // The host lacks L1D_FLUSH and IBRS, so their bits go, and STIBP stays. + // Bit 0, which no feature controls, keeps the host's value, and the + // hypervisor range is dropped. + let cpuid = vec![ + CpuidEntry::new(0, None, [7, 0, 0, 0]), + CpuidEntry::new(7, Some(0), [0, 0, 0, (1 << 28) | (1 << 27) | (1 << 26) | 1]), + CpuidEntry::new( + HYPERVISOR_LEAF_BASE, + None, + [HYPERVISOR_LEAF_BASE + 5, 0, 0, 0], + ), + ]; + let surface = surface_from_host_cpuid(cpuid, host, 46); + let leaves: Vec<_> = surface + .cpuid + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result)) + .collect(); + assert_eq!( + leaves, + [ + (0, None, [7, 0, 0, 0]), + (7, Some(0), [0, 0, 0, (1 << 27) | 1]), + ] + ); + assert_eq!(surface.physical_address_width, 46); + let [msr] = surface.msrs.as_slice() else { + panic!("{:?}", surface.msrs) + }; + assert_eq!(msr.index, MSR_ARCH_CAPABILITIES); + assert_eq!(msr.supported, 0); + assert_eq!(msr.controllable, hv_banks::ARCH_CAPABILITIES_BANK_MASK); + } + + #[test] + fn the_surface_supports_each_host_profile() { + // On a host whose CPUID already matches its profile, the surface of the + // fingerprinted banks supports the profile. + for (id, host) in HOSTS { + let profile = profile(id); + let cpuid = profile + .cpuid() + .iter() + .map(|entry| { + let (leaf, subleaf) = entry.key(); + CpuidEntry::new(leaf, subleaf, entry.values()) + }) + .collect(); + let surface = surface_from_host_cpuid(cpuid, host, 52); + let host_surface = cpu_profile::HostCpuSurface { + cpuid: surface + .cpuid + .iter() + .map(|leaf| CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(), + physical_address_width: surface.physical_address_width, + msrs: surface + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + }; + let violations = cpu_profile::support_violations(profile, &host_surface); + assert!(violations.is_empty(), "{id}: {violations:#?}"); + } + } +} + +/// Hardware tests: `cargo test -p virt_mshv -- --ignored --nocapture +/// --test-threads=1 profile_features::hw`. +#[cfg(test)] +mod hw { + use super::*; + use cpu_profile::HostCpuSignature; + use cpu_profile::hv_banks::CpuidBit; + use cpu_profile::hv_banks::HV_FEATURES; + use cpu_profile::hv_banks::HvFeature; + use mshv_ioctls::VcpuFd; + use mshv_ioctls::VmFd; + + /// A transient partition with one VP that never runs. + struct Probe { + vmfd: VmFd, + vp: VcpuFd, + } + + fn probe_partition(mshv: &Mshv, features: HvFeatures) -> Result { + let args = super::super::time_abi::with_features( + super::super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false) + .unwrap(), + features, + ); + let vmfd = crate::create_vm_with_retry(mshv, &args).map_err(|e| e.to_string())?; + vmfd.initialize().map_err(|e| e.to_string())?; + let vp = vmfd.create_vcpu(0).map_err(|e| e.to_string())?; + Ok(Probe { vmfd, vp }) + } + + /// The leaves and subleaves of the mapped features. + fn feature_leaves() -> Vec<(u32, u32)> { + let mut leaves: Vec<_> = HV_FEATURES + .iter() + .map(|feature| (feature.cpuid.leaf, feature.cpuid.subleaf)) + .collect(); + leaves.sort_unstable(); + leaves.dedup(); + leaves + } + + fn feature_cpuid(probe: &Probe, leaves: &[(u32, u32)]) -> Vec<[u32; 4]> { + leaves + .iter() + .map(|&(leaf, subleaf)| probe.vp.get_cpuid_values(leaf, subleaf, 0, 0).unwrap()) + .collect() + } + + /// Returns the CPUID bits set in `base` and clear in `probe`. + fn removed(leaves: &[(u32, u32)], base: &[[u32; 4]], probe: &[[u32; 4]]) -> Vec { + let mut bits = Vec::new(); + for (index, &(leaf, subleaf)) in leaves.iter().enumerate() { + for register in 0..4 { + let gone = base[index][register] & !probe[index][register]; + for bit in (0..32).filter(|bit| gone & (1 << bit) != 0) { + bits.push(CpuidBit::new(leaf, subleaf, register, bit)); + } + } + } + bits + } + + fn list(bits: &[CpuidBit]) -> String { + if bits.is_empty() { + return "nothing".to_owned(); + } + bits.iter() + .map(ToString::to_string) + .collect::>() + .join(" ") + } + + /// Whether another feature bit controls the same CPUID bit, so that + /// clearing one of them alone may leave it set. + fn shared(feature: &HvFeature) -> bool { + HV_FEATURES + .iter() + .filter(|other| other.cpuid == feature.cpuid) + .count() + > 1 + } + + /// Clears each feature bit the host offers, one at a time, and checks that + /// every mapped one removes its CPUID bit. + #[test] + #[ignore = "requires /dev/mshv"] + fn features_control_the_mapped_cpuid_bits() { + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + println!( + "host: bank0 {:#x} bank1 {:#x} xsave {:#x}", + host.banks[0], host.banks[1], host.xsave + ); + let leaves = feature_leaves(); + let base = feature_cpuid(&probe_partition(&mshv, host).unwrap(), &leaves); + let mut failures = Vec::new(); + for word in HvFeatureWord::ALL { + for bit in (0..64).filter(|bit| host.word(word) & (1 << bit) != 0) { + let mask = 1u64 << bit; + let feature = HV_FEATURES + .iter() + .find(|feature| feature.word == word && feature.mask == mask); + let name = feature.map_or("(unmapped)", |feature| feature.name()); + let mut features = host; + *features.word_mut(word) &= !mask; + let probe = match probe_partition(&mshv, features) { + Ok(probe) => feature_cpuid(&probe, &leaves), + Err(error) => { + println!("{} bit {bit} {name}: rejected: {error}", word.name()); + continue; + } + }; + let removed = removed(&leaves, &base, &probe); + let mut verdict = ""; + if let Some(feature) = feature { + let index = leaves + .iter() + .position(|&key| key == (feature.cpuid.leaf, feature.cpuid.subleaf)) + .unwrap(); + let offered = + base[index][feature.cpuid.register] & (1 << feature.cpuid.bit) != 0; + if offered && !shared(feature) && !removed.contains(&feature.cpuid) { + verdict = " MISMATCH"; + failures.push(format!( + "{} bit {bit} {name}: expected {} removed {}", + word.name(), + feature.cpuid, + list(&removed) + )); + } + } + println!( + "{} bit {bit} {name}: removes {}{verdict}", + word.name(), + list(&removed) + ); + } + } + assert!(failures.is_empty(), "{failures:#?}"); + } + + fn host_surface(surface: &SupportedCpuSurface) -> cpu_profile::HostCpuSurface { + let mut cpuid = surface + .cpuid + .iter() + .map(|leaf| CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::cpuid::normalize(&mut cpuid); + cpu_profile::HostCpuSurface { + cpuid, + physical_address_width: surface.physical_address_width, + msrs: surface + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + } + } + + /// Compares the cheap surface (host CPUID and banks) with the surface of a + /// probe partition that enables every offered feature, which is how the + /// CPU fingerprint measures it: every pinned profile must get the same + /// support verdict from both. + #[test] + #[ignore = "requires /dev/mshv"] + fn the_cheap_surface_gives_the_probe_verdicts() { + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + let probe = probe_partition(&mshv, host).unwrap(); + let width = probe + .vmfd + .get_partition_property(HvPartitionPropertyCode::PhysicalAddressWidth.0) + .unwrap() as u8; + let probe_cpuid = cpu_profile::cpuid::enumerate(|leaf, subleaf| { + probe.vp.get_cpuid_values(leaf, subleaf, 0, 0) + }) + .unwrap(); + // The probe partition's own CPUID already presents its features. + let msr = hv_banks::arch_capabilities_msr(host.banks); + let probe_surface = SupportedCpuSurface { + cpuid: probe_cpuid + .iter() + .filter(|entry| !(HYPERVISOR_LEAF_BASE..EXTENDED_LEAF_BASE).contains(&entry.leaf.0)) + .map(|entry| { + let leaf = CpuidLeaf::new(entry.leaf.0, entry.registers()); + match entry.subleaf { + Some(subleaf) => leaf.indexed(subleaf.0), + None => leaf, + } + }) + .collect(), + physical_address_width: width, + msrs: vec![SupportedMsrValue { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }], + }; + + let mut timings = Vec::new(); + let mut cheap = None; + for _ in 0..10 { + let started = std::time::Instant::now(); + cheap = Some(supported_cpu_surface(host, width)); + timings.push(started.elapsed().as_micros()); + } + timings.sort_unstable(); + let cheap = cheap.unwrap(); + println!( + "host {}: cheap surface {} leaves in {} us (p50 of 10; min {} max {}), probe surface {} leaves", + HostCpuSignature::current(), + cheap.cpuid.len(), + timings[5], + timings[0], + timings[9], + probe_surface.cpuid.len() + ); + + // Report the bits that differ, for the record. The probe's bits that the + // cheap surface lacks would make it under-report. + let cheap_host = host_surface(&cheap); + let probe_host = host_surface(&probe_surface); + let mut under = 0; + for entry in &probe_host.cpuid { + let (leaf, subleaf) = entry.key(); + let theirs = entry.registers(); + let ours = cpu_profile::cpuid::lookup(&cheap_host.cpuid, leaf, subleaf.unwrap_or(0)) + .unwrap_or_default(); + for register in 0..4 { + if theirs[register] != ours[register] { + under += (theirs[register] & !ours[register]).count_ones(); + println!( + " {leaf:#x}.{}:{} probe {:#010x} cheap {:#010x} (only probe {:#x}, only cheap {:#x})", + subleaf.unwrap_or(0), + ["eax", "ebx", "ecx", "edx"][register], + theirs[register], + ours[register], + theirs[register] & !ours[register], + ours[register] & !theirs[register] + ); + } + } + } + println!("bits only the probe has: {under}"); + + let mut failures = Vec::new(); + for profile in cpu_profile::pinned_profiles() { + let theirs = cpu_profile::support_violations(profile, &probe_host); + let ours = cpu_profile::support_violations(profile, &cheap_host); + println!( + "{}: probe {} violations, cheap {} violations", + profile.id(), + theirs.len(), + ours.len() + ); + if theirs != ours { + failures.push(format!( + "{}: probe {theirs:#?} cheap {ours:#?}", + profile.id() + )); + } + } + let own = cpu_profile::select_auto(&HostCpuSignature::current()).unwrap(); + assert!( + cpu_profile::support_violations(own, &cheap_host).is_empty(), + "{}", + own.id() + ); + assert!(failures.is_empty(), "{failures:#?}"); + } + + /// Creates a partition with this host's profile features and no CPUID + /// results, and checks that the banks alone present every mapped feature + /// as the profile pins it. The CPUID results supply the rest. + #[test] + #[ignore = "requires /dev/mshv"] + fn host_profile_features_present_the_profile() { + let profile = cpu_profile::select_auto(&HostCpuSignature::current()).unwrap(); + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + let features = time_abi_features(profile, host).unwrap(); + println!( + "{}: bank0 {:#x} bank1 {:#x} xsave {:#x} (host {:#x} {:#x} {:#x})", + profile.id(), + features.banks[0], + features.banks[1], + features.xsave, + host.banks[0], + host.banks[1], + host.xsave + ); + let probe = probe_partition(&mshv, features).unwrap(); + let mut failures = Vec::new(); + let mut unmapped = 0; + for entry in profile.cpuid() { + let (leaf, subleaf) = entry.key(); + let presented = probe + .vp + .get_cpuid_values(leaf, subleaf.unwrap_or(0), 0, 0) + .unwrap(); + for (register, &value) in presented.iter().enumerate() { + let mask = entry.masks()[register]; + let differs = (value ^ entry.values()[register]) & mask; + for bit in (0..32).filter(|bit| differs & (1 << bit) != 0) { + let at = CpuidBit::new(leaf, subleaf.unwrap_or(0), register, bit); + let feature = HV_FEATURES.iter().find(|feature| feature.cpuid == at); + match feature { + Some(feature) if !feature.is_time_policy() => failures.push(format!( + "{at} ({}): presented {}, pinned {}", + feature.name(), + value >> bit & 1, + entry.values()[register] >> bit & 1 + )), + _ => unmapped += 1, + } + } + } + } + println!( + "{}: {unmapped} pinned bits that no feature maps differ from the hypervisor's own CPUID", + profile.id() + ); + assert!(failures.is_empty(), "{failures:#?}"); + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 86e8418998b..8f1fe65822d 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -19,8 +19,9 @@ //! - CPUID intercept results apply the configured CPUID over the hypervisor's //! own. The hypervisor reports no hypervisor leaf of its own, which //! preflight verifies at sentinel leaves. -//! - The processor feature banks keep the invariant TSC and hide the -//! TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. +//! - The processor feature banks follow the CPU profile (see +//! [`profile_features`](super::profile_features)), keep the invariant TSC, +//! and hide the TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. //! - The synchronized TSC set freezes partition time, writes the target to //! every created VP, reads it back, and resumes partition time right after a //! good read-back. Guest time therefore runs from the restore anchor, as on @@ -34,6 +35,7 @@ use crate::MshvPartition; use crate::MshvPartitionInner; use crate::MshvProcessor; use crate::VcpuFdExt; +use cpu_profile::hv_banks::HvFeatures; use hvdef::HvInterceptAccessType; use hvdef::HvMessage; use hvdef::HvPartitionPropertyCode; @@ -69,6 +71,7 @@ use virt::time_abi::msr::MSR_APIC_FREQUENCY; use virt::time_abi::msr::MSR_TSC_FREQUENCY; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use virt::time_abi::msr::MSR_VP_INDEX; +use virt::time_abi::surface::SupportedCpuSurface; /// The identity MSRs that OpenVMM serves. The hypervisor raises #GP for /// every other MSR of the identity range. @@ -123,15 +126,24 @@ pub(crate) struct MshvTimeAbi { /// Set by the synchronized TSC set. VP creation fails afterwards. #[inspect(with = "|x| x.load(Ordering::Relaxed)")] vp_set_sealed: AtomicBool, + /// The processor features the host partition offered at creation, for + /// the supported CPU surface. + #[inspect(skip)] + host_features: HvFeatures, } impl MshvTimeAbi { - pub(super) fn new(config: &TimeAbiConfig, registered_cpuid: &CpuidLeafSet) -> Self { + pub(super) fn new( + config: &TimeAbiConfig, + registered_cpuid: &CpuidLeafSet, + host_features: HvFeatures, + ) -> Self { Self { msrs: config.msrs.clone(), registered_cpuid: registered_cpuid.leaves().to_vec(), effective_cpuid: std::sync::OnceLock::new(), vp_set_sealed: AtomicBool::new(false), + host_features, } } @@ -189,14 +201,40 @@ pub(super) fn processor_features1( .with_a_count_m_count_support(false) } -/// Applies the time ABI's processor feature bank 1 to the partition creation -/// arguments, whose banks hold the disabled features. -pub(super) fn with_feature_banks( +/// Returns the processor features of a time ABI partition with CPU profile +/// `id` on a host whose partition offers `host`: the profile's, within what +/// the host offers, under the time ABI's policy (see +/// [`time_abi_features`](super::profile_features::time_abi_features)). Fails +/// with `E_PROFILE_UNKNOWN` or `E_PROFILE_UNSUPPORTED`. +pub(super) fn partition_features(id: &str, host: HvFeatures) -> Result { + let profile = cpu_profile::pinned(id).ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ProfileUnknown, + format!("CPU profile {id} is not pinned"), + ) + })?; + let features = super::profile_features::time_abi_features(profile, host)?; + tracing::info!( + cpu_profile = id, + bank0 = features.banks[0], + bank1 = features.banks[1], + xsave = features.xsave, + host_bank0 = host.banks[0], + host_bank1 = host.banks[1], + host_xsave = host.xsave, + "MSHV processor features from the CPU profile" + ); + Ok(features) +} + +/// Applies `features` to the partition creation arguments, whose banks hold +/// the disabled features. +pub(super) fn with_features( mut args: mshv_bindings::mshv_create_partition_v2, + features: HvFeatures, ) -> mshv_bindings::mshv_create_partition_v2 { - let mut banks = args.pt_cpu_fbanks; - banks[1] = !u64::from(processor_features1(super::supported_processor_features1())); - args.pt_cpu_fbanks = banks; + args.pt_cpu_fbanks = [!features.banks[0], !features.banks[1]]; + args.pt_disabled_xsave = !features.xsave; args } @@ -444,7 +482,7 @@ fn select_anchor(candidates: &[AnchorCandidate]) -> Result String { +pub(super) fn error_chain(error: &dyn std::error::Error) -> String { let mut text = error.to_string(); let mut source = error.source(); while let Some(error) = source { @@ -714,6 +752,33 @@ impl TimeAbiBackend for MshvPartition { Ok(self.inner.time_abi_effective_cpuid()?.to_vec()) } + fn supported_cpu_surface(&self) -> Result, TimeAbiError> { + let started = std::time::Instant::now(); + let state = self.inner.time_abi_state()?; + let width = self + .inner + .vmfd + .get_partition_property(HvPartitionPropertyCode::PhysicalAddressWidth.0) + .map_err(|error| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "cannot read the PhysicalAddressWidth partition property: {}", + error_chain(&KernelError::from(error)) + ), + ) + })?; + let surface = + super::profile_features::supported_cpu_surface(state.host_features, width as u8); + tracing::info!( + leaves = surface.cpuid.len(), + physical_address_width = surface.physical_address_width, + elapsed_us = started.elapsed().as_micros() as u64, + "MSHV supported CPU surface" + ); + Ok(Some(surface)) + } + fn capture_anchor(&self) -> Result { let mut candidates = Vec::with_capacity(ANCHOR_ATTEMPTS); for _ in 0..ANCHOR_ATTEMPTS { @@ -1084,16 +1149,30 @@ mod tests { let others = !u64::from(time_bits); assert_eq!(u64::from(features) & others, u64::from(supported) & others); - let args = with_feature_banks( + let features = HvFeatures { + banks: [0x1234, 0x5678], + xsave: 0x3f, + }; + let args = with_features( super::super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false) .unwrap(), + features, ); let banks = args.pt_cpu_fbanks; - assert_eq!(banks[1], !u64::from(features)); - assert_eq!( - banks[0], - !u64::from(super::super::supported_processor_features()) - ); + let disabled_xsave = args.pt_disabled_xsave; + assert_eq!(banks, [!0x1234, !0x5678]); + assert_eq!(disabled_xsave, !0x3f); + } + + #[test] + fn an_unpinned_cpu_profile_is_unknown() { + let error = partition_features( + "intel.skylake-sp.v0", + super::super::profile_features::legacy_features(), + ) + .unwrap_err(); + assert_eq!(error.code, TimeAbiCode::ProfileUnknown); + assert!(error.message.contains("intel.skylake-sp.v0"), "{error}"); } #[test] @@ -1147,7 +1226,11 @@ mod tests { msrs: Arc::new(TimeAbiMsrs::new()), cpu_profile: String::new(), }; - let state = MshvTimeAbi::new(&config, &partition_cpuid(Vec::new(), &config.cpuid)); + let state = MshvTimeAbi::new( + &config, + &partition_cpuid(Vec::new(), &config.cpuid), + super::super::profile_features::legacy_features(), + ); state.check_vp_creation(VpIndex::new(4)).unwrap(); state.vp_set_sealed.store(true, Ordering::SeqCst); let error = state.check_vp_creation(VpIndex::new(4)).unwrap_err(); @@ -1262,6 +1345,38 @@ mod hw { const VP_CAPACITY: u32 = 4; + /// Returns the ID of the pinned CPU profile of this host's generation. + fn host_profile_id() -> String { + cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) + .unwrap() + .id() + .to_owned() + } + + /// Converts a backend's CPU surface to the profile crate's form, as core + /// does for the support check. + fn host_cpu_surface(surface: &SupportedCpuSurface) -> cpu_profile::HostCpuSurface { + let mut cpuid = surface + .cpuid + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::cpuid::normalize(&mut cpuid); + cpu_profile::HostCpuSurface { + cpuid, + physical_address_width: surface.physical_address_width, + msrs: surface + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + } + } + fn percentile(sorted: &[u64], percent: usize) -> u64 { sorted[(sorted.len() - 1) * percent / 100] } @@ -1296,7 +1411,7 @@ mod hw { time_abi: Some(TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(VP_CAPACITY, true)), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: String::new(), + cpu_profile: host_profile_id(), }), }) .unwrap() @@ -1344,6 +1459,16 @@ mod hw { check_plausible_tsc_hz(tsc_hz).unwrap(); assert_eq!(backend.lapic_hz().unwrap(), LAPIC_HZ_HYPERV); + // The supported surface is cheap and supports this host's profile. + let started = std::time::Instant::now(); + let surface = backend + .supported_cpu_surface() + .unwrap() + .expect("MSHV reports its CPU surface"); + let surface_us = started.elapsed().as_micros(); + let profile = cpu_profile::pinned(&host_profile_id()).unwrap(); + cpu_profile::verify_support(profile, &host_cpu_surface(&surface)).unwrap(); + let started = std::time::Instant::now(); let effective = CpuidLeafSet::new(backend.effective_cpuid().unwrap()); let effective_us = started.elapsed().as_micros(); @@ -1465,9 +1590,12 @@ mod hw { println!( "time ABI backend: created {created}/{VP_CAPACITY} tsc_hz={tsc_hz} \ build_us={build_us} preflight_us={preflight_us} effective_cpuid_us={effective_us} \ - ({} leaves) set_us={set_us} anchor_pairing_ns min={} p50={} p99={} max={} \ + ({} leaves) surface_us={surface_us} ({} leaves, {}-bit) set_us={set_us} \ + anchor_pairing_ns min={} p50={} p99={} max={} \ anchor_rate_deviation_ppm={deviation_ppm:.3} {osxsave} thawed={after:?}", effective.leaves().len(), + surface.cpuid.len(), + surface.physical_address_width, pairings[0], percentile(&pairings, 50), percentile(&pairings, 99), @@ -1482,11 +1610,17 @@ mod hw { #[test] #[ignore = "requires /dev/mshv"] fn refused_msr_intercept_fails_identity_routing() { - let args = with_feature_banks( + let mshv = mshv_ioctls::Mshv::new().unwrap(); + let features = partition_features( + &host_profile_id(), + super::super::profile_features::host_features(&mshv).unwrap(), + ) + .unwrap(); + let args = with_features( super::super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false) .unwrap(), + features, ); - let mshv = mshv_ioctls::Mshv::new().unwrap(); let vmfd = crate::create_vm_with_retry(&mshv, &args).unwrap(); vmfd.initialize().unwrap(); route_identity_msrs(&vmfd).unwrap(); From 34574729693c02a1cf235025a075884cd926e74a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 15:14:47 -0700 Subject: [PATCH 213/304] openvmm_core: log the phases of the time ABI restore clock Log the duration of each phase of the restore clock: the timer check, the synchronized TSC set, the LAPIC advance, and the VM time advance, with their total. This attributes the restore.time_abi_clock profile phase, which costs 0.7 to 1.1 ms per WHP restore (whp R8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../openvmm_core/src/worker/dispatch/time_abi.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index f1c84326d87..dfe41d1a62f 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -451,7 +451,9 @@ impl LoadedVm { let tsc_hz = input.contract.tsc_frequency_hz; let apic_hz = input.contract.apic_frequency_hz; + let started = std::time::Instant::now(); self.inner.partition_unit.check_one_shot_timers().await?; + let timers_checked = started.elapsed(); let partition = self.inner.partition.clone(); let mut downtime = None; @@ -470,6 +472,7 @@ impl LoadedVm { })?; let downtime = downtime.context("the backend set the TSC without selecting the downtime")?; + let tsc_set = started.elapsed(); if let Some(step_ns) = downtime.host_wall_clock_step_ns { tracing::warn!( step_ns, @@ -489,10 +492,22 @@ impl LoadedVm { .partition_unit .advance_lapic_timers(downtime.nanos, apic_hz) .await?; + let lapic_advanced = started.elapsed(); self.state_units .advance_time(Duration::from_nanos(downtime.nanos)) .await .context("failed to advance restored VM time")?; + let finished = started.elapsed(); + // Per-phase durations attribute the restore.time_abi_clock profile + // phase. + tracing::info!( + timers_us = timers_checked.as_micros() as u64, + tsc_set_us = (tsc_set - timers_checked).as_micros() as u64, + lapic_us = (lapic_advanced - tsc_set).as_micros() as u64, + vm_time_us = (finished - lapic_advanced).as_micros() as u64, + total_us = finished.as_micros() as u64, + "time ABI restore clock" + ); input.restore_record.send(RestoreTimeRecord { downtime_ns: downtime.nanos, From 943aa2af91981bcce82006380dec6943f238613b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 13:54:53 -0700 Subject: [PATCH 214/304] virt_whp: serve the complete effective CPUID through CpuidResultList2 TimeAbiConfig::cpuid is now the partition's complete effective CPUID (35c347de9). Exiting on every leaf of it makes every guest CPUID instruction exit, so WHP now presents it the way its CPUID results can: - Every leaf outside the hypervisor range is a CpuidResultList2 result with its mask, which WHP returns without an exit. This replaces the results programmed from the profile alone. - Only the leaves WHP cannot present that way exit: the leaves with per-VP APIC identity fields (1, 0xB, 0x1F, and 0x8000001E), which the exit handler sets for each VP, and the identity leaves. On Skylake-SP the exit list shrinks from 39 leaves to 8. - The backend's own CPUID results apply only to the leaves that exit, and only hypervisor-range leaves must exit to reach the guest. - VP 0's effective-CPUID report mirrors the exit handler, including the per-VP topology fixup (now a function the handler shares), so core's E_CPU_SURFACE check sees what the guest sees. - The interim CPU profile branch is gone: configure requires a pinned profile, from which the banks and XSAVE features always derive. The unit tests build TimeAbiConfig::cpuid as core does and run core's masked comparison over a simulated WHP for every pinned profile, VP count, and APIC mode; the hardware test runs it on WHP for every VP. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/cpu_contract.rs | 75 +- vmm_core/virt_whp/src/profile_features.rs | 128 +-- vmm_core/virt_whp/src/time_abi.rs | 1191 ++++++++++++++------- 3 files changed, 862 insertions(+), 532 deletions(-) diff --git a/vmm_core/virt_whp/src/cpu_contract.rs b/vmm_core/virt_whp/src/cpu_contract.rs index 433e8e5fa62..f55089e1ff9 100644 --- a/vmm_core/virt_whp/src/cpu_contract.rs +++ b/vmm_core/virt_whp/src/cpu_contract.rs @@ -100,37 +100,52 @@ impl WhpProcessor<'_> { /// Replaces the BSP identity in the partition's topology CPUID results /// with this VP's APIC-derived identity. pub(crate) fn fixup_topology_cpuid(&self, function: u32, index: u32, default: &mut [u32; 4]) { - match CpuidFunction(function) { - CpuidFunction::VersionAndFeatures => { - let ebx = x86defs::cpuid::VersionAndFeaturesEbx::from(default[1]); - default[1] = ebx - .with_initial_apic_id(self.inner.vp_info.apic_id as u8) - .into(); - } - CpuidFunction::ExtendedTopologyEnumeration - | CpuidFunction::V2ExtendedTopologyEnumeration => { - fixup_extended_topology(index, self.inner.vp_info.apic_id, default); - } - CpuidFunction::ProcessorTopologyDefinition => { - let topology = &self.vp.partition.cpuid_topology; - let apic_id = self.inner.vp_info.apic_id; - default[0] = x86defs::cpuid::ProcessorTopologyDefinitionEax::from(default[0]) - .with_extended_apic_id(apic_id) - .into(); - let threads_per_core = if topology.smt_enabled { 2 } else { 1 }; - default[1] = x86defs::cpuid::ProcessorTopologyDefinitionEbx::from(default[1]) - .with_compute_unit_id( - ((apic_id % topology.reserved_vps_per_socket) / threads_per_core) as u8, - ) - .with_threads_per_compute_unit((threads_per_core - 1) as u8) - .into(); - default[2] = x86defs::cpuid::ProcessorTopologyDefinitionEcx::from(default[2]) - .with_node_id((apic_id / topology.reserved_vps_per_socket) as u8) - .with_nodes_per_processor(0) - .into(); - } - _ => {} + fixup_vp_topology_cpuid( + &self.vp.partition.cpuid_topology, + self.inner.vp_info.apic_id, + function, + index, + default, + ); + } +} + +/// Replaces the BSP identity in the partition's topology CPUID results with +/// the identity of the VP whose APIC ID is `apic_id`, as the CPUID exit +/// handler does for each VP. +pub(crate) fn fixup_vp_topology_cpuid( + topology: &CpuidTopology, + apic_id: u32, + function: u32, + index: u32, + default: &mut [u32; 4], +) { + match CpuidFunction(function) { + CpuidFunction::VersionAndFeatures => { + let ebx = x86defs::cpuid::VersionAndFeaturesEbx::from(default[1]); + default[1] = ebx.with_initial_apic_id(apic_id as u8).into(); + } + CpuidFunction::ExtendedTopologyEnumeration + | CpuidFunction::V2ExtendedTopologyEnumeration => { + fixup_extended_topology(index, apic_id, default); + } + CpuidFunction::ProcessorTopologyDefinition => { + default[0] = x86defs::cpuid::ProcessorTopologyDefinitionEax::from(default[0]) + .with_extended_apic_id(apic_id) + .into(); + let threads_per_core = if topology.smt_enabled { 2 } else { 1 }; + default[1] = x86defs::cpuid::ProcessorTopologyDefinitionEbx::from(default[1]) + .with_compute_unit_id( + ((apic_id % topology.reserved_vps_per_socket) / threads_per_core) as u8, + ) + .with_threads_per_compute_unit((threads_per_core - 1) as u8) + .into(); + default[2] = x86defs::cpuid::ProcessorTopologyDefinitionEcx::from(default[2]) + .with_node_id((apic_id / topology.reserved_vps_per_socket) as u8) + .with_nodes_per_processor(0) + .into(); } + _ => {} } } diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index 2c7ca44b011..49ced144184 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -15,7 +15,8 @@ //! capability. Features that no bit controls (for example PKU), the //! descriptive leaves (caches, the brand string), and the time policy bits a //! host lacks (ARAT and invariant TSC on Azure) reach the guest through the -//! CPUID results ([`profile_cpuid_results`] and the time ABI CPUID). +//! time ABI's CPUID results: the partition's complete effective CPUID, which +//! `configure` programs with `CpuidResultList2`. //! //! The hardware test `features_control_the_mapped_cpuid_bits` checks the table //! against what WHP presents on a host. @@ -28,10 +29,6 @@ use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; use virt::time_abi::TimeAbiCode; use virt::time_abi::TimeAbiError; -use whp::abi::WHV_CPUID_OUTPUT; -use whp::abi::WHV_X64_CPUID_RESULT2; -use whp::abi::WHV_X64_CPUID_RESULT2_FLAGS; -use whp::abi::WHvX64CpuidResult2FlagSubleafSpecific; /// `IA32_ARCH_CAPABILITIES`. const MSR_ARCH_CAPABILITIES: u32 = 0x10a; @@ -375,48 +372,6 @@ pub(crate) fn profile_features( Ok(features) } -/// Returns the CPUID results that present `profile`'s pinned bits: one -/// `CpuidResultList2` entry per profile leaf, with the profile's mask, so the -/// pinned bits (features, descriptors, the brand string) come from the -/// profile and the rest (runtime state such as OSXSAVE, and the VM-owned -/// topology fields) from WHP and OpenVMM. The VM-owned topology leaves are -/// left to OpenVMM's CPUID exits. -/// -/// WHP returns these results for leaves without an exit, and as the default -/// result of a CPUID exit, on which OpenVMM applies the time ABI CPUID. -pub(crate) fn profile_cpuid_results(profile: &CpuProfile) -> Vec { - profile - .cpuid() - .iter() - .filter_map(|entry| { - let (leaf, subleaf) = entry.key(); - let mask = entry.masks(); - if cpu_profile::VM_OWNED_LEAVES.contains(&leaf) || mask == [0; 4] { - return None; - } - let values = entry.values(); - let output = |registers: [u32; 4]| WHV_CPUID_OUTPUT { - Eax: registers[EAX], - Ebx: registers[EBX], - Ecx: registers[ECX], - Edx: registers[EDX], - }; - Some(WHV_X64_CPUID_RESULT2 { - Function: leaf, - Index: subleaf.unwrap_or(0), - VpIndex: 0, - Flags: if subleaf.is_some() { - WHvX64CpuidResult2FlagSubleafSpecific - } else { - WHV_X64_CPUID_RESULT2_FLAGS(0) - }, - Output: output(std::array::from_fn(|i| values[i] & mask[i])), - Mask: output(mask), - }) - }) - .collect() -} - #[cfg(test)] mod tests { use super::*; @@ -562,81 +517,6 @@ mod tests { assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 27)), Pinned::Unpinned); assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 26)), Pinned::Set); } - - #[test] - fn profile_results_present_every_pinned_leaf() { - for (id, _) in HOSTS { - let profile = profile(id); - let results = profile_cpuid_results(profile); - let pinned_entries = profile - .cpuid() - .iter() - .filter(|entry| { - !cpu_profile::VM_OWNED_LEAVES.contains(&entry.key().0) - && entry.masks() != [0; 4] - }) - .count(); - assert_eq!(results.len(), pinned_entries, "{id}"); - for result in &results { - assert!(!cpu_profile::VM_OWNED_LEAVES.contains(&result.Function)); - let entry = profile - .cpuid() - .iter() - .find(|entry| { - let (leaf, subleaf) = entry.key(); - leaf == result.Function - && subleaf.unwrap_or(0) == result.Index - && subleaf.is_some() - == result.Flags.is_set(WHvX64CpuidResult2FlagSubleafSpecific) - }) - .unwrap(); - let output = [ - result.Output.Eax, - result.Output.Ebx, - result.Output.Ecx, - result.Output.Edx, - ]; - let mask = [ - result.Mask.Eax, - result.Mask.Ebx, - result.Mask.Ecx, - result.Mask.Edx, - ]; - assert_eq!(mask, entry.masks(), "{id} {:#x}", result.Function); - for register in 0..4 { - assert_eq!( - output[register], - entry.values()[register] & mask[register], - "{id} {:#x}", - result.Function - ); - } - } - // The brand string is the profile's generic one, not the host's. - let brand: String = [0x8000_0002, 0x8000_0003, 0x8000_0004] - .iter() - .flat_map(|&leaf| { - let result = results.iter().find(|r| r.Function == leaf).unwrap(); - [ - result.Output.Eax, - result.Output.Ebx, - result.Output.Ecx, - result.Output.Edx, - ] - }) - .flat_map(u32::to_le_bytes) - .take_while(|&byte| byte != 0) - .map(char::from) - .collect(); - assert!( - brand.starts_with("Intel(R) Xeon(R) Processor ("), - "{id}: {brand}" - ); - // Runtime state stays with the hypervisor: OSXSAVE is unpinned. - let leaf1 = results.iter().find(|r| r.Function == 1).unwrap(); - assert_eq!(leaf1.Mask.Ecx & (1 << 27), 0, "{id}"); - } - } } #[cfg(test)] @@ -646,9 +526,13 @@ mod whp_tests { use super::*; use cpu_profile::HostCpuSignature; + use whp::abi::WHV_CPUID_OUTPUT; use whp::abi::WHV_PROCESSOR_FEATURES; use whp::abi::WHV_PROCESSOR_FEATURES1; use whp::abi::WHV_PROCESSOR_XSAVE_FEATURES; + use whp::abi::WHV_X64_CPUID_RESULT2; + use whp::abi::WHV_X64_CPUID_RESULT2_FLAGS; + use whp::abi::WHvX64CpuidResult2FlagSubleafSpecific; /// The leaves whose feature bits the WHP features control. const FEATURE_LEAVES: [(u32, u32); 14] = [ diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 118d210429b..682ddffccf6 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -12,18 +12,22 @@ //! features), the in-hypervisor (offloaded) APIC, no isolation, no VTL2, //! and no nested virtualization. Without synthetic features WHP reports no //! hypervisor CPUID leaf and serves no synthetic MSR of its own. -//! - **Processor features.** Both feature banks are set explicitly to every -//! feature WHP offers, without the TSC-deadline timer, `IA32_TSC_ADJUST`, -//! or APERF/MPERF. WHP's default banks omit speculation controls that the -//! host offers (SPEC_CTRL, STIBP, and SSBD on Skylake-SP, PSFD on Ice Lake), -//! so they are never used. The CPU profiles will supply the banks. -//! - **CPUID.** The topology leaves and every leaf of the time ABI CPUID exit -//! to OpenVMM, which answers from the partition's CPUID results; the time -//! ABI CPUID applies after every other source. The explicit zero leaves of -//! the identity range do not exit: WHP answers the whole hypervisor range -//! with zeros natively, which preflight samples. Each exit-list entry costs -//! about 25 µs of partition setup, and each native CPUID read about 18 µs. -//! - **Invariant TSC.** The time ABI CPUID sets bit 8 of CPUID `0x80000007` +//! - **Processor features.** Both feature banks and the XSAVE features derive +//! from the partition's CPU profile ([`profile_features`]), without the +//! TSC-deadline timer, `IA32_TSC_ADJUST`, or APERF/MPERF. WHP's default +//! banks omit speculation controls that the host offers (SPEC_CTRL, STIBP, +//! and SSBD on Skylake-SP, PSFD on Ice Lake), so they are never used. +//! - **CPUID.** [`TimeAbiConfig::cpuid`] is the partition's complete +//! effective CPUID. Every leaf of it outside the hypervisor range is a +//! `CpuidResultList2` result, which WHP returns without an exit. Only the +//! leaves WHP cannot present that way exit to OpenVMM: the leaves with +//! per-VP APIC identity fields (1, `0xB`, `0x1F`, and `0x8000001E`), which +//! the exit handler sets for each VP, and the identity leaves. The explicit +//! zero leaves of the identity range do not exit: WHP answers the whole +//! hypervisor range with zeros natively, which preflight samples. Each +//! exit-list entry costs about 25 µs of partition setup, and each native +//! CPUID read about 18 µs. +//! - **Invariant TSC.** The effective CPUID sets bit 8 of CPUID `0x80000007` //! EDX on every host. Azure's WHP offers no `TscInvariantSupport` to its L1 //! partitions, so there the bit comes from the CPUID result alone; host //! qualification measures the invariance. @@ -48,8 +52,9 @@ use crate::WhpPartitionInner; use crate::WhpProcessor; use crate::WhpResultExt; +use crate::cpu_contract::CpuidTopology; +use crate::cpu_contract::fixup_vp_topology_cpuid; use crate::profile_features::WhpFeatures; -use crate::profile_features::profile_cpuid_results; use crate::profile_features::profile_features; use cpu_profile::CpuProfile; use inspect::Inspect; @@ -75,12 +80,16 @@ use virt::time_abi::host::sample_host_time; use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_MAX_LEAF; -use virt::time_abi::surface::is_interim_cpu_profile; +use virt::x86::topology::per_vp_cpuid_bits; +use whp::abi::WHV_CPUID_OUTPUT; use whp::abi::WHV_EXTENDED_VM_EXITS; use whp::abi::WHV_PROCESSOR_FEATURES; use whp::abi::WHV_PROCESSOR_FEATURES1; use whp::abi::WHV_PROCESSOR_XSAVE_FEATURES; +use whp::abi::WHV_X64_CPUID_RESULT2; +use whp::abi::WHV_X64_CPUID_RESULT2_FLAGS; use whp::abi::WHV_X64_MSR_EXIT_BITMAP; +use whp::abi::WHvX64CpuidResult2FlagSubleafSpecific; use x86defs::cpuid::CpuidFunction; /// `IA32_TSC_ADJUST`, which the time ABI hides. @@ -95,23 +104,6 @@ const MSR_IA32_TSC_DEADLINE: u32 = x86defs::X86X_MSR_TSC_DEADLINE; /// partition, as on MSHV. const LEGACY_L2_CACHE_MSRS: [u32; 9] = [0x88, 0x89, 0x8a, 0x116, 0x118, 0x119, 0x11a, 0x11b, 0x11e]; -/// Leaves whose results OpenVMM computes from the topology on every vendor -/// (see [`virt::x86::topology::topology_cpuid`]) and adjusts per VP. -const TOPOLOGY_CPUID_EXITS: [u32; 3] = [ - CpuidFunction::VersionAndFeatures.0, - CpuidFunction::ExtendedTopologyEnumeration.0, - CpuidFunction::V2ExtendedTopologyEnumeration.0, -]; - -/// Topology leaves OpenVMM computes only on Intel-compatible hosts. -const INTEL_TOPOLOGY_CPUID_EXITS: [u32; 1] = [CpuidFunction::CacheParameters.0]; - -/// Topology leaves OpenVMM computes only on AMD-compatible hosts. -const AMD_TOPOLOGY_CPUID_EXITS: [u32; 2] = [ - CpuidFunction::ExtendedAddressSpaceSizes.0, - CpuidFunction::ProcessorTopologyDefinition.0, -]; - /// The feature-bank bits the time ABI clears: the TSC-deadline timer, /// `IA32_TSC_ADJUST`, and APERF/MPERF. const HIDDEN_FEATURES1: WHV_PROCESSOR_FEATURES1 = WHV_PROCESSOR_FEATURES1( @@ -166,7 +158,8 @@ pub(crate) struct WhpTimeAbi { with = "|x| x.tsc_invariant_control()" )] msrs: Arc, - /// The time ABI CPUID. + /// The partition's complete effective CPUID, with the per-VP APIC + /// identity bits unmasked. #[inspect(skip)] cpuid: Arc, /// The CPUID exit list. @@ -178,12 +171,12 @@ pub(crate) struct WhpTimeAbi { /// Processor feature bank 1. #[inspect(hex)] features_bank1: u64, - /// The XSAVE features, when a CPU profile set them. + /// The XSAVE features. #[inspect(hex)] - features_xsave: Option, - /// The CPU profile the features derive from, if any. - cpu_profile: Option, - /// How many `CpuidResultList2` entries present the CPU profile's leaves. + features_xsave: u64, + /// The CPU profile the features derive from. + cpu_profile: String, + /// How many `CpuidResultList2` entries present the effective CPUID. cpuid_results: usize, /// The effective CPUID record, computed once: it depends only on the /// configuration, because it excludes guest state. @@ -193,29 +186,19 @@ pub(crate) struct WhpTimeAbi { impl WhpTimeAbi { /// Programs a new WHP partition for the time ABI: the CPUID exits, the - /// processor features, and the unhandled-MSR exits. The partition must - /// have passed [`validate_partition`]. + /// processor features, the CPUID results, and the unhandled-MSR exits. + /// The partition must have passed [`validate_partition`]. /// - /// With a pinned CPU profile (`config.cpu_profile`), the processor feature - /// banks and the XSAVE features derive from it ([`profile_features`]); - /// with an interim profile, the banks are WHP's capability without the - /// hidden time features. + /// The processor feature banks and the XSAVE features derive from the + /// pinned CPU profile `config.cpu_profile` ([`profile_features`]), and + /// `config.cpuid`, the complete effective CPUID, becomes the CPUID results + /// ([`cpuid_results`]) and the exits ([`cpuid_exits`]). pub(crate) fn configure( config: &TimeAbiConfig, whp_config: &mut whp::PartitionConfig, extended_exits: &mut WHV_EXTENDED_VM_EXITS, ) -> Result { let profile = pinned_profile(&config.cpu_profile)?; - let amd = match whp::capabilities::processor_vendor() { - Ok(whp::abi::WHvProcessorVendorIntel) => false, - Ok(whp::abi::WHvProcessorVendorAmd | whp::abi::WHvProcessorVendorHygon) => true, - Ok(_) => return Err(routing_error("the processor vendor is not x86")), - Err(err) => { - return Err(routing_error(format!( - "cannot query the processor vendor: {err}" - ))); - } - }; let available_exits = whp::capabilities::extended_vm_exits() .map_err(|err| routing_error(format!("cannot query the extended VM exits: {err}")))?; @@ -228,7 +211,7 @@ impl WhpTimeAbi { } *extended_exits |= exits; - let cpuid_exits = cpuid_exits(amd, &config.cpuid); + let cpuid_exits = cpuid_exits(&config.cpuid); whp_config .set_property(whp::PartitionProperty::CpuidExitList(&cpuid_exits)) .map_err(|err| routing_error(format!("cannot set the CPUID exit list: {err}")))?; @@ -239,29 +222,24 @@ impl WhpTimeAbi { format!("cannot query the processor features: {err}"), ) })?; - let (features, xsave) = match profile { - Some(profile) => { - let available_xsave = - whp::capabilities::processor_xsave_features().map_err(|err| { - TimeAbiError::new( - TimeAbiCode::ProfileUnsupported, - format!("cannot query the XSAVE features: {err}"), - ) - })?; - let derived = profile_features( - profile, - WhpFeatures { - banks: [available.bank0.0, available.bank1.0], - xsave: available_xsave.0, - }, - )?; - let mut features = available; - features.bank0 = WHV_PROCESSOR_FEATURES(derived.banks[0]); - features.bank1 = WHV_PROCESSOR_FEATURES1(derived.banks[1]); - (processor_features(features), Some(derived.xsave)) - } - None => (processor_features(available), None), - }; + let available_xsave = whp::capabilities::processor_xsave_features().map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot query the XSAVE features: {err}"), + ) + })?; + let derived = profile_features( + profile, + WhpFeatures { + banks: [available.bank0.0, available.bank1.0], + xsave: available_xsave.0, + }, + )?; + let mut features = available; + features.bank0 = WHV_PROCESSOR_FEATURES(derived.banks[0]); + features.bank1 = WHV_PROCESSOR_FEATURES1(derived.banks[1]); + let features = processor_features(features); + let xsave = derived.xsave; whp_config .set_property(whp::PartitionProperty::ProcessorFeaturesBanks(features)) .map_err(|err| { @@ -273,32 +251,28 @@ impl WhpTimeAbi { ), ) })?; - if let Some(xsave) = xsave { - whp_config - .set_property(whp::PartitionProperty::ProcessorXsaveFeatures( - WHV_PROCESSOR_XSAVE_FEATURES(xsave), - )) - .map_err(|err| { - TimeAbiError::new( - TimeAbiCode::ProfileUnsupported, - format!("cannot set XSAVE features {xsave:#x}: {err}"), - ) - })?; - } - let cpuid_results = profile.map(profile_cpuid_results).unwrap_or_default(); - if !cpuid_results.is_empty() { - whp_config - .set_property(whp::PartitionProperty::CpuidResultList2(&cpuid_results)) - .map_err(|err| { - TimeAbiError::new( - TimeAbiCode::ProfileUnsupported, - format!( - "cannot program the {} CPUID results of the CPU profile: {err}", - cpuid_results.len() - ), - ) - })?; - } + whp_config + .set_property(whp::PartitionProperty::ProcessorXsaveFeatures( + WHV_PROCESSOR_XSAVE_FEATURES(xsave), + )) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot set XSAVE features {xsave:#x}: {err}"), + ) + })?; + let cpuid_results = cpuid_results(&config.cpuid); + whp_config + .set_property(whp::PartitionProperty::CpuidResultList2(&cpuid_results)) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "cannot program the {} CPUID results of the effective CPUID: {err}", + cpuid_results.len() + ), + ) + })?; let msr_exits = whp::capabilities::x64_msr_exit_bitmap() .map_err(|err| routing_error(format!("cannot query the MSR exits: {err}")))?; @@ -317,12 +291,12 @@ impl WhpTimeAbi { tracing::info!( cpuid_exits = cpuid_exits.len(), cpuid_results = cpuid_results.len(), - cpu_profile = profile.map(|profile| profile.id()), + cpu_profile = profile.id(), available_bank0 = format_args!("{:#x}", available.bank0.0), available_bank1 = format_args!("{:#x}", available.bank1.0), bank0 = format_args!("{:#x}", features.bank0.0), bank1 = format_args!("{:#x}", features.bank1.0), - xsave = xsave.map(|xsave| format!("{xsave:#x}")), + xsave = format_args!("{xsave:#x}"), tsc_invariant_feature = features .bank1 .is_set(WHV_PROCESSOR_FEATURES1::TscInvariantSupport), @@ -336,7 +310,7 @@ impl WhpTimeAbi { features_bank0: features.bank0.0, features_bank1: features.bank1.0, features_xsave: xsave, - cpu_profile: profile.map(|profile| profile.id().to_owned()), + cpu_profile: profile.id().to_owned(), cpuid_results: cpuid_results.len(), effective: OnceLock::new(), }) @@ -349,7 +323,7 @@ impl WhpTimeAbi { &self, own: Vec, ) -> Result { - let cpuid = partition_cpuid(own, &self.cpuid)?; + let cpuid = partition_cpuid(own, &self.cpuid, &self.cpuid_exits)?; check_cpuid_delivery(&cpuid, &self.cpuid_exits)?; Ok(cpuid) } @@ -359,15 +333,11 @@ fn routing_error(message: impl Into) -> TimeAbiError { TimeAbiError::new(TimeAbiCode::IdentityRouting, message) } -/// Returns the pinned CPU profile that `id` names, or `None` for an interim -/// profile, which keeps WHP's own CPU features. Core selects and verifies the -/// profile before the partition exists, so an unknown ID is an internal +/// Returns the pinned CPU profile that `id` names. Core selects and verifies +/// the profile before the partition exists, so an unknown ID is an internal /// error (`E_PROFILE_UNKNOWN`). -fn pinned_profile(id: &str) -> Result, TimeAbiError> { - if is_interim_cpu_profile(id) { - return Ok(None); - } - cpu_profile::pinned(id).map(Some).ok_or_else(|| { +fn pinned_profile(id: &str) -> Result<&'static CpuProfile, TimeAbiError> { + cpu_profile::pinned(id).ok_or_else(|| { TimeAbiError::new( TimeAbiCode::ProfileUnknown, format!("CPU profile {id:?} is not pinned in this OpenVMM"), @@ -409,28 +379,63 @@ pub(crate) fn validate_partition( Ok(()) } -/// Returns the CPUID exit list of a time ABI partition: the topology leaves -/// of the host's vendor and every leaf of the time ABI CPUID except the -/// explicit zero leaves, sorted and unique. -pub(crate) fn cpuid_exits(amd: bool, config: &CpuidLeafSet) -> Vec { - let mut exits = TOPOLOGY_CPUID_EXITS.to_vec(); - if amd { - exits.extend(AMD_TOPOLOGY_CPUID_EXITS); - } else { - exits.extend(INTEL_TOPOLOGY_CPUID_EXITS); - } - exits.extend( - config - .leaves() - .iter() - .filter(|leaf| !is_zero_fill(leaf)) - .map(|leaf| leaf.function), - ); +/// Returns whether WHP cannot present `function` through `CpuidResultList2` +/// alone, so that it must exit to OpenVMM: a leaf with per-VP APIC identity +/// fields, which the exit handler sets for each VP, or a hypervisor-range +/// leaf, which WHP does not serve without synthetic features. +fn must_exit(function: u32) -> bool { + per_vp_cpuid_bits(function) != [0; 4] || HYPERVISOR_CPUID_RANGE.contains(&function) +} + +/// Returns the CPUID exit list of a time ABI partition whose effective CPUID +/// is `config`: every leaf of it that [`must_exit`], except the explicit zero +/// leaves, sorted and unique. The other leaves are `CpuidResultList2` results +/// ([`cpuid_results`]). +pub(crate) fn cpuid_exits(config: &CpuidLeafSet) -> Vec { + let mut exits: Vec = config + .leaves() + .iter() + .filter(|leaf| must_exit(leaf.function) && !is_zero_fill(leaf)) + .map(|leaf| leaf.function) + .collect(); exits.sort_unstable(); exits.dedup(); exits } +/// Returns the `CpuidResultList2` results of a time ABI partition whose +/// effective CPUID is `config`: one per leaf and subleaf outside the +/// hypervisor range, with its mask. The per-VP APIC identity bits are +/// unmasked in `config`, so they keep WHP's value for each VP. +/// +/// WHP returns these results for leaves without an exit, and as the default +/// result of a CPUID exit, on which OpenVMM applies `config` again. +pub(crate) fn cpuid_results(config: &CpuidLeafSet) -> Vec { + let output = |registers: [u32; 4]| WHV_CPUID_OUTPUT { + Eax: registers[0], + Ebx: registers[1], + Ecx: registers[2], + Edx: registers[3], + }; + config + .leaves() + .iter() + .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function) && leaf.mask != [0; 4]) + .map(|leaf| WHV_X64_CPUID_RESULT2 { + Function: leaf.function, + Index: leaf.index.unwrap_or(0), + VpIndex: 0, + Flags: if leaf.index.is_some() { + WHvX64CpuidResult2FlagSubleafSpecific + } else { + WHV_X64_CPUID_RESULT2_FLAGS(0) + }, + Output: output(std::array::from_fn(|i| leaf.result[i] & leaf.mask[i])), + Mask: output(leaf.mask), + }) + .collect() +} + /// Returns the processor features of a time ABI partition: `available` /// without the TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. The /// invariant TSC stays where WHP offers it. @@ -450,9 +455,11 @@ fn is_zero_fill(leaf: &CpuidLeaf) -> bool { && leaf.mask == [!0; 4] } -/// Returns the CPUID results of a time ABI partition: `own`, the backend's -/// other results, without their hypervisor-range leaves, then `config`, so -/// that the time ABI CPUID overrides every other source. +/// Returns the CPUID results that the exit handler applies on a time ABI +/// partition: `own`, the backend's other results, for the leaves in `exits` +/// outside the hypervisor range, then `config`, so that the effective CPUID +/// overrides every other source. A leaf that does not exit reaches the guest +/// from `config` alone, through `CpuidResultList2`. /// /// Fails with `E_IDENTITY_ROUTING` if another result would shadow part of /// `config`: [`CpuidLeafSet::result`] applies only the first matching leaf, @@ -460,10 +467,14 @@ fn is_zero_fill(leaf: &CpuidLeaf) -> bool { pub(crate) fn partition_cpuid( own: Vec, config: &CpuidLeafSet, + exits: &[u32], ) -> Result { let mut leaves: Vec = own .into_iter() - .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) + .filter(|leaf| { + !HYPERVISOR_CPUID_RANGE.contains(&leaf.function) + && exits.binary_search(&leaf.function).is_ok() + }) .collect(); leaves.extend(config.leaves().iter().copied()); let cpuid = CpuidLeafSet::new(leaves); @@ -487,18 +498,20 @@ pub(crate) fn partition_cpuid( Ok(cpuid) } -/// Checks that every leaf of `cpuid` reaches the guest: each leaf exits, -/// except the explicit zero leaves, which WHP answers natively. Fails with -/// `E_IDENTITY_ROUTING`, naming the first leaf that would not. +/// Checks that every leaf of `cpuid` reaches the guest: a leaf in the +/// hypervisor range exits, except the explicit zero leaves, which WHP +/// answers natively; any other leaf exits or is a `CpuidResultList2` result +/// ([`cpuid_results`]). Fails with `E_IDENTITY_ROUTING`, naming the first +/// leaf that would not. pub(crate) fn check_cpuid_delivery( cpuid: &CpuidLeafSet, exits: &[u32], ) -> Result<(), TimeAbiError> { - if let Some(leaf) = cpuid - .leaves() - .iter() - .find(|leaf| !is_zero_fill(leaf) && exits.binary_search(&leaf.function).is_err()) - { + if let Some(leaf) = cpuid.leaves().iter().find(|leaf| { + HYPERVISOR_CPUID_RANGE.contains(&leaf.function) + && !is_zero_fill(leaf) + && exits.binary_search(&leaf.function).is_err() + }) { return Err(routing_error(format!( "CPUID leaf {:#x} has a programmed result but does not exit", leaf.function @@ -521,10 +534,11 @@ fn normalize_cpuid(function: u32, index: u32, mut result: [u32; 4]) -> [u32; 4] } /// Returns the effective CPUID record: the results VP 0 sees, from `vp0`, -/// for the maximum basic and extended leaves and for every leaf the -/// partition programs, `cpuid` (the topology, profile, and identity leaves), -/// with guest-state bits cleared. The explicit zero leaves are reported as -/// programmed; preflight samples them natively. +/// for the maximum basic and extended leaves and for every leaf of the +/// partition's CPUID results `cpuid` (the effective CPUID, and the backend's +/// own results for the leaves that exit), with guest-state bits cleared. The +/// explicit zero leaves are reported as programmed; preflight samples them +/// natively. pub(crate) fn effective_cpuid( cpuid: &CpuidLeafSet, mut vp0: impl FnMut(u32, u32) -> Result<[u32; 4], E>, @@ -757,27 +771,47 @@ impl<'a> NativeCpuid<'a> { } } -/// Returns the result VP 0 sees for a leaf: the partition's CPUID results -/// over WHP's own. Every leaf with a programmed result exits (see -/// [`check_cpuid_delivery`]), the exit handler applies the partition's -/// results over WHP's own, and VP 0 carries the BSP identity that the -/// topology results already hold. WHP's result is read only if a bit of it -/// shows through. -fn vp0_cpuid( - cpuid: &CpuidLeafSet, - native: &mut NativeCpuid<'_>, - function: u32, - index: u32, -) -> Result<[u32; 4], whp::WHvError> { - let programmed = cpuid - .leaves() - .iter() - .find(|leaf| leaf.matches(function, index)); - let base = match programmed { - Some(leaf) if leaf.mask == [!0; 4] => [0; 4], - _ => native.get(function, index)?, - }; - Ok(cpuid.result(function, index, &base)) +/// The CPUID that one VP of a time ABI partition observes. +pub(crate) struct VpCpuid<'a> { + /// The partition's CPUID results, which the exit handler applies. + pub cpuid: &'a CpuidLeafSet, + /// The CPUID exit list. + pub exits: &'a [u32], + /// The topology behind the per-VP fields. + pub topology: &'a CpuidTopology, + /// The VP's APIC ID. + pub apic_id: u32, +} + +impl VpCpuid<'_> { + /// Returns the result the VP sees for `function` and `index`, given + /// `native`, which reads WHP's result (including its `CpuidResultList2` + /// result). A leaf that exits gets the partition's CPUID results over + /// WHP's result, with the VP's APIC identity, as the exit handler + /// computes them; any other leaf gets WHP's result. WHP's result is read + /// only if a bit of it shows through. + pub fn result( + &self, + function: u32, + index: u32, + native: impl FnOnce() -> Result<[u32; 4], E>, + ) -> Result<[u32; 4], E> { + if self.exits.binary_search(&function).is_err() { + return native(); + } + let programmed = self + .cpuid + .leaves() + .iter() + .find(|leaf| leaf.matches(function, index)); + let base = match programmed { + Some(leaf) if leaf.mask == [!0; 4] => [0; 4], + _ => native()?, + }; + let mut result = self.cpuid.result(function, index, &base); + fixup_vp_topology_cpuid(self.topology, self.apic_id, function, index, &mut result); + Ok(result) + } } impl WhpPartitionInner { @@ -799,13 +833,20 @@ impl WhpPartitionInner { if let Some(record) = state.effective.get() { return Ok(record.clone()); } + let vp0 = VpCpuid { + cpuid: &self.cpuid, + exits: &state.cpuid_exits, + topology: &self.cpuid_topology, + apic_id: self.vps.first().map_or(0, |vp| vp.vp_info.apic_id), + }; let record = effective_cpuid(&self.cpuid, |function, index| { - vp0_cpuid(&self.cpuid, native, function, index).map_err(|err| { - TimeAbiError::new( - TimeAbiCode::CpuSurface, - format!("cannot read CPUID {function:#x}/{index:#x} of VP 0: {err}"), - ) - }) + vp0.result(function, index, || native.get(function, index)) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!("cannot read CPUID {function:#x}/{index:#x} of VP 0: {err}"), + ) + }) })?; Ok(state.effective.get_or_init(|| record).clone()) } @@ -1094,28 +1135,154 @@ impl WhpProcessor<'_> { } } +/// Test support: the complete effective CPUID that core passes in +/// [`TimeAbiConfig::cpuid`], built as core builds it, and core's check of the +/// CPUID that VP 0 presents. +#[cfg(test)] +pub(crate) mod test_cpuid { + use cpu_profile::CpuProfile; + use cpu_profile::EffectiveCpuid; + use virt::CpuidLeaf; + use virt::CpuidLeafSet; + use vm_topology::processor::ProcessorTopology; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::ApicMode; + use vm_topology::processor::x86::X2ApicState; + + /// The pinned CPU profiles. + pub const PROFILES: [&str; 3] = [ + "intel.skylake-sp.v1", + "intel.icelake-sp.v1", + "intel.emeraldrapids.v1", + ]; + + /// Returns the topology of a VM with `vp_count` VPs in one socket. + pub fn topology(vp_count: u32, x2apic: X2ApicState) -> ProcessorTopology { + TopologyBuilder::new_x86() + .vps_per_socket(vp_count) + .x2apic(x2apic) + .build(vp_count) + .unwrap() + } + + /// Returns the effective CPUID of a partition with `profile` and + /// `topology`, as core's `time_abi::effective_cpuid` builds it. + pub fn effective_cpuid(profile: &CpuProfile, topology: &ProcessorTopology) -> EffectiveCpuid { + let result = |leaf: &CpuidLeaf| cpu_profile::CpuidResult { + function: leaf.function, + index: leaf.index, + result: leaf.result, + mask: leaf.mask, + }; + let mut topology_leaves = Vec::new(); + virt::x86::topology::topology_cpuid( + topology, + &|leaf, subleaf| profile.lookup(leaf, subleaf), + &mut topology_leaves, + ) + .unwrap(); + let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); + vm.push(cpu_profile::x2apic_cpuid(!matches!( + topology.apic_mode(), + ApicMode::XApic + ))); + let identity: Vec<_> = virt::time_abi::identity::identity_cpuid_leaves(topology.vp_count()) + .iter() + .map(result) + .chain(virt::time_abi::identity::identity_zero_cpuid_leaves().map(|leaf| result(&leaf))) + .collect(); + profile.effective_cpuid(&vm, &identity).unwrap() + } + + /// Returns [`TimeAbiConfig::cpuid`](virt::time_abi::TimeAbiConfig::cpuid) + /// for `effective`, as core's `backend_cpuid` builds it: the per-VP APIC + /// identity bits unmasked. + pub fn config_cpuid(effective: &EffectiveCpuid) -> CpuidLeafSet { + CpuidLeafSet::new( + effective + .results() + .map(|result| { + let per_vp = virt::x86::topology::per_vp_cpuid_bits(result.function); + CpuidLeaf { + function: result.function, + index: result.index, + result: result.result, + mask: [0, 1, 2, 3] + .map(|register| result.mask[register] & !per_vp[register]), + } + }) + .collect(), + ) + } + + /// Lists every result of `effective` that `presented` does not match + /// under the result's masks, as core's `check_presented_cpuid` does. + pub fn differences(presented: &CpuidLeafSet, effective: &EffectiveCpuid) -> Vec { + effective + .results() + .filter_map(|expected| { + let actual = + presented.result(expected.function, expected.index.unwrap_or(0), &[0; 4]); + let registers: Vec = (0..4) + .filter(|®ister| { + (actual[register] ^ expected.result[register]) & expected.mask[register] + != 0 + }) + .map(|register| { + format!( + "register {register} is {:#010x}, not {:#010x} under mask {:#010x}", + actual[register], expected.result[register], expected.mask[register] + ) + }) + .collect(); + (!registers.is_empty()).then(|| { + format!( + "CPUID {:#x}/{:?}: {}", + expected.function, + expected.index, + registers.join(", ") + ) + }) + }) + .collect() + } + + /// Returns the backend's own CPUID results, as the partition composes + /// them before [`super::WhpTimeAbi::partition_cpuid`]: the x2APIC bit and + /// OpenVMM's topology leaves, computed over `native`. + pub fn own_cpuid( + topology: &ProcessorTopology, + native: &dyn Fn(u32, u32) -> [u32; 4], + ) -> Vec { + let mask = [0, 0, 1 << 21, 0]; + let value = match topology.apic_mode() { + ApicMode::XApic => [0; 4], + ApicMode::X2ApicSupported | ApicMode::X2ApicEnabled => mask, + }; + let mut own = vec![CpuidLeaf::new(1, value).masked(mask)]; + virt::x86::topology::topology_cpuid(topology, native, &mut own).unwrap(); + own + } +} + #[cfg(test)] mod tests { + use super::test_cpuid; use super::*; use virt::time_abi::DeclaredRates; - use virt::time_abi::identity::check_identity; - use virt::time_abi::identity::check_time_bits; - use virt::time_abi::identity::time_abi_cpuid; use virt::time_abi::msr::MSR_APIC_FREQUENCY; use virt::time_abi::msr::MSR_TSC_FREQUENCY; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use virt::time_abi::msr::MSR_VP_INDEX; - use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; const VP_COUNT: u32 = 8; #[test] fn cpu_profile_ids_resolve_to_pinned_profiles() { - let interim = virt::time_abi::surface::interim_cpu_profile_id("whp"); - assert!(pinned_profile(&interim).unwrap().is_none()); - let skylake = pinned_profile("intel.skylake-sp.v1").unwrap().unwrap(); + let skylake = pinned_profile("intel.skylake-sp.v1").unwrap(); assert_eq!(skylake.id(), "intel.skylake-sp.v1"); - for id in ["intel.skylake-sp.whp.v1", ""] { + for id in ["interim.host.whp.v1", "intel.skylake-sp.whp.v1", ""] { assert_eq!( pinned_profile(id).unwrap_err().code, TimeAbiCode::ProfileUnknown, @@ -1146,15 +1313,108 @@ mod tests { } } - /// Leaves the backend contributes before the time ABI CPUID: the - /// topology in leaf 1 and a stale hypervisor leaf. - fn own_leaves() -> Vec { - vec![ - CpuidLeaf::new(1, [0, 0x0008_0000, 0, 0]).masked([0, 0x00ff_0000, 0, 0]), - CpuidLeaf::new(4, [0x1c00_4121, 0, 0, 0]).indexed(0), - CpuidLeaf::new(0x4000_0000, [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d]), - CpuidLeaf::new(0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), - ] + /// A simulated WHP partition: noise in every bit of every leaf, with the + /// `CpuidResultList2` results applied, as the CPUID output and the + /// default result of a CPUID exit report it. Only programmed bits can + /// match the effective CPUID. + struct FakeWhp { + results: Vec, + } + + impl FakeWhp { + fn new(config: &CpuidLeafSet) -> Self { + Self { + results: cpuid_results(config), + } + } + + fn native(&self, function: u32, index: u32) -> [u32; 4] { + let seed = function.wrapping_mul(0x9e37_79b9) ^ index.wrapping_mul(0x85eb_ca6b); + let mut result = [ + seed, + seed.rotate_left(8) ^ 0xa5a5_a5a5, + seed.rotate_left(16) ^ 0x5a5a_5a5a, + !seed, + ]; + for programmed in self.results.iter().filter(|result| { + result.Function == function + && (!result.Flags.is_set(WHvX64CpuidResult2FlagSubleafSpecific) + || result.Index == index) + }) { + let output = programmed.Output; + let mask = programmed.Mask; + let output = [output.Eax, output.Ebx, output.Ecx, output.Edx]; + let mask = [mask.Eax, mask.Ebx, mask.Ecx, mask.Edx]; + for register in 0..4 { + result[register] = + (result[register] & !mask[register]) | (output[register] & mask[register]); + } + } + result + } + } + + /// A time ABI partition's CPUID, as `configure` and the partition build + /// compose it for `profile` and `vp_count` VPs. + struct Partition { + topology: vm_topology::processor::ProcessorTopology, + effective: cpu_profile::EffectiveCpuid, + config: CpuidLeafSet, + exits: Vec, + whp: FakeWhp, + cpuid: CpuidLeafSet, + cpuid_topology: CpuidTopology, + } + + impl Partition { + fn new(profile: &str, vp_count: u32, x2apic: X2ApicState) -> Self { + let topology = test_cpuid::topology(vp_count, x2apic); + let effective = + test_cpuid::effective_cpuid(cpu_profile::pinned(profile).unwrap(), &topology); + let config = test_cpuid::config_cpuid(&effective); + let exits = cpuid_exits(&config); + let whp = FakeWhp::new(&config); + let own = + test_cpuid::own_cpuid(&topology, &|function, index| whp.native(function, index)); + let cpuid = partition_cpuid(own, &config, &exits).unwrap(); + check_cpuid_delivery(&cpuid, &exits).unwrap(); + let cpuid_topology = CpuidTopology::new(&topology); + Self { + topology, + effective, + config, + exits, + whp, + cpuid, + cpuid_topology, + } + } + + fn vp(&self, apic_id: u32) -> VpCpuid<'_> { + VpCpuid { + cpuid: &self.cpuid, + exits: &self.exits, + topology: &self.cpuid_topology, + apic_id, + } + } + + /// The CPUID that the VP with `apic_id` observes. + fn observe(&self, apic_id: u32, function: u32, index: u32) -> [u32; 4] { + self.vp(apic_id) + .result(function, index, || { + Ok::<_, std::convert::Infallible>(self.whp.native(function, index)) + }) + .unwrap() + } + + /// VP 0's effective CPUID record, as the backend reports it. + fn record(&self) -> Vec { + effective_cpuid(&self.cpuid, |function, index| { + Ok::<_, std::convert::Infallible>(self.observe(0, function, index)) + }) + .unwrap() + } } fn key(leaf: &CpuidLeaf) -> (u32, Option, [u32; 4], [u32; 4]) { @@ -1211,22 +1471,101 @@ mod tests { } #[test] - fn exit_list_covers_the_topology_and_the_time_abi_cpuid() { - let config = time_abi_cpuid(VP_COUNT, true); - let mut expected = vec![1, 4, 6, 7, 0xa, 0xb, 0x15, 0x16, 0x1f]; - expected.extend(0x4000_0000..=0x4000_0005); - expected.extend([0x8000_0001, 0x8000_0007]); - assert_eq!(cpuid_exits(false, &config), expected); - - let amd = cpuid_exits(true, &config); - assert!(!amd.contains(&4)); - assert!(amd.contains(&0x8000_0008) && amd.contains(&0x8000_001e)); - assert!(amd.windows(2).all(|pair| pair[0] < pair[1])); - // WHP answers the explicit zero leaves natively. - assert!( - !amd.iter() - .any(|leaf| (0x4000_0006..=0x4000_00ff).contains(leaf)) - ); + fn exit_list_covers_the_per_vp_and_identity_leaves() { + for profile in test_cpuid::PROFILES { + let partition = Partition::new(profile, VP_COUNT, X2ApicState::Supported); + let mut expected = vec![1, 0xb]; + if partition + .config + .leaves() + .iter() + .any(|leaf| leaf.function == 0x1f) + { + expected.push(0x1f); + } + expected.extend(0x4000_0000..=0x4000_0005); + // WHP answers the explicit zero leaves natively, and every other + // leaf through CpuidResultList2. + assert_eq!(partition.exits, expected, "{profile}"); + } + } + + #[test] + fn cpuid_results_present_every_leaf_outside_the_hypervisor_range() { + for profile in test_cpuid::PROFILES { + let partition = Partition::new(profile, VP_COUNT, X2ApicState::Supported); + let results = &partition.whp.results; + let leaves: Vec<_> = partition + .config + .leaves() + .iter() + .filter(|leaf| { + !HYPERVISOR_CPUID_RANGE.contains(&leaf.function) && leaf.mask != [0; 4] + }) + .collect(); + assert_eq!(results.len(), leaves.len(), "{profile}"); + for (result, leaf) in results.iter().zip(leaves) { + let output = [ + result.Output.Eax, + result.Output.Ebx, + result.Output.Ecx, + result.Output.Edx, + ]; + let mask = [ + result.Mask.Eax, + result.Mask.Ebx, + result.Mask.Ecx, + result.Mask.Edx, + ]; + assert_eq!( + ( + result.Function, + result.Index, + result.Flags.is_set(WHvX64CpuidResult2FlagSubleafSpecific) + ), + (leaf.function, leaf.index.unwrap_or(0), leaf.index.is_some()), + "{profile}" + ); + assert_eq!(mask, leaf.mask, "{profile} {:#x}", leaf.function); + assert_eq!( + output, + [0, 1, 2, 3].map(|register| leaf.result[register] & leaf.mask[register]), + "{profile} {:#x}", + leaf.function + ); + } + // The per-VP APIC identity and the runtime state (OSXSAVE) keep + // WHP's value for each VP. + let leaf = |function, index| { + results + .iter() + .find(|result| result.Function == function && result.Index == index) + .unwrap() + }; + assert_eq!(leaf(1, 0).Mask.Ebx & 0xff00_0000, 0, "{profile}"); + assert_eq!(leaf(1, 0).Mask.Ecx & ECX1_OSXSAVE, 0, "{profile}"); + assert_eq!(leaf(0xb, 0).Mask.Edx, 0, "{profile}"); + // The brand string is the profile's generic one, not the host's. + let brand: String = [0x8000_0002, 0x8000_0003, 0x8000_0004] + .iter() + .flat_map(|&function| { + let result = leaf(function, 0); + [ + result.Output.Eax, + result.Output.Ebx, + result.Output.Ecx, + result.Output.Edx, + ] + }) + .flat_map(u32::to_le_bytes) + .take_while(|&byte| byte != 0) + .map(char::from) + .collect(); + assert!( + brand.starts_with("Intel(R) Xeon(R) Processor ("), + "{profile}: {brand}" + ); + } } #[test] @@ -1247,35 +1586,110 @@ mod tests { assert_eq!(processor_features(available).bank1.0, 0xc_0000_0050); } + /// The check core runs before any VP runs (`E_CPU_SURFACE`): VP 0's + /// record equals the effective CPUID under its masks, on every pinned + /// profile, VP count, and APIC mode. + #[test] + fn vp0_presents_the_effective_cpuid() { + for profile in test_cpuid::PROFILES { + for vp_count in [1, 2, 4, 8, 64] { + for x2apic in [ + X2ApicState::Supported, + X2ApicState::Enabled, + X2ApicState::Unsupported, + ] { + if x2apic == X2ApicState::Unsupported && vp_count > 8 { + continue; + } + let partition = Partition::new(profile, vp_count, x2apic); + let record = CpuidLeafSet::new(partition.record()); + let differences = test_cpuid::differences(&record, &partition.effective); + assert!( + differences.is_empty(), + "{profile}, {vp_count} VPs, {x2apic:?}: {differences:#?}" + ); + let mut lookup = |function, index| record.result(function, index, &[0; 4]); + virt::time_abi::identity::check_identity(&mut lookup, vp_count).unwrap(); + virt::time_abi::identity::check_time_bits(&mut lookup, true).unwrap(); + } + } + } + } + + /// Every VP observes the effective CPUID with its own APIC identity in + /// the per-VP fields. + #[test] + fn each_vp_observes_its_own_apic_identity() { + for profile in test_cpuid::PROFILES { + let partition = Partition::new(profile, VP_COUNT, X2ApicState::Supported); + for vp in partition.topology.vps_arch() { + let apic_id = vp.apic_id; + assert_eq!( + partition.observe(apic_id, 1, 0)[1] >> 24, + apic_id, + "{profile}" + ); + for index in [0, 1] { + assert_eq!(partition.observe(apic_id, 0xb, index)[3], apic_id); + } + for leaf in partition.config.leaves() { + let index = leaf.index.unwrap_or(0); + let mut theirs = partition.observe(apic_id, leaf.function, index); + let mut ours = partition.observe(0, leaf.function, index); + let per_vp = per_vp_cpuid_bits(leaf.function); + for register in 0..4 { + theirs[register] &= !per_vp[register]; + ours[register] &= !per_vp[register]; + } + assert_eq!(theirs, ours, "{profile} VP {apic_id} {:#x}", leaf.function); + } + } + } + } + #[test] - fn partition_cpuid_puts_the_time_abi_last() { - let config = time_abi_cpuid(VP_COUNT, true); - let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); - let mut effective = - |function, index| cpuid.result(function, index, &whp_cpuid(function, index)); - check_identity(&mut effective, VP_COUNT).unwrap(); - check_time_bits(&mut effective, true).unwrap(); - // The topology survives in the bits the time ABI does not own. - assert_eq!(effective(1, 0)[1] & 0x00ff_0000, 0x0008_0000); - assert_eq!(effective(4, 0)[0], 0x1c00_4121); - // No hypervisor leaf of another source remains. - assert_eq!(effective(0x4000_0100, 0), [0; 4]); + fn partition_cpuid_keeps_own_results_only_for_exiting_leaves() { + let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); + let own = vec![ + CpuidLeaf::new(4, [0x1c00_4121, 0, 0, 0]).indexed(0), + CpuidLeaf::new(0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), + CpuidLeaf::new(1, [0, 0x0008_0000, 0, 0]).masked([0, 0x00ff_0000, 0, 0]), + ]; + let cpuid = partition_cpuid(own, &partition.config, &partition.exits).unwrap(); + for leaf in cpuid.leaves() { + let programmed = partition + .config + .leaves() + .iter() + .find(|c| (c.function, c.index) == (leaf.function, leaf.index)); + match programmed { + // Leaves that do not exit come from the effective CPUID alone. + Some(c) if partition.exits.binary_search(&c.function).is_err() => { + assert_eq!(key(leaf), key(c), "{:#x}", leaf.function); + } + Some(_) => {} + None => assert!( + partition.exits.binary_search(&leaf.function).is_ok(), + "{:#x}", + leaf.function + ), + } + } assert!( - cpuid + !cpuid .leaves() .iter() - .filter(|leaf| HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) - .all(|leaf| config.leaves().iter().any(|c| key(c) == key(leaf))) + .any(|leaf| leaf.function == 0x4000_0100) ); } #[test] fn partition_cpuid_rejects_shadowed_subleaves() { - let mut own = own_leaves(); - // A subleaf-less leaf 7 would hide the time ABI's leaf 7.0. - own.push(CpuidLeaf::new(7, [0, 1 << 1, 0, 0]).masked([0, 1 << 1, 0, 0])); + let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); + // A subleaf-less leaf 0xB would hide the effective CPUID's 0xB.0. + let own = vec![CpuidLeaf::new(0xb, [0; 4]).masked([0, !0, 0, 0])]; assert_eq!( - partition_cpuid(own, &time_abi_cpuid(VP_COUNT, true)) + partition_cpuid(own, &partition.config, &partition.exits) .unwrap_err() .code, TimeAbiCode::IdentityRouting @@ -1283,73 +1697,43 @@ mod tests { } #[test] - fn every_programmed_leaf_must_exit() { - let config = time_abi_cpuid(VP_COUNT, true); - let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); - let exits = cpuid_exits(false, &config); - check_cpuid_delivery(&cpuid, &exits).unwrap(); - let without: Vec = exits + fn hypervisor_leaves_must_exit() { + let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); + let without: Vec = partition + .exits .iter() .copied() - .filter(|&leaf| leaf != 0x8000_0007) + .filter(|&leaf| leaf != 0x4000_0003) .collect(); - let error = check_cpuid_delivery(&cpuid, &without).unwrap_err(); + let error = check_cpuid_delivery(&partition.cpuid, &without).unwrap_err(); assert_eq!(error.code, TimeAbiCode::IdentityRouting); - assert!(error.message.contains("0x80000007"), "{error}"); - } - - #[test] - fn effective_cpuid_reports_every_programmed_leaf() { - let config = time_abi_cpuid(VP_COUNT, true); - let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); - let vp0 = |function, index| { - Ok::<_, std::convert::Infallible>(cpuid.result( - function, - index, - &whp_cpuid(function, index), - )) - }; - let record = effective_cpuid(&cpuid, vp0).unwrap(); - let mut keys: Vec<(u32, Option)> = cpuid - .leaves() + assert!(error.message.contains("0x40000003"), "{error}"); + // A leaf outside the hypervisor range reaches the guest through + // CpuidResultList2 without an exit. + let only_identity: Vec = partition + .exits .iter() - .map(|leaf| (leaf.function, leaf.index)) - .chain([(0, None), (0x8000_0000, None)]) + .copied() + .filter(|leaf| HYPERVISOR_CPUID_RANGE.contains(leaf)) .collect(); - keys.sort(); - assert_eq!( - record - .iter() - .map(|leaf| (leaf.function, leaf.index)) - .collect::>(), - keys - ); - assert!(record.iter().all(|leaf| leaf.mask == [!0; 4])); - - let record = CpuidLeafSet::new(record); - let mut lookup = |function, index| record.result(function, index, &[0; 4]); - check_identity(&mut lookup, VP_COUNT).unwrap(); - check_time_bits(&mut lookup, true).unwrap(); - // Guest CR4 state does not reach the record. - assert_eq!(lookup(1, 0)[2] & ECX1_OSXSAVE, 0); - assert_eq!(lookup(7, 0)[2] & ECX7_OSPKE, 0); - assert_eq!(lookup(0, 0), whp_cpuid(0, 0)); - // The topology leaves are part of the record. - assert_eq!(lookup(4, 0)[0], 0x1c00_4121); - assert_eq!(lookup(1, 0)[1] & 0x00ff_0000, 0x0008_0000); + check_cpuid_delivery(&partition.config, &only_identity).unwrap(); } #[test] fn effective_cpuid_ignores_guest_cr4_state() { - let config = time_abi_cpuid(VP_COUNT, true); - let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); + let partition = Partition::new(test_cpuid::PROFILES[1], VP_COUNT, X2ApicState::Supported); let record = |cr4_bits: bool| { - effective_cpuid(&cpuid, |function, index| { - let mut result = cpuid.result(function, index, &whp_cpuid(function, index)); - match (function, cr4_bits) { - (1, false) => result[2] &= !ECX1_OSXSAVE, - (7, false) => result[2] &= !ECX7_OSPKE, - _ => {} + effective_cpuid(&partition.cpuid, |function, index| { + let mut result = partition.observe(0, function, index); + let (register, bit) = match (function, index) { + (1, _) => (2, ECX1_OSXSAVE), + (7, 0) => (2, ECX7_OSPKE), + _ => (0, 0), + }; + if cr4_bits { + result[register] |= bit; + } else { + result[register] &= !bit; } Ok::<_, std::convert::Infallible>(result) }) @@ -1375,8 +1759,8 @@ mod tests { #[test] fn effective_cpuid_reports_read_failures() { - let config = time_abi_cpuid(VP_COUNT, true); - let error = effective_cpuid(&config, |function, _| { + let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); + let error = effective_cpuid(&partition.cpuid, |function, _| { if function == 0x8000_0007 { Err(function) } else { @@ -1396,7 +1780,8 @@ mod tests { }) .unwrap(); assert_eq!(read, PREFLIGHT_SENTINEL_LEAVES); - let config = time_abi_cpuid(VP_COUNT, true); + let config = + Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported).config; for leaf in [0x4000_0006, 0x4000_0081] { assert!( config @@ -1590,30 +1975,18 @@ mod tests { #[test] fn capabilities_exclude_hv1_and_the_kvm_clock() { - let topology = TopologyBuilder::new_x86().build(1).unwrap(); - let config = time_abi_cpuid(1, true); - let cpuid = partition_cpuid(own_leaves(), &config).unwrap(); - let x2apic = topology.apic_mode() != vm_topology::processor::x86::ApicMode::XApic; - let mut lookup = |function, index| { - let mut result = cpuid.result(function, index, &whp_cpuid(function, index)); - if function == 1 { - // Match the topology's APIC mode and drop XSAVE, which the - // fake host does not describe. - result[2] &= !((1 << 21) | (1 << 26) | ECX1_OSXSAVE); - if x2apic { - result[2] |= 1 << 21; - } - } - result - }; - let caps = virt::x86::X86PartitionCapabilities::from_cpuid( - &topology, - &mut virt::time_abi::identity::capabilities_cpuid(&mut lookup), - ) - .unwrap(); - assert!(!caps.hv1); - assert!(!caps.kvm_clock); - assert!(!caps.tsc_deadline); + for profile in test_cpuid::PROFILES { + let partition = Partition::new(profile, 1, X2ApicState::Supported); + let mut lookup = |function, index| partition.observe(0, function, index); + let caps = virt::x86::X86PartitionCapabilities::from_cpuid( + &partition.topology, + &mut virt::time_abi::identity::capabilities_cpuid(&mut lookup), + ) + .unwrap(); + assert!(!caps.hv1, "{profile}"); + assert!(!caps.kvm_clock, "{profile}"); + assert!(!caps.tsc_deadline, "{profile}"); + } } } @@ -1621,28 +1994,38 @@ mod tests { /// Windows Hypervisor Platform. #[cfg(test)] mod whp_tests { + use super::test_cpuid; use super::*; use virt::time_abi::identity::check_identity; use virt::time_abi::identity::check_time_bits; - use virt::time_abi::identity::time_abi_cpuid; use virt::time_abi::rate::LAPIC_HZ_HYPERV; + use vm_topology::processor::x86::X2ApicState; - /// Returns a time ABI partition with `vp_count` VPs, configured as - /// `VtlPartition::new` configures one, and its time ABI state. - fn partition(vp_count: u32) -> (whp::Partition, WhpTimeAbi) { - partition_with(vp_count, None) + /// A time ABI partition, its time ABI state, and its effective CPUID. + struct Partition { + partition: whp::Partition, + time_abi: WhpTimeAbi, + topology: vm_topology::processor::ProcessorTopology, + effective: cpu_profile::EffectiveCpuid, } - /// Returns a time ABI partition with `vp_count` VPs whose features derive - /// from `profile`, if any. - fn partition_with(vp_count: u32, profile: Option<&CpuProfile>) -> (whp::Partition, WhpTimeAbi) { + /// Returns a time ABI partition with `vp_count` VPs and this host's CPU + /// profile, configured as `VtlPartition::new` configures one, or `None` + /// on a host outside the pinned generations, where the tests skip. + fn partition(vp_count: u32) -> Option { + let profile = match cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) { + Ok(profile) => profile, + Err(err) => { + println!("skipped: no profile for this host: {err}"); + return None; + } + }; + let topology = test_cpuid::topology(vp_count, X2ApicState::Supported); + let effective = test_cpuid::effective_cpuid(profile, &topology); let config = TimeAbiConfig { - cpuid: Arc::new(time_abi_cpuid(vp_count, true)), + cpuid: Arc::new(test_cpuid::config_cpuid(&effective)), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: profile.map_or_else( - || virt::time_abi::surface::interim_cpu_profile_id("whp"), - |profile| profile.id().to_owned(), - ), + cpu_profile: profile.id().to_owned(), }; let mut whp_config = whp::PartitionConfig::new().unwrap(); whp_config @@ -1662,18 +2045,32 @@ mod whp_tests { for vp in 0..vp_count { partition.create_vp(vp).create().unwrap(); } - (partition, time_abi) + Some(Partition { + partition, + time_abi, + topology, + effective, + }) } - fn native( + fn native_vp( partition: &whp::Partition, + vp: u32, function: u32, index: u32, ) -> Result<[u32; 4], whp::WHvError> { - let output = partition.vp(0).get_cpuid_output(function, index)?; + let output = partition.vp(vp).get_cpuid_output(function, index)?; Ok([output.Eax, output.Ebx, output.Ecx, output.Edx]) } + fn native( + partition: &whp::Partition, + function: u32, + index: u32, + ) -> Result<[u32; 4], whp::WHvError> { + native_vp(partition, 0, function, index) + } + fn tsc(partition: &whp::Partition, vp: u32) -> u64 { partition.vp(vp).get_register(whp::Register64::Tsc).unwrap() } @@ -1681,7 +2078,14 @@ mod whp_tests { #[test] #[ignore = "requires WHP"] fn partition_meets_the_static_obligations() { - let (partition, time_abi) = partition(4); + let Some(Partition { + partition, + time_abi, + .. + }) = partition(4) + else { + return; + }; let started = std::time::Instant::now(); check_native_sentinels(|function| native(&partition, function, 0)).unwrap(); check_feature_banks(|function, index| native(&partition, function, index)).unwrap(); @@ -1711,16 +2115,6 @@ mod whp_tests { .unwrap() .is_set(WHV_X64_MSR_EXIT_BITMAP::UnhandledMsrs) ); - - let cpuid = time_abi.partition_cpuid(Vec::new()).unwrap(); - let record = effective_cpuid(&cpuid, |function, index| { - native(&partition, function, index).map(|result| cpuid.result(function, index, &result)) - }) - .unwrap(); - let record = CpuidLeafSet::new(record); - let mut lookup = |function, index| record.result(function, index, &[0; 4]); - check_identity(&mut lookup, 4).unwrap(); - check_time_bits(&mut lookup, true).unwrap(); println!( "tsc_hz {} bank1 {:#x} native 0x80000007 {:#x?}", partition.tsc_frequency().unwrap(), @@ -1729,61 +2123,92 @@ mod whp_tests { ); } - /// Configures a partition from this host's CPU profile, as partitions - /// will once `TimeAbiConfig` carries the profile, and checks the static - /// obligations and that the guest CPUID equals the profile's wherever the - /// profile pins it. + /// Configures a partition from this host's CPU profile and runs the + /// check core runs before any VP runs (`E_CPU_SURFACE`) on every VP: the + /// CPUID it observes, through `CpuidResultList2` or the exit handler, + /// equals the effective CPUID under its masks, with its own APIC + /// identity in the per-VP fields. #[test] #[ignore = "requires WHP"] fn host_profile_configures_the_partition() { - let profile = match cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) { - Ok(profile) => profile, - Err(err) => { - println!("skipped: no profile for this host: {err}"); - return; - } + const VP_COUNT: u32 = 4; + let Some(Partition { + partition, + time_abi, + topology, + effective, + }) = partition(VP_COUNT) + else { + return; }; - let (partition, time_abi) = partition_with(4, Some(profile)); - assert_eq!(time_abi.cpu_profile.as_deref(), Some(profile.id())); - assert!(time_abi.features_xsave.is_some()); check_native_sentinels(|function| native(&partition, function, 0)).unwrap(); check_feature_banks(|function, index| native(&partition, function, index)).unwrap(); - let cpuid = time_abi.partition_cpuid(Vec::new()).unwrap(); - let record = effective_cpuid(&cpuid, |function, index| { - native(&partition, function, index).map(|result| cpuid.result(function, index, &result)) - }) - .unwrap(); - let record = CpuidLeafSet::new(record); - let mut lookup = |function, index| record.result(function, index, &[0; 4]); - check_identity(&mut lookup, 4).unwrap(); - check_time_bits(&mut lookup, true).unwrap(); - - // What the guest sees: the time ABI CPUID over WHP's results. - let mut differences = Vec::new(); - for entry in profile.cpuid() { - let (function, index) = entry.key(); - let index = index.unwrap_or(0); - let native = native(&partition, function, index).unwrap(); - let guest = cpuid.result(function, index, &native); - let pinned = entry.values().into_iter().zip(entry.masks()); - for (register, (actual, (value, mask))) in guest.into_iter().zip(pinned).enumerate() { - let differs = (actual ^ value) & mask; - if differs != 0 { - differences.push(format!( - "{function:#x}.{index} register {register}: guest {actual:#010x} profile {value:#010x} differing bits {differs:#010x}", - )); + // The backend's own results, as the partition build composes them. + let own = test_cpuid::own_cpuid(&topology, &|function, index| { + native(&partition, function, index).unwrap() + }); + let cpuid = time_abi.partition_cpuid(own).unwrap(); + let cpuid_topology = CpuidTopology::new(&topology); + let started = std::time::Instant::now(); + let mut reads = 0; + for vp in topology.vps_arch() { + let index = vp.base.vp_index.index(); + let view = VpCpuid { + cpuid: &cpuid, + exits: &time_abi.cpuid_exits, + topology: &cpuid_topology, + apic_id: vp.apic_id, + }; + let record = effective_cpuid(&cpuid, |function, subleaf| { + view.result(function, subleaf, || { + reads += 1; + native_vp(&partition, index, function, subleaf) + }) + }) + .unwrap(); + let record = CpuidLeafSet::new(record); + if index == 0 { + println!( + "VP 0 record: {} results, {reads} native reads in {} us", + record.leaves().len(), + started.elapsed().as_micros() + ); + let mut lookup = |function, index| record.result(function, index, &[0; 4]); + check_identity(&mut lookup, VP_COUNT).unwrap(); + check_time_bits(&mut lookup, true).unwrap(); + let differences = test_cpuid::differences(&record, &effective); + assert!(differences.is_empty(), "VP 0: {differences:#?}"); + } else { + // Each VP observes VP 0's CPUID outside the per-VP fields, + // and its own APIC ID in them. + assert_eq!(record.result(1, 0, &[0; 4])[1] >> 24, vp.apic_id); + assert_eq!(record.result(0xb, 0, &[0; 4])[3], vp.apic_id); + for leaf in effective.results() { + let subleaf = leaf.index.unwrap_or(0); + let per_vp = per_vp_cpuid_bits(leaf.function); + let actual = record.result(leaf.function, subleaf, &[0; 4]); + for register in 0..4 { + let mask = leaf.mask[register] & !per_vp[register]; + assert_eq!( + (actual[register] ^ leaf.result[register]) & mask, + 0, + "VP {index} CPUID {:#x}/{subleaf} register {register}", + leaf.function + ); + } } } } println!( - "{}: bank0 {:#x} bank1 {:#x} xsave {:#x?}", - profile.id(), + "{}: exits {:#x?} results {} bank0 {:#x} bank1 {:#x} xsave {:#x}", + time_abi.cpu_profile, + time_abi.cpuid_exits, + time_abi.cpuid_results, time_abi.features_bank0, time_abi.features_bank1, time_abi.features_xsave ); - assert!(differences.is_empty(), "{differences:#?}"); } #[test] @@ -1791,7 +2216,9 @@ mod whp_tests { fn time_suspension_is_idempotent() { // The preflight probe and the synchronized set both suspend time, and // a fresh partition's time is frozen until a VP first runs. - let (partition, _) = partition(2); + let Some(Partition { partition, .. }) = partition(2) else { + return; + }; partition.suspend_time().unwrap(); partition.suspend_time().unwrap(); let frozen = tsc(&partition, 0); @@ -1807,7 +2234,9 @@ mod whp_tests { #[ignore = "requires WHP"] fn synchronized_set_is_exact_while_suspended() { for vp_count in [1, 2, 4, 8] { - let (partition, _) = partition(vp_count); + let Some(Partition { partition, .. }) = partition(vp_count) else { + return; + }; let hz = partition.tsc_frequency().unwrap(); partition.resume_time().unwrap(); for vp in 0..vp_count { @@ -1850,7 +2279,9 @@ mod whp_tests { #[test] #[ignore = "requires WHP"] fn capture_anchor_pairs_within_the_bound() { - let (partition, _) = partition(1); + let Some(Partition { partition, .. }) = partition(1) else { + return; + }; partition.resume_time().unwrap(); let bsp = partition.vp(0); let candidates: Vec<_> = (0..ANCHOR_ATTEMPTS) From eecdd504a55a912bcecac0a055677829ada5e22c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 13:57:05 -0700 Subject: [PATCH 215/304] virt_whp: assert a pending LAPIC vector written into a halted VP's IRR On prometheus28, a VP inspect 2 s after the restore marker of idle 8-VP restores found VP 7 halted with Linux's timer vector 0xec pending in IRR (PPR 0) after every legacy restore (4/4), and no such VP after any time ABI restore (0/8). The legacy restore clock (TscAdvance) queues the vector of every one-shot timer that expired during the downtime and writes the LAPIC state through the offloaded APIC's state page, then suspends partition time and rewrites the AP TSCs before the first VP run. The hypervisor doesn't deliver the vector, so the VP's timers stall until some other interrupt wakes it. The mshv backend has the same defect (b846c4182, 1affa4447). After writing an offloaded LAPIC state, assert its highest-priority pending edge-triggered vector through WHvRequestInterrupt, and assert it again when the VP next runs, after any partition time suspension. For a vector that is already pending this is idempotent, and it wakes the VP when the vector is deliverable; the hypervisor still applies the PPR and IF rules. Level-triggered vectors are left to their IOAPIC, and vectors 0 to 15 (whose IRR bits the state page reuses) are ignored. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/apic.rs | 127 ++++++++++++++++++++++++++++++++++ vmm_core/virt_whp/src/lib.rs | 7 ++ vmm_core/virt_whp/src/vp.rs | 11 +++ 3 files changed, 145 insertions(+) diff --git a/vmm_core/virt_whp/src/apic.rs b/vmm_core/virt_whp/src/apic.rs index 60fcb73aeb5..4d399d61eff 100644 --- a/vmm_core/virt_whp/src/apic.rs +++ b/vmm_core/virt_whp/src/apic.rs @@ -314,11 +314,62 @@ impl WhpProcessor<'_> { .whp(vtl) .set_apic(&apic.as_page()) .for_op("set apic state")?; + self.assert_pending_interrupt(vtl, &apic)?; + if vtl == Vtl::Vtl0 { + self.inner.lapic_written.store(true, Ordering::Release); + } } } Ok(()) } + + /// Asserts the highest-priority pending edge-triggered vector of an + /// offloaded LAPIC state just written with `set_apic` through WHP's + /// interrupt path. + /// + /// A snapshot restore writes saved IRR bits this way, and so does the + /// restore clock when it queues the vector of a one-shot timer that + /// expired during the downtime. The hypervisor does not always deliver + /// such a vector to a halted VP: on prometheus28, 2 s after every legacy + /// 8-VP restore, a VP was still halted with the timer vector pending, and + /// its timers stalled until some other interrupt woke it. Asserting a + /// vector that is already pending is idempotent, and wakes the VP when + /// the vector is deliverable: the hypervisor still applies the PPR and IF + /// rules. + fn assert_pending_interrupt(&self, vtl: Vtl, apic: &vp::ApicRegisters) -> Result<(), Error> { + let Some(vector) = highest_pending_edge_vector(apic) else { + return Ok(()); + }; + self.vp + .partition + .vtlp(vtl) + .whp + .interrupt( + whp::abi::WHvX64InterruptTypeFixed, + whp::abi::WHvX64InterruptDestinationModePhysical, + whp::abi::WHvX64InterruptTriggerModeEdge, + self.inner.vp_info.apic_id, + vector.into(), + ) + .for_op("assert a pending LAPIC vector")?; + Ok(()) + } + + /// Asserts the highest pending edge-triggered vector of the VP's current + /// offloaded VTL0 LAPIC state again, if the VMM wrote that state since the + /// VP last ran: partition time may have been suspended and resumed since + /// the write (the legacy restore clock does both), so the VP runs only + /// after the assertion. + pub(crate) fn reassert_written_lapic(&mut self) -> Result<(), Error> { + if !self.inner.lapic_written.swap(false, Ordering::AcqRel) { + return Ok(()); + } + let apic = vp::ApicRegisters::from_page( + &self.vp.whp(Vtl::Vtl0).get_apic().for_op("get apic state")?, + ); + self.assert_pending_interrupt(Vtl::Vtl0, &apic) + } pub(crate) fn apic_write(&mut self, address: u64, data: &[u8], dev: &impl CpuIo) { if let Some(lapic) = self.state.vtls.lapic(self.state.active_vtl) { lapic @@ -750,3 +801,79 @@ impl ApicState { *nmi_pending = false; } } + +/// Returns the highest-priority vector pending in `apic`'s IRR that is +/// edge-triggered (TMR clear). Vectors 0 to 15 are skipped: they are not valid +/// fixed interrupts, and the Hyper-V LAPIC state page reuses their IRR bits +/// (for example for the NMI pending bit). Level-triggered vectors are left to +/// their IOAPIC, whose EOI handling an edge assertion would bypass. +fn highest_pending_edge_vector(apic: &vp::ApicRegisters) -> Option { + (16..=255u8).rev().find(|&vector| { + let (word, bit) = (usize::from(vector / 32), vector % 32); + apic.irr[word] >> bit & 1 == 1 && apic.tmr[word] >> bit & 1 == 0 + }) +} + +#[cfg(test)] +mod tests { + use super::highest_pending_edge_vector; + use virt::x86::vp::ApicRegisters; + use zerocopy::FromZeros; + + fn pending(irr: &[u8], level: &[u8]) -> ApicRegisters { + let mut apic = ApicRegisters::new_zeroed(); + for &vector in irr { + apic.irr[usize::from(vector / 32)] |= 1 << (vector % 32); + } + for &vector in level { + apic.tmr[usize::from(vector / 32)] |= 1 << (vector % 32); + } + apic + } + + #[test] + fn nothing_pending_asserts_nothing() { + assert_eq!(highest_pending_edge_vector(&pending(&[], &[])), None); + } + + #[test] + fn the_expired_timer_vector_is_asserted() { + // Linux's LOCAL_TIMER_VECTOR, as the restore clock queues it. + assert_eq!( + highest_pending_edge_vector(&pending(&[0xec], &[])), + Some(0xec) + ); + } + + #[test] + fn the_highest_edge_vector_wins() { + assert_eq!( + highest_pending_edge_vector(&pending(&[0x30, 0xec, 0x41], &[])), + Some(0xec) + ); + assert_eq!( + highest_pending_edge_vector(&pending(&[0xff, 0x10], &[])), + Some(0xff) + ); + } + + #[test] + fn level_triggered_vectors_are_left_to_their_ioapic() { + assert_eq!( + highest_pending_edge_vector(&pending(&[0x41], &[0x41])), + None + ); + assert_eq!( + highest_pending_edge_vector(&pending(&[0x41, 0x30], &[0x41])), + Some(0x30) + ); + } + + #[test] + fn reserved_vectors_and_the_nmi_pending_bit_are_ignored() { + let mut apic = pending(&[], &[]); + apic.set_hv_apic_nmi_pending(true); + apic.irr[0] |= 0xffff; + assert_eq!(highest_pending_edge_vector(&apic), None); + } +} diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index b5f8cb5991f..7a214ca6ad3 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -237,6 +237,11 @@ struct WhpVp { vtl2_wake: AtomicBool, /// Enable VTL2 at the next opportunity. vtl2_enable: AtomicBool, + /// Set when the VMM writes this VP's offloaded VTL0 LAPIC state. The next + /// VP run consumes it and asserts the highest pending edge-triggered + /// vector again (see `WhpProcessor::assert_pending_interrupt`). + #[cfg(guest_arch = "x86_64")] + lapic_written: AtomicBool, vp_info: TargetVpInfo, waker: RwLock>, } @@ -411,6 +416,8 @@ impl WhpVp { interrupt: NeedsYield::new(), vtl2_wake: false.into(), vtl2_enable: vtl2_enabled.into(), + #[cfg(guest_arch = "x86_64")] + lapic_written: false.into(), vp_info: vp, waker: Default::default(), } diff --git a/vmm_core/virt_whp/src/vp.rs b/vmm_core/virt_whp/src/vp.rs index 7ecd59881c7..4f5749dbd41 100644 --- a/vmm_core/virt_whp/src/vp.rs +++ b/vmm_core/virt_whp/src/vp.rs @@ -236,6 +236,17 @@ impl<'a> WhpProcessor<'a> { dev: &impl CpuIo, ) -> Result { tracing::trace!(vtl = ?self.state.active_vtl, "current vtl"); + // A LAPIC state written since the last run may hold a pending vector + // that the hypervisor has not delivered, and partition time may have + // been suspended and resumed since then. Assert it again now that the + // VP is about to run. + #[cfg(guest_arch = "x86_64")] + if let Err(err) = self.reassert_written_lapic() { + tracelimit::warn_ratelimited!( + error = &err as &dyn std::error::Error, + "failed to assert a pending LAPIC vector" + ); + } let mut last_waker = None; loop { self.inner.interrupt.maybe_yield().await; From d92acd124a2027a2876a5033409cb4ef94ab7fb1 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 14:26:56 -0700 Subject: [PATCH 216/304] virt_whp: report a cheap supported CPU surface for the time ABI Core checks the CPU profile against the backend's supported CPU surface at partition creation when the backend reports one (verify_support, E_PROFILE_UNSUPPORTED). WHP has no CPUID capability query, and the --cpu-fingerprint probe partition costs 2.6 to 5.5 ms per partition, so the surface comes from what is cheap to read: - the host's CPUID as the root partition sees it, without the hypervisor range; - every feature bit that a WHP processor or XSAVE feature controls set exactly where WHP offers the feature (the profile_features table); - only the XSAVE state components that the XSAVE features enable, with the host's layout; - the partition's guest physical address width, since WHP's limit can differ from the host's (46 against 42 bits on an Alder Lake laptop); - IA32_ARCH_CAPABILITIES as the banks derive it. The root partition sees features that WHP never offers a guest (MONITOR, VMX, Intel PT, the PMU and thermal leaves). No pinned profile sets them, so they cannot make a profile look unsupported. A hardware test compares the surface with the probe partition's: every pinned profile gets the same verdict from both, and the host's own profile passes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/profile_features.rs | 246 +++++++++++++++++++++- vmm_core/virt_whp/src/time_abi.rs | 54 +++++ 2 files changed, 299 insertions(+), 1 deletion(-) diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index 49ced144184..027127454a1 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -23,12 +23,15 @@ use cpu_profile::CpuProfile; use cpu_profile::TIME_POLICY_BITS; +use cpu_profile::cpuid::CpuidEntry; use cpu_profile::hv_banks; use hvdef::HvX64PartitionProcessorFeatures as Bank0; use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; use virt::time_abi::TimeAbiCode; use virt::time_abi::TimeAbiError; +use virt::time_abi::surface::SupportedCpuSurface; +use virt::time_abi::surface::SupportedMsrValue; /// `IA32_ARCH_CAPABILITIES`. const MSR_ARCH_CAPABILITIES: u32 = 0x10a; @@ -61,7 +64,6 @@ impl WhpFeatures { } } - #[cfg(test)] fn word(&self, bank: Bank) -> u64 { match bank { Bank::Features0 => self.banks[0], @@ -372,6 +374,116 @@ pub(crate) fn profile_features( Ok(features) } +/// Returns whether a partition with `available` features supports XSAVE +/// state component `component` (an XCR0 or `IA32_XSS` bit). PKRU has no WHP +/// feature: like PKU, it follows the host. +fn xsave_component_enabled(component: u32, available: WhpFeatures) -> bool { + let xsave = XsaveBank::from(available.xsave); + let bank1 = Bank1::from(available.banks[1]); + if !xsave.xsave_support() { + return false; + } + match component { + // x87, SSE, and PKRU. + 0 | 1 | 9 => true, + 2 => xsave.avx_support(), + 3 | 4 => xsave.mpx_support(), + 5..=7 => xsave.avx512_support(), + // CET user and supervisor state. + 11 | 12 => { + xsave.xsave_supervisor_support() && (bank1.cet_ss_support() || bank1.cet_ibt_support()) + } + 17 | 18 => xsave.amx_tile_support(), + _ => false, + } +} + +/// Returns the CPU surface that WHP supports for a partition with every +/// feature in `available`, without a probe partition: `host`, the host's +/// CPUID as the root partition sees it, with +/// +/// - every CPUID feature bit that a WHP feature controls ([`FEATURES`]) set +/// exactly where `available` offers one of its features; +/// - only the XSAVE state components that `available` enables, in leaf +/// `0xD` (subleaves 0 and 1, and the component subleaves, whose layout is +/// the host's); +/// - no hypervisor-range leaf, since the partition has no synthetic +/// processor features. +/// +/// The guest physical address width, in the surface and in CPUID +/// `0x80000008` EAX[7:0], is `physical_address_width`, the partition's: WHP's +/// limit can differ from the host's. The other bits, the maximum leaves, and +/// the linear address width are the host's. `IA32_ARCH_CAPABILITIES` is what +/// the banks derive. +pub(crate) fn supported_surface( + host: &[CpuidEntry], + available: WhpFeatures, + physical_address_width: u8, +) -> SupportedCpuSurface { + let mut controlled = std::collections::BTreeMap::<(u32, u32, usize), [u32; 2]>::new(); + for feature in FEATURES { + let bit = feature.cpuid; + let masks = controlled + .entry((bit.leaf, bit.subleaf, bit.register)) + .or_default(); + masks[0] |= 1 << bit.bit; + if available.word(feature.bank) & feature.mask != 0 { + masks[1] |= 1 << bit.bit; + } + } + let components = (0..64) + .filter(|&component| xsave_component_enabled(component, available)) + .fold(0u64, |mask, component| mask | 1 << component); + + let cpuid = host + .iter() + .filter(|entry| !(0x4000_0000..=0x4fff_ffff).contains(&entry.leaf.0)) + .filter_map(|entry| { + let (leaf, subleaf) = entry.key(); + let index = subleaf.unwrap_or(0); + if leaf == 0xd && (2..64).contains(&index) && components & (1 << index) == 0 { + return None; + } + let mut registers = entry.registers(); + for (register, value) in registers.iter_mut().enumerate() { + if let Some(&[mask, offered]) = controlled.get(&(leaf, index, register)) { + *value = (*value & !mask) | offered; + } + } + match (leaf, index) { + (0xd, 0) => { + registers[EAX] &= components as u32; + registers[EDX] &= (components >> 32) as u32; + } + (0xd, 1) => { + registers[ECX] &= components as u32; + registers[EDX] &= (components >> 32) as u32; + } + (X8, _) => { + registers[EAX] = (registers[EAX] & !0xff) | u32::from(physical_address_width); + } + _ => {} + } + let result = virt::CpuidLeaf::new(leaf, registers); + Some(match subleaf { + Some(subleaf) => result.indexed(subleaf), + None => result, + }) + }) + .collect(); + + let arch_capabilities = hv_banks::arch_capabilities_msr(available.banks); + SupportedCpuSurface { + cpuid, + physical_address_width, + msrs: vec![SupportedMsrValue { + index: arch_capabilities.index, + supported: arch_capabilities.supported, + controllable: arch_capabilities.controllable, + }], + } +} + #[cfg(test)] mod tests { use super::*; @@ -517,6 +629,58 @@ mod tests { assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 27)), Pinned::Unpinned); assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 26)), Pinned::Set); } + #[test] + fn the_supported_surface_follows_the_capability() { + // A host that reports every feature, AVX-512 and AMX state, and a + // hypervisor leaf. + let mut host = vec![ + CpuidEntry::new(0, None, [0xd, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), + CpuidEntry::new(1, None, [0x0005_0654, 0, !0, !0]), + CpuidEntry::new(7, Some(0), [0, !0, !0, !0]), + CpuidEntry::new(0xd, Some(0), [0x6_02e7, 0x2b00, 0x2b00, 0]), + CpuidEntry::new(0xd, Some(1), [0xf, 0, 0x1800, 0]), + CpuidEntry::new(0x4000_0000, None, [0x4000_000b, 1, 2, 3]), + CpuidEntry::new(X8, None, [0x3_302e, 0, 0, 0]), + ]; + for component in [2, 5, 6, 7, 9, 17, 18] { + host.push(CpuidEntry::new(0xd, Some(component), [8, 64, 0, 0])); + } + host.sort_by_key(CpuidEntry::key); + let mut available = AZURE_8370C; + available.xsave &= !XsaveBank::new().with_avx512_support(true).into_bits(); + available.banks[0] &= !Bank0::new().with_smep_support(true).into_bits(); + let surface = supported_surface(&host, available, 0x2e); + let lookup = |leaf: u32, subleaf: u32| { + surface + .cpuid + .iter() + .find(|result| result.matches(leaf, subleaf)) + .map(|result| result.result) + }; + // A feature WHP does not offer is cleared; one it offers stays. + assert_eq!(lookup(7, 0).unwrap()[EBX] & (1 << 7), 0, "SMEP"); + assert_ne!(lookup(7, 0).unwrap()[EBX] & 1, 0, "FSGSBASE"); + assert_eq!(lookup(7, 0).unwrap()[EBX] & (1 << 16), 0, "AVX512F"); + // Bits no WHP feature controls keep the host's value. + assert_ne!(lookup(1, 0).unwrap()[EDX] & 1, 0, "FPU"); + // Only the XSAVE components the features enable remain: no AVX-512 + // and no AMX (the 8370C offers no AMX tiles). + assert_eq!(lookup(0xd, 0).unwrap()[EAX], 0x207); + assert!(lookup(0xd, 5).is_none() && lookup(0xd, 17).is_none()); + assert_eq!(lookup(0xd, 2).unwrap(), [8, 64, 0, 0]); + // No hypervisor leaf; the physical address width is the partition's. + assert!(lookup(0x4000_0000, 0).is_none()); + assert_eq!(surface.physical_address_width, 0x2e); + let narrow = supported_surface(&host, available, 0x27); + let x8 = narrow + .cpuid + .iter() + .find(|result| result.function == X8) + .unwrap(); + assert_eq!(x8.result[EAX], 0x3_3027); + assert_eq!(surface.msrs.len(), 1); + assert_eq!(surface.msrs[0].index, MSR_ARCH_CAPABILITIES); + } } #[cfg(test)] @@ -890,6 +1054,86 @@ mod whp_tests { assert!(failures.is_empty(), "{failures:?}"); } + /// Compares the cheap supported surface (host CPUID and WHP's + /// capabilities) with the probe partition's (`--cpu-fingerprint`): every + /// pinned profile gets the same `verify_support` verdict from both, and + /// the host's own profile passes. + #[test] + #[ignore = "requires WHP"] + fn cheap_surface_matches_the_probe_surface() { + let fingerprint = crate::fingerprint::cpu_fingerprint().unwrap(); + let probe = cpu_profile::HostCpuSurface::from_fingerprint(&fingerprint); + let started = std::time::Instant::now(); + let cheap = supported_surface( + &crate::time_abi::host_cpuid(), + available(), + probe.physical_address_width, + ); + let elapsed = started.elapsed(); + let mut cpuid: Vec = cheap + .cpuid + .iter() + .map(|leaf| CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::cpuid::normalize(&mut cpuid); + let cheap = cpu_profile::HostCpuSurface { + cpuid, + physical_address_width: cheap.physical_address_width, + msrs: cheap + .msrs + .iter() + .map(|msr| cpu_profile::SupportedMsr { + index: msr.index, + supported: msr.supported, + controllable: msr.controllable, + }) + .collect(), + }; + println!( + "cheap surface: {} entries in {} us; probe surface: {} entries; widths {} and {}", + cheap.cpuid.len(), + elapsed.as_micros(), + probe.cpuid.len(), + cheap.physical_address_width, + probe.physical_address_width + ); + let host = cpu_profile::select_auto(&HostCpuSignature::current()).ok(); + let mut mismatches = Vec::new(); + for profile in cpu_profile::pinned_profiles() { + // Where the two surfaces differ on a leaf the profile lists. + for entry in profile.cpuid() { + let (leaf, subleaf) = entry.key(); + let subleaf = subleaf.unwrap_or(0); + let ours = + cpu_profile::cpuid::lookup(&cheap.cpuid, leaf, subleaf).unwrap_or_default(); + let theirs = + cpu_profile::cpuid::lookup(&probe.cpuid, leaf, subleaf).unwrap_or_default(); + for register in 0..4 { + let differs = ours[register] ^ theirs[register]; + if differs != 0 { + println!( + "{}: {leaf:#x}.{subleaf} register {register}: cheap {:#010x} probe {:#010x} pinned mask {:#010x}", + profile.id(), + ours[register], + theirs[register], + entry.masks()[register] + ); + } + } + } + let ours = cpu_profile::support_violations(profile, &cheap); + let theirs = cpu_profile::support_violations(profile, &probe); + println!("{}: cheap {ours:?}; probe {theirs:?}", profile.id()); + if ours.is_empty() != theirs.is_empty() { + mismatches.push(profile.id()); + } + if host.is_some_and(|host| host.id() == profile.id()) { + assert!(ours.is_empty(), "{}: {ours:?}", profile.id()); + } + } + assert!(mismatches.is_empty(), "{mismatches:?}"); + } + /// Times what a WHP `supported_cpu_surface()` costs with a probe /// partition: creating a one-VP partition with every feature WHP offers, /// reading its whole CPUID, and tearing it down. diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 682ddffccf6..087e06fee8a 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -56,6 +56,7 @@ use crate::cpu_contract::CpuidTopology; use crate::cpu_contract::fixup_vp_topology_cpuid; use crate::profile_features::WhpFeatures; use crate::profile_features::profile_features; +use crate::profile_features::supported_surface; use cpu_profile::CpuProfile; use inspect::Inspect; use std::sync::Arc; @@ -80,6 +81,7 @@ use virt::time_abi::host::sample_host_time; use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_MAX_LEAF; +use virt::time_abi::surface::SupportedCpuSurface; use virt::x86::topology::per_vp_cpuid_bits; use whp::abi::WHV_CPUID_OUTPUT; use whp::abi::WHV_EXTENDED_VM_EXITS; @@ -333,6 +335,24 @@ fn routing_error(message: impl Into) -> TimeAbiError { TimeAbiError::new(TimeAbiCode::IdentityRouting, message) } +/// Returns the host's CPUID as the root partition sees it: every leaf and +/// subleaf that [`cpu_profile::cpuid::enumerate`] walks. It describes the +/// processor that WHP virtualizes, for [`supported_surface`]. +pub(crate) fn host_cpuid() -> Vec { + // The CPUID instruction exists only on x86-64 hosts, the only hosts of + // x86-64 WHP partitions. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + let query = |leaf, subleaf| { + let result = core::arch::x86_64::__cpuid_count(leaf, subleaf); + Ok::<_, std::convert::Infallible>([result.eax, result.ebx, result.ecx, result.edx]) + }; + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + let query = |_, _| Ok::<_, std::convert::Infallible>([0; 4]); + cpu_profile::cpuid::enumerate(query).unwrap_or_else(|never| match never {}) +} + /// Returns the pinned CPU profile that `id` names. Core selects and verifies /// the profile before the partition exists, so an unknown ID is an internal /// error (`E_PROFILE_UNKNOWN`). @@ -968,6 +988,40 @@ impl TimeAbiBackend for WhpPartitionInner { self.effective_record(state, &mut NativeCpuid::new(&self.vtl0.whp)) } + fn supported_cpu_surface(&self) -> Result, TimeAbiError> { + let started = std::time::Instant::now(); + let unsupported = |what: &str, err: whp::WHvError| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!("cannot query {what}: {err}"), + ) + }; + let banks = whp::capabilities::processor_features() + .map_err(|err| unsupported("the processor features", err))?; + let xsave = whp::capabilities::processor_xsave_features() + .map_err(|err| unsupported("the XSAVE features", err))?; + let physical_address_width = self + .vtl0 + .whp + .physical_address_width() + .map_err(|err| unsupported("the physical address width", err))?; + let surface = supported_surface( + &host_cpuid(), + WhpFeatures { + banks: [banks.bank0.0, banks.bank1.0], + xsave: xsave.0, + }, + physical_address_width.try_into().unwrap_or(u8::MAX), + ); + tracing::info!( + cpuid = surface.cpuid.len(), + physical_address_width = surface.physical_address_width, + elapsed_us = started.elapsed().as_micros() as u64, + "time ABI: WHP supported CPU surface" + ); + Ok(Some(surface)) + } + fn capture_anchor(&self) -> Result { self.time_abi_state()?; let bsp = self.vtl0.whp.vp(VpIndex::BSP.index()); From a1bafb6cbe7236da047c7685213a75579739649b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 15:01:04 -0700 Subject: [PATCH 217/304] virt_whp: use the shared Hyper-V feature table cpu_profile::hv_banks now holds the table that maps each WHP processor and XSAVE feature bit to the CPUID bit it controls, and the derivation of a partition's features from its CPU profile (0a604db84), shared with MSHV. Use them and drop virt_whp's copies: - profile_features() wraps hv_banks::profile_features and maps its error to E_PROFILE_UNSUPPORTED. - The supported CPU surface clears the CPUID bits of missing features with hv_banks::restrict_cpuid_to_features, as MSHV does. A bit that several features control is now cleared when any of them is missing. - The table's unit tests moved with it; the hardware tests use the shared table. The features a partition gets are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/profile_features.rs | 536 ++-------------------- 1 file changed, 50 insertions(+), 486 deletions(-) diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index 027127454a1..8fbaecf6f1f 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -1,31 +1,31 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! WHP processor features derived from a CPU profile. +//! WHP processor features derived from a CPU profile, and the CPU surface +//! that WHP supports. //! //! A WHP partition presents a CPU feature only when the feature's processor //! feature bank bit (or XSAVE feature bit) is set, and some of those bits also //! decide which MSRs the guest may use (for example `IA32_SPEC_CTRL`). -//! [`profile_features`] starts from the features WHP offers, clears every bit -//! whose CPUID feature the profile clears, and restricts the +//! [`profile_features`] derives the partition's features from its CPU profile +//! with the Hyper-V feature table that MSHV shares +//! ([`hv_banks::HV_FEATURES`]): it starts from the features WHP offers, clears +//! every bit whose CPUID feature the profile clears, and restricts the //! `IA32_ARCH_CAPABILITIES` bits that the banks derive to the profile's pinned -//! value. The partition then behaves as the profile's CPUID describes. -//! -//! Bits without a CPUID feature, such as the nested-paging details, keep WHP's -//! capability. Features that no bit controls (for example PKU), the -//! descriptive leaves (caches, the brand string), and the time policy bits a -//! host lacks (ARAT and invariant TSC on Azure) reach the guest through the -//! time ABI's CPUID results: the partition's complete effective CPUID, which +//! value. Features that no bit controls (for example PKU), the descriptive +//! leaves (caches, the brand string), and the time policy bits a host lacks +//! (ARAT and invariant TSC on Azure) reach the guest through the time ABI's +//! CPUID results: the partition's complete effective CPUID, which //! `configure` programs with `CpuidResultList2`. //! -//! The hardware test `features_control_the_mapped_cpuid_bits` checks the table -//! against what WHP presents on a host. +//! [`supported_surface`] reports what WHP supports without a probe partition. +//! The hardware tests check the table and the surface against what WHP +//! presents on a host. use cpu_profile::CpuProfile; -use cpu_profile::TIME_POLICY_BITS; use cpu_profile::cpuid::CpuidEntry; use cpu_profile::hv_banks; -use hvdef::HvX64PartitionProcessorFeatures as Bank0; +use cpu_profile::hv_banks::HvFeatures; use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; use virt::time_abi::TimeAbiCode; @@ -49,329 +49,18 @@ const X7: u32 = 0x8000_0007; const X8: u32 = 0x8000_0008; /// WHP processor features: feature banks 0 and 1, and the XSAVE features. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub(crate) struct WhpFeatures { - pub banks: [u64; 2], - pub xsave: u64, -} - -impl WhpFeatures { - fn word_mut(&mut self, bank: Bank) -> &mut u64 { - match bank { - Bank::Features0 => &mut self.banks[0], - Bank::Features1 => &mut self.banks[1], - Bank::Xsave => &mut self.xsave, - } - } - - fn word(&self, bank: Bank) -> u64 { - match bank { - Bank::Features0 => self.banks[0], - Bank::Features1 => self.banks[1], - Bank::Xsave => self.xsave, - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -enum Bank { - Features0, - Features1, - Xsave, -} - -#[cfg(test)] -impl Bank { - fn name(self) -> &'static str { - match self { - Bank::Features0 => "bank0", - Bank::Features1 => "bank1", - Bank::Xsave => "xsave", - } - } -} - -/// A CPUID feature bit: leaf, subleaf, register (EAX through EDX), and bit. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -struct CpuidBit { - leaf: u32, - subleaf: u32, - register: usize, - bit: u32, -} - -impl std::fmt::Display for CpuidBit { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let register = ["eax", "ebx", "ecx", "edx"][self.register]; - write!( - f, - "{:#x}.{}:{}[{}]", - self.leaf, self.subleaf, register, self.bit - ) - } -} - -const fn cpuid(leaf: u32, subleaf: u32, register: usize, bit: u32) -> CpuidBit { - CpuidBit { - leaf, - subleaf, - register, - bit, - } -} - -/// A WHP feature bit and the CPUID feature bit it controls. -struct Feature { - name: &'static str, - bank: Bank, - mask: u64, - cpuid: CpuidBit, -} - -impl Feature { - fn name(&self) -> &'static str { - self.name.trim_start_matches("with_") - } - - fn is_time_policy(&self) -> bool { - let bit = self.cpuid; - TIME_POLICY_BITS.contains(&(bit.leaf, bit.subleaf, bit.register, 1 << bit.bit)) - } -} - -macro_rules! feature { - ($bank:ident, $ty:ident, $with:ident, $leaf:expr, $subleaf:expr, $register:expr, $bit:expr) => { - Feature { - name: stringify!($with), - bank: Bank::$bank, - mask: $ty::new().$with(true).into_bits(), - cpuid: cpuid($leaf, $subleaf, $register, $bit), - } - }; -} - -macro_rules! bank0 { - ($with:ident, $($cpuid:expr),+) => { - feature!(Features0, Bank0, $with, $($cpuid),+) - }; -} - -macro_rules! bank1 { - ($with:ident, $($cpuid:expr),+) => { - feature!(Features1, Bank1, $with, $($cpuid),+) - }; -} +pub(crate) type WhpFeatures = HvFeatures; -macro_rules! xsave { - ($with:ident, $($cpuid:expr),+) => { - feature!(Xsave, XsaveBank, $with, $($cpuid),+) - }; -} - -/// The WHP feature bits with a CPUID feature, as leaf, subleaf, register, and -/// bit. The leaves follow the Intel SDM and the AMD APM. The bits that the -/// banks derive into `IA32_ARCH_CAPABILITIES` are [`hv_banks`]'s. -const FEATURES: &[Feature] = &[ - // Bank 0. - bank0!(with_sse3_support, 1, 0, ECX, 0), - bank0!(with_lahf_sahf_support, X1, 0, ECX, 0), - bank0!(with_ssse3_support, 1, 0, ECX, 9), - bank0!(with_sse4_1_support, 1, 0, ECX, 19), - bank0!(with_sse4_2_support, 1, 0, ECX, 20), - bank0!(with_sse4a_support, X1, 0, ECX, 6), - bank0!(with_xop_support, X1, 0, ECX, 11), - bank0!(with_pop_cnt_support, 1, 0, ECX, 23), - bank0!(with_cmpxchg16b_support, 1, 0, ECX, 13), - bank0!(with_altmovcr8_support, X1, 0, ECX, 4), - bank0!(with_lzcnt_support, X1, 0, ECX, 5), - bank0!(with_mis_align_sse_support, X1, 0, ECX, 7), - bank0!(with_mmx_ext_support, X1, 0, EDX, 22), - bank0!(with_amd3d_now_support, X1, 0, EDX, 31), - bank0!(with_extended_amd3d_now_support, X1, 0, EDX, 30), - bank0!(with_page_1gb_support, X1, 0, EDX, 26), - bank0!(with_aes_support, 1, 0, ECX, 25), - bank0!(with_pclmulqdq_support, 1, 0, ECX, 1), - bank0!(with_pcid_support, 1, 0, ECX, 17), - bank0!(with_fma4_support, X1, 0, ECX, 16), - bank0!(with_f16c_support, 1, 0, ECX, 29), - bank0!(with_rd_rand_support, 1, 0, ECX, 30), - bank0!(with_rd_wr_fs_gs_support, 7, 0, EBX, 0), - bank0!(with_smep_support, 7, 0, EBX, 7), - bank0!(with_enhanced_fast_string_support, 7, 0, EBX, 9), - bank0!(with_bmi1_support, 7, 0, EBX, 3), - bank0!(with_bmi2_support, 7, 0, EBX, 8), - bank0!(with_movbe_support, 1, 0, ECX, 22), - bank0!(with_dep_x87_fpu_save_support, 7, 0, EBX, 13), - bank0!(with_rd_seed_support, 7, 0, EBX, 18), - bank0!(with_adx_support, 7, 0, EBX, 19), - bank0!(with_intel_prefetch_support, X1, 0, ECX, 8), - bank0!(with_smap_support, 7, 0, EBX, 20), - bank0!(with_hle_support, 7, 0, EBX, 4), - bank0!(with_rtm_support, 7, 0, EBX, 11), - bank0!(with_rdtscp_support, X1, 0, EDX, 27), - bank0!(with_clflushopt_support, 7, 0, EBX, 23), - bank0!(with_clwb_support, 7, 0, EBX, 24), - bank0!(with_sha_support, 7, 0, EBX, 29), - bank0!(with_x87_pointers_saved_support, X8, 0, EBX, 2), - bank0!(with_invpcid_support, 7, 0, EBX, 10), - bank0!(with_ibrs_support, 7, 0, EDX, 26), - bank0!(with_stibp_support, 7, 0, EDX, 27), - // CPUID enumerates IBPB with IBRS; WHP derives the bit from IBRS. - bank0!(with_ibpb_support, 7, 0, EDX, 26), - bank0!(with_mdd_support, 7, 0, EDX, 31), - bank0!(with_fast_short_rep_mov_support, 7, 0, EDX, 4), - bank0!(with_l1d_cache_flush_support, 7, 0, EDX, 28), - bank0!(with_rd_pid_support, 7, 0, ECX, 22), - bank0!(with_umip_support, 7, 0, ECX, 2), - bank0!(with_mb_clear_support, 7, 0, EDX, 10), - // Bank 1. - bank1!(with_a_count_m_count_support, 6, 0, ECX, 0), - bank1!(with_tsc_invariant_support, X7, 0, EDX, 8), - bank1!(with_cl_zero_support, X8, 0, EBX, 0), - bank1!(with_rdpru_support, X8, 0, EBX, 4), - bank1!(with_la57_support, 7, 0, ECX, 16), - bank1!(with_nested_virt_support, 1, 0, ECX, 5), - bank1!(with_psfd_support, 7, 2, EDX, 0), - bank1!(with_cet_ss_support, 7, 0, ECX, 7), - bank1!(with_cet_ibt_support, 7, 0, EDX, 20), - bank1!(with_enqcmd_support, 7, 0, ECX, 29), - bank1!(with_umwait_tpause_support, 7, 0, ECX, 5), - bank1!(with_movdiri_support, 7, 0, ECX, 27), - bank1!(with_movdir64b_support, 7, 0, ECX, 28), - bank1!(with_cldemote_support, 7, 0, ECX, 25), - bank1!(with_serialize_support, 7, 0, EDX, 14), - bank1!(with_tsc_deadline_tmr_support, 1, 0, ECX, 24), - bank1!(with_tsc_adjust_support, 7, 0, EBX, 1), - bank1!(with_fz_l_rep_movsb, 7, 1, EAX, 10), - bank1!(with_fs_rep_stosb, 7, 1, EAX, 11), - bank1!(with_fs_rep_cmpsb, 7, 1, EAX, 12), - bank1!(with_tsx_ld_trk_support, 7, 0, EDX, 16), - bank1!(with_cmpccxadd_support, 7, 1, EAX, 7), - bank1!(with_bhi_dis_support, 7, 2, EDX, 4), - bank1!(with_prefetch_i_support, 7, 1, EDX, 14), - bank1!(with_sha512_support, 7, 1, EAX, 0), - bank1!(with_sm3_support, 7, 1, EAX, 1), - bank1!(with_sm4_support, 7, 1, EAX, 2), - bank1!(with_lass_support, 7, 1, EAX, 6), - // XSAVE features. - xsave!(with_xsave_support, 1, 0, ECX, 26), - xsave!(with_xsaveopt_support, 0xd, 1, EAX, 0), - xsave!(with_avx_support, 1, 0, ECX, 28), - xsave!(with_avx2_support, 7, 0, EBX, 5), - xsave!(with_fma_support, 1, 0, ECX, 12), - xsave!(with_mpx_support, 7, 0, EBX, 14), - xsave!(with_avx512_support, 7, 0, EBX, 16), - xsave!(with_avx512_dq_support, 7, 0, EBX, 17), - xsave!(with_avx512_cd_support, 7, 0, EBX, 28), - xsave!(with_avx512_bw_support, 7, 0, EBX, 30), - xsave!(with_avx512_vl_support, 7, 0, EBX, 31), - xsave!(with_xsave_comp_support, 0xd, 1, EAX, 1), - xsave!(with_xsave_supervisor_support, 0xd, 1, EAX, 3), - xsave!(with_xcr1_support, 0xd, 1, EAX, 2), - xsave!(with_avx512_bitalg_support, 7, 0, ECX, 12), - xsave!(with_avx512_ifma_support, 7, 0, EBX, 21), - xsave!(with_avx512_vbmi_support, 7, 0, ECX, 1), - xsave!(with_avx512_vbmi2_support, 7, 0, ECX, 6), - xsave!(with_avx512_vnni_support, 7, 0, ECX, 11), - xsave!(with_gfni_support, 7, 0, ECX, 8), - xsave!(with_vaes_support, 7, 0, ECX, 9), - xsave!(with_avx512_vpopcntdq_support, 7, 0, ECX, 14), - xsave!(with_vpclmulqdq_support, 7, 0, ECX, 10), - xsave!(with_avx512_bf16_support, 7, 1, EAX, 5), - xsave!(with_avx512_vp2_intersect_support, 7, 0, EDX, 8), - xsave!(with_avx512_fp16_support, 7, 0, EDX, 23), - xsave!(with_xfd_support, 0xd, 1, EAX, 4), - xsave!(with_amx_tile_support, 7, 0, EDX, 24), - xsave!(with_amx_bf16_support, 7, 0, EDX, 22), - xsave!(with_amx_int8_support, 7, 0, EDX, 25), - xsave!(with_avx_vnni_support, 7, 1, EAX, 4), - xsave!(with_avx_ifma_support, 7, 1, EAX, 23), - xsave!(with_avx_ne_convert_support, 7, 1, EDX, 5), - xsave!(with_avx_vnni_int8_support, 7, 1, EDX, 4), - xsave!(with_avx_vnni_int16_support, 7, 1, EDX, 10), - // CPUID enumerates AVX10 once; leaf 0x24 reports its vector lengths. - xsave!(with_avx10_1_256_support, 7, 1, EDX, 19), - xsave!(with_avx10_1_512_support, 7, 1, EDX, 19), - xsave!(with_amx_fp16_support, 7, 1, EAX, 21), -]; - -/// How a profile pins a CPUID feature bit. -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -enum Pinned { - Set, - Clear, - Unpinned, -} - -/// Returns how `profile` pins `bit`. A leaf the profile does not list reads -/// zero, pinned. -fn pinned(profile: &CpuProfile, bit: CpuidBit) -> Pinned { - let (value, mask) = profile - .cpuid() - .iter() - .find(|entry| { - let (leaf, subleaf) = entry.key(); - leaf == bit.leaf && subleaf.unwrap_or(0) == bit.subleaf - }) - .map_or((0, !0), |entry| { - (entry.values()[bit.register], entry.masks()[bit.register]) - }); - let flag = 1 << bit.bit; - if mask & flag == 0 { - Pinned::Unpinned - } else if value & flag != 0 { - Pinned::Set - } else { - Pinned::Clear - } -} - -/// Returns the WHP features for `profile`, given the features WHP offers. -/// -/// Every feature bit whose CPUID feature the profile clears is cleared, and -/// the `IA32_ARCH_CAPABILITIES` bits the banks derive are restricted to the -/// profile's pinned value. Fails with `E_PROFILE_UNSUPPORTED` when the profile -/// sets a CPUID feature whose bit WHP does not offer (except the time policy -/// bits, which the CPUID results supply), or when the banks cannot present -/// the profile's `IA32_ARCH_CAPABILITIES`. +/// Returns the WHP features of a partition that presents `profile`, given +/// the features WHP offers ([`hv_banks::profile_features`]). Fails with +/// `E_PROFILE_UNSUPPORTED` when WHP cannot present the profile's features +/// or its `IA32_ARCH_CAPABILITIES`. pub(crate) fn profile_features( profile: &CpuProfile, available: WhpFeatures, ) -> Result { - let mut features = available; - let mut missing = Vec::new(); - for feature in FEATURES { - let word = features.word_mut(feature.bank); - match pinned(profile, feature.cpuid) { - Pinned::Clear => *word &= !feature.mask, - Pinned::Set if *word & feature.mask == 0 && !feature.is_time_policy() => { - missing.push(feature.name().to_owned()); - } - Pinned::Set | Pinned::Unpinned => {} - } - } - hv_banks::restrict_banks_to_profile(profile, &mut features.banks); - if let Some((value, mask)) = profile.msr(MSR_ARCH_CAPABILITIES) { - let presented = hv_banks::arch_capabilities_from_banks(features.banks); - if presented & mask != value & mask { - missing.push(format!( - "IA32_ARCH_CAPABILITIES {:#x} (the banks present {:#x} under mask {mask:#x})", - value & mask, - presented & mask - )); - } - } - if !missing.is_empty() { - return Err(TimeAbiError::new( - TimeAbiCode::ProfileUnsupported, - format!( - "WHP cannot present CPU profile {}: it lacks {}", - profile.id(), - missing.join(", ") - ), - )); - } - Ok(features) + hv_banks::profile_features(profile, available) + .map_err(|err| TimeAbiError::new(TimeAbiCode::ProfileUnsupported, err.message)) } /// Returns whether a partition with `available` features supports XSAVE @@ -402,8 +91,8 @@ fn xsave_component_enabled(component: u32, available: WhpFeatures) -> bool { /// feature in `available`, without a probe partition: `host`, the host's /// CPUID as the root partition sees it, with /// -/// - every CPUID feature bit that a WHP feature controls ([`FEATURES`]) set -/// exactly where `available` offers one of its features; +/// - every CPUID feature bit that a WHP feature controls cleared where +/// `available` lacks the feature ([`hv_banks::restrict_cpuid_to_features`]); /// - only the XSAVE state components that `available` enables, in leaf /// `0xD` (subleaves 0 and 1, and the component subleaves, whose layout is /// the host's); @@ -420,24 +109,18 @@ pub(crate) fn supported_surface( available: WhpFeatures, physical_address_width: u8, ) -> SupportedCpuSurface { - let mut controlled = std::collections::BTreeMap::<(u32, u32, usize), [u32; 2]>::new(); - for feature in FEATURES { - let bit = feature.cpuid; - let masks = controlled - .entry((bit.leaf, bit.subleaf, bit.register)) - .or_default(); - masks[0] |= 1 << bit.bit; - if available.word(feature.bank) & feature.mask != 0 { - masks[1] |= 1 << bit.bit; - } - } let components = (0..64) .filter(|&component| xsave_component_enabled(component, available)) .fold(0u64, |mask, component| mask | 1 << component); + let mut host: Vec = host + .iter() + .filter(|entry| !(0x4000_0000..=0x4fff_ffff).contains(&entry.leaf.0)) + .cloned() + .collect(); + hv_banks::restrict_cpuid_to_features(&mut host, available); let cpuid = host .iter() - .filter(|entry| !(0x4000_0000..=0x4fff_ffff).contains(&entry.leaf.0)) .filter_map(|entry| { let (leaf, subleaf) = entry.key(); let index = subleaf.unwrap_or(0); @@ -445,11 +128,6 @@ pub(crate) fn supported_surface( return None; } let mut registers = entry.registers(); - for (register, value) in registers.iter_mut().enumerate() { - if let Some(&[mask, offered]) = controlled.get(&(leaf, index, register)) { - *value = (*value & !mask) | offered; - } - } match (leaf, index) { (0xd, 0) => { registers[EAX] &= components as u32; @@ -487,11 +165,11 @@ pub(crate) fn supported_surface( #[cfg(test)] mod tests { use super::*; + use hvdef::HvX64PartitionProcessorFeatures as Bank0; /// Feature capabilities that WHP reported on the fleet's hosts (CPU - /// fingerprints): prometheus28 (Skylake-SP, bare metal), the 8370C - /// runners (Ice Lake, nested), and the 8573C runners (Emerald Rapids, - /// nested). + /// fingerprints): prometheus28 (Skylake-SP, bare metal) and the 8370C + /// runners (Ice Lake, nested). const PROMETHEUS28: WhpFeatures = WhpFeatures { banks: [0x1001_f9ff_e7f7_859f, 0x0000_000f_1086_0063], xsave: 0x3fff, @@ -500,135 +178,17 @@ mod tests { banks: [0x2e0a_8bff_e7f7_859f, 0x0001_000e_0000_00f1], xsave: 0x7f_ffdf, }; - const AZURE_8573C: WhpFeatures = WhpFeatures { - banks: [0x0e0a_8bff_e7f7_859f, 0x0000_000c_0000_0051], - xsave: 0x7f_ffdf, - }; - - const HOSTS: [(&str, WhpFeatures); 3] = [ - ("intel.skylake-sp.v1", PROMETHEUS28), - ("intel.icelake-sp.v1", AZURE_8370C), - ("intel.emeraldrapids.v1", AZURE_8573C), - ]; - - fn profile(id: &str) -> &'static CpuProfile { - cpu_profile::pinned(id).unwrap_or_else(|| panic!("profile {id} is not pinned")) - } - - fn has(features: &WhpFeatures, bank: Bank, mask: u64) -> bool { - features.word(bank) & mask != 0 - } #[test] - fn every_feature_is_one_distinct_bit() { - let mut seen = Vec::new(); - for feature in FEATURES { - assert_eq!(feature.mask.count_ones(), 1, "{}", feature.name()); - assert!( - !seen.contains(&(feature.bank, feature.mask)), - "{} repeats a bit", - feature.name() - ); - seen.push((feature.bank, feature.mask)); - } - } - - #[test] - fn pinned_profiles_derive_on_their_hosts() { - for (id, available) in HOSTS { - let profile = profile(id); - let features = profile_features(profile, available).unwrap(); - for bank in [Bank::Features0, Bank::Features1, Bank::Xsave] { - assert_eq!( - features.word(bank) & !available.word(bank), - 0, - "{id}: {} gained bits", - bank.name() - ); - } - // Every mapped bit follows the profile's CPUID. - for feature in FEATURES { - let offered = has(&available, feature.bank, feature.mask); - let kept = has(&features, feature.bank, feature.mask); - match pinned(profile, feature.cpuid) { - Pinned::Clear => assert!(!kept, "{id}: {}", feature.name()), - Pinned::Set | Pinned::Unpinned => { - assert_eq!(kept, offered, "{id}: {}", feature.name()) - } - } - } - // The time policy: no TSC-deadline, TSC_ADJUST, or APERF/MPERF. - for mask in [ - Bank1::new().with_tsc_deadline_tmr_support(true).into_bits(), - Bank1::new().with_tsc_adjust_support(true).into_bits(), - Bank1::new().with_a_count_m_count_support(true).into_bits(), - ] { - assert!(!has(&features, Bank::Features1, mask), "{id}: {mask:#x}"); - } - let (value, mask) = profile.msr(MSR_ARCH_CAPABILITIES).unwrap(); - assert_eq!( - hv_banks::arch_capabilities_from_banks(features.banks) & mask, - value & mask, - "{id}" - ); - } - } - - #[test] - fn skylake_keeps_the_speculation_controls() { - // The Skylake-SP profile pins SPEC_CTRL, STIBP, SSBD, and MD_CLEAR - // (CPUID 7.0 EDX 0xac000400), which WHP's default features omit. The - // Azure profiles pin none of them (EDX 0x20000010), as Azure's WHP - // offers none. - let features = profile_features(profile("intel.skylake-sp.v1"), PROMETHEUS28).unwrap(); - for mask in [ - Bank0::new().with_ibrs_support(true).into_bits(), - Bank0::new().with_ibpb_support(true).into_bits(), - Bank0::new().with_stibp_support(true).into_bits(), - Bank0::new().with_mdd_support(true).into_bits(), - Bank0::new().with_mb_clear_support(true).into_bits(), - ] { - assert!(has(&features, Bank::Features0, mask), "{mask:#x}"); - } - } - - #[test] - fn invariant_tsc_follows_the_host_and_the_cpuid_results_supply_it() { - let invariant = Bank1::new().with_tsc_invariant_support(true).into_bits(); - let skylake = profile_features(profile("intel.skylake-sp.v1"), PROMETHEUS28).unwrap(); - assert!(has(&skylake, Bank::Features1, invariant)); - // Azure's WHP offers no invariant TSC; the profile still pins it, and - // the derivation leaves it to the CPUID results. - let icelake = profile_features(profile("intel.icelake-sp.v1"), AZURE_8370C).unwrap(); - assert!(!has(&icelake, Bank::Features1, invariant)); - } - - #[test] - fn a_missing_feature_is_unsupported() { - let mut available = PROMETHEUS28; - available.xsave &= !XsaveBank::new().with_avx512_support(true).into_bits(); - available.banks[0] &= !Bank0::new().with_ibrs_support(true).into_bits(); - let error = profile_features(profile("intel.skylake-sp.v1"), available).unwrap_err(); + fn profiles_derive_and_shortfalls_are_unsupported() { + let skylake = cpu_profile::pinned("intel.skylake-sp.v1").unwrap(); + profile_features(skylake, PROMETHEUS28).unwrap(); + let emerald = cpu_profile::pinned("intel.emeraldrapids.v1").unwrap(); + let error = profile_features(emerald, PROMETHEUS28).unwrap_err(); assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); - assert!(error.message.contains("avx512_support"), "{error}"); - assert!(error.message.contains("ibrs_support"), "{error}"); + assert!(error.message.contains("avx512"), "{error}"); } - #[test] - fn a_newer_profile_is_unsupported_on_an_older_host() { - let error = profile_features(profile("intel.emeraldrapids.v1"), PROMETHEUS28).unwrap_err(); - assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); - } - - #[test] - fn unlisted_leaves_read_zero_and_unmasked_bits_are_unpinned() { - let skylake = profile("intel.skylake-sp.v1"); - // Skylake-SP lists no leaf 7.1, so its features are pinned clear. - assert_eq!(pinned(skylake, cpuid(7, 1, EAX, 5)), Pinned::Clear); - // OSXSAVE is guest state: the profile leaves it unpinned. - assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 27)), Pinned::Unpinned); - assert_eq!(pinned(skylake, cpuid(1, 0, ECX, 26)), Pinned::Set); - } #[test] fn the_supported_surface_follows_the_capability() { // A host that reports every feature, AVX-512 and AMX state, and a @@ -690,6 +250,10 @@ mod whp_tests { use super::*; use cpu_profile::HostCpuSignature; + use cpu_profile::hv_banks::CpuidBit; + use cpu_profile::hv_banks::HV_FEATURES; + use cpu_profile::hv_banks::HvFeature; + use cpu_profile::hv_banks::HvFeatureWord; use whp::abi::WHV_CPUID_OUTPUT; use whp::abi::WHV_PROCESSOR_FEATURES; use whp::abi::WHV_PROCESSOR_FEATURES1; @@ -788,7 +352,7 @@ mod whp_tests { let lost = base[index][register] & !probe[index][register]; for bit in 0..32 { if lost & (1 << bit) != 0 { - bits.push(cpuid(leaf, subleaf, register, bit)); + bits.push(CpuidBit::new(leaf, subleaf, register, bit)); } } } @@ -798,8 +362,8 @@ mod whp_tests { /// Whether another feature maps the same CPUID bit (as IBRS and IBPB /// do): WHP may derive the bit from either. - fn shared(feature: &Feature) -> bool { - FEATURES + fn shared(feature: &HvFeature) -> bool { + HV_FEATURES .iter() .any(|other| !std::ptr::eq(other, feature) && other.cpuid == feature.cpuid) } @@ -827,13 +391,13 @@ mod whp_tests { available.banks[0], available.banks[1], available.xsave ); let mut failures = Vec::new(); - for bank in [Bank::Features0, Bank::Features1, Bank::Xsave] { + for bank in HvFeatureWord::ALL { let word = available.word(bank); for bit in (0..64).filter(|bit| word & (1 << bit) != 0) { let mask = 1u64 << bit; - let feature = FEATURES + let feature = HV_FEATURES .iter() - .find(|feature| feature.bank == bank && feature.mask == mask); + .find(|feature| feature.word == bank && feature.mask == mask); let name = feature.map_or("(unmapped)", |feature| feature.name()); let mut features = available; *features.word_mut(bank) &= !mask; @@ -912,12 +476,12 @@ mod whp_tests { } let bits: Vec<_> = (0..32) .filter(|bit| differs & (1 << bit) != 0) - .map(|bit| cpuid(leaf, subleaf, register, bit)) + .map(|bit| CpuidBit::new(leaf, subleaf, register, bit)) .collect(); let mapped: Vec<_> = bits .iter() .filter(|&&bit| { - FEATURES + HV_FEATURES .iter() .any(|feature| feature.cpuid == bit && !feature.is_time_policy()) }) From ebef8ce5f8a95969e5fe46c5cff5022442487082 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 15:26:07 -0700 Subject: [PATCH 218/304] openvmm_core: drop the time ABI restore's separate LAPIC timer pass The LAPIC advance rejects a periodic or TSC-deadline timer with the same codes as the timer check, before any VP runs. The restore's separate check cost one round trip to every VP (0.13 to 0.36 ms per restore in a debug build on MSHV), so the restore clock drops it. Capture keeps its check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/worker/dispatch/time_abi.rs | 17 +++++++---------- vmm_core/src/partition_unit/vp_set/time_abi.rs | 9 ++++++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index dfe41d1a62f..f4fd31b863e 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -427,14 +427,15 @@ pub(super) fn capture_records( } impl LoadedVm { - /// Restores the guest clocks: restore steps 11 to 16 of the + /// Restores the guest clocks: restore steps 12 to 16 of the /// specification, after the saved state is restored and before any /// restored VP runs. /// - /// It checks the LAPIC timers, performs the synchronized TSC set at the - /// downtime selected at the restore anchor, advances and sets every - /// LAPIC, advances VM time and the RTC, and seals the time fields of the - /// restore packet. The PIT checks itself when restored. + /// It performs the synchronized TSC set at the downtime selected at the + /// restore anchor, advances and sets every LAPIC, advances VM time and + /// the RTC, and seals the time fields of the restore packet. The LAPIC + /// advance rejects a periodic or TSC-deadline timer, so restore needs no + /// separate timer check, and the PIT checks itself when restored. pub(super) async fn time_abi_restore(&mut self, input: RestoreTimeInput) -> anyhow::Result<()> { let state = self .inner @@ -452,9 +453,6 @@ impl LoadedVm { let apic_hz = input.contract.apic_frequency_hz; let started = std::time::Instant::now(); - self.inner.partition_unit.check_one_shot_timers().await?; - let timers_checked = started.elapsed(); - let partition = self.inner.partition.clone(); let mut downtime = None; let set = @@ -501,8 +499,7 @@ impl LoadedVm { // Per-phase durations attribute the restore.time_abi_clock profile // phase. tracing::info!( - timers_us = timers_checked.as_micros() as u64, - tsc_set_us = (tsc_set - timers_checked).as_micros() as u64, + tsc_set_us = tsc_set.as_micros() as u64, lapic_us = (lapic_advanced - tsc_set).as_micros() as u64, vm_time_us = (finished - lapic_advanced).as_micros() as u64, total_us = finished.as_micros() as u64, diff --git a/vmm_core/src/partition_unit/vp_set/time_abi.rs b/vmm_core/src/partition_unit/vp_set/time_abi.rs index cfd9f9a1323..19989453748 100644 --- a/vmm_core/src/partition_unit/vp_set/time_abi.rs +++ b/vmm_core/src/partition_unit/vp_set/time_abi.rs @@ -51,8 +51,9 @@ impl LapicAdvance { } impl VpSet { - /// Checks the LAPIC timer of every stopped VP: restore step 11 and - /// capture step 1 of the time ABI. + /// Checks the LAPIC timer of every stopped VP: capture step 1 of the time + /// ABI. Restore needs no separate check, because the LAPIC advance + /// rejects the same timers. pub async fn check_one_shot_timers(&mut self) -> anyhow::Result<()> { anyhow::ensure!( !self.started, @@ -74,7 +75,9 @@ impl VpSet { /// Advances the one-shot LAPIC timer of every stopped VP by `downtime_ns` /// at `apic_hz` and sets every VP's LAPIC state again: restore step 14 of - /// the time ABI, after the synchronized TSC set. + /// the time ABI, after the synchronized TSC set. A periodic or + /// TSC-deadline timer fails it (`E_LAPIC_PERIODIC`, + /// `E_LAPIC_TSC_DEADLINE`) before any VP runs. pub async fn advance_lapic_timers( &mut self, downtime_ns: u64, From b2452ca826dc170cc5578d0804d04f384a39800f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 15:47:14 -0700 Subject: [PATCH 219/304] virt_mshv: characterize how MSHV composes CPUID results Move the registration of a CPUID intercept result into register_cpuid_result, and add an ignored hardware test that records how MSHV composes the results, on leaf 4: - A subleaf result applies over the hypervisor's own value, so the bits it leaves unmasked stay native (the per-VP APIC IDs and the runtime XSAVE sizes rely on this), and the subleaves it does not name stay native. - A whole-leaf result answers every subleaf. - The hypervisor refuses a whole-leaf result and a subleaf result for the same leaf, whichever comes second. So on MSHV, a table's unlisted subleaves cannot read zero through one whole-leaf zero result under the listed ones; each would need its own result. Every architecturally reachable subleaf is listed, the terminators included. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/mod.rs | 75 ++++++++++--------- .../virt_mshv/src/x86_64/profile_features.rs | 48 ++++++++++++ 2 files changed, 89 insertions(+), 34 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 7e3855e1104..eea04d338f2 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -295,6 +295,46 @@ fn partition_create_args( }) } +/// Registers `leaf` as the partition-wide result of CPUID intercepts for its +/// leaf (and subleaf, if it has one), applied over the hypervisor's own result +/// under the leaf's mask. +fn register_cpuid_result(vmfd: &mshv_ioctls::VmFd, leaf: &virt::CpuidLeaf) -> Result<(), Error> { + let input = hvdef::hypercall::RegisterInterceptResultCpuid { + partition_id: 0, + vp_index: hvdef::HV_ANY_VP, + intercept_type: hvdef::hypercall::HvInterceptType::HvInterceptTypeX64Cpuid, + parameters: hvdef::hypercall::HvRegisterX64CpuidResultParameters { + input: hvdef::hypercall::HvRegisterX64CpuidResultParametersInput { + eax: leaf.function, + ecx: leaf.index.unwrap_or(0), + subleaf_specific: u8::from(leaf.index.is_some()), + always_override: 1, + padding: 0, + }, + result: hvdef::hypercall::HvRegisterX64CpuidResultParametersOutput { + eax: leaf.result[0], + eax_mask: leaf.mask[0], + ebx: leaf.result[1], + ebx_mask: leaf.mask[1], + ecx: leaf.result[2], + ecx_mask: leaf.mask[2], + edx: leaf.result[3], + edx_mask: leaf.mask[3], + }, + }, + _reserved: 0, + }; + let mut args = mshv_bindings::mshv_root_hvcall { + code: hvdef::HypercallCode::HvCallRegisterInterceptResult.0, + in_sz: size_of_val(&input) as u16, + in_ptr: std::ptr::addr_of!(input) as u64, + ..Default::default() + }; + vmfd.hvcall(&mut args) + .map_err(|e| ErrorInner::RegisterCpuid(e.into()))?; + Ok(()) +} + fn snp_synthetic_features() -> hvdef::HvPartitionSyntheticProcessorFeatures { hvdef::HvPartitionSyntheticProcessorFeatures::new() .with_hypervisor_present(true) @@ -512,40 +552,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { // Apply CPUID overrides partition-wide. let started = std::time::Instant::now(); for leaf in cpuid.leaves().iter() { - let input = hvdef::hypercall::RegisterInterceptResultCpuid { - partition_id: 0, - vp_index: hvdef::HV_ANY_VP, - intercept_type: hvdef::hypercall::HvInterceptType::HvInterceptTypeX64Cpuid, - parameters: hvdef::hypercall::HvRegisterX64CpuidResultParameters { - input: hvdef::hypercall::HvRegisterX64CpuidResultParametersInput { - eax: leaf.function, - ecx: leaf.index.unwrap_or(0), - subleaf_specific: u8::from(leaf.index.is_some()), - always_override: 1, - padding: 0, - }, - result: hvdef::hypercall::HvRegisterX64CpuidResultParametersOutput { - eax: leaf.result[0], - eax_mask: leaf.mask[0], - ebx: leaf.result[1], - ebx_mask: leaf.mask[1], - ecx: leaf.result[2], - ecx_mask: leaf.mask[2], - edx: leaf.result[3], - edx_mask: leaf.mask[3], - }, - }, - _reserved: 0, - }; - let mut args = mshv_bindings::mshv_root_hvcall { - code: hvdef::HypercallCode::HvCallRegisterInterceptResult.0, - in_sz: size_of_val(&input) as u16, - in_ptr: std::ptr::addr_of!(input) as u64, - ..Default::default() - }; - self.vmfd - .hvcall(&mut args) - .map_err(|e| ErrorInner::RegisterCpuid(e.into()))?; + register_cpuid_result(&self.vmfd, leaf)?; } tracing::info!( leaves = cpuid.leaves().len(), diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index a2802167342..70ea15327a9 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -666,6 +666,54 @@ mod hw { assert!(failures.is_empty(), "{failures:#?}"); } + /// Characterizes how MSHV composes CPUID results, using leaf 4, whose + /// subleaves differ natively: + /// + /// - A subleaf result applies over the hypervisor's own value, so the bits + /// it leaves unmasked stay native (the per-VP APIC IDs and the runtime + /// XSAVE sizes rely on this), and the subleaves it does not name stay + /// native. + /// - A whole-leaf result answers every subleaf. + /// - The hypervisor refuses a whole-leaf result and a subleaf result for + /// the same leaf, whichever comes second. So unlisted subleaves cannot + /// read zero through one whole-leaf result under the listed ones. + #[test] + #[ignore = "requires /dev/mshv"] + fn cpuid_results_compose_over_native_values() { + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + let read = |probe: &Probe| -> Vec<[u32; 4]> { + (0..4) + .map(|subleaf| probe.vp.get_cpuid_values(4, subleaf, 0, 0).unwrap()) + .collect() + }; + let native = read(&probe_partition(&mshv, host).unwrap()); + println!("native leaf 4: {native:#010x?}"); + assert_ne!(native[1], [0; 4], "leaf 4 has no native subleaf 1"); + let whole = CpuidLeaf::new(4, [0; 4]); + let pinned = [0x1111_1111, 0x2222_2222, 0x3333_3333]; + // Subleaf 0 pins EBX through EDX and leaves EAX to the hypervisor. + let listed = CpuidLeaf::new(4, [0, pinned[0], pinned[1], pinned[2]]) + .indexed(0) + .masked([0, !0, !0, !0]); + + let probe = probe_partition(&mshv, host).unwrap(); + super::super::register_cpuid_result(&probe.vmfd, &listed).unwrap(); + let refused = super::super::register_cpuid_result(&probe.vmfd, &whole).unwrap_err(); + let leaf4 = read(&probe); + println!("subleaf first: {leaf4:#010x?}; whole leaf refused: {refused}"); + assert_eq!(leaf4[0][1..], pinned); + assert_eq!(leaf4[0][0], native[0][0], "unmasked bits stay native"); + assert_eq!(leaf4[1..], native[1..], "unnamed subleaves stay native"); + + let probe = probe_partition(&mshv, host).unwrap(); + super::super::register_cpuid_result(&probe.vmfd, &whole).unwrap(); + let refused = super::super::register_cpuid_result(&probe.vmfd, &listed).unwrap_err(); + let leaf4 = read(&probe); + println!("whole leaf first: {leaf4:#010x?}; subleaf refused: {refused}"); + assert!(leaf4.iter().all(|subleaf| *subleaf == [0; 4])); + } + /// Creates a partition with this host's profile features and no CPUID /// results, and checks that the banks alone present every mapped feature /// as the profile pins it. The CPUID results supply the rest. From 9c8bc2ecda8e65d5c906d300ce2e90e8179a5b7c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 15:56:22 -0700 Subject: [PATCH 220/304] virt_whp: check that unlisted CPUID subleaves read zero A CPU profile's contract is that a leaf or subleaf its table does not list reads zero, while WHP serves its own value for anything that CpuidResultList2 does not program. Add a hardware test that sweeps subleaves 0 to 63 of every indexed leaf of the effective CPUID, and the four leaves past each maximum leaf, as VP 0 observes them. All 520 probes on prometheus28 (Skylake-SP) and all 648 on 8370C (Ice Lake-SP) and 8573C (Emerald Rapids) match: WHP already reads zero there, so the backend programs no explicit zero results. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/time_abi.rs | 82 +++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 087e06fee8a..b60f1195b72 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -2265,6 +2265,88 @@ mod whp_tests { ); } + /// Sweeps subleaves 0 to 63 of every indexed leaf of the effective CPUID, + /// and the four leaves past the maximum basic and extended leaves, and + /// checks that VP 0 observes the effective CPUID there. By the profile's + /// contract a subleaf or leaf that the table does not list reads zero, + /// while WHP serves its own value for anything not programmed: WHP reads + /// zero there on prometheus28, 8370C, and 8573C, so the backend programs + /// no explicit zero results. Listed results must match under their masks, + /// as `host_profile_configures_the_partition` checks too. + #[test] + #[ignore = "requires WHP"] + fn unlisted_subleaves_read_zero() { + let Some(Partition { + partition, + time_abi, + topology, + effective, + }) = partition(1) + else { + return; + }; + let own = test_cpuid::own_cpuid(&topology, &|function, index| { + native(&partition, function, index).unwrap() + }); + let cpuid = time_abi.partition_cpuid(own).unwrap(); + let cpuid_topology = CpuidTopology::new(&topology); + let vp0 = VpCpuid { + cpuid: &cpuid, + exits: &time_abi.cpuid_exits, + topology: &cpuid_topology, + apic_id: 0, + }; + let mut indexed: Vec = effective + .results() + .filter(|result| result.index.is_some()) + .map(|result| result.function) + .collect(); + indexed.dedup(); + let max_basic = effective.lookup(0, 0)[0]; + let max_extended = effective.lookup(0x8000_0000, 0)[0]; + let probes = indexed + .iter() + .flat_map(|&function| (0..64).map(move |index| (function, index))) + .chain((max_basic + 1..=max_basic + 4).map(|function| (function, 0))) + .chain((max_extended + 1..=max_extended + 4).map(|function| (function, 0))); + let (mut count, mut listed_failures, mut unlisted) = (0, Vec::new(), Vec::new()); + for (function, index) in probes { + count += 1; + let listed = effective.results().find(|result| { + result.function == function && result.index.is_none_or(|i| i == index) + }); + let (expected, mask) = + listed.map_or(([0; 4], [!0; 4]), |result| (result.result, result.mask)); + let actual = normalize_cpuid( + function, + index, + vp0.result(function, index, || native(&partition, function, index)) + .unwrap(), + ); + if (0..4).all(|register| (actual[register] ^ expected[register]) & mask[register] == 0) + { + continue; + } + let line = format!("{function:#x}.{index}: {actual:08x?}"); + if listed.is_some() { + listed_failures.push(format!("{line} expected {expected:08x?} mask {mask:08x?}")); + } else { + unlisted.push(line); + } + } + println!( + "{}: {count} probes of {} indexed leaves (max basic {max_basic:#x}, max extended {max_extended:#x}); {} unlisted results are not zero:", + time_abi.cpu_profile, + indexed.len(), + unlisted.len() + ); + for line in &unlisted { + println!(" {line}"); + } + assert!(listed_failures.is_empty(), "{listed_failures:#?}"); + assert!(unlisted.is_empty(), "{unlisted:#?}"); + } + #[test] #[ignore = "requires WHP"] fn time_suspension_is_idempotent() { From 3426760b768e4c9008684b82a04d364f1d57c5c2 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 15:53:10 -0700 Subject: [PATCH 221/304] cpu_profile: check the guest physical address width once A support check compared the guest physical address width twice: as a limit on the surface's CPUID 0x80000008 EAX[7:0], and against the surface's physical_address_width. The two agree for a probe partition, but a cheap surface built from the host's own CPUID carries the host's width, and a hypervisor may give guests another one: WHP gives guests its partition's 46 bits on a host whose CPUID reports 42. Such a surface could fail a profile that the backend supports. Check the width only against physical_address_width, which the backend takes from the hypervisor, and keep the linear address width as a CPUID limit. Also record what the root views of MSHV and WHP over-claim in restrict_cpuid_to_features's documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/cpu_profile/src/hv_banks.rs | 12 ++++++---- vmm_core/cpu_profile/src/surface.rs | 33 ++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/vmm_core/cpu_profile/src/hv_banks.rs b/vmm_core/cpu_profile/src/hv_banks.rs index 9d014804c67..5746906bd9b 100644 --- a/vmm_core/cpu_profile/src/hv_banks.rs +++ b/vmm_core/cpu_profile/src/hv_banks.rs @@ -542,10 +542,14 @@ pub fn profile_features( /// The function only clears, so the host's own view bounds the result. /// MSHV's root CPUID hides `TSC_ADJUST` (`7.0:EBX[1]`), which a partition with /// `tsc_adjust_support` presents, so that bit is under-reported; every profile -/// pins it clear. Root-only bits the table does not map, such as MONITOR, DS, -/// TM, the PMU, PT, and the topology fields, stay set. The derivation policy -/// clears them in every profile, so [`verify_support`](crate::verify_support) -/// never relies on them. +/// pins it clear. The root views of MSHV and WHP also show what neither gives +/// a guest. Those over-claims stay set: MONITOR, VMX, EST, TM and TM2, PDCM, +/// DS, ACPI, HTT, PT, TME, the hybrid bit, the topology fields, and leaves 5, +/// 6, `0xA`, `0x14`, and `0x15`. They are harmless to +/// [`verify_support`](crate::verify_support): no profile sets them, except +/// ARAT in leaf 6, a time policy bit that support checks exempt. +/// The surface's guest physical address width must come from the hypervisor, +/// not from this CPUID (see [`HostCpuSurface`](crate::HostCpuSurface)). pub fn restrict_cpuid_to_features(cpuid: &mut [CpuidEntry], available: HvFeatures) { for feature in HV_FEATURES { if available.word(feature.word) & feature.mask != 0 { diff --git a/vmm_core/cpu_profile/src/surface.rs b/vmm_core/cpu_profile/src/surface.rs index 67f9ecc3050..eb353fb2134 100644 --- a/vmm_core/cpu_profile/src/surface.rs +++ b/vmm_core/cpu_profile/src/surface.rs @@ -27,6 +27,11 @@ pub struct HostCpuSurface { /// every available processor and XSAVE feature enabled. pub cpuid: Vec, /// The widest guest physical address the backend supports, in bits. + /// + /// Support checks use this rather than the CPUID's `0x80000008:EAX[7:0]`: + /// a surface built from the host's own CPUID carries the host's width, + /// and a hypervisor may give guests another one. WHP, for one, reports + /// its partition property, 46 bits on a host whose CPUID reports 42. pub physical_address_width: u8, /// The MSR values the backend can present to a guest. pub msrs: Vec, @@ -167,6 +172,12 @@ pub fn support_violations(profile: &CpuProfile, surface: &HostCpuSurface) -> Vec } RegisterClass::Limits(fields) => { for &(shift, width) in fields { + // The guest physical address width is checked against + // `surface.physical_address_width` below. The surface's + // CPUID may hold the host's width instead. + if (leaf, register, shift) == (EXTENDED_LEAF_BASE + 8, 0, 0) { + continue; + } let field = |value: u32| field_value(value, shift, width); if field(pinned) > field(host[register]) { violations.push(format!( @@ -347,10 +358,24 @@ mod tests { let violations = support_violations(profile, &surface); assert_eq!( violations, - [ - "CPUID 0x80000008 EAX[7:0] is 0x2e, above the supported 0x27", - "the guest physical address width 46 exceeds the supported 39", - ] + ["the guest physical address width 46 exceeds the supported 39"] + ); + + // The surface's own field decides the physical address width, not its + // CPUID: a cheap surface from the host's CPUID may report the host's + // narrower width. + let mut surface = self::surface(profile); + edit(&mut surface, 0x8000_0008, 0, 0, |eax| (eax & !0xff) | 42); + verify_support(profile, &surface).unwrap(); + + // The linear address width is still a CPUID limit. + let mut surface = self::surface(profile); + edit(&mut surface, 0x8000_0008, 0, 0, |eax| { + (eax & !0xff00) | 48 << 8 + }); + assert_eq!( + support_violations(profile, &surface), + ["CPUID 0x80000008 EAX[15:8] is 0x39, above the supported 0x30"] ); let mut surface = self::surface(profile); From 1b6cef364333039bda9ede150de9547f3f2df487 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 15:48:47 -0700 Subject: [PATCH 222/304] virt: verify the CPU profile's support at every time ABI boot Every backend now reports a cheap supported CPU surface, so core checks the CPU profile against it at every cold boot and restore, as the time ABI specification requires. TimeAbiBackend::supported_cpu_surface has no default; a backend that reports no surface fails the boot with E_PROFILE_UNSUPPORTED instead of deferring the check to verification mode. Verification mode no longer fingerprints the backend after the worker exits: the worker's boot check covers it without a probe partition. Delete the interim CPU profile helpers, which nothing uses any more. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/worker/dispatch/time_abi.rs | 30 ++++++++++--------- openvmm/openvmm_entry/src/microvm/verify.rs | 25 ---------------- vmm_core/virt/src/time_abi/backend.rs | 23 +++++++------- vmm_core/virt/src/time_abi/surface.rs | 29 ++---------------- 4 files changed, 31 insertions(+), 76 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index f4fd31b863e..d5a6824061a 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -333,25 +333,27 @@ fn cpuid_differences(presented: &CpuidLeafSet, effective: &EffectiveCpuid) -> Ve .collect() } -/// Checks that the backend supports the CPU profile (`E_PROFILE_UNSUPPORTED`) -/// when it reports its CPU surface, and returns whether it did. Until every -/// backend reports one, verification mode checks support from the backend's -/// CPU fingerprint instead. +/// Checks that the backend supports the CPU profile, from the CPU surface it +/// reports (`E_PROFILE_UNSUPPORTED`), at every cold boot and restore. A +/// backend that reports no surface fails the check. pub(super) fn check_profile_support( partition: &dyn HvlitePartition, hypervisor: &str, profile: &CpuProfile, -) -> anyhow::Result { - let Some(surface) = backend(partition, hypervisor)?.supported_cpu_surface()? else { - tracing::info!( - hypervisor, - cpu_profile = profile.id(), - "the backend reports no CPU surface; profile support is checked in verification mode" - ); - return Ok(false); - }; +) -> anyhow::Result<()> { + let surface = backend(partition, hypervisor)? + .supported_cpu_surface()? + .ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + format!( + "the {hypervisor} backend reports no CPU surface to verify CPU profile {} against", + profile.id() + ), + ) + })?; cpu_profile::verify_support(profile, &host_cpu_surface(&surface))?; - Ok(true) + Ok(()) } /// Converts a backend's CPU surface to the profile crate's form. diff --git a/openvmm/openvmm_entry/src/microvm/verify.rs b/openvmm/openvmm_entry/src/microvm/verify.rs index 5fb7ac9f2e1..0797ad09481 100644 --- a/openvmm/openvmm_entry/src/microvm/verify.rs +++ b/openvmm/openvmm_entry/src/microvm/verify.rs @@ -48,35 +48,10 @@ pub(crate) async fn report_time_abi_verification( .join() .await .context("VM worker failed after time ABI verification")?; - // The worker checks profile support only where the backend reports a - // cheap CPU surface; verification mode always checks it, from the - // backend's CPU fingerprint, once the worker's partition is gone. - if let Err(err) = report_field(&report, "cpu_profile") - .and_then(|cpu_profile| check_profile_support(hypervisor, &cpu_profile)) - { - tracing::error!(error = format!("{err:#}"), "time ABI verification failed"); - println!("{}", failure_line(hypervisor, &err)); - return Ok(1); - } println!("{line}"); Ok(0) } -/// Checks that the backend supports the CPU profile `id`, from the backend's -/// CPU fingerprint (`E_PROFILE_UNSUPPORTED`). Fingerprinting creates a probe -/// partition, which only verification mode can afford. -fn check_profile_support(hypervisor: &str, id: &str) -> anyhow::Result<()> { - let profile = cpu_profile::pinned(id) - .with_context(|| format!("the CPU profile '{id}' is not pinned in this OpenVMM"))?; - let fingerprint = openvmm_helpers::hypervisor::cpu_fingerprint(Some(hypervisor)) - .context("failed to fingerprint the hypervisor backend")?; - cpu_profile::verify_support( - profile, - &cpu_profile::HostCpuSurface::from_fingerprint(&fingerprint), - )?; - Ok(()) -} - /// Returns the string value of `name` in the worker's `time-abi` inspect /// node. fn report_field(node: &inspect::Node, name: &str) -> anyhow::Result { diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index 27e337b55e8..90eb21219a9 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -52,19 +52,14 @@ pub trait TimeAbiBackend: Send + Sync { fn effective_cpuid(&self) -> Result, TimeAbiError>; /// Returns the CPU surface the backend supports on this host, for the CPU - /// profile's support check (`E_PROFILE_UNSUPPORTED`) at partition - /// creation. It must be cheap: capability queries and the host's CPUID, - /// never a probe partition. - /// - /// The default returns `None`; core then checks support only in - /// verification mode (`--x-time-abi-verify`), from the backend's CPU - /// fingerprint. Every backend implements it before the time ABI becomes - /// the only microVM path. + /// profile's support check (`E_PROFILE_UNSUPPORTED`), which core runs at + /// every cold boot and restore. It must be cheap: capability queries and + /// the host's CPUID, never a probe partition. A backend that cannot report + /// its surface returns `None`, which fails every time ABI boot + /// (`E_PROFILE_UNSUPPORTED`). fn supported_cpu_surface( &self, - ) -> Result, TimeAbiError> { - Ok(None) - } + ) -> Result, TimeAbiError>; /// Takes the capture anchor: VP 0's TSC paired with /// [`sample_host_time`](super::host::sample_host_time). Fails with @@ -235,6 +230,12 @@ mod tests { unreachable!() } + fn supported_cpu_surface( + &self, + ) -> Result, TimeAbiError> { + unreachable!() + } + fn capture_anchor(&self) -> Result { unreachable!() } diff --git a/vmm_core/virt/src/time_abi/surface.rs b/vmm_core/virt/src/time_abi/surface.rs index e90ecb0a35f..ced99848fc5 100644 --- a/vmm_core/virt/src/time_abi/surface.rs +++ b/vmm_core/virt/src/time_abi/surface.rs @@ -1,26 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Host CPU facts for the CPU profile record of a snapshot, the CPU surface a -//! backend supports, and the interim CPU profile IDs that backends still -//! recognize. - -/// The prefix of every interim CPU profile ID. -const INTERIM_CPU_PROFILE_PREFIX: &str = "interim.host."; - -/// Returns the ID of the interim CPU profile of `hypervisor`, which named the -/// backend's own CPU features before CPU profiles. Core no longer selects or -/// restores interim profiles; backends that still recognize the ID drop it -/// together with this function. -pub fn interim_cpu_profile_id(hypervisor: &str) -> String { - format!("{INTERIM_CPU_PROFILE_PREFIX}{hypervisor}.v1") -} - -/// Returns whether `id` names an interim CPU profile rather than a pinned -/// one. See [`interim_cpu_profile_id`]. -pub fn is_interim_cpu_profile(id: &str) -> bool { - id.starts_with(INTERIM_CPU_PROFILE_PREFIX) -} +//! Host CPU facts for the CPU profile record of a snapshot, and the CPU +//! surface a backend supports. /// Returns the host's display signature, CPUID.1:EAX, as the VMM's host OS /// sees it, or `None` on a host that is not x86-64. @@ -69,12 +51,7 @@ mod tests { use super::*; #[test] - fn interim_cpu_profiles() { - assert_eq!(interim_cpu_profile_id("kvm"), "interim.host.kvm.v1"); - assert!(is_interim_cpu_profile("interim.host.mshv.v1")); - for id in ["auto", "intel.icelake-sp.v1", "", "interim"] { - assert!(!is_interim_cpu_profile(id), "{id}"); - } + fn host_cpu_signature_is_reported() { assert_ne!(host_cpu_signature(), Some(0)); } } From ffb80e816c6b0cccbd6851e6747e4d2f04e487b0 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 16:16:13 -0700 Subject: [PATCH 223/304] virt_mshv: make the time ABI's unlisted CPUID leaves and subleaves read zero A CPU profile's effective CPUID requires every leaf and subleaf that its table does not list to read zero. MSHV serves its own value for anything without a registered result, and refuses a whole-leaf result and a subleaf result for the same leaf, so it needs explicit zero results: - a subleaf result for every subleaf the host enumerates of a leaf that the table lists by subleaf, unless the table lists that subleaf; - a whole-leaf result for every basic and extended leaf the table does not list, up to the host's maximum leaves or four past the table's, whichever is higher. The host's CPUID table is read once when the partition is built, and the supported CPU surface reuses it. A hardware test sweeps subleaves 0 to 63 of every indexed leaf of the effective CPUID and the four leaves past each maximum, as WHP's does, and the backend's hardware test now programs core's complete table for this host's profile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/mod.rs | 19 +- .../virt_mshv/src/x86_64/profile_features.rs | 138 ++++++++++++-- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 172 +++++++++++++++++- 3 files changed, 313 insertions(+), 16 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index eea04d338f2..ff87a466657 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -549,13 +549,29 @@ impl ProtoPartition for MshvProtoPartition<'_> { None => virt::CpuidLeafSet::new(tsc::add_cpuid_leaves(&self.vmfd, cpuid)?), }; + // A time ABI partition's unlisted leaves and subleaves read zero, which + // takes explicit results on MSHV. + let started = std::time::Instant::now(); + let (zero_results, host_cpuid) = if self.config.time_abi.is_some() { + let host_cpuid = profile_features::host_cpuid_table(); + ( + profile_features::unlisted_zero_results(&cpuid, &host_cpuid), + host_cpuid, + ) + } else { + (Vec::new(), Vec::new()) + }; + let host_cpuid_us = started.elapsed().as_micros() as u64; + // Apply CPUID overrides partition-wide. let started = std::time::Instant::now(); - for leaf in cpuid.leaves().iter() { + for leaf in cpuid.leaves().iter().chain(&zero_results) { register_cpuid_result(&self.vmfd, leaf)?; } tracing::info!( leaves = cpuid.leaves().len(), + zero_results = zero_results.len(), + host_cpuid_us, elapsed_us = started.elapsed().as_micros() as u64, "registered MSHV CPUID results" ); @@ -565,6 +581,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { &cpuid, self.time_abi_host .expect("a time ABI partition reads the host's features"), + host_cpuid, ) }); diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index 70ea15327a9..824b303d2c8 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -113,24 +113,92 @@ fn host_cpuid(_leaf: u32, _subleaf: u32) -> [u32; 4] { [0; 4] } -/// Returns the CPU surface that MSHV supports on this host, given the -/// features the host partition offers and the guest physical address width. +/// Returns the host's CPUID table, as the CPUID instruction enumerates it. +pub(crate) fn host_cpuid_table() -> Vec { + cpu_profile::cpuid::enumerate(|leaf, subleaf| { + Ok::<_, std::convert::Infallible>(host_cpuid(leaf, subleaf)) + }) + .unwrap_or_else(|never| match never {}) +} + +/// The leaves past each maximum leaf that read zero even beyond the host's +/// own maximum leaf. +const ZERO_LEAVES_PAST_MAXIMUM: u32 = 4; + +/// Returns the zero results that make the leaves and subleaves `cpuid` does +/// not list read zero, as a CPU profile's effective CPUID requires, where the +/// hypervisor would otherwise serve its own value. +/// +/// MSHV serves its own value for anything without a registered result, and +/// refuses a whole-leaf result and a subleaf result for the same leaf. So this +/// returns: +/// +/// - a subleaf result for every subleaf that `host` enumerates of a leaf that +/// `cpuid` lists by subleaf, unless `cpuid` lists that subleaf; +/// - a whole-leaf result for every basic and extended leaf that `cpuid` does +/// not list at all, up to the host's maximum leaves or +/// [`ZERO_LEAVES_PAST_MAXIMUM`] past `cpuid`'s, whichever is higher. +/// +/// The hypervisor range belongs to the time ABI's identity and explicit zero +/// leaves, which `cpuid` lists. +pub(crate) fn unlisted_zero_results( + cpuid: &virt::CpuidLeafSet, + host: &[CpuidEntry], +) -> Vec { + let leaves = cpuid.leaves(); + let listed = |function: u32| leaves.iter().any(|leaf| leaf.function == function); + let by_subleaf = |function: u32| { + leaves + .iter() + .filter(|leaf| leaf.function == function) + .all(|leaf| leaf.index.is_some()) + }; + let host_max = |base: u32| cpu_profile::cpuid::lookup(host, base, 0).map_or(base, |r| r[0]); + let table_max = |base: u32| cpuid.result(base, 0, &[base, 0, 0, 0])[0]; + + let mut zeros = Vec::new(); + // Unlisted subleaves of the leaves the table lists by subleaf. + for entry in host { + let (function, subleaf) = entry.key(); + if let Some(subleaf) = subleaf { + if listed(function) + && by_subleaf(function) + && !leaves + .iter() + .any(|leaf| leaf.function == function && leaf.index == Some(subleaf)) + { + zeros.push(CpuidLeaf::new(function, [0; 4]).indexed(subleaf)); + } + } + } + // Unlisted leaves, including those past the maximum leaves. + for base in [0, EXTENDED_LEAF_BASE] { + let last = host_max(base).max(table_max(base) + ZERO_LEAVES_PAST_MAXIMUM); + if !(base..base + 0x100).contains(&last) { + continue; + } + for function in base..=last { + if !listed(function) { + zeros.push(CpuidLeaf::new(function, [0; 4])); + } + } + } + zeros +} + +/// Returns the CPU surface that MSHV supports on this host, given the host's +/// CPUID table ([`host_cpuid_table`]), the features the host partition +/// offers, and the guest physical address width. /// /// This is what a partition that enables every offered feature presents, /// approximated without creating one: the host's CPUID without the mapped /// features `host` lacks. `IA32_ARCH_CAPABILITIES` follows the banks. pub(crate) fn supported_cpu_surface( + host_cpuid: Vec, host: HvFeatures, physical_address_width: u8, ) -> SupportedCpuSurface { - surface_from_host_cpuid( - cpu_profile::cpuid::enumerate(|leaf, subleaf| { - Ok::<_, std::convert::Infallible>(host_cpuid(leaf, subleaf)) - }) - .unwrap_or_else(|never| match never {}), - host, - physical_address_width, - ) + surface_from_host_cpuid(host_cpuid, host, physical_address_width) } fn surface_from_host_cpuid( @@ -353,6 +421,54 @@ mod tests { assert_eq!(msr.controllable, hv_banks::ARCH_CAPABILITIES_BANK_MASK); } + #[test] + fn unlisted_leaves_and_subleaves_get_zero_results() { + let entry = |leaf, subleaf, eax| CpuidEntry::new(leaf, subleaf, [eax, 1, 2, 3]); + let host = vec![ + entry(0, None, 0xa), + entry(1, None, 0), + entry(2, None, 0), + entry(4, Some(0), 0x121), + entry(4, Some(1), 0x122), + entry(4, Some(2), 0x143), + entry(4, Some(3), 0x163), + entry(7, Some(0), 2), + entry(7, Some(1), 0), + entry(7, Some(2), 0), + entry(0xa, None, 0), + entry(0xd, Some(0), 0), + entry(0xd, Some(1), 0), + entry(0xd, Some(2), 0), + entry(HYPERVISOR_LEAF_BASE, None, HYPERVISOR_LEAF_BASE + 0xb), + entry(EXTENDED_LEAF_BASE, None, EXTENDED_LEAF_BASE + 8), + ]; + let table = virt::CpuidLeafSet::new(vec![ + CpuidLeaf::new(0, [7, 0, 0, 0]), + CpuidLeaf::new(1, [0; 4]), + CpuidLeaf::new(4, [0x121, 0, 0, 0]).indexed(0), + CpuidLeaf::new(4, [0x122, 0, 0, 0]).indexed(1), + CpuidLeaf::new(7, [0; 4]).indexed(0), + // A leaf listed whole gets no subleaf results: MSHV refuses both. + CpuidLeaf::new(0xd, [0; 4]), + CpuidLeaf::new(HYPERVISOR_LEAF_BASE, [HYPERVISOR_LEAF_BASE + 5, 0, 0, 0]), + CpuidLeaf::new(EXTENDED_LEAF_BASE, [EXTENDED_LEAF_BASE + 1, 0, 0, 0]), + CpuidLeaf::new(EXTENDED_LEAF_BASE + 1, [0; 4]), + ]); + let zeros: Vec<_> = unlisted_zero_results(&table, &host) + .iter() + .map(|leaf| { + assert_eq!((leaf.result, leaf.mask), ([0; 4], [!0; 4])); + (leaf.function, leaf.index) + }) + .collect(); + let mut expected = vec![(4, Some(2)), (4, Some(3)), (7, Some(1)), (7, Some(2))]; + // Basic leaves up to the host's maximum (0xa) or four past the table's + // (0xb), whichever is higher; extended up to the host's 0x80000008. + expected.extend([2, 3, 5, 6, 8, 9, 0xa, 0xb].map(|leaf| (leaf, None))); + expected.extend((2..=8).map(|leaf| (EXTENDED_LEAF_BASE + leaf, None))); + assert_eq!(zeros, expected); + } + #[test] fn the_surface_supports_each_host_profile() { // On a host whose CPUID already matches its profile, the surface of the @@ -598,7 +714,7 @@ mod hw { let mut cheap = None; for _ in 0..10 { let started = std::time::Instant::now(); - cheap = Some(supported_cpu_surface(host, width)); + cheap = Some(supported_cpu_surface(host_cpuid_table(), host, width)); timings.push(started.elapsed().as_micros()); } timings.sort_unstable(); diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 8f1fe65822d..0ca1a8a0f42 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -130,6 +130,10 @@ pub(crate) struct MshvTimeAbi { /// the supported CPU surface. #[inspect(skip)] host_features: HvFeatures, + /// The host's CPUID table, read once at creation for the zero results + /// and the supported CPU surface. + #[inspect(skip)] + host_cpuid: Vec, } impl MshvTimeAbi { @@ -137,6 +141,7 @@ impl MshvTimeAbi { config: &TimeAbiConfig, registered_cpuid: &CpuidLeafSet, host_features: HvFeatures, + host_cpuid: Vec, ) -> Self { Self { msrs: config.msrs.clone(), @@ -144,6 +149,7 @@ impl MshvTimeAbi { effective_cpuid: std::sync::OnceLock::new(), vp_set_sealed: AtomicBool::new(false), host_features, + host_cpuid, } } @@ -768,8 +774,11 @@ impl TimeAbiBackend for MshvPartition { ), ) })?; - let surface = - super::profile_features::supported_cpu_surface(state.host_features, width as u8); + let surface = super::profile_features::supported_cpu_surface( + state.host_cpuid.clone(), + state.host_features, + width as u8, + ); tracing::info!( leaves = surface.cpuid.len(), physical_address_width = surface.physical_address_width, @@ -1230,6 +1239,7 @@ mod tests { &config, &partition_cpuid(Vec::new(), &config.cpuid), super::super::profile_features::legacy_features(), + Vec::new(), ); state.check_vp_creation(VpIndex::new(4)).unwrap(); state.vp_set_sealed.store(true, Ordering::SeqCst); @@ -1334,7 +1344,6 @@ mod hw { use virt::ProtoPartitionConfig; use virt::time_abi::identity::check_identity; use virt::time_abi::identity::check_time_bits; - use virt::time_abi::identity::time_abi_cpuid; use virt::time_abi::rate::LAPIC_HZ_HYPERV; use virt::time_abi::rate::check_plausible_tsc_hz; use vm_topology::memory::MemoryLayout; @@ -1353,6 +1362,55 @@ mod hw { .to_owned() } + /// Returns the effective CPUID of this host's profile for `topology`, and + /// the CPUID table that core programs for it (`TimeAbiConfig::cpuid`): the + /// effective CPUID with the per-VP APIC identity bits unmasked. This is + /// what core's `effective_cpuid` and `backend_cpuid` build. + fn host_profile_cpuid( + topology: &vm_topology::processor::ProcessorTopology, + ) -> (cpu_profile::EffectiveCpuid, CpuidLeafSet) { + let profile = cpu_profile::pinned(&host_profile_id()).unwrap(); + let result = |leaf: &CpuidLeaf| cpu_profile::CpuidResult { + function: leaf.function, + index: leaf.index, + result: leaf.result, + mask: leaf.mask, + }; + let mut topology_leaves = Vec::new(); + virt::x86::topology::topology_cpuid( + topology, + &|leaf, subleaf| profile.lookup(leaf, subleaf), + &mut topology_leaves, + ) + .unwrap(); + let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); + vm.push(cpu_profile::x2apic_cpuid(!matches!( + topology.apic_mode(), + vm_topology::processor::x86::ApicMode::XApic + ))); + let identity: Vec<_> = virt::time_abi::identity::identity_cpuid_leaves(topology.vp_count()) + .iter() + .map(result) + .chain(virt::time_abi::identity::identity_zero_cpuid_leaves().map(|leaf| result(&leaf))) + .collect(); + let effective = profile.effective_cpuid(&vm, &identity).unwrap(); + let table = CpuidLeafSet::new( + effective + .results() + .map(|result| { + let per_vp = virt::x86::topology::per_vp_cpuid_bits(result.function); + CpuidLeaf { + function: result.function, + index: result.index, + result: result.result, + mask: [0, 1, 2, 3].map(|i| result.mask[i] & !per_vp[i]), + } + }) + .collect(), + ); + (effective, table) + } + /// Converts a backend's CPU surface to the profile crate's form, as core /// does for the support check. fn host_cpu_surface(surface: &SupportedCpuSurface) -> cpu_profile::HostCpuSurface { @@ -1409,7 +1467,7 @@ mod hw { lazy_memory_registration: false, versioned_cpu_contract: false, time_abi: Some(TimeAbiConfig { - cpuid: Arc::new(time_abi_cpuid(VP_CAPACITY, true)), + cpuid: Arc::new(host_profile_cpuid(&processor_topology).1), msrs: Arc::new(TimeAbiMsrs::new()), cpu_profile: host_profile_id(), }), @@ -1604,6 +1662,112 @@ mod hw { } } + /// Sweeps, as VP 0 of a time ABI partition observes them, subleaves 0 to + /// 63 of every indexed leaf of the effective CPUID and the four leaves + /// past the maximum basic and extended leaves. Listed results must match + /// under their masks. By the profile's contract a leaf or subleaf that the + /// table does not list reads zero, which the zero results provide on MSHV. + #[async_test] + #[ignore = "requires /dev/mshv"] + async fn unlisted_cpuid_entries_read_zero(driver: DefaultDriver) { + let processor_topology = TopologyBuilder::new_x86() + .x2apic(X2ApicState::Supported) + .build(2) + .unwrap(); + let (effective, table) = host_profile_cpuid(&processor_topology); + let mem_layout = MemoryLayout::new(0x400000, &[], &[], &[], None).unwrap(); + let vmtime_keeper = VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); + let vmtime = vmtime_keeper.builder().build(&driver).await.unwrap(); + let guest_memory = GuestMemory::allocate(mem_layout.end_of_ram() as usize); + let mut mshv = LinuxMshv::new().unwrap(); + let (partition, mut binders) = mshv + .new_partition(ProtoPartitionConfig { + processor_topology: &processor_topology, + hv_config: None, + vmtime: &vmtime, + isolation: virt::ProtoPartitionIsolation::None, + nested_virt: false, + user_mode_memory_faults: false, + lazy_memory_registration: false, + versioned_cpu_contract: false, + time_abi: Some(TimeAbiConfig { + cpuid: Arc::new(table), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: host_profile_id(), + }), + }) + .unwrap() + .build(PartitionConfig { + mem_layout: &mem_layout, + guest_memory: &guest_memory, + cpuid: &[], + vtl0_alias_map: None, + fault_resolver: None, + }) + .unwrap(); + let ram = guest_memory.inner_buf().unwrap(); + // SAFETY: the guest memory outlives the partition. + unsafe { + partition.memory_mapper(Vtl::Vtl0).map_range( + ram.as_ptr().cast_mut().cast(), + ram.len(), + 0, + true, + true, + ) + } + .unwrap(); + partition.finalize_memory().unwrap(); + binders[0].bind().unwrap(); + let bsp = &partition.inner.finalized().unwrap().bsp_vcpufd; + + let mut indexed: Vec = effective + .results() + .filter(|result| result.index.is_some()) + .map(|result| result.function) + .collect(); + indexed.dedup(); + let max_basic = effective.lookup(0, 0)[0]; + let max_extended = effective.lookup(0x8000_0000, 0)[0]; + let probes = indexed + .iter() + .flat_map(|&function| (0..64).map(move |index| (function, index))) + .chain((max_basic + 1..=max_basic + 4).map(|function| (function, 0))) + .chain((max_extended + 1..=max_extended + 4).map(|function| (function, 0))); + let (mut count, mut listed_failures, mut unlisted) = (0, Vec::new(), Vec::new()); + for (function, index) in probes { + count += 1; + let listed = effective.results().find(|result| { + result.function == function && result.index.is_none_or(|i| i == index) + }); + let (expected, mask) = + listed.map_or(([0; 4], [!0; 4]), |result| (result.result, result.mask)); + let actual = bsp.get_cpuid_values(function, index, 0, 0).unwrap(); + if (0..4).all(|register| (actual[register] ^ expected[register]) & mask[register] == 0) + { + continue; + } + let line = format!("{function:#x}.{index}: {actual:08x?}"); + if listed.is_some() { + listed_failures.push(format!("{line} expected {expected:08x?} mask {mask:08x?}")); + } else { + unlisted.push(line); + } + } + println!( + "{}: {count} probes of {} indexed leaves (max basic {max_basic:#x}, max extended {max_extended:#x}); {} listed results differ, {} unlisted results are not zero", + host_profile_id(), + indexed.len(), + listed_failures.len(), + unlisted.len() + ); + for line in listed_failures.iter().chain(&unlisted) { + println!(" {line}"); + } + assert!(listed_failures.is_empty(), "{listed_failures:#?}"); + assert!(unlisted.is_empty(), "{unlisted:#?}"); + } + /// The negative path of identity routing: the hypervisor refuses an /// MSR-index intercept for `IA32_ARCH_CAPABILITIES` (AccessDenied), which /// must surface as `E_IDENTITY_ROUTING` naming the MSR. From 593a4b43443ced3160be22d442a510bd86da0fe3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 16:45:41 -0700 Subject: [PATCH 224/304] virt_whp: check that core's CPUID table presents what the profile did Since step B (dbe5e26f8), WHP programs core's complete effective CPUID as the single source of the CPUID. Before it, WHP programmed the profile's leaves directly, with the identity and time-bit overlay applied on CPUID exits. Add a unit test and a hardware test that compare what VP 0 observes under both programmings, reconstructing the one of de39f6aab. They cover every leaf and subleaf that either lists, subleaves 0 to 63 of every indexed leaf, and the four leaves past each maximum. The unit test covers the three pinned profiles at 1 and 8 VPs on a simulated WHP. On WHP the views are identical: 563 probes on prometheus28 (Skylake-SP), 694 on 8370C (Ice Lake-SP), and 695 on 8573C (Emerald Rapids). The core-table programming needs 8 CPUID exits instead of 17. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/time_abi.rs | 278 +++++++++++++++++++++++++++++- 1 file changed, 272 insertions(+), 6 deletions(-) diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index b60f1195b72..35dd469f0d5 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -1317,6 +1317,83 @@ pub(crate) mod test_cpuid { virt::x86::topology::topology_cpuid(topology, native, &mut own).unwrap(); own } + + /// The CPUID programming of `de39f6aab`, before core's complete table: + /// the profile's leaves other than the VM-owned topology leaves as + /// `CpuidResultList2` results with the profile's masks, and exits for the + /// topology leaves and for every leaf of the identity and time-bit + /// overlay (`time_abi_cpuid`), which the exit handler applied last. + pub struct ProfileProgramming { + /// The `CpuidResultList2` results. + pub results: Vec, + /// The CPUID exit list. + pub exits: Vec, + /// The overlay that the exit handler applied. + pub overlay: CpuidLeafSet, + } + + /// Returns [`ProfileProgramming`] for `profile` and `vp_count` VPs. + pub fn profile_programming(profile: &CpuProfile, vp_count: u32) -> ProfileProgramming { + let overlay = virt::time_abi::identity::time_abi_cpuid(vp_count, true); + let mut exits = vec![1, 4, 0xb, 0x1f]; + exits.extend( + overlay + .leaves() + .iter() + .filter(|leaf| !super::is_zero_fill(leaf)) + .map(|leaf| leaf.function), + ); + exits.sort_unstable(); + exits.dedup(); + let leaves = profile + .cpuid() + .iter() + .filter(|entry| !cpu_profile::VM_OWNED_LEAVES.contains(&entry.key().0)) + .map(|entry| { + let (function, index) = entry.key(); + CpuidLeaf { + function, + index, + result: entry.values(), + mask: entry.masks(), + } + }) + .collect(); + ProfileProgramming { + results: super::cpuid_results(&CpuidLeafSet::new(leaves)), + exits, + overlay, + } + } + + /// Returns the leaves and subleaves on which to compare two CPUID views: + /// every result of `effective` and of `profile`, subleaves 0 to 63 of + /// every indexed leaf, and the four leaves past each maximum leaf. + pub fn probes(effective: &EffectiveCpuid, profile: &CpuProfile) -> Vec<(u32, u32)> { + let mut probes: Vec<(u32, u32)> = effective + .results() + .map(|result| (result.function, result.index.unwrap_or(0))) + .chain(profile.cpuid().iter().map(|entry| { + let (leaf, subleaf) = entry.key(); + (leaf, subleaf.unwrap_or(0)) + })) + .collect(); + let indexed: Vec = effective + .results() + .filter(|result| result.index.is_some()) + .map(|result| result.function) + .collect(); + for function in indexed { + probes.extend((0..64).map(|index| (function, index))); + } + let max_basic = effective.lookup(0, 0)[0]; + let max_extended = effective.lookup(0x8000_0000, 0)[0]; + probes.extend((max_basic + 1..=max_basic + 4).map(|function| (function, 0))); + probes.extend((max_extended + 1..=max_extended + 4).map(|function| (function, 0))); + probes.sort_unstable(); + probes.dedup(); + probes + } } #[cfg(test)] @@ -1373,23 +1450,30 @@ mod tests { /// match the effective CPUID. struct FakeWhp { results: Vec, + /// Whether unprogrammed bits read noise rather than zero. + noise: bool, } impl FakeWhp { fn new(config: &CpuidLeafSet) -> Self { Self { results: cpuid_results(config), + noise: true, } } fn native(&self, function: u32, index: u32) -> [u32; 4] { let seed = function.wrapping_mul(0x9e37_79b9) ^ index.wrapping_mul(0x85eb_ca6b); - let mut result = [ - seed, - seed.rotate_left(8) ^ 0xa5a5_a5a5, - seed.rotate_left(16) ^ 0x5a5a_5a5a, - !seed, - ]; + let mut result = if self.noise { + [ + seed, + seed.rotate_left(8) ^ 0xa5a5_a5a5, + seed.rotate_left(16) ^ 0x5a5a_5a5a, + !seed, + ] + } else { + [0; 4] + }; for programmed in self.results.iter().filter(|result| { result.Function == function && (!result.Flags.is_set(WHvX64CpuidResult2FlagSubleafSpecific) @@ -1444,6 +1528,33 @@ mod tests { } } + /// A partition with the profile programming of `de39f6aab` + /// ([`test_cpuid::profile_programming`]) on a WHP whose unprogrammed + /// bits read zero if `noise` is false. + fn profile_programmed(profile: &str, vp_count: u32, noise: bool) -> Self { + let topology = test_cpuid::topology(vp_count, X2ApicState::Supported); + let pinned = cpu_profile::pinned(profile).unwrap(); + let effective = test_cpuid::effective_cpuid(pinned, &topology); + let programming = test_cpuid::profile_programming(pinned, vp_count); + let whp = FakeWhp { + results: programming.results, + noise, + }; + let own = + test_cpuid::own_cpuid(&topology, &|function, index| whp.native(function, index)); + let cpuid = partition_cpuid(own, &programming.overlay, &programming.exits).unwrap(); + let cpuid_topology = CpuidTopology::new(&topology); + Self { + topology, + effective, + config: programming.overlay, + exits: programming.exits, + whp, + cpuid, + cpuid_topology, + } + } + fn vp(&self, apic_id: u32) -> VpCpuid<'_> { VpCpuid { cpuid: &self.cpuid, @@ -1701,6 +1812,49 @@ mod tests { } } + /// Since step B, WHP programs core's complete table as the single source + /// of the CPUID. VP 0 observes exactly the CPUID of the profile + /// programming of `de39f6aab` on every leaf and subleaf that either + /// lists, on their unlisted subleaves, and past the maximum leaves, on a + /// WHP whose unprogrammed bits read zero. + #[test] + fn core_table_presents_what_the_profile_programming_did() { + for profile in test_cpuid::PROFILES { + for vp_count in [1, 8] { + let mut new = Partition::new(profile, vp_count, X2ApicState::Supported); + new.whp.noise = false; + let old = Partition::profile_programmed(profile, vp_count, false); + let probes = + test_cpuid::probes(&new.effective, cpu_profile::pinned(profile).unwrap()); + let differences: Vec = probes + .iter() + .filter_map(|&(function, index)| { + let ours = normalize_cpuid(function, index, new.observe(0, function, index)); + let theirs = normalize_cpuid(function, index, old.observe(0, function, index)); + (ours != theirs).then(|| { + format!("{function:#x}.{index}: core table {ours:08x?}, profile {theirs:08x?}") + }) + }) + .collect(); + assert!( + differences.is_empty(), + "{profile}, {vp_count} VPs, {} probes: {differences:#?}", + probes.len() + ); + // The views carry the profile: the comparison is not vacuous. + let populated = probes + .iter() + .filter(|&&(function, index)| new.observe(0, function, index) != [0; 4]) + .count(); + let entries = cpu_profile::pinned(profile).unwrap().cpuid().len(); + assert!( + populated >= entries / 2, + "{profile}: {populated} populated results of {entries} profile entries" + ); + } + } + } + #[test] fn partition_cpuid_keeps_own_results_only_for_exiting_leaves() { let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); @@ -2347,6 +2501,118 @@ mod whp_tests { assert!(unlisted.is_empty(), "{unlisted:#?}"); } + /// The comparison of `tests::core_table_presents_what_the_profile_programming_did` + /// on WHP: two partitions with this host's profile and the same features, + /// one programmed from core's complete table and one with the profile + /// programming of `de39f6aab`, present the same CPUID to VP 0. + #[test] + #[ignore = "requires WHP"] + fn core_table_presents_what_the_profile_programming_did_on_whp() { + const VP_COUNT: u32 = 2; + let Some(new) = partition(VP_COUNT) else { + return; + }; + let profile = cpu_profile::pinned(&new.time_abi.cpu_profile).unwrap(); + let programming = test_cpuid::profile_programming(profile, VP_COUNT); + let mut whp_config = whp::PartitionConfig::new().unwrap(); + whp_config + .set_property(whp::PartitionProperty::ProcessorCount(VP_COUNT)) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::CpuidExitList(&programming.exits)) + .unwrap(); + let mut features = whp::capabilities::processor_features().unwrap(); + features.bank0 = WHV_PROCESSOR_FEATURES(new.time_abi.features_bank0); + features.bank1 = WHV_PROCESSOR_FEATURES1(new.time_abi.features_bank1); + whp_config + .set_property(whp::PartitionProperty::ProcessorFeaturesBanks(features)) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::ProcessorXsaveFeatures( + WHV_PROCESSOR_XSAVE_FEATURES(new.time_abi.features_xsave), + )) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::CpuidResultList2( + &programming.results, + )) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeXApic, + )) + .unwrap(); + whp_config + .set_property(whp::PartitionProperty::ExtendedVmExits( + WHV_EXTENDED_VM_EXITS::X64CpuidExit | WHV_EXTENDED_VM_EXITS::X64MsrExit, + )) + .unwrap(); + let old = whp_config.create().unwrap(); + for vp in 0..VP_COUNT { + old.create_vp(vp).create().unwrap(); + } + + let cpuid_topology = CpuidTopology::new(&new.topology); + let new_cpuid = new + .time_abi + .partition_cpuid(test_cpuid::own_cpuid(&new.topology, &|function, index| { + native(&new.partition, function, index).unwrap() + })) + .unwrap(); + let old_cpuid = partition_cpuid( + test_cpuid::own_cpuid(&new.topology, &|function, index| { + native(&old, function, index).unwrap() + }), + &programming.overlay, + &programming.exits, + ) + .unwrap(); + let view = + |partition: &whp::Partition, cpuid: &CpuidLeafSet, exits: &[u32], function, index| { + let vp0 = VpCpuid { + cpuid, + exits, + topology: &cpuid_topology, + apic_id: 0, + }; + let result = vp0 + .result(function, index, || native(partition, function, index)) + .unwrap(); + normalize_cpuid(function, index, result) + }; + let probes = test_cpuid::probes(&new.effective, profile); + let mut populated = 0; + let mut differences = Vec::new(); + for &(function, index) in &probes { + let ours = view( + &new.partition, + &new_cpuid, + &new.time_abi.cpuid_exits, + function, + index, + ); + let theirs = view(&old, &old_cpuid, &programming.exits, function, index); + populated += usize::from(ours != [0; 4]); + if ours != theirs { + differences.push(format!( + "{function:#x}.{index}: core table {ours:08x?}, profile {theirs:08x?}" + )); + } + } + println!( + "{}: {} probes, {populated} populated, {} differences; exits {} against {}, results {} against {}", + profile.id(), + probes.len(), + differences.len(), + new.time_abi.cpuid_exits.len(), + programming.exits.len(), + new.time_abi.cpuid_results, + programming.results.len() + ); + assert!(differences.is_empty(), "{differences:#?}"); + assert!(populated >= profile.cpuid().len() / 2); + } + #[test] #[ignore = "requires WHP"] fn time_suspension_is_idempotent() { From c6740906d48d48ce1ea9e0ef6d90b0aee3c04b24 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 16:57:31 -0700 Subject: [PATCH 225/304] virt_whp: build the test-only CPUID constants only for tests Since e8011b08b moved the feature table to cpu_profile::hv_banks, four constants in profile_features.rs (MSR_ARCH_CAPABILITIES, EBX, X1, and X7) are used only by its tests, and a non-test build warns that they are never used. Compile them only for tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/profile_features.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index 8fbaecf6f1f..432c9fd4f7e 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -34,16 +34,20 @@ use virt::time_abi::surface::SupportedCpuSurface; use virt::time_abi::surface::SupportedMsrValue; /// `IA32_ARCH_CAPABILITIES`. +#[cfg(test)] const MSR_ARCH_CAPABILITIES: u32 = 0x10a; const EAX: usize = 0; +#[cfg(test)] const EBX: usize = 1; const ECX: usize = 2; const EDX: usize = 3; /// The extended feature leaf. +#[cfg(test)] const X1: u32 = 0x8000_0001; /// The advanced power management leaf. +#[cfg(test)] const X7: u32 = 0x8000_0007; /// The AMD extended feature identifiers leaf. const X8: u32 = 0x8000_0008; From c2a2ff3648fa4a9d3153116870eff1be23d8308a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 17:11:50 -0700 Subject: [PATCH 226/304] virt_mshv: program the time ABI CPUID verbatim and check XSAVE components The time ABI CPUID is the CPU profile's complete effective CPUID, so a time ABI partition now registers it verbatim, as KVM does, instead of merging the worker's leaves under it. With the time ABI the worker passes none today (--hv and SNP are rejected), so the registered results are unchanged. Add a unit test that the features derived from each pinned profile enable exactly the profile's XCR0 components, and a hardware test that compares the XCR0 and IA32_XSS components a derived partition supports, in its own CPUID.(0xd,0) and CPUID.(0xd,1), with the profile's. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/mod.rs | 2 +- .../virt_mshv/src/x86_64/profile_features.rs | 81 ++++++++++++- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 113 ++++++------------ 3 files changed, 114 insertions(+), 82 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index ff87a466657..bb3bd4e928a 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -545,7 +545,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { } } let cpuid = match &self.config.time_abi { - Some(time_abi) => time_abi::partition_cpuid(cpuid, &time_abi.cpuid), + Some(time_abi) => time_abi::partition_cpuid(&time_abi.cpuid), None => virt::CpuidLeafSet::new(tsc::add_cpuid_leaves(&self.vmfd, cpuid)?), }; diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index 824b303d2c8..69db234325b 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -11,8 +11,10 @@ //! the profile decides ([`hv_banks::mapped_mask`]) follow the profile. The //! other bits, such as the nested-paging and VMX details, keep OpenVMM's //! legacy policy: what the host offers of OpenVMM's supported lists. The -//! CPUID intercept results then present the profile's leaves verbatim, -//! including the descriptive leaves and the time policy bits a host lacks. +//! CPUID intercept results then present core's time ABI CPUID verbatim (the +//! profile's complete effective CPUID), including the descriptive leaves and +//! the time policy bits a host lacks. Only the banks and the surface read the +//! profile itself. //! //! [`supported_cpu_surface`] reports what MSHV supports on this host without a //! probe partition: the host partition's processor features, the host's CPUID @@ -505,6 +507,35 @@ mod tests { assert!(violations.is_empty(), "{id}: {violations:#?}"); } } + + /// Returns the XCR0 state components that MSHV enables with `features`: + /// x87 and SSE with XSAVE itself, then AVX, MPX's bound registers, + /// AVX-512's opmask and upper ZMM states, and AMX's tile configuration and + /// data. No MSHV feature controls PKRU. + fn xcr0_components(features: &HvFeatures) -> u64 { + let xsave = XsaveBank::from_bits(features.xsave); + [ + (xsave.xsave_support(), 0x3), + (xsave.avx_support(), 1 << 2), + (xsave.mpx_support(), 0x3 << 3), + (xsave.avx512_support(), 0x7 << 5), + (xsave.amx_tile_support(), 0x3 << 17), + ] + .into_iter() + .filter(|&(enabled, _)| enabled) + .fold(0, |xcr0, (_, components)| xcr0 | components) + } + + #[test] + fn the_xsave_features_enable_the_profile_xcr0() { + // The features derive from the profile's CPUID bits; the components + // they enable are the profile's XCR0, with MPX on Skylake-SP only. + for (id, host) in HOSTS { + let profile = profile(id); + let features = time_abi_features(profile, host).unwrap(); + assert_eq!(xcr0_components(&features), profile.xcr0(), "{id}"); + } + } } /// Hardware tests: `cargo test -p virt_mshv -- --ignored --nocapture @@ -883,4 +914,50 @@ mod hw { ); assert!(failures.is_empty(), "{failures:#?}"); } + + /// A partition with the derived features supports exactly the profile's + /// XSAVE state components: its own `CPUID.(0xd,0)` (XCR0) and + /// `CPUID.(0xd,1)` (IA32_XSS), before any CPUID result applies. A PKRU + /// state the profile lacks is reported rather than failed, because no MSHV + /// feature controls it. + #[test] + #[ignore = "requires /dev/mshv"] + fn host_profile_features_enable_the_profile_xsave_components() { + const XCR0_PKRU: u64 = 1 << 9; + let profile = cpu_profile::select_auto(&HostCpuSignature::current()).unwrap(); + let mshv = Mshv::new().unwrap(); + let host = host_features(&mshv).unwrap(); + let features = time_abi_features(profile, host).unwrap(); + let probe = probe_partition(&mshv, features).unwrap(); + let partition = |subleaf, low: usize| { + let registers = probe.vp.get_cpuid_values(0xd, subleaf, 0, 0).unwrap(); + u64::from(registers[low]) | u64::from(registers[3]) << 32 + }; + let host_cpuid = host_cpuid_table(); + let root = |subleaf, low: usize| { + cpu_profile::cpuid::lookup(&host_cpuid, 0xd, subleaf).map_or(0, |registers| { + u64::from(registers[low]) | u64::from(registers[3]) << 32 + }) + }; + let (xcr0, xss) = (partition(0, 0), partition(1, 2)); + println!( + "{}: xsave features {:#x}: XCR0 {xcr0:#x} (profile {:#x}, root {:#x}), \ + IA32_XSS {xss:#x} (profile {:#x}, root {:#x})", + profile.id(), + features.xsave, + profile.xcr0(), + root(0, 0), + profile.xss(), + root(1, 2) + ); + let unpinned_pkru = XCR0_PKRU & !profile.xcr0(); + if xcr0 & unpinned_pkru != 0 { + println!( + "{}: the partition supports PKRU, which the profile lacks", + profile.id() + ); + } + assert_eq!(xcr0 & !unpinned_pkru, profile.xcr0(), "XCR0 components"); + assert_eq!(xss, profile.xss(), "IA32_XSS components"); + } } diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 0ca1a8a0f42..6b2391dc829 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -16,9 +16,11 @@ //! `HV_X64_MSR_TSC_INVARIANT_CONTROL` to [`TimeAbiMsrs`]. The hypervisor //! raises #GP itself for writes to the three read-only identity MSRs, even //! when they are intercepted, and for every other MSR of the identity range. -//! - CPUID intercept results apply the configured CPUID over the hypervisor's -//! own. The hypervisor reports no hypervisor leaf of its own, which -//! preflight verifies at sentinel leaves. +//! - CPUID intercept results present the configured CPUID verbatim: it is the +//! CPU profile's complete effective CPUID, and the backend adds nothing to +//! it except zero results for the leaves and subleaves it does not list. The +//! hypervisor reports no hypervisor leaf of its own, which preflight +//! verifies at sentinel leaves. //! - The processor feature banks follow the CPU profile (see //! [`profile_features`](super::profile_features)), keep the invariant TSC, //! and hide the TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. @@ -64,7 +66,6 @@ use virt::time_abi::TscAnchor; use virt::time_abi::TscSetReport; use virt::time_abi::TscSyncMethod; use virt::time_abi::host::sample_host_time; -use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_MAX_LEAF; use virt::time_abi::msr::MSR_APIC_FREQUENCY; @@ -278,20 +279,20 @@ fn install_msr_intercepts(vmfd: &VmFd, msrs: &[u32]) -> Result<(), TimeAbiError> Ok(()) } -/// Returns the CPUID results to register for a time ABI partition. +/// Returns the CPUID results to register for a time ABI partition: the time +/// ABI CPUID verbatim. /// -/// The hypervisor-range leaves of `own`, the backend's other CPUID results, -/// are dropped, and `config` is applied last, over everything else. Without -/// synthetic processor features, the hypervisor itself reports every -/// hypervisor-range leaf as zero, so the identity range holds exactly the -/// configured leaves (the identity and the explicit zero leaves) and zeros. -pub(super) fn partition_cpuid(own: Vec, config: &CpuidLeafSet) -> CpuidLeafSet { - let mut leaves: Vec = own - .into_iter() - .filter(|leaf| !HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) - .collect(); - leaves.extend(config.leaves().iter().copied()); - CpuidLeafSet::new(leaves) +/// It is the CPU profile's complete effective CPUID (the profile's leaves, +/// OpenVMM's topology leaves and APIC mode, and the identity and explicit zero +/// leaves), so neither the worker's leaves nor the backend's own add a leaf or +/// a bit. The hypervisor's own values show only where a result's mask is clear +/// (each VP's APIC identity, the runtime XSAVE sizes) and where no result +/// applies, which the zero results of +/// [`unlisted_zero_results`](super::profile_features::unlisted_zero_results) +/// cover. Without synthetic processor features, the hypervisor itself reports +/// every other hypervisor-range leaf as zero. +pub(super) fn partition_cpuid(config: &CpuidLeafSet) -> CpuidLeafSet { + CpuidLeafSet::new(config.leaves().to_vec()) } /// Returns whether `leaf` is an explicit zero leaf past the identity, which @@ -1040,17 +1041,18 @@ mod tests { } #[test] - fn partition_cpuid_registers_the_configured_identity_verbatim() { - let own = vec![ - CpuidLeaf::new(0x4000_0000, [0x4000_0006, 1, 2, 3]), - CpuidLeaf::new(0x4000_0081, [0x3123_5356, 0, 0, 0]), - CpuidLeaf::new(0x4000_0100, [0x4000_0101, 0x4b4d_564b, 0x564b_4d56, 0x4d]), - CpuidLeaf::new(0xd, [7, 0x340, 0x340, 0]).indexed(0), - ]; + fn partition_cpuid_is_the_time_abi_cpuid_verbatim() { let config = time_abi_cpuid(VP_COUNT, true); - let cpuid = partition_cpuid(own, &config); - let result = |function| cpuid.result(function, 0, &[0; 4]); + let cpuid = partition_cpuid(&config); + let leaves = |set: &CpuidLeafSet| -> Vec<_> { + set.leaves() + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result, leaf.mask)) + .collect() + }; + assert_eq!(leaves(&cpuid), leaves(&config)); + let result = |function| cpuid.result(function, 0, &[0; 4]); assert_eq!( result(0x4000_0000), [0x4000_0005, 0x7263_694d, 0x666f_736f, 0x7648_2074] @@ -1060,63 +1062,16 @@ mod tests { assert_eq!(result(0x4000_0003), [0x8860, 0, 0, 0x100]); assert_eq!(result(0x4000_0004), [0, 0xffff_ffff, 0, 0]); assert_eq!(result(0x4000_0005), [VP_COUNT, VP_COUNT, 0, 0]); - // The backend's own hypervisor-range leaves are dropped: every - // registered hypervisor-range leaf is a configured one, verbatim. - let hypervisor: Vec<_> = cpuid - .leaves() - .iter() - .filter(|leaf| HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) - .collect(); - let configured: Vec<_> = config - .leaves() - .iter() - .filter(|leaf| HYPERVISOR_CPUID_RANGE.contains(&leaf.function)) - .collect(); - assert_eq!(hypervisor.len(), configured.len()); - for (registered, configured) in hypervisor.iter().zip(&configured) { - assert_eq!( - ( - registered.function, - registered.index, - registered.result, - registered.mask - ), - ( - configured.function, - configured.index, - configured.result, - configured.mask - ) - ); - } assert_eq!(result(0x4000_0081), [0; 4]); - assert_eq!(result(0x4000_0100), [0; 4]); - // Other leaves are kept. - assert_eq!(cpuid.result(0xd, 0, &[0; 4]), [7, 0x340, 0x340, 0]); - } + let mut lookup = |leaf, subleaf| cpuid.result(leaf, subleaf, &[0; 4]); + virt::time_abi::identity::check_identity(&mut lookup, VP_COUNT).unwrap(); - #[test] - fn partition_cpuid_applies_the_time_bits_last() { - let own = vec![ - CpuidLeaf::new( - 1, - [ - 0x0005_0657, - 0x0080_0800, - 0x7ffa_fbff | (1 << 24), - 0xbfeb_fbff, - ], - ), - CpuidLeaf::new(6, [0x77, 2, 9, 0]), - CpuidLeaf::new(0x15, [2, 0xd4, 0x017d_7840, 0]), - ]; - let cpuid = partition_cpuid(own, &time_abi_cpuid(VP_COUNT, true)); - let [eax, ebx, ecx, edx] = cpuid.result(1, 0, &[0; 4]); - assert_eq!((eax, ebx), (0x0005_0657, 0x0080_0800)); + // The time bits are the configured ones, and the bits the table does + // not mask are left to the hypervisor. + let [_, _, ecx, _] = cpuid.result(1, 0, &[!0; 4]); assert_eq!(ecx & (1 << 31), 1 << 31, "hypervisor present"); assert_eq!(ecx & (1 << 24), 0, "TSC-deadline timer"); assert_eq!(ecx & (1 << 15), 0, "PDCM"); - assert_eq!(edx, 0xbfeb_fbff); assert_eq!(cpuid.result(6, 0, &[!0; 4]), [4, 0, 0, 0]); assert_eq!(cpuid.result(0x15, 0, &[!0; 4]), [0; 4]); assert_eq!(cpuid.result(0x8000_0007, 0, &[!0; 4]), [0, 0, 0, 0x100]); @@ -1237,7 +1192,7 @@ mod tests { }; let state = MshvTimeAbi::new( &config, - &partition_cpuid(Vec::new(), &config.cpuid), + &partition_cpuid(&config.cpuid), super::super::profile_features::legacy_features(), Vec::new(), ); From 54c03461c569616617e0221a02947213d5886bc6 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 17:29:59 -0700 Subject: [PATCH 227/304] openvmm: verify the restore's CPU profile records by digest Step B made every validation of a time ABI manifest decode both CPU profile records, and a restore validates the manifest several times before the partition exists: when the manifest is read, for each microVM contract check, and in the restore preflight, which then decoded the profile document once more. Each decode costs about a millisecond in a release build, so MSHV restores took about 11.5 ms longer before partition creation (p50 53 to 65 ms at one vCPU on prometheus30). Validate the records by shape and digest only. The restore preflight requires the recorded digest to be the pinned profile's, so the embedded document, which hashes to it, is that profile's canonical encoding without being decoded; it no longer calls restore_profile, which decoded it. The worker compares the effective CPUID record with the encoding of its recomputation and decodes the record only to name differences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/worker/dispatch/time_abi.rs | 20 ++++++- openvmm/openvmm_helpers/src/snapshot/time.rs | 59 +++++++++++-------- 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index d5a6824061a..d6a19f25b8a 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -260,11 +260,18 @@ pub(super) fn declare_rates( /// Compares the effective CPUID recomputed for this partition with the one /// the snapshot recorded: restore step 8 of the specification /// (`E_PROFILE_DIGEST` if the record does not verify, `E_CPU_SURFACE` if the -/// two differ). +/// two differ). A record that is this partition's own canonical encoding +/// needs no decoding; any other is decoded to name its differences. pub(super) fn check_recorded_cpuid( effective_cpuid: &EffectiveCpuid, record: &SnapshotCpuProfile, ) -> anyhow::Result<()> { + let encoded = effective_cpuid.encode(); + if encoded == record.effective_cpuid + && sha2::Sha256::digest(&encoded).as_slice() == record.effective_cpuid_sha256.as_slice() + { + return Ok(()); + } let recorded = EffectiveCpuid::decode_verified(&record.effective_cpuid, &record.effective_cpuid_sha256)?; effective_cpuid.check_matches(&recorded)?; @@ -743,6 +750,17 @@ mod tests { code(check_recorded_cpuid(&effective, &tampered)), "E_PROFILE_DIGEST" ); + + // A digest that verifies over bytes that are not the canonical + // encoding. + let mut noncanonical = record.clone(); + noncanonical.effective_cpuid.insert(1, b' '); + noncanonical.effective_cpuid_sha256 = + sha2::Sha256::digest(&noncanonical.effective_cpuid).to_vec(); + assert_eq!( + code(check_recorded_cpuid(&effective, &noncanonical)), + "E_PROFILE_DIGEST" + ); } #[test] diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index 47d7f0eddee..65a1e90c366 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -110,10 +110,12 @@ fn verify_sha256(bytes: &[u8], digest: &[u8], description: &str) -> Result<(), T Ok(()) } -/// Validates a CPU profile record: its shape, both digests, and both canonical -/// encodings. The embedded profile must be the one the record names -/// (`E_PROFILE_DIGEST`); whether this OpenVMM pins it is the restore -/// preflight's check. +/// Validates a CPU profile record: its shape and both digests. It decodes +/// neither document, because manifests are validated several times per +/// restore and each decode costs about a millisecond. The restore preflight +/// requires the profile digest to be the pinned profile's, so the embedded +/// document, which hashes to it, is that profile's canonical encoding; the +/// worker compares the effective CPUID record with its recomputation. pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), TimeAbiError> { let SnapshotCpuProfile { id, @@ -144,8 +146,6 @@ pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), Ti return Err(manifest_error("effective CPUID record size is invalid")); } verify_sha256(effective_cpuid, effective_cpuid_sha256, "effective CPUID")?; - cpu_profile::verify_profile_record(id, sha256, profile).map_err(profile_error)?; - cpu_profile::EffectiveCpuid::decode(effective_cpuid).map_err(profile_error)?; Ok(()) } @@ -253,8 +253,10 @@ pub struct TimeAbiRestorePreflight { /// identity and clocks. /// /// The snapshot's profile must be pinned in this OpenVMM with the same -/// digest, and `host` must be in its generation. The worker recomputes the -/// effective CPUID and compares it with the record. +/// digest (`E_PROFILE_UNKNOWN`, `E_PROFILE_DIGEST`), and `host` must be in its +/// generation. The embedded document hashes to that digest, so it is the +/// pinned profile's canonical encoding without being decoded. The worker +/// recomputes the effective CPUID and compares it with the record. pub fn preflight_time_abi_restore( contract: &SnapshotMachineContract, hypervisor: &str, @@ -292,8 +294,9 @@ pub fn preflight_time_abi_restore( "the host CPU cannot be identified", ) })?; - cpu_profile::restore_profile(&record.id, &record.sha256, &record.profile, &host) - .map_err(profile_error)?; + let profile = + cpu_profile::pinned_for_restore(&record.id, &record.sha256).map_err(profile_error)?; + cpu_profile::check_generation(profile, &host).map_err(profile_error)?; let capture = capture_record(time)?; let downtime = select_downtime(&capture, destination, now, hooks)?; Ok(TimeAbiRestorePreflight { @@ -435,7 +438,7 @@ pub(super) mod tests { #[test] fn cpu_profile_record_rules() { validate_cpu_profile_record(&test_cpu_profile()).unwrap(); - let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 12] = [ + let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 10] = [ (|r| r.id.clear(), TimeAbiCode::ManifestTime), (|r| r.id = "Intel_SKX".to_owned(), TimeAbiCode::ManifestTime), ( @@ -446,11 +449,6 @@ pub(super) mod tests { |r| r.id = "intel.skylake-sp.kvm.v1.".to_owned(), TimeAbiCode::ManifestTime, ), - // The embedded profile is not the one the record names. - ( - |r| r.id = "intel.skylake-sp.v1".to_owned(), - TimeAbiCode::ProfileDigest, - ), (|r| r.profile.push(0), TimeAbiCode::ProfileDigest), ( |r| { @@ -461,15 +459,6 @@ pub(super) mod tests { ), (|r| r.sha256.truncate(31), TimeAbiCode::ManifestTime), (|r| r.effective_cpuid[0] = 1, TimeAbiCode::ProfileDigest), - // A digest that verifies over bytes that are not the canonical - // encoding. - ( - |r| { - r.effective_cpuid.insert(1, b' '); - r.effective_cpuid_sha256 = sha2::Sha256::digest(&r.effective_cpuid).to_vec(); - }, - TimeAbiCode::ProfileDigest, - ), (|r| r.effective_cpuid.clear(), TimeAbiCode::ManifestTime), ( |r| r.profile = vec![0; MAX_CPU_PROFILE_BYTES + 1], @@ -486,6 +475,18 @@ pub(super) mod tests { record.id ); } + + // Validation does not decode the documents. The restore preflight + // rejects a document that is not the named profile, and the worker an + // effective CPUID record that is not this partition's encoding. + let mut renamed = test_cpu_profile(); + renamed.id = "intel.skylake-sp.v1".to_owned(); + validate_cpu_profile_record(&renamed).unwrap(); + let mut noncanonical = test_cpu_profile(); + noncanonical.effective_cpuid.insert(1, b' '); + noncanonical.effective_cpuid_sha256 = + sha2::Sha256::digest(&noncanonical.effective_cpuid).to_vec(); + validate_cpu_profile_record(&noncanonical).unwrap(); } #[test] @@ -755,6 +756,14 @@ pub(super) mod tests { TimeAbiCode::ProfileDigest ); + // The record names another pinned profile than its document. + let mut renamed = contract.clone(); + renamed.cpu_profile.as_mut().unwrap().id = "intel.skylake-sp.v1".to_owned(); + assert_eq!( + fail(&renamed, "whp", "auto", host, &now, &hooks), + TimeAbiCode::ProfileDigest + ); + let mut exhausted = contract.clone(); exhausted.time.as_mut().unwrap().capture_generation = u32::MAX; assert_eq!( From 87b60625354e31d64c392d75ed0db46038f97c66 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 18:08:32 -0700 Subject: [PATCH 228/304] cpu_profile: parse only the profiles a start needs, and encode directly Every OpenVMM process paid about 4 ms in a release build, by the kvm agent's micro-benchmark, to load the profile catalog: it parsed all three pinned profiles through a JSON value tree, re-encoded each to check its pretty canonical form, and encoded it again to check its golden digest. Each canonical encoding, of a profile or an effective CPUID, cost about 0.4 ms more, because it built and sorted a value tree with an allocation per register value. The kvm agent measured time ABI restores 8.5 to 12.3 ms slower on prometheus32 after the profile integration. - The catalog parses each pinned profile on first use, so a restore, which names its profile, parses one. Parsing goes straight to the struct, and a start no longer re-encodes a pinned profile or checks its golden digest. The tests check every pinned file's ID, pretty canonical form, and golden digest instead. - pinned_for_restore compares the recorded digest with the golden one, without encoding the profile. - Every serialized struct declares its fields in key order, so encode and to_pretty_json serialize directly. Tests check that this gives the same bytes as the sorting encoder, so digests and pinned files are unchanged. - Register values serialize and deserialize without allocating. In a release build on the workstation, loading the whole catalog falls from 19 ms to 0.3 to 0.7 ms in a fresh process, and a restore's pinned_for_restore from about 20 ms to 0.2 to 0.45 ms. Encoding a profile or an effective CPUID falls from 0.34 to 0.4 ms to 19 to 33 us, and decoding one from 0.47 to 0.65 ms to 60 to 125 us. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cpu_profile/examples/derive_profile.rs | 3 +- vmm_core/cpu_profile/src/canonical.rs | 22 +++ vmm_core/cpu_profile/src/catalog.rs | 130 +++++++++++------- vmm_core/cpu_profile/src/cpuid.rs | 11 +- vmm_core/cpu_profile/src/effective.rs | 47 +++++-- vmm_core/cpu_profile/src/hex.rs | 55 +++++++- vmm_core/cpu_profile/src/profile.rs | 107 ++++++++++---- 7 files changed, 283 insertions(+), 92 deletions(-) diff --git a/vmm_core/cpu_profile/examples/derive_profile.rs b/vmm_core/cpu_profile/examples/derive_profile.rs index 8a1c5782f1f..595513d6eae 100644 --- a/vmm_core/cpu_profile/examples/derive_profile.rs +++ b/vmm_core/cpu_profile/examples/derive_profile.rs @@ -9,7 +9,8 @@ //! //! Writes the profile, as pretty canonical JSON, to standard output, and its //! ID and digest to standard error. Pin it under -//! `vmm_core/cpu_profile/profiles/.json` with its digest in the catalog. +//! `vmm_core/cpu_profile/profiles/.json`, with its ID and digest in the +//! catalog's `PINNED` table. use cpu_profile::derive; use cpu_profile::fingerprint::CpuFingerprint; diff --git a/vmm_core/cpu_profile/src/canonical.rs b/vmm_core/cpu_profile/src/canonical.rs index 464453489da..f4cead58bf3 100644 --- a/vmm_core/cpu_profile/src/canonical.rs +++ b/vmm_core/cpu_profile/src/canonical.rs @@ -9,6 +9,7 @@ //! pretty form indents by two spaces and is what fingerprints are written as. //! Both are independent of how `serde_json` orders map keys. +use serde::Serialize; use serde_json::Value; use sha2::Digest; @@ -22,6 +23,27 @@ pub(crate) fn to_compact(value: &Value) -> String { out } +/// Returns the compact canonical encoding of a value whose serialization is +/// already canonical: every object's fields are serialized in the byte order +/// of their keys, and no map is unordered. +/// +/// This serializes directly, without the [`Value`] tree that [`to_compact`] +/// sorts, which costs a few hundred microseconds per profile on every +/// OpenVMM start. Tests check that both give the same bytes for every type +/// that uses it. +pub(crate) fn to_compact_ordered(value: &T) -> Vec { + serde_json::to_vec(value).expect("serialization with string keys is infallible") +} + +/// Returns the pretty canonical encoding of a value whose serialization is +/// already canonical, as for [`to_compact_ordered`], with a final newline. +pub(crate) fn to_pretty_ordered(value: &T) -> String { + let mut out = + serde_json::to_string_pretty(value).expect("serialization with string keys is infallible"); + out.push('\n'); + out +} + /// Returns the pretty canonical encoding of `value`, with a final newline. pub(crate) fn to_pretty(value: &Value) -> String { let mut out = String::new(); diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index 6b803350eed..8f35bfd457c 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -15,53 +15,65 @@ use std::sync::OnceLock; /// generation. pub const AUTO: &str = "auto"; -/// A pinned profile: its pretty canonical JSON and its golden digest. +/// A pinned profile: its ID, its golden digest, and its pretty canonical JSON. struct Pinned { - json: &'static str, + id: &'static str, digest: &'static str, + json: &'static str, } /// The pinned profiles. Released profiles are immutable: a change is a new -/// revision with a new ID, and a test checks every golden digest. -const PINNED: &[Pinned] = &[ +/// revision with a new ID. The tests check every file's ID, pretty canonical +/// form, and golden digest, so an OpenVMM start checks none of them. +const PINNED: [Pinned; 3] = [ Pinned { - json: include_str!("../profiles/intel.skylake-sp.v1.json"), + id: "intel.skylake-sp.v1", digest: "sha256:b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492", + json: include_str!("../profiles/intel.skylake-sp.v1.json"), }, Pinned { - json: include_str!("../profiles/intel.icelake-sp.v1.json"), + id: "intel.icelake-sp.v1", digest: "sha256:a35f3bb9bc30337b8b76af4939d5914f878ac7dc2a2d6d9d6bf4d8278b91f301", + json: include_str!("../profiles/intel.icelake-sp.v1.json"), }, Pinned { - json: include_str!("../profiles/intel.emeraldrapids.v1.json"), + id: "intel.emeraldrapids.v1", digest: "sha256:73c084783f26df29871c72200ea34470e4afec6187bf96de6ac051dbaed6727d", + json: include_str!("../profiles/intel.emeraldrapids.v1.json"), }, ]; -/// Returns every pinned profile. -pub fn pinned_profiles() -> &'static [CpuProfile] { - static CATALOG: OnceLock> = OnceLock::new(); - CATALOG.get_or_init(|| { - PINNED - .iter() - .map(|pinned| { - let profile = CpuProfile::from_pretty_json(pinned.json) - .unwrap_or_else(|error| panic!("invalid pinned CPU profile: {error}")); - assert_eq!( - profile.digest_string(), - pinned.digest, - "pinned CPU profile {} changed, but released profiles are immutable", - profile.id() - ); - profile - }) - .collect() +/// Each pinned profile, parsed on first use, so that a restore, which names +/// its profile, parses only that one. +static PARSED: [OnceLock; PINNED.len()] = [const { OnceLock::new() }; PINNED.len()]; + +/// Returns the pinned profile at `index` in [`PINNED`], parsing and +/// validating it on first use. +fn load(index: usize) -> &'static CpuProfile { + PARSED[index].get_or_init(|| { + let pinned = &PINNED[index]; + let profile = CpuProfile::from_pinned_json(pinned.json) + .unwrap_or_else(|error| panic!("invalid pinned CPU profile {}: {error}", pinned.id)); + assert_eq!(profile.id(), pinned.id, "pinned CPU profile ID mismatch"); + profile }) } +/// Returns the index in [`PINNED`] of the profile `id`. +fn position(id: &str) -> Option { + PINNED.iter().position(|pinned| pinned.id == id) +} + +/// Returns every pinned profile, parsing each on first use. Selecting by ID +/// with [`pinned`] or [`pinned_for_restore`] parses only that profile. +pub fn pinned_profiles() -> &'static [CpuProfile] { + static ALL: OnceLock> = OnceLock::new(); + ALL.get_or_init(|| (0..PINNED.len()).map(|index| load(index).clone()).collect()) +} + /// Returns the pinned profile `id`. pub fn pinned(id: &str) -> Option<&'static CpuProfile> { - pinned_profiles().iter().find(|profile| profile.id() == id) + position(id).map(load) } /// Returns the name of the generation of the host CPU, if a pinned profile @@ -79,11 +91,12 @@ pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { /// host, or when profiles of more than one generation do; there is no host /// CPUID passthrough. pub fn select_auto(host: &HostCpuSignature) -> Result<&'static CpuProfile, ProfileError> { - select_auto_in(pinned_profiles(), host) + let profiles = (0..PINNED.len()).map(load).collect::>(); + select_auto_in(&profiles, host) } fn select_auto_in<'a>( - profiles: &'a [CpuProfile], + profiles: &[&'a CpuProfile], host: &HostCpuSignature, ) -> Result<&'a CpuProfile, ProfileError> { let ids = || { @@ -95,6 +108,7 @@ fn select_auto_in<'a>( }; let matches = profiles .iter() + .copied() .filter(|profile| profile.generation().contains(profile.vendor(), host)) .collect::>(); let mut generations = matches @@ -188,19 +202,22 @@ pub fn verify_profile_record( /// Returns the pinned profile that a snapshot recorded by ID and SHA-256. /// /// Fails with `E_PROFILE_UNKNOWN` when this OpenVMM does not pin the ID, and -/// with `E_PROFILE_DIGEST` when its pinned profile has another digest. +/// with `E_PROFILE_DIGEST` when its pinned profile has another digest. The +/// pinned profile's digest is its golden digest, which the catalog's tests +/// check, so a restore encodes nothing here. pub fn pinned_for_restore(id: &str, sha256: &[u8]) -> Result<&'static CpuProfile, ProfileError> { - let profile = pinned(id).ok_or_else(|| unknown(id))?; - if profile.digest().as_slice() != sha256 { + let index = position(id).ok_or_else(|| unknown(id))?; + let golden = PINNED[index].digest; + if !<&[u8; 32]>::try_from(sha256).is_ok_and(|sha256| canonical::format_digest(sha256) == golden) + { return Err(ProfileError::new( ProfileErrorCode::ProfileDigest, format!( - "the snapshot's CPU profile {id} differs from the pinned profile of the same ID ({})", - profile.digest_string() + "the snapshot's CPU profile {id} differs from the pinned profile of the same ID ({golden})" ), )); } - Ok(profile) + Ok(load(index)) } /// Runs the profile checks of a restore: [`verify_profile_record`], @@ -229,9 +246,9 @@ fn unknown(id: &str) -> ProfileError { } fn pinned_ids() -> String { - pinned_profiles() + PINNED .iter() - .map(|profile| profile.id()) + .map(|pinned| pinned.id) .collect::>() .join(", ") } @@ -266,20 +283,34 @@ mod tests { #[test] fn pins_the_three_generations_with_their_golden_digests() { - let pinned = pinned_profiles() - .iter() - .map(|profile| (profile.id(), profile.digest_string())) - .collect::>(); - let golden = PINNED - .iter() - .zip([ + let ids = PINNED.iter().map(|pinned| pinned.id).collect::>(); + assert_eq!( + ids, + [ "intel.skylake-sp.v1", "intel.icelake-sp.v1", "intel.emeraldrapids.v1", - ]) - .map(|(pinned, id)| (id, pinned.digest.to_owned())) - .collect::>(); - assert_eq!(pinned, golden); + ] + ); + for (index, pinned) in PINNED.iter().enumerate() { + // The checks that an OpenVMM start skips: the file is in pretty + // canonical form, under its ID, with its golden digest. + let profile = CpuProfile::from_pretty_json(pinned.json).unwrap(); + assert_eq!(profile.id(), pinned.id); + assert_eq!(profile.digest_string(), pinned.digest, "{}", pinned.id); + assert_eq!(load(index), &profile); + assert!(std::ptr::eq( + pinned_for_restore(pinned.id, &profile.digest()).unwrap(), + load(index) + )); + } + assert_eq!( + pinned_profiles() + .iter() + .map(CpuProfile::id) + .collect::>(), + ids + ); } #[test] @@ -353,7 +384,9 @@ mod tests { CpuProfile::from_pretty_json(&json).unwrap(), ]; assert_eq!( - select_auto_in(&revisions, &host).unwrap().id(), + select_auto_in(&revisions.iter().collect::>(), &host) + .unwrap() + .id(), "intel.skylake-sp.v10" ); @@ -362,6 +395,7 @@ mod tests { CpuProfile::from_pretty_json(&json).unwrap(), variant("skylake-sp", "skylake-x"), ]; + let ambiguous = ambiguous.iter().collect::>(); let error = select_auto_in(&ambiguous, &host).unwrap_err(); assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown); assert!( diff --git a/vmm_core/cpu_profile/src/cpuid.rs b/vmm_core/cpu_profile/src/cpuid.rs index 162aa80b1e9..f2ab110b128 100644 --- a/vmm_core/cpu_profile/src/cpuid.rs +++ b/vmm_core/cpu_profile/src/cpuid.rs @@ -280,20 +280,23 @@ fn xsave_mask(low: u32, high: u32) -> u64 { } /// One XSAVE state component, from CPUID.(0xd,n) for component `n >= 2`. +/// +/// The fields are declared in the byte order of their keys, so profiles can +/// serialize it directly in canonical form. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct XsaveComponent { + /// The component is 64-byte aligned in the compacted format (`ECX[1]`). + pub align64: bool, /// The component number, which is its bit in XCR0 or IA32_XSS. pub index: u32, - /// The size of the component in bytes, from EAX. - pub size: u32, /// The offset of the component in the standard format, from EBX. It is /// zero for supervisor components, which only the compacted format holds. pub offset: u32, + /// The size of the component in bytes, from EAX. + pub size: u32, /// The component is a supervisor state, enabled in IA32_XSS (`ECX[0]`). pub supervisor: bool, - /// The component is 64-byte aligned in the compacted format (`ECX[1]`). - pub align64: bool, /// The component supports extended feature disable (`ECX[2]`). pub xfd: bool, } diff --git a/vmm_core/cpu_profile/src/effective.rs b/vmm_core/cpu_profile/src/effective.rs index f9b34d240ab..1b13eb8efcc 100644 --- a/vmm_core/cpu_profile/src/effective.rs +++ b/vmm_core/cpu_profile/src/effective.rs @@ -89,11 +89,19 @@ pub struct EffectiveCpuid { entries: Vec, } -#[derive(Serialize, Deserialize)] +#[derive(Deserialize)] #[serde(deny_unknown_fields)] struct EffectiveCpuidDocument { - schema: String, cpuid: Vec, + schema: String, +} + +/// The document [`EffectiveCpuid::encode`] serializes, with its fields in the +/// byte order of their keys, which is the canonical form. +#[derive(Serialize)] +struct EffectiveCpuidDocumentRef<'a> { + cpuid: &'a [CpuidLeafValue], + schema: &'a str, } impl EffectiveCpuid { @@ -115,13 +123,10 @@ impl EffectiveCpuid { /// Returns the canonical encoding: compact canonical JSON. pub fn encode(&self) -> Vec { - let document = EffectiveCpuidDocument { - schema: EFFECTIVE_CPUID_SCHEMA.to_owned(), - cpuid: self.entries.clone(), - }; - let value = - serde_json::to_value(document).expect("effective CPUID serialization is infallible"); - canonical::to_compact(&value).into_bytes() + canonical::to_compact_ordered(&EffectiveCpuidDocumentRef { + cpuid: &self.entries, + schema: EFFECTIVE_CPUID_SCHEMA, + }) } /// Returns the SHA-256 of [`Self::encode`]. @@ -567,6 +572,30 @@ mod tests { ); } + /// `encode` serializes directly, which is canonical only while the + /// document and its entries declare their fields in key order. + #[test] + fn direct_serialization_matches_the_sorting_encoder() { + for profile in crate::pinned_profiles() { + for vp_count in [1, 8] { + let effective = profile + .effective_cpuid(&vm_leaves(profile, vp_count), &identity()) + .unwrap(); + let value = serde_json::to_value(EffectiveCpuidDocumentRef { + cpuid: &effective.entries, + schema: EFFECTIVE_CPUID_SCHEMA, + }) + .unwrap(); + assert_eq!( + String::from_utf8(effective.encode()).unwrap(), + canonical::to_compact(&value), + "{} at {vp_count} VPs", + profile.id() + ); + } + } + } + /// The contract with core: OpenVMM's topology leaves and the time ABI's /// identity leaves complete every pinned profile into an effective CPUID /// that satisfies the CPU time bits and the identity. diff --git a/vmm_core/cpu_profile/src/hex.rs b/vmm_core/cpu_profile/src/hex.rs index 0c7e4378c52..7d2c99bb298 100644 --- a/vmm_core/cpu_profile/src/hex.rs +++ b/vmm_core/cpu_profile/src/hex.rs @@ -67,14 +67,43 @@ impl From for u32 { impl Serialize for Hex32 { fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(self) + serializer.serialize_str(format_hex(&mut [0; 10], self.0.into())) } } impl<'de> Deserialize<'de> for Hex32 { fn deserialize>(deserializer: D) -> Result { - let value = String::deserialize(deserializer)?; - Self::parse(&value).map_err(serde::de::Error::custom) + Ok(Self( + deserializer.deserialize_str(HexVisitor { digits: 8 })? as u32, + )) + } +} + +/// Formats `value` as `0x` and `buffer.len() - 2` hex digits in `buffer`, +/// without the formatting machinery, as profiles hold hundreds of values. +fn format_hex(buffer: &mut [u8], value: u64) -> &str { + let digits = buffer.len() - 2; + buffer[..2].copy_from_slice(b"0x"); + for (i, digit) in buffer[2..].iter_mut().enumerate() { + *digit = b"0123456789abcdef"[((value >> (4 * (digits - 1 - i))) & 0xf) as usize]; + } + std::str::from_utf8(buffer).expect("hex digits are ASCII") +} + +/// Parses a hex string without allocating, as profiles hold hundreds of them. +struct HexVisitor { + digits: usize, +} + +impl serde::de::Visitor<'_> for HexVisitor { + type Value = u64; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "0x followed by up to {} hex digits", self.digits) + } + + fn visit_str(self, value: &str) -> Result { + parse_hex(value, self.digits).map_err(E::custom) } } @@ -109,14 +138,15 @@ impl From for u64 { impl Serialize for Hex64 { fn serialize(&self, serializer: S) -> Result { - serializer.collect_str(self) + serializer.serialize_str(format_hex(&mut [0; 18], self.0)) } } impl<'de> Deserialize<'de> for Hex64 { fn deserialize>(deserializer: D) -> Result { - let value = String::deserialize(deserializer)?; - Self::parse(&value).map_err(serde::de::Error::custom) + Ok(Self( + deserializer.deserialize_str(HexVisitor { digits: 16 })?, + )) } } @@ -132,6 +162,19 @@ mod tests { assert_eq!(Hex32(u32::MAX).to_string(), "0xffffffff"); assert_eq!(Hex64(0x10a).to_string(), "0x000000000000010a"); assert_eq!(Hex64(u64::MAX).to_string(), "0xffffffffffffffff"); + // Serialization formats the same digits as Display. + for value in [0, 0x1f, 0x8000_0000, 0x0123_4567, u32::MAX] { + assert_eq!( + serde_json::to_string(&Hex32(value)).unwrap(), + format!("\"{}\"", Hex32(value)) + ); + } + for value in [0, 0x10a, 0x0123_4567_89ab_cdef, u64::MAX] { + assert_eq!( + serde_json::to_string(&Hex64(value)).unwrap(), + format!("\"{}\"", Hex64(value)) + ); + } } #[test] diff --git a/vmm_core/cpu_profile/src/profile.rs b/vmm_core/cpu_profile/src/profile.rs index ef3cf7276af..2ee3d710845 100644 --- a/vmm_core/cpu_profile/src/profile.rs +++ b/vmm_core/cpu_profile/src/profile.rs @@ -16,6 +16,10 @@ //! their UTF-8 bytes, no whitespace), and the profile digest is its SHA-256. //! Pinned profiles are stored as pretty canonical JSON. Decoding accepts only //! the canonical bytes, so a profile has exactly one encoding and one digest. +//! +//! Every serialized struct declares its fields in the byte order of their +//! keys, so serializing it directly gives the canonical form (tests check +//! this against the sorting encoder). Keep new fields in that order. use crate::Hex32; use crate::Hex64; @@ -49,10 +53,10 @@ const LEAF7_EDX_ARCH_CAPABILITIES: u32 = 1 << 29; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Generation { - /// The generation name used in logs and reports, such as `icelake-sp`. - pub name: String, /// The CPU models of the generation. pub cpus: Vec, + /// The generation name used in logs and reports, such as `icelake-sp`. + pub name: String, } impl Generation { @@ -92,14 +96,14 @@ pub struct GenerationCpu { pub struct CpuidLeafValue { /// The leaf, the input value of `EAX`. pub leaf: Hex32, + /// The bits of each output register that the values define. + pub mask: [Hex32; 4], /// The subleaf, the input value of `ECX`, or `None` for a leaf whose /// output does not depend on `ECX`. pub subleaf: Option, /// The output values of `EAX`, `EBX`, `ECX`, and `EDX`. Bits outside the /// mask are zero. pub value: [Hex32; 4], - /// The bits of each output register that the values define. - pub mask: [Hex32; 4], } impl CpuidLeafValue { @@ -136,10 +140,10 @@ impl CpuidLeafValue { pub struct PinnedMsr { /// The MSR index. pub index: Hex32, - /// The pinned value. Bits outside the mask are zero. - pub value: Hex64, /// The bits the profile pins. The backend defines the others. pub mask: Hex64, + /// The pinned value. Bits outside the mask are zero. + pub value: Hex64, } /// Where a profile came from. Informative; part of the digest like every @@ -170,18 +174,18 @@ pub struct ProvenanceSource { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct CpuProfile { - pub(crate) schema: String, - pub(crate) id: String, + pub(crate) cpuid: Vec, pub(crate) description: String, - pub(crate) vendor: String, pub(crate) generation: Generation, - pub(crate) cpuid: Vec, - pub(crate) xcr0: Hex64, - pub(crate) xss: Hex64, - pub(crate) xsave_components: Vec, - pub(crate) physical_address_width: u32, + pub(crate) id: String, pub(crate) msrs: Vec, + pub(crate) physical_address_width: u32, pub(crate) provenance: Provenance, + pub(crate) schema: String, + pub(crate) vendor: String, + pub(crate) xcr0: Hex64, + pub(crate) xsave_components: Vec, + pub(crate) xss: Hex64, } impl CpuProfile { @@ -260,13 +264,13 @@ impl CpuProfile { /// Returns the canonical encoding: compact canonical JSON. pub fn encode(&self) -> Vec { - canonical::to_compact(&to_value(self)).into_bytes() + canonical::to_compact_ordered(self) } /// Returns the profile as pretty canonical JSON, with a final newline: /// the form in which profiles are pinned. pub fn to_pretty_json(&self) -> String { - canonical::to_pretty(&to_value(self)) + canonical::to_pretty_ordered(self) } /// Returns the profile digest: the SHA-256 of [`Self::encode`]. @@ -309,18 +313,38 @@ impl CpuProfile { Ok(this) } + /// Parses and validates a profile pinned in this crate. Unlike + /// [`Self::from_pretty_json`], it does not re-encode the profile to + /// check its form: the catalog's tests check every pinned file's form and + /// golden digest, and this runs on every OpenVMM start. + pub(crate) fn from_pinned_json(text: &str) -> Result { + Self::parse(text) + } + fn parse(text: &str) -> Result { - let value: serde_json::Value = serde_json::from_str(text) - .map_err(|error| invalid(format!("malformed profile: {error}")))?; - let schema = value.get("schema").and_then(|schema| schema.as_str()); - if schema != Some(SCHEMA) { - return Err(invalid(format!( + let schema_error = |schema: Option<&str>| { + invalid(format!( "unsupported profile schema {:?}, expected {SCHEMA:?}", schema.unwrap_or_default() - ))); + )) + }; + let this: Self = match serde_json::from_str(text) { + Ok(this) => this, + Err(error) => { + // Name an unsupported schema rather than the field that a + // document of another schema lacks. + let value: serde_json::Value = serde_json::from_str(text) + .map_err(|error| invalid(format!("malformed profile: {error}")))?; + let schema = value.get("schema").and_then(|schema| schema.as_str()); + if schema != Some(SCHEMA) { + return Err(schema_error(schema)); + } + return Err(invalid(format!("malformed profile: {error}"))); + } + }; + if this.schema != SCHEMA { + return Err(schema_error(Some(&this.schema))); } - let this: Self = serde_json::from_value(value) - .map_err(|error| invalid(format!("malformed profile: {error}")))?; this.validate() .map_err(|message| invalid(format!("profile {:?} is invalid: {message}", this.id)))?; Ok(this) @@ -660,6 +684,7 @@ fn invalid(message: String) -> ProfileError { ProfileError::new(ProfileErrorCode::ProfileDigest, message) } +#[cfg(test)] fn to_value(value: &T) -> serde_json::Value { // Every field serializes infallibly with string map keys. serde_json::to_value(value).expect("profile serialization is infallible") @@ -716,6 +741,40 @@ mod tests { } } + /// The direct serialization that `encode` and `to_pretty_json` use is + /// canonical only while every struct declares its fields in key order. + #[test] + fn direct_serialization_matches_the_sorting_encoder() { + for profile in crate::pinned_profiles() { + let value = to_value(profile); + assert_eq!( + String::from_utf8(profile.encode()).unwrap(), + canonical::to_compact(&value), + "{}", + profile.id() + ); + assert_eq!( + profile.to_pretty_json(), + canonical::to_pretty(&value), + "{}", + profile.id() + ); + } + // Fields that the pinned profiles leave at one value. + let component = XsaveComponent { + align64: true, + index: 12, + offset: 0, + size: 24, + supervisor: true, + xfd: true, + }; + assert_eq!( + canonical::to_compact_ordered(&component), + canonical::to_compact(&to_value(&component)).into_bytes() + ); + } + #[test] fn decoding_rejects_every_non_canonical_form() { let profile = profile(ICELAKE); From 11a8ef1bb807dfcb8264c877c5f05dea32458f8d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 18:22:16 -0700 Subject: [PATCH 229/304] virt: add the E_CPU_UNLISTED time ABI code The orchestrator decided that CPUID entries reachable only by probing past an advertised maximum are reserved, and that profile verification fails a host whose hypervisor presents a non-zero entry outside the profile's tables, where MSHV and WHP pass such entries through. Add the stable code that check reports, after E_CPU_SURFACE in specification order, so the profiles crate can map its new error to it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt/src/time_abi/mod.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index 83685e593e5..5ba6c51eb0a 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -105,6 +105,9 @@ time_abi_codes! { ProfileUnsupported => "E_PROFILE_UNSUPPORTED", /// The recomputed effective CPUID differs from the recorded one. CpuSurface => "E_CPU_SURFACE", + /// The hypervisor presents a non-zero CPUID entry outside the profile's + /// tables, which would expose a host feature in a reserved entry. + CpuUnlisted => "E_CPU_UNLISTED", /// The backend cannot deliver the identity CPUID or MSRs. IdentityRouting => "E_IDENTITY_ROUTING", /// The backend lacks the synchronized TSC set. @@ -435,6 +438,7 @@ mod tests { "E_CPU_GENERATION", "E_PROFILE_UNSUPPORTED", "E_CPU_SURFACE", + "E_CPU_UNLISTED", "E_IDENTITY_ROUTING", "E_TSC_SYNC_UNSUPPORTED", "E_TSC_SCALING_ACTIVE", From 1779ce44651b7483a7c99078ac1297113309cbc6 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 18:32:00 -0700 Subject: [PATCH 230/304] cpu_profile: select a profile without parsing the others select(auto), which every cold boot runs, parsed all three pinned profiles to match the host against their generations, and kept the cold boot path about twice the restore path, which parses one. Each pinned entry now names its generation from derive's KNOWN_GENERATIONS, and selection matches the host against those, then parses only the profiles that cover it. A test checks that each entry's generation is its profile's, and that both cover the same signatures across families, models, steppings, and vendors. In a fresh process, in a release build on the workstation, select(auto) plus building and encoding the effective CPUID now takes 258 to 466 us (median 273), and the restore path (pinned_for_restore, check_generation, effective_cpuid, encode) 251 to 408 us (median 305). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/cpu_profile/src/catalog.rs | 114 ++++++++++++++++++++++------ 1 file changed, 92 insertions(+), 22 deletions(-) diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index 8f35bfd457c..0f23ce02be9 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -5,46 +5,57 @@ //! profile checks of cold boot and restore. use crate::canonical; +use crate::derive::KNOWN_GENERATIONS; +use crate::derive::KnownGeneration; use crate::error::ProfileError; use crate::error::ProfileErrorCode; use crate::profile::CpuProfile; use crate::signature::HostCpuSignature; +use crate::signature::decode_signature; use std::sync::OnceLock; /// The `--cpu-profile` value that selects the profile of the host's /// generation. pub const AUTO: &str = "auto"; -/// A pinned profile: its ID, its golden digest, and its pretty canonical JSON. +/// A pinned profile: its ID, its generation, its golden digest, and its +/// pretty canonical JSON. struct Pinned { id: &'static str, + /// The generation that the profile serves, so that selection parses only + /// the profile it selects. + generation: &'static KnownGeneration, digest: &'static str, json: &'static str, } /// The pinned profiles. Released profiles are immutable: a change is a new -/// revision with a new ID. The tests check every file's ID, pretty canonical -/// form, and golden digest, so an OpenVMM start checks none of them. +/// revision with a new ID. The tests check every file's ID, generation, +/// pretty canonical form, and golden digest, so an OpenVMM start checks none +/// of them. const PINNED: [Pinned; 3] = [ Pinned { id: "intel.skylake-sp.v1", + generation: &KNOWN_GENERATIONS[0], digest: "sha256:b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492", json: include_str!("../profiles/intel.skylake-sp.v1.json"), }, Pinned { id: "intel.icelake-sp.v1", + generation: &KNOWN_GENERATIONS[1], digest: "sha256:a35f3bb9bc30337b8b76af4939d5914f878ac7dc2a2d6d9d6bf4d8278b91f301", json: include_str!("../profiles/intel.icelake-sp.v1.json"), }, Pinned { id: "intel.emeraldrapids.v1", + generation: &KNOWN_GENERATIONS[2], digest: "sha256:73c084783f26df29871c72200ea34470e4afec6187bf96de6ac051dbaed6727d", json: include_str!("../profiles/intel.emeraldrapids.v1.json"), }, ]; -/// Each pinned profile, parsed on first use, so that a restore, which names -/// its profile, parses only that one. +/// Each pinned profile, parsed on first use, so that a cold boot or a +/// restore parses only its own. static PARSED: [OnceLock; PINNED.len()] = [const { OnceLock::new() }; PINNED.len()]; /// Returns the pinned profile at `index` in [`PINNED`], parsing and @@ -85,27 +96,41 @@ pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { } /// Selects the profile of the host CPU's generation: its latest pinned -/// revision. +/// revision. Only the profiles whose generation covers the host are parsed. /// /// Fails with `E_PROFILE_HOST_UNKNOWN` when no pinned profile serves the /// host, or when profiles of more than one generation do; there is no host /// CPUID passthrough. pub fn select_auto(host: &HostCpuSignature) -> Result<&'static CpuProfile, ProfileError> { - let profiles = (0..PINNED.len()).map(load).collect::>(); - select_auto_in(&profiles, host) + let candidates = (0..PINNED.len()) + .filter(|&index| covers(PINNED[index].generation, host)) + .map(load) + .collect::>(); + select_auto_in(&candidates, host, pinned_ids) } +/// Returns whether `generation` covers the host CPU, as +/// [`Generation::contains`](crate::Generation::contains) does for the +/// generation of a parsed profile. +fn covers(generation: &KnownGeneration, host: &HostCpuSignature) -> bool { + let vendor = generation.vendor.as_bytes(); + let (family, model, stepping) = decode_signature(vendor, host.signature()); + host.vendor().as_slice() == vendor + && generation + .cpus + .iter() + .any(|&(cpu_family, cpu_model, [first, last])| { + (cpu_family, cpu_model) == (family, model) && (first..=last).contains(&stepping) + }) +} + +/// Selects among `profiles`, as [`select_auto`] does; `ids` lists every +/// pinned profile for the failure messages. fn select_auto_in<'a>( profiles: &[&'a CpuProfile], host: &HostCpuSignature, + ids: impl Fn() -> String, ) -> Result<&'a CpuProfile, ProfileError> { - let ids = || { - profiles - .iter() - .map(|profile| profile.id()) - .collect::>() - .join(", ") - }; let matches = profiles .iter() .copied() @@ -294,9 +319,21 @@ mod tests { ); for (index, pinned) in PINNED.iter().enumerate() { // The checks that an OpenVMM start skips: the file is in pretty - // canonical form, under its ID, with its golden digest. + // canonical form, under its ID and generation, with its golden + // digest. let profile = CpuProfile::from_pretty_json(pinned.json).unwrap(); assert_eq!(profile.id(), pinned.id); + assert_eq!(profile.vendor(), pinned.generation.vendor); + assert_eq!(profile.generation().name, pinned.generation.name); + assert_eq!( + profile + .generation() + .cpus + .iter() + .map(|cpu| (cpu.family, cpu.model, cpu.steppings)) + .collect::>(), + pinned.generation.cpus + ); assert_eq!(profile.digest_string(), pinned.digest, "{}", pinned.id); assert_eq!(load(index), &profile); assert!(std::ptr::eq( @@ -378,13 +415,14 @@ mod tests { let variant = |from: &str, to: &str| CpuProfile::from_pretty_json(&json.replace(from, to)).unwrap(); let host = intel(SKYLAKE); + let ids = || "the test's".to_owned(); let revisions = [ variant("intel.skylake-sp.v1", "intel.skylake-sp.v2"), variant("intel.skylake-sp.v1", "intel.skylake-sp.v10"), CpuProfile::from_pretty_json(&json).unwrap(), ]; assert_eq!( - select_auto_in(&revisions.iter().collect::>(), &host) + select_auto_in(&revisions.iter().collect::>(), &host, ids) .unwrap() .id(), "intel.skylake-sp.v10" @@ -396,20 +434,52 @@ mod tests { variant("skylake-sp", "skylake-x"), ]; let ambiguous = ambiguous.iter().collect::>(); - let error = select_auto_in(&ambiguous, &host).unwrap_err(); + let error = select_auto_in(&ambiguous, &host, ids).unwrap_err(); assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown); assert!( - error - .message - .contains("is in more than one CPU generation (skylake-sp, skylake-x)"), + error.message.contains( + "is in more than one CPU generation (skylake-sp, skylake-x); \ + pinned profiles: the test's" + ), "{error}" ); assert_eq!( - code(select_auto_in(&ambiguous, &intel(ICELAKE))), + code(select_auto_in(&ambiguous, &intel(ICELAKE), ids)), ProfileErrorCode::ProfileHostUnknown ); } + /// Selection matches the host against each pinned entry's generation, to + /// parse only the profile it selects; that agrees with the profiles' own + /// generations. + #[test] + fn the_static_generations_cover_what_the_profiles_do() { + for (index, pinned) in PINNED.iter().enumerate() { + let profile = load(index); + for vendor in [*b"GenuineIntel", *b"AuthenticAMD"] { + for (family, model) in [(6u32, 85u32), (6, 106), (6, 143), (6, 207), (25, 17)] { + for stepping in 0..16 { + // Encode the display family and model as CPUID.1:EAX does. + let extended_family = family.saturating_sub(15); + let base_family = family - extended_family; + let signature = extended_family << 20 + | (model >> 4) << 16 + | base_family << 8 + | (model & 0xf) << 4 + | stepping; + let host = HostCpuSignature::new(vendor, signature); + assert_eq!( + covers(pinned.generation, &host), + profile.generation().contains(profile.vendor(), &host), + "{} on {host}", + pinned.id + ); + } + } + } + } + } + #[test] fn explicit_selection_checks_the_id_and_the_generation() { let host = intel(ICELAKE); From 1127ab18cae0b24e6684093cd241486e24c92f5b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 17:03:27 -0700 Subject: [PATCH 231/304] cpu_profile: fail pass-through backends that present unlisted CPUID entries KVM installs a VM's effective CPUID as the guest's whole table, so every entry it does not list reads zero. MSHV and WHP program it over the hypervisor's own CPUID, so every other entry reads the hypervisor's value. The time ABI reserves the entries past a profile's advertised maxima, and the orchestrator's "(A) plus verification" requires a pass-through backend to present zero at every entry outside the profile's tables, other than the identity range and the VM-owned topology leaves. The check fails with E_CPU_UNLISTED, the code that d69897db3 added. HostCpuSurface gains a required CpuidPresentation, so every surface states its backend kind explicitly: - Table: KVM. Nothing to check. - PassThroughGuestView: what a probe partition presents, as --cpu-fingerprint records it for MSHV and WHP. verify_support fails on every non-zero unlisted entry, after any support violation. - PassThroughHostView: the cheap supported_cpu_surface() of MSHV and WHP, built from the host's CPUID. The root partition sees entries that guests never do: on Skylake-SP, both roots read Intel PT's subleaf 0x14.1 as non-zero, so checking it would fail every boot. verify_support skips the check there, as the specification leaves reserved entries unchecked at boot. check_unlisted_cpuid checks a guest's view directly, and unlisted_cpuid_candidates lists the host entries where a backend could read VP 0's view for it. All 18 fleet fingerprints still pass. Each of the 12 MSHV and WHP fingerprints has six unlisted entries (0xf.1, 0x10.1 to 0x10.3, 0x12.1, and 0x12.2), all zero. Injecting the root's 0x14.1 into the prometheus28 fingerprint fails it with E_CPU_UNLISTED. Core marks its surfaces Table for KVM and PassThroughHostView otherwise, which keeps today's boot behaviour, and the MSHV and WHP test literals take the new field. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 11 +- .../src/worker/dispatch/time_abi.rs | 15 +- openvmm/openvmm_entry/src/cli_args.rs | 7 +- openvmm/openvmm_entry/src/cpu_fingerprint.rs | 6 +- vmm_core/cpu_profile/src/check.rs | 41 ++- vmm_core/cpu_profile/src/error.rs | 9 +- vmm_core/cpu_profile/src/hv_banks.rs | 6 +- vmm_core/cpu_profile/src/lib.rs | 10 +- vmm_core/cpu_profile/src/surface.rs | 146 +++++++++- vmm_core/cpu_profile/src/unlisted.rs | 251 ++++++++++++++++++ .../virt_mshv/src/x86_64/profile_features.rs | 2 + vmm_core/virt_mshv/src/x86_64/time_abi.rs | 1 + vmm_core/virt_whp/src/profile_features.rs | 1 + 13 files changed, 482 insertions(+), 24 deletions(-) create mode 100644 vmm_core/cpu_profile/src/unlisted.rs diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 35147ec9135..a2ea87d9523 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -495,9 +495,14 @@ guest state and protect the directory with host access controls. If no pinned profile serves the host, or the backend does not support every feature, limit, XSAVE layout, and MSR value of the profile, the line has `status=fail`, `code`, and `detail`, and OpenVMM exits with status 1 and the - same code (`E_PROFILE_HOST_UNKNOWN` or `E_PROFILE_UNSUPPORTED`). The - fingerprint is still written, so hosts of new generations can be - fingerprinted to derive their profiles. + same code (`E_PROFILE_HOST_UNKNOWN` or `E_PROFILE_UNSUPPORTED`). On MSHV and + WHP, which present the hypervisor's own value at every CPUID entry that the + profile does not list, a probe partition that reads non-zero at such an + entry fails with `E_CPU_UNLISTED`; the identity range + `0x40000000` to `0x4fffffff` and the topology leaves `0xb` and `0x1f` are + exempt. KVM installs the profile as the guest's whole CPUID table, so every + other entry reads zero. The fingerprint is still written, so hosts of new + generations can be fingerprinted to derive their profiles. ```bash openvmm --hypervisor kvm --cpu-fingerprint host.json diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index d6a19f25b8a..09bc67a1a32 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -359,12 +359,17 @@ pub(super) fn check_profile_support( ), ) })?; - cpu_profile::verify_support(profile, &host_cpu_surface(&surface))?; + cpu_profile::verify_support(profile, &host_cpu_surface(&surface, hypervisor))?; Ok(()) } -/// Converts a backend's CPU surface to the profile crate's form. -fn host_cpu_surface(surface: &SupportedCpuSurface) -> cpu_profile::HostCpuSurface { +/// Converts a backend's CPU surface to the profile crate's form. KVM's is a +/// table; MSHV and WHP build theirs from the host's CPUID, which does not show +/// what a guest reads outside the profile's tables. +fn host_cpu_surface( + surface: &SupportedCpuSurface, + hypervisor: &str, +) -> cpu_profile::HostCpuSurface { let mut cpuid = surface .cpuid .iter() @@ -373,6 +378,10 @@ fn host_cpu_surface(surface: &SupportedCpuSurface) -> cpu_profile::HostCpuSurfac cpu_profile::cpuid::normalize(&mut cpuid); cpu_profile::HostCpuSurface { cpuid, + presentation: match hypervisor { + "kvm" => cpu_profile::CpuidPresentation::Table, + _ => cpu_profile::CpuidPresentation::PassThroughHostView, + }, physical_address_width: surface.physical_address_width, msrs: surface .msrs diff --git a/openvmm/openvmm_entry/src/cli_args.rs b/openvmm/openvmm_entry/src/cli_args.rs index 83415fcfe5c..c879d4689ac 100644 --- a/openvmm/openvmm_entry/src/cli_args.rs +++ b/openvmm/openvmm_entry/src/cli_args.rs @@ -1194,9 +1194,10 @@ guest; the backend may create, and then destroys, a transient probe partition. After writing the fingerprint, OpenVMM checks it against the pinned CPU profile of the host's generation and prints one "NVX-CPU-PROFILE:" line to stderr. It -exits with status 1 and the failure code (E_PROFILE_HOST_UNKNOWN or -E_PROFILE_UNSUPPORTED) if no profile serves the host or the backend does not -support it. +exits with status 1 and the failure code (E_PROFILE_HOST_UNKNOWN, +E_PROFILE_UNSUPPORTED, or E_CPU_UNLISTED) if no profile serves the +host, the backend does not support it, or MSHV or WHP presents a non-zero CPUID +entry outside the profile's tables. Examples: --hypervisor kvm --cpu-fingerprint host.json diff --git a/openvmm/openvmm_entry/src/cpu_fingerprint.rs b/openvmm/openvmm_entry/src/cpu_fingerprint.rs index 432f36fcbd8..5a674332378 100644 --- a/openvmm/openvmm_entry/src/cpu_fingerprint.rs +++ b/openvmm/openvmm_entry/src/cpu_fingerprint.rs @@ -15,9 +15,9 @@ use std::path::Path; /// `-`, and checks it against the CPU profile of the host's generation. /// /// The check prints one `NVX-CPU-PROFILE:` line to stderr and fails with its -/// code (`E_PROFILE_HOST_UNKNOWN` or `E_PROFILE_UNSUPPORTED`) after the -/// fingerprint is written, so hosts of new generations can still be -/// fingerprinted. +/// code (`E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNSUPPORTED`, or, for MSHV and +/// WHP, `E_CPU_UNLISTED`) after the fingerprint is written, so hosts +/// of new generations can still be fingerprinted. pub(crate) fn write(path: &Path, hypervisor: Option<&str>) -> anyhow::Result<()> { let backend = openvmm_helpers::hypervisor::cpu_fingerprint(hypervisor) .context("failed to fingerprint the hypervisor backend")?; diff --git a/vmm_core/cpu_profile/src/check.rs b/vmm_core/cpu_profile/src/check.rs index e270dece106..331f25fd30a 100644 --- a/vmm_core/cpu_profile/src/check.rs +++ b/vmm_core/cpu_profile/src/check.rs @@ -67,7 +67,9 @@ impl FingerprintCheck { /// Checks a host fingerprint: selects the profile of the host's generation /// (`E_PROFILE_HOST_UNKNOWN`) and verifies that the backend supports it -/// (`E_PROFILE_UNSUPPORTED`). +/// (`E_PROFILE_UNSUPPORTED`) and, for MSHV and WHP, whose fingerprints record +/// a probe partition's view, that it presents zero at every CPUID entry +/// outside the profile's tables (`E_CPU_UNLISTED`). pub fn check_fingerprint(fingerprint: &CpuFingerprint) -> FingerprintCheck { let cpu = &fingerprint.host.cpu; let mut vendor = [0; 12]; @@ -170,4 +172,41 @@ mod tests { check.summary_line(&no_avx512) ); } + + #[test] + fn fails_pass_through_hosts_that_present_unlisted_entries() { + let profile = profile("intel.skylake-sp.v1"); + // The RDT monitoring subleaf, which the profile does not list. + let mut entries = profile_entries(profile); + entries.push(CpuidEntry::new(0xf, Some(1), [0, 0xb, 0x6f, 0x7])); + entries.sort_by_key(CpuidEntry::key); + for backend in ["mshv", "whp"] { + let fingerprint = fingerprint_with(profile, backend, entries.clone()); + let check = check_fingerprint(&fingerprint); + assert_eq!( + check.result.as_ref().unwrap_err().code, + ProfileErrorCode::CpuUnlisted + ); + let line = check.summary_line(&fingerprint); + assert!( + line.starts_with(&format!( + "NVX-CPU-PROFILE: status=fail backend={backend} generation=skylake-sp \ + profile=intel.skylake-sp.v1 profile_digest=" + )), + "{line}" + ); + assert!( + line.ends_with( + " code=E_CPU_UNLISTED detail=\"the backend presents CPUID entries \ + outside CPU profile intel.skylake-sp.v1: CPUID 0xf.1 is outside the profile \ + and reads EAX 0x0, EBX 0xb, ECX 0x6f, EDX 0x7\"" + ), + "{line}" + ); + } + + // KVM's table makes every unlisted entry read zero. + let fingerprint = fingerprint_with(profile, "kvm", entries); + check_fingerprint(&fingerprint).result.unwrap(); + } } diff --git a/vmm_core/cpu_profile/src/error.rs b/vmm_core/cpu_profile/src/error.rs index 2f9a21f7faa..c9b848959ad 100644 --- a/vmm_core/cpu_profile/src/error.rs +++ b/vmm_core/cpu_profile/src/error.rs @@ -26,17 +26,21 @@ pub enum ProfileErrorCode { /// `E_CPU_SURFACE`: an effective CPUID differs from the recorded one, or /// the VM's CPUID inputs cannot complete the profile. CpuSurface, + /// `E_CPU_UNLISTED`: a pass-through backend presents a non-zero CPUID + /// entry outside the profile's tables. + CpuUnlisted, } impl ProfileErrorCode { - /// Every code. - pub const ALL: [Self; 6] = [ + /// Every code, in specification order. + pub const ALL: [Self; 7] = [ Self::ProfileUnknown, Self::ProfileDigest, Self::ProfileHostUnknown, Self::CpuGeneration, Self::ProfileUnsupported, Self::CpuSurface, + Self::CpuUnlisted, ]; /// Returns the code as the specification spells it, such as @@ -49,6 +53,7 @@ impl ProfileErrorCode { Self::CpuGeneration => "E_CPU_GENERATION", Self::ProfileUnsupported => "E_PROFILE_UNSUPPORTED", Self::CpuSurface => "E_CPU_SURFACE", + Self::CpuUnlisted => "E_CPU_UNLISTED", } } } diff --git a/vmm_core/cpu_profile/src/hv_banks.rs b/vmm_core/cpu_profile/src/hv_banks.rs index 5746906bd9b..80f76ad717e 100644 --- a/vmm_core/cpu_profile/src/hv_banks.rs +++ b/vmm_core/cpu_profile/src/hv_banks.rs @@ -547,7 +547,11 @@ pub fn profile_features( /// DS, ACPI, HTT, PT, TME, the hybrid bit, the topology fields, and leaves 5, /// 6, `0xA`, `0x14`, and `0x15`. They are harmless to /// [`verify_support`](crate::verify_support): no profile sets them, except -/// ARAT in leaf 6, a time policy bit that support checks exempt. +/// ARAT in leaf 6, a time policy bit that support checks exempt. The entries +/// outside a profile's tables over-claim too: on Skylake-SP, the root of both +/// MSHV and WHP reads Intel PT's subleaf `0x14.1` as non-zero, though neither +/// gives guests PT. So a surface built here is a +/// [`CpuidPresentation::PassThroughHostView`](crate::CpuidPresentation::PassThroughHostView). /// The surface's guest physical address width must come from the hypervisor, /// not from this CPUID (see [`HostCpuSurface`](crate::HostCpuSurface)). pub fn restrict_cpuid_to_features(cpuid: &mut [CpuidEntry], available: HvFeatures) { diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index cb740c25df6..fd3cef73d1e 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -17,7 +17,10 @@ //! CPU ([`HostCpuSignature`]) to a pinned profile. //! - **Verification:** each backend reports the CPU surface it supports //! ([`HostCpuSurface`]), and [`verify_support`] checks that it covers the -//! profile. +//! profile. A pass-through backend (MSHV, WHP) must also present zero at +//! every CPUID entry outside the profile's tables +//! ([`check_unlisted_cpuid`]), which KVM does by construction +//! ([`CpuidPresentation`]). //! - **Restore:** [`restore_profile`] checks a snapshot's profile record, //! and [`EffectiveCpuid::check_matches`] its effective CPUID. //! @@ -48,6 +51,7 @@ mod signature; mod surface; #[cfg(test)] mod test_support; +mod unlisted; pub use catalog::AUTO; pub use catalog::check_generation; @@ -84,8 +88,12 @@ pub use profile::pinned_mask; pub use profile::runtime_owned_bits; pub use profile::vm_owned_bits; pub use signature::HostCpuSignature; +pub use surface::CpuidPresentation; pub use surface::HostCpuSurface; pub use surface::SupportedMsr; pub use surface::TIME_POLICY_BITS; pub use surface::support_violations; pub use surface::verify_support; +pub use unlisted::check_unlisted_cpuid; +pub use unlisted::unlisted_cpuid_candidates; +pub use unlisted::unlisted_cpuid_violations; diff --git a/vmm_core/cpu_profile/src/surface.rs b/vmm_core/cpu_profile/src/surface.rs index eb353fb2134..88d65fcdac6 100644 --- a/vmm_core/cpu_profile/src/surface.rs +++ b/vmm_core/cpu_profile/src/surface.rs @@ -13,6 +13,8 @@ use crate::fingerprint::IA32_ARCH_CAPABILITIES; use crate::hv_banks; use crate::profile::CpuProfile; use crate::profile::describe; +use crate::unlisted::unlisted_cpuid_error; +use crate::unlisted::unlisted_cpuid_violations; /// The register names, for messages. const REGISTERS: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"]; @@ -23,9 +25,14 @@ const REGISTERS: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"]; pub struct HostCpuSurface { /// Every CPUID leaf and subleaf the backend can expose, with every /// feature it can expose set, normalized with [`cpuid::normalize`]. KVM - /// reports `KVM_GET_SUPPORTED_CPUID`; MSHV and WHP report a partition with - /// every available processor and XSAVE feature enabled. + /// reports `KVM_GET_SUPPORTED_CPUID`. A CPU fingerprint of MSHV or WHP + /// records a probe partition with every available processor and XSAVE + /// feature enabled, and their `supported_cpu_surface()` approximates it + /// from the host's CPUID and processor features. pub cpuid: Vec, + /// How the backend presents the CPUID entries outside a profile's + /// tables, and what `cpuid` holds there. + pub presentation: CpuidPresentation, /// The widest guest physical address the backend supports, in bits. /// /// Support checks use this rather than the CPUID's `0x80000008:EAX[7:0]`: @@ -37,6 +44,35 @@ pub struct HostCpuSurface { pub msrs: Vec, } +/// How a backend presents the CPUID entries that a VM's effective CPUID does +/// not list, and what a [`HostCpuSurface`] holds there, which decides whether +/// [`verify_support`] checks them (`E_CPU_UNLISTED`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CpuidPresentation { + /// A table backend, KVM: `KVM_SET_CPUID2` installs the effective CPUID as + /// the guest's whole CPUID table, and every entry it does not list reads + /// zero. The surface lists what the backend can support, not what a + /// guest reads, so there is nothing to check. + Table, + /// A pass-through backend, MSHV or WHP, which programs the effective + /// CPUID over the hypervisor's own: every entry the effective CPUID does + /// not list reads the hypervisor's value. The surface is what the + /// hypervisor presents to a guest, as a probe partition enumerates it for + /// `--cpu-fingerprint`, so every non-zero entry outside the profile's + /// tables fails the check. + PassThroughGuestView, + /// A pass-through backend whose surface comes from the host's own CPUID, + /// as the cheap `supported_cpu_surface()` of MSHV and WHP builds it. + /// Outside the profile's tables, the host's view is not the guest's: the + /// root partition sees entries that guests never do, such as Intel PT's + /// subleaf `0x14.1` on Skylake-SP. [`verify_support`] skips the check, + /// which [`check_unlisted_cpuid`](crate::check_unlisted_cpuid) makes on VP + /// 0's view at the host's + /// [`unlisted_cpuid_candidates`](crate::unlisted_cpuid_candidates) + /// instead. + PassThroughHostView, +} + /// The values of one MSR that a backend can present to a guest. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SupportedMsr { @@ -54,7 +90,9 @@ impl HostCpuSurface { /// Returns the surface that a CPU fingerprint recorded. /// /// `IA32_ARCH_CAPABILITIES` comes from the KVM feature MSR, or, for the - /// Hyper-V backends, from the processor feature banks. + /// Hyper-V backends, from the processor feature banks. A KVM fingerprint + /// is a [`CpuidPresentation::Table`] surface; any other records a probe + /// partition, a [`CpuidPresentation::PassThroughGuestView`]. pub fn from_fingerprint(backend: &BackendFingerprint) -> Self { let width = cpuid::lookup(&backend.cpuid, EXTENDED_LEAF_BASE + 8, 0).unwrap_or_default()[0]; let mut msrs = Vec::new(); @@ -69,6 +107,10 @@ impl HostCpuSurface { } Self { cpuid: backend.cpuid.clone(), + presentation: match backend.name.as_str() { + "kvm" => CpuidPresentation::Table, + _ => CpuidPresentation::PassThroughGuestView, + }, physical_address_width: (width & 0xff) as u8, msrs, } @@ -123,22 +165,41 @@ pub(crate) fn register_class(leaf: u32, subleaf: u32, register: usize) -> Regist /// Checks that the backend described by `surface` supports `profile`: every /// feature bit the profile sets except the [`TIME_POLICY_BITS`], every /// numeric limit, the exact XSAVE layout of every enabled component, the -/// guest physical address width, and every pinned MSR value. +/// guest physical address width, and every pinned MSR value. For a +/// [`CpuidPresentation::PassThroughGuestView`] surface, it also checks that +/// every entry outside the profile's tables reads zero, as +/// [`check_unlisted_cpuid`](crate::check_unlisted_cpuid) does. /// /// Fails with `E_PROFILE_UNSUPPORTED`, naming every violation at once: each /// missing leaf, subleaf, register, and bit, as [`support_violations`] lists -/// them. +/// them, then each non-zero entry outside the profile's tables. A surface +/// whose only violations are such entries fails with +/// `E_CPU_UNLISTED`. pub fn verify_support(profile: &CpuProfile, surface: &HostCpuSurface) -> Result<(), ProfileError> { let violations = support_violations(profile, surface); + let unlisted = match surface.presentation { + CpuidPresentation::PassThroughGuestView => { + unlisted_cpuid_violations(profile, &surface.cpuid) + } + CpuidPresentation::Table | CpuidPresentation::PassThroughHostView => Vec::new(), + }; if violations.is_empty() { - return Ok(()); + if unlisted.is_empty() { + return Ok(()); + } + return Err(unlisted_cpuid_error(profile, &unlisted)); } Err(ProfileError::new( ProfileErrorCode::ProfileUnsupported, format!( "the backend does not support CPU profile {}: {}", profile.id(), - violations.join("; ") + violations + .iter() + .chain(&unlisted) + .map(String::as_str) + .collect::>() + .join("; ") ), )) } @@ -287,7 +348,9 @@ fn bits_phrase(bits: u64) -> String { mod tests { use super::*; use crate::test_support::fingerprint; + use crate::test_support::fingerprint_with; use crate::test_support::profile; + use crate::test_support::profile_entries; use test_with_tracing::test; const ICELAKE: &str = "intel.icelake-sp.v1"; @@ -325,9 +388,78 @@ mod tests { fn every_pinned_profile_supports_itself() { for profile in crate::pinned_profiles() { verify_support(profile, &surface(profile)).unwrap(); + for backend in ["mshv", "whp"] { + let surface = + HostCpuSurface::from_fingerprint(&fingerprint(profile, backend).backend); + assert_eq!( + surface.presentation, + CpuidPresentation::PassThroughGuestView + ); + verify_support(profile, &surface).unwrap(); + } } } + #[test] + fn checks_unlisted_entries_of_a_pass_through_guest_view_only() { + let profile = profile(ICELAKE); + // Intel PT's subleaf 1, which the profile does not list, as the root + // partition of a Skylake-SP host reads it. + let mut entries = profile_entries(profile); + entries.push(CpuidEntry::new( + 0x14, + Some(1), + [0x0249_0002, 0x003f_3fff, 0, 0], + )); + entries.sort_by_key(CpuidEntry::key); + const PT: &str = "CPUID 0x14.1 is outside the profile and reads EAX 0x2490002, \ + EBX 0x3f3fff, ECX 0x0, EDX 0x0"; + + let whp = HostCpuSurface::from_fingerprint( + &fingerprint_with(profile, "whp", entries.clone()).backend, + ); + let error = verify_support(profile, &whp).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert_eq!( + error.message, + format!( + "the backend presents CPUID entries outside CPU profile intel.icelake-sp.v1: {PT}" + ) + ); + assert!(support_violations(profile, &whp).is_empty()); + + // KVM installs the effective CPUID as the guest's whole table. + let kvm = HostCpuSurface::from_fingerprint( + &fingerprint_with(profile, "kvm", entries.clone()).backend, + ); + assert_eq!(kvm.presentation, CpuidPresentation::Table); + verify_support(profile, &kvm).unwrap(); + + // The host's own view does not show what a guest reads there. + let host_view = HostCpuSurface { + presentation: CpuidPresentation::PassThroughHostView, + ..whp.clone() + }; + verify_support(profile, &host_view).unwrap(); + + // An unlisted entry that reads zero is what the profile requires. + let mut zero = whp.clone(); + edit(&mut zero, 0x14, 1, 0, |_| 0); + edit(&mut zero, 0x14, 1, 1, |_| 0); + verify_support(profile, &zero).unwrap(); + + // Every violation is named at once, the missing features first. + let mut both = whp; + edit(&mut both, 7, 0, 1, |ebx| ebx & !(1 << 16)); + assert_eq!( + unsupported(profile, &both), + format!( + "the backend does not support CPU profile intel.icelake-sp.v1: \ + CPUID 0x7.0 EBX bit 16 is not supported; {PT}" + ) + ); + } + #[test] fn the_time_policy_bits_are_exempt() { let profile = profile(ICELAKE); diff --git a/vmm_core/cpu_profile/src/unlisted.rs b/vmm_core/cpu_profile/src/unlisted.rs new file mode 100644 index 00000000000..923116737a4 --- /dev/null +++ b/vmm_core/cpu_profile/src/unlisted.rs @@ -0,0 +1,251 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! CPUID entries outside a profile's tables. +//! +//! KVM installs a VM's effective CPUID as the guest's whole CPUID table, so +//! every entry the table does not list reads zero. MSHV and WHP program the +//! effective CPUID over the hypervisor's own CPUID instead, so every entry it +//! does not list reads the hypervisor's value. The time ABI reserves the +//! entries past a profile's advertised maxima, and verification requires that +//! a pass-through backend presents zero at every entry outside the profile's +//! tables (`E_CPU_UNLISTED`), so no host data reaches the guest +//! there. + +use crate::cpuid::CpuidEntry; +use crate::cpuid::HYPERVISOR_LEAF_BASE; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; +use crate::profile::CpuProfile; +use crate::profile::VM_OWNED_LEAVES; +use crate::profile::describe_leaf; +use std::ops::RangeInclusive; + +/// The leaves reserved for hypervisors, where the time ABI's identity leaves +/// are. +const IDENTITY_RANGE: RangeInclusive = HYPERVISOR_LEAF_BASE..=0x4fff_ffff; + +/// Returns whether the entry for `leaf` and `subleaf` is outside `profile`'s +/// tables and outside the leaves that the time ABI owns: the identity range +/// `0x40000000` to `0x4fffffff` and the VM-owned topology leaves. A VM's +/// effective CPUID adds those, and other checks cover them. +/// +/// A subleaf-independent entry, in the profile or presented, covers every +/// subleaf of its leaf. +fn is_unlisted(profile: &CpuProfile, leaf: u32, subleaf: Option) -> bool { + let owned = IDENTITY_RANGE.contains(&leaf) || VM_OWNED_LEAVES.contains(&leaf); + !owned + && !profile.cpuid().iter().any(|entry| { + entry.leaf.0 == leaf + && match (entry.subleaf, subleaf) { + (Some(listed), Some(subleaf)) => listed.0 == subleaf, + (None, _) | (_, None) => true, + } + }) +} + +/// Returns every non-zero entry of `presented`, in order, that is outside +/// `profile`'s tables, other than the identity range and the VM-owned +/// topology leaves, as [`check_unlisted_cpuid`] reports it. +pub fn unlisted_cpuid_violations(profile: &CpuProfile, presented: &[CpuidEntry]) -> Vec { + presented + .iter() + .filter(|entry| { + let (leaf, subleaf) = entry.key(); + entry.registers() != [0; 4] && is_unlisted(profile, leaf, subleaf) + }) + .map(|entry| { + let (leaf, subleaf) = entry.key(); + let [eax, ebx, ecx, edx] = entry.registers(); + format!( + "{} is outside the profile and reads EAX {eax:#x}, EBX {ebx:#x}, ECX {ecx:#x}, EDX {edx:#x}", + describe_leaf(leaf, subleaf) + ) + }) + .collect() +} + +/// Checks that a guest of a pass-through backend reads zero at every CPUID +/// entry of `presented` that is outside `profile`'s tables, other than the +/// identity range and the VM-owned topology leaves. +/// +/// `presented` is what a guest reads: a probe partition's architectural +/// enumeration, as `--cpu-fingerprint` records it, or VP 0's view at the +/// host's [`unlisted_cpuid_candidates`]. A table backend, KVM, passes by +/// construction. +/// +/// Fails with `E_CPU_UNLISTED`, naming every such entry and its +/// values at once. +pub fn check_unlisted_cpuid( + profile: &CpuProfile, + presented: &[CpuidEntry], +) -> Result<(), ProfileError> { + let violations = unlisted_cpuid_violations(profile, presented); + if violations.is_empty() { + return Ok(()); + } + Err(unlisted_cpuid_error(profile, &violations)) +} + +/// Returns the `E_CPU_UNLISTED` failure for `violations`. +pub(crate) fn unlisted_cpuid_error(profile: &CpuProfile, violations: &[String]) -> ProfileError { + ProfileError::new( + ProfileErrorCode::CpuUnlisted, + format!( + "the backend presents CPUID entries outside CPU profile {}: {}", + profile.id(), + violations.join("; ") + ), + ) +} + +/// Returns the keys of the entries of `host`, in order, that are outside +/// `profile`'s tables, other than the identity range and the VM-owned +/// topology leaves. +/// +/// `host` is the host's own CPUID, as [`cpuid::enumerate`](crate::cpuid::enumerate) +/// walks it. It reaches every entry the processor implements, including those +/// past the profile's maxima and those that only the root partition sees, so +/// these are the entries where a pass-through backend's guest may read the +/// hypervisor's value. A backend reads VP 0's view there, at subleaf 0 for a +/// subleaf-independent key, for [`check_unlisted_cpuid`]. +pub fn unlisted_cpuid_candidates( + profile: &CpuProfile, + host: &[CpuidEntry], +) -> Vec<(u32, Option)> { + host.iter() + .map(CpuidEntry::key) + .filter(|&(leaf, subleaf)| is_unlisted(profile, leaf, subleaf)) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::profile; + use crate::test_support::profile_entries; + use test_with_tracing::test; + + const SKYLAKE: &str = "intel.skylake-sp.v1"; + + /// Returns `profile`'s entries with `extra` added, sorted. + fn with(profile: &CpuProfile, extra: &[CpuidEntry]) -> Vec { + let mut entries = profile_entries(profile); + entries.extend_from_slice(extra); + entries.sort_by_key(CpuidEntry::key); + entries + } + + #[test] + fn every_pinned_profile_lists_its_own_entries() { + for profile in crate::pinned_profiles() { + let entries = profile_entries(profile); + assert!(unlisted_cpuid_candidates(profile, &entries).is_empty()); + check_unlisted_cpuid(profile, &entries).unwrap(); + } + } + + #[test] + fn names_every_non_zero_entry_outside_the_tables() { + let profile = profile(SKYLAKE); + let presented = with( + profile, + &[ + // RDT monitoring and allocation, Intel PT (as the root + // partition of a Skylake-SP host reads it), and the PKRU XSAVE + // component, which the profile does not enable. + CpuidEntry::new(0xf, Some(1), [0, 0xb, 0x6f, 0x7]), + CpuidEntry::new(0x10, Some(1), [0xa, 0x600, 0x4, 0xf]), + CpuidEntry::new(0x14, Some(1), [0x0249_0002, 0x003f_3fff, 0, 0]), + CpuidEntry::new(0xd, Some(9), [8, 0xa80, 0, 0]), + // A leaf past the profile's maximum basic leaf, 0x16. + CpuidEntry::new(0x17, Some(0), [0, 0, 0, 1]), + ], + ); + let error = check_unlisted_cpuid(profile, &presented).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert_eq!( + error.to_string(), + "[E_CPU_UNLISTED] the backend presents CPUID entries outside CPU profile \ + intel.skylake-sp.v1: \ + CPUID 0xd.9 is outside the profile and reads EAX 0x8, EBX 0xa80, ECX 0x0, EDX 0x0; \ + CPUID 0xf.1 is outside the profile and reads EAX 0x0, EBX 0xb, ECX 0x6f, EDX 0x7; \ + CPUID 0x10.1 is outside the profile and reads EAX 0xa, EBX 0x600, ECX 0x4, EDX 0xf; \ + CPUID 0x14.1 is outside the profile and reads EAX 0x2490002, EBX 0x3f3fff, ECX 0x0, EDX 0x0; \ + CPUID 0x17.0 is outside the profile and reads EAX 0x0, EBX 0x0, ECX 0x0, EDX 0x1" + ); + } + + #[test] + fn accepts_zeros_and_the_leaves_the_time_abi_owns() { + let profile = profile(SKYLAKE); + let presented = with( + profile, + &[ + // Subleaves that read zero, as a guest enumerates them. + CpuidEntry::new(0xf, Some(1), [0; 4]), + CpuidEntry::new(0x10, Some(3), [0; 4]), + CpuidEntry::new(0x12, Some(2), [0; 4]), + // The hypervisor range, where the identity leaves are. + CpuidEntry::new(0x4000_0000, None, [0x4000_000b, 0x7263_694d, 0, 0]), + CpuidEntry::new(0x4000_0003, None, [0x3fff, 0x2bfe, 0x2, 0xbed7b2]), + // The topology leaves, which the VM's effective CPUID adds. + CpuidEntry::new(0xb, Some(0), [1, 2, 0x100, 0]), + CpuidEntry::new(0xb, Some(1), [5, 20, 0x201, 0]), + CpuidEntry::new(0x1f, Some(1), [5, 20, 0x201, 0]), + ], + ); + check_unlisted_cpuid(profile, &presented).unwrap(); + assert_eq!( + unlisted_cpuid_candidates(profile, &presented), + [(0xf, Some(1)), (0x10, Some(3)), (0x12, Some(2))] + ); + } + + #[test] + fn a_subleaf_independent_entry_covers_every_subleaf() { + let profile = profile(SKYLAKE); + // Leaf 6 is subleaf-independent in the profile, and a backend may + // report an indexed leaf without its subleaf. + let presented = [ + CpuidEntry::new(0x6, Some(3), [0x4, 0, 0, 0]), + CpuidEntry::new(0x7, None, [0, 0xd19f_4fbb, 0x8, 0x8400_0000]), + ]; + check_unlisted_cpuid(profile, &presented).unwrap(); + assert!(unlisted_cpuid_candidates(profile, &presented).is_empty()); + } + + #[test] + fn the_identity_range_ends_at_0x4fffffff() { + let profile = profile(SKYLAKE); + let presented = [ + CpuidEntry::new(0x4fff_ffff, None, [1, 0, 0, 0]), + CpuidEntry::new(0x5000_0000, None, [1, 0, 0, 0]), + ]; + assert_eq!( + unlisted_cpuid_violations(profile, &presented), + [ + "CPUID 0x50000000 is outside the profile and reads EAX 0x1, EBX 0x0, ECX 0x0, EDX 0x0" + ] + ); + } + + #[test] + fn candidates_are_the_host_entries_outside_the_tables() { + let profile = profile(SKYLAKE); + let host = with( + profile, + &[ + CpuidEntry::new(0xb, Some(0), [1, 2, 0x100, 0]), + CpuidEntry::new(0xf, Some(1), [0, 0xb, 0x6f, 0x7]), + CpuidEntry::new(0x14, Some(1), [0x0249_0002, 0x003f_3fff, 0, 0]), + CpuidEntry::new(0x4000_0000, None, [0x4000_000b, 0, 0, 0]), + CpuidEntry::new(0x8000_0009, None, [0; 4]), + ], + ); + assert_eq!( + unlisted_cpuid_candidates(profile, &host), + [(0xf, Some(1)), (0x14, Some(1)), (0x8000_0009, None)] + ); + } +} diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index 69db234325b..eadcfa46b9d 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -492,6 +492,7 @@ mod tests { .iter() .map(|leaf| CpuidEntry::new(leaf.function, leaf.index, leaf.result)) .collect(), + presentation: cpu_profile::CpuidPresentation::PassThroughHostView, physical_address_width: surface.physical_address_width, msrs: surface .msrs @@ -688,6 +689,7 @@ mod hw { cpu_profile::cpuid::normalize(&mut cpuid); cpu_profile::HostCpuSurface { cpuid, + presentation: cpu_profile::CpuidPresentation::PassThroughHostView, physical_address_width: surface.physical_address_width, msrs: surface .msrs diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 6b2391dc829..858f54c0b16 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -1377,6 +1377,7 @@ mod hw { cpu_profile::cpuid::normalize(&mut cpuid); cpu_profile::HostCpuSurface { cpuid, + presentation: cpu_profile::CpuidPresentation::PassThroughHostView, physical_address_width: surface.physical_address_width, msrs: surface .msrs diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index 432c9fd4f7e..d8eef764bde 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -646,6 +646,7 @@ mod whp_tests { cpu_profile::cpuid::normalize(&mut cpuid); let cheap = cpu_profile::HostCpuSurface { cpuid, + presentation: cpu_profile::CpuidPresentation::PassThroughHostView, physical_address_width: cheap.physical_address_width, msrs: cheap .msrs From ffb14b773126f1684b7eaf74b1bad53053e796de Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 18:49:19 -0700 Subject: [PATCH 232/304] state_unit: advance time only in the units that keep their own StateUnits::advance_time sent a request to every state unit and waited for each in dependency order, although only VM time and the CMOS RTC do anything with it; every other unit derives its deadlines from VM time. The fan-out cost 0.23 to 0.32 ms of every time ABI restore on KVM and about 0.35 ms on MSHV, at every vCPU count. A unit now declares that it advances time with UnitBuilder::advances_time, and only such units receive the request; the others pass through the operation without a round trip. A chipset device declares it with ChangeDeviceState::advances_time, which the CMOS RTCs return true from and the chipset builders carry to their units. The VM time units of OpenVMM and OpenHCL declare it too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openhcl/underhill_core/src/worker.rs | 1 + openvmm/openvmm_core/src/worker/dispatch.rs | 1 + vm/chipset_device_resources/src/lib.rs | 8 ++ vm/devices/chipset/src/cmos_rtc.rs | 4 + .../chipset/src/cmos_rtc/tests/snapshot.rs | 3 + .../chipset_legacy/src/piix4_cmos_rtc.rs | 4 + vm/vmcore/src/device_state.rs | 9 +++ vmm_core/state_unit/src/advance_time.rs | 5 +- vmm_core/state_unit/src/lib.rs | 74 ++++++++++++++++++- .../src/chipset/backing/arc_mutex/services.rs | 3 + .../vmotherboard/src/chipset/builder/mod.rs | 6 +- 11 files changed, 115 insertions(+), 3 deletions(-) diff --git a/openhcl/underhill_core/src/worker.rs b/openhcl/underhill_core/src/worker.rs index 047b284ffad..c3f0052c6ab 100644 --- a/openhcl/underhill_core/src/worker.rs +++ b/openhcl/underhill_core/src/worker.rs @@ -2329,6 +2329,7 @@ async fn new_underhill_vm( let vmtime_source = vmtime_keeper.builder().build(tp.driver(0)).await.unwrap(); let vmtime = state_units .add("vmtime") + .advances_time() .spawn(&tp, |recv| { let mut vmtime = vmtime_keeper; async move { diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 2db9300a85c..c4dad54aaa7 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1934,6 +1934,7 @@ impl InitializedVm { let vmtime = state_units .add("vmtime") + .advances_time() .spawn(driver_source.simple(), { |recv| { let mut vmtime = vmtime_keeper; diff --git a/vm/chipset_device_resources/src/lib.rs b/vm/chipset_device_resources/src/lib.rs index d34a9d9328f..6401ab1abf6 100644 --- a/vm/chipset_device_resources/src/lib.rs +++ b/vm/chipset_device_resources/src/lib.rs @@ -98,6 +98,7 @@ trait DynChipsetDevice: ChipsetDevice + ProtobufSaveRestore + InspectMut { async fn stop(&mut self); async fn reset(&mut self); async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()>; + fn advances_time(&self) -> bool; } #[async_trait] @@ -125,6 +126,9 @@ impl Dy async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { self.advance_time(duration).await } + fn advances_time(&self) -> bool { + ChangeDeviceState::advances_time(self) + } } /// A resolved chipset device resource. @@ -171,6 +175,10 @@ impl ChangeDeviceState for ErasedChipsetDevice { async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { self.0.advance_time(duration).await } + + fn advances_time(&self) -> bool { + self.0.advances_time() + } } impl ChipsetDevice for ErasedChipsetDevice { diff --git a/vm/devices/chipset/src/cmos_rtc.rs b/vm/devices/chipset/src/cmos_rtc.rs index 3f427a70be0..ae091aec84b 100644 --- a/vm/devices/chipset/src/cmos_rtc.rs +++ b/vm/devices/chipset/src/cmos_rtc.rs @@ -249,6 +249,10 @@ impl ChangeDeviceState for Rtc { async fn advance_time(&mut self, duration: Duration) -> anyhow::Result<()> { self.advance_clock(duration) } + + fn advances_time(&self) -> bool { + true + } } impl ChipsetDevice for Rtc { diff --git a/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs b/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs index ca59bd4cea2..8b9e104a5a6 100644 --- a/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs +++ b/vm/devices/chipset/src/cmos_rtc/tests/snapshot.rs @@ -60,6 +60,9 @@ fn restore_preserves_guest_epoch_and_advances_downtime() { clock.get_time() - captured_time, Duration::from_secs(3).into() ); + // The RTC keeps its own guest-visible time, so the state unit that wraps + // it must receive the advance. + assert!(rtc.advances_time()); } #[test] diff --git a/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs b/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs index af8a786f477..e667766223f 100644 --- a/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs +++ b/vm/devices/chipset_legacy/src/piix4_cmos_rtc.rs @@ -80,6 +80,10 @@ impl ChangeDeviceState for Piix4CmosRtc { async fn advance_time(&mut self, duration: std::time::Duration) -> anyhow::Result<()> { self.inner.advance_time(duration).await } + + fn advances_time(&self) -> bool { + self.inner.advances_time() + } } impl ChipsetDevice for Piix4CmosRtc { diff --git a/vm/vmcore/src/device_state.rs b/vm/vmcore/src/device_state.rs index 088d900f576..094144a76a9 100644 --- a/vm/vmcore/src/device_state.rs +++ b/vm/vmcore/src/device_state.rs @@ -71,4 +71,13 @@ pub trait ChangeDeviceState { ) -> impl Send + Future> { async { Ok(()) } } + + /// Returns whether [`Self::advance_time`] does any work for this device, + /// because it keeps guest-visible time of its own instead of deriving its + /// deadlines from VM time. Only such devices receive the advance after a + /// restore, so a device that implements [`Self::advance_time`] must + /// return `true`. + fn advances_time(&self) -> bool { + false + } } diff --git a/vmm_core/state_unit/src/advance_time.rs b/vmm_core/state_unit/src/advance_time.rs index e06a8382471..68e33602749 100644 --- a/vmm_core/state_unit/src/advance_time.rs +++ b/vmm_core/state_unit/src/advance_time.rs @@ -12,6 +12,9 @@ use std::time::Duration; impl StateUnits { /// Advances guest-visible time on all stopped units in dependency order. + /// Only units added with [`UnitBuilder::advances_time`](crate::UnitBuilder::advances_time) + /// receive the request; the others pass through the operation without a + /// round trip. pub async fn advance_time(&mut self, duration: Duration) -> Result<(), StateTransitionError> { assert!(!self.running); let results = self @@ -22,7 +25,7 @@ impl StateUnits { State::AdvancingTime, State::Stopped, StateRequest::AdvanceTime, - |_, _| Some(duration), + |_, unit| unit.advances_time.then_some(duration), |unit| &unit.dependencies, ) .await; diff --git a/vmm_core/state_unit/src/lib.rs b/vmm_core/state_unit/src/lib.rs index 5f0e86eb89b..e06ab65ce9a 100644 --- a/vmm_core/state_unit/src/lib.rs +++ b/vmm_core/state_unit/src/lib.rs @@ -140,7 +140,8 @@ pub trait StateUnit: InspectMut { async fn restore(&mut self, buffer: SavedStateBlob) -> Result<(), RestoreError>; /// Advance guest-visible time after restore. Most units derive their - /// deadlines from VM time and need no direct adjustment. + /// deadlines from VM time and need no direct adjustment; only units added + /// with [`UnitBuilder::advances_time`] receive this request. async fn advance_time(&mut self, _duration: std::time::Duration) -> anyhow::Result<()> { Ok(()) } @@ -282,6 +283,7 @@ struct Unit { dependents: Vec, state: State, inspect_sensitivity: inspect::SensitivityLevel, + advances_time: bool, } /// An error returned when a state unit name is already in use. @@ -497,6 +499,7 @@ impl StateUnits { dependencies: Vec::new(), dependents: Vec::new(), inspect_sensitivity: inspect::SensitivityLevel::Unspecified, + advances_time: false, } } @@ -871,9 +874,18 @@ pub struct UnitBuilder<'a> { dependencies: Vec, dependents: Vec, inspect_sensitivity: inspect::SensitivityLevel, + advances_time: bool, } impl UnitBuilder<'_> { + /// Marks this new unit as one whose [`StateUnit::advance_time`] does + /// work, so that [`StateUnits::advance_time`] sends it the request. + /// Other units derive their deadlines from VM time and are not contacted. + pub fn advances_time(mut self) -> Self { + self.advances_time = true; + self + } + /// Adds `handle` as a dependency of this new unit. /// /// Operations will be ordered to ensure that a dependency will stop after @@ -942,6 +954,7 @@ impl UnitBuilder<'_> { dependents: self.dependents, state: State::Stopped, inspect_sensitivity: self.inspect_sensitivity, + advances_time: self.advances_time, }, ); let unit_id = UnitId { @@ -1153,6 +1166,65 @@ mod tests { } } + /// A unit that records whether it was asked to advance time. + struct TimeUnit(Arc); + + impl StateUnit for TimeUnit { + async fn start(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn stop(&mut self) {} + + async fn reset(&mut self) -> anyhow::Result<()> { + Ok(()) + } + + async fn save(&mut self) -> Result, SaveError> { + Ok(None) + } + + async fn restore(&mut self, _state: SavedStateBlob) -> Result<(), RestoreError> { + Ok(()) + } + + async fn advance_time(&mut self, _duration: Duration) -> anyhow::Result<()> { + self.0.store(true, Ordering::Relaxed); + Ok(()) + } + } + + impl InspectMut for TimeUnit { + fn inspect_mut(&mut self, req: inspect::Request<'_>) { + req.respond(); + } + } + + #[async_test] + async fn advance_time_reaches_only_units_that_advance_time(driver: DefaultDriver) { + let mut units = StateUnits::new(); + let flagged = Arc::new(AtomicBool::new(false)); + let unflagged = Arc::new(AtomicBool::new(false)); + let a = units + .add("flagged") + .advances_time() + .spawn(&driver, |recv| run_unit(TimeUnit(flagged.clone()), recv)) + .unwrap(); + let _b = units + .add("unflagged") + .depends_on(a.handle()) + .spawn(&driver, |recv| run_unit(TimeUnit(unflagged.clone()), recv)) + .unwrap(); + + units.advance_time(Duration::from_secs(1)).await.unwrap(); + assert!(flagged.load(Ordering::Relaxed)); + assert!(!unflagged.load(Ordering::Relaxed)); + + // Both units are stopped again and can start. + units.start().await.unwrap(); + units.stop().await; + } + #[async_test] async fn test_state_change(driver: DefaultDriver) { let mut units = StateUnits::new(); diff --git a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/services.rs b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/services.rs index d5d05ff27d0..26026c63387 100644 --- a/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/services.rs +++ b/vmm_core/vmotherboard/src/chipset/backing/arc_mutex/services.rs @@ -79,6 +79,9 @@ impl ArcMutexChipsetServicesFinalize for ArcMutexChipset .map(|d| d.valid_lines().to_vec()); let mut builder = self.builder.units.add(dev_name.clone()); + if device.advances_time() { + builder = builder.advances_time(); + } // Before stopping the device, the chipset interface must stop // running so that it stops issuing MMIO/PIO requests. if device.supports_mmio().is_some() diff --git a/vmm_core/vmotherboard/src/chipset/builder/mod.rs b/vmm_core/vmotherboard/src/chipset/builder/mod.rs index a8e47c0fae0..899caa972e3 100644 --- a/vmm_core/vmotherboard/src/chipset/builder/mod.rs +++ b/vmm_core/vmotherboard/src/chipset/builder/mod.rs @@ -82,8 +82,12 @@ impl ChipsetDevices { ) .await?; let device = arc_builder.build(CloseableMutex::new(device)); + let advances_time = device.lock().advances_time(); let device_unit = ArcMutexChipsetDeviceUnit::new(device.clone(), false); - let builder = units.add(name).dependency_of(self.chipset_unit()); + let mut builder = units.add(name).dependency_of(self.chipset_unit()); + if advances_time { + builder = builder.advances_time(); + } let unit = builder .spawn(driver_source.simple(), |recv| device_unit.run(recv)) .context("name in use")?; From 916f3528d234a6b08631be1506dec12d96eb8cd6 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 18:59:06 -0700 Subject: [PATCH 233/304] virt_whp: keep the extended topology subleaves the time ABI lists Core is adding the subleaf that ends leaf 0Bh (and 1Fh where a profile reaches it) to the effective CPUID: an invalid level with ECX[7:0] = 2 and VP 0's x2APIC ID in EDX, which each VP presents with its own ID. WHP's exit handler zeroed every 0Bh and 1Fh subleaf from 2 up after applying the partition's CPUID results, so VP 0 would present zeros there and core's preflight would fail every time ABI boot with E_CPU_SURFACE. For a time ABI partition, CpuidTopology now records the extended topology subleaves past the core level that the effective CPUID lists. The exit handler and VP 0's report keep them, with each VP's x2APIC ID in EDX like the SMT and core levels, and still zero the unlisted subleaves past them, which the time ABI reserves. Other partitions list none and are unchanged, and so is a time ABI partition whose effective CPUID lists no such subleaf. The tests' copy of core's effective CPUID gains the terminator, so the unit and hardware tests program it: every VP presents [0, 0, 2, its x2APIC ID] at 0Bh.2 and zeros at 0Bh.3. The comparison with the profile programming of de39f6aab expects the terminator where that programming read zero. Without the change, four unit tests fail, vp0_presents_the_effective_cpuid among them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/cpu_contract.rs | 100 +++++++++++++-- vmm_core/virt_whp/src/lib.rs | 5 +- vmm_core/virt_whp/src/time_abi.rs | 172 ++++++++++++++++++++++---- 3 files changed, 242 insertions(+), 35 deletions(-) diff --git a/vmm_core/virt_whp/src/cpu_contract.rs b/vmm_core/virt_whp/src/cpu_contract.rs index f55089e1ff9..68cadba48f2 100644 --- a/vmm_core/virt_whp/src/cpu_contract.rs +++ b/vmm_core/virt_whp/src/cpu_contract.rs @@ -75,6 +75,12 @@ pub(crate) fn configure_versioned_contract( pub(crate) struct CpuidTopology { reserved_vps_per_socket: u32, smt_enabled: bool, + /// The extended topology subleaves past the core level (leaves 0Bh and + /// 1Fh, subleaf 2 and up) that a time ABI partition's effective CPUID + /// lists, such as the terminating subleaf, sorted. Other partitions list + /// none. + #[inspect(debug)] + listed_extended_topology: Vec<(u32, u32)>, } impl CpuidTopology { @@ -82,14 +88,46 @@ impl CpuidTopology { Self { reserved_vps_per_socket: topology.reserved_vps_per_socket(), smt_enabled: topology.smt_enabled(), + listed_extended_topology: Vec::new(), } } + + /// Keeps the extended topology subleaves past the core level that + /// `cpuid`, a time ABI partition's effective CPUID, lists: each VP sees + /// them with its own x2APIC ID, instead of the zeros that end the + /// enumeration on other partitions. + pub(crate) fn with_listed_extended_topology(mut self, cpuid: &virt::CpuidLeafSet) -> Self { + let mut listed: Vec<(u32, u32)> = cpuid + .leaves() + .iter() + .filter(|leaf| is_extended_topology(leaf.function)) + .filter_map(|leaf| Some((leaf.function, leaf.index.filter(|&index| index >= 2)?))) + .collect(); + listed.sort_unstable(); + listed.dedup(); + self.listed_extended_topology = listed; + self + } + + fn lists(&self, function: u32, index: u32) -> bool { + self.listed_extended_topology + .binary_search(&(function, index)) + .is_ok() + } +} + +fn is_extended_topology(function: u32) -> bool { + matches!( + CpuidFunction(function), + CpuidFunction::ExtendedTopologyEnumeration | CpuidFunction::V2ExtendedTopologyEnumeration + ) } /// Reports `apic_id` for the SMT and core levels of the extended topology -/// leaves, and terminates the enumeration after them. -fn fixup_extended_topology(index: u32, apic_id: u32, result: &mut [u32; 4]) { - if index >= 2 { +/// leaves and for each further subleaf that is `listed`, and terminates the +/// enumeration after the core level otherwise. +fn fixup_extended_topology(index: u32, apic_id: u32, listed: bool, result: &mut [u32; 4]) { + if index >= 2 && !listed { *result = [0; 4]; } else { result[3] = apic_id; @@ -127,7 +165,7 @@ pub(crate) fn fixup_vp_topology_cpuid( } CpuidFunction::ExtendedTopologyEnumeration | CpuidFunction::V2ExtendedTopologyEnumeration => { - fixup_extended_topology(index, apic_id, default); + fixup_extended_topology(index, apic_id, topology.lists(function, index), default); } CpuidFunction::ProcessorTopologyDefinition => { default[0] = x86defs::cpuid::ProcessorTopologyDefinitionEax::from(default[0]) @@ -151,7 +189,9 @@ pub(crate) fn fixup_vp_topology_cpuid( #[cfg(test)] mod tests { + use super::CpuidTopology; use super::fixup_extended_topology; + use super::fixup_vp_topology_cpuid; use super::mask_gpa_pinning_enlightenment; #[test] @@ -182,15 +222,59 @@ mod tests { #[test] fn extended_topology_uses_canonical_levels_and_terminates() { for index in [0, 1] { - let mut result = [1, 2, 3, 99]; - fixup_extended_topology(index, 7, &mut result); - assert_eq!(result, [1, 2, 3, 7]); + for listed in [false, true] { + let mut result = [1, 2, 3, 99]; + fixup_extended_topology(index, 7, listed, &mut result); + assert_eq!(result, [1, 2, 3, 7]); + } } for index in [2, 3, u32::MAX] { let mut result = [1, 2, 3, 99]; - fixup_extended_topology(index, 7, &mut result); + fixup_extended_topology(index, 7, false, &mut result); assert_eq!(result, [0; 4]); } } + + /// A time ABI partition's effective CPUID lists the subleaf that ends the + /// enumeration (core's `terminate_extended_topology`): each VP sees it + /// with its own x2APIC ID. Subleaves past it still read zero, and other + /// partitions keep ending the enumeration with zeros. + #[test] + fn listed_extended_topology_subleaves_keep_their_values() { + let topology = vm_topology::processor::TopologyBuilder::new_x86() + .vps_per_socket(4) + .build(4) + .unwrap(); + let terminator = |function| { + virt::CpuidLeaf::new(function, [0, 0, 2, 0]) + .indexed(2) + .masked([!0, !0, !0, 0]) + }; + let cpuid = virt::CpuidLeafSet::new(vec![ + virt::CpuidLeaf::new(0xb, [1, 1, 0x100, 0]) + .indexed(0) + .masked([!0, !0, !0, 0]), + terminator(0xb), + terminator(0x1f), + ]); + let time_abi = CpuidTopology::new(&topology).with_listed_extended_topology(&cpuid); + assert_eq!(time_abi.listed_extended_topology, [(0xb, 2), (0x1f, 2)]); + let legacy = CpuidTopology::new(&topology); + for function in [0xb, 0x1f] { + for apic_id in [0, 3] { + let mut result = [0, 0, 2, 99]; + fixup_vp_topology_cpuid(&time_abi, apic_id, function, 2, &mut result); + assert_eq!(result, [0, 0, 2, apic_id]); + + let mut result = [5, 6, 7, 8]; + fixup_vp_topology_cpuid(&time_abi, apic_id, function, 3, &mut result); + assert_eq!(result, [0; 4]); + + let mut result = [0, 0, 2, 99]; + fixup_vp_topology_cpuid(&legacy, apic_id, function, 2, &mut result); + assert_eq!(result, [0; 4]); + } + } + } } diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 7a214ca6ad3..247b465312d 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -1451,7 +1451,10 @@ impl WhpPartitionInner { #[cfg(guest_arch = "x86_64")] cpuid, #[cfg(guest_arch = "x86_64")] - cpuid_topology: cpu_contract::CpuidTopology::new(proto_config.processor_topology), + cpuid_topology: match &time_abi { + Some(time_abi) => time_abi.cpuid_topology(proto_config.processor_topology), + None => cpu_contract::CpuidTopology::new(proto_config.processor_topology), + }, #[cfg(guest_arch = "x86_64")] clock: tsc::PartitionClock::new(tsc_frequency), #[cfg(guest_arch = "x86_64")] diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 35dd469f0d5..67dcad8b648 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -329,6 +329,17 @@ impl WhpTimeAbi { check_cpuid_delivery(&cpuid, &self.cpuid_exits)?; Ok(cpuid) } + + /// Returns the topology parameters of the per-VP CPUID results for + /// `topology`. The extended topology subleaves past the core level that + /// the effective CPUID lists, such as the subleaf that ends the + /// enumeration, keep their values, with each VP's x2APIC ID. + pub(crate) fn cpuid_topology( + &self, + topology: &vm_topology::processor::ProcessorTopology, + ) -> CpuidTopology { + CpuidTopology::new(topology).with_listed_extended_topology(&self.cpuid) + } } fn routing_error(message: impl Into) -> TimeAbiError { @@ -1200,6 +1211,7 @@ pub(crate) mod test_cpuid { use virt::CpuidLeafSet; use vm_topology::processor::ProcessorTopology; use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::VpIndex; use vm_topology::processor::x86::ApicMode; use vm_topology::processor::x86::X2ApicState; @@ -1220,7 +1232,8 @@ pub(crate) mod test_cpuid { } /// Returns the effective CPUID of a partition with `profile` and - /// `topology`, as core's `time_abi::effective_cpuid` builds it. + /// `topology`, as core's `time_abi::effective_cpuid` builds it, including + /// the subleaf that ends each extended topology leaf. pub fn effective_cpuid(profile: &CpuProfile, topology: &ProcessorTopology) -> EffectiveCpuid { let result = |leaf: &CpuidLeaf| cpu_profile::CpuidResult { function: leaf.function, @@ -1235,6 +1248,7 @@ pub(crate) mod test_cpuid { &mut topology_leaves, ) .unwrap(); + terminate_extended_topology(topology, &mut topology_leaves); let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); vm.push(cpu_profile::x2apic_cpuid(!matches!( topology.apic_mode(), @@ -1248,6 +1262,40 @@ pub(crate) mod test_cpuid { profile.effective_cpuid(&vm, &identity).unwrap() } + /// Adds the subleaf that ends each extended topology leaf in `leaves` + /// after its levels, as core's `terminate_extended_topology` does: an + /// invalid level with its own number in `ECX[7:0]` and the BSP's x2APIC + /// ID in `EDX`. + fn terminate_extended_topology(topology: &ProcessorTopology, leaves: &mut Vec) { + let bsp_apic_id = topology.vp_arch(VpIndex::BSP).apic_id; + for function in [0xb, 0x1f] { + let levels = leaves + .iter() + .filter(|leaf| leaf.function == function) + .count() as u32; + if levels == 0 { + continue; + } + let ecx = x86defs::cpuid::ExtendedTopologyEcx::new().with_level_number(levels as u8); + leaves.push( + CpuidLeaf::new(function, [0, 0, ecx.into(), bsp_apic_id]) + .indexed(levels) + .masked([!0; 4]), + ); + } + } + + /// Returns whether `effective` lists `function`/`index` as an extended + /// topology subleaf past the core level: the subleaf that ends the + /// enumeration, which the profile programming of `de39f6aab` left zero. + pub fn is_listed_terminator(effective: &EffectiveCpuid, function: u32, index: u32) -> bool { + matches!(function, 0xb | 0x1f) + && index >= 2 + && effective + .results() + .any(|result| result.function == function && result.index == Some(index)) + } + /// Returns [`TimeAbiConfig::cpuid`](virt::time_abi::TimeAbiConfig::cpuid) /// for `effective`, as core's `backend_cpuid` builds it: the per-VP APIC /// identity bits unmasked. @@ -1516,7 +1564,8 @@ mod tests { test_cpuid::own_cpuid(&topology, &|function, index| whp.native(function, index)); let cpuid = partition_cpuid(own, &config, &exits).unwrap(); check_cpuid_delivery(&cpuid, &exits).unwrap(); - let cpuid_topology = CpuidTopology::new(&topology); + let cpuid_topology = + CpuidTopology::new(&topology).with_listed_extended_topology(&config); Self { topology, effective, @@ -1816,7 +1865,10 @@ mod tests { /// of the CPUID. VP 0 observes exactly the CPUID of the profile /// programming of `de39f6aab` on every leaf and subleaf that either /// lists, on their unlisted subleaves, and past the maximum leaves, on a - /// WHP whose unprogrammed bits read zero. + /// WHP whose unprogrammed bits read zero. The one deliberate change is the + /// subleaf that ends each extended topology leaf: core's table lists it, + /// and VP 0 presents it as listed, where the profile programming read + /// zero. #[test] fn core_table_presents_what_the_profile_programming_did() { for profile in test_cpuid::PROFILES { @@ -1830,6 +1882,12 @@ mod tests { .iter() .filter_map(|&(function, index)| { let ours = normalize_cpuid(function, index, new.observe(0, function, index)); + if test_cpuid::is_listed_terminator(&new.effective, function, index) { + let expected = new.effective.lookup(function, index); + return (ours != expected).then(|| { + format!("{function:#x}.{index}: terminator {ours:08x?}, not {expected:08x?}") + }); + } let theirs = normalize_cpuid(function, index, old.observe(0, function, index)); (ours != theirs).then(|| { format!("{function:#x}.{index}: core table {ours:08x?}, profile {theirs:08x?}") @@ -1855,6 +1913,35 @@ mod tests { } } + /// Core's table lists the subleaf that ends leaf 0Bh, with VP 0's x2APIC + /// ID and `EDX` unmasked: every VP presents it with its own ID, and the + /// subleaves past it still read zero. Before core listed it, the exit + /// handler zeroed it, and VP 0's report failed `E_CPU_SURFACE`. + #[test] + fn extended_topology_terminator_carries_each_vps_x2apic_id() { + for profile in test_cpuid::PROFILES { + for vp_count in [1, 2, 8] { + let partition = Partition::new(profile, vp_count, X2ApicState::Supported); + assert!(test_cpuid::is_listed_terminator( + &partition.effective, + 0xb, + 2 + )); + for vp in partition.topology.vps_arch() { + assert_eq!( + partition.observe(vp.apic_id, 0xb, 2), + [0, 0, 2, vp.apic_id], + "{profile}, {vp_count} VPs" + ); + assert_eq!(partition.observe(vp.apic_id, 0xb, 3), [0; 4]); + } + let presented = CpuidLeafSet::new(partition.record()); + let differences = test_cpuid::differences(&presented, &partition.effective); + assert!(differences.is_empty(), "{profile}: {differences:#?}"); + } + } + } + #[test] fn partition_cpuid_keeps_own_results_only_for_exiting_leaves() { let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); @@ -2357,7 +2444,7 @@ mod whp_tests { native(&partition, function, index).unwrap() }); let cpuid = time_abi.partition_cpuid(own).unwrap(); - let cpuid_topology = CpuidTopology::new(&topology); + let cpuid_topology = time_abi.cpuid_topology(&topology); let started = std::time::Instant::now(); let mut reads = 0; for vp in topology.vps_arch() { @@ -2376,6 +2463,13 @@ mod whp_tests { }) .unwrap(); let record = CpuidLeafSet::new(record); + // Each VP ends leaf 0Bh with the listed terminator and its own + // x2APIC ID. + assert_eq!( + record.result(0xb, 2, &[0; 4]), + [0, 0, 2, vp.apic_id], + "VP {index}" + ); if index == 0 { println!( "VP 0 record: {} results, {reads} native reads in {} us", @@ -2421,12 +2515,14 @@ mod whp_tests { /// Sweeps subleaves 0 to 63 of every indexed leaf of the effective CPUID, /// and the four leaves past the maximum basic and extended leaves, and - /// checks that VP 0 observes the effective CPUID there. By the profile's - /// contract a subleaf or leaf that the table does not list reads zero, - /// while WHP serves its own value for anything not programmed: WHP reads - /// zero there on prometheus28, 8370C, and 8573C, so the backend programs - /// no explicit zero results. Listed results must match under their masks, - /// as `host_profile_configures_the_partition` checks too. + /// checks that VP 0 observes the effective CPUID there. Listed results + /// must match under their masks, as `host_profile_configures_the_partition` + /// checks too. The time ABI reserves the unlisted entries: a backend may + /// return zeros or the hypervisor's own values there, and WHP serves its + /// own value for anything not programmed. WHP reads zero there on + /// prometheus28, 8370C, and 8573C, which the fingerprint verification of + /// pass-through backends (`E_CPU_UNLISTED`) expects, so the backend + /// programs no explicit zero results. #[test] #[ignore = "requires WHP"] fn unlisted_subleaves_read_zero() { @@ -2443,7 +2539,7 @@ mod whp_tests { native(&partition, function, index).unwrap() }); let cpuid = time_abi.partition_cpuid(own).unwrap(); - let cpuid_topology = CpuidTopology::new(&topology); + let cpuid_topology = time_abi.cpuid_topology(&topology); let vp0 = VpCpuid { cpuid: &cpuid, exits: &time_abi.cpuid_exits, @@ -2504,7 +2600,9 @@ mod whp_tests { /// The comparison of `tests::core_table_presents_what_the_profile_programming_did` /// on WHP: two partitions with this host's profile and the same features, /// one programmed from core's complete table and one with the profile - /// programming of `de39f6aab`, present the same CPUID to VP 0. + /// programming of `de39f6aab`, present the same CPUID to VP 0, except the + /// listed subleaf that ends leaf 0Bh, which the profile programming left + /// zero. #[test] #[ignore = "requires WHP"] fn core_table_presents_what_the_profile_programming_did_on_whp() { @@ -2552,7 +2650,8 @@ mod whp_tests { old.create_vp(vp).create().unwrap(); } - let cpuid_topology = CpuidTopology::new(&new.topology); + let new_topology = new.time_abi.cpuid_topology(&new.topology); + let old_topology = CpuidTopology::new(&new.topology); let new_cpuid = new .time_abi .partition_cpuid(test_cpuid::own_cpuid(&new.topology, &|function, index| { @@ -2567,19 +2666,23 @@ mod whp_tests { &programming.exits, ) .unwrap(); - let view = - |partition: &whp::Partition, cpuid: &CpuidLeafSet, exits: &[u32], function, index| { - let vp0 = VpCpuid { - cpuid, - exits, - topology: &cpuid_topology, - apic_id: 0, - }; - let result = vp0 - .result(function, index, || native(partition, function, index)) - .unwrap(); - normalize_cpuid(function, index, result) + let view = |partition: &whp::Partition, + cpuid: &CpuidLeafSet, + exits: &[u32], + topology: &CpuidTopology, + function, + index| { + let vp0 = VpCpuid { + cpuid, + exits, + topology, + apic_id: 0, }; + let result = vp0 + .result(function, index, || native(partition, function, index)) + .unwrap(); + normalize_cpuid(function, index, result) + }; let probes = test_cpuid::probes(&new.effective, profile); let mut populated = 0; let mut differences = Vec::new(); @@ -2588,11 +2691,28 @@ mod whp_tests { &new.partition, &new_cpuid, &new.time_abi.cpuid_exits, + &new_topology, function, index, ); - let theirs = view(&old, &old_cpuid, &programming.exits, function, index); populated += usize::from(ours != [0; 4]); + if test_cpuid::is_listed_terminator(&new.effective, function, index) { + let expected = new.effective.lookup(function, index); + if ours != expected { + differences.push(format!( + "{function:#x}.{index}: terminator {ours:08x?}, not {expected:08x?}" + )); + } + continue; + } + let theirs = view( + &old, + &old_cpuid, + &programming.exits, + &old_topology, + function, + index, + ); if ours != theirs { differences.push(format!( "{function:#x}.{index}: core table {ours:08x?}, profile {theirs:08x?}" From 6a06fdab4603dadf4d3ad51740f6436efaae4633 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 19:08:27 -0700 Subject: [PATCH 234/304] openvmm_core: fail a presented non-zero CPUID entry outside the profile The time ABI reserves the CPUID entries that a guest reaches only by probing past an advertised maximum, and verification fails a pass-through host that presents a non-zero one (E_CPU_UNLISTED). The boot-time check could not run on the cheap host surface, since the root's own view over-claims exactly there (PT's 0x14.1, RDT's 0xF.1 and 0x10.1-3), so it runs on VP 0's view instead. After the E_CPU_SURFACE comparison passes, check_presented_cpuid passes the backend's whole report to cpu_profile::check_unlisted_cpuid. A pass-through backend (MSHV, WHP) adds VP 0's view at cpu_profile::unlisted_cpuid_candidates to its effective_cpuid report, as the trait's documentation now requires; until it does, the check is inert. A table backend (KVM) reports only the table, and its guests read zero outside it by construction. The comparison moves to check_presented, which a new test drives with synthetic reports: every pinned profile's own table passes at 1 to 64 VPs with and without x2APIC, a zero unlisted entry passes, a non-zero one gives E_CPU_UNLISTED, and a governed difference still reports E_CPU_SURFACE first. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/worker/dispatch/time_abi.rs | 68 ++++++++++++++++++- vmm_core/virt/src/time_abi/backend.rs | 7 ++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 09bc67a1a32..204fe4bc16e 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -282,15 +282,38 @@ pub(super) fn check_recorded_cpuid( /// before any VP runs (`E_CPU_SURFACE`): the backend's report, looked up as /// the guest's CPUID instruction finds it, equals every governed leaf under /// its masks, and a leaf without a subleaf is read at subleaf 0. +/// +/// The report of a pass-through backend also holds VP 0's view at the host's +/// entries outside the profile's tables +/// (`cpu_profile::unlisted_cpuid_candidates`); each must read zero, so that +/// no reserved entry exposes a host feature (`E_CPU_UNLISTED`). A table +/// backend reports none, and its guests read zero there by construction. pub(super) fn check_presented_cpuid( partition: &dyn HvlitePartition, hypervisor: &str, profile: &CpuProfile, effective_cpuid: &EffectiveCpuid, ) -> anyhow::Result<()> { - let presented = CpuidLeafSet::new(backend(partition, hypervisor)?.effective_cpuid()?); + let leaves = backend(partition, hypervisor)?.effective_cpuid()?; + check_presented(profile, effective_cpuid, leaves) +} + +/// Checks a backend's report of VP 0's CPUID, as [`check_presented_cpuid`] +/// describes. +fn check_presented( + profile: &CpuProfile, + effective_cpuid: &EffectiveCpuid, + leaves: Vec, +) -> anyhow::Result<()> { + let presented = CpuidLeafSet::new(leaves); let differences = cpuid_differences(&presented, effective_cpuid); if differences.is_empty() { + let entries: Vec<_> = presented + .leaves() + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::check_unlisted_cpuid(profile, &entries)?; return Ok(()); } let count = differences.len(); @@ -700,6 +723,49 @@ mod tests { } } + /// A pass-through backend's report may hold VP 0's view at the host's + /// entries outside the profile: zero there passes, and anything else is + /// `E_CPU_UNLISTED`. A report of the table alone passes, for every pinned + /// profile and topology. + #[test] + fn presented_cpuid_rejects_non_zero_unlisted_entries() { + for profile in cpu_profile::pinned_profiles() { + for vp_count in [1, 2, 8, 64] { + for x2apic in [X2ApicState::Unsupported, X2ApicState::Supported] { + let effective = effective_cpuid(profile, &topology(vp_count, x2apic)).unwrap(); + let (_, config) = partition_config(profile, &effective); + check_presented(profile, &effective, config.cpuid.leaves().to_vec()) + .unwrap_or_else(|err| { + panic!("{} with {vp_count} VPs, {x2apic:?}: {err:#}", profile.id()) + }); + } + } + } + + let (_, config, effective) = test_config(2); + let profile = test_profile(); + let table = config.cpuid.leaves().to_vec(); + + // Leaf 0x1D is above Ice Lake-SP's maximum basic leaf (0x1B). + let mut zero = table.clone(); + zero.push(CpuidLeaf::new(0x1d, [0; 4])); + check_presented(profile, &effective, zero).unwrap(); + + let mut leaked = table.clone(); + leaked.push(CpuidLeaf::new(0x1d, [1, 0, 0, 0])); + assert_eq!( + code(check_presented(profile, &effective, leaked.clone())), + "E_CPU_UNLISTED" + ); + + // A governed difference is reported first. + leaked.retain(|leaf| leaf.function != 0x8000_0002); + assert_eq!( + code(check_presented(profile, &effective, leaked)), + "E_CPU_SURFACE" + ); + } + #[test] fn presented_cpuid_must_match_under_the_masks() { let (_, config, effective) = test_config(2); diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index 90eb21219a9..2721eb20c90 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -44,6 +44,13 @@ pub trait TimeAbiBackend: Send + Sync { /// with the CPU profile's effective CPUID under its masks /// (`E_CPU_SURFACE`). Fails with `E_CPU_SURFACE`. /// + /// A backend whose guests read the hypervisor's own values outside that + /// table (MSHV, WHP) also returns VP 0's view at every entry the host's + /// CPUID enumerates outside the profile's tables + /// (`cpu_profile::unlisted_cpuid_candidates`), at subleaf 0 for a + /// subleaf-independent entry. Core requires each to read zero + /// (`E_CPU_UNLISTED`), so that no reserved entry exposes a host feature. + /// /// Core calls it with all VPs stopped, before any VP runs and before any /// VP state is restored, on cold boot and restore. Core's first call to /// this method or to [`Self::preflight`] comes before any VP runs, so a From 8ad666898323aa80994d6044bbdabda7b3140e7d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 19:18:40 -0700 Subject: [PATCH 235/304] cpu_profile: embed the pinned profiles as static data Every VMM start still parsed the JSON of the profile it selected, and restore encoded canonical JSON through serde, which kept profile access at 0.25 to 0.47 ms of every cold boot and restore in a release build. The orchestrator's target is under 0.2 ms, with no JSON parsing and no hashing at run time. The pinned profiles are now Rust constants in pinned_data.rs, which the new generate_pinned example generates from profiles/.json: every value, the canonical encoding, and the digest. The first use of a profile copies its constants into a CpuProfile, so selection, pinned_for_restore, and check_generation neither parse nor hash. pinned_record returns the precomputed encoding and digest, so capture can record them, and restore can compare a recorded profile with them, without encoding or hashing. The tests check every constant against the JSON files, which stay the reviewed source, and against the golden digests, now held by the catalog test. The effective CPUID, which restore recomputes and compares with its record, builds in a sorted vector instead of a tree, formats leaf names only for errors, and encodes with a writer for its fixed shape, which tests check against the sorting encoder byte for byte. The unlisted-CPUID check that core now runs on every boot's report looks entries up by binary search. In fresh processes, in a release build on the workstation, the restore path (pinned_for_restore, check_generation, effective_cpuid, encode, and verify_support) takes 92 to 166 us (median 110), and the cold-boot path (select, effective_cpuid) 57 to 110 us. Warm, each step takes 1 to 17 us. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cpu_profile/examples/derive_profile.rs | 5 +- .../cpu_profile/examples/generate_pinned.rs | 188 ++++ vmm_core/cpu_profile/src/catalog.rs | 161 ++- vmm_core/cpu_profile/src/derive.rs | 3 +- vmm_core/cpu_profile/src/effective.rs | 133 ++- vmm_core/cpu_profile/src/lib.rs | 4 + vmm_core/cpu_profile/src/pinned.rs | 208 ++++ vmm_core/cpu_profile/src/pinned_data.rs | 937 ++++++++++++++++++ vmm_core/cpu_profile/src/profile.rs | 8 - vmm_core/cpu_profile/src/unlisted.rs | 17 +- 10 files changed, 1527 insertions(+), 137 deletions(-) create mode 100644 vmm_core/cpu_profile/examples/generate_pinned.rs create mode 100644 vmm_core/cpu_profile/src/pinned.rs create mode 100644 vmm_core/cpu_profile/src/pinned_data.rs diff --git a/vmm_core/cpu_profile/examples/derive_profile.rs b/vmm_core/cpu_profile/examples/derive_profile.rs index 595513d6eae..9129ca4e972 100644 --- a/vmm_core/cpu_profile/examples/derive_profile.rs +++ b/vmm_core/cpu_profile/examples/derive_profile.rs @@ -9,8 +9,9 @@ //! //! Writes the profile, as pretty canonical JSON, to standard output, and its //! ID and digest to standard error. Pin it under -//! `vmm_core/cpu_profile/profiles/.json`, with its ID and digest in the -//! catalog's `PINNED` table. +//! `vmm_core/cpu_profile/profiles/.json`, regenerate the static data in +//! `src/pinned_data.rs` with the `generate_pinned` example, and add the file +//! and its golden digest to the catalog test's `FILES` table. use cpu_profile::derive; use cpu_profile::fingerprint::CpuFingerprint; diff --git a/vmm_core/cpu_profile/examples/generate_pinned.rs b/vmm_core/cpu_profile/examples/generate_pinned.rs new file mode 100644 index 00000000000..5491dfe2872 --- /dev/null +++ b/vmm_core/cpu_profile/examples/generate_pinned.rs @@ -0,0 +1,188 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Generates `src/pinned_data.rs`, the pinned profiles as static data, from +//! their pinned JSON files. +//! +//! ```text +//! cargo run -p cpu_profile --example generate_pinned -- vmm_core/cpu_profile/profiles/.json... > vmm_core/cpu_profile/src/pinned_data.rs +//! cargo xtask fmt --pass rustfmt --fix +//! ``` +//! +//! List the files in catalog order. The crate's tests check every generated +//! constant against the files and the golden digests. + +use cpu_profile::CpuProfile; +use cpu_profile::derive::KNOWN_GENERATIONS; +use std::fmt::Write; +use std::process::ExitCode; + +fn main() -> ExitCode { + match run() { + Ok(source) => { + print!("{source}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("error: {error}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result { + let mut profiles = Vec::new(); + for path in std::env::args().skip(1) { + let json = std::fs::read_to_string(&path).map_err(|error| format!("{path}: {error}"))?; + let profile = + CpuProfile::from_pretty_json(&json).map_err(|error| format!("{path}: {error}"))?; + profiles.push(profile); + } + if profiles.is_empty() { + return Err("no profile files given".to_owned()); + } + let mut out = String::new(); + writeln!( + out, + "// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The pinned profiles as static data, generated from `profiles/.json` by +//! the `generate_pinned` example. Do not edit: the tests check every constant +//! against the JSON files and the golden digests. + +use crate::derive::KNOWN_GENERATIONS; +use crate::pinned::PinnedProfile; +use crate::pinned::digest; +use crate::pinned::leaf; +use crate::pinned::msr; +use crate::pinned::source; +use crate::pinned::xsave; + +/// The pinned profiles, in catalog order. Released profiles are immutable: a +/// change is a new revision with a new ID. +pub(crate) const PINNED: [PinnedProfile; {}] = [", + profiles.len() + ) + .unwrap(); + for profile in &profiles { + render(&mut out, profile)?; + } + out.push_str("];\n"); + Ok(out) +} + +fn render(out: &mut String, profile: &CpuProfile) -> Result<(), String> { + let generation = KNOWN_GENERATIONS + .iter() + .position(|known| known.name == profile.generation().name) + .ok_or_else(|| format!("{}: unknown generation", profile.id()))?; + let encoding = String::from_utf8(profile.encode()).expect("canonical JSON is UTF-8"); + let hashes = (0..) + .find(|&count| !encoding.contains(&format!("\"{}", "#".repeat(count + 1)))) + .unwrap() + + 1; + let fence = "#".repeat(hashes); + let digest = profile + .digest() + .iter() + .fold(String::new(), |mut hex, byte| { + write!(hex, "{byte:02x}").unwrap(); + hex + }); + writeln!(out, " PinnedProfile {{").unwrap(); + writeln!(out, " id: {:?},", profile.id()).unwrap(); + writeln!(out, " digest: digest({digest:?}),").unwrap(); + writeln!(out, " encoding: r{fence}\"{encoding}\"{fence},").unwrap(); + writeln!(out, " description: {:?},", profile.description()).unwrap(); + writeln!(out, " generation: &KNOWN_GENERATIONS[{generation}],").unwrap(); + writeln!(out, " cpuid: &[").unwrap(); + for entry in profile.cpuid() { + let subleaf = match entry.subleaf { + Some(subleaf) => format!("Some({})", hex(subleaf.0.into())), + None => "None".to_owned(), + }; + writeln!( + out, + " leaf({}, {subleaf}, {}, {}),", + hex(entry.leaf.0.into()), + hex_array(entry.values()), + hex_array(entry.masks()) + ) + .unwrap(); + } + writeln!(out, " ],").unwrap(); + writeln!(out, " xcr0: {},", hex(profile.xcr0())).unwrap(); + writeln!(out, " xss: {},", hex(profile.xss())).unwrap(); + writeln!(out, " xsave_components: &[").unwrap(); + for component in profile.xsave_components() { + writeln!( + out, + " xsave({}, {}, {}, {}, {}, {}),", + component.index, + component.size, + component.offset, + component.supervisor, + component.align64, + component.xfd + ) + .unwrap(); + } + writeln!(out, " ],").unwrap(); + writeln!( + out, + " physical_address_width: {},", + profile.physical_address_width() + ) + .unwrap(); + writeln!(out, " msrs: &[").unwrap(); + for msr in profile.msrs() { + writeln!( + out, + " msr({}, {}, {}),", + hex(msr.index.0.into()), + hex(msr.value.0), + hex(msr.mask.0) + ) + .unwrap(); + } + writeln!(out, " ],").unwrap(); + writeln!( + out, + " provenance_method: {:?},", + profile.provenance().method + ) + .unwrap(); + writeln!(out, " provenance_sources: &[").unwrap(); + for source in &profile.provenance().sources { + writeln!( + out, + " source({:?}, {}, &{:?}),", + source.backend, source.hosts, source.surface_digests + ) + .unwrap(); + } + writeln!(out, " ],").unwrap(); + writeln!(out, " }},").unwrap(); + Ok(()) +} + +/// Returns `value` as a Rust hex literal, with digit groups of four. +fn hex(value: u64) -> String { + let digits = format!("{value:x}"); + if digits.len() <= 4 { + return format!("0x{digits}"); + } + let mut grouped = String::new(); + for (i, digit) in digits.chars().enumerate() { + if i != 0 && (digits.len() - i) % 4 == 0 { + grouped.push('_'); + } + grouped.push(digit); + } + format!("0x{grouped}") +} + +fn hex_array(values: [u32; 4]) -> String { + format!("[{}]", values.map(|value| hex(value.into())).join(", ")) +} diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index 0f23ce02be9..ea06136718c 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -5,10 +5,11 @@ //! profile checks of cold boot and restore. use crate::canonical; -use crate::derive::KNOWN_GENERATIONS; use crate::derive::KnownGeneration; use crate::error::ProfileError; use crate::error::ProfileErrorCode; +use crate::pinned::PinnedRecord; +use crate::pinned_data::PINNED; use crate::profile::CpuProfile; use crate::signature::HostCpuSignature; use crate::signature::decode_signature; @@ -18,56 +19,15 @@ use std::sync::OnceLock; /// generation. pub const AUTO: &str = "auto"; -/// A pinned profile: its ID, its generation, its golden digest, and its -/// pretty canonical JSON. -struct Pinned { - id: &'static str, - /// The generation that the profile serves, so that selection parses only - /// the profile it selects. - generation: &'static KnownGeneration, - digest: &'static str, - json: &'static str, -} +/// Each pinned profile, built from its static data on first use, so that a +/// cold boot or a restore builds only its own. +static PROFILES: [OnceLock; PINNED.len()] = [const { OnceLock::new() }; PINNED.len()]; -/// The pinned profiles. Released profiles are immutable: a change is a new -/// revision with a new ID. The tests check every file's ID, generation, -/// pretty canonical form, and golden digest, so an OpenVMM start checks none -/// of them. -const PINNED: [Pinned; 3] = [ - Pinned { - id: "intel.skylake-sp.v1", - generation: &KNOWN_GENERATIONS[0], - digest: "sha256:b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492", - json: include_str!("../profiles/intel.skylake-sp.v1.json"), - }, - Pinned { - id: "intel.icelake-sp.v1", - generation: &KNOWN_GENERATIONS[1], - digest: "sha256:a35f3bb9bc30337b8b76af4939d5914f878ac7dc2a2d6d9d6bf4d8278b91f301", - json: include_str!("../profiles/intel.icelake-sp.v1.json"), - }, - Pinned { - id: "intel.emeraldrapids.v1", - generation: &KNOWN_GENERATIONS[2], - digest: "sha256:73c084783f26df29871c72200ea34470e4afec6187bf96de6ac051dbaed6727d", - json: include_str!("../profiles/intel.emeraldrapids.v1.json"), - }, -]; - -/// Each pinned profile, parsed on first use, so that a cold boot or a -/// restore parses only its own. -static PARSED: [OnceLock; PINNED.len()] = [const { OnceLock::new() }; PINNED.len()]; - -/// Returns the pinned profile at `index` in [`PINNED`], parsing and -/// validating it on first use. +/// Returns the pinned profile at `index` in [`PINNED`], building it from its +/// constants on first use: no parsing, no validation, and no hashing, which +/// the tests do for every pinned profile instead. fn load(index: usize) -> &'static CpuProfile { - PARSED[index].get_or_init(|| { - let pinned = &PINNED[index]; - let profile = CpuProfile::from_pinned_json(pinned.json) - .unwrap_or_else(|error| panic!("invalid pinned CPU profile {}: {error}", pinned.id)); - assert_eq!(profile.id(), pinned.id, "pinned CPU profile ID mismatch"); - profile - }) + PROFILES[index].get_or_init(|| PINNED[index].to_profile()) } /// Returns the index in [`PINNED`] of the profile `id`. @@ -75,8 +35,8 @@ fn position(id: &str) -> Option { PINNED.iter().position(|pinned| pinned.id == id) } -/// Returns every pinned profile, parsing each on first use. Selecting by ID -/// with [`pinned`] or [`pinned_for_restore`] parses only that profile. +/// Returns every pinned profile. Selecting by ID with [`pinned`] or +/// [`pinned_for_restore`] builds only that profile. pub fn pinned_profiles() -> &'static [CpuProfile] { static ALL: OnceLock> = OnceLock::new(); ALL.get_or_init(|| (0..PINNED.len()).map(|index| load(index).clone()).collect()) @@ -87,6 +47,12 @@ pub fn pinned(id: &str) -> Option<&'static CpuProfile> { position(id).map(load) } +/// Returns the precomputed record of the pinned profile `id`: its canonical +/// encoding and digest, for a snapshot's CPU profile record. +pub fn pinned_record(id: &str) -> Option { + position(id).map(|index| PINNED[index].record()) +} + /// Returns the name of the generation of the host CPU, if a pinned profile /// serves it: the generation of the profile [`select_auto`] selects. pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { @@ -96,7 +62,7 @@ pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { } /// Selects the profile of the host CPU's generation: its latest pinned -/// revision. Only the profiles whose generation covers the host are parsed. +/// revision. Only the profiles whose generation covers the host are built. /// /// Fails with `E_PROFILE_HOST_UNKNOWN` when no pinned profile serves the /// host, or when profiles of more than one generation do; there is no host @@ -228,17 +194,17 @@ pub fn verify_profile_record( /// /// Fails with `E_PROFILE_UNKNOWN` when this OpenVMM does not pin the ID, and /// with `E_PROFILE_DIGEST` when its pinned profile has another digest. The -/// pinned profile's digest is its golden digest, which the catalog's tests -/// check, so a restore encodes nothing here. +/// pinned profile's digest is a constant, which the catalog's tests check, so +/// a restore encodes and hashes nothing here. pub fn pinned_for_restore(id: &str, sha256: &[u8]) -> Result<&'static CpuProfile, ProfileError> { let index = position(id).ok_or_else(|| unknown(id))?; - let golden = PINNED[index].digest; - if !<&[u8; 32]>::try_from(sha256).is_ok_and(|sha256| canonical::format_digest(sha256) == golden) - { + let golden = &PINNED[index].digest; + if sha256 != golden { return Err(ProfileError::new( ProfileErrorCode::ProfileDigest, format!( - "the snapshot's CPU profile {id} differs from the pinned profile of the same ID ({golden})" + "the snapshot's CPU profile {id} differs from the pinned profile of the same ID ({})", + canonical::format_digest(golden) ), )); } @@ -306,47 +272,70 @@ mod tests { result.unwrap_err().code } + /// The pinned JSON files, the source of the static data, and their golden + /// digests. Released profiles are immutable: changing a golden digest is + /// a deliberate act, here. + const FILES: [(&str, &str, &str); 3] = [ + ( + "intel.skylake-sp.v1", + include_str!("../profiles/intel.skylake-sp.v1.json"), + "sha256:b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492", + ), + ( + "intel.icelake-sp.v1", + include_str!("../profiles/intel.icelake-sp.v1.json"), + "sha256:a35f3bb9bc30337b8b76af4939d5914f878ac7dc2a2d6d9d6bf4d8278b91f301", + ), + ( + "intel.emeraldrapids.v1", + include_str!("../profiles/intel.emeraldrapids.v1.json"), + "sha256:73c084783f26df29871c72200ea34470e4afec6187bf96de6ac051dbaed6727d", + ), + ]; + + /// The checks that an OpenVMM start skips: every pinned file is a valid + /// profile in pretty canonical form with its golden digest, and the static + /// data generated from it holds the same profile, canonical encoding, + /// digest, and generation. #[test] - fn pins_the_three_generations_with_their_golden_digests() { - let ids = PINNED.iter().map(|pinned| pinned.id).collect::>(); - assert_eq!( - ids, - [ - "intel.skylake-sp.v1", - "intel.icelake-sp.v1", - "intel.emeraldrapids.v1", - ] - ); - for (index, pinned) in PINNED.iter().enumerate() { - // The checks that an OpenVMM start skips: the file is in pretty - // canonical form, under its ID and generation, with its golden - // digest. - let profile = CpuProfile::from_pretty_json(pinned.json).unwrap(); - assert_eq!(profile.id(), pinned.id); + fn the_static_data_is_the_pinned_files() { + assert_eq!(PINNED.len(), FILES.len()); + for (index, (pinned, (id, json, golden))) in PINNED.iter().zip(FILES).enumerate() { + let profile = CpuProfile::from_pretty_json(json).unwrap(); + assert_eq!(profile.id(), id); + assert_eq!(profile.digest_string(), golden, "{id}"); + assert_eq!(pinned.id, id); + assert_eq!(&pinned.to_profile(), &profile, "{id}"); + assert_eq!(load(index), &profile, "{id}"); + assert_eq!(pinned.encoding.as_bytes(), profile.encode(), "{id}"); + assert_eq!(canonical::format_digest(&pinned.digest), golden, "{id}"); + assert_eq!( + canonical::sha256(pinned.encoding.as_bytes()), + pinned.digest, + "{id}" + ); assert_eq!(profile.vendor(), pinned.generation.vendor); - assert_eq!(profile.generation().name, pinned.generation.name); + assert_eq!(profile.generation(), &pinned.generation.generation()); assert_eq!( - profile - .generation() - .cpus - .iter() - .map(|cpu| (cpu.family, cpu.model, cpu.steppings)) - .collect::>(), - pinned.generation.cpus + pinned_record(id), + Some(PinnedRecord { + id: pinned.id, + encoding: pinned.encoding.as_bytes(), + digest: profile.digest(), + }) ); - assert_eq!(profile.digest_string(), pinned.digest, "{}", pinned.id); - assert_eq!(load(index), &profile); assert!(std::ptr::eq( - pinned_for_restore(pinned.id, &profile.digest()).unwrap(), + pinned_for_restore(id, &profile.digest()).unwrap(), load(index) )); } + assert_eq!(pinned_record("intel.icelake-sp.v2"), None); assert_eq!( pinned_profiles() .iter() .map(CpuProfile::id) .collect::>(), - ids + FILES.map(|(id, ..)| id) ); } diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs index da1daf8d07f..0bf3a5e8897 100644 --- a/vmm_core/cpu_profile/src/derive.rs +++ b/vmm_core/cpu_profile/src/derive.rs @@ -119,7 +119,8 @@ pub struct KnownGeneration { } impl KnownGeneration { - fn generation(&self) -> Generation { + /// Returns the generation as profiles record it. + pub(crate) fn generation(&self) -> Generation { Generation { name: self.name.to_owned(), cpus: self diff --git a/vmm_core/cpu_profile/src/effective.rs b/vmm_core/cpu_profile/src/effective.rs index 1b13eb8efcc..32633f6d150 100644 --- a/vmm_core/cpu_profile/src/effective.rs +++ b/vmm_core/cpu_profile/src/effective.rs @@ -4,6 +4,7 @@ //! The effective guest CPUID of a VM: its profile's pinned values, the fields //! its topology and APIC mode define, and the hypervisor identity leaves. +use crate::Hex32; use crate::canonical; use crate::error::ProfileError; use crate::error::ProfileErrorCode; @@ -14,9 +15,7 @@ use crate::profile::describe_leaf; use crate::profile::find; use crate::profile::vm_owned_bits; use serde::Deserialize; -use serde::Serialize; use std::cmp::Ordering; -use std::collections::BTreeMap; /// The schema of the effective-CPUID records this crate reads and writes. pub const EFFECTIVE_CPUID_SCHEMA: &str = "openvmm-effective-cpuid/v1"; @@ -96,14 +95,39 @@ struct EffectiveCpuidDocument { schema: String, } -/// The document [`EffectiveCpuid::encode`] serializes, with its fields in the -/// byte order of their keys, which is the canonical form. -#[derive(Serialize)] +/// The document that [`EffectiveCpuid::encode`] writes, as `serde` would +/// serialize it, for the tests' comparison with the sorting encoder. +#[cfg(test)] +#[derive(serde::Serialize)] struct EffectiveCpuidDocumentRef<'a> { cpuid: &'a [CpuidLeafValue], schema: &'a str, } +/// The size of one entry in the canonical encoding, with a null subleaf. +const ENCODED_ENTRY_BYTES: usize = 167; + +/// Appends `"0x`, the eight hex digits of `value`, and `"`. +fn push_hex32(out: &mut Vec, value: u32) { + let mut text = *b"\"0x00000000\""; + for (i, digit) in text[3..11].iter_mut().enumerate() { + *digit = b"0123456789abcdef"[((value >> (28 - 4 * i)) & 0xf) as usize]; + } + out.extend_from_slice(&text); +} + +/// Appends a JSON array of four register values. +fn push_registers(out: &mut Vec, registers: &[Hex32; 4]) { + out.push(b'['); + for (i, register) in registers.iter().enumerate() { + if i != 0 { + out.push(b','); + } + push_hex32(out, register.0); + } + out.push(b']'); +} + impl EffectiveCpuid { /// Returns the results, sorted by leaf and subleaf. pub fn results(&self) -> impl Iterator + '_ { @@ -122,11 +146,35 @@ impl EffectiveCpuid { } /// Returns the canonical encoding: compact canonical JSON. + /// + /// It writes the document's fixed shape directly, keys in byte order and + /// register values as `0x` and eight hex digits, because restore encodes + /// the recomputed effective CPUID to compare it with the record. Tests + /// check it against the sorting encoder. pub fn encode(&self) -> Vec { - canonical::to_compact_ordered(&EffectiveCpuidDocumentRef { - cpuid: &self.entries, - schema: EFFECTIVE_CPUID_SCHEMA, - }) + let mut out = Vec::with_capacity(64 + self.entries.len() * ENCODED_ENTRY_BYTES); + out.extend_from_slice(b"{\"cpuid\":["); + for (i, entry) in self.entries.iter().enumerate() { + if i != 0 { + out.push(b','); + } + out.extend_from_slice(b"{\"leaf\":"); + push_hex32(&mut out, entry.leaf.0); + out.extend_from_slice(b",\"mask\":"); + push_registers(&mut out, &entry.mask); + out.extend_from_slice(b",\"subleaf\":"); + match entry.subleaf { + Some(subleaf) => push_hex32(&mut out, subleaf.0), + None => out.extend_from_slice(b"null"), + } + out.extend_from_slice(b",\"value\":"); + push_registers(&mut out, &entry.value); + out.push(b'}'); + } + out.extend_from_slice(b"],\"schema\":\""); + out.extend_from_slice(EFFECTIVE_CPUID_SCHEMA.as_bytes()); + out.extend_from_slice(b"\"}"); + out } /// Returns the SHA-256 of [`Self::encode`]. @@ -287,32 +335,33 @@ impl CpuProfile { ), ) }; + // The profile's table is sorted by key, so a sorted vector serves as + // the map, with less code to run on a VM's first start than a tree. let mut entries = self .cpuid() .iter() .map(|entry| (entry.key(), (entry.values(), entry.masks()))) - .collect::>(); + .collect::>(); let max_basic = self.lookup(0, 0)[0]; for leaf in vm.iter().filter(|leaf| leaf.mask != [0; 4]) { - let what = describe_leaf(leaf.function, leaf.index); + let what = || describe_leaf(leaf.function, leaf.index); if VM_OWNED_LEAVES.contains(&leaf.function) { if leaf.function > max_basic { - return Err(error(format!("{what} is beyond the maximum basic leaf"))); + return Err(error(format!( + "{} is beyond the maximum basic leaf", + what() + ))); } if leaf.index.is_none() { - return Err(error(format!("{what} has no subleaf"))); + return Err(error(format!("{} has no subleaf", what()))); } - merge( - entries - .entry((leaf.function, leaf.index)) - .or_insert(([0; 4], [0; 4])), - leaf, - ); + merge(slot(&mut entries, (leaf.function, leaf.index)), leaf); continue; } let mut applied = false; - for (&(function, index), slot) in entries.range_mut((leaf.function, None)..) { + let start = entries.partition_point(|&((function, _), _)| function < leaf.function); + for &mut ((function, index), ref mut slot) in &mut entries[start..] { if function != leaf.function { break; } @@ -328,7 +377,8 @@ impl CpuProfile { for (register, (bits, owned)) in leaf.mask.iter().zip(owned).enumerate() { if bits & !owned != 0 { return Err(error(format!( - "{what} sets {} bits {:#x}, which the profile pins", + "{} sets {} bits {:#x}, which the profile pins", + what(), REGISTERS[register], bits & !owned ))); @@ -339,39 +389,40 @@ impl CpuProfile { } if !applied { return Err(error(format!( - "{what} matches no profile leaf with VM fields" + "{} matches no profile leaf with VM fields", + what() ))); } } for leaf in identity { - let what = describe_leaf(leaf.function, leaf.index); if !HYPERVISOR_LEAVES.contains(&leaf.function) { return Err(error(format!( - "identity {what} is outside the hypervisor range" + "identity {} is outside the hypervisor range", + describe_leaf(leaf.function, leaf.index) ))); } - merge( - entries - .entry((leaf.function, leaf.index)) - .or_insert(([0; 4], [0; 4])), - leaf, - ); + merge(slot(&mut entries, (leaf.function, leaf.index)), leaf); } for &leaf in &VM_OWNED_LEAVES { - if leaf <= max_basic && !entries.contains_key(&(leaf, Some(0))) { + let key = (leaf, Some(0)); + if leaf <= max_basic + && entries + .binary_search_by(|(entry, _)| entry.cmp(&key)) + .is_err() + { return Err(error(format!( "the VM did not define {}", describe_leaf(leaf, Some(0)) ))); } } - for (&(function, index), (value, mask)) in &entries { + for &((function, index), (value, mask)) in &entries { if HYPERVISOR_LEAVES.contains(&function) { continue; } - let owned = vm_owned_bits(function, *value); + let owned = vm_owned_bits(function, value); for (register, (owned, mask)) in owned.iter().zip(mask).enumerate() { if owned & !mask != 0 { return Err(error(format!( @@ -395,6 +446,22 @@ impl CpuProfile { } } +/// Returns the slot of `key` in the sorted `entries`, inserting an empty one +/// if there is none. +fn slot( + entries: &mut Vec<((u32, Option), ([u32; 4], [u32; 4]))>, + key: (u32, Option), +) -> &mut ([u32; 4], [u32; 4]) { + let index = match entries.binary_search_by(|(entry, _)| entry.cmp(&key)) { + Ok(index) => index, + Err(index) => { + entries.insert(index, (key, ([0; 4], [0; 4]))); + index + } + }; + &mut entries[index].1 +} + fn merge(slot: &mut ([u32; 4], [u32; 4]), leaf: &CpuidResult) { let (value, mask) = slot; for register in 0..4 { diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index fd3cef73d1e..4796a4a15e8 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -46,6 +46,8 @@ mod check; mod effective; mod error; mod hex; +mod pinned; +mod pinned_data; mod profile; mod signature; mod surface; @@ -59,6 +61,7 @@ pub use catalog::generation_of; pub use catalog::pinned; pub use catalog::pinned_for_restore; pub use catalog::pinned_profiles; +pub use catalog::pinned_record; pub use catalog::restore_profile; pub use catalog::select; pub use catalog::select_auto; @@ -75,6 +78,7 @@ pub use error::ProfileErrorCode; pub use hex::Hex32; pub use hex::Hex64; pub use hex::ParseHexError; +pub use pinned::PinnedRecord; pub use profile::CpuProfile; pub use profile::CpuidLeafValue; pub use profile::Generation; diff --git a/vmm_core/cpu_profile/src/pinned.rs b/vmm_core/cpu_profile/src/pinned.rs new file mode 100644 index 00000000000..d1498df6c1e --- /dev/null +++ b/vmm_core/cpu_profile/src/pinned.rs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The pinned profiles as compile-time static data. +//! +//! `pinned_data.rs` holds every pinned profile as Rust constants: its values, +//! its canonical encoding, and its golden digest. The `generate_pinned` +//! example generates the file from `profiles/.json`. An OpenVMM start +//! therefore neither parses JSON nor hashes: the first use of a profile +//! copies its constants into a [`CpuProfile`], and snapshot records use the +//! precomputed encoding and digest. The tests check every constant against +//! the JSON files and the golden digests. + +use crate::Hex32; +use crate::Hex64; +use crate::cpuid::XsaveComponent; +use crate::derive::KnownGeneration; +use crate::profile::CpuProfile; +use crate::profile::CpuidLeafValue; +use crate::profile::PinnedMsr; +use crate::profile::Provenance; +use crate::profile::ProvenanceSource; +use crate::profile::SCHEMA; + +/// A pinned profile as static data. +pub(crate) struct PinnedProfile { + /// The profile ID. + pub(crate) id: &'static str, + /// The golden digest: the SHA-256 of `encoding`. + pub(crate) digest: [u8; 32], + /// The canonical encoding, compact canonical JSON. + pub(crate) encoding: &'static str, + /// The human-readable description. + pub(crate) description: &'static str, + /// The generation, which also gives the vendor. + pub(crate) generation: &'static KnownGeneration, + /// The CPUID table, sorted by leaf and subleaf. + pub(crate) cpuid: &'static [CpuidLeafValue], + /// The XCR0 bits the guest may enable. + pub(crate) xcr0: u64, + /// The IA32_XSS bits the guest may enable. + pub(crate) xss: u64, + /// The layout of every enabled XSAVE state component beyond x87 and SSE. + pub(crate) xsave_components: &'static [XsaveComponent], + /// The guest physical address width in bits. + pub(crate) physical_address_width: u32, + /// The pinned MSR values, sorted by index. + pub(crate) msrs: &'static [PinnedMsr], + /// How the profile was derived. + pub(crate) provenance_method: &'static str, + /// The host fingerprints it was derived from, by backend. + pub(crate) provenance_sources: &'static [PinnedSource], +} + +/// The fingerprints of one backend that a pinned profile was derived from. +pub(crate) struct PinnedSource { + backend: &'static str, + hosts: u32, + surface_digests: &'static [&'static str], +} + +impl PinnedProfile { + /// Returns the profile, copied from the constants. + pub(crate) fn to_profile(&self) -> CpuProfile { + CpuProfile { + cpuid: self.cpuid.to_vec(), + description: self.description.to_owned(), + generation: self.generation.generation(), + id: self.id.to_owned(), + msrs: self.msrs.to_vec(), + physical_address_width: self.physical_address_width, + provenance: Provenance { + method: self.provenance_method.to_owned(), + sources: self + .provenance_sources + .iter() + .map(|source| ProvenanceSource { + backend: source.backend.to_owned(), + hosts: source.hosts, + surface_digests: source + .surface_digests + .iter() + .map(|&digest| digest.to_owned()) + .collect(), + }) + .collect(), + }, + schema: SCHEMA.to_owned(), + vendor: self.generation.vendor.to_owned(), + xcr0: Hex64(self.xcr0), + xsave_components: self.xsave_components.to_vec(), + xss: Hex64(self.xss), + } + } + + /// Returns the profile's record. + pub(crate) fn record(&self) -> PinnedRecord { + PinnedRecord { + id: self.id, + encoding: self.encoding.as_bytes(), + digest: self.digest, + } + } +} + +/// The CPU profile record of a pinned profile, precomputed at build time: the +/// canonical encoding that a snapshot embeds, and its SHA-256, the profile +/// digest. Capture can record them, and restore can compare a recorded +/// profile with them, without encoding or hashing. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PinnedRecord { + /// The profile ID. + pub id: &'static str, + /// The canonical encoding, [`CpuProfile::encode`] of the profile. + pub encoding: &'static [u8], + /// The profile digest, the SHA-256 of `encoding`. + pub digest: [u8; 32], +} + +/// Returns a CPUID entry of a pinned profile. +pub(crate) const fn leaf( + leaf: u32, + subleaf: Option, + value: [u32; 4], + mask: [u32; 4], +) -> CpuidLeafValue { + CpuidLeafValue { + leaf: Hex32(leaf), + mask: [ + Hex32(mask[0]), + Hex32(mask[1]), + Hex32(mask[2]), + Hex32(mask[3]), + ], + subleaf: match subleaf { + Some(subleaf) => Some(Hex32(subleaf)), + None => None, + }, + value: [ + Hex32(value[0]), + Hex32(value[1]), + Hex32(value[2]), + Hex32(value[3]), + ], + } +} + +/// Returns an XSAVE state component of a pinned profile. +pub(crate) const fn xsave( + index: u32, + size: u32, + offset: u32, + supervisor: bool, + align64: bool, + xfd: bool, +) -> XsaveComponent { + XsaveComponent { + align64, + index, + offset, + size, + supervisor, + xfd, + } +} + +/// Returns a pinned MSR value of a pinned profile. +pub(crate) const fn msr(index: u32, value: u64, mask: u64) -> PinnedMsr { + PinnedMsr { + index: Hex32(index), + mask: Hex64(mask), + value: Hex64(value), + } +} + +/// Returns a provenance source of a pinned profile. +pub(crate) const fn source( + backend: &'static str, + hosts: u32, + surface_digests: &'static [&'static str], +) -> PinnedSource { + PinnedSource { + backend, + hosts, + surface_digests, + } +} + +/// Returns the digest that `hex`, 64 lowercase hex digits, spells. Malformed +/// input fails the build. +pub(crate) const fn digest(hex: &str) -> [u8; 32] { + const fn nibble(digit: u8) -> u8 { + match digit { + b'0'..=b'9' => digit - b'0', + b'a'..=b'f' => digit - b'a' + 10, + _ => panic!("a digest is lowercase hex"), + } + } + let hex = hex.as_bytes(); + assert!(hex.len() == 64, "a digest has 64 hex digits"); + let mut bytes = [0; 32]; + let mut i = 0; + while i < 32 { + bytes[i] = nibble(hex[2 * i]) << 4 | nibble(hex[2 * i + 1]); + i += 1; + } + bytes +} diff --git a/vmm_core/cpu_profile/src/pinned_data.rs b/vmm_core/cpu_profile/src/pinned_data.rs new file mode 100644 index 00000000000..b37d3854138 --- /dev/null +++ b/vmm_core/cpu_profile/src/pinned_data.rs @@ -0,0 +1,937 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The pinned profiles as static data, generated from `profiles/.json` by +//! the `generate_pinned` example. Do not edit: the tests check every constant +//! against the JSON files and the golden digests. + +use crate::derive::KNOWN_GENERATIONS; +use crate::pinned::PinnedProfile; +use crate::pinned::digest; +use crate::pinned::leaf; +use crate::pinned::msr; +use crate::pinned::source; +use crate::pinned::xsave; + +/// The pinned profiles, in catalog order. Released profiles are immutable: a +/// change is a new revision with a new ID. +pub(crate) const PINNED: [PinnedProfile; 3] = [ + PinnedProfile { + id: "intel.skylake-sp.v1", + digest: digest("b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000016","0x756e6547","0x6c65746e","0x49656e69"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x00050654","0x00000800","0xf6da3203","0x0f8bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x76036301","0x00f0b5ff","0x00000000","0x00c30000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x03c0003f","0x000003ff","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x0280003f","0x00004fff","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0xd19f6fb9","0x00000000","0xac000400"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x000000ff","0x00000000","0x00000a80","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000040","0x000003c0","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000040","0x00000400","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000005","value":["0x00000040","0x00000440","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000006","value":["0x00000200","0x00000480","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000007","value":["0x00000400","0x00000680","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000008","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000121","0x2c100800"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x65746e49","0x2952286c","0x6f655820","0x2952286e"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x6f725020","0x73736563","0x2820726f","0x6c796b53"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x2d656b61","0x00295053","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x01006040","0x00000000"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000302e","0x00000000","0x00000000","0x00000000"]}],"description":"Intel Xeon Scalable, first generation (Skylake-SP), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":6,"model":85,"steppings":[0,4]}],"name":"skylake-sp"},"id":"intel.skylake-sp.v1","msrs":[{"index":"0x0000010a","mask":"0x400000000c12e1bb","value":"0x0000000000000000"}],"physical_address_width":46,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"kvm","hosts":1,"surface_digests":["sha256:862ab0052435bb3c005481854c4aa92c6185ca3f8c6b280bd760d18927a29b9c"]},{"backend":"mshv","hosts":1,"surface_digests":["sha256:75f96c74049a61d5cccca3f83a8dadbf8975cb41bc53a69e5ddcee0511714e82"]},{"backend":"whp","hosts":1,"surface_digests":["sha256:d67f24b8d101181f10b59022682b4945a7014c77dd095a138cc168adae2e5e2b"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"GenuineIntel","xcr0":"0x00000000000000ff","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false},{"align64":false,"index":3,"offset":960,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":4,"offset":1024,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":5,"offset":1088,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":6,"offset":1152,"size":512,"supervisor":false,"xfd":false},{"align64":false,"index":7,"offset":1664,"size":1024,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "Intel Xeon Scalable, first generation (Skylake-SP), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[0], + cpuid: &[ + leaf( + 0x0, + None, + [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0x5_0654, 0x800, 0xf6da_3203, 0xf8b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0x7603_6301, 0xf0_b5ff, 0x0, 0xc3_0000], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x121, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x2), + [0x143, 0x3c0_003f, 0x3ff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x3), + [0x163, 0x280_003f, 0x4fff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x0, 0xd19f_6fb9, 0x0, 0xac00_0400], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0xff, 0x0, 0xa80, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x3), + [0x40, 0x3c0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x4), + [0x40, 0x400, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x5), + [0x40, 0x440, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x6), + [0x200, 0x480, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x7), + [0x400, 0x680, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0008, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0x0, 0x0, 0x121, 0x2c10_0800], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x6574_6e49, 0x2952_286c, 0x6f65_5820, 0x2952_286e], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x6f72_5020, 0x7373_6563, 0x2820_726f, 0x6c79_6b53], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x2d65_6b61, 0x29_5053, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x0, 0x0, 0x100_6040, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x302e, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0xff, + xss: 0x0, + xsave_components: &[ + xsave(2, 256, 576, false, false, false), + xsave(3, 64, 960, false, false, false), + xsave(4, 64, 1024, false, false, false), + xsave(5, 64, 1088, false, false, false), + xsave(6, 512, 1152, false, false, false), + xsave(7, 1024, 1664, false, false, false), + ], + physical_address_width: 46, + msrs: &[msr(0x10a, 0x0, 0x4000_0000_0c12_e1bb)], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[ + source( + "kvm", + 1, + &["sha256:862ab0052435bb3c005481854c4aa92c6185ca3f8c6b280bd760d18927a29b9c"], + ), + source( + "mshv", + 1, + &["sha256:75f96c74049a61d5cccca3f83a8dadbf8975cb41bc53a69e5ddcee0511714e82"], + ), + source( + "whp", + 1, + &["sha256:d67f24b8d101181f10b59022682b4945a7014c77dd095a138cc168adae2e5e2b"], + ), + ], + }, + PinnedProfile { + id: "intel.icelake-sp.v1", + digest: digest("a35f3bb9bc30337b8b76af4939d5914f878ac7dc2a2d6d9d6bf4d8278b91f301"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001b","0x756e6547","0x6c65746e","0x49656e69"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x000606a6","0x00000800","0xf6da3203","0x0f8bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00feff01","0x000000f0","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x02c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x04c0003f","0x000003ff","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x02c0003f","0x0000ffff","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000002","0xf1bf2fb9","0x00415f46","0x20000010"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000000","0x00000000","0x00000000","0x00000001"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x000000e7","0x00000000","0x00000a80","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000005","value":["0x00000040","0x00000440","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000006","value":["0x00000200","0x00000480","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000007","value":["0x00000400","0x00000680","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000008","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000121","0x2c100800"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x65746e49","0x2952286c","0x6f655820","0x2952286e"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x6f725020","0x73736563","0x2820726f","0x20656349"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x656b614c","0x2950532d","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x01006040","0x00000000"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000392e","0x00000000","0x00000000","0x00000000"]}],"description":"Intel Xeon Scalable, third generation (Ice Lake-SP), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":6,"model":106,"steppings":[0,15]}],"name":"icelake-sp"},"id":"intel.icelake-sp.v1","msrs":[{"index":"0x0000010a","mask":"0x400000000c12e1bb","value":"0x0000000008000121"}],"physical_address_width":46,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"kvm","hosts":5,"surface_digests":["sha256:6286956acbefac80fa56363b3b1d5933567e6e1e3a9b15788ddc832e994e772f"]},{"backend":"mshv","hosts":1,"surface_digests":["sha256:270942e3223b3b9ea22d62250659e51248a0de98d412aef3b1ba09ac01ac7379"]},{"backend":"whp","hosts":3,"surface_digests":["sha256:ffc115da4f1554c9bb84f2ff8034bd64eeb8844cb7a8daeb8e1da8867fa2ef1e"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"GenuineIntel","xcr0":"0x00000000000000e7","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false},{"align64":false,"index":5,"offset":1088,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":6,"offset":1152,"size":512,"supervisor":false,"xfd":false},{"align64":false,"index":7,"offset":1664,"size":1024,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "Intel Xeon Scalable, third generation (Ice Lake-SP), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[1], + cpuid: &[ + leaf( + 0x0, + None, + [0x1b, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0x6_06a6, 0x800, 0xf6da_3203, 0xf8b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0xfe_ff01, 0xf0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x121, 0x2c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x2), + [0x143, 0x4c0_003f, 0x3ff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x3), + [0x163, 0x2c0_003f, 0xffff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x2, 0xf1bf_2fb9, 0x41_5f46, 0x2000_0010], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x1), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x2), + [0x0, 0x0, 0x0, 0x1], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0xe7, 0x0, 0xa80, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x5), + [0x40, 0x440, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x6), + [0x200, 0x480, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x7), + [0x400, 0x680, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0008, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0x0, 0x0, 0x121, 0x2c10_0800], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x6574_6e49, 0x2952_286c, 0x6f65_5820, 0x2952_286e], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x6f72_5020, 0x7373_6563, 0x2820_726f, 0x2065_6349], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x656b_614c, 0x2950_532d, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x0, 0x0, 0x100_6040, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x392e, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0xe7, + xss: 0x0, + xsave_components: &[ + xsave(2, 256, 576, false, false, false), + xsave(5, 64, 1088, false, false, false), + xsave(6, 512, 1152, false, false, false), + xsave(7, 1024, 1664, false, false, false), + ], + physical_address_width: 46, + msrs: &[msr(0x10a, 0x800_0121, 0x4000_0000_0c12_e1bb)], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[ + source( + "kvm", + 5, + &["sha256:6286956acbefac80fa56363b3b1d5933567e6e1e3a9b15788ddc832e994e772f"], + ), + source( + "mshv", + 1, + &["sha256:270942e3223b3b9ea22d62250659e51248a0de98d412aef3b1ba09ac01ac7379"], + ), + source( + "whp", + 3, + &["sha256:ffc115da4f1554c9bb84f2ff8034bd64eeb8844cb7a8daeb8e1da8867fa2ef1e"], + ), + ], + }, + PinnedProfile { + id: "intel.emeraldrapids.v1", + digest: digest("73c084783f26df29871c72200ea34470e4afec6187bf96de6ac051dbaed6727d"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001c","0x756e6547","0x6c65746e","0x49656e69"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x000c06f2","0x00000800","0xf6da3203","0x0f8bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00feff01","0x000000f0","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x02c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x03c0003f","0x000007ff","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x04c0003f","0x00033fff","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0xf1bf2fb9","0x00415f46","0x20000010"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x000000e7","0x00000000","0x00000a80","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000005","value":["0x00000040","0x00000440","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000006","value":["0x00000200","0x00000480","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000007","value":["0x00000400","0x00000680","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000008","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000121","0x2c100800"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x65746e49","0x2952286c","0x6f655820","0x2952286e"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x6f725020","0x73736563","0x2820726f","0x72656d45"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x20646c61","0x69706152","0x00297364","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x08007040","0x00000000"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00003930","0x00000000","0x00000000","0x00000000"]}],"description":"Intel Xeon Scalable, fifth generation (Emerald Rapids), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":6,"model":207,"steppings":[0,15]}],"name":"emeraldrapids"},"id":"intel.emeraldrapids.v1","msrs":[{"index":"0x0000010a","mask":"0x400000000c12e1bb","value":"0x0000000000000021"}],"physical_address_width":48,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"mshv","hosts":4,"surface_digests":["sha256:2c5bfaecc6f6ea9fc4ee8c152291264ecbe4ab1e56a57393bb8f5039a0aecfe1"]},{"backend":"whp","hosts":2,"surface_digests":["sha256:257b3a4f975154c05e1d781fa8000bbe525be97f4889f5d700ef2df16ffe7510"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"GenuineIntel","xcr0":"0x00000000000000e7","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false},{"align64":false,"index":5,"offset":1088,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":6,"offset":1152,"size":512,"supervisor":false,"xfd":false},{"align64":false,"index":7,"offset":1664,"size":1024,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "Intel Xeon Scalable, fifth generation (Emerald Rapids), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[2], + cpuid: &[ + leaf( + 0x0, + None, + [0x1c, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0xc_06f2, 0x800, 0xf6da_3203, 0xf8b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0xfe_ff01, 0xf0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x121, 0x2c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x2), + [0x143, 0x3c0_003f, 0x7ff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x3), + [0x163, 0x4c0_003f, 0x3_3fff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x0, 0xf1bf_2fb9, 0x41_5f46, 0x2000_0010], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0xe7, 0x0, 0xa80, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x5), + [0x40, 0x440, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x6), + [0x200, 0x480, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x7), + [0x400, 0x680, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0008, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0x0, 0x0, 0x121, 0x2c10_0800], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x6574_6e49, 0x2952_286c, 0x6f65_5820, 0x2952_286e], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x6f72_5020, 0x7373_6563, 0x2820_726f, 0x7265_6d45], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x2064_6c61, 0x6970_6152, 0x29_7364, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x0, 0x0, 0x800_7040, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x3930, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0xe7, + xss: 0x0, + xsave_components: &[ + xsave(2, 256, 576, false, false, false), + xsave(5, 64, 1088, false, false, false), + xsave(6, 512, 1152, false, false, false), + xsave(7, 1024, 1664, false, false, false), + ], + physical_address_width: 48, + msrs: &[msr(0x10a, 0x21, 0x4000_0000_0c12_e1bb)], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[ + source( + "mshv", + 4, + &["sha256:2c5bfaecc6f6ea9fc4ee8c152291264ecbe4ab1e56a57393bb8f5039a0aecfe1"], + ), + source( + "whp", + 2, + &["sha256:257b3a4f975154c05e1d781fa8000bbe525be97f4889f5d700ef2df16ffe7510"], + ), + ], + }, +]; diff --git a/vmm_core/cpu_profile/src/profile.rs b/vmm_core/cpu_profile/src/profile.rs index 2ee3d710845..5e1401a9ece 100644 --- a/vmm_core/cpu_profile/src/profile.rs +++ b/vmm_core/cpu_profile/src/profile.rs @@ -313,14 +313,6 @@ impl CpuProfile { Ok(this) } - /// Parses and validates a profile pinned in this crate. Unlike - /// [`Self::from_pretty_json`], it does not re-encode the profile to - /// check its form: the catalog's tests check every pinned file's form and - /// golden digest, and this runs on every OpenVMM start. - pub(crate) fn from_pinned_json(text: &str) -> Result { - Self::parse(text) - } - fn parse(text: &str) -> Result { let schema_error = |schema: Option<&str>| { invalid(format!( diff --git a/vmm_core/cpu_profile/src/unlisted.rs b/vmm_core/cpu_profile/src/unlisted.rs index 923116737a4..d66d3b47b0f 100644 --- a/vmm_core/cpu_profile/src/unlisted.rs +++ b/vmm_core/cpu_profile/src/unlisted.rs @@ -34,14 +34,17 @@ const IDENTITY_RANGE: RangeInclusive = HYPERVISOR_LEAF_BASE..=0x4fff_ffff; /// subleaf of its leaf. fn is_unlisted(profile: &CpuProfile, leaf: u32, subleaf: Option) -> bool { let owned = IDENTITY_RANGE.contains(&leaf) || VM_OWNED_LEAVES.contains(&leaf); + // The table is sorted by leaf, and core checks every boot's report. + let table = profile.cpuid(); + let start = table.partition_point(|entry| entry.leaf.0 < leaf); !owned - && !profile.cpuid().iter().any(|entry| { - entry.leaf.0 == leaf - && match (entry.subleaf, subleaf) { - (Some(listed), Some(subleaf)) => listed.0 == subleaf, - (None, _) | (_, None) => true, - } - }) + && !table[start..] + .iter() + .take_while(|entry| entry.leaf.0 == leaf) + .any(|entry| match (entry.subleaf, subleaf) { + (Some(listed), Some(subleaf)) => listed.0 == subleaf, + (None, _) | (_, None) => true, + }) } /// Returns every non-zero entry of `presented`, in order, that is outside From 2a108a06f60211368cd4aa4750fff99fa9483ebd Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 19:11:04 -0700 Subject: [PATCH 236/304] virt_mshv: give each VP its x2APIC ID in the time ABI's topology leaves MSHV does not implement the extended topology leaves for these partitions. With core's effective CPUID registered partition-wide, EDX of 0xB reads 0 on every VP, and Linux logs "APIC ID mismatch" for every AP (G_APIC_ID_MISMATCH, the spec's TBD(mshv)). Register the 0xB and 0x1F entries for each VP instead, with that VP's x2APIC ID in EDX, when the VP is created: the hypervisor refuses a per-VP result for a VP that does not exist yet (InvalidVpIndex). Leave them out of the zero-fill: they are the VM's, and the entries past the topology's terminator are reserved. Add a hardware test that reads every VP's topology entries and initial APIC ID, and make the CPUID sweep report reserved topology entries instead of failing on them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/mod.rs | 49 +++++- .../virt_mshv/src/x86_64/profile_features.rs | 27 ++- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 164 +++++++++++++++++- 3 files changed, 223 insertions(+), 17 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index bb3bd4e928a..e4f5cea7392 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -299,9 +299,19 @@ fn partition_create_args( /// leaf (and subleaf, if it has one), applied over the hypervisor's own result /// under the leaf's mask. fn register_cpuid_result(vmfd: &mshv_ioctls::VmFd, leaf: &virt::CpuidLeaf) -> Result<(), Error> { + register_cpuid_result_for(vmfd, hvdef::HV_ANY_VP, leaf) +} + +/// Registers `leaf` as the result of CPUID intercepts for its leaf (and +/// subleaf) on VP `vp_index` only, or on every VP for `HV_ANY_VP`. +fn register_cpuid_result_for( + vmfd: &mshv_ioctls::VmFd, + vp_index: u32, + leaf: &virt::CpuidLeaf, +) -> Result<(), Error> { let input = hvdef::hypercall::RegisterInterceptResultCpuid { partition_id: 0, - vp_index: hvdef::HV_ANY_VP, + vp_index, intercept_type: hvdef::hypercall::HvInterceptType::HvInterceptTypeX64Cpuid, parameters: hvdef::hypercall::HvRegisterX64CpuidResultParameters { input: hvdef::hypercall::HvRegisterX64CpuidResultParametersInput { @@ -563,14 +573,26 @@ impl ProtoPartition for MshvProtoPartition<'_> { }; let host_cpuid_us = started.elapsed().as_micros() as u64; - // Apply CPUID overrides partition-wide. + // Apply CPUID overrides partition-wide, except a time ABI partition's + // extended topology leaves: their EDX is each VP's x2APIC ID, which + // the hypervisor does not provide for these partitions, so every VP + // gets its own results when it is created (see `create_vp`). let started = std::time::Instant::now(); - for leaf in cpuid.leaves().iter().chain(&zero_results) { + let per_vp = |leaf: &&virt::CpuidLeaf| { + self.config.time_abi.is_some() && time_abi::is_per_vp_leaf(leaf.function) + }; + for leaf in cpuid + .leaves() + .iter() + .chain(&zero_results) + .filter(|leaf| !per_vp(leaf)) + { register_cpuid_result(&self.vmfd, leaf)?; } tracing::info!( leaves = cpuid.leaves().len(), zero_results = zero_results.len(), + per_vp_leaves = cpuid.leaves().iter().filter(per_vp).count(), host_cpuid_us, elapsed_us = started.elapsed().as_micros() as u64, "registered MSHV CPUID results" @@ -833,6 +855,11 @@ impl MshvPartitionInner { /// Creates the hypervisor VP and records that it exists. Every VP is /// created here, so restored-TSC synchronization sees exactly the created /// VPs. + /// + /// A time ABI partition's extended topology leaves get this VP's own + /// results here, with its x2APIC ID in EDX: the hypervisor does not + /// provide that ID for these partitions, and accepts a per-VP result only + /// once the VP exists. fn create_vp(&self, vp_index: VpIndex) -> Result { if let Some(time_abi) = &self.time_abi { time_abi.check_vp_creation(vp_index)?; @@ -843,6 +870,22 @@ impl MshvPartitionInner { u8::try_from(vp_index.index()).expect("VP count validated at partition creation"), ) .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; + if self.time_abi.is_some() { + let apic_id = self.vp(vp_index).vp_info.apic_id; + for leaf in self + .config + .cpuid + .leaves() + .iter() + .filter(|leaf| time_abi::is_per_vp_leaf(leaf.function)) + { + register_cpuid_result_for( + &self.vmfd, + vp_index.index(), + &time_abi::with_x2apic_id(leaf, apic_id), + )?; + } + } self.vp(vp_index) .created .store(true, std::sync::atomic::Ordering::SeqCst); diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index eadcfa46b9d..acc50c77726 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -142,13 +142,17 @@ const ZERO_LEAVES_PAST_MAXIMUM: u32 = 4; /// [`ZERO_LEAVES_PAST_MAXIMUM`] past `cpuid`'s, whichever is higher. /// /// The hypervisor range belongs to the time ABI's identity and explicit zero -/// leaves, which `cpuid` lists. +/// leaves, which `cpuid` lists. The extended topology leaves belong to the VM, +/// whose topology `cpuid` lists, and the backend registers them once per VP +/// (see [`is_per_vp_leaf`](super::time_abi::is_per_vp_leaf)), so they get no +/// zero results. Their entries past the topology's terminator are reserved. pub(crate) fn unlisted_zero_results( cpuid: &virt::CpuidLeafSet, host: &[CpuidEntry], ) -> Vec { let leaves = cpuid.leaves(); let listed = |function: u32| leaves.iter().any(|leaf| leaf.function == function); + let vm_owned = super::time_abi::is_per_vp_leaf; let by_subleaf = |function: u32| { leaves .iter() @@ -163,7 +167,8 @@ pub(crate) fn unlisted_zero_results( for entry in host { let (function, subleaf) = entry.key(); if let Some(subleaf) = subleaf { - if listed(function) + if !vm_owned(function) + && listed(function) && by_subleaf(function) && !leaves .iter() @@ -180,7 +185,7 @@ pub(crate) fn unlisted_zero_results( continue; } for function in base..=last { - if !listed(function) { + if !vm_owned(function) && !listed(function) { zeros.push(CpuidLeaf::new(function, [0; 4])); } } @@ -427,7 +432,7 @@ mod tests { fn unlisted_leaves_and_subleaves_get_zero_results() { let entry = |leaf, subleaf, eax| CpuidEntry::new(leaf, subleaf, [eax, 1, 2, 3]); let host = vec![ - entry(0, None, 0xa), + entry(0, None, 0xb), entry(1, None, 0), entry(2, None, 0), entry(4, Some(0), 0x121), @@ -438,6 +443,9 @@ mod tests { entry(7, Some(1), 0), entry(7, Some(2), 0), entry(0xa, None, 0), + entry(0xb, Some(0), 1), + entry(0xb, Some(1), 4), + entry(0xb, Some(2), 0), entry(0xd, Some(0), 0), entry(0xd, Some(1), 0), entry(0xd, Some(2), 0), @@ -450,6 +458,9 @@ mod tests { CpuidLeaf::new(4, [0x121, 0, 0, 0]).indexed(0), CpuidLeaf::new(4, [0x122, 0, 0, 0]).indexed(1), CpuidLeaf::new(7, [0; 4]).indexed(0), + // The VM's topology: two levels, without the host's terminator. + CpuidLeaf::new(0xb, [0, 1, 0x100, 0]).indexed(0), + CpuidLeaf::new(0xb, [1, 1, 0x201, 0]).indexed(1), // A leaf listed whole gets no subleaf results: MSHV refuses both. CpuidLeaf::new(0xd, [0; 4]), CpuidLeaf::new(HYPERVISOR_LEAF_BASE, [HYPERVISOR_LEAF_BASE + 5, 0, 0, 0]), @@ -463,10 +474,12 @@ mod tests { (leaf.function, leaf.index) }) .collect(); + // The host's topology terminator 0xb.2 gets none: the topology leaves + // are the VM's. let mut expected = vec![(4, Some(2)), (4, Some(3)), (7, Some(1)), (7, Some(2))]; - // Basic leaves up to the host's maximum (0xa) or four past the table's - // (0xb), whichever is higher; extended up to the host's 0x80000008. - expected.extend([2, 3, 5, 6, 8, 9, 0xa, 0xb].map(|leaf| (leaf, None))); + // Basic leaves up to the host's maximum or four past the table's (both + // 0xb), whichever is higher; extended up to the host's 0x80000008. + expected.extend([2, 3, 5, 6, 8, 9, 0xa].map(|leaf| (leaf, None))); expected.extend((2..=8).map(|leaf| (EXTENDED_LEAF_BASE + leaf, None))); assert_eq!(zeros, expected); } diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 858f54c0b16..fa73045e529 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -19,7 +19,9 @@ //! - CPUID intercept results present the configured CPUID verbatim: it is the //! CPU profile's complete effective CPUID, and the backend adds nothing to //! it except zero results for the leaves and subleaves it does not list. The -//! hypervisor reports no hypervisor leaf of its own, which preflight +//! extended topology leaves are registered once per VP with that VP's +//! x2APIC ID, which the hypervisor does not provide for these partitions. +//! The hypervisor reports no hypervisor leaf of its own, which preflight //! verifies at sentinel leaves. //! - The processor feature banks follow the CPU profile (see //! [`profile_features`](super::profile_features)), keep the invariant TSC, @@ -295,6 +297,24 @@ pub(super) fn partition_cpuid(config: &CpuidLeafSet) -> CpuidLeafSet { CpuidLeafSet::new(config.leaves().to_vec()) } +/// Returns whether CPUID leaf `function` carries a field that differs per VP +/// and that the hypervisor does not provide for a time ABI partition: the +/// extended topology leaves `0xB` and `0x1F`, whose `EDX` is the VP's x2APIC +/// ID. The hypervisor does not implement them for these partitions and reads +/// that `EDX` as 0 on every VP, so each VP gets its own result. Leaf 1's +/// initial APIC ID, the other per-VP field on Intel, is the hypervisor's own. +pub(super) fn is_per_vp_leaf(function: u32) -> bool { + matches!(function, 0xb | 0x1f) +} + +/// Returns `leaf` for the VP with x2APIC ID `apic_id`: its `EDX` is that ID. +pub(super) fn with_x2apic_id(leaf: &CpuidLeaf, apic_id: u32) -> CpuidLeaf { + let mut leaf = *leaf; + leaf.result[3] = apic_id; + leaf.mask[3] = !0; + leaf +} + /// Returns whether `leaf` is an explicit zero leaf past the identity, which /// the effective CPUID reports as registered instead of reading it back. /// Preflight reads such leaves back at its sentinels. @@ -1077,6 +1097,25 @@ mod tests { assert_eq!(cpuid.result(0x8000_0007, 0, &[!0; 4]), [0, 0, 0, 0x100]); } + #[test] + fn extended_topology_leaves_take_each_vp_x2apic_id() { + for function in [0xb, 0x1f] { + assert!(is_per_vp_leaf(function), "{function:#x}"); + } + // Leaf 1's initial APIC ID is the hypervisor's own per-VP value. + for function in [0, 1, 4, 0xd, 0x4000_0000, 0x8000_001e] { + assert!(!is_per_vp_leaf(function), "{function:#x}"); + } + let level = CpuidLeaf::new(0xb, [1, 2, 0x100, 0]) + .indexed(0) + .masked([!0, !0, !0, 0]); + let leaf = with_x2apic_id(&level, 5); + assert_eq!( + (leaf.function, leaf.index, leaf.result, leaf.mask), + (0xb, Some(0), [1, 2, 0x100, 5], [!0; 4]) + ); + } + #[test] fn zero_fill_leaves_are_full_zero_leaves_past_the_identity() { for function in [0x4000_0006, 0x4000_0081, 0x4000_00ff] { @@ -1621,8 +1660,10 @@ mod hw { /// Sweeps, as VP 0 of a time ABI partition observes them, subleaves 0 to /// 63 of every indexed leaf of the effective CPUID and the four leaves /// past the maximum basic and extended leaves. Listed results must match - /// under their masks. By the profile's contract a leaf or subleaf that the - /// table does not list reads zero, which the zero results provide on MSHV. + /// under their masks. A leaf or subleaf that the table does not list must + /// read zero, which the zero results provide on MSHV, except past the VM + /// topology's levels in the extended topology leaves: those are reserved, + /// and the sweep only reports them. #[async_test] #[ignore = "requires /dev/mshv"] async fn unlisted_cpuid_entries_read_zero(driver: DefaultDriver) { @@ -1690,7 +1731,8 @@ mod hw { .flat_map(|&function| (0..64).map(move |index| (function, index))) .chain((max_basic + 1..=max_basic + 4).map(|function| (function, 0))) .chain((max_extended + 1..=max_extended + 4).map(|function| (function, 0))); - let (mut count, mut listed_failures, mut unlisted) = (0, Vec::new(), Vec::new()); + let (mut count, mut listed_failures, mut unlisted, mut reserved) = + (0, Vec::new(), Vec::new(), Vec::new()); for (function, index) in probes { count += 1; let listed = effective.results().find(|result| { @@ -1706,24 +1748,132 @@ mod hw { let line = format!("{function:#x}.{index}: {actual:08x?}"); if listed.is_some() { listed_failures.push(format!("{line} expected {expected:08x?} mask {mask:08x?}")); + } else if is_per_vp_leaf(function) { + reserved.push(line); } else { unlisted.push(line); } } println!( - "{}: {count} probes of {} indexed leaves (max basic {max_basic:#x}, max extended {max_extended:#x}); {} listed results differ, {} unlisted results are not zero", + "{}: {count} probes of {} indexed leaves (max basic {max_basic:#x}, max extended {max_extended:#x}); {} listed results differ, {} unlisted results are not zero, {} reserved topology entries are not zero", host_profile_id(), indexed.len(), listed_failures.len(), - unlisted.len() + unlisted.len(), + reserved.len() ); - for line in listed_failures.iter().chain(&unlisted) { + for line in listed_failures.iter().chain(&unlisted).chain(&reserved) { println!(" {line}"); } assert!(listed_failures.is_empty(), "{listed_failures:#?}"); assert!(unlisted.is_empty(), "{unlisted:#?}"); } + /// Every VP of a time ABI partition reads its own x2APIC ID in `EDX` of + /// the extended topology leaves, which OpenVMM registers per VP because + /// the hypervisor reads it as 0 on every VP, and the table's other + /// registers. Leaf 1's initial APIC ID is the hypervisor's own and must + /// be the VP's too. + #[async_test] + #[ignore = "requires /dev/mshv"] + async fn extended_topology_reports_each_vp_x2apic_id(driver: DefaultDriver) { + const VPS: u32 = 4; + let processor_topology = TopologyBuilder::new_x86() + .x2apic(X2ApicState::Supported) + .build(VPS) + .unwrap(); + let (_, table) = host_profile_cpuid(&processor_topology); + let topology_leaves: Vec = table + .leaves() + .iter() + .filter(|leaf| is_per_vp_leaf(leaf.function)) + .copied() + .collect(); + assert!(!topology_leaves.is_empty()); + let mem_layout = MemoryLayout::new(0x400000, &[], &[], &[], None).unwrap(); + let vmtime_keeper = VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); + let vmtime = vmtime_keeper.builder().build(&driver).await.unwrap(); + let guest_memory = GuestMemory::allocate(mem_layout.end_of_ram() as usize); + let mut mshv = LinuxMshv::new().unwrap(); + let (partition, mut binders) = mshv + .new_partition(ProtoPartitionConfig { + processor_topology: &processor_topology, + hv_config: None, + vmtime: &vmtime, + isolation: virt::ProtoPartitionIsolation::None, + nested_virt: false, + user_mode_memory_faults: false, + lazy_memory_registration: false, + versioned_cpu_contract: false, + time_abi: Some(TimeAbiConfig { + cpuid: Arc::new(table), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: host_profile_id(), + }), + }) + .unwrap() + .build(PartitionConfig { + mem_layout: &mem_layout, + guest_memory: &guest_memory, + cpuid: &[], + vtl0_alias_map: None, + fault_resolver: None, + }) + .unwrap(); + let ram = guest_memory.inner_buf().unwrap(); + // SAFETY: the guest memory outlives the partition. + unsafe { + partition.memory_mapper(Vtl::Vtl0).map_range( + ram.as_ptr().cast_mut().cast(), + ram.len(), + 0, + true, + true, + ) + } + .unwrap(); + partition.finalize_memory().unwrap(); + for binder in &mut binders { + binder.bind().unwrap(); + } + let finalized = partition.inner.finalized().unwrap(); + let mut checked = 0; + for (vp, binder) in processor_topology.vps_arch().zip(&binders) { + let vcpufd = if vp.base.vp_index.is_bsp() { + &finalized.bsp_vcpufd + } else { + binder.vcpufd.as_ref().unwrap() + }; + for leaf in &topology_leaves { + let index = leaf.index.unwrap_or(0); + let actual = vcpufd.get_cpuid_values(leaf.function, index, 0, 0).unwrap(); + let expected = with_x2apic_id(leaf, vp.apic_id); + assert!( + (0..4).all(|r| (actual[r] ^ expected.result[r]) & expected.mask[r] == 0), + "VP {} CPUID {:#x}.{index}: {actual:08x?}, expected {:08x?}", + vp.base.vp_index.index(), + leaf.function, + expected.result + ); + checked += 1; + } + let [_, ebx, _, _] = vcpufd.get_cpuid_values(1, 0, 0, 0).unwrap(); + assert_eq!( + ebx >> 24, + vp.apic_id & 0xff, + "VP {} initial APIC ID", + vp.base.vp_index.index() + ); + } + println!( + "{VPS} VPs with x2APIC IDs {:?}: {checked} extended topology entries carry each VP's ID, and leaf 1 its initial APIC ID", + processor_topology + .vps_arch() + .map(|vp| vp.apic_id) + .collect::>() + ); + } + /// The negative path of identity routing: the hypervisor refuses an /// MSR-index intercept for `IA32_ARCH_CAPABILITIES` (AccessDenied), which /// must surface as `E_IDENTITY_ROUTING` naming the MSR. From 4b75f77416b1d608a3b88a2328ef5e032645f974 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 16:34:50 -0700 Subject: [PATCH 237/304] openvmm_core: list the extended topology terminator in the effective CPUID The effective CPUID is the guest's whole CPUID on every backend, but it listed only leaf 0Bh's SMT and core levels. The subleaf that ends the enumeration, which Linux reads, was left to each backend: KVM synthesizes Intel's invalid level (ECX[7:0] = 2, EDX = the x2APIC ID), WHP's exit handler zeroes it, and MSHV shows the hypervisor's own value. List that terminator after the levels of 0Bh, and of 1Fh where a profile reaches it, with VP 0's x2APIC ID in EDX like the other levels. Backends present it with each VP's own ID, so the guest sees the same terminator everywhere and the preflight CPUID check covers it. No pinned profile reaches 1Fh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/worker/dispatch/time_abi.rs | 96 ++++++++++++++++++- 1 file changed, 94 insertions(+), 2 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 204fe4bc16e..ce5813d48bb 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -41,10 +41,13 @@ use virt::time_abi::TimeAbiTestHooks; use virt::time_abi::TscSyncMethod; use virt::time_abi::surface::SupportedCpuSurface; use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::VpIndex; use vm_topology::processor::x86::ApicMode; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; use vmcore::save_restore::SavedStateBlob; +use x86defs::cpuid::CpuidFunction; +use x86defs::cpuid::ExtendedTopologyEcx; /// The name of the time ABI state unit. pub(super) const TIME_ABI_UNIT: &str = "time-abi"; @@ -106,7 +109,8 @@ pub(super) fn select_cpu_profile(requested: &str) -> anyhow::Result<&'static Cpu } /// Builds the effective CPUID of a partition with `profile` and `topology`: -/// the profile completed with OpenVMM's topology leaves, the APIC mode, and +/// the profile completed with OpenVMM's topology leaves, each extended +/// topology leaf's terminating subleaf, the APIC mode, and /// the time ABI's identity and explicit zero leaves. Fails with /// `E_CPU_SURFACE` if they do not complete the profile exactly. pub(super) fn effective_cpuid( @@ -134,6 +138,7 @@ pub(super) fn effective_cpuid( ), ) })?; + terminate_extended_topology(topology, &mut topology_leaves); let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); vm.push(cpu_profile::x2apic_cpuid(!matches!( topology.apic_mode(), @@ -147,6 +152,33 @@ pub(super) fn effective_cpuid( Ok(profile.effective_cpuid(&vm, &identity)?) } +/// Adds the subleaf that ends each extended topology leaf in `leaves` (0Bh, +/// and 1Fh where the profile reaches it) after its levels: an invalid level +/// with its own number in `ECX[7:0]` and the BSP's x2APIC ID in `EDX`, as +/// Intel defines it. Linux reads it to end its topology enumeration, so the +/// effective CPUID lists it instead of leaving it to each backend. +fn terminate_extended_topology(topology: &ProcessorTopology, leaves: &mut Vec) { + let bsp_apic_id = topology.vp_arch(VpIndex::BSP).apic_id; + for function in [ + CpuidFunction::ExtendedTopologyEnumeration, + CpuidFunction::V2ExtendedTopologyEnumeration, + ] { + let levels = leaves + .iter() + .filter(|leaf| leaf.function == function.0) + .count() as u32; + if levels == 0 { + continue; + } + let ecx = ExtendedTopologyEcx::new().with_level_number(levels as u8); + leaves.push( + CpuidLeaf::new(function.0, [0, 0, ecx.into(), bsp_apic_id]) + .indexed(levels) + .masked([!0; 4]), + ); + } +} + /// Returns the CPUID results a backend programs for `effective`: every /// result of the effective CPUID, with the per-VP APIC identity bits /// unmasked, so that the backend sets each VP's own. @@ -597,7 +629,6 @@ mod tests { use super::*; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use vm_topology::processor::TopologyBuilder; - use vm_topology::processor::VpIndex; use vm_topology::processor::x86::X2ApicState; const TEST_PROFILE: &str = "intel.icelake-sp.v1"; @@ -766,6 +797,67 @@ mod tests { ); } + /// The effective CPUID ends leaf 0Bh with Intel's terminating subleaf, + /// which carries VP 0's x2APIC ID; the backend sets each VP's own. No + /// pinned profile reaches leaf 1Fh. + #[test] + fn effective_cpuid_terminates_the_extended_topology() { + for profile in cpu_profile::pinned_profiles() { + assert!(profile.lookup(0, 0)[0] < 0x1f, "{}", profile.id()); + for vp_count in [1, 2, 8] { + let effective = + effective_cpuid(profile, &topology(vp_count, X2ApicState::Supported)).unwrap(); + let subleaves = |function| { + effective + .results() + .filter(|result| result.function == function) + .map(|result| (result.index, result.result, result.mask)) + .collect::>() + }; + let leaf_b = subleaves(0xb); + assert_eq!(leaf_b.len(), 3, "{}: {leaf_b:x?}", profile.id()); + assert_eq!(leaf_b[2], (Some(2), [0, 0, 2, 0], [!0; 4])); + assert!(subleaves(0x1f).is_empty()); + + let (_, config) = partition_config(profile, &effective); + let terminator = config + .cpuid + .leaves() + .iter() + .find(|leaf| leaf.function == 0xb && leaf.index == Some(2)) + .unwrap(); + assert_eq!(terminator.mask, [!0, !0, !0, 0]); + } + } + } + + #[test] + fn every_extended_topology_leaf_gets_a_terminator() { + let topology = topology(4, X2ApicState::Supported); + let mut leaves = Vec::new(); + for function in [0xb, 0x1f] { + for index in 0..2 { + leaves.push( + CpuidLeaf::new(function, [1, 2, 3, 0]) + .indexed(index) + .masked([!0; 4]), + ); + } + } + terminate_extended_topology(&topology, &mut leaves); + let terminators: Vec<_> = leaves[4..] + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result, leaf.mask)) + .collect(); + assert_eq!( + terminators, + [ + (0xb, Some(2), [0, 0, 2, 0], [!0; 4]), + (0x1f, Some(2), [0, 0, 2, 0], [!0; 4]), + ] + ); + } + #[test] fn presented_cpuid_must_match_under_the_masks() { let (_, config, effective) = test_config(2); From 259186e440d0ee2353df1132c0888ec9545a29ef Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 20:13:54 -0700 Subject: [PATCH 238/304] virt: share the extended topology terminator with backend tests Core's effective CPUID ends 0Bh, and 1Fh where a profile reaches it, with Intel's terminating subleaf. The WHP and MSHV hardware tests mirror the effective CPUID because their crates cannot depend on openvmm_core: whp copied the terminator into its mirror, and mshv's mirror still lacks it. Move terminate_extended_topology from openvmm_core into virt::x86::topology, next to topology_cpuid, which every backend already uses, so the mirrors can call the one implementation. topology_cpuid itself still does not add the subleaf, so partitions without the time ABI keep their backend's own answer there. The function is unchanged, and its test moves with it and also checks that a table without extended topology leaves gains nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/worker/dispatch/time_abi.rs | 60 +--------------- vmm_core/virt/src/x86/topology.rs | 68 +++++++++++++++++++ 2 files changed, 70 insertions(+), 58 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index ce5813d48bb..665663582be 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -41,13 +41,10 @@ use virt::time_abi::TimeAbiTestHooks; use virt::time_abi::TscSyncMethod; use virt::time_abi::surface::SupportedCpuSurface; use vm_topology::processor::ProcessorTopology; -use vm_topology::processor::VpIndex; use vm_topology::processor::x86::ApicMode; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; use vmcore::save_restore::SavedStateBlob; -use x86defs::cpuid::CpuidFunction; -use x86defs::cpuid::ExtendedTopologyEcx; /// The name of the time ABI state unit. pub(super) const TIME_ABI_UNIT: &str = "time-abi"; @@ -138,7 +135,7 @@ pub(super) fn effective_cpuid( ), ) })?; - terminate_extended_topology(topology, &mut topology_leaves); + virt::x86::topology::terminate_extended_topology(topology, &mut topology_leaves); let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); vm.push(cpu_profile::x2apic_cpuid(!matches!( topology.apic_mode(), @@ -152,33 +149,6 @@ pub(super) fn effective_cpuid( Ok(profile.effective_cpuid(&vm, &identity)?) } -/// Adds the subleaf that ends each extended topology leaf in `leaves` (0Bh, -/// and 1Fh where the profile reaches it) after its levels: an invalid level -/// with its own number in `ECX[7:0]` and the BSP's x2APIC ID in `EDX`, as -/// Intel defines it. Linux reads it to end its topology enumeration, so the -/// effective CPUID lists it instead of leaving it to each backend. -fn terminate_extended_topology(topology: &ProcessorTopology, leaves: &mut Vec) { - let bsp_apic_id = topology.vp_arch(VpIndex::BSP).apic_id; - for function in [ - CpuidFunction::ExtendedTopologyEnumeration, - CpuidFunction::V2ExtendedTopologyEnumeration, - ] { - let levels = leaves - .iter() - .filter(|leaf| leaf.function == function.0) - .count() as u32; - if levels == 0 { - continue; - } - let ecx = ExtendedTopologyEcx::new().with_level_number(levels as u8); - leaves.push( - CpuidLeaf::new(function.0, [0, 0, ecx.into(), bsp_apic_id]) - .indexed(levels) - .masked([!0; 4]), - ); - } -} - /// Returns the CPUID results a backend programs for `effective`: every /// result of the effective CPUID, with the per-VP APIC identity bits /// unmasked, so that the backend sets each VP's own. @@ -629,6 +599,7 @@ mod tests { use super::*; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::VpIndex; use vm_topology::processor::x86::X2ApicState; const TEST_PROFILE: &str = "intel.icelake-sp.v1"; @@ -831,33 +802,6 @@ mod tests { } } - #[test] - fn every_extended_topology_leaf_gets_a_terminator() { - let topology = topology(4, X2ApicState::Supported); - let mut leaves = Vec::new(); - for function in [0xb, 0x1f] { - for index in 0..2 { - leaves.push( - CpuidLeaf::new(function, [1, 2, 3, 0]) - .indexed(index) - .masked([!0; 4]), - ); - } - } - terminate_extended_topology(&topology, &mut leaves); - let terminators: Vec<_> = leaves[4..] - .iter() - .map(|leaf| (leaf.function, leaf.index, leaf.result, leaf.mask)) - .collect(); - assert_eq!( - terminators, - [ - (0xb, Some(2), [0, 0, 2, 0], [!0; 4]), - (0x1f, Some(2), [0, 0, 2, 0], [!0; 4]), - ] - ); - } - #[test] fn presented_cpuid_must_match_under_the_masks() { let (_, config, effective) = test_config(2); diff --git a/vmm_core/virt/src/x86/topology.rs b/vmm_core/virt/src/x86/topology.rs index ad4b05ae668..35ce027bcc0 100644 --- a/vmm_core/virt/src/x86/topology.rs +++ b/vmm_core/virt/src/x86/topology.rs @@ -9,6 +9,7 @@ use crate::CpuidLeaf; use std::cmp::min; use thiserror::Error; use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::VpIndex; use x86defs::cpuid::CacheParametersEax; use x86defs::cpuid::CpuidFunction; use x86defs::cpuid::ExtendedAddressSpaceSizesEcx; @@ -131,6 +132,37 @@ pub fn topology_cpuid<'a>( Ok(()) } +/// Adds the subleaf that ends each extended topology leaf in `leaves` (0Bh, +/// and 1Fh where present) after its levels: an invalid level with its own +/// number in `ECX[7:0]` and the BSP's x2APIC ID in `EDX`, as Intel defines +/// it. Linux reads it to end its topology enumeration. +/// +/// [`topology_cpuid`] does not add it, so partitions without the NVX time ABI +/// keep their backend's own answer there. The time ABI's effective CPUID +/// lists it; backend tests that mirror the effective CPUID call this function +/// instead of copying it. +pub fn terminate_extended_topology(topology: &ProcessorTopology, leaves: &mut Vec) { + let bsp_apic_id = topology.vp_arch(VpIndex::BSP).apic_id; + for function in [ + CpuidFunction::ExtendedTopologyEnumeration, + CpuidFunction::V2ExtendedTopologyEnumeration, + ] { + let levels = leaves + .iter() + .filter(|leaf| leaf.function == function.0) + .count() as u32; + if levels == 0 { + continue; + } + let ecx = ExtendedTopologyEcx::new().with_level_number(levels as u8); + leaves.push( + CpuidLeaf::new(function.0, [0, 0, ecx.into(), bsp_apic_id]) + .indexed(levels) + .masked([!0; 4]), + ); + } +} + /// Adds subleaves for leaf 04h. /// /// Only valid for Intel processors. @@ -364,4 +396,40 @@ mod tests { ); assert_eq!(per_vp_cpuid_bits(7), [0; 4]); } + + #[test] + fn every_extended_topology_leaf_gets_a_terminator() { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(4) + .x2apic(X2ApicState::Supported) + .build(4) + .unwrap(); + let mut leaves = Vec::new(); + for function in [0xb, 0x1f] { + for index in 0..2 { + leaves.push( + CpuidLeaf::new(function, [1, 2, 3, 0]) + .indexed(index) + .masked([!0; 4]), + ); + } + } + terminate_extended_topology(&topology, &mut leaves); + let terminators: Vec<_> = leaves[4..] + .iter() + .map(|leaf| (leaf.function, leaf.index, leaf.result, leaf.mask)) + .collect(); + assert_eq!( + terminators, + [ + (0xb, Some(2), [0, 0, 2, 0], [!0; 4]), + (0x1f, Some(2), [0, 0, 2, 0], [!0; 4]), + ] + ); + + // Without extended topology leaves, nothing is added. + let mut leaves = vec![CpuidLeaf::new(1, [0; 4])]; + terminate_extended_topology(&topology, &mut leaves); + assert_eq!(leaves.len(), 1); + } } From ba88ab325ff1b8ba757a82d005db52746a4a03e7 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 20:24:41 -0700 Subject: [PATCH 239/304] cpu_profile: state that no CPUID entry outside the profile carries host data Align the contract wording with the time ABI specification. KVM installs the effective CPUID as the whole CPUID table and answers every other entry from it: zero, Intel's out-of-range result above the maximum basic leaf, or an invalid topology level with the x2APIC ID past the last level. It does not make every unlisted entry read zero, as the documentation said. MSHV and WHP present the hypervisor's own guest view there unless the backend registers a result, and verification requires zero (E_CPU_UNLISTED). Also document in hv_banks that no XSAVE feature bit controls PKRU, so a root that offered it would let a partition enable it beyond a profile without PKU, and update the --cpu-fingerprint Guide entry to match. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 16 ++++++++------ vmm_core/cpu_profile/src/check.rs | 3 ++- vmm_core/cpu_profile/src/hv_banks.rs | 7 ++++-- vmm_core/cpu_profile/src/lib.rs | 8 +++---- vmm_core/cpu_profile/src/surface.rs | 18 ++++++++------- vmm_core/cpu_profile/src/unlisted.rs | 22 +++++++++++-------- 6 files changed, 43 insertions(+), 31 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index a2ea87d9523..88467bec3f3 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -496,13 +496,15 @@ guest state and protect the directory with host access controls. feature, limit, XSAVE layout, and MSR value of the profile, the line has `status=fail`, `code`, and `detail`, and OpenVMM exits with status 1 and the same code (`E_PROFILE_HOST_UNKNOWN` or `E_PROFILE_UNSUPPORTED`). On MSHV and - WHP, which present the hypervisor's own value at every CPUID entry that the - profile does not list, a probe partition that reads non-zero at such an - entry fails with `E_CPU_UNLISTED`; the identity range - `0x40000000` to `0x4fffffff` and the topology leaves `0xb` and `0x1f` are - exempt. KVM installs the profile as the guest's whole CPUID table, so every - other entry reads zero. The fingerprint is still written, so hosts of new - generations can be fingerprinted to derive their profiles. + WHP, which present the hypervisor's own guest view at every CPUID entry that + the profile does not list, a probe partition that reads non-zero at such an + entry fails with `E_CPU_UNLISTED`, so that no such entry carries host data; + the identity range `0x40000000` to `0x4fffffff` and the topology leaves + `0xb` and `0x1f` are exempt. KVM installs the profile as the guest's whole + CPUID table and answers every other entry from it (zero, or the + architecture's out-of-range and topology results), so the check does not + apply. The fingerprint is still written, so hosts of new generations can be + fingerprinted to derive their profiles. ```bash openvmm --hypervisor kvm --cpu-fingerprint host.json diff --git a/vmm_core/cpu_profile/src/check.rs b/vmm_core/cpu_profile/src/check.rs index 331f25fd30a..e404df28aef 100644 --- a/vmm_core/cpu_profile/src/check.rs +++ b/vmm_core/cpu_profile/src/check.rs @@ -205,7 +205,8 @@ mod tests { ); } - // KVM's table makes every unlisted entry read zero. + // KVM answers unlisted entries from its table, so the check does not + // apply. let fingerprint = fingerprint_with(profile, "kvm", entries); check_fingerprint(&fingerprint).result.unwrap(); } diff --git a/vmm_core/cpu_profile/src/hv_banks.rs b/vmm_core/cpu_profile/src/hv_banks.rs index 80f76ad717e..158903ca2f5 100644 --- a/vmm_core/cpu_profile/src/hv_banks.rs +++ b/vmm_core/cpu_profile/src/hv_banks.rs @@ -25,7 +25,10 @@ //! Bits without a CPUID feature, such as the nested-paging details, are the //! backend's to decide. Features that no bit controls (for example PKU), the //! descriptive leaves (caches, the brand string), and the time policy bits a -//! host lacks reach the guest through the backend's CPUID results. +//! host lacks reach the guest through the backend's CPUID results. No XSAVE +//! feature bit controls PKRU either: a root that offered PKRU would let a +//! partition enable it in XCR0 under a profile without PKU, although the +//! guest's CPUID hides it. The roots of the fleet's hosts do not offer it. //! //! The hypervisor refuses some feature sets. MSHV fails to create a partition //! with `mb_clear_support` cleared on its own on a Skylake-SP host, with @@ -457,7 +460,7 @@ pub enum Pinned { } /// Returns how `profile` pins `bit`. A leaf or subleaf the profile does not -/// list reads zero, pinned. +/// list counts as zero, pinned. pub fn pinned(profile: &CpuProfile, bit: CpuidBit) -> Pinned { let (value, mask) = profile .cpuid() diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index 4796a4a15e8..b4ac2a36462 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -17,10 +17,10 @@ //! CPU ([`HostCpuSignature`]) to a pinned profile. //! - **Verification:** each backend reports the CPU surface it supports //! ([`HostCpuSurface`]), and [`verify_support`] checks that it covers the -//! profile. A pass-through backend (MSHV, WHP) must also present zero at -//! every CPUID entry outside the profile's tables -//! ([`check_unlisted_cpuid`]), which KVM does by construction -//! ([`CpuidPresentation`]). +//! profile. No CPUID entry outside the profile's tables carries host data: +//! a pass-through backend (MSHV, WHP) must present zero there +//! ([`check_unlisted_cpuid`]), and KVM answers those entries from the +//! effective CPUID itself ([`CpuidPresentation`]). //! - **Restore:** [`restore_profile`] checks a snapshot's profile record, //! and [`EffectiveCpuid::check_matches`] its effective CPUID. //! diff --git a/vmm_core/cpu_profile/src/surface.rs b/vmm_core/cpu_profile/src/surface.rs index 88d65fcdac6..2a855867277 100644 --- a/vmm_core/cpu_profile/src/surface.rs +++ b/vmm_core/cpu_profile/src/surface.rs @@ -50,16 +50,18 @@ pub struct HostCpuSurface { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum CpuidPresentation { /// A table backend, KVM: `KVM_SET_CPUID2` installs the effective CPUID as - /// the guest's whole CPUID table, and every entry it does not list reads - /// zero. The surface lists what the backend can support, not what a - /// guest reads, so there is nothing to check. + /// the guest's whole CPUID table, and KVM answers every entry it does not + /// list from the table itself: zero, or the architecture's out-of-range + /// and topology results, never host data. The surface lists what the + /// backend can support, not what a guest reads, so there is nothing to + /// check. Table, /// A pass-through backend, MSHV or WHP, which programs the effective - /// CPUID over the hypervisor's own: every entry the effective CPUID does - /// not list reads the hypervisor's value. The surface is what the - /// hypervisor presents to a guest, as a probe partition enumerates it for - /// `--cpu-fingerprint`, so every non-zero entry outside the profile's - /// tables fails the check. + /// CPUID over the hypervisor's own guest view: every entry the effective + /// CPUID does not list reads that view unless the backend registers a + /// result there. The surface is what the hypervisor presents to a guest, + /// as a probe partition enumerates it for `--cpu-fingerprint`, so every + /// non-zero entry outside the profile's tables fails the check. PassThroughGuestView, /// A pass-through backend whose surface comes from the host's own CPUID, /// as the cheap `supported_cpu_surface()` of MSHV and WHP builds it. diff --git a/vmm_core/cpu_profile/src/unlisted.rs b/vmm_core/cpu_profile/src/unlisted.rs index d66d3b47b0f..2082a7d2eca 100644 --- a/vmm_core/cpu_profile/src/unlisted.rs +++ b/vmm_core/cpu_profile/src/unlisted.rs @@ -3,13 +3,17 @@ //! CPUID entries outside a profile's tables. //! -//! KVM installs a VM's effective CPUID as the guest's whole CPUID table, so -//! every entry the table does not list reads zero. MSHV and WHP program the -//! effective CPUID over the hypervisor's own CPUID instead, so every entry it -//! does not list reads the hypervisor's value. The time ABI reserves the -//! entries past a profile's advertised maxima, and verification requires that -//! a pass-through backend presents zero at every entry outside the profile's -//! tables (`E_CPU_UNLISTED`), so no host data reaches the guest +//! No CPUID entry outside a VM's effective CPUID carries host data. KVM +//! installs the effective CPUID as the guest's whole CPUID table and answers +//! every other entry from it: zero, or a value the architecture's rules derive +//! from the table (Intel's out-of-range result, the highest basic leaf's, +//! above the maximum basic leaf, and an invalid level with the x2APIC ID past +//! the last topology level). MSHV and WHP program the effective CPUID over the +//! hypervisor's own guest view instead, so an entry it does not list reads +//! that view unless the backend registers a result there. The time ABI +//! reserves the entries past a profile's advertised maxima, and verification +//! requires that a pass-through backend presents zero at every entry outside +//! the profile's tables (`E_CPU_UNLISTED`), so no host data reaches the guest //! there. use crate::cpuid::CpuidEntry; @@ -110,8 +114,8 @@ pub(crate) fn unlisted_cpuid_error(profile: &CpuProfile, violations: &[String]) /// walks it. It reaches every entry the processor implements, including those /// past the profile's maxima and those that only the root partition sees, so /// these are the entries where a pass-through backend's guest may read the -/// hypervisor's value. A backend reads VP 0's view there, at subleaf 0 for a -/// subleaf-independent key, for [`check_unlisted_cpuid`]. +/// hypervisor's own guest view. A backend reads VP 0's view there, at subleaf +/// 0 for a subleaf-independent key, for [`check_unlisted_cpuid`]. pub fn unlisted_cpuid_candidates( profile: &CpuProfile, host: &[CpuidEntry], From dedd8cf1a6166117c04b4daf36ed2098da5933ca Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 19:46:42 -0700 Subject: [PATCH 240/304] virt_mshv: report VP 0's view at the host's unlisted CPUID entries Core now requires a pass-through backend's effective CPUID report to hold VP 0's view at every entry the host's CPUID enumerates outside the CPU profile's tables (cpu_profile::unlisted_cpuid_candidates), and fails a non-zero one with E_CPU_UNLISTED. Compute the candidates once from the pinned profile and the host's CPUID table, read VP 0's view there with the rest of the effective CPUID, and log the read's size and cost. The zero results already cover these entries, so they read zero. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 97 ++++++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index fa73045e529..4bebfe5fd66 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -121,7 +121,8 @@ pub(crate) struct MshvTimeAbi { #[inspect(skip)] registered_cpuid: Vec, /// The effective CPUID: VP 0's view of the registered leaves at reset, - /// read once before any VP runs. Later reads would fold in VP state (the + /// and of the host's entries outside the CPU profile's tables, read once + /// before any VP runs. Later reads would fold in VP state (the /// hypervisor applies the VP's XCR0, XSS, and control registers), so the /// capture and the restore of one snapshot would disagree. #[inspect(skip)] @@ -137,6 +138,12 @@ pub(crate) struct MshvTimeAbi { /// and the supported CPU surface. #[inspect(skip)] host_cpuid: Vec, + /// The entries the host's CPUID enumerates outside the CPU profile's + /// tables (`cpu_profile::unlisted_cpuid_candidates`). The effective CPUID + /// reports VP 0's view there, which core requires to be zero + /// (`E_CPU_UNLISTED`). + #[inspect(skip)] + unlisted_candidates: Vec<(u32, Option)>, } impl MshvTimeAbi { @@ -146,6 +153,9 @@ impl MshvTimeAbi { host_features: HvFeatures, host_cpuid: Vec, ) -> Self { + let unlisted_candidates = cpu_profile::pinned(&config.cpu_profile) + .map(|profile| cpu_profile::unlisted_cpuid_candidates(profile, &host_cpuid)) + .unwrap_or_default(); Self { msrs: config.msrs.clone(), registered_cpuid: registered_cpuid.leaves().to_vec(), @@ -153,6 +163,7 @@ impl MshvTimeAbi { vp_set_sealed: AtomicBool::new(false), host_features, host_cpuid, + unlisted_candidates, } } @@ -643,6 +654,7 @@ impl MshvPartitionInner { None => leaf, }) }; + let started = std::time::Instant::now(); let mut leaves = EFFECTIVE_CPUID_MAX_LEAVES .into_iter() .map(|function| read(function, None)) @@ -654,6 +666,19 @@ impl MshvPartitionInner { read(leaf.function, leaf.index)? }); } + // Guests read the hypervisor's own values outside the registered + // results, so the report also holds VP 0's view where the host's + // CPUID has entries outside the profile's tables, at subleaf 0 for a + // subleaf-independent entry. Core requires each to read zero. + for &(function, index) in &state.unlisted_candidates { + leaves.push(read(function, index)?); + } + tracing::info!( + leaves = leaves.len(), + unlisted_candidates = state.unlisted_candidates.len(), + elapsed_us = started.elapsed().as_micros() as u64, + "MSHV effective CPUID read" + ); // A concurrent first read stores identical values. let _ = state .effective_cpuid @@ -1116,6 +1141,48 @@ mod tests { ); } + #[test] + fn the_effective_cpuid_reads_the_host_entries_outside_the_profile() { + let profile = cpu_profile::pinned("intel.skylake-sp.v1").unwrap(); + let entry = cpu_profile::cpuid::CpuidEntry::new; + let mut host: Vec<_> = profile + .cpuid() + .iter() + .map(|listed| { + let (leaf, subleaf) = listed.key(); + entry(leaf, subleaf, listed.values()) + }) + .collect(); + // A cache past the profile's terminator and leaves past its maxima. + host.push(entry(4, Some(5), [0x121, 0, 0, 0])); + host.push(entry(0x17, None, [1, 0, 0, 0])); + host.push(entry(0x8000_0009, None, [1, 0, 0, 0])); + // The VM's topology and the identity range are not candidates. + host.push(entry(0xb, Some(2), [0, 0, 2, 0])); + host.push(entry(0x4000_0000, None, [0x4000_000b, 0, 0, 0])); + host.sort_by_key(cpu_profile::cpuid::CpuidEntry::key); + let mut config = TimeAbiConfig { + cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: profile.id().to_owned(), + }; + let features = super::super::profile_features::legacy_features(); + let state = MshvTimeAbi::new( + &config, + &partition_cpuid(&config.cpuid), + features, + host.clone(), + ); + assert_eq!( + state.unlisted_candidates, + [(4, Some(5)), (0x17, None), (0x8000_0009, None)] + ); + // A profile that isn't pinned has none. + config.cpu_profile = String::new(); + let state = MshvTimeAbi::new(&config, &partition_cpuid(&config.cpuid), features, host); + assert!(state.unlisted_candidates.is_empty()); + } + #[test] fn zero_fill_leaves_are_full_zero_leaves_past_the_identity() { for function in [0x4000_0006, 0x4000_0081, 0x4000_00ff] { @@ -1529,6 +1596,34 @@ mod hw { check_time_bits(&mut lookup, true).unwrap(); check_identity(&mut lookup, VP_CAPACITY).unwrap(); + // The report holds VP 0's view at every entry the host's CPUID has + // outside the profile's tables, and each reads zero. + let candidates = cpu_profile::unlisted_cpuid_candidates( + profile, + &super::super::profile_features::host_cpuid_table(), + ); + for &(function, index) in &candidates { + assert!( + effective + .leaves() + .iter() + .any(|leaf| leaf.function == function && leaf.index == index), + "{function:#x} {index:?} is missing from the effective CPUID" + ); + } + let entries: Vec<_> = effective + .leaves() + .iter() + .map(|leaf| { + cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result) + }) + .collect(); + cpu_profile::check_unlisted_cpuid(profile, &entries).unwrap(); + println!( + "time ABI backend: {} unlisted candidates read zero: {candidates:x?}", + candidates.len() + ); + // The hypervisor folds VP state into CPUID reads, so the effective // CPUID must stay VP 0's view at reset, whatever the guest does. let bsp = &partition.inner.finalized().unwrap().bsp_vcpufd; From e5e67246171814c03ff6a70a30e97bc929565375 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 20:06:51 -0700 Subject: [PATCH 241/304] virt_mshv: add the extended topology terminator to the hardware tests' CPUID Core lists leaf 0Bh's terminating subleaf in the effective CPUID (acf50ab18) and shares its construction as virt::x86::topology::terminate_extended_topology (eabe080e9). The backend already registers every 0Bh and 1Fh result per VP with the VP's own x2APIC ID, so the terminator needs no backend change. The hardware tests, however, build their CPUID table like core does, and lacked it. Call the shared function there, so that the topology test checks the terminator on every VP and the sweep checks it as a listed entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 4bebfe5fd66..390c0d697cc 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -1444,6 +1444,7 @@ mod hw { &mut topology_leaves, ) .unwrap(); + virt::x86::topology::terminate_extended_topology(topology, &mut topology_leaves); let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); vm.push(cpu_profile::x2apic_cpuid(!matches!( topology.apic_mode(), @@ -1756,9 +1757,9 @@ mod hw { /// 63 of every indexed leaf of the effective CPUID and the four leaves /// past the maximum basic and extended leaves. Listed results must match /// under their masks. A leaf or subleaf that the table does not list must - /// read zero, which the zero results provide on MSHV, except past the VM - /// topology's levels in the extended topology leaves: those are reserved, - /// and the sweep only reports them. + /// read zero, which the zero results provide on MSHV, except past the + /// extended topology leaves' terminators: those are reserved, and the + /// sweep only reports them. #[async_test] #[ignore = "requires /dev/mshv"] async fn unlisted_cpuid_entries_read_zero(driver: DefaultDriver) { From 399c62a02be24fc8afe8f117f233c179f3b011b9 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 20:10:35 -0700 Subject: [PATCH 242/304] virt_whp: report VP 0's view of the host entries outside the profile The time ABI reserves the CPUID entries that a guest reaches only past an advertised maximum, and core's presented-CPUID check (95d76f3cc) requires a pass-through backend to report VP 0's view at every entry the host's CPUID enumerates outside the profile's tables, each reading zero (E_CPU_UNLISTED). WHP serves its own value wherever the effective CPUID programs none, so the check guards against a reserved entry exposing a host feature. The effective CPUID record now ends with VP 0's view at cpu_profile::unlisted_cpuid_candidates over the host's CPUID, read through the same path as the rest of the record, at subleaf 0 for a subleaf-independent entry. The host's CPUID is enumerated once per process and shared with the supported CPU surface. Building the record logs its size, the unlisted entries, and the native reads it took. A unit test feeds a synthetic host through a simulated WHP: zeros pass core's check, and WHP noise at the unlisted entries fails it with E_CPU_UNLISTED. A hardware test reads every candidate of the host it runs on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/profile_features.rs | 2 +- vmm_core/virt_whp/src/time_abi.rs | 192 +++++++++++++++++++--- 2 files changed, 174 insertions(+), 20 deletions(-) diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index d8eef764bde..7543a720ae3 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -633,7 +633,7 @@ mod whp_tests { let probe = cpu_profile::HostCpuSurface::from_fingerprint(&fingerprint); let started = std::time::Instant::now(); let cheap = supported_surface( - &crate::time_abi::host_cpuid(), + crate::time_abi::host_cpuid(), available(), probe.physical_address_width, ); diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 67dcad8b648..57159cfc9bd 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -347,21 +347,53 @@ fn routing_error(message: impl Into) -> TimeAbiError { } /// Returns the host's CPUID as the root partition sees it: every leaf and -/// subleaf that [`cpu_profile::cpuid::enumerate`] walks. It describes the -/// processor that WHP virtualizes, for [`supported_surface`]. -pub(crate) fn host_cpuid() -> Vec { - // The CPUID instruction exists only on x86-64 hosts, the only hosts of - // x86-64 WHP partitions. - // xtask-fmt allow-target-arch cpu-intrinsic - #[cfg(target_arch = "x86_64")] - let query = |leaf, subleaf| { - let result = core::arch::x86_64::__cpuid_count(leaf, subleaf); - Ok::<_, std::convert::Infallible>([result.eax, result.ebx, result.ecx, result.edx]) - }; - // xtask-fmt allow-target-arch cpu-intrinsic - #[cfg(not(target_arch = "x86_64"))] - let query = |_, _| Ok::<_, std::convert::Infallible>([0; 4]); - cpu_profile::cpuid::enumerate(query).unwrap_or_else(|never| match never {}) +/// subleaf that [`cpu_profile::cpuid::enumerate`] walks, read once per +/// process. It describes the processor that WHP virtualizes, for +/// [`supported_surface`] and for the host entries outside the profile +/// ([`unlisted_cpuid`]). +pub(crate) fn host_cpuid() -> &'static [cpu_profile::cpuid::CpuidEntry] { + static HOST_CPUID: OnceLock> = OnceLock::new(); + HOST_CPUID.get_or_init(|| { + // The CPUID instruction exists only on x86-64 hosts, the only hosts + // of x86-64 WHP partitions. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + let query = |leaf, subleaf| { + let result = core::arch::x86_64::__cpuid_count(leaf, subleaf); + Ok::<_, std::convert::Infallible>([result.eax, result.ebx, result.ecx, result.edx]) + }; + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + let query = |_, _| Ok::<_, std::convert::Infallible>([0; 4]); + cpu_profile::cpuid::enumerate(query).unwrap_or_else(|never| match never {}) + }) +} + +/// Returns VP 0's view, from `vp0`, at every entry of `host` outside +/// `profile`'s tables ([`cpu_profile::unlisted_cpuid_candidates`]), read at +/// subleaf 0 for a subleaf-independent entry. The time ABI reserves these +/// entries, but WHP serves its own value wherever the effective CPUID does +/// not program one, so core requires each to read zero (`E_CPU_UNLISTED`): +/// no reserved entry may expose a host feature. +pub(crate) fn unlisted_cpuid( + profile: &CpuProfile, + host: &[cpu_profile::cpuid::CpuidEntry], + mut vp0: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + cpu_profile::unlisted_cpuid_candidates(profile, host) + .into_iter() + .map(|(function, index)| { + let subleaf = index.unwrap_or(0); + let leaf = CpuidLeaf::new( + function, + normalize_cpuid(function, subleaf, vp0(function, subleaf)?), + ); + Ok(match index { + Some(index) => leaf.indexed(index), + None => leaf, + }) + }) + .collect() } /// Returns the pinned CPU profile that `id` names. Core selects and verifies @@ -855,7 +887,9 @@ impl WhpPartitionInner { }) } - /// Returns the effective CPUID record, computing it on first use. + /// Returns the effective CPUID record, computing it on first use: VP 0's + /// view of the effective CPUID, and of the host entries outside the + /// profile ([`unlisted_cpuid`]). fn effective_record( &self, state: &WhpTimeAbi, @@ -864,13 +898,14 @@ impl WhpPartitionInner { if let Some(record) = state.effective.get() { return Ok(record.clone()); } + let started = std::time::Instant::now(); let vp0 = VpCpuid { cpuid: &self.cpuid, exits: &state.cpuid_exits, topology: &self.cpuid_topology, apic_id: self.vps.first().map_or(0, |vp| vp.vp_info.apic_id), }; - let record = effective_cpuid(&self.cpuid, |function, index| { + let mut read = |function, index| { vp0.result(function, index, || native.get(function, index)) .map_err(|err| { TimeAbiError::new( @@ -878,7 +913,18 @@ impl WhpPartitionInner { format!("cannot read CPUID {function:#x}/{index:#x} of VP 0: {err}"), ) }) - })?; + }; + let mut record = effective_cpuid(&self.cpuid, &mut read)?; + let unlisted = + unlisted_cpuid(pinned_profile(&state.cpu_profile)?, host_cpuid(), &mut read)?; + tracing::info!( + results = record.len(), + unlisted = unlisted.len(), + native_reads = native.results.len(), + elapsed_us = started.elapsed().as_micros() as u64, + "time ABI: WHP effective CPUID record" + ); + record.extend(unlisted); Ok(state.effective.get_or_init(|| record).clone()) } @@ -1017,7 +1063,7 @@ impl TimeAbiBackend for WhpPartitionInner { .physical_address_width() .map_err(|err| unsupported("the physical address width", err))?; let surface = supported_surface( - &host_cpuid(), + host_cpuid(), WhpFeatures { banks: [banks.bank0.0, banks.bank1.0], xsave: xsave.0, @@ -1942,6 +1988,58 @@ mod tests { } } + /// The report holds VP 0's view at the host's entries outside the + /// profile, which core requires to read zero (`E_CPU_UNLISTED`): zeros + /// pass, and a WHP that showed its own values there fails. + #[test] + fn report_reads_vp0_at_host_entries_outside_the_profile() { + for profile_id in test_cpuid::PROFILES { + let profile = cpu_profile::pinned(profile_id).unwrap(); + let past_max = profile.lookup(0, 0)[0] + 1; + let mut host: Vec<_> = profile + .cpuid() + .iter() + .map(|entry| { + let (leaf, subleaf) = entry.key(); + cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, entry.values()) + }) + .collect(); + let unlisted = [(0xf, Some(1)), (0x14, Some(1)), (past_max, None)]; + host.extend(unlisted.iter().map(|&(leaf, subleaf)| { + cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, [1, 2, 3, 4]) + })); + for noise in [false, true] { + let mut partition = Partition::new(profile_id, 2, X2ApicState::Supported); + partition.whp.noise = noise; + let report = unlisted_cpuid(profile, &host, |function, index| { + Ok::<_, std::convert::Infallible>(partition.observe(0, function, index)) + }) + .unwrap(); + let keys: Vec<_> = report + .iter() + .map(|leaf| (leaf.function, leaf.index)) + .collect(); + assert_eq!(keys, unlisted, "{profile_id}"); + let entries: Vec<_> = report + .iter() + .map(|leaf| { + cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result) + }) + .collect(); + match cpu_profile::check_unlisted_cpuid(profile, &entries) { + Ok(()) => assert!(!noise, "{profile_id}: noise passed"), + Err(err) => { + assert!(noise, "{profile_id}: {err}"); + assert!(matches!( + err.code, + cpu_profile::ProfileErrorCode::CpuUnlisted + )); + } + } + } + } + } + #[test] fn partition_cpuid_keeps_own_results_only_for_exiting_leaves() { let partition = Partition::new(test_cpuid::PROFILES[0], VP_COUNT, X2ApicState::Supported); @@ -2597,6 +2695,62 @@ mod whp_tests { assert!(unlisted.is_empty(), "{unlisted:#?}"); } + /// Reads VP 0's view at every entry this host's CPUID enumerates outside + /// the profile's tables, as the backend's report does, and checks core's + /// verdict on them (`E_CPU_UNLISTED`): each must read zero. + #[test] + #[ignore = "requires WHP"] + fn host_entries_outside_the_profile_read_zero() { + let Some(Partition { + partition, + time_abi, + topology, + .. + }) = partition(1) + else { + return; + }; + let own = test_cpuid::own_cpuid(&topology, &|function, index| { + native(&partition, function, index).unwrap() + }); + let cpuid = time_abi.partition_cpuid(own).unwrap(); + let cpuid_topology = time_abi.cpuid_topology(&topology); + let vp0 = VpCpuid { + cpuid: &cpuid, + exits: &time_abi.cpuid_exits, + topology: &cpuid_topology, + apic_id: 0, + }; + let profile = cpu_profile::pinned(&time_abi.cpu_profile).unwrap(); + let host = host_cpuid(); + let started = std::time::Instant::now(); + let report = unlisted_cpuid(profile, host, |function, index| { + vp0.result(function, index, || native(&partition, function, index)) + }) + .unwrap(); + let elapsed = started.elapsed(); + let keys: Vec = report + .iter() + .map(|leaf| match leaf.index { + Some(index) => format!("{:#x}.{index}", leaf.function), + None => format!("{:#x}", leaf.function), + }) + .collect(); + println!( + "{}: {} host entries, {} outside the profile, read in {} us: {}", + profile.id(), + host.len(), + report.len(), + elapsed.as_micros(), + keys.join(" ") + ); + let entries: Vec<_> = report + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + cpu_profile::check_unlisted_cpuid(profile, &entries).unwrap(); + } + /// The comparison of `tests::core_table_presents_what_the_profile_programming_did` /// on WHP: two partitions with this host's profile and the same features, /// one programmed from core's complete table and one with the profile From c45d6a3a0052d284a62a44a91d07cdd6854b00c8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 20:54:24 -0700 Subject: [PATCH 243/304] virt_whp: use the shared extended topology terminator in tests The tests' copy of core's effective CPUID duplicated core's terminate_extended_topology. eabe080e9 moved that function into virt::x86::topology for backend tests, so the copy calls it and the duplicate goes. The tests' effective CPUID is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/time_abi.rs | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 57159cfc9bd..2ce67205aba 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -1257,7 +1257,6 @@ pub(crate) mod test_cpuid { use virt::CpuidLeafSet; use vm_topology::processor::ProcessorTopology; use vm_topology::processor::TopologyBuilder; - use vm_topology::processor::VpIndex; use vm_topology::processor::x86::ApicMode; use vm_topology::processor::x86::X2ApicState; @@ -1294,7 +1293,7 @@ pub(crate) mod test_cpuid { &mut topology_leaves, ) .unwrap(); - terminate_extended_topology(topology, &mut topology_leaves); + virt::x86::topology::terminate_extended_topology(topology, &mut topology_leaves); let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); vm.push(cpu_profile::x2apic_cpuid(!matches!( topology.apic_mode(), @@ -1308,29 +1307,6 @@ pub(crate) mod test_cpuid { profile.effective_cpuid(&vm, &identity).unwrap() } - /// Adds the subleaf that ends each extended topology leaf in `leaves` - /// after its levels, as core's `terminate_extended_topology` does: an - /// invalid level with its own number in `ECX[7:0]` and the BSP's x2APIC - /// ID in `EDX`. - fn terminate_extended_topology(topology: &ProcessorTopology, leaves: &mut Vec) { - let bsp_apic_id = topology.vp_arch(VpIndex::BSP).apic_id; - for function in [0xb, 0x1f] { - let levels = leaves - .iter() - .filter(|leaf| leaf.function == function) - .count() as u32; - if levels == 0 { - continue; - } - let ecx = x86defs::cpuid::ExtendedTopologyEcx::new().with_level_number(levels as u8); - leaves.push( - CpuidLeaf::new(function, [0, 0, ecx.into(), bsp_apic_id]) - .indexed(levels) - .masked([!0; 4]), - ); - } - } - /// Returns whether `effective` lists `function`/`index` as an extended /// topology subleaf past the core level: the subleaf that ends the /// enumeration, which the profile programming of `de39f6aab` left zero. From 07ca8550cf015d0d6d13b9aec9b99e4ed2936b61 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 21:16:27 -0700 Subject: [PATCH 244/304] virt_mshv: check the probe partition's surface as a guest view The cheap-surface hardware test converts both of its surfaces to cpu_profile::HostCpuSurface as PassThroughHostView, the root's view. That is right for the cheap surface, which comes from the host's CPUID, but the probe partition's CPUID is what the hypervisor presents to a guest: PassThroughGuestView, as --cpu-fingerprint records it. Pass each surface's presentation, and require the host's own profile to pass verify_support on the probe's surface. Unlike support_violations, that also checks that every entry outside the profile's tables reads zero in the hypervisor's guest view (E_CPU_UNLISTED). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../virt_mshv/src/x86_64/profile_features.rs | 26 +++++++++++++++---- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index acc50c77726..cbf9d7afe7e 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -693,7 +693,13 @@ mod hw { assert!(failures.is_empty(), "{failures:#?}"); } - fn host_surface(surface: &SupportedCpuSurface) -> cpu_profile::HostCpuSurface { + /// Converts `surface` to the profile crate's form. `presentation` says + /// whose view its CPUID is: the root's for the cheap surface, a guest's + /// for a probe partition. + fn host_surface( + surface: &SupportedCpuSurface, + presentation: cpu_profile::CpuidPresentation, + ) -> cpu_profile::HostCpuSurface { let mut cpuid = surface .cpuid .iter() @@ -702,7 +708,7 @@ mod hw { cpu_profile::cpuid::normalize(&mut cpuid); cpu_profile::HostCpuSurface { cpuid, - presentation: cpu_profile::CpuidPresentation::PassThroughHostView, + presentation, physical_address_width: surface.physical_address_width, msrs: surface .msrs @@ -719,7 +725,9 @@ mod hw { /// Compares the cheap surface (host CPUID and banks) with the surface of a /// probe partition that enables every offered feature, which is how the /// CPU fingerprint measures it: every pinned profile must get the same - /// support verdict from both. + /// support verdict from both. The host's own profile must also pass the + /// full check on the probe's guest view, which requires the entries + /// outside its tables to read zero, as `--cpu-fingerprint` checks them. #[test] #[ignore = "requires /dev/mshv"] fn the_cheap_surface_gives_the_probe_verdicts() { @@ -777,8 +785,11 @@ mod hw { // Report the bits that differ, for the record. The probe's bits that the // cheap surface lacks would make it under-report. - let cheap_host = host_surface(&cheap); - let probe_host = host_surface(&probe_surface); + let cheap_host = host_surface(&cheap, cpu_profile::CpuidPresentation::PassThroughHostView); + let probe_host = host_surface( + &probe_surface, + cpu_profile::CpuidPresentation::PassThroughGuestView, + ); let mut under = 0; for entry in &probe_host.cpuid { let (leaf, subleaf) = entry.key(); @@ -825,6 +836,11 @@ mod hw { "{}", own.id() ); + // The probe partition is a guest's view, as `--cpu-fingerprint` + // records it, so the full check also requires every entry outside the + // profile's tables to read zero there (`E_CPU_UNLISTED`). + cpu_profile::verify_support(own, &probe_host) + .unwrap_or_else(|error| panic!("probe partition: {error}")); assert!(failures.is_empty(), "{failures:#?}"); } From 093780bee0fe27f7747258cdf86daf505ee31cb4 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 21:17:34 -0700 Subject: [PATCH 245/304] openvmm: record the CPU profile without encoding or hashing it Every OpenVMM start paid canonical JSON encodes and SHA-256 digests of the CPU profile and the effective CPUID. The entry verified two digests of the manifest's CPU profile record each time it validated the manifest, and the restoring worker encoded and hashed the recomputed effective CPUID to compare it with the record. With the profile catalog now compile-time data, these were the last per-start profile costs. The snapshot's CPU profile record now holds: - the pinned profile's precomputed digest and canonical encoding, which capture copies from cpu_profile::pinned_record and the restore preflight compares with the pinned record byte for byte (E_PROFILE_UNKNOWN, E_PROFILE_DIGEST); - the effective CPUID as packed 44-byte little-endian entries (openvmm_defs::time_abi::encode_effective_cpuid), which the worker compares with its recomputation encoded the same way (E_CPU_SURFACE), decoding the record only to name the differences. Field 5, the digest of the effective CPUID's former JSON form, is retired. Manifest validation checks only the record's shape and computes no digest, and openvmm_core no longer depends on sha2. In a release build, the worker's check takes about 5 us and the entry's shape check 5 to 20 us. Before, the check took about 25 to 40 us, and each manifest validation computed two digests. The record shrinks from 10.4 KB to 3 KB at 8 VPs. The worker logs "time ABI CPU checks passed" with the time of its recorded, presented, and support checks, for start-up attribution. A snapshot captured before this change fails restore with E_MANIFEST_TIME; the time ABI is not released. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 1 - openvmm/openvmm_core/Cargo.toml | 1 - openvmm/openvmm_core/src/worker/dispatch.rs | 11 ++ .../src/worker/dispatch/time_abi.rs | 175 ++++++++++++++---- openvmm/openvmm_defs/src/time_abi.rs | 153 ++++++++++++++- openvmm/openvmm_entry/src/microvm/restore.rs | 1 - openvmm/openvmm_helpers/src/snapshot/time.rs | 163 +++++++++------- 7 files changed, 389 insertions(+), 116 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61cbb8d9304..87e1236ac04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5944,7 +5944,6 @@ dependencies = [ "scsi_core", "scsidisk", "serial_16550_resources", - "sha2", "smmu", "sparse_mmap", "state_unit", diff --git a/openvmm/openvmm_core/Cargo.toml b/openvmm/openvmm_core/Cargo.toml index 33e8a02d69d..b60c3e7df2f 100644 --- a/openvmm/openvmm_core/Cargo.toml +++ b/openvmm/openvmm_core/Cargo.toml @@ -107,7 +107,6 @@ futures.workspace = true futures-concurrency.workspace = true getrandom.workspace = true parking_lot.workspace = true -sha2.workspace = true thiserror.workspace = true tracelimit.workspace = true tracing.workspace = true diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index c4dad54aaa7..2cbd30cbd92 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1964,6 +1964,7 @@ impl InitializedVm { saved_state.is_some() == restore.is_some(), "a time ABI restore requires both the saved state and the time ABI restore inputs" ); + let checks_started = std::time::Instant::now(); if let Some(input) = restore { anyhow::ensure!( input.cpu_profile.id == profile.id(), @@ -1973,13 +1974,23 @@ impl InitializedVm { ); time_abi::check_recorded_cpuid(&effective_cpuid, &input.cpu_profile)?; } + let recorded_done = std::time::Instant::now(); time_abi::check_presented_cpuid( partition.as_ref(), hypervisor, profile, &effective_cpuid, )?; + let presented_done = std::time::Instant::now(); time_abi::check_profile_support(partition.as_ref(), hypervisor, profile)?; + let support_done = std::time::Instant::now(); + tracing::info!( + cpu_profile = profile.id(), + recorded_cpuid_us = (recorded_done - checks_started).as_micros() as u64, + presented_cpuid_us = (presented_done - recorded_done).as_micros() as u64, + profile_support_us = (support_done - presented_done).as_micros() as u64, + "time ABI CPU checks passed" + ); let report = time_abi::declare_rates( partition.as_ref(), &msrs, diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 665663582be..1dc78e968a4 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -16,13 +16,16 @@ use cpu_profile::CpuProfile; use cpu_profile::EffectiveCpuid; use inspect::Inspect; use inspect::InspectMut; +use openvmm_defs::time_abi::EffectiveCpuidEntry; use openvmm_defs::time_abi::RestoreTimeInput; use openvmm_defs::time_abi::SnapshotCpuProfile; use openvmm_defs::time_abi::SnapshotTimeContract; use openvmm_defs::time_abi::TimeCapture; -use sha2::Digest as _; +use openvmm_defs::time_abi::decode_effective_cpuid; +use openvmm_defs::time_abi::encode_effective_cpuid; use state_unit::SpawnedUnit; use state_unit::StateUnit; +use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; use virt::CpuidLeaf; @@ -261,23 +264,85 @@ pub(super) fn declare_rates( /// Compares the effective CPUID recomputed for this partition with the one /// the snapshot recorded: restore step 8 of the specification -/// (`E_PROFILE_DIGEST` if the record does not verify, `E_CPU_SURFACE` if the -/// two differ). A record that is this partition's own canonical encoding -/// needs no decoding; any other is decoded to name its differences. +/// (`E_CPU_SURFACE`). It compares the binary records, about a microsecond of +/// work, and decodes the snapshot's only to name the differences. pub(super) fn check_recorded_cpuid( effective_cpuid: &EffectiveCpuid, record: &SnapshotCpuProfile, ) -> anyhow::Result<()> { - let encoded = effective_cpuid.encode(); - if encoded == record.effective_cpuid - && sha2::Sha256::digest(&encoded).as_slice() == record.effective_cpuid_sha256.as_slice() - { + if encode_effective_cpuid(record_entries(effective_cpuid)) == record.effective_cpuid { return Ok(()); } - let recorded = - EffectiveCpuid::decode_verified(&record.effective_cpuid, &record.effective_cpuid_sha256)?; - effective_cpuid.check_matches(&recorded)?; - Ok(()) + let recorded = decode_effective_cpuid(&record.effective_cpuid).map_err(|err| { + TimeAbiError::new( + TimeAbiCode::ManifestTime, + format!("the snapshot's effective CPUID record is malformed: {err}"), + ) + })?; + let differences = recorded_cpuid_differences(effective_cpuid, &recorded); + let count = differences.len(); + let shown = &differences[..count.min(MAX_REPORTED_CPUID_DIFFERENCES)]; + Err(TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "the effective CPUID of CPU profile {} differs from the snapshot's in {count} entries: {}", + record.id, + shown.join("; ") + ), + ) + .into()) +} + +/// Returns the entries of the snapshot record of `effective`, in its order. +fn record_entries(effective: &EffectiveCpuid) -> impl Iterator + '_ { + effective.results().map(|result| EffectiveCpuidEntry { + function: result.function, + index: result.index, + result: result.result, + mask: result.mask, + }) +} + +/// Lists the entries in which this partition's effective CPUID and a +/// snapshot's `recorded` one differ. +fn recorded_cpuid_differences( + effective: &EffectiveCpuid, + recorded: &[EffectiveCpuidEntry], +) -> Vec { + let key = |entry: &EffectiveCpuidEntry| (entry.function, entry.index); + let name = |(function, index): (u32, Option)| match index { + Some(index) => format!("CPUID {function:#x}.{index}"), + None => format!("CPUID {function:#x}"), + }; + let ours: BTreeMap<_, _> = record_entries(effective) + .map(|entry| (key(&entry), entry)) + .collect(); + let theirs: BTreeMap<_, _> = recorded.iter().map(|entry| (key(entry), *entry)).collect(); + let mut differences = Vec::new(); + for (&entry_key, entry) in &ours { + match theirs.get(&entry_key) { + None => differences.push(format!("{} is not in the snapshot", name(entry_key))), + Some(theirs) if theirs != entry => differences.push(format!( + "{} is {:#x?} under mask {:#x?}, and {:#x?} under mask {:#x?} in the snapshot", + name(entry_key), + entry.result, + entry.mask, + theirs.result, + theirs.mask + )), + Some(_) => {} + } + } + for &entry_key in theirs + .keys() + .filter(|entry_key| !ours.contains_key(entry_key)) + { + differences.push(format!("{} is only in the snapshot", name(entry_key))); + } + if differences.is_empty() { + differences.push("the snapshot lists the same entries in another order".to_owned()); + } + differences } /// Checks that VP 0 observes the effective CPUID, on cold boot and restore @@ -435,8 +500,8 @@ pub(super) fn capture_records( .context("the time ABI rates are not declared")?; let anchor = backend.capture_anchor()?; let identity = virt::time_abi::host::host_identity()?; - let profile = state.profile.encode(); - let effective_cpuid = state.effective_cpuid.encode(); + let pinned = cpu_profile::pinned_record(state.profile.id()) + .with_context(|| format!("CPU profile {} is not pinned", state.profile.id()))?; tracing::info!( tsc = anchor.tsc, utc_ns = anchor.sample.utc_ns, @@ -459,11 +524,10 @@ pub(super) fn capture_records( capture_generation: state.generation, }, cpu_profile: SnapshotCpuProfile { - id: state.profile.id().to_owned(), - sha256: sha2::Sha256::digest(&profile).to_vec(), - profile, - effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), - effective_cpuid, + id: pinned.id.to_owned(), + sha256: pinned.digest.to_vec(), + profile: pinned.encoding.to_vec(), + effective_cpuid: encode_effective_cpuid(record_entries(&state.effective_cpuid)), capture_cpu_signature: virt::time_abi::surface::host_cpu_signature().unwrap_or(0), }, }) @@ -597,6 +661,7 @@ impl StateUnit for TimeAbiUnit { #[cfg(test)] mod tests { use super::*; + use openvmm_defs::time_abi::EFFECTIVE_CPUID_ENTRY_BYTES; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use vm_topology::processor::TopologyBuilder; use vm_topology::processor::VpIndex; @@ -839,41 +904,73 @@ mod tests { } #[test] - fn recorded_cpuid_verifies_and_must_match() { + fn recorded_cpuid_must_match() { let (_, _, effective) = test_config(2); - let profile = test_profile().encode(); + let pinned = cpu_profile::pinned_record(TEST_PROFILE).unwrap(); let record = SnapshotCpuProfile { id: TEST_PROFILE.to_owned(), - sha256: sha2::Sha256::digest(&profile).to_vec(), - profile, - effective_cpuid: effective.encode(), - effective_cpuid_sha256: effective.digest().to_vec(), + sha256: pinned.digest.to_vec(), + profile: pinned.encoding.to_vec(), + effective_cpuid: encode_effective_cpuid(record_entries(&effective)), capture_cpu_signature: 0, }; check_recorded_cpuid(&effective, &record).unwrap(); + // Another topology changes the topology leaves. let (_, _, other) = test_config(4); - assert_eq!(code(check_recorded_cpuid(&other, &record)), "E_CPU_SURFACE"); - - let mut tampered = record.clone(); - tampered.effective_cpuid_sha256[0] ^= 1; + let message = format!("{:#}", check_recorded_cpuid(&other, &record).unwrap_err()); + assert!(message.starts_with("[E_CPU_SURFACE]"), "{message}"); + assert!(message.contains("CPUID 0xb.1"), "{message}"); + + // One flipped bit is named. + let mut flipped = record.clone(); + flipped.effective_cpuid[3 * 4] ^= 1; + let message = format!( + "{:#}", + check_recorded_cpuid(&effective, &flipped).unwrap_err() + ); + assert!(message.starts_with("[E_CPU_SURFACE]"), "{message}"); + assert!(message.contains("CPUID 0x0 is"), "{message}"); + + // A missing entry, and the same entries in another order. + let mut truncated = record.clone(); + truncated + .effective_cpuid + .truncate(record.effective_cpuid.len() - EFFECTIVE_CPUID_ENTRY_BYTES); assert_eq!( - code(check_recorded_cpuid(&effective, &tampered)), - "E_PROFILE_DIGEST" + code(check_recorded_cpuid(&effective, &truncated)), + "E_CPU_SURFACE" + ); + let mut reordered = record.clone(); + let (first, second) = reordered + .effective_cpuid + .split_at_mut(EFFECTIVE_CPUID_ENTRY_BYTES); + first.swap_with_slice(&mut second[..EFFECTIVE_CPUID_ENTRY_BYTES]); + assert_eq!( + code(check_recorded_cpuid(&effective, &reordered)), + "E_CPU_SURFACE" ); - // A digest that verifies over bytes that are not the canonical - // encoding. - let mut noncanonical = record.clone(); - noncanonical.effective_cpuid.insert(1, b' '); - noncanonical.effective_cpuid_sha256 = - sha2::Sha256::digest(&noncanonical.effective_cpuid).to_vec(); + let mut malformed = record.clone(); + malformed.effective_cpuid.pop(); assert_eq!( - code(check_recorded_cpuid(&effective, &noncanonical)), - "E_PROFILE_DIGEST" + code(check_recorded_cpuid(&effective, &malformed)), + "E_MANIFEST_TIME" ); } + /// Capture copies the pinned profile's precomputed record, so restore + /// never encodes or hashes a profile. + #[test] + fn pinned_records_are_the_profiles() { + for profile in cpu_profile::pinned_profiles() { + let pinned = cpu_profile::pinned_record(profile.id()).unwrap(); + assert_eq!(pinned.id, profile.id()); + assert_eq!(pinned.encoding, profile.encode().as_slice()); + assert_eq!(pinned.digest, profile.digest()); + } + } + #[test] fn cpu_profile_selection() { for requested in ["interim.host.kvm.v1", "intel.cascadelake.v1"] { diff --git a/openvmm/openvmm_defs/src/time_abi.rs b/openvmm/openvmm_defs/src/time_abi.rs index 745f2c7e2a1..4fda0875c74 100644 --- a/openvmm/openvmm_defs/src/time_abi.rs +++ b/openvmm/openvmm_defs/src/time_abi.rs @@ -89,6 +89,12 @@ impl SnapshotTimeContract { } /// The manifest's CPU profile record. +/// +/// Capture writes it from constants and a cheap binary encoding, and restore +/// checks it by comparison, so neither encodes a document nor computes a +/// digest: `sha256` and `profile` are the pinned profile's precomputed digest +/// and canonical encoding, and `effective_cpuid` is +/// [`encode_effective_cpuid`] of the partition's effective CPUID. #[derive(Clone, Debug, PartialEq, Eq, Protobuf)] #[mesh(package = "openvmm.snapshot")] pub struct SnapshotCpuProfile { @@ -101,17 +107,99 @@ pub struct SnapshotCpuProfile { /// The canonical profile encoding. #[mesh(3)] pub profile: Vec, - /// The canonical encoding of the effective guest CPUID. + /// The effective guest CPUID, as [`encode_effective_cpuid`] writes it. #[mesh(4)] pub effective_cpuid: Vec, - /// The 32-byte digest of `effective_cpuid`. - #[mesh(5)] - pub effective_cpuid_sha256: Vec, /// CPUID.1:EAX of the capture host, for diagnostics. #[mesh(6)] pub capture_cpu_signature: u32, } +/// The size of one entry of [`SnapshotCpuProfile::effective_cpuid`]. +pub const EFFECTIVE_CPUID_ENTRY_BYTES: usize = 44; + +/// The most entries [`SnapshotCpuProfile::effective_cpuid`] may hold. +pub const MAX_EFFECTIVE_CPUID_ENTRIES: usize = 1024; + +/// One entry of the effective CPUID record: a CPUID result and the mask of +/// the bits it defines, for one leaf and subleaf, or for every subleaf of the +/// leaf when `index` is `None`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EffectiveCpuidEntry { + /// The leaf. + pub function: u32, + /// The subleaf, or `None` for every subleaf. + pub index: Option, + /// `EAX`, `EBX`, `ECX`, and `EDX`. + pub result: [u32; 4], + /// The bits of each register that `result` defines. + pub mask: [u32; 4], +} + +/// Encodes the effective CPUID record: for each entry, in order, eleven +/// little-endian `u32` values, namely the leaf, 1 and the subleaf (or 0 and 0 +/// for every subleaf), the four registers, and their four masks. +pub fn encode_effective_cpuid(entries: impl IntoIterator) -> Vec { + let entries = entries.into_iter(); + let mut out = Vec::with_capacity(entries.size_hint().0 * EFFECTIVE_CPUID_ENTRY_BYTES); + for entry in entries { + let (has_index, index) = match entry.index { + Some(index) => (1, index), + None => (0, 0), + }; + for value in [entry.function, has_index, index] + .into_iter() + .chain(entry.result) + .chain(entry.mask) + { + out.extend_from_slice(&value.to_le_bytes()); + } + } + out +} + +/// Decodes an effective CPUID record that [`encode_effective_cpuid`] wrote, +/// with between 1 and [`MAX_EFFECTIVE_CPUID_ENTRIES`] entries, or describes +/// why it is malformed. +pub fn decode_effective_cpuid(bytes: &[u8]) -> Result, String> { + if bytes.is_empty() || !bytes.len().is_multiple_of(EFFECTIVE_CPUID_ENTRY_BYTES) { + return Err(format!( + "{} bytes is not a whole number of {EFFECTIVE_CPUID_ENTRY_BYTES}-byte entries", + bytes.len() + )); + } + let count = bytes.len() / EFFECTIVE_CPUID_ENTRY_BYTES; + if count > MAX_EFFECTIVE_CPUID_ENTRIES { + return Err(format!( + "{count} entries is more than {MAX_EFFECTIVE_CPUID_ENTRIES}" + )); + } + bytes + .as_chunks::() + .0 + .iter() + .map(|entry| { + let word = |i: usize| u32::from_le_bytes(entry[4 * i..4 * i + 4].try_into().unwrap()); + let index = match (word(1), word(2)) { + (1, index) => Some(index), + (0, 0) => None, + _ => { + return Err(format!( + "the entry for leaf {:#x} has an invalid subleaf", + word(0) + )); + } + }; + Ok(EffectiveCpuidEntry { + function: word(0), + index, + result: [word(3), word(4), word(5), word(6)], + mask: [word(7), word(8), word(9), word(10)], + }) + }) + .collect() +} + /// The time ABI records the worker returns at capture. #[derive(Debug, MeshPayload)] pub struct TimeCapture { @@ -150,3 +238,60 @@ pub struct TimeAbiParameters { /// The active test hooks. pub hooks: TimeAbiTestHooks, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn effective_cpuid_records_round_trip() { + let entries = [ + EffectiveCpuidEntry { + function: 0, + index: None, + result: [0x16, 1, 2, 3], + mask: [!0; 4], + }, + EffectiveCpuidEntry { + function: 0xb, + index: Some(2), + result: [0, 0, 2, 7], + mask: [!0, !0, !0, 0], + }, + ]; + let bytes = encode_effective_cpuid(entries); + assert_eq!(bytes.len(), 2 * EFFECTIVE_CPUID_ENTRY_BYTES); + assert_eq!(&bytes[44..56], &[0xb, 0, 0, 0, 1, 0, 0, 0, 2, 0, 0, 0]); + assert_eq!(decode_effective_cpuid(&bytes).unwrap(), entries); + } + + #[test] + fn malformed_effective_cpuid_records_fail() { + let entry = EffectiveCpuidEntry { + function: 1, + index: None, + result: [0; 4], + mask: [0; 4], + }; + let bytes = encode_effective_cpuid([entry]); + assert!(decode_effective_cpuid(&[]).is_err()); + assert!(decode_effective_cpuid(&bytes[1..]).is_err()); + + // A subleaf-independent entry must record subleaf 0, and the flag + // is 0 or 1. + let mut stray_index = bytes.clone(); + stray_index[8] = 1; + assert!(decode_effective_cpuid(&stray_index).is_err()); + let mut bad_flag = bytes.clone(); + bad_flag[4] = 2; + assert!(decode_effective_cpuid(&bad_flag).is_err()); + + let full = encode_effective_cpuid(vec![entry; MAX_EFFECTIVE_CPUID_ENTRIES]); + assert_eq!( + decode_effective_cpuid(&full).unwrap().len(), + MAX_EFFECTIVE_CPUID_ENTRIES + ); + let over = encode_effective_cpuid(vec![entry; MAX_EFFECTIVE_CPUID_ENTRIES + 1]); + assert!(decode_effective_cpuid(&over).is_err()); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 4e351e20622..9a365b931e3 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -778,7 +778,6 @@ mod tests { sha256: Vec::new(), profile: Vec::new(), effective_cpuid: Vec::new(), - effective_cpuid_sha256: Vec::new(), capture_cpu_signature: 0, }); MicrovmRestore { diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index 65a1e90c366..77b10f9c370 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -11,7 +11,7 @@ use super::microvm::SnapshotMachineContract; use mesh::payload::Timestamp; use openvmm_defs::time_abi::SnapshotCpuProfile; use openvmm_defs::time_abi::SnapshotTimeContract; -use sha2::Digest; +use openvmm_defs::time_abi::decode_effective_cpuid; use virt::time_abi::CaptureTimeRecord; use virt::time_abi::Downtime; use virt::time_abi::HostClockKind; @@ -32,8 +32,6 @@ pub(super) const NO_TIMESTAMP: Timestamp = Timestamp { /// The largest canonical CPU profile encoding a manifest may carry. pub const MAX_CPU_PROFILE_BYTES: usize = 1024 * 1024; -/// The largest canonical effective-CPUID encoding a manifest may carry. -pub const MAX_EFFECTIVE_CPUID_BYTES: usize = 64 * 1024; fn manifest_error(message: impl Into) -> TimeAbiError { TimeAbiError::new(TimeAbiCode::ManifestTime, message) @@ -94,35 +92,18 @@ pub fn validate_time_contract(contract: &SnapshotTimeContract) -> Result<(), Tim Ok(()) } -fn verify_sha256(bytes: &[u8], digest: &[u8], description: &str) -> Result<(), TimeAbiError> { - if digest.len() != 32 { - return Err(manifest_error(format!( - "{description} digest is {} bytes, not 32", - digest.len() - ))); - } - if sha2::Sha256::digest(bytes).as_slice() != digest { - return Err(TimeAbiError::new( - TimeAbiCode::ProfileDigest, - format!("{description} digest does not verify"), - )); - } - Ok(()) -} - -/// Validates a CPU profile record: its shape and both digests. It decodes -/// neither document, because manifests are validated several times per -/// restore and each decode costs about a millisecond. The restore preflight -/// requires the profile digest to be the pinned profile's, so the embedded -/// document, which hashes to it, is that profile's canonical encoding; the -/// worker compares the effective CPUID record with its recomputation. +/// Validates a CPU profile record's shape: the ID, the 32-byte digest, the +/// document's size, and a well-formed effective CPUID record. It computes no +/// digest and decodes no document, because manifests are validated several +/// times per restore. The restore preflight compares the digest and the +/// document with the pinned profile's precomputed ones, and the worker +/// compares the effective CPUID record with its recomputation. pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), TimeAbiError> { let SnapshotCpuProfile { id, sha256, profile, effective_cpuid, - effective_cpuid_sha256, capture_cpu_signature: _, } = record; let id_valid = !id.is_empty() @@ -138,14 +119,17 @@ pub fn validate_cpu_profile_record(record: &SnapshotCpuProfile) -> Result<(), Ti "CPU profile ID '{id}' is malformed" ))); } + if sha256.len() != 32 { + return Err(manifest_error(format!( + "CPU profile digest is {} bytes, not 32", + sha256.len() + ))); + } if profile.is_empty() || profile.len() > MAX_CPU_PROFILE_BYTES { return Err(manifest_error("CPU profile record size is invalid")); } - verify_sha256(profile, sha256, "CPU profile")?; - if effective_cpuid.is_empty() || effective_cpuid.len() > MAX_EFFECTIVE_CPUID_BYTES { - return Err(manifest_error("effective CPUID record size is invalid")); - } - verify_sha256(effective_cpuid, effective_cpuid_sha256, "effective CPUID")?; + decode_effective_cpuid(effective_cpuid) + .map_err(|err| manifest_error(format!("effective CPUID record: {err}")))?; Ok(()) } @@ -253,10 +237,11 @@ pub struct TimeAbiRestorePreflight { /// identity and clocks. /// /// The snapshot's profile must be pinned in this OpenVMM with the same -/// digest (`E_PROFILE_UNKNOWN`, `E_PROFILE_DIGEST`), and `host` must be in its -/// generation. The embedded document hashes to that digest, so it is the -/// pinned profile's canonical encoding without being decoded. The worker -/// recomputes the effective CPUID and compares it with the record. +/// digest, and the embedded document must be the pinned profile's canonical +/// encoding (`E_PROFILE_UNKNOWN`, `E_PROFILE_DIGEST`). Both are compared with +/// the build's precomputed record, without encoding or hashing. `host` must +/// be in the profile's generation. The worker recomputes the effective CPUID +/// and compares it with the record. pub fn preflight_time_abi_restore( contract: &SnapshotMachineContract, hypervisor: &str, @@ -296,6 +281,21 @@ pub fn preflight_time_abi_restore( })?; let profile = cpu_profile::pinned_for_restore(&record.id, &record.sha256).map_err(profile_error)?; + let pinned = cpu_profile::pinned_record(&record.id).ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ProfileUnknown, + format!("CPU profile {} is not pinned", record.id), + ) + })?; + if record.profile != pinned.encoding { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileDigest, + format!( + "the snapshot's embedded CPU profile is not the canonical encoding of the pinned profile {}", + record.id + ), + )); + } cpu_profile::check_generation(profile, &host).map_err(profile_error)?; let capture = capture_record(time)?; let downtime = select_downtime(&capture, destination, now, hooks)?; @@ -379,16 +379,26 @@ pub(super) mod tests { profile.effective_cpuid(&vm, &[]).unwrap() } + /// Returns the snapshot record of `effective`. + fn effective_cpuid_record(effective: &cpu_profile::EffectiveCpuid) -> Vec { + openvmm_defs::time_abi::encode_effective_cpuid(effective.results().map(|result| { + openvmm_defs::time_abi::EffectiveCpuidEntry { + function: result.function, + index: result.index, + result: result.result, + mask: result.mask, + } + })) + } + pub(in crate::snapshot) fn test_cpu_profile() -> SnapshotCpuProfile { let pinned = cpu_profile::pinned(TEST_PROFILE).unwrap(); - let profile = pinned.encode(); - let effective_cpuid = test_effective_cpuid(pinned, 2).encode(); + let record = cpu_profile::pinned_record(TEST_PROFILE).unwrap(); SnapshotCpuProfile { id: TEST_PROFILE.to_owned(), - sha256: sha2::Sha256::digest(&profile).to_vec(), - profile, - effective_cpuid_sha256: sha2::Sha256::digest(&effective_cpuid).to_vec(), - effective_cpuid, + sha256: record.digest.to_vec(), + profile: record.encoding.to_vec(), + effective_cpuid: effective_cpuid_record(&test_effective_cpuid(pinned, 2)), capture_cpu_signature: TEST_SIGNATURE, } } @@ -438,7 +448,7 @@ pub(super) mod tests { #[test] fn cpu_profile_record_rules() { validate_cpu_profile_record(&test_cpu_profile()).unwrap(); - let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 10] = [ + let mutations: [(fn(&mut SnapshotCpuProfile), TimeAbiCode); 11] = [ (|r| r.id.clear(), TimeAbiCode::ManifestTime), (|r| r.id = "Intel_SKX".to_owned(), TimeAbiCode::ManifestTime), ( @@ -449,17 +459,18 @@ pub(super) mod tests { |r| r.id = "intel.skylake-sp.kvm.v1.".to_owned(), TimeAbiCode::ManifestTime, ), - (|r| r.profile.push(0), TimeAbiCode::ProfileDigest), + (|r| r.profile.clear(), TimeAbiCode::ManifestTime), + (|r| r.sha256.truncate(31), TimeAbiCode::ManifestTime), + (|r| r.sha256.push(0), TimeAbiCode::ManifestTime), + (|r| r.effective_cpuid.clear(), TimeAbiCode::ManifestTime), ( |r| { - r.profile.clear(); - r.sha256 = sha2::Sha256::digest(&r.profile).to_vec(); + r.effective_cpuid.pop(); }, TimeAbiCode::ManifestTime, ), - (|r| r.sha256.truncate(31), TimeAbiCode::ManifestTime), - (|r| r.effective_cpuid[0] = 1, TimeAbiCode::ProfileDigest), - (|r| r.effective_cpuid.clear(), TimeAbiCode::ManifestTime), + // The subleaf flag of the first entry. + (|r| r.effective_cpuid[4] = 2, TimeAbiCode::ManifestTime), ( |r| r.profile = vec![0; MAX_CPU_PROFILE_BYTES + 1], TimeAbiCode::ManifestTime, @@ -476,17 +487,18 @@ pub(super) mod tests { ); } - // Validation does not decode the documents. The restore preflight - // rejects a document that is not the named profile, and the worker an - // effective CPUID record that is not this partition's encoding. + // Validation checks only the shape. The restore preflight rejects a + // document or digest that is not the named pinned profile's, and the + // worker an effective CPUID record that is not this partition's. let mut renamed = test_cpu_profile(); renamed.id = "intel.skylake-sp.v1".to_owned(); validate_cpu_profile_record(&renamed).unwrap(); - let mut noncanonical = test_cpu_profile(); - noncanonical.effective_cpuid.insert(1, b' '); - noncanonical.effective_cpuid_sha256 = - sha2::Sha256::digest(&noncanonical.effective_cpuid).to_vec(); - validate_cpu_profile_record(&noncanonical).unwrap(); + let mut other_document = test_cpu_profile(); + other_document.profile.push(b' '); + validate_cpu_profile_record(&other_document).unwrap(); + let mut other_cpuid = test_cpu_profile(); + other_cpuid.effective_cpuid[12] ^= 1; + validate_cpu_profile_record(&other_cpuid).unwrap(); } #[test] @@ -724,15 +736,14 @@ pub(super) mod tests { let record = interim.cpu_profile.as_mut().unwrap(); record.id = "interim.host.whp.v1".to_owned(); record.profile = Vec::new(); - record.sha256 = sha2::Sha256::digest(b"").to_vec(); + record.sha256 = vec![0; 32]; record.effective_cpuid = vec![7; 48]; - record.effective_cpuid_sha256 = sha2::Sha256::digest(&record.effective_cpuid).to_vec(); assert_eq!( fail(&interim, "whp", "auto", host, &now, &hooks), TimeAbiCode::ManifestTime ); - // A valid profile document that this OpenVMM does not pin. + // A profile that this OpenVMM does not pin. let mut unknown = contract.clone(); let record = unknown.cpu_profile.as_mut().unwrap(); record.id = "intel.icelake-sp.v9".to_owned(); @@ -740,23 +751,24 @@ pub(super) mod tests { .unwrap() .replace("\"intel.icelake-sp.v1\"", "\"intel.icelake-sp.v9\"") .into_bytes(); - record.sha256 = sha2::Sha256::digest(&record.profile).to_vec(); + record.sha256 = vec![9; 32]; assert_eq!( fail(&unknown, "whp", "auto", host, &now, &hooks), TimeAbiCode::ProfileUnknown ); - // The embedded document is another profile than the record names. + // The record's digest and document are another pinned profile's. let mut swapped = contract.clone(); let record = swapped.cpu_profile.as_mut().unwrap(); - record.profile = cpu_profile::pinned("intel.skylake-sp.v1").unwrap().encode(); - record.sha256 = sha2::Sha256::digest(&record.profile).to_vec(); + let other = cpu_profile::pinned_record("intel.skylake-sp.v1").unwrap(); + record.profile = other.encoding.to_vec(); + record.sha256 = other.digest.to_vec(); assert_eq!( fail(&swapped, "whp", "auto", host, &now, &hooks), TimeAbiCode::ProfileDigest ); - // The record names another pinned profile than its document. + // The record names another pinned profile than its digest. let mut renamed = contract.clone(); renamed.cpu_profile.as_mut().unwrap().id = "intel.skylake-sp.v1".to_owned(); assert_eq!( @@ -764,6 +776,15 @@ pub(super) mod tests { TimeAbiCode::ProfileDigest ); + // The digest is the pinned profile's, but the document is not its + // canonical encoding. + let mut altered = contract.clone(); + altered.cpu_profile.as_mut().unwrap().profile.push(b' '); + assert_eq!( + fail(&altered, "whp", "auto", host, &now, &hooks), + TimeAbiCode::ProfileDigest + ); + let mut exhausted = contract.clone(); exhausted.time.as_mut().unwrap().capture_generation = u32::MAX; assert_eq!( @@ -792,16 +813,18 @@ pub(super) mod tests { #[test] fn effective_cpuid_records_fit_the_manifest() { for profile in cpu_profile::pinned_profiles() { - let encoded = test_effective_cpuid(profile, 8).encode(); + let record = effective_cpuid_record(&test_effective_cpuid(profile, 8)); + let entries = record.len() / openvmm_defs::time_abi::EFFECTIVE_CPUID_ENTRY_BYTES; assert!( - encoded.len() < MAX_EFFECTIVE_CPUID_BYTES / 2, - "{}: {} bytes", - profile.id(), - encoded.len() + entries < openvmm_defs::time_abi::MAX_EFFECTIVE_CPUID_ENTRIES / 4, + "{}: {entries} entries", + profile.id() ); - assert!(profile.encode().len() < MAX_CPU_PROFILE_BYTES / 16); + let pinned = cpu_profile::pinned_record(profile.id()).unwrap(); + assert!(pinned.encoding.len() < MAX_CPU_PROFILE_BYTES / 16); } } + #[test] fn profile_errors_keep_their_codes() { for code in cpu_profile::ProfileErrorCode::ALL { From e5606bfdf9e1d0f173cf3290ffb78d40fc94fc37 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 21:33:29 -0700 Subject: [PATCH 246/304] virt_mshv: read the effective CPUID in one hypercall The time ABI's effective CPUID report reads about 60 entries from VP 0 at every boot and restore, one HvCallGetVpCpuidValues per entry: about 520 us on bare metal and 820 us nested, almost all of it per-call overhead. HvCallGetVpCpuidValues is a rep hypercall, and mshv-ioctls already issues it with a repetition count of one. Read every entry of the report in one call instead, through the MSHV_ROOT_HVCALL pass-through, with the flags that single reads use: the VP's XFEM and XSS, and the registered results applied. A call takes up to 128 entries, the most whose input fits in the kernel's page, and continues with the rest if the hypervisor completes only some. If the call fails, the report falls back to one read per entry and warns. Its log line records whether the read was in bulk. The CPUID sweep hardware test checks that one call reads every probe as the single reads do, and times both ways over the report's entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 223 ++++++++++++++++++---- 1 file changed, 191 insertions(+), 32 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 390c0d697cc..b868692e8f9 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -599,6 +599,92 @@ fn vp_cpuid(bsp: &VcpuFd, function: u32, index: u32) -> Result<[u32; 4], KernelE Ok(bsp.get_cpuid_values(function, index, 0, 0)?) } +/// The most CPUID entries that one `HvCallGetVpCpuidValues` reads. Its input, +/// a header and one leaf description per entry, must fit in the page that the +/// kernel copies it to. +const CPUID_READS_PER_CALL: usize = 128; + +/// The input of `HvCallGetVpCpuidValues` for up to [`CPUID_READS_PER_CALL`] +/// entries. +#[repr(C, packed)] +struct VpCpuidValuesInput { + header: mshv_bindings::hv_input_get_vp_cpuid_values, + leaves: [mshv_bindings::hv_cpuid_leaf_info; CPUID_READS_PER_CALL], +} + +const _: () = assert!(size_of::() <= 4096); + +/// Reads CPUID `entries`, each a leaf and subleaf, as VP `vp_index` sees them +/// with the registered results applied, which is how +/// `VcpuFd::get_cpuid_values` reads one entry. `HvCallGetVpCpuidValues` is a +/// rep hypercall, so one call reads up to [`CPUID_READS_PER_CALL`] entries +/// instead of paying a hypercall per entry. +fn vp_cpuid_many( + vp: &VcpuFd, + vp_index: u32, + entries: &[(u32, u32)], +) -> Result, KernelError> { + use mshv_bindings::hv_cpuid_leaf_info; + use mshv_bindings::hv_input_get_vp_cpuid_values; + use mshv_bindings::hv_output_get_vp_cpuid_values; + + let mut input = VpCpuidValuesInput { + header: hv_input_get_vp_cpuid_values { + vp_index, + ..Default::default() + }, + leaves: [hv_cpuid_leaf_info::default(); CPUID_READS_PER_CALL], + }; + // SAFETY: both fields of the flags union are the same 32 bits. + unsafe { + let flags = &mut input.header.flags.__bindgen_anon_1; + flags.set_use_vp_xfem_xss(1); + flags.set_apply_registered_values(1); + } + let mut output = [hv_output_get_vp_cpuid_values::default(); CPUID_READS_PER_CALL]; + let mut results = Vec::with_capacity(entries.len()); + let mut pending = entries; + while !pending.is_empty() { + let count = pending.len().min(CPUID_READS_PER_CALL); + for (leaf, &(eax, ecx)) in input.leaves.iter_mut().zip(&pending[..count]) { + *leaf = hv_cpuid_leaf_info { + eax, + ecx, + xfem: 0, + xss: 0, + }; + } + let mut args = mshv_bindings::mshv_root_hvcall { + code: hvdef::HypercallCode::HvCallGetVpCpuidValues.0, + reps: count as u16, + in_sz: (size_of::() + + count * size_of::()) as u16, + in_ptr: std::ptr::addr_of!(input) as u64, + out_sz: (count * size_of::()) as u16, + out_ptr: output.as_mut_ptr() as u64, + ..Default::default() + }; + vp.hvcall(&mut args)?; + // A call that completes only some of its entries is continued with + // the rest. + let completed = usize::from(args.reps); + if completed == 0 || completed > count { + return Err(KernelError::Kernel(std::io::Error::from_raw_os_error( + libc::EINTR, + ))); + } + // SAFETY: both fields of the output union are the same four + // registers. + results.extend( + output[..completed] + .iter() + .map(|values| unsafe { values.as_uint32 }), + ); + pending = &pending[completed..]; + } + Ok(results) +} + impl MshvPartitionInner { fn time_abi_state(&self) -> Result<&MshvTimeAbi, TimeAbiError> { self.time_abi.as_ref().ok_or_else(|| { @@ -637,44 +723,79 @@ impl MshvPartitionInner { return Ok(leaves); } let bsp = self.time_abi_bsp(TimeAbiCode::CpuSurface)?; - let read = |function: u32, index: Option| { - let result = vp_cpuid(bsp, function, index.unwrap_or(0)).map_err(|error| { - TimeAbiError::new( - TimeAbiCode::CpuSurface, - format!( - "cannot read CPUID {function:#x}/{:#x} of VP 0: {}", - index.unwrap_or(0), - error_chain(&error) - ), - ) - })?; - let leaf = CpuidLeaf::new(function, result); - Ok(match index { - Some(index) => leaf.indexed(index), - None => leaf, - }) - }; let started = std::time::Instant::now(); - let mut leaves = EFFECTIVE_CPUID_MAX_LEAVES + // VP 0 reads, in the report's order: the maximum leaves, the + // registered results except the explicit zero leaves past the + // identity, which are reported as registered, and the entries outside + // the profile's tables where the host's CPUID has any. Guests read the + // hypervisor's own values there, at subleaf 0 for a subleaf-independent + // entry, and core requires each to read zero. + let reads: Vec<(u32, Option)> = EFFECTIVE_CPUID_MAX_LEAVES .into_iter() - .map(|function| read(function, None)) - .collect::, TimeAbiError>>()?; + .map(|function| (function, None)) + .chain( + state + .registered_cpuid + .iter() + .filter(|leaf| !is_zero_fill(leaf)) + .map(|leaf| (leaf.function, leaf.index)), + ) + .chain(state.unlisted_candidates.iter().copied()) + .collect(); + let entries: Vec<(u32, u32)> = reads + .iter() + .map(|&(function, index)| (function, index.unwrap_or(0))) + .collect(); + let (values, bulk) = match vp_cpuid_many(bsp, VpIndex::BSP.index(), &entries) { + Ok(values) => (values, true), + Err(error) => { + tracing::warn!( + error = &error as &dyn std::error::Error, + "MSHV bulk CPUID read failed, reading one entry per call" + ); + let values = entries + .iter() + .map(|&(function, index)| { + vp_cpuid(bsp, function, index).map_err(|error| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "cannot read CPUID {function:#x}/{index:#x} of VP 0: {}", + error_chain(&error) + ), + ) + }) + }) + .collect::, _>>()?; + (values, false) + } + }; + let mut read = reads + .iter() + .zip(values) + .map(|(&(function, index), result)| { + let leaf = CpuidLeaf::new(function, result); + match index { + Some(index) => leaf.indexed(index), + None => leaf, + } + }); + let mut leaves: Vec<_> = read + .by_ref() + .take(EFFECTIVE_CPUID_MAX_LEAVES.len()) + .collect(); for leaf in &state.registered_cpuid { leaves.push(if is_zero_fill(leaf) { *leaf } else { - read(leaf.function, leaf.index)? + read.next().expect("one value per read") }); } - // Guests read the hypervisor's own values outside the registered - // results, so the report also holds VP 0's view where the host's - // CPUID has entries outside the profile's tables, at subleaf 0 for a - // subleaf-independent entry. Core requires each to read zero. - for &(function, index) in &state.unlisted_candidates { - leaves.push(read(function, index)?); - } + leaves.extend(read); tracing::info!( leaves = leaves.len(), + reads = entries.len(), + bulk, unlisted_candidates = state.unlisted_candidates.len(), elapsed_us = started.elapsed().as_micros() as u64, "MSHV effective CPUID read" @@ -1759,7 +1880,8 @@ mod hw { /// under their masks. A leaf or subleaf that the table does not list must /// read zero, which the zero results provide on MSHV, except past the /// extended topology leaves' terminators: those are reserved, and the - /// sweep only reports them. + /// sweep only reports them. One rep hypercall must read every probe as + /// the single reads do, and the test times both ways. #[async_test] #[ignore = "requires /dev/mshv"] async fn unlisted_cpuid_entries_read_zero(driver: DefaultDriver) { @@ -1822,14 +1944,16 @@ mod hw { indexed.dedup(); let max_basic = effective.lookup(0, 0)[0]; let max_extended = effective.lookup(0x8000_0000, 0)[0]; - let probes = indexed + let probes: Vec<(u32, u32)> = indexed .iter() .flat_map(|&function| (0..64).map(move |index| (function, index))) .chain((max_basic + 1..=max_basic + 4).map(|function| (function, 0))) - .chain((max_extended + 1..=max_extended + 4).map(|function| (function, 0))); + .chain((max_extended + 1..=max_extended + 4).map(|function| (function, 0))) + .collect(); let (mut count, mut listed_failures, mut unlisted, mut reserved) = (0, Vec::new(), Vec::new(), Vec::new()); - for (function, index) in probes { + let mut single = Vec::with_capacity(probes.len()); + for &(function, index) in &probes { count += 1; let listed = effective.results().find(|result| { result.function == function && result.index.is_none_or(|i| i == index) @@ -1837,6 +1961,7 @@ mod hw { let (expected, mask) = listed.map_or(([0; 4], [!0; 4]), |result| (result.result, result.mask)); let actual = bsp.get_cpuid_values(function, index, 0, 0).unwrap(); + single.push(actual); if (0..4).all(|register| (actual[register] ^ expected[register]) & mask[register] == 0) { continue; @@ -1863,6 +1988,40 @@ mod hw { } assert!(listed_failures.is_empty(), "{listed_failures:#?}"); assert!(unlisted.is_empty(), "{unlisted:#?}"); + + // One rep hypercall reads the same values, registered results + // included, over several calls when the probes exceed one call. + assert_eq!( + vp_cpuid_many(bsp, 0, &probes).unwrap(), + single, + "bulk and single CPUID reads differ" + ); + // Time both ways over the entries that the effective CPUID report + // reads, as `time_abi_effective_cpuid` does. + let report: Vec<(u32, u32)> = effective + .results() + .map(|result| (result.function, result.index.unwrap_or(0))) + .collect(); + let (mut single_us, mut bulk_us) = (Vec::new(), Vec::new()); + for _ in 0..11 { + let started = std::time::Instant::now(); + for &(function, index) in &report { + bsp.get_cpuid_values(function, index, 0, 0).unwrap(); + } + single_us.push(started.elapsed().as_micros() as u64); + let started = std::time::Instant::now(); + vp_cpuid_many(bsp, 0, &report).unwrap(); + bulk_us.push(started.elapsed().as_micros() as u64); + } + single_us.sort_unstable(); + bulk_us.sort_unstable(); + println!( + "bulk CPUID read: {} probes match single reads; {} entries in {} us one per call, {} us in one call (p50 of 11)", + probes.len(), + report.len(), + single_us[5], + bulk_us[5] + ); } /// Every VP of a time ABI partition reads its own x2APIC ID in `EDX` of From 93f9f11cd9727b6f5eb26767a76c2e2efd78ebad Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 22:11:58 -0700 Subject: [PATCH 247/304] virt_mshv: check that CPUID reads zero far outside the effective CPUID The time ABI specification guarantees that no CPUID entry outside the effective CPUID reads host data; on MSHV, each reads zero (spec 3c2aed7). The CPUID sweep hardware test checks it where guests enumerate: subleaves 0 to 63 of every indexed leaf and the four leaves past each maximum, which the backend's zero results cover. Check it farther out too, where no enumeration reaches and the hypervisor answers by itself: subleaves 64 to 255 of every indexed leaf, the leaves past the sweep up to 0xff and 0x800000ff, the hypervisor range up to 0x400001ff, and a few distant leaves. The bulk read makes the ~2,900 extra probes cost about a millisecond. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 60 ++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index b868692e8f9..380cd1d9204 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -1881,7 +1881,11 @@ mod hw { /// read zero, which the zero results provide on MSHV, except past the /// extended topology leaves' terminators: those are reserved, and the /// sweep only reports them. One rep hypercall must read every probe as - /// the single reads do, and the test times both ways. + /// the single reads do, and the test times both ways. Farther out, where + /// no guest enumeration reaches (subleaves 64 to 255, leaves up to 0xff + /// and 0x800000ff, the hypervisor range up to 0x400001ff, and a few + /// distant leaves), every unlisted entry must read zero as well: no entry + /// outside the effective CPUID reads host data. #[async_test] #[ignore = "requires /dev/mshv"] async fn unlisted_cpuid_entries_read_zero(driver: DefaultDriver) { @@ -2022,6 +2026,60 @@ mod hw { single_us[5], bulk_us[5] ); + + // Farther out, every entry the effective CPUID does not list must read + // zero too: subleaves 64 to 255 of every indexed leaf, the leaves past + // the probes above up to 0xff and 0x800000ff, the rest of the + // hypervisor range up to 0x400001ff, and a few distant leaves. + let far: Vec<(u32, u32)> = indexed + .iter() + .flat_map(|&function| (64..256).map(move |index| (function, index))) + .chain((max_basic + 5..=0xff).map(|function| (function, 0))) + .chain((max_extended + 5..=0x8000_00ff).map(|function| (function, 0))) + .chain((0x4000_0000..=0x4000_01ff).map(|function| (function, 0))) + .chain( + [ + 0x100, + 0x1000, + 0x2000_0000, + 0x3fff_ffff, + 0x8000_0100, + 0x8fff_ffff, + 0xc000_0000, + 0xc000_0001, + 0xffff_ffff, + ] + .map(|function| (function, 0)), + ) + .filter(|&(function, index)| { + !effective.results().any(|result| { + result.function == function && result.index.is_none_or(|i| i == index) + }) + }) + .collect(); + let values = vp_cpuid_many(bsp, 0, &far).unwrap(); + let (mut far_unlisted, mut far_reserved) = (Vec::new(), Vec::new()); + for (&(function, index), actual) in far.iter().zip(&values) { + if *actual == [0; 4] { + continue; + } + let line = format!("{function:#x}.{index}: {actual:08x?}"); + if is_per_vp_leaf(function) { + far_reserved.push(line); + } else { + far_unlisted.push(line); + } + } + println!( + "{} far probes: {} unlisted results are not zero, {} reserved topology entries are not zero", + far.len(), + far_unlisted.len(), + far_reserved.len() + ); + for line in far_unlisted.iter().chain(&far_reserved) { + println!(" {line}"); + } + assert!(far_unlisted.is_empty(), "{far_unlisted:#?}"); } /// Every VP of a time ABI partition reads its own x2APIC ID in `EDX` of From ce3c054d96de0936925fff9b8c392eba77e21d9b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 22:25:00 -0700 Subject: [PATCH 248/304] cpu_profile: enumerate the host CPUID without the hypervisor range Add cpuid::enumerate_basic_and_extended, which walks the basic and extended ranges as enumerate does but never queries 0x40000000..=0x4fffffff. A host's hypervisor leaves describe the hypervisor, not the processor. Profiles list none, and unlisted_cpuid_candidates exempts the range, so neither the supported CPU surface nor the candidates read them. In a Hyper-V root partition every CPUID instruction exits to the hypervisor: about 0.5 us each on bare metal and 2.8-3.6 us nested on Azure. The 13 hypervisor leaves of the fleet's Hyper-V roots are about a fifth of the queries, so MSHV and WHP can save up to about 0.06 ms per start by switching their host enumeration to this helper. A test checks that the helper never queries the range and returns exactly enumerate's entries without it, for a plausible and an implausible hypervisor maximum leaf. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/cpu_profile/src/cpuid.rs | 62 +++++++++++++++++++++++++++- vmm_core/cpu_profile/src/unlisted.rs | 7 +++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/vmm_core/cpu_profile/src/cpuid.rs b/vmm_core/cpu_profile/src/cpuid.rs index f2ab110b128..dc6e4e45a02 100644 --- a/vmm_core/cpu_profile/src/cpuid.rs +++ b/vmm_core/cpu_profile/src/cpuid.rs @@ -132,10 +132,36 @@ pub fn has_bit(entries: &[CpuidEntry], leaf: u32, subleaf: u32, register: usize, /// only when it reports a plausible maximum leaf; otherwise only its first /// leaf is recorded. The result is sorted. pub fn enumerate( + query: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + enumerate_ranges(&[0, HYPERVISOR_LEAF_BASE, EXTENDED_LEAF_BASE], query) +} + +/// Enumerates the basic and extended ranges of the CPUID table reported by +/// `query(leaf, subleaf)` as [`enumerate`] does, without querying the +/// hypervisor range `0x40000000..=0x4fffffff`. +/// +/// A host's hypervisor leaves describe the hypervisor it runs on, not its +/// processor. Nothing that checks a profile against the host reads them: +/// profiles list no hypervisor leaves, and +/// [`unlisted_cpuid_candidates`](crate::unlisted_cpuid_candidates) exempts +/// the range. Every CPUID instruction in a Hyper-V root partition exits to the +/// hypervisor, so a backend that enumerates its host's CPUID at partition +/// creation saves the range's queries, about a fifth of the total on the +/// fleet's Hyper-V hosts. +pub fn enumerate_basic_and_extended( + query: impl FnMut(u32, u32) -> Result<[u32; 4], E>, +) -> Result, E> { + enumerate_ranges(&[0, EXTENDED_LEAF_BASE], query) +} + +/// Enumerates the ranges that start at `bases`, as [`enumerate`] describes. +fn enumerate_ranges( + bases: &[u32], mut query: impl FnMut(u32, u32) -> Result<[u32; 4], E>, ) -> Result, E> { let mut entries = Vec::new(); - for base in [0, HYPERVISOR_LEAF_BASE, EXTENDED_LEAF_BASE] { + for &base in bases { let max = query(base, 0)?[0]; let last = if (base..base + MAX_LEAVES_PER_RANGE).contains(&max) { max @@ -437,6 +463,40 @@ mod tests { assert!(keys.windows(2).all(|pair| pair[0] < pair[1])); } + #[test] + fn basic_and_extended_enumeration_skips_only_the_hypervisor_range() { + // A root partition's plausible hypervisor range, 0x40000000 to + // 0x4000000c as on the fleet's Hyper-V hosts, and the fixture's + // implausible one. + let mut hyperv = table(); + hyperv.insert( + (0x4000_0000, 0), + [0x4000_000c, 0x7263_694d, 0x666f_736f, 0x7648_2074], + ); + for leaf in 0x4000_0001..=0x4000_000c { + hyperv.insert((leaf, 0), [leaf, 1, 2, 3]); + } + let hypervisor = HYPERVISOR_LEAF_BASE..EXTENDED_LEAF_BASE; + for host in [hyperv, table()] { + let all = enumerate(query(&host)).unwrap(); + assert!(all.iter().any(|entry| hypervisor.contains(&entry.leaf.0))); + + let mut queried = Vec::new(); + let mut lookup = query(&host); + let entries = enumerate_basic_and_extended(|leaf, subleaf| { + queried.push(leaf); + lookup(leaf, subleaf) + }) + .unwrap(); + assert!(queried.iter().all(|leaf| !hypervisor.contains(leaf))); + let expected = all + .into_iter() + .filter(|entry| !hypervisor.contains(&entry.leaf.0)) + .collect::>(); + assert_eq!(entries, expected); + } + } + #[test] fn enumeration_is_bounded() { // A hypervisor that reports endless valid subleaves and leaves. diff --git a/vmm_core/cpu_profile/src/unlisted.rs b/vmm_core/cpu_profile/src/unlisted.rs index 2082a7d2eca..36cef79b532 100644 --- a/vmm_core/cpu_profile/src/unlisted.rs +++ b/vmm_core/cpu_profile/src/unlisted.rs @@ -114,8 +114,11 @@ pub(crate) fn unlisted_cpuid_error(profile: &CpuProfile, violations: &[String]) /// walks it. It reaches every entry the processor implements, including those /// past the profile's maxima and those that only the root partition sees, so /// these are the entries where a pass-through backend's guest may read the -/// hypervisor's own guest view. A backend reads VP 0's view there, at subleaf -/// 0 for a subleaf-independent key, for [`check_unlisted_cpuid`]. +/// hypervisor's own guest view. Because the identity range is exempt, +/// [`cpuid::enumerate_basic_and_extended`](crate::cpuid::enumerate_basic_and_extended) +/// gives the same candidates with fewer queries. A backend reads VP 0's view +/// there, at subleaf 0 for a subleaf-independent key, for +/// [`check_unlisted_cpuid`]. pub fn unlisted_cpuid_candidates( profile: &CpuProfile, host: &[CpuidEntry], From ee5bd0874f3aee3e1a79a9a25dab4040d8dad687 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 22:33:54 -0700 Subject: [PATCH 249/304] cpu_profile: keep the codecs and digests off the start paths Core's c63394446 records and checks a snapshot's CPU profile with pinned_record's constants and compares the effective CPUID as core's packed binary record, so no start path calls the canonical JSON codecs or digests any more. Remove restore_profile and verify_profile_record, which only this crate's tests still called. Their checks now live in the restore preflight: pinned_for_restore against the constant digest, a byte compare with pinned_record's encoding, and check_generation. The restore test exercises exactly those, with the same error codes. Document the codecs and digests (CpuProfile::{encode, digest, digest_string, decode, from_pretty_json, to_pretty_json}, EffectiveCpuid::{encode, digest, decode, decode_verified}) and pinned_profiles as offline only: tools, tests, and --cpu-fingerprint, never a cold boot or restore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/cpu_profile/src/catalog.rs | 101 ++++++-------------------- vmm_core/cpu_profile/src/effective.rs | 15 ++-- vmm_core/cpu_profile/src/lib.rs | 18 ++++- vmm_core/cpu_profile/src/profile.rs | 15 +++- 4 files changed, 59 insertions(+), 90 deletions(-) diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index ea06136718c..a64ec33baa2 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -37,6 +37,10 @@ fn position(id: &str) -> Option { /// Returns every pinned profile. Selecting by ID with [`pinned`] or /// [`pinned_for_restore`] builds only that profile. +/// +/// Offline only: this builds every profile, for tools and tests. A cold boot +/// or restore selects its one profile with [`select`] or +/// [`pinned_for_restore`]. pub fn pinned_profiles() -> &'static [CpuProfile] { static ALL: OnceLock> = OnceLock::new(); ALL.get_or_init(|| (0..PINNED.len()).map(|index| load(index).clone()).collect()) @@ -48,7 +52,8 @@ pub fn pinned(id: &str) -> Option<&'static CpuProfile> { } /// Returns the precomputed record of the pinned profile `id`: its canonical -/// encoding and digest, for a snapshot's CPU profile record. +/// encoding and digest, constants that a snapshot's CPU profile record +/// copies at capture and that the restore preflight compares byte for byte. pub fn pinned_record(id: &str) -> Option { position(id).map(|index| PINNED[index].record()) } @@ -163,39 +168,14 @@ pub fn check_generation(profile: &CpuProfile, host: &HostCpuSignature) -> Result } } -/// Verifies the CPU profile record of a snapshot: the embedded canonical -/// profile matches its recorded SHA-256 and recorded ID, and decodes -/// (`E_PROFILE_DIGEST`). -pub fn verify_profile_record( - id: &str, - sha256: &[u8], - embedded: &[u8], -) -> Result { - if canonical::sha256(embedded).as_slice() != sha256 { - return Err(ProfileError::new( - ProfileErrorCode::ProfileDigest, - format!("the embedded CPU profile {id} does not match its recorded digest"), - )); - } - let profile = CpuProfile::decode(embedded)?; - if profile.id() != id { - return Err(ProfileError::new( - ProfileErrorCode::ProfileDigest, - format!( - "the embedded CPU profile is {}, but the record names {id}", - profile.id() - ), - )); - } - Ok(profile) -} - /// Returns the pinned profile that a snapshot recorded by ID and SHA-256. /// /// Fails with `E_PROFILE_UNKNOWN` when this OpenVMM does not pin the ID, and /// with `E_PROFILE_DIGEST` when its pinned profile has another digest. The /// pinned profile's digest is a constant, which the catalog's tests check, so -/// a restore encodes and hashes nothing here. +/// a restore encodes and hashes nothing here. The restore preflight also +/// compares the recorded document with [`pinned_record`]'s encoding, and +/// checks the destination host with [`check_generation`]. pub fn pinned_for_restore(id: &str, sha256: &[u8]) -> Result<&'static CpuProfile, ProfileError> { let index = position(id).ok_or_else(|| unknown(id))?; let golden = &PINNED[index].digest; @@ -211,21 +191,6 @@ pub fn pinned_for_restore(id: &str, sha256: &[u8]) -> Result<&'static CpuProfile Ok(load(index)) } -/// Runs the profile checks of a restore: [`verify_profile_record`], -/// [`pinned_for_restore`], and [`check_generation`] for the destination -/// host. -pub fn restore_profile( - id: &str, - sha256: &[u8], - embedded: &[u8], - host: &HostCpuSignature, -) -> Result<&'static CpuProfile, ProfileError> { - verify_profile_record(id, sha256, embedded)?; - let profile = pinned_for_restore(id, sha256)?; - check_generation(profile, host)?; - Ok(profile) -} - fn unknown(id: &str) -> ProfileError { ProfileError::new( ProfileErrorCode::ProfileUnknown, @@ -488,48 +453,40 @@ mod tests { } #[test] - fn restore_checks_the_record_the_pinned_profile_and_the_host() { + fn restore_checks_the_pinned_record_and_the_host() { let profile = profile("intel.icelake-sp.v1"); - let bytes = profile.encode(); let digest = profile.digest(); let host = intel(ICELAKE); + assert_eq!(pinned_for_restore(profile.id(), &digest).unwrap(), profile); + check_generation(profile, &host).unwrap(); + // The preflight compares the recorded document with the pinned + // encoding, which is the profile's. assert_eq!( - restore_profile(profile.id(), &digest, &bytes, &host).unwrap(), - profile + pinned_record(profile.id()).unwrap().encoding, + profile.encode().as_slice() ); - // A corrupted record. - let mut corrupted = bytes.clone(); + // A corrupted, short, or another profile's digest. + let mut corrupted = digest; corrupted[10] ^= 1; assert_eq!( - code(restore_profile(profile.id(), &digest, &corrupted, &host)), + code(pinned_for_restore(profile.id(), &corrupted)), ProfileErrorCode::ProfileDigest ); assert_eq!( - code(restore_profile(profile.id(), &digest[..31], &bytes, &host)), + code(pinned_for_restore(profile.id(), &digest[..31])), ProfileErrorCode::ProfileDigest ); assert_eq!( - code(restore_profile( - "intel.skylake-sp.v1", - &digest, - &bytes, - &host - )), + code(pinned_for_restore("intel.skylake-sp.v1", &digest)), ProfileErrorCode::ProfileDigest ); // A profile this OpenVMM does not pin. let mut unknown = profile.clone(); unknown.id = "intel.icelake-sp.v9".to_owned(); - let unknown_bytes = unknown.encode(); assert_eq!( - code(restore_profile( - unknown.id(), - &unknown.digest(), - &unknown_bytes, - &host - )), + code(pinned_for_restore(unknown.id(), &unknown.digest())), ProfileErrorCode::ProfileUnknown ); @@ -537,23 +494,13 @@ mod tests { let mut changed = profile.clone(); changed.description.push('!'); assert_eq!( - code(restore_profile( - changed.id(), - &changed.digest(), - &changed.encode(), - &host - )), + code(pinned_for_restore(changed.id(), &changed.digest())), ProfileErrorCode::ProfileDigest ); // Another generation on the destination. assert_eq!( - code(restore_profile( - profile.id(), - &digest, - &bytes, - &intel(EMERALDRAPIDS) - )), + code(check_generation(profile, &intel(EMERALDRAPIDS))), ProfileErrorCode::CpuGeneration ); } diff --git a/vmm_core/cpu_profile/src/effective.rs b/vmm_core/cpu_profile/src/effective.rs index 32633f6d150..41defe12d45 100644 --- a/vmm_core/cpu_profile/src/effective.rs +++ b/vmm_core/cpu_profile/src/effective.rs @@ -148,9 +148,12 @@ impl EffectiveCpuid { /// Returns the canonical encoding: compact canonical JSON. /// /// It writes the document's fixed shape directly, keys in byte order and - /// register values as `0x` and eight hex digits, because restore encodes - /// the recomputed effective CPUID to compare it with the record. Tests - /// check it against the sorting encoder. + /// register values as `0x` and eight hex digits. Tests check it against + /// the sorting encoder. + /// + /// Offline only, for tools and tests: a snapshot records the effective + /// CPUID in core's packed binary form, built from [`Self::results`], and + /// a restore compares those bytes. pub fn encode(&self) -> Vec { let mut out = Vec::with_capacity(64 + self.entries.len() * ENCODED_ENTRY_BYTES); out.extend_from_slice(b"{\"cpuid\":["); @@ -177,7 +180,7 @@ impl EffectiveCpuid { out } - /// Returns the SHA-256 of [`Self::encode`]. + /// Returns the SHA-256 of [`Self::encode`]. Offline only. pub fn digest(&self) -> [u8; 32] { canonical::sha256(&self.encode()) } @@ -185,7 +188,7 @@ impl EffectiveCpuid { /// Decodes an effective CPUID from its canonical encoding. /// /// Bytes that are not exactly a canonical encoding fail with - /// `E_PROFILE_DIGEST`. + /// `E_PROFILE_DIGEST`. Offline only. pub fn decode(bytes: &[u8]) -> Result { let invalid = |message: &str| { ProfileError::new( @@ -227,7 +230,7 @@ impl EffectiveCpuid { } /// Decodes a recorded effective CPUID and verifies it against its - /// recorded SHA-256 (`E_PROFILE_DIGEST`). + /// recorded SHA-256 (`E_PROFILE_DIGEST`). Offline only. pub fn decode_verified(bytes: &[u8], sha256: &[u8]) -> Result { if canonical::sha256(bytes).as_slice() != sha256 { return Err(ProfileError::new( diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index b4ac2a36462..1ebd2fc1b21 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -21,8 +21,20 @@ //! a pass-through backend (MSHV, WHP) must present zero there //! ([`check_unlisted_cpuid`]), and KVM answers those entries from the //! effective CPUID itself ([`CpuidPresentation`]). -//! - **Restore:** [`restore_profile`] checks a snapshot's profile record, -//! and [`EffectiveCpuid::check_matches`] its effective CPUID. +//! - **Restore:** [`pinned_for_restore`] checks a snapshot's recorded profile +//! digest against the pinned constant, core compares the recorded document +//! with [`pinned_record`]'s encoding, and [`check_generation`] checks the +//! destination host. Core compares the effective CPUID record with the +//! recomputed [`EffectiveCpuid`]. +//! - **Offline only:** the codecs and digests +//! ([`CpuProfile::encode`], [`CpuProfile::digest`], +//! [`CpuProfile::digest_string`], [`CpuProfile::decode`], +//! [`CpuProfile::from_pretty_json`], [`CpuProfile::to_pretty_json`], +//! [`EffectiveCpuid::encode`], [`EffectiveCpuid::digest`], +//! [`EffectiveCpuid::decode`], and [`EffectiveCpuid::decode_verified`]) +//! and [`pinned_profiles`] parse, encode, or hash. They serve tools, tests, +//! and `--cpu-fingerprint`, never a cold boot or restore, whose profile +//! work uses only constants. //! //! Failures carry the stable codes of the time ABI specification //! ([`ProfileError`]). @@ -62,10 +74,8 @@ pub use catalog::pinned; pub use catalog::pinned_for_restore; pub use catalog::pinned_profiles; pub use catalog::pinned_record; -pub use catalog::restore_profile; pub use catalog::select; pub use catalog::select_auto; -pub use catalog::verify_profile_record; pub use check::FingerprintCheck; pub use check::SUMMARY_PREFIX; pub use check::check_fingerprint; diff --git a/vmm_core/cpu_profile/src/profile.rs b/vmm_core/cpu_profile/src/profile.rs index 5e1401a9ece..f829f591e8e 100644 --- a/vmm_core/cpu_profile/src/profile.rs +++ b/vmm_core/cpu_profile/src/profile.rs @@ -263,22 +263,29 @@ impl CpuProfile { } /// Returns the canonical encoding: compact canonical JSON. + /// + /// Offline only, for tools and tests: a pinned profile's encoding is the + /// constant [`pinned_record`](crate::pinned_record) returns. pub fn encode(&self) -> Vec { canonical::to_compact_ordered(self) } /// Returns the profile as pretty canonical JSON, with a final newline: - /// the form in which profiles are pinned. + /// the form in which profiles are pinned. Offline only. pub fn to_pretty_json(&self) -> String { canonical::to_pretty_ordered(self) } /// Returns the profile digest: the SHA-256 of [`Self::encode`]. + /// + /// Offline only, for tools and tests: a pinned profile's digest is the + /// constant [`pinned_record`](crate::pinned_record) returns. pub fn digest(&self) -> [u8; 32] { canonical::sha256(&self.encode()) } - /// Returns the profile digest as `sha256:`. + /// Returns the profile digest as `sha256:`. Offline only, for + /// `--cpu-fingerprint` and tools. pub fn digest_string(&self) -> String { canonical::format_digest(&self.digest()) } @@ -287,6 +294,8 @@ impl CpuProfile { /// /// Bytes that are not exactly the canonical encoding of a valid profile /// fail with `E_PROFILE_DIGEST`: they cannot carry a verifiable digest. + /// Offline only: a restore compares the recorded document with the + /// pinned constant instead of decoding it. pub fn decode(bytes: &[u8]) -> Result { let text = std::str::from_utf8(bytes) .map_err(|_| invalid("the encoding is not UTF-8".to_owned()))?; @@ -301,7 +310,7 @@ impl CpuProfile { } /// Parses a profile from pretty canonical JSON, the pinned form, and - /// validates it. + /// validates it. Offline only, for the profile generator and tests. pub fn from_pretty_json(text: &str) -> Result { let this = Self::parse(text)?; if this.to_pretty_json() != text { From 3cf947c5a6ae1508c635dd10e0f76cd826c4bec7 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 22:21:37 -0700 Subject: [PATCH 250/304] virt_mshv: drop the time ABI's zero results for reserved CPUID entries The backend registered a zero result for every reserved CPUID entry that the host's CPUID enumerates outside the effective CPUID (15 on the Skylake-SP hosts, 13 on the Azure 8573C runners), about 65 us of registrations at every boot and restore, so that those entries would read zero. The specification now reserves them and verifies them instead: a host whose hypervisor presents a non-zero one fails with E_CPU_UNLISTED (verification step 6), which the backend's effective CPUID report checks at every boot and restore. Whether MSHV still needs the zero results was left to its native view of those entries. It does not. On a partition built like a time ABI partition but without them, the hypervisor's own guest view read zero at every one of those entries and at every other reserved entry of the CPUID sweep: 15 and 2,755 probes on prometheus30 (10.0.26100.30000), and 13 and 3,261 on azure-azlinux-5 (nested 10.0.26100.9444). That includes Intel PT's 0x14.1, which the Skylake-SP root reads as non-zero. Remove unlisted_zero_results and register only the effective CPUID. The host's CPUID is still read once, for the supported CPU surface and the E_CPU_UNLISTED candidates. The CPUID sweep hardware test now checks the hypervisor's own view directly, near and far, and its probe lists move into a helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/mod.rs | 25 ++-- .../virt_mshv/src/x86_64/profile_features.rs | 126 ----------------- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 132 +++++++++++------- 3 files changed, 87 insertions(+), 196 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index e4f5cea7392..fd7833c105b 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -559,17 +559,16 @@ impl ProtoPartition for MshvProtoPartition<'_> { None => virt::CpuidLeafSet::new(tsc::add_cpuid_leaves(&self.vmfd, cpuid)?), }; - // A time ABI partition's unlisted leaves and subleaves read zero, which - // takes explicit results on MSHV. + // A time ABI partition reads the host's CPUID once: its supported CPU + // surface derives from it, and its effective CPUID report covers the + // reserved entries the host enumerates (`E_CPU_UNLISTED`). Those + // entries need no results of their own: the hypervisor's guest view + // reads zero there, as the CPUID sweep hardware test checks. let started = std::time::Instant::now(); - let (zero_results, host_cpuid) = if self.config.time_abi.is_some() { - let host_cpuid = profile_features::host_cpuid_table(); - ( - profile_features::unlisted_zero_results(&cpuid, &host_cpuid), - host_cpuid, - ) + let host_cpuid = if self.config.time_abi.is_some() { + profile_features::host_cpuid_table() } else { - (Vec::new(), Vec::new()) + Vec::new() }; let host_cpuid_us = started.elapsed().as_micros() as u64; @@ -581,17 +580,11 @@ impl ProtoPartition for MshvProtoPartition<'_> { let per_vp = |leaf: &&virt::CpuidLeaf| { self.config.time_abi.is_some() && time_abi::is_per_vp_leaf(leaf.function) }; - for leaf in cpuid - .leaves() - .iter() - .chain(&zero_results) - .filter(|leaf| !per_vp(leaf)) - { + for leaf in cpuid.leaves().iter().filter(|leaf| !per_vp(leaf)) { register_cpuid_result(&self.vmfd, leaf)?; } tracing::info!( leaves = cpuid.leaves().len(), - zero_results = zero_results.len(), per_vp_leaves = cpuid.leaves().iter().filter(per_vp).count(), host_cpuid_us, elapsed_us = started.elapsed().as_micros() as u64, diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index cbf9d7afe7e..6183e3a030d 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -123,76 +123,6 @@ pub(crate) fn host_cpuid_table() -> Vec { .unwrap_or_else(|never| match never {}) } -/// The leaves past each maximum leaf that read zero even beyond the host's -/// own maximum leaf. -const ZERO_LEAVES_PAST_MAXIMUM: u32 = 4; - -/// Returns the zero results that make the leaves and subleaves `cpuid` does -/// not list read zero, as a CPU profile's effective CPUID requires, where the -/// hypervisor would otherwise serve its own value. -/// -/// MSHV serves its own value for anything without a registered result, and -/// refuses a whole-leaf result and a subleaf result for the same leaf. So this -/// returns: -/// -/// - a subleaf result for every subleaf that `host` enumerates of a leaf that -/// `cpuid` lists by subleaf, unless `cpuid` lists that subleaf; -/// - a whole-leaf result for every basic and extended leaf that `cpuid` does -/// not list at all, up to the host's maximum leaves or -/// [`ZERO_LEAVES_PAST_MAXIMUM`] past `cpuid`'s, whichever is higher. -/// -/// The hypervisor range belongs to the time ABI's identity and explicit zero -/// leaves, which `cpuid` lists. The extended topology leaves belong to the VM, -/// whose topology `cpuid` lists, and the backend registers them once per VP -/// (see [`is_per_vp_leaf`](super::time_abi::is_per_vp_leaf)), so they get no -/// zero results. Their entries past the topology's terminator are reserved. -pub(crate) fn unlisted_zero_results( - cpuid: &virt::CpuidLeafSet, - host: &[CpuidEntry], -) -> Vec { - let leaves = cpuid.leaves(); - let listed = |function: u32| leaves.iter().any(|leaf| leaf.function == function); - let vm_owned = super::time_abi::is_per_vp_leaf; - let by_subleaf = |function: u32| { - leaves - .iter() - .filter(|leaf| leaf.function == function) - .all(|leaf| leaf.index.is_some()) - }; - let host_max = |base: u32| cpu_profile::cpuid::lookup(host, base, 0).map_or(base, |r| r[0]); - let table_max = |base: u32| cpuid.result(base, 0, &[base, 0, 0, 0])[0]; - - let mut zeros = Vec::new(); - // Unlisted subleaves of the leaves the table lists by subleaf. - for entry in host { - let (function, subleaf) = entry.key(); - if let Some(subleaf) = subleaf { - if !vm_owned(function) - && listed(function) - && by_subleaf(function) - && !leaves - .iter() - .any(|leaf| leaf.function == function && leaf.index == Some(subleaf)) - { - zeros.push(CpuidLeaf::new(function, [0; 4]).indexed(subleaf)); - } - } - } - // Unlisted leaves, including those past the maximum leaves. - for base in [0, EXTENDED_LEAF_BASE] { - let last = host_max(base).max(table_max(base) + ZERO_LEAVES_PAST_MAXIMUM); - if !(base..base + 0x100).contains(&last) { - continue; - } - for function in base..=last { - if !vm_owned(function) && !listed(function) { - zeros.push(CpuidLeaf::new(function, [0; 4])); - } - } - } - zeros -} - /// Returns the CPU surface that MSHV supports on this host, given the host's /// CPUID table ([`host_cpuid_table`]), the features the host partition /// offers, and the guest physical address width. @@ -428,62 +358,6 @@ mod tests { assert_eq!(msr.controllable, hv_banks::ARCH_CAPABILITIES_BANK_MASK); } - #[test] - fn unlisted_leaves_and_subleaves_get_zero_results() { - let entry = |leaf, subleaf, eax| CpuidEntry::new(leaf, subleaf, [eax, 1, 2, 3]); - let host = vec![ - entry(0, None, 0xb), - entry(1, None, 0), - entry(2, None, 0), - entry(4, Some(0), 0x121), - entry(4, Some(1), 0x122), - entry(4, Some(2), 0x143), - entry(4, Some(3), 0x163), - entry(7, Some(0), 2), - entry(7, Some(1), 0), - entry(7, Some(2), 0), - entry(0xa, None, 0), - entry(0xb, Some(0), 1), - entry(0xb, Some(1), 4), - entry(0xb, Some(2), 0), - entry(0xd, Some(0), 0), - entry(0xd, Some(1), 0), - entry(0xd, Some(2), 0), - entry(HYPERVISOR_LEAF_BASE, None, HYPERVISOR_LEAF_BASE + 0xb), - entry(EXTENDED_LEAF_BASE, None, EXTENDED_LEAF_BASE + 8), - ]; - let table = virt::CpuidLeafSet::new(vec![ - CpuidLeaf::new(0, [7, 0, 0, 0]), - CpuidLeaf::new(1, [0; 4]), - CpuidLeaf::new(4, [0x121, 0, 0, 0]).indexed(0), - CpuidLeaf::new(4, [0x122, 0, 0, 0]).indexed(1), - CpuidLeaf::new(7, [0; 4]).indexed(0), - // The VM's topology: two levels, without the host's terminator. - CpuidLeaf::new(0xb, [0, 1, 0x100, 0]).indexed(0), - CpuidLeaf::new(0xb, [1, 1, 0x201, 0]).indexed(1), - // A leaf listed whole gets no subleaf results: MSHV refuses both. - CpuidLeaf::new(0xd, [0; 4]), - CpuidLeaf::new(HYPERVISOR_LEAF_BASE, [HYPERVISOR_LEAF_BASE + 5, 0, 0, 0]), - CpuidLeaf::new(EXTENDED_LEAF_BASE, [EXTENDED_LEAF_BASE + 1, 0, 0, 0]), - CpuidLeaf::new(EXTENDED_LEAF_BASE + 1, [0; 4]), - ]); - let zeros: Vec<_> = unlisted_zero_results(&table, &host) - .iter() - .map(|leaf| { - assert_eq!((leaf.result, leaf.mask), ([0; 4], [!0; 4])); - (leaf.function, leaf.index) - }) - .collect(); - // The host's topology terminator 0xb.2 gets none: the topology leaves - // are the VM's. - let mut expected = vec![(4, Some(2)), (4, Some(3)), (7, Some(1)), (7, Some(2))]; - // Basic leaves up to the host's maximum or four past the table's (both - // 0xb), whichever is higher; extended up to the host's 0x80000008. - expected.extend([2, 3, 5, 6, 8, 9, 0xa].map(|leaf| (leaf, None))); - expected.extend((2..=8).map(|leaf| (EXTENDED_LEAF_BASE + leaf, None))); - assert_eq!(zeros, expected); - } - #[test] fn the_surface_supports_each_host_profile() { // On a host whose CPUID already matches its profile, the surface of the diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 380cd1d9204..25b6d305344 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -18,11 +18,14 @@ //! when they are intercepted, and for every other MSR of the identity range. //! - CPUID intercept results present the configured CPUID verbatim: it is the //! CPU profile's complete effective CPUID, and the backend adds nothing to -//! it except zero results for the leaves and subleaves it does not list. The -//! extended topology leaves are registered once per VP with that VP's -//! x2APIC ID, which the hypervisor does not provide for these partitions. -//! The hypervisor reports no hypervisor leaf of its own, which preflight -//! verifies at sentinel leaves. +//! it. The extended topology leaves are registered once per VP with that +//! VP's x2APIC ID, which the hypervisor does not provide for these +//! partitions. Entries the effective CPUID does not list show the +//! hypervisor's own guest view, which reads zero there: the CPUID sweep +//! hardware test checks it, and verification (`E_CPU_UNLISTED`) checks the +//! ones the host enumerates at every boot and restore. The hypervisor +//! reports no hypervisor leaf of its own, which preflight verifies at +//! sentinel leaves. //! - The processor feature banks follow the CPU profile (see //! [`profile_features`](super::profile_features)), keep the invariant TSC, //! and hide the TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. @@ -134,8 +137,8 @@ pub(crate) struct MshvTimeAbi { /// the supported CPU surface. #[inspect(skip)] host_features: HvFeatures, - /// The host's CPUID table, read once at creation for the zero results - /// and the supported CPU surface. + /// The host's CPUID table, read once at creation for the supported CPU + /// surface and the unlisted candidates. #[inspect(skip)] host_cpuid: Vec, /// The entries the host's CPUID enumerates outside the CPU profile's @@ -300,10 +303,9 @@ fn install_msr_intercepts(vmfd: &VmFd, msrs: &[u32]) -> Result<(), TimeAbiError> /// leaves), so neither the worker's leaves nor the backend's own add a leaf or /// a bit. The hypervisor's own values show only where a result's mask is clear /// (each VP's APIC identity, the runtime XSAVE sizes) and where no result -/// applies, which the zero results of -/// [`unlisted_zero_results`](super::profile_features::unlisted_zero_results) -/// cover. Without synthetic processor features, the hypervisor itself reports -/// every other hypervisor-range leaf as zero. +/// applies: the reserved entries, which its guest view reads as zero. Without +/// synthetic processor features, the hypervisor itself reports every other +/// hypervisor-range leaf as zero. pub(super) fn partition_cpuid(config: &CpuidLeafSet) -> CpuidLeafSet { CpuidLeafSet::new(config.leaves().to_vec()) } @@ -1623,6 +1625,61 @@ mod hw { sorted[(sorted.len() - 1) * percent / 100] } + /// Whether `effective` lists CPUID `function` at subleaf `index`. + fn is_listed(effective: &cpu_profile::EffectiveCpuid, function: u32, index: u32) -> bool { + effective + .results() + .any(|result| result.function == function && result.index.is_none_or(|i| i == index)) + } + + /// The CPUID entries that a sweep of VP 0 probes for `effective`. + /// + /// - Near, where guests enumerate: subleaves 0 to 63 of every indexed + /// leaf, and the four leaves past each maximum. + /// - Far, where no enumeration reaches, without the entries `effective` + /// lists: subleaves 64 to 255 of every indexed leaf, the leaves past the + /// near ones up to 0xff and 0x800000ff, the hypervisor range up to + /// 0x400001ff, and a few distant leaves. + fn sweep_probes(effective: &cpu_profile::EffectiveCpuid) -> (Vec<(u32, u32)>, Vec<(u32, u32)>) { + let mut indexed: Vec = effective + .results() + .filter(|result| result.index.is_some()) + .map(|result| result.function) + .collect(); + indexed.dedup(); + let max_basic = effective.lookup(0, 0)[0]; + let max_extended = effective.lookup(0x8000_0000, 0)[0]; + let near = indexed + .iter() + .flat_map(|&function| (0..64).map(move |index| (function, index))) + .chain((max_basic + 1..=max_basic + 4).map(|function| (function, 0))) + .chain((max_extended + 1..=max_extended + 4).map(|function| (function, 0))) + .collect(); + let far = indexed + .iter() + .flat_map(|&function| (64..256).map(move |index| (function, index))) + .chain((max_basic + 5..=0xff).map(|function| (function, 0))) + .chain((max_extended + 5..=0x8000_00ff).map(|function| (function, 0))) + .chain((0x4000_0000..=0x4000_01ff).map(|function| (function, 0))) + .chain( + [ + 0x100, + 0x1000, + 0x2000_0000, + 0x3fff_ffff, + 0x8000_0100, + 0x8fff_ffff, + 0xc000_0000, + 0xc000_0001, + 0xffff_ffff, + ] + .map(|function| (function, 0)), + ) + .filter(|&(function, index)| !is_listed(effective, function, index)) + .collect(); + (near, far) + } + /// Builds a time ABI partition, binds the first `created` VPs, and runs /// every backend primitive against the hypervisor. #[async_test] @@ -1878,14 +1935,14 @@ mod hw { /// 63 of every indexed leaf of the effective CPUID and the four leaves /// past the maximum basic and extended leaves. Listed results must match /// under their masks. A leaf or subleaf that the table does not list must - /// read zero, which the zero results provide on MSHV, except past the - /// extended topology leaves' terminators: those are reserved, and the - /// sweep only reports them. One rep hypercall must read every probe as - /// the single reads do, and the test times both ways. Farther out, where - /// no guest enumeration reaches (subleaves 64 to 255, leaves up to 0xff - /// and 0x800000ff, the hypervisor range up to 0x400001ff, and a few - /// distant leaves), every unlisted entry must read zero as well: no entry - /// outside the effective CPUID reads host data. + /// read zero, which is the hypervisor's own guest view there (the backend + /// registers no result for it), except past the extended topology leaves' + /// terminators: those are reserved, and the sweep only reports them. One + /// rep hypercall must read every probe as the single reads do, and the + /// test times both ways. Farther out, where no guest enumeration reaches + /// (subleaves 64 to 255, leaves up to 0xff and 0x800000ff, the hypervisor + /// range up to 0x400001ff, and a few distant leaves), every unlisted entry + /// must read zero as well (see `sweep_probes`). #[async_test] #[ignore = "requires /dev/mshv"] async fn unlisted_cpuid_entries_read_zero(driver: DefaultDriver) { @@ -1948,12 +2005,7 @@ mod hw { indexed.dedup(); let max_basic = effective.lookup(0, 0)[0]; let max_extended = effective.lookup(0x8000_0000, 0)[0]; - let probes: Vec<(u32, u32)> = indexed - .iter() - .flat_map(|&function| (0..64).map(move |index| (function, index))) - .chain((max_basic + 1..=max_basic + 4).map(|function| (function, 0))) - .chain((max_extended + 1..=max_extended + 4).map(|function| (function, 0))) - .collect(); + let (probes, far) = sweep_probes(&effective); let (mut count, mut listed_failures, mut unlisted, mut reserved) = (0, Vec::new(), Vec::new(), Vec::new()); let mut single = Vec::with_capacity(probes.len()); @@ -2028,35 +2080,7 @@ mod hw { ); // Farther out, every entry the effective CPUID does not list must read - // zero too: subleaves 64 to 255 of every indexed leaf, the leaves past - // the probes above up to 0xff and 0x800000ff, the rest of the - // hypervisor range up to 0x400001ff, and a few distant leaves. - let far: Vec<(u32, u32)> = indexed - .iter() - .flat_map(|&function| (64..256).map(move |index| (function, index))) - .chain((max_basic + 5..=0xff).map(|function| (function, 0))) - .chain((max_extended + 5..=0x8000_00ff).map(|function| (function, 0))) - .chain((0x4000_0000..=0x4000_01ff).map(|function| (function, 0))) - .chain( - [ - 0x100, - 0x1000, - 0x2000_0000, - 0x3fff_ffff, - 0x8000_0100, - 0x8fff_ffff, - 0xc000_0000, - 0xc000_0001, - 0xffff_ffff, - ] - .map(|function| (function, 0)), - ) - .filter(|&(function, index)| { - !effective.results().any(|result| { - result.function == function && result.index.is_none_or(|i| i == index) - }) - }) - .collect(); + // zero too (see `sweep_probes`). let values = vp_cpuid_many(bsp, 0, &far).unwrap(); let (mut far_unlisted, mut far_reserved) = (Vec::new(), Vec::new()); for (&(function, index), actual) in far.iter().zip(&values) { From c4955982cb19df8ded03f2a0df9355fcad911d5b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 22:29:26 -0700 Subject: [PATCH 251/304] virt_mshv: enumerate the host CPUID without the hypervisor range The time ABI reads the host's CPUID once per partition, for the supported CPU surface and the E_CPU_UNLISTED candidates. Neither uses the hypervisor range: the surface drops it, and unlisted_cpuid_candidates exempts it. Each CPUID instruction in the root partition exits to the hypervisor, so read the table with cpu_profile::cpuid::enumerate_basic_and_extended (56213a814), which skips the host's hypervisor leaves and returns the same entries otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/profile_features.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index 6183e3a030d..60dca6fcafa 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -115,9 +115,13 @@ fn host_cpuid(_leaf: u32, _subleaf: u32) -> [u32; 4] { [0; 4] } -/// Returns the host's CPUID table, as the CPUID instruction enumerates it. +/// Returns the host's CPUID table, as the CPUID instruction enumerates it, +/// without the hypervisor range: that describes the host's hypervisor rather +/// than the processor, so neither the supported CPU surface nor the unlisted +/// candidates use it, and every CPUID in the root partition exits to the +/// hypervisor. pub(crate) fn host_cpuid_table() -> Vec { - cpu_profile::cpuid::enumerate(|leaf, subleaf| { + cpu_profile::cpuid::enumerate_basic_and_extended(|leaf, subleaf| { Ok::<_, std::convert::Infallible>(host_cpuid(leaf, subleaf)) }) .unwrap_or_else(|never| match never {}) From c26852c89edcf40631bbbfd84ddd3dd0302564e8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 22:58:11 -0700 Subject: [PATCH 252/304] virt_mshv: test that a non-zero reserved CPUID entry fails E_CPU_UNLISTED Verification step 6 relies on the backend's effective CPUID report holding VP 0's own view at the reserved entries that the host's CPUID enumerates (unlisted_cpuid_candidates): a report that synthesized zero there would make core's E_CPU_UNLISTED check vacuous. At the end of the CPUID sweep hardware test, plant a non-zero value at the host's first candidate as a registered result, then check that the backend's report carries it and that cpu_profile::check_unlisted_cpuid, which core runs on the report, fails with E_CPU_UNLISTED. The sweep also times the bulk read with the candidates added, which is their cost per boot and restore. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 68 +++++++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 25b6d305344..e48c3d9cca4 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -1942,7 +1942,10 @@ mod hw { /// test times both ways. Farther out, where no guest enumeration reaches /// (subleaves 64 to 255, leaves up to 0xff and 0x800000ff, the hypervisor /// range up to 0x400001ff, and a few distant leaves), every unlisted entry - /// must read zero as well (see `sweep_probes`). + /// must read zero as well (see `sweep_probes`). Last, a non-zero value + /// planted at a reserved entry that the host enumerates must reach the + /// backend's effective CPUID report and fail core's `E_CPU_UNLISTED` + /// check. #[async_test] #[ignore = "requires /dev/mshv"] async fn unlisted_cpuid_entries_read_zero(driver: DefaultDriver) { @@ -2053,12 +2056,27 @@ mod hw { "bulk and single CPUID reads differ" ); // Time both ways over the entries that the effective CPUID report - // reads, as `time_abi_effective_cpuid` does. + // reads, as `time_abi_effective_cpuid` does, and the bulk read with + // the host's unlisted candidates as well. let report: Vec<(u32, u32)> = effective .results() .map(|result| (result.function, result.index.unwrap_or(0))) .collect(); - let (mut single_us, mut bulk_us) = (Vec::new(), Vec::new()); + let profile = cpu_profile::pinned(&host_profile_id()).unwrap(); + let candidates = cpu_profile::unlisted_cpuid_candidates( + profile, + &super::super::profile_features::host_cpuid_table(), + ); + let with_candidates: Vec<(u32, u32)> = report + .iter() + .copied() + .chain( + candidates + .iter() + .map(|&(function, index)| (function, index.unwrap_or(0))), + ) + .collect(); + let (mut single_us, mut bulk_us, mut candidates_us) = (Vec::new(), Vec::new(), Vec::new()); for _ in 0..11 { let started = std::time::Instant::now(); for &(function, index) in &report { @@ -2068,15 +2086,21 @@ mod hw { let started = std::time::Instant::now(); vp_cpuid_many(bsp, 0, &report).unwrap(); bulk_us.push(started.elapsed().as_micros() as u64); + let started = std::time::Instant::now(); + vp_cpuid_many(bsp, 0, &with_candidates).unwrap(); + candidates_us.push(started.elapsed().as_micros() as u64); } single_us.sort_unstable(); bulk_us.sort_unstable(); + candidates_us.sort_unstable(); println!( - "bulk CPUID read: {} probes match single reads; {} entries in {} us one per call, {} us in one call (p50 of 11)", + "bulk CPUID read: {} probes match single reads; {} entries in {} us one per call, {} us in one call, {} us with the {} unlisted candidates (p50 of 11)", probes.len(), report.len(), single_us[5], - bulk_us[5] + bulk_us[5], + candidates_us[5], + candidates.len() ); // Farther out, every entry the effective CPUID does not list must read @@ -2104,6 +2128,40 @@ mod hw { println!(" {line}"); } assert!(far_unlisted.is_empty(), "{far_unlisted:#?}"); + + // The negative path of verification step 6. A non-zero value at a + // reserved entry that the host enumerates, planted here as a + // registered result, must reach the backend's effective CPUID report, + // which core then rejects with E_CPU_UNLISTED: the report reads the + // hypervisor's view at the candidates and never synthesizes zero. + // Nothing has read the report before, so its first read sees the plant. + let &(function, index) = candidates + .first() + .expect("the host enumerates reserved entries"); + let planted = CpuidLeaf::new(function, [0x5a5a_5a5a, 0, 0, 0]); + let planted = match index { + Some(index) => planted.indexed(index), + None => planted, + }; + super::super::register_cpuid_result(&partition.inner.vmfd, &planted).unwrap(); + let backend_report = partition.inner.time_abi_effective_cpuid().unwrap(); + assert!( + backend_report.iter().any(|leaf| leaf.function == function + && leaf.index == index + && leaf.result == planted.result), + "the report lacks the planted {function:#x} {index:?}" + ); + let entries: Vec<_> = backend_report + .iter() + .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect(); + let error = cpu_profile::check_unlisted_cpuid(profile, &entries).unwrap_err(); + assert_eq!( + error.code, + cpu_profile::ProfileErrorCode::CpuUnlisted, + "{error}" + ); + println!("planted {function:#x}.{index:?} = {planted:x?}: {error}"); } /// Every VP of a time ABI partition reads its own x2APIC ID in `EDX` of From cf90a88c60b02357a17a66820c64588d12440ef7 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 22:54:28 -0700 Subject: [PATCH 253/304] openvmm_core: advance the LAPICs and VM time concurrently on restore Restore steps 14 and 15 of the time ABI touch disjoint state. The LAPIC advance sets each VP's LAPIC through its VP thread, and under the time ABI every backend uses a hypervisor LAPIC that never reads VM time: KVM's in-kernel LAPIC, MSHV's, and WHP's offloaded APIC (the user-mode APIC is rejected). VM time and the RTC advance through the state units: the VM time keeper, its secondary keepers, then the chipset. Running the two in sequence cost both round-trip chains, about 0.47 ms of restore.time_abi_clock at 1 vCPU on MSHV, of which VM time is about 0.26 ms. Join the two instead of awaiting them in turn. Both run to completion, so a failure never cancels a state unit operation midway, and a LAPIC failure is still reported first. The restore clock log keeps its fields; lapic_us and vm_time_us now overlap, each measured from the end of the TSC set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/worker/dispatch/time_abi.rs | 47 +++++++++++++------ 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 1dc78e968a4..d2d983b23da 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -14,6 +14,7 @@ use anyhow::Context as _; use chipset_resources::microvm_time::RestoreTimeRecord; use cpu_profile::CpuProfile; use cpu_profile::EffectiveCpuid; +use futures_concurrency::future::Join; use inspect::Inspect; use inspect::InspectMut; use openvmm_defs::time_abi::EffectiveCpuidEntry; @@ -539,10 +540,11 @@ impl LoadedVm { /// restored VP runs. /// /// It performs the synchronized TSC set at the downtime selected at the - /// restore anchor, advances and sets every LAPIC, advances VM time and - /// the RTC, and seals the time fields of the restore packet. The LAPIC - /// advance rejects a periodic or TSC-deadline timer, so restore needs no - /// separate timer check, and the PIT checks itself when restored. + /// restore anchor, then concurrently advances and sets every LAPIC and + /// advances VM time and the RTC, and seals the time fields of the restore + /// packet. The LAPIC advance rejects a periodic or TSC-deadline timer, so + /// restore needs no separate timer check, and the PIT checks itself when + /// restored. pub(super) async fn time_abi_restore(&mut self, input: RestoreTimeInput) -> anyhow::Result<()> { let state = self .inner @@ -593,22 +595,37 @@ impl LoadedVm { "time ABI synchronized TSC set" ); - self.inner - .partition_unit - .advance_lapic_timers(downtime.nanos, apic_hz) - .await?; - let lapic_advanced = started.elapsed(); - self.state_units - .advance_time(Duration::from_nanos(downtime.nanos)) - .await - .context("failed to advance restored VM time")?; + // Steps 14 and 15 touch disjoint state, since no backend's LAPIC + // reads VM time under the time ABI, so they run concurrently. Both + // run to completion, so a failure never cancels a state unit + // operation midway. + let downtime_ns = downtime.nanos; + let partition_unit = &mut self.inner.partition_unit; + let state_units = &mut self.state_units; + let lapic_advance = async move { + let result = partition_unit + .advance_lapic_timers(downtime_ns, apic_hz) + .await; + (result, started.elapsed()) + }; + let vm_time_advance = async move { + let result = state_units + .advance_time(Duration::from_nanos(downtime_ns)) + .await; + (result, started.elapsed()) + }; + let ((lapic_result, lapic_advanced), (vm_time_result, vm_time_advanced)) = + (lapic_advance, vm_time_advance).join().await; + lapic_result?; + vm_time_result.context("failed to advance restored VM time")?; let finished = started.elapsed(); // Per-phase durations attribute the restore.time_abi_clock profile - // phase. + // phase. The LAPIC and VM time phases overlap; each is measured from + // the end of the TSC set. tracing::info!( tsc_set_us = tsc_set.as_micros() as u64, lapic_us = (lapic_advanced - tsc_set).as_micros() as u64, - vm_time_us = (finished - lapic_advanced).as_micros() as u64, + vm_time_us = (vm_time_advanced - tsc_set).as_micros() as u64, total_us = finished.as_micros() as u64, "time ABI restore clock" ); From 91b5decb7607db6d8e41fadfe6be48511ec0a330 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 23:46:38 -0700 Subject: [PATCH 254/304] virt_mshv: read the preflight's identity sentinels in one hypercall The time ABI preflight checks that VP 0 reads zero at six sentinel leaves of the identity range, one HvCallGetVpCpuidValues each: about 50 us on bare metal and 80 us nested at every boot and restore. Read them in one bulk call, as the effective CPUID report already does since 985ad1534. Both now go through vp0_cpuid_entries, which falls back to one read per entry, with a warning, if the bulk call fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 73 ++++++++++++----------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index e48c3d9cca4..4f935c193a2 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -748,30 +748,7 @@ impl MshvPartitionInner { .iter() .map(|&(function, index)| (function, index.unwrap_or(0))) .collect(); - let (values, bulk) = match vp_cpuid_many(bsp, VpIndex::BSP.index(), &entries) { - Ok(values) => (values, true), - Err(error) => { - tracing::warn!( - error = &error as &dyn std::error::Error, - "MSHV bulk CPUID read failed, reading one entry per call" - ); - let values = entries - .iter() - .map(|&(function, index)| { - vp_cpuid(bsp, function, index).map_err(|error| { - TimeAbiError::new( - TimeAbiCode::CpuSurface, - format!( - "cannot read CPUID {function:#x}/{index:#x} of VP 0: {}", - error_chain(&error) - ), - ) - }) - }) - .collect::, _>>()?; - (values, false) - } - }; + let (values, bulk) = vp0_cpuid_entries(bsp, &entries, TimeAbiCode::CpuSurface)?; let mut read = reads .iter() .zip(values) @@ -821,22 +798,50 @@ impl MshvPartitionInner { let actual = effective.result(leaf.function, leaf.index.unwrap_or(0), &[0; 4]); check_leaf(leaf, actual)?; } - for function in PREFLIGHT_SENTINEL_LEAVES { - let actual = vp_cpuid(bsp, function, 0).map_err(|error| { - TimeAbiError::new( - TimeAbiCode::IdentityRouting, - format!( - "cannot read CPUID {function:#x} of VP 0: {}", - error_chain(&error) - ), - ) - })?; + let sentinels = PREFLIGHT_SENTINEL_LEAVES.map(|function| (function, 0)); + let (values, _) = vp0_cpuid_entries(bsp, &sentinels, TimeAbiCode::IdentityRouting)?; + for (&(function, _), actual) in sentinels.iter().zip(values) { check_leaf(&CpuidLeaf::new(function, [0; 4]), actual)?; } Ok(()) } } +/// Reads CPUID `entries` of VP 0 in one bulk call ([`vp_cpuid_many`]), +/// falling back to one read per entry, with a warning, if the bulk call +/// fails. Returns the values and whether the bulk call served them; a read +/// that fails fails with `code`. +fn vp0_cpuid_entries( + bsp: &VcpuFd, + entries: &[(u32, u32)], + code: TimeAbiCode, +) -> Result<(Vec<[u32; 4]>, bool), TimeAbiError> { + match vp_cpuid_many(bsp, VpIndex::BSP.index(), entries) { + Ok(values) => Ok((values, true)), + Err(error) => { + tracing::warn!( + error = &error as &dyn std::error::Error, + "MSHV bulk CPUID read failed, reading one entry per call" + ); + let values = entries + .iter() + .map(|&(function, index)| { + vp_cpuid(bsp, function, index).map_err(|error| { + TimeAbiError::new( + code, + format!( + "cannot read CPUID {function:#x}/{index:#x} of VP 0: {}", + error_chain(&error) + ), + ) + }) + }) + .collect::, _>>()?; + Ok((values, false)) + } + } +} + /// Fails with `E_IDENTITY_ROUTING` unless VP 0's `actual` result agrees with /// `leaf` under its mask. fn check_leaf(leaf: &CpuidLeaf, actual: [u32; 4]) -> Result<(), TimeAbiError> { From 12b4f6083f065d3fdec28f4afe806fc71e5df09c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 14:05:28 -0700 Subject: [PATCH 255/304] openvmm: make the NVX time ABI the only microVM time path Every microVM now uses the NVX time ABI v1, so the hidden --x-time-abi-v1 development switch and the legacy microVM clock paths are removed. - entry: --cpu-profile, --x-time-abi-test-hook, and --x-time-abi-verify require the microVM machine profile. Every microVM cold boot gets the time ABI parameters, the portb time sample, and the PIT strict mode. Every microVM restore requires manifest version 6 and exposes restore packet version 4. The legacy downtime, the version 1 to 3 packet encoder, and the restore-entropy warning are removed; --restore-entropy is accepted and has no effect. - ttrpc: microVMs created through the management endpoint use the time ABI, with the host's CPU profile at cold boot and the snapshot's on restore. Restore packet version 4 replaces the entropy packets, and the ttrpc test guest parses it. - worker: the legacy capture (rates, wall clock, CPU contract), the legacy restore clock (rate checks, downtime advance, CPU contract check), the versioned CPU contract of microVM partitions, and the cold-boot tsc_early_khz and lapic_timer_hz propagation are removed. Cold boot only rejects the clock tokens. - defs: the legacy clock fields of the worker parameters and the snapshot response are removed, and the microVM profile requires the time ABI parameters. - petri: microVMs use the time ABI. - Guide: document the time ABI, restore packet version 4, the time sample, --cpu-profile, and --x-time-abi-verify. The backends keep their legacy code until their legacy-deletion branches land. The old trait methods, the CPU compatibility contract, kvmclock, the device's version 1 to 3 packets, and the manifest version 2 to 5 code go after them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/dev_guide/snapshot_format.md | 16 +- Guide/src/reference/openvmm/management/cli.md | 48 +- Guide/src/user_guide/openvmm/snapshots.md | 152 +++-- openvmm/openvmm_core/src/worker/dispatch.rs | 13 +- .../openvmm_core/src/worker/dispatch/clock.rs | 175 ----- .../src/worker/dispatch/microvm.rs | 186 ++---- .../src/worker/dispatch/restore.rs | 130 ++-- .../src/worker/dispatch/time_abi.rs | 7 +- .../src/worker/vm_loaders/microvm.rs | 598 ------------------ openvmm/openvmm_defs/src/microvm.rs | 8 +- openvmm/openvmm_defs/src/rpc.rs | 14 +- openvmm/openvmm_defs/src/worker.rs | 12 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 24 +- openvmm/openvmm_entry/src/lib.rs | 5 - openvmm/openvmm_entry/src/microvm/config.rs | 66 +- openvmm/openvmm_entry/src/microvm/launch.rs | 5 - openvmm/openvmm_entry/src/microvm/mod.rs | 37 +- openvmm/openvmm_entry/src/microvm/restore.rs | 392 +++--------- .../openvmm_entry/src/snapshot_restore/mod.rs | 36 +- .../src/snapshot_restore/prepare.rs | 31 +- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 172 +++-- openvmm/openvmm_entry/src/ttrpc/mod.rs | 18 +- .../src/vm_controller/microvm.rs | 24 +- openvmm/openvmm_helpers/src/snapshot/time.rs | 51 +- petri/src/vm/openvmm/construct.rs | 2 +- petri/src/vm/openvmm/microvm.rs | 31 +- petri/src/worker.rs | 5 - .../vmm_tests/tests/tests/ttrpc/microvm.rs | 15 +- 28 files changed, 592 insertions(+), 1681 deletions(-) delete mode 100644 openvmm/openvmm_core/src/worker/dispatch/clock.rs diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 54c22bb74dd..343935381b3 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -23,18 +23,22 @@ The manifest is a protobuf message defined as in `openvmm/openvmm_helpers/src/snapshot.rs`, encoded using the `mesh` crate's protobuf encoding. -New snapshots use manifest version 5. The legacy `state_sha256` and +MicroVM snapshots use manifest version 6, and host-driven standard-machine +snapshots use version 5. Version 6 adds the NVX time ABI records to the +microVM machine contract (the time contract and the CPU profile record) and +requires the clock fields it retires (the capture wall clock, the TSC and LAPIC +frequencies, the TSC tolerance and clock policy, and the CPU contract) to be +empty. MicroVM restore accepts only version 6. The legacy `state_sha256` and `memory_sha256` protobuf tags remain reserved so version 2 manifests can be -decoded; versions 3 through 5 require both fields to be absent. Restore accepts -versions 2 through 4 for compatibility. Tiered microVM snapshots require version -5, which records capture tier, clone/resume policy, and consumed configuration -sections. +decoded; versions 3 through 6 require both fields to be absent. Standard-machine +restore accepts versions 2 through 5 for compatibility. Tiered microVM snapshots +record capture tier, clone/resume policy, and consumed configuration sections. The default format is a local machine-state contract, not an authenticated container. All versions receive the same regular-file, no-follow/no-reparse, bounded decoding, exact-length, inventory, and machine-contract validation, but the on-disk format does not authenticate same-length payload changes. -Versions 4 and 5 record the SHA-256 and exact length of `scratch.img`, because guest +Versions 4 through 6 record the SHA-256 and exact length of `scratch.img`, because guest RAM and a mounted writable filesystem must be restored as one exact pair. Export or transport layers must provide broader integrity and authentication outside this format. diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 88467bec3f3..58ee4f12158 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -289,8 +289,8 @@ describes the source definitions. does not upload it. * `--restore-snapshot `: Restore a microVM from a committed snapshot. The manifest supplies the authoritative RAM size, topology, ABI, - fixed device inventory, effective kernel command line, source backend, CPU - contract, and TSC frequency. Kernel, initrd, command-line, ordinary + fixed device inventory, effective kernel command line, source backend, time + contract, and CPU profile. Kernel, initrd, command-line, ordinary `--memory`, processor, device, and topology overrides are not accepted; expansion-capable snapshots use only `--restore-memory`. Repeat the snapshot's exact `--processors` count; a mismatch is rejected before any VP @@ -343,39 +343,43 @@ describes the source definitions. and stops the VM. The peer must accept and read the event while startup is in progress; Windows flush completion waits for the named-pipe peer to consume the complete frame. -* `--restore-entropy`: Make a fresh `OPENVMM_ENTROPY_V1` packet available on - the private portb restore channel. The guest must consume the packet and - explicitly reseed its RNG. Restoring cloned RNG state without this option is - unsafe for cryptographic workloads and emits a warning. - Processor activation uses `OPENVMM_ENTROPY_V2`. Memory expansion uses the - backward-compatible `OPENVMM_ENTROPY_V3` packet. Its exact format is the - 19-byte `OPENVMM_ENTROPY_V3\0` header, a one-byte online-VP target (zero - means none), a one-byte expansion-range count, that many little-endian - `(u64 GPA start, u64 byte length)` pairs, and 64 bytes of fresh entropy. - Explicitly selecting the snapshot base size with `--restore-memory` still - emits V3 with an expansion-range count of zero; omitting the option preserves - V1/V2 behavior. Private portb status bit 3 reports a V3 memory target, while - bit 4 additionally reports that the packet contains one or more expansion - ranges, allowing a zero-range target to avoid post-restore repair. +* `--restore-entropy`: Accepted for compatibility; it has no effect. Every + microVM restore makes restore packet version 4 available on the private + portb restore channel. The packet carries 64 bytes of fresh entropy, the + online-VP target, the memory-expansion ranges, the downtime, and the TSC + rate deviation; the guest must consume it and explicitly reseed its RNG. + [Snapshots](../../../user_guide/openvmm/snapshots.md) documents its format, + the portb status bits, and the time sample. Every microVM portb device also reports generation-ID support in status bit 5. Writing `0xa6` to the status port and reading 16 bytes from the data port returns an opaque ID that is stable for that VM process and may be selected repeatedly. OpenVMM creates it before vCPU entry and does not serialize it. - On restore, it is the first 16 bytes of the fresh entropy packet, allowing - the guest repair path to update clone identity without additional port I/O. + On restore, it is the first 16 bytes of the restore packet's fresh entropy, + allowing the guest repair path to update clone identity without additional + port I/O. * `--restore-processors `: For an opt-in microVM snapshot, bring the contiguous VP prefix `0..COUNT-1` online before restore readiness. The snapshot's manifest VP count remains immutable capacity and must still match `--processors`. The target must be 1, 2, 4, or 8 and satisfy - `boot-online <= target <= capacity`. This option implies a version-2 private - restore packet and the post-restore gate. Snapshots without activation - metadata reject it. An + `boot-online <= target <= capacity`. This option sets the restore packet's + online-VP target and implies the post-restore gate. Snapshots without + activation metadata reject it. An explicit MSHV restore instantiates and binds only the requested prefix while validating the full saved VP inventory; that reduced-prefix process cannot be saved again. MSHV restores without this option, and KVM and WHP restores, instantiate the full VP capacity. * `--restore-gate-timeout-ms `: Bound microVM guest repair and gate acknowledgement after restore. The default is 60000 milliseconds. +* `--cpu-profile `: Select the microVM's pinned CPU profile, or `auto` + (the default) for the profile of the host's CPU generation. A host without a + profile, or a profile of another generation, fails before the partition is + created. A restore uses the snapshot's profile and rejects any other. See + [Time and CPU compatibility](../../../user_guide/openvmm/snapshots.md#time-and-cpu-compatibility). +* `--x-time-abi-verify`: Build the microVM partition and run the time ABI + checks (CPU profile support, effective CPUID, rates) without starting the + guest, print one `NVX-TIME-ABI-VERIFY:` line, and exit with status 0 or 1. + Host qualification uses it; it cannot be combined with snapshot capture or + restore. * `--snapshot-tier `: Required for snapshot capture with sandbox blocks. Choose `platform`, `workload-start`, or `instance-checkpoint`. The first two are reusable clone policies; instance checkpoints use single-use resume policy. @@ -388,7 +392,7 @@ private writable copy-on-write mapping and paired scratch is privately copied, so clone-policy snapshots can be restored repeatedly without modifying artifacts. Instance-checkpoint snapshots permit one restore attempt. -Versions 3 through 5 do not embed or validate checksums for `state.bin` or `memory.bin`; +Versions 3 through 6 do not embed or validate checksums for `state.bin` or `memory.bin`; legacy version 2 checksum fields are accepted without re-hashing their payloads. This format does not detect same-length payload changes, authenticate, or encrypt a snapshot. Treat all three artifacts as sensitive diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 47d3dfac6df..4a77195e711 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -23,6 +23,14 @@ These are stored as three required files and one optional paired file: | `memory.bin` | Memory backing file | | `scratch.img` | Paired microVM scratch, when declared | +MicroVM manifests also record the NVX time ABI contract: the declared TSC and +LAPIC rates, the capture anchor (VP 0's TSC paired with host UTC and host +monotonic time), the capture host's identity, and the CPU profile with the +effective CPUID that the guest observes. Every microVM snapshot is manifest +version 6, and microVM restore accepts only version 6; recapture snapshots +taken by earlier versions. See +[Time and CPU compatibility](#time-and-cpu-compatibility). + ## Prerequisites Host-driven snapshots require **file-backed guest memory**. Pass `file=` @@ -164,9 +172,38 @@ The no-ACPI microVM portb contract exposes a process-local 16-byte generation ID. Status bit 5 advertises the feature; writing `0xa6` to status port `0xea` and reading 16 bytes from data port `0xe9` returns the ID. It is repeatable within one process and is never restored from snapshot state. A restore derives -the ID from the first 16 bytes of the fresh entropy packet, so the guest can -reject an unchanged clone identity, reseed Linux, refresh runtime identifiers, -and acknowledge the input gate without another PMIO transfer. +the ID from the first 16 bytes of the restore packet's fresh entropy, so the +guest can reject an unchanged clone identity, reseed Linux, refresh runtime +identifiers, and acknowledge the input gate without another PMIO transfer. + +Every microVM restore exposes restore packet version 4. Status bit 1 +advertises it, and writing `0xa5` to the status port selects it for reading +from the data port. Its 32-byte little-endian header holds: + +- the magic `OVR` and version 4; +- flags: bit 0, the downtime came from host UTC; bit 1, a memory target; + bit 2, the guest must acknowledge the restore through port `0x605`; bit 3, + a test hook is active; +- the online-VP target (0 for none), the memory-range count, and a reserved + zero byte; +- the generation counter (`u32`), which is 0 at cold boot and one more than + the snapshot's at each restore; +- the TSC rate deviation (`i32`, ppm scaled by 2^16); +- the downtime in nanoseconds (`u64`); and +- host UTC in nanoseconds since the Unix epoch (`u64`), latched when the guest + first selects the packet. + +The `(u64 GPA start, u64 length)` memory ranges and 64 bytes of fresh entropy +follow the header. Status bits 2, 3, and 4 report an online-VP target, a +memory target, and one or more memory ranges, so a zero-range target can skip +post-restore repair. + +Status bit 6 advertises the time sample. Writing `0xa7` to the status port +latches a 16-byte sample: version 1, flags (bit 3: a test hook is active), two +reserved zero bytes, the generation counter (`u32`), and host UTC in +nanoseconds (`u64`). The guest reads it from window port `0xeb`, so samples +never interleave with console input on the data port. The selected packet and +the window can be read one, two, or four bytes at a time. A microVM template may opt into restore-time vCPU activation by booting with an explicit canonical `maxcpus=1`, `2`, `4`, or `8` value below or equal to its @@ -178,41 +215,20 @@ onlines and verifies that prefix before acknowledging the restore gate. On MSHV, an explicit target instantiates and binds only that VP prefix; saving such a reduced-prefix runtime is unsupported. MSHV restores without an explicit target, and all KVM and WHP restores, instantiate the full VP capacity. -Versioned MSHV CPU contracts do not expose `IA32_TSC_ADJUST` because snapshot -state cannot preserve that register independently of `IA32_TSC`; this prevents -host-side TSC correction from appearing as per-VP firmware adjustment skew. -KVM advances snapshot downtime through each VP's TSC offset rather than an -`IA32_TSC` write. KVM's synchronization heuristic can discard sub-second -counter writes, leaving `kvm-clock` ahead of the TSC and triggering Linux's -clocksource watchdog. Relative offset updates preserve per-VP synchronization -without adding host read/write latency. KVM snapshot resume requires -`KVM_VCPU_TSC_CTRL` / `KVM_VCPU_TSC_OFFSET` support; unavailable offset access -fails restore explicitly instead of falling back to imprecise counter writes. -All backends read back the adjusted TSC before resume and reject a discarded -or incomplete downtime adjustment. -When more than one VP is instantiated, MSHV and WHP freeze partition time -after restoring counters and advancing snapshot time, and align every -instantiated VP's TSC to the BSP's advanced counter before any VP runs. The -first VP run thaws time. Setting counters while time is running would -introduce inter-VP skew from host scheduling delays, which can make Linux -reject the TSC clocksource during CPU activation. -For restored WHP partitions with multiple VPs, RDTSC, RDTSCP, and -`IA32_TSC` reads additionally use one partition-reference-time epoch anchored -to the advanced BSP counter. This clock has 100-nanosecond resolution and -keeps unmodified guest counters synchronized after time resumes; equal -frozen WHP register values alone do not guarantee equal live counters. -RDTSCP retains the guest's TSC_AUX value, and timestamp access restrictions -remain enforced. Explicit guest writes to `IA32_TSC`, or changes to -`IA32_TSC_ADJUST`, retain native WHP counter and deadline-timer semantics on -the affected VP. Reset restores the original intercept configuration. -Ordinary boots, single-VP partitions, and configurations exposing nested -virtualization to the guest retain their native counter path. -Snapshots without the explicit capture-time `maxcpus` opt-in, including legacy -snapshots, reject a restore target. This is not a post-readiness hotplug API and -cannot add VPs absent from the saved topology. +A microVM restore sets the TSC of every instantiated VP in one synchronized +operation before any VP runs: each TSC continues from the capture anchor, +advanced by the host downtime at the declared rate. KVM sets one common TSC +offset; MSHV and WHP freeze partition time, write the same TSC to every VP, +and resume partition time. Every backend reads each VP's TSC back and rejects +a restore whose values differ (`E_TSC_SYNC_READBACK`), and one-shot LAPIC +timers advance by the same downtime. The guest TSC is never scaled, and +RDTSC is never trapped or emulated. +Snapshots without the explicit capture-time `maxcpus` opt-in reject a restore +target. This is not a post-readiness hotplug API and cannot add VPs absent +from the saved topology. ```admonish warning -Versions 3 through 5 do not contain or validate embedded checksums for +Versions 3 through 6 do not contain or validate embedded checksums for `state.bin` or `memory.bin`. Restore still requires regular files, bounded manifest and state decoding, exact artifact lengths, and a compatible machine contract, but same-length payload changes are not detected. Paired @@ -228,6 +244,49 @@ the snapshot manifest. If they do not match, OpenVMM will report a validation error and refuse to start. ``` +## Time and CPU compatibility + +Every microVM uses the NVX time ABI v1. The guest sees a minimal Hyper-V +identity: CPUID leaves `0x40000000` through `0x40000005`, explicit zero leaves +`0x40000006` through `0x4000000f` and `0x40000080` through `0x40000082`, and +identity MSRs that return the declared TSC rate (`0x40000022`) and LAPIC rate +(`0x40000023`). Linux takes both rates from these MSRs, so a cold-boot kernel +command line must not set `tsc_early_khz=` or `lapic_timer_hz=` +(`E_CMDLINE_CLOCK_TOKEN`). The TSC is invariant, and the LAPIC timer runs in +one-shot mode at a fixed rate: 1 GHz on KVM and 200 MHz on MSHV and WHP. +TSC-deadline mode, `IA32_TSC_ADJUST`, and kvmclock are not exposed. + +The guest CPU is a pinned CPU profile: one per CPU generation, the same on +every backend. `--cpu-profile ` selects one; the default, `auto`, selects +the profile of the host's generation, and a host without one fails +(`E_PROFILE_HOST_UNKNOWN`). Every boot checks that the backend supports the +profile (`E_PROFILE_UNSUPPORTED`) and that VP 0 observes its effective CPUID +(`E_CPU_SURFACE`). A snapshot records the profile and the effective CPUID. +Restore requires the same profile, pinned in this OpenVMM with the same +digest, a host of the profile's generation, and an identical effective CPUID. + +Restore also requires: + +- the backend that captured the snapshot; +- a native TSC rate within 250 ppm of the snapshot's + (`E_TSC_RATE_TOLERANCE`), and the identical LAPIC rate + (`E_LAPIC_RATE_MISMATCH`); and +- a downtime between 0 and 30 days (`E_DOWNTIME_NEGATIVE`, + `E_DOWNTIME_EXCESSIVE`). + +The declared rates never change across a restore; the restore packet reports +the rate deviation so the guest can compensate. The downtime is host monotonic +time when capture and restore run on the same host boot, and host UTC +otherwise. Capture and restore reject an armed periodic or TSC-deadline LAPIC +timer and a counting PIT channel 0 (`E_LAPIC_PERIODIC`, +`E_LAPIC_TSC_DEADLINE`, `E_PIT_ACTIVE`). + +A time ABI error leads its message with a stable code in brackets, for +example `[E_TSC_RATE_TOLERANCE]`, and OpenVMM exits with status 1. For host +qualification, `--x-time-abi-verify` builds the partition, runs these checks +without starting the guest, prints one `NVX-TIME-ABI-VERIFY:` line, and exits +with status 0 or 1. + ## Device configuration on restore For standard-machine snapshots, device flags must still be supplied on restore @@ -236,25 +295,6 @@ authoritative for RAM, topology, ABI, fixed devices, placement, features, interrupts, and the effective Linux direct command line. Restore-time guest-visible overrides are rejected. -The CPU contract records the effective CPUID/XSTATE surface and TSC frequency. -Restore recreates and validates that rate before any vCPU runs. KVM snapshots -likewise require the destination to reproduce their saved backend CPU and -clock contract. - -For a microVM boot configured with `--snapshot-destination`, OpenVMM adds -the backend TSC frequency to the effective kernel command line so the captured -guest clock matches this contract. Ordinary boots that cannot publish a -snapshot retain the guest's normal TSC discovery path. - -All cold microVM boots also receive `lapic_timer_hz=` when the backend -reports its LAPIC clock frequency. The NVX kernel uses this authoritative rate -instead of verifying a counting LAPIC against scheduling-sensitive emulated -PIT interrupts. TSC-deadline timers are unchanged. The parameter is canonicalized -before device discovery and `--`; conflicting, duplicate, malformed, or -out-of-range values are rejected. Platform snapshot validation checks a saved parameter -against its APIC frequency contract, while snapshots without the parameter -remain supported. - Every snapshot records a complete state-unit inventory. Each emulated device saves state under a unique name (for example `"pit"`, `"vmbus"`, or `"ide"`), and restore requires the saved and current inventories to match exactly. A @@ -389,3 +429,5 @@ immediately with a clear error if any active device does not support it. `--processors`. MicroVM restore reads them authoritatively from the manifest and rejects overrides. Persisted microVM ABI and boot-layout value 2 are supported. +- MicroVM restore requires the backend, CPU generation, and CPU profile of + the capture. Snapshots from manifest versions before 6 must be recaptured. diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 2cbd30cbd92..9df9caeab7c 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -2,7 +2,6 @@ // Licensed under the MIT License. mod amd_iommu_wiring; -mod clock; mod dump; mod ecam_config_access; mod intel_vtd_wiring; @@ -358,19 +357,18 @@ impl Worker for VmWorker { .shared_memory .map(|fd| restore_params.shared_memory_backing(fd)); - let mut vm = block_on(InitializedVm::new( + let vm = block_on(InitializedVm::new( VmTaskDriverSource::new(ThreadDriverBackend::new(device_driver)), hypervisor.0, manifest, shared_memory, ))?; - restore::validate_restore_cpu_contract(vm.partition.as_ref(), restore_params.cpu_contract)?; let saved_state = parameters .saved_state .map(|m| m.parse()) .transpose() .context("failed to decode saved state")?; - microvm_params.prepare_cold_boot(&mut vm, saved_state.is_some())?; + microvm_params.check_cold_boot(&vm, saved_state.is_some())?; let mut vm = block_with_io(|_| vm.load(saved_state, parameters.notify, restore_params.state))?; @@ -1282,11 +1280,6 @@ impl InitializedVm { } _ => (None, None), }; - #[cfg(guest_arch = "x86_64")] - let versioned_cpu_contract = - microvm::uses_versioned_cpu_contract(cfg.machine_profile) && time_abi_config.is_none(); - #[cfg(not(guest_arch = "x86_64"))] - let versioned_cpu_contract = microvm::uses_versioned_cpu_contract(cfg.machine_profile); let partition_prototype = openvmm_defs::profile::ProfileSpan::start(); let proto = hypervisor @@ -1300,7 +1293,7 @@ impl InitializedVm { device_assignment_msi_iova_range, user_mode_memory_faults, lazy_memory_registration, - versioned_cpu_contract, + versioned_cpu_contract: false, #[cfg(guest_arch = "x86_64")] time_abi: time_abi_config, }) diff --git a/openvmm/openvmm_core/src/worker/dispatch/clock.rs b/openvmm/openvmm_core/src/worker/dispatch/clock.rs deleted file mode 100644 index 1c64fc08aad..00000000000 --- a/openvmm/openvmm_core/src/worker/dispatch/clock.rs +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! Restore-time guest clock handling for the VM worker: the saved clock -//! contract, the destination clock checks made before the saved state is -//! restored, and the compensation for the host downtime between snapshot -//! capture and restore. - -use super::LoadedVm; -use anyhow::Context; -use std::time::Duration; - -/// Saved guest-clock contract of a restore: the host downtime to apply, the -/// saved guest TSC frequency, and the saved local APIC timer frequency, when -/// the snapshot recorded one. -pub(super) type RestoreTime = (Duration, u64, Option); - -pub(super) fn restore_time_contract( - downtime: Option, - tsc_frequency_hz: Option, - apic_frequency_hz: Option, -) -> anyhow::Result> { - match (downtime, tsc_frequency_hz, apic_frequency_hz) { - (Some(downtime), Some(tsc_frequency), apic_frequency) => { - Ok(Some((downtime, tsc_frequency, apic_frequency))) - } - (None, None, None) => Ok(None), - _ => anyhow::bail!("restore downtime and TSC frequency must be provided together"), - } -} - -#[cfg(guest_arch = "x86_64")] -pub(super) fn validate_snapshot_restore_partition_presence( - saved_state: &openvmm_defs::worker::SavedState, - restore_time: Option, -) -> anyhow::Result<()> { - anyhow::ensure!( - restore_time.is_none() - || saved_state - .units - .iter() - .any(|unit| unit.name == "partition"), - "time-adjusted snapshot restore requires partition state" - ); - Ok(()) -} - -impl LoadedVm { - /// Checks the destination clock frequencies against the saved contract - /// and requests the saved TSC frequency, before the saved state is - /// restored. - pub(super) fn validate_restore_clock( - &self, - restore_time: Option, - ) -> anyhow::Result<()> { - if let Some((_, saved_frequency, saved_apic_frequency)) = restore_time { - let destination_frequency = self - .inner - .partition - .tsc_frequency_hz()? - .context("destination backend does not expose a guest TSC frequency")?; - anyhow::ensure!( - destination_frequency == saved_frequency, - "destination TSC frequency {destination_frequency} Hz does not match saved frequency {saved_frequency} Hz" - ); - self.inner.partition.set_tsc_frequency_hz(saved_frequency)?; - let destination_apic_frequency = self - .inner - .partition - .apic_frequency_hz()? - .context("destination backend does not expose a local APIC frequency")?; - if let Some(saved_apic_frequency) = saved_apic_frequency { - anyhow::ensure!( - destination_apic_frequency == saved_apic_frequency, - "destination APIC frequency {destination_apic_frequency} Hz does not match saved frequency {saved_apic_frequency} Hz" - ); - } - } - Ok(()) - } - - /// Advances the restored VM's clocks by the snapshot downtime, after the - /// saved state is restored. - pub(super) async fn advance_restored_clock( - &mut self, - restore_time: Option, - ) -> anyhow::Result<()> { - if let Some((downtime, frequency, saved_apic_frequency)) = restore_time { - self.state_units - .advance_time(downtime) - .await - .context("failed to advance restored VM time")?; - #[cfg(guest_arch = "x86_64")] - { - let apic_frequency = - match saved_apic_frequency { - Some(frequency) => frequency, - None => self.inner.partition.apic_frequency_hz()?.context( - "destination backend does not expose a local APIC frequency", - )?, - }; - self.inner - .partition_unit - .advance_tsc(downtime, frequency, Some(apic_frequency)) - .await - .context("failed to advance restored vCPU TSC")?; - } - self.inner - .partition - .advance_snapshot_time(downtime) - .context("failed to advance backend snapshot clock")?; - } - Ok(()) - } -} - -#[cfg(all(test, guest_arch = "x86_64"))] -mod tests { - use super::*; - use openvmm_defs::worker::SavedState; - use state_unit::SavedStateUnit; - use test_with_tracing::test; - use vmcore::save_restore::NoSavedState; - use vmcore::save_restore::SavedStateBlob; - - fn saved_state(unit_names: &[&str]) -> SavedState { - SavedState { - units: unit_names - .iter() - .map(|name| SavedStateUnit { - name: (*name).to_owned(), - state: SavedStateBlob::new(NoSavedState), - }) - .collect(), - inventory: Vec::new(), - } - } - - #[test] - fn restore_without_time_adjustment_allows_missing_partition_state() { - validate_snapshot_restore_partition_presence(&saved_state(&[]), None).unwrap(); - } - - #[test] - fn time_adjusted_restore_allows_partition_state() { - validate_snapshot_restore_partition_presence( - &saved_state(&["partition"]), - Some((Duration::ZERO, 1, None)), - ) - .unwrap(); - } - - #[test] - fn time_adjusted_restore_rejects_missing_partition_state() { - let error = validate_snapshot_restore_partition_presence( - &saved_state(&["other"]), - Some((Duration::ZERO, 1, None)), - ) - .unwrap_err(); - - assert_eq!( - error.to_string(), - "time-adjusted snapshot restore requires partition state" - ); - } - - #[test] - fn time_adjusted_restore_requires_partition_payload_not_inventory() { - let mut saved_state = saved_state(&[]); - saved_state.inventory.push("partition".to_owned()); - - validate_snapshot_restore_partition_presence(&saved_state, Some((Duration::ZERO, 1, None))) - .unwrap_err(); - } -} diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index f28fda39e84..bde54c194a3 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -83,8 +83,6 @@ impl From for MicrovmManifest { pub(super) struct MicrovmParameters { /// Snapshot boundary channels handed to the loaded VM. pub(super) snapshot_boundary: SnapshotBoundary, - /// Whether this cold boot can publish a microVM snapshot. - snapshot_capture_enabled: bool, } impl MicrovmParameters { @@ -96,30 +94,22 @@ impl MicrovmParameters { ready: parameters.snapshot_ready.take(), ..Default::default() }; - let snapshot_capture_enabled = parameters.snapshot_capture_enabled; openvmm_defs::microvm::validate_machine_config( ¶meters.cfg, Some(parameters.hypervisor.id()), )?; - Ok(Self { - snapshot_boundary, - snapshot_capture_enabled, - }) + Ok(Self { snapshot_boundary }) } - /// Prepares the kernel command line of a microVM cold boot, before the VM - /// is loaded. - pub(super) fn prepare_cold_boot( + /// Checks the kernel command line of a microVM cold boot, before the VM + /// is loaded: the time ABI declares both clock rates, so the command line + /// must not set them (`E_CMDLINE_CLOCK_TOKEN`). + pub(super) fn check_cold_boot( &self, - vm: &mut InitializedVm, + vm: &InitializedVm, restored_from_snapshot: bool, ) -> anyhow::Result<()> { - prepare_cold_boot_command_line( - &mut vm.cfg, - vm.partition.as_ref(), - restored_from_snapshot, - self.snapshot_capture_enabled, - ) + check_cold_boot_command_line(&vm.cfg, restored_from_snapshot) } } @@ -137,8 +127,6 @@ pub(super) struct SnapshotBoundary { stop_guard: Option, /// Completes the guest's snapshot transaction when the boundary is released. transaction_complete: Option>, - /// Host wall time at the stopped capture boundary. - capture_wall_clock: Option, /// Input-gate timeout of the active boundary. input_gate_timeout: Option, } @@ -269,8 +257,6 @@ impl LoadedVm { ); self.snapshot_boundary.stop_guard = Some(stop_guard); self.snapshot_boundary.transaction_complete = Some(request.transaction_complete); - self.snapshot_boundary.capture_wall_clock = - Some(std::time::SystemTime::now().into()); self.snapshot_boundary.input_gate_timeout = Some(request.input_gate_timeout); snapshot_ready.send(request.scratch_policy); true @@ -305,7 +291,6 @@ impl LoadedVm { .take() .context("snapshot boundary is missing its vCPU stop guard")?; let restore_gate_profile = self.snapshot_restore.gate_profile.take(); - self.snapshot_boundary.capture_wall_clock = None; self.snapshot_boundary.input_gate_timeout = None; self.snapshot_restore.gate_timeout = None; self.snapshot_restore.gate_deadline = None; @@ -351,26 +336,9 @@ impl LoadedVm { // Time ABI capture steps 1 and 2: the LAPIC timers, then the capture // anchor and records. The PIT checks itself when saved. - #[cfg(guest_arch = "x86_64")] - let time = match &self.inner.time_abi { - Some(state) => { - let rollback_safe = |error: anyhow::Error| { - openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) - }; - self.inner - .partition_unit - .check_one_shot_timers() - .await - .map_err(rollback_safe)?; - Some( - super::time_abi::capture_records(self.inner.partition.as_ref(), state) - .map_err(rollback_safe)?, - ) - } - None => None, - }; - #[cfg(not(guest_arch = "x86_64"))] - let time = None; + let time = self.capture_time_abi().await.map_err(|error| { + openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) + })?; let save_state = openvmm_defs::profile::ProfileSpan::start(); let saved_state = self.save().await.map_err(|error| { @@ -400,62 +368,33 @@ impl LoadedVm { )); } }; - if let Some(time) = time { - return Ok(openvmm_defs::rpc::SnapshotSaveResponse { - state_unit_names: saved_state.inventory.clone(), - saved_state: ProtobufMessage::new(saved_state), - effective_command_line, - tsc_frequency_hz: 0, - apic_frequency_hz: 0, - capture_wall_clock: mesh::payload::Timestamp { - seconds: 0, - nanos: 0, - }, - cpu_contract: Vec::new(), - time: Some(time), - }); - } - let tsc_frequency_hz = self - .inner - .partition - .tsc_frequency_hz() - .map_err(|error| { - openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) - })? - .ok_or_else(|| { - openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new( - anyhow::anyhow!("backend does not expose a guest TSC frequency"), - )) - })?; - let apic_frequency_hz = self - .inner - .partition - .apic_frequency_hz() - .map_err(|error| { - openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new(error)) - })? - .ok_or_else(|| { - openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new( - anyhow::anyhow!("backend does not expose a local APIC frequency"), - )) - })?; - let capture_wall_clock = self.snapshot_boundary.capture_wall_clock.ok_or_else(|| { - openvmm_defs::rpc::SnapshotQuiesceError::RollbackSafe(RemoteError::new( - anyhow::anyhow!("snapshot boundary has no wall-clock timestamp"), - )) - })?; Ok(openvmm_defs::rpc::SnapshotSaveResponse { state_unit_names: saved_state.inventory.clone(), saved_state: ProtobufMessage::new(saved_state), effective_command_line, - tsc_frequency_hz, - apic_frequency_hz, - capture_wall_clock, - cpu_contract: mesh::payload::encode(self.inner.partition.cpu_compatibility_contract()), - time: None, + time, }) } + /// Takes the time ABI records of a capture: checks the LAPIC timers, then + /// takes the capture anchor and records (capture steps 1 to 3 of the + /// specification). + #[cfg(guest_arch = "x86_64")] + async fn capture_time_abi(&mut self) -> anyhow::Result { + let state = self + .inner + .time_abi + .as_ref() + .context("a microVM snapshot requires the time ABI")?; + self.inner.partition_unit.check_one_shot_timers().await?; + super::time_abi::capture_records(self.inner.partition.as_ref(), state) + } + + #[cfg(not(guest_arch = "x86_64"))] + async fn capture_time_abi(&mut self) -> anyhow::Result { + anyhow::bail!("microVM snapshots require an x86-64 guest") + } + /// Resumes the VM after a rollback-safe snapshot failure and releases the /// snapshot boundary. pub(super) async fn resume_after_failed_snapshot( @@ -741,10 +680,6 @@ pub(super) fn uses_lazy_memory_registration( && shared_memory.is_some_and(SharedMemoryBacking::is_copy_on_write) } -pub(super) fn uses_versioned_cpu_contract(machine_profile: MachineProfile) -> bool { - machine_profile == MachineProfile::Microvm -} - /// Returns the number of virtio-mmio slots that the memory layout allocates. /// MicroVM devices use fixed slots in the low MMIO aperture instead. pub(super) fn virtio_mmio_count( @@ -770,69 +705,30 @@ pub(super) fn trace_unknown_pio(machine_profile: MachineProfile) -> bool { } #[cfg(guest_arch = "x86_64")] -fn prepare_cold_boot_command_line( - cfg: &mut Manifest, - partition: &dyn HvlitePartition, +fn check_cold_boot_command_line( + cfg: &Manifest, restored_from_snapshot: bool, - snapshot_capture_enabled: bool, ) -> anyhow::Result<()> { if restored_from_snapshot || cfg.machine_profile != MachineProfile::Microvm { return Ok(()); } - let cmdline = match &mut cfg.load_mode { - openvmm_defs::config::LoadMode::Linux { - cmdline, - boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, - .. - } => Some(cmdline), - _ => None, - }; - let Some(cmdline) = cmdline else { + let openvmm_defs::config::LoadMode::Linux { + cmdline, + boot_mode: openvmm_defs::config::LinuxDirectBootMode::MpTable, + .. + } = &cfg.load_mode + else { anyhow::bail!("microVM has no supported cold-boot command line"); }; - if cfg.microvm.time_abi.is_some() { - // The time ABI declares both rates through the identity MSRs. - virt::time_abi::check_command_line_clock_tokens(cmdline)?; - return Ok(()); - } - match partition - .tsc_frequency_hz() - .context("failed to query the backend guest TSC frequency")? - { - Some(frequency_hz) => { - crate::worker::vm_loaders::microvm::propagate_snapshot_tsc_frequency( - cmdline, - frequency_hz, - snapshot_capture_enabled, - ) - .context("failed to propagate the guest TSC frequency")?; - } - None => tracing::warn!( - "backend does not expose a guest TSC frequency; preserving the microVM command line" - ), - } - match partition - .apic_frequency_hz() - .context("failed to query the backend guest LAPIC frequency")? - { - Some(frequency_hz) => { - crate::worker::vm_loaders::microvm::propagate_apic_frequency(cmdline, frequency_hz) - .context("failed to propagate the guest LAPIC frequency")?; - } - None => tracing::warn!( - "backend does not expose a guest LAPIC frequency; retaining guest timer calibration" - ), - } + virt::time_abi::check_command_line_clock_tokens(cmdline)?; Ok(()) } #[cfg(not(guest_arch = "x86_64"))] -fn prepare_cold_boot_command_line( - _cfg: &mut Manifest, - _partition: &dyn HvlitePartition, +fn check_cold_boot_command_line( + _cfg: &Manifest, _restored_from_snapshot: bool, - _snapshot_capture_enabled: bool, ) -> anyhow::Result<()> { Ok(()) } diff --git a/openvmm/openvmm_core/src/worker/dispatch/restore.rs b/openvmm/openvmm_core/src/worker/dispatch/restore.rs index 3be613333f0..9d92f856154 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/restore.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/restore.rs @@ -8,9 +8,6 @@ //! (input gate, readiness event, and VP release). use super::LoadedVm; -use super::clock; -use super::clock::RestoreTime; -use crate::partition::HvlitePartition; use anyhow::Context; use futures::FutureExt; use futures_concurrency::future::Race; @@ -39,8 +36,6 @@ use vmm_core::partition_unit::StopGuard; pub(super) struct RestoreParameters { /// Restore state handed to the loaded VM. pub(super) state: SnapshotRestore, - /// Saved canonical CPU contract required by restore. - pub(super) cpu_contract: Option>, /// Mapping mode of the file-backed guest RAM handle. file_mapping_mode: FileMappingMode, /// Snapshot generation handles that must outlive the restored VM. @@ -48,18 +43,11 @@ pub(super) struct RestoreParameters { } impl RestoreParameters { - /// Takes the restore inputs out of the worker parameters and validates - /// the saved time contract. + /// Takes the restore inputs out of the worker parameters. pub(super) fn take(parameters: &mut VmWorkerParameters) -> anyhow::Result { let guards = parameters.snapshot_restore_guards.take(); let ready_sink = parameters.restore_ready_sink.take(); let gate_timeout = parameters.restore_gate_timeout.take(); - let restore_time = clock::restore_time_contract( - parameters.restore_downtime, - parameters.restore_tsc_frequency_hz, - parameters.restore_apic_frequency_hz, - )?; - tracing::debug!(?restore_time, "received snapshot restore time contract"); let restore_vp_count = parameters.restore_vp_count.take(); let vp_prefix = restore_vp_prefix(parameters.hypervisor.id(), restore_vp_count); tracing::debug!( @@ -67,12 +55,7 @@ impl RestoreParameters { ?vp_prefix, "received restore-time VP activation target" ); - let cpu_contract = parameters.restore_cpu_contract.take(); let time_abi = parameters.restore_time.take(); - anyhow::ensure!( - time_abi.is_none() || (restore_time.is_none() && cpu_contract.is_none()), - "a restore carries either the time ABI inputs or the legacy clock contract" - ); let file_mapping_mode = if parameters.shared_memory_copy_on_write { FileMappingMode::CopyOnWrite } else { @@ -80,14 +63,12 @@ impl RestoreParameters { }; Ok(Self { state: SnapshotRestore { - time: restore_time, time_abi, vp_prefix, ready_sink, gate_timeout, ..Default::default() }, - cpu_contract, file_mapping_mode, guards, }) @@ -140,8 +121,6 @@ fn select_instantiated_vps( /// Snapshot-restore state of a [`LoadedVm`]. #[derive(Default)] pub(super) struct SnapshotRestore { - /// Saved guest-clock contract, checked and applied around the restore. - time: Option, /// The time ABI inputs of a restore, validated by the controller. pub(super) time_abi: Option, /// VP prefix instantiated for an explicit MSHV restore-time activation @@ -258,39 +237,20 @@ impl SnapshotRestore { } } +/// Checks that the saved state of a time ABI restore holds the partition +/// state, which the restore clock sets and advances. #[cfg(guest_arch = "x86_64")] -pub(super) fn validate_restore_cpu_contract( - partition: &dyn HvlitePartition, - expected_cpu_contract: Option>, -) -> anyhow::Result<()> { - if let Some(expected_cpu_contract) = expected_cpu_contract { - let destination_contract = partition.cpu_compatibility_contract(); - let destination_cpu_contract = mesh::payload::encode(destination_contract.clone()); - if destination_cpu_contract != expected_cpu_contract { - let expected_contract: virt::x86::CpuCompatibilityContract = - mesh::payload::decode(&expected_cpu_contract) - .context("failed to decode snapshot CPU contract")?; - let first_cpuid_difference = expected_contract - .cpuid - .iter() - .zip(&destination_contract.cpuid) - .find(|(expected, destination)| expected != destination); - anyhow::bail!( - "destination CPU contract does not match the snapshot; first CPUID difference: {first_cpuid_difference:?}" - ); - } - } - Ok(()) -} - -#[cfg(not(guest_arch = "x86_64"))] -pub(super) fn validate_restore_cpu_contract( - _partition: &dyn HvlitePartition, - expected_cpu_contract: Option>, +fn validate_snapshot_restore_partition_presence( + saved_state: &SavedState, + time_abi_restore: bool, ) -> anyhow::Result<()> { anyhow::ensure!( - expected_cpu_contract.is_none(), - "snapshot CPU contracts are only supported for x86-64 guests" + !time_abi_restore + || saved_state + .units + .iter() + .any(|unit| unit.name == "partition"), + "a time ABI restore requires partition state" ); Ok(()) } @@ -308,31 +268,29 @@ pub(super) fn validate_inventory( } impl LoadedVm { - /// Prepares to restore `saved_state` into the loaded VM: checks the saved - /// time contract against the saved state and the destination clocks, and - /// starts the profile span of the restore. A time ABI restore drops the - /// VPs' saved TSC values, which its synchronized TSC set supersedes. + /// Prepares to restore `saved_state` into the loaded VM: checks that a + /// time ABI restore has partition state, and starts the profile span of + /// the restore. A time ABI restore drops the VPs' saved TSC values, which + /// its synchronized TSC set supersedes. pub(super) async fn begin_snapshot_restore( &mut self, #[cfg_attr(not(guest_arch = "x86_64"), expect(unused_variables))] saved_state: &SavedState, ) -> anyhow::Result { self.snapshot_restore.restored_from_snapshot = true; - let restore_time = self.snapshot_restore.time; - #[cfg(guest_arch = "x86_64")] - clock::validate_snapshot_restore_partition_presence(saved_state, restore_time)?; - - self.validate_restore_clock(restore_time)?; - #[cfg(guest_arch = "x86_64")] - if self.snapshot_restore.time_abi.is_some() { - self.inner.partition_unit.omit_saved_tsc().await; + { + let time_abi_restore = self.snapshot_restore.time_abi.is_some(); + validate_snapshot_restore_partition_presence(saved_state, time_abi_restore)?; + if time_abi_restore { + self.inner.partition_unit.omit_saved_tsc().await; + } } Ok(ProfileSpan::start()) } - /// Completes a restore after the saved state is applied: advances the - /// guest clocks by the snapshot downtime and holds the VPs stopped until - /// the VM first starts. + /// Completes a restore after the saved state is applied: sets and + /// advances the guest clocks of a time ABI restore, and holds the VPs + /// stopped until the VM first starts. pub(super) async fn finish_snapshot_restore( &mut self, saved_state_restore: ProfileSpan, @@ -345,8 +303,6 @@ impl LoadedVm { self.time_abi_restore(input).await?; clock_restore.complete("restore", "time_abi_clock", Default::default()); } - self.advance_restored_clock(self.snapshot_restore.time) - .await?; self.snapshot_restore.start_guard = Some(self.inner.partition_unit.temporarily_stop_vps().await); Ok(()) @@ -427,6 +383,42 @@ mod tests { (0..8).collect() } + #[cfg(guest_arch = "x86_64")] + fn saved_state(unit_names: &[&str]) -> SavedState { + use state_unit::SavedStateUnit; + use vmcore::save_restore::NoSavedState; + use vmcore::save_restore::SavedStateBlob; + + SavedState { + units: unit_names + .iter() + .map(|name| SavedStateUnit { + name: (*name).to_owned(), + state: SavedStateBlob::new(NoSavedState), + }) + .collect(), + inventory: Vec::new(), + } + } + + #[cfg(guest_arch = "x86_64")] + #[test] + fn time_abi_restore_requires_partition_state() { + validate_snapshot_restore_partition_presence(&saved_state(&[]), false).unwrap(); + validate_snapshot_restore_partition_presence(&saved_state(&["partition"]), true).unwrap(); + let error = validate_snapshot_restore_partition_presence(&saved_state(&["other"]), true) + .unwrap_err(); + assert_eq!( + error.to_string(), + "a time ABI restore requires partition state" + ); + + // The payload must hold the unit; the inventory alone does not. + let mut inventory_only = saved_state(&[]); + inventory_only.inventory.push("partition".to_owned()); + validate_snapshot_restore_partition_presence(&inventory_only, true).unwrap_err(); + } + #[test] fn limits_only_explicit_mshv_restores() { assert_eq!(restore_vp_prefix("mshv", Some(2)), Some(2)); diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index d2d983b23da..6cbff78e9bc 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -1,10 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! NVX time ABI v1 in the VM worker, selected by the hidden `--x-time-abi-v1` -//! development switch: the partition configuration, the backend preflight -//! and rate policy, the `time-abi` state unit, the capture records, and the -//! restore clock. +//! NVX time ABI v1 in the VM worker, which every microVM uses: the partition +//! configuration, the backend preflight and rate policy, the `time-abi` state +//! unit, the capture records, and the restore clock. #![cfg(guest_arch = "x86_64")] diff --git a/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs b/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs index 9af124ab451..d850a721792 100644 --- a/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs +++ b/openvmm/openvmm_core/src/worker/vm_loaders/microvm.rs @@ -13,18 +13,9 @@ use loader::linux::InitrdConfig; use memory_range::MemoryRange; use std::ffi::CString; use std::io::Seek; -use thiserror::Error; use vm_loader::InitialLoad; use vm_loader::Loader; -const MIN_TSC_FREQUENCY_HZ: u64 = 500_000_000; -const MAX_TSC_FREQUENCY_HZ: u64 = 10_000_000_000; -const HZ_PER_KHZ: u64 = 1000; -const TSC_EARLY_KHZ: &str = "tsc_early_khz"; -const LAPIC_TIMER_HZ: &str = "lapic_timer_hz"; -const MIN_APIC_FREQUENCY_HZ: u64 = 1_000_000; -const VIRTIO_MMIO_DEVICE: &str = "virtio_mmio.device="; - #[cfg_attr(not(guest_arch = "x86_64"), expect(dead_code))] pub fn load_linux_x86_mptable( cfg: &KernelConfig<'_>, @@ -69,592 +60,3 @@ pub fn load_linux_x86_mptable( .map_err(LinuxError::Loader)?; Ok(loader.initial_regs_and_page_imports()) } - -#[derive(Debug, Error, PartialEq, Eq)] -pub(crate) enum FrequencyParameterError { - #[error("duplicate parameter")] - Duplicate, - #[error("malformed parameter: {0}")] - Malformed(String), - #[error("value {specified} does not match the backend-reported value {reported}")] - Mismatch { specified: u64, reported: u64 }, -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub(crate) enum TscFrequencyError { - #[error("TSC frequency {0} Hz is too small to convert to kHz")] - TooSmall(u64), - #[error("TSC frequency {0} Hz is outside the supported range of 500 MHz through 10 GHz")] - OutOfRange(u64), - #[error("kernel command line contains duplicate tsc_early_khz parameters")] - Duplicate, - #[error("kernel command line contains malformed tsc_early_khz parameter: {0}")] - Malformed(String), - #[error( - "kernel command line tsc_early_khz value {specified} does not match the backend-reported value {reported}" - )] - Mismatch { specified: u64, reported: u64 }, -} - -impl From for TscFrequencyError { - fn from(error: FrequencyParameterError) -> Self { - match error { - FrequencyParameterError::Duplicate => Self::Duplicate, - FrequencyParameterError::Malformed(parameter) => Self::Malformed(parameter), - FrequencyParameterError::Mismatch { - specified, - reported, - } => Self::Mismatch { - specified, - reported, - }, - } - } -} - -#[derive(Debug, Error, PartialEq, Eq)] -pub(crate) enum ApicFrequencyError { - #[error("LAPIC frequency {0} Hz is outside the supported range of 1 MHz through 4294967295 Hz")] - OutOfRange(u64), - #[error("invalid lapic_timer_hz: {0}")] - Parameter(#[from] FrequencyParameterError), -} - -fn command_line_tokens(cmdline: &str) -> (Vec<(usize, &str)>, Option) { - let mut tokens = Vec::new(); - let mut token_start = None; - let mut in_quote = false; - for (offset, character) in cmdline.char_indices() { - if character == '"' { - in_quote = !in_quote; - } - if character.is_ascii_whitespace() && !in_quote { - if let Some(start) = token_start.take() { - tokens.push((start, &cmdline[start..offset])); - } - } else if token_start.is_none() { - token_start = Some(offset); - } - } - let unterminated_quote_offset = if in_quote { token_start } else { None }; - if let Some(start) = token_start { - tokens.push((start, &cmdline[start..])); - } - (tokens, unterminated_quote_offset) -} - -fn linux_parameter_name_matches(actual: &str, expected: &str) -> bool { - actual - .bytes() - .map(|byte| if byte == b'-' { b'_' } else { byte }) - .eq(expected - .bytes() - .map(|byte| if byte == b'-' { b'_' } else { byte })) -} - -fn parse_linux_uint(value: &str) -> Option { - let value = if let Some(value) = value.strip_prefix('"') { - value.strip_suffix('"')? - } else { - if value.ends_with('"') { - return None; - } - value - }; - let value = value.strip_prefix('+').unwrap_or(value); - if value.is_empty() { - return None; - } - - let (digits, radix) = if let Some(digits) = value - .strip_prefix("0x") - .or_else(|| value.strip_prefix("0X")) - { - (digits, 16) - } else if value.starts_with('0') { - (value, 8) - } else { - (value, 10) - }; - u32::from_str_radix(digits, radix).ok() -} - -pub(crate) fn propagate_tsc_frequency( - cmdline: &mut String, - frequency_hz: u64, -) -> Result<(), TscFrequencyError> { - if frequency_hz < HZ_PER_KHZ { - return Err(TscFrequencyError::TooSmall(frequency_hz)); - } - if !(MIN_TSC_FREQUENCY_HZ..=MAX_TSC_FREQUENCY_HZ).contains(&frequency_hz) { - return Err(TscFrequencyError::OutOfRange(frequency_hz)); - } - Ok(propagate_frequency_parameter( - cmdline, - TSC_EARLY_KHZ, - frequency_hz / HZ_PER_KHZ, - )?) -} - -pub(crate) fn propagate_apic_frequency( - cmdline: &mut String, - frequency_hz: u64, -) -> Result<(), ApicFrequencyError> { - if !(MIN_APIC_FREQUENCY_HZ..=u64::from(u32::MAX)).contains(&frequency_hz) { - return Err(ApicFrequencyError::OutOfRange(frequency_hz)); - } - Ok(propagate_frequency_parameter( - cmdline, - LAPIC_TIMER_HZ, - frequency_hz, - )?) -} - -fn propagate_frequency_parameter( - cmdline: &mut String, - parameter_name: &str, - frequency: u64, -) -> Result<(), FrequencyParameterError> { - let canonical_parameter = format!("{parameter_name}={frequency}"); - - let mut parameter = None; - let mut delimiter_offset = None; - let mut discovery_offset = None; - let (tokens, unterminated_quote_offset) = command_line_tokens(cmdline); - for (offset, raw_token) in tokens { - let token_length = raw_token.len(); - let token = raw_token.strip_prefix('"').map_or(raw_token, |token| { - if token - .split_once('=') - .is_some_and(|(_name, value)| value.starts_with('"')) - { - token - } else { - token.strip_suffix('"').unwrap_or(token) - } - }); - if token == "--" { - delimiter_offset = Some(offset); - break; - } - if discovery_offset.is_none() && token.starts_with(VIRTIO_MMIO_DEVICE) { - discovery_offset = Some(offset); - } - let name = token.split_once('=').map_or(token, |(name, _value)| name); - if linux_parameter_name_matches(name, parameter_name) { - if parameter.is_some() { - return Err(FrequencyParameterError::Duplicate); - } - parameter = Some((offset..offset + token_length, token.to_owned())); - } - } - - if let Some((range, parameter)) = parameter { - let Some((_name, value)) = parameter.split_once('=') else { - return Err(FrequencyParameterError::Malformed(parameter)); - }; - let specified = parse_linux_uint(value) - .map(u64::from) - .ok_or_else(|| FrequencyParameterError::Malformed(parameter.clone()))?; - if specified != frequency { - return Err(FrequencyParameterError::Mismatch { - specified, - reported: frequency, - }); - } - cmdline.replace_range(range, &canonical_parameter); - return Ok(()); - } - - if let Some(offset) = discovery_offset - .or(delimiter_offset) - .or(unterminated_quote_offset) - { - cmdline.insert_str(offset, &format!("{canonical_parameter} ")); - return Ok(()); - } - if !cmdline.is_empty() - && !cmdline - .as_bytes() - .last() - .is_some_and(|byte| byte.is_ascii_whitespace()) - { - cmdline.push(' '); - } - cmdline.push_str(&canonical_parameter); - Ok(()) -} - -pub(crate) fn propagate_snapshot_tsc_frequency( - cmdline: &mut String, - frequency_hz: u64, - snapshot_capture_enabled: bool, -) -> Result<(), TscFrequencyError> { - if !snapshot_capture_enabled { - return Ok(()); - } - propagate_tsc_frequency(cmdline, frequency_hz) -} - -#[cfg(test)] -mod tests { - use super::*; - use test_with_tracing::test; - - const TSC_FREQUENCY_HZ: u64 = 2_500_000_999; - - #[test] - fn propagates_backend_apic_frequency_without_changing_tsc_policy() { - let mut cmdline = "console=hvc0 -- tenant".to_owned(); - propagate_snapshot_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ, false).unwrap(); - propagate_apic_frequency(&mut cmdline, 200_000_000).unwrap(); - assert_eq!(cmdline, "console=hvc0 lapic_timer_hz=200000000 -- tenant"); - - let mut snapshot = "console=hvc0 virtio_mmio.device=0x1000@0xd0002000:7".to_owned(); - propagate_snapshot_tsc_frequency(&mut snapshot, TSC_FREQUENCY_HZ, true).unwrap(); - propagate_apic_frequency(&mut snapshot, 200_000_000).unwrap(); - assert_eq!( - snapshot, - "console=hvc0 tsc_early_khz=2500000 lapic_timer_hz=200000000 \ - virtio_mmio.device=0x1000@0xd0002000:7" - ); - } - - #[test] - fn canonicalizes_apic_frequency_using_linux_parameter_rules() { - for parameter in [ - "lapic_timer_hz=200000000", - "lapic-timer-hz=200000000", - "lapic_timer_hz=0xbebc200", - r#"lapic_timer_hz="200000000""#, - r#""lapic_timer_hz=200000000""#, - ] { - let mut cmdline = format!("console=hvc0 {parameter} -- lapic_timer_hz=1"); - propagate_apic_frequency(&mut cmdline, 200_000_000).unwrap(); - assert_eq!( - cmdline, - "console=hvc0 lapic_timer_hz=200000000 -- lapic_timer_hz=1" - ); - } - } - - #[test] - fn rejects_invalid_apic_frequency_without_mutating_command_line() { - for parameter in [ - "lapic_timer_hz", - "lapic_timer_hz=", - "lapic_timer_hz=0", - "lapic_timer_hz=199999999", - "lapic_timer_hz=4294967296", - "lapic_timer_hz=200000000 lapic-timer-hz=200000000", - ] { - let mut cmdline = parameter.to_owned(); - let error = propagate_apic_frequency(&mut cmdline, 200_000_000).unwrap_err(); - assert!(error.to_string().contains(LAPIC_TIMER_HZ)); - assert_eq!(cmdline, parameter); - } - for frequency in [0, MIN_APIC_FREQUENCY_HZ - 1, u64::from(u32::MAX) + 1] { - let mut cmdline = "console=hvc0".to_owned(); - assert_eq!( - propagate_apic_frequency(&mut cmdline, frequency), - Err(ApicFrequencyError::OutOfRange(frequency)) - ); - assert_eq!(cmdline, "console=hvc0"); - } - for frequency in [MIN_APIC_FREQUENCY_HZ, u64::from(u32::MAX)] { - propagate_apic_frequency(&mut String::new(), frequency).unwrap(); - } - } - - #[test] - fn preserves_non_snapshot_command_line() { - let mut cmdline = "console=ttyS0 tsc_early_khz=not-a-number".to_owned(); - let original = cmdline.clone(); - - propagate_snapshot_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ, false).unwrap(); - - assert_eq!(cmdline, original); - } - - #[test] - fn appends_normalized_frequency() { - let mut cmdline = "console=ttyS0".to_owned(); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(cmdline, "console=ttyS0 tsc_early_khz=2500000"); - } - - #[test] - fn inserts_before_fixed_virtio_discovery_suffix() { - let mut cmdline = "console=hvc1 \ - virtio_mmio.device=0x1000@0xd0002000:7 \ - virtio_mmio.device=0x1000@0xd0003000:4" - .to_owned(); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!( - cmdline, - "console=hvc1 tsc_early_khz=2500000 \ - virtio_mmio.device=0x1000@0xd0002000:7 \ - virtio_mmio.device=0x1000@0xd0003000:4" - ); - } - - #[test] - fn accepts_matching_frequency_without_mutation() { - let mut cmdline = "console=ttyS0 tsc_early_khz=2500000".to_owned(); - let original = cmdline.clone(); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(cmdline, original); - } - - #[test] - fn rejects_mismatched_frequency() { - let mut cmdline = "tsc_early_khz=2499999".to_owned(); - assert_eq!( - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Mismatch { - specified: 2_499_999, - reported: 2_500_000, - }) - ); - } - - #[test] - fn rejects_malformed_frequency() { - for parameter in [ - "tsc_early_khz", - "tsc_early_khz=", - "tsc_early_khz=2.5", - "tsc_early_khz=18446744073709551616", - ] { - let mut cmdline = parameter.to_owned(); - assert!(matches!( - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Malformed(_)) - )); - } - } - - #[test] - fn rejects_duplicate_frequency() { - let mut cmdline = "tsc_early_khz=2500000 console=ttyS0 tsc_early_khz=2500000".to_owned(); - assert_eq!( - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Duplicate) - ); - } - - #[test] - fn validates_frequency_range_before_conversion() { - let mut cmdline = String::new(); - assert_eq!( - propagate_tsc_frequency(&mut cmdline, 999), - Err(TscFrequencyError::TooSmall(999)) - ); - assert_eq!( - propagate_tsc_frequency(&mut cmdline, MIN_TSC_FREQUENCY_HZ - 1), - Err(TscFrequencyError::OutOfRange(MIN_TSC_FREQUENCY_HZ - 1)) - ); - assert_eq!( - propagate_tsc_frequency(&mut cmdline, MAX_TSC_FREQUENCY_HZ + 1), - Err(TscFrequencyError::OutOfRange(MAX_TSC_FREQUENCY_HZ + 1)) - ); - - propagate_tsc_frequency(&mut String::new(), MIN_TSC_FREQUENCY_HZ).unwrap(); - propagate_tsc_frequency(&mut String::new(), MAX_TSC_FREQUENCY_HZ).unwrap(); - } - - #[test] - fn handles_ascii_whitespace_without_adding_extra_whitespace() { - let mut empty = String::new(); - propagate_tsc_frequency(&mut empty, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(empty, "tsc_early_khz=2500000"); - - let mut trailing = "console=ttyS0 \t".to_owned(); - propagate_tsc_frequency(&mut trailing, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(trailing, "console=ttyS0 \ttsc_early_khz=2500000"); - - let mut supplied = "console=ttyS0\t tsc_early_khz=2500000\n".to_owned(); - let original = supplied.clone(); - propagate_tsc_frequency(&mut supplied, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(supplied, original); - } - - #[test] - fn inserts_before_delimiter_and_ignores_tokens_after_it() { - let mut cmdline = "console=ttyS0 \t-- tsc_early_khz=1 tsc_early_khz=malformed".to_owned(); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!( - cmdline, - "console=ttyS0 \ttsc_early_khz=2500000 -- \ - tsc_early_khz=1 tsc_early_khz=malformed" - ); - - let mut supplied = "tsc_early_khz=2500000 -- tsc_early_khz=1 tsc_early_khz=2".to_owned(); - let original = supplied.clone(); - propagate_tsc_frequency(&mut supplied, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(supplied, original); - } - - #[test] - fn treats_hyphens_and_underscores_as_the_same_parameter_name() { - let mut matching = "tsc-early-khz=2500000".to_owned(); - propagate_tsc_frequency(&mut matching, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(matching, "tsc_early_khz=2500000"); - - let mut mismatched = "tsc-early-khz=2499999".to_owned(); - assert_eq!( - propagate_tsc_frequency(&mut mismatched, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Mismatch { - specified: 2_499_999, - reported: 2_500_000, - }) - ); - - let mut duplicate = "tsc-early-khz=2500000 tsc_early_khz=2500000".to_owned(); - assert_eq!( - propagate_tsc_frequency(&mut duplicate, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Duplicate) - ); - } - - #[test] - fn canonicalizes_quote_stripped_decimal_frequency() { - let mut cmdline = r#"console=ttyS0 tsc_early_khz="2500000""#.to_owned(); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(cmdline, "console=ttyS0 tsc_early_khz=2500000"); - } - - #[test] - fn ignores_parameter_text_inside_quoted_values() { - for quoted_text in [ - "--", - "tsc_early_khz=1", - "virtio_mmio.device=0x1000@0xd0002000:7", - ] { - let original = format!(r#"console=hvc1 note="left {quoted_text} right""#); - let mut cmdline = original.clone(); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(cmdline, format!("{original} tsc_early_khz=2500000")); - - let original = format!("{original} tsc_early_khz=2500000"); - let mut cmdline = original.clone(); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(cmdline, original); - } - } - - #[test] - fn inserts_before_whole_quoted_discovery_or_delimiter() { - for suffix in [ - r#""virtio_mmio.device=0x1000@0xd0002000:7""#, - r#""--" tsc_early_khz=1"#, - ] { - let mut cmdline = format!("console=hvc1 {suffix}"); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!( - cmdline, - format!("console=hvc1 tsc_early_khz=2500000 {suffix}") - ); - } - } - - #[test] - fn inserts_before_unterminated_quoted_argument() { - for suffix in [ - r#"note="left -- right"#, - r#"note="left tsc_early_khz=1"#, - r#"-- note="left"#, - ] { - let mut cmdline = format!("console=hvc1 {suffix}"); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!( - cmdline, - format!("console=hvc1 tsc_early_khz=2500000 {suffix}") - ); - } - } - - #[test] - fn canonicalizes_whole_quoted_frequency_parameters() { - let mut matching = r#""tsc_early_khz=2500000""#.to_owned(); - propagate_tsc_frequency(&mut matching, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(matching, "tsc_early_khz=2500000"); - - let mut mismatched = r#""tsc_early_khz=2499999""#.to_owned(); - assert_eq!( - propagate_tsc_frequency(&mut mismatched, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Mismatch { - specified: 2_499_999, - reported: 2_500_000, - }) - ); - - let mut duplicate = r#""tsc_early_khz=2500000" tsc_early_khz=2500000"#.to_owned(); - assert_eq!( - propagate_tsc_frequency(&mut duplicate, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Duplicate) - ); - - for parameter in [r#""tsc_early_khz""#, r#""tsc_early_khz="2500000"""#] { - let mut cmdline = parameter.to_owned(); - assert!(matches!( - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Malformed(_)) - )); - } - } - - #[test] - fn canonicalizes_hexadecimal_frequency() { - let mut matching = "tsc_early_khz=0x2625a0".to_owned(); - propagate_tsc_frequency(&mut matching, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!(matching, "tsc_early_khz=2500000"); - - let mut mismatched = "tsc_early_khz=0X26259F".to_owned(); - assert_eq!( - propagate_tsc_frequency(&mut mismatched, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Mismatch { - specified: 2_499_999, - reported: 2_500_000, - }) - ); - } - - #[test] - fn canonicalizes_platform_snapshot_command_line() { - for supplied in [ - "tsc-early-khz=2500000", - "tsc_early_khz=0x2625a0", - r#"tsc_early_khz="2500000""#, - ] { - let mut cmdline = format!( - "earlycon=xe9 console=hvc0 nvx_snapshot_tier=platform {supplied} \ - virtio_mmio.device=0x1000@0xd0002000:7" - ); - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ).unwrap(); - assert_eq!( - cmdline, - "earlycon=xe9 console=hvc0 nvx_snapshot_tier=platform \ - tsc_early_khz=2500000 virtio_mmio.device=0x1000@0xd0002000:7" - ); - } - } - - #[test] - fn rejects_malformed_or_overflowing_linux_uint() { - for parameter in [ - r#"tsc_early_khz="2500000"#, - "tsc_early_khz=0x", - "tsc_early_khz=08", - "tsc_early_khz=-2500000", - "tsc_early_khz=4294967296", - "tsc_early_khz=0x100000000", - ] { - let mut cmdline = parameter.to_owned(); - assert!(matches!( - propagate_tsc_frequency(&mut cmdline, TSC_FREQUENCY_HZ), - Err(TscFrequencyError::Malformed(_)) - )); - } - } -} diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 5d4b5fcd32c..dc4f8f72a81 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -1061,8 +1061,8 @@ pub struct MicrovmConfig { pub snapshot_memory_ranges: Vec, /// Fresh private GPA ranges selected for this restore launch. pub restore_memory_ranges: Vec, - /// Selects the NVX time ABI v1. Set by the hidden `--x-time-abi-v1` - /// development switch until the time ABI becomes the only microVM path. + /// The NVX time ABI v1 parameters. Every microVM uses the time ABI, so + /// the microVM profile requires them. pub time_abi: Option, } @@ -1172,6 +1172,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> validate_microvm_virtio_reservations()?; validate_microvm_command_line(config, hypervisor_id)?; validate_machine_load_mode(config.machine_profile, &config.load_mode)?; + anyhow::ensure!( + config.microvm.time_abi.is_some(), + "the microVM profile requires the NVX time ABI parameters" + ); if let Some(hypervisor_id) = hypervisor_id { anyhow::ensure!( matches!(hypervisor_id, "kvm" | "mshv" | "whp"), diff --git a/openvmm/openvmm_defs/src/rpc.rs b/openvmm/openvmm_defs/src/rpc.rs index d0490efe6cd..79080ef23c0 100644 --- a/openvmm/openvmm_defs/src/rpc.rs +++ b/openvmm/openvmm_defs/src/rpc.rs @@ -69,17 +69,9 @@ pub struct SnapshotSaveResponse { pub state_unit_names: Vec, /// Effective Linux direct command line loaded into the guest. pub effective_command_line: String, - /// Effective guest TSC frequency. - pub tsc_frequency_hz: u64, - /// Effective local APIC timer frequency. - pub apic_frequency_hz: u64, - /// Host wall time at the stopped capture boundary. - pub capture_wall_clock: mesh::payload::Timestamp, - /// Canonical effective CPU compatibility contract. - pub cpu_contract: Vec, - /// The NVX time ABI records of the capture. With them, the four clock - /// fields above are empty and the manifest is version 6. - pub time: Option, + /// The NVX time ABI records of the capture, which make the manifest + /// version 6. + pub time: crate::time_abi::TimeCapture, } /// Failure classification for a bounded snapshot quiesce/save operation. diff --git a/openvmm/openvmm_defs/src/worker.rs b/openvmm/openvmm_defs/src/worker.rs index c385e577cd8..91b9c82f196 100644 --- a/openvmm/openvmm_defs/src/worker.rs +++ b/openvmm/openvmm_defs/src/worker.rs @@ -73,18 +73,8 @@ pub struct VmWorkerParameters { /// Notifies the controller after the worker establishes the boundary. pub snapshot_ready: Option>, - /// Whether this cold boot can publish a microVM snapshot. - pub snapshot_capture_enabled: bool, - /// Host downtime to apply before starting a restored VM. - pub restore_downtime: Option, - /// Saved effective TSC frequency required by restore. - pub restore_tsc_frequency_hz: Option, - /// Saved local APIC timer frequency required by restore. - pub restore_apic_frequency_hz: Option, - /// Saved canonical CPU contract required by restore. - pub restore_cpu_contract: Option>, /// The time ABI inputs of a restored microVM, validated by the - /// controller. They replace the four fields above under the time ABI. + /// controller. pub restore_time: Option, /// Single-use process-local sink for the restore readiness event. pub restore_ready_sink: Option, diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index eed6f28f3dc..306bbc44113 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -191,7 +191,9 @@ impl From for MachineProfile { /// Options of the microVM machine profile. #[derive(clap::Args)] pub struct MicrovmCli { - /// Expose a fresh OPENVMM_ENTROPY_V1 packet through the private portb restore channel. + /// Accepted for compatibility: every microVM restore exposes restore packet + /// version 4, which carries fresh entropy, through the private portb restore + /// channel. #[clap(long, requires = "restore_snapshot")] pub restore_entropy: bool, @@ -359,28 +361,13 @@ pub struct MicrovmCli { )] pub microvm_control_auth_timeout_ms: u64, - /// use the NVX time ABI v1 (development switch; removed when the time ABI - /// becomes the only microVM path) - #[clap(long = "x-time-abi-v1", hide = true)] - pub x_time_abi_v1: bool, - /// microVM CPU profile: a pinned profile ID, or `auto` (the default) to /// select the host's profile; a restore must name the snapshot's profile - #[clap( - long = "cpu-profile", - value_name = "ID", - hide = true, - requires = "x_time_abi_v1" - )] + #[clap(long = "cpu-profile", value_name = "ID", hide = true)] pub cpu_profile: Option, /// time ABI test hook (repeatable; testing only) - #[clap( - long = "x-time-abi-test-hook", - value_name = "HOOK", - hide = true, - requires = "x_time_abi_v1" - )] + #[clap(long = "x-time-abi-test-hook", value_name = "HOOK", hide = true)] pub x_time_abi_test_hook: Vec, /// build the partition and run the time ABI preflight without running @@ -389,7 +376,6 @@ pub struct MicrovmCli { #[clap( long = "x-time-abi-verify", hide = true, - requires = "x_time_abi_v1", conflicts_with_all = ["restore_snapshot", "snapshot_destination"] )] pub x_time_abi_verify: bool, diff --git a/openvmm/openvmm_entry/src/lib.rs b/openvmm/openvmm_entry/src/lib.rs index 1e3e2e5c761..30f74668b37 100644 --- a/openvmm/openvmm_entry/src/lib.rs +++ b/openvmm/openvmm_entry/src/lib.rs @@ -3004,11 +3004,6 @@ async fn run_control_inner( snapshot_restore_guards: restore.guards, snapshot_boundary_requests, snapshot_ready, - snapshot_capture_enabled: microvm.snapshot_capture_enabled(), - restore_downtime: restore.downtime, - restore_tsc_frequency_hz: restore.tsc_frequency_hz, - restore_apic_frequency_hz: restore.apic_frequency_hz, - restore_cpu_contract: restore.cpu_contract, restore_time: restore.time, restore_ready_sink, restore_gate_timeout: microvm.restore_gate_timeout(&opt), diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index fb684df80a9..5ee10d24a15 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -23,7 +23,6 @@ use super::output::MicrovmOutputDrain; use super::output::OutputCompletion; use super::output::spawn_output; use super::restore::fresh_microvm_generation_id; -use super::restore::fresh_microvm_restore_packet; use crate::ConsoleState; use crate::Options; use crate::VmResources; @@ -394,53 +393,27 @@ impl<'a> MicrovmConfigBuilder<'a> { let opt = self.opt; let (drain, output_drain) = MicrovmOutputDrain::new(Some(output_completion)); self.resources.output_drain = Some(drain); - let mut time_abi_restore = None; - let (generation_id, restore_entropy) = - if opt.microvm.x_time_abi_v1 && self.restore.time_abi_records().is_some() { - let (generation_id, base) = self - .restore - .time_abi_restore_packet(opt.microvm.restore_processors)?; - time_abi_restore = Some(self.resources.time_abi_restore_packet(base)); - (generation_id, Vec::new()) - } else if opt.microvm.restore_entropy || self.restore.memory_target_requested { - fresh_microvm_restore_packet( - opt.microvm.restore_processors, - self.restore.memory_target_requested, - &self.restore.memory_ranges, - )? - } else { - (fresh_microvm_generation_id()?, Vec::new()) - }; - let time_abi = if opt.microvm.x_time_abi_v1 { - let hooks = time_abi_test_hooks(opt)?; - // The time ABI forbids a periodic PIT channel 0 at capture and - // restore (`E_PIT_ACTIVE`). - for device in chipset_devices.iter_mut() { - if device.name == chipset_resources::pit::PitDeviceHandle::ID { - device.resource = - chipset_resources::pit::PitDeviceHandle { time_abi: true }.into_resource(); - } - } - Some(chipset_resources::microvm::MicrovmPortbTimeAbi { - generation: time_abi_restore - .as_ref() - .map_or(0, |restore| restore.base.generation), - utc_offset_ms: hooks.utc_offset_ms, - sample_delay_us: hooks.sample_delay_us, - test_hooks: hooks.active(), - restore: time_abi_restore, - }) + let (generation_id, time_abi_restore) = if self.restore.machine_contract.is_some() { + let (generation_id, base) = self + .restore + .time_abi_restore_packet(opt.microvm.restore_processors)?; + ( + generation_id, + Some(self.resources.time_abi_restore_packet(base)), + ) } else { - None + (fresh_microvm_generation_id()?, None) }; + let hooks = time_abi_test_hooks(opt)?; + let time_abi = super::time_abi_chipset(chipset_devices, &hooks, time_abi_restore); chipset_devices.push(ChipsetDeviceHandle { name: MicrovmPortbHandle::ID.to_owned(), resource: MicrovmPortbHandle { io, generation_id, - restore_entropy, + restore_entropy: Vec::new(), output_drain: Some(output_drain), - time_abi, + time_abi: Some(time_abi), } .into_resource(), }); @@ -710,11 +683,7 @@ impl<'a> MicrovmConfigBuilder<'a> { memory_range::MemoryRange::new(range.gpa_start..range.gpa_start + range.length) }) .collect(); - if opt.microvm.x_time_abi_v1 { - anyhow::ensure!( - cfg.machine_profile == MachineProfile::Microvm, - "--x-time-abi-v1 requires the microVM machine profile" - ); + if cfg.machine_profile == MachineProfile::Microvm { let hooks = time_abi_test_hooks(opt)?; if hooks.active() { tracing::warn!(?hooks, "time ABI test hooks are active"); @@ -733,6 +702,13 @@ impl<'a> MicrovmConfigBuilder<'a> { generation, hooks, }); + } else { + anyhow::ensure!( + opt.microvm.cpu_profile.is_none() + && opt.microvm.x_time_abi_test_hook.is_empty() + && !opt.microvm.x_time_abi_verify, + "--cpu-profile, --x-time-abi-test-hook, and --x-time-abi-verify require the microVM machine profile" + ); } let requested_hypervisor = opt diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 0746456f517..1298e55b18f 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -145,11 +145,6 @@ impl MicrovmLaunch { }) } - /// Returns whether guest-requested snapshot capture is configured. - pub(crate) fn snapshot_capture_enabled(&self) -> bool { - self.snapshot_destination.is_some() - } - /// Duplicates the exact RAM handle used for guest-requested snapshot /// capture, as the VM worker's shared guest RAM. pub(crate) fn capture_shared_memory(&self) -> anyhow::Result> { diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 5ce9db91163..16a0d5c0172 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -41,13 +41,13 @@ pub(crate) use filesystem::validate_microvm_filesystem_private_storage; pub(crate) use launch::MicrovmLaunch; pub(crate) use restore::ExpectedRestoreContract; pub(crate) use restore::MicrovmRestore; -pub(crate) use restore::RestoreClock; +pub(crate) use restore::TimeAbiRestore; pub(crate) use restore::TimeAbiRestoreOptions; #[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use restore::fresh_microvm_generation_id; -#[cfg(any(feature = "ttrpc", feature = "grpc"))] -pub(crate) use restore::fresh_microvm_restore_packet; pub(crate) use restore::prepare_restore; +#[cfg(any(feature = "ttrpc", feature = "grpc"))] +pub(crate) use restore::restore_packet_base; pub(crate) use restore::validate_restore_contract; pub(crate) use verify::fatal_error_message; pub(crate) use verify::report_time_abi_verification; @@ -97,7 +97,7 @@ pub(crate) struct MicrovmResources { impl MicrovmResources { /// Returns the portb source of a time ABI restore packet with `base`, and /// keeps the restoring worker's ends of its channels. - fn time_abi_restore_packet( + pub(crate) fn time_abi_restore_packet( &mut self, base: chipset_resources::microvm_time::RestorePacketBase, ) -> chipset_resources::microvm::MicrovmRestorePacketSource { @@ -115,3 +115,32 @@ impl MicrovmResources { } } } + +/// Configures the chipset devices of a microVM for the time ABI: puts the PIT +/// in strict mode, which forbids a periodic channel 0 at capture and restore +/// (`E_PIT_ACTIVE`), and returns the portb device's time ABI configuration, +/// with the restore packet of a restore. +pub(crate) fn time_abi_chipset( + chipset_devices: &mut [vmotherboard::ChipsetDeviceHandle], + hooks: &virt::time_abi::TimeAbiTestHooks, + restore: Option, +) -> chipset_resources::microvm::MicrovmPortbTimeAbi { + use vm_resource::IntoResource; + use vm_resource::ResourceId; + + for device in chipset_devices.iter_mut() { + if device.name == chipset_resources::pit::PitDeviceHandle::ID { + device.resource = + chipset_resources::pit::PitDeviceHandle { time_abi: true }.into_resource(); + } + } + chipset_resources::microvm::MicrovmPortbTimeAbi { + generation: restore + .as_ref() + .map_or(0, |restore| restore.base.generation), + utc_offset_ms: hooks.utc_offset_ms, + sample_delay_us: hooks.sample_delay_us, + test_hooks: hooks.active(), + restore, + } +} diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 9a365b931e3..965ce63bd15 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -31,74 +31,10 @@ use openvmm_helpers::snapshot::microvm::SnapshotMemoryExpansionRange; use openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock; use openvmm_helpers::snapshot::restore::OpenedSnapshot; use std::path::Path; -use std::time::Duration; use virt::time_abi::HostIdentity; use virt::time_abi::TimeAbiTestHooks; -const MAX_SNAPSHOT_DOWNTIME: Duration = Duration::from_secs(30 * 24 * 60 * 60); - -fn calculate_snapshot_downtime( - capture_time: std::time::SystemTime, - destination_time: std::time::SystemTime, -) -> anyhow::Result { - let downtime = destination_time - .duration_since(capture_time) - .context("destination wall clock is before snapshot capture time")?; - anyhow::ensure!( - downtime <= MAX_SNAPSHOT_DOWNTIME, - "snapshot host downtime exceeds the supported 30-day bound" - ); - Ok(downtime) -} - -fn microvm_restore_packet( - entropy: &[u8; 64], - restore_online_vp_count: Option, - restore_memory_target_requested: bool, - restore_memory_ranges: &[SnapshotMemoryExpansionRange], -) -> anyhow::Result> { - anyhow::ensure!( - restore_memory_target_requested || restore_memory_ranges.is_empty(), - "restore memory ranges require an explicit memory target" - ); - let mut packet = if restore_memory_target_requested { - // V3 is: 19-byte NUL-terminated header, u8 online-VP target (zero - // means absent), u8 range count, repeated little-endian (u64 GPA, - // u64 length) pairs, and 64 bytes of entropy. The exact GPA ranges - // keep guest repair independent of the host's layout implementation. - let range_count = u8::try_from(restore_memory_ranges.len()) - .context("restore memory range count does not fit in u8")?; - let mut packet = b"OPENVMM_ENTROPY_V3\0".to_vec(); - let online_vp_count = restore_online_vp_count - .map(u8::try_from) - .transpose() - .context("restore-online VP count does not fit in u8")? - .unwrap_or(0); - packet.push(online_vp_count); - packet.push(range_count); - for range in restore_memory_ranges { - anyhow::ensure!(range.length != 0, "restore memory range is empty"); - range - .gpa_start - .checked_add(range.length) - .context("restore memory range overflows GPA space")?; - packet.extend_from_slice(&range.gpa_start.to_le_bytes()); - packet.extend_from_slice(&range.length.to_le_bytes()); - } - packet - } else if let Some(count) = restore_online_vp_count { - let count = u8::try_from(count).context("restore-online VP count does not fit in u8")?; - let mut packet = b"OPENVMM_ENTROPY_V2\0".to_vec(); - packet.push(count); - packet - } else { - b"OPENVMM_ENTROPY_V1\0".to_vec() - }; - packet.extend(entropy); - Ok(packet) -} - -fn microvm_generation_id(entropy: &[u8; 64]) -> [u8; 16] { +fn microvm_generation_id(entropy: &[u8; RESTORE_ENTROPY_LEN]) -> [u8; 16] { let mut generation_id = [0; 16]; generation_id.copy_from_slice(&entropy[..16]); generation_id @@ -110,25 +46,6 @@ pub(crate) fn fresh_microvm_generation_id() -> anyhow::Result<[u8; 16]> { Ok(generation_id) } -pub(crate) fn fresh_microvm_restore_packet( - restore_online_vp_count: Option, - restore_memory_target_requested: bool, - restore_memory_ranges: &[SnapshotMemoryExpansionRange], -) -> anyhow::Result<([u8; 16], Vec)> { - let generation_id_create = openvmm_defs::profile::ProfileSpan::start(); - let mut entropy = [0_u8; 64]; - getrandom::fill(&mut entropy).context("failed to generate restore entropy")?; - let generation_id = microvm_generation_id(&entropy); - let packet = microvm_restore_packet( - &entropy, - restore_online_vp_count, - restore_memory_target_requested, - restore_memory_ranges, - )?; - generation_id_create.complete("restore", "generation_id_create", Default::default()); - Ok((generation_id, packet)) -} - pub(super) fn align_legacy_network_policy_contract( saved: &SnapshotMachineContract, expected: &mut SnapshotMachineContract, @@ -210,45 +127,65 @@ impl MicrovmRestore { let (time, _) = self .time_abi_records() .context("time ABI restore requires the snapshot's time contract")?; - let generation = time.capture_generation.checked_add(1).ok_or_else(|| { - virt::time_abi::TimeAbiError::new( - virt::time_abi::TimeAbiCode::GenerationExhausted, - "the snapshot's generation counter cannot be incremented", - ) - })?; - anyhow::ensure!( - self.memory_target_requested || self.memory_ranges.is_empty(), - "restore memory ranges require an explicit memory target" - ); - let generation_id_create = openvmm_defs::profile::ProfileSpan::start(); - let mut entropy = [0_u8; RESTORE_ENTROPY_LEN]; - getrandom::fill(&mut entropy).context("failed to generate restore entropy")?; - let base = RestorePacketBase { - online_vp_count: restore_online_vp_count - .map(u8::try_from) - .transpose() - .context("restore-online VP count does not fit in u8")? - .unwrap_or(0), - memory_target: self.memory_target_requested, - ack_required: self.gate_required, - generation, - ranges: self - .memory_ranges - .iter() - .map(|range| RestoreMemoryRange { - gpa_start: range.gpa_start, - length: range.length, - }) - .collect(), - entropy, - }; - base.validate() - .context("restore packet version 4 is invalid")?; - generation_id_create.complete("restore", "generation_id_create", Default::default()); - Ok((microvm_generation_id(&entropy), base)) + restore_packet_base( + time, + restore_online_vp_count, + self.memory_target_requested, + &self.memory_ranges, + self.gate_required, + ) } } +/// Returns the controller's fields of restore packet version 4 for a time +/// ABI restore of a snapshot with the time contract `time`, and the +/// generation ID they imply: restore step 7 of the specification. The +/// generation counter is the snapshot's plus one, and `ack_required` makes +/// the guest acknowledge the restore through port `0x605`. +pub(crate) fn restore_packet_base( + time: &SnapshotTimeContract, + restore_online_vp_count: Option, + memory_target_requested: bool, + memory_ranges: &[SnapshotMemoryExpansionRange], + ack_required: bool, +) -> anyhow::Result<([u8; 16], RestorePacketBase)> { + let generation = time.capture_generation.checked_add(1).ok_or_else(|| { + virt::time_abi::TimeAbiError::new( + virt::time_abi::TimeAbiCode::GenerationExhausted, + "the snapshot's generation counter cannot be incremented", + ) + })?; + anyhow::ensure!( + memory_target_requested || memory_ranges.is_empty(), + "restore memory ranges require an explicit memory target" + ); + let generation_id_create = openvmm_defs::profile::ProfileSpan::start(); + let mut entropy = [0_u8; RESTORE_ENTROPY_LEN]; + getrandom::fill(&mut entropy).context("failed to generate restore entropy")?; + let base = RestorePacketBase { + online_vp_count: restore_online_vp_count + .map(u8::try_from) + .transpose() + .context("restore-online VP count does not fit in u8")? + .unwrap_or(0), + memory_target: memory_target_requested, + ack_required, + generation, + ranges: memory_ranges + .iter() + .map(|range| RestoreMemoryRange { + gpa_start: range.gpa_start, + length: range.length, + }) + .collect(), + entropy, + }; + base.validate() + .context("restore packet version 4 is invalid")?; + generation_id_create.complete("restore", "generation_id_create", Default::default()); + Ok((microvm_generation_id(&entropy), base)) +} + /// Validates a snapshot restore against the microVM profile and prepares the /// restore-time inputs of the microVM configuration. /// @@ -291,10 +228,7 @@ pub(crate) fn prepare_restore( "snapshot machine profile does not match the requested microVM machine" ); openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; - openvmm_helpers::snapshot::time::check_time_abi_manifest_version( - manifest, - opt.microvm.x_time_abi_v1, - )?; + openvmm_helpers::snapshot::time::check_time_abi_manifest_version(manifest)?; if let Some(time) = &contract.time { openvmm_helpers::snapshot::time::validate_time_contract(time)?; } @@ -402,21 +336,6 @@ pub(crate) fn prepare_restore( } else { None }; - // A time ABI restore always exposes a restore packet with fresh entropy. - if restore_gate_required - || opt.microvm.restore_processors.is_some() - || (opt.microvm.x_time_abi_v1 && restore_machine_contract.is_some()) - { - opt.microvm.restore_entropy = true; - } - if restore_machine_contract.is_some() - && !opt.microvm.restore_entropy - && !restore_memory_target_requested - { - tracing::warn!( - "restoring cloned guest RNG state without fresh entropy injection; cryptographic workloads are unsafe" - ); - } Ok(MicrovmRestore { machine_contract: restore_machine_contract, gate_required: restore_gate_required, @@ -447,10 +366,6 @@ pub(crate) type ExpectedRestoreContract<'a> = ( Vec, ); -/// Clock and CPU state recorded at the capture boundary of a restored -/// microVM: host downtime, TSC and APIC frequencies, and the CPU contract. -pub(crate) type RestoreTime = (Duration, u64, Option, Vec); - /// The time ABI options of a microVM restore. #[derive(Clone, Copy)] pub(crate) struct TimeAbiRestoreOptions<'a> { @@ -460,15 +375,6 @@ pub(crate) struct TimeAbiRestoreOptions<'a> { pub(crate) hooks: &'a TimeAbiTestHooks, } -/// The clock inputs of a restored microVM's worker. -pub(crate) enum RestoreClock { - /// The clock fields of manifest versions before 6. - Legacy(RestoreTime), - /// The time ABI records of a version 6 manifest, after the controller's - /// time ABI preflight. - TimeAbi(Box), -} - /// A time ABI restore validated by the controller: restore steps 1 to 6 of /// the specification. pub(crate) struct TimeAbiRestore { @@ -478,6 +384,18 @@ pub(crate) struct TimeAbiRestore { } impl TimeAbiRestore { + /// Returns the snapshot's time contract. + #[cfg(any(feature = "ttrpc", feature = "grpc"))] + pub(crate) fn contract(&self) -> &SnapshotTimeContract { + &self.contract + } + + /// Returns the snapshot's CPU profile record. + #[cfg(any(feature = "ttrpc", feature = "grpc"))] + pub(crate) fn cpu_profile(&self) -> &SnapshotCpuProfile { + &self.cpu_profile + } + /// Returns the worker's restore input. The worker seals the time fields /// of the restore packet through `restore_record`, and `packet_selected`, /// when present, reports the guest's first selection of the packet. @@ -499,9 +417,9 @@ impl TimeAbiRestore { /// Validates the authoritative machine contract of a microVM snapshot against /// the restore-time configuration. /// -/// With `time_abi`, the manifest must be version 6, and the time ABI -/// preflight checks its records, the backend, the CPU profile, and the -/// downtime against this host before the rest of the contract is compared. +/// The manifest must be version 6: the time ABI preflight checks its records, +/// the backend, the CPU profile, and the downtime against this host before +/// the rest of the contract is compared. pub(crate) fn validate_restore_contract( manifest: &SnapshotManifest, expected_memory_size: u64, @@ -515,16 +433,20 @@ pub(crate) fn validate_restore_contract( control_console_attachment, sandbox_blocks, ): ExpectedRestoreContract<'_>, - time_abi: Option>, -) -> anyhow::Result { + time_abi: TimeAbiRestoreOptions<'_>, +) -> anyhow::Result { let saved_contract = manifest .machine_contract .as_ref() .context("microVM snapshot is missing its authoritative machine contract")?; - openvmm_helpers::snapshot::time::check_time_abi_manifest_version(manifest, time_abi.is_some())?; - let time_abi_destination = time_abi - .map(|options| preflight_time_abi_restore(saved_contract, expected_hypervisor, options)) - .transpose()?; + openvmm_helpers::snapshot::time::check_time_abi_manifest_version(manifest)?; + let destination = preflight_time_abi_restore(saved_contract, expected_hypervisor, time_abi)?; + let (Some(contract), Some(cpu_profile)) = ( + saved_contract.time.clone(), + saved_contract.cpu_profile.clone(), + ) else { + unreachable!("the time ABI preflight requires both records"); + }; let network = network.map(|(config, policy, attachment)| (config, policy, attachment.clone())); let filesystem_slot = microvm_filesystem_slot_from_snapshot(saved_contract)?; let filesystem = saved_contract @@ -532,18 +454,6 @@ pub(crate) fn validate_restore_contract( .as_ref() .and(filesystem) .map(|(config, root_path, attachment)| (config, root_path, attachment.clone())); - let clock = match (&saved_contract.time, &saved_contract.cpu_profile) { - (Some(time), Some(cpu_profile)) => MicrovmClockContract::TimeAbi { - time: time.clone(), - cpu_profile: cpu_profile.clone(), - }, - _ => MicrovmClockContract::Legacy { - capture_wall_clock: saved_contract.capture_wall_clock, - tsc_frequency_hz: saved_contract.tsc_frequency_hz, - apic_frequency_hz: saved_contract.apic_frequency_hz, - cpu_contract: saved_contract.cpu_contract.clone(), - }, - }; let mut expected_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_clock( expected_hypervisor, @@ -560,7 +470,10 @@ pub(crate) fn validate_restore_contract( (saved_contract.memory_expansion_version != 0) .then_some(saved_contract.memory_capacity_bytes), saved_contract.state_unit_names.clone(), - clock, + MicrovmClockContract::TimeAbi { + time: contract.clone(), + cpu_profile: cpu_profile.clone(), + }, )?; align_legacy_network_policy_contract(saved_contract, &mut expected_contract); align_restore_console_listener_contract(saved_contract, &mut expected_contract); @@ -568,30 +481,11 @@ pub(crate) fn validate_restore_contract( manifest, &expected_contract, )?; - if let Some(destination) = time_abi_destination { - let (Some(contract), Some(cpu_profile)) = ( - saved_contract.time.clone(), - saved_contract.cpu_profile.clone(), - ) else { - unreachable!("the time ABI preflight requires both records"); - }; - return Ok(RestoreClock::TimeAbi(Box::new(TimeAbiRestore { - contract, - cpu_profile, - destination, - }))); - } - let capture_time: std::time::SystemTime = saved_contract - .capture_wall_clock - .try_into() - .context("snapshot capture wall clock is invalid")?; - let downtime = calculate_snapshot_downtime(capture_time, std::time::SystemTime::now())?; - Ok(RestoreClock::Legacy(( - downtime, - saved_contract.tsc_frequency_hz, - saved_contract.apic_frequency_hz, - saved_contract.cpu_contract.clone(), - ))) + Ok(TimeAbiRestore { + contract, + cpu_profile, + destination, + }) } /// Runs the controller's time ABI preflight (restore steps 2 to 5 of the @@ -657,107 +551,11 @@ mod tests { } #[test] - fn snapshot_downtime_accepts_supported_elapsed_time() { - let capture = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1); - - assert_eq!( - calculate_snapshot_downtime(capture, capture).unwrap(), - Duration::ZERO - ); - assert_eq!( - calculate_snapshot_downtime(capture, capture + MAX_SNAPSHOT_DOWNTIME).unwrap(), - MAX_SNAPSHOT_DOWNTIME - ); - } - - #[test] - fn snapshot_downtime_rejects_rollback_and_excessive_elapsed_time() { - let capture = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1); - - let rollback = - calculate_snapshot_downtime(capture, std::time::SystemTime::UNIX_EPOCH).unwrap_err(); - assert!( - rollback - .to_string() - .contains("before snapshot capture time") - ); - - let excessive = calculate_snapshot_downtime( - capture, - capture + MAX_SNAPSHOT_DOWNTIME + Duration::from_secs(1), - ) - .unwrap_err(); - assert!(excessive.to_string().contains("exceeds the supported")); - } - - #[test] - fn restore_packet_versions_preserve_entropy_and_online_target() { - let entropy = [0x5a; 64]; + fn generation_id_is_the_entropy_prefix() { + let mut entropy = [0x5a; RESTORE_ENTROPY_LEN]; + entropy[16] = 0xa5; assert_eq!(microvm_generation_id(&entropy), [0x5a; 16]); - let v1 = microvm_restore_packet(&entropy, None, false, &[]).unwrap(); - assert_eq!(&v1[..19], b"OPENVMM_ENTROPY_V1\0"); - assert_eq!(&v1[19..], &entropy); - - let v2 = microvm_restore_packet(&entropy, Some(8), false, &[]).unwrap(); - assert_eq!(&v2[..19], b"OPENVMM_ENTROPY_V2\0"); - assert_eq!(v2[19], 8); - assert_eq!(&v2[20..], &entropy); - - let ranges = [ - SnapshotMemoryExpansionRange { - gpa_start: 0x2000_0000, - length: 0x2000_0000, - }, - SnapshotMemoryExpansionRange { - gpa_start: 0x1_0000_0000, - length: 0x4000_0000, - }, - ]; - let v3 = microvm_restore_packet(&entropy, Some(4), true, &ranges).unwrap(); - assert_eq!(&v3[..19], b"OPENVMM_ENTROPY_V3\0"); - assert_eq!(v3[19], 4); - assert_eq!(v3[20], 2); - assert_eq!( - &v3[21..37], - &[ - ranges[0].gpa_start.to_le_bytes(), - ranges[0].length.to_le_bytes(), - ] - .concat() - ); - assert_eq!( - &v3[37..53], - &[ - ranges[1].gpa_start.to_le_bytes(), - ranges[1].length.to_le_bytes(), - ] - .concat() - ); - assert_eq!(&v3[53..], &entropy); - - let v3_without_cpu = microvm_restore_packet(&entropy, None, true, &ranges[..1]).unwrap(); - assert_eq!(&v3_without_cpu[..19], b"OPENVMM_ENTROPY_V3\0"); - assert_eq!(v3_without_cpu[19], 0); - assert_eq!(v3_without_cpu[20], 1); - assert_eq!( - &v3_without_cpu[21..37], - &[ - ranges[0].gpa_start.to_le_bytes(), - ranges[0].length.to_le_bytes(), - ] - .concat() - ); - assert_eq!(&v3_without_cpu[37..], &entropy); - - let v3_explicit_base = microvm_restore_packet(&entropy, None, true, &[]).unwrap(); - assert_eq!(&v3_explicit_base[..19], b"OPENVMM_ENTROPY_V3\0"); - assert_eq!(v3_explicit_base[19], 0); - assert_eq!(v3_explicit_base[20], 0); - assert_eq!(&v3_explicit_base[21..], &entropy); - - assert!(microvm_restore_packet(&entropy, None, false, &ranges[..1]).is_err()); } - fn time_abi_restore(capture_generation: u32) -> MicrovmRestore { let mut contract: SnapshotMachineContract = mesh::payload::decode(&[]).unwrap(); contract.time = Some(SnapshotTimeContract { diff --git a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs index 2dc1345c37a..59760e19e6e 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/mod.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/mod.rs @@ -31,14 +31,12 @@ pub(crate) use ready::restore_ready_sink; use crate::Options; use crate::microvm::MicrovmLaunch; -use crate::microvm::RestoreClock; use anyhow::Context; use mesh::payload::message::ProtobufMessage; use openvmm_defs::profile::ProfileSpan; use openvmm_defs::worker::SharedMemoryFd; use openvmm_defs::worker::SnapshotRestoreGuards; use openvmm_helpers::snapshot::restore::OpenedSnapshot; -use std::time::Duration; /// The snapshot restore of a VM launched from the command line. pub(crate) struct SnapshotRestore { @@ -55,14 +53,6 @@ pub(crate) struct WorkerRestore { pub(crate) shared_memory_copy_on_write: bool, /// Snapshot generation handles that must outlive the restored VM. pub(crate) guards: Option, - /// Host downtime to apply before starting a restored microVM. - pub(crate) downtime: Option, - /// Saved effective TSC frequency of a restored microVM. - pub(crate) tsc_frequency_hz: Option, - /// Saved local APIC timer frequency of a restored microVM. - pub(crate) apic_frequency_hz: Option, - /// Saved canonical CPU contract of a restored microVM. - pub(crate) cpu_contract: Option>, /// The time ABI inputs of a restored microVM, validated by the /// controller's restore preflight. pub(crate) time: Option, @@ -128,26 +118,12 @@ impl SnapshotRestore { )?; self.worker.shared_memory_copy_on_write = true; self.worker.guards = Some(prepared.guards); - match prepared.restore_time { - Some(RestoreClock::Legacy(( - downtime, - tsc_frequency_hz, - apic_frequency_hz, - cpu_contract, - ))) => { - self.worker.downtime = Some(downtime); - self.worker.tsc_frequency_hz = Some(tsc_frequency_hz); - self.worker.apic_frequency_hz = apic_frequency_hz; - self.worker.cpu_contract = Some(cpu_contract); - } - Some(RestoreClock::TimeAbi(time)) => { - let restore_record = microvm - .take_restore_time_record() - .context("time ABI restore is missing its restore packet")?; - let packet_selected = microvm.take_restore_packet_selected(); - self.worker.time = Some(time.into_input(restore_record, packet_selected)); - } - None => {} + if let Some(time) = prepared.restore_time { + let restore_record = microvm + .take_restore_time_record() + .context("time ABI restore is missing its restore packet")?; + let packet_selected = microvm.take_restore_packet_selected(); + self.worker.time = Some(time.into_input(restore_record, packet_selected)); } Ok((prepared.shared_memory, prepared.saved_state)) } diff --git a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs index 5d03e078fac..3a79741f757 100644 --- a/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs +++ b/openvmm/openvmm_entry/src/snapshot_restore/prepare.rs @@ -14,7 +14,8 @@ pub(crate) struct PreparedSnapshotRestore { pub(crate) shared_memory: openvmm_defs::worker::SharedMemoryFd, pub(crate) guards: openvmm_defs::worker::SnapshotRestoreGuards, pub(crate) saved_state: mesh::payload::message::ProtobufMessage, - pub(crate) restore_time: Option, + /// The time ABI restore of a microVM. + pub(crate) restore_time: Option, } /// Validate an opened snapshot generation against the current VM config. @@ -29,33 +30,31 @@ pub(super) fn prepare_snapshot_restore( let expected_microvm_contract = microvm.expected_restore_contract(opt, snapshot.manifest(), expected_hypervisor)?; let hooks = virt::time_abi::TimeAbiTestHooks::parse(&opt.microvm.x_time_abi_test_hook)?; - let time_abi = opt - .microvm - .x_time_abi_v1 - .then(|| microvm::TimeAbiRestoreOptions { - cpu_profile: opt.microvm.cpu_profile.as_deref().unwrap_or("auto"), - hooks: &hooks, - }); + let time_abi = microvm::TimeAbiRestoreOptions { + cpu_profile: opt.microvm.cpu_profile.as_deref().unwrap_or("auto"), + hooks: &hooks, + }; prepare_snapshot_restore_for_config( snapshot, base_memory_size, opt.memory_size(), opt.processors, - expected_microvm_contract, - time_abi, + expected_microvm_contract.map(|contract| (contract, time_abi)), ) } /// Validates an opened snapshot generation against a VM configuration and -/// prepares it for the VM worker. `time_abi` selects the time ABI restore -/// path of a microVM. +/// prepares it for the VM worker. A microVM restore passes the contract the +/// snapshot must match and the options of its time ABI restore. pub(crate) fn prepare_snapshot_restore_for_config( snapshot: openvmm_helpers::snapshot::restore::OpenedSnapshot, expected_memory_size: u64, selected_memory_size: u64, expected_vp_count: u32, - expected_microvm_contract: Option>, - time_abi: Option>, + microvm: Option<( + microvm::ExpectedRestoreContract<'_>, + microvm::TimeAbiRestoreOptions<'_>, + )>, ) -> anyhow::Result { let artifact_prepare = openvmm_defs::profile::ProfileSpan::start(); let manifest = snapshot.manifest(); @@ -67,8 +66,8 @@ pub(crate) fn prepare_snapshot_restore_for_config( expected_vp_count, crate::system_page_size(), )?; - let restore_time = expected_microvm_contract - .map(|contract| { + let restore_time = microvm + .map(|(contract, time_abi)| { microvm::validate_restore_contract( manifest, expected_memory_size, diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index 3c1ffcf03a5..a29f66c05d8 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -45,7 +45,6 @@ use vm_resource::kind::SerialBackendHandle; use vm_resource::kind::VirtioDeviceHandle; use vmotherboard::ChipsetDeviceHandle; -type RestoreTime = (Duration, u64, Option, Vec); type SerialPorts = [Option>; 4]; struct AuthoritativeRestore { @@ -75,7 +74,6 @@ pub(super) struct CreateVm { restore_filesystem_config: Option, restored_filesystem: Option, snapshot_destination: Option, - restore_entropy: bool, restore_online_vp_count: Option, restore_gate_timeout: Duration, snapshot_quiesce_timeout: Duration, @@ -85,7 +83,8 @@ pub(super) struct CreateVm { shared_memory: Option, snapshot_restore_guards: Option, saved_state: Option, - restore_time: Option, + /// The time ABI restore validated by the restore preflight. + restore_time: Option, snapshot_notify: Option>, snapshot_ready: Option>, snapshot_requests: Option>, @@ -335,7 +334,6 @@ impl CreateVm { restore_filesystem_config, restored_filesystem: None, snapshot_destination, - restore_entropy, restore_online_vp_count, restore_gate_timeout, snapshot_quiesce_timeout, @@ -472,6 +470,9 @@ impl CreateVm { restore.machine_contract.microvm_network.is_none(), "ttrpc restore does not yet expose microVM network attachments" ); + // The management endpoint restores with the snapshot's CPU + // profile and no time ABI test hooks. + let hooks = virt::time_abi::TimeAbiTestHooks::default(); let prepared = crate::snapshot_restore::prepare_snapshot_restore_for_config( restore .snapshot @@ -481,43 +482,34 @@ impl CreateVm { restore.selected_memory_size, restore.vp_count, Some(( - &self.source_hypervisor, - &restore.machine_contract.effective_command_line, - None, - self.restored_filesystem.as_ref().map(|filesystem| { - ( - &filesystem.config, - Path::new(&filesystem.root_path), - &filesystem.attachment, - ) - }), - restore - .machine_contract - .attachments - .iter() - .find(|attachment| attachment.stable_id == "console:microvm-virtio0"), - None, - restore.machine_contract.microvm_sandbox_blocks.clone(), + ( + &self.source_hypervisor, + &restore.machine_contract.effective_command_line, + None, + self.restored_filesystem.as_ref().map(|filesystem| { + ( + &filesystem.config, + Path::new(&filesystem.root_path), + &filesystem.attachment, + ) + }), + restore + .machine_contract + .attachments + .iter() + .find(|attachment| attachment.stable_id == "console:microvm-virtio0"), + None, + restore.machine_contract.microvm_sandbox_blocks.clone(), + ), + crate::microvm::TimeAbiRestoreOptions { + cpu_profile: "auto", + hooks: &hooks, + }, )), - None, )?; - let restore_time = match prepared + let restore_time = prepared .restore_time - .context("microVM snapshot is missing its restore-time contract")? - { - crate::microvm::RestoreClock::Legacy(restore_time) => restore_time, - crate::microvm::RestoreClock::TimeAbi(_) => { - anyhow::bail!("ttrpc restore does not support time ABI snapshots yet") - } - }; - if !self.restore_entropy - && self.restore_online_vp_count.is_none() - && !restore.restore_memory_target_requested - { - tracing::warn!( - "restoring cloned guest RNG state without fresh entropy injection; cryptographic workloads are unsafe" - ); - } + .context("microVM snapshot is missing its time ABI records")?; self.shared_memory = Some(prepared.shared_memory); self.snapshot_restore_guards = Some(prepared.guards); self.saved_state = Some(prepared.saved_state); @@ -729,36 +721,41 @@ impl CreateVm { self.resources.output_drain = Some(drain); requests }); - let restore_memory_ranges = self - .authoritative_restore - .as_ref() - .map(|restore| restore.restore_memory_ranges.as_slice()) - .unwrap_or_default(); - let restore_memory_target_requested = self - .authoritative_restore - .as_ref() - .is_some_and(|restore| restore.restore_memory_target_requested); - let (generation_id, restore_entropy) = if self.restore_entropy - || self.restore_online_vp_count.is_some() - || restore_memory_target_requested - { - crate::microvm::fresh_microvm_restore_packet( - self.restore_online_vp_count, - restore_memory_target_requested, - restore_memory_ranges, - )? - } else { - (crate::microvm::fresh_microvm_generation_id()?, Vec::new()) + let gate_required = self.restore_gate_required(); + let (generation_id, restore) = match &self.restore_time { + Some(restore_time) => { + let restore = self + .authoritative_restore + .as_ref() + .context("time ABI restore is missing its snapshot")?; + let (generation_id, base) = crate::microvm::restore_packet_base( + restore_time.contract(), + self.restore_online_vp_count, + restore.restore_memory_target_requested, + &restore.restore_memory_ranges, + gate_required, + )?; + ( + generation_id, + Some(self.resources.time_abi_restore_packet(base)), + ) + } + None => (crate::microvm::fresh_microvm_generation_id()?, None), }; + let time_abi = crate::microvm::time_abi_chipset( + devices, + &virt::time_abi::TimeAbiTestHooks::default(), + restore, + ); devices.extend([ ChipsetDeviceHandle { name: MicrovmPortbHandle::ID.to_owned(), resource: MicrovmPortbHandle { io, generation_id, - restore_entropy, + restore_entropy: Vec::new(), output_drain, - time_abi: None, + time_abi: Some(time_abi), } .into_resource(), }, @@ -815,6 +812,20 @@ impl CreateVm { })); config.hypervisor.with_hv = false; config.vmbus = None; + // A restore uses the snapshot's CPU profile and the next + // generation counter; a cold boot selects the host's profile. + let (cpu_profile, generation) = match &self.restore_time { + Some(restore_time) => ( + restore_time.cpu_profile().id.clone(), + restore_time.contract().capture_generation.saturating_add(1), + ), + None => ("auto".to_owned(), 0), + }; + config.microvm.time_abi = Some(openvmm_defs::time_abi::TimeAbiParameters { + cpu_profile, + generation, + hooks: Default::default(), + }); } config.microvm.filesystem_bootstrap = self .authoritative_restore @@ -1240,12 +1251,24 @@ impl CreateVm { file, )?); } - let restore_gate_timeout = (self.restore_online_vp_count.is_some() - || self - .authoritative_restore - .as_ref() - .is_some_and(|restore| !restore.restore_memory_ranges.is_empty())) - .then_some(self.restore_gate_timeout); + let restore_gate_timeout = self + .restore_gate_required() + .then_some(self.restore_gate_timeout); + let restore_time = self + .restore_time + .take() + .map(|restore_time| -> anyhow::Result<_> { + let restore_record = self + .resources + .restore_time_record + .take() + .context("time ABI restore is missing its restore packet")?; + Ok(restore_time.into_input( + restore_record, + self.resources.restore_packet_selected.take(), + )) + }) + .transpose()?; Ok(WorkerFields { saved_state: self.saved_state.take(), shared_memory: self.shared_memory.take(), @@ -1253,13 +1276,23 @@ impl CreateVm { snapshot_restore_guards: self.snapshot_restore_guards.take(), snapshot_boundary_requests: self.resources.snapshot_requests.take(), snapshot_ready: self.snapshot_ready.take(), - snapshot_capture_enabled: self.snapshot_destination.is_some(), - restore_time: self.restore_time.take(), + restore_time, restore_gate_timeout, restore_vp_count: self.restore_online_vp_count, }) } + /// Returns whether a restore arms the post-restore input gate, which the + /// guest acknowledges through port `0x605`: with a restore-time VP or RAM + /// target. + fn restore_gate_required(&self) -> bool { + self.restore_online_vp_count.is_some() + || self + .authoritative_restore + .as_ref() + .is_some_and(|restore| !restore.restore_memory_ranges.is_empty()) + } + /// Converts the remaining state into VM-controller launch fields. pub(super) fn into_controller(self) -> ControllerFields { ControllerFields { @@ -1293,8 +1326,7 @@ pub(super) struct WorkerFields { pub(super) snapshot_restore_guards: Option, pub(super) snapshot_boundary_requests: Option>, pub(super) snapshot_ready: Option>, - pub(super) snapshot_capture_enabled: bool, - pub(super) restore_time: Option, + pub(super) restore_time: Option, pub(super) restore_gate_timeout: Option, pub(super) restore_vp_count: Option, } diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index d3acf3651b5..9c4157541bc 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1367,23 +1367,7 @@ impl VmService { snapshot_restore_guards: worker_fields.snapshot_restore_guards, snapshot_boundary_requests: worker_fields.snapshot_boundary_requests, snapshot_ready: worker_fields.snapshot_ready, - snapshot_capture_enabled: worker_fields.snapshot_capture_enabled, - restore_downtime: worker_fields - .restore_time - .as_ref() - .map(|(downtime, _, _, _)| *downtime), - restore_tsc_frequency_hz: worker_fields - .restore_time - .as_ref() - .map(|(_, frequency, _, _)| *frequency), - restore_apic_frequency_hz: worker_fields - .restore_time - .as_ref() - .and_then(|(_, _, frequency, _)| *frequency), - restore_cpu_contract: worker_fields - .restore_time - .map(|(_, _, _, cpu_contract)| cpu_contract), - restore_time: None, + restore_time: worker_fields.restore_time, restore_ready_sink, restore_gate_timeout: worker_fields.restore_gate_timeout, restore_vp_count: worker_fields.restore_vp_count, diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 4832a85c997..e4a83772ea6 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -296,26 +296,12 @@ impl VmController { block.identity.clear(); } } - let (clock, version, format_magic) = match response.time { - Some(time) => ( - openvmm_helpers::snapshot::microvm::MicrovmClockContract::TimeAbi { - time: time.time, - cpu_profile: time.cpu_profile, - }, - openvmm_helpers::snapshot::format::TIME_ABI_MANIFEST_VERSION, - openvmm_helpers::snapshot::format::TIME_ABI_SNAPSHOT_FORMAT_MAGIC, - ), - None => ( - openvmm_helpers::snapshot::microvm::MicrovmClockContract::Legacy { - capture_wall_clock: response.capture_wall_clock, - tsc_frequency_hz: response.tsc_frequency_hz, - apic_frequency_hz: Some(response.apic_frequency_hz), - cpu_contract: response.cpu_contract, - }, - openvmm_helpers::snapshot::MANIFEST_VERSION, - openvmm_helpers::snapshot::format::SNAPSHOT_FORMAT_MAGIC, - ), + let clock = openvmm_helpers::snapshot::microvm::MicrovmClockContract::TimeAbi { + time: response.time.time, + cpu_profile: response.time.cpu_profile, }; + let version = openvmm_helpers::snapshot::format::TIME_ABI_MANIFEST_VERSION; + let format_magic = openvmm_helpers::snapshot::format::TIME_ABI_SNAPSHOT_FORMAT_MAGIC; let machine_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_clock( &self.microvm.source_hypervisor, diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index 77b10f9c370..e48347c6915 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -193,27 +193,19 @@ pub fn set_time_abi_records( Ok(()) } -/// Requires the manifest version that matches the selected restore path: -/// version 6 with the time ABI, and a version before 6 without it. -pub fn check_time_abi_manifest_version( - manifest: &SnapshotManifest, - time_abi: bool, -) -> Result<(), TimeAbiError> { - let is_time_abi = manifest.version == TIME_ABI_MANIFEST_VERSION; - match (time_abi, is_time_abi) { - (true, false) => Err(TimeAbiError::new( - TimeAbiCode::SnapshotVersion, - format!( - "snapshot manifest version {} predates the time ABI; recapture the snapshot", - manifest.version - ), - )), - (false, true) => Err(TimeAbiError::new( - TimeAbiCode::SnapshotVersion, - "a time ABI snapshot requires --x-time-abi-v1", - )), - _ => Ok(()), - } +/// Requires manifest version 6, the time ABI's: every microVM restore uses +/// the time ABI (`E_SNAPSHOT_VERSION`). +pub fn check_time_abi_manifest_version(manifest: &SnapshotManifest) -> Result<(), TimeAbiError> { + if manifest.version == TIME_ABI_MANIFEST_VERSION { + return Ok(()); + } + Err(TimeAbiError::new( + TimeAbiCode::SnapshotVersion, + format!( + "snapshot manifest version {} predates the time ABI; recapture the snapshot", + manifest.version + ), + )) } /// The controller's view of a time ABI restore after restore steps 2 to 5 of @@ -597,19 +589,12 @@ pub(super) mod tests { } #[test] - fn manifest_version_must_match_the_restore_path() { - let version_6 = version_6_manifest(); + fn restore_requires_manifest_version_6() { + check_time_abi_manifest_version(&version_6_manifest()).unwrap(); let version_5 = crate::snapshot::tests::test_manifest(); - check_time_abi_manifest_version(&version_6, true).unwrap(); - check_time_abi_manifest_version(&version_5, false).unwrap(); - for (manifest, time_abi) in [(&version_5, true), (&version_6, false)] { - assert_eq!( - check_time_abi_manifest_version(manifest, time_abi) - .unwrap_err() - .code, - TimeAbiCode::SnapshotVersion - ); - } + let err = check_time_abi_manifest_version(&version_5).unwrap_err(); + assert_eq!(err.code, TimeAbiCode::SnapshotVersion); + assert!(err.to_string().contains("recapture the snapshot"), "{err}"); } /// A version 6 machine contract captured on WHP with the test profile, diff --git a/petri/src/vm/openvmm/construct.rs b/petri/src/vm/openvmm/construct.rs index 6a232257c6c..07f0fb5f3ce 100644 --- a/petri/src/vm/openvmm/construct.rs +++ b/petri/src/vm/openvmm/construct.rs @@ -669,7 +669,7 @@ impl PetriVmConfigOpenVmm { let config = Config { // Firmware machine_profile, - microvm: Default::default(), + microvm: super::microvm::microvm_config(machine_profile), load_mode, firmware_event_send: super::microvm::firmware_event_send( machine_profile, diff --git a/petri/src/vm/openvmm/microvm.rs b/petri/src/vm/openvmm/microvm.rs index f708dfd474b..11502eb382a 100644 --- a/petri/src/vm/openvmm/microvm.rs +++ b/petri/src/vm/openvmm/microvm.rs @@ -9,14 +9,18 @@ use crate::Firmware; use crate::PetriLogFile; use anyhow::Context; use chipset_resources::microvm::MicrovmPortbHandle; +use chipset_resources::microvm::MicrovmPortbTimeAbi; use chipset_resources::microvm::MicrovmShutdownHandle; use chipset_resources::microvm::MicrovmSnapshotRequestHandle; +use chipset_resources::pit::PitDeviceHandle; use futures::AsyncWriteExt; use openvmm_defs::config::Config; use openvmm_defs::config::LinuxDirectBootMode; use openvmm_defs::config::LinuxIsolationConfig; use openvmm_defs::config::LoadMode; use openvmm_defs::microvm::MachineProfile; +use openvmm_defs::microvm::MicrovmConfig; +use openvmm_defs::time_abi::TimeAbiParameters; use pal_async::DefaultDriver; use pal_async::socket::PolledSocket; use pal_async::socket::WriteHalf; @@ -44,6 +48,19 @@ pub(super) fn is_enabled(machine_profile: MachineProfile) -> bool { machine_profile == MachineProfile::Microvm } +/// Returns the microVM configuration of a VM: every microVM uses the NVX time +/// ABI, with the host's CPU profile. +pub(super) fn microvm_config(machine_profile: MachineProfile) -> MicrovmConfig { + MicrovmConfig { + time_abi: is_enabled(machine_profile).then(|| TimeAbiParameters { + cpu_profile: "auto".to_owned(), + generation: 0, + hooks: Default::default(), + }), + ..Default::default() + } +} + pub(super) fn validate_profile( machine_profile: MachineProfile, arch: MachineArch, @@ -143,6 +160,12 @@ pub(super) fn attach_chipset_devices( Some(PortbSerial { io, runtime }) => (io, Some(runtime)), None => (DisconnectedSerialBackendHandle.into_resource(), None), }; + // The time ABI puts the PIT in strict mode. + for device in chipset_devices.iter_mut() { + if device.name == PitDeviceHandle::ID { + device.resource = PitDeviceHandle { time_abi: true }.into_resource(); + } + } chipset_devices.extend([ ChipsetDeviceHandle { name: MicrovmPortbHandle::ID.to_owned(), @@ -151,7 +174,13 @@ pub(super) fn attach_chipset_devices( generation_id: [0x5a; 16], restore_entropy: Vec::new(), output_drain: None, - time_abi: None, + time_abi: Some(MicrovmPortbTimeAbi { + generation: 0, + utc_offset_ms: 0, + sample_delay_us: 0, + test_hooks: false, + restore: None, + }), } .into_resource(), }, diff --git a/petri/src/worker.rs b/petri/src/worker.rs index 45031929502..297722662a7 100644 --- a/petri/src/worker.rs +++ b/petri/src/worker.rs @@ -43,11 +43,6 @@ impl Worker { snapshot_restore_guards: None, snapshot_boundary_requests: None, snapshot_ready: None, - snapshot_capture_enabled: false, - restore_downtime: None, - restore_tsc_frequency_hz: None, - restore_apic_frequency_hz: None, - restore_cpu_contract: None, restore_time: None, restore_ready_sink: None, restore_gate_timeout: None, diff --git a/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs b/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs index 7283642bf71..9a899ff49d1 100644 --- a/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs +++ b/vmm_tests/vmm_tests/tests/tests/ttrpc/microvm.rs @@ -364,14 +364,16 @@ if [ $((status & 8)) -eq 0 ]; then while :; do sleep 1; done fi write_port 234 165 -index=0 -while [ "$index" -lt 19 ]; do - read_port 233 >/dev/null - index=$((index + 1)) -done +magic="$(read_port 233) $(read_port 233) $(read_port 233) $(read_port 233)" +flags=$(read_port 233) target=$(read_port 233) range_count=$(read_port 233) -remaining=$((range_count * 16 + 64)) +if [ "$magic" != "79 86 82 4" ] || [ $((flags & 4)) -eq 0 ]; then +{} + write_port 1540 255 + while :; do sleep 1; done +fi +remaining=$((25 + range_count * 16 + 64)) index=0 while [ "$index" -lt "$remaining" ]; do read_port 233 >/dev/null @@ -420,6 +422,7 @@ done portb_output(READY_MARKER)?, portb_output(SNAPSHOT_CONTINUED_MARKER)?, portb_output(b"RESTORE-PACKET-MISSING")?, + portb_output(b"RESTORE-PACKET-INVALID")?, portb_output(b"RESTORE-TARGET-INVALID")?, portb_output(b"RESTORE-TARGET=1 MEMORY-RANGES=0")?, portb_output(b"RESTORE-TARGET=1 MEMORY-RANGES=1")?, From 6c1aa27d7ef55481b2a8d10281919a5358373fa5 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 04:19:17 -0700 Subject: [PATCH 256/304] virt_kvm: delete the legacy microVM time code With the time ABI the only microVM time path, the KVM pieces of the old clock contract are dead: - the BSP TSC frequency CPUID leaves (tsc.rs add_frequency_leaves), the partition clock hooks (tsc_frequency_hz, set_tsc_frequency_hz, apic_frequency_hz, advance_snapshot_time with KVM_GET_CLOCK and KVM_SET_CLOCK), and the per-VP TSC offset advance, with their tests; - the kvm-clock MSR state saved with the synthetic MSRs (vp_state/kvm_clock.rs), and the versioned-contract exposure of KVM's hypervisor leaves and hypervisor bit (cpu_contract.rs); - KVM_SET_TSC_KHZ in the kvm crate, and the SnapshotClockOverflow and TscFrequencyCpuid errors. KVM's hypervisor CPUID leaves are always removed again, as before the versioned contract. The time ABI no longer checks the kvm_clock capability, which core deletes, and CET-SS stays hidden. The Hyper-V reference time keeps using KVM_GET_CLOCK and KVM_SET_CLOCK. cpu_compatibility_contract stays until core deletes the trait method, which has no default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vm/kvm/src/lib.rs | 17 -- .../virt_kvm/src/arch/x86_64/cpu_contract.rs | 53 +----- vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 40 +---- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 18 ++- vmm_core/virt_kvm/src/arch/x86_64/tsc.rs | 151 ------------------ vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs | 8 +- .../src/arch/x86_64/vp_state/kvm_clock.rs | 55 ------- vmm_core/virt_kvm/src/lib.rs | 5 - 8 files changed, 15 insertions(+), 332 deletions(-) delete mode 100644 vmm_core/virt_kvm/src/arch/x86_64/tsc.rs delete mode 100644 vmm_core/virt_kvm/src/arch/x86_64/vp_state/kvm_clock.rs diff --git a/vm/kvm/src/lib.rs b/vm/kvm/src/lib.rs index 6d80e94f1d8..c61709ce951 100644 --- a/vm/kvm/src/lib.rs +++ b/vm/kvm/src/lib.rs @@ -103,8 +103,6 @@ mod ioctl { #[cfg(target_arch = "x86_64")] ioctl_write_ptr!(kvm_set_debugregs, KVMIO, 0xa2, kvm_debugregs); #[cfg(target_arch = "x86_64")] - ioctl_write_int_bad!(kvm_set_tsc_khz, request_code_none!(KVMIO, 0xa2)); - #[cfg(target_arch = "x86_64")] ioctl_write_int_bad!(kvm_get_tsc_khz, request_code_none!(KVMIO, 0xa3)); ioctl_write_ptr!(kvm_enable_cap, KVMIO, 0xa3, kvm_enable_cap); #[cfg(target_arch = "x86_64")] @@ -300,9 +298,6 @@ pub enum Error { #[error("GetTscFrequency")] GetTscFrequency(#[source] nix::Error), #[cfg(target_arch = "x86_64")] - #[error("SetTscFrequency")] - SetTscFrequency(#[source] nix::Error), - #[cfg(target_arch = "x86_64")] #[error("GetTscOffset")] GetTscOffset(#[source] nix::Error), #[cfg(target_arch = "x86_64")] @@ -1548,18 +1543,6 @@ impl<'a> Processor<'a> { Ok(khz as u64 * 1000) } - #[cfg(target_arch = "x86_64")] - pub fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<()> { - let khz = frequency_hz - .checked_div(1000) - .and_then(|khz| libc::c_int::try_from(khz).ok()) - .ok_or(Error::SetTscFrequency(nix::errno::Errno::EINVAL))?; - // SAFETY: Calling the documented vCPU ioctl with its integer value. - unsafe { ioctl::kvm_set_tsc_khz(self.get().vcpu.as_raw_fd(), khz) } - .map_err(Error::SetTscFrequency)?; - Ok(()) - } - /// Gets the guest TSC offset, in guest TSC cycles. #[cfg(target_arch = "x86_64")] pub fn tsc_offset(&self) -> Result { diff --git a/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs b/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs index 60d657bbea0..f6bb03790d5 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/cpu_contract.rs @@ -1,61 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! CPUID adjustments for the CPU compatibility contract: KVM paravirtual clock -//! exposure for versioned machine profiles, and hiding CET-SS, whose state KVM -//! cannot save. +//! CPUID adjustments for snapshots: hiding CET-SS, whose state KVM cannot +//! save. -use kvm::KVM_CPUID_FLAG_SIGNIFCANT_INDEX; use virt::CpuidLeaf; use x86defs::cpuid::CpuidFunction; -/// Returns the leaf to expose for a KVM hypervisor CPUID entry -/// (`0x4000_0000..=0x4fff_ffff`), if any. -/// -/// KVM's entries are filtered out unless the versioned CPU contract exposes -/// the KVM clock. Then the feature leaf is reduced to the clock features and -/// the other entries pass through unchanged. -pub(super) fn hypervisor_leaf( - entry: &kvm::kvm_cpuid_entry2, - expose_kvm_clock: bool, -) -> Option { - if !expose_kvm_clock { - return None; - } - if entry.function == 0x4000_0001 { - const KVM_FEATURE_CLOCKSOURCE2: u32 = 1 << 3; - const KVM_FEATURE_CLOCKSOURCE_STABLE_BIT: u32 = 1 << 24; - return Some(CpuidLeaf::new( - entry.function, - [ - entry.eax & (KVM_FEATURE_CLOCKSOURCE2 | KVM_FEATURE_CLOCKSOURCE_STABLE_BIT), - 0, - 0, - 0, - ], - )); - } - let mut leaf = CpuidLeaf::new(entry.function, [entry.eax, entry.ebx, entry.ecx, entry.edx]); - if entry.flags & KVM_CPUID_FLAG_SIGNIFCANT_INDEX != 0 { - leaf = leaf.indexed(entry.index); - } - - Some(leaf) -} - -/// Returns the leaf that sets the hypervisor-present bit when the KVM clock is -/// exposed. -pub(super) fn hypervisor_bit(expose_kvm_clock: bool) -> Option { - expose_kvm_clock.then(|| { - CpuidLeaf::new(CpuidFunction::VersionAndFeatures.0, [0, 0, 1 << 31, 0]).masked([ - 0, - 0, - 1 << 31, - 0, - ]) - }) -} - /// Returns the leaf that hides CET-SS from the guest. /// /// KVM does not expose an API to save or restore the shadow-stack diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index 97293533e92..b46638894f6 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -10,7 +10,6 @@ mod fingerprint; mod regs; pub(crate) mod snp; mod time_abi; -mod tsc; mod vm_state; mod vp_state; @@ -225,7 +224,7 @@ impl virt::Hypervisor for Kvm { .filter_map(|entry| { // Filter out KVM CPUID entries. if entry.function & 0xf0000000 == 0x40000000 { - return cpu_contract::hypervisor_leaf(&entry, config.versioned_cpu_contract); + return None; } let mut leaf = CpuidLeaf::new(entry.function, [entry.eax, entry.ebx, entry.ecx, entry.edx]); @@ -237,8 +236,6 @@ impl virt::Hypervisor for Kvm { }) .collect::>(); - cpuid_entries.extend(cpu_contract::hypervisor_bit(config.versioned_cpu_contract)); - // When nested virt is disabled, strip the virtualization // CPUID bit for the host's vendor. if !nested_virt { @@ -578,10 +575,6 @@ impl ProtoPartition for KvmProtoPartition<'_> { self.vm.add_vp(vp_info.apic_id)?; } - let cpuid = match &self.config.time_abi { - None => tsc::add_frequency_leaves(&self.vm, bsp_apic_id, cpuid)?, - Some(_) => cpuid, - }; let mut caps = if self.config.time_abi.is_some() { // The identity leaves must not make the partition model Hyper-V // or the KVM clock. @@ -876,32 +869,6 @@ impl Partition for KvmPartition { virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) } - fn tsc_frequency_hz(&self) -> Result, Self::Error> { - self.inner.tsc_frequency_hz() - } - - fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), Self::Error> { - if self.inner.time_abi.is_some() { - return Err(KvmError::TimeAbiUnsupported( - "TSC scaling (KVM_SET_TSC_KHZ)", - )); - } - self.inner.set_tsc_frequency_hz(frequency_hz) - } - - fn apic_frequency_hz(&self) -> Result, Self::Error> { - Ok(Some(tsc::APIC_FREQUENCY_HZ)) - } - - fn advance_snapshot_time(&self, duration: Duration) -> Result<(), Self::Error> { - if self.inner.time_abi.is_some() { - return Err(KvmError::TimeAbiUnsupported( - "the KVM clock (KVM_SET_CLOCK)", - )); - } - self.inner.advance_snapshot_time(duration) - } - fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { self.inner .time_abi @@ -2037,11 +2004,6 @@ impl<'p> Processor for KvmProcessor<'p> { assert_eq!(vtl, Vtl::Vtl0); KvmVpStateAccess::new(self) } - - fn advance_tsc(&mut self, cycles: u64) -> anyhow::Result<()> { - tsc::advance_tsc(&self.partition.kvm.vp(self.inner.vp_info.apic_id), cycles)?; - Ok(()) - } } impl virt::synic::Synic for KvmPartitionInner { diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index b9e945526cb..04a41359d61 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -19,7 +19,8 @@ //! KVM's own value in the others. KVM's supported CPUID and //! `IA32_ARCH_CAPABILITIES` feature MSR form the surface for the profile's //! support check. The partition capabilities come from the CPUID with the -//! hypervisor range masked, so neither `hv1` nor `kvm_clock` is present. +//! hypervisor range masked, so the partition does not model Hyper-V +//! (`hv1`). //! - **Identity MSRs.** An MSR filter denies `0x40000000..=0x400001ff`, //! `IA32_TSC_ADJUST`, `IA32_TSC_DEADLINE`, and the legacy P6 L2-cache MSRs, //! and `KVM_MSR_EXIT_REASON_FILTER` delivers every guest access to OpenVMM: @@ -746,13 +747,14 @@ impl TimeAbiBackend for KvmPartitionInner { } } -/// Reports the time ABI capabilities that the partition does not have. +/// Rejects partition capabilities that model Hyper-V (`hv1`), which the +/// identity leaves must not turn on. No KVM clock can appear: every KVM +/// signature leaf is removed. pub(crate) fn check_capabilities(caps: &virt::PartitionCapabilities) -> Result<(), KvmError> { - if caps.hv1 || caps.kvm_clock { - return Err(KvmError::TimeAbi(routing_error(format!( - "partition capabilities expose hv1 ({}) or the KVM clock ({})", - caps.hv1, caps.kvm_clock - )))); + if caps.hv1 { + return Err(KvmError::TimeAbi(routing_error( + "partition capabilities expose hv1", + ))); } Ok(()) } @@ -960,7 +962,7 @@ mod tests { } #[test] - fn capabilities_of_the_partition_cpuid_exclude_hv1_and_kvm_clock() { + fn capabilities_of_the_partition_cpuid_exclude_hv1() { let topology = vm_topology::processor::TopologyBuilder::new_x86() .build(2) .unwrap(); diff --git a/vmm_core/virt_kvm/src/arch/x86_64/tsc.rs b/vmm_core/virt_kvm/src/arch/x86_64/tsc.rs deleted file mode 100644 index 162cc899c75..00000000000 --- a/vmm_core/virt_kvm/src/arch/x86_64/tsc.rs +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! TSC and clock support: the snapshot-downtime TSC offset adjustment, the -//! CPUID leaves that expose the exact TSC frequency, and the partition clock -//! controls used by snapshot restore. - -use crate::KvmError; -use crate::KvmPartitionInner; -use std::time::Duration; -use virt::CpuidLeafSet; -use vm_topology::processor::x86::X86VpInfo; -use x86defs::cpuid::CpuidFunction; - -/// KVM's in-kernel local APIC uses a fixed one-nanosecond bus cycle. -pub(super) const APIC_FREQUENCY_HZ: u64 = 1_000_000_000; - -fn kvm_vcpu_id(vp_info: &X86VpInfo) -> u32 { - vp_info.apic_id -} - -/// Adds the CPUID leaves that expose the BSP's exact TSC frequency. -pub(super) fn add_frequency_leaves( - vm: &kvm::Partition, - bsp_vcpu_id: u32, - cpuid: CpuidLeafSet, -) -> Result { - let tsc_frequency_hz = vm.vp(bsp_vcpu_id).tsc_frequency_hz()?; - let current_max_basic_leaf = cpuid.result(CpuidFunction::VendorAndMaxFunction.0, 0, &[0; 4])[0]; - let mut cpuid = cpuid.into_leaves(); - cpuid.extend(virt::x86::tsc::tsc_frequency_cpuid_leaves( - tsc_frequency_hz, - current_max_basic_leaf, - )?); - Ok(CpuidLeafSet::new(cpuid)) -} - -impl KvmPartitionInner { - pub(super) fn tsc_frequency_hz(&self) -> Result, KvmError> { - let bsp_vcpu_id = kvm_vcpu_id(&self.bsp().vp_info); - Ok(Some(self.kvm.vp(bsp_vcpu_id).tsc_frequency_hz()?)) - } - - pub(super) fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), KvmError> { - for vp in &self.vps { - self.kvm - .vp(kvm_vcpu_id(&vp.vp_info)) - .set_tsc_frequency_hz(frequency_hz)?; - } - Ok(()) - } - - pub(super) fn advance_snapshot_time(&self, duration: Duration) -> Result<(), KvmError> { - let clock = self.kvm.get_clock_ns()?; - let delta = - u64::try_from(duration.as_nanos()).map_err(|_| KvmError::SnapshotClockOverflow)?; - let requested_clock = clock - .clock - .checked_add(delta) - .ok_or(KvmError::SnapshotClockOverflow)?; - self.kvm.set_clock_ns(requested_clock)?; - Ok(()) - } -} - -pub(crate) fn advance_tsc(vp: &kvm::Processor<'_>, cycles: u64) -> Result<(), KvmError> { - // KVM can discard sub-second IA32_TSC writes as synchronization attempts. - // Adjusting the offset also avoids introducing read/write latency skew. - let offset = vp.tsc_offset()?; - vp.set_tsc_offset(offset.wrapping_add(cycles))?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::advance_tsc; - use super::kvm_vcpu_id; - use test_with_tracing::test; - use virt::VpIndex; - use vm_topology::processor::VpInfo; - use vm_topology::processor::x86::X86VpInfo; - - #[test] - fn kvm_vcpu_ids_follow_sparse_apic_ids() { - let vp0 = X86VpInfo { - base: VpInfo { - vp_index: VpIndex::new(0), - vnode: 0, - }, - apic_id: 253, - }; - let vp1 = X86VpInfo { - base: VpInfo { - vp_index: VpIndex::new(1), - vnode: 1, - }, - apic_id: 254, - }; - - assert_eq!(kvm_vcpu_id(&vp0), 253); - assert_eq!(kvm_vcpu_id(&vp1), 254); - } - - #[test] - #[ignore = "requires /dev/kvm"] - fn snapshot_tsc_downtime_advances_exact_cycles() { - let kvm = kvm::Kvm::new().unwrap(); - for vp_count in [1, 2, 4, 8] { - let mut partition = kvm.new_vm(kvm::VmType::Default).unwrap(); - for index in 0..vp_count { - partition.add_vp(index).unwrap(); - } - let frequency = partition.vp(0).tsc_frequency_hz().unwrap(); - for index in 0..vp_count { - partition - .vp(index) - .set_msrs(&[(x86defs::X86X_MSR_TSC, frequency * 10)]) - .unwrap(); - } - for cycles in [frequency / 4, frequency * 2, 0, 1] { - for index in 0..vp_count { - let vp = partition.vp(index); - let before = vp.tsc_offset().unwrap(); - advance_tsc(&vp, cycles).unwrap(); - assert_eq!( - vp.tsc_offset().unwrap(), - before.wrapping_add(cycles), - "VP {index} of {vp_count}, delta {cycles} cycles" - ); - } - } - } - } - - #[test] - #[ignore = "requires /dev/kvm"] - fn snapshot_tsc_downtime_preserves_wrapping_per_vp_offsets() { - let kvm = kvm::Kvm::new().unwrap(); - let mut partition = kvm.new_vm(kvm::VmType::Default).unwrap(); - let offsets = [u64::MAX - 10, 0, 1_000, 10_000]; - for (index, offset) in offsets.into_iter().enumerate() { - partition.add_vp(index as u32).unwrap(); - partition.vp(index as u32).set_tsc_offset(offset).unwrap(); - } - for (index, offset) in offsets.into_iter().enumerate() { - let vp = partition.vp(index as u32); - advance_tsc(&vp, 100).unwrap(); - assert_eq!(vp.tsc_offset().unwrap(), offset.wrapping_add(100)); - } - } -} diff --git a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs index 4937a7f1647..7fe4c3f538f 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/vp_state.rs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -mod kvm_clock; - use super::KvmProcessor; use super::regs::register_to_msr; use crate::KvmError; @@ -566,13 +564,11 @@ impl AccessVpState for KvmVpStateAccess<'_, '_> { } fn synic_msrs(&mut self) -> Result { - self.synthetic_msrs() + self.get_register_state() } fn set_synic_msrs(&mut self, value: &vp::SyntheticMsrs) -> Result<(), Self::Error> { - if !self.set_synthetic_msrs(value)? { - return Ok(()); - } + self.set_register_state(value)?; // Mirror the restored synic MSRs into the processor's tracked state and // overlay pages. Runs before set_synic_{message,event_flags}_page (per diff --git a/vmm_core/virt_kvm/src/arch/x86_64/vp_state/kvm_clock.rs b/vmm_core/virt_kvm/src/arch/x86_64/vp_state/kvm_clock.rs deleted file mode 100644 index d6c64517826..00000000000 --- a/vmm_core/virt_kvm/src/arch/x86_64/vp_state/kvm_clock.rs +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! KVM paravirtual clock MSR state, which is saved and restored with the -//! synthetic MSRs. - -use super::KvmVpStateAccess; -use crate::KvmError; -use virt::x86::vp; -use virt::x86::vp::AccessVpState; - -const MSR_KVM_WALL_CLOCK_NEW: u32 = 0x4b56_4d00; -const MSR_KVM_SYSTEM_TIME_NEW: u32 = 0x4b56_4d01; - -impl KvmVpStateAccess<'_, '_> { - /// Reads the Hyper-V synthetic MSRs and the KVM clock MSRs that the - /// partition exposes. - pub(super) fn synthetic_msrs(&mut self) -> Result { - let mut value = if self.caps().hv1 { - self.get_register_state()? - } else { - vp::SyntheticMsrs::default() - }; - if self.caps().kvm_clock { - let mut msrs = [0; 2]; - self.kvm().get_msrs( - &[MSR_KVM_WALL_CLOCK_NEW, MSR_KVM_SYSTEM_TIME_NEW], - &mut msrs, - )?; - [value.kvm_wall_clock, value.kvm_system_time] = msrs; - } - Ok(value) - } - - /// Writes the Hyper-V synthetic MSRs and the KVM clock MSRs that the - /// partition exposes. - /// - /// Returns whether the Hyper-V synthetic MSRs were written, in which case - /// the caller must mirror them into the processor state. - pub(super) fn set_synthetic_msrs( - &mut self, - value: &vp::SyntheticMsrs, - ) -> Result { - if self.caps().hv1 { - self.set_register_state(value)?; - } - if self.caps().kvm_clock { - self.kvm().set_msrs(&[ - (MSR_KVM_WALL_CLOCK_NEW, value.kvm_wall_clock), - (MSR_KVM_SYSTEM_TIME_NEW, value.kvm_system_time), - ])?; - } - Ok(self.caps().hv1) - } -} diff --git a/vmm_core/virt_kvm/src/lib.rs b/vmm_core/virt_kvm/src/lib.rs index be1f36aea51..0019c108e6e 100644 --- a/vmm_core/virt_kvm/src/lib.rs +++ b/vmm_core/virt_kvm/src/lib.rs @@ -67,8 +67,6 @@ pub enum KvmError { State(#[from] Box>), #[error("invalid state while restoring: {0}")] InvalidState(&'static str), - #[error("snapshot clock downtime adjustment overflows")] - SnapshotClockOverflow, #[error("unsupported isolation configuration: {0}")] UnsupportedIsolationConfiguration(&'static str), #[error("misaligned gic base address")] @@ -95,9 +93,6 @@ pub enum KvmError { TopologyCpuid(#[source] virt::x86::topology::UnknownVendor), #[cfg(guest_arch = "x86_64")] #[error(transparent)] - TscFrequencyCpuid(#[from] virt::x86::tsc::TscFrequencyCpuidError), - #[cfg(guest_arch = "x86_64")] - #[error(transparent)] TimeAbi(#[from] virt::time_abi::TimeAbiError), #[cfg(guest_arch = "x86_64")] #[error("the time ABI does not support {0}")] From 947291e75969e785fc1d092e33e2c2f01f0483dd Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 03:53:27 -0700 Subject: [PATCH 257/304] virt_mshv: remove the legacy microVM clock code The NVX time ABI replaces the clock hooks that the microVM worker used before it. Every microVM now restores its TSCs through the time ABI's synchronized set, which writes one target to every created VP while partition time is frozen, and declares its rates through the time ABI. Remove the MSHV code that no caller reaches once the worker always uses the time ABI: the TSC and LAPIC frequency hooks, the exact TSC frequency check, the BSP-copy alignment of restored TSCs and its created-AP selection, the CPUID leaf 0x15 synthesis, and the TSC_ADJUST policy of versioned CPU contracts, together with their tests and errors. Keep hiding the TSC-deadline timer from every partition, since MSHV does not reliably deliver its events to direct-boot guests. The feature bank alone hides it in CPUID, which a new hardware test checks, so CPUID no longer masks it as well. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/lib.rs | 19 +- vmm_core/virt_mshv/src/x86_64/mod.rs | 27 +- vmm_core/virt_mshv/src/x86_64/tsc.rs | 413 +++------------------------ 3 files changed, 47 insertions(+), 412 deletions(-) diff --git a/vmm_core/virt_mshv/src/lib.rs b/vmm_core/virt_mshv/src/lib.rs index d201a77736f..bb69770fb55 100644 --- a/vmm_core/virt_mshv/src/lib.rs +++ b/vmm_core/virt_mshv/src/lib.rs @@ -658,8 +658,8 @@ impl virt::Processor for MshvProcessor<'_> { // A LAPIC state written since the last run may hold a pending vector // that the hypervisor hasn't delivered, and partition time may have - // been frozen and thawed since then (the legacy restore clock does). - // Assert it again now that the VP is about to run. + // been frozen and thawed since then. Assert it again now that the VP + // is about to run. #[cfg(guest_arch = "x86_64")] if vpinner.lapic_written.swap(false, Ordering::AcqRel) { if let Err(err) = self.reassert_pending_interrupt() { @@ -848,13 +848,6 @@ enum ErrorInner { #[error("register access error")] Register(#[source] KernelError), #[cfg(guest_arch = "x86_64")] - #[error("failed to synchronize restored VP {vp_index} TSC")] - SynchronizeTsc { - vp_index: u32, - #[source] - error: KernelError, - }, - #[cfg(guest_arch = "x86_64")] #[error("failed to get VP state {ty}")] GetVpState { #[source] @@ -888,14 +881,6 @@ enum ErrorInner { #[error("unsupported processor vendor: {0:?}")] UnsupportedProcessorVendor(hvdef::HvProcessorVendor), #[cfg(guest_arch = "x86_64")] - #[error( - "TSC frequency mismatch between snapshot ({saved} Hz) and destination ({destination} Hz)" - )] - TscFrequencyMismatch { saved: u64, destination: u64 }, - #[cfg(guest_arch = "x86_64")] - #[error(transparent)] - TscFrequencyCpuid(#[from] virt::x86::tsc::TscFrequencyCpuidError), - #[cfg(guest_arch = "x86_64")] #[error("failed to create virtual device")] NewDevice(#[source] virt::x86::apic_software_device::DeviceIdInUse), #[cfg(guest_arch = "x86_64")] diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index fd7833c105b..f0ab09c0692 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -142,10 +142,7 @@ impl virt::Hypervisor for LinuxMshv { let features = time_abi::partition_features(&time_abi.cpu_profile, host)?; (time_abi::with_features(create_args, features), Some(host)) } - None => ( - tsc::with_features1(create_args, config.versioned_cpu_contract), - None, - ), + None => (tsc::with_features1(create_args), None), }; let vmfd = create_vm_with_retry(&self.mshv, &create_args)?; @@ -556,7 +553,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { } let cpuid = match &self.config.time_abi { Some(time_abi) => time_abi::partition_cpuid(&time_abi.cpuid), - None => virt::CpuidLeafSet::new(tsc::add_cpuid_leaves(&self.vmfd, cpuid)?), + None => virt::CpuidLeafSet::new(cpuid), }; // A time ABI partition reads the host's CPUID once: its supported CPU @@ -673,22 +670,6 @@ impl virt::Partition for MshvPartition { virt::x86::CpuCompatibilityContract::new(self.inner.caps(), &self.inner.config.cpuid) } - fn tsc_frequency_hz(&self) -> Result, Self::Error> { - self.inner.tsc_frequency_hz() - } - - fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), Self::Error> { - self.inner.set_tsc_frequency_hz(frequency_hz) - } - - fn advance_snapshot_time(&self, _duration: std::time::Duration) -> Result<(), Self::Error> { - self.inner.advance_snapshot_time() - } - - fn apic_frequency_hz(&self) -> Result, Self::Error> { - self.inner.apic_frequency_hz() - } - fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { self.inner.time_abi.as_ref().map(|_| self as _) } @@ -846,8 +827,8 @@ impl virt::irqcon::IoApicRouting for MshvPartitionInner { impl MshvPartitionInner { /// Creates the hypervisor VP and records that it exists. Every VP is - /// created here, so restored-TSC synchronization sees exactly the created - /// VPs. + /// created here, so the time ABI's synchronized TSC set sees exactly the + /// created VPs. /// /// A time ABI partition's extended topology leaves get this VP's own /// results here, with its x2APIC ID in EDX: the hypervisor does not diff --git a/vmm_core/virt_mshv/src/x86_64/tsc.rs b/vmm_core/virt_mshv/src/x86_64/tsc.rs index 701a8be0323..c2c403eccee 100644 --- a/vmm_core/virt_mshv/src/x86_64/tsc.rs +++ b/vmm_core/virt_mshv/src/x86_64/tsc.rs @@ -1,401 +1,70 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! TSC support: the TSC deadline timer and TSC_ADJUST feature policy, the -//! CPUID leaves that expose the exact TSC frequency, the clock frequencies -//! used by snapshot restore, and restored-TSC synchronization. - -use crate::Error; -use crate::ErrorInner; -use crate::KernelError; -use crate::MshvPartitionInner; -use crate::VcpuFdExt; -use hvdef::HvPartitionPropertyCode; -use hvdef::HvX64RegisterName; -use hvdef::hypercall::HvRegisterAssoc; -use mshv_ioctls::VcpuFd; -use mshv_ioctls::VmFd; -use x86defs::cpuid::CpuidFunction; +//! The TSC-deadline timer policy of MSHV partitions. +//! +//! MSHV does not reliably deliver TSC-deadline timer events to direct-boot +//! guests, so no partition exposes the timer. The time ABI applies its own +//! feature bank, which hides it too. /// Returns the processor features (bank 1) to expose: the supported features -/// without the TSC deadline timer, and without TSC_ADJUST for a versioned CPU -/// contract. -fn supported_features1(versioned_cpu_contract: bool) -> hvdef::HvX64PartitionProcessorFeatures1 { - super::supported_processor_features1() - .with_tsc_deadline_tmr_support(false) - .with_tsc_adjust_support(!versioned_cpu_contract) +/// without the TSC-deadline timer. +fn supported_features1() -> hvdef::HvX64PartitionProcessorFeatures1 { + super::supported_processor_features1().with_tsc_deadline_tmr_support(false) } -/// Applies [`supported_features1`] to the partition creation arguments. +/// Applies [`supported_features1`] to the partition creation arguments, whose +/// banks hold the disabled features. pub(super) fn with_features1( mut args: mshv_bindings::mshv_create_partition_v2, - versioned_cpu_contract: bool, ) -> mshv_bindings::mshv_create_partition_v2 { let mut banks = args.pt_cpu_fbanks; - banks[1] = !u64::from(supported_features1(versioned_cpu_contract)); + banks[1] = !u64::from(supported_features1()); args.pt_cpu_fbanks = banks; args } -/// Adds the CPUID leaves that expose the exact TSC frequency and that hide the -/// TSC deadline timer. -pub(super) fn add_cpuid_leaves( - vmfd: &VmFd, - cpuid: Vec, -) -> Result, Error> { - let cpuid = virt::CpuidLeafSet::new(cpuid); - let tsc_frequency_hz = vmfd - .get_partition_property(HvPartitionPropertyCode::ProcessorClockFrequency.0) - .map_err(|error| ErrorInner::GetPartitionProperty(error.into()))?; - let current_max_basic_leaf = cpuid.result(CpuidFunction::VendorAndMaxFunction.0, 0, &[0; 4])[0]; - let mut cpuid = cpuid.into_leaves(); - cpuid.extend(virt::x86::tsc::tsc_frequency_cpuid_leaves( - tsc_frequency_hz, - current_max_basic_leaf, - )?); - cpuid.push( - virt::CpuidLeaf::new(CpuidFunction::VersionAndFeatures.0, [0; 4]).masked([ - 0, - 0, - 1 << 24, - 0, - ]), - ); - Ok(cpuid) -} - -impl MshvPartitionInner { - pub(super) fn tsc_frequency_hz(&self) -> Result, Error> { - Ok(Some( - self.vmfd - .get_partition_property(HvPartitionPropertyCode::ProcessorClockFrequency.0) - .map_err(|error| ErrorInner::GetPartitionProperty(error.into()))?, - )) - } - - pub(super) fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), Error> { - let destination = self - .vmfd - .get_partition_property(HvPartitionPropertyCode::ProcessorClockFrequency.0) - .map_err(|error| ErrorInner::GetPartitionProperty(error.into()))?; - if frequency_hz != destination { - return Err(ErrorInner::TscFrequencyMismatch { - saved: frequency_hz, - destination, - } - .into()); - } - Ok(()) - } - - /// Aligns the restored counters of the created application processors to - /// the advanced BSP counter. - pub(super) fn advance_snapshot_time(&self) -> Result<(), Error> { - let aps = created_aps( - self.vps - .iter() - .map(|vp| vp.created.load(std::sync::atomic::Ordering::Acquire)), - ); - // Uncreated VPs are skipped silently, so record how many are aligned. - tracing::info!( - created_aps = aps.len(), - vp_capacity = self.vps.len(), - "aligning restored AP TSCs to the BSP" - ); - if aps.is_empty() { - return Ok(()); - } - - // Per-VP counter writes run at different host times. Freeze before - // aligning them to the advanced BSP counter; the first VP run thaws time. - self.freeze_time()?; - synchronize_restored_tscs(&self.vmfd, &self.finalized()?.bsp_vcpufd, &aps) - } - - pub(super) fn apic_frequency_hz(&self) -> Result, Error> { - Ok(Some( - self.vmfd - .get_partition_property(HvPartitionPropertyCode::ApicFrequency.0) - .map_err(|error| ErrorInner::GetPartitionProperty(error.into()))?, - )) - } -} - -/// Returns the VP indices of the created application processors, given -/// whether each VP of the topology was created. A restore-time VP prefix -/// leaves the suffix VPs uncreated, and the hypervisor rejects register access -/// to a VP that does not exist. -fn created_aps(created: impl IntoIterator) -> Vec { - created - .into_iter() - .enumerate() - .skip(1) - .filter_map(|(vp_index, created)| created.then_some(vp_index as u32)) - .collect() -} - -fn synchronize_restored_tscs(vmfd: &VmFd, bsp: &VcpuFd, aps: &[u32]) -> Result<(), Error> { - let mut registers = [HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0_u64))]; - bsp.get_hvdef_regs(&mut registers) - .map_err(ErrorInner::Register)?; - - #[repr(C)] - struct SetTsc { - header: hvdef::hypercall::GetSetVpRegisters, - register: HvRegisterAssoc, - } - - let mut input = SetTsc { - header: hvdef::hypercall::GetSetVpRegisters { - partition_id: 0, - vp_index: 0, - target_vtl: hvdef::hypercall::HvInputVtl::CURRENT_VTL, - rsvd: [0; 3], - }, - register: registers[0], - }; - for &vp_index in aps { - input.header.vp_index = vp_index; - let mut args = mshv_bindings::mshv_root_hvcall { - code: hvdef::HypercallCode::HvCallSetVpRegisters.0, - in_sz: size_of::() as u16, - in_ptr: std::ptr::addr_of!(input) as u64, - reps: 1, - ..Default::default() - }; - vmfd.hvcall(&mut args) - .map_err(|error| ErrorInner::SynchronizeTsc { - vp_index, - error: error.into(), - })?; - if args.reps != 1 { - return Err(ErrorInner::SynchronizeTsc { - vp_index, - error: KernelError::Kernel(std::io::Error::from_raw_os_error(libc::EINTR)), - } - .into()); - } - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; - use crate::LinuxMshv; use crate::x86_64::partition_create_args; - use guestmem::GuestMemory; - use hvdef::HvError; - use hvdef::HypercallCode; - use hvdef::Vtl; - use mshv_ioctls::Mshv; - use pal_async::DefaultDriver; - use pal_async::async_test; - use std::borrow::Borrow; - use std::sync::atomic::Ordering; - use test_with_tracing::test; - use virt::BindProcessor; - use virt::Hypervisor; - use virt::Partition; - use virt::PartitionConfig; - use virt::PartitionMemoryMapper; - use virt::ProtoPartition; - use virt::ProtoPartitionConfig; - use vm_topology::memory::MemoryLayout; - use vm_topology::processor::TopologyBuilder; - use vm_topology::processor::x86::X2ApicState; - use vmcore::vmtime::VmTime; - use vmcore::vmtime::VmTimeKeeper; - - #[test] - fn versioned_cpu_contract_does_not_expose_tsc_adjust() { - for versioned_cpu_contract in [false, true] { - let processor_features1 = supported_features1(versioned_cpu_contract); - assert_eq!( - processor_features1.tsc_adjust_support(), - !versioned_cpu_contract - ); - let args = with_features1( - partition_create_args(&virt::ProtoPartitionIsolation::None, false, false).unwrap(), - versioned_cpu_contract, - ); - let pt_cpu_fbanks = args.pt_cpu_fbanks; - assert_eq!(pt_cpu_fbanks[1], !u64::from(processor_features1)); - } - } - - #[test] - fn restored_tsc_synchronization_targets_only_created_aps() { - // The BSP is the synchronization source, never a target. - assert!(created_aps([true]).is_empty()); - assert!(created_aps([true, false, false, false]).is_empty()); - assert_eq!(created_aps([true, true, false, false]), [1]); - assert_eq!(created_aps([true, true, true, true]), [1, 2, 3]); - } - - fn create_frozen_partition(vp_count: u8) -> (VmFd, Vec) { - let mshv = Mshv::new().unwrap(); - let vmfd = mshv.create_vm().unwrap(); - vmfd.initialize().unwrap(); - let vps: Vec<_> = (0..vp_count) - .map(|index| vmfd.create_vcpu(index).unwrap()) - .collect(); - vmfd.set_partition_property(HvPartitionPropertyCode::TimeFreeze.0, 1) - .unwrap(); - (vmfd, vps) - } - - fn set_skewed_tscs(vps: &[impl Borrow], tsc: u64) { - for (index, vp) in vps.iter().enumerate() { - vp.borrow() - .set_hvdef_regs(&[HvRegisterAssoc::from(( - HvX64RegisterName::Tsc, - tsc + index as u64 * 100_000, - ))]) - .unwrap(); - } - } - - fn assert_tscs(vps: &[impl Borrow], expected_tsc: u64) { - for (index, vp) in vps.iter().enumerate() { - let mut registers = [HvRegisterAssoc::from((HvX64RegisterName::Tsc, 0_u64))]; - vp.borrow().get_hvdef_regs(&mut registers).unwrap(); - assert_eq!( - registers[0].value.as_u64(), - expected_tsc, - "VP {index} TSC differs with {} VPs", - vps.len() - ); - } - } #[test] - #[ignore = "requires /dev/mshv"] - fn restored_tscs_are_identical_while_partition_time_is_frozen() { - for vp_count in [1, 2, 4, 8] { - let (vmfd, vps) = create_frozen_partition(vp_count); - let expected_tsc = 1_000_000_u64; - set_skewed_tscs(&vps, expected_tsc); - - let aps = created_aps(std::iter::repeat_n(true, usize::from(vp_count))); - synchronize_restored_tscs(&vmfd, &vps[0], &aps).unwrap(); - std::thread::sleep(std::time::Duration::from_millis(10)); - - assert_tscs(&vps, expected_tsc); - } + fn partitions_do_not_expose_the_tsc_deadline_timer() { + let processor_features1 = supported_features1(); + assert!(!processor_features1.tsc_deadline_tmr_support()); + assert!(processor_features1.tsc_adjust_support()); + let args = with_features1( + partition_create_args(&virt::ProtoPartitionIsolation::None, false, false).unwrap(), + ); + let pt_cpu_fbanks = args.pt_cpu_fbanks; + assert_eq!(pt_cpu_fbanks[1], !u64::from(processor_features1)); } + /// The feature bank alone hides the TSC-deadline timer in CPUID, so no + /// CPUID result needs to hide it as well. #[test] #[ignore = "requires /dev/mshv"] - fn restored_tsc_synchronization_skips_uncreated_suffix_vps() { - const VP_CAPACITY: u8 = 8; - for vp_count in [1, 2, 4] { - let (vmfd, vps) = create_frozen_partition(vp_count); - let expected_tsc = 1_000_000_u64; - set_skewed_tscs(&vps, expected_tsc); - - // The hypervisor rejects register access to an uncreated VP. - let uncreated = u32::from(vp_count); - let failure = synchronize_restored_tscs(&vmfd, &vps[0], &[uncreated]).unwrap_err(); - match failure.0 { - ErrorInner::SynchronizeTsc { - vp_index, - error: KernelError::Hypercall { code, error }, - } => { - assert_eq!(vp_index, uncreated); - assert_eq!(code, HypercallCode::HvCallSetVpRegisters); - assert_eq!(error, HvError::InvalidVpIndex); - } - other => panic!("unexpected error: {other:?}"), + fn the_feature_bank_hides_the_tsc_deadline_timer_in_cpuid() { + for (exposed, features1) in [ + (true, super::super::supported_processor_features1()), + (false, supported_features1()), + ] { + let mut args = + partition_create_args(&virt::ProtoPartitionIsolation::None, false, false).unwrap(); + let mut banks = args.pt_cpu_fbanks; + banks[1] = !u64::from(features1); + args.pt_cpu_fbanks = banks; + let mshv = mshv_ioctls::Mshv::new().unwrap(); + let vmfd = crate::create_vm_with_retry(&mshv, &args).unwrap(); + vmfd.initialize().unwrap(); + let vp = vmfd.create_vcpu(0).unwrap(); + let [_, _, ecx, _] = vp.get_cpuid_values(1, 0, 0, 0).unwrap(); + let tsc_deadline = ecx & (1 << 24) != 0; + println!("bank 1 tsc_deadline_tmr_support={exposed}: CPUID.1:ECX[24]={tsc_deadline}"); + if !exposed { + assert!(!tsc_deadline); } - - let aps = created_aps((0..VP_CAPACITY).map(|index| index < vp_count)); - synchronize_restored_tscs(&vmfd, &vps[0], &aps).unwrap(); - std::thread::sleep(std::time::Duration::from_millis(10)); - - assert_tscs(&vps, expected_tsc); - } - } - - #[async_test] - #[ignore = "requires /dev/mshv"] - async fn restored_tsc_synchronization_aligns_only_bound_vps(driver: DefaultDriver) { - const VP_CAPACITY: u32 = 4; - let processor_topology = TopologyBuilder::new_x86() - .x2apic(X2ApicState::Supported) - .build(VP_CAPACITY) - .unwrap(); - let mem_layout = MemoryLayout::new(0x400000, &[], &[], &[], None).unwrap(); - let vmtime_keeper = VmTimeKeeper::new(&driver, VmTime::from_100ns(0)); - let vmtime = vmtime_keeper.builder().build(&driver).await.unwrap(); - - for vp_count in [1, 2, VP_CAPACITY] { - let guest_memory = GuestMemory::allocate(mem_layout.end_of_ram() as usize); - let mut mshv = LinuxMshv::new().unwrap(); - let (partition, mut binders) = mshv - .new_partition(ProtoPartitionConfig { - processor_topology: &processor_topology, - hv_config: None, - vmtime: &vmtime, - isolation: virt::ProtoPartitionIsolation::None, - nested_virt: false, - user_mode_memory_faults: false, - lazy_memory_registration: false, - versioned_cpu_contract: false, - time_abi: None, - }) - .unwrap() - .build(PartitionConfig { - mem_layout: &mem_layout, - guest_memory: &guest_memory, - cpuid: &[], - vtl0_alias_map: None, - fault_resolver: None, - }) - .unwrap(); - let ram = guest_memory.inner_buf().unwrap(); - // SAFETY: the guest memory outlives the partition. - unsafe { - partition.memory_mapper(Vtl::Vtl0).map_range( - ram.as_ptr().cast_mut().cast(), - ram.len(), - 0, - true, - true, - ) - } - .unwrap(); - partition.finalize_memory().unwrap(); - - // A restore binds only the VPs that it runs, which creates them. - for binder in &mut binders[..vp_count as usize] { - binder.bind().unwrap(); - } - let created: Vec<_> = partition - .inner - .vps - .iter() - .map(|vp| vp.created.load(Ordering::Acquire)) - .collect(); - let expected_created: Vec<_> = (0..VP_CAPACITY).map(|index| index < vp_count).collect(); - assert_eq!(created, expected_created); - - partition.inner.freeze_time().unwrap(); - let bound: Vec<&VcpuFd> = - std::iter::once(&partition.inner.finalized().unwrap().bsp_vcpufd) - .chain( - binders[1..vp_count as usize] - .iter() - .map(|binder| binder.vcpufd.as_ref().unwrap()), - ) - .collect(); - let expected_tsc = 1_000_000_u64; - set_skewed_tscs(&bound, expected_tsc); - - partition.inner.advance_snapshot_time().unwrap(); - std::thread::sleep(std::time::Duration::from_millis(10)); - - assert_tscs(&bound, expected_tsc); } } } From cfdfcdcbcd604342b7de8590dc275c96e0eb2077 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 1 Oct 2026 06:28:27 -0700 Subject: [PATCH 258/304] virt_whp: remove the legacy microVM clock paths Once the microVM always uses the NVX time ABI (core's flip), the WHP paths that kept the legacy microVM clock are dead. Remove them: - the versioned CPU contract's fixed 1 GHz TSC request and its silent fallback to the host rate (configure_versioned_contract); - the restored-TSC clock (PartitionClock, RestoredTsc, ReferenceTsc) with its RDTSC, RDTSCP, IA32_TSC, IA32_TSC_ADJUST, and IA32_TSC_DEADLINE exits, its per-VP TSC save and restore hooks, and its reset; - the legacy advance_snapshot_time, the saved TSC frequency check, and the TSC and APIC frequency queries, including the emulated-APIC rate branch. The worker no longer calls them, so the Partition defaults apply. Leaf 0x15 synthesis (add_frequency_leaves) stays for partitions without the time ABI: Hyper-V partitions exit every CPUID leaf and still receive it. It goes when virt::x86::tsc::tsc_frequency_cpuid_leaves does. This branch is prepared for the legacy-deletion step and applies after core's flip; it is not integrated before then. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/cpu_contract.rs | 47 +--- vmm_core/virt_whp/src/lib.rs | 63 +---- vmm_core/virt_whp/src/tsc.rs | 378 +------------------------- vmm_core/virt_whp/src/vp.rs | 12 - vmm_core/virt_whp/src/vp/tsc.rs | 233 ---------------- vmm_core/virt_whp/src/vp_state.rs | 6 +- vmm_core/virt_whp/src/vp_state/tsc.rs | 44 +-- 7 files changed, 14 insertions(+), 769 deletions(-) delete mode 100644 vmm_core/virt_whp/src/vp/tsc.rs diff --git a/vmm_core/virt_whp/src/cpu_contract.rs b/vmm_core/virt_whp/src/cpu_contract.rs index 68cadba48f2..d312bb66055 100644 --- a/vmm_core/virt_whp/src/cpu_contract.rs +++ b/vmm_core/virt_whp/src/cpu_contract.rs @@ -1,13 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! CPUID policy for the CPU compatibility contract: the fixed TSC frequency -//! and CPUID exits of a versioned CPU contract, the per-VP topology results -//! returned on CPUID exits, and hiding the L0 GPA pinning enlightenment. +//! CPUID policy for the CPU compatibility contract: the per-VP topology +//! results returned on CPUID exits, and hiding the L0 GPA pinning +//! enlightenment. -use crate::Error; use crate::WhpProcessor; -use crate::WhpResultExt; use inspect::Inspect; use x86defs::cpuid::CpuidFunction; @@ -31,45 +29,6 @@ pub(crate) fn mask_gpa_pinning_enlightenment() -> virt::CpuidLeaf { ]) } -/// Sets the fixed TSC frequency of a versioned CPU contract and routes the -/// topology and clock CPUID leaves to the VMM. -pub(crate) fn configure_versioned_contract( - whp_config: &mut whp::PartitionConfig, - extended_exits: &mut whp::abi::WHV_EXTENDED_VM_EXITS, -) -> Result<(), Error> { - const VERSIONED_TSC_FREQUENCY_HZ: u64 = 1_000_000_000; - - match whp_config.set_property(whp::PartitionProperty::ProcessorClockFrequency( - VERSIONED_TSC_FREQUENCY_HZ, - )) { - Ok(_) => {} - Err(err @ (whp::WHvError::ERROR_NOT_SUPPORTED | whp::WHvError::WHV_E_UNKNOWN_PROPERTY)) => { - tracing::warn!( - error = %err, - "WHP cannot set the versioned TSC frequency; using the host frequency" - ); - } - Err(err) => { - return Err(err).for_op("set versioned CPU contract TSC frequency"); - } - } - *extended_exits |= whp::abi::WHV_EXTENDED_VM_EXITS::X64CpuidExit; - let cpuid_exit_list = [ - CpuidFunction::VendorAndMaxFunction.0, - CpuidFunction::VersionAndFeatures.0, - CpuidFunction::CacheParameters.0, - CpuidFunction::ExtendedTopologyEnumeration.0, - CpuidFunction::V2ExtendedTopologyEnumeration.0, - CpuidFunction::ExtendedAddressSpaceSizes.0, - CpuidFunction::ProcessorTopologyDefinition.0, - CpuidFunction::CoreCrystalClockInformation.0, - ]; - whp_config - .set_property(whp::PartitionProperty::CpuidExitList(&cpuid_exit_list)) - .for_op("set versioned CPU contract CPUID exits")?; - Ok(()) -} - /// Processor topology parameters of the per-VP topology CPUID results. #[derive(Inspect)] pub(crate) struct CpuidTopology { diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 247b465312d..42d10e2e640 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -137,9 +137,6 @@ struct WhpPartitionInner { #[cfg(guest_arch = "x86_64")] #[inspect(flatten)] cpuid_topology: cpu_contract::CpuidTopology, - #[cfg(guest_arch = "x86_64")] - #[inspect(flatten)] - clock: tsc::PartitionClock, /// The NVX time ABI state, for a partition built with it. #[cfg(guest_arch = "x86_64")] time_abi: Option, @@ -563,8 +560,6 @@ impl virt::ResetPartition for WhpPartition { type Error = Error; fn reset(&self) -> Result<(), Error> { - #[cfg(guest_arch = "x86_64")] - self.inner.reset_restored_tsc()?; self.inner.vtl0.reset()?; self.validate_is_reset(Vtl::Vtl0); @@ -648,31 +643,6 @@ impl virt::Partition for WhpPartition { virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) } - #[cfg(guest_arch = "x86_64")] - fn tsc_frequency_hz(&self) -> Result, Self::Error> { - Ok(Some(self.inner.clock.tsc_frequency_hz)) - } - - #[cfg(guest_arch = "x86_64")] - fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> Result<(), Self::Error> { - self.inner.clock.check_frequency(frequency_hz) - } - - #[cfg(guest_arch = "x86_64")] - fn advance_snapshot_time(&self, _duration: std::time::Duration) -> Result<(), Self::Error> { - if self.inner.time_abi.is_some() { - // The time ABI's synchronized TSC set replaces the restored-TSC - // clock. - return Err(Error::NotWithTimeAbi("legacy snapshot time advance")); - } - self.inner.advance_snapshot_time() - } - - #[cfg(guest_arch = "x86_64")] - fn apic_frequency_hz(&self) -> Result, Self::Error> { - self.inner.apic_frequency_hz() - } - #[cfg(guest_arch = "x86_64")] fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { self.inner.time_abi.as_ref().map(|_| &*self.inner as _) @@ -911,17 +881,9 @@ pub enum Error { NestedVirtIncompatibleWithIsolation, #[error("WHP does not support {0:?} isolation")] IsolationNotSupported(IsolationType), - #[error("saved TSC frequency {saved} Hz does not match destination frequency {destination} Hz")] - TscFrequencyMismatch { saved: u64, destination: u64 }, - #[cfg(guest_arch = "x86_64")] - #[error("timestamp intercept arrived without a restored TSC clock")] - UnexpectedTimestampExit, #[cfg(guest_arch = "x86_64")] #[error(transparent)] TimeAbi(#[from] virt::time_abi::TimeAbiError), - #[cfg(guest_arch = "x86_64")] - #[error("{0} is not available on a time ABI partition")] - NotWithTimeAbi(&'static str), } trait WhpResultExt { @@ -1456,8 +1418,6 @@ impl WhpPartitionInner { None => cpu_contract::CpuidTopology::new(proto_config.processor_topology), }, #[cfg(guest_arch = "x86_64")] - clock: tsc::PartitionClock::new(tsc_frequency), - #[cfg(guest_arch = "x86_64")] time_abi, vtl0_alias_map_offset, monitor_page: MonitorPage::new(), @@ -1618,22 +1578,13 @@ impl VtlPartition { .for_op("set processor count")?; #[cfg(guest_arch = "x86_64")] - let time_abi = match &config.time_abi { - Some(time_abi) => Some(time_abi::WhpTimeAbi::configure( - time_abi, - &mut whp_config, - &mut extended_exits, - )?), - None => { - if config.versioned_cpu_contract { - cpu_contract::configure_versioned_contract( - &mut whp_config, - &mut extended_exits, - )?; - } - None - } - }; + let time_abi = config + .time_abi + .as_ref() + .map(|time_abi| { + time_abi::WhpTimeAbi::configure(time_abi, &mut whp_config, &mut extended_exits) + }) + .transpose()?; #[cfg(guest_arch = "x86_64")] if nested_virt { diff --git a/vmm_core/virt_whp/src/tsc.rs b/vmm_core/virt_whp/src/tsc.rs index 1319463fdba..1bfcc9f6d8d 100644 --- a/vmm_core/virt_whp/src/tsc.rs +++ b/vmm_core/virt_whp/src/tsc.rs @@ -1,51 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! Restored-TSC support: the partition clock and its TSC model for restored -//! SMP partitions, TSC synchronization on restore, the CPUID leaves that -//! expose the exact TSC frequency, and the clock frequencies used by snapshot -//! restore. +//! The CPUID leaves that expose the exact TSC frequency to partitions without +//! the time ABI. use crate::Error; -use crate::LocalApicKind; use crate::VtlPartition; -use crate::WhpPartitionInner; -use crate::WhpResultExt; -use inspect::Inspect; -use parking_lot::Mutex; use x86defs::cpuid::CpuidFunction; -pub(crate) const IA32_TSC_ADJUST: u32 = 0x3b; -const REFERENCE_TIME_FREQUENCY: u128 = 10_000_000; - -/// The TSC frequency and the restored-TSC clock of a partition. -#[derive(Inspect)] -pub(crate) struct PartitionClock { - pub(crate) tsc_frequency_hz: u64, - #[inspect(skip)] - pub(crate) restored_tsc: Mutex>, -} - -impl PartitionClock { - pub(crate) fn new(tsc_frequency_hz: u64) -> Self { - Self { - tsc_frequency_hz, - restored_tsc: Mutex::new(None), - } - } - - /// Checks that a saved TSC frequency matches this partition's. - pub(crate) fn check_frequency(&self, frequency_hz: u64) -> Result<(), Error> { - if frequency_hz != self.tsc_frequency_hz { - return Err(Error::TscFrequencyMismatch { - saved: frequency_hz, - destination: self.tsc_frequency_hz, - }); - } - Ok(()) - } -} - /// Adds the CPUID leaves that expose the exact TSC frequency. pub(crate) fn add_frequency_leaves( cpuid: Vec, @@ -62,339 +24,3 @@ pub(crate) fn add_frequency_leaves( )?); Ok(cpuid) } - -impl WhpPartitionInner { - /// Disables the restored-TSC clock before a partition reset. - pub(crate) fn reset_restored_tsc(&self) -> Result<(), Error> { - let mut restored_tsc = self.clock.restored_tsc.lock(); - if let Some(clock) = &*restored_tsc { - clock.disable(&self.vtl0.whp)?; - } - *restored_tsc = None; - Ok(()) - } - - /// Aligns the restored VP counters with partition time suspended, and - /// enables the restored-TSC clock. - pub(crate) fn advance_snapshot_time(&self) -> Result<(), Error> { - if self.vps.len() <= 1 { - return Ok(()); - } - - let partition = &self.vtl0.whp; - partition - .suspend_time() - .for_op("suspend restored partition time")?; - let tsc = synchronize_restored_tscs(partition, self.vps.len())?; - if self.caps.nested_virt { - return Ok(()); - } - let leaf = CpuidFunction::ExtendedFeatures.0; - let features = self.cpuid.result(leaf, 0, &self.vtl0.cpuid(leaf, 0)); - let tsc_adjust_supported = - x86defs::cpuid::ExtendedFeatureSubleaf0Ebx::from(features[1]).tsc_adjust(); - let mut clock = self.clock.restored_tsc.lock(); - *clock = Some(RestoredTsc::enable( - partition, - tsc, - self.clock.tsc_frequency_hz, - self.vps.len(), - tsc_adjust_supported, - clock.as_ref(), - )?); - Ok(()) - } - - pub(crate) fn apic_frequency_hz(&self) -> Result, Error> { - let frequency = match &self.vtl0.lapic { - LocalApicKind::Emulated(_) => virt_support_apic::TIMER_FREQUENCY, - LocalApicKind::Offloaded => self - .vtl0 - .whp - .apic_frequency() - .for_op("get APIC clock frequency")?, - }; - Ok(Some(frequency)) - } -} - -fn synchronize_restored_tscs(partition: &whp::Partition, vp_count: usize) -> Result { - let tsc = partition - .vp(0) - .get_register(whp::Register64::Tsc) - .for_op("read restored BSP TSC")?; - for vp_index in 1..vp_count as u32 { - partition - .vp(vp_index) - .set_register(whp::Register64::Tsc, tsc) - .for_op("synchronize restored VP TSC")?; - } - Ok(tsc) -} - -pub(crate) struct RestoredTsc { - clock: ReferenceTsc, - original_exits: whp::abi::WHV_EXTENDED_VM_EXITS, - original_msr_exits: whp::abi::WHV_X64_MSR_EXIT_BITMAP, - pub(crate) tsc_adjust_supported: bool, -} - -impl RestoredTsc { - pub(crate) fn enable( - partition: &whp::Partition, - tsc: u64, - frequency_hz: u64, - vp_count: usize, - tsc_adjust_supported: bool, - previous: Option<&Self>, - ) -> Result { - let exits = partition - .extended_vm_exits() - .for_op("get restored VM exits")?; - let msr_exits = partition - .x64_msr_exit_bitmap() - .for_op("get restored MSR exits")?; - let reference_time = partition - .reference_time() - .for_op("get restored TSC epoch")?; - let (original_exits, original_msr_exits) = previous - .map(|previous| (previous.original_exits, previous.original_msr_exits)) - .unwrap_or((exits, msr_exits)); - - partition - .set_property(whp::PartitionProperty::X64MsrExitBitmap( - msr_exits - | whp::abi::WHV_X64_MSR_EXIT_BITMAP::TscMsrRead - | whp::abi::WHV_X64_MSR_EXIT_BITMAP::TscMsrWrite, - )) - .for_op("intercept restored TSC MSRs")?; - partition - .set_property(whp::PartitionProperty::ExtendedVmExits( - exits - | whp::abi::WHV_EXTENDED_VM_EXITS::X64RdtscExit - | whp::abi::WHV_EXTENDED_VM_EXITS::X64MsrExit, - )) - .for_op("intercept restored TSC reads")?; - - Ok(Self { - clock: ReferenceTsc::new(tsc, reference_time, frequency_hz, vp_count), - original_exits, - original_msr_exits, - tsc_adjust_supported, - }) - } - - pub(crate) fn disable(&self, partition: &whp::Partition) -> Result<(), Error> { - partition - .set_property(whp::PartitionProperty::ExtendedVmExits(self.original_exits)) - .for_op("reset restored TSC instruction exits")?; - partition - .set_property(whp::PartitionProperty::X64MsrExitBitmap( - self.original_msr_exits, - )) - .for_op("reset restored TSC MSR exits")?; - Ok(()) - } - - pub(crate) fn read(&self, vp_index: u32, reference_time: u64) -> Option { - self.clock.read(vp_index, reference_time) - } - - pub(crate) fn restore(&mut self, vp_index: u32, tsc: u64, reference_time: u64) { - self.clock.restore(vp_index, tsc, reference_time); - } - - pub(crate) fn guest_write(&mut self, vp_index: u32) { - self.clock.guest_write(vp_index); - } -} - -pub(crate) fn msr_index(exit_msr: u32, write: bool, rcx: u64) -> u32 { - // WHP reports TSC_ADJUST and TSC_DEADLINE writes as TSC writes. - // ECX retains the original selector. - if write && exit_msr == x86defs::X86X_MSR_TSC { - rcx as u32 - } else { - exit_msr - } -} - -struct ReferenceTsc { - frequency_hz: u64, - offsets: Vec>, -} - -impl ReferenceTsc { - fn new(tsc: u64, reference_time: u64, frequency_hz: u64, vp_count: usize) -> Self { - let mut clock = Self { - frequency_hz, - offsets: Vec::new(), - }; - let offset = tsc.wrapping_sub(clock.ticks(reference_time)); - clock.offsets.resize(vp_count, Some(offset)); - clock - } - - fn ticks(&self, reference_time: u64) -> u64 { - // Reference time is partition-wide, in 100ns units. The architectural - // TSC wraps at 64 bits, including at frequencies requiring a wide product. - (u128::from(reference_time) * u128::from(self.frequency_hz) / REFERENCE_TIME_FREQUENCY) - as u64 - } - - fn read(&self, vp_index: u32, reference_time: u64) -> Option { - self.offsets[vp_index as usize] - .map(|offset| self.ticks(reference_time).wrapping_add(offset)) - } - - fn restore(&mut self, vp_index: u32, tsc: u64, reference_time: u64) { - self.offsets[vp_index as usize] = Some(tsc.wrapping_sub(self.ticks(reference_time))); - } - - fn guest_write(&mut self, vp_index: u32) { - // A guest explicitly programming one counter owns its new timeline. - // Preserve WHP's native TSC/TSC_ADJUST and deadline-timer semantics. - self.offsets[vp_index as usize] = None; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use test_with_tracing::test; - - #[test] - fn tsc_adjust_write_uses_the_original_guest_msr_selector() { - assert_eq!(msr_index(0x10, true, 0x10), 0x10); - assert_eq!(msr_index(0x10, true, 0x3b), 0x3b); - assert_eq!(msr_index(0x10, true, 0x6e0), 0x6e0); - assert_eq!(msr_index(0x10, true, 0xffff_ffff_0000_003b), 0x3b); - assert_eq!(msr_index(0x3b, false, 0x3b), 0x3b); - assert_eq!(msr_index(0x6e0, false, 0x6e0), 0x6e0); - assert_eq!(msr_index(0x10, false, 0x10), 0x10); - } - - #[test] - fn restored_counters_share_one_advancing_clock() { - for frequency in [1_000_000_000, 2_793_439_000] { - for count in [1, 2, 4, 8] { - let clock = ReferenceTsc::new(123_456_789, 1234, frequency, count); - let mut previous = 123_456_789; - for reference in [1234, 1234, 1235, 1236, 10_001_234] { - for vp in 0..count as u32 { - let value = clock.read(vp, reference).unwrap(); - assert!(value >= previous); - assert_eq!(clock.read(0, reference), Some(value)); - previous = value; - } - } - assert_eq!(clock.read(0, 10_001_234), Some(123_456_789 + frequency)); - } - } - } - - #[test] - fn counter_and_offset_arithmetic_wraps() { - let clock = ReferenceTsc::new(u64::MAX - 100, 0, 1_000_000_000, 2); - assert_eq!(clock.read(1, 2), Some(99)); - let clock = ReferenceTsc::new(1, 1000, 1_000_000_000, 2); - assert_eq!(clock.read(0, 1000), Some(1)); - assert_eq!(clock.read(1, 1001), Some(101)); - let clock = ReferenceTsc::new(0, 0, 100_000_000, 1); - assert_eq!(clock.read(0, u64::MAX), Some(u64::MAX.wrapping_mul(10))); - } - - #[test] - fn explicit_guest_writes_affect_only_the_target_counter() { - let mut clock = ReferenceTsc::new(1000, 0, 1_000_000_000, 4); - clock.guest_write(1); - assert_eq!(clock.read(1, 10), None); - for vp in [0, 2, 3] { - assert_eq!(clock.read(vp, 10), Some(2000)); - } - clock.restore(1, 7, 10); - assert_eq!(clock.read(1, 10), Some(7)); - assert_eq!(clock.read(1, 11), Some(107)); - assert_eq!(clock.read(0, 11), Some(2100)); - } - - #[test] - fn restored_reference_time_can_move_backwards() { - let clock = ReferenceTsc::new(10_000, 100, 1_000_000_000, 2); - assert_eq!(clock.read(0, 50), Some(5000)); - assert_eq!(clock.read(1, 100), Some(10_000)); - } - - #[test] - #[ignore = "requires WHP"] - fn restored_tscs_are_identical_while_partition_time_is_suspended() { - for vp_count in [1, 2, 4, 8] { - let mut config = whp::PartitionConfig::new().unwrap(); - config - .set_property(whp::PartitionProperty::ProcessorCount(vp_count)) - .unwrap(); - let partition = config.create().unwrap(); - for vp_index in 0..vp_count { - partition.create_vp(vp_index).create().unwrap(); - } - - partition.suspend_time().unwrap(); - let expected_tsc = 1_000_000_u64; - for vp_index in 0..vp_count { - partition - .vp(vp_index) - .set_register( - whp::Register64::Tsc, - expected_tsc + u64::from(vp_index) * 100_000, - ) - .unwrap(); - } - - synchronize_restored_tscs(&partition, vp_count as usize).unwrap(); - std::thread::sleep(std::time::Duration::from_millis(10)); - - for vp_index in 0..vp_count { - let actual_tsc = partition - .vp(vp_index) - .get_register(whp::Register64::Tsc) - .unwrap(); - assert_eq!(actual_tsc, expected_tsc, "VP {vp_index} TSC differs"); - } - } - } - - #[test] - #[ignore = "requires WHP"] - fn restored_tsc_intercepts_are_reversible() { - let mut config = whp::PartitionConfig::new().unwrap(); - config - .set_property(whp::PartitionProperty::ProcessorCount(2)) - .unwrap(); - let partition = config.create().unwrap(); - for vp in 0..2 { - partition.create_vp(vp).create().unwrap(); - } - partition.suspend_time().unwrap(); - let original_exits = partition.extended_vm_exits().unwrap(); - let original_msrs = partition.x64_msr_exit_bitmap().unwrap(); - let frequency = partition.tsc_frequency().unwrap(); - let clock = RestoredTsc::enable(&partition, 1_000_000, frequency, 2, false, None).unwrap(); - assert!( - partition - .extended_vm_exits() - .unwrap() - .is_set(whp::abi::WHV_EXTENDED_VM_EXITS::X64RdtscExit) - ); - assert!( - partition - .x64_msr_exit_bitmap() - .unwrap() - .is_set(whp::abi::WHV_X64_MSR_EXIT_BITMAP::TscMsrRead) - ); - let clock = - RestoredTsc::enable(&partition, 2_000_000, frequency, 2, false, Some(&clock)).unwrap(); - clock.disable(&partition).unwrap(); - assert_eq!(partition.extended_vm_exits().unwrap().0, original_exits.0); - assert_eq!(partition.x64_msr_exit_bitmap().unwrap().0, original_msrs.0); - } -} diff --git a/vmm_core/virt_whp/src/vp.rs b/vmm_core/virt_whp/src/vp.rs index 4f5749dbd41..1c6b413d8f9 100644 --- a/vmm_core/virt_whp/src/vp.rs +++ b/vmm_core/virt_whp/src/vp.rs @@ -1,9 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -#[cfg(guest_arch = "x86_64")] -mod tsc; - use super::Vplc; use super::VtlPartition; use super::vtl2::Vtl2InterceptState; @@ -42,8 +39,6 @@ pub(crate) struct ExitStats { #[cfg(guest_arch = "x86_64")] cpuid: Counter, #[cfg(guest_arch = "x86_64")] - rdtsc: Counter, - #[cfg(guest_arch = "x86_64")] apic_eoi: Counter, cancel: Counter, halt: Counter, @@ -578,10 +573,6 @@ mod x86 { self.handle_cpuid(info, exit); &mut self.state.exits.cpuid } - ExitReason::Rdtsc(info) => { - self.handle_rdtsc_exit(dev, info, exit)?; - &mut self.state.exits.rdtsc - } ExitReason::ApicEoi(info) => { self.handle_apic_eoi(info, dev); &mut self.state.exits.apic_eoi @@ -1255,9 +1246,6 @@ mod x86 { { return Ok(()); } - if self.handle_restored_tsc_msr_exit(dev, info, exit)? { - return Ok(()); - } let handled = if info.AccessInfo.IsWrite() { self.msr_write(dev, exit, info.MsrNumber, info.Rax, info.Rdx) } else { diff --git a/vmm_core/virt_whp/src/vp/tsc.rs b/vmm_core/virt_whp/src/vp/tsc.rs deleted file mode 100644 index 56d95acd7b0..00000000000 --- a/vmm_core/virt_whp/src/vp/tsc.rs +++ /dev/null @@ -1,233 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! RDTSC and TSC MSR exits of a restored SMP partition. While the partition's -//! restored-TSC clock (see [`crate::tsc`]) is active, guest timestamp reads -//! use its partition-wide TSC model. - -use crate::WhpProcessor; -use crate::WhpResultExt; -use hvdef::Vtl; -use virt::VpHaltReason; -use virt::io::CpuIo; -use whp::set_registers; - -impl WhpProcessor<'_> { - /// Handles an RDTSC or RDTSCP exit. - pub(super) fn handle_rdtsc_exit( - &self, - dev: &impl CpuIo, - info: &whp::abi::WHV_X64_RDTSC_CONTEXT, - exit: whp::Exit<'_>, - ) -> Result<(), VpHaltReason> { - self.handle_rdtsc(info, exit) - .map_err(|error| dev.fatal_error(error.into())) - } - - /// Handles a TSC, TSC_ADJUST, or TSC deadline MSR exit while the - /// restored-TSC clock is active. Returns whether the access was handled. - pub(super) fn handle_restored_tsc_msr_exit( - &self, - dev: &impl CpuIo, - info: &whp::abi::WHV_X64_MSR_ACCESS_CONTEXT, - exit: whp::Exit<'_>, - ) -> Result { - self.handle_restored_tsc_msr(info, exit) - .map_err(|error| dev.fatal_error(error.into())) - } - - fn inject_general_protection_fault(&self) -> Result<(), crate::Error> { - let event = hvdef::HvX64PendingExceptionEvent::new() - .with_event_pending(true) - .with_event_type(hvdef::HV_X64_PENDING_EVENT_EXCEPTION) - .with_deliver_error_code(true) - .with_vector(0xd); - self.current_whp() - .set_register(whp::Register128::PendingEvent, event.into()) - .for_op("inject general protection fault") - } - - fn read_restored_tsc(&self) -> Result { - let reference = self - .current_vtlp() - .whp - .reference_time() - .for_op("read guest TSC reference time")?; - let value = { - let clock = self.vp.partition.clock.restored_tsc.lock(); - clock - .as_ref() - .ok_or(crate::Error::UnexpectedTimestampExit)? - .read(self.vp.index.index(), reference) - }; - match value { - Some(value) => Ok(value), - None => self - .current_whp() - .get_register(whp::Register64::Tsc) - .for_op("read guest-programmed TSC"), - } - } - - fn complete_tsc_read( - &self, - value: u64, - rip: u64, - tsc_aux: Option, - ) -> Result<(), crate::Error> { - let rax = value & 0xffff_ffff; - let rdx = value >> 32; - match tsc_aux { - Some(aux) => { - set_registers!( - self.current_whp(), - [ - (whp::Register64::Rax, rax), - (whp::Register64::Rdx, rdx), - (whp::Register64::Rip, rip), - (whp::Register64::Rcx, aux & 0xffff_ffff), - ], - ) - .for_op("complete guest timestamp read")?; - } - None => { - set_registers!( - self.current_whp(), - [ - (whp::Register64::Rax, rax), - (whp::Register64::Rdx, rdx), - (whp::Register64::Rip, rip), - ], - ) - .for_op("complete guest timestamp read")?; - } - } - Ok(()) - } - - fn handle_rdtsc( - &self, - info: &whp::abi::WHV_X64_RDTSC_CONTEXT, - exit: whp::Exit<'_>, - ) -> Result<(), crate::Error> { - if exit.vp_context.ExecutionState.Cpl() != 0 { - let cr4 = self - .current_whp() - .get_register(whp::Register64::Cr4) - .for_op("check timestamp access permission")?; - if cr4 & x86defs::X64_CR4_TSD != 0 { - return self.inject_general_protection_fault(); - } - } - let value = self.read_restored_tsc()?; - if self.state.exits.rdtsc.get() < 4 { - tracing::debug!( - vp = self.vp.index.index(), - value, - whp_tsc = info.Tsc, - whp_reference_time = info.ReferenceTime, - "restored timestamp read" - ); - } - let rip = exit - .vp_context - .Rip - .wrapping_add(u64::from(exit.vp_context.InstructionLength())); - let tsc_aux = (info.RdtscInfo & 1 != 0).then_some(info.TscAux); - self.complete_tsc_read(value, rip, tsc_aux) - } - - fn handle_restored_tsc_msr( - &self, - info: &whp::abi::WHV_X64_MSR_ACCESS_CONTEXT, - exit: whp::Exit<'_>, - ) -> Result { - if self.state.active_vtl != Vtl::Vtl0 - || !matches!( - info.MsrNumber, - x86defs::X86X_MSR_TSC - | crate::tsc::IA32_TSC_ADJUST - | x86defs::X86X_MSR_TSC_DEADLINE - ) - { - return Ok(false); - } - let tsc_adjust_supported = { - let clock = self.vp.partition.clock.restored_tsc.lock(); - let Some(clock) = clock.as_ref() else { - return Ok(false); - }; - clock.tsc_adjust_supported - }; - if exit.vp_context.ExecutionState.Cpl() != 0 { - self.inject_general_protection_fault()?; - return Ok(true); - } - let rcx = self - .current_whp() - .get_register(whp::Register64::Rcx) - .for_op("read timestamp MSR selector")?; - let msr = crate::tsc::msr_index(info.MsrNumber, info.AccessInfo.IsWrite(), rcx); - if !matches!( - msr, - x86defs::X86X_MSR_TSC | crate::tsc::IA32_TSC_ADJUST | x86defs::X86X_MSR_TSC_DEADLINE - ) || (msr == crate::tsc::IA32_TSC_ADJUST && !tsc_adjust_supported) - || (msr == x86defs::X86X_MSR_TSC_DEADLINE && !self.vp.partition.caps.tsc_deadline) - { - self.inject_general_protection_fault()?; - return Ok(true); - } - - let rip = exit.vp_context.Rip.wrapping_add(2); - if info.AccessInfo.IsWrite() { - let value = (info.Rax & 0xffff_ffff) | (info.Rdx << 32); - let changed = if msr == x86defs::X86X_MSR_TSC_DEADLINE { - self.current_whp() - .set_register(whp::Register64::TscDeadline, value) - .for_op("write guest TSC deadline")?; - false - } else if msr == crate::tsc::IA32_TSC_ADJUST { - let previous = self - .current_whp() - .get_register(whp::Register64::TscAdjust) - .for_op("read guest TSC adjustment")?; - self.current_whp() - .set_register(whp::Register64::TscAdjust, value) - .for_op("write guest TSC adjustment")?; - value != previous - } else { - self.current_whp() - .set_register(whp::Register64::Tsc, value) - .for_op("write guest TSC")?; - true - }; - if changed { - self.vp - .partition - .clock - .restored_tsc - .lock() - .as_mut() - .ok_or(crate::Error::UnexpectedTimestampExit)? - .guest_write(self.vp.index.index()); - } - self.current_whp() - .set_register(whp::Register64::Rip, rip) - .for_op("complete guest TSC write")?; - } else { - let value = if msr == x86defs::X86X_MSR_TSC_DEADLINE { - self.current_whp() - .get_register(whp::Register64::TscDeadline) - .for_op("read guest TSC deadline")? - } else if msr == crate::tsc::IA32_TSC_ADJUST { - self.current_whp() - .get_register(whp::Register64::TscAdjust) - .for_op("read guest TSC adjustment")? - } else { - self.read_restored_tsc()? - }; - self.complete_tsc_read(value, rip, None)?; - } - Ok(true) - } -} diff --git a/vmm_core/virt_whp/src/vp_state.rs b/vmm_core/virt_whp/src/vp_state.rs index 854d5cb7abc..b80adf4906d 100644 --- a/vmm_core/virt_whp/src/vp_state.rs +++ b/vmm_core/virt_whp/src/vp_state.rs @@ -191,15 +191,11 @@ mod x86 { } fn tsc(&mut self) -> Result { - if let Some(tsc) = self.restored_tsc()? { - return Ok(tsc); - } self.run.vp.get_register_state(self.vtl) } fn set_tsc(&mut self, value: &vp::Tsc) -> Result<(), Self::Error> { - self.run.vp.set_register_state(self.vtl, value)?; - self.restore_tsc(value) + self.run.vp.set_register_state(self.vtl, value) } fn tsc_deadline(&mut self) -> Result { diff --git a/vmm_core/virt_whp/src/vp_state/tsc.rs b/vmm_core/virt_whp/src/vp_state/tsc.rs index a6505792642..e1507c5ce44 100644 --- a/vmm_core/virt_whp/src/vp_state/tsc.rs +++ b/vmm_core/virt_whp/src/vp_state/tsc.rs @@ -1,11 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! IA32_TSC and IA32_TSC_DEADLINE state access with the restored-TSC clock. +//! IA32_TSC_DEADLINE state access ordering. use super::WhpVpStateAccess; use crate::Error; -use crate::WhpResultExt; use virt::x86::vp; use virt::x86::vp::AccessVpState; @@ -36,45 +35,4 @@ impl WhpVpStateAccess<'_, '_> { self.run.vp.get_register_state(self.vtl) } } - - /// Returns the VTL0 TSC of the restored-TSC clock, unless the guest has - /// programmed this VP's counter. - pub(super) fn restored_tsc(&self) -> Result, Error> { - if self.vtl == hvdef::Vtl::Vtl0 { - let clock = self.run.vp.partition.clock.restored_tsc.lock(); - if let Some(clock) = &*clock { - let reference = self - .run - .vp - .partition - .vtl0 - .whp - .reference_time() - .for_op("read restored TSC reference time")?; - if let Some(value) = clock.read(self.run.vp.index.index(), reference) { - return Ok(Some(vp::Tsc { value })); - } - } - } - Ok(None) - } - - /// Restores this VP's VTL0 counter in the restored-TSC clock. - pub(super) fn restore_tsc(&self, value: &vp::Tsc) -> Result<(), Error> { - if self.vtl == hvdef::Vtl::Vtl0 { - let mut clock = self.run.vp.partition.clock.restored_tsc.lock(); - if let Some(clock) = &mut *clock { - let reference = self - .run - .vp - .partition - .vtl0 - .whp - .reference_time() - .for_op("restore TSC reference time")?; - clock.restore(self.run.vp.index.index(), value.value, reference); - } - } - Ok(()) - } } From aaff0bbc25b69818ec7c17591f21d9df5f824c01 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 00:18:46 -0700 Subject: [PATCH 259/304] virt, vmm_core, openvmm_core: delete the legacy microVM clock interfaces After the flip, the time ABI is the only microVM time path, and the backends have removed their legacy clock code. Delete what remains of the legacy interfaces, all NVX additions that the upstream fork base never had: - virt: Partition::{cpu_compatibility_contract, tsc_frequency_hz, set_tsc_frequency_hz, advance_snapshot_time, apic_frequency_hz}, Processor::advance_tsc, ProtoPartitionConfig::versioned_cpu_contract, the x86 cpu_contract module (CpuCompatibilityContract and its leaf and XSAVE records), X86PartitionCapabilities::kvm_clock with its CPUID detection, the KVM clock fields 6 and 7 of SyntheticMsrs (which restores the upstream layout), and Apic::{advance_timer, advance_tsc_deadline}, which advance_one_shot_timer replaced. SyntheticMsrs and ReferenceTime are present only with hv1 again. - vmm_core: the partition unit's legacy TSC advance (TscAdvance, VpSet::advance_tsc, PartitionUnit::advance_tsc, and their requests). - openvmm_core: the microVM partition's legacy forwarding methods and Processor::advance_tsc. The trait methods and the capability field cannot outlive their implementations, so this commit also makes the matching mechanical edits in the backends (the cpu_compatibility_contract impls of KVM, MSHV, WHP, and OpenHCL's virt_mshv_vtl, and the kvm_clock checks and initializer of MSHV and WHP), tmk_vmm, and MSHV's tests. virt::x86::tsc keeps tsc_frequency_cpuid_leaves: WHP still uses it for partitions without the time ABI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openhcl/virt_mshv_vtl/src/lib.rs | 5 - openvmm/openvmm_core/src/partition.rs | 5 - openvmm/openvmm_core/src/partition/microvm.rs | 39 ---- openvmm/openvmm_core/src/worker/dispatch.rs | 1 - tmk/tmk_vmm/src/host_vmm.rs | 1 - vmm_core/src/partition_unit/snapshot.rs | 32 +-- vmm_core/src/partition_unit/vp_set.rs | 15 -- vmm_core/src/partition_unit/vp_set/tsc.rs | 194 ------------------ vmm_core/virt/src/generic.rs | 34 --- vmm_core/virt/src/time_abi/identity.rs | 6 +- vmm_core/virt/src/x86/cpu_contract.rs | 118 ----------- vmm_core/virt/src/x86/mod.rs | 6 - vmm_core/virt/src/x86/tsc.rs | 44 +--- vmm_core/virt/src/x86/vm.rs | 2 +- vmm_core/virt/src/x86/vp.rs | 13 +- vmm_core/virt/src/x86/vp/snapshot.rs | 183 ----------------- vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 4 - vmm_core/virt_mshv/src/x86_64/finalize.rs | 4 +- vmm_core/virt_mshv/src/x86_64/mod.rs | 5 - vmm_core/virt_mshv/src/x86_64/time_abi.rs | 4 - vmm_core/virt_whp/src/lib.rs | 9 +- vmm_core/virt_whp/src/time_abi.rs | 3 +- 22 files changed, 13 insertions(+), 714 deletions(-) delete mode 100644 vmm_core/src/partition_unit/vp_set/tsc.rs delete mode 100644 vmm_core/virt/src/x86/cpu_contract.rs diff --git a/openhcl/virt_mshv_vtl/src/lib.rs b/openhcl/virt_mshv_vtl/src/lib.rs index 0617cae3537..47c82cc9631 100755 --- a/openhcl/virt_mshv_vtl/src/lib.rs +++ b/openhcl/virt_mshv_vtl/src/lib.rs @@ -923,11 +923,6 @@ impl virt::Partition for UhPartition { virt::InitialVpStateSource::Registers } - #[cfg(guest_arch = "x86_64")] - fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { - virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) - } - fn supports_reset(&self) -> Option<&dyn virt::ResetPartition> { None } diff --git a/openvmm/openvmm_core/src/partition.rs b/openvmm/openvmm_core/src/partition.rs index 50b70714101..304c2071b73 100644 --- a/openvmm/openvmm_core/src/partition.rs +++ b/openvmm/openvmm_core/src/partition.rs @@ -394,11 +394,6 @@ impl Processor for WrappedVp<'_, T> { self.0.access_state(vtl) } - #[cfg(guest_arch = "x86_64")] - fn advance_tsc(&mut self, cycles: u64) -> anyhow::Result<()> { - self.0.advance_tsc(cycles) - } - fn vtl_inspectable(&self, vtl: Vtl) -> bool { self.0.vtl_inspectable(vtl) } diff --git a/openvmm/openvmm_core/src/partition/microvm.rs b/openvmm/openvmm_core/src/partition/microvm.rs index 9b67644dda1..05052f16d31 100644 --- a/openvmm/openvmm_core/src/partition/microvm.rs +++ b/openvmm/openvmm_core/src/partition/microvm.rs @@ -11,22 +11,6 @@ use virt::PartitionMemoryMapper; /// Backend partition operations required by the microVM lifecycle. pub trait MicrovmPartition: Send + Sync { - /// Returns the effective x86 CPU compatibility contract. - #[cfg(guest_arch = "x86_64")] - fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract; - - /// Returns the effective guest TSC frequency. - fn tsc_frequency_hz(&self) -> anyhow::Result>; - - /// Requests the effective guest TSC frequency. - fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> anyhow::Result<()>; - - /// Advances backend-specific guest clock state after snapshot downtime. - fn advance_snapshot_time(&self, duration: std::time::Duration) -> anyhow::Result<()>; - - /// Returns the LAPIC interrupt clock frequency when available. - fn apic_frequency_hz(&self) -> anyhow::Result>; - /// Returns the NVX time ABI primitives, if the partition was built with a /// time ABI configuration. #[cfg(guest_arch = "x86_64")] @@ -37,29 +21,6 @@ impl MicrovmPartition for T where T: BasicPartitionStateAccess + ArchPartition + PartitionMemoryMapper + PartitionAccessState, { - #[cfg(guest_arch = "x86_64")] - fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { - Partition::cpu_compatibility_contract(self) - } - - fn tsc_frequency_hz(&self) -> anyhow::Result> { - Ok(Partition::tsc_frequency_hz(self)?) - } - - fn set_tsc_frequency_hz(&self, frequency_hz: u64) -> anyhow::Result<()> { - Partition::set_tsc_frequency_hz(self, frequency_hz)?; - Ok(()) - } - - fn advance_snapshot_time(&self, duration: std::time::Duration) -> anyhow::Result<()> { - Partition::advance_snapshot_time(self, duration)?; - Ok(()) - } - - fn apic_frequency_hz(&self) -> anyhow::Result> { - Ok(Partition::apic_frequency_hz(self)?) - } - #[cfg(guest_arch = "x86_64")] fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { Partition::time_abi(self) diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 9df9caeab7c..79f0f927a5f 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1293,7 +1293,6 @@ impl InitializedVm { device_assignment_msi_iova_range, user_mode_memory_faults, lazy_memory_registration, - versioned_cpu_contract: false, #[cfg(guest_arch = "x86_64")] time_abi: time_abi_config, }) diff --git a/tmk/tmk_vmm/src/host_vmm.rs b/tmk/tmk_vmm/src/host_vmm.rs index 240bc9f7bbb..b3d67dca5d0 100644 --- a/tmk/tmk_vmm/src/host_vmm.rs +++ b/tmk/tmk_vmm/src/host_vmm.rs @@ -49,7 +49,6 @@ impl RunContext<'_> { device_assignment_msi_iova_range: None, user_mode_memory_faults: true, lazy_memory_registration: false, - versioned_cpu_contract: false, #[cfg(guest_arch = "x86_64")] time_abi: None, }) diff --git a/vmm_core/src/partition_unit/snapshot.rs b/vmm_core/src/partition_unit/snapshot.rs index 301abcf4523..9a5a0658758 100644 --- a/vmm_core/src/partition_unit/snapshot.rs +++ b/vmm_core/src/partition_unit/snapshot.rs @@ -2,8 +2,8 @@ // Licensed under the MIT License. //! Partition unit support for snapshots: validating the instantiated VP prefix, -//! stopping VPs at a deferred I/O boundary for capture, and advancing TSC after -//! restore downtime. +//! stopping VPs at a deferred I/O boundary for capture, and the NVX time ABI's +//! LAPIC timer checks and restore advance. use super::Error; use super::PartitionRequest; @@ -18,8 +18,6 @@ use mesh::rpc::RpcSend; pub(super) enum SnapshotRequest { StopVpsAtIoBoundary(FailableRpc<(mesh::OneshotSender<()>, mesh::OneshotReceiver<()>), ()>), #[cfg(guest_arch = "x86_64")] - AdvanceTsc(FailableRpc<(std::time::Duration, u64, Option), ()>), - #[cfg(guest_arch = "x86_64")] CheckTimers(FailableRpc<(), ()>), #[cfg(guest_arch = "x86_64")] AdvanceLapic(FailableRpc<(u64, u64), ()>), @@ -70,23 +68,6 @@ impl PartitionUnit { Ok(StopGuard(self.req_send.clone())) } - /// Advances TSC state on all stopped vCPUs. - #[cfg(guest_arch = "x86_64")] - pub async fn advance_tsc( - &mut self, - duration: std::time::Duration, - frequency_hz: u64, - apic_frequency_hz: Option, - ) -> anyhow::Result<()> { - self.req_send - .call_failable( - |rpc| PartitionRequest::Snapshot(SnapshotRequest::AdvanceTsc(rpc)), - (duration, frequency_hz, apic_frequency_hz), - ) - .await?; - Ok(()) - } - /// Checks the LAPIC timer of every stopped vCPU for the NVX time ABI /// (`E_LAPIC_PERIODIC`, `E_LAPIC_TSC_DEADLINE`). #[cfg(guest_arch = "x86_64")] @@ -149,15 +130,6 @@ impl PartitionUnitRunner { .await } #[cfg(guest_arch = "x86_64")] - SnapshotRequest::AdvanceTsc(rpc) => { - rpc.handle_failable(async |(duration, frequency_hz, apic_frequency_hz)| { - self.vp_set - .advance_tsc(duration, frequency_hz, apic_frequency_hz) - .await - }) - .await - } - #[cfg(guest_arch = "x86_64")] SnapshotRequest::CheckTimers(rpc) => { rpc.handle_failable(async |()| self.vp_set.check_one_shot_timers().await) .await diff --git a/vmm_core/src/partition_unit/vp_set.rs b/vmm_core/src/partition_unit/vp_set.rs index 3bb95f56828..67935fe3ea5 100644 --- a/vmm_core/src/partition_unit/vp_set.rs +++ b/vmm_core/src/partition_unit/vp_set.rs @@ -7,8 +7,6 @@ mod boundary; mod prefix; #[cfg(guest_arch = "x86_64")] mod time_abi; -#[cfg(guest_arch = "x86_64")] -mod tsc; use super::HaltReason; use super::HaltReasonReceiver; @@ -85,10 +83,6 @@ trait ControlVp: ProtobufSaveRestore { /// Scrub per-VP state for a VTL. fn scrub(&mut self, vtl: Vtl) -> anyhow::Result<()>; - /// Advances the stopped vCPU TSC after snapshot downtime. - #[cfg(guest_arch = "x86_64")] - fn advance_tsc(&mut self, advance: tsc::TscAdvance) -> anyhow::Result<()>; - /// Checks the stopped vCPU's LAPIC timer for the time ABI. #[cfg(guest_arch = "x86_64")] fn check_one_shot_timer(&mut self) -> anyhow::Result<()>; @@ -194,11 +188,6 @@ where } } - #[cfg(guest_arch = "x86_64")] - fn advance_tsc(&mut self, advance: tsc::TscAdvance) -> anyhow::Result<()> { - advance.apply(self.vp) - } - #[cfg(guest_arch = "x86_64")] fn check_one_shot_timer(&mut self) -> anyhow::Result<()> { time_abi::check_one_shot_timer(self.vp) @@ -1145,8 +1134,6 @@ enum StateEvent { Reset(mesh::rpc::FailableRpc<(), ()>), Scrub(mesh::rpc::FailableRpc), #[cfg(guest_arch = "x86_64")] - AdvanceTsc(mesh::rpc::FailableRpc), - #[cfg(guest_arch = "x86_64")] CheckTimers(mesh::rpc::FailableRpc<(), ()>), #[cfg(guest_arch = "x86_64")] AdvanceLapic(mesh::rpc::FailableRpc), @@ -1397,8 +1384,6 @@ impl RunnerInner { StateEvent::Reset(rpc) => rpc.handle_failable_sync(|()| vp.reset()), StateEvent::Scrub(rpc) => rpc.handle_failable_sync(|vtl| vp.scrub(vtl)), #[cfg(guest_arch = "x86_64")] - StateEvent::AdvanceTsc(rpc) => rpc.handle_failable_sync(|tsc| vp.advance_tsc(tsc)), - #[cfg(guest_arch = "x86_64")] StateEvent::CheckTimers(rpc) => { rpc.handle_failable_sync(|()| vp.check_one_shot_timer()) } diff --git a/vmm_core/src/partition_unit/vp_set/tsc.rs b/vmm_core/src/partition_unit/vp_set/tsc.rs deleted file mode 100644 index 107535a69dd..00000000000 --- a/vmm_core/src/partition_unit/vp_set/tsc.rs +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! TSC advancement for snapshot restore downtime. - -use super::StateEvent; -use super::VpEvent; -use super::VpSet; -use anyhow::Context as _; -use futures::future::TryJoinAll; -use hvdef::Vtl; -use mesh::rpc::RpcSend; -use std::time::Duration; -use virt::Processor; -use virt::vp::AccessVpState; - -/// A request to advance the TSC of a stopped VP by the restore downtime. -#[derive(Debug, Copy, Clone)] -pub(super) struct TscAdvance { - duration: Duration, - frequency_hz: u64, - apic_frequency_hz: Option, -} - -struct AdvancedTsc { - value: u64, - cycles: u64, -} - -impl AdvancedTsc { - fn validate(&self, observed_tsc: u64) -> anyhow::Result<()> { - anyhow::ensure!( - observed_tsc >= self.value, - "restored TSC did not advance by the required downtime: requested {:#x}, observed {observed_tsc:#x}, adjustment {} cycles", - self.value, - self.cycles, - ); - Ok(()) - } -} - -fn advance_tsc_state( - previous_tsc: u64, - duration: Duration, - frequency_hz: u64, -) -> anyhow::Result { - let cycles = duration - .as_nanos() - .checked_mul(u128::from(frequency_hz)) - .context("TSC downtime adjustment overflows")? - / 1_000_000_000; - let cycles = u64::try_from(cycles).context("TSC downtime adjustment exceeds u64")?; - let value = previous_tsc - .checked_add(cycles) - .context("TSC downtime adjustment exceeds the counter range")?; - Ok(AdvancedTsc { value, cycles }) -} - -impl TscAdvance { - /// Advances the TSC of the stopped VP `vp`, together with its LAPIC timer - /// and TSC deadline when present. - pub(super) fn apply(self, vp: &mut impl Processor) -> anyhow::Result<()> { - let Self { - duration, - frequency_hz, - apic_frequency_hz, - } = self; - let mut access = vp.access_state(Vtl::Vtl0); - let tsc = access.tsc().context("failed to read stopped vCPU TSC")?; - let mut tsc_deadline = access - .caps() - .tsc_deadline - .then(|| access.tsc_deadline()) - .transpose() - .context("failed to read stopped vCPU TSC deadline")?; - let mut apic = apic_frequency_hz - .map(|frequency| { - let mut apic = access - .apic() - .context("failed to read stopped LAPIC timer")?; - apic.advance_timer(duration, frequency)?; - anyhow::Ok(apic) - }) - .transpose()?; - let previous_tsc = tsc.value; - let advanced = advance_tsc_state(previous_tsc, duration, frequency_hz)?; - if let (Some(apic), Some(tsc_deadline)) = (apic.as_mut(), tsc_deadline.as_mut()) { - tsc_deadline.value = - apic.advance_tsc_deadline(previous_tsc, advanced.value, tsc_deadline.value); - } - drop(access); - vp.advance_tsc(advanced.cycles) - .context("failed to adjust stopped vCPU TSC")?; - let mut access = vp.access_state(Vtl::Vtl0); - if let Some(apic) = apic.take() { - access - .set_apic(&apic) - .context("failed to reprogram stopped LAPIC timer")?; - } - if let Some(tsc_deadline) = tsc_deadline { - access - .set_tsc_deadline(&tsc_deadline) - .context("failed to reprogram stopped vCPU TSC deadline")?; - } - access - .commit() - .context("failed to commit adjusted vCPU TSC")?; - let observed_tsc = access - .tsc() - .context("failed to read back adjusted vCPU TSC")? - .value; - tracing::debug!( - previous_tsc, - requested_tsc = advanced.value, - observed_tsc, - cycles = advanced.cycles, - frequency_hz, - ?duration, - "adjusted restored vCPU TSC" - ); - advanced.validate(observed_tsc)?; - Ok(()) - } -} - -impl VpSet { - /// Advances TSC state on every stopped vCPU. - pub async fn advance_tsc( - &mut self, - duration: Duration, - frequency_hz: u64, - apic_frequency_hz: Option, - ) -> anyhow::Result<()> { - anyhow::ensure!(!self.started, "vCPUs must be stopped before adjusting TSC"); - self.vps - .iter() - .enumerate() - .map(|(index, vp)| async move { - vp.send - .call_failable( - |rpc| VpEvent::State(StateEvent::AdvanceTsc(rpc)), - TscAdvance { - duration, - frequency_hz, - apic_frequency_hz, - }, - ) - .await - .with_context(|| format!("vp{index} TSC adjustment")) - }) - .collect::>() - .await?; - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - use test_with_tracing::test; - - #[test] - fn downtime_advances_tsc_by_elapsed_cycles() { - let advanced = advance_tsc_state(1_000, Duration::from_secs(1), 1_000).unwrap(); - - assert_eq!(advanced.value, 2_000); - assert_eq!(advanced.cycles, 1_000); - } - - #[test] - fn downtime_rejects_tsc_overflow() { - assert!(advance_tsc_state(u64::MAX, Duration::from_nanos(1), 1_000_000_000).is_err()); - assert!(advance_tsc_state(0, Duration::MAX, u64::MAX).is_err()); - } - - #[test] - fn downtime_rejects_discarded_or_partial_tsc_advancement() { - let advanced = advance_tsc_state(1_000, Duration::from_millis(250), 1_000_000_000).unwrap(); - - assert!(advanced.validate(1_000).is_err()); - assert!(advanced.validate(advanced.value - 1).is_err()); - } - - #[test] - fn downtime_allows_tsc_progress_during_adjustment() { - for duration in [Duration::ZERO, Duration::from_millis(250)] { - let advanced = advance_tsc_state(1_000, duration, 1_000_000_000).unwrap(); - - advanced.validate(advanced.value).unwrap(); - advanced.validate(advanced.value + 1_000).unwrap(); - } - } -} diff --git a/vmm_core/virt/src/generic.rs b/vmm_core/virt/src/generic.rs index 8ce86d8117b..642385bcb74 100644 --- a/vmm_core/virt/src/generic.rs +++ b/vmm_core/virt/src/generic.rs @@ -362,8 +362,6 @@ pub struct ProtoPartitionConfig<'a> { /// Whether supported backends should register large guest-memory ranges /// incrementally as the guest accesses them. pub lazy_memory_registration: bool, - /// Build the reproducible CPU/clock contract used by a versioned machine profile. - pub versioned_cpu_contract: bool, /// The NVX time ABI configuration. A backend that implements the time ABI /// returns `Some` from [`Partition::time_abi`] for such a partition; the /// worker rejects one that does not. @@ -544,30 +542,6 @@ pub trait Partition: 'static + Hv1 + Inspect + Send + Sync { Ok(()) } - /// Returns the effective x86 CPU compatibility contract. - #[cfg(guest_arch = "x86_64")] - fn cpu_compatibility_contract(&self) -> crate::x86::CpuCompatibilityContract; - - /// Returns the effective guest TSC frequency when applicable. - fn tsc_frequency_hz(&self) -> Result, Self::Error> { - Ok(None) - } - - /// Requests the effective guest TSC frequency when supported. - fn set_tsc_frequency_hz(&self, _frequency_hz: u64) -> Result<(), Self::Error> { - Ok(()) - } - - /// Advances any backend-specific guest clock by snapshot downtime. - fn advance_snapshot_time(&self, _duration: std::time::Duration) -> Result<(), Self::Error> { - Ok(()) - } - - /// Returns the effective LAPIC interrupt clock frequency when available. - fn apic_frequency_hz(&self) -> Result, Self::Error> { - Ok(None) - } - /// Returns the NVX time ABI primitives, if the partition was built with /// [`ProtoPartitionConfig::time_abi`]. #[cfg(guest_arch = "x86_64")] @@ -802,14 +776,6 @@ pub trait Processor: InspectMut { } fn access_state(&mut self, vtl: Vtl) -> Self::StateAccess<'_>; - - /// Advances the stopped VTL0 TSC by the specified number of guest cycles. - /// - /// Backends may override this to adjust a running counter's offset directly. - #[cfg(guest_arch = "x86_64")] - fn advance_tsc(&mut self, cycles: u64) -> anyhow::Result<()> { - crate::x86::tsc::advance_tsc(self, cycles) - } } /// A source for [`StopVp`]. diff --git a/vmm_core/virt/src/time_abi/identity.rs b/vmm_core/virt/src/time_abi/identity.rs index 9ae1583a784..98179d76454 100644 --- a/vmm_core/virt/src/time_abi/identity.rs +++ b/vmm_core/virt/src/time_abi/identity.rs @@ -134,9 +134,8 @@ pub fn time_abi_cpuid(vp_count: u32, invariant_tsc: bool) -> CpuidLeafSet { /// /// Backends pass this view to /// [`X86PartitionCapabilities::from_cpuid`](crate::x86::X86PartitionCapabilities::from_cpuid), -/// so the identity leaves never make `hv1` or `kvm_clock` true: the time ABI -/// exposes neither the synthetic MSR, SynIC, and reference-time state that -/// `hv1` implies nor the KVM clock. +/// so the identity leaves never make `hv1` true: the time ABI does not expose +/// the synthetic MSR, SynIC, and reference-time state that `hv1` implies. pub fn capabilities_cpuid<'a>( cpuid: &'a mut dyn FnMut(u32, u32) -> [u32; 4], ) -> impl FnMut(u32, u32) -> [u32; 4] + 'a { @@ -467,7 +466,6 @@ mod tests { X86PartitionCapabilities::from_cpuid(&topology, &mut capabilities_cpuid(&mut cpuid)) .unwrap(); assert!(!masked.hv1); - assert!(!masked.kvm_clock); assert!(!masked.tsc_deadline); } } diff --git a/vmm_core/virt/src/x86/cpu_contract.rs b/vmm_core/virt/src/x86/cpu_contract.rs deleted file mode 100644 index 16960326324..00000000000 --- a/vmm_core/virt/src/x86/cpu_contract.rs +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -//! The effective x86 CPU compatibility contract that a snapshot restore -//! destination must reproduce exactly. - -use super::X86PartitionCapabilities; - -/// Canonical CPUID leaf in a saved CPU compatibility contract. -#[derive(Debug, Clone, PartialEq, Eq, mesh_protobuf::Protobuf)] -#[mesh(package = "virt.x86")] -pub struct CpuContractCpuidLeaf { - #[mesh(1)] - pub function: u32, - #[mesh(2)] - pub index: Option, - #[mesh(3)] - pub result: [u32; 4], - #[mesh(4)] - pub mask: [u32; 4], -} - -/// XSAVE component layout in a saved CPU compatibility contract. -#[derive(Debug, Clone, PartialEq, Eq, mesh_protobuf::Protobuf)] -#[mesh(package = "virt.x86")] -pub struct CpuContractXsaveComponent { - #[mesh(1)] - pub index: u32, - #[mesh(2)] - pub offset: u32, - #[mesh(3)] - pub length: u32, - #[mesh(4)] - pub align: bool, -} - -/// Effective CPU contract that a destination must reproduce exactly. -#[derive(Debug, Clone, PartialEq, Eq, mesh_protobuf::Protobuf)] -#[mesh(package = "virt.x86")] -pub struct CpuCompatibilityContract { - #[mesh(1)] - pub vendor: [u8; 12], - #[mesh(2)] - pub cpuid: Vec, - #[mesh(3)] - pub xcr0_supported: u64, - #[mesh(4)] - pub xss_supported: u64, - #[mesh(5)] - pub xsave_standard_len: u32, - #[mesh(6)] - pub xsave_compact_len: u32, - #[mesh(7)] - pub xsave_components: Vec, - #[mesh(8)] - pub x2apic: bool, - #[mesh(9)] - pub x2apic_enabled: bool, - #[mesh(10)] - pub cet: bool, - #[mesh(11)] - pub cet_ss: bool, - #[mesh(12)] - pub sgx: bool, - #[mesh(13)] - pub tsc_aux: bool, - #[mesh(14)] - pub physical_address_width: u32, - #[mesh(15)] - pub tsc_deadline: bool, - #[mesh(16)] - pub kvm_clock: bool, -} - -impl CpuCompatibilityContract { - /// Builds a canonical contract from effective partition state. - pub fn new(caps: &X86PartitionCapabilities, cpuid: &crate::CpuidLeafSet) -> Self { - Self { - vendor: caps.vendor.0, - cpuid: cpuid - .leaves() - .iter() - .map(|leaf| CpuContractCpuidLeaf { - function: leaf.function, - index: leaf.index, - result: leaf.result, - mask: leaf.mask, - }) - .collect(), - xcr0_supported: caps.xsave.features, - xss_supported: caps.xsave.supervisor_features, - xsave_standard_len: caps.xsave.standard_len, - xsave_compact_len: caps.xsave.compact_len, - xsave_components: caps - .xsave - .feature_info - .iter() - .enumerate() - .filter(|(_, feature)| feature.len != 0) - .map(|(index, feature)| CpuContractXsaveComponent { - index: index as u32, - offset: feature.offset, - length: feature.len, - align: feature.align, - }) - .collect(), - x2apic: caps.x2apic, - x2apic_enabled: caps.x2apic_enabled, - cet: caps.cet, - cet_ss: caps.cet_ss, - sgx: caps.sgx, - tsc_aux: caps.tsc_aux, - physical_address_width: u32::from(caps.physical_address_width), - tsc_deadline: caps.tsc_deadline, - kvm_clock: caps.kvm_clock, - } - } -} diff --git a/vmm_core/virt/src/x86/mod.rs b/vmm_core/virt/src/x86/mod.rs index 1f1152c2223..f7cdc9b99aa 100644 --- a/vmm_core/virt/src/x86/mod.rs +++ b/vmm_core/virt/src/x86/mod.rs @@ -4,7 +4,6 @@ //! x86-specific state. pub mod apic_software_device; -pub mod cpu_contract; pub mod snp; pub mod topology; pub mod tsc; @@ -12,7 +11,6 @@ pub mod vm; pub mod vp; use crate::state::StateElement; -pub use cpu_contract::CpuCompatibilityContract; use inspect::Inspect; use mesh_protobuf::Protobuf; use std::fmt::Debug; @@ -79,8 +77,6 @@ pub struct X86PartitionCapabilities { pub tsc_aux: bool, /// IA32_TSC_DEADLINE is supported. pub tsc_deadline: bool, - /// KVM's paravirtual clock and its configuration MSRs are exposed. - pub kvm_clock: bool, /// The address of the virtual top of memory, for encrypted VMs. /// /// This is computed from the Hyper-V isolation leaf. It is guaranteed to be @@ -141,7 +137,6 @@ impl X86PartitionCapabilities { sgx: false, tsc_aux: false, tsc_deadline: false, - kvm_clock: false, vtom: None, physical_address_width: max_physical_address_size_from_cpuid(&mut *f), snp_c_bit: snp_c_bit_from_cpuid(&mut *f), @@ -227,7 +222,6 @@ impl X86PartitionCapabilities { // Hypervisor info. if hypervisor { - this.kvm_clock = tsc::kvm_clock_from_cpuid(&mut *f); let hv_max = f(hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION, 0)[0]; if hv_max >= hvdef::HV_CPUID_FUNCTION_MS_HV_ENLIGHTENMENT_INFORMATION && f(hvdef::HV_CPUID_FUNCTION_HV_INTERFACE, 0)[0] == u32::from_le_bytes(*b"Hv#1") diff --git a/vmm_core/virt/src/x86/tsc.rs b/vmm_core/virt/src/x86/tsc.rs index e903cbfe554..599e20060a4 100644 --- a/vmm_core/virt/src/x86/tsc.rs +++ b/vmm_core/virt/src/x86/tsc.rs @@ -1,9 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -//! TSC and paravirtual clock support: CPUID leaves that expose an exact TSC -//! frequency, KVM clock detection, and the default snapshot-downtime TSC -//! adjustment. +//! CPUID leaves that expose an exact TSC frequency, for partitions without +//! the NVX time ABI. use thiserror::Error; use x86defs::cpuid::CpuidFunction; @@ -63,45 +62,6 @@ pub fn tsc_frequency_cpuid_leaves( ]) } -/// Returns whether the hypervisor CPUID leaves report KVM with its -/// paravirtual clock (`KVM_FEATURE_CLOCKSOURCE2`). -pub(super) fn kvm_clock_from_cpuid(f: &mut dyn FnMut(u32, u32) -> [u32; 4]) -> bool { - let [_, ebx, ecx, edx] = f(hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION, 0); - let mut vendor = [0_u8; 12]; - vendor[0..4].copy_from_slice(&ebx.to_le_bytes()); - vendor[4..8].copy_from_slice(&ecx.to_le_bytes()); - vendor[8..12].copy_from_slice(&edx.to_le_bytes()); - if vendor.starts_with(b"KVMKVMKVM") { - const KVM_FEATURE_CLOCKSOURCE2: u32 = 1 << 3; - f(hvdef::HV_CPUID_FUNCTION_HV_INTERFACE, 0)[0] & KVM_FEATURE_CLOCKSOURCE2 != 0 - } else { - false - } -} - -/// Advances the stopped VTL0 TSC of `processor` by `cycles` guest cycles -/// through its state access interface. -/// -/// This is the default implementation of `Processor::advance_tsc`. -#[cfg(guest_arch = "x86_64")] -pub(crate) fn advance_tsc( - processor: &mut P, - cycles: u64, -) -> anyhow::Result<()> { - use crate::x86::vp::AccessVpState; - use anyhow::Context as _; - - let mut access = processor.access_state(hvdef::Vtl::Vtl0); - let mut tsc = access.tsc()?; - tsc.value = tsc - .value - .checked_add(cycles) - .context("TSC downtime adjustment exceeds the counter range")?; - access.set_tsc(&tsc)?; - access.commit()?; - Ok(()) -} - #[cfg(test)] mod tests { use super::tsc_frequency_cpuid_leaves; diff --git a/vmm_core/virt/src/x86/vm.rs b/vmm_core/virt/src/x86/vm.rs index df99c18fd9c..1dda3a92aeb 100644 --- a/vmm_core/virt/src/x86/vm.rs +++ b/vmm_core/virt/src/x86/vm.rs @@ -119,7 +119,7 @@ impl HvRegisterState for ReferenceTime { impl StateElement for ReferenceTime { fn is_present(caps: &X86PartitionCapabilities) -> bool { - caps.hv1 || caps.kvm_clock + caps.hv1 } fn at_reset(_caps: &X86PartitionCapabilities, _vp_info: &X86VpInfo) -> Self { diff --git a/vmm_core/virt/src/x86/vp.rs b/vmm_core/virt/src/x86/vp.rs index f725b93bd95..cfb10b97861 100644 --- a/vmm_core/virt/src/x86/vp.rs +++ b/vmm_core/virt/src/x86/vp.rs @@ -1710,12 +1710,6 @@ pub struct SyntheticMsrs { #[mesh(5)] #[inspect(iter_by_index)] pub sint: [u64; 16], - /// KVM wall-clock GPA/configuration MSR. - #[mesh(6)] - pub kvm_wall_clock: u64, - /// KVM system-time GPA/configuration MSR. - #[mesh(7)] - pub kvm_system_time: u64, } impl HvRegisterState for SyntheticMsrs { @@ -1771,21 +1765,16 @@ impl HvRegisterState for SyntheticMsrs { impl StateElement for SyntheticMsrs { fn is_present(caps: &X86PartitionCapabilities) -> bool { - caps.hv1 || caps.kvm_clock + caps.hv1 } fn at_reset(_caps: &X86PartitionCapabilities, _vp_info: &X86VpInfo) -> Self { - if !_caps.hv1 { - return Self::default(); - } Self { vp_assist_page: 0, scontrol: 1, siefp: 0, simp: 0, sint: [0x10000; 16], - kvm_wall_clock: 0, - kvm_system_time: 0, } } } diff --git a/vmm_core/virt/src/x86/vp/snapshot.rs b/vmm_core/virt/src/x86/vp/snapshot.rs index db790ecb87d..532442c3e94 100644 --- a/vmm_core/virt/src/x86/vp/snapshot.rs +++ b/vmm_core/virt/src/x86/vp/snapshot.rs @@ -12,7 +12,6 @@ use crate::time_abi::TimeAbiCode; use crate::time_abi::TimeAbiError; use crate::time_abi::rate; use crate::x86::X86PartitionCapabilities; -use anyhow::Context as _; use hvdef::HvRegisterValue; use hvdef::HvX64RegisterName; use inspect::Inspect; @@ -35,80 +34,6 @@ impl Apic { self.auto_eoi[bank] &= !mask; } - /// Advances an active TSC deadline and returns the restored deadline value. - pub fn advance_tsc_deadline( - &mut self, - previous_tsc: u64, - advanced_tsc: u64, - deadline: u64, - ) -> u64 { - let mut registers = ApicRegisters::from_array(self.registers); - let timer_mode = (registers.lvt_timer >> 17) & 0x3; - let deadline_distance = deadline.wrapping_sub(previous_tsc) as i64; - let elapsed_ticks = advanced_tsc - previous_tsc; - if deadline != 0 - && timer_mode == 2 - && (deadline_distance <= 0 || deadline_distance as u64 <= elapsed_ticks) - { - self.queue_timer_interrupt(&mut registers); - self.registers = *registers.as_array(); - 0 - } else { - deadline - } - } - - /// Advances the LAPIC timer by host downtime using the interrupt clock. - pub fn advance_timer( - &mut self, - duration: std::time::Duration, - frequency_hz: u64, - ) -> anyhow::Result<()> { - let registers = ApicRegisters::from_array(self.registers); - let mut registers = registers; - if registers.timer_ccr == 0 { - return Ok(()); - } - - let timer_mode = (registers.lvt_timer >> 17) & 0x3; - if timer_mode == 2 { - // TSC-deadline mode is handled through IA32_TSC_DEADLINE. - return Ok(()); - } - let divider_shift = match registers.timer_dcr & 0xb { - 0xb => 0, - 0 => 1, - 1 => 2, - 2 => 3, - 3 => 4, - 8 => 5, - 9 => 6, - 0xa => 7, - _ => unreachable!(), - }; - let elapsed_ticks = duration - .as_nanos() - .checked_mul(u128::from(frequency_hz)) - .context("LAPIC timer downtime adjustment overflows")? - / 1_000_000_000 - / (1_u128 << divider_shift); - - if elapsed_ticks >= u128::from(registers.timer_ccr) { - self.queue_timer_interrupt(&mut registers); - if timer_mode == 1 && registers.timer_icr != 0 { - let remaining = elapsed_ticks - u128::from(registers.timer_ccr); - let period = u128::from(registers.timer_icr); - registers.timer_ccr = (period - remaining % period) as u32; - } else { - registers.timer_ccr = 0; - } - } else { - registers.timer_ccr -= elapsed_ticks as u32; - } - self.registers = *registers.as_array(); - Ok(()) - } - /// Checks the LAPIC timer against the NVX time ABI: an armed periodic /// timer fails with `E_LAPIC_PERIODIC`, and TSC-deadline (or reserved) /// timer mode with `E_LAPIC_TSC_DEADLINE`. @@ -215,7 +140,6 @@ impl StateElement for TscDeadline { #[cfg(test)] mod tests { use super::*; - use std::time::Duration; use test_with_tracing::test; use zerocopy::FromZeros; @@ -244,113 +168,6 @@ mod tests { registers.irr[TIMER_VECTOR as usize / 32] & (1 << (TIMER_VECTOR as usize % 32)) != 0 } - #[test] - fn snapshot_downtime_expires_one_shot_once() { - let mut apic = apic(TIMER_VECTOR, 100, 50); - - apic.advance_timer(Duration::from_nanos(50), 1_000_000_000) - .unwrap(); - - assert_eq!(apic.registers().timer_ccr, 0); - assert!(timer_pending(&apic)); - - apic.advance_timer(Duration::from_secs(1), 1_000_000_000) - .unwrap(); - assert!(timer_pending(&apic)); - } - - #[test] - fn snapshot_downtime_does_not_queue_masked_one_shot() { - let mut apic = apic(TIMER_VECTOR | TIMER_MASKED, 100, 50); - - apic.advance_timer(Duration::from_nanos(50), 1_000_000_000) - .unwrap(); - - assert_eq!(apic.registers().timer_ccr, 0); - assert!(!timer_pending(&apic)); - } - - #[test] - fn snapshot_downtime_coalesces_periods_and_preserves_phase() { - let mut apic = apic(TIMER_VECTOR | TIMER_PERIODIC, 100, 25); - - apic.advance_timer(Duration::from_nanos(250), 1_000_000_000) - .unwrap(); - - assert_eq!(apic.registers().timer_ccr, 75); - assert!(timer_pending(&apic)); - } - - #[test] - fn snapshot_downtime_preserves_near_expiry_timer() { - let mut apic = apic(TIMER_VECTOR, 100, 50); - - apic.advance_timer(Duration::from_nanos(49), 1_000_000_000) - .unwrap(); - - assert_eq!(apic.registers().timer_ccr, 1); - assert!(!timer_pending(&apic)); - } - - #[test] - fn snapshot_downtime_rejects_lapic_tick_overflow() { - let mut apic = apic(TIMER_VECTOR, 100, 50); - - let error = apic.advance_timer(Duration::MAX, u64::MAX).unwrap_err(); - - assert!(error.to_string().contains("overflows")); - } - - #[test] - fn snapshot_downtime_expires_tsc_deadline_once() { - let mut apic = apic(TIMER_VECTOR | TIMER_TSC_DEADLINE, 0, 0); - - let deadline = apic.advance_tsc_deadline(1_000, 2_000, 2_000); - - assert_eq!(deadline, 0); - assert!(timer_pending(&apic)); - } - - #[test] - fn snapshot_downtime_expires_already_overdue_tsc_deadline() { - let mut apic = apic(TIMER_VECTOR | TIMER_TSC_DEADLINE, 0, 0); - - let deadline = apic.advance_tsc_deadline(1_000, 2_000, 500); - - assert_eq!(deadline, 0); - assert!(timer_pending(&apic)); - } - - #[test] - fn snapshot_downtime_preserves_wrapped_future_tsc_deadline() { - let mut apic = apic(TIMER_VECTOR | TIMER_TSC_DEADLINE, 0, 0); - - let deadline = apic.advance_tsc_deadline(u64::MAX - 1_000, u64::MAX - 900, 500); - - assert_eq!(deadline, 500); - assert!(!timer_pending(&apic)); - } - - #[test] - fn snapshot_downtime_drops_masked_tsc_deadline() { - let mut apic = apic(TIMER_VECTOR | TIMER_TSC_DEADLINE | TIMER_MASKED, 0, 0); - - let deadline = apic.advance_tsc_deadline(1_000, 2_000, 2_000); - - assert_eq!(deadline, 0); - assert!(!timer_pending(&apic)); - } - - #[test] - fn snapshot_downtime_ignores_deadline_outside_deadline_mode() { - let mut apic = apic(TIMER_VECTOR, 0, 0); - - let deadline = apic.advance_tsc_deadline(1_000, 2_000, 1_500); - - assert_eq!(deadline, 1_500); - assert!(!timer_pending(&apic)); - } - fn with_divide(apic: Apic, dcr: u32) -> Apic { let mut registers = ApicRegisters::from_array(apic.registers); registers.timer_dcr = dcr; diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index b46638894f6..d9e961d70c9 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -865,10 +865,6 @@ impl Partition for KvmPartition { virt::InitialVpStateSource::Registers } - fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { - virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) - } - fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { self.inner .time_abi diff --git a/vmm_core/virt_mshv/src/x86_64/finalize.rs b/vmm_core/virt_mshv/src/x86_64/finalize.rs index ff7ec131f8e..b0ab1fef3e9 100644 --- a/vmm_core/virt_mshv/src/x86_64/finalize.rs +++ b/vmm_core/virt_mshv/src/x86_64/finalize.rs @@ -165,10 +165,10 @@ impl MshvPartitionInner { caps.xsaves_state_bv_broken = true; // Ordinary state access does not freeze the partition clock. caps.can_freeze_time = false; - if self.time_abi.is_some() && (caps.hv1 || caps.kvm_clock) { + if self.time_abi.is_some() && caps.hv1 { return Err(virt::time_abi::TimeAbiError::new( virt::time_abi::TimeAbiCode::IdentityRouting, - "the time ABI partition capabilities include hv1 or the KVM clock", + "the time ABI partition capabilities include hv1", ) .into()); } diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index f0ab09c0692..1186d12b009 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -485,7 +485,6 @@ impl MshvPartitionInner { // MSHV does not reliably deliver TSC deadline events to direct-boot // guests. Fall back to the LAPIC count-mode clockevent. tsc_deadline: false, - kvm_clock: false, vtom: None, physical_address_width: self .vmfd @@ -666,10 +665,6 @@ impl virt::Partition for MshvPartition { self.inner.finalize_memory() } - fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { - virt::x86::CpuCompatibilityContract::new(self.inner.caps(), &self.inner.config.cpuid) - } - fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { self.inner.time_abi.as_ref().map(|_| self as _) } diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 4f935c193a2..2fa392e734a 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -1711,7 +1711,6 @@ mod hw { nested_virt: false, user_mode_memory_faults: false, lazy_memory_registration: false, - versioned_cpu_contract: false, time_abi: Some(TimeAbiConfig { cpuid: Arc::new(host_profile_cpuid(&processor_topology).1), msrs: Arc::new(TimeAbiMsrs::new()), @@ -1746,7 +1745,6 @@ mod hw { } assert!(!partition.caps().hv1); - assert!(!partition.caps().kvm_clock); let backend = partition.time_abi().expect("a time ABI partition"); let started = std::time::Instant::now(); @@ -1973,7 +1971,6 @@ mod hw { nested_virt: false, user_mode_memory_faults: false, lazy_memory_registration: false, - versioned_cpu_contract: false, time_abi: Some(TimeAbiConfig { cpuid: Arc::new(table), msrs: Arc::new(TimeAbiMsrs::new()), @@ -2204,7 +2201,6 @@ mod hw { nested_virt: false, user_mode_memory_faults: false, lazy_memory_registration: false, - versioned_cpu_contract: false, time_abi: Some(TimeAbiConfig { cpuid: Arc::new(table), msrs: Arc::new(TimeAbiMsrs::new()), diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 42d10e2e640..c57b303356f 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -638,11 +638,6 @@ impl virt::Partition for WhpPartition { virt::InitialVpStateSource::Registers } - #[cfg(guest_arch = "x86_64")] - fn cpu_compatibility_contract(&self) -> virt::x86::CpuCompatibilityContract { - virt::x86::CpuCompatibilityContract::new(&self.inner.caps, &self.inner.cpuid) - } - #[cfg(guest_arch = "x86_64")] fn time_abi(&self) -> Option<&dyn virt::time_abi::TimeAbiBackend> { self.inner.time_abi.as_ref().map(|_| &*self.inner as _) @@ -1346,10 +1341,10 @@ impl WhpPartitionInner { caps.xsaves_state_bv_broken = true; caps.dr6_tsx_broken = true; caps.nested_virt = nested_virt; - if time_abi.is_some() && (caps.hv1 || caps.kvm_clock) { + if time_abi.is_some() && caps.hv1 { return Err(virt::time_abi::TimeAbiError::new( virt::time_abi::TimeAbiCode::IdentityRouting, - "the time ABI partition capabilities include hv1 or the KVM clock", + "the time ABI partition capabilities include hv1", ) .into()); } diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 2ce67205aba..427dd0c6df2 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -2343,7 +2343,7 @@ mod tests { } #[test] - fn capabilities_exclude_hv1_and_the_kvm_clock() { + fn capabilities_exclude_hv1() { for profile in test_cpuid::PROFILES { let partition = Partition::new(profile, 1, X2ApicState::Supported); let mut lookup = |function, index| partition.observe(0, function, index); @@ -2353,7 +2353,6 @@ mod tests { ) .unwrap(); assert!(!caps.hv1, "{profile}"); - assert!(!caps.kvm_clock, "{profile}"); assert!(!caps.tsc_deadline, "{profile}"); } } From 9383bbc356fb1f4b26ea96fe5d8a7cc1be33b7ae Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 01:05:51 -0700 Subject: [PATCH 260/304] virt_whp: enumerate the host CPUID without the hypervisor range host_cpuid() feeds the cheap supported surface and the host entries outside the profile. Neither reads the hypervisor range: the surface drops it and unlisted_cpuid_candidates exempts it. In a root partition every CPUID instruction exits, so enumerate only the basic and extended ranges with cpu_profile's enumerate_basic_and_extended. That skips the root's 13 hypervisor leaves, about a fifth of the queries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_whp/src/time_abi.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 427dd0c6df2..6726103d3c1 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -347,10 +347,12 @@ fn routing_error(message: impl Into) -> TimeAbiError { } /// Returns the host's CPUID as the root partition sees it: every leaf and -/// subleaf that [`cpu_profile::cpuid::enumerate`] walks, read once per +/// subleaf of the basic and extended ranges that +/// [`cpu_profile::cpuid::enumerate_basic_and_extended`] walks, read once per /// process. It describes the processor that WHP virtualizes, for /// [`supported_surface`] and for the host entries outside the profile -/// ([`unlisted_cpuid`]). +/// ([`unlisted_cpuid`]). Neither uses the hypervisor range, which describes +/// the root's hypervisor, and each of its queries exits in a root partition. pub(crate) fn host_cpuid() -> &'static [cpu_profile::cpuid::CpuidEntry] { static HOST_CPUID: OnceLock> = OnceLock::new(); HOST_CPUID.get_or_init(|| { @@ -365,7 +367,8 @@ pub(crate) fn host_cpuid() -> &'static [cpu_profile::cpuid::CpuidEntry] { // xtask-fmt allow-target-arch cpu-intrinsic #[cfg(not(target_arch = "x86_64"))] let query = |_, _| Ok::<_, std::convert::Infallible>([0; 4]); - cpu_profile::cpuid::enumerate(query).unwrap_or_else(|never| match never {}) + cpu_profile::cpuid::enumerate_basic_and_extended(query) + .unwrap_or_else(|never| match never {}) }) } From b164a64c4a9a78affabc32010f18be0a9f45c720 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 01:23:39 -0700 Subject: [PATCH 261/304] openvmm_helpers, openvmm_entry: make manifest version 6 the only snapshot format Every microVM snapshot has carried the NVX time ABI records in manifest version 6 since the flip, and restore already rejected versions 2 to 5. Delete the remaining legacy manifest code, so version 6 is the format's only version for every snapshot, regular VMs included. - format: MANIFEST_VERSION is 6 and SNAPSHOT_FORMAT_MAGIC is OPENVMM_SNAPSHOT_V6. The TIME_ABI_* aliases and the version 2 to 5 constants and rules are removed. The header check rejects any other version or magic with E_SNAPSHOT_VERSION, so every reader reports the code, and validate_manifest_contents checks the time records of a microVM machine contract before the capture tier. - manifest: the version 2 artifact digests (fields 9 and 10) are retired. - machine contract: the clock fields of versions 2 to 5 (fields 11 to 15, 17, and 20), MicrovmClockContract, set_cpu_compatibility_contract, ADVANCE_BY_HOST_DOWNTIME, and the legacy clock validation are removed. microvm_machine_contract takes the time contract and the CPU profile record. Platform snapshots no longer special-case the tsc_early_khz and lapic_timer_hz tokens, which the time ABI rejects with E_CMDLINE_CLOCK_TOKEN. - time: set_time_abi_records, check_time_abi_manifest_version, and the retired-field checks are removed. required_machine_contract reports a microVM manifest without a machine contract as E_MANIFEST_TIME; CLI and ttrpc restore use it. - Guide: the snapshot format page describes the single version and the retired tags. Mesh decoding skips unknown fields, so a manifest that still sets a retired field decodes and then fails the version check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/dev_guide/snapshot_format.md | 24 +- .../openvmm_entry/src/microvm/filesystem.rs | 12 +- openvmm/openvmm_entry/src/microvm/network.rs | 6 +- openvmm/openvmm_entry/src/microvm/restore.rs | 96 +-- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 6 +- .../src/vm_controller/microvm.rs | 46 +- .../examples/phase2_snapshot_bench.rs | 2 - openvmm/openvmm_helpers/src/snapshot.rs | 17 +- .../openvmm_helpers/src/snapshot/format.rs | 209 +++---- .../openvmm_helpers/src/snapshot/microvm.rs | 580 +++--------------- .../openvmm_helpers/src/snapshot/publish.rs | 20 +- .../openvmm_helpers/src/snapshot/restore.rs | 62 +- openvmm/openvmm_helpers/src/snapshot/time.rs | 142 +---- 13 files changed, 302 insertions(+), 920 deletions(-) diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 343935381b3..3d658125d73 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -23,22 +23,21 @@ The manifest is a protobuf message defined as in `openvmm/openvmm_helpers/src/snapshot.rs`, encoded using the `mesh` crate's protobuf encoding. -MicroVM snapshots use manifest version 6, and host-driven standard-machine -snapshots use version 5. Version 6 adds the NVX time ABI records to the -microVM machine contract (the time contract and the CPU profile record) and -requires the clock fields it retires (the capture wall clock, the TSC and LAPIC -frequencies, the TSC tolerance and clock policy, and the CPU contract) to be -empty. MicroVM restore accepts only version 6. The legacy `state_sha256` and -`memory_sha256` protobuf tags remain reserved so version 2 manifests can be -decoded; versions 3 through 6 require both fields to be absent. Standard-machine -restore accepts versions 2 through 5 for compatibility. Tiered microVM snapshots +Every snapshot uses manifest version 6 with format magic +`OPENVMM_SNAPSHOT_V6\0`. Restore rejects any other version or magic with +`E_SNAPSHOT_VERSION`, which asks for a recapture. A microVM snapshot's machine +contract carries the NVX time ABI records (the time contract and the CPU +profile record), and a host-driven standard-machine snapshot has no machine +contract. The `SnapshotManifest` tags 9 and 10 (the version 2 artifact +digests) and the machine contract tags 11 to 15, 17, and 20 (the clock fields +of versions 2 to 5) are retired and never reused. Tiered microVM snapshots record capture tier, clone/resume policy, and consumed configuration sections. The default format is a local machine-state contract, not an authenticated -container. All versions receive the same regular-file, no-follow/no-reparse, +container. Every snapshot receives the same regular-file, no-follow/no-reparse, bounded decoding, exact-length, inventory, and machine-contract validation, but the on-disk format does not authenticate same-length payload changes. -Versions 4 through 6 record the SHA-256 and exact length of `scratch.img`, because guest +A paired capture records the SHA-256 and exact length of `scratch.img`, because guest RAM and a mounted writable filesystem must be restored as one exact pair. Export or transport layers must provide broader integrity and authentication outside this format. @@ -183,7 +182,8 @@ Key unsupported categories: ## Extending the format When adding new fields to `SnapshotManifest`, use the next available mesh -tag number. The protobuf encoding is forward-compatible: older readers will +tag number, never a retired one; the struct definitions list the retired tags. +The protobuf encoding is forward-compatible: older readers will ignore unknown fields. However, removing or reordering existing fields is a breaking change. See [Save State](contrib/save-state.md) for the full set of compatibility rules. diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index a832ba21976..65330f7cf5e 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -490,10 +490,8 @@ mod tests { ] .map(str::to_owned) .to_vec(), - std::time::SystemTime::now().into(), - 1_000_000_000, - Some(1_000_000_000), - vec![1, 2, 3], + crate::microvm::restore::tests::test_time_contract(), + crate::microvm::restore::tests::test_cpu_profile(), ) .unwrap() } @@ -538,10 +536,8 @@ mod tests { ] .map(str::to_owned) .to_vec(), - std::time::SystemTime::now().into(), - 1_000_000_000, - Some(1_000_000_000), - vec![1, 2, 3], + crate::microvm::restore::tests::test_time_contract(), + crate::microvm::restore::tests::test_cpu_profile(), ) .unwrap() } diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index 028cd33fd13..7d7d416d08a 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -244,10 +244,8 @@ pub(crate) mod tests { ] .map(str::to_owned) .to_vec(), - std::time::SystemTime::now().into(), - 1_000_000_000, - Some(1_000_000_000), - vec![1, 2, 3], + crate::microvm::restore::tests::test_time_contract(), + crate::microvm::restore::tests::test_cpu_profile(), ) .unwrap() } diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 965ce63bd15..bf9f8709f1b 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -24,7 +24,6 @@ use openvmm_defs::time_abi::RestoreTimeInput; use openvmm_defs::time_abi::SnapshotCpuProfile; use openvmm_defs::time_abi::SnapshotTimeContract; use openvmm_helpers::snapshot::SnapshotManifest; -use openvmm_helpers::snapshot::microvm::MicrovmClockContract; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use openvmm_helpers::snapshot::microvm::SnapshotMachineContract; use openvmm_helpers::snapshot::microvm::SnapshotMemoryExpansionRange; @@ -219,16 +218,12 @@ pub(crate) fn prepare_restore( .expect("restore manifest requires a snapshot path"); restore_gate_required = openvmm_helpers::snapshot::microvm::requires_post_restore_gate(manifest); - let contract = manifest - .machine_contract - .as_ref() - .context("microVM snapshot is missing its authoritative machine contract")?; + let contract = openvmm_helpers::snapshot::time::required_machine_contract(manifest)?; anyhow::ensure!( contract.machine_profile == "microvm", "snapshot machine profile does not match the requested microVM machine" ); openvmm_helpers::snapshot::microvm::validate_supported_microvm_contract(contract)?; - openvmm_helpers::snapshot::time::check_time_abi_manifest_version(manifest)?; if let Some(time) = &contract.time { openvmm_helpers::snapshot::time::validate_time_contract(time)?; } @@ -417,9 +412,10 @@ impl TimeAbiRestore { /// Validates the authoritative machine contract of a microVM snapshot against /// the restore-time configuration. /// -/// The manifest must be version 6: the time ABI preflight checks its records, -/// the backend, the CPU profile, and the downtime against this host before -/// the rest of the contract is compared. +/// The manifest, version 6 like every readable one, must carry the time ABI +/// records: the time ABI preflight checks them, the backend, the CPU profile, +/// and the downtime against this host before the rest of the contract is +/// compared. pub(crate) fn validate_restore_contract( manifest: &SnapshotManifest, expected_memory_size: u64, @@ -435,11 +431,7 @@ pub(crate) fn validate_restore_contract( ): ExpectedRestoreContract<'_>, time_abi: TimeAbiRestoreOptions<'_>, ) -> anyhow::Result { - let saved_contract = manifest - .machine_contract - .as_ref() - .context("microVM snapshot is missing its authoritative machine contract")?; - openvmm_helpers::snapshot::time::check_time_abi_manifest_version(manifest)?; + let saved_contract = openvmm_helpers::snapshot::time::required_machine_contract(manifest)?; let destination = preflight_time_abi_restore(saved_contract, expected_hypervisor, time_abi)?; let (Some(contract), Some(cpu_profile)) = ( saved_contract.time.clone(), @@ -454,27 +446,24 @@ pub(crate) fn validate_restore_contract( .as_ref() .and(filesystem) .map(|(config, root_path, attachment)| (config, root_path, attachment.clone())); - let mut expected_contract = - openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_clock( - expected_hypervisor, - openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, - effective_command_line.to_owned(), - network, - filesystem_slot, - filesystem, - console_attachment.cloned(), - control_console_attachment.cloned(), - sandbox_blocks, - expected_vp_count, - expected_memory_size, - (saved_contract.memory_expansion_version != 0) - .then_some(saved_contract.memory_capacity_bytes), - saved_contract.state_unit_names.clone(), - MicrovmClockContract::TimeAbi { - time: contract.clone(), - cpu_profile: cpu_profile.clone(), - }, - )?; + let mut expected_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( + expected_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + effective_command_line.to_owned(), + network, + filesystem_slot, + filesystem, + console_attachment.cloned(), + control_console_attachment.cloned(), + sandbox_blocks, + expected_vp_count, + expected_memory_size, + (saved_contract.memory_expansion_version != 0) + .then_some(saved_contract.memory_capacity_bytes), + saved_contract.state_unit_names.clone(), + contract.clone(), + cpu_profile.clone(), + )?; align_legacy_network_policy_contract(saved_contract, &mut expected_contract); align_restore_console_listener_contract(saved_contract, &mut expected_contract); openvmm_helpers::snapshot::microvm::validate_microvm_machine_contract( @@ -523,11 +512,46 @@ fn preflight_time_abi_restore( } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; use openvmm_helpers::snapshot::microvm::SnapshotMemoryExpansionRange; use test_with_tracing::test; + /// Returns a valid time contract for test machine contracts. + pub(crate) fn test_time_contract() -> SnapshotTimeContract { + SnapshotTimeContract { + time_abi_version: 1, + tsc_frequency_hz: 2_100_000_000, + tsc_tolerance_ppm: 250, + apic_frequency_hz: virt::time_abi::rate::LAPIC_HZ_HYPERV, + capture_tsc: 1, + capture_utc_ns: 2, + capture_monotonic_ns: 3, + host_clock: "linux-boottime".to_owned(), + host_id: vec![1; 16], + host_boot_id: vec![2; 16], + capture_generation: 0, + } + } + + /// Returns a well-formed CPU profile record for test machine contracts. + pub(crate) fn test_cpu_profile() -> SnapshotCpuProfile { + SnapshotCpuProfile { + id: "intel.skylake-sp.v1".to_owned(), + sha256: vec![0; 32], + profile: vec![1], + effective_cpuid: openvmm_defs::time_abi::encode_effective_cpuid([ + openvmm_defs::time_abi::EffectiveCpuidEntry { + function: 0, + index: None, + result: [0xd, 0, 0, 0], + mask: [!0, 0, 0, 0], + }, + ]), + capture_cpu_signature: 0, + } + } + #[test] fn listener_replacement_alignment_preserves_saved_machine_contract() { let saved_attachment = SnapshotAttachment { diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index a29f66c05d8..5d6a69a97eb 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -194,10 +194,8 @@ impl CreateVm { manifest.vp_count, crate::system_page_size(), )?; - let machine_contract = manifest - .machine_contract - .as_ref() - .context("microVM snapshot is missing its authoritative machine contract")?; + let machine_contract = + openvmm_helpers::snapshot::time::required_machine_contract(manifest)?; anyhow::ensure!( machine_contract.machine_profile == "microvm", "snapshot machine profile is not microvm" diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index e4a83772ea6..e6f9d832e25 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -296,31 +296,25 @@ impl VmController { block.identity.clear(); } } - let clock = openvmm_helpers::snapshot::microvm::MicrovmClockContract::TimeAbi { - time: response.time.time, - cpu_profile: response.time.cpu_profile, - }; - let version = openvmm_helpers::snapshot::format::TIME_ABI_MANIFEST_VERSION; - let format_magic = openvmm_helpers::snapshot::format::TIME_ABI_SNAPSHOT_FORMAT_MAGIC; - let machine_contract = - openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_clock( - &self.microvm.source_hypervisor, - openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, - command_line, - network, - self.microvm.filesystem_slot, - filesystem, - self.microvm.resources.console_attachment.clone(), - self.microvm.resources.control_console_attachment.clone(), - blocks, - self.processors, - self.memory, - self.microvm.memory_capacity, - response.state_unit_names, - clock, - )?; + let machine_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( + &self.microvm.source_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + network, + self.microvm.filesystem_slot, + filesystem, + self.microvm.resources.console_attachment.clone(), + self.microvm.resources.control_console_attachment.clone(), + blocks, + self.processors, + self.memory, + self.microvm.memory_capacity, + response.state_unit_names, + response.time.time, + response.time.cpu_profile, + )?; let manifest = openvmm_helpers::snapshot::SnapshotManifest { - version, + version: openvmm_helpers::snapshot::MANIFEST_VERSION, created_at: std::time::SystemTime::now().into(), openvmm_version: env!("CARGO_PKG_VERSION").to_owned(), memory_size_bytes: self.memory, @@ -328,10 +322,8 @@ impl VmController { page_size: crate::system_page_size(), architecture: crate::GUEST_ARCH.to_owned(), state_size_bytes: 0, - state_sha256: Vec::new(), - memory_sha256: Vec::new(), machine_contract: Some(machine_contract), - format_magic: format_magic.to_vec(), + format_magic: openvmm_helpers::snapshot::format::SNAPSHOT_FORMAT_MAGIC.to_vec(), saved_state_schema_version: openvmm_helpers::snapshot::format::SAVED_STATE_SCHEMA_VERSION, saved_state_root_type: openvmm_helpers::snapshot::format::SAVED_STATE_ROOT_TYPE diff --git a/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs b/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs index 44b1cef5f94..9e8e79bede2 100644 --- a/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs +++ b/openvmm/openvmm_helpers/examples/phase2_snapshot_bench.rs @@ -181,8 +181,6 @@ fn run_iteration( page_size: SparseMapping::page_size() as u32, architecture: "x86_64".to_owned(), state_size_bytes: 0, - state_sha256: Vec::new(), - memory_sha256: Vec::new(), machine_contract: None, format_magic: openvmm_helpers::snapshot::format::SNAPSHOT_FORMAT_MAGIC.to_vec(), saved_state_schema_version: openvmm_helpers::snapshot::format::SAVED_STATE_SCHEMA_VERSION, diff --git a/openvmm/openvmm_helpers/src/snapshot.rs b/openvmm/openvmm_helpers/src/snapshot.rs index 0537c3bc451..b87d052b5a1 100644 --- a/openvmm/openvmm_helpers/src/snapshot.rs +++ b/openvmm/openvmm_helpers/src/snapshot.rs @@ -15,8 +15,9 @@ pub mod time; pub use publish::write_snapshot; -/// Current manifest format version. Bump when making incompatible changes. -pub const MANIFEST_VERSION: u32 = 5; +/// The manifest format version, the only one this OpenVMM reads or writes. +/// Bump when making incompatible changes. +pub const MANIFEST_VERSION: u32 = 6; /// Manifest describing a VM snapshot. #[derive(Clone, Protobuf)] @@ -46,12 +47,8 @@ pub struct SnapshotManifest { /// Length of `state.bin` in bytes. #[mesh(8)] pub state_size_bytes: u64, - /// Legacy v2 SHA-256 digest of `state.bin`; empty in v3 through v5. - #[mesh(9)] - pub state_sha256: Vec, - /// Legacy v2 SHA-256 digest of `memory.bin`; empty in v3 through v5. - #[mesh(10)] - pub memory_sha256: Vec, + // Fields 9 and 10 held the artifact digests of manifest version 2. They + // are retired, and their numbers are never reused. /// Authoritative machine composition for versioned machine profiles. #[mesh(11)] pub machine_contract: Option, @@ -88,7 +85,7 @@ pub fn validate_manifest( expected_page_size: u32, ) -> anyhow::Result<()> { format::validate_manifest_header(manifest)?; - format::validate_manifest_version(manifest)?; + format::validate_manifest_contents(manifest)?; if manifest.architecture != expected_arch { anyhow::bail!( @@ -167,8 +164,6 @@ mod tests { assert_eq!(read_manifest.vp_count, manifest.vp_count); assert_eq!(read_manifest.architecture, manifest.architecture); assert_eq!(read_manifest.state_size_bytes, state.len() as u64); - assert!(read_manifest.state_sha256.is_empty()); - assert!(read_manifest.memory_sha256.is_empty()); assert_eq!(read_state, state); // memory.bin should exist in the snapshot directory. diff --git a/openvmm/openvmm_helpers/src/snapshot/format.rs b/openvmm/openvmm_helpers/src/snapshot/format.rs index ff2ad035a26..5693a41bb42 100644 --- a/openvmm/openvmm_helpers/src/snapshot/format.rs +++ b/openvmm/openvmm_helpers/src/snapshot/format.rs @@ -2,27 +2,19 @@ // Licensed under the MIT License. //! Snapshot format identifiers and manifest format validation: format magic -//! and supported versions, saved-state schema, capture tiers, restore -//! policies, configuration sections, artifact names, and size limits. +//! and version, saved-state schema, capture tiers, restore policies, +//! configuration sections, artifact names, and size limits. use super::MANIFEST_VERSION; use super::SnapshotManifest; use super::microvm; use mesh::payload::Timestamp; use sha2::Digest; +use virt::time_abi::TimeAbiCode; +use virt::time_abi::TimeAbiError; /// Magic identifying the OpenVMM snapshot manifest format. -pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V5\0"; -/// Manifest version of snapshots that carry the NVX time ABI records. -pub const TIME_ABI_MANIFEST_VERSION: u32 = 6; -/// Magic identifying a time ABI snapshot manifest. -pub const TIME_ABI_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V6\0"; -const VERSION_4_MANIFEST_VERSION: u32 = 4; -const VERSION_4_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V4\0"; -pub(super) const PREVIOUS_MANIFEST_VERSION: u32 = 3; -pub(super) const PREVIOUS_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V3\0"; -pub(super) const LEGACY_MANIFEST_VERSION: u32 = 2; -pub(super) const LEGACY_SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V2\0"; +pub const SNAPSHOT_FORMAT_MAGIC: &[u8] = b"OPENVMM_SNAPSHOT_V6\0"; /// Saved-state schema version used by the VM worker envelope. pub const SAVED_STATE_SCHEMA_VERSION: u32 = 1; /// Protobuf root type stored in `state.bin`. @@ -76,8 +68,6 @@ impl Default for SnapshotManifest { page_size: 0, architecture: String::new(), state_size_bytes: 0, - state_sha256: Vec::new(), - memory_sha256: Vec::new(), machine_contract: None, format_magic: SNAPSHOT_FORMAT_MAGIC.to_vec(), saved_state_schema_version: SAVED_STATE_SCHEMA_VERSION, @@ -111,21 +101,27 @@ pub(super) fn verify_digest( Ok(()) } +/// Validates the manifest's format identity: version 6, the matching magic, +/// and the saved-state schema. Any other version or magic is +/// `E_SNAPSHOT_VERSION`, which asks for a recapture. pub(super) fn validate_manifest_header(manifest: &SnapshotManifest) -> anyhow::Result<()> { - let expected_magic = match manifest.version { - LEGACY_MANIFEST_VERSION => LEGACY_SNAPSHOT_FORMAT_MAGIC, - PREVIOUS_MANIFEST_VERSION => PREVIOUS_SNAPSHOT_FORMAT_MAGIC, - VERSION_4_MANIFEST_VERSION => VERSION_4_SNAPSHOT_FORMAT_MAGIC, - MANIFEST_VERSION => SNAPSHOT_FORMAT_MAGIC, - TIME_ABI_MANIFEST_VERSION => TIME_ABI_SNAPSHOT_FORMAT_MAGIC, - version => anyhow::bail!( - "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {TIME_ABI_MANIFEST_VERSION})" - ), - }; - anyhow::ensure!( - manifest.format_magic == expected_magic, - "snapshot format magic is invalid" - ); + if manifest.version != MANIFEST_VERSION { + return Err(TimeAbiError::new( + TimeAbiCode::SnapshotVersion, + format!( + "snapshot manifest version {} is not supported (expected {MANIFEST_VERSION}); recapture the snapshot", + manifest.version + ), + ) + .into()); + } + if manifest.format_magic != SNAPSHOT_FORMAT_MAGIC { + return Err(TimeAbiError::new( + TimeAbiCode::SnapshotVersion, + "snapshot format magic is invalid; recapture the snapshot", + ) + .into()); + } anyhow::ensure!( manifest.saved_state_schema_version == SAVED_STATE_SCHEMA_VERSION, "snapshot saved-state schema version {} is unsupported", @@ -139,122 +135,47 @@ pub(super) fn validate_manifest_header(manifest: &SnapshotManifest) -> anyhow::R Ok(()) } -pub(super) fn validate_manifest_version(manifest: &SnapshotManifest) -> anyhow::Result<()> { - if manifest.version < MANIFEST_VERSION { - anyhow::ensure!( - manifest.snapshot_tier.is_empty() - && manifest.restore_policy.is_empty() - && manifest.consumed_config_sections == 0, - "snapshot manifest version {} cannot contain snapshot tier metadata", - manifest.version, - ); - } - match manifest.version { - LEGACY_MANIFEST_VERSION => { - anyhow::ensure!( - manifest.state_sha256.len() == SHA256_SIZE, - "legacy state.bin SHA-256 digest has invalid length {}", - manifest.state_sha256.len(), - ); - anyhow::ensure!( - manifest.memory_sha256.len() == SHA256_SIZE, - "legacy memory.bin SHA-256 digest has invalid length {}", - manifest.memory_sha256.len(), - ); - anyhow::ensure!( - !microvm::has_sandbox_blocks(manifest), - "snapshot manifest version {LEGACY_MANIFEST_VERSION} cannot contain microVM sandbox blocks" - ); - } - PREVIOUS_MANIFEST_VERSION - | VERSION_4_MANIFEST_VERSION - | MANIFEST_VERSION - | TIME_ABI_MANIFEST_VERSION => { - anyhow::ensure!( - manifest.state_sha256.is_empty() && manifest.memory_sha256.is_empty(), - "snapshot manifest version {} must not contain legacy artifact digests", - manifest.version, - ); - if manifest.version == PREVIOUS_MANIFEST_VERSION { - anyhow::ensure!( - !microvm::has_sandbox_blocks(manifest), - "snapshot manifest version {PREVIOUS_MANIFEST_VERSION} cannot contain microVM sandbox blocks" - ); - } - if manifest.version >= MANIFEST_VERSION { - microvm::validate_snapshot_tier(manifest)?; - } - } - version => anyhow::bail!( - "snapshot manifest version {version} is not supported (expected {LEGACY_MANIFEST_VERSION} through {TIME_ABI_MANIFEST_VERSION})" - ), - } - let contract = manifest.machine_contract.as_ref(); - if manifest.version == TIME_ABI_MANIFEST_VERSION { - let contract = contract.ok_or_else(|| { - virt::time_abi::TimeAbiError::new( - virt::time_abi::TimeAbiCode::ManifestTime, - "a version 6 manifest requires a microVM machine contract", - ) - })?; +/// Validates the manifest's machine contract and capture tier. A microVM +/// machine contract must carry valid time ABI records; a manifest without a +/// machine contract, a regular VM's, has none. +pub(super) fn validate_manifest_contents(manifest: &SnapshotManifest) -> anyhow::Result<()> { + // The time records come first, so a clock parameter on a platform + // snapshot's command line reports `E_CMDLINE_CLOCK_TOKEN`. + if let Some(contract) = &manifest.machine_contract { super::time::validate_time_abi_contract(contract)?; - } else { - anyhow::ensure!( - contract - .is_none_or(|contract| contract.time.is_none() && contract.cpu_profile.is_none()), - "snapshot manifest version {} cannot carry time ABI records", - manifest.version, - ); } - Ok(()) + microvm::validate_snapshot_tier(manifest) } #[cfg(test)] mod tests { - use super::super::microvm::paired_scratch_manifest; use super::super::tests::test_manifest; use super::super::validate_manifest; use super::*; #[test] - fn version_4_manifest_has_no_snapshot_tier_metadata() { - let scratch = vec![0x5a; 512]; - let mut manifest = paired_scratch_manifest(&scratch); - manifest.version = VERSION_4_MANIFEST_VERSION; - manifest.format_magic = VERSION_4_SNAPSHOT_FORMAT_MAGIC.to_vec(); - manifest.snapshot_tier.clear(); - manifest.restore_policy.clear(); - manifest.consumed_config_sections = 0; - validate_manifest_header(&manifest).unwrap(); - validate_manifest_version(&manifest).unwrap(); - - manifest.snapshot_tier = SNAPSHOT_TIER_WORKLOAD_START.to_owned(); - assert!(validate_manifest_version(&manifest).is_err()); - } - - #[test] - fn current_manifest_rejects_legacy_payload_digests() { - let mut manifest = test_manifest(); - manifest.state_sha256 = vec![0; SHA256_SIZE]; - let error = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); - assert!(error.to_string().contains("legacy artifact digests")); - } - - #[test] - fn previous_v3_manifest_remains_accepted() { - let mut manifest = test_manifest(); - manifest.version = PREVIOUS_MANIFEST_VERSION; - manifest.format_magic = PREVIOUS_SNAPSHOT_FORMAT_MAGIC.to_vec(); - - validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap(); + fn other_versions_are_rejected_with_e_snapshot_version() { + for version in [0, 1, 2, 3, 4, 5, 7, u32::MAX] { + let mut manifest = test_manifest(); + manifest.version = version; + let error = format!( + "{:#}", + validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err() + ); + assert!(error.contains("[E_SNAPSHOT_VERSION]"), "{error}"); + assert!(error.contains("recapture the snapshot"), "{error}"); + } } #[test] fn validate_manifest_wrong_magic() { - let mut manifest = test_manifest(); - manifest.format_magic = b"NOT_OPENVMM".to_vec(); - let err = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); - assert!(err.to_string().contains("format magic")); + for magic in [&b"NOT_OPENVMM"[..], b"OPENVMM_SNAPSHOT_V5\0"] { + let mut manifest = test_manifest(); + manifest.format_magic = magic.to_vec(); + let err = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); + assert!(err.to_string().contains("[E_SNAPSHOT_VERSION]"), "{err}"); + assert!(err.to_string().contains("format magic"), "{err}"); + } } #[test] @@ -272,4 +193,32 @@ mod tests { let err = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); assert!(err.to_string().contains("root type")); } + + #[test] + fn retired_digest_fields_are_ignored_when_decoding() { + // A manifest encoded with fields 9 and 10 still decodes; the version + // check rejects every manifest that set them. + #[derive(mesh::payload::Protobuf)] + #[mesh(package = "openvmm.snapshot")] + struct WithDigests { + #[mesh(1)] + version: u32, + #[mesh(9)] + state_sha256: Vec, + #[mesh(10)] + memory_sha256: Vec, + } + let bytes = mesh::payload::encode(WithDigests { + version: 2, + state_sha256: vec![0xa5; SHA256_SIZE], + memory_sha256: vec![0x5a; SHA256_SIZE], + }); + let manifest: SnapshotManifest = mesh::payload::decode(&bytes).unwrap(); + assert_eq!(manifest.version, 2); + let error = validate_manifest_header(&manifest).unwrap_err(); + assert!( + error.to_string().contains("[E_SNAPSHOT_VERSION]"), + "{error}" + ); + } } diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 5d272bd8ddb..c1c71953fdc 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -3,7 +3,6 @@ //! microVM snapshot machine-contract types, construction, and validation. -use super::MANIFEST_VERSION; use super::SnapshotManifest; use super::format::SCRATCH_FILE_NAME; #[cfg(test)] @@ -19,7 +18,6 @@ use super::format::validate_sha256; use super::format::verify_digest; use anyhow::Context; use mesh::payload::Protobuf; -use mesh::payload::Timestamp; use sha2::Digest; use std::collections::HashSet; use std::path::Path; @@ -35,8 +33,6 @@ pub const MICROVM_MEMORY_BLOCK_SIZE_BYTES: u64 = openvmm_defs::microvm::MICROVM_MEMORY_BLOCK_SIZE_BYTES; /// Snapshot contract name for shared-status edge interrupts. pub const MICROVM_SHARED_STATUS_INTERRUPT_MODE: &str = "edge-shared-status"; -/// Clock policy applied when a snapshot is restored. -pub const ADVANCE_BY_HOST_DOWNTIME: &str = "advance_by_host_downtime"; const MAX_MEMORY_RANGES: usize = 128; const MAX_DEVICES: usize = 64; const MAX_DEVICE_RANGES: usize = 16; @@ -44,7 +40,6 @@ const MAX_STATE_UNITS: usize = 512; const MAX_ATTACHMENTS: usize = 64; const MAX_ATTACHMENT_IDENTITY_BYTES: usize = 4096; const MAX_COMMAND_LINE_BYTES: usize = 64 * 1024; -const MAX_CPU_CONTRACT_BYTES: usize = 1024 * 1024; /// A guest RAM range and its corresponding offset in `memory.bin`. #[derive(Clone, Debug, PartialEq, Eq, Protobuf)] @@ -384,36 +379,17 @@ pub struct SnapshotMachineContract { /// Required host attachments. #[mesh(10)] pub attachments: Vec, - /// Host wall time at the stopped capture boundary. - #[mesh(11)] - pub capture_wall_clock: Timestamp, - /// Effective guest TSC frequency. - #[mesh(12)] - pub tsc_frequency_hz: u64, - /// Accepted destination TSC frequency tolerance in parts per million. - #[mesh(13)] - pub tsc_tolerance_ppm: u32, - /// Canonical protobuf-encoded effective CPU contract. - #[mesh(14)] - pub cpu_contract: Vec, - /// SHA-256 of the canonical CPU contract. - #[mesh(15)] - pub cpu_contract_sha256: Vec, + // Fields 11 to 15, 17, and 20 held the clock fields of manifest versions + // 2 to 5. They are retired, and their numbers are never reused. /// Version of the fixed cold-boot and memory layout. #[mesh(16)] pub boot_layout_version: u32, - /// Policy used to advance clocks and deadlines over host downtime. - #[mesh(17)] - pub clock_policy: String, /// Static identity of the optional microVM virtio-net device. #[mesh(18)] pub microvm_network: Option, /// Guest-visible policy of the optional microVM virtio-fs device. #[mesh(19)] pub microvm_filesystem: Option, - /// Effective local APIC timer frequency. - #[mesh(20)] - pub apic_frequency_hz: Option, /// Fixed-role sandbox blocks in guest-visible order. #[mesh(21)] pub microvm_sandbox_blocks: Vec, @@ -444,11 +420,10 @@ pub struct SnapshotMachineContract { /// Canonical capacity ranges absent from the captured boot memory map. #[mesh(30)] pub memory_expansion_ranges: Vec, - /// The NVX time ABI time contract; required in manifest version 6, which - /// leaves fields 11 to 15, 17, and 20 empty. + /// The NVX time ABI time contract; required. #[mesh(31)] pub time: Option, - /// The NVX time ABI CPU profile record; required in manifest version 6. + /// The NVX time ABI CPU profile record; required. #[mesh(32)] pub cpu_profile: Option, } @@ -459,12 +434,6 @@ impl SnapshotMachineContract { self.effective_command_line_sha256 = sha2::Sha256::digest(command_line.as_bytes()).to_vec(); self.effective_command_line = command_line; } - - /// Sets the canonical CPU compatibility contract and its digest together. - pub fn set_cpu_compatibility_contract(&mut self, cpu_contract: Vec) { - self.cpu_contract_sha256 = sha2::Sha256::digest(&cpu_contract).to_vec(); - self.cpu_contract = cpu_contract; - } } fn microvm_snapshot_topology(processor_count: u32) -> anyhow::Result { @@ -650,8 +619,8 @@ fn memory_expansion_prefix( Ok(prefix) } -/// Builds the authoritative microVM machine contract with the clock fields of -/// manifest versions 2 through 5. +/// Builds the authoritative microVM machine contract, with the time ABI +/// records `time` and `cpu_profile`. pub fn microvm_machine_contract( source_hypervisor: &str, boot_layout_version: u32, @@ -674,83 +643,8 @@ pub fn microvm_machine_contract( memory_size: u64, memory_capacity: Option, state_unit_names: Vec, - capture_wall_clock: Timestamp, - tsc_frequency_hz: u64, - apic_frequency_hz: Option, - cpu_contract: Vec, -) -> anyhow::Result { - microvm_machine_contract_with_clock( - source_hypervisor, - boot_layout_version, - effective_command_line, - network, - filesystem_slot, - filesystem, - console_attachment, - control_console_attachment, - sandbox_blocks, - processor_count, - memory_size, - memory_capacity, - state_unit_names, - MicrovmClockContract::Legacy { - capture_wall_clock, - tsc_frequency_hz, - apic_frequency_hz, - cpu_contract, - }, - ) -} - -/// The clock records of a microVM machine contract. -#[derive(Clone, Debug)] -pub enum MicrovmClockContract { - /// The clock fields of manifest versions 2 through 5. - Legacy { - /// Host UTC at capture. - capture_wall_clock: Timestamp, - /// The effective guest TSC frequency. - tsc_frequency_hz: u64, - /// The guest LAPIC timer frequency. - apic_frequency_hz: Option, - /// The canonical CPU compatibility contract. - cpu_contract: Vec, - }, - /// The NVX time ABI records of manifest version 6, which leave the - /// retired clock fields empty. - TimeAbi { - /// The time contract. - time: openvmm_defs::time_abi::SnapshotTimeContract, - /// The CPU profile record. - cpu_profile: openvmm_defs::time_abi::SnapshotCpuProfile, - }, -} - -/// Builds the authoritative microVM machine contract with the given clock -/// records. -pub fn microvm_machine_contract_with_clock( - source_hypervisor: &str, - boot_layout_version: u32, - effective_command_line: String, - network: Option<( - &openvmm_defs::microvm::MicrovmNetworkConfig, - &net_backend_resources::egress::EgressPolicy, - SnapshotAttachment, - )>, - filesystem_slot: bool, - filesystem: Option<( - &openvmm_defs::microvm::MicrovmFilesystemConfig, - &Path, - SnapshotAttachment, - )>, - console_attachment: Option, - control_console_attachment: Option, - sandbox_blocks: Vec, - processor_count: u32, - memory_size: u64, - memory_capacity: Option, - state_unit_names: Vec, - clock: MicrovmClockContract, + time: openvmm_defs::time_abi::SnapshotTimeContract, + cpu_profile: openvmm_defs::time_abi::SnapshotCpuProfile, ) -> anyhow::Result { anyhow::ensure!( matches!(source_hypervisor, "kvm" | "mshv" | "whp"), @@ -1173,39 +1067,6 @@ pub fn microvm_machine_contract_with_clock( attachments.push(attachment); } - let ( - capture_wall_clock, - tsc_frequency_hz, - apic_frequency_hz, - clock_policy, - cpu_contract, - time, - cpu_profile, - ) = match clock { - MicrovmClockContract::Legacy { - capture_wall_clock, - tsc_frequency_hz, - apic_frequency_hz, - cpu_contract, - } => ( - capture_wall_clock, - tsc_frequency_hz, - apic_frequency_hz, - ADVANCE_BY_HOST_DOWNTIME.to_owned(), - Some(cpu_contract), - None, - None, - ), - MicrovmClockContract::TimeAbi { time, cpu_profile } => ( - super::time::NO_TIMESTAMP, - 0, - None, - String::new(), - None, - Some(time), - Some(cpu_profile), - ), - }; let mut contract = SnapshotMachineContract { machine_profile: "microvm".to_owned(), microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, @@ -1217,16 +1078,9 @@ pub fn microvm_machine_contract_with_clock( devices, state_unit_names, attachments, - capture_wall_clock, - tsc_frequency_hz, - tsc_tolerance_ppm: 0, - cpu_contract: Vec::new(), - cpu_contract_sha256: Vec::new(), boot_layout_version, - clock_policy, microvm_network, microvm_filesystem, - apic_frequency_hz, microvm_sandbox_blocks: sandbox_blocks, microvm_filesystem_slot_version: if filesystem_slot { MICROVM_FILESYSTEM_SLOT_VERSION @@ -1241,35 +1095,24 @@ pub fn microvm_machine_contract_with_clock( memory_capacity_bytes, memory_block_size_bytes, memory_expansion_ranges, - time, - cpu_profile, + time: Some(time), + cpu_profile: Some(cpu_profile), }; contract.set_effective_command_line(effective_command_line); - if let Some(cpu_contract) = cpu_contract { - contract.set_cpu_compatibility_contract(cpu_contract); - } validate_machine_contract_shape(&contract, memory_size, processor_count)?; Ok(contract) } /// Returns whether restore must hold external device input until guest repair completes. pub fn requires_post_restore_gate(manifest: &SnapshotManifest) -> bool { - manifest.version >= MANIFEST_VERSION - && manifest.machine_contract.as_ref().is_some_and(|contract| { - matches!( - contract.microvm_abi_version, - openvmm_defs::microvm::MICROVM_ABI_VERSION_2 - ) - }) - && !manifest.snapshot_tier.is_empty() + manifest.machine_contract.as_ref().is_some_and(|contract| { + matches!( + contract.microvm_abi_version, + openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + ) + }) && !manifest.snapshot_tier.is_empty() } -pub(super) fn has_sandbox_blocks(manifest: &SnapshotManifest) -> bool { - manifest - .machine_contract - .as_ref() - .is_some_and(|contract| !contract.microvm_sandbox_blocks.is_empty()) -} pub(super) fn paired_scratch_block( manifest: &SnapshotManifest, ) -> Option<&SnapshotMicrovmSandboxBlock> { @@ -1346,10 +1189,6 @@ pub fn validate_microvm_machine_contract( && contract.virtio_shared_status_page_size == expected.virtio_shared_status_page_size, "snapshot virtio interrupt contract doesn't match the requested machine" ); - anyhow::ensure!( - contract.clock_policy == expected.clock_policy || contract.time.is_some(), - "snapshot clock policy doesn't match the requested machine" - ); anyhow::ensure!( contract.effective_command_line == expected.effective_command_line, "snapshot effective command line doesn't match the requested machine" @@ -1409,53 +1248,8 @@ pub fn validate_microvm_machine_contract( }, "snapshot sandbox block topology or identity doesn't match the requested machine" ); - if contract.time.is_some() { - // The time ABI replaces exact clock equality with the rate policy and - // the CPU profile checks of restore preflight. - return Ok(()); - } - anyhow::ensure!( - contract.tsc_frequency_hz == expected.tsc_frequency_hz - && contract.tsc_tolerance_ppm == expected.tsc_tolerance_ppm, - "snapshot TSC frequency contract doesn't match the destination" - ); - anyhow::ensure!( - contract.apic_frequency_hz == expected.apic_frequency_hz, - "snapshot APIC frequency contract doesn't match the destination" - ); - anyhow::ensure!( - contract.cpu_contract == expected.cpu_contract, - "snapshot CPU compatibility contract doesn't match the destination" - ); - Ok(()) -} - -/// Validates the clock fields of a machine contract before manifest version 6. -fn validate_clock_contract(contract: &SnapshotMachineContract) -> anyhow::Result<()> { - anyhow::ensure!( - contract.tsc_frequency_hz != 0, - "snapshot TSC frequency must be nonzero" - ); - if let Some(apic_frequency_hz) = contract.apic_frequency_hz { - anyhow::ensure!( - apic_frequency_hz != 0, - "snapshot APIC frequency must be nonzero" - ); - } - anyhow::ensure!( - !contract.cpu_contract.is_empty() && contract.cpu_contract.len() <= MAX_CPU_CONTRACT_BYTES, - "snapshot CPU contract size is invalid" - ); - validate_sha256(&contract.cpu_contract_sha256, "CPU contract")?; - verify_digest( - &contract.cpu_contract, - &contract.cpu_contract_sha256, - "CPU contract", - )?; - let _: std::time::SystemTime = contract - .capture_wall_clock - .try_into() - .context("snapshot capture wall clock is invalid")?; + // The time ABI records are not compared: the restore preflight checks + // them against this host's rates and CPU profile. Ok(()) } @@ -1473,12 +1267,6 @@ pub(super) fn validate_machine_contract_shape( == openvmm_defs::microvm::MICROVM_SHARED_STATUS_PAGE_SIZE, "snapshot microVM shared-status interrupt contract is invalid" ); - let time_abi = contract.time.is_some() || contract.cpu_profile.is_some(); - anyhow::ensure!( - contract.clock_policy == ADVANCE_BY_HOST_DOWNTIME || time_abi, - "snapshot clock policy '{}' is unsupported", - contract.clock_policy - ); anyhow::ensure!( contract.effective_command_line.len() < MAX_COMMAND_LINE_BYTES, "snapshot command line exceeds the 64-KiB limit" @@ -1496,11 +1284,7 @@ pub(super) fn validate_machine_contract_shape( &contract.effective_command_line_sha256, "effective command line", )?; - if time_abi { - super::time::validate_time_abi_contract(contract)?; - } else { - validate_clock_contract(contract)?; - } + super::time::validate_time_abi_contract(contract)?; anyhow::ensure!( !contract.memory_ranges.is_empty() && contract.memory_ranges.len() <= MAX_MEMORY_RANGES, @@ -1970,17 +1754,6 @@ pub(super) fn validate_snapshot_tier(manifest: &SnapshotManifest) -> anyhow::Res manifest.snapshot_tier, ); if manifest.snapshot_tier == SNAPSHOT_TIER_PLATFORM { - let expected_tsc_frequency = format!("tsc_early_khz={}", contract.tsc_frequency_hz / 1000); - let tsc_frequency_tokens = contract - .effective_command_line - .split_ascii_whitespace() - .filter(|token| token.starts_with("tsc_early_khz=")) - .collect::>(); - // A time ABI contract forbids the token instead. - anyhow::ensure!( - contract.time.is_some() || tsc_frequency_tokens == [expected_tsc_frequency.as_str()], - "platform snapshot command line TSC frequency does not match its machine contract" - ); let control_tty_count = contract .effective_command_line .split_ascii_whitespace() @@ -1990,22 +1763,6 @@ pub(super) fn validate_snapshot_tier(manifest: &SnapshotManifest) -> anyhow::Res control_tty_count <= 1, "platform snapshot command line contains duplicate control tty configuration" ); - let apic_frequency_tokens = contract - .effective_command_line - .split_ascii_whitespace() - .filter(|token| token.starts_with("lapic_timer_hz=")) - .collect::>(); - if !apic_frequency_tokens.is_empty() { - let expected = contract - .apic_frequency_hz - .map(|frequency| format!("lapic_timer_hz={frequency}")); - anyhow::ensure!( - expected - .as_deref() - .is_some_and(|expected| apic_frequency_tokens == [expected]), - "platform snapshot command line LAPIC frequency does not match its machine contract" - ); - } let processor_limit_tokens = contract .effective_command_line .split_ascii_whitespace() @@ -2044,10 +1801,8 @@ fn platform_command_line_token_is_invariant(token: &str) -> bool { | "nvx_sandbox=1" | "nvx_config=0xd0010000,65536" | "nvx_snapshot_tier=platform" - ) || token.starts_with("tsc_early_khz=") - || token.starts_with("nr_cpus=") + ) || token.starts_with("nr_cpus=") || token == openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE - || token.starts_with("lapic_timer_hz=") || [ "virtio_mmio.device=", "virtnet_ip=", @@ -2164,16 +1919,9 @@ pub(super) fn test_machine_contract() -> SnapshotMachineContract { ], state_unit_names: vec!["portb".to_owned(), "shutdown".to_owned()], attachments: Vec::new(), - capture_wall_clock: std::time::SystemTime::now().into(), - tsc_frequency_hz: 1_000_000_000, - tsc_tolerance_ppm: 0, - cpu_contract: Vec::new(), - cpu_contract_sha256: Vec::new(), boot_layout_version: MICROVM_BOOT_LAYOUT_VERSION, - clock_policy: ADVANCE_BY_HOST_DOWNTIME.to_owned(), microvm_network: None, microvm_filesystem: None, - apic_frequency_hz: Some(1_000_000_000), microvm_sandbox_blocks: Vec::new(), microvm_filesystem_slot_version: 0, boot_online_vp_count: 0, @@ -2184,26 +1932,19 @@ pub(super) fn test_machine_contract() -> SnapshotMachineContract { memory_capacity_bytes: 0, memory_block_size_bytes: 0, memory_expansion_ranges: Vec::new(), - time: None, - cpu_profile: None, + time: Some(super::time::tests::test_time_contract()), + cpu_profile: Some(super::time::tests::test_cpu_profile()), }; contract.set_effective_command_line("console=hvc0".to_owned()); - contract.set_cpu_compatibility_contract(vec![1, 2, 3]); contract } #[cfg(test)] mod tests { - use super::super::format::LEGACY_MANIFEST_VERSION; - use super::super::format::LEGACY_SNAPSHOT_FORMAT_MAGIC; - use super::super::format::PREVIOUS_MANIFEST_VERSION; - use super::super::format::PREVIOUS_SNAPSHOT_FORMAT_MAGIC; - use super::super::format::validate_manifest_version; + use super::super::format::validate_manifest_contents; use super::super::tests::test_manifest; - use super::super::validate_manifest; use super::*; - const TEST_FREQUENCY_HZ: u64 = 1_000_000_000; const TEST_SCRATCH_IDENTITY_BYTE: u8 = 0x22; fn virtio_state_unit_name(kind: &str, mmio_base: u64) -> String { @@ -2375,10 +2116,8 @@ mod tests { ] .map(str::to_owned) .to_vec(), - std::time::SystemTime::now().into(), - 1_000_000_000, - Some(1_000_000_000), - vec![1, 2, 3], + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), ) .unwrap() } @@ -2412,10 +2151,8 @@ mod tests { ] .map(str::to_owned) .to_vec(), - std::time::SystemTime::now().into(), - 1_000_000_000, - Some(1_000_000_000), - vec![1, 2, 3], + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), ) .unwrap() } @@ -2506,10 +2243,8 @@ mod tests { openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, ), ], - std::time::SystemTime::now().into(), - TEST_FREQUENCY_HZ, - Some(TEST_FREQUENCY_HZ), - vec![1, 2, 3], + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), ) .unwrap() } @@ -2564,10 +2299,8 @@ mod tests { ] .map(str::to_owned) .to_vec(), - std::time::SystemTime::now().into(), - 1_000_000_000, - Some(1_000_000_000), - vec![1, 2, 3], + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), ) .unwrap() } @@ -2601,10 +2334,8 @@ mod tests { ] .map(str::to_owned) .to_vec(), - std::time::SystemTime::now().into(), - 1_000_000_000, - Some(1_000_000_000), - vec![1, 2, 3], + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), ) .unwrap() } @@ -2960,100 +2691,6 @@ mod tests { assert!(err.to_string().contains("command line")); } - #[test] - fn validate_microvm_machine_contract_rejects_cpu_contract() { - let mut manifest = test_manifest(); - let contract = test_machine_contract(); - manifest.machine_contract = Some(contract.clone()); - let mut expected = contract; - expected.set_cpu_compatibility_contract(vec![9, 9, 9]); - let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); - assert!(err.to_string().contains("CPU compatibility")); - } - - #[test] - fn validate_microvm_machine_contract_rejects_tsc_frequency() { - let mut manifest = test_manifest(); - let contract = test_machine_contract(); - manifest.machine_contract = Some(contract.clone()); - let mut expected = contract; - expected.tsc_frequency_hz += 1; - let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); - assert!(err.to_string().contains("TSC frequency")); - } - - #[test] - fn validate_microvm_machine_contract_rejects_apic_frequency() { - let mut manifest = test_manifest(); - let contract = test_machine_contract(); - manifest.machine_contract = Some(contract.clone()); - let mut expected = contract; - expected.apic_frequency_hz = expected.apic_frequency_hz.map(|frequency| frequency + 1); - let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); - assert!(err.to_string().contains("APIC frequency")); - } - - #[test] - fn validate_microvm_machine_contract_accepts_legacy_apic_frequency() { - let mut manifest = test_manifest(); - let mut contract = test_machine_contract(); - contract.apic_frequency_hz = None; - manifest.machine_contract = Some(contract.clone()); - - validate_microvm_machine_contract(&manifest, &contract).unwrap(); - } - - #[test] - fn platform_snapshot_checks_apic_frequency_parameter() { - for (parameter, frequency, valid) in [ - ("", Some(1_000_000_000), true), - ("lapic_timer_hz=1000000000", Some(1_000_000_000), true), - ("lapic_timer_hz=200000000", Some(1_000_000_000), false), - ("lapic_timer_hz=1000000000", None, false), - ( - "lapic_timer_hz=1000000000 lapic_timer_hz=1000000000", - Some(1_000_000_000), - false, - ), - ] { - let mut manifest = paired_scratch_manifest(&[0x5a; 512]); - manifest.snapshot_tier = SNAPSHOT_TIER_PLATFORM.to_owned(); - manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); - manifest.consumed_config_sections = SNAPSHOT_CONFIG_INVARIANTS; - let contract = manifest.machine_contract.as_mut().unwrap(); - for block in contract - .microvm_sandbox_blocks - .iter_mut() - .filter(|block| block.read_only) - { - block.identity_kind = "unbound".to_owned(); - block.identity.clear(); - } - let scratch = contract.microvm_sandbox_blocks.last_mut().unwrap(); - scratch.identity_kind = "fresh".to_owned(); - scratch.identity.clear(); - scratch.artifact.clear(); - contract.apic_frequency_hz = frequency; - contract.set_effective_command_line(format!( - "console=hvc0 nvx_snapshot_tier=platform tsc_early_khz=1000000 {parameter}" - )); - let result = validate_manifest_version(&manifest); - if valid { - result.unwrap(); - } else { - assert!( - result - .unwrap_err() - .to_string() - .contains("LAPIC frequency does not match") - ); - } - } - assert!(platform_command_line_token_is_invariant( - "lapic_timer_hz=200000000" - )); - } - #[test] fn validate_microvm_machine_contract_rejects_unsupported_boot_layout() { let mut manifest = test_manifest(); @@ -3117,17 +2754,6 @@ mod tests { assert!(error.to_string().contains("not canonical")); } - #[test] - fn validate_microvm_machine_contract_rejects_clock_policy() { - let mut manifest = test_manifest(); - let contract = test_machine_contract(); - manifest.machine_contract = Some(contract.clone()); - let mut expected = contract; - expected.clock_policy = "freeze_during_downtime".to_owned(); - let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); - assert!(err.to_string().contains("clock policy")); - } - #[test] fn validate_microvm_machine_contract_rejects_topology() { let mut manifest = test_manifest(); @@ -3330,20 +2956,15 @@ mod tests { 512 * MB, Some(512 * MB + 1), Vec::new(), - std::time::SystemTime::now().into(), - 1_000_000_000, - Some(1_000_000_000), - vec![1], + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), ) .is_err() ); assert!(canonical_microvm_memory_ranges(u64::MAX).is_err()); } - fn canonical_worker_platform_command_line( - tsc_frequency_hz: u64, - processor_count: u32, - ) -> String { + fn canonical_worker_platform_command_line(processor_count: u32) -> String { let mut command_line = openvmm_defs::microvm::build_microvm_control_command_line( &[ "nvx_sandbox=1".to_owned(), @@ -3354,10 +2975,7 @@ mod tests { .unwrap(); openvmm_defs::microvm::append_microvm_processor_limit(&mut command_line, processor_count) .unwrap(); - command_line.push_str(&format!( - " nvx_snapshot_tier=platform tsc_early_khz={}", - tsc_frequency_hz / 1000 - )); + command_line.push_str(" nvx_snapshot_tier=platform"); openvmm_defs::microvm::append_microvm_virtio_discovery( &mut command_line, None, @@ -3398,17 +3016,15 @@ mod tests { scratch.identity.clear(); scratch.artifact.clear(); let processor_count = u32::try_from(contract.topology.apic_ids.len()).unwrap(); - contract.set_effective_command_line(canonical_worker_platform_command_line( - contract.tsc_frequency_hz, - processor_count, - )); + contract + .set_effective_command_line(canonical_worker_platform_command_line(processor_count)); } #[test] fn abi_v2_snapshot_tier_contract_is_canonical() { let scratch = vec![0x5a; 512]; let mut manifest = paired_scratch_manifest(&scratch); - validate_manifest_version(&manifest).unwrap(); + validate_manifest_contents(&manifest).unwrap(); manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); @@ -3420,19 +3036,19 @@ mod tests { .set_effective_command_line( "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), ); - validate_manifest_version(&manifest).unwrap(); + validate_manifest_contents(&manifest).unwrap(); manifest.snapshot_tier = SNAPSHOT_TIER_WORKLOAD_START.to_owned(); manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); - assert!(validate_manifest_version(&manifest).is_err()); + assert!(validate_manifest_contents(&manifest).is_err()); manifest.snapshot_tier = SNAPSHOT_TIER_PLATFORM.to_owned(); manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_CLONE.to_owned(); manifest.consumed_config_sections = SNAPSHOT_CONFIG_INVARIANTS; - assert!(validate_manifest_version(&manifest).is_err()); + assert!(validate_manifest_contents(&manifest).is_err()); make_platform_snapshot(&mut manifest); - validate_manifest_version(&manifest).unwrap(); + validate_manifest_contents(&manifest).unwrap(); } #[test] @@ -3447,14 +3063,14 @@ mod tests { )); assert!( - validate_manifest_version(&manifest) + validate_manifest_contents(&manifest) .unwrap_err() .to_string() .contains("contains tenant or unsupported configuration") ); make_platform_snapshot(&mut manifest); - validate_manifest_version(&manifest).unwrap(); + validate_manifest_contents(&manifest).unwrap(); let contract = manifest.machine_contract.as_mut().unwrap(); contract.set_effective_command_line( @@ -3463,7 +3079,7 @@ mod tests { .replace("nvx_config=0xd0010000,65536", "nvx_config=tenant-data"), ); assert!( - validate_manifest_version(&manifest) + validate_manifest_contents(&manifest) .unwrap_err() .to_string() .contains("contains tenant or unsupported configuration") @@ -3483,39 +3099,13 @@ mod tests { ); assert!( - validate_manifest_version(&manifest) + validate_manifest_contents(&manifest) .unwrap_err() .to_string() .contains("processor capacity") ); } - #[test] - fn platform_snapshot_rejects_invalid_tsc_frequency_tokens() { - let scratch = vec![0x5a; 512]; - for invalid in [ - "", - "tsc_early_khz=999999", - "tsc_early_khz=1000000 tsc_early_khz=1000000", - ] { - let mut manifest = paired_scratch_manifest(&scratch); - make_platform_snapshot(&mut manifest); - let contract = manifest.machine_contract.as_mut().unwrap(); - contract.set_effective_command_line( - contract - .effective_command_line - .replace("tsc_early_khz=1000000", invalid), - ); - - assert!( - validate_manifest_version(&manifest) - .unwrap_err() - .to_string() - .contains("TSC frequency does not match") - ); - } - } - #[test] fn platform_snapshot_accepts_worker_effective_canonical_command_line() { let scratch = vec![0x5a; 512]; @@ -3531,44 +3121,40 @@ mod tests { "earlycon=xe9 console=hvc1 reboot=t panic=-1 \ nvx_sandbox=1 nvx_config=0xd0010000,65536 nr_cpus=2 \ nvx_snapshot_tier=platform \ - tsc_early_khz=1000000 \ virtio_mmio.device=0x1000@0xd0002000:7 \ virtio_mmio.device=0x1000@0xd0003000:4 \ virtio_mmio.device=0x1000@0xd0006000:11 \ virtio_mmio.device=0x1000@0xd0007000:3 \ nvx_control_tty=hvc2" ); - validate_manifest_version(&manifest).unwrap(); + validate_manifest_contents(&manifest).unwrap(); } #[test] - fn platform_time_abi_snapshot_carries_no_clock_token() { + fn platform_snapshot_carries_no_clock_token() { let scratch = vec![0x5a; 512]; let mut manifest = paired_scratch_manifest(&scratch); make_platform_snapshot(&mut manifest); - crate::snapshot::time::set_time_abi_records( - &mut manifest, - crate::snapshot::time::tests::test_time_contract(), - crate::snapshot::time::tests::test_cpu_profile(), - ) - .unwrap(); - let contract = manifest.machine_contract.as_mut().unwrap(); - contract.set_effective_command_line( - contract - .effective_command_line - .replace(" tsc_early_khz=1000000", ""), - ); - validate_manifest_version(&manifest).unwrap(); + validate_manifest_contents(&manifest).unwrap(); + let canonical = manifest + .machine_contract + .as_ref() + .unwrap() + .effective_command_line + .clone(); - let contract = manifest.machine_contract.as_mut().unwrap(); - contract.set_effective_command_line(format!( - "{} tsc_early_khz=2100000", - contract.effective_command_line - )); - let err = validate_manifest_version(&manifest) - .unwrap_err() - .to_string(); - assert!(err.contains("[E_CMDLINE_CLOCK_TOKEN]"), "{err}"); + for token in ["tsc_early_khz=2100000", "lapic_timer_hz=200000000"] { + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line(format!("{canonical} {token}")); + let err = validate_manifest_contents(&manifest) + .unwrap_err() + .to_string(); + assert!(err.contains("[E_CMDLINE_CLOCK_TOKEN]"), "{err}"); + assert!(!platform_command_line_token_is_invariant(token)); + } } #[test] @@ -3594,33 +3180,7 @@ mod tests { .set_effective_command_line( canonical.replace("nvx_control_tty=hvc2", invalid_control), ); - assert!(validate_manifest_version(&manifest).is_err()); - } - } - - #[test] - fn legacy_formats_reject_abi_v2_blocks() { - let scratch = vec![0x5a_u8; 1024]; - for (version, magic) in [ - (LEGACY_MANIFEST_VERSION, LEGACY_SNAPSHOT_FORMAT_MAGIC), - (PREVIOUS_MANIFEST_VERSION, PREVIOUS_SNAPSHOT_FORMAT_MAGIC), - ] { - let mut manifest = paired_scratch_manifest(&scratch); - manifest.version = version; - manifest.format_magic = magic.to_vec(); - manifest.snapshot_tier.clear(); - manifest.restore_policy.clear(); - manifest.consumed_config_sections = 0; - if version == LEGACY_MANIFEST_VERSION { - manifest.state_sha256 = vec![0; SHA256_SIZE]; - manifest.memory_sha256 = vec![0; SHA256_SIZE]; - } - let error = validate_manifest(&manifest, "x86_64", 1024, 2, 4096).unwrap_err(); - assert!( - error - .to_string() - .contains("cannot contain microVM sandbox blocks") - ); + assert!(validate_manifest_contents(&manifest).is_err()); } } diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs index 02b7c0248fb..1f3385fa21e 100644 --- a/openvmm/openvmm_helpers/src/snapshot/publish.rs +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -6,7 +6,6 @@ //! place in one operation; failures roll back and clean up the staging //! directory. -use super::MANIFEST_VERSION; use super::SnapshotManifest; use super::format::MANIFEST_FILE_NAME; use super::format::MAX_MANIFEST_SIZE_BYTES; @@ -14,9 +13,8 @@ use super::format::MAX_SAVED_STATE_SIZE_BYTES; use super::format::MEMORY_FILE_NAME; use super::format::SCRATCH_FILE_NAME; use super::format::STATE_FILE_NAME; -use super::format::TIME_ABI_MANIFEST_VERSION; +use super::format::validate_manifest_contents; use super::format::validate_manifest_header; -use super::format::validate_manifest_version; use super::fs::OpenedSnapshotDirectory; use super::fs::allocated_file_bytes; use super::fs::copy_exact; @@ -219,7 +217,7 @@ fn stage_snapshot( memory_publication: MemoryPublication, ) -> Result { validate_manifest_header(manifest)?; - validate_manifest_version(manifest)?; + validate_manifest_contents(manifest)?; if let Some(contract) = &manifest.machine_contract { microvm::validate_machine_contract_shape( contract, @@ -227,15 +225,7 @@ fn stage_snapshot( manifest.vp_count, )?; } - if manifest.version != MANIFEST_VERSION && manifest.version != TIME_ABI_MANIFEST_VERSION { - return Err(anyhow::anyhow!( - "snapshot manifest version {} is not supported for writing (expected {} or {})", - manifest.version, - MANIFEST_VERSION, - TIME_ABI_MANIFEST_VERSION, - ) - .into()); - } + if u64::try_from(saved_state_bytes.len()).unwrap_or(u64::MAX) > MAX_SAVED_STATE_SIZE_BYTES { return Err(anyhow::anyhow!( "saved state exceeds the maximum size of {MAX_SAVED_STATE_SIZE_BYTES} bytes" @@ -307,10 +297,6 @@ fn stage_snapshot( let mut published_manifest = manifest.clone(); published_manifest.state_size_bytes = saved_state_bytes.len() as u64; - // Current local snapshots use strict structure, length, generation, - // and machine-contract checks without RAM-sized in-band hashing. - published_manifest.state_sha256.clear(); - published_manifest.memory_sha256.clear(); let manifest_bytes = mesh::payload::encode(published_manifest); anyhow::ensure!( diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs index 3329f3e40af..85365d93b96 100644 --- a/openvmm/openvmm_helpers/src/snapshot/restore.rs +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -14,8 +14,8 @@ use super::format::RESUME_CLAIM_FILE_NAME; use super::format::SCRATCH_FILE_NAME; use super::format::SNAPSHOT_RESTORE_POLICY_RESUME; use super::format::STATE_FILE_NAME; +use super::format::validate_manifest_contents; use super::format::validate_manifest_header; -use super::format::validate_manifest_version; use super::fs::OpenedFileGeneration; use super::fs::OpenedSnapshotDirectory; use super::fs::allocated_file_bytes; @@ -194,7 +194,7 @@ fn decode_snapshot_manifest(manifest_file: &std::fs::File) -> anyhow::Result anyhow::Result<(Vec, std::fs::File)> { let directory = OpenedSnapshotDirectory::open(dir)?; validate_manifest_header(manifest)?; - validate_manifest_version(manifest)?; + validate_manifest_contents(manifest)?; if let Some(contract) = &manifest.machine_contract { microvm::validate_machine_contract_shape( contract, @@ -356,7 +356,7 @@ fn claim_snapshot_for_restore_in_directory( manifest: &SnapshotManifest, ) -> anyhow::Result<()> { validate_manifest_header(manifest)?; - validate_manifest_version(manifest)?; + validate_manifest_contents(manifest)?; if manifest.restore_policy != SNAPSHOT_RESTORE_POLICY_RESUME { return Ok(()); } @@ -422,9 +422,6 @@ fn validate_snapshot_directory( #[cfg(test)] mod tests { - use super::super::format::LEGACY_MANIFEST_VERSION; - use super::super::format::LEGACY_SNAPSHOT_FORMAT_MAGIC; - use super::super::format::SHA256_SIZE; use super::super::format::SNAPSHOT_TIER_INSTANCE_CHECKPOINT; use super::super::microvm::paired_scratch_manifest; use super::super::publish::write_snapshot; @@ -466,7 +463,7 @@ mod tests { } #[test] - fn read_snapshot_accepts_same_length_state_change_without_legacy_checksum_validation() { + fn read_snapshot_accepts_same_length_state_change_without_checksums() { let dir = tempfile::tempdir().unwrap(); let snap_dir = dir.path().join("snap"); let mem_path = dir.path().join("memory.bin"); @@ -479,7 +476,7 @@ mod tests { } #[test] - fn read_snapshot_accepts_same_length_memory_change_without_legacy_checksum_validation() { + fn read_snapshot_accepts_same_length_memory_change_without_checksums() { let dir = tempfile::tempdir().unwrap(); let snap_dir = dir.path().join("snap"); let mem_path = dir.path().join("memory.bin"); @@ -513,59 +510,24 @@ mod tests { } #[test] - fn read_snapshot_accepts_legacy_v2_manifest_without_verifying_digests() { + fn read_snapshot_rejects_an_earlier_manifest_version() { let dir = tempfile::tempdir().unwrap(); let snap_dir = dir.path().join("snap"); let mem_path = dir.path().join("memory.bin"); std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); let mut manifest = read_snapshot_manifest(&snap_dir).unwrap(); - manifest.version = LEGACY_MANIFEST_VERSION; - manifest.format_magic = LEGACY_SNAPSHOT_FORMAT_MAGIC.to_vec(); - manifest.state_sha256 = vec![0xa5; SHA256_SIZE]; - manifest.memory_sha256 = vec![0x5a; SHA256_SIZE]; + manifest.version = 5; + manifest.format_magic = b"OPENVMM_SNAPSHOT_V5\0".to_vec(); std::fs::write( snap_dir.join(MANIFEST_FILE_NAME), mesh::payload::encode(manifest), ) .unwrap(); - std::fs::write(snap_dir.join(STATE_FILE_NAME), b"other").unwrap(); - std::fs::write(snap_dir.join(MEMORY_FILE_NAME), vec![1_u8; 1024]).unwrap(); - - let (manifest, state) = read_snapshot(&snap_dir, 1024).unwrap(); - assert_eq!(manifest.version, LEGACY_MANIFEST_VERSION); - assert_eq!(state, b"other"); - } - - #[test] - fn read_snapshot_rejects_malformed_legacy_digest_lengths() { - let dir = tempfile::tempdir().unwrap(); - let snap_dir = dir.path().join("snap"); - let mem_path = dir.path().join("memory.bin"); - std::fs::write(&mem_path, vec![0_u8; 1024]).unwrap(); - write_snapshot(&snap_dir, &test_manifest(), b"state", &mem_path).unwrap(); - let mut manifest = read_snapshot_manifest(&snap_dir).unwrap(); - manifest.version = LEGACY_MANIFEST_VERSION; - manifest.format_magic = LEGACY_SNAPSHOT_FORMAT_MAGIC.to_vec(); - manifest.state_sha256 = vec![0; SHA256_SIZE - 1]; - manifest.memory_sha256 = vec![0; SHA256_SIZE]; - std::fs::write( - snap_dir.join(MANIFEST_FILE_NAME), - mesh::payload::encode(manifest.clone()), - ) - .unwrap(); - let error = read_snapshot(&snap_dir, 1024).err().unwrap(); - assert!(error.to_string().contains("state.bin SHA-256 digest")); - manifest.state_sha256 = vec![0; SHA256_SIZE]; - manifest.memory_sha256 = vec![0; SHA256_SIZE + 1]; - std::fs::write( - snap_dir.join(MANIFEST_FILE_NAME), - mesh::payload::encode(manifest), - ) - .unwrap(); - let error = read_snapshot(&snap_dir, 1024).err().unwrap(); - assert!(error.to_string().contains("memory.bin SHA-256 digest")); + let error = format!("{:#}", read_snapshot(&snap_dir, 1024).err().unwrap()); + assert!(error.contains("[E_SNAPSHOT_VERSION]"), "{error}"); + assert!(error.contains("recapture the snapshot"), "{error}"); } #[test] diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index e48347c6915..21ebfaee509 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -2,13 +2,10 @@ // Licensed under the MIT License. //! The NVX time ABI records of a snapshot manifest: the time contract and the -//! CPU profile record of manifest version 6. +//! CPU profile record of a microVM machine contract. use super::SnapshotManifest; -use super::format::TIME_ABI_MANIFEST_VERSION; -use super::format::TIME_ABI_SNAPSHOT_FORMAT_MAGIC; use super::microvm::SnapshotMachineContract; -use mesh::payload::Timestamp; use openvmm_defs::time_abi::SnapshotCpuProfile; use openvmm_defs::time_abi::SnapshotTimeContract; use openvmm_defs::time_abi::decode_effective_cpuid; @@ -24,12 +21,6 @@ use virt::time_abi::TimeAbiTestHooks; use virt::time_abi::downtime::select_downtime; use virt::time_abi::rate; -/// The value of the retired capture wall clock in a version 6 manifest. -pub(super) const NO_TIMESTAMP: Timestamp = Timestamp { - seconds: 0, - nanos: 0, -}; - /// The largest canonical CPU profile encoding a manifest may carry. pub const MAX_CPU_PROFILE_BYTES: usize = 1024 * 1024; @@ -139,9 +130,8 @@ pub fn capture_record(contract: &SnapshotTimeContract) -> Result Result<(), TimeAbiError> { let time = contract .time @@ -153,61 +143,18 @@ pub fn validate_time_abi_contract(contract: &SnapshotMachineContract) -> Result< .ok_or_else(|| manifest_error("the CPU profile record is missing"))?; validate_time_contract(time)?; validate_cpu_profile_record(cpu_profile)?; - let retired_fields_empty = contract.capture_wall_clock == NO_TIMESTAMP - && contract.tsc_frequency_hz == 0 - && contract.tsc_tolerance_ppm == 0 - && contract.cpu_contract.is_empty() - && contract.cpu_contract_sha256.is_empty() - && contract.clock_policy.is_empty() - && contract.apic_frequency_hz.is_none(); - if !retired_fields_empty { - return Err(manifest_error( - "a version 6 machine contract carries a retired clock field", - )); - } virt::time_abi::check_command_line_clock_tokens(&contract.effective_command_line) } -/// Makes `manifest` a version 6 manifest carrying the time ABI records, and -/// clears the retired clock fields of its machine contract. -pub fn set_time_abi_records( - manifest: &mut SnapshotManifest, - time: SnapshotTimeContract, - cpu_profile: SnapshotCpuProfile, -) -> anyhow::Result<()> { - let contract = manifest - .machine_contract - .as_mut() - .ok_or_else(|| manifest_error("the time ABI requires a microVM machine contract"))?; - contract.capture_wall_clock = NO_TIMESTAMP; - contract.tsc_frequency_hz = 0; - contract.tsc_tolerance_ppm = 0; - contract.cpu_contract.clear(); - contract.cpu_contract_sha256.clear(); - contract.clock_policy.clear(); - contract.apic_frequency_hz = None; - contract.time = Some(time); - contract.cpu_profile = Some(cpu_profile); - manifest.version = TIME_ABI_MANIFEST_VERSION; - manifest.format_magic = TIME_ABI_SNAPSHOT_FORMAT_MAGIC.to_vec(); - Ok(()) -} - -/// Requires manifest version 6, the time ABI's: every microVM restore uses -/// the time ABI (`E_SNAPSHOT_VERSION`). -pub fn check_time_abi_manifest_version(manifest: &SnapshotManifest) -> Result<(), TimeAbiError> { - if manifest.version == TIME_ABI_MANIFEST_VERSION { - return Ok(()); - } - Err(TimeAbiError::new( - TimeAbiCode::SnapshotVersion, - format!( - "snapshot manifest version {} predates the time ABI; recapture the snapshot", - manifest.version - ), - )) +/// Returns the machine contract of a microVM snapshot's manifest, which +/// carries the time ABI records (`E_MANIFEST_TIME` when it is missing). +pub fn required_machine_contract( + manifest: &SnapshotManifest, +) -> Result<&SnapshotMachineContract, TimeAbiError> { + manifest.machine_contract.as_ref().ok_or_else(|| { + manifest_error("the microVM snapshot has no machine contract, so no time ABI records") + }) } - /// The controller's view of a time ABI restore after restore steps 2 to 5 of /// the specification. #[derive(Debug, Clone, PartialEq, Eq)] @@ -507,13 +454,12 @@ pub(super) mod tests { fn version_6_manifest() -> SnapshotManifest { let mut manifest = crate::snapshot::tests::test_manifest(); manifest.machine_contract = Some(crate::snapshot::microvm::test_machine_contract()); - set_time_abi_records(&mut manifest, test_time_contract(), test_cpu_profile()).unwrap(); manifest } fn validate(manifest: &SnapshotManifest) -> anyhow::Result<()> { crate::snapshot::format::validate_manifest_header(manifest)?; - crate::snapshot::format::validate_manifest_version(manifest)?; + crate::snapshot::format::validate_manifest_contents(manifest)?; crate::snapshot::microvm::validate_microvm_machine_contract( manifest, &crate::snapshot::microvm::test_machine_contract(), @@ -525,11 +471,7 @@ pub(super) mod tests { let manifest = version_6_manifest(); assert_eq!(manifest.version, 6); assert_eq!(manifest.format_magic, b"OPENVMM_SNAPSHOT_V6\0"); - // The destination's pre-v6 clock fields are not compared. validate(&manifest).unwrap(); - let contract = manifest.machine_contract.as_ref().unwrap(); - assert_eq!(contract.tsc_frequency_hz, 0); - assert!(contract.cpu_contract.is_empty() && contract.clock_policy.is_empty()); } #[test] @@ -544,27 +486,27 @@ pub(super) mod tests { let err = validate(&manifest).unwrap_err().to_string(); assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); + // A manifest without a machine contract is a regular VM's; a microVM + // restore requires the contract. let mut manifest = version_6_manifest(); manifest.machine_contract = None; - let err = validate(&manifest).unwrap_err().to_string(); - assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); + crate::snapshot::format::validate_manifest_contents(&manifest).unwrap(); + let err = required_machine_contract(&manifest).unwrap_err(); + assert_eq!(err.code, TimeAbiCode::ManifestTime); } #[test] - fn version_6_manifest_rejects_retired_fields_and_clock_tokens() { - let mut manifest = version_6_manifest(); - manifest.machine_contract.as_mut().unwrap().tsc_frequency_hz = 1; - let err = validate(&manifest).unwrap_err().to_string(); - assert!(err.contains("retired clock field"), "{err}"); - - let mut manifest = version_6_manifest(); - manifest - .machine_contract - .as_mut() - .unwrap() - .set_effective_command_line("console=hvc0 tsc_early_khz=2100000".to_owned()); - let err = validate(&manifest).unwrap_err().to_string(); - assert!(err.contains("[E_CMDLINE_CLOCK_TOKEN]"), "{err}"); + fn version_6_manifest_rejects_clock_tokens_and_invalid_records() { + for token in ["tsc_early_khz=2100000", "lapic_timer_hz=200000000"] { + let mut manifest = version_6_manifest(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line(format!("console=hvc0 {token}")); + let err = validate(&manifest).unwrap_err().to_string(); + assert!(err.contains("[E_CMDLINE_CLOCK_TOKEN]"), "{err}"); + } let mut manifest = version_6_manifest(); manifest @@ -579,39 +521,21 @@ pub(super) mod tests { assert!(err.contains("[E_MANIFEST_TIME]"), "{err}"); } - #[test] - fn earlier_versions_cannot_carry_records() { - let mut manifest = version_6_manifest(); - manifest.version = crate::snapshot::MANIFEST_VERSION; - manifest.format_magic = crate::snapshot::format::SNAPSHOT_FORMAT_MAGIC.to_vec(); - let err = validate(&manifest).unwrap_err().to_string(); - assert!(err.contains("cannot carry time ABI records"), "{err}"); - } - - #[test] - fn restore_requires_manifest_version_6() { - check_time_abi_manifest_version(&version_6_manifest()).unwrap(); - let version_5 = crate::snapshot::tests::test_manifest(); - let err = check_time_abi_manifest_version(&version_5).unwrap_err(); - assert_eq!(err.code, TimeAbiCode::SnapshotVersion); - assert!(err.to_string().contains("recapture the snapshot"), "{err}"); - } - /// A version 6 machine contract captured on WHP with the test profile, /// and a destination sample 5 s after its capture anchor on the same host /// boot. fn test_restore() -> (SnapshotMachineContract, HostIdentity, HostTimeSample) { - let mut manifest = crate::snapshot::tests::test_manifest(); - manifest.machine_contract = Some(crate::snapshot::microvm::test_machine_contract()); - set_time_abi_records(&mut manifest, test_time_contract(), test_cpu_profile()).unwrap(); let capture = capture_record(&test_time_contract()).unwrap(); let now = HostTimeSample { utc_ns: capture.sample.utc_ns + 5_000_000_000, monotonic_ns: capture.sample.monotonic_ns + 5_000_000_000, }; - (manifest.machine_contract.unwrap(), capture.identity, now) + ( + crate::snapshot::microvm::test_machine_contract(), + capture.identity, + now, + ) } - fn preflight( contract: &SnapshotMachineContract, hypervisor: &str, From 0205e8cd864248a6b0f3b779c9650989e83d94a3 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 01:37:30 -0700 Subject: [PATCH 262/304] chipset, chipset_resources: remove the microVM portb legacy entropy packets Since the flip, every microVM portb device runs with the NVX time ABI: entry, ttrpc, and petri all pass an empty entropy packet and the time ABI configuration. Remove the device behavior that only a VM without the time ABI reached. - chipset_resources: MicrovmPortbHandle loses restore_entropy, and its time_abi configuration is required. - chipset: MicrovmPortb::new takes the time ABI configuration, replacing with_time_abi. The OPENVMM_ENTROPY_V2 and V3 target packets, the caller-supplied entropy packet, the single-byte record reads, and the two-port region without the 0xeb window are removed. Selector 0xa5 always selects restore packet version 4, and the status port always advertises the time sample. The packet queue and its selection are renamed restore_packet. The saved state is unchanged. - Tests: the target-status tests use restore packet version 4, and the saved-state test checks that a restored process serves its own packet. - Guide: the management API reference describes restore_entropy, restore_processor_count, and restore_memory_bytes with restore packet version 4. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/reference/openvmm/management/grpc.md | 21 +- openvmm/openvmm_entry/src/microvm/config.rs | 3 +- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 3 +- petri/src/vm/openvmm/microvm.rs | 5 +- vm/devices/chipset/src/microvm.rs | 578 +++++++----------- vm/devices/chipset/src/microvm/resolver.rs | 12 +- vm/devices/chipset_resources/src/microvm.rs | 9 +- 7 files changed, 248 insertions(+), 383 deletions(-) diff --git a/Guide/src/reference/openvmm/management/grpc.md b/Guide/src/reference/openvmm/management/grpc.md index 957903ea8a2..a849a6b6ee9 100644 --- a/Guide/src/reference/openvmm/management/grpc.md +++ b/Guide/src/reference/openvmm/management/grpc.md @@ -139,13 +139,14 @@ restore flow over both transports: Guest-visible boot, memory, processor topology, other device, NUMA, and PCIe fields are rejected. A saved listener is reconstructed from the manifest; a saved client requires the matching path configuration. -* `restore_entropy` requests fresh entropy and is valid only with - `restore_path`. Every microVM process also receives a fresh, non-serialized - 16-byte generation ID through the fixed portb selector. On restore, the ID is - the first 16 bytes of the entropy packet. +* `restore_entropy` is accepted for compatibility and is valid only with + `restore_path`; it has no effect, because every restore delivers restore + packet version 4 with fresh entropy. Every microVM process also receives a + fresh, non-serialized 16-byte generation ID through the fixed portb selector. + On restore, the ID is the first 16 bytes of the restore packet's entropy. * `restore_processor_count` requests restore-time activation of the contiguous - VP prefix `0..count-1`. Zero preserves legacy behavior. A nonzero value is - valid only for a snapshot that advertises processor activation, implies fresh entropy and the + VP prefix `0..count-1`. Zero requests no activation. A nonzero value is + valid only for a snapshot that advertises processor activation, implies the post-restore gate, and must satisfy the snapshot's boot-online and immutable capacity bounds. `ProcessorConfig.processor_count`, when present, remains an exact capacity assertion. On MSHV, a nonzero value instantiates and binds @@ -153,10 +154,10 @@ restore flow over both transports: target, and all KVM and WHP restores, instantiate the full VP capacity. * `restore_memory_bytes` selects a 128-MiB-aligned total RAM target between the snapshot base and immutable capacity. Zero selects the base. Expansion uses - fresh private zeroed backing, implies fresh restore packet delivery and the - post-restore repair gate, and is rejected for legacy snapshots. An explicit - base-size value emits restore packet V3 with zero expansion ranges; zero - preserves V1/V2 packet selection. + fresh private zeroed backing, implies the post-restore repair gate, and is + rejected for snapshots without a RAM capacity contract. A nonzero value sets + the memory-target flag of restore packet version 4, with the expansion + ranges (none at the base size); zero leaves the flag clear. * `restore_gate_timeout_ms` bounds gated guest repair. Zero selects the 60-second default; a nonzero value is valid only with `restore_path`. * `restore_ready_path` names an existing Unix domain socket on Linux or a diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 5ee10d24a15..b18d45e4324 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -411,9 +411,8 @@ impl<'a> MicrovmConfigBuilder<'a> { resource: MicrovmPortbHandle { io, generation_id, - restore_entropy: Vec::new(), output_drain: Some(output_drain), - time_abi: Some(time_abi), + time_abi, } .into_resource(), }); diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index 5d6a69a97eb..bcccbc304c8 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -751,9 +751,8 @@ impl CreateVm { resource: MicrovmPortbHandle { io, generation_id, - restore_entropy: Vec::new(), output_drain, - time_abi: Some(time_abi), + time_abi, } .into_resource(), }, diff --git a/petri/src/vm/openvmm/microvm.rs b/petri/src/vm/openvmm/microvm.rs index 11502eb382a..0fd096cf81f 100644 --- a/petri/src/vm/openvmm/microvm.rs +++ b/petri/src/vm/openvmm/microvm.rs @@ -172,15 +172,14 @@ pub(super) fn attach_chipset_devices( resource: MicrovmPortbHandle { io, generation_id: [0x5a; 16], - restore_entropy: Vec::new(), output_drain: None, - time_abi: Some(MicrovmPortbTimeAbi { + time_abi: MicrovmPortbTimeAbi { generation: 0, utc_offset_ms: 0, sample_delay_us: 0, test_hooks: false, restore: None, - }), + }, } .into_resource(), }, diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index 5584a6660b4..c9edcaaf135 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -47,12 +47,10 @@ const STATUS_PORT: u16 = 0xea; const TIME_WINDOW_PORT: u16 = chipset_resources::microvm_time::TIME_WINDOW_PORT; const SHUTDOWN_PORT: u16 = 0x604; const SNAPSHOT_PORT: u16 = 0x605; -const RESTORE_ENTROPY_SELECT: u8 = 0xa5; +const RESTORE_PACKET_SELECT: u8 = 0xa5; const GENERATION_ID_SELECT: u8 = 0xa6; const TIME_SAMPLE_SELECT: u8 = chipset_resources::microvm_time::TIME_SAMPLE_SELECT; const GENERATION_ID_SIZE: usize = 16; -const RESTORE_PROCESSOR_TARGET_PACKET_HEADER: &[u8] = b"OPENVMM_ENTROPY_V2\0"; -const RESTORE_MEMORY_TARGET_PACKET_HEADER: &[u8] = b"OPENVMM_ENTROPY_V3\0"; const STATUS_INPUT_AVAILABLE: u8 = 1 << 0; const STATUS_RESTORE_PACKET_AVAILABLE: u8 = 1 << 1; const STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE: u8 = 1 << 2; @@ -135,15 +133,16 @@ pub struct MicrovmPortb { tx_buffer: VecDeque, generation_id: [u8; GENERATION_ID_SIZE], generation_id_read_index: Option, + /// The encoded restore packet, from its first selection until it is read. #[inspect(with = "VecDeque::len")] - restore_entropy: VecDeque, - restore_entropy_selected: bool, + restore_packet: VecDeque, + restore_packet_selected: bool, restore_processor_target_available: bool, restore_memory_target_available: bool, restore_memory_expansion_available: bool, input_gated: bool, #[inspect(skip)] - time_abi: Option, + time_abi: PortbTimeAbi, #[inspect(with = "VecDeque::len")] time_window: VecDeque, #[inspect(skip)] @@ -157,37 +156,57 @@ pub struct MicrovmPortb { } impl MicrovmPortb { - /// Creates a portb console using `io` as its host endpoint. + /// Creates a portb console using `io` as its host endpoint, with the NVX + /// time ABI v1 configuration `time_abi`: restore packet version 4 and the + /// time-sample window at port `0xeb`. pub fn new( io: Box, generation_id: [u8; GENERATION_ID_SIZE], - restore_entropy: Vec, + time_abi: MicrovmPortbTimeAbi, ) -> Self { - let restore_processor_target_available = restore_entropy - .starts_with(RESTORE_PROCESSOR_TARGET_PACKET_HEADER) - || (restore_entropy.starts_with(RESTORE_MEMORY_TARGET_PACKET_HEADER) - && restore_entropy - .get(RESTORE_MEMORY_TARGET_PACKET_HEADER.len()) - .is_some_and(|online_vp_count| *online_vp_count != 0)); - let restore_memory_target_available = - restore_entropy.starts_with(RESTORE_MEMORY_TARGET_PACKET_HEADER); - let restore_memory_expansion_available = restore_entropy - .get(RESTORE_MEMORY_TARGET_PACKET_HEADER.len() + 1) - .is_some_and(|range_count| restore_memory_target_available && *range_count != 0); + let MicrovmPortbTimeAbi { + generation, + utc_offset_ms, + sample_delay_us, + test_hooks, + restore, + } = time_abi; + let (processor_target, memory_target, memory_expansion) = + restore.as_ref().map_or((false, false, false), |source| { + ( + source.base.online_vp_count != 0, + source.base.memory_target, + !source.base.ranges.is_empty(), + ) + }); + let (restore, packet_selected) = match restore { + Some(source) => ( + RestorePacketState::Pending(source.base, source.time), + source.selected, + ), + None => (RestorePacketState::None, None), + }; Self { - io_region: ("microvm-portb", DATA_PORT..=STATUS_PORT), + io_region: ("microvm-portb", DATA_PORT..=TIME_WINDOW_PORT), io, rx_buffer: VecDeque::new(), tx_buffer: VecDeque::new(), generation_id, generation_id_read_index: None, - restore_entropy: restore_entropy.into(), - restore_entropy_selected: false, - restore_processor_target_available, - restore_memory_target_available, - restore_memory_expansion_available, + restore_packet: VecDeque::new(), + restore_packet_selected: false, + restore_processor_target_available: processor_target, + restore_memory_target_available: memory_target, + restore_memory_expansion_available: memory_expansion, input_gated: false, - time_abi: None, + time_abi: PortbTimeAbi { + generation, + utc_offset_ns: i128::from(utc_offset_ms) * 1_000_000, + sample_delay: std::time::Duration::from_micros(sample_delay_us.into()), + test_hooks, + restore, + packet_selected, + }, time_window: VecDeque::new(), rx_waker: None, tx_waker: None, @@ -196,52 +215,10 @@ impl MicrovmPortb { } } - /// Selects the NVX time ABI v1: restore packet version 4 instead of the - /// entropy packets, and the time-sample window at port `0xeb`. - pub fn with_time_abi(mut self, config: MicrovmPortbTimeAbi) -> Self { - let MicrovmPortbTimeAbi { - generation, - utc_offset_ms, - sample_delay_us, - test_hooks, - restore, - } = config; - if !self.restore_entropy.is_empty() { - tracing::warn!("ignoring microVM entropy packet under the time ABI"); - self.restore_entropy.clear(); - } - self.io_region = ("microvm-portb", DATA_PORT..=TIME_WINDOW_PORT); - self.restore_processor_target_available = false; - self.restore_memory_target_available = false; - self.restore_memory_expansion_available = false; - let mut packet_selected = None; - let restore = match restore { - Some(source) => { - self.restore_processor_target_available = source.base.online_vp_count != 0; - self.restore_memory_target_available = source.base.memory_target; - self.restore_memory_expansion_available = !source.base.ranges.is_empty(); - packet_selected = source.selected; - RestorePacketState::Pending(source.base, source.time) - } - None => RestorePacketState::None, - }; - self.time_abi = Some(PortbTimeAbi { - generation, - utc_offset_ns: i128::from(utc_offset_ms) * 1_000_000, - sample_delay: std::time::Duration::from_micros(sample_delay_us.into()), - test_hooks, - restore, - packet_selected, - }); - self - } - - /// Handles selector `0xa5` under the time ABI: encodes the sealed packet - /// with host UTC at its first selection, then selects it. - fn select_restore_packet_v4(&mut self) { - let Some(time_abi) = &mut self.time_abi else { - return; - }; + /// Handles selector `0xa5`: encodes the sealed packet with host UTC at + /// its first selection, then selects it. + fn select_restore_packet(&mut self) { + let time_abi = &mut self.time_abi; if time_abi.poll_sealed() { let RestorePacketState::Sealed(base, time) = std::mem::replace(&mut time_abi.restore, RestorePacketState::Encoded) @@ -257,7 +234,7 @@ impl MicrovmPortb { utc_ns: time_abi.latch_utc_ns(), }; match packet.encode() { - Ok(bytes) => self.restore_entropy = bytes.into(), + Ok(bytes) => self.restore_packet = bytes.into(), Err(error) => { tracelimit::error_ratelimited!( error = &error as &dyn std::error::Error, @@ -268,34 +245,29 @@ impl MicrovmPortb { } } } - if !self.restore_entropy.is_empty() { + if !self.restore_packet.is_empty() { self.generation_id_read_index = None; - self.restore_entropy_selected = true; + self.restore_packet_selected = true; } } /// Handles selector `0xa7`: latches a fresh time sample into the window. fn latch_time_sample(&mut self) { - let Some(time_abi) = &self.time_abi else { - return; - }; let sample = TimeSample { - test_hooks: time_abi.test_hooks, - generation: time_abi.generation, - utc_ns: time_abi.latch_utc_ns(), + test_hooks: self.time_abi.test_hooks, + generation: self.time_abi.generation, + utc_ns: self.time_abi.latch_utc_ns(), }; self.time_window = sample.encode().into(); } fn clear_restore_packet(&mut self) { - self.restore_entropy.clear(); - self.restore_entropy_selected = false; + self.restore_packet.clear(); + self.restore_packet_selected = false; self.restore_processor_target_available = false; self.restore_memory_target_available = false; self.restore_memory_expansion_available = false; - if let Some(time_abi) = &mut self.time_abi { - time_abi.restore = RestorePacketState::None; - } + self.time_abi.restore = RestorePacketState::None; } /// Installs the output drain request channel. @@ -420,17 +392,10 @@ impl ChangeDeviceState for MicrovmPortb { async fn reset(&mut self) { self.rx_buffer.clear(); self.tx_buffer.clear(); - self.restore_entropy.clear(); self.generation_id_read_index = None; - self.restore_entropy_selected = false; - self.restore_processor_target_available = false; - self.restore_memory_target_available = false; - self.restore_memory_expansion_available = false; self.input_gated = false; self.time_window.clear(); - if let Some(time_abi) = &mut self.time_abi { - time_abi.restore = RestorePacketState::None; - } + self.clear_restore_packet(); } } @@ -517,17 +482,12 @@ impl PortIoIntercept for MicrovmPortb { return IoResult::Err(IoError::InvalidAccessSize); } data.fill(0); - // The time ABI reads selected records 1, 2, or 4 bytes at a time; - // console input stays one byte per read. - let record_len = if self.time_abi.is_some() { - data.len() - } else { - 1 - }; match io_port { + // Selected records are read 1, 2, or 4 bytes at a time; console + // input stays one byte per read. DATA_PORT => { if self.generation_id_read_index.is_some() { - for byte in &mut data[..record_len] { + for byte in data.iter_mut() { let Some(index) = self.generation_id_read_index else { break; }; @@ -535,15 +495,15 @@ impl PortIoIntercept for MicrovmPortb { self.generation_id_read_index = (index + 1 < self.generation_id.len()).then_some(index + 1); } - } else if self.restore_entropy_selected { - for byte in &mut data[..record_len] { - let Some(value) = self.restore_entropy.pop_front() else { + } else if self.restore_packet_selected { + for byte in data.iter_mut() { + let Some(value) = self.restore_packet.pop_front() else { break; }; *byte = value; } - if self.restore_entropy.is_empty() { - self.restore_entropy_selected = false; + if self.restore_packet.is_empty() { + self.restore_packet_selected = false; self.restore_processor_target_available = false; self.restore_memory_target_available = false; self.restore_memory_expansion_available = false; @@ -556,29 +516,23 @@ impl PortIoIntercept for MicrovmPortb { STATUS_PORT => { data[0] = if !self.input_gated && self.generation_id_read_index.is_none() - && !self.restore_entropy_selected + && !self.restore_packet_selected && !self.rx_buffer.is_empty() { STATUS_INPUT_AVAILABLE } else { 0 }; - let sealed_packet = self - .time_abi - .as_mut() - .is_some_and(|time_abi| time_abi.poll_sealed()); - if self - .time_abi - .as_ref() - .is_some_and(|time_abi| matches!(time_abi.restore, RestorePacketState::None)) - && self.restore_entropy.is_empty() + let sealed_packet = self.time_abi.poll_sealed(); + if matches!(self.time_abi.restore, RestorePacketState::None) + && self.restore_packet.is_empty() { - // An unsealed time ABI packet was dropped: no targets. + // An unsealed packet was dropped: no targets. self.restore_processor_target_available = false; self.restore_memory_target_available = false; self.restore_memory_expansion_available = false; } - if !self.restore_entropy.is_empty() || sealed_packet { + if !self.restore_packet.is_empty() || sealed_packet { data[0] |= STATUS_RESTORE_PACKET_AVAILABLE; } if self.restore_processor_target_available { @@ -590,12 +544,9 @@ impl PortIoIntercept for MicrovmPortb { if self.restore_memory_expansion_available { data[0] |= STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE; } - data[0] |= STATUS_GENERATION_ID_AVAILABLE; - if self.time_abi.is_some() { - data[0] |= STATUS_TIME_SAMPLE_AVAILABLE; - } + data[0] |= STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE; } - TIME_WINDOW_PORT if self.time_abi.is_some() => { + TIME_WINDOW_PORT => { for byte in data.iter_mut() { let Some(value) = self.time_window.pop_front() else { break; @@ -622,23 +573,15 @@ impl PortIoIntercept for MicrovmPortb { self.wake_tx(); } STATUS_PORT => match data.first() { - Some(&RESTORE_ENTROPY_SELECT) if self.time_abi.is_some() => { - self.select_restore_packet_v4(); - } - Some(&RESTORE_ENTROPY_SELECT) if !self.restore_entropy.is_empty() => { - self.generation_id_read_index = None; - self.restore_entropy_selected = true; - } + Some(&RESTORE_PACKET_SELECT) => self.select_restore_packet(), Some(&GENERATION_ID_SELECT) => { - self.restore_entropy_selected = false; + self.restore_packet_selected = false; self.generation_id_read_index = Some(0); } - Some(&TIME_SAMPLE_SELECT) if self.time_abi.is_some() => { - self.latch_time_sample(); - } + Some(&TIME_SAMPLE_SELECT) => self.latch_time_sample(), _ => {} }, - TIME_WINDOW_PORT if self.time_abi.is_some() => {} + TIME_WINDOW_PORT => {} _ => return IoResult::Err(IoError::InvalidRegister), } IoResult::Ok @@ -672,7 +615,7 @@ impl SaveRestore for MicrovmPortb { self.rx_buffer = state.rx_buffer.into(); self.tx_buffer = state.tx_buffer.into(); self.generation_id_read_index = None; - self.restore_entropy_selected = false; + self.restore_packet_selected = false; self.time_window.clear(); Ok(()) } @@ -957,6 +900,18 @@ mod tests { const TEST_GENERATION_ID: [u8; GENERATION_ID_SIZE] = [0x3c; GENERATION_ID_SIZE]; + fn test_time_abi( + restore: Option, + ) -> MicrovmPortbTimeAbi { + MicrovmPortbTimeAbi { + generation: 7, + utc_offset_ms: 0, + sample_delay_us: 0, + test_hooks: true, + restore, + } + } + #[derive(Default)] struct OutputState { bytes: Vec, @@ -1022,7 +977,7 @@ mod tests { let mut portb = MicrovmPortb::new( Box::new(Connected::new(BufferedOutput(state.clone()))), TEST_GENERATION_ID, - Vec::new(), + test_time_abi(None), ) .with_output_drain(Some(receiver)); let payload = b"OPENVMM-SNAPSHOT-RESTORE-OK\n\0\xff"; @@ -1058,7 +1013,7 @@ mod tests { let mut portb = MicrovmPortb::new( Box::new(Connected::new(BufferedOutput(state.clone()))), TEST_GENERATION_ID, - Vec::new(), + test_time_abi(None), ) .with_output_drain(Some(receiver)); assert!(matches!(portb.io_write(DATA_PORT, b"marker"), IoResult::Ok)); @@ -1089,7 +1044,7 @@ mod tests { let mut portb = MicrovmPortb::new( Box::new(Connected::new(BufferedOutput(state.clone()))), TEST_GENERATION_ID, - Vec::new(), + test_time_abi(None), ) .with_output_drain(Some(receiver)); // The guest writes a marker just before its snapshot request, while @@ -1132,8 +1087,12 @@ mod tests { #[test] fn portb_snapshot_flush_without_endpoint_keeps_output_for_restore() { let (requests, receiver) = mesh::channel(); - let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()) - .with_output_drain(Some(receiver)); + let mut portb = MicrovmPortb::new( + Box::new(Disconnected), + TEST_GENERATION_ID, + test_time_abi(None), + ) + .with_output_drain(Some(receiver)); assert!(matches!(portb.io_write(DATA_PORT, b"marker"), IoResult::Ok)); let flush = requests.call_failable(std::convert::identity, MicrovmPortbDrain::Flush); portb.poll_device(&mut Context::from_waker(Waker::noop())); @@ -1151,7 +1110,7 @@ mod tests { let mut portb = MicrovmPortb::new( Box::new(Connected::new(BufferedOutput(state.clone()))), TEST_GENERATION_ID, - Vec::new(), + test_time_abi(None), ) .with_output_drain(Some(receiver)); let mut cx = Context::from_waker(Waker::noop()); @@ -1197,7 +1156,7 @@ mod tests { let mut portb = MicrovmPortb::new( Box::new(Connected::new(BufferedOutput(state.clone()))), TEST_GENERATION_ID, - Vec::new(), + test_time_abi(None), ); assert!(matches!(portb.io_write(DATA_PORT, b"lost"), IoResult::Ok)); portb.poll_device(&mut cx); @@ -1272,7 +1231,7 @@ mod tests { #[test] fn portb_preserves_wide_binary_output_and_zero_fills_reads() { - let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()); + let mut portb = time_abi_portb(None); assert!(matches!( portb.io_write(DATA_PORT, b"\0\xffA\x80"), IoResult::Ok @@ -1288,7 +1247,9 @@ mod tests { assert_eq!( status, [ - STATUS_INPUT_AVAILABLE | STATUS_GENERATION_ID_AVAILABLE, + STATUS_INPUT_AVAILABLE + | STATUS_GENERATION_ID_AVAILABLE + | STATUS_TIME_SAMPLE_AVAILABLE, 0, 0, 0 @@ -1301,13 +1262,32 @@ mod tests { } #[test] - fn pending_portb_bytes_survive_restore_and_fresh_entropy_is_private() { - let mut portb = MicrovmPortb::new(Box::new(Disconnected), [1; 16], Vec::new()); + fn pending_portb_bytes_survive_restore_and_the_restore_packet_is_private() { + let mut portb = time_abi_portb(None); portb.rx_buffer.extend([1, 2, 3]); portb.tx_buffer.extend([4, 5, 6]); let state = portb.save().unwrap(); - let mut restored = MicrovmPortb::new(Box::new(Disconnected), [2; 16], vec![7, 8]); + let base = RestorePacketBase { + online_vp_count: 0, + memory_target: false, + ack_required: false, + generation: 8, + ranges: Vec::new(), + entropy: [0x7e; 64], + }; + let (send, recv) = mesh::oneshot(); + let mut restored = MicrovmPortb::new( + Box::new(Disconnected), + [2; 16], + test_time_abi(Some( + chipset_resources::microvm::MicrovmRestorePacketSource { + base: base.clone(), + time: recv, + selected: None, + }, + )), + ); restored.restore(state).unwrap(); assert_eq!(restored.rx_buffer, [1, 2, 3]); assert_eq!(restored.tx_buffer, [4, 5, 6]); @@ -1326,32 +1306,25 @@ mod tests { IoResult::Ok )); assert_eq!(data, [0]); - assert_eq!(restored.restore_entropy, [7, 8]); + // The restored process serves its own packet, never saved state. + send.send(RestoreTimeRecord { + downtime_ns: 1_000, + downtime_utc: false, + rate_deviation: 0, + test_hooks: true, + }); assert!(matches!( - restored.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), - IoResult::Ok - )); - assert!(matches!( - restored.io_read(STATUS_PORT, &mut data), + restored.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), IoResult::Ok )); - assert_eq!( - data, - [STATUS_RESTORE_PACKET_AVAILABLE | STATUS_GENERATION_ID_AVAILABLE] - ); - for expected in [7, 8] { - assert!(matches!( - restored.io_read(DATA_PORT, &mut data), - IoResult::Ok - )); - assert_eq!(data, [expected]); - } + let bytes = read_record(&mut restored, DATA_PORT, base.encoded_len()); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap().base, base); } #[test] fn generation_id_is_repeatable_and_not_consumed() { - let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()); + let mut portb = time_abi_portb(None); let mut data = [0]; for _ in 0..2 { @@ -1359,7 +1332,10 @@ mod tests { portb.io_read(STATUS_PORT, &mut data), IoResult::Ok )); - assert_eq!(data, [STATUS_GENERATION_ID_AVAILABLE]); + assert_eq!( + data, + [STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE] + ); assert!(matches!( portb.io_write(STATUS_PORT, &[GENERATION_ID_SELECT]), IoResult::Ok @@ -1372,156 +1348,75 @@ mod tests { } #[test] - fn processor_target_restore_packet_has_distinct_status() { - let packet = [RESTORE_PROCESSOR_TARGET_PACKET_HEADER, &[2]].concat(); - let mut portb = - MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, packet.clone()); - let mut data = [0]; - - assert!(matches!( - portb.io_read(STATUS_PORT, &mut data), - IoResult::Ok - )); - assert_eq!( - data, - [STATUS_RESTORE_PACKET_AVAILABLE - | STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE - | STATUS_GENERATION_ID_AVAILABLE] - ); - assert!(matches!( - portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), - IoResult::Ok - )); - for expected in packet { - assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); - assert_eq!(data, [expected]); - } - assert!(matches!( - portb.io_read(STATUS_PORT, &mut data), - IoResult::Ok - )); - assert_eq!(data, [STATUS_GENERATION_ID_AVAILABLE]); - } - - #[test] - fn base_memory_target_restore_packet_has_memory_status() { - let packet = [RESTORE_MEMORY_TARGET_PACKET_HEADER, &[0, 0], &[0x5a; 64]].concat(); - let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, packet); - let mut data = [0]; - - assert!(matches!( - portb.io_read(STATUS_PORT, &mut data), - IoResult::Ok - )); - assert_eq!( - data, - [STATUS_RESTORE_PACKET_AVAILABLE - | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE - | STATUS_GENERATION_ID_AVAILABLE] - ); - } - - #[test] - fn memory_target_restore_packet_has_distinct_status() { - let packet = [ - RESTORE_MEMORY_TARGET_PACKET_HEADER, - &[2, 1], - &0x2000_0000_u64.to_le_bytes(), - &0x2000_0000_u64.to_le_bytes(), - &[0x5a; 64], - ] - .concat(); - let mut portb = - MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, packet.clone()); - let mut data = [0]; - - assert!(matches!( - portb.io_read(STATUS_PORT, &mut data), - IoResult::Ok - )); - assert_eq!( - data, - [STATUS_RESTORE_PACKET_AVAILABLE - | STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE - | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE - | STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE - | STATUS_GENERATION_ID_AVAILABLE] - ); - assert!(matches!( - portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), - IoResult::Ok - )); - for expected in packet { - assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); - assert_eq!(data, [expected]); + fn restore_packet_v4_advertises_its_targets() { + let range = chipset_resources::microvm_time::RestoreMemoryRange { + gpa_start: 0x2000_0000, + length: 0x2000_0000, + }; + for (online_vp_count, memory_target, ranges, targets) in [ + ( + 2, + false, + Vec::new(), + STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE, + ), + (0, true, Vec::new(), STATUS_RESTORE_MEMORY_TARGET_AVAILABLE), + ( + 0, + true, + vec![range], + STATUS_RESTORE_MEMORY_TARGET_AVAILABLE | STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE, + ), + ( + 2, + true, + vec![range], + STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE + | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE + | STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE, + ), + ] { + let base = RestorePacketBase { + online_vp_count, + memory_target, + ack_required: true, + generation: 7, + ranges, + entropy: [0x5a; 64], + }; + let (send, recv) = mesh::oneshot(); + let mut portb = time_abi_portb(Some( + chipset_resources::microvm::MicrovmRestorePacketSource { + base: base.clone(), + time: recv, + selected: None, + }, + )); + send.send(RestoreTimeRecord { + downtime_ns: 0, + downtime_utc: false, + rate_deviation: 0, + test_hooks: true, + }); + assert_eq!( + read_status(&mut portb), + STATUS_RESTORE_PACKET_AVAILABLE + | targets + | STATUS_GENERATION_ID_AVAILABLE + | STATUS_TIME_SAMPLE_AVAILABLE + ); + assert!(matches!( + portb.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), + IoResult::Ok + )); + let bytes = read_record(&mut portb, DATA_PORT, base.encoded_len()); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap().base, base); + // Reading the whole packet clears every target. + assert_eq!( + read_status(&mut portb), + STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE + ); } - assert!(matches!( - portb.io_read(STATUS_PORT, &mut data), - IoResult::Ok - )); - assert_eq!(data, [STATUS_GENERATION_ID_AVAILABLE]); - - let memory_only_packet = [ - RESTORE_MEMORY_TARGET_PACKET_HEADER, - &[0, 1], - &0x2000_0000_u64.to_le_bytes(), - &0x2000_0000_u64.to_le_bytes(), - &[0x5a; 64], - ] - .concat(); - let mut portb = MicrovmPortb::new( - Box::new(Disconnected), - TEST_GENERATION_ID, - memory_only_packet, - ); - assert!(matches!( - portb.io_read(STATUS_PORT, &mut data), - IoResult::Ok - )); - assert_eq!( - data, - [STATUS_RESTORE_PACKET_AVAILABLE - | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE - | STATUS_RESTORE_MEMORY_EXPANSION_AVAILABLE - | STATUS_GENERATION_ID_AVAILABLE] - ); - - let processor_only_packet = - [RESTORE_MEMORY_TARGET_PACKET_HEADER, &[2, 0], &[0x5a; 64]].concat(); - let mut portb = MicrovmPortb::new( - Box::new(Disconnected), - TEST_GENERATION_ID, - processor_only_packet, - ); - assert!(matches!( - portb.io_read(STATUS_PORT, &mut data), - IoResult::Ok - )); - assert_eq!( - data, - [STATUS_RESTORE_PACKET_AVAILABLE - | STATUS_RESTORE_PROCESSOR_TARGET_AVAILABLE - | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE - | STATUS_GENERATION_ID_AVAILABLE] - ); - - let explicit_base_packet = - [RESTORE_MEMORY_TARGET_PACKET_HEADER, &[0, 0], &[0x5a; 64]].concat(); - let mut portb = MicrovmPortb::new( - Box::new(Disconnected), - TEST_GENERATION_ID, - explicit_base_packet, - ); - assert!(matches!( - portb.io_read(STATUS_PORT, &mut data), - IoResult::Ok - )); - assert_eq!( - data, - [STATUS_RESTORE_PACKET_AVAILABLE - | STATUS_RESTORE_MEMORY_TARGET_AVAILABLE - | STATUS_GENERATION_ID_AVAILABLE] - ); } #[test] @@ -1532,7 +1427,7 @@ mod tests { byte: Some(0x5a), }), TEST_GENERATION_ID, - Vec::new(), + test_time_abi(None), ); portb.poll_device(&mut Context::from_waker(Waker::noop())); assert_eq!(portb.rx_buffer, [0x5a]); @@ -1541,14 +1436,10 @@ mod tests { fn time_abi_portb( restore: Option, ) -> MicrovmPortb { - MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()).with_time_abi( - MicrovmPortbTimeAbi { - generation: 7, - utc_offset_ms: 0, - sample_delay_us: 0, - test_hooks: true, - restore, - }, + MicrovmPortb::new( + Box::new(Disconnected), + TEST_GENERATION_ID, + test_time_abi(restore), ) } @@ -1589,10 +1480,10 @@ mod tests { ); // No packet on a cold boot: selecting it reads console input. assert!(matches!( - portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), + portb.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), IoResult::Ok )); - assert!(!portb.restore_entropy_selected); + assert!(!portb.restore_packet_selected); // The window is empty until a sample is latched. assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); @@ -1666,7 +1557,7 @@ mod tests { let before = now_ns(); assert!(matches!( - portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), + portb.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), IoResult::Ok )); let after = now_ns(); @@ -1682,10 +1573,10 @@ mod tests { STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE ); assert!(matches!( - portb.io_write(STATUS_PORT, &[RESTORE_ENTROPY_SELECT]), + portb.io_write(STATUS_PORT, &[RESTORE_PACKET_SELECT]), IoResult::Ok )); - assert!(!portb.restore_entropy_selected); + assert!(!portb.restore_packet_selected); } #[test] @@ -1731,24 +1622,6 @@ mod tests { assert_eq!(read_record(&mut portb, TIME_WINDOW_PORT, 4), [0; 4]); } - #[test] - fn legacy_portb_keeps_single_byte_records_and_two_ports() { - let mut portb = MicrovmPortb::new(Box::new(Disconnected), TEST_GENERATION_ID, Vec::new()); - assert_eq!(portb.get_static_regions()[0].1, 0xe9..=0xea); - assert!(matches!( - portb.io_write(STATUS_PORT, &[GENERATION_ID_SELECT]), - IoResult::Ok - )); - let mut data = [0xff; 4]; - assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); - assert_eq!(data, [TEST_GENERATION_ID[0], 0, 0, 0]); - assert!(matches!( - portb.io_write(STATUS_PORT, &[TIME_SAMPLE_SELECT]), - IoResult::Ok - )); - assert!(portb.time_window.is_empty()); - } - #[test] fn portb_input_gate_blocks_rx_but_not_tx() { let mut portb = MicrovmPortb::new( @@ -1757,7 +1630,7 @@ mod tests { byte: Some(0x5a), }), TEST_GENERATION_ID, - Vec::new(), + test_time_abi(None), ); portb.rx_buffer.push_back(0x44); futures::executor::block_on(portb.quiesce_input()).unwrap(); @@ -1768,7 +1641,10 @@ mod tests { portb.io_read(STATUS_PORT, &mut data), IoResult::Ok )); - assert_eq!(data, [STATUS_GENERATION_ID_AVAILABLE]); + assert_eq!( + data, + [STATUS_GENERATION_ID_AVAILABLE | STATUS_TIME_SAMPLE_AVAILABLE] + ); assert!(matches!(portb.io_read(DATA_PORT, &mut data), IoResult::Ok)); assert_eq!(data, [0]); assert_eq!(portb.rx_buffer, [0x44]); diff --git a/vm/devices/chipset/src/microvm/resolver.rs b/vm/devices/chipset/src/microvm/resolver.rs index ef0f74a8d5a..8384912f93d 100644 --- a/vm/devices/chipset/src/microvm/resolver.rs +++ b/vm/devices/chipset/src/microvm/resolver.rs @@ -60,16 +60,8 @@ impl AsyncResolveResource for Micro ) .await .map_err(ResolveMicrovmPortbError::ResolveBackend)?; - let portb = MicrovmPortb::new( - io.0.into_io(), - resource.generation_id, - resource.restore_entropy, - ) - .with_output_drain(resource.output_drain); - let portb = match resource.time_abi { - Some(time_abi) => portb.with_time_abi(time_abi), - None => portb, - }; + let portb = MicrovmPortb::new(io.0.into_io(), resource.generation_id, resource.time_abi) + .with_output_drain(resource.output_drain); Ok(portb.into()) } } diff --git a/vm/devices/chipset_resources/src/microvm.rs b/vm/devices/chipset_resources/src/microvm.rs index c092d9a6f6c..cc913c41015 100644 --- a/vm/devices/chipset_resources/src/microvm.rs +++ b/vm/devices/chipset_resources/src/microvm.rs @@ -44,21 +44,20 @@ pub enum MicrovmPortbDrain { Close, } -/// The microVM bidirectional portb console at ports `0xe9` and `0xea`. +/// The microVM bidirectional portb console at ports `0xe9` and `0xea`, with +/// the NVX time ABI time-sample window at `0xeb`. #[derive(MeshPayload)] pub struct MicrovmPortbHandle { /// Host serial endpoint used for raw input and output. pub io: Resource, /// Fresh generation ID for this microVM instance. pub generation_id: [u8; 16], - /// Fresh entropy exposed only through the private restore-input selector. - pub restore_entropy: Vec, /// Requests to deliver accepted output before a snapshot capture or before /// terminating the VM process. pub output_drain: Option>>, /// NVX time ABI v1 configuration: restore packet version 4 and the time - /// samples at port `0xeb`. `restore_entropy` must be empty when set. - pub time_abi: Option, + /// samples at port `0xeb`. + pub time_abi: MicrovmPortbTimeAbi, } /// NVX time ABI v1 configuration of the portb device. From 862e838990194e3b5a36924068dc380d2db95b89 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 02:12:10 -0700 Subject: [PATCH 263/304] Guide: describe manifest version 6 as the only snapshot version Since the manifest cleanup, version 6 is the format's only version for every snapshot, standard machines included. Say so in the snapshots page, drop the version range from the checksum warning, and name the PIT condition capture rejects precisely: a periodically counting channel 0. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/user_guide/openvmm/snapshots.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 4a77195e711..912833de6f2 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -26,9 +26,9 @@ These are stored as three required files and one optional paired file: MicroVM manifests also record the NVX time ABI contract: the declared TSC and LAPIC rates, the capture anchor (VP 0's TSC paired with host UTC and host monotonic time), the capture host's identity, and the CPU profile with the -effective CPUID that the guest observes. Every microVM snapshot is manifest -version 6, and microVM restore accepts only version 6; recapture snapshots -taken by earlier versions. See +effective CPUID that the guest observes. Every snapshot, microVM or standard +machine, is manifest version 6, and restore accepts only version 6 +(`E_SNAPSHOT_VERSION`); recapture snapshots taken by earlier versions. See [Time and CPU compatibility](#time-and-cpu-compatibility). ## Prerequisites @@ -228,7 +228,7 @@ target. This is not a post-readiness hotplug API and cannot add VPs absent from the saved topology. ```admonish warning -Versions 3 through 6 do not contain or validate embedded checksums for +Snapshots do not contain or validate embedded checksums for `state.bin` or `memory.bin`. Restore still requires regular files, bounded manifest and state decoding, exact artifact lengths, and a compatible machine contract, but same-length payload changes are not detected. Paired @@ -278,7 +278,7 @@ The declared rates never change across a restore; the restore packet reports the rate deviation so the guest can compensate. The downtime is host monotonic time when capture and restore run on the same host boot, and host UTC otherwise. Capture and restore reject an armed periodic or TSC-deadline LAPIC -timer and a counting PIT channel 0 (`E_LAPIC_PERIODIC`, +timer and a periodically counting PIT channel 0 (`E_LAPIC_PERIODIC`, `E_LAPIC_TSC_DEADLINE`, `E_PIT_ACTIVE`). A time ABI error leads its message with a stable code in brackets, for From cacff5582e2222fcd01a73b2ddd2e4dc99143338 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 00:46:08 -0700 Subject: [PATCH 264/304] virt_mshv: read the host CPUID on another thread A time ABI partition reads the host's CPUID once, for its supported CPU surface and the E_CPU_UNLISTED candidates of its effective CPUID report. Every CPUID instruction in the root partition exits to the hypervisor, so the read takes about 50 us on bare metal and 220 us nested, and it ran in partition build, on the start path of every boot and restore. Both uses come after guest memory registration, about 8 ms later for a 128 MiB guest. Start the read on its own thread at partition build, which also finds the CPU profile's unlisted candidates in the table, and take both at first use, which waits only if the thread has not finished. The thread sends its result over a channel and is detached. If no thread can start, the read happens at once, as before. The CPUID registration's log line reports how long starting the thread took (host_cpuid_spawn_us). The first use frees nothing. Joining the thread (which unmaps its stack) or dropping the channel took 12-18 us there on prometheus30, where returning memory to the kernel is expensive, so the drained channel lives until the partition drops. The effective CPUID's log line reports how long the read took and how long its first use waited (host_cpuid_read_us, host_cpuid_wait_us); a separate log event at first use would cost more of the start path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/mod.rs | 30 ++-- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 185 +++++++++++++++++++--- 2 files changed, 179 insertions(+), 36 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 1186d12b009..a1d2bb78c60 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -555,18 +555,20 @@ impl ProtoPartition for MshvProtoPartition<'_> { None => virt::CpuidLeafSet::new(cpuid), }; - // A time ABI partition reads the host's CPUID once: its supported CPU - // surface derives from it, and its effective CPUID report covers the - // reserved entries the host enumerates (`E_CPU_UNLISTED`). Those - // entries need no results of their own: the hypervisor's guest view - // reads zero there, as the CPUID sweep hardware test checks. - let started = std::time::Instant::now(); - let host_cpuid = if self.config.time_abi.is_some() { - profile_features::host_cpuid_table() - } else { - Vec::new() - }; - let host_cpuid_us = started.elapsed().as_micros() as u64; + // A time ABI partition reads the host's CPUID once, on another thread, + // which also finds the CPU profile's unlisted candidates in it: its + // supported CPU surface derives from the table, and its effective + // CPUID report covers the candidates (`E_CPU_UNLISTED`), both after + // guest memory registration. Those entries need no results of their + // own: the hypervisor's guest view reads zero there, as the CPUID + // sweep hardware test checks. + let spawned = std::time::Instant::now(); + let host_cpuid = self + .config + .time_abi + .as_ref() + .map(|config| time_abi::HostCpuidSource::spawn(&config.cpu_profile)); + let host_cpuid_spawn_us = spawned.elapsed().as_micros() as u64; // Apply CPUID overrides partition-wide, except a time ABI partition's // extended topology leaves: their EDX is each VP's x2APIC ID, which @@ -582,7 +584,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { tracing::info!( leaves = cpuid.leaves().len(), per_vp_leaves = cpuid.leaves().iter().filter(per_vp).count(), - host_cpuid_us, + host_cpuid_spawn_us, elapsed_us = started.elapsed().as_micros() as u64, "registered MSHV CPUID results" ); @@ -592,7 +594,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { &cpuid, self.time_abi_host .expect("a time ABI partition reads the host's features"), - host_cpuid, + host_cpuid.expect("started for a time ABI partition"), ) }); diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 2fa392e734a..a75a753acbc 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -137,16 +137,100 @@ pub(crate) struct MshvTimeAbi { /// the supported CPU surface. #[inspect(skip)] host_features: HvFeatures, - /// The host's CPUID table, read once at creation for the supported CPU - /// surface and the unlisted candidates. + /// The CPU profile, whose unlisted candidates the host's CPUID gives. #[inspect(skip)] - host_cpuid: Vec, + cpu_profile: String, + /// Where the host's CPUID table comes from. After its first use, this + /// holds only the drained channel, if any, which the partition frees when + /// it drops. + #[inspect(skip)] + host_cpuid_source: parking_lot::Mutex>, + /// The host's CPUID table and the CPU profile's unlisted candidates in + /// it, from [`Self::host_cpuid_source`] at first use. + #[inspect(skip)] + host_cpuid: std::sync::OnceLock, +} + +/// The host's CPUID table, for the supported CPU surface and the unlisted +/// candidates. +pub(super) enum HostCpuidSource { + /// Already read. + Ready(HostCpuid), + /// Being read on another thread, which sends the table with its unlisted + /// candidates and how long it took, in microseconds. Every CPUID + /// instruction in the root partition exits to the hypervisor, so this + /// keeps the read off the start path: its first use comes after guest + /// memory registration. + Pending(std::sync::mpsc::Receiver<(HostCpuid, u64)>), +} + +impl HostCpuidSource { + /// Starts reading the host's CPUID table and finding `cpu_profile`'s + /// unlisted candidates in it on another thread, or does both here if no + /// thread can start. + pub(super) fn spawn(cpu_profile: &str) -> Self { + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + let thread_profile = cpu_profile.to_owned(); + // The thread is detached, so it releases its own stack when it exits + // instead of a join on the start path. + let read = move || { + let started = std::time::Instant::now(); + let host = HostCpuid::read(&thread_profile); + let _ = sender.send((host, started.elapsed().as_micros() as u64)); + }; + match std::thread::Builder::new() + .name("mshv-host-cpuid".into()) + .spawn(read) + { + Ok(_) => Self::Pending(receiver), + Err(error) => { + tracing::warn!( + error = &error as &dyn std::error::Error, + "cannot start a thread to read the host CPUID, reading it now" + ); + Self::Ready(HostCpuid::read(cpu_profile)) + } + } + } +} + +/// The host's CPUID table and the CPU profile's unlisted candidates in it. +pub(super) struct HostCpuid { + table: Vec, /// The entries the host's CPUID enumerates outside the CPU profile's /// tables (`cpu_profile::unlisted_cpuid_candidates`). The effective CPUID /// reports VP 0's view there, which core requires to be zero /// (`E_CPU_UNLISTED`). - #[inspect(skip)] unlisted_candidates: Vec<(u32, Option)>, + /// How long the other thread took to read the table and find the + /// candidates, in microseconds, if it did. + read_us: Option, + /// How long the first use waited for them, in microseconds. The + /// effective CPUID's log line reports both, so the first use logs + /// nothing of its own: a separate event cost about 25 us on the start + /// path. + wait_us: u64, +} + +impl HostCpuid { + /// Finds `cpu_profile`'s unlisted candidates in `table`. A profile that + /// isn't pinned has none. + fn new(cpu_profile: &str, table: Vec) -> Self { + let unlisted_candidates = cpu_profile::pinned(cpu_profile) + .map(|profile| cpu_profile::unlisted_cpuid_candidates(profile, &table)) + .unwrap_or_default(); + Self { + table, + unlisted_candidates, + read_us: None, + wait_us: 0, + } + } + + /// Reads the host's CPUID table on this thread. + fn read(cpu_profile: &str) -> Self { + Self::new(cpu_profile, super::profile_features::host_cpuid_table()) + } } impl MshvTimeAbi { @@ -154,22 +238,49 @@ impl MshvTimeAbi { config: &TimeAbiConfig, registered_cpuid: &CpuidLeafSet, host_features: HvFeatures, - host_cpuid: Vec, + host_cpuid: HostCpuidSource, ) -> Self { - let unlisted_candidates = cpu_profile::pinned(&config.cpu_profile) - .map(|profile| cpu_profile::unlisted_cpuid_candidates(profile, &host_cpuid)) - .unwrap_or_default(); Self { msrs: config.msrs.clone(), registered_cpuid: registered_cpuid.leaves().to_vec(), effective_cpuid: std::sync::OnceLock::new(), vp_set_sealed: AtomicBool::new(false), host_features, - host_cpuid, - unlisted_candidates, + cpu_profile: config.cpu_profile.clone(), + host_cpuid_source: parking_lot::Mutex::new(Some(host_cpuid)), + host_cpuid: std::sync::OnceLock::new(), } } + /// Returns the host's CPUID table and the unlisted candidates, waiting + /// for the read that partition creation started if it is still running. + fn host_cpuid(&self) -> &HostCpuid { + self.host_cpuid.get_or_init(|| { + let started = std::time::Instant::now(); + let mut source = self.host_cpuid_source.lock(); + let (mut host, read_us) = match source.take() { + Some(HostCpuidSource::Ready(host)) => (host, None), + Some(HostCpuidSource::Pending(receiver)) => { + let received = receiver.recv(); + // Freeing the channel can hand its memory back to the + // kernel, which took about 12 us here on prometheus30, + // so the channel lives until the partition drops. + *source = Some(HostCpuidSource::Pending(receiver)); + match received { + Ok((host, read_us)) => (host, Some(read_us)), + // The reading thread panicked. + Err(_) => (HostCpuid::read(&self.cpu_profile), None), + } + } + None => (HostCpuid::read(&self.cpu_profile), None), + }; + drop(source); + host.read_us = read_us; + host.wait_us = started.elapsed().as_micros() as u64; + host + }) + } + /// Fails with `E_VP_LATE_CREATION` once the synchronized TSC set has run. /// /// VP creation checks this both before and after it marks a VP created, @@ -742,7 +853,7 @@ impl MshvPartitionInner { .filter(|leaf| !is_zero_fill(leaf)) .map(|leaf| (leaf.function, leaf.index)), ) - .chain(state.unlisted_candidates.iter().copied()) + .chain(state.host_cpuid().unlisted_candidates.iter().copied()) .collect(); let entries: Vec<(u32, u32)> = reads .iter() @@ -771,11 +882,15 @@ impl MshvPartitionInner { }); } leaves.extend(read); + let host = state.host_cpuid(); tracing::info!( leaves = leaves.len(), reads = entries.len(), bulk, - unlisted_candidates = state.unlisted_candidates.len(), + unlisted_candidates = host.unlisted_candidates.len(), + host_cpuid_entries = host.table.len(), + host_cpuid_read_us = host.read_us, + host_cpuid_wait_us = host.wait_us, elapsed_us = started.elapsed().as_micros() as u64, "MSHV effective CPUID read" ); @@ -949,7 +1064,7 @@ impl TimeAbiBackend for MshvPartition { ) })?; let surface = super::profile_features::supported_cpu_surface( - state.host_cpuid.clone(), + state.host_cpuid().table.clone(), state.host_features, width as u8, ); @@ -1289,26 +1404,52 @@ mod tests { host.push(entry(0xb, Some(2), [0, 0, 2, 0])); host.push(entry(0x4000_0000, None, [0x4000_000b, 0, 0, 0])); host.sort_by_key(cpu_profile::cpuid::CpuidEntry::key); - let mut config = TimeAbiConfig { + let config = TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), msrs: Arc::new(TimeAbiMsrs::new()), cpu_profile: profile.id().to_owned(), }; - let features = super::super::profile_features::legacy_features(); let state = MshvTimeAbi::new( &config, &partition_cpuid(&config.cpuid), - features, - host.clone(), + super::super::profile_features::legacy_features(), + HostCpuidSource::Ready(HostCpuid::new(&config.cpu_profile, host.clone())), ); assert_eq!( - state.unlisted_candidates, + state.host_cpuid().unlisted_candidates, [(4, Some(5)), (0x17, None), (0x8000_0009, None)] ); // A profile that isn't pinned has none. - config.cpu_profile = String::new(); - let state = MshvTimeAbi::new(&config, &partition_cpuid(&config.cpuid), features, host); - assert!(state.unlisted_candidates.is_empty()); + assert!(HostCpuid::new("", host).unlisted_candidates.is_empty()); + } + + #[test] + fn the_host_cpuid_is_read_in_the_background() { + let config = TimeAbiConfig { + cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), + msrs: Arc::new(TimeAbiMsrs::new()), + cpu_profile: "intel.skylake-sp.v1".to_owned(), + }; + let state = MshvTimeAbi::new( + &config, + &partition_cpuid(&config.cpuid), + super::super::profile_features::legacy_features(), + HostCpuidSource::spawn(&config.cpu_profile), + ); + let here = HostCpuid::read(&config.cpu_profile); + // The reading thread may run on another CPU, whose per-CPU fields + // (the APIC IDs) differ, so compare the entries it enumerates. The + // candidates exclude the topology leaves, so they are the same. + let keys = |table: &[cpu_profile::cpuid::CpuidEntry]| { + table + .iter() + .map(cpu_profile::cpuid::CpuidEntry::key) + .collect::>() + }; + let host = state.host_cpuid(); + assert!(host.read_us.is_some(), "read on another thread"); + assert_eq!(keys(&host.table), keys(&here.table)); + assert_eq!(host.unlisted_candidates, here.unlisted_candidates); } #[test] @@ -1428,7 +1569,7 @@ mod tests { &config, &partition_cpuid(&config.cpuid), super::super::profile_features::legacy_features(), - Vec::new(), + HostCpuidSource::Ready(HostCpuid::new("", Vec::new())), ); state.check_vp_creation(VpIndex::new(4)).unwrap(); state.vp_set_sealed.store(true, Ordering::SeqCst); From b06c3a89f8c9479c6df175bc03791a271a2bf956 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 03:46:26 -0700 Subject: [PATCH 265/304] virt_mshv: keep partition time frozen from preflight to the TSC set Preflight checked that partition time can be frozen by freezing and thawing it: two TimeFreeze writes, each about 25 us on prometheus30. A restore then froze and thawed it again around the synchronized TSC set, so every restore wrote TimeFreeze four times on its start path. Freeze it in preflight and leave it frozen, as a reset does. The synchronized TSC set finds it frozen and resumes it right after its read-back, and on a cold boot the first VP run resumes it, as after a reset. A restore writes TimeFreeze twice, and a cold boot twice, as before. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index a75a753acbc..893abf185f0 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -814,19 +814,6 @@ impl MshvPartitionInner { .map_err(|error| TimeAbiError::new(code, format!("VP 0 is unavailable: {error}"))) } - /// Checks that partition time can be frozen, leaving it as it was: a - /// frozen partition stays frozen until a VP runs. - fn probe_time_freeze(&self) -> Result<(), KernelError> { - let frozen = self.time_frozen.lock(); - self.vmfd - .set_partition_property(HvPartitionPropertyCode::TimeFreeze.0, 1)?; - if !*frozen { - self.vmfd - .set_partition_property(HvPartitionPropertyCode::TimeFreeze.0, 0)?; - } - Ok(()) - } - /// Returns the effective CPUID, reading it from VP 0 the first time. The /// first read happens in preflight, before any VP runs, on both cold boot /// and restore, so it is VP 0's view at reset. @@ -1028,7 +1015,11 @@ impl TimeAbiBackend for MshvPartition { let bsp = self.inner.time_abi_bsp(TimeAbiCode::IdentityRouting)?; self.inner.check_identity_cpuid(bsp)?; - self.inner.probe_time_freeze().map_err(|error| { + // Freezing partition time checks that it can be frozen. Time stays + // frozen, as after a reset, until the synchronized TSC set resumes it + // after its read-back or a VP first runs, so a restore freezes it + // only once. + self.inner.freeze_time().map_err(|error| { TimeAbiError::new( TimeAbiCode::TscSyncUnsupported, format!("cannot freeze partition time: {}", error_chain(&error)), @@ -1898,6 +1889,10 @@ mod hw { sync: TscSyncMethod::FrozenWrite, } ); + // Preflight leaves partition time frozen, as a reset does, until + // the synchronized TSC set or the first VP run resumes it. + assert!(*partition.inner.time_frozen.lock()); + partition.inner.thaw_time().unwrap(); let tsc_hz = backend.native_tsc_hz().unwrap(); check_plausible_tsc_hz(tsc_hz).unwrap(); assert_eq!(backend.lapic_hz().unwrap(), LAPIC_HZ_HYPERV); From b23fc1a0cfd9bb47d73c516c6dca11d68293e711 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 04:01:56 -0700 Subject: [PATCH 266/304] virt_mshv: register only the CPUID results VP 0 does not already present A time ABI partition registered every partition-wide result of its CPUID table in partition build: 61 HvCallRegisterInterceptResult calls, about 200 us on prometheus30 and 300 us nested, on the start path of every boot and restore. Under the CPU profile's processor features the hypervisor's own view already presents most of the profile, and without synthetic processor features it reads every hypervisor-range leaf past the identity as zero. On prometheus30 (intel.skylake-sp.v1) 50 of the 61 results match VP 0's view at reset; the other 11 are the hypervisor present bit, one cache descriptor, ARAT, the six identity leaves, and two brand string leaves. Register the results once VP 0 exists, in finalization: read VP 0's view of every partition-wide entry in one bulk call and register only those it does not present under the result's mask. A failed bulk read registers every result, as before. The extended topology leaves keep their per-VP results. The effective CPUID report now reads every leaf of the table back from VP 0, the explicit zero leaves included, so core's check of the presented CPUID covers the unregistered results too. The registration log line reports the leaves left to the hypervisor's own view (native_leaves) and those registered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/finalize.rs | 3 + vmm_core/virt_mshv/src/x86_64/mod.rs | 31 ++-- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 182 ++++++++++++++-------- 3 files changed, 132 insertions(+), 84 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/finalize.rs b/vmm_core/virt_mshv/src/x86_64/finalize.rs index b0ab1fef3e9..b96f50d1ca1 100644 --- a/vmm_core/virt_mshv/src/x86_64/finalize.rs +++ b/vmm_core/virt_mshv/src/x86_64/finalize.rs @@ -117,6 +117,9 @@ impl MshvPartitionInner { "MSHV_CREATE_VCPU completed" ); let bsp_vcpufd = result?; + if let Some(time_abi) = &self.time_abi { + time_abi.register_cpuid(&self.vmfd, &bsp_vcpufd, &self.config.cpuid)?; + } let caps = self.build_caps(&bsp_vcpufd)?; Ok(MshvFinalizedPartition { bsp_vcpufd, caps }) }) diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index a1d2bb78c60..72a78acc96b 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -570,24 +570,20 @@ impl ProtoPartition for MshvProtoPartition<'_> { .map(|config| time_abi::HostCpuidSource::spawn(&config.cpu_profile)); let host_cpuid_spawn_us = spawned.elapsed().as_micros() as u64; - // Apply CPUID overrides partition-wide, except a time ABI partition's - // extended topology leaves: their EDX is each VP's x2APIC ID, which - // the hypervisor does not provide for these partitions, so every VP - // gets its own results when it is created (see `create_vp`). - let started = std::time::Instant::now(); - let per_vp = |leaf: &&virt::CpuidLeaf| { - self.config.time_abi.is_some() && time_abi::is_per_vp_leaf(leaf.function) - }; - for leaf in cpuid.leaves().iter().filter(|leaf| !per_vp(leaf)) { - register_cpuid_result(&self.vmfd, leaf)?; + // Apply CPUID overrides partition-wide. A time ABI partition registers + // its results once VP 0 exists, only where VP 0's own view differs + // (see `MshvTimeAbi::register_cpuid`). + if self.config.time_abi.is_none() { + let started = std::time::Instant::now(); + for leaf in cpuid.leaves() { + register_cpuid_result(&self.vmfd, leaf)?; + } + tracing::info!( + leaves = cpuid.leaves().len(), + elapsed_us = started.elapsed().as_micros() as u64, + "registered MSHV CPUID results" + ); } - tracing::info!( - leaves = cpuid.leaves().len(), - per_vp_leaves = cpuid.leaves().iter().filter(per_vp).count(), - host_cpuid_spawn_us, - elapsed_us = started.elapsed().as_micros() as u64, - "registered MSHV CPUID results" - ); let time_abi = self.config.time_abi.as_ref().map(|config| { time_abi::MshvTimeAbi::new( config, @@ -595,6 +591,7 @@ impl ProtoPartition for MshvProtoPartition<'_> { self.time_abi_host .expect("a time ABI partition reads the host's features"), host_cpuid.expect("started for a time ABI partition"), + host_cpuid_spawn_us, ) }); diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 893abf185f0..aab27d1397e 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -120,12 +120,14 @@ pub(crate) struct MshvTimeAbi { with = "|x| x.tsc_invariant_control()" )] msrs: Arc, - /// The CPUID results registered with the hypervisor. + /// The time ABI CPUID. Its partition-wide results are registered where + /// VP 0's own view differs (see [`Self::register_cpuid`]), and its + /// extended topology leaves for each VP when it is created. #[inspect(skip)] - registered_cpuid: Vec, - /// The effective CPUID: VP 0's view of the registered leaves at reset, - /// and of the host's entries outside the CPU profile's tables, read once - /// before any VP runs. Later reads would fold in VP state (the + cpuid: Vec, + /// The effective CPUID: VP 0's view of the time ABI CPUID's leaves at + /// reset, and of the host's entries outside the CPU profile's tables, + /// read once before any VP runs. Later reads would fold in VP state (the /// hypervisor applies the VP's XCR0, XSS, and control registers), so the /// capture and the restore of one snapshot would disagree. #[inspect(skip)] @@ -149,6 +151,10 @@ pub(crate) struct MshvTimeAbi { /// it, from [`Self::host_cpuid_source`] at first use. #[inspect(skip)] host_cpuid: std::sync::OnceLock, + /// How long partition build took to start reading the host's CPUID, in + /// microseconds. + #[inspect(skip)] + host_cpuid_spawn_us: u64, } /// The host's CPUID table, for the supported CPU surface and the unlisted @@ -236,20 +242,83 @@ impl HostCpuid { impl MshvTimeAbi { pub(super) fn new( config: &TimeAbiConfig, - registered_cpuid: &CpuidLeafSet, + cpuid: &CpuidLeafSet, host_features: HvFeatures, host_cpuid: HostCpuidSource, + host_cpuid_spawn_us: u64, ) -> Self { Self { msrs: config.msrs.clone(), - registered_cpuid: registered_cpuid.leaves().to_vec(), + cpuid: cpuid.leaves().to_vec(), effective_cpuid: std::sync::OnceLock::new(), vp_set_sealed: AtomicBool::new(false), host_features, cpu_profile: config.cpu_profile.clone(), host_cpuid_source: parking_lot::Mutex::new(Some(host_cpuid)), host_cpuid: std::sync::OnceLock::new(), + host_cpuid_spawn_us, + } + } + + /// Registers the partition-wide results of the time ABI CPUID that VP 0's + /// own view does not already present, once VP 0 exists and before any VP + /// runs or the effective CPUID is read. + /// + /// Under the CPU profile's processor features the hypervisor's own view + /// presents most of the profile, and every hypervisor-range leaf past the + /// identity reads zero. A result it already presents would change nothing + /// the guest reads but cost a hypercall (about 3.3 us on bare metal and + /// 5 us nested), so one bulk read of VP 0's view at reset decides. The + /// effective CPUID report reads every leaf back, and core checks it. If + /// the bulk read fails, every result is registered. The extended topology + /// leaves get per-VP results when each VP is created (see `create_vp`). + pub(super) fn register_cpuid( + &self, + vmfd: &VmFd, + bsp: &VcpuFd, + cpuid: &CpuidLeafSet, + ) -> Result<(), crate::Error> { + let started = std::time::Instant::now(); + let leaves: Vec<&CpuidLeaf> = cpuid + .leaves() + .iter() + .filter(|leaf| !is_per_vp_leaf(leaf.function)) + .collect(); + let entries: Vec<(u32, u32)> = leaves + .iter() + .map(|leaf| (leaf.function, leaf.index.unwrap_or(0))) + .collect(); + let native = vp_cpuid_many(bsp, VpIndex::BSP.index(), &entries) + .inspect_err(|error| { + tracing::warn!( + error = error as &dyn std::error::Error, + "MSHV bulk CPUID read failed, registering every time ABI CPUID result" + ); + }) + .ok(); + let read_us = started.elapsed().as_micros() as u64; + let mut registered = 0; + for (index, leaf) in leaves.iter().enumerate() { + if native + .as_ref() + .is_some_and(|native| presents(leaf, native[index])) + { + continue; + } + super::register_cpuid_result(vmfd, leaf)?; + registered += 1; } + tracing::info!( + leaves = cpuid.leaves().len(), + per_vp_leaves = cpuid.leaves().len() - leaves.len(), + native_leaves = leaves.len() - registered, + registered_leaves = registered, + host_cpuid_spawn_us = self.host_cpuid_spawn_us, + read_us, + elapsed_us = started.elapsed().as_micros() as u64, + "registered MSHV CPUID results" + ); + Ok(()) } /// Returns the host's CPUID table and the unlisted candidates, waiting @@ -406,17 +475,19 @@ fn install_msr_intercepts(vmfd: &VmFd, msrs: &[u32]) -> Result<(), TimeAbiError> Ok(()) } -/// Returns the CPUID results to register for a time ABI partition: the time -/// ABI CPUID verbatim. +/// Returns the CPUID table of a time ABI partition: the time ABI CPUID +/// verbatim. /// /// It is the CPU profile's complete effective CPUID (the profile's leaves, /// OpenVMM's topology leaves and APIC mode, and the identity and explicit zero /// leaves), so neither the worker's leaves nor the backend's own add a leaf or -/// a bit. The hypervisor's own values show only where a result's mask is clear -/// (each VP's APIC identity, the runtime XSAVE sizes) and where no result -/// applies: the reserved entries, which its guest view reads as zero. Without -/// synthetic processor features, the hypervisor itself reports every other -/// hypervisor-range leaf as zero. +/// a bit. The partition registers its results where VP 0's own view differs +/// ([`MshvTimeAbi::register_cpuid`]): without synthetic processor features the +/// hypervisor reports every hypervisor-range leaf past the identity as zero, +/// and under the profile's processor features it presents most of the +/// profile. The hypervisor's own values also show where a result's mask is +/// clear (each VP's APIC identity, the runtime XSAVE sizes) and where no +/// result applies: the reserved entries, which its guest view reads as zero. pub(super) fn partition_cpuid(config: &CpuidLeafSet) -> CpuidLeafSet { CpuidLeafSet::new(config.leaves().to_vec()) } @@ -439,16 +510,6 @@ pub(super) fn with_x2apic_id(leaf: &CpuidLeaf, apic_id: u32) -> CpuidLeaf { leaf } -/// Returns whether `leaf` is an explicit zero leaf past the identity, which -/// the effective CPUID reports as registered instead of reading it back. -/// Preflight reads such leaves back at its sentinels. -fn is_zero_fill(leaf: &CpuidLeaf) -> bool { - leaf.function > IDENTITY_MAX_LEAF - && IDENTITY_CPUID_RANGE.contains(&leaf.function) - && leaf.result == [0; 4] - && leaf.mask == [!0; 4] -} - /// An intercepted access to an identity MSR. #[derive(Debug, Copy, Clone, PartialEq, Eq)] enum MsrAccess { @@ -824,22 +885,16 @@ impl MshvPartitionInner { } let bsp = self.time_abi_bsp(TimeAbiCode::CpuSurface)?; let started = std::time::Instant::now(); - // VP 0 reads, in the report's order: the maximum leaves, the - // registered results except the explicit zero leaves past the - // identity, which are reported as registered, and the entries outside - // the profile's tables where the host's CPUID has any. Guests read the - // hypervisor's own values there, at subleaf 0 for a subleaf-independent - // entry, and core requires each to read zero. + // VP 0 reads, in the report's order: the maximum leaves, the time ABI + // CPUID's leaves, and the entries outside the profile's tables where + // the host's CPUID has any. Guests read the hypervisor's own values + // wherever no result was registered (see `register_cpuid`) and at the + // unlisted entries (at subleaf 0 for a subleaf-independent entry), + // which core requires to read zero. let reads: Vec<(u32, Option)> = EFFECTIVE_CPUID_MAX_LEAVES .into_iter() .map(|function| (function, None)) - .chain( - state - .registered_cpuid - .iter() - .filter(|leaf| !is_zero_fill(leaf)) - .map(|leaf| (leaf.function, leaf.index)), - ) + .chain(state.cpuid.iter().map(|leaf| (leaf.function, leaf.index))) .chain(state.host_cpuid().unlisted_candidates.iter().copied()) .collect(); let entries: Vec<(u32, u32)> = reads @@ -847,7 +902,7 @@ impl MshvPartitionInner { .map(|&(function, index)| (function, index.unwrap_or(0))) .collect(); let (values, bulk) = vp0_cpuid_entries(bsp, &entries, TimeAbiCode::CpuSurface)?; - let mut read = reads + let leaves: Vec<_> = reads .iter() .zip(values) .map(|(&(function, index), result)| { @@ -856,19 +911,8 @@ impl MshvPartitionInner { Some(index) => leaf.indexed(index), None => leaf, } - }); - let mut leaves: Vec<_> = read - .by_ref() - .take(EFFECTIVE_CPUID_MAX_LEAVES.len()) + }) .collect(); - for leaf in &state.registered_cpuid { - leaves.push(if is_zero_fill(leaf) { - *leaf - } else { - read.next().expect("one value per read") - }); - } - leaves.extend(read); let host = state.host_cpuid(); tracing::info!( leaves = leaves.len(), @@ -893,7 +937,7 @@ impl MshvPartitionInner { fn check_identity_cpuid(&self, bsp: &VcpuFd) -> Result<(), TimeAbiError> { let state = self.time_abi_state()?; let effective = CpuidLeafSet::new(self.time_abi_effective_cpuid()?.to_vec()); - let identity = state.registered_cpuid.iter().filter(|leaf| { + let identity = state.cpuid.iter().filter(|leaf| { leaf.function <= IDENTITY_MAX_LEAF && IDENTITY_CPUID_RANGE.contains(&leaf.function) }); for leaf in identity { @@ -944,12 +988,19 @@ fn vp0_cpuid_entries( } } +/// Returns whether VP 0's `actual` result agrees with `leaf` under its mask. +fn presents(leaf: &CpuidLeaf, actual: [u32; 4]) -> bool { + let mut expected = actual; + leaf.apply(&mut expected); + actual == expected +} + /// Fails with `E_IDENTITY_ROUTING` unless VP 0's `actual` result agrees with /// `leaf` under its mask. fn check_leaf(leaf: &CpuidLeaf, actual: [u32; 4]) -> Result<(), TimeAbiError> { - let mut expected = actual; - leaf.apply(&mut expected); - if actual != expected { + if !presents(leaf, actual) { + let mut expected = actual; + leaf.apply(&mut expected); return Err(TimeAbiError::new( TimeAbiCode::IdentityRouting, format!( @@ -1405,6 +1456,7 @@ mod tests { &partition_cpuid(&config.cpuid), super::super::profile_features::legacy_features(), HostCpuidSource::Ready(HostCpuid::new(&config.cpu_profile, host.clone())), + 0, ); assert_eq!( state.host_cpuid().unlisted_candidates, @@ -1426,6 +1478,7 @@ mod tests { &partition_cpuid(&config.cpuid), super::super::profile_features::legacy_features(), HostCpuidSource::spawn(&config.cpu_profile), + 0, ); let here = HostCpuid::read(&config.cpu_profile); // The reading thread may run on another CPU, whose per-CPU fields @@ -1444,19 +1497,13 @@ mod tests { } #[test] - fn zero_fill_leaves_are_full_zero_leaves_past_the_identity() { - for function in [0x4000_0006, 0x4000_0081, 0x4000_00ff] { - assert!(is_zero_fill(&CpuidLeaf::new(function, [0; 4]))); - } - // Identity leaves, nonzero or partial leaves, and zeroed time-bit - // leaves outside the range are read back from VP 0. - assert!(!is_zero_fill(&CpuidLeaf::new(0x4000_0004, [0; 4]))); - assert!(!is_zero_fill(&CpuidLeaf::new(0x4000_0006, [1, 0, 0, 0]))); - assert!(!is_zero_fill(&CpuidLeaf::new(0x4000_0100, [0; 4]))); - assert!(!is_zero_fill(&CpuidLeaf::new(0x15, [0; 4]))); - assert!(!is_zero_fill( - &CpuidLeaf::new(0x4000_0006, [0; 4]).masked([1, 0, 0, 0]) - )); + fn a_result_is_presented_when_the_view_agrees_under_its_mask() { + let leaf = CpuidLeaf::new(6, [4, 0, 0, 0]).masked([!0, !0, 1, 0]); + assert!(presents(&leaf, [4, 0, 0, 0])); + // Bits outside the mask are the hypervisor's own. + assert!(presents(&leaf, [4, 0, 2, 0x55])); + assert!(!presents(&leaf, [0, 0, 0, 0])); + assert!(!presents(&leaf, [4, 0, 1, 0])); } #[test] @@ -1561,6 +1608,7 @@ mod tests { &partition_cpuid(&config.cpuid), super::super::profile_features::legacy_features(), HostCpuidSource::Ready(HostCpuid::new("", Vec::new())), + 0, ); state.check_vp_creation(VpIndex::new(4)).unwrap(); state.vp_set_sealed.store(true, Ordering::SeqCst); From c5ef7ec6fb931c44d4ebaa5aca025f0d1d8eb22e Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 04:02:14 -0700 Subject: [PATCH 267/304] virt_mshv: read the partition capabilities' CPUID in one hypercall Finalization derives the partition capabilities from the BSP's CPUID (X86PartitionCapabilities::from_cpuid), one HvCallGetVpCpuidValues per entry: about 15 entries for a time ABI partition, 9 us apiece on prometheus30, so about 0.15 ms on the start path of every boot and restore, for every MSHV partition. Read the entries it can ask for in one bulk call first: the maximum and feature leaves, SGX, every architectural XSAVE component, the address sizes and SEV leaves, and the hypervisor leaves. Any other entry, or every entry if the bulk call fails, falls back to a single read. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_mshv/src/x86_64/finalize.rs | 59 +++++++++++++++++++++++ vmm_core/virt_mshv/src/x86_64/time_abi.rs | 2 +- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/vmm_core/virt_mshv/src/x86_64/finalize.rs b/vmm_core/virt_mshv/src/x86_64/finalize.rs index b96f50d1ca1..4f513eed433 100644 --- a/vmm_core/virt_mshv/src/x86_64/finalize.rs +++ b/vmm_core/virt_mshv/src/x86_64/finalize.rs @@ -23,6 +23,46 @@ pub(crate) struct MshvFinalizedPartition { pub(super) caps: virt::PartitionCapabilities, } +/// The CPUID entries that the partition capabilities read +/// (`X86PartitionCapabilities::from_cpuid`): the maximum and feature leaves, +/// SGX, every architectural XSAVE component, the address sizes and SEV +/// leaves, and the hypervisor leaves. Finalization reads them in one bulk +/// call. +const CAPS_CPUID_ENTRIES: [(u32, u32); 32] = [ + (0x0, 0), + (0x1, 0), + (0x7, 0), + (0x12, 2), + (0xd, 0), + (0xd, 1), + (0xd, 2), + (0xd, 3), + (0xd, 4), + (0xd, 5), + (0xd, 6), + (0xd, 7), + (0xd, 8), + (0xd, 9), + (0xd, 10), + (0xd, 11), + (0xd, 12), + (0xd, 13), + (0xd, 14), + (0xd, 15), + (0xd, 16), + (0xd, 17), + (0xd, 18), + (0xd, 19), + (0x8000_0000, 0), + (0x8000_0001, 0), + (0x8000_0008, 0), + (0x8000_001f, 0), + (hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION, 0), + (hvdef::HV_CPUID_FUNCTION_HV_INTERFACE, 0), + (hvdef::HV_CPUID_FUNCTION_MS_HV_FEATURES, 0), + (hvdef::HV_CPUID_FUNCTION_MS_HV_ISOLATION_CONFIGURATION, 0), +]; + /// Partition creation settings that are needed after the partition is built. #[derive(Inspect)] pub(crate) struct CreationConfig { @@ -126,8 +166,27 @@ impl MshvPartitionInner { } fn build_caps(&self, bsp: &VcpuFd) -> Result { + // One bulk read serves the entries the capabilities read, instead of + // one hypercall each (about 15 of them, 9 us apiece on bare metal); + // any other entry falls back to a single read. + let prefetched = + super::time_abi::vp_cpuid_many(bsp, VpIndex::BSP.index(), &CAPS_CPUID_ENTRIES) + .inspect_err(|error| { + tracing::debug!( + error = error as &dyn std::error::Error, + "MSHV bulk CPUID read failed, reading the capabilities one entry at a time" + ); + }) + .unwrap_or_default(); let mut cpuid_error = None; let mut cpuid = |function, index| { + if let Some(position) = CAPS_CPUID_ENTRIES + .iter() + .position(|&entry| entry == (function, index)) + .filter(|&position| position < prefetched.len()) + { + return prefetched[position]; + } bsp.get_cpuid_values(function, index, 0, 0) .unwrap_or_else(|error| { cpuid_error.get_or_insert(error); diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index aab27d1397e..9361716280d 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -793,7 +793,7 @@ const _: () = assert!(size_of::() <= 4096); /// `VcpuFd::get_cpuid_values` reads one entry. `HvCallGetVpCpuidValues` is a /// rep hypercall, so one call reads up to [`CPUID_READS_PER_CALL`] entries /// instead of paying a hypercall per entry. -fn vp_cpuid_many( +pub(super) fn vp_cpuid_many( vp: &VcpuFd, vp_index: u32, entries: &[(u32, u32)], From 46e06c0e51e13a2c463d2eebd5cc7d5183b70237 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 02:50:37 -0700 Subject: [PATCH 268/304] virt: type the x86 topology helpers for x86 topologies virt::x86::topology took the default-typed ProcessorTopology, which is the aarch64 topology in an aarch64 build, and read the BSP's apic_id. virt therefore failed to build for aarch64 (E0609, no field apic_id on Aarch64VpInfo) since "virt: describe the deterministic microVM topology" and the extended topology terminator. That breaks every aarch64 build of virt, OpenHCL's included. Type the helpers' topology as ProcessorTopology, as x86/mod.rs already does. The profiles agent found the break and prepared the patch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt/src/x86/topology.rs | 16 ++++++++++------ vmm_core/virt/src/x86/topology/bsp_identity.rs | 3 ++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/vmm_core/virt/src/x86/topology.rs b/vmm_core/virt/src/x86/topology.rs index 35ce027bcc0..71e8cd0f01f 100644 --- a/vmm_core/virt/src/x86/topology.rs +++ b/vmm_core/virt/src/x86/topology.rs @@ -10,6 +10,7 @@ use std::cmp::min; use thiserror::Error; use vm_topology::processor::ProcessorTopology; use vm_topology::processor::VpIndex; +use vm_topology::processor::x86::X86Topology; use x86defs::cpuid::CacheParametersEax; use x86defs::cpuid::CpuidFunction; use x86defs::cpuid::ExtendedAddressSpaceSizesEcx; @@ -70,7 +71,7 @@ pub fn per_vp_cpuid_bits(function: u32) -> [u32; 4] { /// This includes some bits of leaves 01h and 04h, plus all of leaves 0Bh and /// 1Fh pub fn topology_cpuid<'a>( - topology: &'a ProcessorTopology, + topology: &'a ProcessorTopology, cpuid: CpuidFn<'a>, leaves: &mut Vec, ) -> Result<(), UnknownVendor> { @@ -141,7 +142,10 @@ pub fn topology_cpuid<'a>( /// keep their backend's own answer there. The time ABI's effective CPUID /// lists it; backend tests that mirror the effective CPUID call this function /// instead of copying it. -pub fn terminate_extended_topology(topology: &ProcessorTopology, leaves: &mut Vec) { +pub fn terminate_extended_topology( + topology: &ProcessorTopology, + leaves: &mut Vec, +) { let bsp_apic_id = topology.vp_arch(VpIndex::BSP).apic_id; for function in [ CpuidFunction::ExtendedTopologyEnumeration, @@ -167,7 +171,7 @@ pub fn terminate_extended_topology(topology: &ProcessorTopology, leaves: &mut Ve /// /// Only valid for Intel processors. fn cache_parameters_cpuid( - topology: &ProcessorTopology, + topology: &ProcessorTopology, cpuid: CpuidFn<'_>, leaves: &mut Vec, ) { @@ -220,7 +224,7 @@ fn cache_parameters_cpuid( /// The x2APIC values in edx will be zero. The caller will need to ensure /// these are set correctly for each VP. fn extended_topology_cpuid( - topology: &ProcessorTopology, + topology: &ProcessorTopology, function: CpuidFunction, leaves: &mut Vec, ) { @@ -262,7 +266,7 @@ fn extended_topology_cpuid( /// /// This leaf contains core count and APIC ID size information. fn amd_extended_address_space_sizes_cpuid( - topology: &ProcessorTopology, + topology: &ProcessorTopology, leaves: &mut Vec, ) { let nc = (topology.reserved_vps_per_socket() - 1) as u8; @@ -286,7 +290,7 @@ fn amd_extended_address_space_sizes_cpuid( /// Adds leaf 8000001Eh (Processor Topology Definition) for AMD processors. fn amd_processor_topology_definition_cpuid( - topology: &ProcessorTopology, + topology: &ProcessorTopology, leaves: &mut Vec, ) { // threads_per_compute_unit is (threads per core - 1). diff --git a/vmm_core/virt/src/x86/topology/bsp_identity.rs b/vmm_core/virt/src/x86/topology/bsp_identity.rs index ce02a24ff6d..868fbbd020e 100644 --- a/vmm_core/virt/src/x86/topology/bsp_identity.rs +++ b/vmm_core/virt/src/x86/topology/bsp_identity.rs @@ -6,6 +6,7 @@ use crate::CpuidLeaf; use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::x86::X86Topology; use x86defs::cpuid::CpuidFunction; use x86defs::cpuid::ProcessorTopologyDefinitionEax; use x86defs::cpuid::ProcessorTopologyDefinitionEbx; @@ -16,7 +17,7 @@ use x86defs::cpuid::VersionAndFeaturesEbx; /// [`super::topology_cpuid`]: the initial APIC ID in leaf 01h, the x2APIC ID /// in leaves 0Bh and 1Fh, and the extended APIC, compute unit, and node IDs /// in leaf 8000001Eh, each included in the leaf's mask. -pub(super) fn apply(topology: &ProcessorTopology, leaves: &mut [CpuidLeaf]) { +pub(super) fn apply(topology: &ProcessorTopology, leaves: &mut [CpuidLeaf]) { let bsp_apic_id = topology.vp_arch(crate::VpIndex::BSP).apic_id; for leaf in leaves { match CpuidFunction(leaf.function) { From 8470b7b8a8c6e981e83dc60f0937fbea8e8e320d Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 01:50:54 -0700 Subject: [PATCH 269/304] virt: build the time ABI effective CPUID in one place Core built a partition's effective CPUID in openvmm_core, and virt_whp, virt_mshv, and cpu_profile repeated the construction in their tests. Move the builder into virt, so that core, the backends, and their tests share one implementation. - virt::time_abi::cpuid::effective_cpuid builds the effective CPUID of a profile and x86 topology: OpenVMM's topology leaves, each extended topology leaf's terminating subleaf, the APIC mode, and the time ABI's identity and explicit zero leaves. backend_cpuid returns the table a backend programs, with the per-VP APIC identity bits unmasked. - virt depends on cpu_profile. Cargo cannot key a dependency on guest_arch, so the dependency is unconditional, as openvmm_core's is; the builder is x86-64 only. - TimeAbiError converts from cpu_profile::ProfileError and keeps the code, and CpuidResult converts from a CpuidLeaf. openvmm_helpers uses the conversion directly, so its profile_error wrapper goes, and the test that every profile code has a time ABI code moves next to the conversion. - Core calls the builder, and the copies go: virt_whp's test_cpuid::effective_cpuid and config_cpuid, the body of virt_mshv's host_profile_cpuid, and cpu_profile's contract test, which moves into virt as the builder's test and now covers both APIC modes and the extended topology terminator. cpu_profile no longer dev-depends on virt or vm_topology. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 3 +- openvmm/openvmm_core/src/worker/dispatch.rs | 6 +- .../src/worker/dispatch/time_abi.rs | 70 +------- openvmm/openvmm_helpers/src/snapshot/time.rs | 21 +-- vmm_core/cpu_profile/Cargo.toml | 2 - vmm_core/cpu_profile/src/effective.rs | 57 ------ vmm_core/virt/Cargo.toml | 1 + vmm_core/virt/src/time_abi/cpuid.rs | 162 ++++++++++++++++++ vmm_core/virt/src/time_abi/mod.rs | 20 +++ vmm_core/virt_mshv/src/x86_64/time_abi.rs | 46 +---- vmm_core/virt_whp/src/time_abi.rs | 73 ++------ 11 files changed, 208 insertions(+), 253 deletions(-) create mode 100644 vmm_core/virt/src/time_abi/cpuid.rs diff --git a/Cargo.lock b/Cargo.lock index 87e1236ac04..24d91b01f6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1007,8 +1007,6 @@ dependencies = [ "sha2", "test_with_tracing", "thiserror 2.0.16", - "virt", - "vm_topology", "windows-sys 0.61.0", ] @@ -9773,6 +9771,7 @@ dependencies = [ "aarch64defs", "anyhow", "build_rs_guest_arch", + "cpu_profile", "guestmem", "hvdef", "inspect", diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 79f0f927a5f..91dd8efb64a 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -1266,8 +1266,10 @@ impl InitializedVm { let (time_abi_partition, time_abi_config) = match &cfg.microvm.time_abi { Some(parameters) if cfg.machine_profile == MachineProfile::Microvm => { let profile = time_abi::select_cpu_profile(¶meters.cpu_profile)?; - let effective_cpuid = - Arc::new(time_abi::effective_cpuid(profile, &processor_topology)?); + let effective_cpuid = Arc::new(virt::time_abi::cpuid::effective_cpuid( + profile, + &processor_topology, + )?); let (msrs, config) = time_abi::partition_config(profile, &effective_cpuid); ( Some(time_abi::PartitionTimeAbi { diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 6cbff78e9bc..9f3c2f8c195 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -43,8 +43,6 @@ use virt::time_abi::TimeAbiMsrs; use virt::time_abi::TimeAbiTestHooks; use virt::time_abi::TscSyncMethod; use virt::time_abi::surface::SupportedCpuSurface; -use vm_topology::processor::ProcessorTopology; -use vm_topology::processor::x86::ApicMode; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; use vmcore::save_restore::SavedStateBlob; @@ -91,7 +89,7 @@ pub(super) fn partition_config( ) -> (Arc, TimeAbiConfig) { let msrs = Arc::new(TimeAbiMsrs::new()); let config = TimeAbiConfig { - cpuid: Arc::new(backend_cpuid(effective_cpuid)), + cpuid: Arc::new(virt::time_abi::cpuid::backend_cpuid(effective_cpuid)), msrs: msrs.clone(), cpu_profile: profile.id().to_owned(), }; @@ -108,70 +106,6 @@ pub(super) fn select_cpu_profile(requested: &str) -> anyhow::Result<&'static Cpu Ok(cpu_profile::select(requested, &host_cpu()?)?) } -/// Builds the effective CPUID of a partition with `profile` and `topology`: -/// the profile completed with OpenVMM's topology leaves, each extended -/// topology leaf's terminating subleaf, the APIC mode, and -/// the time ABI's identity and explicit zero leaves. Fails with -/// `E_CPU_SURFACE` if they do not complete the profile exactly. -pub(super) fn effective_cpuid( - profile: &CpuProfile, - topology: &ProcessorTopology, -) -> anyhow::Result { - let result = |leaf: &CpuidLeaf| cpu_profile::CpuidResult { - function: leaf.function, - index: leaf.index, - result: leaf.result, - mask: leaf.mask, - }; - let mut topology_leaves = Vec::new(); - virt::x86::topology::topology_cpuid( - topology, - &|leaf, subleaf| profile.lookup(leaf, subleaf), - &mut topology_leaves, - ) - .map_err(|err| { - TimeAbiError::new( - TimeAbiCode::CpuSurface, - format!( - "cannot build the topology CPUID of CPU profile {}: {err}", - profile.id() - ), - ) - })?; - virt::x86::topology::terminate_extended_topology(topology, &mut topology_leaves); - let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); - vm.push(cpu_profile::x2apic_cpuid(!matches!( - topology.apic_mode(), - ApicMode::XApic - ))); - let identity: Vec<_> = virt::time_abi::identity::identity_cpuid_leaves(topology.vp_count()) - .iter() - .map(result) - .chain(virt::time_abi::identity::identity_zero_cpuid_leaves().map(|leaf| result(&leaf))) - .collect(); - Ok(profile.effective_cpuid(&vm, &identity)?) -} - -/// Returns the CPUID results a backend programs for `effective`: every -/// result of the effective CPUID, with the per-VP APIC identity bits -/// unmasked, so that the backend sets each VP's own. -fn backend_cpuid(effective: &EffectiveCpuid) -> CpuidLeafSet { - CpuidLeafSet::new( - effective - .results() - .map(|result| { - let per_vp = virt::x86::topology::per_vp_cpuid_bits(result.function); - CpuidLeaf { - function: result.function, - index: result.index, - result: result.result, - mask: [0, 1, 2, 3].map(|register| result.mask[register] & !per_vp[register]), - } - }) - .collect(), - ) -} - /// Returns the vendor and signature of the host CPU (`E_CPU_GENERATION` if /// the host is not x86-64). fn host_cpu() -> Result { @@ -678,7 +612,9 @@ impl StateUnit for TimeAbiUnit { mod tests { use super::*; use openvmm_defs::time_abi::EFFECTIVE_CPUID_ENTRY_BYTES; + use virt::time_abi::cpuid::effective_cpuid; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; + use vm_topology::processor::ProcessorTopology; use vm_topology::processor::TopologyBuilder; use vm_topology::processor::VpIndex; use vm_topology::processor::x86::X2ApicState; diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index 21ebfaee509..04ed8031c54 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -218,8 +218,7 @@ pub fn preflight_time_abi_restore( "the host CPU cannot be identified", ) })?; - let profile = - cpu_profile::pinned_for_restore(&record.id, &record.sha256).map_err(profile_error)?; + let profile = cpu_profile::pinned_for_restore(&record.id, &record.sha256)?; let pinned = cpu_profile::pinned_record(&record.id).ok_or_else(|| { TimeAbiError::new( TimeAbiCode::ProfileUnknown, @@ -235,7 +234,7 @@ pub fn preflight_time_abi_restore( ), )); } - cpu_profile::check_generation(profile, &host).map_err(profile_error)?; + cpu_profile::check_generation(profile, &host)?; let capture = capture_record(time)?; let downtime = select_downtime(&capture, destination, now, hooks)?; Ok(TimeAbiRestorePreflight { @@ -246,13 +245,6 @@ pub fn preflight_time_abi_restore( }) } -/// Converts a CPU profile failure to the time ABI failure with the same code. -pub fn profile_error(error: cpu_profile::ProfileError) -> TimeAbiError { - let code = TimeAbiCode::from_name(error.code.as_str()) - .expect("every CPU profile code is a time ABI code"); - TimeAbiError::new(code, error.message) -} - /// Returns the vendor and signature of the host CPU, or `None` on a host that /// is not x86-64. pub fn host_cpu() -> Option { @@ -733,13 +725,4 @@ pub(super) mod tests { assert!(pinned.encoding.len() < MAX_CPU_PROFILE_BYTES / 16); } } - - #[test] - fn profile_errors_keep_their_codes() { - for code in cpu_profile::ProfileErrorCode::ALL { - let error = profile_error(cpu_profile::ProfileError::new(code, "message")); - assert_eq!(error.code.as_str(), code.as_str()); - assert_eq!(error.message, "message"); - } - } } diff --git a/vmm_core/cpu_profile/Cargo.toml b/vmm_core/cpu_profile/Cargo.toml index 3b24b58aa71..f3db0c69e18 100644 --- a/vmm_core/cpu_profile/Cargo.toml +++ b/vmm_core/cpu_profile/Cargo.toml @@ -21,8 +21,6 @@ features = ["Win32_Foundation", "Win32_System_Registry"] [dev-dependencies] test_with_tracing.workspace = true -virt.workspace = true -vm_topology.workspace = true [build-dependencies] build_rs_guest_arch.workspace = true diff --git a/vmm_core/cpu_profile/src/effective.rs b/vmm_core/cpu_profile/src/effective.rs index 41defe12d45..0bc272ba2a0 100644 --- a/vmm_core/cpu_profile/src/effective.rs +++ b/vmm_core/cpu_profile/src/effective.rs @@ -665,61 +665,4 @@ mod tests { } } } - - /// The contract with core: OpenVMM's topology leaves and the time ABI's - /// identity leaves complete every pinned profile into an effective CPUID - /// that satisfies the CPU time bits and the identity. - #[cfg(guest_arch = "x86_64")] - #[test] - fn pinned_profiles_satisfy_the_time_abi_with_openvmm_topologies() { - use virt::time_abi::identity; - use vm_topology::processor::TopologyBuilder; - use vm_topology::processor::x86::X2ApicState; - - let result = |leaf: &virt::CpuidLeaf| CpuidResult { - function: leaf.function, - index: leaf.index, - result: leaf.result, - mask: leaf.mask, - }; - for profile in crate::pinned_profiles() { - for vp_count in [1, 2, 8] { - let topology = TopologyBuilder::new_x86() - .vps_per_socket(vp_count) - .x2apic(X2ApicState::Unsupported) - .build(vp_count) - .unwrap(); - let mut topology_leaves = Vec::new(); - virt::x86::topology::topology_cpuid( - &topology, - &|leaf, subleaf| profile.lookup(leaf, subleaf), - &mut topology_leaves, - ) - .unwrap(); - let mut vm = topology_leaves.iter().map(result).collect::>(); - vm.push(x2apic_cpuid(false)); - let identity_leaves = identity::identity_cpuid_leaves(vp_count) - .iter() - .map(result) - .chain(identity::identity_zero_cpuid_leaves().map(|leaf| result(&leaf))) - .collect::>(); - let effective = profile.effective_cpuid(&vm, &identity_leaves).unwrap(); - - identity::check_time_bits( - &mut |leaf, subleaf| effective.lookup(leaf, subleaf), - true, - ) - .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); - identity::check_identity( - &mut |leaf, subleaf| effective.lookup(leaf, subleaf), - vp_count, - ) - .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); - assert_eq!( - EffectiveCpuid::decode(&effective.encode()).unwrap(), - effective - ); - } - } - } } diff --git a/vmm_core/virt/Cargo.toml b/vmm_core/virt/Cargo.toml index 262bd8cfdb2..0837e0cc279 100644 --- a/vmm_core/virt/Cargo.toml +++ b/vmm_core/virt/Cargo.toml @@ -8,6 +8,7 @@ rust-version.workspace = true [dependencies] aarch64defs.workspace = true +cpu_profile.workspace = true hvdef.workspace = true pci_core.workspace = true memory_range = { workspace = true, features = ["inspect"] } diff --git a/vmm_core/virt/src/time_abi/cpuid.rs b/vmm_core/virt/src/time_abi/cpuid.rs new file mode 100644 index 00000000000..cb2d1581e01 --- /dev/null +++ b/vmm_core/virt/src/time_abi/cpuid.rs @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The effective CPUID of a time ABI partition: its CPU profile completed with +//! OpenVMM's topology leaves, the APIC mode, and the time ABI's identity +//! leaves. Core, the backends, and their tests all build it here. + +use super::TimeAbiCode; +use super::TimeAbiError; +use crate::CpuidLeaf; +use crate::CpuidLeafSet; +use cpu_profile::CpuProfile; +use cpu_profile::CpuidResult; +use cpu_profile::EffectiveCpuid; +use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::x86::ApicMode; +use vm_topology::processor::x86::X86Topology; + +impl From<&CpuidLeaf> for CpuidResult { + fn from(leaf: &CpuidLeaf) -> Self { + Self { + function: leaf.function, + index: leaf.index, + result: leaf.result, + mask: leaf.mask, + } + } +} + +/// Builds the effective CPUID of a partition with `profile` and `topology`: +/// the profile completed with OpenVMM's topology leaves, each extended +/// topology leaf's terminating subleaf, the APIC mode, and the time ABI's +/// identity and explicit zero leaves. Fails with `E_CPU_SURFACE` if they do +/// not complete the profile exactly. +pub fn effective_cpuid( + profile: &CpuProfile, + topology: &ProcessorTopology, +) -> Result { + let mut topology_leaves = Vec::new(); + crate::x86::topology::topology_cpuid( + topology, + &|leaf, subleaf| profile.lookup(leaf, subleaf), + &mut topology_leaves, + ) + .map_err(|err| { + TimeAbiError::new( + TimeAbiCode::CpuSurface, + format!( + "cannot build the topology CPUID of CPU profile {}: {err}", + profile.id() + ), + ) + })?; + crate::x86::topology::terminate_extended_topology(topology, &mut topology_leaves); + let mut vm: Vec<_> = topology_leaves.iter().map(CpuidResult::from).collect(); + vm.push(cpu_profile::x2apic_cpuid(!matches!( + topology.apic_mode(), + ApicMode::XApic + ))); + let identity: Vec<_> = super::identity::identity_cpuid_leaves(topology.vp_count()) + .iter() + .map(CpuidResult::from) + .chain(super::identity::identity_zero_cpuid_leaves().map(|leaf| CpuidResult::from(&leaf))) + .collect(); + Ok(profile.effective_cpuid(&vm, &identity)?) +} + +/// Returns the CPUID results a backend programs for `effective` +/// ([`TimeAbiConfig::cpuid`](super::TimeAbiConfig::cpuid)): every result of +/// the effective CPUID, with the per-VP APIC identity bits unmasked, so that +/// the backend sets each VP's own. +pub fn backend_cpuid(effective: &EffectiveCpuid) -> CpuidLeafSet { + CpuidLeafSet::new( + effective + .results() + .map(|result| { + let per_vp = crate::x86::topology::per_vp_cpuid_bits(result.function); + CpuidLeaf { + function: result.function, + index: result.index, + result: result.result, + mask: [0, 1, 2, 3].map(|register| result.mask[register] & !per_vp[register]), + } + }) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time_abi::identity; + use test_with_tracing::test; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + + fn topology(vp_count: u32, x2apic: X2ApicState) -> ProcessorTopology { + TopologyBuilder::new_x86() + .vps_per_socket(vp_count) + .x2apic(x2apic) + .build(vp_count) + .unwrap() + } + + /// The contract between OpenVMM and the profiles: OpenVMM's topology + /// leaves and the time ABI's identity leaves complete every pinned + /// profile into an effective CPUID that satisfies the CPU time bits and + /// the identity, in both APIC modes. + #[test] + fn pinned_profiles_satisfy_the_time_abi_with_openvmm_topologies() { + for profile in cpu_profile::pinned_profiles() { + for vp_count in [1, 2, 8] { + for x2apic in [X2ApicState::Unsupported, X2ApicState::Supported] { + let effective = effective_cpuid(profile, &topology(vp_count, x2apic)) + .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); + identity::check_time_bits( + &mut |leaf, subleaf| effective.lookup(leaf, subleaf), + true, + ) + .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); + identity::check_identity( + &mut |leaf, subleaf| effective.lookup(leaf, subleaf), + vp_count, + ) + .unwrap_or_else(|error| panic!("{}: {error}", profile.id())); + assert_eq!( + EffectiveCpuid::decode(&effective.encode()).unwrap(), + effective + ); + } + } + } + } + + #[test] + fn backend_cpuid_unmasks_only_the_per_vp_apic_bits() { + for profile in cpu_profile::pinned_profiles() { + let effective = effective_cpuid(profile, &topology(2, X2ApicState::Supported)).unwrap(); + let table = backend_cpuid(&effective); + assert_eq!( + table.leaves().len(), + effective.results().count(), + "{}", + profile.id() + ); + for result in effective.results() { + let leaf = table + .leaves() + .iter() + .find(|leaf| leaf.function == result.function && leaf.index == result.index) + .unwrap_or_else(|| panic!("{}: {result:?} is missing", profile.id())); + let per_vp = crate::x86::topology::per_vp_cpuid_bits(result.function); + assert_eq!(leaf.result, result.result); + for ((mask, effective_mask), per_vp) in + leaf.mask.iter().zip(result.mask).zip(per_vp) + { + assert_eq!(*mask, effective_mask & !per_vp); + } + } + } + } +} diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index 5ba6c51eb0a..0278201b7c1 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -10,6 +10,8 @@ #[cfg(guest_arch = "x86_64")] pub mod backend; +#[cfg(guest_arch = "x86_64")] +pub mod cpuid; pub mod downtime; pub mod host; #[cfg(guest_arch = "x86_64")] @@ -188,6 +190,15 @@ impl fmt::Display for TimeAbiError { impl std::error::Error for TimeAbiError {} +impl From for TimeAbiError { + /// Keeps the code: every CPU profile code is a time ABI code. + fn from(error: cpu_profile::ProfileError) -> Self { + let code = TimeAbiCode::from_name(error.code.as_str()) + .expect("every CPU profile code is a time ABI code"); + Self::new(code, error.message) + } +} + /// The rates the guest reads from MSRs `0x40000022` and `0x40000023`. /// /// They are declared once per VM process: a cold boot declares the native TSC @@ -422,6 +433,15 @@ pub struct TimeAbiConfig { mod tests { use super::*; + #[test] + fn profile_errors_keep_their_codes() { + for code in cpu_profile::ProfileErrorCode::ALL { + let error = TimeAbiError::from(cpu_profile::ProfileError::new(code, "message")); + assert_eq!(error.code.as_str(), code.as_str()); + assert_eq!(error.message, "message"); + } + } + #[test] fn codes_match_the_specification() { let names: Vec<_> = TimeAbiCode::ALL.iter().map(|code| code.as_str()).collect(); diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 9361716280d..37a67f2eb83 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -1732,55 +1732,15 @@ mod hw { } /// Returns the effective CPUID of this host's profile for `topology`, and - /// the CPUID table that core programs for it (`TimeAbiConfig::cpuid`): the - /// effective CPUID with the per-VP APIC identity bits unmasked. This is - /// what core's `effective_cpuid` and `backend_cpuid` build. + /// the CPUID table that core programs for it (`TimeAbiConfig::cpuid`). fn host_profile_cpuid( topology: &vm_topology::processor::ProcessorTopology, ) -> (cpu_profile::EffectiveCpuid, CpuidLeafSet) { let profile = cpu_profile::pinned(&host_profile_id()).unwrap(); - let result = |leaf: &CpuidLeaf| cpu_profile::CpuidResult { - function: leaf.function, - index: leaf.index, - result: leaf.result, - mask: leaf.mask, - }; - let mut topology_leaves = Vec::new(); - virt::x86::topology::topology_cpuid( - topology, - &|leaf, subleaf| profile.lookup(leaf, subleaf), - &mut topology_leaves, - ) - .unwrap(); - virt::x86::topology::terminate_extended_topology(topology, &mut topology_leaves); - let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); - vm.push(cpu_profile::x2apic_cpuid(!matches!( - topology.apic_mode(), - vm_topology::processor::x86::ApicMode::XApic - ))); - let identity: Vec<_> = virt::time_abi::identity::identity_cpuid_leaves(topology.vp_count()) - .iter() - .map(result) - .chain(virt::time_abi::identity::identity_zero_cpuid_leaves().map(|leaf| result(&leaf))) - .collect(); - let effective = profile.effective_cpuid(&vm, &identity).unwrap(); - let table = CpuidLeafSet::new( - effective - .results() - .map(|result| { - let per_vp = virt::x86::topology::per_vp_cpuid_bits(result.function); - CpuidLeaf { - function: result.function, - index: result.index, - result: result.result, - mask: [0, 1, 2, 3].map(|i| result.mask[i] & !per_vp[i]), - } - }) - .collect(), - ); + let effective = virt::time_abi::cpuid::effective_cpuid(profile, topology).unwrap(); + let table = virt::time_abi::cpuid::backend_cpuid(&effective); (effective, table) } - /// Converts a backend's CPU surface to the profile crate's form, as core /// does for the support check. fn host_cpu_surface(surface: &SupportedCpuSurface) -> cpu_profile::HostCpuSurface { diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 6726103d3c1..accc25f702c 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -1249,9 +1249,9 @@ impl WhpProcessor<'_> { } } -/// Test support: the complete effective CPUID that core passes in -/// [`TimeAbiConfig::cpuid`], built as core builds it, and core's check of the -/// CPUID that VP 0 presents. +/// Test support: topologies of the pinned CPU profiles, and core's check of +/// the CPUID that VP 0 presents. `virt::time_abi::cpuid` builds the effective +/// CPUID and the [`TimeAbiConfig::cpuid`] table. #[cfg(test)] pub(crate) mod test_cpuid { use cpu_profile::CpuProfile; @@ -1279,37 +1279,6 @@ pub(crate) mod test_cpuid { .unwrap() } - /// Returns the effective CPUID of a partition with `profile` and - /// `topology`, as core's `time_abi::effective_cpuid` builds it, including - /// the subleaf that ends each extended topology leaf. - pub fn effective_cpuid(profile: &CpuProfile, topology: &ProcessorTopology) -> EffectiveCpuid { - let result = |leaf: &CpuidLeaf| cpu_profile::CpuidResult { - function: leaf.function, - index: leaf.index, - result: leaf.result, - mask: leaf.mask, - }; - let mut topology_leaves = Vec::new(); - virt::x86::topology::topology_cpuid( - topology, - &|leaf, subleaf| profile.lookup(leaf, subleaf), - &mut topology_leaves, - ) - .unwrap(); - virt::x86::topology::terminate_extended_topology(topology, &mut topology_leaves); - let mut vm: Vec<_> = topology_leaves.iter().map(result).collect(); - vm.push(cpu_profile::x2apic_cpuid(!matches!( - topology.apic_mode(), - ApicMode::XApic - ))); - let identity: Vec<_> = virt::time_abi::identity::identity_cpuid_leaves(topology.vp_count()) - .iter() - .map(result) - .chain(virt::time_abi::identity::identity_zero_cpuid_leaves().map(|leaf| result(&leaf))) - .collect(); - profile.effective_cpuid(&vm, &identity).unwrap() - } - /// Returns whether `effective` lists `function`/`index` as an extended /// topology subleaf past the core level: the subleaf that ends the /// enumeration, which the profile programming of `de39f6aab` left zero. @@ -1321,27 +1290,6 @@ pub(crate) mod test_cpuid { .any(|result| result.function == function && result.index == Some(index)) } - /// Returns [`TimeAbiConfig::cpuid`](virt::time_abi::TimeAbiConfig::cpuid) - /// for `effective`, as core's `backend_cpuid` builds it: the per-VP APIC - /// identity bits unmasked. - pub fn config_cpuid(effective: &EffectiveCpuid) -> CpuidLeafSet { - CpuidLeafSet::new( - effective - .results() - .map(|result| { - let per_vp = virt::x86::topology::per_vp_cpuid_bits(result.function); - CpuidLeaf { - function: result.function, - index: result.index, - result: result.result, - mask: [0, 1, 2, 3] - .map(|register| result.mask[register] & !per_vp[register]), - } - }) - .collect(), - ) - } - /// Lists every result of `effective` that `presented` does not match /// under the result's masks, as core's `check_presented_cpuid` does. pub fn differences(presented: &CpuidLeafSet, effective: &EffectiveCpuid) -> Vec { @@ -1580,9 +1528,12 @@ mod tests { impl Partition { fn new(profile: &str, vp_count: u32, x2apic: X2ApicState) -> Self { let topology = test_cpuid::topology(vp_count, x2apic); - let effective = - test_cpuid::effective_cpuid(cpu_profile::pinned(profile).unwrap(), &topology); - let config = test_cpuid::config_cpuid(&effective); + let effective = virt::time_abi::cpuid::effective_cpuid( + cpu_profile::pinned(profile).unwrap(), + &topology, + ) + .unwrap(); + let config = virt::time_abi::cpuid::backend_cpuid(&effective); let exits = cpuid_exits(&config); let whp = FakeWhp::new(&config); let own = @@ -1608,7 +1559,7 @@ mod tests { fn profile_programmed(profile: &str, vp_count: u32, noise: bool) -> Self { let topology = test_cpuid::topology(vp_count, X2ApicState::Supported); let pinned = cpu_profile::pinned(profile).unwrap(); - let effective = test_cpuid::effective_cpuid(pinned, &topology); + let effective = virt::time_abi::cpuid::effective_cpuid(pinned, &topology).unwrap(); let programming = test_cpuid::profile_programming(pinned, vp_count); let whp = FakeWhp { results: programming.results, @@ -2392,9 +2343,9 @@ mod whp_tests { } }; let topology = test_cpuid::topology(vp_count, X2ApicState::Supported); - let effective = test_cpuid::effective_cpuid(profile, &topology); + let effective = virt::time_abi::cpuid::effective_cpuid(profile, &topology).unwrap(); let config = TimeAbiConfig { - cpuid: Arc::new(test_cpuid::config_cpuid(&effective)), + cpuid: Arc::new(virt::time_abi::cpuid::backend_cpuid(&effective)), msrs: Arc::new(TimeAbiMsrs::new()), cpu_profile: profile.id().to_owned(), }; From 2bc9ccd34c1f34bb5317a80e99871f3815fa09dd Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 03:33:42 -0700 Subject: [PATCH 270/304] virt: share the L3 cache across the socket in CPUID leaf 4 The leaf 4 builder means to report the L3 cache as shared by the socket, but it tested the level of the result it was building, whose level field is never set. Every guest with more than one VP therefore saw a private L3 cache per VP, unlike the processors it models. Take the level from the cache's own descriptor instead. Such guests now see one L3 cache shared by every VP of the socket, and the L1 and L2 caches stay per core. On KVM, a Linux guest's cold boot is about 5 ms faster with 2 VPs and 7 ms faster with 8. The change reaches a time ABI partition's effective CPUID, in the L3 subleaf of leaf 4, so snapshots of multi-VP partitions captured before it fail restore with E_CPU_SURFACE. Single-VP snapshots are unaffected. The bug dates from OpenVMM's initial public commit. The kvm agent found and diagnosed it, and the profiles agent confirmed it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt/src/time_abi/cpuid.rs | 41 +++++++++++++++++++++++++ vmm_core/virt/src/x86/topology.rs | 47 ++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/vmm_core/virt/src/time_abi/cpuid.rs b/vmm_core/virt/src/time_abi/cpuid.rs index cb2d1581e01..d68cdf81d65 100644 --- a/vmm_core/virt/src/time_abi/cpuid.rs +++ b/vmm_core/virt/src/time_abi/cpuid.rs @@ -93,6 +93,7 @@ mod tests { use test_with_tracing::test; use vm_topology::processor::TopologyBuilder; use vm_topology::processor::x86::X2ApicState; + use x86defs::cpuid::CacheParametersEax; fn topology(vp_count: u32, x2apic: X2ApicState) -> ProcessorTopology { TopologyBuilder::new_x86() @@ -132,6 +133,46 @@ mod tests { } } + /// Every vCPU of a time ABI partition has its own L1 and L2 caches, and + /// all of them share the L3 cache, as in one socket of the profile's + /// generation. + #[test] + fn pinned_profiles_share_the_l3_cache_across_the_socket() { + for profile in cpu_profile::pinned_profiles() { + for vp_count in [1, 2, 8] { + let topology = topology(vp_count, X2ApicState::Supported); + let socket = topology.reserved_vps_per_socket(); + let effective = effective_cpuid(profile, &topology).unwrap(); + let mut levels = Vec::new(); + for subleaf in 0..16 { + let eax = CacheParametersEax::from(effective.lookup(4, subleaf)[0]); + if eax.cache_type() == 0 { + break; + } + let sharing = if eax.cache_level() == 3 { + socket - 1 + } else { + 0 + }; + assert_eq!( + eax.threads_sharing_cache_minus_one(), + sharing, + "{}, {vp_count} VPs, subleaf {subleaf}", + profile.id() + ); + assert_eq!( + eax.cores_per_socket_minus_one(), + socket - 1, + "{}, {vp_count} VPs, subleaf {subleaf}", + profile.id() + ); + levels.push(eax.cache_level()); + } + assert_eq!(levels.last(), Some(&3), "{}", profile.id()); + } + } + } + #[test] fn backend_cpuid_unmasks_only_the_per_vp_apic_bits() { for profile in cpu_profile::pinned_profiles() { diff --git a/vmm_core/virt/src/x86/topology.rs b/vmm_core/virt/src/x86/topology.rs index 71e8cd0f01f..99bb77650c0 100644 --- a/vmm_core/virt/src/x86/topology.rs +++ b/vmm_core/virt/src/x86/topology.rs @@ -199,7 +199,9 @@ fn cache_parameters_cpuid( } // The level 3 cache is not per-VP; indicate that it is per-socket. - if eax.cache_level() == 3 { + // The level comes from the cache's own descriptor: `eax` carries only + // the topology fields. + if CacheParametersEax::from(result[0]).cache_level() == 3 { eax.set_threads_sharing_cache_minus_one(topology.reserved_vps_per_socket() - 1); } @@ -372,6 +374,49 @@ mod tests { assert_eq!(set.result(4, 4, &[0; 4]), [0; 4]); } + /// The L1 and L2 caches belong to a core, and the L3 cache to the socket. + /// Leaf 4 counts addressable IDs, so the socket's VP count is rounded up + /// to a power of two. + #[test] + fn l3_cache_is_shared_by_the_socket() { + for vps in [1, 2, 6, 8] { + for smt in [false, true] { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(vps) + .smt_enabled(smt) + .x2apic(X2ApicState::Supported) + .build(vps) + .unwrap(); + let threads_per_core = if topology.smt_enabled() { 2 } else { 1 }; + let reserved = topology.reserved_vps_per_socket(); + let mut leaves = Vec::new(); + topology_cpuid(&topology, &intel_cpuid, &mut leaves).unwrap(); + let set = CpuidLeafSet::new(leaves); + for subleaf in 0..4 { + let eax = CacheParametersEax::from( + set.result(4, subleaf, &intel_cpuid(4, subleaf))[0], + ); + let sharing = if eax.cache_level() == 3 { + reserved + } else { + threads_per_core + }; + let context = format!("{vps} VPs, smt {smt}, subleaf {subleaf}"); + assert_eq!( + eax.threads_sharing_cache_minus_one() + 1, + sharing, + "{context}" + ); + assert_eq!( + eax.cores_per_socket_minus_one() + 1, + reserved / threads_per_core, + "{context}" + ); + } + } + } + } + #[test] fn per_vp_bits_cover_the_bsp_identity() { let topology = TopologyBuilder::new_x86() From bfc2324164fe0ee20591fbe35542156b6df1bc11 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 03:12:13 -0700 Subject: [PATCH 271/304] cpu_profile: point the effective CPUID docs at virt's builder virt::time_abi::cpuid::effective_cpuid now assembles the inputs of CpuProfile::effective_cpuid for core, the backends, and their tests. Name it in the method's and the crate's documentation, list the subleaf that terminates each extended topology leaf among the VM's inputs, and note that virt converts a CpuidLeaf to a CpuidResult. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/cpu_profile/src/effective.rs | 14 ++++++++++---- vmm_core/cpu_profile/src/lib.rs | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/vmm_core/cpu_profile/src/effective.rs b/vmm_core/cpu_profile/src/effective.rs index 0bc272ba2a0..2c16c3781ca 100644 --- a/vmm_core/cpu_profile/src/effective.rs +++ b/vmm_core/cpu_profile/src/effective.rs @@ -28,9 +28,9 @@ const REGISTERS: [&str; 4] = ["EAX", "EBX", "ECX", "EDX"]; /// One CPUID result and the mask of the bits it defines. /// -/// The fields match `virt::CpuidLeaf` one for one, so converting between the -/// two is field for field. `index` is `None` for a result that applies to -/// every subleaf. +/// The fields match `virt::CpuidLeaf` one for one, and `virt` converts a +/// `CpuidLeaf` to a `CpuidResult` field for field. `index` is `None` for a +/// result that applies to every subleaf. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct CpuidResult { /// The leaf, the input value of `EAX`. @@ -312,9 +312,15 @@ fn describe(entry: &CpuidLeafValue) -> String { impl CpuProfile { /// Builds the effective guest CPUID of a VM that uses this profile. /// + /// OpenVMM assembles the inputs from a VM's topology in one place, + /// `virt::time_abi::cpuid::effective_cpuid`, which core, the backends, and + /// their tests share. + /// /// `vm` holds the results that the VM configuration defines: the topology /// leaves and fields (`virt::x86::topology::topology_cpuid`, with the - /// profile's [`CpuProfile::lookup`] as its CPUID source) and the APIC + /// profile's [`CpuProfile::lookup`] as its CPUID source), the subleaf that + /// terminates each extended topology leaf + /// (`virt::x86::topology::terminate_extended_topology`), and the APIC /// mode ([`x2apic_cpuid`]). They may set only the bits that /// [`vm_owned_bits`] assigns to the VM, and together they must set all of /// them. A result for every subleaf (`index` of `None`) applies to each of diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index 1ebd2fc1b21..1518d66963f 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -11,7 +11,7 @@ //! ([`pinned`]), immutable once released, and identified by ID and SHA-256 //! digest. A VM's effective CPUID ([`CpuProfile::effective_cpuid`]) is a pure //! function of its profile, its topology, and the time ABI's identity -//! leaves. +//! leaves; OpenVMM builds it in `virt::time_abi::cpuid::effective_cpuid`. //! //! - **Selection:** [`select`] maps `--cpu-profile ` and the host //! CPU ([`HostCpuSignature`]) to a pinned profile. From 5eec3135dd38927c7dcff83895f1fb12b4963dc4 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 05:37:13 -0700 Subject: [PATCH 272/304] openvmm_entry, virt: trim the time ABI restore preflight The controller's time ABI restore preflight takes about 0.1 ms of every restore on bare-metal MSHV. Two parts of it did avoidable work: - Contract validation cloned the time contract and the CPU profile record, several kilobytes, into the expected contract, although the validation leaves them to the preflight. Move them in and take them back afterwards. - Reading the host and boot IDs queried each file's metadata and allocated a string. Read each file once into a stack buffer instead: every system call is costly in a Hyper-V root partition. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_entry/src/microvm/restore.rs | 12 ++++++++++-- vmm_core/virt/src/time_abi/host.rs | 20 ++++++++++++++++++-- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index bf9f8709f1b..e1b508e40f6 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -461,8 +461,8 @@ pub(crate) fn validate_restore_contract( (saved_contract.memory_expansion_version != 0) .then_some(saved_contract.memory_capacity_bytes), saved_contract.state_unit_names.clone(), - contract.clone(), - cpu_profile.clone(), + contract, + cpu_profile, )?; align_legacy_network_policy_contract(saved_contract, &mut expected_contract); align_restore_console_listener_contract(saved_contract, &mut expected_contract); @@ -470,6 +470,14 @@ pub(crate) fn validate_restore_contract( manifest, &expected_contract, )?; + // The validation leaves the time ABI records to the preflight, so take + // them back rather than copying them. + let (Some(contract), Some(cpu_profile)) = ( + expected_contract.time.take(), + expected_contract.cpu_profile.take(), + ) else { + unreachable!("the expected contract holds the records it was built with"); + }; Ok(TimeAbiRestore { contract, cpu_profile, diff --git a/vmm_core/virt/src/time_abi/host.rs b/vmm_core/virt/src/time_abi/host.rs index a6f990a3a10..dc79cd10a49 100644 --- a/vmm_core/virt/src/time_abi/host.rs +++ b/vmm_core/virt/src/time_abi/host.rs @@ -163,9 +163,25 @@ mod sys { } fn read_id(path: &str) -> Result<[u8; 16], TimeAbiError> { - let text = std::fs::read_to_string(path) + use std::io::Read; + + // The identity files hold 33 to 37 bytes, which one read returns. Read + // them into a stack buffer with no metadata query: every system call + // is costly in a Hyper-V root partition. + let mut buf = [0; 64]; + let mut file = std::fs::File::open(path) .map_err(|err| host_error(format!("cannot read {path}: {err}")))?; - parse_hex_id(&text).ok_or_else(|| host_error(format!("{path} is malformed"))) + let len = loop { + match file.read(&mut buf) { + Ok(len) => break len, + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {} + Err(err) => return Err(host_error(format!("cannot read {path}: {err}"))), + } + }; + std::str::from_utf8(&buf[..len]) + .ok() + .and_then(parse_hex_id) + .ok_or_else(|| host_error(format!("{path} is malformed"))) } pub(super) fn host_identity() -> Result { From c71664cf569494fbe6ccde77d775aa7d6ce6654c Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 06:29:26 -0700 Subject: [PATCH 273/304] Guide: state that an OpenVMM build with a different effective CPUID needs recapture OpenVMM computes the effective CPUID, including its own leaves and the bits it owns, such as the cache topology. A build that computes it differently, as the L3 cache fix does, rejects earlier microVM snapshots with E_CPU_SURFACE. Say so in the snapshot guide's compatibility section and limitations, and drop --restore-entropy, which has no effect, from the CLI reference's restore example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 2 +- Guide/src/user_guide/openvmm/snapshots.md | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 58ee4f12158..fb9fccc40df 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -329,7 +329,7 @@ describes the source definitions. ```bash openvmm --machine microvm --hypervisor kvm \ - --restore-snapshot snapshot --restore-entropy + --restore-snapshot snapshot ``` * `--restore-ready-path `: Connect to an existing Unix domain socket on Linux or a `//./pipe/...` named pipe on Windows and write exactly diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 912833de6f2..d706795e15a 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -264,6 +264,9 @@ profile (`E_PROFILE_UNSUPPORTED`) and that VP 0 observes its effective CPUID (`E_CPU_SURFACE`). A snapshot records the profile and the effective CPUID. Restore requires the same profile, pinned in this OpenVMM with the same digest, a host of the profile's generation, and an identical effective CPUID. +OpenVMM computes the effective CPUID, including its own leaves and the bits +it owns, such as the cache topology, so an OpenVMM build that computes it +differently rejects earlier snapshots with `E_CPU_SURFACE`. Restore also requires: @@ -430,4 +433,6 @@ immediately with a clear error if any active device does not support it. and rejects overrides. Persisted microVM ABI and boot-layout value 2 are supported. - MicroVM restore requires the backend, CPU generation, and CPU profile of - the capture. Snapshots from manifest versions before 6 must be recaptured. + the capture, and an OpenVMM build that computes the same effective CPUID. + Snapshots from manifest versions before 6, or from a build that computes a + different effective CPUID, must be recaptured. From 0846686d9dea5e1b72e690c27300a63dec56a28b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 11:08:00 -0700 Subject: [PATCH 274/304] cpu_profile: name the Skylake-SP test hosts by their CPU The Hyper-V feature table's tests named the two bare-metal Skylake-SP hosts, whose processor is a Xeon Silver 4114, by host name. Label them by CPU, as the Ice Lake-SP (8370C) and Emerald Rapids (8573C) rows already are, and rename the WHP host's features constant to SKYLAKE_SP_WHP_HOST. Test code only: no profile, digest, or runtime behavior changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/cpu_profile/src/hv_banks.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/vmm_core/cpu_profile/src/hv_banks.rs b/vmm_core/cpu_profile/src/hv_banks.rs index 158903ca2f5..80c4762d625 100644 --- a/vmm_core/cpu_profile/src/hv_banks.rs +++ b/vmm_core/cpu_profile/src/hv_banks.rs @@ -661,7 +661,7 @@ mod tests { const HOSTS: [(&str, &str, HvFeatures); 6] = [ ( "intel.skylake-sp.v1", - "whp prometheus28", + "whp 4114", HvFeatures { banks: [0x1001_f9ff_e7f7_859f, 0x0000_000f_1086_0063], xsave: 0x3fff, @@ -685,7 +685,7 @@ mod tests { ), ( "intel.skylake-sp.v1", - "mshv prometheus30", + "mshv 4114", HvFeatures { banks: [0x1005_f9ff_fff7_859f, 0x0000_008f_1086_0063], xsave: 0x3fff, @@ -709,8 +709,8 @@ mod tests { ), ]; - /// The features prometheus28's WHP offers. - const PROMETHEUS28: HvFeatures = HOSTS[0].2; + /// The features WHP offers on the bare-metal Skylake-SP host. + const SKYLAKE_SP_WHP_HOST: HvFeatures = HOSTS[0].2; fn mask(word: HvFeatureWord, name: &str) -> u64 { HV_FEATURES @@ -839,7 +839,8 @@ mod tests { #[test] fn invariant_tsc_follows_the_host() { - let skylake = profile_features(profile("intel.skylake-sp.v1"), PROMETHEUS28).unwrap(); + let skylake = + profile_features(profile("intel.skylake-sp.v1"), SKYLAKE_SP_WHP_HOST).unwrap(); assert!(has(&skylake, HvFeatureWord::Bank1, "tsc_invariant_support")); // Azure's WHP offers no invariant TSC. The profile still pins it, and // the time ABI's CPUID supplies it. @@ -885,7 +886,7 @@ mod tests { #[test] fn a_missing_feature_is_unsupported() { - let mut available = PROMETHEUS28; + let mut available = SKYLAKE_SP_WHP_HOST; available.xsave &= !mask(HvFeatureWord::Xsave, "avx512_support"); available.banks[0] &= !mask(HvFeatureWord::Bank0, "ibrs_support"); let error = profile_features(profile("intel.skylake-sp.v1"), available).unwrap_err(); @@ -894,7 +895,8 @@ mod tests { assert!(error.message.contains("ibrs_support"), "{error}"); // A newer generation's profile on an older host. - let error = profile_features(profile("intel.emeraldrapids.v1"), PROMETHEUS28).unwrap_err(); + let error = + profile_features(profile("intel.emeraldrapids.v1"), SKYLAKE_SP_WHP_HOST).unwrap_err(); assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); } @@ -937,7 +939,7 @@ mod tests { // A missing feature clears its CPUID bit, and nothing else. let skylake = profile_entries(profile("intel.skylake-sp.v1")); - let mut available = PROMETHEUS28; + let mut available = SKYLAKE_SP_WHP_HOST; available.xsave &= !mask(HvFeatureWord::Xsave, "avx512_support"); available.banks[0] &= !mask(HvFeatureWord::Bank0, "ibpb_support"); let mut cpuid = skylake.clone(); From 44b6574e1203080b1a3886a96fa9f83d73d3eb2f Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 11:16:51 -0700 Subject: [PATCH 275/304] virt: describe test hosts generically in comments The repository is public, so comments name no internal hosts. Describe them by role and CPU instead: a bare-metal Skylake-SP host, and the 8370C and 8573C runners. Comments only; no code changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 2 +- vmm_core/virt_mshv/src/x86_64/profile_features.rs | 4 ++-- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 2 +- vmm_core/virt_mshv/src/x86_64/vp_state.rs | 2 +- vmm_core/virt_whp/src/apic.rs | 2 +- vmm_core/virt_whp/src/profile_features.rs | 2 +- vmm_core/virt_whp/src/time_abi.rs | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index 04a41359d61..32da04e92d2 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -1264,7 +1264,7 @@ mod tests { fn cycle_conversion_rounds_up() { assert_eq!(cycles_to_ns_ceil(0, 2_000_000_000), 0); assert_eq!(cycles_to_ns_ceil(1, 2_000_000_000), 1); - // One millisecond at the prometheus32 rate, and a hair over a + // One millisecond at the bare-metal KVM host's rate, and a hair over a // microsecond. assert_eq!(cycles_to_ns_ceil(2_194_804, 2_194_804_000), 1_000_000); assert_eq!(cycles_to_ns_ceil(2_195, 2_194_804_000), 1_001); diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index 60dca6fcafa..3bc221a98ec 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -184,7 +184,7 @@ mod tests { const MSR_ARCH_CAPABILITIES: u32 = 0x10a; /// Features that the host partition offered on the fleet's MSHV hosts - /// (CPU fingerprints): prometheus30 (Skylake-SP, bare metal) and the 8573C + /// (CPU fingerprints): the bare-metal Skylake-SP host and the 8573C /// runners (Emerald Rapids, nested). const PROMETHEUS30: HvFeatures = HvFeatures { banks: [0x1005_f9ff_fff7_859f, 0x0000_008f_1086_0063], @@ -274,7 +274,7 @@ mod tests { #[test] fn bits_the_profile_does_not_decide_keep_the_legacy_policy() { let features = time_abi_features(profile("intel.skylake-sp.v1"), PROMETHEUS30).unwrap(); - // prometheus30 offers the deprecated HLE and RTM bits, which no CPUID + // The bare-metal host offers the deprecated HLE and RTM bits, which no CPUID // feature maps and OpenVMM never listed: they stay off. for mask in [ Bank0::new().with_hle_support_deprecated(true).into_bits(), diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 37a67f2eb83..8e81ada93cc 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -332,7 +332,7 @@ impl MshvTimeAbi { Some(HostCpuidSource::Pending(receiver)) => { let received = receiver.recv(); // Freeing the channel can hand its memory back to the - // kernel, which took about 12 us here on prometheus30, + // kernel, which took about 12 us on a bare-metal host, // so the channel lives until the partition drops. *source = Some(HostCpuidSource::Pending(receiver)); match received { diff --git a/vmm_core/virt_mshv/src/x86_64/vp_state.rs b/vmm_core/virt_mshv/src/x86_64/vp_state.rs index de3c000538d..769f7a7d73f 100644 --- a/vmm_core/virt_mshv/src/x86_64/vp_state.rs +++ b/vmm_core/virt_mshv/src/x86_64/vp_state.rs @@ -136,7 +136,7 @@ impl MshvProcessor<'_> { /// VMM writes its IRR through the LAPIC state page, as a snapshot restore /// does, or the restore clock when it queues the vector of an expired /// one-shot timer. Such a VP stays halted until some other interrupt - /// arrives, and so do the timers it owns: on prometheus30, 2 s after a + /// arrives, and so do the timers it owns: on a bare-metal host, 2 s after a /// restore, VPs were still halted with the timer vector pending. Asserting /// a vector that is already pending through the interrupt path is /// idempotent, and wakes the VP when the vector is deliverable: the diff --git a/vmm_core/virt_whp/src/apic.rs b/vmm_core/virt_whp/src/apic.rs index 4d399d61eff..b217b0aab19 100644 --- a/vmm_core/virt_whp/src/apic.rs +++ b/vmm_core/virt_whp/src/apic.rs @@ -331,7 +331,7 @@ impl WhpProcessor<'_> { /// A snapshot restore writes saved IRR bits this way, and so does the /// restore clock when it queues the vector of a one-shot timer that /// expired during the downtime. The hypervisor does not always deliver - /// such a vector to a halted VP: on prometheus28, 2 s after every legacy + /// such a vector to a halted VP: on a bare-metal host, 2 s after every legacy /// 8-VP restore, a VP was still halted with the timer vector pending, and /// its timers stalled until some other interrupt woke it. Asserting a /// vector that is already pending is idempotent, and wakes the VP when diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index 7543a720ae3..c76f435999e 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -172,7 +172,7 @@ mod tests { use hvdef::HvX64PartitionProcessorFeatures as Bank0; /// Feature capabilities that WHP reported on the fleet's hosts (CPU - /// fingerprints): prometheus28 (Skylake-SP, bare metal) and the 8370C + /// fingerprints): the bare-metal Skylake-SP host and the 8370C /// runners (Ice Lake, nested). const PROMETHEUS28: WhpFeatures = WhpFeatures { banks: [0x1001_f9ff_e7f7_859f, 0x0000_000f_1086_0063], diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index accc25f702c..2912ad24040 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -2547,7 +2547,7 @@ mod whp_tests { /// checks too. The time ABI reserves the unlisted entries: a backend may /// return zeros or the hypervisor's own values there, and WHP serves its /// own value for anything not programmed. WHP reads zero there on - /// prometheus28, 8370C, and 8573C, which the fingerprint verification of + /// bare-metal, 8370C, and 8573C hosts, which the fingerprint verification of /// pass-through backends (`E_CPU_UNLISTED`) expects, so the backend /// programs no explicit zero results. #[test] From 53c4d1ae2f1db503f59470418197a03fa186c0af Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 2 Oct 2026 11:16:55 -0700 Subject: [PATCH 276/304] virt_mshv, virt_whp: name test host fixtures by CPU The repository is public, so test code names no internal hosts. Rename the bare-metal Skylake-SP feature fixtures to SKYLAKE_SP_MSHV_HOST and SKYLAKE_SP_WHP_HOST, as cpu_profile's hv_banks tests now name theirs. Test code only. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../virt_mshv/src/x86_64/profile_features.rs | 20 ++++++++++++------- vmm_core/virt_whp/src/profile_features.rs | 6 +++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index 3bc221a98ec..61f5872258f 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -186,7 +186,7 @@ mod tests { /// Features that the host partition offered on the fleet's MSHV hosts /// (CPU fingerprints): the bare-metal Skylake-SP host and the 8573C /// runners (Emerald Rapids, nested). - const PROMETHEUS30: HvFeatures = HvFeatures { + const SKYLAKE_SP_MSHV_HOST: HvFeatures = HvFeatures { banks: [0x1005_f9ff_fff7_859f, 0x0000_008f_1086_0063], xsave: 0x3fff, }; @@ -196,7 +196,7 @@ mod tests { }; const HOSTS: [(&str, HvFeatures); 2] = [ - ("intel.skylake-sp.v1", PROMETHEUS30), + ("intel.skylake-sp.v1", SKYLAKE_SP_MSHV_HOST), ("intel.emeraldrapids.v1", AZURE_8573C), ]; @@ -273,14 +273,18 @@ mod tests { #[test] fn bits_the_profile_does_not_decide_keep_the_legacy_policy() { - let features = time_abi_features(profile("intel.skylake-sp.v1"), PROMETHEUS30).unwrap(); + let features = + time_abi_features(profile("intel.skylake-sp.v1"), SKYLAKE_SP_MSHV_HOST).unwrap(); // The bare-metal host offers the deprecated HLE and RTM bits, which no CPUID // feature maps and OpenVMM never listed: they stay off. for mask in [ Bank0::new().with_hle_support_deprecated(true).into_bits(), Bank0::new().with_rtm_support_deprecated(true).into_bits(), ] { - assert!(has(&PROMETHEUS30, HvFeatureWord::Bank0, mask), "{mask:#x}"); + assert!( + has(&SKYLAKE_SP_MSHV_HOST, HvFeatureWord::Bank0, mask), + "{mask:#x}" + ); assert!(!has(&features, HvFeatureWord::Bank0, mask), "{mask:#x}"); } // Unrestricted guest support is listed and offered: it stays on. @@ -294,20 +298,22 @@ mod tests { fn skylake_presents_no_arch_capabilities() { // The Skylake-SP profile pins every bank-derived bit clear, including // RDCL_NO, IBRS_ALL, and TSX_CTRL, which OpenVMM's lists enable. - let features = time_abi_features(profile("intel.skylake-sp.v1"), PROMETHEUS30).unwrap(); + let features = + time_abi_features(profile("intel.skylake-sp.v1"), SKYLAKE_SP_MSHV_HOST).unwrap(); assert_eq!(hv_banks::arch_capabilities_from_banks(features.banks), 0); } #[test] fn a_missing_feature_is_unsupported() { - let mut host = PROMETHEUS30; + let mut host = SKYLAKE_SP_MSHV_HOST; host.xsave &= !XsaveBank::new().with_avx512_support(true).into_bits(); host.banks[0] &= !Bank0::new().with_ibrs_support(true).into_bits(); let error = time_abi_features(profile("intel.skylake-sp.v1"), host).unwrap_err(); assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); assert!(error.message.contains("avx512_support"), "{error}"); assert!(error.message.contains("ibrs_support"), "{error}"); - let error = time_abi_features(profile("intel.emeraldrapids.v1"), PROMETHEUS30).unwrap_err(); + let error = + time_abi_features(profile("intel.emeraldrapids.v1"), SKYLAKE_SP_MSHV_HOST).unwrap_err(); assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); } diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index c76f435999e..6130219d500 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -174,7 +174,7 @@ mod tests { /// Feature capabilities that WHP reported on the fleet's hosts (CPU /// fingerprints): the bare-metal Skylake-SP host and the 8370C /// runners (Ice Lake, nested). - const PROMETHEUS28: WhpFeatures = WhpFeatures { + const SKYLAKE_SP_WHP_HOST: WhpFeatures = WhpFeatures { banks: [0x1001_f9ff_e7f7_859f, 0x0000_000f_1086_0063], xsave: 0x3fff, }; @@ -186,9 +186,9 @@ mod tests { #[test] fn profiles_derive_and_shortfalls_are_unsupported() { let skylake = cpu_profile::pinned("intel.skylake-sp.v1").unwrap(); - profile_features(skylake, PROMETHEUS28).unwrap(); + profile_features(skylake, SKYLAKE_SP_WHP_HOST).unwrap(); let emerald = cpu_profile::pinned("intel.emeraldrapids.v1").unwrap(); - let error = profile_features(emerald, PROMETHEUS28).unwrap_err(); + let error = profile_features(emerald, SKYLAKE_SP_WHP_HOST).unwrap_err(); assert_eq!(error.code, TimeAbiCode::ProfileUnsupported); assert!(error.message.contains("avx512"), "{error}"); } From c61f60f14d25c37439eeab0328176480601960bd Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 3 Oct 2026 03:58:42 -0700 Subject: [PATCH 277/304] virt_support_x86emu: make the tests' vector and VTL comparisons explicit The time ABI's cpu_profile crate brings serde_json into virt's dependency graph, and serde_json implements PartialEq for the integer types. Three assertions in the virt_tests target compared a u16 or u8 with a bare .into(), whose target type was inferred from the only PartialEq impl before and is now ambiguous (E0283), so the target no longer compiled. Convert explicitly with u16::from and u8::from, the conversions that inference chose before. No behavior changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vmm_core/virt_support_x86emu/tests/tests/checkvtlaccess.rs | 2 +- vmm_core/virt_support_x86emu/tests/tests/common.rs | 2 +- vmm_core/virt_support_x86emu/tests/tests/translate.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/vmm_core/virt_support_x86emu/tests/tests/checkvtlaccess.rs b/vmm_core/virt_support_x86emu/tests/tests/checkvtlaccess.rs index 51004c6af8f..09c29beb184 100644 --- a/vmm_core/virt_support_x86emu/tests/tests/checkvtlaccess.rs +++ b/vmm_core/virt_support_x86emu/tests/tests/checkvtlaccess.rs @@ -240,7 +240,7 @@ fn validate_vtl_access_event( hvdef::HV_X64_PENDING_EVENT_MEMORY_INTERCEPT ); - assert_eq!(event.target_vtl, expected_vtl.into()); + assert_eq!(event.target_vtl, u8::from(expected_vtl)); if check_execute { assert_eq!(event.access_type, hvdef::HvInterceptAccessType::EXECUTE); diff --git a/vmm_core/virt_support_x86emu/tests/tests/common.rs b/vmm_core/virt_support_x86emu/tests/tests/common.rs index 47925767030..3b0fc919974 100644 --- a/vmm_core/virt_support_x86emu/tests/tests/common.rs +++ b/vmm_core/virt_support_x86emu/tests/tests/common.rs @@ -97,7 +97,7 @@ pub fn validate_gpf_event(pending_event: hvdef::HvX64PendingEvent) { assert_eq!( event.vector(), - x86defs::Exception::GENERAL_PROTECTION_FAULT.0.into() + u16::from(x86defs::Exception::GENERAL_PROTECTION_FAULT.0) ); assert!(event.deliver_error_code()); diff --git a/vmm_core/virt_support_x86emu/tests/tests/translate.rs b/vmm_core/virt_support_x86emu/tests/tests/translate.rs index 850cae4b38a..d212929c761 100644 --- a/vmm_core/virt_support_x86emu/tests/tests/translate.rs +++ b/vmm_core/virt_support_x86emu/tests/tests/translate.rs @@ -433,7 +433,7 @@ async fn translate_gva_page_faults() { assert_eq!( injected_event.vector(), - x86defs::Exception::PAGE_FAULT.0.into(), + u16::from(x86defs::Exception::PAGE_FAULT.0), "expected page fault vector type for {:?}", c ); From 974f356521829b96798ee6ac30cc5d1f0e1bb87b Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Sat, 3 Oct 2026 06:19:03 -0700 Subject: [PATCH 278/304] kvm: pad the MSR filter bitmaps and drop the generic vCPU enable_cap KVM copies BITS_TO_LONGS(nmsrs) * sizeof(long) bytes of each MSR filter range's bitmap (kvm_add_msr_filter), but MsrFilterRange holds nmsrs.div_ceil(8) bytes. For the time ABI's ranges of one to four MSRs, the kernel read seven bytes past each one-byte bitmap. set_msr_filter now gives KVM zero-padded copies in whole 64-bit words, which keep every bit where KVM tests it, and checks KVM's size limit on the padded size. A unit test checks the padding and the bits. Vp::enable_cap passed any capability and arguments to KVM_ENABLE_CAP from safe code, but some vCPU capabilities take a pointer in args[0] that KVM writes through. Replace it with enable_enforce_pv_feature_cpuid, its only use. Also correct the SAFETY comment on a test's union read in virt_mshv, and free the page that a virt_whp test maps with a guard that is dropped after the partition, so that a failing test no longer leaks it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vm/kvm/src/lib.rs | 98 ++++++++++++++++--- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 6 +- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 3 +- vmm_core/virt_whp/src/profile_features.rs | 47 ++++++--- 4 files changed, 122 insertions(+), 32 deletions(-) diff --git a/vm/kvm/src/lib.rs b/vm/kvm/src/lib.rs index c61709ce951..461c8358f0c 100644 --- a/vm/kvm/src/lib.rs +++ b/vm/kvm/src/lib.rs @@ -795,7 +795,7 @@ impl MsrFilterRange { return Err(Error::InvalidMsrFilter("range applies to no access")); } if self.bitmap.len() != self.nmsrs.div_ceil(8) as usize - || self.bitmap.len() > KVM_MSR_FILTER_MAX_BITMAP_SIZE as usize + || self.kvm_bitmap_words() * 8 > KVM_MSR_FILTER_MAX_BITMAP_SIZE as usize { return Err(Error::InvalidMsrFilter( "bitmap size does not match the range", @@ -803,6 +803,25 @@ impl MsrFilterRange { } Ok(()) } + + /// The number of 64-bit words of the bitmap that KVM copies, + /// `BITS_TO_LONGS(nmsrs)`. + fn kvm_bitmap_words(&self) -> usize { + self.nmsrs.div_ceil(64) as usize + } + + /// Returns the bitmap as KVM reads it: [`Self::kvm_bitmap_words`] words + /// that hold the bytes of `bitmap` in order, little-endian, followed by + /// zero padding. Bit `i` stays the bit for MSR `base + i`. + fn kvm_bitmap(&self) -> Vec { + let mut words = vec![0; self.kvm_bitmap_words()]; + for (word, bytes) in words.iter_mut().zip(self.bitmap.chunks(8)) { + let mut le = [0; 8]; + le[..bytes.len()].copy_from_slice(bytes); + *word = u64::from_le_bytes(le); + } + words + } } #[derive(Debug)] @@ -1067,19 +1086,27 @@ impl Partition { }, ..Default::default() }; - for (slot, range) in filter.ranges.iter_mut().zip(ranges) { - range.validate()?; + // KVM copies each bitmap in whole `long`s, so it gets padded copies. + let bitmaps = ranges + .iter() + .map(|range| { + range.validate()?; + Ok(range.kvm_bitmap()) + }) + .collect::>>()?; + for ((slot, range), bitmap) in filter.ranges.iter_mut().zip(ranges).zip(&bitmaps) { *slot = kvm_msr_filter_range { flags: if range.read { KVM_MSR_FILTER_READ } else { 0 } | if range.write { KVM_MSR_FILTER_WRITE } else { 0 }, nmsrs: range.nmsrs, base: range.base, // KVM only reads the bitmap, and copies it during the ioctl. - bitmap: range.bitmap.as_ptr().cast_mut(), + bitmap: bitmap.as_ptr().cast::().cast_mut(), }; } - // SAFETY: `filter` and the bitmaps it points to, which `ranges` owns, - // are valid for the duration of the ioctl. + // SAFETY: `filter` and `bitmaps` are valid for the duration of the + // ioctl. KVM copies `BITS_TO_LONGS(nmsrs) * sizeof(long)` bytes of + // each range's bitmap, which is the size of its padded copy. unsafe { ioctl::kvm_x86_set_msr_filter(self.vm.as_raw_fd(), &filter) .map_err(Error::SetMsrFilter)?; @@ -1586,20 +1613,24 @@ impl<'a> Processor<'a> { unsafe { ioctl::kvm_has_device_attr(self.get().vcpu.as_raw_fd(), &attr) }.is_ok() } - /// Enables the vCPU capability `cap` with `args` (`KVM_ENABLE_CAP` on the - /// vCPU). `name` identifies the capability in errors. - pub fn enable_cap(&self, name: &'static str, cap: u32, args: [u64; 4]) -> Result<()> { - // SAFETY: Calling IOCTL as documented, with no special requirements. + /// Makes KVM honor the guest's KVM paravirtual feature leaf (`0x40000001`), + /// so that the paravirtual features it doesn't advertise, such as KVM's + /// paravirtual MSRs, are unavailable to the guest + /// (`KVM_CAP_ENFORCE_PV_FEATURE_CPUID` on the vCPU). + #[cfg(target_arch = "x86_64")] + pub fn enable_enforce_pv_feature_cpuid(&self) -> Result<()> { + // SAFETY: Calling IOCTL as documented. The capability's only argument + // is a flag, which KVM does not dereference. unsafe { ioctl::kvm_enable_cap( self.get().vcpu.as_raw_fd(), &kvm_enable_cap { - cap, - args, + cap: KVM_CAP_ENFORCE_PV_FEATURE_CPUID, + args: [1, 0, 0, 0], ..Default::default() }, ) - .map_err(|err| Error::EnableCap(name, err))?; + .map_err(|err| Error::EnableCap("enforce_pv_feature_cpuid", err))?; } Ok(()) } @@ -2612,6 +2643,47 @@ mod tests { single.validate().unwrap(); } + #[test] + fn msr_filter_bitmap_is_padded_to_whole_words() { + let max = KVM_MSR_FILTER_MAX_BITMAP_SIZE * 8; + for (nmsrs, words) in [ + (1, 1), + (8, 1), + (63, 1), + (64, 1), + (65, 2), + (256, 4), + (max, max as usize / 64), + ] { + let mut range = MsrFilterRange::deny(0x100..=0x100 + nmsrs - 1); + for (i, byte) in range.bitmap.iter_mut().enumerate() { + *byte = (i as u8).wrapping_mul(37) ^ 0xa5; + } + range.validate().unwrap(); + let padded = range.kvm_bitmap(); + // KVM copies BITS_TO_LONGS(nmsrs) * sizeof(long) bytes. + assert_eq!(padded.len(), words, "{nmsrs} MSRs"); + let bytes: Vec = padded.iter().flat_map(|word| word.to_le_bytes()).collect(); + assert_eq!( + bytes[..range.bitmap.len()], + range.bitmap[..], + "{nmsrs} MSRs" + ); + assert!( + bytes[range.bitmap.len()..].iter().all(|&byte| byte == 0), + "{nmsrs} MSRs" + ); + // KVM tests bit `i` of the `long` array for MSR `base + i`. + for i in 0..nmsrs as usize { + assert_eq!( + u64::from((range.bitmap[i / 8] >> (i % 8)) & 1), + (padded[i / 64] >> (i % 64)) & 1, + "{nmsrs} MSRs, bit {i}" + ); + } + } + } + #[test] fn msr_filter_rejects_malformed_ranges() { let valid = MsrFilterRange::deny(0x10..=0x1f); diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index 32da04e92d2..48d80108221 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -415,11 +415,7 @@ impl KvmTimeAbi { ) -> Result { for &vcpu in vcpus { vm.vp(vcpu) - .enable_cap( - "enforce_pv_feature_cpuid", - kvm::KVM_CAP_ENFORCE_PV_FEATURE_CPUID, - [1, 0, 0, 0], - ) + .enable_enforce_pv_feature_cpuid() .map_err(|err| { routing_error(format!( "cannot hide KVM's paravirtual MSRs on vCPU {vcpu}: {err:#}" diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index 8e81ada93cc..af43ecb524a 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -1354,7 +1354,8 @@ mod tests { intercept.intercept_type, mshv_bindings::hv_intercept_type_HV_INTERCEPT_TYPE_X64_MSR_INDEX ); - // SAFETY: every view of this C union is a plain integer. + // SAFETY: `msr_index_intercept` writes the union through + // `as_uint64`, so all eight bytes are initialized. let parameter = unsafe { intercept.intercept_parameter.as_uint64 }; assert_eq!(parameter, u64::from(msr)); } diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index 6130219d500..b71858745b7 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -734,6 +734,31 @@ mod whp_tests { } } + /// A zeroed 4 KiB page that a test maps into a partition, freed on drop. + /// Declare it before the partition, so that it outlives the partition's + /// mapping of it, also when the test panics. + struct TestPage { + ptr: *mut u8, + layout: std::alloc::Layout, + } + + impl TestPage { + fn new() -> Self { + let layout = std::alloc::Layout::from_size_align(4096, 4096).unwrap(); + // SAFETY: the layout has a nonzero size. + let ptr = unsafe { std::alloc::alloc_zeroed(layout) }; + assert!(!ptr.is_null()); + Self { ptr, layout } + } + } + + impl Drop for TestPage { + fn drop(&mut self) { + // SAFETY: `ptr` was allocated with `layout`. + unsafe { std::alloc::dealloc(self.ptr, self.layout) }; + } + } + /// Runs `cpuid; hlt` in real mode at the reset vector and checks how /// `CpuidResultList2` reaches the guest: a leaf that also exits reports /// the programmed result as the exit's default result, which OpenVMM's @@ -751,6 +776,12 @@ mod whp_tests { result2(EXIT_LEAF, None, None, exit_value, [!0; 4]), result2(PLAIN_LEAF, None, None, plain_value, [!0; 4]), ]; + // At 0xffff0: cpuid; hlt; jmp back to the cpuid. Declared before the + // partition, so that it is freed after the partition is dropped. + let page = TestPage::new(); + let code = [0x0f, 0xa2, 0xf4, 0xeb, 0xfb]; + // SAFETY: the code fits in the page at offset 0xff0. + unsafe { std::ptr::copy_nonoverlapping(code.as_ptr(), page.ptr.add(0xff0), code.len()) }; let mut config = whp::PartitionConfig::new().unwrap(); config .set_property(whp::PartitionProperty::ProcessorCount(1)) @@ -769,21 +800,14 @@ mod whp_tests { let partition = config.create().unwrap(); partition.create_vp(0).create().unwrap(); - // At 0xffff0: cpuid; hlt; jmp back to the cpuid. - let layout = std::alloc::Layout::from_size_align(4096, 4096).unwrap(); - // SAFETY: the layout has a nonzero size. - let page = unsafe { std::alloc::alloc_zeroed(layout) }; - assert!(!page.is_null()); - let code = [0x0f, 0xa2, 0xf4, 0xeb, 0xfb]; - // SAFETY: `page` is a fresh allocation of 4096 bytes. - unsafe { std::ptr::copy_nonoverlapping(code.as_ptr(), page.add(0xff0), code.len()) }; let rwx = whp::abi::WHV_MAP_GPA_RANGE_FLAGS( whp::abi::WHvMapGpaRangeFlagRead.0 | whp::abi::WHvMapGpaRangeFlagWrite.0 | whp::abi::WHvMapGpaRangeFlagExecute.0, ); - // SAFETY: `page` stays allocated until after the partition is dropped. - unsafe { partition.map_range(None, page, 4096, 0xff000, rwx) }.unwrap(); + // SAFETY: `page` is dropped after the partition, so its memory is not + // reused while the partition maps it. + unsafe { partition.map_range(None, page.ptr, 4096, 0xff000, rwx) }.unwrap(); let vp = partition.vp(0); let registers = |vp: &whp::Processor<'_>| { @@ -846,9 +870,6 @@ mod whp_tests { let plain = registers(&vp); println!("plain leaf {PLAIN_LEAF:#x}: guest result {plain:08x?}"); - drop(partition); - // SAFETY: the partition that mapped the page is gone. - unsafe { std::alloc::dealloc(page, layout) }; assert_eq!(default, exit_value, "the exit's default result"); assert_eq!(plain, plain_value, "the guest's result"); } From 8c01aabc1dc0af8e2021d94884b34a43d92bceda Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 09:33:18 -0700 Subject: [PATCH 279/304] lxutil: switch filesystem credentials per thread on Linux The microVM virtio-fs server must perform each guest request as the guest's caller (microsoft/nvx#297). Linux keeps credentials per thread, but the C library's setgroups and set*id wrappers change every thread in the process, so a worker thread can't use them to act as another identity without affecting the rest of OpenVMM. Add `with_fs_identity`, which runs one operation with the calling thread's filesystem identity set to a given UID and GID. It issues the raw system calls, which change only the calling thread: - setfsgid and setfsuid set the identity. Neither reports failure, so the identity that is actually in effect is read back. - setgroups clears the supplementary groups unless the only one is the new GID, so that the operation gains no access through the groups of the original identity. - capset clears the effective capabilities, so that even an OpenVMM running as root can't bypass permission checks, change ownership, or create device nodes during the operation. Assuming another identity, or dropping supplementary groups, requires CAP_SETUID and CAP_SETGID. If any step fails, the operation doesn't run and the call returns EPERM; it never falls back to the original identity. A guard then restores the capabilities, identity, and groups on the same thread, and panics if it can't, because the thread would otherwise go on to run unrelated work as the wrong identity. Unprivileged unit tests cover keeping the current identity, a foreign identity and other supplementary groups failing closed, and invalid IDs. Two ignored tests need CAP_SETUID and CAP_SETGID: one checks that only the switched thread changes identity, that the files it creates belong to that identity, and that the host enforces permissions for it; the other checks that keeping the current identity drops the other groups. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- vm/devices/support/fs/lxutil/src/lib.rs | 4 + .../support/fs/lxutil/src/unix/credentials.rs | 538 ++++++++++++++++++ vm/devices/support/fs/lxutil/src/unix/mod.rs | 1 + 3 files changed, 543 insertions(+) create mode 100644 vm/devices/support/fs/lxutil/src/unix/credentials.rs diff --git a/vm/devices/support/fs/lxutil/src/lib.rs b/vm/devices/support/fs/lxutil/src/lib.rs index c61a7dfd686..7b48c0d6353 100644 --- a/vm/devices/support/fs/lxutil/src/lib.rs +++ b/vm/devices/support/fs/lxutil/src/lib.rs @@ -25,6 +25,10 @@ use windows as sys; pub use path::PathBufExt; pub use path::PathExt; +#[cfg(unix)] +pub use unix::credentials::FsIdentity; +#[cfg(unix)] +pub use unix::credentials::with_fs_identity; /// A platform-independent abstraction that allows you to treat an area of the file system as if /// it has Unix semantics. diff --git a/vm/devices/support/fs/lxutil/src/unix/credentials.rs b/vm/devices/support/fs/lxutil/src/unix/credentials.rs new file mode 100644 index 00000000000..3cd9cee0d94 --- /dev/null +++ b/vm/devices/support/fs/lxutil/src/unix/credentials.rs @@ -0,0 +1,538 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Per-thread filesystem credentials. +//! +//! Linux keeps credentials per thread. The raw `setfsuid`, `setfsgid`, `setgroups`, and `capset` +//! system calls change only the calling thread, whereas the C library wrappers for `setgroups` and +//! the `set*id` family change every thread in the process. This module therefore issues the raw +//! system calls, so that one worker thread can perform an operation as another identity without +//! affecting the rest of the process. + +use super::util; + +/// The ID that no user or group has. `setfsuid` and `setfsgid` report the current ID without +/// changing it when passed this value. +const INVALID_ID: u32 = u32::MAX; + +/// `_LINUX_CAPABILITY_VERSION_3` from `linux/capability.h`. +const CAPABILITY_VERSION_3: u32 = 0x2008_0522; + +/// `struct __user_cap_header_struct` from `linux/capability.h`. +#[repr(C)] +struct CapabilityHeader { + version: u32, + pid: libc::c_int, +} + +/// `struct __user_cap_data_struct` from `linux/capability.h`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct CapabilityData { + effective: u32, + permitted: u32, + inheritable: u32, +} + +/// The 64 capability bits of version 3, in two 32-bit words. +type Capabilities = [CapabilityData; 2]; + +/// A host filesystem identity. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FsIdentity { + /// The filesystem user ID. + pub uid: lx::uid_t, + /// The filesystem group ID. + pub gid: lx::gid_t, +} + +impl FsIdentity { + /// Returns the filesystem identity of the calling thread. + pub fn current() -> Self { + Self { + uid: set_fsuid(INVALID_ID), + gid: set_fsgid(INVALID_ID), + } + } +} + +/// Runs `operation` with the calling thread's filesystem identity set to `identity`, and then +/// restores the thread's credentials. +/// +/// The operation runs without effective capabilities and without supplementary groups other +/// than the GID of `identity`, so that it gains no access through the privileges or groups of +/// the thread's original identity. Assuming a different identity, or dropping supplementary +/// groups, requires `CAP_SETUID` and `CAP_SETGID`. A thread that already has `identity` and no +/// other supplementary groups needs no privilege. +/// +/// # Errors +/// +/// Returns `EPERM` without running `operation` if the thread cannot assume `identity` or drop +/// its other supplementary groups. The operation never falls back to the thread's original +/// identity. +/// +/// # Panics +/// +/// Panics if the thread's original credentials cannot be restored, because the thread would +/// otherwise run unrelated work under the wrong identity. +pub fn with_fs_identity(identity: FsIdentity, operation: impl FnOnce() -> T) -> lx::Result { + let credentials = SwitchedCredentials::switch(identity).map_err(|_| lx::Error::EPERM)?; + let result = operation(); + drop(credentials); + Ok(result) +} + +/// The original credentials of a thread whose filesystem identity was switched. +/// +/// Dropping the value restores them on the same thread. It is created and dropped within +/// [`with_fs_identity`], so it never moves to another thread. +struct SwitchedCredentials { + original: FsIdentity, + capabilities: Capabilities, + /// The supplementary groups, if they were cleared. + groups: Option>, + /// Whether the filesystem UID and GID may have changed. + switched_identity: bool, + /// Whether the effective capabilities were cleared. + cleared_capabilities: bool, +} + +impl SwitchedCredentials { + fn switch(identity: FsIdentity) -> lx::Result { + if identity.uid == INVALID_ID || identity.gid == INVALID_ID { + return Err(lx::Error::EPERM); + } + let mut credentials = Self { + original: FsIdentity::current(), + capabilities: capabilities()?, + groups: None, + switched_identity: false, + cleared_capabilities: false, + }; + // On an error below, dropping `credentials` restores whatever was already changed. + let groups = groups()?; + if groups.iter().any(|&group| group != identity.gid) { + set_groups(&[])?; + credentials.groups = Some(groups); + } + if identity != credentials.original { + credentials.switched_identity = true; + set_fsgid(identity.gid); + set_fsuid(identity.uid); + // Neither call reports failure, so read back the identity actually in effect. + if FsIdentity::current() != identity { + return Err(lx::Error::EPERM); + } + } + if credentials + .capabilities + .iter() + .any(|data| data.effective != 0) + { + let cleared = credentials.capabilities.map(|data| CapabilityData { + effective: 0, + ..data + }); + set_capabilities(&cleared)?; + credentials.cleared_capabilities = true; + } + Ok(credentials) + } +} + +impl Drop for SwitchedCredentials { + fn drop(&mut self) { + // Restore the capabilities first, because resetting the groups and the identity may need + // CAP_SETGID and CAP_SETUID. + if self.cleared_capabilities || self.switched_identity || self.groups.is_some() { + set_capabilities(&self.capabilities) + .expect("failed to restore the thread's effective capabilities"); + } + if self.switched_identity { + set_fsgid(self.original.gid); + set_fsuid(self.original.uid); + assert_eq!( + FsIdentity::current(), + self.original, + "failed to restore the thread's filesystem identity" + ); + } + if let Some(groups) = &self.groups { + set_groups(groups).expect("failed to restore the thread's supplementary groups"); + } + if self.switched_identity { + // Changing the filesystem UID to or from 0 adjusts the effective capabilities. + set_capabilities(&self.capabilities) + .expect("failed to restore the thread's effective capabilities"); + } + } +} + +/// Sets the calling thread's filesystem UID and returns the previous one. +fn set_fsuid(uid: lx::uid_t) -> lx::uid_t { + // SAFETY: setfsuid takes an integer and changes only the calling thread's credentials. + let previous = unsafe { libc::syscall(libc::SYS_setfsuid, libc::c_long::from(uid)) }; + previous as lx::uid_t +} + +/// Sets the calling thread's filesystem GID and returns the previous one. +fn set_fsgid(gid: lx::gid_t) -> lx::gid_t { + // SAFETY: setfsgid takes an integer and changes only the calling thread's credentials. + let previous = unsafe { libc::syscall(libc::SYS_setfsgid, libc::c_long::from(gid)) }; + previous as lx::gid_t +} + +/// Returns the calling thread's supplementary groups. +fn groups() -> lx::Result> { + // SAFETY: A zero size only queries the number of groups. + let count = util::check_lx_errno(unsafe { libc::getgroups(0, std::ptr::null_mut()) })?; + let mut groups = vec![0; count as usize]; + // SAFETY: `groups` has room for `count` IDs. + let count = util::check_lx_errno(unsafe { libc::getgroups(count, groups.as_mut_ptr()) })?; + groups.truncate(count as usize); + Ok(groups) +} + +/// Sets the calling thread's supplementary groups. +fn set_groups(groups: &[lx::gid_t]) -> lx::Result<()> { + let count = libc::c_long::try_from(groups.len()).map_err(|_| lx::Error::EINVAL)?; + // SAFETY: `groups` holds `count` IDs. The raw system call changes only the calling thread. + util::check_lx_errno(unsafe { libc::syscall(libc::SYS_setgroups, count, groups.as_ptr()) })?; + Ok(()) +} + +/// Returns the calling thread's capabilities. +fn capabilities() -> lx::Result { + let mut header = CapabilityHeader { + version: CAPABILITY_VERSION_3, + pid: 0, + }; + let mut data = Capabilities::default(); + // SAFETY: The header and both data structures are valid for writes. + util::check_lx_errno(unsafe { + libc::syscall(libc::SYS_capget, &raw mut header, data.as_mut_ptr()) + })?; + Ok(data) +} + +/// Sets the calling thread's capabilities. +fn set_capabilities(data: &Capabilities) -> lx::Result<()> { + let mut header = CapabilityHeader { + version: CAPABILITY_VERSION_3, + pid: 0, + }; + // SAFETY: The header and both data structures are valid. A zero PID selects the calling + // thread, the only one whose capabilities capset changes. + util::check_lx_errno(unsafe { + libc::syscall(libc::SYS_capset, &raw mut header, data.as_ptr()) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + use std::sync::atomic::AtomicBool; + use std::sync::atomic::Ordering; + + const CAP_SETGID: u32 = 6; + const CAP_SETUID: u32 = 7; + + fn has_capability(capability: u32) -> bool { + let data = capabilities().unwrap(); + data[(capability / 32) as usize].effective & (1 << (capability % 32)) != 0 + } + + /// Returns an identity that differs from every ID of the calling thread. + fn foreign_identity() -> FsIdentity { + // SAFETY: These calls have no preconditions. + let ids = unsafe { + [ + libc::getuid(), + libc::geteuid(), + libc::getgid(), + libc::getegid(), + ] + }; + let current = FsIdentity::current(); + let unused = |mut candidate: u32| { + while ids.contains(&candidate) || candidate == current.uid || candidate == current.gid { + candidate += 1; + } + candidate + }; + FsIdentity { + uid: unused(4242), + gid: unused(4343), + } + } + + fn assert_restored(original: FsIdentity, groups_before: &[u32], before: &Capabilities) { + assert_eq!(FsIdentity::current(), original); + assert_eq!(groups().unwrap(), groups_before); + assert_eq!(&capabilities().unwrap(), before); + } + + /// Returns whether the calling thread has supplementary groups other than `gid`. + fn has_other_groups(gid: u32) -> bool { + groups().unwrap().iter().any(|&group| group != gid) + } + + /// Runs `test` on a new thread that has no effective capabilities, after giving that thread + /// exactly the supplementary `groups` if it may. + fn without_capabilities(groups: Vec, test: impl FnOnce() + Send + 'static) { + std::thread::spawn(move || { + if has_capability(CAP_SETGID) { + set_groups(&groups).unwrap(); + } + let mut data = capabilities().unwrap(); + for word in &mut data { + word.effective = 0; + } + set_capabilities(&data).unwrap(); + test(); + }) + .join() + .unwrap(); + } + + #[test] + fn current_identity_is_the_effective_identity() { + // SAFETY: These calls have no preconditions. + let (uid, gid) = unsafe { (libc::geteuid(), libc::getegid()) }; + assert_eq!(FsIdentity::current(), FsIdentity { uid, gid }); + } + + #[test] + fn same_identity_without_other_groups_needs_no_privilege() { + without_capabilities(Vec::new(), || { + let original = FsIdentity::current(); + if has_other_groups(original.gid) { + // An unprivileged thread cannot drop the groups that it was given. + return; + } + let directory = tempfile::tempdir().unwrap(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let path = directory.path().join("file"); + let (identity, groups_inside, inside) = with_fs_identity(original, || { + std::fs::write(&path, b"data").unwrap(); + ( + FsIdentity::current(), + groups().unwrap(), + capabilities().unwrap(), + ) + }) + .unwrap(); + + assert_eq!(identity, original); + assert_eq!(groups_inside, groups_before); + assert!(inside.iter().all(|data| data.effective == 0)); + let metadata = std::fs::metadata(&path).unwrap(); + assert_eq!(metadata.uid(), original.uid); + assert_restored(original, &groups_before, &before); + }); + } + + #[test] + fn other_groups_fail_closed_without_privilege() { + // A group that the thread is given only if the test process is privileged. + const OTHER_GROUP: u32 = 4444; + // SAFETY: getegid has no preconditions. + let gid = unsafe { libc::getegid() }; + without_capabilities(vec![gid, OTHER_GROUP], || { + let original = FsIdentity::current(); + if !has_other_groups(original.gid) { + // An unprivileged thread without other groups cannot be given one. + return; + } + let directory = tempfile::tempdir().unwrap(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let ran = AtomicBool::new(false); + let path = directory.path().join("file"); + let result = with_fs_identity(original, || { + ran.store(true, Ordering::Relaxed); + std::fs::write(&path, b"data") + }); + + assert_eq!(result.unwrap_err(), lx::Error::EPERM); + assert!(!ran.load(Ordering::Relaxed)); + assert!(!path.exists()); + assert_restored(original, &groups_before, &before); + }); + } + + #[test] + fn foreign_identity_fails_closed_without_privilege() { + // Capabilities are per thread, so drop them on a dedicated thread. The test then behaves + // the same whether or not the test process is privileged. + std::thread::spawn(|| { + let mut data = capabilities().unwrap(); + for word in &mut data { + word.effective = 0; + } + set_capabilities(&data).unwrap(); + + let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o777)) + .unwrap(); + let original = FsIdentity::current(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let ran = AtomicBool::new(false); + let path = directory.path().join("file"); + let result = with_fs_identity(foreign_identity(), || { + ran.store(true, Ordering::Relaxed); + std::fs::write(&path, b"data") + }); + + assert_eq!(result.unwrap_err(), lx::Error::EPERM); + assert!(!ran.load(Ordering::Relaxed)); + assert!(!path.exists()); + assert_restored(original, &groups_before, &before); + }) + .join() + .unwrap(); + } + + #[test] + fn invalid_identity_fails_closed() { + let original = FsIdentity::current(); + for identity in [ + FsIdentity { + uid: INVALID_ID, + gid: original.gid, + }, + FsIdentity { + uid: original.uid, + gid: INVALID_ID, + }, + ] { + let ran = AtomicBool::new(false); + let result = with_fs_identity(identity, || ran.store(true, Ordering::Relaxed)); + assert_eq!(result.unwrap_err(), lx::Error::EPERM); + assert!(!ran.load(Ordering::Relaxed)); + assert_eq!(FsIdentity::current(), original); + } + } + + /// Run with `CAP_SETUID` and `CAP_SETGID`, for example as root. + #[test] + #[ignore = "requires CAP_SETUID and CAP_SETGID"] + fn foreign_identity_switches_one_thread_with_privilege() { + assert!( + has_capability(CAP_SETUID) && has_capability(CAP_SETGID), + "this test requires CAP_SETUID and CAP_SETGID" + ); + let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + let original = FsIdentity::current(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let identity = foreign_identity(); + + // Another thread keeps its identity while this one is switched. + let (request, requests) = std::sync::mpsc::channel::<()>(); + let (reply, replies) = std::sync::mpsc::channel(); + let observer = std::thread::spawn(move || { + for () in requests { + reply.send(FsIdentity::current()).unwrap(); + } + }); + + let (inside, groups_inside, capabilities_inside, other_thread) = + with_fs_identity(identity, || { + std::fs::write(directory.path().join("file"), b"data").unwrap(); + std::fs::create_dir(directory.path().join("directory")).unwrap(); + std::fs::write(directory.path().join("directory/nested"), b"data").unwrap(); + request.send(()).unwrap(); + ( + FsIdentity::current(), + groups().unwrap(), + capabilities().unwrap(), + replies.recv().unwrap(), + ) + }) + .unwrap(); + drop(request); + observer.join().unwrap(); + + assert_eq!(inside, identity); + assert!(groups_inside.is_empty()); + assert!(capabilities_inside.iter().all(|data| data.effective == 0)); + assert_eq!(other_thread, original); + for name in ["file", "directory", "directory/nested"] { + let metadata = std::fs::symlink_metadata(directory.path().join(name)).unwrap(); + assert_eq!( + (metadata.uid(), metadata.gid()), + (identity.uid, identity.gid), + "{name}" + ); + } + assert_restored(original, &groups_before, &before); + + // The switched identity is subject to ordinary permission checks. + let private = tempfile::tempdir().unwrap(); + std::fs::set_permissions(private.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); + let denied = with_fs_identity(identity, || { + std::fs::write(private.path().join("file"), b"data") + }) + .unwrap(); + assert_eq!( + denied.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + assert_restored(original, &groups_before, &before); + + // Only the switched identity can empty the directory it created. + with_fs_identity(identity, || { + std::fs::remove_dir_all(directory.path().join("directory")) + }) + .unwrap() + .unwrap(); + } + + /// Run with `CAP_SETGID`, for example as root. + #[test] + #[ignore = "requires CAP_SETGID"] + fn same_identity_drops_other_groups_with_privilege() { + assert!(has_capability(CAP_SETGID), "this test requires CAP_SETGID"); + let other_group = foreign_identity().gid; + // Only this thread's groups change. + std::thread::spawn(move || { + let original = FsIdentity::current(); + set_groups(&[original.gid, other_group]).unwrap(); + let groups_before = groups().unwrap(); + let before = capabilities().unwrap(); + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("file"); + std::fs::write(&path, b"data").unwrap(); + + let (identity, groups_inside, inside, chgrp) = with_fs_identity(original, || { + ( + FsIdentity::current(), + groups().unwrap(), + capabilities().unwrap(), + std::os::unix::fs::chown(&path, None, Some(other_group)), + ) + }) + .unwrap(); + + assert_eq!(identity, original); + assert!(groups_inside.iter().all(|&group| group == original.gid)); + assert!(inside.iter().all(|data| data.effective == 0)); + // The operation cannot give a file a group that only the thread has. + assert_eq!( + chgrp.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + assert_restored(original, &groups_before, &before); + std::os::unix::fs::chown(&path, None, Some(other_group)).unwrap(); + }) + .join() + .unwrap(); + } +} diff --git a/vm/devices/support/fs/lxutil/src/unix/mod.rs b/vm/devices/support/fs/lxutil/src/unix/mod.rs index cf9ebb92658..a58e8690e4c 100644 --- a/vm/devices/support/fs/lxutil/src/unix/mod.rs +++ b/vm/devices/support/fs/lxutil/src/unix/mod.rs @@ -8,6 +8,7 @@ mod confine; #[cfg(test)] mod confine_tests; +pub(crate) mod credentials; pub(crate) mod path; mod util; From b7696e7bac7344b9f415c64fbd6372d3959076c1 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 09:33:19 -0700 Subject: [PATCH 280/304] virtiofs: perform microVM share requests as the guest caller The microVM virtio-fs server performs every guest request as the OpenVMM process. On a Linux host, the files that an NVX sandbox workload creates in a read-write share are therefore owned by the OpenVMM user, and a directory that the workload creates is owned by OpenVMM with mode 0755, so the guest kernel's default_permissions check stops the workload from creating anything inside it (microsoft/nvx#297). Add `--mount-owner ` for the microVM `--mount` attachment. `vmm`, the default, keeps the current behavior. With `caller`: - The request worker performs each request that can access host files as the UID and GID in its FUSE header, through lxutil's per-thread credentials, and so without OpenVMM's supplementary groups or effective capabilities. INIT, DESTROY, FORGET, BATCH_FORGET, INTERRUPT, RELEASE, and RELEASEDIR don't access host files and run unchanged, so the guest can always mount, unmount, and release what it opened. - Guest UID 0 and GID 0 are squashed, independently, to the owner and group of the export root, so the guest can create neither root-owned nor setuid-root host files. The command line and the device both refuse an export root owned by UID 0 or GID 0. - A request that can't run as its caller, because a credential switch fails or OpenVMM lacks CAP_SETUID and CAP_SETGID, fails with EPERM. It never falls back to OpenVMM's identity, groups, or capabilities. - Windows has no per-request POSIX identity to switch to, so the command line and the device profile reject `caller` there. The mode is part of the snapshot: - The filesystem contract records it in a new `owner_mode` field: empty for `vmm`, which is also what older snapshots decode to, or `caller`. Restoring an active attachment from the command line requires the same `--mount-owner`. The TTRPC management API has no ownership field: it cold-boots attachments as `vmm` and restores the mode that the snapshot records. - The device-private state records the mode, and a `caller` attachment saves schema version 6. Earlier releases accept only versions 4 and 5 and ignore unknown fields, so they reject a caller-owned state instead of restoring the share as `vmm`. A `vmm` attachment still saves version 5. - Capture and restore still inspect and reopen the guest's files as OpenVMM, so an unprivileged OpenVMM can't capture or restore a guest that holds a file that only its caller can reach or reopen. The Guide documents the mode, the capabilities it needs, the trust it places in the identities that the guest kernel reports, and these snapshot rules. New unit tests cover the squash mapping; the identity that each request runs as, through an injected credential switch, including guest root squashed to an owner other than OpenVMM; negotiation bypassing the switch; fail-closed EPERM without privilege; root-owned export roots; `vmm` mode; the saved-state mode and schema version; CLI parsing and validation; restore-time owner matching; and the canonical snapshot field. An ignored test needs CAP_SETUID and CAP_SETGID and checks that foreign callers own what they create while the host enforces their permissions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/reference/devices/virtio/virtio-fs.md | 65 ++- Guide/src/reference/openvmm/management/cli.md | 12 +- Guide/src/user_guide/openvmm/snapshots.md | 4 +- openvmm/openvmm_defs/src/microvm.rs | 35 ++ openvmm/openvmm_entry/src/cli_args/microvm.rs | 42 ++ openvmm/openvmm_entry/src/microvm/config.rs | 5 + .../openvmm_entry/src/microvm/filesystem.rs | 183 ++++++- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 2 + .../openvmm_helpers/src/snapshot/microvm.rs | 76 ++- .../virtio/virtio_resources/src/fs/microvm.rs | 4 + vm/devices/virtio/virtiofs/src/lib.rs | 4 + .../virtio/virtiofs/src/microvm/device.rs | 11 +- vm/devices/virtio/virtiofs/src/microvm/fs.rs | 25 +- vm/devices/virtio/virtiofs/src/microvm/mod.rs | 3 + .../virtio/virtiofs/src/microvm/owner.rs | 136 +++++ .../virtiofs/src/microvm/owner_tests.rs | 516 ++++++++++++++++++ .../virtio/virtiofs/src/microvm/profile.rs | 67 ++- .../virtio/virtiofs/src/microvm/resolver.rs | 8 + .../virtiofs/src/microvm/saved_state.rs | 9 + .../virtio/virtiofs/src/microvm/state.rs | 16 +- vm/devices/virtio/virtiofs/src/virtio.rs | 12 +- 21 files changed, 1216 insertions(+), 19 deletions(-) create mode 100644 vm/devices/virtio/virtiofs/src/microvm/owner.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/owner_tests.rs diff --git a/Guide/src/reference/devices/virtio/virtio-fs.md b/Guide/src/reference/devices/virtio/virtio-fs.md index 7d9907a90f7..a5a42da72af 100644 --- a/Guide/src/reference/devices/virtio/virtio-fs.md +++ b/Guide/src/reference/devices/virtio/virtio-fs.md @@ -72,6 +72,58 @@ WSL-style links, which Windows path resolution never follows, so Windows tools see guest-created links as inaccessible files. Host software that later reads the exported directory must treat guest-created links as untrusted. +### Host identity of guest operations + +`--mount-owner` selects the host identity that performs the guest's +operations: + +- `vmm` (the default): every operation runs as the OpenVMM process, which + therefore owns every file and directory that the guest creates. The guest + enforces permissions against the ownership and mode bits that HostFs + reports. +- `caller`: each operation runs as the UID and GID that the guest kernel + reports for its caller, so a workload's files are owned by its own numeric + identity on the host, and the host also enforces permissions for that + identity. Guest UID 0 and GID 0 are squashed to the owner and group of the + export root, so the guest can create neither root-owned nor setuid-root + host files. OpenVMM therefore refuses an export root owned by UID 0 or + GID 0. + +```bash +openvmm --machine microvm \ + --mount /mnt/share,path/to/share,rw \ + --mount-owner caller \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz +``` + +In `caller` mode, the request queue worker switches only its own thread's +credentials for one request at a time: it sets the filesystem UID and GID +with `setfsuid` and `setfsgid`, drops every supplementary group other than +that GID, and clears the effective capabilities, then restores all of them +before it handles anything else. Session negotiation, `FORGET`, and handle +release do not access host files and run unchanged. Assuming an identity +other than OpenVMM's own, or dropping OpenVMM's other supplementary groups, +needs `CAP_SETUID` and `CAP_SETGID`, for example as ambient capabilities of +an unprivileged OpenVMM process. Without them, only a caller whose UID and +GID match OpenVMM's filesystem UID and GID can run, and only if OpenVMM has +no other supplementary groups. A request that cannot run as its caller, +because a credential switch fails or the capabilities are missing, fails +with `EPERM`; HostFs never falls back to OpenVMM's identity, groups, or +capabilities. `caller` mode requires a Linux host. Windows has no +per-request POSIX identity to switch to, so OpenVMM rejects +`--mount-owner caller` there. + +```admonish warning +The guest kernel reports each caller's identity, and guest root may assume +any identity inside the guest. Guest root and a compromised guest kernel can +therefore act as any nonzero host UID and GID inside the export, including +leaving setuid files owned by it. Export only trees that every such identity +may modify, and keep the export on a host filesystem mounted `nosuid` when +host users might execute guest-created files. Grant OpenVMM only +`CAP_SETUID` and `CAP_SETGID`: these capabilities let it assume any host +identity. +``` + `SectionFs`, aggregate roots, alternate tags, PCI transport, DAX, and extra queues are not part of the microVM profile. @@ -88,13 +140,22 @@ lookups and newly opened directories still observe the live host tree. Restoring a snapshot captured with an active attachment requires a fresh `--mount` argument and the exact same denied-path set, canonical host path, -guest target, and mode. Identity validation remains independent: before any vCPU starts, +guest target, mode, and `--mount-owner` mode. Identity validation remains independent: before any vCPU starts, OpenVMM pins the supplied root and validates its saved root and object identities. Missing, moved, replaced, ambiguous, or no-longer-reopenable objects fail restore. A saved symbolic link is revalidated as the link itself, without being followed, and an alias whose ancestor has become a link fails restore. +`--mount-owner caller` applies only to guest requests. Capture and restore +still revalidate saved names and reopen saved handles as the OpenVMM process. +Unless OpenVMM may bypass file permissions, as root may, capture therefore +fails while the guest holds a name inside a directory that OpenVMM cannot +search. Restore fails when OpenVMM cannot reopen a saved handle with its saved +access, such as a handle open for writing on a file that a guest caller owns. +OpenVMM releases that predate `--mount-owner` reject the device state of a +`caller` attachment rather than restore it as `vmm`. + A snapshot captured without `--mount` records the fixed slot as dormant. It may restore without an attachment, or bind a new `--mount` attachment. Because execution resumes after the cold-boot mount hook, the guest must mount the @@ -163,6 +224,8 @@ false success. - Device implementation: `vm/devices/virtio/virtiofs/` +- Per-thread filesystem credentials: + `vm/devices/support/fs/lxutil/src/unix/credentials.rs` - FUSE session implementation: `vm/devices/support/fs/fuse/` - Resource contract: diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index fb9fccc40df..2f69f1f4c31 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -209,10 +209,20 @@ describes the source definitions. same device, hard-link aliases, symlinks, junctions, and bind-mount aliases cannot re-expose it. + `--mount-owner ` selects the host identity of the guest's + operations. `vmm`, the default, runs them all as OpenVMM. `caller` runs + each one as its guest caller's UID and GID, without supplementary groups or + capabilities, and squashes guest UID 0 and GID 0 to the owner of the export + root, which must not be root. `caller` requires a Linux host. It also + requires `CAP_SETUID` and `CAP_SETGID` unless every caller has OpenVMM's + own UID and GID and OpenVMM has no other supplementary groups; an + operation that cannot run as its caller fails with `EPERM`. + See [virtio-fs](../../devices/virtio/virtio-fs.md#host-identity-of-guest-operations). + Filesystem snapshots contain guest-visible FUSE and queue state, not host directory contents or native handles. An active snapshot requires `--mount` again with the exact canonical host path, guest target, access - mode, and denied-path set; the live root and every saved object identity are + mode, denied-path set, and `--mount-owner` mode; the live root and every saved object identity are also revalidated before vCPUs start. A snapshot captured without `--mount` may remain dormant or bind a new attachment. The resumed guest must then explicitly run `mount -t virtiofs microvm ` because its cold-boot mount hook diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index d706795e15a..eb3d3767959 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -323,7 +323,7 @@ For microVM virtio-fs, the manifest always records the fixed, guest-discoverable slot. A dormant slot has no host attachment or filesystem policy and carries explicit dormant device-private state. An active slot also records the stable attachment ID, exact canonical host path, pinned root identity, guest mount -target, access mode, no-DAX queue policy, and `live-revalidate` restore mode. +target, access mode, ownership mode, no-DAX queue policy, and `live-revalidate` restore mode. Its device-private payload records FUSE negotiation, namespace IDs and aliases, lookup counts, reopenable handles, bounded directory-entry snapshots and cookies, and queue progress. Native file descriptors and Windows handles are @@ -331,7 +331,7 @@ never serialized. Restoring an active slot requires a fresh `--mount ` attachment with the same canonical -host path, target, and mode. OpenVMM independently validates the root and every +host path, target, and mode, and the same `--mount-owner` mode. OpenVMM independently validates the root and every saved object identity before starting a vCPU. A dormant-slot snapshot may restore without an attachment or bind a new one. For a new attachment, the resumed guest explicitly mounts tag `microvm`; the cold-boot mount hook does not diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index dc4f8f72a81..0b4d8342de6 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -282,6 +282,32 @@ impl MicrovmFilesystemAccess { } } +/// Host identity that performs the guest's operations on the microVM +/// filesystem. +#[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq)] +pub enum MicrovmFilesystemOwner { + /// Run every operation as the VMM process. + Vmm, + /// Run each operation as its guest caller's UID and GID, with UID 0 and + /// GID 0 squashed to the owner of the export root. Linux only. + Caller, +} + +impl MicrovmFilesystemOwner { + /// Returns the command-line and snapshot spelling of this mode. + pub fn as_str(self) -> &'static str { + match self { + Self::Vmm => "vmm", + Self::Caller => "caller", + } + } + + /// Returns whether operations run as their guest callers. + pub fn is_caller(self) -> bool { + matches!(self, Self::Caller) + } +} + /// Guest-visible configuration for the microVM virtio-fs device. #[derive(MeshPayload, Clone, Debug, PartialEq, Eq)] pub struct MicrovmFilesystemConfig { @@ -291,6 +317,8 @@ pub struct MicrovmFilesystemConfig { pub access: MicrovmFilesystemAccess, /// Canonical host-relative paths hidden by the virtio-fs server. pub denied_paths: Vec, + /// Snapshot-authoritative host identity of guest operations. + pub owner: MicrovmFilesystemOwner, } impl MicrovmFilesystemConfig { @@ -319,9 +347,16 @@ impl MicrovmFilesystemConfig { guest_mount_target, access, denied_paths: Vec::new(), + owner: MicrovmFilesystemOwner::Vmm, }) } + /// Selects the host identity that performs the guest's operations. + pub fn with_owner(mut self, owner: MicrovmFilesystemOwner) -> Self { + self.owner = owner; + self + } + /// Adds a canonical, non-overlapping denied-path policy. pub fn with_denied_paths( mut self, diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 306bbc44113..344e3662b4c 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -75,6 +75,25 @@ pub enum MicrovmLifecycleCli { Managed, } +/// Host identity that performs the guest's operations on the microVM share. +#[derive(Debug, Copy, Clone, ValueEnum, PartialEq, Eq)] +pub enum MicrovmMountOwnerCli { + /// Run every operation as OpenVMM, which owns the files the guest creates. + Vmm, + /// Run each operation as its guest caller's UID and GID, with root + /// squashed to the owner of the export root (Linux only). + Caller, +} + +impl From for openvmm_defs::microvm::MicrovmFilesystemOwner { + fn from(owner: MicrovmMountOwnerCli) -> Self { + match owner { + MicrovmMountOwnerCli::Vmm => Self::Vmm, + MicrovmMountOwnerCli::Caller => Self::Caller, + } + } +} + /// Protocol for a localhost-to-guest port forward. #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum MicrovmLoopbackForwardProtocol { @@ -336,6 +355,24 @@ pub struct MicrovmCli { )] pub microvm_mount_deny: Vec, + /// Select the host identity of the guest's `--mount` operations + /// + /// `vmm` (the default) performs every operation as OpenVMM. `caller` + /// performs each operation as the guest caller's UID and GID, without + /// supplementary groups or capabilities, and squashes guest UID 0 and + /// GID 0 to the owner of the export root, which must not be root. + /// `caller` requires a Linux host. It also requires CAP_SETUID and + /// CAP_SETGID unless every caller has OpenVMM's own UID and GID and + /// OpenVMM has no other supplementary groups; an operation that cannot + /// run as its caller fails with EPERM. + #[clap( + long = "mount-owner", + value_enum, + value_name = "OWNER", + requires = "microvm_mount" + )] + pub microvm_mount_owner: Option, + /// dedicated microVM control console backed by a local serial endpoint /// /// Accepts listen=\ or none. The boot @@ -734,6 +771,11 @@ impl Options { self.microvm.microvm_mount_deny.len() <= 128, "microVM filesystem permits at most 128 denied paths" ); + anyhow::ensure!( + cfg!(target_os = "linux") + || self.microvm.microvm_mount_owner != Some(MicrovmMountOwnerCli::Caller), + "--mount-owner caller requires a Linux host" + ); for (index, block) in self.microvm.microvm_sandbox_block.iter().enumerate() { anyhow::ensure!( block.disk.read_only == block.role.is_read_only(), diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index b18d45e4324..28ec69adbcc 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -107,6 +107,10 @@ impl<'a> MicrovmConfigBuilder<'a> { effective_microvm_filesystem( opt.microvm.microvm_mount.as_ref(), &opt.microvm.microvm_mount_deny, + opt.microvm.microvm_mount_owner.map_or( + openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm, + Into::into, + ), restore_machine_contract, )? } else { @@ -554,6 +558,7 @@ impl<'a> MicrovmConfigBuilder<'a> { root_identity: filesystem.attachment.identity.clone(), read_only: filesystem.config.access.is_read_only(), denied_paths: filesystem.config.denied_paths.clone(), + caller_identity: filesystem.config.owner.is_caller(), }, ) } else { diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index 65330f7cf5e..53dbe458260 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -267,15 +267,20 @@ pub(crate) fn microvm_filesystem_attachment( fn microvm_filesystem_from_mount( requested: &cli_args::microvm::MicrovmMountCli, denied_paths: &[PathBuf], + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, ) -> anyhow::Result { let (root_path, attachment) = microvm_filesystem_attachment(&requested.host_path)?; + if owner.is_caller() { + validate_caller_owned_root(Path::new(&root_path))?; + } let denied_paths = canonical_microvm_filesystem_denied_paths(Path::new(&root_path), denied_paths)?; let config = openvmm_defs::microvm::MicrovmFilesystemConfig::new( requested.guest_target.clone(), requested.access, )? - .with_denied_paths(denied_paths)?; + .with_denied_paths(denied_paths)? + .with_owner(owner); Ok(EffectiveMicrovmFilesystem { config, root_path, @@ -283,6 +288,26 @@ fn microvm_filesystem_from_mount( }) } +/// Rejects an export root that caller ownership cannot squash guest root to. +fn validate_caller_owned_root(root_path: &Path) -> anyhow::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + let metadata = fs_err::symlink_metadata(root_path)?; + anyhow::ensure!( + metadata.uid() != 0 && metadata.gid() != 0, + "--mount-owner caller squashes guest root to the owner of the export root, so {} must not be owned by UID 0 or GID 0", + root_path.display() + ); + Ok(()) + } + #[cfg(not(unix))] + { + let _ = root_path; + anyhow::bail!("--mount-owner caller requires a Linux host") + } +} + pub(crate) fn validate_microvm_filesystem_private_storage( root_path: &Path, snapshot_destination: Option<&Path>, @@ -349,6 +374,13 @@ pub(crate) fn microvm_filesystem_from_snapshot( openvmm_defs::microvm::MicrovmFilesystemConfig::new(saved.guest_mount_target.clone(), access)? .with_denied_paths(saved.denied_paths.clone()) .context("snapshot microVM filesystem policy is invalid") + .and_then(|config| { + Ok(config.with_owner( + openvmm_helpers::snapshot::microvm::snapshot_microvm_filesystem_owner( + &saved.owner_mode, + )?, + )) + }) } pub(crate) fn microvm_filesystem_slot_from_snapshot( @@ -376,11 +408,12 @@ pub(crate) fn microvm_filesystem_slot_from_snapshot( pub(super) fn effective_microvm_filesystem( requested: Option<&cli_args::microvm::MicrovmMountCli>, denied_paths: &[PathBuf], + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, ) -> anyhow::Result> { let Some(restore) = restore else { return requested - .map(|requested| microvm_filesystem_from_mount(requested, denied_paths)) + .map(|requested| microvm_filesystem_from_mount(requested, denied_paths, owner)) .transpose(); }; @@ -405,7 +438,7 @@ pub(super) fn effective_microvm_filesystem( == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION, "snapshot does not support restore-time microVM filesystem attachment" ); - return microvm_filesystem_from_mount(requested, denied_paths).map(Some); + return microvm_filesystem_from_mount(requested, denied_paths, owner).map(Some); }; let requested = requested .context("snapshot restore requires a fresh --mount attachment for fs:microvm0")?; @@ -414,7 +447,13 @@ pub(super) fn effective_microvm_filesystem( requested.guest_target == config.guest_mount_target && requested.access == config.access, "restore-time mount target or access mode does not match the snapshot contract" ); - let effective = microvm_filesystem_from_mount(requested, denied_paths)?; + anyhow::ensure!( + owner == config.owner, + "restore-time --mount-owner {} does not match the snapshot contract ({})", + owner.as_str(), + config.owner.as_str() + ); + let effective = microvm_filesystem_from_mount(requested, denied_paths, owner)?; anyhow::ensure!( !saved.canonical_host_path.is_empty(), "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" @@ -443,14 +482,25 @@ mod tests { use openvmm_defs::microvm::build_microvm_command_line; use test_with_tracing::test; + const VMM: openvmm_defs::microvm::MicrovmFilesystemOwner = + openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm; + fn filesystem_contract( root: &Path, + ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { + filesystem_contract_with_owner(root, VMM) + } + + fn filesystem_contract_with_owner( + root: &Path, + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( "/mnt/share".to_owned(), openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, ) - .unwrap(); + .unwrap() + .with_owner(owner); let (root_path, attachment) = microvm_filesystem_attachment(root).unwrap(); let mut command_line = build_microvm_command_line(&[], false).unwrap(); openvmm_defs::microvm::append_microvm_virtio_discovery( @@ -574,6 +624,7 @@ mod tests { let filesystem = effective_microvm_filesystem( options.microvm.microvm_mount.as_ref(), &options.microvm.microvm_mount_deny, + VMM, None, ) .unwrap() @@ -599,6 +650,7 @@ mod tests { let restored = effective_microvm_filesystem( options.microvm.microvm_mount.as_ref(), &[], + VMM, Some(&contract), ) .unwrap() @@ -612,6 +664,7 @@ mod tests { effective_microvm_filesystem( replacement_options.microvm.microvm_mount.as_ref(), &[], + VMM, Some(&contract) ) .is_err() @@ -632,6 +685,7 @@ mod tests { effective_microvm_filesystem( options.microvm.microvm_mount.as_ref(), &[], + VMM, Some(&contract) ) .is_err() @@ -642,13 +696,14 @@ mod tests { fn filesystem_restore_rejects_missing_or_changed_policy() { let root = tempfile::tempdir().unwrap(); let contract = filesystem_contract(root.path()); - assert!(effective_microvm_filesystem(None, &[], Some(&contract)).is_err()); + assert!(effective_microvm_filesystem(None, &[], VMM, Some(&contract)).is_err()); let changed_mode = restore_mount_options(root.path(), "rw"); assert!( effective_microvm_filesystem( changed_mode.microvm.microvm_mount.as_ref(), &[], + VMM, Some(&contract), ) .is_err() @@ -659,7 +714,7 @@ mod tests { fn filesystem_restore_without_mount_preserves_dormant_slot() { let contract = dormant_filesystem_contract(); assert!( - effective_microvm_filesystem(None, &[], Some(&contract)) + effective_microvm_filesystem(None, &[], VMM, Some(&contract)) .unwrap() .is_none() ); @@ -673,6 +728,7 @@ mod tests { let filesystem = effective_microvm_filesystem( options.microvm.microvm_mount.as_ref(), &[], + VMM, Some(&contract), ) .unwrap() @@ -696,6 +752,7 @@ mod tests { let error = match effective_microvm_filesystem( options.microvm.microvm_mount.as_ref(), &[], + VMM, Some(&contract), ) { Err(error) => error, @@ -714,6 +771,118 @@ mod tests { assert!(canonical_microvm_filesystem_root(&root.path().join("child").join("..")).is_err()); } + #[test] + fn mount_owner_defaults_to_the_vmm_and_requires_a_mount() { + let root = tempfile::tempdir().unwrap(); + let mount = format!("/mnt/share,{},rw", root.path().display()); + let parse = |extra: &[&str]| { + Options::try_parse_from( + ["openvmm", "--machine", "microvm"] + .into_iter() + .chain(extra.iter().copied()), + ) + }; + let options = parse(&["--mount", &mount]).unwrap(); + assert_eq!(options.microvm.microvm_mount_owner, None); + let options = parse(&["--mount", &mount, "--mount-owner", "vmm"]).unwrap(); + assert_eq!( + options.microvm.microvm_mount_owner, + Some(cli_args::microvm::MicrovmMountOwnerCli::Vmm) + ); + options.validate_microvm_options().unwrap(); + assert!(parse(&["--mount-owner", "caller"]).is_err()); + assert!(parse(&["--mount", &mount, "--mount-owner", "root"]).is_err()); + + let options = parse(&["--mount", &mount, "--mount-owner", "caller"]).unwrap(); + let validation = options.validate_microvm_options(); + if cfg!(target_os = "linux") { + validation.unwrap(); + } else { + assert!( + validation + .unwrap_err() + .to_string() + .contains("--mount-owner caller requires a Linux host") + ); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn caller_owner_rejects_a_root_owned_export_root() { + use std::os::unix::fs::MetadataExt as _; + let caller = openvmm_defs::microvm::MicrovmFilesystemOwner::Caller; + let root_owned = Path::new("/"); + let metadata = fs_err::metadata(root_owned).unwrap(); + if metadata.uid() == 0 && metadata.gid() == 0 { + let options = restore_mount_options(root_owned, "ro"); + let error = match effective_microvm_filesystem( + options.microvm.microvm_mount.as_ref(), + &[], + caller, + None, + ) { + Err(error) => error, + Ok(_) => panic!("a root-owned export root was accepted for caller ownership"), + }; + assert!( + error + .to_string() + .contains("must not be owned by UID 0 or GID 0") + ); + } + + let root = tempfile::tempdir().unwrap(); + let metadata = fs_err::metadata(root.path()).unwrap(); + if metadata.uid() != 0 && metadata.gid() != 0 { + let options = restore_mount_options(root.path(), "rw"); + let filesystem = effective_microvm_filesystem( + options.microvm.microvm_mount.as_ref(), + &[], + caller, + None, + ) + .unwrap() + .unwrap(); + assert_eq!(filesystem.config.owner, caller); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn filesystem_restore_requires_the_same_owner_mode() { + use std::os::unix::fs::MetadataExt as _; + let caller = openvmm_defs::microvm::MicrovmFilesystemOwner::Caller; + let root = tempfile::tempdir().unwrap(); + let metadata = fs_err::metadata(root.path()).unwrap(); + if metadata.uid() == 0 || metadata.gid() == 0 { + return; + } + let options = restore_mount_options(root.path(), "ro"); + let requested = options.microvm.microvm_mount.as_ref(); + + let contract = filesystem_contract_with_owner(root.path(), caller); + assert_eq!( + contract.microvm_filesystem.as_ref().unwrap().owner_mode, + "caller" + ); + let restored = effective_microvm_filesystem(requested, &[], caller, Some(&contract)) + .unwrap() + .unwrap(); + assert_eq!(restored.config.owner, caller); + let error = match effective_microvm_filesystem(requested, &[], VMM, Some(&contract)) { + Err(error) => error, + Ok(_) => panic!("restore accepted a different ownership mode"), + }; + assert!(error.to_string().contains("--mount-owner vmm")); + + // Snapshots record VMM ownership as an empty mode, like those that + // predate the field. + let contract = filesystem_contract(root.path()); + assert_eq!(contract.microvm_filesystem.as_ref().unwrap().owner_mode, ""); + assert!(effective_microvm_filesystem(requested, &[], caller, Some(&contract)).is_err()); + } + #[cfg(unix)] #[test] fn filesystem_root_rejects_symbolic_link_components() { diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index bcccbc304c8..73968fe1b2a 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -880,6 +880,7 @@ impl CreateVm { root_identity: filesystem.attachment.identity.clone(), read_only: filesystem.config.access.is_read_only(), denied_paths: Vec::new(), + caller_identity: filesystem.config.owner.is_caller(), }, } .into_resource(), @@ -1033,6 +1034,7 @@ impl CreateVm { root_identity: attachment.identity.clone(), read_only: filesystem_config.access.is_read_only(), denied_paths: Vec::new(), + caller_identity: filesystem_config.owner.is_caller(), }, } .into_resource(); diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index c1c71953fdc..799bc212300 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -246,6 +246,10 @@ pub struct SnapshotMicrovmFilesystem { /// Canonical host-relative paths hidden by the virtio-fs server. #[mesh(12)] pub denied_paths: Vec, + /// Host identity of guest operations: empty for the VMM, which is also + /// what snapshots that predate this field used, or `caller`. + #[mesh(13)] + pub owner_mode: String, } /// Authoritative identity and snapshot policy for a microVM sandbox block. @@ -296,10 +300,30 @@ impl SnapshotMicrovmFilesystem { attribute_cache_timeout_ns: 0, canonical_host_path: canonical_host_path.to_owned(), denied_paths: config.denied_paths.clone(), + owner_mode: match config.owner { + openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm => String::new(), + openvmm_defs::microvm::MicrovmFilesystemOwner::Caller => { + openvmm_defs::microvm::MicrovmFilesystemOwner::Caller + .as_str() + .to_owned() + } + }, } } } +/// Parses the host identity of guest operations recorded in a snapshot +/// filesystem policy. +pub fn snapshot_microvm_filesystem_owner( + owner_mode: &str, +) -> anyhow::Result { + match owner_mode { + "" => Ok(openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm), + "caller" => Ok(openvmm_defs::microvm::MicrovmFilesystemOwner::Caller), + mode => anyhow::bail!("snapshot filesystem owner mode '{mode}' is unsupported"), + } +} + impl SnapshotMicrovmNetwork { fn new( config: &openvmm_defs::microvm::MicrovmNetworkConfig, @@ -1446,7 +1470,8 @@ pub(super) fn validate_machine_contract_shape( access, ) .and_then(|config| config.with_denied_paths(filesystem.denied_paths.clone())) - .context("snapshot filesystem policy is invalid")?; + .context("snapshot filesystem policy is invalid")? + .with_owner(snapshot_microvm_filesystem_owner(&filesystem.owner_mode)?); anyhow::ensure!( *filesystem == SnapshotMicrovmFilesystem::new(&parsed, &filesystem.canonical_host_path), "snapshot filesystem policy is not canonical" @@ -2254,12 +2279,23 @@ mod tests { } fn generated_filesystem_contract(source_hypervisor: &str) -> SnapshotMachineContract { + generated_filesystem_contract_with_owner( + source_hypervisor, + openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm, + ) + } + + fn generated_filesystem_contract_with_owner( + source_hypervisor: &str, + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, + ) -> SnapshotMachineContract { let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( "/mnt/share".to_owned(), openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, ) .and_then(|config| config.with_denied_paths(vec!["secrets".to_owned()])) - .unwrap(); + .unwrap() + .with_owner(owner); let command_line = format!( "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6 {}", filesystem.command_line_fragment() @@ -2538,6 +2574,42 @@ mod tests { assert!(validate_machine_contract_shape(&contract, 1024, 1).is_err()); } + #[test] + fn microvm_filesystem_owner_mode_is_canonical() { + use openvmm_defs::microvm::MicrovmFilesystemOwner; + assert_eq!( + snapshot_microvm_filesystem_owner("").unwrap(), + MicrovmFilesystemOwner::Vmm + ); + assert_eq!( + snapshot_microvm_filesystem_owner("caller").unwrap(), + MicrovmFilesystemOwner::Caller + ); + for mode in ["vmm", "root", "Caller"] { + assert!(snapshot_microvm_filesystem_owner(mode).is_err()); + } + // VMM ownership keeps the encoding of snapshots that predate the field. + for (owner, mode) in [ + (MicrovmFilesystemOwner::Vmm, ""), + (MicrovmFilesystemOwner::Caller, "caller"), + ] { + let contract = generated_filesystem_contract_with_owner("kvm", owner); + assert_eq!( + contract.microvm_filesystem.as_ref().unwrap().owner_mode, + mode + ); + validate_machine_contract_shape(&contract, 1024, 1).unwrap(); + } + let mut contract = generated_filesystem_contract("kvm"); + contract.microvm_filesystem.as_mut().unwrap().owner_mode = "vmm".to_owned(); + let error = validate_machine_contract_shape(&contract, 1024, 1).unwrap_err(); + assert!( + error + .to_string() + .contains("owner mode 'vmm' is unsupported") + ); + } + #[test] fn validate_microvm_filesystem_contract_rejects_policy_change() { let contract = generated_filesystem_contract("whp"); diff --git a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs index 9a8c150de23..0d595bb1994 100644 --- a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs +++ b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs @@ -17,6 +17,10 @@ pub enum VirtioFsProfile { root_identity: Vec, read_only: bool, denied_paths: Vec, + /// Perform each guest request as the host UID and GID of its caller, + /// with root squashed to the owner of the export root, instead of as + /// the VMM. Linux only. + caller_identity: bool, }, MicrovmDormant { stable_id: String, diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index dadd3e267d5..0c61f968d15 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -73,6 +73,8 @@ struct VirtioFsInner { files: RwLock>>, mode: VirtioFsMode, microvm_profile: Option, + /// Set when the microVM profile performs requests as their callers. + caller_identity: Option, negotiation: RwLock, } @@ -645,6 +647,7 @@ impl VirtioFs { files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Direct, microvm_profile: None, + caller_identity: None, negotiation: RwLock::new(FuseNegotiation::default()), }), }) @@ -664,6 +667,7 @@ impl VirtioFs { files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Aggregate(AggregateState::new()), microvm_profile: None, + caller_identity: None, negotiation: RwLock::new(FuseNegotiation::default()), }), } diff --git a/vm/devices/virtio/virtiofs/src/microvm/device.rs b/vm/devices/virtio/virtiofs/src/microvm/device.rs index 7f7d275ae78..4a499b89914 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/device.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/device.rs @@ -6,6 +6,7 @@ use super::profile::MICROVM_ATTACHMENT_ID; use super::profile::MICROVM_MOUNT_TAG; use super::profile::MICROVM_REQUEST_QUEUES; +use super::profile::MicroVmOwnerMode; use super::profile::MicroVmVirtioFsProfile; use super::saved_state::SavedState; use super::state::save_dormant_microvm_state; @@ -55,6 +56,7 @@ impl VirtioFsDevice { ); device.microvm_attachment_id = Some(attachment_id); device.microvm_profile = Some(profile); + device.caller_identity = fs.caller_identity(); device.stateful_fs = Some(fs); Ok(device) } @@ -83,12 +85,14 @@ impl VirtioFsDevice { /// Creates the fixed no-DAX microVM HostFs device directly from the /// `Microvm` resource fields and its process-local host root. + #[expect(clippy::too_many_arguments)] pub fn new_microvm_hostfs( driver_source: &VmTaskDriverSource, stable_id: String, root_identity: Vec, read_only: bool, denied_paths: Vec, + owner_mode: MicroVmOwnerMode, root_path: impl AsRef, notify_corruption: Option>, ) -> anyhow::Result { @@ -97,7 +101,8 @@ impl VirtioFsDevice { root_identity, read_only, denied_paths, - )?; + )? + .with_owner_mode(owner_mode)?; let fs = VirtioFs::new_microvm(root_path, profile.clone())?; Self::new_microvm(driver_source, profile, fs, notify_corruption) } @@ -292,6 +297,7 @@ mod tests { root_identity, true, Vec::new(), + MicroVmOwnerMode::Vmm, temporary_directory.path(), None, ) @@ -340,6 +346,7 @@ mod tests { microvm_root_identity(root.path()).unwrap(), false, Vec::new(), + MicroVmOwnerMode::Vmm, root.path(), None, ) @@ -357,6 +364,7 @@ mod tests { microvm_root_identity(root.path()).unwrap(), false, Vec::new(), + MicroVmOwnerMode::Vmm, root.path(), None, ) @@ -428,6 +436,7 @@ mod tests { microvm_root_identity(root.path()).unwrap(), false, Vec::new(), + MicroVmOwnerMode::Vmm, root.path(), None, ) diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs.rs b/vm/devices/virtio/virtiofs/src/microvm/fs.rs index 9763f8de28c..dd7dd241611 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/fs.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/fs.rs @@ -4,7 +4,10 @@ //! microVM filesystem construction, policy hooks, and attachment restore. use super::MAX_FUSE_REQUEST_BYTES; +use super::owner::CallerIdentity; +use super::profile::MicroVmOwnerMode; use super::profile::MicroVmVirtioFsProfile; +use super::saved_state::CALLER_IDENTITY_SCHEMA_VERSION; use super::saved_state::MAX_ALIAS_BYTES; use super::saved_state::MAX_ALIASES; use super::saved_state::MAX_ALIASES_PER_INODE; @@ -165,6 +168,13 @@ impl VirtioFs { )); let (root_inode, root_stat) = VirtioFsInode::new(Arc::clone(&volume), PathBuf::new())?; profile.validate_opened_root(root_path, &root_stat)?; + let caller_identity = match profile.owner_mode() { + MicroVmOwnerMode::Vmm => None, + MicroVmOwnerMode::Caller => Some(CallerIdentity::for_export_root_owner( + root_stat.uid, + root_stat.gid, + )?), + }; if inodes.insert(root_inode)?.1 != FUSE_ROOT_ID { anyhow::bail!("microVM virtio-fs root received an invalid node ID"); } @@ -174,6 +184,7 @@ impl VirtioFs { files: RwLock::new(HandleMap::new()), mode: VirtioFsMode::Direct, microvm_profile: Some(profile), + caller_identity, negotiation: RwLock::new(FuseNegotiation::default()), }), }) @@ -183,6 +194,12 @@ impl VirtioFs { self.inner.microvm_profile.as_ref() } + /// Returns the mapping of guest callers to host identities, if requests + /// run as their callers. + pub(crate) fn caller_identity(&self) -> Option { + self.inner.caller_identity + } + pub(crate) fn is_microvm(&self) -> bool { self.inner.microvm_profile.is_some() } @@ -383,8 +400,13 @@ impl VirtioFs { *self.inner.negotiation.read() == negotiation, "FUSE session and filesystem negotiation state disagree" ); + let caller_identity = profile.owner_mode() == MicroVmOwnerMode::Caller; Ok(SavedState { - schema_version: SCHEMA_VERSION, + schema_version: if caller_identity { + CALLER_IDENTITY_SCHEMA_VERSION + } else { + SCHEMA_VERSION + }, attachment_id: profile.attachment_id().to_owned(), access_mode: match profile.access_mode() { super::profile::MicroVmAccessMode::ReadOnly => 1, @@ -404,6 +426,7 @@ impl VirtioFs { attachment_root_identity: profile.root_identity().to_vec(), maximum_request_size: MAX_FUSE_REQUEST_BYTES as u32, dormant: false, + caller_identity, }) } diff --git a/vm/devices/virtio/virtiofs/src/microvm/mod.rs b/vm/devices/virtio/virtiofs/src/microvm/mod.rs index deed7d94c50..994fa200634 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/mod.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/mod.rs @@ -10,6 +10,9 @@ pub(crate) mod fs; mod fs_tests; pub(crate) mod inode; mod limits; +pub(crate) mod owner; +#[cfg(test)] +mod owner_tests; pub mod profile; pub(crate) mod resolver; pub(crate) mod saved_state; diff --git a/vm/devices/virtio/virtiofs/src/microvm/owner.rs b/vm/devices/virtio/virtiofs/src/microvm/owner.rs new file mode 100644 index 00000000000..f0721887ab3 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/owner.rs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Host identity of guest filesystem requests (`--mount-owner`). +//! +//! In caller mode, a worker thread performs each request as the host UID and +//! GID of its guest caller, as the guest kernel reports them in the FUSE +//! request header. Guest UID 0 and GID 0 are squashed to the owner of the +//! export root, so the guest can create neither root-owned nor setuid-root +//! files on the host. A request that cannot run as its caller fails with +//! `EPERM`; it never falls back to the VMM's own identity. + +use fuse::protocol::FUSE_BATCH_FORGET; +use fuse::protocol::FUSE_DESTROY; +use fuse::protocol::FUSE_FORGET; +use fuse::protocol::FUSE_INIT; +use fuse::protocol::FUSE_INTERRUPT; +use fuse::protocol::FUSE_RELEASE; +use fuse::protocol::FUSE_RELEASEDIR; + +/// Maps guest callers to the host identities that perform their requests. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CallerIdentity { + /// The owner of the export root, which replaces guest UID 0. + squash_uid: lx::uid_t, + /// The group of the export root, which replaces guest GID 0. + squash_gid: lx::gid_t, +} + +impl CallerIdentity { + /// Builds the mapping for an export root owned by `uid` and `gid`. + pub(crate) fn for_export_root_owner(uid: lx::uid_t, gid: lx::gid_t) -> anyhow::Result { + anyhow::ensure!( + uid != 0 && gid != 0, + "microVM virtio-fs caller ownership squashes guest root to the owner of the export root, which must not be UID 0 or GID 0" + ); + Ok(Self { + squash_uid: uid, + squash_gid: gid, + }) + } + + /// Returns the host UID and GID that perform a request from guest `uid` + /// and `gid`. + pub(crate) fn host_identity(&self, uid: lx::uid_t, gid: lx::gid_t) -> (lx::uid_t, lx::gid_t) { + ( + if uid == 0 { self.squash_uid } else { uid }, + if gid == 0 { self.squash_gid } else { gid }, + ) + } +} + +/// Returns whether handling a request with `opcode` may access the host +/// filesystem. +/// +/// Negotiating the session and releasing host objects never check +/// permissions, so these requests run as the VMM. The guest can therefore +/// always mount, unmount, and release what it opened, even when its other +/// requests fail closed. +fn accesses_host_files(opcode: u32) -> bool { + !matches!( + opcode, + FUSE_INIT + | FUSE_DESTROY + | FUSE_FORGET + | FUSE_BATCH_FORGET + | FUSE_INTERRUPT + | FUSE_RELEASE + | FUSE_RELEASEDIR + ) +} + +/// Dispatches `request` to `session`, performing it as the host identity of +/// its caller when `caller_identity` is set. +pub(crate) fn dispatch( + caller_identity: Option<&CallerIdentity>, + session: &fuse::Session, + request: fuse::Request, + sender: &mut impl fuse::ReplySender, + mapper: Option<&dyn fuse::Mapper>, +) { + dispatch_with(caller_identity, session, request, sender, mapper, run_as); +} + +/// Dispatches `request` like [`dispatch`], but performs a request that runs +/// as its caller through `run_as`, which receives the caller's host UID and +/// GID and either performs the operation as that identity or fails. +pub(crate) fn dispatch_with( + caller_identity: Option<&CallerIdentity>, + session: &fuse::Session, + request: fuse::Request, + sender: &mut impl fuse::ReplySender, + mapper: Option<&dyn fuse::Mapper>, + run_as: impl FnOnce(lx::uid_t, lx::gid_t, &mut dyn FnMut()) -> lx::Result<()>, +) { + match caller_identity { + Some(caller_identity) if accesses_host_files(request.opcode()) => { + let unique = request.unique(); + let (uid, gid) = caller_identity.host_identity(request.uid(), request.gid()); + let mut request = Some(request); + let result = run_as(uid, gid, &mut || { + if let Some(request) = request.take() { + session.dispatch(request, sender, mapper); + } + }); + if let Err(error) = result { + tracelimit::warn_ratelimited!( + uid, + gid, + error = error.value(), + "virtio-fs request cannot run as its caller; failing it with EPERM" + ); + if let Err(error) = sender.send_error(unique, lx::Error::EPERM.value()) { + tracelimit::error_ratelimited!( + unique, + error = &error as &dyn std::error::Error, + "Failed to send reply", + ); + } + } + } + _ => session.dispatch(request, sender, mapper), + } +} + +/// Performs `operation` as host `uid` and `gid`. +#[cfg(target_os = "linux")] +fn run_as(uid: lx::uid_t, gid: lx::gid_t, operation: &mut dyn FnMut()) -> lx::Result<()> { + lxutil::with_fs_identity(lxutil::FsIdentity { uid, gid }, operation) +} + +/// Fails, because the profile accepts caller ownership only on Linux. +#[cfg(not(target_os = "linux"))] +fn run_as(_uid: lx::uid_t, _gid: lx::gid_t, _operation: &mut dyn FnMut()) -> lx::Result<()> { + Err(lx::Error::EPERM) +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/owner_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/owner_tests.rs new file mode 100644 index 00000000000..426d1a02aec --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/owner_tests.rs @@ -0,0 +1,516 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for performing microVM requests as their guest callers. + +use super::owner::CallerIdentity; +use test_with_tracing::test; + +#[test] +fn guest_root_is_squashed_to_the_export_root_owner() { + let identity = CallerIdentity::for_export_root_owner(1000, 1001).unwrap(); + assert_eq!(identity.host_identity(0, 0), (1000, 1001)); + assert_eq!(identity.host_identity(0, 5), (1000, 5)); + assert_eq!(identity.host_identity(4242, 0), (4242, 1001)); + assert_eq!(identity.host_identity(4242, 4343), (4242, 4343)); +} + +#[test] +fn root_owned_export_roots_are_rejected() { + for (uid, gid) in [(0, 1000), (1000, 0), (0, 0)] { + assert!(CallerIdentity::for_export_root_owner(uid, gid).is_err()); + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::super::owner; + use super::super::owner::CallerIdentity; + use super::super::profile::MICROVM_ATTACHMENT_ID; + use super::super::profile::MicroVmOwnerMode; + use super::super::profile::MicroVmVirtioFsProfile; + use super::super::profile::microvm_root_identity; + use super::super::saved_state::CALLER_IDENTITY_SCHEMA_VERSION; + use super::super::saved_state::PREVIOUS_SCHEMA_VERSION; + use super::super::saved_state::SCHEMA_VERSION; + use super::super::state::validate_microvm_state; + use crate::VirtioFs; + use fuse::Request; + use fuse::Session; + use fuse::SessionState; + use fuse::protocol::FUSE_GETATTR; + use fuse::protocol::FUSE_INIT; + use fuse::protocol::FUSE_MKDIR; + use fuse::protocol::FUSE_ROOT_ID; + use fuse::protocol::fuse_entry_out; + use fuse::protocol::fuse_getattr_in; + use fuse::protocol::fuse_in_header; + use fuse::protocol::fuse_init_in; + use fuse::protocol::fuse_mkdir_in; + use fuse::protocol::fuse_out_header; + use lxutil::FsIdentity; + use std::os::unix::fs::MetadataExt; + use std::os::unix::fs::PermissionsExt; + use std::path::Path; + use tempfile::TempDir; + use test_with_tracing::test; + use zerocopy::FromBytes; + use zerocopy::FromZeros; + use zerocopy::IntoBytes; + + const EACCES: i32 = -13; + const EPERM: i32 = -1; + + /// The export root owner when the tests run as root, which caller + /// ownership refuses as an owner. + const ROOT_RUN_OWNER: u32 = 65533; + + fn profile(root: &Path, owner_mode: MicroVmOwnerMode) -> MicroVmVirtioFsProfile { + MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(root).unwrap(), + false, + Vec::new(), + ) + .unwrap() + .with_owner_mode(owner_mode) + .unwrap() + } + + /// Creates a world-writable export root that root does not own. + fn export_root() -> TempDir { + let directory = tempfile::tempdir().unwrap(); + std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o777)).unwrap(); + if FsIdentity::current().uid == 0 { + std::os::unix::fs::chown(directory.path(), Some(ROOT_RUN_OWNER), Some(ROOT_RUN_OWNER)) + .unwrap(); + } + directory + } + + fn owner(path: &Path) -> (u32, u32) { + let metadata = std::fs::symlink_metadata(path).unwrap(); + (metadata.uid(), metadata.gid()) + } + + fn vmm() -> (u32, u32) { + let current = FsIdentity::current(); + (current.uid, current.gid) + } + + /// Returns whether the calling thread has supplementary groups other than + /// its filesystem GID, which an unprivileged VMM cannot drop. + fn has_other_groups() -> bool { + let gid = FsIdentity::current().gid; + let status = std::fs::read_to_string("/proc/thread-self/status").unwrap(); + let groups = status + .lines() + .find_map(|line| line.strip_prefix("Groups:")) + .unwrap(); + groups + .split_whitespace() + .any(|group| group.parse::().unwrap() != gid) + } + + /// Runs `test` without effective capabilities, as an unprivileged VMM does, + /// even if the tests run as root. + fn unprivileged(test: impl FnOnce() -> T) -> T { + let mut test = Some(test); + // Keeping the current identity clears the capabilities and the other + // supplementary groups. Only a thread without CAP_SETGID, which is + // already unprivileged, fails to drop those groups. + match lxutil::with_fs_identity(FsIdentity::current(), || test.take().unwrap()()) { + Ok(result) => result, + Err(_) => test.take().unwrap()(), + } + } + + /// Records the host identities that `dispatch_with` assumes, performing + /// each request as the test's own identity. + #[derive(Default)] + struct Recorder(Vec<(u32, u32)>); + + impl Recorder { + fn run_as(&mut self) -> impl FnOnce(u32, u32, &mut dyn FnMut()) -> lx::Result<()> + '_ { + |uid, gid, operation| { + self.0.push((uid, gid)); + operation(); + Ok(()) + } + } + } + + #[derive(Default)] + struct Reply(Vec); + + impl fuse::ReplySender for Reply { + fn send(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result<()> { + self.0.clear(); + for buf in bufs { + self.0.extend_from_slice(buf); + } + Ok(()) + } + } + + /// A mounted microVM share whose requests are dispatched the way the + /// virtio-fs worker dispatches them. + struct Share { + directory: TempDir, + session: Session, + caller_identity: Option, + next_unique: u64, + } + + impl Share { + fn new(owner_mode: MicroVmOwnerMode) -> Self { + let directory = export_root(); + let fs = VirtioFs::new_microvm(directory.path(), profile(directory.path(), owner_mode)) + .unwrap(); + let mut share = Self { + session: Session::new(fs.clone()), + caller_identity: fs.caller_identity(), + directory, + next_unique: 1, + }; + // Negotiation never accesses host files, so it never runs as its + // caller, and any caller may mount. + let mut init = fuse_init_in::new_zeroed(); + init.major = 7; + init.minor = 31; + let foreign = share.foreign(); + let (error, _) = share.send_with( + FUSE_INIT, + FUSE_ROOT_ID, + foreign, + init.as_bytes(), + |_, _, _| unreachable!("negotiation never runs as its caller"), + ); + assert_eq!(error, 0); + share + } + + /// Returns an identity that is neither the VMM's nor the export root + /// owner's. + fn foreign(&self) -> (u32, u32) { + let taken = [vmm(), self.root_owner()]; + let mut candidate = (4242, 4343); + while taken + .iter() + .any(|&(uid, gid)| uid == candidate.0 || gid == candidate.1) + { + candidate = (candidate.0 + 2, candidate.1 + 2); + } + candidate + } + + fn root_owner(&self) -> (u32, u32) { + owner(self.directory.path()) + } + + fn owner(&self, name: &str) -> (u32, u32) { + owner(&self.directory.path().join(name)) + } + + /// Sends one request and returns the reply's error and payload. + fn send( + &mut self, + opcode: u32, + node_id: u64, + caller: (u32, u32), + body: &[u8], + ) -> (i32, Vec) { + self.exchange( + opcode, + node_id, + caller, + body, + |identity, session, request, reply| { + owner::dispatch(identity, session, request, reply, None); + }, + ) + } + + /// Sends one request like [`Self::send`], but performs a request that + /// runs as its caller through `run_as`. + fn send_with( + &mut self, + opcode: u32, + node_id: u64, + caller: (u32, u32), + body: &[u8], + run_as: impl FnOnce(u32, u32, &mut dyn FnMut()) -> lx::Result<()>, + ) -> (i32, Vec) { + self.exchange( + opcode, + node_id, + caller, + body, + |identity, session, request, reply| { + owner::dispatch_with(identity, session, request, reply, None, run_as); + }, + ) + } + + fn exchange( + &mut self, + opcode: u32, + node_id: u64, + (uid, gid): (u32, u32), + body: &[u8], + dispatch: impl FnOnce(Option<&CallerIdentity>, &Session, Request, &mut Reply), + ) -> (i32, Vec) { + let header = fuse_in_header { + len: (size_of::() + body.len()) as u32, + opcode, + unique: self.next_unique, + nodeid: node_id, + uid, + gid, + pid: 1, + padding: 0, + }; + self.next_unique += 1; + let mut bytes = header.as_bytes().to_vec(); + bytes.extend_from_slice(body); + let mut reply = Reply::default(); + dispatch( + self.caller_identity.as_ref(), + &self.session, + Request::new(bytes.as_slice()).unwrap(), + &mut reply, + ); + let (header, payload) = fuse_out_header::read_from_prefix(&reply.0).unwrap(); + (header.error, payload.to_vec()) + } + + /// Creates the directory `name` in `parent` as `caller` and returns + /// the reply's error and the new node ID. + fn mkdir(&mut self, parent: u64, name: &str, caller: (u32, u32)) -> (i32, u64) { + let (error, payload) = self.send(FUSE_MKDIR, parent, caller, &mkdir_body(name)); + (error, entry_node_id(error, &payload)) + } + + /// Creates a directory like [`Self::mkdir`], but performs the request + /// through `run_as`. + fn mkdir_with( + &mut self, + parent: u64, + name: &str, + caller: (u32, u32), + run_as: impl FnOnce(u32, u32, &mut dyn FnMut()) -> lx::Result<()>, + ) -> (i32, u64) { + let (error, payload) = + self.send_with(FUSE_MKDIR, parent, caller, &mkdir_body(name), run_as); + (error, entry_node_id(error, &payload)) + } + + fn getattr(&mut self, node_id: u64, caller: (u32, u32)) -> i32 { + let arg = fuse_getattr_in::new_zeroed(); + self.send(FUSE_GETATTR, node_id, caller, arg.as_bytes()).0 + } + } + + fn mkdir_body(name: &str) -> Vec { + let mut body = fuse_mkdir_in { + mode: 0o755, + umask: 0, + } + .as_bytes() + .to_vec(); + body.extend_from_slice(name.as_bytes()); + body.push(0); + body + } + + fn entry_node_id(error: i32, payload: &[u8]) -> u64 { + if error == 0 { + fuse_entry_out::read_from_prefix(payload).unwrap().0.nodeid + } else { + 0 + } + } + + #[test] + fn requests_run_as_their_callers_with_guest_root_squashed() { + let mut share = Share::new(MicroVmOwnerMode::Caller); + let foreign = share.foreign(); + // Only a privileged process can create an export root that neither it + // nor `foreign` owns, so map guest root to such an owner directly. + let mut owner = (foreign.0 + 1, foreign.1 + 1); + while owner.0 == vmm().0 || owner.1 == vmm().1 { + owner = (owner.0 + 2, owner.1 + 2); + } + share.caller_identity = + Some(CallerIdentity::for_export_root_owner(owner.0, owner.1).unwrap()); + for (name, caller, expected) in [ + ("root", (0, 0), owner), + ("root-user", (0, foreign.1), (owner.0, foreign.1)), + ("root-group", (foreign.0, 0), (foreign.0, owner.1)), + ("foreign", foreign, foreign), + ] { + let mut recorder = Recorder::default(); + let (error, _) = share.mkdir_with(FUSE_ROOT_ID, name, caller, recorder.run_as()); + assert_eq!(error, 0, "{name}"); + assert_eq!(recorder.0, [expected], "{name}"); + } + + // A request that cannot run as its caller fails without being + // performed. + let (error, _) = share.mkdir_with(FUSE_ROOT_ID, "denied", foreign, |_, _, _| { + Err(lx::Error::EPERM) + }); + assert_eq!(error, EPERM); + assert!(!share.directory.path().join("denied").exists()); + } + + #[test] + fn guest_root_creates_entries_as_the_export_root_owner() { + let mut share = Share::new(MicroVmOwnerMode::Caller); + let (uid, gid) = share.root_owner(); + if lxutil::with_fs_identity(FsIdentity { uid, gid }, || ()).is_err() { + // An unprivileged VMM with other supplementary groups cannot drop + // them, so even guest root fails closed. + assert_eq!(share.mkdir(FUSE_ROOT_ID, "root", (0, 0)).0, EPERM); + assert!(!share.directory.path().join("root").exists()); + return; + } + let (error, directory) = share.mkdir(FUSE_ROOT_ID, "root", (0, 0)); + assert_eq!(error, 0); + let (error, _) = share.mkdir(directory, "nested", (0, 0)); + assert_eq!(error, 0); + assert_eq!(share.owner("root"), share.root_owner()); + assert_eq!(share.owner("root/nested"), share.root_owner()); + } + + #[test] + fn callers_with_the_vmm_identity_need_no_privilege_without_other_groups() { + // Root cannot own an export root that caller ownership accepts. + if FsIdentity::current().uid == 0 { + return; + } + let mut share = Share::new(MicroVmOwnerMode::Caller); + assert_eq!(share.root_owner(), vmm()); + unprivileged(|| { + if has_other_groups() { + // An unprivileged VMM cannot drop its other supplementary + // groups, so even callers with its identity fail closed. + assert_eq!(share.mkdir(FUSE_ROOT_ID, "root", (0, 0)).0, EPERM); + assert_eq!(share.mkdir(FUSE_ROOT_ID, "vmm", vmm()).0, EPERM); + assert!(!share.directory.path().join("root").exists()); + assert!(!share.directory.path().join("vmm").exists()); + } else { + assert_eq!(share.mkdir(FUSE_ROOT_ID, "root", (0, 0)).0, 0); + assert_eq!(share.mkdir(FUSE_ROOT_ID, "vmm", vmm()).0, 0); + assert_eq!(share.owner("root"), vmm()); + assert_eq!(share.owner("vmm"), vmm()); + } + }); + } + + #[test] + fn foreign_callers_fail_closed_without_privilege() { + let mut share = Share::new(MicroVmOwnerMode::Caller); + let foreign = share.foreign(); + let identity = FsIdentity::current(); + unprivileged(|| { + assert_eq!(share.getattr(FUSE_ROOT_ID, foreign), EPERM); + assert_eq!(share.mkdir(FUSE_ROOT_ID, "foreign", foreign).0, EPERM); + assert_eq!(FsIdentity::current(), identity); + }); + assert!(!share.directory.path().join("foreign").exists()); + } + + #[test] + fn vmm_mode_performs_every_request_as_the_vmm() { + let mut share = Share::new(MicroVmOwnerMode::Vmm); + assert!(share.caller_identity.is_none()); + let foreign = share.foreign(); + assert_eq!(share.mkdir(FUSE_ROOT_ID, "foreign", foreign).0, 0); + assert_eq!(share.owner("foreign"), vmm()); + } + + #[test] + fn root_owned_export_root_is_rejected() { + let root = Path::new("/"); + if owner(root) != (0, 0) { + return; + } + let error = VirtioFs::new_microvm(root, profile(root, MicroVmOwnerMode::Caller)) + .err() + .unwrap(); + assert!(error.to_string().contains("must not be UID 0 or GID 0")); + VirtioFs::new_microvm(root, profile(root, MicroVmOwnerMode::Vmm)).unwrap(); + } + + #[test] + fn saved_state_requires_the_same_owner_mode() { + let directory = export_root(); + let caller = profile(directory.path(), MicroVmOwnerMode::Caller); + let vmm = profile(directory.path(), MicroVmOwnerMode::Vmm); + for (saved, other) in [(&caller, &vmm), (&vmm, &caller)] { + let mut state = VirtioFs::new_microvm(directory.path(), saved.clone()) + .unwrap() + .save_microvm_state(saved, SessionState::default()) + .unwrap(); + let caller_owned = saved.owner_mode() == MicroVmOwnerMode::Caller; + assert_eq!(state.caller_identity, caller_owned); + assert_eq!( + state.schema_version, + if caller_owned { + CALLER_IDENTITY_SCHEMA_VERSION + } else { + SCHEMA_VERSION + } + ); + // Readers that predate caller ownership accept only the previous + // versions, so they refuse to restore a caller-owned attachment as + // the VMM. + assert_eq!( + matches!( + state.schema_version, + PREVIOUS_SCHEMA_VERSION | SCHEMA_VERSION + ), + !caller_owned + ); + validate_microvm_state(&state, saved).unwrap(); + assert!(validate_microvm_state(&state, other).is_err()); + + state.schema_version = if caller_owned { + SCHEMA_VERSION + } else { + CALLER_IDENTITY_SCHEMA_VERSION + }; + assert!(validate_microvm_state(&state, saved).is_err()); + } + } + + /// Run with `CAP_SETUID` and `CAP_SETGID`, for example as root. + #[test] + #[ignore = "requires CAP_SETUID and CAP_SETGID"] + fn foreign_callers_own_what_they_create_with_privilege() { + let mut share = Share::new(MicroVmOwnerMode::Caller); + let caller = share.foreign(); + let (error, directory) = share.mkdir(FUSE_ROOT_ID, "workload", caller); + assert_eq!(error, 0, "run with CAP_SETUID and CAP_SETGID"); + // The caller can populate the directory that it created. + assert_eq!(share.mkdir(directory, "nested", caller).0, 0); + assert_eq!(share.owner("workload"), caller); + assert_eq!(share.owner("workload/nested"), caller); + + // Host permissions apply to the caller, without the VMM's capabilities. + let intruder = (caller.0 + 1, caller.1 + 1); + assert_eq!(share.mkdir(directory, "intruder", intruder).0, EACCES); + // Guest root is squashed to the export root owner, never host root. + assert_eq!(share.mkdir(FUSE_ROOT_ID, "root", (0, 0)).0, 0); + assert_eq!(share.owner("root"), share.root_owner()); + + let (uid, gid) = caller; + let workload = share.directory.path().join("workload"); + lxutil::with_fs_identity(FsIdentity { uid, gid }, || { + std::fs::remove_dir_all(workload) + }) + .unwrap() + .unwrap(); + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/profile.rs b/vm/devices/virtio/virtiofs/src/microvm/profile.rs index 986cf32ff09..3f5c64dcdd6 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/profile.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/profile.rs @@ -72,6 +72,19 @@ pub enum MicroVmAccessMode { ReadWrite, } +/// Host identity that performs the guest's filesystem operations. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum MicroVmOwnerMode { + /// Run every operation as the VMM process, which then owns the files that + /// the guest creates. + Vmm, + /// Run each operation as the UID and GID of its guest caller, without + /// supplementary groups or capabilities. Guest UID 0 and GID 0 are replaced + /// by the owner of the export root, which therefore must not be UID 0 or + /// GID 0. Requires a Linux host. + Caller, +} + /// Errors in the fixed microVM virtio-fs attachment contract. #[derive(Debug, thiserror::Error)] pub enum MicroVmProfileError { @@ -87,6 +100,10 @@ pub enum MicroVmProfileError { /// The denied-path list was not canonical. #[error("microVM virtio-fs denied paths are invalid")] InvalidDeniedPaths, + /// Caller ownership needs per-thread filesystem credentials, which only + /// Linux provides. + #[error("microVM virtio-fs caller ownership requires a Linux host")] + UnsupportedOwnerMode, } /// Immutable profile settings for the microVM virtio-fs device. @@ -100,12 +117,14 @@ pub struct MicroVmVirtioFsProfile { root_identity: Vec, access_mode: MicroVmAccessMode, denied_paths: Vec, + owner_mode: MicroVmOwnerMode, } impl MicroVmVirtioFsProfile { /// Builds the constrained microVM profile from the resource attachment /// fields. `stable_id`, `root_identity`, and `read_only` correspond - /// exactly to `VirtioFsProfile::Microvm`. + /// exactly to `VirtioFsProfile::Microvm`. The guest's operations run as + /// the VMM until [`Self::with_owner_mode`] selects otherwise. pub fn from_attachment( stable_id: String, root_identity: Vec, @@ -128,9 +147,23 @@ impl MicroVmVirtioFsProfile { MicroVmAccessMode::ReadWrite }, denied_paths, + owner_mode: MicroVmOwnerMode::Vmm, }) } + /// Selects the host identity that performs the guest's filesystem + /// operations. + pub fn with_owner_mode( + mut self, + owner_mode: MicroVmOwnerMode, + ) -> Result { + if owner_mode == MicroVmOwnerMode::Caller && !cfg!(target_os = "linux") { + return Err(MicroVmProfileError::UnsupportedOwnerMode); + } + self.owner_mode = owner_mode; + Ok(self) + } + /// Returns the stable attachment ID used to match a restore attachment. pub fn attachment_id(&self) -> &str { &self.stable_id @@ -220,6 +253,11 @@ impl MicroVmVirtioFsProfile { matches!(self.access_mode, MicroVmAccessMode::ReadOnly) } + /// Returns the host identity that performs the guest's operations. + pub const fn owner_mode(&self) -> MicroVmOwnerMode { + self.owner_mode + } + /// Returns canonical host-relative paths hidden from the guest. pub fn denied_paths(&self) -> &[PathBuf] { &self.denied_paths @@ -376,11 +414,38 @@ mod tests { assert_eq!(profile.fuse_negotiation().protocol_major(), 7); assert_eq!(profile.fuse_negotiation().minimum_minor(), 31); assert_eq!(profile.fuse_negotiation().maximum_write(), 1024 * 1024); + assert_eq!(profile.owner_mode(), MicroVmOwnerMode::Vmm); profile .validate_root_path(temporary_directory.path()) .unwrap(); } + #[test] + fn profile_selects_caller_ownership_only_on_linux() { + let temporary_directory = tempfile::tempdir().unwrap(); + let profile = MicroVmVirtioFsProfile::from_attachment( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(temporary_directory.path()).unwrap(), + false, + Vec::new(), + ) + .unwrap(); + let vmm = profile.clone().with_owner_mode(MicroVmOwnerMode::Vmm); + assert_eq!(vmm.unwrap(), profile); + + let caller = profile.clone().with_owner_mode(MicroVmOwnerMode::Caller); + if cfg!(target_os = "linux") { + let caller = caller.unwrap(); + assert_eq!(caller.owner_mode(), MicroVmOwnerMode::Caller); + assert_ne!(caller, profile); + } else { + assert!(matches!( + caller, + Err(MicroVmProfileError::UnsupportedOwnerMode) + )); + } + } + #[test] fn profile_rejects_an_attachment_identity_mismatch() { let temporary_directory = tempfile::tempdir().unwrap(); diff --git a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs index 979ce74741f..214e555126a 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs @@ -4,6 +4,7 @@ //! microVM resource-profile resolution. use super::profile::MICROVM_MOUNT_TAG; +use super::profile::MicroVmOwnerMode; use crate::virtio::VirtioFsDevice; use virtio_resources::fs::VirtioFsBackend; use virtio_resources::fs::VirtioFsHandle; @@ -33,6 +34,7 @@ pub(crate) fn resolve( root_identity, read_only, denied_paths, + caller_identity, } => { anyhow::ensure!( resource.tag == MICROVM_MOUNT_TAG, @@ -56,6 +58,11 @@ pub(crate) fn resolve( root_identity.clone(), *read_only, denied_paths.clone(), + if *caller_identity { + MicroVmOwnerMode::Caller + } else { + MicroVmOwnerMode::Vmm + }, root_path, None, )? @@ -101,6 +108,7 @@ mod tests { root_identity, read_only: true, denied_paths: Vec::new(), + caller_identity: false, }, }, VirtioResolveInput { diff --git a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs index acbe29362bd..3a165bb4369 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs @@ -12,6 +12,10 @@ use vmcore::save_restore::SavedStateRoot; pub(crate) const PREVIOUS_SCHEMA_VERSION: u32 = 4; pub(crate) const SCHEMA_VERSION: u32 = 5; +/// The schema version of an attachment that performs requests as their guest +/// callers. Readers that predate caller ownership ignore `caller_identity` +/// and would restore the attachment as the VMM, so they must reject its state. +pub(crate) const CALLER_IDENTITY_SCHEMA_VERSION: u32 = 6; pub(crate) const MAX_INODES: usize = 4096; pub(crate) const MAX_HANDLES: usize = 4096; pub(crate) const MAX_PATH_BYTES: usize = 4096; @@ -61,6 +65,11 @@ pub(crate) struct SavedState { pub maximum_request_size: u32, #[mesh(17)] pub dormant: bool, + /// Whether requests run as their guest callers, which version + /// [`CALLER_IDENTITY_SCHEMA_VERSION`] states require. States that predate + /// this field ran every request as the VMM. + #[mesh(18)] + pub caller_identity: bool, } #[derive(Protobuf)] diff --git a/vm/devices/virtio/virtiofs/src/microvm/state.rs b/vm/devices/virtio/virtiofs/src/microvm/state.rs index de885eb418a..9e60ae365a2 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/state.rs @@ -9,7 +9,9 @@ use super::profile::MICROVM_FUSE_MAX_WRITE; use super::profile::MICROVM_FUSE_MIN_MINOR; use super::profile::MICROVM_REQUEST_QUEUES; use super::profile::MicroVmAccessMode; +use super::profile::MicroVmOwnerMode; use super::profile::MicroVmVirtioFsProfile; +use super::saved_state::CALLER_IDENTITY_SCHEMA_VERSION; use super::saved_state::MAX_ALIAS_BYTES; use super::saved_state::MAX_ALIASES; use super::saved_state::MAX_ALIASES_PER_INODE; @@ -215,6 +217,7 @@ pub(crate) fn save_dormant_microvm_state( attachment_root_identity: Vec::new(), maximum_request_size: MAX_FUSE_REQUEST_BYTES as u32, dormant: true, + caller_identity: false, }) } @@ -243,7 +246,8 @@ pub(crate) fn validate_dormant_microvm_state( && state.next_handle_id == 0 && state.inodes.is_empty() && state.handles.is_empty() - && state.attachment_root_identity.is_empty(), + && state.attachment_root_identity.is_empty() + && !state.caller_identity, "dormant virtio-fs state contains active filesystem policy or objects" ); let session_state = session_state_from_saved(&state.negotiation)?; @@ -261,11 +265,15 @@ pub(crate) fn validate_microvm_state( anyhow::ensure!( matches!( state.schema_version, - PREVIOUS_SCHEMA_VERSION | SCHEMA_VERSION + PREVIOUS_SCHEMA_VERSION | SCHEMA_VERSION | CALLER_IDENTITY_SCHEMA_VERSION ) && !state.dormant, "unsupported virtio-fs state schema version {}", state.schema_version ); + anyhow::ensure!( + state.caller_identity == (state.schema_version == CALLER_IDENTITY_SCHEMA_VERSION), + "saved ownership mode does not match the virtio-fs state schema version" + ); anyhow::ensure!( state.attachment_id == profile.attachment_id(), "saved attachment ID does not match the microVM ABI" @@ -278,6 +286,10 @@ pub(crate) fn validate_microvm_state( state.access_mode == saved_access_mode(profile.access_mode()), "saved access mode does not match the restore profile" ); + anyhow::ensure!( + state.caller_identity == (profile.owner_mode() == MicroVmOwnerMode::Caller), + "saved ownership mode does not match the restore profile" + ); anyhow::ensure!( state.request_queues == profile.request_queues() && state.shared_memory_size == 0 diff --git a/vm/devices/virtio/virtiofs/src/virtio.rs b/vm/devices/virtio/virtiofs/src/virtio.rs index 5e5ac1591d7..31960ac9989 100644 --- a/vm/devices/virtio/virtiofs/src/virtio.rs +++ b/vm/devices/virtio/virtiofs/src/virtio.rs @@ -3,6 +3,7 @@ use crate::VirtioFs; use crate::microvm::MAX_FUSE_REQUEST_BYTES; +use crate::microvm::owner::CallerIdentity; use crate::profile::MicroVmVirtioFsProfile; use crate::virtio_util::VirtioPayloadReader; use crate::virtio_util::VirtioPayloadWriter; @@ -86,6 +87,9 @@ pub struct VirtioFsDevice { pub(crate) microvm_profile: Option, #[inspect(skip)] pub(crate) stateful_fs: Option, + /// Set when requests run as their guest callers. + #[inspect(skip)] + pub(crate) caller_identity: Option, #[inspect(skip)] pub(crate) admission: Arc, #[inspect(skip)] @@ -229,6 +233,7 @@ impl VirtioFsDevice { microvm_attachment_id: None, microvm_profile: None, stateful_fs: None, + caller_identity: None, admission: Arc::new(RequestAdmission::new()), save_error: None, } @@ -290,6 +295,7 @@ impl VirtioDevice for VirtioFsDevice { shared_memory_size: self.shmem_size, notify_corruption: self.notify_corruption.clone(), admission: Arc::clone(&self.admission), + caller_identity: self.caller_identity, }); let queue_event = PolledWait::new(&self.driver, resources.event) @@ -323,6 +329,7 @@ impl VirtioDevice for VirtioFsDevice { shared_memory_size: 0, notify_corruption: self.notify_corruption.clone(), admission: Arc::clone(&self.admission), + caller_identity: self.caller_identity, }) }); } @@ -388,6 +395,7 @@ pub(crate) struct VirtioFsWorker { shared_memory_size: u64, notify_corruption: Arc, admission: Arc, + caller_identity: Option, } pub(crate) struct VirtioFsQueue { @@ -479,7 +487,9 @@ fn process_virtiofs_request( region: shared_memory_region.as_ref(), size: worker.shared_memory_size, }); - worker.fs.dispatch( + crate::microvm::owner::dispatch( + worker.caller_identity.as_ref(), + &worker.fs, request, &mut sender, mapper.as_ref().map(|x| x as &dyn fuse::Mapper), From fc7f7b2c4e0253d5485e4691452d9bcd051f0662 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 10:30:28 -0700 Subject: [PATCH 281/304] openvmm: pin an Alder Lake CPU profile and add opt-in host profiles Every microVM cold boot selects a pinned CPU profile, and the catalog served only three Xeon generations. On every other CPU, every Intel Core client CPU included, OpenVMM failed with E_PROFILE_HOST_UNKNOWN before it created the partition, so developers could no longer boot microVMs on the laptops and desktops that they use (microsoft/nvx#390, finding 1). Pin intel.alderlake.v1 for the twelfth-generation Core CPUs, Alder Lake-S (family 6, model 151) and Alder Lake-P and -H (6/154). The derivation policy produced it, unchanged, from the WHP fingerprint of a Core i9-12900H (6/154/3): the policy already clears the hybrid flag, CPUID.(7,0):EDX[15], and keeps no data in leaf 0x1A. Its XCR0 is 0x7, because WHP offers that host only x87, SSE, and AVX state, and its digest, sha256:2dfb6ccfd5a195b1614e6d963615c3372f40806b8f19b0d8425142f8c18a0c2e, equals the prototype's in the issue. The generated pinned_data.rs keeps the three existing profiles' constants byte for byte. The tests that used Alder Lake as an unknown host use Tiger Lake (6/140) instead, which still has no profile. A WHP fingerprint alone leaves Alder Lake hosts on KVM, other SKUs, and Alder Lake-S unverified; such a host fails with E_PROFILE_UNSUPPORTED if it lacks one of the profile's features. A pinned profile needs its generation's fingerprints on every backend that it serves, so most client CPUs will stay without one for a while. Add an opt-in host profile for development hosts instead of host CPUID passthrough: with --cpu-profile host, the VM worker fingerprints the backend on this host, as --cpu-fingerprint does, and derives intel.host.v1 from that one fingerprint under the same policy (derive_host_profile). The guest still sees a policy-filtered surface, and every verification step applies unchanged. The generation, named host, covers only the host's family, model, and stepping, and the brand string is "Intel(R) Processor (host profile)". auto never selects a host profile, and a CPU of another vendor than Intel fails with E_PROFILE_HOST_UNKNOWN. A host profile has no constants, so the partition holds it rather than the process. Selection returns a PartitionProfile: a pinned profile, or a reference-counted host profile with its encoding and digest, computed once. Core hands it to the backend in the partition's TimeAbiConfig, where the backends looked up a pinned profile's ID before, and capture copies its record. Each VM of a process therefore holds its own host profile, as successive VMs of one process, such as the ttrpc server's, need. A snapshot carries the only copy of the profile: the restore preflight and the VM worker decode the recorded document and accept it only as the canonical encoding of a valid host profile of the recorded ID and digest (host_profile_for_restore, E_PROFILE_DIGEST), and the worker's manifest carries the snapshot's record to the partition's profile selection. A host profile's ID, .host.v, follows the syntax of every profile ID, so a malformed revision such as intel.host.v0 names no profile (E_PROFILE_UNKNOWN). A restore accepts --cpu-profile host for a snapshot of a host profile. Deriving the profile costs a probe partition at each cold boot: on the i9-12900H, the partition was configured 5 ms later than with auto. The Guide lists the pinned profiles and describes host profiles and their restore rule. Unit tests cover the Alder Lake selection, the host profile's derivation and its failure codes, its ID syntax, its partition profile and record, two host profiles of one ID in one process, its restore checks, and the worker's and the restore preflight's handling. The package-scoped clippy, documentation, and unit-test gates and xtask fmt pass, and virt_kvm and virt_mshv pass clippy for Linux. On the i9-12900H with WHP, auto selects intel.alderlake.v1, the virt_whp hardware tests pass on it, and --cpu-profile host boots, captures, and restores, also on a build without the Alder Lake profile. Part of microsoft/nvx#390. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 11 +- Guide/src/user_guide/openvmm/snapshots.md | 27 +- openvmm/openvmm_core/src/worker/dispatch.rs | 22 +- .../src/worker/dispatch/microvm.rs | 5 + .../src/worker/dispatch/time_abi.rs | 274 +++++- openvmm/openvmm_defs/src/time_abi.rs | 9 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 5 +- openvmm/openvmm_helpers/src/snapshot/time.rs | 163 +++- .../profiles/intel.alderlake.v1.json | 781 ++++++++++++++++++ vmm_core/cpu_profile/src/catalog.rs | 382 ++++++++- vmm_core/cpu_profile/src/check.rs | 10 +- vmm_core/cpu_profile/src/derive.rs | 247 +++++- vmm_core/cpu_profile/src/lib.rs | 24 +- vmm_core/cpu_profile/src/pinned.rs | 19 +- vmm_core/cpu_profile/src/pinned_data.rs | 292 ++++++- vmm_core/cpu_profile/src/profile.rs | 29 +- vmm_core/virt/src/time_abi/mod.rs | 9 +- vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 2 +- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 33 +- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 93 +-- vmm_core/virt_whp/src/time_abi.rs | 52 +- 21 files changed, 2232 insertions(+), 257 deletions(-) create mode 100644 vmm_core/cpu_profile/profiles/intel.alderlake.v1.json diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 2f69f1f4c31..e05bc855b67 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -380,10 +380,13 @@ describes the source definitions. instantiate the full VP capacity. * `--restore-gate-timeout-ms `: Bound microVM guest repair and gate acknowledgement after restore. The default is 60000 milliseconds. -* `--cpu-profile `: Select the microVM's pinned CPU profile, or `auto` - (the default) for the profile of the host's CPU generation. A host without a - profile, or a profile of another generation, fails before the partition is - created. A restore uses the snapshot's profile and rejects any other. See +* `--cpu-profile `: Select the microVM's pinned CPU profile, `auto` + (the default) for the profile of the host's CPU generation, or `host` to + derive a development profile from this host's backend fingerprint for a + host that no pinned profile serves. `auto` never selects a host profile. A + host without a profile, or a profile of another generation, fails before the + partition is created. A restore uses the snapshot's profile and rejects any + other; `host` also names a host profile that the snapshot recorded. See [Time and CPU compatibility](../../../user_guide/openvmm/snapshots.md#time-and-cpu-compatibility). * `--x-time-abi-verify`: Build the microVM partition and run the time ABI checks (CPU profile support, effective CPUID, rates) without starting the diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index eb3d3767959..fbecb575d64 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -257,13 +257,24 @@ one-shot mode at a fixed rate: 1 GHz on KVM and 200 MHz on MSHV and WHP. TSC-deadline mode, `IA32_TSC_ADJUST`, and kvmclock are not exposed. The guest CPU is a pinned CPU profile: one per CPU generation, the same on -every backend. `--cpu-profile ` selects one; the default, `auto`, selects -the profile of the host's generation, and a host without one fails -(`E_PROFILE_HOST_UNKNOWN`). Every boot checks that the backend supports the -profile (`E_PROFILE_UNSUPPORTED`) and that VP 0 observes its effective CPUID -(`E_CPU_SURFACE`). A snapshot records the profile and the effective CPUID. -Restore requires the same profile, pinned in this OpenVMM with the same -digest, a host of the profile's generation, and an identical effective CPUID. +every backend. OpenVMM pins `intel.skylake-sp.v1` (Xeon Scalable, first +generation: family 6, model 85, steppings 0 to 4), `intel.icelake-sp.v1` +(Xeon Scalable, third generation: 6/106), `intel.emeraldrapids.v1` (Xeon +Scalable, fifth generation: 6/207), and `intel.alderlake.v1` (Core, twelfth +generation: 6/151 and 6/154). `--cpu-profile ` selects one; the default, +`auto`, selects the profile of the host's generation, and a host without one +fails (`E_PROFILE_HOST_UNKNOWN`). On an Intel development host without one, +`--cpu-profile host` boots on a host profile instead: OpenVMM fingerprints the +backend on this host, as `--cpu-fingerprint` does, and applies the pinned +profiles' derivation policy to it. A host profile, `intel.host.v1`, serves only +the host's CPU model and stepping, is not pinned, and can change with the +host's microcode, firmware, or hypervisor. Every boot checks that the backend +supports the profile (`E_PROFILE_UNSUPPORTED`) and that VP 0 observes its +effective CPUID (`E_CPU_SURFACE`). A snapshot records the profile and the +effective CPUID. Restore requires the same profile, pinned in this OpenVMM with +the same digest, or, for a host profile, the snapshot's own copy of it, which +must match the recorded digest; a host of the profile's generation; and an +identical effective CPUID. OpenVMM computes the effective CPUID, including its own leaves and the bits it owns, such as the cache topology, so an OpenVMM build that computes it differently rejects earlier snapshots with `E_CPU_SURFACE`. @@ -434,5 +445,7 @@ immediately with a clear error if any active device does not support it. supported. - MicroVM restore requires the backend, CPU generation, and CPU profile of the capture, and an OpenVMM build that computes the same effective CPUID. + A host profile's snapshot restores only on a host of the capture host's CPU + model and stepping. Snapshots from manifest versions before 6, or from a build that computes a different effective CPUID, must be recaptured. diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 91dd8efb64a..512b2c030f3 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -353,6 +353,14 @@ impl Worker for VmWorker { let hypervisor = block_on(ResourceResolver::new().resolve(parameters.hypervisor, ())) .context("failed to resolve hypervisor backend")?; + // A snapshot of a host CPU profile carries the profile's only copy, + // from which the partition takes its profile. + #[cfg(guest_arch = "x86_64")] + if let Some(input) = &restore_params.state.time_abi { + manifest.microvm.restored_host_profile = + time_abi::restored_host_profile(&input.cpu_profile); + } + let shared_memory = parameters .shared_memory .map(|fd| restore_params.shared_memory_backing(fd)); @@ -1265,12 +1273,16 @@ impl InitializedVm { #[cfg(guest_arch = "x86_64")] let (time_abi_partition, time_abi_config) = match &cfg.microvm.time_abi { Some(parameters) if cfg.machine_profile == MachineProfile::Microvm => { - let profile = time_abi::select_cpu_profile(¶meters.cpu_profile)?; + let profile = time_abi::select_cpu_profile( + ¶meters.cpu_profile, + &cfg.microvm.hypervisor_id, + cfg.microvm.restored_host_profile.as_ref(), + )?; let effective_cpuid = Arc::new(virt::time_abi::cpuid::effective_cpuid( - profile, + &profile, &processor_topology, )?); - let (msrs, config) = time_abi::partition_config(profile, &effective_cpuid); + let (msrs, config) = time_abi::partition_config(&profile, &effective_cpuid); ( Some(time_abi::PartitionTimeAbi { msrs, @@ -1972,11 +1984,11 @@ impl InitializedVm { time_abi::check_presented_cpuid( partition.as_ref(), hypervisor, - profile, + &profile, &effective_cpuid, )?; let presented_done = std::time::Instant::now(); - time_abi::check_profile_support(partition.as_ref(), hypervisor, profile)?; + time_abi::check_profile_support(partition.as_ref(), hypervisor, &profile)?; let support_done = std::time::Instant::now(); tracing::info!( cpu_profile = profile.id(), diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index bde54c194a3..dfa23f5ab15 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -51,6 +51,10 @@ pub(super) struct MicrovmManifest { pub(super) restore_memory_ranges: Vec, /// The NVX time ABI parameters, when the time ABI is selected. pub(super) time_abi: Option, + /// The CPU profile record of the snapshot that the worker restores, set + /// by the worker when the profile is a host profile: the snapshot carries + /// the profile's only copy, from which the partition takes its profile. + pub(super) restored_host_profile: Option, /// The hypervisor backend ID (`kvm`, `mshv`, or `whp`), set by the /// worker. pub(super) hypervisor_id: String, @@ -74,6 +78,7 @@ impl From for MicrovmManifest { snapshot_memory_ranges, restore_memory_ranges, time_abi, + restored_host_profile: None, hypervisor_id: String::new(), } } diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 9f3c2f8c195..6de6153460b 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -13,6 +13,10 @@ use anyhow::Context as _; use chipset_resources::microvm_time::RestoreTimeRecord; use cpu_profile::CpuProfile; use cpu_profile::EffectiveCpuid; +use cpu_profile::HostCpuSignature; +use cpu_profile::PartitionProfile; +use cpu_profile::ProfileError; +use cpu_profile::ProfileErrorCode; use futures_concurrency::future::Join; use inspect::Inspect; use inspect::InspectMut; @@ -60,7 +64,7 @@ pub(super) struct TimeAbiState { /// The identity MSR handler, holding the declared rates. pub msrs: Arc, /// The CPU profile. - pub profile: &'static CpuProfile, + pub profile: PartitionProfile, /// The effective CPUID of the partition. pub effective_cpuid: Arc, /// The preflight report. @@ -76,7 +80,7 @@ pub(super) struct PartitionTimeAbi { /// The identity MSR handler. pub msrs: Arc, /// The CPU profile. - pub profile: &'static CpuProfile, + pub profile: PartitionProfile, /// The effective CPUID of the partition. pub effective_cpuid: Arc, } @@ -84,36 +88,117 @@ pub(super) struct PartitionTimeAbi { /// Returns the identity MSR handler and the time ABI configuration of a new /// partition with the CPU profile `profile` and its effective CPUID. pub(super) fn partition_config( - profile: &CpuProfile, + profile: &PartitionProfile, effective_cpuid: &EffectiveCpuid, ) -> (Arc, TimeAbiConfig) { let msrs = Arc::new(TimeAbiMsrs::new()); let config = TimeAbiConfig { cpuid: Arc::new(virt::time_abi::cpuid::backend_cpuid(effective_cpuid)), msrs: msrs.clone(), - cpu_profile: profile.id().to_owned(), + cpu_profile: profile.clone(), }; (msrs, config) } /// Selects the CPU profile of a new partition before it is created. /// -/// `requested` is `--cpu-profile` on cold boot (`auto` or an ID), or the -/// profile a snapshot recorded on restore. It selects a pinned profile whose -/// generation contains the host CPU (`E_PROFILE_HOST_UNKNOWN`, -/// `E_PROFILE_UNKNOWN`, `E_CPU_GENERATION`). -pub(super) fn select_cpu_profile(requested: &str) -> anyhow::Result<&'static CpuProfile> { - Ok(cpu_profile::select(requested, &host_cpu()?)?) +/// `requested` is `--cpu-profile` on cold boot (`auto`, `host`, or an ID), +/// or the profile a snapshot recorded on restore. An ID or `auto` selects a +/// pinned profile whose generation contains the host CPU +/// (`E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNKNOWN`, `E_CPU_GENERATION`). +/// `host` derives a host profile from the `hypervisor` backend's fingerprint +/// of this host. A host profile's ID selects the profile of `restored`, the +/// record of the snapshot being restored, which carries the profile's only +/// copy ([`restored_host_profile`]). +pub(super) fn select_cpu_profile( + requested: &str, + hypervisor: &str, + restored: Option<&SnapshotCpuProfile>, +) -> anyhow::Result { + let host = host_cpu()?; + if requested == cpu_profile::HOST { + let profile = PartitionProfile::host(host_profile(hypervisor)?)?; + cpu_profile::check_generation(&profile, &host)?; + tracing::warn!( + cpu_profile = profile.id(), + profile_digest = %profile.digest_string(), + hypervisor, + "using a host CPU profile: it is not pinned, and its snapshots restore only on a host \ + of the same CPU model and stepping that supports it" + ); + return Ok(profile); + } + Ok(select_on_host(requested, &host, restored)?) +} + +/// Selects the CPU profile `requested` on the host CPU `host`, as +/// [`select_cpu_profile`] does for any request but `host`. +fn select_on_host( + requested: &str, + host: &HostCpuSignature, + restored: Option<&SnapshotCpuProfile>, +) -> Result { + if cpu_profile::is_host_profile_id(requested) { + let record = restored + .filter(|record| record.id == requested) + .ok_or_else(|| { + ProfileError::new( + ProfileErrorCode::ProfileUnknown, + format!( + "CPU profile {requested:?} is a host profile, which only a snapshot of it \ + carries" + ), + ) + })?; + let profile = PartitionProfile::host(cpu_profile::host_profile_for_restore( + &record.id, + &record.sha256, + &record.profile, + )?)?; + cpu_profile::check_generation(&profile, host)?; + return Ok(profile); + } + cpu_profile::select(requested, host) +} + +/// Derives the host profile of this host and the `hypervisor` backend: the +/// backend's CPU fingerprint, as `--cpu-fingerprint` takes it, under the +/// profiles' derivation policy. +fn host_profile(hypervisor: &str) -> anyhow::Result { + let probe = hypervisor_resources::probe_by_name(hypervisor) + .with_context(|| format!("the {hypervisor} backend cannot be fingerprinted"))?; + let backend = probe + .cpu_fingerprint(&[]) + .with_context(|| format!("failed to fingerprint the {hypervisor} backend"))?; + let host = cpu_profile::host::HostIdentity::collect().context("failed to identify the host")?; + let fingerprint = cpu_profile::fingerprint::CpuFingerprint::new( + cpu_profile::fingerprint::ToolIdentity { + name: "openvmm".to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + }, + host, + backend, + ); + Ok(cpu_profile::derive_host_profile(&fingerprint)?) +} + +/// Returns the CPU profile record of a snapshot that the worker restores, if +/// its profile is a host profile: the snapshot carries the profile's only +/// copy, from which [`select_cpu_profile`] takes the partition's profile. The +/// controller's restore preflight has checked the record; the selection +/// decodes it again, because the VM worker can run in another process. +pub(super) fn restored_host_profile(record: &SnapshotCpuProfile) -> Option { + cpu_profile::is_host_profile_id(&record.id).then(|| record.clone()) } /// Returns the vendor and signature of the host CPU (`E_CPU_GENERATION` if /// the host is not x86-64). -fn host_cpu() -> Result { +fn host_cpu() -> Result { // The host CPU is identified with the host's CPUID instruction. // xtask-fmt allow-target-arch cpu-intrinsic #[cfg(target_arch = "x86_64")] { - Ok(cpu_profile::HostCpuSignature::current()) + Ok(HostCpuSignature::current()) } // xtask-fmt allow-target-arch cpu-intrinsic #[cfg(not(target_arch = "x86_64"))] @@ -434,8 +519,7 @@ pub(super) fn capture_records( .context("the time ABI rates are not declared")?; let anchor = backend.capture_anchor()?; let identity = virt::time_abi::host::host_identity()?; - let pinned = cpu_profile::pinned_record(state.profile.id()) - .with_context(|| format!("CPU profile {} is not pinned", state.profile.id()))?; + let record = state.profile.record(); tracing::info!( tsc = anchor.tsc, utc_ns = anchor.sample.utc_ns, @@ -458,9 +542,9 @@ pub(super) fn capture_records( capture_generation: state.generation, }, cpu_profile: SnapshotCpuProfile { - id: pinned.id.to_owned(), - sha256: pinned.digest.to_vec(), - profile: pinned.encoding.to_vec(), + id: record.id.to_owned(), + sha256: record.digest.to_vec(), + profile: record.encoding.to_vec(), effective_cpuid: encode_effective_cpuid(record_entries(&state.effective_cpuid)), capture_cpu_signature: virt::time_abi::surface::host_cpu_signature().unwrap_or(0), }, @@ -621,10 +705,18 @@ mod tests { const TEST_PROFILE: &str = "intel.icelake-sp.v1"; + /// The signature of the test profile's CPU, an Ice Lake-SP Xeon. + const TEST_SIGNATURE: u32 = 0x0006_06a6; + fn test_profile() -> &'static CpuProfile { cpu_profile::pinned(TEST_PROFILE).unwrap() } + /// Returns the partition profile of the pinned `profile`. + fn partition_profile(profile: &CpuProfile) -> PartitionProfile { + PartitionProfile::pinned(profile.id()).unwrap() + } + fn topology(vp_count: u32, x2apic: X2ApicState) -> ProcessorTopology { TopologyBuilder::new_x86() .vps_per_socket(vp_count) @@ -639,7 +731,7 @@ mod tests { let profile = test_profile(); let effective = effective_cpuid(profile, &topology(vp_count, X2ApicState::Supported)).unwrap(); - let (msrs, config) = partition_config(profile, &effective); + let (msrs, config) = partition_config(&partition_profile(profile), &effective); (msrs, config, effective) } @@ -717,8 +809,8 @@ mod tests { for vp_count in [1, 2, 8] { for x2apic in [X2ApicState::Unsupported, X2ApicState::Supported] { let effective = effective_cpuid(profile, &topology(vp_count, x2apic)).unwrap(); - let (_, config) = partition_config(profile, &effective); - assert_eq!(config.cpu_profile, profile.id()); + let (_, config) = partition_config(&partition_profile(profile), &effective); + assert_eq!(config.cpu_profile.id(), profile.id()); let mut cpuid = |leaf, subleaf| config.cpuid.result(leaf, subleaf, &[0; 4]); virt::time_abi::identity::check_identity(&mut cpuid, vp_count).unwrap(); @@ -752,7 +844,7 @@ mod tests { for vp_count in [1, 2, 8, 64] { for x2apic in [X2ApicState::Unsupported, X2ApicState::Supported] { let effective = effective_cpuid(profile, &topology(vp_count, x2apic)).unwrap(); - let (_, config) = partition_config(profile, &effective); + let (_, config) = partition_config(&partition_profile(profile), &effective); check_presented(profile, &effective, config.cpuid.leaves().to_vec()) .unwrap_or_else(|err| { panic!("{} with {vp_count} VPs, {x2apic:?}: {err:#}", profile.id()) @@ -807,7 +899,7 @@ mod tests { assert_eq!(leaf_b[2], (Some(2), [0, 0, 2, 0], [!0; 4])); assert!(subleaves(0x1f).is_empty()); - let (_, config) = partition_config(profile, &effective); + let (_, config) = partition_config(&partition_profile(profile), &effective); let terminator = config .cpuid .leaves() @@ -925,19 +1017,26 @@ mod tests { #[test] fn cpu_profile_selection() { - for requested in ["interim.host.kvm.v1", "intel.cascadelake.v1"] { - assert_eq!(code(select_cpu_profile(requested)), "E_PROFILE_UNKNOWN"); + for requested in [ + "interim.host.kvm.v1", + "intel.cascadelake.v1", + "intel.host.v1", + ] { + assert_eq!( + code(select_cpu_profile(requested, "kvm", None)), + "E_PROFILE_UNKNOWN" + ); } // `auto` selects the pinned profile of the host's generation, if any, // and a pinned ID selects its profile only in its generation. let host = host_cpu().unwrap(); - match select_cpu_profile("auto") { - Ok(profile) => assert!(cpu_profile::check_generation(profile, &host).is_ok()), + match select_cpu_profile("auto", "kvm", None) { + Ok(profile) => assert!(cpu_profile::check_generation(&profile, &host).is_ok()), Err(err) => assert_eq!(code::<()>(Err(err)), "E_PROFILE_HOST_UNKNOWN"), } for profile in cpu_profile::pinned_profiles() { - let selected = select_cpu_profile(profile.id()); + let selected = select_cpu_profile(profile.id(), "kvm", None); if cpu_profile::check_generation(profile, &host).is_ok() { assert_eq!(selected.unwrap().id(), profile.id()); } else { @@ -945,4 +1044,125 @@ mod tests { } } } + + #[test] + fn a_host_profile_needs_a_backend_fingerprint() { + let error = select_cpu_profile(cpu_profile::HOST, "nosuch", None).unwrap_err(); + assert_eq!( + format!("{error:#}"), + "the nosuch backend cannot be fingerprinted" + ); + } + + /// Returns the host profile that `--cpu-profile host` derives on a + /// `backend` host of the test profile's CPU whose surface is the test + /// profile's, and a snapshot's record of it. + fn test_host_profile(backend: &str) -> (CpuProfile, SnapshotCpuProfile) { + let cpuid = test_profile() + .cpuid() + .iter() + .map(|entry| { + let (leaf, subleaf) = entry.key(); + cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, entry.values()) + }) + .collect(); + let host = cpu_profile::host::HostIdentity { + cpu: cpu_profile::host::HostCpu { + vendor: "GenuineIntel".to_owned(), + signature: cpu_profile::Hex32(TEST_SIGNATURE), + family: 6, + model: 106, + stepping: 6, + brand: String::new(), + microcode: Vec::new(), + invariant_tsc: true, + tsc_deadline: true, + tsc_adjust: true, + }, + os: cpu_profile::host::HostOs { + kind: "windows".to_owned(), + release: None, + version: None, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }, + hypervisor: None, + }; + let fingerprint = cpu_profile::fingerprint::CpuFingerprint::new( + cpu_profile::fingerprint::ToolIdentity { + name: "test".to_owned(), + version: "0".to_owned(), + }, + host, + cpu_profile::fingerprint::BackendFingerprint::new(backend, "test", cpuid), + ); + let profile = cpu_profile::derive_host_profile(&fingerprint).unwrap(); + let record = SnapshotCpuProfile { + id: profile.id().to_owned(), + sha256: profile.digest().to_vec(), + profile: profile.encode(), + effective_cpuid: Vec::new(), + capture_cpu_signature: TEST_SIGNATURE, + }; + (profile, record) + } + + #[test] + fn restore_takes_a_host_profile_from_the_snapshot() { + let (profile, record) = test_host_profile("whp"); + let host = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); + assert_eq!(restored_host_profile(&record), Some(record.clone())); + let selected = select_on_host(profile.id(), &host, Some(&record)).unwrap(); + assert!(selected.is_host()); + assert_eq!(*selected, profile); + assert_eq!(selected.record().encoding, record.profile.as_slice()); + assert_eq!( + selected.record().digest.as_slice(), + record.sha256.as_slice() + ); + + // Each VM takes its own snapshot's host profile, even one of the same + // ID, here derived from another backend's fingerprint. + let (other, other_record) = test_host_profile("kvm"); + assert_eq!(other.id(), profile.id()); + let reselected = select_on_host(other.id(), &host, Some(&other_record)).unwrap(); + assert_eq!(*reselected, other); + assert_ne!(reselected.record().digest, selected.record().digest); + + let error_code = |result: Result| result.unwrap_err().code; + // Only the snapshot of a host profile carries it. + assert_eq!( + error_code(select_on_host(profile.id(), &host, None)), + ProfileErrorCode::ProfileUnknown + ); + let mut renamed = record.clone(); + renamed.id = "intel.host.v2".to_owned(); + assert_eq!( + error_code(select_on_host(profile.id(), &host, Some(&renamed))), + ProfileErrorCode::ProfileUnknown + ); + // A host profile serves only its own CPU model and stepping. + let elsewhere = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE + 1); + assert_eq!( + error_code(select_on_host(profile.id(), &elsewhere, Some(&record))), + ProfileErrorCode::CpuGeneration + ); + // A pinned profile's document under the host profile's ID. + let pinned = cpu_profile::pinned_record(TEST_PROFILE).unwrap(); + let mut swapped = record.clone(); + swapped.profile = pinned.encoding.to_vec(); + swapped.sha256 = pinned.digest.to_vec(); + assert_eq!( + error_code(select_on_host(profile.id(), &host, Some(&swapped))), + ProfileErrorCode::ProfileDigest + ); + // A pinned profile's snapshot needs no record: its ID selects it. + let mut pinned_snapshot = swapped; + pinned_snapshot.id = TEST_PROFILE.to_owned(); + assert_eq!(restored_host_profile(&pinned_snapshot), None); + let selected = select_on_host(TEST_PROFILE, &host, None).unwrap(); + assert!(!selected.is_host()); + assert_eq!(selected.record(), pinned); + } } diff --git a/openvmm/openvmm_defs/src/time_abi.rs b/openvmm/openvmm_defs/src/time_abi.rs index 4fda0875c74..31056ab5a67 100644 --- a/openvmm/openvmm_defs/src/time_abi.rs +++ b/openvmm/openvmm_defs/src/time_abi.rs @@ -94,7 +94,10 @@ impl SnapshotTimeContract { /// checks it by comparison, so neither encodes a document nor computes a /// digest: `sha256` and `profile` are the pinned profile's precomputed digest /// and canonical encoding, and `effective_cpuid` is -/// [`encode_effective_cpuid`] of the partition's effective CPUID. +/// [`encode_effective_cpuid`] of the partition's effective CPUID. A host +/// profile (`--cpu-profile host`) has no constants: capture records the +/// digest and encoding computed when the VM worker selected it, and restore +/// decodes the document and checks it against the digest. #[derive(Clone, Debug, PartialEq, Eq, Protobuf)] #[mesh(package = "openvmm.snapshot")] pub struct SnapshotCpuProfile { @@ -230,8 +233,8 @@ pub struct RestoreTimeInput { /// Time ABI parameters of every microVM worker. #[derive(Debug, MeshPayload)] pub struct TimeAbiParameters { - /// The CPU profile: a pinned profile ID or `auto`. On restore it must - /// match the snapshot's profile. + /// The CPU profile: a pinned profile ID, `auto`, or `host`. On restore it + /// is the snapshot's profile ID. pub cpu_profile: String, /// The generation counter of this VM process. pub generation: u32, diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 344e3662b4c..fac6929c31c 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -398,8 +398,9 @@ pub struct MicrovmCli { )] pub microvm_control_auth_timeout_ms: u64, - /// microVM CPU profile: a pinned profile ID, or `auto` (the default) to - /// select the host's profile; a restore must name the snapshot's profile + /// microVM CPU profile: a pinned profile ID, `auto` (the default) to + /// select the host's profile, or `host` to derive a development profile + /// from this host; a restore must name the snapshot's profile #[clap(long = "cpu-profile", value_name = "ID", hide = true)] pub cpu_profile: Option, diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index 04ed8031c54..0d1656df913 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -178,9 +178,11 @@ pub struct TimeAbiRestorePreflight { /// The snapshot's profile must be pinned in this OpenVMM with the same /// digest, and the embedded document must be the pinned profile's canonical /// encoding (`E_PROFILE_UNKNOWN`, `E_PROFILE_DIGEST`). Both are compared with -/// the build's precomputed record, without encoding or hashing. `host` must -/// be in the profile's generation. The worker recomputes the effective CPUID -/// and compares it with the record. +/// the build's precomputed record, without encoding or hashing. A host +/// profile (`--cpu-profile host`) has no pinned record, so its embedded +/// document is decoded and checked against the recorded digest instead +/// (`E_PROFILE_DIGEST`). `host` must be in the profile's generation. The +/// worker recomputes the effective CPUID and compares it with the record. pub fn preflight_time_abi_restore( contract: &SnapshotMachineContract, hypervisor: &str, @@ -203,7 +205,11 @@ pub fn preflight_time_abi_restore( ), )); } - if requested_cpu_profile != cpu_profile::AUTO && requested_cpu_profile != record.id { + let host_profile = cpu_profile::is_host_profile_id(&record.id); + if requested_cpu_profile != cpu_profile::AUTO + && requested_cpu_profile != record.id + && !(host_profile && requested_cpu_profile == cpu_profile::HOST) + { return Err(TimeAbiError::new( TimeAbiCode::ProfileUnknown, format!( @@ -218,23 +224,29 @@ pub fn preflight_time_abi_restore( "the host CPU cannot be identified", ) })?; - let profile = cpu_profile::pinned_for_restore(&record.id, &record.sha256)?; - let pinned = cpu_profile::pinned_record(&record.id).ok_or_else(|| { - TimeAbiError::new( - TimeAbiCode::ProfileUnknown, - format!("CPU profile {} is not pinned", record.id), - ) - })?; - if record.profile != pinned.encoding { - return Err(TimeAbiError::new( - TimeAbiCode::ProfileDigest, - format!( - "the snapshot's embedded CPU profile is not the canonical encoding of the pinned profile {}", - record.id - ), - )); + if host_profile { + let profile = + cpu_profile::host_profile_for_restore(&record.id, &record.sha256, &record.profile)?; + cpu_profile::check_generation(&profile, &host)?; + } else { + let profile = cpu_profile::pinned_for_restore(&record.id, &record.sha256)?; + let pinned = cpu_profile::pinned_record(&record.id).ok_or_else(|| { + TimeAbiError::new( + TimeAbiCode::ProfileUnknown, + format!("CPU profile {} is not pinned", record.id), + ) + })?; + if record.profile != pinned.encoding { + return Err(TimeAbiError::new( + TimeAbiCode::ProfileDigest, + format!( + "the snapshot's embedded CPU profile is not the canonical encoding of the pinned profile {}", + record.id + ), + )); + } + cpu_profile::check_generation(profile, &host)?; } - cpu_profile::check_generation(profile, &host)?; let capture = capture_record(time)?; let downtime = select_downtime(&capture, destination, now, hooks)?; Ok(TimeAbiRestorePreflight { @@ -709,6 +721,117 @@ pub(super) mod tests { fail(&contract, "whp", "auto", host, &now, &excessive), TimeAbiCode::DowntimeExcessive ); + // `--cpu-profile host` names no pinned profile. + assert_eq!( + fail(&contract, "whp", cpu_profile::HOST, host, &now, &hooks), + TimeAbiCode::ProfileUnknown + ); + } + + /// Returns the host profile that `--cpu-profile host` derives on a WHP + /// host of the test profile's generation whose surface is the test + /// profile's. + fn test_host_profile() -> cpu_profile::CpuProfile { + let pinned = cpu_profile::pinned(TEST_PROFILE).unwrap(); + let cpuid = pinned + .cpuid() + .iter() + .map(|entry| { + let (leaf, subleaf) = entry.key(); + cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, entry.values()) + }) + .collect(); + let host = cpu_profile::host::HostIdentity { + cpu: cpu_profile::host::HostCpu { + vendor: "GenuineIntel".to_owned(), + signature: cpu_profile::Hex32(TEST_SIGNATURE), + family: 6, + model: 106, + stepping: 6, + brand: String::new(), + microcode: Vec::new(), + invariant_tsc: true, + tsc_deadline: true, + tsc_adjust: true, + }, + os: cpu_profile::host::HostOs { + kind: "windows".to_owned(), + release: None, + version: None, + cpu_flags: Vec::new(), + clocksource: None, + available_clocksources: Vec::new(), + }, + hypervisor: None, + }; + let fingerprint = cpu_profile::fingerprint::CpuFingerprint::new( + cpu_profile::fingerprint::ToolIdentity { + name: "test".to_owned(), + version: "0".to_owned(), + }, + host, + cpu_profile::fingerprint::BackendFingerprint::new("whp", "test", cpuid), + ); + cpu_profile::derive_host_profile(&fingerprint).unwrap() + } + + #[test] + fn restore_preflight_checks_a_recorded_host_profile() { + let (mut contract, destination, now) = test_restore(); + let hooks = TimeAbiTestHooks::default(); + let profile = test_host_profile(); + assert_eq!(profile.id(), "intel.host.v1"); + *contract.cpu_profile.as_mut().unwrap() = SnapshotCpuProfile { + id: profile.id().to_owned(), + sha256: profile.digest().to_vec(), + profile: profile.encode(), + effective_cpuid: effective_cpuid_record(&test_effective_cpuid(&profile, 2)), + capture_cpu_signature: TEST_SIGNATURE, + }; + let result = |contract: &SnapshotMachineContract, requested: &str, signature: u32| { + preflight( + contract, + "whp", + requested, + Some(signature), + &destination, + &now, + &hooks, + ) + }; + for requested in ["auto", cpu_profile::HOST, profile.id()] { + let restored = result(&contract, requested, TEST_SIGNATURE).unwrap(); + assert_eq!(restored.cpu_profile, profile.id()); + assert_eq!(restored.generation, 4); + } + let code = |result: Result| result.unwrap_err().code; + // A pinned profile is not the snapshot's. + assert_eq!( + code(result(&contract, TEST_PROFILE, TEST_SIGNATURE)), + TimeAbiCode::ProfileUnknown + ); + // A host profile serves only its own CPU model and stepping. + assert_eq!( + code(result(&contract, "auto", TEST_SIGNATURE + 1)), + TimeAbiCode::CpuGeneration + ); + // The digest must match the embedded document. + let mut corrupted = contract.clone(); + corrupted.cpu_profile.as_mut().unwrap().sha256[0] ^= 1; + assert_eq!( + code(result(&corrupted, "auto", TEST_SIGNATURE)), + TimeAbiCode::ProfileDigest + ); + // A pinned profile's document under the host profile's ID. + let mut swapped = contract.clone(); + let record = swapped.cpu_profile.as_mut().unwrap(); + let pinned = cpu_profile::pinned_record(TEST_PROFILE).unwrap(); + record.profile = pinned.encoding.to_vec(); + record.sha256 = pinned.digest.to_vec(); + assert_eq!( + code(result(&swapped, "auto", TEST_SIGNATURE)), + TimeAbiCode::ProfileDigest + ); } #[test] diff --git a/vmm_core/cpu_profile/profiles/intel.alderlake.v1.json b/vmm_core/cpu_profile/profiles/intel.alderlake.v1.json new file mode 100644 index 00000000000..58258c60f90 --- /dev/null +++ b/vmm_core/cpu_profile/profiles/intel.alderlake.v1.json @@ -0,0 +1,781 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001c", + "0x756e6547", + "0x6c65746e", + "0x49656e69" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x000906a3", + "0x00000800", + "0xf6da3203", + "0x0f8bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00feff01", + "0x000000f0", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x02c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x0240003f", + "0x000007ff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0x00003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x02c0003f", + "0x00007fff", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000002", + "0x219c27a9", + "0x18400724", + "0xac004410" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000810", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000001" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000007", + "0x00000000", + "0x00000340", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000008", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000121", + "0x2c100800" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x65746e49", + "0x2952286c", + "0x726f4320", + "0x4d542865" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x72502029", + "0x7365636f", + "0x20726f73", + "0x646c4128" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x4c207265", + "0x29656b61", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x05007040", + "0x00000000" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000302e", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "Intel Core, twelfth generation (Alder Lake), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 6, + "model": 151, + "steppings": [ + 0, + 15 + ] + }, + { + "family": 6, + "model": 154, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "alderlake" + }, + "id": "intel.alderlake.v1", + "msrs": [ + { + "index": "0x0000010a", + "mask": "0x400000000c12e1bb", + "value": "0x000000000002e123" + } + ], + "physical_address_width": 46, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "whp", + "hosts": 1, + "surface_digests": [ + "sha256:8e0184d897cb2641df7306afe86a1d4af5f4740d775719f4be49bcd1e0f15b53" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "GenuineIntel", + "xcr0": "0x0000000000000007", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index a64ec33baa2..01312cef6e8 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -5,20 +5,34 @@ //! profile checks of cold boot and restore. use crate::canonical; +use crate::derive::HOST_GENERATION; use crate::derive::KnownGeneration; use crate::error::ProfileError; use crate::error::ProfileErrorCode; use crate::pinned::PinnedRecord; use crate::pinned_data::PINNED; use crate::profile::CpuProfile; +use crate::profile::is_id_component; +use crate::profile::is_revision; use crate::signature::HostCpuSignature; use crate::signature::decode_signature; +use std::fmt; +use std::ops::Deref; +use std::sync::Arc; use std::sync::OnceLock; /// The `--cpu-profile` value that selects the profile of the host's /// generation. pub const AUTO: &str = "auto"; +/// The `--cpu-profile` value that selects a host profile: a profile that the +/// VM worker derives from the backend's fingerprint of this host +/// ([`derive_host_profile`](crate::derive::derive_host_profile)) and that +/// only its partition holds ([`PartitionProfile::host`]). It is opt-in, for +/// development hosts that no pinned profile serves: [`AUTO`] never selects +/// it. +pub const HOST: &str = "host"; + /// Each pinned profile, built from its static data on first use, so that a /// cold boot or a restore builds only its own. static PROFILES: [OnceLock; PINNED.len()] = [const { OnceLock::new() }; PINNED.len()]; @@ -54,10 +68,160 @@ pub fn pinned(id: &str) -> Option<&'static CpuProfile> { /// Returns the precomputed record of the pinned profile `id`: its canonical /// encoding and digest, constants that a snapshot's CPU profile record /// copies at capture and that the restore preflight compares byte for byte. -pub fn pinned_record(id: &str) -> Option { +pub fn pinned_record(id: &str) -> Option> { position(id).map(|index| PINNED[index].record()) } +/// A host profile with its record, computed once, when the VM worker selects +/// the profile. +struct HostProfile { + profile: CpuProfile, + encoding: Vec, + digest: [u8; 32], +} + +/// The CPU profile of a partition: a pinned profile, or a host profile. +/// +/// A host profile has no constants in this OpenVMM. The VM worker derives it +/// for `--cpu-profile host` +/// ([`derive_host_profile`](crate::derive::derive_host_profile)) or decodes +/// it from the snapshot that it restores ([`host_profile_for_restore`]), and +/// only the partitions that use it hold it, so each VM of a process can have +/// its own. Core hands the partition's profile to the backend in its time ABI +/// configuration, and capture copies its [`record`](Self::record). Clones +/// share the profile. +#[derive(Clone)] +pub struct PartitionProfile(Source); + +#[derive(Clone)] +enum Source { + /// The pinned profile at this index in [`PINNED`]. + Pinned(usize), + /// A host profile. + Host(Arc), +} + +impl PartitionProfile { + /// Returns the partition profile of the pinned profile `id`, if this + /// OpenVMM pins it. + pub fn pinned(id: &str) -> Option { + position(id).map(|index| Self(Source::Pinned(index))) + } + + /// Returns the partition profile of `profile`, a host profile, and + /// computes its record. + /// + /// Fails with `E_PROFILE_UNKNOWN` for a profile that is not a host + /// profile. + pub fn host(profile: CpuProfile) -> Result { + if profile.generation().name != HOST_GENERATION || !is_host_profile_id(profile.id()) { + return Err(ProfileError::new( + ProfileErrorCode::ProfileUnknown, + format!("CPU profile {} is not a host profile", profile.id()), + )); + } + let encoding = profile.encode(); + let digest = canonical::sha256(&encoding); + Ok(Self(Source::Host(Arc::new(HostProfile { + profile, + encoding, + digest, + })))) + } + + /// Returns whether the profile is a host profile. + pub fn is_host(&self) -> bool { + matches!(self.0, Source::Host(_)) + } + + /// Returns the profile's record, which capture copies into a snapshot: + /// the pinned profile's precomputed constants, or the host profile's + /// encoding and digest, computed when it was selected. + pub fn record(&self) -> PinnedRecord<'_> { + match &self.0 { + Source::Pinned(index) => PINNED[*index].record(), + Source::Host(host) => PinnedRecord { + id: host.profile.id(), + encoding: &host.encoding, + digest: host.digest, + }, + } + } +} + +impl Deref for PartitionProfile { + type Target = CpuProfile; + + fn deref(&self) -> &CpuProfile { + match &self.0 { + Source::Pinned(index) => load(*index), + Source::Host(host) => &host.profile, + } + } +} + +impl fmt::Debug for PartitionProfile { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PartitionProfile") + .field("id", &self.id()) + .field("host", &self.is_host()) + .finish() + } +} + +/// Returns whether `id` names a host profile, `.host.v`, +/// with the syntax of every profile ID: the vendor is lowercase ASCII +/// letters, digits, and hyphens, and the revision a decimal number without a +/// leading zero. No pinned profile has such an ID. +pub fn is_host_profile_id(id: &str) -> bool { + let mut components = id.split('.'); + matches!( + (components.next(), components.next(), components.next(), components.next()), + (Some(vendor), Some(HOST_GENERATION), Some(revision), None) + if is_id_component(vendor) && revision.strip_prefix('v').is_some_and(is_revision) + ) +} + +/// Returns the host profile that a snapshot recorded by ID, SHA-256, and +/// canonical document, for a restore, after checking that the document is +/// the canonical encoding of a valid host profile with that ID and digest +/// (`E_PROFILE_DIGEST`). An ID that does not name a host profile fails with +/// `E_PROFILE_UNKNOWN`. +/// +/// Unlike [`pinned_for_restore`], this decodes and hashes the document: no +/// constant exists to compare it with. The restore preflight then checks +/// the destination host with [`check_generation`], and the VM worker +/// selects the profile for the partition ([`PartitionProfile::host`]). +pub fn host_profile_for_restore( + id: &str, + sha256: &[u8], + document: &[u8], +) -> Result { + if !is_host_profile_id(id) { + return Err(ProfileError::new( + ProfileErrorCode::ProfileUnknown, + format!("CPU profile {id:?} is not a host profile"), + )); + } + if canonical::sha256(document).as_slice() != sha256 { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!("the snapshot's host CPU profile {id} does not match its recorded digest"), + )); + } + let profile = CpuProfile::decode(document)?; + if profile.id() != id || profile.generation().name != HOST_GENERATION { + return Err(ProfileError::new( + ProfileErrorCode::ProfileDigest, + format!( + "the snapshot records CPU profile {id}, but its embedded document is {}", + profile.id() + ), + )); + } + Ok(profile) +} + /// Returns the name of the generation of the host CPU, if a pinned profile /// serves it: the generation of the profile [`select_auto`] selects. pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { @@ -137,17 +301,21 @@ fn select_auto_in<'a>( }) } -/// Selects the profile for `--cpu-profile spec`, [`AUTO`] or a pinned ID, -/// and checks that the host CPU is in its generation. +/// Selects the pinned profile for `--cpu-profile spec`, [`AUTO`] or a pinned +/// ID, and checks that the host CPU is in its generation. A host profile has +/// no ID to select it by: the VM worker derives it for [`HOST`], or takes a +/// restored snapshot's ([`PartitionProfile::host`]). /// /// Fails with `E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNKNOWN` for an ID that is /// not pinned, or `E_CPU_GENERATION`. -pub fn select(spec: &str, host: &HostCpuSignature) -> Result<&'static CpuProfile, ProfileError> { - if spec == AUTO { - return select_auto(host); - } - let profile = pinned(spec).ok_or_else(|| unknown(spec))?; - check_generation(profile, host)?; +pub fn select(spec: &str, host: &HostCpuSignature) -> Result { + let id = if spec == AUTO { + select_auto(host)?.id() + } else { + spec + }; + let profile = PartitionProfile::pinned(id).ok_or_else(|| unknown(spec))?; + check_generation(&profile, host)?; Ok(profile) } @@ -220,6 +388,7 @@ fn revision(id: &str) -> u32 { mod tests { use super::*; use crate::derive; + use crate::test_support::fingerprint; use crate::test_support::profile; use test_with_tracing::test; @@ -228,19 +397,21 @@ mod tests { const ICELAKE: u32 = 0x0006_06a6; const EMERALDRAPIDS: u32 = 0x000c_06f2; const ALDER_LAKE: u32 = 0x0009_06a3; + const ALDER_LAKE_S: u32 = 0x0009_0672; + const TIGER_LAKE: u32 = 0x0008_06c1; fn intel(signature: u32) -> HostCpuSignature { HostCpuSignature::new(*b"GenuineIntel", signature) } - fn code(result: Result) -> ProfileErrorCode { + fn code(result: Result) -> ProfileErrorCode { result.unwrap_err().code } /// The pinned JSON files, the source of the static data, and their golden /// digests. Released profiles are immutable: changing a golden digest is /// a deliberate act, here. - const FILES: [(&str, &str, &str); 3] = [ + const FILES: [(&str, &str, &str); 4] = [ ( "intel.skylake-sp.v1", include_str!("../profiles/intel.skylake-sp.v1.json"), @@ -256,6 +427,11 @@ mod tests { include_str!("../profiles/intel.emeraldrapids.v1.json"), "sha256:73c084783f26df29871c72200ea34470e4afec6187bf96de6ac051dbaed6727d", ), + ( + "intel.alderlake.v1", + include_str!("../profiles/intel.alderlake.v1.json"), + "sha256:2dfb6ccfd5a195b1614e6d963615c3372f40806b8f19b0d8425142f8c18a0c2e", + ), ]; /// The checks that an OpenVMM start skips: every pinned file is a valid @@ -339,6 +515,8 @@ mod tests { (SKYLAKE, "intel.skylake-sp.v1", "skylake-sp"), (ICELAKE, "intel.icelake-sp.v1", "icelake-sp"), (EMERALDRAPIDS, "intel.emeraldrapids.v1", "emeraldrapids"), + (ALDER_LAKE, "intel.alderlake.v1", "alderlake"), + (ALDER_LAKE_S, "intel.alderlake.v1", "alderlake"), ] { assert_eq!(select_auto(&intel(signature)).unwrap().id(), id); assert_eq!(select(AUTO, &intel(signature)).unwrap().id(), id); @@ -346,7 +524,7 @@ mod tests { } for host in [ intel(CASCADE_LAKE), - intel(ALDER_LAKE), + intel(TIGER_LAKE), HostCpuSignature::new(*b"AuthenticAMD", 0x00a1_0f11), ] { assert_eq!( @@ -411,7 +589,16 @@ mod tests { for (index, pinned) in PINNED.iter().enumerate() { let profile = load(index); for vendor in [*b"GenuineIntel", *b"AuthenticAMD"] { - for (family, model) in [(6u32, 85u32), (6, 106), (6, 143), (6, 207), (25, 17)] { + for (family, model) in [ + (6u32, 85u32), + (6, 106), + (6, 140), + (6, 143), + (6, 151), + (6, 154), + (6, 207), + (25, 17), + ] { for stepping in 0..16 { // Encode the display family and model as CPUID.1:EAX does. let extended_family = family.saturating_sub(15); @@ -504,4 +691,173 @@ mod tests { ProfileErrorCode::CpuGeneration ); } + + /// The host profile of this crate's test fixture: Alder Lake's pinned + /// surface, fingerprinted on WHP. + fn host_profile() -> CpuProfile { + derive::derive_host_profile(&fingerprint(profile("intel.alderlake.v1"), "whp")).unwrap() + } + + #[test] + fn host_profile_ids_have_the_host_generation() { + for (id, host) in [ + ("intel.host.v1", true), + ("intel.host.v12", true), + ("amd.host.v1", true), + ("intel.icelake-sp.v1", false), + ("interim.host.kvm.v1", false), + ("intel.host", false), + (".host.v1", false), + ("Intel.host.v1", false), + ("intel.host.v", false), + ("intel.host.v0", false), + ("intel.host.v01", false), + ("intel.host.vfoo", false), + ("intel.host.1", false), + ("host", false), + (HOST, false), + (AUTO, false), + ] { + assert_eq!(is_host_profile_id(id), host, "{id}"); + } + assert!(is_host_profile_id(host_profile().id())); + assert!(pinned_profiles().iter().all(|profile| { + !is_host_profile_id(profile.id()) && profile.generation().name != HOST_GENERATION + })); + } + + #[test] + fn partition_profiles_carry_their_records() { + let pinned = PartitionProfile::pinned("intel.icelake-sp.v1").unwrap(); + assert!(!pinned.is_host()); + assert!(std::ptr::eq( + &*pinned, + super::pinned("intel.icelake-sp.v1").unwrap() + )); + assert_eq!( + pinned.record(), + pinned_record("intel.icelake-sp.v1").unwrap() + ); + assert!(PartitionProfile::pinned("intel.host.v1").is_none()); + + let host = host_profile(); + let selected = PartitionProfile::host(host.clone()).unwrap(); + assert!(selected.is_host()); + assert_eq!(*selected, host); + let record = selected.record(); + assert_eq!(record.id, host.id()); + assert_eq!(record.encoding, host.encode().as_slice()); + assert_eq!(record.digest, host.digest()); + // Clones share the profile. + assert!(std::ptr::eq(&*selected.clone(), &*selected)); + + // A pinned profile is not a host profile. + assert_eq!( + code(PartitionProfile::host( + profile("intel.alderlake.v1").clone() + )), + ProfileErrorCode::ProfileUnknown + ); + } + + /// Every VM of a process holds its own host profile: two host profiles + /// with the same ID, here derived from the fingerprints of two backends, + /// coexist, as a later VM's restore of another host's snapshot needs. + #[test] + fn host_profiles_of_the_same_id_coexist() { + let whp = PartitionProfile::host(host_profile()).unwrap(); + let kvm = PartitionProfile::host( + derive::derive_host_profile(&fingerprint(profile("intel.alderlake.v1"), "kvm")) + .unwrap(), + ) + .unwrap(); + assert_eq!(whp.id(), kvm.id()); + assert_ne!(whp.record().digest, kvm.record().digest); + assert_eq!(whp.record().digest, host_profile().digest()); + } + + #[test] + fn selection_takes_pinned_profiles_only() { + // A host profile has no ID to select it by, and `host` is not an ID. + let host = host_profile(); + for spec in [host.id(), HOST] { + assert_eq!( + code(select(spec, &intel(ALDER_LAKE))), + ProfileErrorCode::ProfileUnknown, + "{spec}" + ); + } + let selected = select(AUTO, &intel(ALDER_LAKE)).unwrap(); + assert!(!selected.is_host()); + assert_eq!(selected.id(), "intel.alderlake.v1"); + assert_eq!( + selected.record(), + pinned_record("intel.alderlake.v1").unwrap() + ); + } + + #[test] + fn restore_decodes_and_checks_a_host_profile() { + let host = host_profile(); + let encoding = host.encode(); + let digest = host.digest(); + assert_eq!( + host_profile_for_restore(host.id(), &digest, &encoding).unwrap(), + host + ); + + // A corrupted document or digest. + let mut corrupted = encoding.clone(); + let last = corrupted.len() - 3; + corrupted[last] ^= 1; + assert_eq!( + code(host_profile_for_restore(host.id(), &digest, &corrupted)), + ProfileErrorCode::ProfileDigest + ); + assert_eq!( + code(host_profile_for_restore( + host.id(), + &digest[..31], + &encoding + )), + ProfileErrorCode::ProfileDigest + ); + // A document that is not canonical, with its own digest. + let pretty = host.to_pretty_json().into_bytes(); + assert_eq!( + code(host_profile_for_restore( + host.id(), + &canonical::sha256(&pretty), + &pretty + )), + ProfileErrorCode::ProfileDigest + ); + // A pinned profile's document under a host profile's ID. + let pinned = pinned_record("intel.icelake-sp.v1").unwrap(); + assert_eq!( + code(host_profile_for_restore( + host.id(), + &pinned.digest, + pinned.encoding + )), + ProfileErrorCode::ProfileDigest + ); + // A pinned profile's ID is not a host profile's. + assert_eq!( + code(host_profile_for_restore( + pinned.id, + &pinned.digest, + pinned.encoding + )), + ProfileErrorCode::ProfileUnknown + ); + // Nor is an ID with a malformed revision, whatever the document. + for id in ["intel.host.v", "intel.host.v0", "intel.host.vfoo"] { + assert_eq!( + code(host_profile_for_restore(id, &digest, &encoding)), + ProfileErrorCode::ProfileUnknown, + "{id}" + ); + } + } } diff --git a/vmm_core/cpu_profile/src/check.rs b/vmm_core/cpu_profile/src/check.rs index e404df28aef..be5fe760d5e 100644 --- a/vmm_core/cpu_profile/src/check.rs +++ b/vmm_core/cpu_profile/src/check.rs @@ -131,15 +131,15 @@ mod tests { #[test] fn fails_unknown_hosts_and_unsupported_profiles() { let profile = profile("intel.icelake-sp.v1"); - let mut alder_lake = fingerprint(profile, "whp"); - alder_lake.host.cpu.signature = Hex32(0x0009_06a3); - alder_lake.host.cpu.invariant_tsc = false; - let check = check_fingerprint(&alder_lake); + let mut tiger_lake = fingerprint(profile, "whp"); + tiger_lake.host.cpu.signature = Hex32(0x0008_06c1); + tiger_lake.host.cpu.invariant_tsc = false; + let check = check_fingerprint(&tiger_lake); assert_eq!( check.result.as_ref().unwrap_err().code, ProfileErrorCode::ProfileHostUnknown ); - let line = check.summary_line(&alder_lake); + let line = check.summary_line(&tiger_lake); assert!( line.starts_with("NVX-CPU-PROFILE: status=fail backend=whp generation=none profile=none surface_digest="), "{line}" diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs index 0bf3a5e8897..7e14fa91cf4 100644 --- a/vmm_core/cpu_profile/src/derive.rs +++ b/vmm_core/cpu_profile/src/derive.rs @@ -20,7 +20,8 @@ //! the XSAVE leaf is rebuilt from them; every host must agree on the //! layout. A feature whose XSAVE state is not enabled is cleared. //! 4. The brand string is generic per generation, -//! `Intel(R) Xeon(R) Processor ()` without a frequency +//! `Intel(R) Xeon(R) Processor ()` or +//! `Intel(R) Core(TM) Processor ()` without a frequency //! ([`KnownGeneration::brand`]), so every host of a generation presents //! it whatever its SKU, and the hosts' brands are not compared. //! 5. `IA32_ARCH_CAPABILITIES` is pinned in [`ARCH_CAPABILITIES_PINNED_MASK`] @@ -28,12 +29,19 @@ //! the bits their processor feature banks derive. `ITS_NO` is pinned //! clear because the Hyper-V backends cannot present it. //! 6. The bits OpenVMM sets per VM are zero and unmasked. +//! +//! [`derive_host_profile`] applies the same policy to one fingerprint of the +//! host that OpenVMM runs on, for `--cpu-profile host`: a development profile +//! that serves only that host's CPU model and stepping, and that no catalog +//! pins. use crate::Hex32; use crate::Hex64; use crate::cpuid; use crate::cpuid::EXTENDED_LEAF_BASE; use crate::cpuid::XsaveComponent; +use crate::error::ProfileError; +use crate::error::ProfileErrorCode; use crate::fingerprint::CpuFingerprint; use crate::fingerprint::IA32_ARCH_CAPABILITIES; use crate::hv_banks; @@ -48,6 +56,7 @@ use crate::profile::VM_OWNED_LEAVES; use crate::profile::describe_leaf; use crate::profile::pinned_mask; use crate::signature::HostCpuSignature; +use crate::signature::decode_signature; use crate::surface::RegisterClass; use crate::surface::field_value; use crate::surface::register_class; @@ -137,7 +146,7 @@ impl KnownGeneration { } /// The generations profiles exist for. -pub const KNOWN_GENERATIONS: [KnownGeneration; 3] = [ +pub const KNOWN_GENERATIONS: [KnownGeneration; 4] = [ KnownGeneration { vendor: "GenuineIntel", vendor_short: "intel", @@ -163,6 +172,15 @@ pub const KNOWN_GENERATIONS: [KnownGeneration; 3] = [ description: "Intel Xeon Scalable, fifth generation (Emerald Rapids)", brand: "Intel(R) Xeon(R) Processor (Emerald Rapids)", }, + KnownGeneration { + vendor: "GenuineIntel", + vendor_short: "intel", + name: "alderlake", + // Alder Lake-S (151) and Alder Lake-P and -H (154). + cpus: &[(6, 151, [0, 15]), (6, 154, [0, 15])], + description: "Intel Core, twelfth generation (Alder Lake)", + brand: "Intel(R) Core(TM) Processor (Alder Lake)", + }, ]; /// Returns the known generation named `name`. @@ -212,31 +230,140 @@ pub enum DeriveError { /// A CPUID table: the four registers of each leaf and subleaf. type Table = BTreeMap<(u32, Option), [u32; 4]>; +/// The generation name of every host profile ([`derive_host_profile`]). No +/// known generation uses it. +pub const HOST_GENERATION: &str = "host"; + +/// The brand string of every host profile. +const HOST_BRAND: &str = "Intel(R) Processor (host profile)"; + +/// What a derivation derives a profile for: a known generation, or one host. +struct Target<'a> { + /// The CPUID vendor string. + vendor: &'a str, + /// The vendor as profile IDs spell it. + vendor_short: &'a str, + /// The generation that the profile records. + generation: Generation, + /// The profile's description. + description: String, + /// The profile's brand string. + brand: &'a str, +} + /// Derives revision `revision` of the profile of `generation` from the /// `fingerprints` of its hosts, on any backends. pub fn derive_profile( generation: &KnownGeneration, revision: u32, fingerprints: &[CpuFingerprint], +) -> Result { + derive( + &Target { + vendor: generation.vendor, + vendor_short: generation.vendor_short, + generation: generation.generation(), + description: format!( + "{}, on every backend; NVX time ABI v1", + generation.description + ), + brand: generation.brand, + }, + revision, + fingerprints, + ) +} + +/// Derives the host profile of the host and backend that `fingerprint` +/// describes, for `--cpu-profile host`: the profile that the derivation +/// policy gives for this one fingerprint, with the ID +/// `.host.v1`, the generation [`HOST_GENERATION`] limited to the +/// host's family, model, and stepping, and a generic brand string. +/// +/// Unlike a pinned profile, a host profile is neither reviewed nor +/// immutable: a microcode, firmware, or hypervisor update can change it. +/// +/// Fails with `E_PROFILE_HOST_UNKNOWN` for a CPU of another vendor than +/// Intel, whose profiles do not exist yet, and with `E_PROFILE_UNSUPPORTED` +/// when the backend lacks a feature that the time ABI requires. +pub fn derive_host_profile(fingerprint: &CpuFingerprint) -> Result { + let host = host_signature(fingerprint); + let cpu = &fingerprint.host.cpu; + let vendor_short = match cpu.vendor.as_str() { + "GenuineIntel" => "intel", + _ => { + return Err(ProfileError::new( + ProfileErrorCode::ProfileHostUnknown, + format!("host CPU profiles support only Intel CPUs, and the host CPU is {host}"), + )); + } + }; + let (family, model, stepping) = decode_signature(cpu.vendor.as_bytes(), cpu.signature.0); + derive( + &Target { + vendor: &cpu.vendor, + vendor_short, + generation: Generation { + name: HOST_GENERATION.to_owned(), + cpus: vec![GenerationCpu { + family, + model, + steppings: [stepping, stepping], + }], + }, + description: format!( + "Host profile of {host}, derived from one {} CPU fingerprint for development; \ + not pinned; NVX time ABI v1", + fingerprint.backend.name + ), + brand: HOST_BRAND, + }, + 1, + std::slice::from_ref(fingerprint), + ) + .map_err(|error| { + let code = match error { + DeriveError::TimeFeature(_) => ProfileErrorCode::ProfileUnsupported, + _ => ProfileErrorCode::ProfileHostUnknown, + }; + ProfileError::new( + code, + format!("cannot derive a host CPU profile for {host}: {error}"), + ) + }) +} + +/// Returns the vendor and signature of the host that `fingerprint` +/// describes. +fn host_signature(fingerprint: &CpuFingerprint) -> HostCpuSignature { + let cpu = &fingerprint.host.cpu; + let mut vendor = [0; 12]; + let host_vendor = cpu.vendor.as_bytes(); + if host_vendor.len() == vendor.len() { + vendor.copy_from_slice(host_vendor); + } + HostCpuSignature::new(vendor, cpu.signature.0) +} + +/// Derives revision `revision` of the profile of `target` from +/// `fingerprints`; see the module documentation. +fn derive( + target: &Target<'_>, + revision: u32, + fingerprints: &[CpuFingerprint], ) -> Result { if fingerprints.is_empty() { return Err(DeriveError::NoFingerprints); } - let profile_generation = generation.generation(); + let profile_generation = &target.generation; for fingerprint in fingerprints { - let cpu = &fingerprint.host.cpu; - let mut vendor = [0; 12]; - let host_vendor = cpu.vendor.as_bytes(); - if host_vendor.len() == vendor.len() { - vendor.copy_from_slice(host_vendor); - } - let host = HostCpuSignature::new(vendor, cpu.signature.0); - if !profile_generation.contains(generation.vendor, &host) { + let host = host_signature(fingerprint); + if !profile_generation.contains(target.vendor, &host) { return Err(DeriveError::WrongGeneration { backend: fingerprint.backend.name.clone(), digest: fingerprint.digest.clone(), host: host.to_string(), - generation: generation.name.to_owned(), + generation: profile_generation.name.clone(), }); } } @@ -263,7 +390,7 @@ pub fn derive_profile( let mut leaves = apply_policy(&combined)?; // The brand string: generic per generation. - for (leaf, value) in (EXTENDED_LEAF_BASE + 2..).zip(brand_leaves(generation.brand)) { + for (leaf, value) in (EXTENDED_LEAF_BASE + 2..).zip(brand_leaves(target.brand)) { if let Some(entry) = leaves.get_mut(&(leaf, None)) { *entry = value; } @@ -384,14 +511,11 @@ pub fn derive_profile( CpuProfile::from_parts( format!( "{}.{}.v{revision}", - generation.vendor_short, generation.name - ), - format!( - "{}, on every backend; NVX time ABI v1", - generation.description + target.vendor_short, profile_generation.name ), - generation.vendor.to_owned(), - profile_generation, + target.description.clone(), + target.vendor.to_owned(), + profile_generation.clone(), leaves, xcr0, xss, @@ -924,4 +1048,87 @@ mod tests { Err(DeriveError::TimeFeature("RDTSCP")) )); } + + #[test] + fn a_host_profile_applies_the_policy_to_one_host() { + let pinned = profile("intel.alderlake.v1"); + let host = derive_host_profile(&fingerprint(pinned, "whp")).unwrap(); + assert_eq!(host.id(), "intel.host.v1"); + assert_eq!(host.vendor(), "GenuineIntel"); + assert_eq!( + host.generation(), + &Generation { + name: HOST_GENERATION.to_owned(), + cpus: vec![GenerationCpu { + family: 6, + model: 154, + steppings: [3, 3], + }], + } + ); + assert!( + host.description() + .starts_with("Host profile of GenuineIntel family 6 model 154 stepping 3"), + "{}", + host.description() + ); + assert!(host.description().contains("one whp CPU fingerprint")); + assert_eq!(brand(&host), "Intel(R) Processor (host profile)"); + // Everything else is what the catalog's derivation gives for the + // same fingerprint. + let brand_leaves = 0x8000_0002..=0x8000_0004; + let without_brand = |profile: &CpuProfile| { + profile + .cpuid() + .iter() + .filter(|entry| !brand_leaves.contains(&entry.leaf.0)) + .cloned() + .collect::>() + }; + assert_eq!(without_brand(&host), without_brand(pinned)); + assert_eq!(host.xcr0(), pinned.xcr0()); + assert_eq!(host.xss(), pinned.xss()); + assert_eq!(host.xsave_components(), pinned.xsave_components()); + assert_eq!(host.msrs(), pinned.msrs()); + assert_eq!( + host.physical_address_width(), + pinned.physical_address_width() + ); + // The derivation is deterministic, and the profile is canonical. + let again = derive_host_profile(&fingerprint(pinned, "whp")).unwrap(); + assert_eq!(again.digest(), host.digest()); + assert_eq!(CpuProfile::decode(&host.encode()).unwrap(), host); + } + + #[test] + fn host_profiles_fail_with_the_time_abi_codes() { + let pinned = profile("intel.alderlake.v1"); + let mut amd = fingerprint(pinned, "kvm"); + amd.host.cpu.vendor = "AuthenticAMD".to_owned(); + let error = derive_host_profile(&amd).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown); + assert!(error.message.contains("only Intel CPUs"), "{error}"); + + let no_rdtscp = edited(pinned, "whp", |entries| { + set(entries, 0x8000_0001, None, 3, |edx| edx & !(1 << 27)); + }); + let error = derive_host_profile(&no_rdtscp).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + assert!( + error.message.starts_with( + "cannot derive a host CPU profile for GenuineIntel family 6 model 154 stepping 3" + ), + "{error}" + ); + assert!( + error + .message + .ends_with("RDTSCP, which the time ABI requires") + ); + } + + #[test] + fn no_known_generation_is_the_host_generation() { + assert!(known_generation(HOST_GENERATION).is_none()); + } } diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index 1518d66963f..f132af0a40f 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -14,7 +14,13 @@ //! leaves; OpenVMM builds it in `virt::time_abi::cpuid::effective_cpuid`. //! //! - **Selection:** [`select`] maps `--cpu-profile ` and the host -//! CPU ([`HostCpuSignature`]) to a pinned profile. +//! CPU ([`HostCpuSignature`]) to a pinned profile. `--cpu-profile host` +//! ([`HOST`]) is the opt-in exception, for development hosts that no +//! pinned profile serves: the VM worker derives a host profile from the +//! backend's fingerprint of this host ([`derive_host_profile`]). Either +//! way, the partition holds its profile in a [`PartitionProfile`], which +//! core hands to the backend, so each VM of a process can have its own +//! host profile. //! - **Verification:** each backend reports the CPU surface it supports //! ([`HostCpuSurface`]), and [`verify_support`] checks that it covers the //! profile. No CPUID entry outside the profile's tables carries host data: @@ -25,7 +31,10 @@ //! digest against the pinned constant, core compares the recorded document //! with [`pinned_record`]'s encoding, and [`check_generation`] checks the //! destination host. Core compares the effective CPUID record with the -//! recomputed [`EffectiveCpuid`]. +//! recomputed [`EffectiveCpuid`]. A snapshot of a host profile carries the +//! only copy of its document, which [`host_profile_for_restore`] decodes +//! and checks against the recorded digest, and from which the restoring +//! VM worker's [`PartitionProfile`] takes it. //! - **Offline only:** the codecs and digests //! ([`CpuProfile::encode`], [`CpuProfile::digest`], //! [`CpuProfile::digest_string`], [`CpuProfile::decode`], @@ -33,8 +42,10 @@ //! [`EffectiveCpuid::encode`], [`EffectiveCpuid::digest`], //! [`EffectiveCpuid::decode`], and [`EffectiveCpuid::decode_verified`]) //! and [`pinned_profiles`] parse, encode, or hash. They serve tools, tests, -//! and `--cpu-fingerprint`, never a cold boot or restore, whose profile -//! work uses only constants. +//! and `--cpu-fingerprint`, never a cold boot or restore of a pinned +//! profile, whose profile work uses only constants. Only a host profile, +//! which has no constants, encodes and hashes at cold boot and decodes at +//! restore. //! //! Failures carry the stable codes of the time ABI specification //! ([`ProfileError`]). @@ -68,8 +79,12 @@ mod test_support; mod unlisted; pub use catalog::AUTO; +pub use catalog::HOST; +pub use catalog::PartitionProfile; pub use catalog::check_generation; pub use catalog::generation_of; +pub use catalog::host_profile_for_restore; +pub use catalog::is_host_profile_id; pub use catalog::pinned; pub use catalog::pinned_for_restore; pub use catalog::pinned_profiles; @@ -79,6 +94,7 @@ pub use catalog::select_auto; pub use check::FingerprintCheck; pub use check::SUMMARY_PREFIX; pub use check::check_fingerprint; +pub use derive::derive_host_profile; pub use effective::CpuidResult; pub use effective::EFFECTIVE_CPUID_SCHEMA; pub use effective::EffectiveCpuid; diff --git a/vmm_core/cpu_profile/src/pinned.rs b/vmm_core/cpu_profile/src/pinned.rs index d1498df6c1e..0583f9af660 100644 --- a/vmm_core/cpu_profile/src/pinned.rs +++ b/vmm_core/cpu_profile/src/pinned.rs @@ -94,7 +94,7 @@ impl PinnedProfile { } /// Returns the profile's record. - pub(crate) fn record(&self) -> PinnedRecord { + pub(crate) fn record(&self) -> PinnedRecord<'static> { PinnedRecord { id: self.id, encoding: self.encoding.as_bytes(), @@ -103,16 +103,19 @@ impl PinnedProfile { } } -/// The CPU profile record of a pinned profile, precomputed at build time: the -/// canonical encoding that a snapshot embeds, and its SHA-256, the profile -/// digest. Capture can record them, and restore can compare a recorded -/// profile with them, without encoding or hashing. +/// The CPU profile record that a snapshot copies: the profile ID, the +/// canonical encoding that the snapshot embeds, and its SHA-256, the profile +/// digest. A pinned profile's record is precomputed at build time +/// ([`pinned_record`](crate::pinned_record)), so capture can record it, and +/// restore can compare a recorded profile with it, without encoding or +/// hashing. A host profile's is computed once, when the VM worker selects the +/// profile ([`PartitionProfile::record`](crate::PartitionProfile::record)). #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct PinnedRecord { +pub struct PinnedRecord<'a> { /// The profile ID. - pub id: &'static str, + pub id: &'a str, /// The canonical encoding, [`CpuProfile::encode`] of the profile. - pub encoding: &'static [u8], + pub encoding: &'a [u8], /// The profile digest, the SHA-256 of `encoding`. pub digest: [u8; 32], } diff --git a/vmm_core/cpu_profile/src/pinned_data.rs b/vmm_core/cpu_profile/src/pinned_data.rs index b37d3854138..7de7bdd9e3f 100644 --- a/vmm_core/cpu_profile/src/pinned_data.rs +++ b/vmm_core/cpu_profile/src/pinned_data.rs @@ -15,7 +15,7 @@ use crate::pinned::xsave; /// The pinned profiles, in catalog order. Released profiles are immutable: a /// change is a new revision with a new ID. -pub(crate) const PINNED: [PinnedProfile; 3] = [ +pub(crate) const PINNED: [PinnedProfile; 4] = [ PinnedProfile { id: "intel.skylake-sp.v1", digest: digest("b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492"), @@ -934,4 +934,294 @@ pub(crate) const PINNED: [PinnedProfile; 3] = [ ), ], }, + PinnedProfile { + id: "intel.alderlake.v1", + digest: digest("2dfb6ccfd5a195b1614e6d963615c3372f40806b8f19b0d8425142f8c18a0c2e"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001c","0x756e6547","0x6c65746e","0x49656e69"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x000906a3","0x00000800","0xf6da3203","0x0f8bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00feff01","0x000000f0","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x02c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x0240003f","0x000007ff","0x00000000"]},{"leaf":"0x00000004","mask":["0x00003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x02c0003f","0x00007fff","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000002","0x219c27a9","0x18400724","0xac004410"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000810","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000000","0x00000000","0x00000000","0x00000001"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000007","0x00000000","0x00000340","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000008","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000121","0x2c100800"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x65746e49","0x2952286c","0x726f4320","0x4d542865"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x72502029","0x7365636f","0x20726f73","0x646c4128"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x4c207265","0x29656b61","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x05007040","0x00000000"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000302e","0x00000000","0x00000000","0x00000000"]}],"description":"Intel Core, twelfth generation (Alder Lake), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":6,"model":151,"steppings":[0,15]},{"family":6,"model":154,"steppings":[0,15]}],"name":"alderlake"},"id":"intel.alderlake.v1","msrs":[{"index":"0x0000010a","mask":"0x400000000c12e1bb","value":"0x000000000002e123"}],"physical_address_width":46,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"whp","hosts":1,"surface_digests":["sha256:8e0184d897cb2641df7306afe86a1d4af5f4740d775719f4be49bcd1e0f15b53"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"GenuineIntel","xcr0":"0x0000000000000007","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "Intel Core, twelfth generation (Alder Lake), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[3], + cpuid: &[ + leaf( + 0x0, + None, + [0x1c, 0x756e_6547, 0x6c65_746e, 0x4965_6e69], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0x9_06a3, 0x800, 0xf6da_3203, 0xf8b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0xfe_ff01, 0xf0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x121, 0x2c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x2), + [0x143, 0x240_003f, 0x7ff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x3), + [0x163, 0x2c0_003f, 0x7fff, 0x0], + [0x3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x2, 0x219c_27a9, 0x1840_0724, 0xac00_4410], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x1), + [0x810, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x2), + [0x0, 0x0, 0x0, 0x1], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0x7, 0x0, 0x340, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0008, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0x0, 0x0, 0x121, 0x2c10_0800], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x6574_6e49, 0x2952_286c, 0x726f_4320, 0x4d54_2865], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x7250_2029, 0x7365_636f, 0x2072_6f73, 0x646c_4128], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x4c20_7265, 0x2965_6b61, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x0, 0x0, 0x500_7040, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x302e, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0x7, + xss: 0x0, + xsave_components: &[xsave(2, 256, 576, false, false, false)], + physical_address_width: 46, + msrs: &[msr(0x10a, 0x2_e123, 0x4000_0000_0c12_e1bb)], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[source( + "whp", + 1, + &["sha256:8e0184d897cb2641df7306afe86a1d4af5f4740d775719f4be49bcd1e0f15b53"], + )], + }, ]; diff --git a/vmm_core/cpu_profile/src/profile.rs b/vmm_core/cpu_profile/src/profile.rs index f829f591e8e..b84944bbb4b 100644 --- a/vmm_core/cpu_profile/src/profile.rs +++ b/vmm_core/cpu_profile/src/profile.rs @@ -416,11 +416,7 @@ impl CpuProfile { vendor => return Err(format!("unsupported vendor {vendor:?}")), }; let name = &self.generation.name; - if name.is_empty() - || !name - .bytes() - .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') - { + if !is_id_component(name) { return Err(format!("invalid generation name {name:?}")); } let revision = self @@ -430,10 +426,7 @@ impl CpuProfile { .and_then(|rest| rest.strip_prefix(name.as_str())) .and_then(|rest| rest.strip_prefix(".v")) .ok_or_else(|| format!("ID {:?} is not {vendor_short}.{name}.v", self.id))?; - if revision.is_empty() - || revision.starts_with('0') - || !revision.bytes().all(|byte| byte.is_ascii_digit()) - { + if !is_revision(revision) { return Err(format!("ID {:?} has an invalid revision", self.id)); } if self.generation.cpus.is_empty() { @@ -622,6 +615,24 @@ impl CpuProfile { } } +/// Returns whether `component` is a valid vendor or generation component of a +/// profile ID: one or more lowercase ASCII letters, digits, and hyphens. +pub(crate) fn is_id_component(component: &str) -> bool { + !component.is_empty() + && component + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-') +} + +/// Returns whether `revision` is a valid profile revision, the decimal number +/// after the `.v` that ends a profile ID: one or more digits without a leading +/// zero. +pub(crate) fn is_revision(revision: &str) -> bool { + !revision.is_empty() + && !revision.starts_with('0') + && revision.bytes().all(|byte| byte.is_ascii_digit()) +} + /// Returns the entry of `entries` that CPUID `leaf` and `subleaf` read, if any. pub(crate) fn find(entries: &[CpuidLeafValue], leaf: u32, subleaf: u32) -> Option<&CpuidLeafValue> { entries.iter().find(|entry| { diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index 0278201b7c1..8acd2bb5cde 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -423,10 +423,11 @@ pub struct TimeAbiConfig { /// The identity MSR handler, shared with the worker's `time-abi` state /// unit. pub msrs: std::sync::Arc, - /// The ID of the partition's CPU profile, selected or restored before the - /// partition is created: `cpu_profile::pinned` returns the profile, from - /// which a backend derives its processor features. - pub cpu_profile: String, + /// The partition's CPU profile, selected or restored before the partition + /// is created: a pinned profile or a host profile, from which a backend + /// derives its processor features. Only the partitions that use a host + /// profile hold it. + pub cpu_profile: cpu_profile::PartitionProfile, } #[cfg(test)] diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index d9e961d70c9..e71b45868bd 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -601,7 +601,7 @@ impl ProtoPartition for KvmProtoPartition<'_> { .map(|vp_info| vp_info.apic_id) .collect(); let surface = self.cpu_surface.take().unwrap_or_default(); - let profile = time_abi::pinned_profile(&config.cpu_profile)?; + let profile = &config.cpu_profile; let supported = surface .msrs .iter() diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index 48d80108221..1e49655ae76 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -224,18 +224,6 @@ pub(crate) fn profile_arch_capabilities( enumerated.then_some((value & mask) | (supported & !mask)) } -/// Returns the pinned CPU profile that `id` names. Core selects or restores -/// it before the partition exists, so an unknown ID is an internal error -/// (`E_PROFILE_UNKNOWN`). -pub(crate) fn pinned_profile(id: &str) -> Result<&'static CpuProfile, TimeAbiError> { - cpu_profile::pinned(id).ok_or_else(|| { - TimeAbiError::new( - TimeAbiCode::ProfileUnknown, - format!("CPU profile {id:?} is not pinned in this OpenVMM"), - ) - }) -} - /// Converts the KVM CPUID entries programmed for a VP to CPUID leaves. pub(crate) fn cpuid_leaves(entries: &[kvm::kvm_cpuid_entry2]) -> Vec { entries @@ -864,13 +852,13 @@ mod tests { let enumerated = CpuidLeafSet::new(vec![CpuidLeaf::new(7, [0, 0, 0, 1 << 29]).indexed(0)]); // The Skylake profile pins its whole mask clear, ITS_NO included; // outside it, KVM's own value stays. - let skylake = pinned_profile("intel.skylake-sp.v1").unwrap(); + let skylake = cpu_profile::pinned("intel.skylake-sp.v1").unwrap(); assert_eq!( profile_arch_capabilities(skylake, supported, &enumerated), Some(1 << 6) ); // The Ice Lake profile pins RDCL_NO, MDS_NO, TAA_NO, and RFDS_NO set. - let icelake = pinned_profile("intel.icelake-sp.v1").unwrap(); + let icelake = cpu_profile::pinned("intel.icelake-sp.v1").unwrap(); assert_eq!( profile_arch_capabilities(icelake, supported, &enumerated), Some(0x0800_0121 | (1 << 6)) @@ -883,21 +871,6 @@ mod tests { ); } - #[test] - fn only_pinned_profiles_resolve() { - assert_eq!( - pinned_profile("intel.skylake-sp.v1").unwrap().id(), - "intel.skylake-sp.v1" - ); - for id in ["interim.host.kvm.v1", "intel.skylake-sp.kvm.v1", ""] { - let error = pinned_profile(id).unwrap_err(); - assert!( - matches!(error.code, TimeAbiCode::ProfileUnknown), - "{id}: {error:?}" - ); - } - } - fn kvm_like_base() -> Vec { vec![ CpuidLeaf::new(0, [0x16, 0x756e_6547, 0x6c65_746e, 0x4965_6e69]), @@ -1409,7 +1382,7 @@ mod kvm_tests { ..Default::default() }]) .unwrap(); - let profile = pinned_profile("intel.skylake-sp.v1").unwrap(); + let profile = cpu_profile::pinned("intel.skylake-sp.v1").unwrap(); let value = profile_arch_capabilities(profile, supported, &enumerated).unwrap(); let time_abi = KvmTimeAbi::new( &vm, diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index af43ecb524a..a693aa6320d 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -42,6 +42,8 @@ use crate::MshvPartition; use crate::MshvPartitionInner; use crate::MshvProcessor; use crate::VcpuFdExt; +use cpu_profile::CpuProfile; +use cpu_profile::PartitionProfile; use cpu_profile::hv_banks::HvFeatures; use hvdef::HvInterceptAccessType; use hvdef::HvMessage; @@ -141,7 +143,7 @@ pub(crate) struct MshvTimeAbi { host_features: HvFeatures, /// The CPU profile, whose unlisted candidates the host's CPUID gives. #[inspect(skip)] - cpu_profile: String, + cpu_profile: PartitionProfile, /// Where the host's CPUID table comes from. After its first use, this /// holds only the drained channel, if any, which the partition frees when /// it drops. @@ -174,9 +176,9 @@ impl HostCpuidSource { /// Starts reading the host's CPUID table and finding `cpu_profile`'s /// unlisted candidates in it on another thread, or does both here if no /// thread can start. - pub(super) fn spawn(cpu_profile: &str) -> Self { + pub(super) fn spawn(cpu_profile: &PartitionProfile) -> Self { let (sender, receiver) = std::sync::mpsc::sync_channel(1); - let thread_profile = cpu_profile.to_owned(); + let thread_profile = cpu_profile.clone(); // The thread is detached, so it releases its own stack when it exits // instead of a join on the start path. let read = move || { @@ -219,12 +221,9 @@ pub(super) struct HostCpuid { } impl HostCpuid { - /// Finds `cpu_profile`'s unlisted candidates in `table`. A profile that - /// isn't pinned has none. - fn new(cpu_profile: &str, table: Vec) -> Self { - let unlisted_candidates = cpu_profile::pinned(cpu_profile) - .map(|profile| cpu_profile::unlisted_cpuid_candidates(profile, &table)) - .unwrap_or_default(); + /// Finds `cpu_profile`'s unlisted candidates in `table`. + fn new(cpu_profile: &CpuProfile, table: Vec) -> Self { + let unlisted_candidates = cpu_profile::unlisted_cpuid_candidates(cpu_profile, &table); Self { table, unlisted_candidates, @@ -234,7 +233,7 @@ impl HostCpuid { } /// Reads the host's CPUID table on this thread. - fn read(cpu_profile: &str) -> Self { + fn read(cpu_profile: &CpuProfile) -> Self { Self::new(cpu_profile, super::profile_features::host_cpuid_table()) } } @@ -405,20 +404,17 @@ pub(super) fn processor_features1( } /// Returns the processor features of a time ABI partition with CPU profile -/// `id` on a host whose partition offers `host`: the profile's, within what -/// the host offers, under the time ABI's policy (see +/// `profile` on a host whose partition offers `host`: the profile's, within +/// what the host offers, under the time ABI's policy (see /// [`time_abi_features`](super::profile_features::time_abi_features)). Fails -/// with `E_PROFILE_UNKNOWN` or `E_PROFILE_UNSUPPORTED`. -pub(super) fn partition_features(id: &str, host: HvFeatures) -> Result { - let profile = cpu_profile::pinned(id).ok_or_else(|| { - TimeAbiError::new( - TimeAbiCode::ProfileUnknown, - format!("CPU profile {id} is not pinned"), - ) - })?; +/// with `E_PROFILE_UNSUPPORTED`. +pub(super) fn partition_features( + profile: &CpuProfile, + host: HvFeatures, +) -> Result { let features = super::profile_features::time_abi_features(profile, host)?; tracing::info!( - cpu_profile = id, + cpu_profile = profile.id(), bank0 = features.banks[0], bank1 = features.banks[1], xsave = features.xsave, @@ -1450,21 +1446,19 @@ mod tests { let config = TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: profile.id().to_owned(), + cpu_profile: PartitionProfile::pinned(profile.id()).unwrap(), }; let state = MshvTimeAbi::new( &config, &partition_cpuid(&config.cpuid), super::super::profile_features::legacy_features(), - HostCpuidSource::Ready(HostCpuid::new(&config.cpu_profile, host.clone())), + HostCpuidSource::Ready(HostCpuid::new(&config.cpu_profile, host)), 0, ); assert_eq!( state.host_cpuid().unlisted_candidates, [(4, Some(5)), (0x17, None), (0x8000_0009, None)] ); - // A profile that isn't pinned has none. - assert!(HostCpuid::new("", host).unlisted_candidates.is_empty()); } #[test] @@ -1472,7 +1466,7 @@ mod tests { let config = TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: "intel.skylake-sp.v1".to_owned(), + cpu_profile: PartitionProfile::pinned("intel.skylake-sp.v1").unwrap(), }; let state = MshvTimeAbi::new( &config, @@ -1542,17 +1536,6 @@ mod tests { assert_eq!(disabled_xsave, !0x3f); } - #[test] - fn an_unpinned_cpu_profile_is_unknown() { - let error = partition_features( - "intel.skylake-sp.v0", - super::super::profile_features::legacy_features(), - ) - .unwrap_err(); - assert_eq!(error.code, TimeAbiCode::ProfileUnknown); - assert!(error.message.contains("intel.skylake-sp.v0"), "{error}"); - } - #[test] fn time_abi_rejects_isolation_and_the_hyperv_interface() { validate_partition(virt::IsolationType::None, false).unwrap(); @@ -1602,13 +1585,13 @@ mod tests { let config = TimeAbiConfig { cpuid: Arc::new(time_abi_cpuid(VP_COUNT, true)), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: String::new(), + cpu_profile: PartitionProfile::pinned("intel.skylake-sp.v1").unwrap(), }; let state = MshvTimeAbi::new( &config, &partition_cpuid(&config.cpuid), super::super::profile_features::legacy_features(), - HostCpuidSource::Ready(HostCpuid::new("", Vec::new())), + HostCpuidSource::Ready(HostCpuid::new(&config.cpu_profile, Vec::new())), 0, ); state.check_vp_creation(VpIndex::new(4)).unwrap(); @@ -1724,12 +1707,10 @@ mod hw { const VP_CAPACITY: u32 = 4; - /// Returns the ID of the pinned CPU profile of this host's generation. - fn host_profile_id() -> String { - cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) - .unwrap() - .id() - .to_owned() + /// Returns the pinned CPU profile of this host's generation. + fn host_profile() -> PartitionProfile { + let selected = cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()).unwrap(); + PartitionProfile::pinned(selected.id()).unwrap() } /// Returns the effective CPUID of this host's profile for `topology`, and @@ -1737,8 +1718,7 @@ mod hw { fn host_profile_cpuid( topology: &vm_topology::processor::ProcessorTopology, ) -> (cpu_profile::EffectiveCpuid, CpuidLeafSet) { - let profile = cpu_profile::pinned(&host_profile_id()).unwrap(); - let effective = virt::time_abi::cpuid::effective_cpuid(profile, topology).unwrap(); + let effective = virt::time_abi::cpuid::effective_cpuid(&host_profile(), topology).unwrap(); let table = virt::time_abi::cpuid::backend_cpuid(&effective); (effective, table) } @@ -1855,7 +1835,7 @@ mod hw { time_abi: Some(TimeAbiConfig { cpuid: Arc::new(host_profile_cpuid(&processor_topology).1), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: host_profile_id(), + cpu_profile: host_profile(), }), }) .unwrap() @@ -1913,7 +1893,8 @@ mod hw { .unwrap() .expect("MSHV reports its CPU surface"); let surface_us = started.elapsed().as_micros(); - let profile = cpu_profile::pinned(&host_profile_id()).unwrap(); + let selected = host_profile(); + let profile = &*selected; cpu_profile::verify_support(profile, &host_cpu_surface(&surface)).unwrap(); let started = std::time::Instant::now(); @@ -2119,7 +2100,7 @@ mod hw { time_abi: Some(TimeAbiConfig { cpuid: Arc::new(table), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: host_profile_id(), + cpu_profile: host_profile(), }), }) .unwrap() @@ -2183,7 +2164,7 @@ mod hw { } println!( "{}: {count} probes of {} indexed leaves (max basic {max_basic:#x}, max extended {max_extended:#x}); {} listed results differ, {} unlisted results are not zero, {} reserved topology entries are not zero", - host_profile_id(), + host_profile().id(), indexed.len(), listed_failures.len(), unlisted.len(), @@ -2209,9 +2190,9 @@ mod hw { .results() .map(|result| (result.function, result.index.unwrap_or(0))) .collect(); - let profile = cpu_profile::pinned(&host_profile_id()).unwrap(); + let profile = host_profile(); let candidates = cpu_profile::unlisted_cpuid_candidates( - profile, + &profile, &super::super::profile_features::host_cpuid_table(), ); let with_candidates: Vec<(u32, u32)> = report @@ -2302,7 +2283,7 @@ mod hw { .iter() .map(|leaf| cpu_profile::cpuid::CpuidEntry::new(leaf.function, leaf.index, leaf.result)) .collect(); - let error = cpu_profile::check_unlisted_cpuid(profile, &entries).unwrap_err(); + let error = cpu_profile::check_unlisted_cpuid(&profile, &entries).unwrap_err(); assert_eq!( error.code, cpu_profile::ProfileErrorCode::CpuUnlisted, @@ -2349,7 +2330,7 @@ mod hw { time_abi: Some(TimeAbiConfig { cpuid: Arc::new(table), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: host_profile_id(), + cpu_profile: host_profile(), }), }) .unwrap() @@ -2423,7 +2404,7 @@ mod hw { fn refused_msr_intercept_fails_identity_routing() { let mshv = mshv_ioctls::Mshv::new().unwrap(); let features = partition_features( - &host_profile_id(), + &host_profile(), super::super::profile_features::host_features(&mshv).unwrap(), ) .unwrap(); diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 2912ad24040..79f5dffc854 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -58,6 +58,7 @@ use crate::profile_features::WhpFeatures; use crate::profile_features::profile_features; use crate::profile_features::supported_surface; use cpu_profile::CpuProfile; +use cpu_profile::PartitionProfile; use inspect::Inspect; use std::sync::Arc; use std::sync::OnceLock; @@ -177,7 +178,8 @@ pub(crate) struct WhpTimeAbi { #[inspect(hex)] features_xsave: u64, /// The CPU profile the features derive from. - cpu_profile: String, + #[inspect(with = "|x| x.id()")] + cpu_profile: PartitionProfile, /// How many `CpuidResultList2` entries present the effective CPUID. cpuid_results: usize, /// The effective CPUID record, computed once: it depends only on the @@ -192,7 +194,7 @@ impl WhpTimeAbi { /// The partition must have passed [`validate_partition`]. /// /// The processor feature banks and the XSAVE features derive from the - /// pinned CPU profile `config.cpu_profile` ([`profile_features`]), and + /// CPU profile `config.cpu_profile` ([`profile_features`]), and /// `config.cpuid`, the complete effective CPUID, becomes the CPUID results /// ([`cpuid_results`]) and the exits ([`cpuid_exits`]). pub(crate) fn configure( @@ -200,7 +202,7 @@ impl WhpTimeAbi { whp_config: &mut whp::PartitionConfig, extended_exits: &mut WHV_EXTENDED_VM_EXITS, ) -> Result { - let profile = pinned_profile(&config.cpu_profile)?; + let profile = &config.cpu_profile; let available_exits = whp::capabilities::extended_vm_exits() .map_err(|err| routing_error(format!("cannot query the extended VM exits: {err}")))?; @@ -312,7 +314,7 @@ impl WhpTimeAbi { features_bank0: features.bank0.0, features_bank1: features.bank1.0, features_xsave: xsave, - cpu_profile: profile.id().to_owned(), + cpu_profile: profile.clone(), cpuid_results: cpuid_results.len(), effective: OnceLock::new(), }) @@ -399,18 +401,6 @@ pub(crate) fn unlisted_cpuid( .collect() } -/// Returns the pinned CPU profile that `id` names. Core selects and verifies -/// the profile before the partition exists, so an unknown ID is an internal -/// error (`E_PROFILE_UNKNOWN`). -fn pinned_profile(id: &str) -> Result<&'static CpuProfile, TimeAbiError> { - cpu_profile::pinned(id).ok_or_else(|| { - TimeAbiError::new( - TimeAbiCode::ProfileUnknown, - format!("CPU profile {id:?} is not pinned in this OpenVMM"), - ) - }) -} - /// Fails unless the partition can carry the time ABI: no isolation, no /// Hyper-V guest interface (whose synthetic processor features would serve /// the identity CPUID and MSRs natively, and which VTL2 requires), no nested @@ -918,8 +908,7 @@ impl WhpPartitionInner { }) }; let mut record = effective_cpuid(&self.cpuid, &mut read)?; - let unlisted = - unlisted_cpuid(pinned_profile(&state.cpu_profile)?, host_cpuid(), &mut read)?; + let unlisted = unlisted_cpuid(&state.cpu_profile, host_cpuid(), &mut read)?; tracing::info!( results = record.len(), unlisted = unlisted.len(), @@ -1430,19 +1419,6 @@ mod tests { const VP_COUNT: u32 = 8; - #[test] - fn cpu_profile_ids_resolve_to_pinned_profiles() { - let skylake = pinned_profile("intel.skylake-sp.v1").unwrap(); - assert_eq!(skylake.id(), "intel.skylake-sp.v1"); - for id in ["interim.host.whp.v1", "intel.skylake-sp.whp.v1", ""] { - assert_eq!( - pinned_profile(id).unwrap_err().code, - TimeAbiCode::ProfileUnknown, - "{id:?}" - ); - } - } - /// WHP's own results on an Intel host without synthetic features: no /// hypervisor leaves, and every time bit wrong. fn whp_cpuid(function: u32, index: u32) -> [u32; 4] { @@ -2336,18 +2312,18 @@ mod whp_tests { /// on a host outside the pinned generations, where the tests skip. fn partition(vp_count: u32) -> Option { let profile = match cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) { - Ok(profile) => profile, + Ok(profile) => PartitionProfile::pinned(profile.id()).unwrap(), Err(err) => { println!("skipped: no profile for this host: {err}"); return None; } }; let topology = test_cpuid::topology(vp_count, X2ApicState::Supported); - let effective = virt::time_abi::cpuid::effective_cpuid(profile, &topology).unwrap(); + let effective = virt::time_abi::cpuid::effective_cpuid(&profile, &topology).unwrap(); let config = TimeAbiConfig { cpuid: Arc::new(virt::time_abi::cpuid::backend_cpuid(&effective)), msrs: Arc::new(TimeAbiMsrs::new()), - cpu_profile: profile.id().to_owned(), + cpu_profile: profile, }; let mut whp_config = whp::PartitionConfig::new().unwrap(); whp_config @@ -2531,7 +2507,7 @@ mod whp_tests { } println!( "{}: exits {:#x?} results {} bank0 {:#x} bank1 {:#x} xsave {:#x}", - time_abi.cpu_profile, + time_abi.cpu_profile.id(), time_abi.cpuid_exits, time_abi.cpuid_results, time_abi.features_bank0, @@ -2613,7 +2589,7 @@ mod whp_tests { } println!( "{}: {count} probes of {} indexed leaves (max basic {max_basic:#x}, max extended {max_extended:#x}); {} unlisted results are not zero:", - time_abi.cpu_profile, + time_abi.cpu_profile.id(), indexed.len(), unlisted.len() ); @@ -2650,7 +2626,7 @@ mod whp_tests { topology: &cpuid_topology, apic_id: 0, }; - let profile = cpu_profile::pinned(&time_abi.cpu_profile).unwrap(); + let profile = &*time_abi.cpu_profile; let host = host_cpuid(); let started = std::time::Instant::now(); let report = unlisted_cpuid(profile, host, |function, index| { @@ -2693,7 +2669,7 @@ mod whp_tests { let Some(new) = partition(VP_COUNT) else { return; }; - let profile = cpu_profile::pinned(&new.time_abi.cpu_profile).unwrap(); + let profile = &*new.time_abi.cpu_profile; let programming = test_cpuid::profile_programming(profile, VP_COUNT); let mut whp_config = whp::PartitionConfig::new().unwrap(); whp_config From 739d94934a9d41603461ebc8f581bd440a42d5cd Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 10:32:27 -0700 Subject: [PATCH 282/304] openvmm: measure --cpu-fingerprint's unlisted CPUID like a cold boot --cpu-fingerprint, and so NVX's doctor H2, failed with E_CPU_UNLISTED on CET-capable hosts whose cold boot passes the same profile's checks (microsoft/nvx#390, finding 2). On a Core i9-12900H with WHP and intel.alderlake.v1, it reported CPUID 0xd.11 and 0xd.12, the XSAVE components of CET's user and supervisor states. The fingerprint's probe partition enables every processor and XSAVE feature that WHP reports as available, CET's supervisor states included, and the check required every entry of that partition's CPUID outside the profile's tables to read zero. No profile enables CET. A cold boot instead derives its partition's feature banks and XSAVE features from the profile and checks VP 0 at the host's unlisted_cpuid_candidates, where WHP presents no CET component. Check what a cold boot checks. check_fingerprint_with takes the entries outside the selected profile's tables from a partition configured from the profile, when the backend can create one, and verify_support_with_unlisted checks them together with the surface's support, so a profile that the backend does not support still fails first, naming every violation. HypervisorProbe::profile_unlisted_cpuid returns that view. WHP creates a second probe partition whose feature banks and XSAVE features derive from the profile as WhpTimeAbi::configure derives them, and reads its VP 0 at the host's candidates (virt_whp::profile_unlisted_cpuid). The fingerprint keeps its probe partition with every feature for the supported surface, and openvmm_helpers::hypervisor::fingerprint_backend resolves the backend once for both probes. MSHV keeps the default, which checks the fingerprint's own probe partition, until a CET-capable MSHV host shows whether it needs the same; KVM answers those entries from its CPUID table, so the check does not apply to it. Unit tests cover a WHP fingerprint that presents CET's components: its own probe partition fails, a configured partition that reads zero passes, and a configured partition that reads non-zero or cannot be created fails, while KVM, an unsupported profile, and an unknown host configure none. A virt_whp hardware test shows on the i9-12900H that all 12 host entries outside intel.alderlake.v1 read zero on the configured partition, while the probe partition with every feature presents 0xd.11 and 0xd.12. There, --cpu-fingerprint and doctor H2 now pass. The package-scoped clippy, documentation, and unit-test gates and xtask fmt pass. Part of microsoft/nvx#390. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 19 +- openvmm/hypervisor_resources/src/lib.rs | 22 +++ openvmm/openvmm_entry/src/cpu_fingerprint.rs | 25 ++- openvmm/openvmm_helpers/src/hypervisor.rs | 41 +++- openvmm/openvmm_hypervisors/src/whp.rs | 11 ++ vmm_core/cpu_profile/src/check.rs | 180 +++++++++++++++++- vmm_core/cpu_profile/src/lib.rs | 2 + vmm_core/cpu_profile/src/surface.rs | 40 +++- vmm_core/virt_whp/src/fingerprint.rs | 141 ++++++++++++-- vmm_core/virt_whp/src/lib.rs | 2 + 10 files changed, 448 insertions(+), 35 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index e05bc855b67..8cacd73b2bd 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -514,14 +514,17 @@ guest state and protect the directory with host access controls. `status=fail`, `code`, and `detail`, and OpenVMM exits with status 1 and the same code (`E_PROFILE_HOST_UNKNOWN` or `E_PROFILE_UNSUPPORTED`). On MSHV and WHP, which present the hypervisor's own guest view at every CPUID entry that - the profile does not list, a probe partition that reads non-zero at such an - entry fails with `E_CPU_UNLISTED`, so that no such entry carries host data; - the identity range `0x40000000` to `0x4fffffff` and the topology leaves - `0xb` and `0x1f` are exempt. KVM installs the profile as the guest's whole - CPUID table and answers every other entry from it (zero, or the - architecture's out-of-range and topology results), so the check does not - apply. The fingerprint is still written, so hosts of new generations can be - fingerprinted to derive their profiles. + the profile does not list, a partition that reads non-zero at such an entry + fails with `E_CPU_UNLISTED`, so that no such entry carries host data; the + identity range `0x40000000` to `0x4fffffff` and the topology leaves `0xb` + and `0x1f` are exempt. WHP reads VP 0 of a second probe partition + configured from the profile, as a cold boot configures its partition, at + the host's CPUID entries outside the profile; MSHV reads the fingerprint's + probe partition, which enables every feature the backend offers. KVM + installs the profile as the guest's whole CPUID table and answers every + other entry from it (zero, or the architecture's out-of-range and topology + results), so the check does not apply. The fingerprint is still written, so + hosts of new generations can be fingerprinted to derive their profiles. ```bash openvmm --hypervisor kvm --cpu-fingerprint host.json diff --git a/openvmm/hypervisor_resources/src/lib.rs b/openvmm/hypervisor_resources/src/lib.rs index 673068fc478..ac326c1c5b8 100644 --- a/openvmm/hypervisor_resources/src/lib.rs +++ b/openvmm/hypervisor_resources/src/lib.rs @@ -138,6 +138,28 @@ pub trait HypervisorProbe: Send + Sync + 'static { self.name() ) } + + /// Returns what VP 0 of a transient probe partition configured from + /// `profile`, as a time ABI cold boot configures its partition, reads at + /// every entry of the host's CPUID outside the profile's tables + /// (`cpu_profile::unlisted_cpuid_candidates`), for the + /// `--cpu-fingerprint` check that those entries read zero + /// (`E_CPU_UNLISTED`). `params` are as for + /// [`cpu_fingerprint`](Self::cpu_fingerprint), and like it, this needs no + /// guest and leaves no state behind. + /// + /// `None` means that the backend has no such probe, so the check reads + /// the fingerprint's own probe partition instead, which enables every + /// feature the backend offers. The default implementation returns + /// `None`. + fn profile_unlisted_cpuid( + &self, + params: &[(&str, &str)], + profile: &cpu_profile::CpuProfile, + ) -> anyhow::Result>> { + let _ = (params, profile); + Ok(None) + } } /// Private module for linkme infrastructure. diff --git a/openvmm/openvmm_entry/src/cpu_fingerprint.rs b/openvmm/openvmm_entry/src/cpu_fingerprint.rs index 5a674332378..31ad43ad6bf 100644 --- a/openvmm/openvmm_entry/src/cpu_fingerprint.rs +++ b/openvmm/openvmm_entry/src/cpu_fingerprint.rs @@ -4,6 +4,8 @@ //! Writes and checks the host CPU fingerprint for `--cpu-fingerprint`. use anyhow::Context; +use cpu_profile::ProfileError; +use cpu_profile::ProfileErrorCode; use cpu_profile::fingerprint::CpuFingerprint; use cpu_profile::fingerprint::ToolIdentity; use cpu_profile::host::HostIdentity; @@ -17,9 +19,14 @@ use std::path::Path; /// The check prints one `NVX-CPU-PROFILE:` line to stderr and fails with its /// code (`E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNSUPPORTED`, or, for MSHV and /// WHP, `E_CPU_UNLISTED`) after the fingerprint is written, so hosts -/// of new generations can still be fingerprinted. +/// of new generations can still be fingerprinted. A backend that can +/// configure a probe partition from the profile, as WHP can, is checked for +/// `E_CPU_UNLISTED` on that partition, as a cold boot checks its own. pub(crate) fn write(path: &Path, hypervisor: Option<&str>) -> anyhow::Result<()> { - let backend = openvmm_helpers::hypervisor::cpu_fingerprint(hypervisor) + let backend = openvmm_helpers::hypervisor::fingerprint_backend(hypervisor) + .context("failed to fingerprint the hypervisor backend")?; + let backend_fingerprint = backend + .cpu_fingerprint() .context("failed to fingerprint the hypervisor backend")?; let host = HostIdentity::collect().context("failed to identify the host")?; let fingerprint = CpuFingerprint::new( @@ -28,7 +35,7 @@ pub(crate) fn write(path: &Path, hypervisor: Option<&str>) -> anyhow::Result<()> version: openvmm_build_info::get().version().to_owned(), }, host, - backend, + backend_fingerprint, ); let json = fingerprint.to_json(); if path == Path::new("-") { @@ -47,7 +54,17 @@ pub(crate) fn write(path: &Path, hypervisor: Option<&str>) -> anyhow::Result<()> "wrote CPU fingerprint" ); - let check = cpu_profile::check_fingerprint(&fingerprint); + let check = cpu_profile::check_fingerprint_with(&fingerprint, |profile| { + backend.profile_unlisted_cpuid(profile).map_err(|err| { + ProfileError::new( + ProfileErrorCode::ProfileUnsupported, + format!( + "cannot configure a probe partition from CPU profile {}: {err:#}", + profile.id() + ), + ) + }) + }); eprintln!("{}", check.summary_line(&fingerprint)); Ok(check.result?) } diff --git a/openvmm/openvmm_helpers/src/hypervisor.rs b/openvmm/openvmm_helpers/src/hypervisor.rs index 318f53104f3..8f24f3ec18e 100644 --- a/openvmm/openvmm_helpers/src/hypervisor.rs +++ b/openvmm/openvmm_helpers/src/hypervisor.rs @@ -4,6 +4,8 @@ //! Hypervisor resource construction and auto-detection for OpenVMM entry //! points. +use cpu_profile::CpuProfile; +use cpu_profile::cpuid::CpuidEntry; use cpu_profile::fingerprint::BackendFingerprint; use hypervisor_resources::HypervisorKind; use vm_resource::Resource; @@ -64,17 +66,34 @@ pub fn hypervisor_resource(spec: &str) -> anyhow::Result) -> anyhow::Result { + fingerprint_backend(spec)?.cpu_fingerprint() +} + +/// A hypervisor backend selected for a host CPU fingerprint and its checks. +pub struct FingerprintBackend<'a> { + probe: &'static dyn hypervisor_resources::HypervisorProbe, + params: Vec<(&'a str, &'a str)>, +} + +/// Selects the hypervisor backend for a host CPU fingerprint. +/// +/// `spec` selects the backend as for [`hypervisor_resource`]. Without it, the +/// first available backend is used, as for [`choose_hypervisor`]. +pub fn fingerprint_backend(spec: Option<&str>) -> anyhow::Result> { match spec { Some(spec) => { let (name, params) = parse_hypervisor_spec(spec)?; let probe = hypervisor_resources::probe_by_name(name) .ok_or_else(|| anyhow::anyhow!("unknown hypervisor: {name}"))?; - probe.cpu_fingerprint(¶ms) + Ok(FingerprintBackend { probe, params }) } None => { for probe in hypervisor_resources::probes() { if probe.try_new_resource()?.is_some() { - return probe.cpu_fingerprint(&[]); + return Ok(FingerprintBackend { + probe, + params: Vec::new(), + }); } } anyhow::bail!("no hypervisor available"); @@ -82,6 +101,24 @@ pub fn cpu_fingerprint(spec: Option<&str>) -> anyhow::Result } } +impl FingerprintBackend<'_> { + /// Returns the guest CPU surface that the backend supports on this host. + pub fn cpu_fingerprint(&self) -> anyhow::Result { + self.probe.cpu_fingerprint(&self.params) + } + + /// Returns what VP 0 of a probe partition configured from `profile` + /// reads at the host's CPUID entries outside the profile's tables, or + /// `None` if the backend has no such probe; see + /// [`HypervisorProbe::profile_unlisted_cpuid`](hypervisor_resources::HypervisorProbe::profile_unlisted_cpuid). + pub fn profile_unlisted_cpuid( + &self, + profile: &CpuProfile, + ) -> anyhow::Result>> { + self.probe.profile_unlisted_cpuid(&self.params, profile) + } +} + #[cfg(test)] mod tests { use super::cpu_fingerprint; diff --git a/openvmm/openvmm_hypervisors/src/whp.rs b/openvmm/openvmm_hypervisors/src/whp.rs index cb0dfca0db8..844406edce6 100644 --- a/openvmm/openvmm_hypervisors/src/whp.rs +++ b/openvmm/openvmm_hypervisors/src/whp.rs @@ -56,4 +56,15 @@ impl hypervisor_resources::HypervisorProbe for WhpProbe { anyhow::ensure!(virt_whp::is_available()?, "WHP is not available"); Ok(virt_whp::cpu_fingerprint()?) } + + #[cfg(guest_arch = "x86_64")] + fn profile_unlisted_cpuid( + &self, + params: &[(&str, &str)], + profile: &cpu_profile::CpuProfile, + ) -> anyhow::Result>> { + crate::reject_fingerprint_params("whp", params)?; + anyhow::ensure!(virt_whp::is_available()?, "WHP is not available"); + Ok(Some(virt_whp::profile_unlisted_cpuid(profile)?)) + } } diff --git a/vmm_core/cpu_profile/src/check.rs b/vmm_core/cpu_profile/src/check.rs index be5fe760d5e..b551793cbc1 100644 --- a/vmm_core/cpu_profile/src/check.rs +++ b/vmm_core/cpu_profile/src/check.rs @@ -5,12 +5,16 @@ //! CPU part of host qualification. use crate::catalog; +use crate::cpuid::CpuidEntry; use crate::error::ProfileError; use crate::fingerprint::CpuFingerprint; use crate::profile::CpuProfile; use crate::signature::HostCpuSignature; +use crate::surface::CpuidPresentation; use crate::surface::HostCpuSurface; +use crate::surface::support_violations; use crate::surface::verify_support; +use crate::surface::verify_support_with_unlisted; /// The prefix of the summary line of a fingerprint check. pub const SUMMARY_PREFIX: &str = "NVX-CPU-PROFILE:"; @@ -71,6 +75,24 @@ impl FingerprintCheck { /// a probe partition's view, that it presents zero at every CPUID entry /// outside the profile's tables (`E_CPU_UNLISTED`). pub fn check_fingerprint(fingerprint: &CpuFingerprint) -> FingerprintCheck { + check_fingerprint_with(fingerprint, |_| Ok(None)) +} + +/// Checks a host fingerprint as [`check_fingerprint`] does, but for MSHV and +/// WHP takes the CPUID entries outside the selected profile's tables from +/// `configured`, as a cold boot checks them: `configured` returns what VP 0 +/// of a partition configured from the profile reads at the host's +/// [`unlisted_cpuid_candidates`](crate::unlisted_cpuid_candidates), or +/// `None` to check the fingerprint's own probe partition instead. +/// +/// The fingerprint's probe partition enables every feature the backend +/// offers, so it can present entries that a partition of the profile does +/// not, such as the XSAVE components of CET. `configured` runs only when the +/// backend supports the profile otherwise; its failure fails the check. +pub fn check_fingerprint_with( + fingerprint: &CpuFingerprint, + configured: impl FnOnce(&CpuProfile) -> Result>, ProfileError>, +) -> FingerprintCheck { let cpu = &fingerprint.host.cpu; let mut vendor = [0; 12]; if cpu.vendor.len() == vendor.len() { @@ -81,10 +103,7 @@ pub fn check_fingerprint(fingerprint: &CpuFingerprint) -> FingerprintCheck { Ok(profile) => FingerprintCheck { host, profile: Some(profile), - result: verify_support( - profile, - &HostCpuSurface::from_fingerprint(&fingerprint.backend), - ), + result: check_support(profile, fingerprint, configured), }, Err(error) => FingerprintCheck { host, @@ -94,6 +113,26 @@ pub fn check_fingerprint(fingerprint: &CpuFingerprint) -> FingerprintCheck { } } +/// Checks that the backend of `fingerprint` supports `profile`, reading the +/// entries outside the profile's tables from `configured` if it returns +/// them. +fn check_support( + profile: &CpuProfile, + fingerprint: &CpuFingerprint, + configured: impl FnOnce(&CpuProfile) -> Result>, ProfileError>, +) -> Result<(), ProfileError> { + let surface = HostCpuSurface::from_fingerprint(&fingerprint.backend); + if surface.presentation != CpuidPresentation::PassThroughGuestView + || !support_violations(profile, &surface).is_empty() + { + return verify_support(profile, &surface); + } + match configured(profile)? { + Some(presented) => verify_support_with_unlisted(profile, &surface, &presented), + None => verify_support(profile, &surface), + } +} + #[cfg(test)] mod tests { use super::*; @@ -210,4 +249,137 @@ mod tests { let fingerprint = fingerprint_with(profile, "kvm", entries); check_fingerprint(&fingerprint).result.unwrap(); } + + /// A WHP host whose probe partition, with every available feature, + /// presents CET's XSAVE components, which the profile does not enable, + /// as the root partition of a 12th generation Intel Core host reports + /// them. + fn cet_host() -> CpuFingerprint { + let profile = profile("intel.alderlake.v1"); + let mut entries = profile_entries(profile); + entries.push(CpuidEntry::new(0xd, Some(11), [0x10, 0, 1, 0])); + entries.push(CpuidEntry::new(0xd, Some(12), [0x18, 0, 1, 0])); + entries.sort_by_key(CpuidEntry::key); + fingerprint_with(profile, "whp", entries) + } + + #[test] + fn checks_unlisted_entries_on_a_partition_configured_from_the_profile() { + let fingerprint = cet_host(); + // The probe partition's own view fails, though a cold boot passes. + let check = check_fingerprint(&fingerprint); + let error = check.result.unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert!( + error.message.ends_with( + "CPUID 0xd.11 is outside the profile and reads EAX 0x10, EBX 0x0, ECX 0x1, \ + EDX 0x0; CPUID 0xd.12 is outside the profile and reads EAX 0x18, EBX 0x0, \ + ECX 0x1, EDX 0x0" + ), + "{error}" + ); + + // A partition configured from the profile reads zero there. + let zeros = [ + (0xd, Some(11)), + (0xd, Some(12)), + (0x14, Some(1)), + (0x20, None), + ] + .map(|(leaf, subleaf)| CpuidEntry::new(leaf, subleaf, [0; 4])); + let mut asked = None; + let check = check_fingerprint_with(&fingerprint, |profile| { + asked = Some(profile.id().to_owned()); + Ok(Some(zeros.to_vec())) + }); + check.result.as_ref().unwrap(); + assert_eq!(asked.as_deref(), Some("intel.alderlake.v1")); + assert!( + check + .summary_line(&fingerprint) + .starts_with("NVX-CPU-PROFILE: status=pass backend=whp generation=alderlake "), + "{}", + check.summary_line(&fingerprint) + ); + + // A non-zero entry of the configured partition fails. + let mut presented = zeros.to_vec(); + presented[2] = CpuidEntry::new(0x14, Some(1), [0x0249_0002, 0x003f_003f, 0, 0]); + let error = check_fingerprint_with(&fingerprint, |_| Ok(Some(presented))) + .result + .unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert!( + error.message.ends_with( + "CPUID 0x14.1 is outside the profile and reads EAX 0x2490002, EBX 0x3f003f, \ + ECX 0x0, EDX 0x0" + ), + "{error}" + ); + + // Without a configured partition, the probe's own view counts. + assert_eq!( + check_fingerprint_with(&fingerprint, |_| Ok(None)) + .result + .unwrap_err() + .code, + ProfileErrorCode::CpuUnlisted + ); + // A backend that cannot configure the partition fails the check. + let error = check_fingerprint_with(&fingerprint, |_| { + Err(ProfileError::new( + ProfileErrorCode::ProfileUnsupported, + "WHP rejects the features", + )) + }) + .result + .unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + } + + #[test] + fn configures_a_partition_only_when_it_decides_the_check() { + let unused = |_: &CpuProfile| -> Result>, ProfileError> { + panic!("the check configured a partition") + }; + // KVM presents no host data outside the profile. + let profile = profile("intel.alderlake.v1"); + check_fingerprint_with(&fingerprint(profile, "kvm"), unused) + .result + .unwrap(); + // An unsupported profile fails first, naming every violation. + let mut entries = profile_entries(profile); + let entry = entries + .iter_mut() + .find(|entry| entry.key() == (7, Some(0))) + .unwrap(); + let [eax, ebx, ecx, edx] = entry.registers(); + *entry = CpuidEntry::new(7, Some(0), [eax, ebx & !(1 << 5), ecx, edx]); + entries.push(CpuidEntry::new(0xd, Some(11), [0x10, 0, 1, 0])); + entries.sort_by_key(CpuidEntry::key); + let error = check_fingerprint_with(&fingerprint_with(profile, "whp", entries), unused) + .result + .unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + assert!( + error + .message + .contains("CPUID 0x7.0 EBX bit 5 is not supported") + ); + assert!( + error + .message + .contains("CPUID 0xd.11 is outside the profile") + ); + // An unknown host has no profile to configure. + let mut unknown = cet_host(); + unknown.host.cpu.signature = Hex32(0x0008_06c1); + assert_eq!( + check_fingerprint_with(&unknown, unused) + .result + .unwrap_err() + .code, + ProfileErrorCode::ProfileHostUnknown + ); + } } diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index f132af0a40f..24ae916e3f6 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -94,6 +94,7 @@ pub use catalog::select_auto; pub use check::FingerprintCheck; pub use check::SUMMARY_PREFIX; pub use check::check_fingerprint; +pub use check::check_fingerprint_with; pub use derive::derive_host_profile; pub use effective::CpuidResult; pub use effective::EFFECTIVE_CPUID_SCHEMA; @@ -124,6 +125,7 @@ pub use surface::SupportedMsr; pub use surface::TIME_POLICY_BITS; pub use surface::support_violations; pub use surface::verify_support; +pub use surface::verify_support_with_unlisted; pub use unlisted::check_unlisted_cpuid; pub use unlisted::unlisted_cpuid_candidates; pub use unlisted::unlisted_cpuid_violations; diff --git a/vmm_core/cpu_profile/src/surface.rs b/vmm_core/cpu_profile/src/surface.rs index 2a855867277..172d7b06f5f 100644 --- a/vmm_core/cpu_profile/src/surface.rs +++ b/vmm_core/cpu_profile/src/surface.rs @@ -178,18 +178,52 @@ pub(crate) fn register_class(leaf: u32, subleaf: u32, register: usize) -> Regist /// whose only violations are such entries fails with /// `E_CPU_UNLISTED`. pub fn verify_support(profile: &CpuProfile, surface: &HostCpuSurface) -> Result<(), ProfileError> { - let violations = support_violations(profile, surface); let unlisted = match surface.presentation { CpuidPresentation::PassThroughGuestView => { unlisted_cpuid_violations(profile, &surface.cpuid) } CpuidPresentation::Table | CpuidPresentation::PassThroughHostView => Vec::new(), }; + support_result(profile, support_violations(profile, surface), &unlisted) +} + +/// Checks, as [`verify_support`] does, that the backend described by +/// `surface` supports `profile`, but checks the entries outside the +/// profile's tables in `presented` instead of `surface`: what VP 0 of a +/// partition configured from `profile`, as a cold boot configures it, reads +/// at the host's +/// [`unlisted_cpuid_candidates`](crate::unlisted_cpuid_candidates). +/// +/// `--cpu-fingerprint` uses it for a pass-through backend whose fingerprint +/// records a probe partition with every available feature: such a partition +/// also presents the XSAVE components of features that no profile enables, +/// such as CET's user and supervisor states, which a partition configured +/// from the profile does not. +pub fn verify_support_with_unlisted( + profile: &CpuProfile, + surface: &HostCpuSurface, + presented: &[CpuidEntry], +) -> Result<(), ProfileError> { + support_result( + profile, + support_violations(profile, surface), + &unlisted_cpuid_violations(profile, presented), + ) +} + +/// Returns the outcome of a support check with `violations` and the +/// `unlisted` entries: `E_PROFILE_UNSUPPORTED` naming both, or +/// `E_CPU_UNLISTED` when only entries outside the profile fail. +fn support_result( + profile: &CpuProfile, + violations: Vec, + unlisted: &[String], +) -> Result<(), ProfileError> { if violations.is_empty() { if unlisted.is_empty() { return Ok(()); } - return Err(unlisted_cpuid_error(profile, &unlisted)); + return Err(unlisted_cpuid_error(profile, unlisted)); } Err(ProfileError::new( ProfileErrorCode::ProfileUnsupported, @@ -198,7 +232,7 @@ pub fn verify_support(profile: &CpuProfile, surface: &HostCpuSurface) -> Result< profile.id(), violations .iter() - .chain(&unlisted) + .chain(unlisted) .map(String::as_str) .collect::>() .join("; ") diff --git a/vmm_core/virt_whp/src/fingerprint.rs b/vmm_core/virt_whp/src/fingerprint.rs index 8c89c193c9d..7b97f0b4266 100644 --- a/vmm_core/virt_whp/src/fingerprint.rs +++ b/vmm_core/virt_whp/src/fingerprint.rs @@ -6,8 +6,17 @@ use crate::Error; use crate::WhpResultExt; +use crate::profile_features::WhpFeatures; +use crate::profile_features::profile_features; +use crate::time_abi::host_cpuid; +use crate::time_abi::processor_features; +use crate::time_abi::unlisted_cpuid; +use cpu_profile::CpuProfile; use cpu_profile::cpuid::CpuidEntry; use cpu_profile::fingerprint::BackendFingerprint; +use whp::abi::WHV_PROCESSOR_FEATURES; +use whp::abi::WHV_PROCESSOR_FEATURES1; +use whp::abi::WHV_PROCESSOR_XSAVE_FEATURES; use whp::abi::WHV_X64_MSR_EXIT_BITMAP; const METHOD: &str = "WHvGetVirtualProcessorCpuidOutput on a probe partition with every processor \ @@ -59,15 +68,15 @@ pub fn cpu_fingerprint() -> Result { .zip(whp::capabilities::processor_xsave_features().ok()); let mut rejected_features = None; let mut partition = None; - if let Some(features) = available { - match probe_partition(Some(features)) { + if let Some((processor, xsave)) = available { + match probe_partition(ProbeFeatures::Available(processor, xsave)) { Ok(probe) => partition = Some(probe), Err(error) => rejected_features = Some(error), } } let partition = match partition { Some(partition) => partition, - None => probe_partition(None)?, + None => probe_partition(ProbeFeatures::Default)?, }; let cpuid = probe_cpuid(&partition)?; @@ -149,15 +158,69 @@ pub fn cpu_fingerprint() -> Result { Ok(fingerprint) } -/// Creates a probe partition with one virtual processor and the -/// in-hypervisor x2APIC. `features` are the processor and XSAVE features to -/// enable, or `None` for WHP's default processor features. -fn probe_partition( - features: Option<( - whp::ProcessorFeatures, - whp::abi::WHV_PROCESSOR_XSAVE_FEATURES, - )>, -) -> Result { +/// Returns what VP 0 of a transient probe partition configured from +/// `profile` reads at every entry of the host's CPUID outside the profile's +/// tables ([`cpu_profile::unlisted_cpuid_candidates`]), each read at +/// subleaf 0 if subleaf-independent, for the `--cpu-fingerprint` check that +/// those entries read zero (`E_CPU_UNLISTED`). +/// +/// It measures what a time ABI cold boot checks on VP 0 of its partition. +/// The probe partition's processor feature banks and XSAVE features derive +/// from the profile as `WhpTimeAbi::configure` derives them, without the +/// features that the time ABI hides, so that, unlike [`cpu_fingerprint`]'s +/// probe partition with every available feature, it does not present the +/// XSAVE components of features that no profile enables, such as CET's. It +/// programs no CPUID results, which cover only the profile's own entries. +/// The partition has no memory, never runs, and is deleted before this +/// returns. +/// +/// Fails with `E_PROFILE_UNSUPPORTED` if WHP cannot present the profile's +/// features. +pub fn profile_unlisted_cpuid(profile: &CpuProfile) -> Result, Error> { + let available = + whp::capabilities::processor_features().for_op("query the processor features")?; + let available_xsave = + whp::capabilities::processor_xsave_features().for_op("query the XSAVE features")?; + let derived = profile_features( + profile, + WhpFeatures { + banks: [available.bank0.0, available.bank1.0], + xsave: available_xsave.0, + }, + )?; + let mut features = available; + features.bank0 = WHV_PROCESSOR_FEATURES(derived.banks[0]); + features.bank1 = WHV_PROCESSOR_FEATURES1(derived.banks[1]); + let partition = probe_partition(ProbeFeatures::Profile( + processor_features(features), + WHV_PROCESSOR_XSAVE_FEATURES(derived.xsave), + ))?; + let vp = partition.vp(0); + let leaves = unlisted_cpuid(profile, host_cpuid(), |leaf, subleaf| { + vp.get_cpuid_output(leaf, subleaf) + .map(|output| [output.Eax, output.Ebx, output.Ecx, output.Edx]) + }) + .for_op("query the probe virtual processor CPUID")?; + Ok(leaves + .into_iter() + .map(|leaf| CpuidEntry::new(leaf.function, leaf.index, leaf.result)) + .collect()) +} + +/// The processor and XSAVE features of a probe partition. +enum ProbeFeatures { + /// WHP's default processor features. + Default, + /// Every processor and XSAVE feature that WHP reports as available. + Available(whp::ProcessorFeatures, WHV_PROCESSOR_XSAVE_FEATURES), + /// The features of a CPU profile's time ABI partition, set through the + /// feature banks as a time ABI partition sets them. + Profile(whp::ProcessorFeatures, WHV_PROCESSOR_XSAVE_FEATURES), +} + +/// Creates a probe partition with one virtual processor, the in-hypervisor +/// x2APIC, and `features`. +fn probe_partition(features: ProbeFeatures) -> Result { let mut config = whp::PartitionConfig::new().for_op("create the fingerprint probe partition")?; config @@ -168,10 +231,23 @@ fn probe_partition( whp::abi::WHvX64LocalApicEmulationModeX2Apic, )) .for_op("set the probe partition APIC emulation mode")?; - if let Some((processor, xsave)) = features { + let (processor, xsave) = match features { + ProbeFeatures::Default => (None, None), + ProbeFeatures::Available(processor, xsave) => ( + Some(whp::PartitionProperty::ProcessorFeatures(processor)), + Some(xsave), + ), + ProbeFeatures::Profile(processor, xsave) => ( + Some(whp::PartitionProperty::ProcessorFeaturesBanks(processor)), + Some(xsave), + ), + }; + if let Some(processor) = processor { config - .set_property(whp::PartitionProperty::ProcessorFeatures(processor)) + .set_property(processor) .for_op("set the probe partition processor features")?; + } + if let Some(xsave) = xsave { config .set_property(whp::PartitionProperty::ProcessorXsaveFeatures(xsave)) .for_op("set the probe partition XSAVE features")?; @@ -320,6 +396,8 @@ fn probe_tsc_scaling(host_frequency_hz: u64, fingerprint: &mut BackendFingerprin #[cfg(test)] mod tests { use super::cpu_fingerprint; + use super::profile_unlisted_cpuid; + use crate::time_abi::host_cpuid; use cpu_profile::cpuid; use test_with_tracing::test; @@ -341,4 +419,39 @@ mod tests { .contains_key("whp.probe.ProcessorFeatures") ); } + + /// The fingerprint check reads the entries outside the host's profile on + /// a partition configured from the profile, as a cold boot does: they + /// read zero there, even where the probe partition with every available + /// feature presents host data, such as the CET XSAVE components of a + /// CET-capable host. + #[test] + #[ignore = "requires WHP"] + fn a_partition_of_the_host_profile_reads_zero_outside_it() { + let profile = match cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) { + Ok(profile) => profile, + Err(err) => { + println!("skipped: no profile for this host: {err}"); + return; + } + }; + let presented = profile_unlisted_cpuid(profile).unwrap(); + let candidates = cpu_profile::unlisted_cpuid_candidates(profile, host_cpuid()); + assert_eq!( + presented + .iter() + .map(cpuid::CpuidEntry::key) + .collect::>(), + candidates + ); + cpu_profile::check_unlisted_cpuid(profile, &presented).unwrap(); + let fingerprint = cpu_fingerprint().unwrap(); + let all_features = cpu_profile::unlisted_cpuid_violations(profile, &fingerprint.cpuid); + println!( + "{}: {} host entries outside the profile read zero on its partition; the probe \ + partition with every feature presents {all_features:?}", + profile.id(), + candidates.len() + ); + } } diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index c57b303356f..81577931534 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -89,6 +89,8 @@ use x86defs::cpuid::Vendor; pub use aarch64::WHP_PMU_GSIV; #[cfg(guest_arch = "x86_64")] pub use fingerprint::cpu_fingerprint; +#[cfg(guest_arch = "x86_64")] +pub use fingerprint::profile_unlisted_cpuid; #[derive(Debug)] pub struct Whp { From 4cb5777fa7604ae6c0f1709ca1c04c3b595bbbf4 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 10:34:37 -0700 Subject: [PATCH 283/304] openvmm_core: name --cpu-profile host when no profile serves the host When --cpu-profile auto finds no pinned profile for the host's CPU, the E_PROFILE_HOST_UNKNOWN error named the host's CPU and the pinned profiles but no way to boot on that host (microsoft/nvx#390, finding 4). The VM worker now ends that error, for auto, with "--cpu-profile host boots on a profile derived from this host, for development", where a host profile can boot: on an Intel CPU. supports_host_profiles decides it, and derive_host_profile now shares its vendor rule, so an AMD host, which no host profile serves yet (microsoft/nvx#396), keeps the error without a suggestion that would fail too. The error keeps its code and the rest of its message, so tools that match the bracketed code at the start of OpenVMM's fatal-error line are unaffected. An explicit profile ID and --cpu-fingerprint's NVX-CPU-PROFILE line keep their messages: host qualification never accepts a host profile. Unit tests check the suggestion on a Tiger Lake host and its absence on an AMD host and for an explicit profile ID, and, on the host that runs them, that it appears exactly where host profiles serve a CPU that no pinned profile serves. On a Core i9-12900H build without the Alder Lake profile, nvx.py run printed it and then booted with --cpu-profile host. The package-scoped clippy, documentation, and unit-test gates and xtask fmt pass. Part of microsoft/nvx#390. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/worker/dispatch/time_abi.rs | 55 ++++++++++++++++++- vmm_core/cpu_profile/src/derive.rs | 54 ++++++++++++++---- vmm_core/cpu_profile/src/lib.rs | 11 ++-- 3 files changed, 102 insertions(+), 18 deletions(-) diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 6de6153460b..9806d9e705e 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -158,7 +158,17 @@ fn select_on_host( cpu_profile::check_generation(&profile, host)?; return Ok(profile); } - cpu_profile::select(requested, host) + cpu_profile::select(requested, host).map_err(|mut err| { + if requested == cpu_profile::AUTO + && err.code == ProfileErrorCode::ProfileHostUnknown + && cpu_profile::supports_host_profiles(host) + { + err.message.push_str( + "; --cpu-profile host boots on a profile derived from this host, for development", + ); + } + err + }) } /// Derives the host profile of this host and the `hypervisor` backend: the @@ -708,6 +718,11 @@ mod tests { /// The signature of the test profile's CPU, an Ice Lake-SP Xeon. const TEST_SIGNATURE: u32 = 0x0006_06a6; + /// The end of the `E_PROFILE_HOST_UNKNOWN` message of `auto` on a host + /// that host profiles serve. + const HOST_PROFILE_HINT: &str = + "; --cpu-profile host boots on a profile derived from this host, for development"; + fn test_profile() -> &'static CpuProfile { cpu_profile::pinned(TEST_PROFILE).unwrap() } @@ -1029,11 +1044,23 @@ mod tests { } // `auto` selects the pinned profile of the host's generation, if any, - // and a pinned ID selects its profile only in its generation. + // and otherwise names the opt-in host profile where one can serve the + // host; a pinned ID selects its profile only in its generation. let host = host_cpu().unwrap(); match select_cpu_profile("auto", "kvm", None) { Ok(profile) => assert!(cpu_profile::check_generation(&profile, &host).is_ok()), - Err(err) => assert_eq!(code::<()>(Err(err)), "E_PROFILE_HOST_UNKNOWN"), + Err(err) => { + let message = format!("{err:#}"); + assert!( + message.starts_with("[E_PROFILE_HOST_UNKNOWN] "), + "{message}" + ); + assert_eq!( + message.ends_with(HOST_PROFILE_HINT), + cpu_profile::supports_host_profiles(&host), + "{message}" + ); + } } for profile in cpu_profile::pinned_profiles() { let selected = select_cpu_profile(profile.id(), "kvm", None); @@ -1045,6 +1072,28 @@ mod tests { } } + /// `auto` names `--cpu-profile host` only where a host profile can boot: + /// on Intel CPUs that no pinned profile serves. + #[test] + fn auto_names_host_profiles_only_where_they_serve() { + let tiger_lake = HostCpuSignature::new(*b"GenuineIntel", 0x0008_06c1); + let zen3 = HostCpuSignature::new(*b"AuthenticAMD", 0x00a2_0f12); + for (host, hint) in [(tiger_lake, true), (zen3, false)] { + let error = select_on_host(cpu_profile::AUTO, &host, None).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown, "{error}"); + assert_eq!(error.message.ends_with(HOST_PROFILE_HINT), hint, "{error}"); + assert_eq!( + error.message.contains("--cpu-profile host"), + hint, + "{error}" + ); + } + // An explicit profile on another generation gets no hint. + let error = select_on_host(TEST_PROFILE, &tiger_lake, None).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuGeneration, "{error}"); + assert!(!error.message.contains("--cpu-profile host"), "{error}"); + } + #[test] fn a_host_profile_needs_a_backend_fingerprint() { let error = select_cpu_profile(cpu_profile::HOST, "nosuch", None).unwrap_err(); diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs index 7e14fa91cf4..64286634798 100644 --- a/vmm_core/cpu_profile/src/derive.rs +++ b/vmm_core/cpu_profile/src/derive.rs @@ -274,6 +274,23 @@ pub fn derive_profile( ) } +/// Returns whether host profiles serve the CPU `host`, so that +/// `--cpu-profile host` can boot on it: [`derive_host_profile`] derives them +/// for Intel CPUs only. +pub fn supports_host_profiles(host: &HostCpuSignature) -> bool { + host_profile_vendor(&host.vendor()).is_some() +} + +/// Returns how host profile IDs spell `vendor`, if host profiles support it. +/// The derivation policy and the VM-owned topology fields cover only Intel's +/// CPUID leaves, so only Intel CPUs have host profiles. +fn host_profile_vendor(vendor: &[u8]) -> Option<&'static str> { + match vendor { + b"GenuineIntel" => Some("intel"), + _ => None, + } +} + /// Derives the host profile of the host and backend that `fingerprint` /// describes, for `--cpu-profile host`: the profile that the derivation /// policy gives for this one fingerprint, with the ID @@ -284,19 +301,16 @@ pub fn derive_profile( /// immutable: a microcode, firmware, or hypervisor update can change it. /// /// Fails with `E_PROFILE_HOST_UNKNOWN` for a CPU of another vendor than -/// Intel, whose profiles do not exist yet, and with `E_PROFILE_UNSUPPORTED` -/// when the backend lacks a feature that the time ABI requires. +/// Intel ([`supports_host_profiles`]), and with `E_PROFILE_UNSUPPORTED` when +/// the backend lacks a feature that the time ABI requires. pub fn derive_host_profile(fingerprint: &CpuFingerprint) -> Result { let host = host_signature(fingerprint); let cpu = &fingerprint.host.cpu; - let vendor_short = match cpu.vendor.as_str() { - "GenuineIntel" => "intel", - _ => { - return Err(ProfileError::new( - ProfileErrorCode::ProfileHostUnknown, - format!("host CPU profiles support only Intel CPUs, and the host CPU is {host}"), - )); - } + let Some(vendor_short) = host_profile_vendor(cpu.vendor.as_bytes()) else { + return Err(ProfileError::new( + ProfileErrorCode::ProfileHostUnknown, + format!("host CPU profiles support only Intel CPUs, and the host CPU is {host}"), + )); }; let (family, model, stepping) = decode_signature(cpu.vendor.as_bytes(), cpu.signature.0); derive( @@ -1131,4 +1145,24 @@ mod tests { fn no_known_generation_is_the_host_generation() { assert!(known_generation(HOST_GENERATION).is_none()); } + + /// Host profiles serve the CPUs that [`derive_host_profile`] accepts. + #[test] + fn host_profiles_serve_intel_cpus_only() { + for (vendor, served) in [ + (*b"GenuineIntel", true), + (*b"AuthenticAMD", false), + (*b"HygonGenuine", false), + ] { + let host = HostCpuSignature::new(vendor, 0x0008_06c1); + assert_eq!(supports_host_profiles(&host), served, "{host}"); + let mut host_fingerprint = fingerprint(profile("intel.alderlake.v1"), "whp"); + host_fingerprint.host.cpu.vendor = host.vendor_str(); + assert_eq!( + derive_host_profile(&host_fingerprint).is_ok(), + served, + "{host}" + ); + } + } } diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index 24ae916e3f6..343e5d7ad8b 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -16,11 +16,11 @@ //! - **Selection:** [`select`] maps `--cpu-profile ` and the host //! CPU ([`HostCpuSignature`]) to a pinned profile. `--cpu-profile host` //! ([`HOST`]) is the opt-in exception, for development hosts that no -//! pinned profile serves: the VM worker derives a host profile from the -//! backend's fingerprint of this host ([`derive_host_profile`]). Either -//! way, the partition holds its profile in a [`PartitionProfile`], which -//! core hands to the backend, so each VM of a process can have its own -//! host profile. +//! pinned profile serves ([`supports_host_profiles`]): the VM worker +//! derives a host profile from the backend's fingerprint of this host +//! ([`derive_host_profile`]). Either way, the partition holds its profile +//! in a [`PartitionProfile`], which core hands to the backend, so each VM +//! of a process can have its own host profile. //! - **Verification:** each backend reports the CPU surface it supports //! ([`HostCpuSurface`]), and [`verify_support`] checks that it covers the //! profile. No CPUID entry outside the profile's tables carries host data: @@ -96,6 +96,7 @@ pub use check::SUMMARY_PREFIX; pub use check::check_fingerprint; pub use check::check_fingerprint_with; pub use derive::derive_host_profile; +pub use derive::supports_host_profiles; pub use effective::CpuidResult; pub use effective::EFFECTIVE_CPUID_SCHEMA; pub use effective::EffectiveCpuid; From 22a868409054c3f4db266b9d0008d997062cfe71 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 20:41:32 -0700 Subject: [PATCH 284/304] microVM: attach a second virtio-fs share with its own slot Part of microsoft/nvx#298 (requirement 2 of microsoft/nvx#290). A sandbox needs a read-write workspace and a read-only tool cache at the same time, each with its own guest target and with its access mode enforced by the host. The microVM had one fixed virtio-fs slot, so a guest could reach only one host directory, under one access mode. Add a second fixed virtio-fs slot and let `--mount` attach a host directory to each slot: - Slots. `MICROVM_FILESYSTEM_SLOTS` describes both slots. The first keeps `fs:microvm0`, tag `microvm`, MMIO 0xd0001000, and IRQ 6. The second uses `fs:microvm1`, tag `microvm1`, MMIO 0xd0008000, IRQ 13, and shared-status word 0x30020. The MP table routes only the ISA interrupts, and no other fixed device of any backend uses IRQ 13. The second slot exists only with a filesystem attached, so a machine with one share or none keeps its device inventory, command line, and snapshot contract. - Command line. `--mount` is repeatable up to the slot count, and the attachments fill the slots in order. Each attachment adds a `virtfs_dir=`, `virtfs_tag=`, `virtfs_mode=` triplet with its slot's tag, and the second slot's discovery token follows the control console's, in address order. The machine contract checks the virtio-fs device inventory, the token counts, and their order for every slot. - Policy. Each slot is a separate HostFs server with its own access mode and denied paths, so the host rejects writes to a read-only share whichever guest mount, tag, or link reaches it. The guest targets and the host directories of two shares must not equal or contain one another. Host directories are compared after canonicalization and by root object identity, so a bind mount can't alias another share. With several shares, each `--mount-deny` must be an absolute path and applies to the share whose root contains it. `--mount-owner` applies to every share. - Snapshots. The contract records the second slot's device and attachment, and its policy in the new `microvm_additional_filesystems` field, and checks that they agree. A restore must supply every captured share, in capture order, with the same canonical path, root identity, guest target, mode, denied paths, and owner. A dormant-slot snapshot can still bind one new attachment to the first slot; the second slot can't be added on restore. Earlier OpenVMM releases reject a two-share snapshot because its device inventory doesn't match. - Devices. The microVM virtio-fs profile, resolver, and dormant device accept either slot's stable ID and require that slot's tag, and the worker places the virtio-fs devices in slot order. - The ttrpc management API still binds only the first slot, and it rejects a snapshot that captured a second share. Update the Guide's virtio-fs, CLI, gRPC, and snapshot pages. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ee620ea-2f80-4743-9e4a-f771fb2f8c86 --- .../src/reference/devices/virtio/virtio-fs.md | 73 ++- Guide/src/reference/openvmm/management/cli.md | 37 +- .../src/reference/openvmm/management/grpc.md | 4 +- Guide/src/user_guide/openvmm/snapshots.md | 13 +- .../src/worker/dispatch/microvm.rs | 18 +- openvmm/openvmm_defs/src/microvm.rs | 463 ++++++++++++-- openvmm/openvmm_entry/src/cli_args/microvm.rs | 96 ++- openvmm/openvmm_entry/src/microvm/config.rs | 78 ++- .../openvmm_entry/src/microvm/filesystem.rs | 591 ++++++++++++++---- openvmm/openvmm_entry/src/microvm/launch.rs | 21 +- openvmm/openvmm_entry/src/microvm/mod.rs | 10 +- openvmm/openvmm_entry/src/microvm/network.rs | 4 +- openvmm/openvmm_entry/src/microvm/restore.rs | 25 +- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 68 +- .../src/vm_controller/microvm.rs | 24 +- .../openvmm_helpers/src/snapshot/microvm.rs | 576 ++++++++++++++--- .../virtio/virtiofs/src/microvm/device.rs | 52 +- .../virtio/virtiofs/src/microvm/profile.rs | 72 ++- .../virtio/virtiofs/src/microvm/resolver.rs | 76 ++- 19 files changed, 1843 insertions(+), 458 deletions(-) diff --git a/Guide/src/reference/devices/virtio/virtio-fs.md b/Guide/src/reference/devices/virtio/virtio-fs.md index a5a42da72af..35249a4b859 100644 --- a/Guide/src/reference/devices/virtio/virtio-fs.md +++ b/Guide/src/reference/devices/virtio/virtio-fs.md @@ -22,9 +22,10 @@ rules. It does not support snapshot and restore. ## microVM -The microVM profile reserves one fixed virtio-fs slot. Without `--mount`, the -slot is guest-discoverable but dormant and has no HostFs backend or filesystem -policy. Configure an active attachment with: +The microVM profile defines two fixed virtio-fs slots. A cold boot always +exposes the first slot; without `--mount`, it is guest-discoverable but +dormant and has no HostFs backend or filesystem policy. Configure an active +attachment with: ```bash openvmm --machine microvm \ @@ -39,11 +40,11 @@ guest-visible configuration: | Property | Value | |---|---| -| Stable ID | `fs:microvm0` | -| Tag | `microvm` | -| Transport | virtio-mmio at `0xd0001000` | -| Interrupt | IRQ 6 | -| Queues | One high-priority and one request queue | +| Stable IDs | `fs:microvm0`, `fs:microvm1` | +| Tags | `microvm`, `microvm1` | +| Transport | virtio-mmio at `0xd0001000`, `0xd0008000` | +| Interrupts | IRQ 6, IRQ 13 | +| Queues | One high-priority and one request queue per slot | | Features | Indirect descriptors, event index, version 1, access-platform | | DAX window | None | | Cache policy | Zero entry and attribute lifetimes | @@ -51,13 +52,42 @@ guest-visible configuration: | Maximum write | 1 MiB payload plus protocol headers | | Symbolic links | `rw`: created with the exact target; `ro`: `EROFS` | +### Several shares + +Repeat `--mount` to attach a second host directory with its own guest target, +tag, and access mode. Attachments fill the slots in command-line order, so +the first uses `fs:microvm0` and tag `microvm`, and the second uses +`fs:microvm1` and tag `microvm1`: + +```bash +openvmm --machine microvm \ + --mount /workspace,path/to/workspace,rw \ + --mount /opt/hostedtoolcache,path/to/toolcache,ro \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz +``` + +The second slot exists only while a directory is attached to it, so machines +with one or no share keep their device inventory and command line. Each slot +is a separate HostFs server, and it enforces its own access mode and denied +paths, so a guest cannot write to a read-only share through any mount of +either tag. OpenVMM rejects more than two attachments, guest targets that +equal or contain one another, and host directories that equal, contain, or +are contained in one another (compared after canonicalization and by root +object identity), because one share could otherwise reach files that the +other share denies or exposes with a different access mode. With several +attachments, each `--mount-deny` must be an absolute host path, and it +applies to the share whose directory contains it. `--mount-owner` applies to +every share. + For an active cold-boot attachment, the profile adds `virtfs_dir`, -`virtfs_tag`, and `virtfs_mode` bootstrap tokens to the kernel command line. -The fixed transport is always discoverable. Active attachment policy and the -canonical absolute host path become snapshot-authoritative. +`virtfs_tag`, and `virtfs_mode` bootstrap tokens to the kernel command line, +one triplet per share in slot order. The first slot is always discoverable, +and the second slot's `virtio_mmio.device=` token follows the control +console's. Active attachment policies and their canonical absolute host paths +become snapshot-authoritative. Repeat `--mount-deny` to hide existing host files or directories. OpenVMM -canonicalizes each entry relative to the export and rejects paths outside the -root, the root itself, overlapping entries, symlink/reparse components, and +canonicalizes each entry relative to its export and rejects paths outside the +roots, a root itself, overlapping entries, symlink/reparse components, and nested-mount crossings before opening the device. A read-write attachment lets the guest create symbolic links, so ordinary @@ -139,8 +169,9 @@ later host additions do not appear midway through that enumeration. New lookups and newly opened directories still observe the live host tree. Restoring a snapshot captured with an active attachment requires a fresh -`--mount` argument and the exact same denied-path set, canonical host path, -guest target, mode, and `--mount-owner` mode. Identity validation remains independent: before any vCPU starts, +`--mount` argument for every captured share, in the same order, with the exact +same denied-path set, canonical host path, guest target, mode, and +`--mount-owner` mode. Identity validation remains independent: before any vCPU starts, OpenVMM pins the supplied root and validates its saved root and object identities. Missing, moved, replaced, ambiguous, or no-longer-reopenable objects fail restore. A saved symbolic link is revalidated as the link itself, @@ -156,10 +187,11 @@ access, such as a handle open for writing on a file that a guest caller owns. OpenVMM releases that predate `--mount-owner` reject the device state of a `caller` attachment rather than restore it as `vmm`. -A snapshot captured without `--mount` records the fixed slot as dormant. It -may restore without an attachment, or bind a new `--mount` attachment. Because -execution resumes after the cold-boot mount hook, the guest must mount the -newly attached backend explicitly: +A snapshot captured without `--mount` records the first slot as dormant. It +may restore without an attachment, or bind one new `--mount` attachment to +that slot; the second slot did not exist at capture, so it cannot be added on +restore. Because execution resumes after the cold-boot mount hook, the guest +must mount the newly attached backend explicitly: ```bash mkdir -p /mnt/share @@ -206,7 +238,8 @@ enumeration omits their names, and denied root object identities reject hard-link, junction, and bind-mount aliases. A guest-created link cannot reach a denied path because the guest resolves it and every resulting host lookup applies the same policy. Mounting the same virtio-fs tag at another guest path -does not change the policy. +does not change the policy, and each share's policy applies only to requests +for its own tag. ```admonish warning On Windows, the check for host-created NT symbolic links and junctions in diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 8cacd73b2bd..f7b21866e36 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -48,7 +48,8 @@ describes the source definitions. `console=`, `virtio_mmio.device=`, `virtnet_*=`, or `virtfs_*=`. One virtio-fs slot is exposed at MMIO `0xd0001000`, IRQ 6 and remains - dormant when `--mount` is omitted; an optional `--mount` binds HostFs to it; + dormant when `--mount` is omitted; `--mount` binds HostFs to it, and a + second `--mount` adds a second slot at MMIO `0xd0008000`, IRQ 13; one optional `--virtio-console ` is exposed at MMIO `0xd0002000`, IRQ 7 as the boot/log console (`hvc1`); and `--microvm-sandbox-block` exposes fixed distro, runtime, custom, and scratch slots starting at MMIO @@ -181,14 +182,21 @@ describes the source definitions. software must establish new host-side flows. * `--mount `: With `--machine microvm`, attach one no-DAX HostFs device at MMIO `0xd0001000`, IRQ 6, with tag `microvm`. - The default mode is read-only; `rw` must be explicit. The guest target must + Repeat it once to attach a second device at MMIO `0xd0008000`, IRQ 13, with + tag `microvm1`; each share has its own guest target, access mode, and + denied paths. The default mode is read-only; `rw` must be explicit. The guest target must be an absolute, non-root Linux path without dot, parent, empty, whitespace, - backslash, or `=` components. + backslash, or `=` components. Guest targets and host directories of + different shares must not equal or contain one another. ```bash openvmm --machine microvm --hypervisor kvm \ --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz \ --mount /mnt/share,path/to/share,ro + openvmm --machine microvm --hypervisor kvm \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz \ + --mount /workspace,path/to/workspace,rw \ + --mount /opt/hostedtoolcache,path/to/toolcache,ro ``` The device has one high-priority queue, one request queue, direct-I/O file @@ -201,9 +209,11 @@ describes the source definitions. links are WSL-style reparse points. `--mount-deny ` is repeatable and hides an existing file or - directory inside the exported root. Paths are canonicalized to - host-relative policy entries before resources are opened. The complete root, - paths outside the root, duplicates, overlaps, symlink/reparse components, + directory inside an exported root. Paths are canonicalized to + host-relative policy entries before resources are opened. With two shares, + each path must be absolute and applies to the share whose root contains it. + The complete root, + paths outside the roots, duplicates, overlaps, symlink/reparse components, and nested-mount crossings are rejected. The virtio-fs server blocks the denied subtree and its root object identity, so `..`, a second mount of the same device, hard-link aliases, symlinks, junctions, and bind-mount aliases @@ -216,15 +226,16 @@ describes the source definitions. root, which must not be root. `caller` requires a Linux host. It also requires `CAP_SETUID` and `CAP_SETGID` unless every caller has OpenVMM's own UID and GID and OpenVMM has no other supplementary groups; an - operation that cannot run as its caller fails with `EPERM`. + operation that cannot run as its caller fails with `EPERM`. The mode applies + to every `--mount`. See [virtio-fs](../../devices/virtio/virtio-fs.md#host-identity-of-guest-operations). Filesystem snapshots contain guest-visible FUSE and queue state, not host directory contents or native handles. An active snapshot requires - `--mount` again with the exact canonical host path, guest target, access - mode, denied-path set, and `--mount-owner` mode; the live root and every saved object identity are + `--mount` again for every captured share, in capture order, with the exact canonical host path, guest target, access + mode, denied-path set, and `--mount-owner` mode; the live roots and every saved object identity are also revalidated before vCPUs start. A snapshot captured without `--mount` may remain dormant - or bind a new attachment. The resumed guest must then explicitly run + or bind one new attachment to the first slot. The resumed guest must then explicitly run `mount -t virtiofs microvm ` because its cold-boot mount hook has already completed. See [virtio-fs](../../devices/virtio/virtio-fs.md). @@ -313,11 +324,11 @@ describes the source definitions. A listener peer may connect after restore; guest transmit descriptors remain pending while no peer is connected. - When the snapshot contains an active virtio-fs attachment, restore requires - a fresh `--mount`. The argument must reproduce the manifest's exact + When the snapshot contains active virtio-fs attachments, restore requires + a fresh `--mount` for each, in capture order. Each argument must reproduce the manifest's exact canonical host path, guest target, and `ro`/`rw` mode while also supplying a live root with the same saved identity. A snapshot advertising the dormant - slot may instead accept a new attachment; snapshots without that capability + slot may instead accept one new attachment; snapshots without that capability reject additive attachment. When the snapshot contains virtio-net, restore also requires diff --git a/Guide/src/reference/openvmm/management/grpc.md b/Guide/src/reference/openvmm/management/grpc.md index a849a6b6ee9..3ff78e48174 100644 --- a/Guide/src/reference/openvmm/management/grpc.md +++ b/Guide/src/reference/openvmm/management/grpc.md @@ -182,7 +182,9 @@ same canonical root path, tag, guest target, and access mode, and the live root must retain its saved identity. A snapshot captured with the slot dormant may omit the attachment or supply a new one; after `ResumeVM`, the guest explicitly mounts tag `microvm`. The fixed device uses MMIO `0xd0001000`, IRQ 6, one -request queue, and no DAX window. +request queue, and no DAX window. The management API binds only the first +microVM virtio-fs slot: the command line's second `--mount` slot is not +exposed, and restoring a snapshot that captured two shares fails. The fork retains `VirtioFs.guest_mount_target` at protobuf field 3 and `read_write` at field 4 for existing microVM clients. Standard-machine diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index fbecb575d64..caf53a73700 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -331,20 +331,23 @@ post-restore acknowledgement boundary. Teardown still depends on the affected backends responding; it does not provide a bounded shutdown deadline. For microVM virtio-fs, the manifest always records the fixed, guest-discoverable -slot. A dormant slot has no host attachment or filesystem policy and carries +first slot. A dormant slot has no host attachment or filesystem policy and carries explicit dormant device-private state. An active slot also records the stable attachment ID, exact canonical host path, pinned root identity, guest mount target, access mode, ownership mode, no-DAX queue policy, and `live-revalidate` restore mode. +A second slot is recorded only when a second share was attached, with the +same fields for that share. Its device-private payload records FUSE negotiation, namespace IDs and aliases, lookup counts, reopenable handles, bounded directory-entry snapshots and cookies, and queue progress. Native file descriptors and Windows handles are never serialized. -Restoring an active slot requires a fresh -`--mount ` attachment with the same canonical -host path, target, and mode, and the same `--mount-owner` mode. OpenVMM independently validates the root and every +Restoring active slots requires a fresh +`--mount ` attachment for each, in capture +order, with the same canonical +host path, target, and mode, and the same `--mount-owner` mode. OpenVMM independently validates the roots and every saved object identity before starting a vCPU. A dormant-slot snapshot may -restore without an attachment or bind a new one. For a new attachment, the +restore without an attachment or bind one new attachment to the first slot. For a new attachment, the resumed guest explicitly mounts tag `microvm`; the cold-boot mount hook does not run again. diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index dfa23f5ab15..420daafb8b8 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -64,7 +64,7 @@ impl From for MicrovmManifest { fn from(config: MicrovmConfig) -> Self { let MicrovmConfig { network: _, - filesystem: _, + filesystems: _, sandbox_blocks, filesystem_bootstrap: _, memory_capacity, @@ -475,6 +475,7 @@ fn virtio_mmio_config( id: &str, sandbox_blocks: &[openvmm_defs::microvm::MicrovmSandboxBlockConfig], sandbox_block_index: &mut usize, + filesystem_index: &mut usize, chipset_mmio: ChipsetMmioRanges, ) -> anyhow::Result<(u64, u64, u32, u64, VirtioMmioInterruptMode)> { let (start, irq) = match id { @@ -482,10 +483,14 @@ fn virtio_mmio_config( openvmm_defs::microvm::MICROVM_VIRTIO_NET_MMIO_BASE, openvmm_defs::microvm::microvm_virtio_net_irq(None)?, ), - "virtiofs" => ( - openvmm_defs::microvm::MICROVM_VIRTIO_FS_MMIO_BASE, - openvmm_defs::microvm::MICROVM_VIRTIO_FS_IRQ, - ), + // The configuration orders virtio-fs devices by slot. + "virtiofs" => { + let slot = openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS + .get(*filesystem_index) + .context("microVM has more virtio-fs devices than fixed slots")?; + *filesystem_index += 1; + (slot.mmio_base, slot.irq) + } "virtio-console" => ( openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_MMIO_BASE, openvmm_defs::microvm::MICROVM_VIRTIO_CONSOLE_IRQ, @@ -535,6 +540,7 @@ fn virtio_mmio_config( pub(super) struct VirtioMmioSlots<'a> { sandbox_blocks: &'a [openvmm_defs::microvm::MicrovmSandboxBlockConfig], sandbox_block_index: usize, + filesystem_index: usize, chipset_mmio: ChipsetMmioRanges, } @@ -546,6 +552,7 @@ impl<'a> VirtioMmioSlots<'a> { Self { sandbox_blocks, sandbox_block_index: 0, + filesystem_index: 0, chipset_mmio, } } @@ -564,6 +571,7 @@ impl<'a> VirtioMmioSlots<'a> { id, self.sandbox_blocks, &mut self.sandbox_block_index, + &mut self.filesystem_index, self.chipset_mmio, )?; let id = format!("{id}-{mmio_start}"); diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 0b4d8342de6..f6b2855fdc8 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -41,12 +41,14 @@ pub const MICROVM_COMMAND_LINE_MAX_SIZE: usize = 64 * 1024; pub const MICROVM_VIRTIO_BLK_MMIO_BASE: u64 = 0xd000_3000; /// Reserved microVM virtio-net MMIO base. pub const MICROVM_VIRTIO_NET_MMIO_BASE: u64 = 0xd000_0000; -/// Reserved microVM virtio-fs MMIO base. +/// Reserved MMIO base of the first microVM virtio-fs slot. pub const MICROVM_VIRTIO_FS_MMIO_BASE: u64 = 0xd000_1000; /// Reserved microVM virtio-console MMIO base. pub const MICROVM_VIRTIO_CONSOLE_MMIO_BASE: u64 = 0xd000_2000; /// Fixed microVM control virtio-console MMIO base. pub const MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE: u64 = 0xd000_7000; +/// Fixed MMIO base of the second microVM virtio-fs slot. +pub const MICROVM_VIRTIO_FS1_MMIO_BASE: u64 = 0xd000_8000; /// Fixed microVM virtio transport window length. pub const MICROVM_VIRTIO_MMIO_LEN: u64 = 0x1000; /// Fixed distro virtio-blk interrupt. @@ -63,8 +65,13 @@ pub const MICROVM_VIRTIO_SCRATCH_BLK_IRQ: u32 = 11; pub const MICROVM_VIRTIO_CONSOLE_IRQ: u32 = 7; /// Fixed microVM control virtio-console interrupt. pub const MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ: u32 = 3; -/// Fixed microVM virtio-fs interrupt. +/// Fixed interrupt of the first microVM virtio-fs slot. pub const MICROVM_VIRTIO_FS_IRQ: u32 = 6; +/// Fixed interrupt of the second microVM virtio-fs slot. +/// +/// The MP table routes only ISA interrupts, and no other device of any +/// backend uses IRQ 13. +pub const MICROVM_VIRTIO_FS1_IRQ: u32 = 13; /// Fixed microVM virtio-net interrupt on KVM. pub const MICROVM_VIRTIO_NET_KVM_IRQ: u32 = 10; /// Fixed microVM virtio-net interrupt on WHP. @@ -81,7 +88,7 @@ pub const MICROVM_VIRTIO_CONTROL_CONSOLE_ID: &str = "virtio-control-console"; /// Host-owned kernel command-line token identifying the control tty. pub const MICROVM_CONTROL_TTY_COMMAND_LINE: &str = "nvx_control_tty=hvc2"; /// MicroVM virtio MMIO reservations in stable device order. -pub const MICROVM_VIRTIO_MMIO_BASES: [u64; 8] = [ +pub const MICROVM_VIRTIO_MMIO_BASES: [u64; 9] = [ MICROVM_VIRTIO_NET_MMIO_BASE, MICROVM_VIRTIO_FS_MMIO_BASE, MICROVM_VIRTIO_CONSOLE_MMIO_BASE, @@ -90,6 +97,48 @@ pub const MICROVM_VIRTIO_MMIO_BASES: [u64; 8] = [ 0xd000_5000, 0xd000_6000, MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, + MICROVM_VIRTIO_FS1_MMIO_BASE, +]; + +/// A fixed microVM virtio-fs slot. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct MicrovmFilesystemSlot { + /// Stable identity of the slot's device and host attachment. + pub stable_id: &'static str, + /// Guest-visible virtio-fs mount tag. + pub tag: &'static str, + /// Base of the slot's virtio-mmio transport window. + pub mmio_base: u64, + /// The slot's edge-triggered interrupt. + pub irq: u32, +} + +impl MicrovmFilesystemSlot { + /// Returns the `virtio_mmio.device=` token that discovers this slot. + pub fn discovery_token(&self) -> String { + format!( + "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{:#x}:{}", + self.mmio_base, self.irq + ) + } +} + +/// The fixed microVM virtio-fs slots. Filesystems occupy them in attachment +/// order. A cold boot always exposes the first slot, dormant when nothing is +/// attached to it; a later slot exists only with a filesystem attached. +pub const MICROVM_FILESYSTEM_SLOTS: [MicrovmFilesystemSlot; 2] = [ + MicrovmFilesystemSlot { + stable_id: "fs:microvm0", + tag: "microvm", + mmio_base: MICROVM_VIRTIO_FS_MMIO_BASE, + irq: MICROVM_VIRTIO_FS_IRQ, + }, + MicrovmFilesystemSlot { + stable_id: "fs:microvm1", + tag: "microvm1", + mmio_base: MICROVM_VIRTIO_FS1_MMIO_BASE, + irq: MICROVM_VIRTIO_FS1_IRQ, + }, ]; /// Fixed sandbox virtio-blk MMIO slots in layer order. pub const MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES: [u64; 4] = [ @@ -104,7 +153,7 @@ pub const MICROVM_SHARED_STATUS_PAGE_GPA: u64 = 0x3_0000; pub const MICROVM_SHARED_STATUS_PAGE_SIZE: u64 = 0x1000; /// Shared-status offset for virtio-net. pub const MICROVM_VIRTIO_NET_STATUS_OFFSET: u64 = 0x00; -/// Shared-status offset for virtio-fs. +/// Shared-status offset for the first virtio-fs slot. pub const MICROVM_VIRTIO_FS_STATUS_OFFSET: u64 = 0x04; /// Shared-status offset for virtio-console. pub const MICROVM_VIRTIO_CONSOLE_STATUS_OFFSET: u64 = 0x08; @@ -118,6 +167,8 @@ pub const MICROVM_VIRTIO_CUSTOM_BLK_STATUS_OFFSET: u64 = 0x14; pub const MICROVM_VIRTIO_SCRATCH_BLK_STATUS_OFFSET: u64 = 0x18; /// Shared-status offset for the dedicated control virtio-console. pub const MICROVM_VIRTIO_CONTROL_CONSOLE_STATUS_OFFSET: u64 = 0x1c; +/// Shared-status offset for the second virtio-fs slot. +pub const MICROVM_VIRTIO_FS1_STATUS_OFFSET: u64 = 0x20; /// Returns the shared interrupt-status word for a fixed virtio-mmio slot. pub const fn microvm_virtio_status_gpa(mmio_base: u64) -> Option { @@ -130,6 +181,7 @@ pub const fn microvm_virtio_status_gpa(mmio_base: u64) -> Option { 0xd000_5000 => MICROVM_VIRTIO_CUSTOM_BLK_STATUS_OFFSET, 0xd000_6000 => MICROVM_VIRTIO_SCRATCH_BLK_STATUS_OFFSET, MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE => MICROVM_VIRTIO_CONTROL_CONSOLE_STATUS_OFFSET, + MICROVM_VIRTIO_FS1_MMIO_BASE => MICROVM_VIRTIO_FS1_STATUS_OFFSET, _ => return None, }; Some(MICROVM_SHARED_STATUS_PAGE_GPA + offset) @@ -406,16 +458,53 @@ impl MicrovmFilesystemConfig { Ok(self) } - /// Returns the pinned guest bootstrap command-line tokens. - pub fn command_line_fragment(&self) -> String { + /// Returns the pinned guest bootstrap command-line tokens of this + /// filesystem when it is attached to `slot`. + pub fn command_line_fragment(&self, slot: &MicrovmFilesystemSlot) -> String { format!( - "virtfs_dir={} virtfs_tag=microvm virtfs_mode={}", + "virtfs_dir={} virtfs_tag={} virtfs_mode={}", self.guest_mount_target, + slot.tag, self.access.as_str() ) } } +/// Returns whether one guest mount target equals or contains the other. +fn microvm_guest_targets_overlap(left: &str, right: &str) -> bool { + let contains = |outer: &str, inner: &str| { + inner + .strip_prefix(outer) + .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('/')) + }; + contains(left, right) || contains(right, left) +} + +/// Validates the filesystems attached to the fixed microVM virtio-fs slots, +/// in slot order: there are no more than the slots, and no guest mount target +/// equals or contains another, so no mount hides or shadows another. +pub fn validate_microvm_filesystems( + filesystems: &[MicrovmFilesystemConfig], +) -> Result<(), InvalidMicrovmFilesystemConfig> { + if filesystems.len() > MICROVM_FILESYSTEM_SLOTS.len() { + return Err(InvalidMicrovmFilesystemConfig::TooManyFilesystems); + } + for (index, filesystem) in filesystems.iter().enumerate() { + for other in &filesystems[..index] { + if microvm_guest_targets_overlap( + &other.guest_mount_target, + &filesystem.guest_mount_target, + ) { + return Err(InvalidMicrovmFilesystemConfig::OverlappingGuestTargets( + other.guest_mount_target.clone(), + filesystem.guest_mount_target.clone(), + )); + } + } + } + Ok(()) +} + /// Error returned for an invalid microVM filesystem specification. #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum InvalidMicrovmFilesystemConfig { @@ -441,6 +530,12 @@ pub enum InvalidMicrovmFilesystemConfig { /// One denied path contained another denied path. #[error("microVM filesystem denied paths overlap")] OverlappingDeniedPaths, + /// More filesystems were attached than the microVM has virtio-fs slots. + #[error("microVM permits at most {} filesystems", MICROVM_FILESYSTEM_SLOTS.len())] + TooManyFilesystems, + /// One guest mount target equals or contains another. + #[error("microVM filesystem guest mount targets '{0}' and '{1}' overlap")] + OverlappingGuestTargets(String, String), } impl MicrovmNetworkConfig { @@ -623,12 +718,30 @@ fn validate_microvm_sandbox_blocks(blocks: &[MicrovmSandboxBlockConfig]) -> anyh Ok(()) } +/// Returns the number of virtio-fs slots present when the first slot is +/// reserved as `first_slot` and `attached` filesystems occupy the slots. +pub fn microvm_filesystem_slot_count(first_slot: bool, attached: usize) -> anyhow::Result { + anyhow::ensure!( + attached == 0 || first_slot, + "microVM filesystem policy requires the fixed virtio-fs slot" + ); + anyhow::ensure!( + attached <= MICROVM_FILESYSTEM_SLOTS.len(), + InvalidMicrovmFilesystemConfig::TooManyFilesystems + ); + Ok(if first_slot { attached.max(1) } else { 0 }) +} + /// Appends sandbox virtio devices in fixed-address order. +/// +/// `filesystem_slot` reserves the first virtio-fs slot, and `filesystems` +/// occupy the virtio-fs slots in order. A virtio-fs slot after the first is +/// discovered only with a filesystem attached. pub fn append_microvm_virtio_discovery( cmdline: &mut String, network: Option<(&MicrovmNetworkConfig, u32, bool)>, filesystem_slot: bool, - filesystem: Option<&MicrovmFilesystemConfig>, + filesystems: &[MicrovmFilesystemConfig], has_console: bool, has_control_console: bool, blocks: &[MicrovmSandboxBlockConfig], @@ -644,10 +757,8 @@ pub fn append_microvm_virtio_discovery( }), "microVM command line already contains virtio-mmio discovery" ); - anyhow::ensure!( - filesystem.is_none() || filesystem_slot, - "microVM filesystem policy requires the fixed virtio-fs slot" - ); + let filesystem_slots = microvm_filesystem_slot_count(filesystem_slot, filesystems.len())?; + validate_microvm_filesystems(filesystems)?; use std::fmt::Write as _; if let Some((_, irq, _)) = network { @@ -660,10 +771,11 @@ pub fn append_microvm_virtio_discovery( " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_NET_MMIO_BASE:#x}:{irq}" )?; } - if filesystem_slot { + if filesystem_slots > 0 { write!( cmdline, - " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_FS_MMIO_BASE:#x}:{MICROVM_VIRTIO_FS_IRQ}" + " {}", + MICROVM_FILESYSTEM_SLOTS[0].discovery_token() )?; } if has_console { @@ -690,6 +802,13 @@ pub fn append_microvm_virtio_discovery( " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE:#x}:{MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ} {MICROVM_CONTROL_TTY_COMMAND_LINE}" )?; } + for slot in MICROVM_FILESYSTEM_SLOTS + .iter() + .take(filesystem_slots) + .skip(1) + { + write!(cmdline, " {}", slot.discovery_token())?; + } if let Some((network, _, gateway_dns)) = network { write!( cmdline, @@ -697,8 +816,8 @@ pub fn append_microvm_virtio_discovery( network.command_line_fragment_with_dns(gateway_dns) )?; } - if let Some(filesystem) = filesystem { - write!(cmdline, " {}", filesystem.command_line_fragment())?; + for (filesystem, slot) in filesystems.iter().zip(&MICROVM_FILESYSTEM_SLOTS) { + write!(cmdline, " {}", filesystem.command_line_fragment(slot))?; } anyhow::ensure!( cmdline.len() < MICROVM_COMMAND_LINE_MAX_SIZE, @@ -707,6 +826,37 @@ pub fn append_microvm_virtio_discovery( Ok(()) } +/// Validates the virtio-fs device inventory against the attached filesystems, +/// which occupy the first slots, and returns the number of virtio-fs devices. +/// Only the first slot may be present without a filesystem. +fn validate_microvm_filesystem_devices(config: &Config) -> anyhow::Result { + let filesystem_count = config + .virtio_devices + .iter() + .filter(|(_, device)| device.id() == "virtiofs") + .count(); + let filesystems = &config.microvm.filesystems; + anyhow::ensure!( + filesystem_count <= MICROVM_FILESYSTEM_SLOTS.len(), + "microVM permits at most {} virtio-fs devices", + MICROVM_FILESYSTEM_SLOTS.len() + ); + anyhow::ensure!( + filesystems.len() <= filesystem_count, + "microVM filesystem policy requires a virtio-fs device" + ); + anyhow::ensure!( + filesystem_count <= filesystems.len().max(1), + "microVM exposes a virtio-fs slot after the first only with a filesystem attached" + ); + validate_microvm_filesystems(filesystems)?; + anyhow::ensure!( + !config.microvm.filesystem_bootstrap || !filesystems.is_empty(), + "microVM filesystem bootstrap requires an active filesystem policy" + ); + Ok(filesystem_count) +} + fn validate_microvm_command_line( config: &Config, hypervisor_id: Option<&str>, @@ -750,22 +900,11 @@ fn validate_microvm_command_line( .virtio_devices .iter() .any(|(_, device)| device.id() == "virtio-net"); - let has_filesystem = config - .virtio_devices - .iter() - .any(|(_, device)| device.id() == "virtiofs"); + let filesystem_count = validate_microvm_filesystem_devices(config)?; anyhow::ensure!( has_network == config.microvm.network.is_some(), "microVM virtio-net device and static network identity must be configured together" ); - anyhow::ensure!( - config.microvm.filesystem.is_none() || has_filesystem, - "microVM filesystem policy requires a virtio-fs device" - ); - anyhow::ensure!( - !config.microvm.filesystem_bootstrap || config.microvm.filesystem.is_some(), - "microVM filesystem bootstrap requires an active filesystem policy" - ); validate_microvm_sandbox_blocks(&config.microvm.sandbox_blocks)?; anyhow::ensure!( block_count == config.microvm.sandbox_blocks.len(), @@ -812,7 +951,11 @@ fn validate_microvm_command_line( "virtio_mmio.device=" => config.virtio_devices.len(), "virtnet_ip=" | "virtnet_mask=" | "virtnet_gw=" => usize::from(has_network), "virtfs_dir=" | "virtfs_tag=" | "virtfs_mode=" => { - usize::from(config.microvm.filesystem_bootstrap) + if config.microvm.filesystem_bootstrap { + config.microvm.filesystems.len() + } else { + 0 + } } _ => 1, }; @@ -848,10 +991,8 @@ fn validate_microvm_command_line( "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_NET_MMIO_BASE:#x}:{irq}" )); } - if has_filesystem { - expected_discovery.push(format!( - "virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{MICROVM_VIRTIO_FS_MMIO_BASE:#x}:{MICROVM_VIRTIO_FS_IRQ}" - )); + if filesystem_count > 0 { + expected_discovery.push(MICROVM_FILESYSTEM_SLOTS[0].discovery_token()); } if has_console { expected_discovery.push(format!( @@ -871,6 +1012,13 @@ fn validate_microvm_command_line( )); expected_discovery.push(MICROVM_CONTROL_TTY_COMMAND_LINE.to_owned()); } + expected_discovery.extend( + MICROVM_FILESYSTEM_SLOTS + .iter() + .take(filesystem_count) + .skip(1) + .map(MicrovmFilesystemSlot::discovery_token), + ); if let Some(network) = &config.microvm.network { expected_discovery.extend( network @@ -880,17 +1028,19 @@ fn validate_microvm_command_line( ); } if config.microvm.filesystem_bootstrap { - let filesystem = config + for (filesystem, slot) in config .microvm - .filesystem - .as_ref() - .expect("filesystem bootstrap policy was validated above"); - expected_discovery.extend( - filesystem - .command_line_fragment() - .split_ascii_whitespace() - .map(str::to_owned), - ); + .filesystems + .iter() + .zip(&MICROVM_FILESYSTEM_SLOTS) + { + expected_discovery.extend( + filesystem + .command_line_fragment(slot) + .split_ascii_whitespace() + .map(str::to_owned), + ); + } } if !expected_discovery.is_empty() { anyhow::ensure!( @@ -1084,11 +1234,13 @@ pub enum MachineProfile { pub struct MicrovmConfig { /// Static identity for the optional microVM virtio-net device. pub network: Option, - /// Guest-visible policy for the optional microVM virtio-fs device. - pub filesystem: Option, + /// Guest-visible policies of the attached microVM filesystems, in + /// virtio-fs slot order. + pub filesystems: Vec, /// Stable sandbox block-device roles in virtio-blk device order. pub sandbox_blocks: Vec, - /// Whether the effective command line bootstraps the active microVM filesystem. + /// Whether the effective command line bootstraps every attached microVM + /// filesystem. pub filesystem_bootstrap: bool, /// Immutable RAM capacity reserved by the microVM layout. pub memory_capacity: Option, @@ -1184,7 +1336,7 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> "static microVM network identity requires the microVM profile" ); anyhow::ensure!( - config.microvm.filesystem.is_none(), + config.microvm.filesystems.is_empty(), "microVM filesystem policy requires the microVM profile" ); anyhow::ensure!( @@ -1342,11 +1494,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> ); anyhow::ensure!( - config.virtio_devices.len() <= 8, + config.virtio_devices.len() <= MICROVM_VIRTIO_MMIO_BASES.len(), "microVM has too many virtio devices" ); let mut has_network = false; - let mut has_filesystem = false; let mut has_console = false; let mut has_control_console = false; let mut block_count = 0; @@ -1360,10 +1511,8 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> !std::mem::replace(&mut has_network, true), "microVM permits only one virtio-net device" ), - "virtiofs" => anyhow::ensure!( - !std::mem::replace(&mut has_filesystem, true), - "microVM permits only one virtio-fs device" - ), + // Counted against the fixed slots below. + "virtiofs" => {} "virtio-console" => anyhow::ensure!( !std::mem::replace(&mut has_console, true), "microVM permits only one virtio-console device" @@ -1391,14 +1540,7 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> has_network == config.microvm.network.is_some(), "microVM virtio-net device and static network identity must be configured together" ); - anyhow::ensure!( - config.microvm.filesystem.is_none() || has_filesystem, - "microVM filesystem policy requires a virtio-fs device" - ); - anyhow::ensure!( - !config.microvm.filesystem_bootstrap || config.microvm.filesystem.is_some(), - "microVM filesystem bootstrap requires an active filesystem policy" - ); + validate_microvm_filesystem_devices(config)?; anyhow::ensure!( config.layout.chipset_low_mmio_size == 1024 * 1024 * 1024 && config.layout.chipset_high_mmio_size == 0 @@ -1535,7 +1677,7 @@ mod tests { validate_microvm_control_console_command_line(&tokens, false).unwrap(); let mut cmdline = cmdline; - append_microvm_virtio_discovery(&mut cmdline, None, false, None, true, false, &[]).unwrap(); + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, false, &[]).unwrap(); assert!(build_microvm_control_command_line(&user_args, true).is_err()); } @@ -1556,7 +1698,7 @@ mod tests { let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); cmdline.push_str(" virtio-mmio.device=0x1000@0xc0000000:1"); assert!( - append_microvm_virtio_discovery(&mut cmdline, None, false, None, true, true, &[]) + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, true, &[]) .is_err() ); } @@ -1607,7 +1749,7 @@ mod tests { validate_microvm_sandbox_blocks(&blocks).unwrap(); let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); - append_microvm_virtio_discovery(&mut cmdline, None, false, None, false, false, &blocks) + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], false, false, &blocks) .unwrap(); assert_eq!( cmdline, @@ -1624,7 +1766,7 @@ mod tests { #[test] fn microvm_control_console_slot_is_stable() { let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); - append_microvm_virtio_discovery(&mut cmdline, None, false, None, true, true, &[]).unwrap(); + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, true, &[]).unwrap(); assert_eq!( cmdline, format!( @@ -1654,6 +1796,7 @@ mod tests { (MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[2], 0x3_0014), (MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[3], 0x3_0018), (MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, 0x3_001c), + (MICROVM_VIRTIO_FS1_MMIO_BASE, 0x3_0020), ]; for (mmio_base, expected_gpa) in slots { assert_eq!(microvm_virtio_status_gpa(mmio_base), Some(expected_gpa)); @@ -1662,7 +1805,195 @@ mod tests { expected_gpa < MICROVM_SHARED_STATUS_PAGE_GPA + MICROVM_SHARED_STATUS_PAGE_SIZE ); } - assert_eq!(microvm_virtio_status_gpa(0xd000_8000), None); + assert_eq!(microvm_virtio_status_gpa(0xd000_9000), None); + } + + #[test] + fn microvm_filesystem_slots_are_stable() { + assert_eq!( + MICROVM_FILESYSTEM_SLOTS.map(|slot| ( + slot.stable_id, + slot.tag, + slot.mmio_base, + slot.irq + )), + [ + ("fs:microvm0", "microvm", 0xd000_1000, 6), + ("fs:microvm1", "microvm1", 0xd000_8000, 13), + ] + ); + assert_eq!( + MICROVM_FILESYSTEM_SLOTS[1].discovery_token(), + "virtio_mmio.device=0x1000@0xd0008000:13" + ); + validate_microvm_virtio_reservations().unwrap(); + // Every slot has its own reserved window and an ISA interrupt that no + // other fixed device of any backend uses. + let other_irqs = [ + 0, + 8, + MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, + MICROVM_VIRTIO_BLK_IRQ, + MICROVM_VIRTIO_NET_WHP_IRQ, + MICROVM_VIRTIO_CONSOLE_IRQ, + MICROVM_VIRTIO_CUSTOM_BLK_IRQ, + MICROVM_VIRTIO_NET_KVM_IRQ, + MICROVM_VIRTIO_SCRATCH_BLK_IRQ, + MICROVM_VIRTIO_RUNTIME_BLK_IRQ, + ]; + for (index, slot) in MICROVM_FILESYSTEM_SLOTS.iter().enumerate() { + assert!(MICROVM_VIRTIO_MMIO_BASES.contains(&slot.mmio_base)); + assert!(microvm_virtio_status_gpa(slot.mmio_base).is_some()); + assert!(slot.irq < 16 && !other_irqs.contains(&slot.irq)); + for other in &MICROVM_FILESYSTEM_SLOTS[..index] { + assert_ne!(other.stable_id, slot.stable_id); + assert_ne!(other.tag, slot.tag); + assert_ne!(other.irq, slot.irq); + } + } + } + + fn filesystem(target: &str, access: MicrovmFilesystemAccess) -> MicrovmFilesystemConfig { + MicrovmFilesystemConfig::new(target.to_owned(), access).unwrap() + } + + #[test] + fn microvm_filesystem_slot_count_follows_attachments() { + assert_eq!(microvm_filesystem_slot_count(false, 0).unwrap(), 0); + assert_eq!(microvm_filesystem_slot_count(true, 0).unwrap(), 1); + assert_eq!(microvm_filesystem_slot_count(true, 1).unwrap(), 1); + assert_eq!(microvm_filesystem_slot_count(true, 2).unwrap(), 2); + assert!(microvm_filesystem_slot_count(false, 1).is_err()); + assert!(microvm_filesystem_slot_count(true, 3).is_err()); + } + + #[test] + fn microvm_filesystems_reject_overlapping_guest_targets() { + use MicrovmFilesystemAccess::ReadOnly; + use MicrovmFilesystemAccess::ReadWrite; + + validate_microvm_filesystems(&[]).unwrap(); + validate_microvm_filesystems(&[ + filesystem("/workspace", ReadWrite), + filesystem("/opt/hostedtoolcache", ReadOnly), + ]) + .unwrap(); + // A shared prefix that is not a path component does not overlap. + validate_microvm_filesystems(&[ + filesystem("/work", ReadWrite), + filesystem("/workspace", ReadOnly), + ]) + .unwrap(); + for (first, second) in [ + ("/workspace", "/workspace"), + ("/workspace", "/workspace/cache"), + ("/opt/hostedtoolcache/node", "/opt"), + ] { + assert_eq!( + validate_microvm_filesystems(&[ + filesystem(first, ReadWrite), + filesystem(second, ReadOnly), + ]), + Err(InvalidMicrovmFilesystemConfig::OverlappingGuestTargets( + first.to_owned(), + second.to_owned() + )) + ); + } + assert_eq!( + validate_microvm_filesystems(&[ + filesystem("/a", ReadOnly), + filesystem("/b", ReadOnly), + filesystem("/c", ReadOnly), + ]), + Err(InvalidMicrovmFilesystemConfig::TooManyFilesystems) + ); + } + + #[test] + fn microvm_discovery_places_later_filesystem_slots_after_the_control_console() { + let network: MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let filesystems = [ + filesystem("/workspace", MicrovmFilesystemAccess::ReadWrite), + filesystem("/opt/hostedtoolcache", MicrovmFilesystemAccess::ReadOnly), + ]; + let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery( + &mut cmdline, + Some((&network, MICROVM_VIRTIO_NET_KVM_IRQ, false)), + true, + &filesystems, + true, + true, + &[], + ) + .unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_CONSOLE_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0000000:10 \ + virtio_mmio.device=0x1000@0xd0001000:6 \ + virtio_mmio.device=0x1000@0xd0002000:7 \ + virtio_mmio.device=0x1000@0xd0007000:3 \ + {MICROVM_CONTROL_TTY_COMMAND_LINE} \ + virtio_mmio.device=0x1000@0xd0008000:13 \ + virtnet_ip=10.0.0.2 virtnet_mask=255.255.255.0 virtnet_gw=10.0.0.1 \ + virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw \ + virtfs_dir=/opt/hostedtoolcache virtfs_tag=microvm1 virtfs_mode=ro" + ) + ); + + // One filesystem keeps the single-slot command line. + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery( + &mut cmdline, + None, + true, + &filesystems[..1], + false, + false, + &[], + ) + .unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_BASE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0001000:6 \ + virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw" + ) + ); + + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + assert!( + append_microvm_virtio_discovery( + &mut cmdline, + None, + false, + &filesystems, + false, + false, + &[] + ) + .is_err() + ); + let overlapping = [ + filesystem("/workspace", MicrovmFilesystemAccess::ReadWrite), + filesystem("/workspace/cache", MicrovmFilesystemAccess::ReadOnly), + ]; + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + assert!( + append_microvm_virtio_discovery( + &mut cmdline, + None, + true, + &overlapping, + false, + false, + &[] + ) + .is_err() + ); } #[test] diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index fac6929c31c..8c435890943 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -335,19 +335,26 @@ pub struct MicrovmCli { #[clap(long, value_name = "IPv4:TCP-PORT", conflicts_with_all = ["allow_host", "block_host"])] pub allow_endpoint: Vec, - /// attach the microVM virtio-fs device + /// attach a host directory to the next fixed microVM virtio-fs slot /// - /// An active snapshot requires the same canonical host path, guest target, - /// and mode. A dormant-slot snapshot may bind a new attachment on restore; - /// the resumed guest must mount the `microvm` tag explicitly. + /// Repeat once to attach a second directory. The first directory uses the + /// `microvm` tag and the second the `microvm1` tag. Guest targets and host + /// directories must not overlap. An active snapshot requires the same + /// canonical host paths, guest targets, and modes, in the same order. A + /// dormant-slot snapshot may bind one new attachment on restore; the + /// resumed guest must mount the `microvm` tag explicitly. #[clap( long = "mount", value_name = "GUEST_TARGET,HOST_PATH[,ro|rw]", conflicts_with_all = ["virtio_fs", "virtio_fs_shmem"] )] - pub microvm_mount: Option, + pub microvm_mount: Vec, - /// Hide an existing host path inside the microVM filesystem export. + /// Hide an existing host path inside a microVM filesystem export. + /// + /// The path is hidden in the `--mount` whose host directory contains it. + /// A relative path is relative to the host directory of the only + /// `--mount`; with several, the path must be absolute. #[clap( long = "mount-deny", value_name = "HOST_PATH", @@ -364,7 +371,8 @@ pub struct MicrovmCli { /// `caller` requires a Linux host. It also requires CAP_SETUID and /// CAP_SETGID unless every caller has OpenVMM's own UID and GID and /// OpenVMM has no other supplementary groups; an operation that cannot - /// run as its caller fails with EPERM. + /// run as its caller fails with EPERM. The mode applies to every + /// `--mount`. #[clap( long = "mount-owner", value_enum, @@ -525,7 +533,7 @@ impl Options { && self.microvm.allow_host.is_empty() && self.microvm.block_host.is_empty() && self.microvm.allow_endpoint.is_empty() - && self.microvm.microvm_mount.is_none() + && self.microvm.microvm_mount.is_empty() && self.microvm.microvm_mount_deny.is_empty() && self.microvm.microvm_sandbox_block.is_empty() && self.microvm.microvm_workload_identity.is_none() @@ -772,6 +780,20 @@ impl Options { self.microvm.microvm_mount_deny.len() <= 128, "microVM filesystem permits at most 128 denied paths" ); + openvmm_defs::microvm::validate_microvm_filesystems( + &self + .microvm + .microvm_mount + .iter() + .map(|mount| { + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + mount.guest_target.clone(), + mount.access, + ) + }) + .collect::, _>>()?, + ) + .context("invalid --mount options")?; anyhow::ensure!( cfg!(target_os = "linux") || self.microvm.microvm_mount_owner != Some(MicrovmMountOwnerCli::Caller), @@ -1339,7 +1361,7 @@ mod tests { read_only: false, }, ]; - append_microvm_virtio_discovery(&mut with_devices, None, false, None, true, false, &blocks) + append_microvm_virtio_discovery(&mut with_devices, None, false, &[], true, false, &blocks) .unwrap(); assert_eq!( with_devices, @@ -1352,7 +1374,7 @@ mod tests { &mut with_control_console, None, false, - None, + &[], true, true, &[], @@ -1379,7 +1401,7 @@ mod tests { false, )), false, - None, + &[], false, false, &[], @@ -1400,7 +1422,7 @@ mod tests { true, )), false, - None, + &[], false, false, &[], @@ -1418,7 +1440,7 @@ mod tests { &mut with_filesystem, None, true, - Some(&filesystem), + std::slice::from_ref(&filesystem), false, false, &[], @@ -2216,4 +2238,52 @@ mod tests { assert!(MicrovmMountCli::from_str("/mnt/../escape,host").is_err()); assert!(MicrovmMountCli::from_str("/mnt/share,host,write").is_err()); } + + #[test] + fn test_microvm_mount_is_repeatable_up_to_the_slot_count() { + let parse = |mounts: &[&str]| { + let mut args = vec!["openvmm", "--machine", "microvm"]; + for mount in mounts { + args.extend(["--mount", mount]); + } + Options::try_parse_from(args).unwrap() + }; + let options = parse(&["/workspace,work,rw", "/opt/hostedtoolcache,tools"]); + options.validate_microvm_options().unwrap(); + let targets = options + .microvm + .microvm_mount + .iter() + .map(|mount| (mount.guest_target.as_str(), mount.access)) + .collect::>(); + assert_eq!( + targets, + [ + ( + "/workspace", + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + ), + ( + "/opt/hostedtoolcache", + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + ), + ] + ); + + for (mounts, expected) in [ + (&["/a,a", "/b,b", "/c,c"][..], "at most 2 filesystems"), + (&["/workspace,a,rw", "/workspace,b"][..], "overlap"), + (&["/workspace,a,rw", "/workspace/cache,b"][..], "overlap"), + (&["/opt/tools/node,a", "/opt,b,rw"][..], "overlap"), + ] { + let error = parse(mounts).validate_microvm_options().unwrap_err(); + assert!(format!("{error:#}").contains(expected), "{error:#}"); + } + + // --mount-deny and --mount-owner still require a --mount. + assert!( + Options::try_parse_from(["openvmm", "--machine", "microvm", "--mount-deny", "x"]) + .is_err() + ); + } } diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 28ec69adbcc..3ede267782c 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -13,8 +13,7 @@ use super::console::microvm_console_socket_cleanup; use super::console::microvm_control_broker_config; use super::console::validate_microvm_console_attachment_namespace; use super::filesystem::EffectiveMicrovmFilesystem; -use super::filesystem::MICROVM_FILESYSTEM_STABLE_ID; -use super::filesystem::effective_microvm_filesystem; +use super::filesystem::effective_microvm_filesystems; use super::filesystem::microvm_filesystem_slot_from_snapshot; use super::network::EffectiveMicrovmNetwork; use super::network::effective_microvm_network; @@ -70,7 +69,7 @@ pub(crate) struct MicrovmConfigBuilder<'a> { active: bool, network: Option, filesystem_slot: bool, - filesystem: Option, + filesystems: Vec, gateway_dns: bool, console: Option, control_console: Option, @@ -103,9 +102,9 @@ impl<'a> MicrovmConfigBuilder<'a> { } else { false }; - let filesystem = if active { - effective_microvm_filesystem( - opt.microvm.microvm_mount.as_ref(), + let filesystems = if active { + effective_microvm_filesystems( + &opt.microvm.microvm_mount, &opt.microvm.microvm_mount_deny, opt.microvm.microvm_mount_owner.map_or( openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm, @@ -114,16 +113,16 @@ impl<'a> MicrovmConfigBuilder<'a> { restore_machine_contract, )? } else { - None + Vec::new() }; - if let Some(filesystem) = &filesystem - && opt.microvm.snapshot_destination.is_some() - { - tracing::warn!( - stable_id = MICROVM_FILESYSTEM_STABLE_ID, - access_mode = filesystem.config.access.as_str(), - "microVM snapshot excludes live host filesystem contents; restore revalidates the external directory and may fail after host changes" - ); + if opt.microvm.snapshot_destination.is_some() { + for filesystem in &filesystems { + tracing::warn!( + stable_id = filesystem.attachment.stable_id, + access_mode = filesystem.config.access.as_str(), + "microVM snapshot excludes live host filesystem contents; restore revalidates the external directory and may fail after host changes" + ); + } } let gateway_dns = network .as_ref() @@ -197,12 +196,14 @@ impl<'a> MicrovmConfigBuilder<'a> { .map(|(_, _, attachment)| attachment.clone()), network_attachment: network.as_ref().map(|network| network.attachment.clone()), egress_policy: network.as_ref().map(|network| network.policy.clone()), - filesystem_attachment: filesystem - .as_ref() - .map(|filesystem| filesystem.attachment.clone()), - filesystem_root_path: filesystem - .as_ref() - .map(|filesystem| PathBuf::from(&filesystem.root_path)), + filesystem_attachments: filesystems + .iter() + .map(|filesystem| filesystem.attachment.clone()) + .collect(), + filesystem_root_paths: filesystems + .iter() + .map(|filesystem| PathBuf::from(&filesystem.root_path)) + .collect(), ..Default::default() }; @@ -212,7 +213,7 @@ impl<'a> MicrovmConfigBuilder<'a> { active, network, filesystem_slot, - filesystem, + filesystems, gateway_dns, console, control_console, @@ -546,15 +547,23 @@ impl<'a> MicrovmConfigBuilder<'a> { ); } - if self.filesystem_slot { - let (fs, profile) = if let Some(filesystem) = &self.filesystem { + let slot_count = openvmm_defs::microvm::microvm_filesystem_slot_count( + self.filesystem_slot, + self.filesystems.len(), + )?; + for (index, slot) in openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS + .iter() + .take(slot_count) + .enumerate() + { + let (fs, profile) = if let Some(filesystem) = self.filesystems.get(index) { ( virtio_resources::fs::VirtioFsBackend::HostFs { root_path: filesystem.root_path.clone(), mount_options: String::new(), }, virtio_resources::fs::microvm::VirtioFsProfile::Microvm { - stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(), + stable_id: slot.stable_id.to_owned(), root_identity: filesystem.attachment.identity.clone(), read_only: filesystem.config.access.is_read_only(), denied_paths: filesystem.config.denied_paths.clone(), @@ -565,14 +574,14 @@ impl<'a> MicrovmConfigBuilder<'a> { ( virtio_resources::fs::VirtioFsBackend::Dormant, virtio_resources::fs::microvm::VirtioFsProfile::MicrovmDormant { - stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(), + stable_id: slot.stable_id.to_owned(), }, ) }; add_virtio_device( VirtioBusCli::Mmio, virtio_resources::fs::VirtioFsHandle { - tag: "microvm".to_owned(), + tag: slot.tag.to_owned(), fs, profile, } @@ -652,14 +661,15 @@ impl<'a> MicrovmConfigBuilder<'a> { } } cfg.microvm.network = self.network.as_ref().map(|network| network.config.clone()); - let microvm_filesystem = self - .filesystem - .as_ref() - .map(|filesystem| filesystem.config.clone()); + let microvm_filesystems = self + .filesystems + .iter() + .map(|filesystem| filesystem.config.clone()) + .collect::>(); cfg.microvm.filesystem_bootstrap = restore_machine_contract .map(|contract| contract.microvm_filesystem.is_some()) - .unwrap_or_else(|| microvm_filesystem.is_some()); - cfg.microvm.filesystem = microvm_filesystem; + .unwrap_or_else(|| !microvm_filesystems.is_empty()); + cfg.microvm.filesystems = microvm_filesystems; cfg.microvm.memory_capacity = restore_machine_contract .and_then(|contract| { (contract.memory_expansion_version != 0).then_some(contract.memory_capacity_bytes) @@ -766,7 +776,7 @@ impl<'a> MicrovmConfigBuilder<'a> { cmdline, network, self.filesystem_slot, - cfg.microvm.filesystem.as_ref(), + &cfg.microvm.filesystems, has_console, has_control_console, &cfg.microvm.sandbox_blocks, diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index 53dbe458260..291ac40f021 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -8,9 +8,10 @@ use anyhow::Context; use std::path::Path; use std::path::PathBuf; -pub(super) const MICROVM_FILESYSTEM_STABLE_ID: &str = "fs:microvm0"; +pub(super) const MICROVM_FILESYSTEM_STABLE_ID: &str = + openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[0].stable_id; -#[derive(Clone)] +#[derive(Clone, Debug)] pub(super) struct EffectiveMicrovmFilesystem { pub(super) config: openvmm_defs::microvm::MicrovmFilesystemConfig, pub(super) root_path: String, @@ -238,8 +239,11 @@ fn canonical_microvm_filesystem_denied_paths( Ok(encoded) } +/// Returns the canonical host root of `host_path` and its live attachment +/// identity in the virtio-fs slot `slot`. pub(crate) fn microvm_filesystem_attachment( host_path: &Path, + slot: &openvmm_defs::microvm::MicrovmFilesystemSlot, ) -> anyhow::Result<( String, openvmm_helpers::snapshot::microvm::SnapshotAttachment, @@ -252,7 +256,7 @@ pub(crate) fn microvm_filesystem_attachment( Ok(( root_path, openvmm_helpers::snapshot::microvm::SnapshotAttachment { - stable_id: MICROVM_FILESYSTEM_STABLE_ID.to_owned(), + stable_id: slot.stable_id.to_owned(), kind: "virtio-fs".to_owned(), required: true, reconnect_policy: "live-revalidate".to_owned(), @@ -264,12 +268,17 @@ pub(crate) fn microvm_filesystem_attachment( )) } -fn microvm_filesystem_from_mount( +/// Builds the filesystem of `requested` in its canonical host root, with the +/// denied paths that `--mount-deny` attributed to it. +fn microvm_filesystem_from_root( requested: &cli_args::microvm::MicrovmMountCli, + (root_path, attachment): ( + String, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + ), denied_paths: &[PathBuf], owner: openvmm_defs::microvm::MicrovmFilesystemOwner, ) -> anyhow::Result { - let (root_path, attachment) = microvm_filesystem_attachment(&requested.host_path)?; if owner.is_caller() { validate_caller_owned_root(Path::new(&root_path))?; } @@ -288,6 +297,95 @@ fn microvm_filesystem_from_mount( }) } +/// Rejects host roots that overlap, so that no share can reach the files of +/// another share, or the paths that another share denies, under a different +/// access policy. Equal root identities catch one directory reached through +/// two canonical paths, such as a bind mount. +fn validate_microvm_filesystem_roots( + roots: &[( + String, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )], +) -> anyhow::Result<()> { + for (index, (root, attachment)) in roots.iter().enumerate() { + for (other, other_attachment) in &roots[..index] { + anyhow::ensure!( + !Path::new(root).starts_with(other) + && !Path::new(other).starts_with(root) + && attachment.identity != other_attachment.identity, + "microVM filesystem host directories must not overlap: {other} and {root}" + ); + } + } + Ok(()) +} + +/// Builds the filesystems of the `--mount` options, which occupy the fixed +/// virtio-fs slots in order. Each `--mount-deny` applies to the export root +/// that contains it; a relative path is relative to the only export root. +fn microvm_filesystems_from_mounts( + requested: &[cli_args::microvm::MicrovmMountCli], + denied_paths: &[PathBuf], + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, +) -> anyhow::Result> { + let slots = &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS; + anyhow::ensure!( + requested.len() <= slots.len(), + openvmm_defs::microvm::InvalidMicrovmFilesystemConfig::TooManyFilesystems + ); + let roots = requested + .iter() + .zip(slots) + .map(|(mount, slot)| microvm_filesystem_attachment(&mount.host_path, slot)) + .collect::>>()?; + validate_microvm_filesystem_roots(&roots)?; + + let mut attributed = vec![Vec::new(); roots.len()]; + match roots.len() { + 0 => anyhow::ensure!(denied_paths.is_empty(), "--mount-deny requires --mount"), + 1 => attributed[0].extend_from_slice(denied_paths), + _ => { + for denied in denied_paths { + anyhow::ensure!( + denied.is_absolute(), + "with several --mount options, --mount-deny requires an absolute host path: {}", + denied.display() + ); + let canonical = fs_err::canonicalize(denied).with_context(|| { + format!( + "failed to canonicalize microVM denied path {}", + denied.display() + ) + })?; + let index = roots + .iter() + .position(|(root, _)| canonical.starts_with(root)) + .with_context(|| { + format!( + "microVM denied path resolves outside the filesystem export roots: {}", + denied.display() + ) + })?; + attributed[index].push(denied.clone()); + } + } + } + + let filesystems = requested + .iter() + .zip(roots) + .zip(&attributed) + .map(|((mount, root), denied)| microvm_filesystem_from_root(mount, root, denied, owner)) + .collect::>>()?; + openvmm_defs::microvm::validate_microvm_filesystems( + &filesystems + .iter() + .map(|filesystem| filesystem.config.clone()) + .collect::>(), + )?; + Ok(filesystems) +} + /// Rejects an export root that caller ownership cannot squash guest root to. fn validate_caller_owned_root(root_path: &Path) -> anyhow::Result<()> { #[cfg(unix)] @@ -405,16 +503,18 @@ pub(crate) fn microvm_filesystem_slot_from_snapshot( } } -pub(super) fn effective_microvm_filesystem( - requested: Option<&cli_args::microvm::MicrovmMountCli>, +/// Returns the effective filesystems of the `--mount` options, in virtio-fs +/// slot order. A restore must supply exactly the snapshot's filesystems, in +/// the same order, unless the snapshot's first slot is dormant, in which case +/// one `--mount` may bind a new filesystem to it. +pub(super) fn effective_microvm_filesystems( + requested: &[cli_args::microvm::MicrovmMountCli], denied_paths: &[PathBuf], owner: openvmm_defs::microvm::MicrovmFilesystemOwner, restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, -) -> anyhow::Result> { +) -> anyhow::Result> { let Some(restore) = restore else { - return requested - .map(|requested| microvm_filesystem_from_mount(requested, denied_paths, owner)) - .transpose(); + return microvm_filesystems_from_mounts(requested, denied_paths, owner); }; let has_device = microvm_filesystem_slot_from_snapshot(restore)?; @@ -429,48 +529,69 @@ pub(super) fn effective_microvm_filesystem( || has_device == restore.microvm_filesystem.is_some()), "snapshot microVM filesystem slot, policy, and attachment inventories disagree" ); - let Some(saved) = restore.microvm_filesystem.as_ref() else { - let Some(requested) = requested else { - return Ok(None); - }; + let saved = restore.microvm_filesystems().collect::>(); + if saved.is_empty() { + if requested.is_empty() { + return Ok(Vec::new()); + } anyhow::ensure!( restore.microvm_filesystem_slot_version == openvmm_helpers::snapshot::microvm::MICROVM_FILESYSTEM_SLOT_VERSION, "snapshot does not support restore-time microVM filesystem attachment" ); - return microvm_filesystem_from_mount(requested, denied_paths, owner).map(Some); - }; - let requested = requested - .context("snapshot restore requires a fresh --mount attachment for fs:microvm0")?; - let config = microvm_filesystem_from_snapshot(saved)?; - anyhow::ensure!( - requested.guest_target == config.guest_mount_target && requested.access == config.access, - "restore-time mount target or access mode does not match the snapshot contract" - ); - anyhow::ensure!( - owner == config.owner, - "restore-time --mount-owner {} does not match the snapshot contract ({})", - owner.as_str(), - config.owner.as_str() - ); - let effective = microvm_filesystem_from_mount(requested, denied_paths, owner)?; - anyhow::ensure!( - !saved.canonical_host_path.is_empty(), - "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" - ); - anyhow::ensure!( - effective.root_path == saved.canonical_host_path, - "restore-time filesystem canonical host path does not match the snapshot contract" - ); + // Only the dormant first slot exists in the snapshot's machine. + anyhow::ensure!( + requested.len() == 1, + "a dormant-slot snapshot can attach only one --mount on restore" + ); + return microvm_filesystems_from_mounts(requested, denied_paths, owner); + } anyhow::ensure!( - Some(&effective.attachment) == saved_attachment, - "restore-time filesystem root identity does not match the snapshot attachment" + !requested.is_empty(), + "snapshot restore requires a fresh --mount attachment for fs:microvm0" ); anyhow::ensure!( - effective.config == config, - "restore-time filesystem denied paths do not match the snapshot contract" + requested.len() == saved.len(), + "snapshot restore requires {} --mount attachments in snapshot order, but {} were supplied", + saved.len(), + requested.len() ); - Ok(Some(effective)) + let mut configs = Vec::with_capacity(saved.len()); + for (requested, saved) in requested.iter().zip(&saved) { + let config = microvm_filesystem_from_snapshot(saved)?; + anyhow::ensure!( + requested.guest_target == config.guest_mount_target + && requested.access == config.access, + "restore-time mount target or access mode does not match the snapshot contract" + ); + anyhow::ensure!( + owner == config.owner, + "restore-time --mount-owner {} does not match the snapshot contract ({})", + owner.as_str(), + config.owner.as_str() + ); + configs.push(config); + } + let effective = microvm_filesystems_from_mounts(requested, denied_paths, owner)?; + for ((effective, saved), config) in effective.iter().zip(&saved).zip(&configs) { + anyhow::ensure!( + !saved.canonical_host_path.is_empty(), + "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" + ); + anyhow::ensure!( + effective.root_path == saved.canonical_host_path, + "restore-time filesystem canonical host path does not match the snapshot contract" + ); + anyhow::ensure!( + restore.attachments.contains(&effective.attachment), + "restore-time filesystem root identity does not match the snapshot attachment" + ); + anyhow::ensure!( + effective.config == *config, + "restore-time filesystem denied paths do not match the snapshot contract" + ); + } + Ok(effective) } #[cfg(test)] @@ -485,6 +606,19 @@ mod tests { const VMM: openvmm_defs::microvm::MicrovmFilesystemOwner = openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm; + /// Resolves at most one `--mount`, like the single-share tests expect. + fn effective_microvm_filesystem( + requested: &[cli_args::microvm::MicrovmMountCli], + denied_paths: &[PathBuf], + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, + ) -> anyhow::Result> { + let mut filesystems = + effective_microvm_filesystems(requested, denied_paths, owner, restore)?; + assert!(filesystems.len() <= 1); + Ok(filesystems.pop()) + } + fn filesystem_contract( root: &Path, ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { @@ -501,91 +635,78 @@ mod tests { ) .unwrap() .with_owner(owner); - let (root_path, attachment) = microvm_filesystem_attachment(root).unwrap(); - let mut command_line = build_microvm_command_line(&[], false).unwrap(); - openvmm_defs::microvm::append_microvm_virtio_discovery( - &mut command_line, - None, - true, - Some(&filesystem), - false, - false, - &[], - ) - .unwrap(); - openvmm_helpers::snapshot::microvm::microvm_machine_contract( - if cfg!(windows) { "whp" } else { "kvm" }, - openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, - command_line, - None, - true, - Some((&filesystem, Path::new(&root_path), attachment)), - None, - None, - Vec::new(), - 1, - 1024, - None, - [ - "partition", - "vmtime", - "pic", - "ioapic", - "pit", - "rtc", - "microvm-portb", - "microvm-shutdown", - "microvm-snapshot-request", - "virtiofs-3489665024", - ] - .map(str::to_owned) - .to_vec(), - crate::microvm::restore::tests::test_time_contract(), - crate::microvm::restore::tests::test_cpu_profile(), - ) - .unwrap() + filesystems_contract(&[(filesystem, root)]) } fn dormant_filesystem_contract() -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { + filesystems_contract(&[]) + } + + /// Builds the contract of a cold-booted machine with `shares` attached to + /// the virtio-fs slots in order, or with a dormant first slot. + fn filesystems_contract( + shares: &[(openvmm_defs::microvm::MicrovmFilesystemConfig, &Path)], + ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { + let filesystems = shares + .iter() + .map(|(filesystem, _)| filesystem.clone()) + .collect::>(); + let roots = shares + .iter() + .zip(&openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS) + .map(|((_, root), slot)| microvm_filesystem_attachment(root, slot).unwrap()) + .collect::>(); let mut command_line = build_microvm_command_line(&[], false).unwrap(); openvmm_defs::microvm::append_microvm_virtio_discovery( &mut command_line, None, true, - None, + &filesystems, false, false, &[], ) .unwrap(); + let mut state_unit_names = [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + ] + .map(str::to_owned) + .to_vec(); + state_unit_names.extend( + openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS + .iter() + .take(shares.len().max(1)) + .map(|slot| format!("virtiofs-{}", slot.mmio_base)), + ); openvmm_helpers::snapshot::microvm::microvm_machine_contract( if cfg!(windows) { "whp" } else { "kvm" }, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, command_line, None, true, - None, + filesystems + .iter() + .zip(&roots) + .map(|(filesystem, (root_path, attachment))| { + (filesystem, Path::new(root_path), attachment.clone()) + }) + .collect(), None, None, Vec::new(), 1, 1024, None, - [ - "partition", - "vmtime", - "pic", - "ioapic", - "pit", - "rtc", - "microvm-portb", - "microvm-shutdown", - "microvm-snapshot-request", - "virtiofs-3489665024", - ] - .map(str::to_owned) - .to_vec(), + state_unit_names, crate::microvm::restore::tests::test_time_contract(), crate::microvm::restore::tests::test_cpu_profile(), ) @@ -622,7 +743,7 @@ mod tests { .unwrap(); options.validate_microvm_options().unwrap(); let filesystem = effective_microvm_filesystem( - options.microvm.microvm_mount.as_ref(), + options.microvm.microvm_mount.as_slice(), &options.microvm.microvm_mount_deny, VMM, None, @@ -648,7 +769,7 @@ mod tests { let contract = filesystem_contract(root.path()); let options = restore_mount_options(root.path(), "ro"); let restored = effective_microvm_filesystem( - options.microvm.microvm_mount.as_ref(), + options.microvm.microvm_mount.as_slice(), &[], VMM, Some(&contract), @@ -662,7 +783,7 @@ mod tests { let replacement_options = restore_mount_options(replacement.path(), "ro"); assert!( effective_microvm_filesystem( - replacement_options.microvm.microvm_mount.as_ref(), + replacement_options.microvm.microvm_mount.as_slice(), &[], VMM, Some(&contract) @@ -683,7 +804,7 @@ mod tests { let options = restore_mount_options(&moved, "ro"); assert!( effective_microvm_filesystem( - options.microvm.microvm_mount.as_ref(), + options.microvm.microvm_mount.as_slice(), &[], VMM, Some(&contract) @@ -696,12 +817,12 @@ mod tests { fn filesystem_restore_rejects_missing_or_changed_policy() { let root = tempfile::tempdir().unwrap(); let contract = filesystem_contract(root.path()); - assert!(effective_microvm_filesystem(None, &[], VMM, Some(&contract)).is_err()); + assert!(effective_microvm_filesystem(&[], &[], VMM, Some(&contract)).is_err()); let changed_mode = restore_mount_options(root.path(), "rw"); assert!( effective_microvm_filesystem( - changed_mode.microvm.microvm_mount.as_ref(), + changed_mode.microvm.microvm_mount.as_slice(), &[], VMM, Some(&contract), @@ -714,7 +835,7 @@ mod tests { fn filesystem_restore_without_mount_preserves_dormant_slot() { let contract = dormant_filesystem_contract(); assert!( - effective_microvm_filesystem(None, &[], VMM, Some(&contract)) + effective_microvm_filesystem(&[], &[], VMM, Some(&contract)) .unwrap() .is_none() ); @@ -726,7 +847,7 @@ mod tests { let contract = dormant_filesystem_contract(); let options = restore_mount_options(root.path(), "rw"); let filesystem = effective_microvm_filesystem( - options.microvm.microvm_mount.as_ref(), + options.microvm.microvm_mount.as_slice(), &[], VMM, Some(&contract), @@ -750,7 +871,7 @@ mod tests { let contract = network_contract(); let options = restore_mount_options(root.path(), "ro"); let error = match effective_microvm_filesystem( - options.microvm.microvm_mount.as_ref(), + options.microvm.microvm_mount.as_slice(), &[], VMM, Some(&contract), @@ -817,7 +938,7 @@ mod tests { if metadata.uid() == 0 && metadata.gid() == 0 { let options = restore_mount_options(root_owned, "ro"); let error = match effective_microvm_filesystem( - options.microvm.microvm_mount.as_ref(), + options.microvm.microvm_mount.as_slice(), &[], caller, None, @@ -837,7 +958,7 @@ mod tests { if metadata.uid() != 0 && metadata.gid() != 0 { let options = restore_mount_options(root.path(), "rw"); let filesystem = effective_microvm_filesystem( - options.microvm.microvm_mount.as_ref(), + options.microvm.microvm_mount.as_slice(), &[], caller, None, @@ -859,7 +980,7 @@ mod tests { return; } let options = restore_mount_options(root.path(), "ro"); - let requested = options.microvm.microvm_mount.as_ref(); + let requested = options.microvm.microvm_mount.as_slice(); let contract = filesystem_contract_with_owner(root.path(), caller); assert_eq!( @@ -928,4 +1049,244 @@ mod tests { ) .unwrap(); } + + fn mount_options(mounts: &[String], extra: &[&str]) -> Options { + let mut args = vec!["openvmm", "--machine", "microvm"]; + for mount in mounts { + args.extend(["--mount", mount.as_str()]); + } + args.extend(extra); + Options::try_parse_from(args).unwrap() + } + + fn workspace_and_toolcache( + workspace: &Path, + toolcache: &Path, + toolcache_mode: &str, + ) -> Vec { + vec![ + format!("/workspace,{},rw", workspace.display()), + format!( + "/opt/hostedtoolcache,{},{toolcache_mode}", + toolcache.display() + ), + ] + } + + #[test] + fn mounts_occupy_the_fixed_slots_in_order() { + let workspace = tempfile::tempdir().unwrap(); + let toolcache = tempfile::tempdir().unwrap(); + let options = mount_options( + &workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"), + &[], + ); + options.validate_microvm_options().unwrap(); + let filesystems = + effective_microvm_filesystems(&options.microvm.microvm_mount, &[], VMM, None).unwrap(); + let summary = filesystems + .iter() + .map(|filesystem| { + ( + filesystem.attachment.stable_id.as_str(), + filesystem.config.guest_mount_target.as_str(), + filesystem.config.access, + ) + }) + .collect::>(); + assert_eq!( + summary, + [ + ( + "fs:microvm0", + "/workspace", + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite + ), + ( + "fs:microvm1", + "/opt/hostedtoolcache", + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly + ), + ] + ); + for (filesystem, root) in filesystems.iter().zip([&workspace, &toolcache]) { + assert_eq!( + filesystem.root_path, + fs_err::canonicalize(root.path()).unwrap().to_str().unwrap() + ); + } + assert_ne!( + filesystems[0].attachment.identity, + filesystems[1].attachment.identity + ); + } + + #[test] + fn mount_deny_applies_to_the_share_that_contains_it() { + let workspace = tempfile::tempdir().unwrap(); + let toolcache = tempfile::tempdir().unwrap(); + let workspace_secrets = workspace.path().join("secrets"); + let toolcache_secrets = toolcache.path().join("credentials"); + fs_err::create_dir(&workspace_secrets).unwrap(); + fs_err::create_dir(&toolcache_secrets).unwrap(); + let mounts = workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"); + let filesystems = effective_microvm_filesystems( + &mount_options(&mounts, &[]).microvm.microvm_mount, + &[toolcache_secrets.clone(), workspace_secrets.clone()], + VMM, + None, + ) + .unwrap(); + assert_eq!(filesystems[0].config.denied_paths, ["secrets"]); + assert_eq!(filesystems[1].config.denied_paths, ["credentials"]); + + // With several shares, a relative path names no export root. + let error = effective_microvm_filesystems( + &mount_options(&mounts, &[]).microvm.microvm_mount, + &[PathBuf::from("secrets")], + VMM, + None, + ) + .unwrap_err(); + assert!( + error.to_string().contains("absolute host path"), + "{error:#}" + ); + + let outside = tempfile::tempdir().unwrap(); + let error = effective_microvm_filesystems( + &mount_options(&mounts, &[]).microvm.microvm_mount, + &[outside.path().to_owned()], + VMM, + None, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("outside the filesystem export roots") + ); + } + + #[test] + fn mounts_reject_overlapping_host_directories_and_guest_targets() { + let parent = tempfile::tempdir().unwrap(); + let nested = parent.path().join("nested"); + fs_err::create_dir(&nested).unwrap(); + for (first, second) in [ + (parent.path(), nested.as_path()), + (nested.as_path(), parent.path()), + (parent.path(), parent.path()), + ] { + let options = mount_options(&workspace_and_toolcache(first, second, "ro"), &[]); + let error = + match effective_microvm_filesystems(&options.microvm.microvm_mount, &[], VMM, None) + { + Err(error) => error, + Ok(_) => panic!("overlapping host directories were accepted"), + }; + assert!(error.to_string().contains("must not overlap"), "{error:#}"); + } + + let other = tempfile::tempdir().unwrap(); + let options = mount_options( + &[ + format!("/workspace,{},rw", parent.path().display()), + format!("/workspace/cache,{},ro", other.path().display()), + ], + &[], + ); + assert!(options.validate_microvm_options().is_err()); + assert!( + effective_microvm_filesystems(&options.microvm.microvm_mount, &[], VMM, None).is_err() + ); + } + + fn two_share_contract( + workspace: &Path, + toolcache: &Path, + ) -> openvmm_helpers::snapshot::microvm::SnapshotMachineContract { + filesystems_contract(&[ + ( + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/workspace".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + ) + .unwrap(), + workspace, + ), + ( + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/opt/hostedtoolcache".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .unwrap(), + toolcache, + ), + ]) + } + + #[test] + fn filesystem_restore_requires_every_share_in_snapshot_order() { + let workspace = tempfile::tempdir().unwrap(); + let toolcache = tempfile::tempdir().unwrap(); + let contract = two_share_contract(workspace.path(), toolcache.path()); + assert_eq!(contract.microvm_additional_filesystems.len(), 1); + let restore = |mounts: &[String]| { + let options = mount_options(mounts, &["--restore-snapshot", "snapshot"]); + effective_microvm_filesystems(&options.microvm.microvm_mount, &[], VMM, Some(&contract)) + }; + + let mounts = workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"); + let restored = restore(&mounts).unwrap(); + assert_eq!(restored.len(), 2); + assert_eq!( + restored + .iter() + .map(|filesystem| filesystem.attachment.clone()) + .collect::>(), + contract.attachments + ); + + let error = restore(&[]).unwrap_err(); + assert!(error.to_string().contains("requires a fresh --mount")); + let error = restore(&mounts[..1]).unwrap_err(); + assert!( + error.to_string().contains("2 --mount attachments"), + "{error:#}" + ); + let swapped = [mounts[1].clone(), mounts[0].clone()]; + assert!(restore(&swapped).is_err()); + let writable = workspace_and_toolcache(workspace.path(), toolcache.path(), "rw"); + assert!(restore(&writable).is_err()); + + let replacement = tempfile::tempdir().unwrap(); + let replaced = workspace_and_toolcache(workspace.path(), replacement.path(), "ro"); + let error = restore(&replaced).unwrap_err(); + assert!( + error.to_string().contains("canonical host path"), + "{error:#}" + ); + } + + #[test] + fn dormant_snapshot_attaches_only_one_mount_on_restore() { + let contract = dormant_filesystem_contract(); + let workspace = tempfile::tempdir().unwrap(); + let toolcache = tempfile::tempdir().unwrap(); + let options = mount_options( + &workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"), + &["--restore-snapshot", "snapshot"], + ); + let error = match effective_microvm_filesystems( + &options.microvm.microvm_mount, + &[], + VMM, + Some(&contract), + ) { + Err(error) => error, + Ok(_) => panic!("a dormant-slot snapshot attached two shares"), + }; + assert!(error.to_string().contains("only one --mount")); + } } diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 1298e55b18f..1e26f528a23 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -33,7 +33,7 @@ pub(crate) struct MicrovmLaunch { resources: MicrovmResources, network: Option, filesystem_slot: bool, - filesystem: Option, + filesystems: Vec, snapshot_destination: Option, snapshot_memory_file: Option, snapshot_memory_handle: Option, @@ -69,7 +69,7 @@ impl MicrovmLaunch { std::env::current_dir().unwrap_or_default().join(path) } }); - if let Some(root_path) = resources.filesystem_root_path.as_deref() { + for root_path in &resources.filesystem_root_paths { validate_microvm_filesystem_private_storage( root_path, snapshot_destination.as_deref(), @@ -137,7 +137,7 @@ impl MicrovmLaunch { resources, network: vm_config.microvm.network.clone(), filesystem_slot, - filesystem: vm_config.microvm.filesystem.clone(), + filesystems: vm_config.microvm.filesystems.clone(), snapshot_destination, snapshot_memory_file, snapshot_memory_handle, @@ -239,11 +239,14 @@ impl MicrovmLaunch { .zip(resources.egress_policy.as_ref()) .zip(resources.network_attachment.as_ref()) .map(|((network, policy), attachment)| (network, policy, attachment)), - self.filesystem - .as_ref() - .zip(resources.filesystem_root_path.as_deref()) - .zip(resources.filesystem_attachment.as_ref()) - .map(|((filesystem, root_path), attachment)| (filesystem, root_path, attachment)), + self.filesystems + .iter() + .zip(&resources.filesystem_root_paths) + .zip(&resources.filesystem_attachments) + .map(|((filesystem, root_path), attachment)| { + (filesystem, root_path.as_path(), attachment) + }) + .collect(), resources.console_attachment.as_ref(), resources.control_console_attachment.as_ref(), sandbox_blocks, @@ -282,7 +285,7 @@ impl MicrovmLaunch { resources: self.resources, network: self.network, filesystem_slot: self.filesystem_slot, - filesystem: self.filesystem, + filesystems: self.filesystems, snapshot_memory_file: self.snapshot_memory_file, _private_scratch_dir: self.restore.private_scratch_dir, } diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 16a0d5c0172..85a93092610 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -81,10 +81,12 @@ pub(crate) struct MicrovmResources { pub(crate) network_attachment: Option, /// The bound run-scoped egress policy. pub(crate) egress_policy: Option, - /// Snapshot identity of the live filesystem root. - pub(crate) filesystem_attachment: Option, - /// Canonical host path of the live filesystem root. - pub(crate) filesystem_root_path: Option, + /// Snapshot identities of the live filesystem roots, in virtio-fs slot + /// order. + pub(crate) filesystem_attachments: Vec, + /// Canonical host paths of the live filesystem roots, in virtio-fs slot + /// order. + pub(crate) filesystem_root_paths: Vec, /// Under the time ABI, seals the time fields of the restore packet; the /// restoring worker takes it. pub(crate) restore_time_record: diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index 7d7d416d08a..7446b7166a0 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -211,7 +211,7 @@ pub(crate) mod tests { &mut command_line, Some((&network, irq, policy.allows_gateway_dns())), false, - None, + &[], false, false, &[], @@ -223,7 +223,7 @@ pub(crate) mod tests { command_line, Some((&network, &policy, microvm_network_attachment())), false, - None, + Vec::new(), None, None, Vec::new(), diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index e1b508e40f6..4852fece6bc 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -341,8 +341,8 @@ pub(crate) fn prepare_restore( } /// The machine contract a microVM snapshot must match to be restored: the -/// hypervisor, effective command line, network, filesystem, boot and control -/// console attachments, and sandbox blocks. +/// hypervisor, effective command line, network, filesystems in virtio-fs slot +/// order, boot and control console attachments, and sandbox blocks. pub(crate) type ExpectedRestoreContract<'a> = ( &'a str, &'a str, @@ -351,7 +351,7 @@ pub(crate) type ExpectedRestoreContract<'a> = ( &'a EgressPolicy, &'a SnapshotAttachment, )>, - Option<( + Vec<( &'a MicrovmFilesystemConfig, &'a Path, &'a SnapshotAttachment, @@ -424,7 +424,7 @@ pub(crate) fn validate_restore_contract( expected_hypervisor, effective_command_line, network, - filesystem, + filesystems, console_attachment, control_console_attachment, sandbox_blocks, @@ -441,18 +441,23 @@ pub(crate) fn validate_restore_contract( }; let network = network.map(|(config, policy, attachment)| (config, policy, attachment.clone())); let filesystem_slot = microvm_filesystem_slot_from_snapshot(saved_contract)?; - let filesystem = saved_contract - .microvm_filesystem - .as_ref() - .and(filesystem) - .map(|(config, root_path, attachment)| (config, root_path, attachment.clone())); + // A dormant-slot snapshot captured no filesystem, so a filesystem that the + // restore attaches to the slot is not part of the snapshot's contract. + let filesystems = if saved_contract.microvm_filesystem.is_some() { + filesystems + .into_iter() + .map(|(config, root_path, attachment)| (config, root_path, attachment.clone())) + .collect() + } else { + Vec::new() + }; let mut expected_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( expected_hypervisor, openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, effective_command_line.to_owned(), network, filesystem_slot, - filesystem, + filesystems, console_attachment.cloned(), control_console_attachment.cloned(), sandbox_blocks, diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index 73968fe1b2a..e2c83a2de58 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -92,7 +92,7 @@ pub(super) struct CreateVm { portb_has_output: bool, resources: crate::microvm::MicrovmResources, filesystem_slot: bool, - filesystem: Option, + filesystems: Vec, effective_command_line: Option, snapshot_memory_file: Option, snapshot_memory_handle: Option, @@ -349,7 +349,7 @@ impl CreateVm { portb_has_output: false, resources: Default::default(), filesystem_slot: active, - filesystem: None, + filesystems: Vec::new(), effective_command_line: None, snapshot_memory_file: None, snapshot_memory_handle: None, @@ -376,12 +376,21 @@ impl CreateVm { } self.restored_filesystem = if let Some(restore) = &self.authoritative_restore { + // The management API describes at most one virtio-fs attachment. + anyhow::ensure!( + restore + .machine_contract + .microvm_additional_filesystems + .is_empty(), + "ttrpc restore does not yet expose more than one microVM filesystem" + ); + let first_slot = &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[0]; let saved_policy = restore.machine_contract.microvm_filesystem.as_ref(); let saved_attachment = restore .machine_contract .attachments .iter() - .find(|attachment| attachment.stable_id == "fs:microvm0"); + .find(|attachment| attachment.stable_id == first_slot.stable_id); anyhow::ensure!( saved_policy.is_some() == saved_attachment.is_some() && (restore.machine_contract.microvm_filesystem_slot_version @@ -404,6 +413,7 @@ impl CreateVm { ); let (root_path, attachment) = crate::microvm::microvm_filesystem_attachment( Path::new(&requested.root_path), + first_slot, )?; anyhow::ensure!( !saved_policy.canonical_host_path.is_empty(), @@ -443,9 +453,10 @@ impl CreateVm { }, )?; let (root_path, attachment) = - crate::microvm::microvm_filesystem_attachment(Path::new( - &requested.root_path, - ))?; + crate::microvm::microvm_filesystem_attachment( + Path::new(&requested.root_path), + first_slot, + )?; Some(RestoredFilesystem { config, root_path, @@ -484,13 +495,16 @@ impl CreateVm { &self.source_hypervisor, &restore.machine_contract.effective_command_line, None, - self.restored_filesystem.as_ref().map(|filesystem| { - ( - &filesystem.config, - Path::new(&filesystem.root_path), - &filesystem.attachment, - ) - }), + self.restored_filesystem + .iter() + .map(|filesystem| { + ( + &filesystem.config, + Path::new(&filesystem.root_path), + &filesystem.attachment, + ) + }) + .collect(), restore .machine_contract .attachments @@ -865,8 +879,8 @@ impl CreateVm { /// Adds devices reconstructed from an authoritative snapshot contract. pub(super) fn add_restored_devices(&mut self, config: &mut Config) -> anyhow::Result<()> { if let Some(filesystem) = self.restored_filesystem.take() { - self.resources.filesystem_root_path = Some(PathBuf::from(&filesystem.root_path)); - config.microvm.filesystem = Some(filesystem.config.clone()); + self.resources.filesystem_root_paths = vec![PathBuf::from(&filesystem.root_path)]; + config.microvm.filesystems = vec![filesystem.config.clone()]; config.virtio_devices.push(( openvmm_defs::config::VirtioBus::Mmio, virtio_resources::fs::VirtioFsHandle { @@ -885,7 +899,7 @@ impl CreateVm { } .into_resource(), )); - self.resources.filesystem_attachment = Some(filesystem.attachment); + self.resources.filesystem_attachments = vec![filesystem.attachment]; } let Some(restore) = &self.authoritative_restore else { @@ -1007,7 +1021,7 @@ impl CreateVm { filesystem: vmservice::VirtioFs, ) -> anyhow::Result<()> { anyhow::ensure!( - config.microvm.filesystem.is_none() + config.microvm.filesystems.is_empty() && filesystem.tag == "microvm" && !filesystem.root_path.is_empty(), "microVM permits one virtio-fs attachment with tag 'microvm'" @@ -1020,9 +1034,11 @@ impl CreateVm { openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly }, )?; - let (root_path, attachment) = - crate::microvm::microvm_filesystem_attachment(Path::new(&filesystem.root_path))?; - self.resources.filesystem_root_path = Some(PathBuf::from(&root_path)); + let (root_path, attachment) = crate::microvm::microvm_filesystem_attachment( + Path::new(&filesystem.root_path), + &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[0], + )?; + self.resources.filesystem_root_paths = vec![PathBuf::from(&root_path)]; let resource = virtio_resources::fs::VirtioFsHandle { tag: "microvm".to_owned(), fs: virtio_resources::fs::VirtioFsBackend::HostFs { @@ -1045,9 +1061,9 @@ impl CreateVm { "microVM snapshot excludes live host filesystem contents; restore revalidates the external directory and may fail after host changes" ); } - config.microvm.filesystem = Some(filesystem_config); + config.microvm.filesystems = vec![filesystem_config]; config.microvm.filesystem_bootstrap = true; - self.resources.filesystem_attachment = Some(attachment); + self.resources.filesystem_attachments = vec![attachment]; config .virtio_devices .push((openvmm_defs::config::VirtioBus::Mmio, resource)); @@ -1153,7 +1169,7 @@ impl CreateVm { cmdline, None, self.filesystem_slot, - config.microvm.filesystem.as_ref(), + &config.microvm.filesystems, has_console, config.virtio_devices.iter().any(|(_, device)| { device.id() == openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_ID @@ -1179,8 +1195,8 @@ impl CreateVm { } => Some(cmdline.clone()), _ => None, }; - self.filesystem.clone_from(&config.microvm.filesystem); - if let Some(root_path) = self.resources.filesystem_root_path.as_deref() { + self.filesystems.clone_from(&config.microvm.filesystems); + for root_path in &self.resources.filesystem_root_paths { crate::microvm::validate_microvm_filesystem_private_storage( root_path, self.snapshot_destination.as_deref(), @@ -1309,7 +1325,7 @@ impl CreateVm { resources: self.resources, network: None, filesystem_slot: self.filesystem_slot, - filesystem: self.filesystem, + filesystems: self.filesystems, snapshot_memory_file: self.snapshot_memory_file, _private_scratch_dir: None, }, diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index e6f9d832e25..bca9a07af98 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -39,10 +39,11 @@ pub(crate) struct MicrovmController { pub(crate) resources: MicrovmResources, /// Static identity of the microVM virtio-net device. pub(crate) network: Option, - /// Whether the microVM virtio-fs slot is present. + /// Whether the first microVM virtio-fs slot is present. pub(crate) filesystem_slot: bool, - /// Guest-visible policy of the active microVM filesystem. - pub(crate) filesystem: Option, + /// Guest-visible policies of the active microVM filesystems, in virtio-fs + /// slot order. + pub(crate) filesystems: Vec, /// Automatic RAM backing created for snapshot capture. pub(crate) snapshot_memory_file: Option, /// Private copy of a paired scratch image, kept alive for the VM lifetime. @@ -277,13 +278,16 @@ impl VmController { .zip(self.microvm.resources.egress_policy.as_ref()) .zip(self.microvm.resources.network_attachment.clone()) .map(|((network, policy), attachment)| (network, policy, attachment)); - let filesystem = self + let filesystems = self .microvm - .filesystem - .as_ref() - .zip(self.microvm.resources.filesystem_root_path.as_deref()) - .zip(self.microvm.resources.filesystem_attachment.clone()) - .map(|((filesystem, root_path), attachment)| (filesystem, root_path, attachment)); + .filesystems + .iter() + .zip(&self.microvm.resources.filesystem_root_paths) + .zip(&self.microvm.resources.filesystem_attachments) + .map(|((filesystem, root_path), attachment)| { + (filesystem, root_path.as_path(), attachment.clone()) + }) + .collect(); let mut blocks = crate::storage_builder::microvm::snapshot_block_contract( &self.microvm.resources.sandbox_block_sources, scratch_policy, @@ -302,7 +306,7 @@ impl VmController { command_line, network, self.microvm.filesystem_slot, - filesystem, + filesystems, self.microvm.resources.console_attachment.clone(), self.microvm.resources.control_console_attachment.clone(), blocks, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 799bc212300..4aca47a1465 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -286,12 +286,13 @@ impl SnapshotMicrovmFilesystem { fn new( config: &openvmm_defs::microvm::MicrovmFilesystemConfig, canonical_host_path: &str, + slot: &openvmm_defs::microvm::MicrovmFilesystemSlot, ) -> Self { Self { guest_mount_target: config.guest_mount_target.clone(), access_mode: config.access.as_str().to_owned(), restore_mode: "live-revalidate".to_owned(), - tag: "microvm".to_owned(), + tag: slot.tag.to_owned(), high_priority_queue_count: 1, request_queue_count: 1, shared_memory_size: 0, @@ -411,7 +412,8 @@ pub struct SnapshotMachineContract { /// Static identity of the optional microVM virtio-net device. #[mesh(18)] pub microvm_network: Option, - /// Guest-visible policy of the optional microVM virtio-fs device. + /// Guest-visible policy of the optional microVM virtio-fs device in the + /// first slot. #[mesh(19)] pub microvm_filesystem: Option, /// Fixed-role sandbox blocks in guest-visible order. @@ -450,6 +452,11 @@ pub struct SnapshotMachineContract { /// The NVX time ABI CPU profile record; required. #[mesh(32)] pub cpu_profile: Option, + /// Guest-visible policies of the virtio-fs slots after the first, in slot + /// order. Each of these slots exists only with its filesystem attached, + /// which requires a filesystem in the first slot. + #[mesh(33)] + pub microvm_additional_filesystems: Vec, } impl SnapshotMachineContract { @@ -458,6 +465,14 @@ impl SnapshotMachineContract { self.effective_command_line_sha256 = sha2::Sha256::digest(command_line.as_bytes()).to_vec(); self.effective_command_line = command_line; } + + /// Returns the policies of the attached microVM filesystems, in + /// virtio-fs slot order. + pub fn microvm_filesystems(&self) -> impl Iterator { + self.microvm_filesystem + .iter() + .chain(&self.microvm_additional_filesystems) + } } fn microvm_snapshot_topology(processor_count: u32) -> anyhow::Result { @@ -643,8 +658,88 @@ fn memory_expansion_prefix( Ok(prefix) } +/// Returns the snapshot device of a fixed virtio-fs slot. +fn microvm_filesystem_device( + slot: &openvmm_defs::microvm::MicrovmFilesystemSlot, + order: usize, +) -> SnapshotDevice { + SnapshotDevice { + stable_id: slot.stable_id.to_owned(), + state_unit_name: format!("virtiofs-{}", slot.mmio_base), + kind: "virtio-fs".to_owned(), + order: order as u32, + ranges: vec![SnapshotDeviceRange { + address_space: "mmio".to_owned(), + start: slot.mmio_base, + length: openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + }], + irq: Some(slot.irq), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![ + openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES as u32, + (openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES >> 32) as u32, + ], + queue_count: 2, + queue_max_sizes: vec![256, 256], + } +} + +/// Validates the live attachment of the filesystem in `slot` and returns its +/// snapshot policy. +fn microvm_filesystem_policy( + source_hypervisor: &str, + effective_command_line: &str, + slot: &openvmm_defs::microvm::MicrovmFilesystemSlot, + filesystem: &openvmm_defs::microvm::MicrovmFilesystemConfig, + canonical_host_path: &Path, + attachment: &SnapshotAttachment, +) -> anyhow::Result { + let canonical_host_path = canonical_host_path + .to_str() + .context("microVM filesystem canonical host path is not valid UTF-8")?; + anyhow::ensure!( + !canonical_host_path.is_empty(), + "microVM filesystem canonical host path is empty" + ); + anyhow::ensure!( + attachment.stable_id == slot.stable_id + && attachment.kind == "virtio-fs" + && attachment.required + && attachment.reconnect_policy == "live-revalidate" + && match source_hypervisor { + "kvm" | "mshv" => attachment.identity_kind == "unix-device-inode-v1", + "whp" => attachment.identity_kind == "windows-volume-file-id-v1", + _ => false, + } + && !attachment.identity.is_empty() + && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES + && attachment.length == 0 + && attachment.reconnect_timeout_ms == 0, + "microVM filesystem attachment has an unsupported live-revalidation policy" + ); + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + let fragment = filesystem.command_line_fragment(slot); + let fragment = fragment.split_ascii_whitespace().collect::>(); + anyhow::ensure!( + tokens + .windows(fragment.len()) + .any(|window| window == fragment), + "microVM filesystem command line does not match its saved policy" + ); + Ok(SnapshotMicrovmFilesystem::new( + filesystem, + canonical_host_path, + slot, + )) +} + /// Builds the authoritative microVM machine contract, with the time ABI /// records `time` and `cpu_profile`. +/// +/// `filesystem_slot` reserves the first virtio-fs slot, and `filesystems` +/// occupy the virtio-fs slots in order. pub fn microvm_machine_contract( source_hypervisor: &str, boot_layout_version: u32, @@ -655,7 +750,7 @@ pub fn microvm_machine_contract( SnapshotAttachment, )>, filesystem_slot: bool, - filesystem: Option<( + filesystems: Vec<( &openvmm_defs::microvm::MicrovmFilesystemConfig, &Path, SnapshotAttachment, @@ -846,88 +941,47 @@ pub fn microvm_machine_contract( } else { None }; - anyhow::ensure!( - filesystem.is_none() || filesystem_slot, - "microVM filesystem policy requires the reserved virtio-fs slot" - ); - if filesystem_slot { - let discovery = format!( - "virtio_mmio.device={:#x}@{:#x}:{}", - openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, - openvmm_defs::microvm::MICROVM_VIRTIO_FS_MMIO_BASE, - openvmm_defs::microvm::MICROVM_VIRTIO_FS_IRQ, - ); + let filesystem_slot_count = + openvmm_defs::microvm::microvm_filesystem_slot_count(filesystem_slot, filesystems.len())?; + let filesystem_slots = + &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[..filesystem_slot_count]; + openvmm_defs::microvm::validate_microvm_filesystems( + &filesystems + .iter() + .map(|(filesystem, _, _)| (*filesystem).clone()) + .collect::>(), + )?; + for slot in filesystem_slots { anyhow::ensure!( effective_command_line .split_ascii_whitespace() - .any(|token| token == discovery), - "microVM virtio-fs slot is missing from the effective command line" + .any(|token| token == slot.discovery_token()), + "microVM virtio-fs slot {} is missing from the effective command line", + slot.stable_id ); - devices.push(SnapshotDevice { - stable_id: "fs:microvm0".to_owned(), - state_unit_name: format!( - "virtiofs-{}", - openvmm_defs::microvm::MICROVM_VIRTIO_FS_MMIO_BASE - ), - kind: "virtio-fs".to_owned(), - order: devices.len() as u32, - ranges: vec![mmio( - openvmm_defs::microvm::MICROVM_VIRTIO_FS_MMIO_BASE, - openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, - )], - irq: Some(openvmm_defs::microvm::MICROVM_VIRTIO_FS_IRQ), - transport: "virtio-mmio".to_owned(), - feature_banks: vec![ - openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES as u32, - (openvmm_defs::microvm::MICROVM_VIRTIO_FS_FEATURES >> 32) as u32, - ], - queue_count: 2, - queue_max_sizes: vec![256, 256], - }); } - let microvm_filesystem = if let Some((filesystem, canonical_host_path, attachment)) = filesystem + if let Some(slot) = filesystem_slots.first() { + devices.push(microvm_filesystem_device(slot, devices.len())); + } + let mut filesystem_policies = Vec::with_capacity(filesystems.len()); + let mut filesystem_attachments = Vec::with_capacity(filesystems.len()); + for ((filesystem, canonical_host_path, attachment), slot) in + filesystems.into_iter().zip(filesystem_slots) { - let canonical_host_path = canonical_host_path - .to_str() - .context("microVM filesystem canonical host path is not valid UTF-8")?; - anyhow::ensure!( - !canonical_host_path.is_empty(), - "microVM filesystem canonical host path is empty" - ); - anyhow::ensure!( - attachment.stable_id == "fs:microvm0" - && attachment.kind == "virtio-fs" - && attachment.required - && attachment.reconnect_policy == "live-revalidate" - && match source_hypervisor { - "kvm" | "mshv" => attachment.identity_kind == "unix-device-inode-v1", - "whp" => attachment.identity_kind == "windows-volume-file-id-v1", - _ => false, - } - && !attachment.identity.is_empty() - && attachment.identity.len() <= MAX_ATTACHMENT_IDENTITY_BYTES - && attachment.length == 0 - && attachment.reconnect_timeout_ms == 0, - "microVM filesystem attachment has an unsupported live-revalidation policy" - ); - let tokens = effective_command_line - .split_ascii_whitespace() - .collect::>(); - anyhow::ensure!( - filesystem - .command_line_fragment() - .split_ascii_whitespace() - .all(|token| tokens.contains(token)), - "microVM filesystem command line does not match its saved policy" - ); - attachments.push(attachment); - Some(SnapshotMicrovmFilesystem::new( + filesystem_policies.push(microvm_filesystem_policy( + source_hypervisor, + &effective_command_line, + slot, filesystem, canonical_host_path, - )) - } else { - None - }; + &attachment, + )?); + filesystem_attachments.push(attachment); + } + let mut filesystem_policies = filesystem_policies.into_iter(); + let mut filesystem_attachments = filesystem_attachments.into_iter(); + let microvm_filesystem = filesystem_policies.next(); + attachments.extend(filesystem_attachments.next()); if let Some(attachment) = console_attachment { let policy_is_valid = match attachment.reconnect_policy.as_str() { "recreate-listener" => { @@ -1090,6 +1144,10 @@ pub fn microvm_machine_contract( }); attachments.push(attachment); } + for slot in filesystem_slots.iter().skip(1) { + devices.push(microvm_filesystem_device(slot, devices.len())); + } + attachments.extend(filesystem_attachments); let mut contract = SnapshotMachineContract { machine_profile: "microvm".to_owned(), @@ -1105,6 +1163,7 @@ pub fn microvm_machine_contract( boot_layout_version, microvm_network, microvm_filesystem, + microvm_additional_filesystems: filesystem_policies.collect(), microvm_sandbox_blocks: sandbox_blocks, microvm_filesystem_slot_version: if filesystem_slot { MICROVM_FILESYSTEM_SLOT_VERSION @@ -1254,7 +1313,8 @@ pub fn validate_microvm_machine_contract( "snapshot static network identity doesn't match the requested machine" ); anyhow::ensure!( - contract.microvm_filesystem == expected.microvm_filesystem, + contract.microvm_filesystem == expected.microvm_filesystem + && contract.microvm_additional_filesystems == expected.microvm_additional_filesystems, "snapshot filesystem policy doesn't match the requested machine" ); anyhow::ensure!( @@ -1455,7 +1515,17 @@ pub(super) fn validate_machine_contract_shape( ); validate_sha256(&network.egress_policy_sha256, "egress policy")?; } - if let Some(filesystem) = &contract.microvm_filesystem { + let slots = &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS; + anyhow::ensure!( + contract.microvm_additional_filesystems.is_empty() || contract.microvm_filesystem.is_some(), + "snapshot microVM filesystem slots after the first require a filesystem in the first slot" + ); + anyhow::ensure!( + contract.microvm_additional_filesystems.len() < slots.len(), + "snapshot has more microVM filesystems than virtio-fs slots" + ); + let mut filesystems = Vec::with_capacity(slots.len()); + for (filesystem, slot) in contract.microvm_filesystems().zip(slots) { anyhow::ensure!( !filesystem.canonical_host_path.is_empty(), "snapshot filesystem canonical host path is missing; this snapshot predates path-bound filesystem restore" @@ -1473,19 +1543,23 @@ pub(super) fn validate_machine_contract_shape( .context("snapshot filesystem policy is invalid")? .with_owner(snapshot_microvm_filesystem_owner(&filesystem.owner_mode)?); anyhow::ensure!( - *filesystem == SnapshotMicrovmFilesystem::new(&parsed, &filesystem.canonical_host_path), + *filesystem + == SnapshotMicrovmFilesystem::new(&parsed, &filesystem.canonical_host_path, slot), "snapshot filesystem policy is not canonical" ); + filesystems.push(parsed); } + openvmm_defs::microvm::validate_microvm_filesystems(&filesystems) + .context("snapshot filesystem policies are invalid")?; let has_filesystem_device = contract .devices .iter() - .any(|device| device.stable_id == "fs:microvm0"); + .any(|device| device.stable_id == slots[0].stable_id); let has_filesystem_attachment = contract .attachments .iter() - .any(|attachment| attachment.stable_id == "fs:microvm0"); + .any(|attachment| attachment.stable_id == slots[0].stable_id); match contract.microvm_filesystem_slot_version { 0 => anyhow::ensure!( has_filesystem_device == contract.microvm_filesystem.is_some() @@ -1501,6 +1575,23 @@ pub(super) fn validate_machine_contract_shape( "snapshot microVM filesystem slot capability version {version} is unsupported" ), } + // A slot after the first exists only with its filesystem attached. + for (index, slot) in slots.iter().enumerate().skip(1) { + let attached = contract.microvm_additional_filesystems.len() >= index; + let has_device = contract + .devices + .iter() + .any(|device| device.stable_id == slot.stable_id); + let has_attachment = contract + .attachments + .iter() + .any(|attachment| attachment.stable_id == slot.stable_id); + anyhow::ensure!( + has_device == attached && has_attachment == attached, + "snapshot microVM filesystem slot {} device, policy, and attachment inventories disagree", + slot.stable_id + ); + } if !contract.microvm_sandbox_blocks.is_empty() { anyhow::ensure!( @@ -1947,6 +2038,7 @@ pub(super) fn test_machine_contract() -> SnapshotMachineContract { boot_layout_version: MICROVM_BOOT_LAYOUT_VERSION, microvm_network: None, microvm_filesystem: None, + microvm_additional_filesystems: Vec::new(), microvm_sandbox_blocks: Vec::new(), microvm_filesystem_slot_version: 0, boot_online_vp_count: 0, @@ -2066,8 +2158,14 @@ mod tests { } fn microvm_filesystem_attachment(source_hypervisor: &str) -> SnapshotAttachment { + microvm_slot_attachment(source_hypervisor, 0) + } + + fn microvm_slot_attachment(source_hypervisor: &str, slot: usize) -> SnapshotAttachment { SnapshotAttachment { - stable_id: "fs:microvm0".to_owned(), + stable_id: openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[slot] + .stable_id + .to_owned(), kind: "virtio-fs".to_owned(), required: true, reconnect_policy: "live-revalidate".to_owned(), @@ -2077,7 +2175,7 @@ mod tests { _ => unreachable!(), } .to_owned(), - identity: b"root-object-v1".to_vec(), + identity: format!("root-object-v1-{slot}").into_bytes(), length: 0, reconnect_timeout_ms: 0, } @@ -2120,7 +2218,7 @@ mod tests { microvm_network_attachment(source_hypervisor), )), false, - None, + Vec::new(), None, None, Vec::new(), @@ -2155,7 +2253,7 @@ mod tests { .to_owned(), None, false, - None, + Vec::new(), Some(microvm_console_attachment()), None, Vec::new(), @@ -2213,7 +2311,7 @@ mod tests { command_line, None, false, - None, + Vec::new(), Some(microvm_console_attachment()), Some(control_console_attachment), vec![ @@ -2298,7 +2396,7 @@ mod tests { .with_owner(owner); let command_line = format!( "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6 {}", - filesystem.command_line_fragment() + filesystem.command_line_fragment(&openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[0]) ); microvm_machine_contract( source_hypervisor, @@ -2306,7 +2404,7 @@ mod tests { command_line, None, true, - Some(( + vec![( &filesystem, Path::new(if cfg!(windows) { r"C:\microvm-share" @@ -2314,7 +2412,7 @@ mod tests { "/microvm-share" }), microvm_filesystem_attachment(source_hypervisor), - )), + )], None, None, Vec::new(), @@ -2349,7 +2447,75 @@ mod tests { .to_owned(), None, true, + Vec::new(), + None, + None, + Vec::new(), + 1, + 1024, + None, + [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + "virtiofs-3489665024", + ] + .map(str::to_owned) + .to_vec(), + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .unwrap() + } + + fn two_filesystems() -> [openvmm_defs::microvm::MicrovmFilesystemConfig; 2] { + [ + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/workspace".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + ) + .and_then(|config| config.with_denied_paths(vec!["secrets".to_owned()])) + .unwrap(), + openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/opt/hostedtoolcache".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, + ) + .unwrap(), + ] + } + + fn microvm_share_path(slot: usize) -> &'static Path { + Path::new(match (cfg!(windows), slot) { + (true, 0) => r"C:\workspace", + (true, _) => r"C:\toolcache", + (false, 0) => "/workspace", + (false, _) => "/toolcache", + }) + } + + fn filesystems_contract( + source_hypervisor: &str, + command_line: String, + filesystem_slot: bool, + filesystems: Vec<( + &openvmm_defs::microvm::MicrovmFilesystemConfig, + &Path, + SnapshotAttachment, + )>, + ) -> anyhow::Result { + microvm_machine_contract( + source_hypervisor, + MICROVM_BOOT_LAYOUT_VERSION, + command_line, None, + filesystem_slot, + filesystems, None, None, Vec::new(), @@ -2367,15 +2533,241 @@ mod tests { "microvm-shutdown", "microvm-snapshot-request", "virtiofs-3489665024", + "virtiofs-3489693696", ] .map(str::to_owned) .to_vec(), crate::snapshot::time::tests::test_time_contract(), crate::snapshot::time::tests::test_cpu_profile(), ) + } + + fn slot_filesystems<'a>( + source_hypervisor: &str, + filesystems: &'a [openvmm_defs::microvm::MicrovmFilesystemConfig], + ) -> Vec<( + &'a openvmm_defs::microvm::MicrovmFilesystemConfig, + &'static Path, + SnapshotAttachment, + )> { + filesystems + .iter() + .enumerate() + .map(|(slot, filesystem)| { + ( + filesystem, + microvm_share_path(slot), + microvm_slot_attachment(source_hypervisor, slot), + ) + }) + .collect() + } + + fn two_filesystem_command_line() -> String { + let mut command_line = + openvmm_defs::microvm::build_microvm_command_line(&[], false).unwrap(); + openvmm_defs::microvm::append_microvm_virtio_discovery( + &mut command_line, + None, + true, + &two_filesystems(), + false, + false, + &[], + ) + .unwrap(); + command_line + } + + fn generated_two_filesystem_contract(source_hypervisor: &str) -> SnapshotMachineContract { + let filesystems = two_filesystems(); + filesystems_contract( + source_hypervisor, + two_filesystem_command_line(), + true, + slot_filesystems(source_hypervisor, &filesystems), + ) .unwrap() } + fn two_filesystem_manifest(contract: &SnapshotMachineContract) -> SnapshotManifest { + let mut manifest = test_manifest(); + manifest.memory_size_bytes = 1024; + manifest.vp_count = 1; + manifest.machine_contract = Some(contract.clone()); + manifest + } + + #[test] + fn generated_microvm_two_filesystem_contract_has_fixed_abi() { + for source_hypervisor in ["kvm", "mshv", "whp"] { + let contract = generated_two_filesystem_contract(source_hypervisor); + let first = contract + .devices + .iter() + .find(|device| device.stable_id == "fs:microvm0") + .unwrap(); + assert_eq!(first.state_unit_name, "virtiofs-3489665024"); + assert_eq!(first.ranges[0].start, 0xd000_1000); + assert_eq!(first.irq, Some(6)); + // The second slot follows every other device, in address order. + let second = contract.devices.last().unwrap(); + assert_eq!(second.stable_id, "fs:microvm1"); + assert_eq!(second.state_unit_name, "virtiofs-3489693696"); + assert_eq!(second.ranges[0].start, 0xd000_8000); + assert_eq!(second.ranges[0].length, 0x1000); + assert_eq!(second.irq, Some(13)); + assert_eq!(second.transport, "virtio-mmio"); + assert_eq!(second.feature_banks, first.feature_banks); + assert_eq!(second.queue_max_sizes, first.queue_max_sizes); + assert_eq!( + contract.attachments, + [ + microvm_slot_attachment(source_hypervisor, 0), + microvm_slot_attachment(source_hypervisor, 1), + ] + ); + + let workspace = contract.microvm_filesystem.as_ref().unwrap(); + assert_eq!(workspace.guest_mount_target, "/workspace"); + assert_eq!(workspace.access_mode, "rw"); + assert_eq!(workspace.tag, "microvm"); + assert_eq!(workspace.denied_paths, ["secrets"]); + let [toolcache] = contract.microvm_additional_filesystems.as_slice() else { + panic!("the second share is missing from the contract"); + }; + assert_eq!(toolcache.guest_mount_target, "/opt/hostedtoolcache"); + assert_eq!(toolcache.access_mode, "ro"); + assert_eq!(toolcache.tag, "microvm1"); + assert!(toolcache.denied_paths.is_empty()); + assert_eq!( + toolcache.canonical_host_path, + microvm_share_path(1).to_str().unwrap() + ); + + validate_microvm_machine_contract(&two_filesystem_manifest(&contract), &contract) + .unwrap(); + } + } + + #[test] + fn validate_microvm_contract_rejects_a_missing_or_changed_second_share() { + let contract = generated_two_filesystem_contract("kvm"); + let manifest = two_filesystem_manifest(&contract); + let filesystems = two_filesystems(); + + let only_first = filesystems_contract( + "kvm", + contract.effective_command_line.clone(), + true, + slot_filesystems("kvm", &filesystems[..1]), + ) + .unwrap(); + let error = validate_microvm_machine_contract(&manifest, &only_first).unwrap_err(); + assert!(error.to_string().contains("device inventory")); + + let mut writable = filesystems.clone(); + writable[1] = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/opt/hostedtoolcache".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + ) + .unwrap(); + let error = filesystems_contract( + "kvm", + contract.effective_command_line.clone(), + true, + slot_filesystems("kvm", &writable), + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("does not match its saved policy") + ); + + let mut replaced = contract.clone(); + replaced.attachments[1].identity = b"another-root".to_vec(); + let error = validate_microvm_machine_contract(&manifest, &replaced).unwrap_err(); + assert!(error.to_string().contains("attachment inventory")); + + let mut moved = contract.clone(); + moved.microvm_additional_filesystems[0].canonical_host_path = "/moved".to_owned(); + let error = validate_microvm_machine_contract(&manifest, &moved).unwrap_err(); + assert!(error.to_string().contains("filesystem policy")); + } + + #[test] + fn microvm_contract_shape_rejects_an_inconsistent_second_slot() { + let contract = generated_two_filesystem_contract("whp"); + validate_machine_contract_shape(&contract, 1024, 1).unwrap(); + + let mut missing_device = contract.clone(); + missing_device.devices.pop(); + let mut missing_attachment = contract.clone(); + missing_attachment.attachments.pop(); + let mut missing_policy = contract.clone(); + missing_policy.microvm_additional_filesystems.clear(); + for invalid in [missing_device, missing_attachment, missing_policy] { + let error = validate_machine_contract_shape(&invalid, 1024, 1).unwrap_err(); + assert!(error.to_string().contains("fs:microvm1"), "{error:#}"); + } + + let mut without_first = contract.clone(); + without_first.microvm_filesystem = None; + let error = validate_machine_contract_shape(&without_first, 1024, 1).unwrap_err(); + assert!(error.to_string().contains("first slot")); + + let mut first_tag = contract.clone(); + first_tag.microvm_additional_filesystems[0].tag = "microvm".to_owned(); + let error = validate_machine_contract_shape(&first_tag, 1024, 1).unwrap_err(); + assert!(error.to_string().contains("not canonical")); + + let mut nested = contract.clone(); + nested.microvm_additional_filesystems[0].guest_mount_target = "/workspace/cache".to_owned(); + let error = validate_machine_contract_shape(&nested, 1024, 1).unwrap_err(); + assert!(format!("{error:#}").contains("overlap")); + + let mut too_many = contract.clone(); + too_many + .microvm_additional_filesystems + .push(too_many.microvm_additional_filesystems[0].clone()); + let error = validate_machine_contract_shape(&too_many, 1024, 1).unwrap_err(); + assert!(error.to_string().contains("more microVM filesystems")); + } + + #[test] + fn microvm_contract_builder_rejects_misplaced_second_share() { + let filesystems = two_filesystems(); + let command_line = two_filesystem_command_line(); + + // Shares occupy the slots after the reserved first slot. + assert!( + filesystems_contract( + "kvm", + command_line.clone(), + false, + slot_filesystems("kvm", &filesystems), + ) + .is_err() + ); + + let mut swapped = slot_filesystems("kvm", &filesystems); + swapped[0].2 = microvm_slot_attachment("kvm", 1); + swapped[1].2 = microvm_slot_attachment("kvm", 0); + let error = filesystems_contract("kvm", command_line.clone(), true, swapped).unwrap_err(); + assert!(error.to_string().contains("live-revalidation policy")); + + let undiscovered = command_line.replace(" virtio_mmio.device=0x1000@0xd0008000:13", ""); + let error = filesystems_contract( + "kvm", + undiscovered, + true, + slot_filesystems("kvm", &filesystems), + ) + .unwrap_err(); + assert!(error.to_string().contains("fs:microvm1")); + } + #[test] fn generated_microvm_console_contract_has_fixed_abi() { let contract = generated_console_contract(); @@ -3020,7 +3412,7 @@ mod tests { "console=hvc0".to_owned(), None, false, - None, + Vec::new(), None, None, Vec::new(), @@ -3052,7 +3444,7 @@ mod tests { &mut command_line, None, false, - None, + &[], true, true, &[ diff --git a/vm/devices/virtio/virtiofs/src/microvm/device.rs b/vm/devices/virtio/virtiofs/src/microvm/device.rs index 4a499b89914..bc22fa5c7bb 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/device.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/device.rs @@ -3,11 +3,10 @@ //! microVM virtio-fs device construction and dormant-slot save/restore. -use super::profile::MICROVM_ATTACHMENT_ID; -use super::profile::MICROVM_MOUNT_TAG; use super::profile::MICROVM_REQUEST_QUEUES; use super::profile::MicroVmOwnerMode; use super::profile::MicroVmVirtioFsProfile; +use super::profile::microvm_mount_tag; use super::saved_state::SavedState; use super::state::save_dormant_microvm_state; use super::state::validate_dormant_microvm_state; @@ -48,7 +47,7 @@ impl VirtioFsDevice { let attachment_id = profile.attachment_id().to_owned(); let mut device = Self::with_num_request_queues( driver_source, - MICROVM_MOUNT_TAG, + profile.mount_tag(), fs.clone(), 0, notify_corruption, @@ -61,19 +60,19 @@ impl VirtioFsDevice { Ok(device) } - /// Creates the fixed no-DAX microVM virtio-fs device without a host attachment. + /// Creates the fixed no-DAX microVM virtio-fs device of the slot + /// identified by `stable_id`, without a host attachment. pub fn new_microvm_dormant( driver_source: &VmTaskDriverSource, stable_id: String, notify_corruption: Option>, ) -> anyhow::Result { - anyhow::ensure!( - stable_id == MICROVM_ATTACHMENT_ID, - "microVM virtio-fs attachment ID must be '{MICROVM_ATTACHMENT_ID}'" - ); + let mount_tag = microvm_mount_tag(&stable_id).ok_or_else(|| { + anyhow::anyhow!("microVM virtio-fs attachment ID '{stable_id}' is not a fixed slot") + })?; let mut device = Self::with_num_request_queues( driver_source, - MICROVM_MOUNT_TAG, + mount_tag, DormantMicrovmFs, 0, notify_corruption, @@ -273,6 +272,8 @@ pub(crate) fn device_state_validator(device: &VirtioFsDevice) -> DeviceStateVali #[cfg(test)] mod tests { use super::*; + use crate::profile::MICROVM_ATTACHMENT_ID; + use crate::profile::MICROVM_SLOTS; use crate::profile::microvm_root_identity; use chipset_device::io::IoResult; use chipset_device::mmio::MmioIntercept; @@ -309,7 +310,38 @@ mod tests { assert!(!device.traits().device_features.ring_packed()); assert!(device.supports_save_restore()); assert!(device.supports_accelerated_doorbells()); - assert_eq!(&device.config.tag[..7], b"microvm"); + assert_eq!(&device.config.tag[..8], b"microvm\0"); + } + + #[async_test] + async fn microvm_slots_expose_their_own_tags(driver: DefaultDriver) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + for (stable_id, tag) in MICROVM_SLOTS { + let temporary_directory = tempfile::tempdir().unwrap(); + let active = VirtioFsDevice::new_microvm_hostfs( + &driver_source, + stable_id.to_owned(), + microvm_root_identity(temporary_directory.path()).unwrap(), + false, + Vec::new(), + MicroVmOwnerMode::Vmm, + temporary_directory.path(), + None, + ) + .unwrap(); + let dormant = + VirtioFsDevice::new_microvm_dormant(&driver_source, stable_id.to_owned(), None) + .unwrap(); + for device in [active, dormant] { + assert_eq!(&device.config.tag[..tag.len()], tag.as_bytes()); + assert_eq!(device.config.tag[tag.len()], 0); + assert_eq!(device.microvm_attachment_id.as_deref(), Some(stable_id)); + } + } + assert!( + VirtioFsDevice::new_microvm_dormant(&driver_source, "fs:microvm2".to_owned(), None) + .is_err() + ); } #[async_test] diff --git a/vm/devices/virtio/virtiofs/src/microvm/profile.rs b/vm/devices/virtio/virtiofs/src/microvm/profile.rs index 3f5c64dcdd6..9ed0b7765af 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/profile.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/profile.rs @@ -9,12 +9,29 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; -/// Stable device-private attachment identifier for the microVM share. +/// Stable device-private attachment identifier of the first microVM share. pub const MICROVM_ATTACHMENT_ID: &str = "fs:microvm0"; -/// The only mount tag accepted by the microVM ABI. +/// The mount tag of the first microVM share. pub const MICROVM_MOUNT_TAG: &str = "microvm"; +/// The attachment identifiers and mount tags of the fixed microVM virtio-fs +/// slots, in slot order. These are the only identities that the microVM ABI +/// accepts. +pub const MICROVM_SLOTS: [(&str, &str); 2] = [ + (MICROVM_ATTACHMENT_ID, MICROVM_MOUNT_TAG), + ("fs:microvm1", "microvm1"), +]; + +/// Returns the mount tag of the fixed microVM slot identified by +/// `attachment_id`, or `None` when no slot has that identifier. +pub fn microvm_mount_tag(attachment_id: &str) -> Option<&'static str> { + MICROVM_SLOTS + .iter() + .find(|(id, _)| *id == attachment_id) + .map(|(_, tag)| *tag) +} + /// The number of FUSE request queues in the microVM ABI. pub const MICROVM_REQUEST_QUEUES: u32 = 1; @@ -88,8 +105,8 @@ pub enum MicroVmOwnerMode { /// Errors in the fixed microVM virtio-fs attachment contract. #[derive(Debug, thiserror::Error)] pub enum MicroVmProfileError { - /// The resource did not identify the only microVM filesystem attachment. - #[error("microVM virtio-fs stable ID must be {MICROVM_ATTACHMENT_ID}")] + /// The resource did not identify a fixed microVM filesystem slot. + #[error("microVM virtio-fs stable ID is not a fixed slot")] InvalidStableId, /// The identity did not use the documented bounded attachment format. #[error("microVM virtio-fs root identity is empty or exceeds {MAX_ROOT_IDENTITY_SIZE} bytes")] @@ -114,6 +131,7 @@ pub enum MicroVmProfileError { #[derive(Clone, Debug, Eq, PartialEq)] pub struct MicroVmVirtioFsProfile { stable_id: String, + mount_tag: &'static str, root_identity: Vec, access_mode: MicroVmAccessMode, denied_paths: Vec, @@ -123,23 +141,24 @@ pub struct MicroVmVirtioFsProfile { impl MicroVmVirtioFsProfile { /// Builds the constrained microVM profile from the resource attachment /// fields. `stable_id`, `root_identity`, and `read_only` correspond - /// exactly to `VirtioFsProfile::Microvm`. The guest's operations run as - /// the VMM until [`Self::with_owner_mode`] selects otherwise. + /// exactly to `VirtioFsProfile::Microvm`; `stable_id` selects the fixed + /// slot and therefore the mount tag. The guest's operations run as the + /// VMM until [`Self::with_owner_mode`] selects otherwise. pub fn from_attachment( stable_id: String, root_identity: Vec, read_only: bool, denied_paths: Vec, ) -> Result { - if stable_id != MICROVM_ATTACHMENT_ID { - return Err(MicroVmProfileError::InvalidStableId); - } + let mount_tag = + microvm_mount_tag(&stable_id).ok_or(MicroVmProfileError::InvalidStableId)?; if root_identity.is_empty() || root_identity.len() > MAX_ROOT_IDENTITY_SIZE { return Err(MicroVmProfileError::InvalidRootIdentity); } let denied_paths = Self::parse_denied_paths(denied_paths)?; Ok(Self { stable_id, + mount_tag, root_identity, access_mode: if read_only { MicroVmAccessMode::ReadOnly @@ -233,9 +252,9 @@ impl MicroVmVirtioFsProfile { self.validate_root_path(root_path) } - /// Returns the fixed FUSE mount tag. + /// Returns the fixed FUSE mount tag of the profile's slot. pub const fn mount_tag(&self) -> &'static str { - MICROVM_MOUNT_TAG + self.mount_tag } /// Returns the fixed request queue count. @@ -420,6 +439,37 @@ mod tests { .unwrap(); } + #[test] + fn profile_selects_the_mount_tag_of_its_slot() { + let temporary_directory = tempfile::tempdir().unwrap(); + let root_identity = microvm_root_identity(temporary_directory.path()).unwrap(); + for (stable_id, tag) in MICROVM_SLOTS { + let profile = MicroVmVirtioFsProfile::from_attachment( + stable_id.to_owned(), + root_identity.clone(), + false, + Vec::new(), + ) + .unwrap(); + assert_eq!(profile.attachment_id(), stable_id); + assert_eq!(profile.mount_tag(), tag); + assert_eq!(microvm_mount_tag(stable_id), Some(tag)); + } + assert_eq!(MICROVM_SLOTS[1], ("fs:microvm1", "microvm1")); + for stable_id in ["fs:microvm2", "microvm1", ""] { + assert_eq!(microvm_mount_tag(stable_id), None); + assert!(matches!( + MicroVmVirtioFsProfile::from_attachment( + stable_id.to_owned(), + root_identity.clone(), + false, + Vec::new(), + ), + Err(MicroVmProfileError::InvalidStableId) + )); + } + } + #[test] fn profile_selects_caller_ownership_only_on_linux() { let temporary_directory = tempfile::tempdir().unwrap(); diff --git a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs index 214e555126a..3e197064811 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs @@ -3,14 +3,26 @@ //! microVM resource-profile resolution. -use super::profile::MICROVM_MOUNT_TAG; use super::profile::MicroVmOwnerMode; +use super::profile::microvm_mount_tag; use crate::virtio::VirtioFsDevice; use virtio_resources::fs::VirtioFsBackend; use virtio_resources::fs::VirtioFsHandle; use virtio_resources::fs::microvm::VirtioFsProfile; use vmcore::vm_task::VmTaskDriverSource; +/// Rejects a resource whose tag is not the fixed tag of its slot. +fn validate_tag(resource: &VirtioFsHandle, stable_id: &str) -> anyhow::Result<()> { + let tag = microvm_mount_tag(stable_id).ok_or_else(|| { + anyhow::anyhow!("microVM virtio-fs attachment ID '{stable_id}' is not a fixed slot") + })?; + anyhow::ensure!( + resource.tag == tag, + "microVM virtio-fs tag for '{stable_id}' must be '{tag}'" + ); + Ok(()) +} + pub(crate) fn resolve( resource: &VirtioFsHandle, driver_source: &VmTaskDriverSource, @@ -18,11 +30,7 @@ pub(crate) fn resolve( let device = match &resource.profile { VirtioFsProfile::Standard => return Ok(None), VirtioFsProfile::MicrovmDormant { stable_id } => { - anyhow::ensure!( - resource.tag == MICROVM_MOUNT_TAG, - "microVM virtio-fs tag must be '{}'", - MICROVM_MOUNT_TAG - ); + validate_tag(resource, stable_id)?; anyhow::ensure!( matches!(resource.fs, VirtioFsBackend::Dormant), "dormant microVM virtio-fs cannot have an active backend" @@ -36,11 +44,7 @@ pub(crate) fn resolve( denied_paths, caller_identity, } => { - anyhow::ensure!( - resource.tag == MICROVM_MOUNT_TAG, - "microVM virtio-fs tag must be '{}'", - MICROVM_MOUNT_TAG - ); + validate_tag(resource, stable_id)?; let VirtioFsBackend::HostFs { root_path, mount_options, @@ -75,6 +79,7 @@ pub(crate) fn resolve( mod tests { use super::*; use crate::profile::MICROVM_ATTACHMENT_ID; + use crate::profile::MICROVM_MOUNT_TAG; use crate::profile::microvm_root_identity; use crate::resolver::VirtioFsResolver; use pal_async::DefaultDriver; @@ -89,6 +94,15 @@ mod tests { driver: DefaultDriver, tag: &str, fs: VirtioFsBackend, + ) -> anyhow::Result { + resolve_slot(driver, MICROVM_ATTACHMENT_ID, tag, fs) + } + + fn resolve_slot( + driver: DefaultDriver, + stable_id: &str, + tag: &str, + fs: VirtioFsBackend, ) -> anyhow::Result { let root_path = match &fs { VirtioFsBackend::HostFs { root_path, .. } => Some(root_path), @@ -104,7 +118,7 @@ mod tests { tag: tag.to_owned(), fs, profile: VirtioFsProfile::Microvm { - stable_id: MICROVM_ATTACHMENT_ID.to_owned(), + stable_id: stable_id.to_owned(), root_identity, read_only: true, denied_paths: Vec::new(), @@ -117,6 +131,44 @@ mod tests { ) } + fn host_fs(root: &tempfile::TempDir) -> VirtioFsBackend { + VirtioFsBackend::HostFs { + root_path: root.path().to_string_lossy().into_owned(), + mount_options: String::new(), + } + } + + #[async_test] + async fn microvm_profile_requires_the_tag_of_its_slot(driver: DefaultDriver) { + let root = tempfile::tempdir().unwrap(); + resolve_slot(driver.clone(), "fs:microvm1", "microvm1", host_fs(&root)).unwrap(); + for (stable_id, tag) in [ + ("fs:microvm1", MICROVM_MOUNT_TAG), + (MICROVM_ATTACHMENT_ID, "microvm1"), + ("fs:microvm2", "microvm2"), + ] { + assert!(resolve_slot(driver.clone(), stable_id, tag, host_fs(&root)).is_err()); + } + + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let dormant = |stable_id: &str, tag: &str| { + VirtioFsResolver.resolve( + VirtioFsHandle { + tag: tag.to_owned(), + fs: VirtioFsBackend::Dormant, + profile: VirtioFsProfile::MicrovmDormant { + stable_id: stable_id.to_owned(), + }, + }, + VirtioResolveInput { + driver_source: &driver_source, + }, + ) + }; + dormant("fs:microvm1", "microvm1").unwrap(); + assert!(dormant("fs:microvm1", MICROVM_MOUNT_TAG).is_err()); + } + #[async_test] async fn microvm_profile_rejects_non_fixed_tag(driver: DefaultDriver) { let root = tempfile::tempdir().unwrap(); From a41cf7351807b2ce7dcfbd52bdd868c5df5d6a71 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 21:50:20 -0700 Subject: [PATCH 285/304] microVM: compare share mount sources and pin the bootstrap order Review follow-ups for the second virtio-fs slot: - On Linux, a bind mount gives a directory a second canonical path and root identity, so it could expose part of one share as the other share's root, or inside its tree, and pass the overlap check. With two shares, OpenVMM now also reads /proc/self/mountinfo and rejects them when a filesystem source that one share reaches, through the mount that contains its root or a mount below it, contains or is contained in a source that the other share reaches. - The snapshot contract required each filesystem's bootstrap triplet only to appear somewhere in the effective command line. It now requires each slot's discovery token exactly once when the slot exists and never otherwise, and the bootstrap tokens to be exactly the attached filesystems' triplets, in slot order. - Wrap the Guide lines that this change added at 80 columns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ee620ea-2f80-4743-9e4a-f771fb2f8c86 --- .../src/reference/devices/virtio/virtio-fs.md | 15 +- Guide/src/reference/openvmm/management/cli.md | 33 +-- Guide/src/user_guide/openvmm/snapshots.md | 24 +-- .../openvmm_entry/src/microvm/filesystem.rs | 197 ++++++++++++++++++ .../openvmm_helpers/src/snapshot/microvm.rs | 99 +++++++-- 5 files changed, 314 insertions(+), 54 deletions(-) diff --git a/Guide/src/reference/devices/virtio/virtio-fs.md b/Guide/src/reference/devices/virtio/virtio-fs.md index 35249a4b859..0cfd9df106e 100644 --- a/Guide/src/reference/devices/virtio/virtio-fs.md +++ b/Guide/src/reference/devices/virtio/virtio-fs.md @@ -74,10 +74,13 @@ either tag. OpenVMM rejects more than two attachments, guest targets that equal or contain one another, and host directories that equal, contain, or are contained in one another (compared after canonicalization and by root object identity), because one share could otherwise reach files that the -other share denies or exposes with a different access mode. With several -attachments, each `--mount-deny` must be an absolute host path, and it -applies to the share whose directory contains it. `--mount-owner` applies to -every share. +other share denies or exposes with a different access mode. On Linux, +OpenVMM also compares the filesystem sources that each directory reaches, +from `/proc/self/mountinfo`, including the mounts below it, so a bind mount +or nested mount can't expose part of one share as, or inside, the other. +With several attachments, each `--mount-deny` must be an absolute host path, +and it applies to the share whose directory contains it. `--mount-owner` +applies to every share. For an active cold-boot attachment, the profile adds `virtfs_dir`, `virtfs_tag`, and `virtfs_mode` bootstrap tokens to the kernel command line, @@ -171,8 +174,8 @@ lookups and newly opened directories still observe the live host tree. Restoring a snapshot captured with an active attachment requires a fresh `--mount` argument for every captured share, in the same order, with the exact same denied-path set, canonical host path, guest target, mode, and -`--mount-owner` mode. Identity validation remains independent: before any vCPU starts, -OpenVMM pins the supplied root and validates its saved root and object +`--mount-owner` mode. Identity validation remains independent: before any vCPU +starts, OpenVMM pins the supplied root and validates its saved root and object identities. Missing, moved, replaced, ambiguous, or no-longer-reopenable objects fail restore. A saved symbolic link is revalidated as the link itself, without being followed, and an alias whose ancestor has become a link fails diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index f7b21866e36..94dd803b560 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -184,10 +184,12 @@ describes the source definitions. one no-DAX HostFs device at MMIO `0xd0001000`, IRQ 6, with tag `microvm`. Repeat it once to attach a second device at MMIO `0xd0008000`, IRQ 13, with tag `microvm1`; each share has its own guest target, access mode, and - denied paths. The default mode is read-only; `rw` must be explicit. The guest target must - be an absolute, non-root Linux path without dot, parent, empty, whitespace, - backslash, or `=` components. Guest targets and host directories of - different shares must not equal or contain one another. + denied paths. The default mode is read-only; `rw` must be explicit. The + guest target must be an absolute, non-root Linux path without dot, parent, + empty, whitespace, backslash, or `=` components. Guest targets and host + directories of different shares must not equal or contain one another; on + Linux, a bind mount or nested mount that reaches the files of another share + counts as containing it. ```bash openvmm --machine microvm --hypervisor kvm \ @@ -232,12 +234,13 @@ describes the source definitions. Filesystem snapshots contain guest-visible FUSE and queue state, not host directory contents or native handles. An active snapshot requires - `--mount` again for every captured share, in capture order, with the exact canonical host path, guest target, access - mode, denied-path set, and `--mount-owner` mode; the live roots and every saved object identity are - also revalidated before vCPUs start. A snapshot captured without `--mount` may remain dormant - or bind one new attachment to the first slot. The resumed guest must then explicitly run - `mount -t virtiofs microvm ` because its cold-boot mount hook - has already completed. + `--mount` again for every captured share, in capture order, with the exact + canonical host path, guest target, access mode, denied-path set, and + `--mount-owner` mode; the live roots and every saved object identity are + also revalidated before vCPUs start. A snapshot captured without `--mount` + may remain dormant or bind one new attachment to the first slot. The resumed + guest must then explicitly run `mount -t virtiofs microvm ` + because its cold-boot mount hook has already completed. See [virtio-fs](../../devices/virtio/virtio-fs.md). * `--snapshot-destination `: Publish a microVM snapshot when the guest writes to PMIO port `0x605`. The destination must not exist and its parent @@ -325,11 +328,11 @@ describes the source definitions. pending while no peer is connected. When the snapshot contains active virtio-fs attachments, restore requires - a fresh `--mount` for each, in capture order. Each argument must reproduce the manifest's exact - canonical host path, guest target, and `ro`/`rw` mode while also supplying a - live root with the same saved identity. A snapshot advertising the dormant - slot may instead accept one new attachment; snapshots without that capability - reject additive attachment. + a fresh `--mount` for each, in capture order. Each argument must reproduce + the manifest's exact canonical host path, guest target, and `ro`/`rw` mode + while also supplying a live root with the same saved identity. A snapshot + advertising the dormant slot may instead accept one new attachment; + snapshots without that capability reject additive attachment. When the snapshot contains virtio-net, restore also requires `--network-profile portable`; the snapshot's profile and canonical egress diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index caf53a73700..12f64a5dad3 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -330,13 +330,13 @@ of attempting a live rollback. This applies to both capture and the post-restore acknowledgement boundary. Teardown still depends on the affected backends responding; it does not provide a bounded shutdown deadline. -For microVM virtio-fs, the manifest always records the fixed, guest-discoverable -first slot. A dormant slot has no host attachment or filesystem policy and carries -explicit dormant device-private state. An active slot also records the stable -attachment ID, exact canonical host path, pinned root identity, guest mount -target, access mode, ownership mode, no-DAX queue policy, and `live-revalidate` restore mode. -A second slot is recorded only when a second share was attached, with the -same fields for that share. +For microVM virtio-fs, the manifest always records the fixed, +guest-discoverable first slot. A dormant slot has no host attachment or +filesystem policy and carries explicit dormant device-private state. An active +slot also records the stable attachment ID, exact canonical host path, pinned +root identity, guest mount target, access mode, ownership mode, no-DAX queue +policy, and `live-revalidate` restore mode. A second slot is recorded only +when a second share was attached, with the same fields for that share. Its device-private payload records FUSE negotiation, namespace IDs and aliases, lookup counts, reopenable handles, bounded directory-entry snapshots and cookies, and queue progress. Native file descriptors and Windows handles are @@ -344,12 +344,12 @@ never serialized. Restoring active slots requires a fresh `--mount ` attachment for each, in capture -order, with the same canonical -host path, target, and mode, and the same `--mount-owner` mode. OpenVMM independently validates the roots and every +order, with the same canonical host path, target, and mode, and the same +`--mount-owner` mode. OpenVMM independently validates the roots and every saved object identity before starting a vCPU. A dormant-slot snapshot may -restore without an attachment or bind one new attachment to the first slot. For a new attachment, the -resumed guest explicitly mounts tag `microvm`; the cold-boot mount hook does not -run again. +restore without an attachment or bind one new attachment to the first slot. +For a new attachment, the resumed guest explicitly mounts tag `microvm`; the +cold-boot mount hook does not run again. ```admonish warning The host directory is external live state, not snapshot content. Host diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index 291ac40f021..4403caf77fb 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -317,9 +317,206 @@ fn validate_microvm_filesystem_roots( ); } } + #[cfg(target_os = "linux")] + if roots.len() > 1 { + let mountinfo = fs_err::read_to_string("/proc/self/mountinfo")?; + let roots = roots + .iter() + .map(|(root, _)| Path::new(root)) + .collect::>(); + mount_sources::validate_disjoint(&mountinfo, &roots)?; + } Ok(()) } +/// Compares where the files of each host root come from. +/// +/// A bind mount gives a directory a second canonical path and root identity, +/// so it can expose part of one share as another share's root, or inside +/// another share's tree. Each root reaches the source directory of the mount +/// that contains it and the source of every mount below it, and two roots +/// overlap when any of those ranges of one filesystem contains another. +#[cfg(target_os = "linux")] +mod mount_sources { + use anyhow::Context; + use std::path::Path; + use std::path::PathBuf; + + struct Mount { + device: String, + root: PathBuf, + mount_point: PathBuf, + } + + /// A directory tree of one filesystem, named by its device and its path + /// within that filesystem. + struct Source { + device: String, + path: PathBuf, + } + + impl Source { + fn overlaps(&self, other: &Source) -> bool { + self.device == other.device + && (self.path.starts_with(&other.path) || other.path.starts_with(&self.path)) + } + } + + fn unescape(field: &str) -> anyhow::Result { + use std::os::unix::ffi::OsStringExt as _; + let bytes = field.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'\\' { + let digits = bytes + .get(index + 1..index + 4) + .and_then(|digits| std::str::from_utf8(digits).ok()) + .and_then(|digits| u8::from_str_radix(digits, 8).ok()) + .with_context(|| format!("invalid escape in mountinfo field '{field}'"))?; + decoded.push(digits); + index += 4; + } else { + decoded.push(bytes[index]); + index += 1; + } + } + Ok(PathBuf::from(std::ffi::OsString::from_vec(decoded))) + } + + fn parse(mountinfo: &str) -> anyhow::Result> { + mountinfo + .lines() + .filter(|line| !line.is_empty()) + .map(|line| { + let fields = line.split(' ').collect::>(); + anyhow::ensure!(fields.len() >= 5, "malformed mountinfo line '{line}'"); + Ok(Mount { + device: fields[2].to_owned(), + root: unescape(fields[3])?, + mount_point: unescape(fields[4])?, + }) + }) + .collect() + } + + fn sources(mounts: &[Mount], root: &Path) -> anyhow::Result> { + // The deepest mount point that contains the root, and among mounts + // stacked there, the latest, which hides the others. + let (_, containing) = mounts + .iter() + .enumerate() + .filter(|(_, mount)| root.starts_with(&mount.mount_point)) + .max_by_key(|(index, mount)| (mount.mount_point.components().count(), *index)) + .with_context(|| format!("no mount contains {}", root.display()))?; + let relative = root.strip_prefix(&containing.mount_point)?; + let mut sources = vec![Source { + device: containing.device.clone(), + path: containing.root.join(relative), + }]; + sources.extend( + mounts + .iter() + .filter(|mount| mount.mount_point != root && mount.mount_point.starts_with(root)) + .map(|mount| Source { + device: mount.device.clone(), + path: mount.root.clone(), + }), + ); + Ok(sources) + } + + /// Rejects canonical roots that reach a common directory of a filesystem. + pub(super) fn validate_disjoint(mountinfo: &str, roots: &[&Path]) -> anyhow::Result<()> { + let mounts = parse(mountinfo)?; + let sources = roots + .iter() + .map(|root| sources(&mounts, root)) + .collect::>>()?; + for (index, root_sources) in sources.iter().enumerate() { + for (other_index, other_sources) in sources[..index].iter().enumerate() { + anyhow::ensure!( + !root_sources + .iter() + .any(|source| other_sources.iter().any(|other| source.overlaps(other))), + "microVM filesystem host directories must not overlap: {} and {} reach the same files through a mount", + roots[other_index].display(), + roots[index].display() + ); + } + } + Ok(()) + } + + #[cfg(test)] + mod tests { + use super::validate_disjoint; + use std::path::Path; + + const BASE: &str = "\ +22 1 8:1 / / rw,relatime shared:1 - ext4 /dev/sda1 rw +23 22 0:21 / /proc rw,nosuid shared:2 - proc proc rw +"; + + fn check(mountinfo: &str, first: &str, second: &str) -> anyhow::Result<()> { + validate_disjoint(mountinfo, &[Path::new(first), Path::new(second)]) + } + + #[test] + fn separate_directories_of_one_filesystem_are_disjoint() { + check(BASE, "/workspace", "/toolcache").unwrap(); + check(BASE, "/work", "/workspace").unwrap(); + } + + #[test] + fn a_bind_mount_of_a_subdirectory_overlaps_its_source() { + let mountinfo = + format!("{BASE}30 22 8:1 /workspace/cache /toolcache rw - ext4 /dev/sda1 rw\n"); + let error = check(&mountinfo, "/workspace", "/toolcache").unwrap_err(); + assert!(error.to_string().contains("through a mount"), "{error:#}"); + check(&mountinfo, "/toolcache", "/workspace").unwrap_err(); + // The bind mount's source is not inside an unrelated directory. + check(&mountinfo, "/srv", "/toolcache").unwrap(); + } + + #[test] + fn a_mount_below_one_root_overlaps_the_other_root() { + let mountinfo = + format!("{BASE}31 22 8:1 /toolcache /workspace/tools rw - ext4 /dev/sda1 rw\n"); + check(&mountinfo, "/workspace", "/toolcache").unwrap_err(); + check(&mountinfo, "/workspace", "/toolcache/node").unwrap_err(); + check(&mountinfo, "/workspace", "/opt").unwrap(); + } + + #[test] + fn other_filesystems_and_unrelated_mounts_do_not_overlap() { + let mountinfo = format!( + "{BASE}40 22 8:2 / /data rw - ext4 /dev/sda2 rw\n\ + 41 22 0:50 / /workspace/tmp rw - tmpfs tmpfs rw\n" + ); + check(&mountinfo, "/workspace", "/data").unwrap(); + // A second mount of the same filesystem reaches the same files. + let twice = format!("{mountinfo}42 22 8:2 / /mnt/data rw - ext4 /dev/sda2 rw\n"); + check(&twice, "/data/a", "/mnt/data/a/b").unwrap_err(); + check(&twice, "/data/a", "/mnt/data/b").unwrap(); + } + + #[test] + fn escaped_paths_and_stacked_mounts_resolve() { + let mountinfo = format!( + "{BASE}50 22 8:1 /src\\040dir /mnt/share\\040one rw - ext4 /dev/sda1 rw\n\ + 51 22 8:2 / /mnt/share\\040one rw - ext4 /dev/sda2 rw\n" + ); + // The later mount hides the earlier one at the same mount point. + check(&mountinfo, "/mnt/share one", "/src dir").unwrap(); + check(&mountinfo, "/mnt/share one", "/data").unwrap(); + let bind = format!("{BASE}52 22 8:1 /src\\040dir /mnt/share rw - ext4 /dev/sda1 rw\n"); + check(&bind, "/mnt/share", "/src dir/sub").unwrap_err(); + assert!(validate_disjoint("22 1 8:1", &[Path::new("/a"), Path::new("/b")]).is_err()); + } + } +} + /// Builds the filesystems of the `--mount` options, which occupy the fixed /// virtio-fs slots in order. Each `--mount-deny` applies to the export root /// that contains it; a relative path is relative to the only export root. diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 4aca47a1465..acd9fa97068 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -688,7 +688,6 @@ fn microvm_filesystem_device( /// snapshot policy. fn microvm_filesystem_policy( source_hypervisor: &str, - effective_command_line: &str, slot: &openvmm_defs::microvm::MicrovmFilesystemSlot, filesystem: &openvmm_defs::microvm::MicrovmFilesystemConfig, canonical_host_path: &Path, @@ -717,17 +716,6 @@ fn microvm_filesystem_policy( && attachment.reconnect_timeout_ms == 0, "microVM filesystem attachment has an unsupported live-revalidation policy" ); - let tokens = effective_command_line - .split_ascii_whitespace() - .collect::>(); - let fragment = filesystem.command_line_fragment(slot); - let fragment = fragment.split_ascii_whitespace().collect::>(); - anyhow::ensure!( - tokens - .windows(fragment.len()) - .any(|window| window == fragment), - "microVM filesystem command line does not match its saved policy" - ); Ok(SnapshotMicrovmFilesystem::new( filesystem, canonical_host_path, @@ -951,15 +939,45 @@ pub fn microvm_machine_contract( .map(|(filesystem, _, _)| (*filesystem).clone()) .collect::>(), )?; - for slot in filesystem_slots { + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + for (index, slot) in openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS + .iter() + .enumerate() + { + let discovery = slot.discovery_token(); + let expected = usize::from(index < filesystem_slot_count); anyhow::ensure!( - effective_command_line - .split_ascii_whitespace() - .any(|token| token == slot.discovery_token()), - "microVM virtio-fs slot {} is missing from the effective command line", + tokens.iter().filter(|token| **token == discovery).count() == expected, + "microVM virtio-fs slot {} must be discovered {expected} time(s) by the effective command line", slot.stable_id ); } + // The bootstrap triplets of the attached filesystems, exactly and in slot + // order. + let bootstrap = tokens + .iter() + .copied() + .filter(|token| { + ["virtfs_dir=", "virtfs_tag=", "virtfs_mode="] + .iter() + .any(|prefix| token.starts_with(prefix)) + }) + .collect::>(); + let expected_bootstrap = filesystems + .iter() + .zip(filesystem_slots) + .map(|((filesystem, _, _), slot)| filesystem.command_line_fragment(slot)) + .collect::>(); + anyhow::ensure!( + bootstrap + == expected_bootstrap + .iter() + .flat_map(|fragment| fragment.split_ascii_whitespace()) + .collect::>(), + "microVM filesystem command line does not match its saved policy" + ); if let Some(slot) = filesystem_slots.first() { devices.push(microvm_filesystem_device(slot, devices.len())); } @@ -970,7 +988,6 @@ pub fn microvm_machine_contract( { filesystem_policies.push(microvm_filesystem_policy( source_hypervisor, - &effective_command_line, slot, filesystem, canonical_host_path, @@ -2564,13 +2581,19 @@ mod tests { } fn two_filesystem_command_line() -> String { + filesystems_command_line(&two_filesystems()) + } + + fn filesystems_command_line( + filesystems: &[openvmm_defs::microvm::MicrovmFilesystemConfig], + ) -> String { let mut command_line = openvmm_defs::microvm::build_microvm_command_line(&[], false).unwrap(); openvmm_defs::microvm::append_microvm_virtio_discovery( &mut command_line, None, true, - &two_filesystems(), + filesystems, false, false, &[], @@ -2656,15 +2679,21 @@ mod tests { let manifest = two_filesystem_manifest(&contract); let filesystems = two_filesystems(); + // A machine with only the first share has another command line and + // device inventory, so it can't restore the two-share snapshot. let only_first = filesystems_contract( "kvm", - contract.effective_command_line.clone(), + filesystems_command_line(&filesystems[..1]), true, slot_filesystems("kvm", &filesystems[..1]), ) .unwrap(); let error = validate_microvm_machine_contract(&manifest, &only_first).unwrap_err(); - assert!(error.to_string().contains("device inventory")); + assert!( + error + .to_string() + .contains("doesn't match the requested machine") + ); let mut writable = filesystems.clone(); writable[1] = openvmm_defs::microvm::MicrovmFilesystemConfig::new( @@ -2766,6 +2795,34 @@ mod tests { ) .unwrap_err(); assert!(error.to_string().contains("fs:microvm1")); + + // The contract requires each slot's discovery token once and the + // bootstrap triplets exactly, in slot order. + let first = "virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw"; + let second = "virtfs_dir=/opt/hostedtoolcache virtfs_tag=microvm1 virtfs_mode=ro"; + assert!(command_line.ends_with(&format!("{first} {second}"))); + for invalid in [ + command_line.replace(&format!("{first} {second}"), &format!("{second} {first}")), + format!("{command_line} {second}"), + format!("{command_line} virtfs_mode=ro"), + command_line.replace( + " virtio_mmio.device=0x1000@0xd0008000:13", + " virtio_mmio.device=0x1000@0xd0008000:13 virtio_mmio.device=0x1000@0xd0008000:13", + ), + ] { + assert!( + filesystems_contract("kvm", invalid, true, slot_filesystems("kvm", &filesystems)) + .is_err() + ); + } + let error = filesystems_contract( + "kvm", + command_line.clone(), + true, + slot_filesystems("kvm", &filesystems[..1]), + ) + .unwrap_err(); + assert!(error.to_string().contains("fs:microvm1"), "{error:#}"); } #[test] From d290df6750202f9f24eebbf16a93cf6bbcb74d71 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 21:02:56 -0700 Subject: [PATCH 286/304] cpu_profile, virt: accept AMD CPUs in CPU profiles and host profiles Only Intel CPUs could have a CPU profile (microsoft/nvx#396, rows 1 to 4 and 7). Identity validation accepted only GenuineIntel, host profiles served only Intel CPUs, and the VM-owned bits, the derivation policy, and OpenVMM's topology leaves covered only Intel's CPUID layout. On an AMD host, auto and --cpu-profile host both failed with E_PROFILE_HOST_UNKNOWN; a profile derived from an AMD fingerprint failed validation, and, once accepted, every cold boot would have failed with E_CPU_SURFACE, because OpenVMM sets AMD topology fields that the profile pinned. A profile's vendor is now a CpuVendor: Intel, or AMD, which profile IDs spell amd. Hygon CPUs, which follow AMD's layout, still have neither a profile nor a host profile. derive_host_profile derives amd.host.v1 on an AMD host, with the brand string "AMD Processor (host profile)", and supports_host_profiles accepts AMD CPUs, so OpenVMM's auto error also names --cpu-profile host there. KnownGeneration takes the vendor too. auto's errors name --cpu-profile host in one more case: a cold boot whose built-in profile the backend does not support now fails with E_PROFILE_UNSUPPORTED and the hint, on an Intel or AMD host, because a host profile derives from what the backend supports and can boot where the built-in one cannot. The VM worker adds the hint as a context of the error, which keeps the backend's chain and code, wherever the code arises: the WHP and MSHV feature banks at partition creation, or the support check of every backend. Explicit profile IDs, host, restores, and other vendors' CPUs get no hint, as for E_PROFILE_HOST_UNKNOWN. vm_owned_bits and pinned_mask take the profile's vendor. AMD profiles leave AMD's topology fields to the VM: the core count and APIC ID size of 0x80000008 ECX, each cache's sharing count in 0x8000001D EAX[25:14], and the processor topology of 0x8000001E (EAX, EBX[15:0], and ECX[10:0]), which leaf 1 and the extended topology leaves complete as on Intel. Leaf 4's fields stay VM-owned on Intel only. The Intel profiles keep pinning AMD's fields, which a vendor-neutral rule would have unpinned, so their encodings and digests are unchanged, as the catalog's golden digests check. The derivation policy gains an AMD section, which only AMD profiles take, so it derives every Intel profile as before and keeps its version. 0x80000005, 0x8000001D, 0x8000001E, and 0x80000021 keep data (AMD_DATA_LEAVES). 0x8000001D lists its subleaves up to the null cache type, as leaf 4 does, in profiles and in derivation, and combines by majority, like the other cache descriptors. The policy clears SVM, IBS, SKINIT, the watchdog timer, LWP, the node ID MSR, the performance counter extensions, the performance TSC, the data breakpoint and address mask extensions, and MONITORX in 0x80000001 ECX; the instructions-retired counter, INVLPGB, RDPRU, memory bandwidth enforcement, PPIN, CPPC, and branch sampling in 0x80000008 EBX; and CPUID faulting, which writes HWCR, and workload classification in 0x80000021 EAX, with its EBX and EDX. It keeps the speculation controls and immunities that KVM can present: IBPB, IBRS, STIBP, and SSBD in 0x80000008 EBX, and AutoIBRS, LFENCE serialization, SBPB, SRSO_NO, and the TSA bits in 0x80000021. BTC_NO goes: KVM never enumerates it in KVM_GET_SUPPORTED_CPUID, so every KVM host would fail a profile that set it, and a guest loses nothing without it, because Linux marks no CPU of family 0x19 or later as affected by RETBLEED (cpu_vuln_blacklist), which is all that BTC_NO decides there. PSFD stays only beside a SPEC_CTRL control, Intel's IBRS or AMD's IBRS, STIBP, or SSBD (has_spec_ctrl_control): PSFD is a bit of SPEC_CTRL, which KVM lets a guest access only with such a control, and OpenVMM's KVM backend withholds PSFD from KVM's supported surface otherwise. strip_psfd_leaf now calls the same function, so the policy and the KVM surface cannot disagree. The rule reads the derived table, which is the guest's CPUID, not each fingerprint: backends that each offer PSFD beside another control would otherwise keep PSFD while the intersection drops both controls. The policy also clears 0x80000001 EBX, the brand ID and package type, and 3DNowPrefetch: on the Azure AMD EPYC 7763 host that this work used, WHP presents 3DNowPrefetch to guests but not to the Hyper-V root, whose CPUID the cold-boot support check reads, so a profile that pinned it would fail there with E_PROFILE_UNSUPPORTED, while Linux infers PREFETCHW from long mode whatever the bit says. 0x80000001 EAX, where AMD repeats the CPU's signature, now describes the CPU as leaf 1's does, so a host of another stepping of a generation, such as Milan-X's stepping 2, supports its profile; Intel CPUs leave it zero. TOPOEXT stays: Linux reads the cache and processor topology through it, and without it finds no shared cache. topology_cpuid therefore writes AMD's cache sharing in 0x8000001D, as it writes leaf 4's on Intel: each VP has its own L1 and L2 caches, shared only with an SMT sibling, and the socket shares the L3, which Linux's cacheinfo_amd_init_llc_id reads from the last cache's count. Before, an AMD guest saw the host's L3 grouping. Every AMD partition gets it, not only the time ABI's, as every Intel partition gets leaf 4's. Unit tests derive host profiles and a Milan profile from the CPUID that a WHP probe partition presented on an AMD EPYC 7763 (family 0x19, model 1, stepping 1), and from that CPUID with what KVM can offer on bare metal added: the identity, the VM-owned fields and their masks, the cache subleaves, the policy's clears and keeps, majority votes on 0x8000001D, and the support of another stepping and of a root that lacks 3DNowPrefetch, and that PSFD stays only beside a SPEC_CTRL control, also when two backends offer different ones, and BTC_NO never. Others check that Intel profiles keep pinning AMD's fields, that an Intel fingerprint with values in AMD's leaves derives what the Intel policy alone gives, PSFD and BTC_NO included, that the effective CPUID of an AMD profile takes OpenVMM's AMD topology, that topology_cpuid shares the L3 across the socket in 0x8000001D at 1 to 8 VPs with and without SMT, and that auto names --cpu-profile host on an AMD host that no pinned profile serves, and not on a Hygon one, and where the backend lacks the profile on Intel and AMD hosts only, not for explicit profiles, host, restores, or other codes. The Guide's snapshot and CLI pages name amd.host.v1 and the hint. Under the CI-pinned Rust 1.95.0, cpu_profile (104 tests), virt (64), openvmm_core (98), and openvmm_helpers (98) pass, as do clippy --all-targets and rustdoc with warnings denied for those packages, clippy for x86_64-unknown-linux-gnu for cpu_profile, virt, virt_kvm, and virt_mshv, and xtask fmt. In a Linux container (rust:1.95.0), cpu_profile, virt_kvm (40 tests), and virt_mshv (76) pass. virt_whp's unit tests and its 16 hardware tests pass on the EPYC 7763 with WHP; no pinned profile serves that host yet, so the profile tests skip. Part of microsoft/nvx#396. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01c35bf1-81da-4f98-8380-8a902ac0e5f9 --- Guide/src/reference/openvmm/management/cli.md | 5 +- Guide/src/user_guide/openvmm/snapshots.md | 18 +- openvmm/openvmm_core/src/worker/dispatch.rs | 21 +- .../src/worker/dispatch/time_abi.rs | 139 +++- vmm_core/cpu_profile/src/catalog.rs | 7 +- vmm_core/cpu_profile/src/derive.rs | 597 +++++++++++++++--- vmm_core/cpu_profile/src/effective.rs | 109 +++- vmm_core/cpu_profile/src/lib.rs | 2 + vmm_core/cpu_profile/src/pinned.rs | 2 +- vmm_core/cpu_profile/src/profile.rs | 147 +++-- vmm_core/cpu_profile/src/surface.rs | 11 +- vmm_core/cpu_profile/src/test_support.rs | 156 ++++- vmm_core/cpu_profile/src/vendor.rs | 85 +++ vmm_core/virt/src/x86/topology.rs | 128 +++- vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 7 +- 15 files changed, 1256 insertions(+), 178 deletions(-) create mode 100644 vmm_core/cpu_profile/src/vendor.rs diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 94dd803b560..b4676a5be52 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -399,7 +399,10 @@ describes the source definitions. derive a development profile from this host's backend fingerprint for a host that no pinned profile serves. `auto` never selects a host profile. A host without a profile, or a profile of another generation, fails before the - partition is created. A restore uses the snapshot's profile and rejects any + partition is created. On an Intel or AMD host, `auto`'s errors name + `--cpu-profile host` when no pinned profile serves the host and when the + backend does not support the pinned profile (`E_PROFILE_UNSUPPORTED`). A + restore uses the snapshot's profile and rejects any other; `host` also names a host profile that the snapshot recorded. See [Time and CPU compatibility](../../../user_guide/openvmm/snapshots.md#time-and-cpu-compatibility). * `--x-time-abi-verify`: Build the microVM partition and run the time ABI diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 12f64a5dad3..aa1a75b8704 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -263,14 +263,18 @@ generation: family 6, model 85, steppings 0 to 4), `intel.icelake-sp.v1` Scalable, fifth generation: 6/207), and `intel.alderlake.v1` (Core, twelfth generation: 6/151 and 6/154). `--cpu-profile ` selects one; the default, `auto`, selects the profile of the host's generation, and a host without one -fails (`E_PROFILE_HOST_UNKNOWN`). On an Intel development host without one, -`--cpu-profile host` boots on a host profile instead: OpenVMM fingerprints the -backend on this host, as `--cpu-fingerprint` does, and applies the pinned -profiles' derivation policy to it. A host profile, `intel.host.v1`, serves only -the host's CPU model and stepping, is not pinned, and can change with the -host's microcode, firmware, or hypervisor. Every boot checks that the backend +fails (`E_PROFILE_HOST_UNKNOWN`). On an Intel or AMD development host without +one, `--cpu-profile host` boots on a host profile instead: OpenVMM +fingerprints the backend on this host, as `--cpu-fingerprint` does, and +applies the pinned profiles' derivation policy to it. A host profile, +`intel.host.v1` or `amd.host.v1`, serves only the host's CPU model and +stepping, is not pinned, and can change with the host's microcode, firmware, +or hypervisor. Every boot checks that the backend supports the profile (`E_PROFILE_UNSUPPORTED`) and that VP 0 observes its -effective CPUID (`E_CPU_SURFACE`). A snapshot records the profile and the +effective CPUID (`E_CPU_SURFACE`). On an Intel or AMD host, `auto`'s errors +name `--cpu-profile host` both where no pinned profile serves the host and +where the backend does not support the one that does, because a host profile +derives from what the backend supports. A snapshot records the profile and the effective CPUID. Restore requires the same profile, pinned in this OpenVMM with the same digest, or, for a host profile, the snapshot's own copy of it, which must match the recorded digest; a host of the profile's generation; and an diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 512b2c030f3..6f974b6b657 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -365,12 +365,28 @@ impl Worker for VmWorker { .shared_memory .map(|fd| restore_params.shared_memory_backing(fd)); + // A cold boot whose built-in CPU profile this host cannot boot names + // the host profile where one could boot instead. + #[cfg(guest_arch = "x86_64")] + let hint = { + let requested = manifest + .microvm + .time_abi + .as_ref() + .map(|time_abi| time_abi.cpu_profile.clone()); + let restoring = parameters.saved_state.is_some(); + move |err| time_abi::hint_cold_boot(err, requested.as_deref(), restoring) + }; + #[cfg(not(guest_arch = "x86_64"))] + let hint = |err: anyhow::Error| err; + let vm = block_on(InitializedVm::new( VmTaskDriverSource::new(ThreadDriverBackend::new(device_driver)), hypervisor.0, manifest, shared_memory, - ))?; + )) + .map_err(&hint)?; let saved_state = parameters .saved_state .map(|m| m.parse()) @@ -379,7 +395,8 @@ impl Worker for VmWorker { microvm_params.check_cold_boot(&vm, saved_state.is_some())?; let mut vm = - block_with_io(|_| vm.load(saved_state, parameters.notify, restore_params.state))?; + block_with_io(|_| vm.load(saved_state, parameters.notify, restore_params.state)) + .map_err(&hint)?; vm.snapshot_boundary = microvm_params.snapshot_boundary; LOADED_VM.store(&vm); diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index 9806d9e705e..e03a61cc301 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -163,14 +163,70 @@ fn select_on_host( && err.code == ProfileErrorCode::ProfileHostUnknown && cpu_profile::supports_host_profiles(host) { - err.message.push_str( - "; --cpu-profile host boots on a profile derived from this host, for development", - ); + err.message.push_str("; "); + err.message.push_str(HOST_PROFILE_HINT); } err }) } +/// What a failed cold boot with `--cpu-profile auto` suggests where host +/// profiles serve the host CPU and a host profile could boot. +const HOST_PROFILE_HINT: &str = + "--cpu-profile host boots on a profile derived from this host, for development"; + +/// Names `--cpu-profile host` in `err`, the failure of a VM worker that cold +/// booted with `--cpu-profile requested` on the host CPU `host`, where a host +/// profile could boot instead: `auto` selected a built-in profile that this +/// host's backend does not support (`E_PROFILE_UNSUPPORTED`), and host +/// profiles serve the CPU's vendor. A host profile derives from what the +/// backend supports. As for `E_PROFILE_HOST_UNKNOWN` +/// ([`select_cpu_profile`]), an explicit profile ID and `host` get no hint. +/// +/// The hint is a context of `err`, which keeps the backend's error chain: +/// the code, where the backend found the shortfall, and what it lacks. +fn hint_host_profile( + err: anyhow::Error, + requested: &str, + host: &HostCpuSignature, +) -> anyhow::Error { + if requested != cpu_profile::AUTO + || !cpu_profile::supports_host_profiles(host) + || leading_code(&err) != Some(TimeAbiCode::ProfileUnsupported) + { + return err; + } + err.context(format!( + "this host cannot boot the built-in CPU profile that --cpu-profile auto selected; \ + {HOST_PROFILE_HINT}" + )) +} + +/// Returns `err`, the failure of a VM worker, with the hint of +/// [`hint_host_profile`] if the worker cold booted a time ABI microVM with +/// `--cpu-profile requested` on this host. A restore (`restoring`), which +/// takes the snapshot's profile, gets none. +pub(super) fn hint_cold_boot( + err: anyhow::Error, + requested: Option<&str>, + restoring: bool, +) -> anyhow::Error { + match (requested, restoring, host_cpu()) { + (Some(requested), false, Ok(host)) => hint_host_profile(err, requested, &host), + _ => err, + } +} + +/// Returns the time ABI code that OpenVMM's exit message gives `err`: the +/// first bracketed code in its chain. +fn leading_code(err: &anyhow::Error) -> Option { + let chain = format!("{err:#}"); + chain.match_indices("[E_").find_map(|(start, _)| { + let code = &chain[start + 1..]; + TimeAbiCode::from_name(&code[..code.find(']')?]) + }) +} + /// Derives the host profile of this host and the `hypervisor` backend: the /// backend's CPU fingerprint, as `--cpu-fingerprint` takes it, under the /// profiles' derivation policy. @@ -718,10 +774,12 @@ mod tests { /// The signature of the test profile's CPU, an Ice Lake-SP Xeon. const TEST_SIGNATURE: u32 = 0x0006_06a6; - /// The end of the `E_PROFILE_HOST_UNKNOWN` message of `auto` on a host - /// that host profiles serve. - const HOST_PROFILE_HINT: &str = - "; --cpu-profile host boots on a profile derived from this host, for development"; + /// Returns whether `message` ends with the `--cpu-profile host` hint, as + /// `auto`'s `E_PROFILE_HOST_UNKNOWN` message does on a host that host + /// profiles serve. + fn ends_with_hint(message: &str) -> bool { + message.ends_with(&format!("; {HOST_PROFILE_HINT}")) + } fn test_profile() -> &'static CpuProfile { cpu_profile::pinned(TEST_PROFILE).unwrap() @@ -1056,7 +1114,7 @@ mod tests { "{message}" ); assert_eq!( - message.ends_with(HOST_PROFILE_HINT), + ends_with_hint(&message), cpu_profile::supports_host_profiles(&host), "{message}" ); @@ -1073,15 +1131,16 @@ mod tests { } /// `auto` names `--cpu-profile host` only where a host profile can boot: - /// on Intel CPUs that no pinned profile serves. + /// on Intel and AMD CPUs that no pinned profile serves. #[test] fn auto_names_host_profiles_only_where_they_serve() { let tiger_lake = HostCpuSignature::new(*b"GenuineIntel", 0x0008_06c1); let zen3 = HostCpuSignature::new(*b"AuthenticAMD", 0x00a2_0f12); - for (host, hint) in [(tiger_lake, true), (zen3, false)] { + let hygon = HostCpuSignature::new(*b"HygonGenuine", 0x0090_0f01); + for (host, hint) in [(tiger_lake, true), (zen3, true), (hygon, false)] { let error = select_on_host(cpu_profile::AUTO, &host, None).unwrap_err(); assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown, "{error}"); - assert_eq!(error.message.ends_with(HOST_PROFILE_HINT), hint, "{error}"); + assert_eq!(ends_with_hint(&error.message), hint, "{error}"); assert_eq!( error.message.contains("--cpu-profile host"), hint, @@ -1094,6 +1153,64 @@ mod tests { assert!(!error.message.contains("--cpu-profile host"), "{error}"); } + /// A cold boot with `auto` names `--cpu-profile host` where the backend + /// does not support the built-in profile that `auto` selected, on Intel + /// and AMD CPUs only, as it does where no built-in profile serves the + /// host. The hint keeps the backend's error chain. + #[test] + fn auto_names_host_profiles_where_the_backend_lacks_the_profile() { + let unsupported = || { + anyhow::Error::new(TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + "the kvm backend does not support CPU profile amd.milan.v1", + )) + .context("failed to create the prototype partition") + }; + let icelake = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); + let milan = HostCpuSignature::new(*b"AuthenticAMD", 0x00a0_0f11); + let hygon = HostCpuSignature::new(*b"HygonGenuine", 0x0090_0f01); + for host in [&icelake, &milan] { + let err = hint_host_profile(unsupported(), cpu_profile::AUTO, host); + let message = format!("{err:#}"); + assert!(message.starts_with("this host cannot boot the built-in CPU profile that --cpu-profile auto selected; --cpu-profile host boots"), "{message}"); + assert!( + message.ends_with(&format!("{:#}", unsupported())), + "{message}" + ); + assert_eq!( + leading_code(&err), + Some(TimeAbiCode::ProfileUnsupported), + "{message}" + ); + } + // Another vendor's CPU, an explicit profile, `host`, and other + // failures get no hint. + for (requested, host, err) in [ + (cpu_profile::AUTO, &hygon, unsupported()), + (TEST_PROFILE, &icelake, unsupported()), + (cpu_profile::HOST, &milan, unsupported()), + ( + cpu_profile::AUTO, + &milan, + anyhow::Error::new(TimeAbiError::new(TimeAbiCode::CpuSurface, "VP 0 differs")) + .context("failed to create the prototype partition"), + ), + ( + cpu_profile::AUTO, + &milan, + anyhow::anyhow!("no time ABI code"), + ), + ] { + let message = format!("{:#}", hint_host_profile(err, requested, host)); + assert!(!message.contains(HOST_PROFILE_HINT), "{message}"); + } + // Neither does a restore, nor a VM without the time ABI. + for (requested, restoring) in [(Some(cpu_profile::AUTO), true), (None, false)] { + let message = format!("{:#}", hint_cold_boot(unsupported(), requested, restoring)); + assert_eq!(message, format!("{:#}", unsupported())); + } + } + #[test] fn a_host_profile_needs_a_backend_fingerprint() { let error = select_cpu_profile(cpu_profile::HOST, "nosuch", None).unwrap_err(); diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index 01312cef6e8..e6824bf4424 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -248,7 +248,7 @@ pub fn select_auto(host: &HostCpuSignature) -> Result<&'static CpuProfile, Profi /// [`Generation::contains`](crate::Generation::contains) does for the /// generation of a parsed profile. fn covers(generation: &KnownGeneration, host: &HostCpuSignature) -> bool { - let vendor = generation.vendor.as_bytes(); + let vendor = generation.vendor.cpuid_vendor().as_bytes(); let (family, model, stepping) = decode_signature(vendor, host.signature()); host.vendor().as_slice() == vendor && generation @@ -455,7 +455,7 @@ mod tests { pinned.digest, "{id}" ); - assert_eq!(profile.vendor(), pinned.generation.vendor); + assert_eq!(profile.vendor(), pinned.generation.vendor.cpuid_vendor()); assert_eq!(profile.generation(), &pinned.generation.generation()); assert_eq!( pinned_record(id), @@ -485,7 +485,8 @@ mod tests { for (i, profile) in pinned_profiles().iter().enumerate() { let generation = profile.generation(); let known = derive::known_generation(&generation.name).unwrap(); - assert_eq!(profile.vendor(), known.vendor); + assert_eq!(profile.vendor(), known.vendor.cpuid_vendor()); + assert_eq!(profile.cpu_vendor(), known.vendor); assert_eq!( generation .cpus diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs index 64286634798..ccceb5ce053 100644 --- a/vmm_core/cpu_profile/src/derive.rs +++ b/vmm_core/cpu_profile/src/derive.rs @@ -12,23 +12,35 @@ //! by minimum, and descriptive values (signature, cache, TLB) by majority, //! one vote per host within a backend and one per backend across them. A //! tie is an error. -//! 2. Only the leaves of [`DATA_LEAVES`] keep data; every other leaf in range -//! is zero. The time ABI's CPU time bits are applied, and features a VM -//! must not see (virtualization, power management, performance -//! monitoring, debug, SGX, RDT, PT, CET, key locker) are cleared. +//! 2. Only the leaves of [`DATA_LEAVES`] keep data, and in AMD profiles also +//! those of [`AMD_DATA_LEAVES`], AMD's cache, topology, and speculation +//! leaves; every other leaf in range is zero. The time ABI's CPU time bits +//! are applied, and features a VM must not see (virtualization, power +//! management, performance monitoring, debug, SGX, RDT, PT, CET, key +//! locker) are cleared, in AMD profiles also where AMD enumerates them +//! (SVM, IBS, LWP, the performance counter extensions, MONITORX, RDPRU, +//! PPIN, CPPC, and the like). AMD's speculation controls and immunities +//! stay, those of `0x80000021` included, but for what KVM cannot present: +//! `BTC_NO` goes, and PSFD stays only beside a `SPEC_CTRL` control +//! ([`has_spec_ctrl_control`]). //! 3. The XSAVE features are the intersection, within [`ALLOWED_XCR0`], and //! the XSAVE leaf is rebuilt from them; every host must agree on the //! layout. A feature whose XSAVE state is not enabled is cleared. -//! 4. The brand string is generic per generation, -//! `Intel(R) Xeon(R) Processor ()` or -//! `Intel(R) Core(TM) Processor ()` without a frequency +//! 4. The brand string is generic per generation, such as +//! `Intel(R) Xeon(R) Processor ()`, +//! `Intel(R) Core(TM) Processor ()`, or +//! `AMD EPYC Processor ()`, without a frequency //! ([`KnownGeneration::brand`]), so every host of a generation presents //! it whatever its SKU, and the hosts' brands are not compared. //! 5. `IA32_ARCH_CAPABILITIES` is pinned in [`ARCH_CAPABILITIES_PINNED_MASK`] //! to what every backend can present: KVM's value, and for MSHV and WHP //! the bits their processor feature banks derive. `ITS_NO` is pinned //! clear because the Hyper-V backends cannot present it. -//! 6. The bits OpenVMM sets per VM are zero and unmasked. +//! 6. The bits OpenVMM sets per VM ([`vm_owned_bits`](crate::vm_owned_bits)) +//! are zero and unmasked. +//! +//! The AMD rules apply only to AMD profiles, so the policy derives every +//! Intel profile as it did before AMD profiles existed. //! //! [`derive_host_profile`] applies the same policy to one fingerprint of the //! host that OpenVMM runs on, for `--cpu-profile host`: a development profile @@ -60,6 +72,7 @@ use crate::signature::decode_signature; use crate::surface::RegisterClass; use crate::surface::field_value; use crate::surface::register_class; +use crate::vendor::CpuVendor; use std::collections::BTreeMap; use std::collections::BTreeSet; use thiserror::Error; @@ -88,6 +101,18 @@ pub const DATA_LEAVES: [u32; 16] = [ EXTENDED_LEAF_BASE + 8, ]; +/// The leaves that also keep data in AMD profiles: the L1 cache and TLB leaf +/// `0x80000005`, the cache topology leaf `0x8000001D`, the processor topology +/// leaf `0x8000001E`, whose fields OpenVMM sets per VM, and `0x80000021`, +/// which enumerates AutoIBRS, `LFENCE` serialization, SBPB, `SRSO_NO`, and +/// the TSA immunities. Intel CPUs leave them zero or out of range. +pub const AMD_DATA_LEAVES: [u32; 4] = [ + EXTENDED_LEAF_BASE + 5, + EXTENDED_LEAF_BASE + 0x1d, + EXTENDED_LEAF_BASE + 0x1e, + EXTENDED_LEAF_BASE + 0x21, +]; + /// The XCR0 bits a profile may enable: x87, SSE, AVX, MPX, AVX-512, and /// PKRU. AMX needs dynamic XSAVE permissions that no backend grants yet. pub const ALLOWED_XCR0: u64 = 0x2ff; @@ -108,13 +133,29 @@ pub const ARCH_CAPABILITIES_ITS_NO: u64 = 1 << 62; pub const ARCH_CAPABILITIES_PINNED_MASK: u64 = hv_banks::ARCH_CAPABILITIES_BANK_MASK | ARCH_CAPABILITIES_ITS_NO; +/// `CPUID.0x80000008:EBX[28]`, PSFD: predictive store forwarding can be +/// disabled, through `SPEC_CTRL`. +pub const AMD_PSFD: u32 = 1 << 28; + +/// `CPUID.0x80000008:EBX[29]`, `BTC_NO`: the CPU is not affected by branch +/// type confusion. +pub const AMD_BTC_NO: u32 = 1 << 29; + +/// Returns whether a CPUID whose `CPUID.(7,0):EDX` is `leaf7_edx` and whose +/// `CPUID.0x80000008:EBX` is `ext8_ebx` enumerates a `SPEC_CTRL` control, as +/// KVM's `guest_has_spec_ctrl_msr()` decides whether a guest may access +/// `SPEC_CTRL`: Intel's IBRS (`EDX[26]`), or AMD's IBRS, STIBP, or SSBD +/// (`EBX` bits 14, 15, and 24). AMD profiles keep [`AMD_PSFD`] only where it +/// holds, and OpenVMM's KVM backend applies it to KVM's supported CPUID. +pub fn has_spec_ctrl_control(leaf7_edx: u32, ext8_ebx: u32) -> bool { + leaf7_edx & 1 << 26 != 0 || ext8_ebx & (1 << 14 | 1 << 15 | 1 << 24) != 0 +} + /// A CPU generation that profiles are derived for. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct KnownGeneration { - /// The CPUID vendor string. - pub vendor: &'static str, - /// The vendor as profile IDs spell it. - pub vendor_short: &'static str, + /// The vendor of the generation's CPUs. + pub vendor: CpuVendor, /// The generation name, as logs and reports spell it. pub name: &'static str, /// The CPUs of the generation: family, model, and inclusive stepping @@ -148,8 +189,7 @@ impl KnownGeneration { /// The generations profiles exist for. pub const KNOWN_GENERATIONS: [KnownGeneration; 4] = [ KnownGeneration { - vendor: "GenuineIntel", - vendor_short: "intel", + vendor: CpuVendor::Intel, name: "skylake-sp", // Steppings 5 to 7 are Cascade Lake, and 10 and 11 Cooper Lake. cpus: &[(6, 85, [0, 4])], @@ -157,24 +197,21 @@ pub const KNOWN_GENERATIONS: [KnownGeneration; 4] = [ brand: "Intel(R) Xeon(R) Processor (Skylake-SP)", }, KnownGeneration { - vendor: "GenuineIntel", - vendor_short: "intel", + vendor: CpuVendor::Intel, name: "icelake-sp", cpus: &[(6, 106, [0, 15])], description: "Intel Xeon Scalable, third generation (Ice Lake-SP)", brand: "Intel(R) Xeon(R) Processor (Ice Lake-SP)", }, KnownGeneration { - vendor: "GenuineIntel", - vendor_short: "intel", + vendor: CpuVendor::Intel, name: "emeraldrapids", cpus: &[(6, 207, [0, 15])], description: "Intel Xeon Scalable, fifth generation (Emerald Rapids)", brand: "Intel(R) Xeon(R) Processor (Emerald Rapids)", }, KnownGeneration { - vendor: "GenuineIntel", - vendor_short: "intel", + vendor: CpuVendor::Intel, name: "alderlake", // Alder Lake-S (151) and Alder Lake-P and -H (154). cpus: &[(6, 151, [0, 15]), (6, 154, [0, 15])], @@ -234,15 +271,18 @@ type Table = BTreeMap<(u32, Option), [u32; 4]>; /// known generation uses it. pub const HOST_GENERATION: &str = "host"; -/// The brand string of every host profile. -const HOST_BRAND: &str = "Intel(R) Processor (host profile)"; +/// Returns the brand string of every host profile of `vendor`'s CPUs. +fn host_brand(vendor: CpuVendor) -> &'static str { + match vendor { + CpuVendor::Intel => "Intel(R) Processor (host profile)", + CpuVendor::Amd => "AMD Processor (host profile)", + } +} /// What a derivation derives a profile for: a known generation, or one host. struct Target<'a> { - /// The CPUID vendor string. - vendor: &'a str, - /// The vendor as profile IDs spell it. - vendor_short: &'a str, + /// The vendor of the profile's CPUs. + vendor: CpuVendor, /// The generation that the profile records. generation: Generation, /// The profile's description. @@ -261,7 +301,6 @@ pub fn derive_profile( derive( &Target { vendor: generation.vendor, - vendor_short: generation.vendor_short, generation: generation.generation(), description: format!( "{}, on every backend; NVX time ABI v1", @@ -276,47 +315,39 @@ pub fn derive_profile( /// Returns whether host profiles serve the CPU `host`, so that /// `--cpu-profile host` can boot on it: [`derive_host_profile`] derives them -/// for Intel CPUs only. +/// for the CPUs of every vendor that profiles serve, Intel's and AMD's. pub fn supports_host_profiles(host: &HostCpuSignature) -> bool { - host_profile_vendor(&host.vendor()).is_some() -} - -/// Returns how host profile IDs spell `vendor`, if host profiles support it. -/// The derivation policy and the VM-owned topology fields cover only Intel's -/// CPUID leaves, so only Intel CPUs have host profiles. -fn host_profile_vendor(vendor: &[u8]) -> Option<&'static str> { - match vendor { - b"GenuineIntel" => Some("intel"), - _ => None, - } + CpuVendor::from_cpuid_vendor(&host.vendor()).is_some() } /// Derives the host profile of the host and backend that `fingerprint` /// describes, for `--cpu-profile host`: the profile that the derivation /// policy gives for this one fingerprint, with the ID /// `.host.v1`, the generation [`HOST_GENERATION`] limited to the -/// host's family, model, and stepping, and a generic brand string. +/// host's family, model, and stepping, and a generic brand string of the +/// vendor. /// /// Unlike a pinned profile, a host profile is neither reviewed nor /// immutable: a microcode, firmware, or hypervisor update can change it. /// -/// Fails with `E_PROFILE_HOST_UNKNOWN` for a CPU of another vendor than -/// Intel ([`supports_host_profiles`]), and with `E_PROFILE_UNSUPPORTED` when -/// the backend lacks a feature that the time ABI requires. +/// Fails with `E_PROFILE_HOST_UNKNOWN` for a CPU of a vendor that profiles do +/// not serve ([`supports_host_profiles`]), and with `E_PROFILE_UNSUPPORTED` +/// when the backend lacks a feature that the time ABI requires. pub fn derive_host_profile(fingerprint: &CpuFingerprint) -> Result { let host = host_signature(fingerprint); let cpu = &fingerprint.host.cpu; - let Some(vendor_short) = host_profile_vendor(cpu.vendor.as_bytes()) else { + let Some(vendor) = CpuVendor::from_cpuid_vendor(cpu.vendor.as_bytes()) else { return Err(ProfileError::new( ProfileErrorCode::ProfileHostUnknown, - format!("host CPU profiles support only Intel CPUs, and the host CPU is {host}"), + format!( + "host CPU profiles support only Intel and AMD CPUs, and the host CPU is {host}" + ), )); }; let (family, model, stepping) = decode_signature(cpu.vendor.as_bytes(), cpu.signature.0); derive( &Target { - vendor: &cpu.vendor, - vendor_short, + vendor, generation: Generation { name: HOST_GENERATION.to_owned(), cpus: vec![GenerationCpu { @@ -330,7 +361,7 @@ pub fn derive_host_profile(fingerprint: &CpuFingerprint) -> Result>(); combine(&tables, &format!("{backend} hosts")) }) .collect::, _>>()?; let combined = combine(&backend_tables, "backends")?; - let mut leaves = apply_policy(&combined)?; + let mut leaves = apply_policy(vendor, &combined)?; // The brand string: generic per generation. for (leaf, value) in (EXTENDED_LEAF_BASE + 2..).zip(brand_leaves(target.brand)) { @@ -493,7 +525,7 @@ fn derive( let leaves = leaves .into_iter() .map(|((leaf, subleaf), value)| { - let mask = pinned_mask(leaf, subleaf, value); + let mask = pinned_mask(vendor, leaf, subleaf, value); let value = [0, 1, 2, 3].map(|register| value[register] & mask[register]); CpuidLeafValue::new(leaf, subleaf, value, mask) }) @@ -523,12 +555,9 @@ fn derive( .collect(), }; CpuProfile::from_parts( - format!( - "{}.{}.v{revision}", - target.vendor_short, profile_generation.name - ), + format!("{}.{}.v{revision}", vendor.id(), profile_generation.name), target.description.clone(), - target.vendor.to_owned(), + vendor.cpuid_vendor().to_owned(), profile_generation.clone(), leaves, xcr0, @@ -543,9 +572,9 @@ fn derive( /// Returns the table of a fingerprint's CPUID for combining: without the /// hypervisor range, the XSAVE leaf (rebuilt from the fingerprints' XSAVE /// information), and the brand string (generic per generation), and with -/// the bits OpenVMM sets per VM cleared, so hosts are compared only on what a -/// profile pins. -fn table(entries: &[cpuid::CpuidEntry]) -> Table { +/// the bits OpenVMM sets per VM in a profile of `vendor`'s CPUs cleared, so +/// hosts are compared only on what a profile pins. +fn table(vendor: CpuVendor, entries: &[cpuid::CpuidEntry]) -> Table { entries .iter() .filter(|entry| { @@ -557,7 +586,7 @@ fn table(entries: &[cpuid::CpuidEntry]) -> Table { .map(|entry| { let (leaf, subleaf) = entry.key(); let value = entry.registers(); - let mask = pinned_mask(leaf, subleaf, value); + let mask = pinned_mask(vendor, leaf, subleaf, value); ( (leaf, subleaf), [0, 1, 2, 3].map(|register| value[register] & mask[register]), @@ -628,10 +657,10 @@ fn majority(values: &[u32]) -> Option { } /// Lays out the profile's leaves from the combined table: the dense leaf and -/// subleaf set, the [`DATA_LEAVES`] allowlist, the CPU time bits, and the -/// features a VM must not see. The XSAVE leaf and the brand are filled in -/// later. -fn apply_policy(combined: &Table) -> Result { +/// subleaf set, the [`DATA_LEAVES`] allowlist (and [`AMD_DATA_LEAVES`] when +/// `vendor` is AMD), the CPU time bits, and the features a VM must not see. +/// The XSAVE leaf and the brand are filled in later. +fn apply_policy(vendor: CpuVendor, combined: &Table) -> Result { let get = |leaf: u32, subleaf: Option| { combined.get(&(leaf, subleaf)).copied().unwrap_or_default() }; @@ -640,14 +669,16 @@ fn apply_policy(combined: &Table) -> Result { let mut leaves = Table::new(); let all_leaves = (0..=max_basic).chain(EXTENDED_LEAF_BASE..=max_extended); for leaf in all_leaves.filter(|leaf| !VM_OWNED_LEAVES.contains(leaf)) { - let data = DATA_LEAVES.contains(&leaf); + let data = DATA_LEAVES.contains(&leaf) + || (vendor == CpuVendor::Amd && AMD_DATA_LEAVES.contains(&leaf)); if !cpuid::is_indexed_leaf(leaf) { leaves.insert((leaf, None), if data { get(leaf, None) } else { [0; 4] }); continue; } let last = match leaf { _ if !data => 0, - 0x4 => (0..0x40) + // Up to and including the first null cache type. + 0x4 | 0x8000_001d => (0..0x40) .find(|&subleaf| get(leaf, Some(subleaf))[0] & 0x1f == 0) .unwrap_or(0x3f), // Rebuilt from the XSAVE features later. @@ -756,6 +787,84 @@ fn apply_policy(combined: &Table) -> Result { ); edit(&mut leaves, (EXTENDED_LEAF_BASE + 8, None), 2, 0, !0); edit(&mut leaves, (EXTENDED_LEAF_BASE + 8, None), 3, 0, !0); + if vendor == CpuVendor::Amd { + // CPUID.0x80000001 EBX: no brand ID or package type, which describe + // the host's SKU and socket rather than the CPU's features. + edit(&mut leaves, (EXTENDED_LEAF_BASE + 1, None), 1, 0, !0); + // CPUID.0x80000001 ECX: no SVM, IBS, SKINIT, watchdog timer, LWP, or + // node ID MSR; no core, northbridge, or LLC performance counter + // extensions, performance TSC, data breakpoint or address mask + // extensions, or MONITORX. No 3DNowPrefetch either: the Hyper-V root + // of a nested Azure host does not see it, so that host's cold-boot + // support check, which reads the root's CPUID, would fail a profile + // that pins it, although WHP's guests see it; Linux infers PREFETCHW + // from long mode whatever the bit says. + edit( + &mut leaves, + (EXTENDED_LEAF_BASE + 1, None), + 2, + 0, + 1 << 2 + | 1 << 8 + | 1 << 10 + | 1 << 12 + | 1 << 13 + | 1 << 15 + | 1 << 19 + | 1 << 23 + | 1 << 24 + | 1 << 26 + | 1 << 27 + | 1 << 28 + | 1 << 29 + | 1 << 30, + ); + // CPUID.0x80000008 EBX: no instructions-retired counter, INVLPGB + // (nested pages included), RDPRU, which reads APERF and MPERF, memory + // bandwidth enforcement, PPIN, CPPC, or branch sampling. No BTC_NO + // either: KVM never enumerates it in KVM_GET_SUPPORTED_CPUID, so every + // KVM host would fail a profile that set it, and a guest loses + // nothing without it, because Linux marks no CPU of family 0x19 or + // later as affected by RETBLEED, which is all the bit decides. The + // speculation controls and the other immunities stay. + edit( + &mut leaves, + (EXTENDED_LEAF_BASE + 8, None), + 1, + 0, + 1 << 1 | 1 << 3 | 1 << 4 | 1 << 6 | 1 << 21 | 1 << 23 | 1 << 27 | AMD_BTC_NO | 1 << 31, + ); + // PSFD is a bit of SPEC_CTRL, so it means nothing to a guest that + // cannot access SPEC_CTRL, and KVM lets a guest access it only if the + // guest's CPUID has a SPEC_CTRL control. The rule reads the derived + // table, the CPUID that a guest of the profile has: applied to each + // fingerprint instead, it would keep PSFD where every backend offers + // it beside a different control, which the intersection then drops. + let register = |leaves: &Table, key, register: usize| { + leaves + .get(&key) + .map_or(0, |value: &[u32; 4]| value[register]) + }; + if !has_spec_ctrl_control( + register(&leaves, (0x7, Some(0)), 3), + register(&leaves, (EXTENDED_LEAF_BASE + 8, None), 1), + ) { + edit(&mut leaves, (EXTENDED_LEAF_BASE + 8, None), 1, 0, AMD_PSFD); + } + // CPUID.0x80000021: AutoIBRS, LFENCE serialization, SBPB, SRSO_NO, + // and the TSA immunities stay; no CPUID faulting, which writes HWCR, + // or workload classification; nothing in EBX (the microcode patch and + // return address predictor sizes) or EDX. + edit( + &mut leaves, + (EXTENDED_LEAF_BASE + 0x21, None), + 0, + 0, + 1 << 17 | 1 << 22, + ); + edit(&mut leaves, (EXTENDED_LEAF_BASE + 0x21, None), 1, 0, !0); + edit(&mut leaves, (EXTENDED_LEAF_BASE + 0x21, None), 3, 0, !0); + } Ok(leaves) } @@ -826,6 +935,8 @@ mod tests { use crate::cpuid::CpuidEntry; use crate::test_support::fingerprint; use crate::test_support::fingerprint_with; + use crate::test_support::host_fingerprint; + use crate::test_support::milan_whp_entries; use crate::test_support::profile; use crate::test_support::profile_entries; use test_with_tracing::test; @@ -1117,11 +1228,11 @@ mod tests { #[test] fn host_profiles_fail_with_the_time_abi_codes() { let pinned = profile("intel.alderlake.v1"); - let mut amd = fingerprint(pinned, "kvm"); - amd.host.cpu.vendor = "AuthenticAMD".to_owned(); - let error = derive_host_profile(&amd).unwrap_err(); + let mut hygon = host_fingerprint("kvm", milan_whp_entries()); + hygon.host.cpu.vendor = "HygonGenuine".to_owned(); + let error = derive_host_profile(&hygon).unwrap_err(); assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown); - assert!(error.message.contains("only Intel CPUs"), "{error}"); + assert!(error.message.contains("only Intel and AMD CPUs"), "{error}"); let no_rdtscp = edited(pinned, "whp", |entries| { set(entries, 0x8000_0001, None, 3, |edx| edx & !(1 << 27)); @@ -1146,23 +1257,339 @@ mod tests { assert!(known_generation(HOST_GENERATION).is_none()); } - /// Host profiles serve the CPUs that [`derive_host_profile`] accepts. + /// Host profiles serve the CPUs that [`derive_host_profile`] accepts: + /// Intel's and AMD's. #[test] - fn host_profiles_serve_intel_cpus_only() { - for (vendor, served) in [ - (*b"GenuineIntel", true), - (*b"AuthenticAMD", false), - (*b"HygonGenuine", false), - ] { - let host = HostCpuSignature::new(vendor, 0x0008_06c1); + fn host_profiles_serve_intel_and_amd_cpus() { + let intel = fingerprint(profile("intel.alderlake.v1"), "whp"); + let amd = host_fingerprint("whp", milan_whp_entries()); + let mut hygon = amd.clone(); + hygon.host.cpu.vendor = "HygonGenuine".to_owned(); + for (fingerprint, served) in [(intel, true), (amd, true), (hygon, false)] { + let host = host_signature(&fingerprint); assert_eq!(supports_host_profiles(&host), served, "{host}"); - let mut host_fingerprint = fingerprint(profile("intel.alderlake.v1"), "whp"); - host_fingerprint.host.cpu.vendor = host.vendor_str(); - assert_eq!( - derive_host_profile(&host_fingerprint).is_ok(), - served, - "{host}" + assert_eq!(derive_host_profile(&fingerprint).is_ok(), served, "{host}"); + } + } + + /// Returns the profile of AMD's Milan generation that `fingerprints` + /// derive, as a catalog would pin it. + fn derive_milan(fingerprints: &[CpuFingerprint]) -> Result { + derive( + &Target { + vendor: CpuVendor::Amd, + generation: Generation { + name: "milan".to_owned(), + cpus: vec![GenerationCpu { + family: 0x19, + model: 1, + steppings: [0, 15], + }], + }, + description: "AMD EPYC (Milan) for a test".to_owned(), + brand: "AMD EPYC Processor (Milan)", + }, + 1, + fingerprints, + ) + } + + /// The CPUID entry of `profile` at `leaf` and `subleaf`. + fn entry(profile: &CpuProfile, leaf: u32, subleaf: Option) -> &CpuidLeafValue { + profile + .cpuid() + .iter() + .find(|entry| entry.key() == (leaf, subleaf)) + .unwrap_or_else(|| panic!("{} lacks {}", profile.id(), describe_leaf(leaf, subleaf))) + } + + #[test] + fn an_amd_host_profile_leaves_amd_topology_to_the_vm() { + let host = derive_host_profile(&host_fingerprint("whp", milan_whp_entries())).unwrap(); + assert_eq!(host.id(), "amd.host.v1"); + assert_eq!(host.vendor(), "AuthenticAMD"); + assert_eq!(host.cpu_vendor(), CpuVendor::Amd); + assert_eq!( + host.generation(), + &Generation { + name: HOST_GENERATION.to_owned(), + cpus: vec![GenerationCpu { + family: 0x19, + model: 1, + steppings: [1, 1], + }], + } + ); + assert!( + host.description() + .starts_with("Host profile of AuthenticAMD family 25 model 1 stepping 1"), + "{}", + host.description() + ); + assert_eq!(brand(&host), "AMD Processor (host profile)"); + + // The cache topology lists every cache up to the null cache type, as + // leaf 4 does on Intel, with the sharing count left to the VM. + let caches = host + .cpuid() + .iter() + .filter(|entry| entry.leaf.0 == 0x8000_001d) + .map(|entry| entry.subleaf.map(|subleaf| subleaf.0)) + .collect::>(); + assert_eq!(caches, [Some(0), Some(1), Some(2), Some(3), Some(4)]); + let l3 = entry(&host, 0x8000_001d, Some(3)); + assert_eq!(l3.values(), [0x163, 0x03c0_003f, 0x7fff, 0x1]); + assert_eq!(l3.masks(), [0xfc00_3fff, !0, !0, !0]); + assert_eq!(entry(&host, 0x8000_001d, Some(4)).masks(), [!0; 4]); + // The core count and APIC ID size, and the processor topology leaf, + // are the VM's. + assert_eq!(entry(&host, 0x8000_0008, None).masks()[2], !0xf0ff); + assert_eq!( + entry(&host, 0x8000_001e, None).masks(), + [0, 0xffff_0000, 0xffff_f800, !0] + ); + // The L1 cache and TLB leaf keeps its data; leaf 4, which AMD CPUs + // leave zero, is pinned zero. + assert_eq!( + host.lookup(0x8000_0005, 0), + [0xff40_ff40, 0xff40_ff40, 0x2008_0140, 0x2008_0140] + ); + assert_eq!(entry(&host, 0x4, Some(0)).masks(), [!0; 4]); + // Topology extensions stay: the cache and processor topology leaves + // depend on them. + assert_ne!(host.lookup(0x8000_0001, 0)[2] & 1 << 22, 0); + // The policy's time bits apply as on Intel. + assert_eq!(host.lookup(0x8000_0007, 0), [0, 0, 0, 1 << 8]); + assert_eq!(host.lookup(6, 0), [1 << 2, 0, 0, 0]); + // The derivation is deterministic, and the profile is canonical. + let again = derive_host_profile(&host_fingerprint("whp", milan_whp_entries())).unwrap(); + assert_eq!(again.digest(), host.digest()); + assert_eq!(CpuProfile::decode(&host.encode()).unwrap(), host); + } + + #[test] + fn the_amd_policy_keeps_the_speculation_controls_and_clears_what_a_vm_must_not_see() { + // A Milan host whose backend offers SVM, IBS, LWP, the performance + // counter extensions, MONITORX, the speculation controls and + // immunities, RDPRU, PPIN, CPPC, and 0x80000021's features, and + // reports its own core count, cache sharing, and topology, as KVM + // can on bare metal, and BTC_NO, as WHP does. + let mut entries = milan_whp_entries(); + set(&mut entries, 0x8000_0001, None, 2, |ecx| { + ecx | 1 << 2 | 1 << 10 | 1 << 15 | 1 << 23 | 1 << 24 | 1 << 28 | 1 << 29 + }); + set(&mut entries, 0x8000_0008, None, 1, |ebx| { + ebx | 1 << 1 | 1 << 12 | 1 << 14 | 1 << 15 | 1 << 23 | 1 << 24 | 1 << 27 + }); + set(&mut entries, 0x8000_0008, None, 2, |_| 0x0000_707f); + set(&mut entries, 0x8000_001d, Some(3), 0, |eax| eax | 15 << 14); + set(&mut entries, 0x8000_001e, None, 1, |_| 0x0000_0103); + set(&mut entries, 0x8000_0021, None, 0, |_| { + 1 << 2 | 1 << 6 | 1 << 8 | 1 << 17 | 1 << 22 | 1 << 27 | 1 << 29 + }); + set(&mut entries, 0x8000_0021, None, 1, |_| 0x10); + set(&mut entries, 0x8000_0021, None, 2, |_| 1 << 1 | 1 << 2); + let derived = derive_milan(&[host_fingerprint("kvm", entries)]).unwrap(); + assert_eq!(derived.id(), "amd.milan.v1"); + assert_eq!(brand(&derived), "AMD EPYC Processor (Milan)"); + + let x1 = derived.lookup(0x8000_0001, 0)[2]; + for (bit, name) in [(0, "LAHF"), (5, "ABM"), (6, "SSE4A"), (22, "TOPOEXT")] { + assert_ne!(x1 & 1 << bit, 0, "{name}"); + } + for (bit, name) in [ + (2, "SVM"), + (8, "3DNowPrefetch"), + (10, "IBS"), + (15, "LWP"), + (23, "PERFCTR_CORE"), + (24, "PERFCTR_NB"), + (28, "PERFCTR_LLC"), + (29, "MONITORX"), + ] { + assert_eq!(x1 & 1 << bit, 0, "{name}"); + } + let x8 = derived.lookup(0x8000_0008, 0)[1]; + // PSFD stays beside IBRS, STIBP, and SSBD, the SPEC_CTRL controls. + for (bit, name) in [ + (0, "CLZERO"), + (12, "IBPB"), + (14, "IBRS"), + (15, "STIBP"), + (24, "SSBD"), + (28, "PSFD"), + ] { + assert_ne!(x8 & 1 << bit, 0, "{name}"); + } + for (bit, name) in [ + (1, "IRPERF"), + (4, "RDPRU"), + (23, "PPIN"), + (27, "CPPC"), + (29, "BTC_NO"), + ] { + assert_eq!(x8 & 1 << bit, 0, "{name}"); + } + // AutoIBRS, LFENCE serialization, the null selector rule, SBPB, + // SRSO_NO, and the TSA immunities stay; CPUID faulting and workload + // classification go, and so does EBX. + assert_eq!( + derived.lookup(0x8000_0021, 0), + [ + 1 << 2 | 1 << 6 | 1 << 8 | 1 << 27 | 1 << 29, + 0, + 1 << 1 | 1 << 2, + 0 + ] + ); + // The host's core count, cache sharing, and topology are not the + // profile's, and neither are its brand ID and package type. + assert_eq!(derived.lookup(0x8000_0008, 0)[2], 0); + assert_eq!(derived.lookup(0x8000_001d, 3)[0], 0x163); + assert_eq!(derived.lookup(0x8000_001e, 0), [0; 4]); + assert_eq!(derived.lookup(0x8000_0001, 0)[1], 0); + } + + /// PSFD is a bit of `SPEC_CTRL`, so an AMD profile keeps it only beside a + /// `SPEC_CTRL` control, as KVM's `guest_has_spec_ctrl_msr()` requires of + /// a guest's CPUID and OpenVMM's KVM backend of KVM's supported CPUID: + /// Intel's IBRS, or AMD's IBRS, STIBP, or SSBD. IBPB controls another + /// MSR. The rule reads the derived profile, so controls that the + /// backends do not share do not keep PSFD. `BTC_NO`, which KVM never + /// offers, always goes. + #[test] + fn an_amd_profile_keeps_psfd_only_beside_a_spec_ctrl_control() { + let with = |leaf: u32, register: usize, bits: u32| { + let mut entries = milan_whp_entries(); + let subleaf = (leaf == 7).then_some(0); + set(&mut entries, leaf, subleaf, register, |value| value | bits); + entries + }; + let spec = + |profile: &CpuProfile| profile.lookup(0x8000_0008, 0)[1] & (AMD_PSFD | AMD_BTC_NO); + // The Milan WHP host offers PSFD and BTC_NO, and no SPEC_CTRL control. + let whp = derive_milan(&[host_fingerprint("whp", milan_whp_entries())]).unwrap(); + assert_eq!(spec(&whp), 0); + let ibpb = derive_milan(&[host_fingerprint("kvm", with(0x8000_0008, 1, 1 << 12))]).unwrap(); + assert_eq!(spec(&ibpb), 0); + for (leaf, register, bit, name) in [ + (0x8000_0008, 1, 14, "AMD IBRS"), + (0x8000_0008, 1, 15, "AMD STIBP"), + (0x8000_0008, 1, 24, "AMD SSBD"), + (0x7, 3, 26, "Intel IBRS"), + ] { + let derived = + derive_milan(&[host_fingerprint("kvm", with(leaf, register, 1 << bit))]).unwrap(); + assert_eq!(spec(&derived), AMD_PSFD, "{name}"); + assert!( + has_spec_ctrl_control(derived.lookup(7, 0)[3], derived.lookup(0x8000_0008, 0)[1]), + "{name}" ); } + // Each backend offers PSFD with another control, so the profile has + // neither control and no PSFD. + let derived = derive_milan(&[ + host_fingerprint("kvm", with(0x8000_0008, 1, 1 << 14)), + host_fingerprint("whp", with(0x8000_0008, 1, 1 << 24)), + ]) + .unwrap(); + assert_eq!( + derived.lookup(0x8000_0008, 0)[1] & (1 << 14 | 1 << 24 | AMD_PSFD | AMD_BTC_NO), + 0 + ); + } + + /// The AMD policy leaves nothing pinned that differs between the hosts of + /// a generation or between a Hyper-V root's view and its guests': AMD + /// CPUs repeat their signature in `0x80000001` EAX, which describes the + /// CPU as leaf 1's does, so a host of another stepping, such as + /// Milan-X's 2, supports the profile, and so does a host whose root does + /// not see 3DNowPrefetch. + #[test] + fn other_hosts_of_the_generation_support_an_amd_profile() { + let derived = derive_milan(&[host_fingerprint("whp", milan_whp_entries())]).unwrap(); + assert_eq!(derived.lookup(0x8000_0001, 0)[0], 0x00a0_0f11); + let mut stepping2 = milan_whp_entries(); + set(&mut stepping2, 1, None, 0, |_| 0x00a0_0f12); + set(&mut stepping2, 0x8000_0001, None, 0, |_| 0x00a0_0f12); + // Its backend reports another package type, too, and, as the + // Hyper-V root of a nested Azure host sees it, no 3DNowPrefetch. + set(&mut stepping2, 0x8000_0001, None, 1, |_| 0); + set(&mut stepping2, 0x8000_0001, None, 2, |ecx| ecx & !(1 << 8)); + let surface = + crate::HostCpuSurface::from_fingerprint(&host_fingerprint("whp", stepping2).backend); + assert_eq!( + crate::support_violations(&derived, &surface), + Vec::::new() + ); + } + + #[test] + fn amd_cache_topology_is_descriptive() { + // Two backends report the reference host's L3, and a third another + // SKU's: the majority's stays, where intersecting would mix them. + let mut other_sku = milan_whp_entries(); + set(&mut other_sku, 0x8000_001d, Some(3), 2, |_| 0x3fff); + let fingerprints = [ + host_fingerprint("kvm", other_sku.clone()), + host_fingerprint("mshv", milan_whp_entries()), + host_fingerprint("whp", milan_whp_entries()), + ]; + let derived = derive_milan(&fingerprints).unwrap(); + assert_eq!(derived.lookup(0x8000_001d, 3)[2], 0x7fff); + // Two backends that disagree have no majority. + let tie = derive_milan(&fingerprints[..2]).unwrap_err(); + assert!( + matches!(&tie, DeriveError::Tie { what, .. } if what == "CPUID 0x8000001d.3 ECX among the backends"), + "{tie}" + ); + } + + /// The AMD rules do not reach Intel profiles: an Intel host whose + /// backend reports values in AMD's leaves and fields derives the + /// profile that the Intel rules alone give. + #[test] + fn the_amd_rules_leave_intel_profiles_unchanged() { + let pinned = profile("intel.alderlake.v1"); + let kvm = edited(pinned, "kvm", |entries| { + // MONITORX and PPIN, which AMD profiles clear; AMD's L1 cache + // leaf, which AMD profiles keep; and an AMD core count, which AMD + // profiles leave to the VM. + set(entries, 0x8000_0001, None, 2, |ecx| ecx | 1 << 29); + set(entries, 0x8000_0005, None, 2, |_| 0x2008_0140); + set(entries, 0x8000_0008, None, 1, |ebx| ebx | 1 << 23); + set(entries, 0x8000_0008, None, 2, |_| 0x0000_701f); + }); + let derived = derive_profile(generation("alderlake"), 1, &[kvm]).unwrap(); + assert_eq!( + derived.lookup(0x8000_0001, 0)[2], + pinned.lookup(0x8000_0001, 0)[2] | 1 << 29 + ); + assert_eq!( + derived.lookup(0x8000_0008, 0)[1], + pinned.lookup(0x8000_0008, 0)[1] | 1 << 23 + ); + assert_eq!(derived.lookup(0x8000_0005, 0), [0; 4]); + assert_eq!(derived.lookup(0x8000_0008, 0)[2], 0); + assert_eq!(entry(&derived, 0x8000_0008, None).masks(), [!0; 4]); + + // PSFD without a SPEC_CTRL control, and BTC_NO, which AMD profiles + // clear, stay too: the Ice Lake-SP profile has no SPEC_CTRL control, + // which Azure withholds from its hosts. + let pinned = profile("intel.icelake-sp.v1"); + assert!(!has_spec_ctrl_control( + pinned.lookup(7, 0)[3], + pinned.lookup(0x8000_0008, 0)[1] + )); + let kvm = edited(pinned, "kvm", |entries| { + set(entries, 0x8000_0008, None, 1, |ebx| { + ebx | AMD_PSFD | AMD_BTC_NO + }); + }); + let derived = derive_profile(generation("icelake-sp"), 1, &[kvm]).unwrap(); + assert_eq!( + derived.lookup(0x8000_0008, 0)[1], + pinned.lookup(0x8000_0008, 0)[1] | AMD_PSFD | AMD_BTC_NO + ); } } diff --git a/vmm_core/cpu_profile/src/effective.rs b/vmm_core/cpu_profile/src/effective.rs index 2c16c3781ca..4bb752e5313 100644 --- a/vmm_core/cpu_profile/src/effective.rs +++ b/vmm_core/cpu_profile/src/effective.rs @@ -352,6 +352,7 @@ impl CpuProfile { .map(|entry| (entry.key(), (entry.values(), entry.masks()))) .collect::>(); let max_basic = self.lookup(0, 0)[0]; + let vendor = self.cpu_vendor(); for leaf in vm.iter().filter(|leaf| leaf.mask != [0; 4]) { let what = || describe_leaf(leaf.function, leaf.index); @@ -377,10 +378,11 @@ impl CpuProfile { if leaf.index.is_some() && index.is_some() && index != leaf.index { continue; } - let owned = vm_owned_bits(function, slot.0); + let owned = vm_owned_bits(vendor, function, slot.0); if leaf.index.is_none() && owned == [0; 4] { // A result for every subleaf skips subleaves without VM - // fields, such as the null cache type that ends leaf 4. + // fields, such as the null cache type that ends a cache + // leaf. continue; } for (register, (bits, owned)) in leaf.mask.iter().zip(owned).enumerate() { @@ -431,7 +433,7 @@ impl CpuProfile { if HYPERVISOR_LEAVES.contains(&function) { continue; } - let owned = vm_owned_bits(function, value); + let owned = vm_owned_bits(vendor, function, value); for (register, (owned, mask)) in owned.iter().zip(mask).enumerate() { if owned & !mask != 0 { return Err(error(format!( @@ -483,19 +485,48 @@ fn merge(slot: &mut ([u32; 4], [u32; 4]), leaf: &CpuidResult) { #[cfg(test)] mod tests { use super::*; + use crate::test_support::host_fingerprint; + use crate::test_support::milan_whp_entries; use crate::test_support::profile; + use crate::vendor::CpuVendor; use test_with_tracing::test; const ICELAKE: &str = "intel.icelake-sp.v1"; /// Returns hand-built VM leaves for `profile`: the fields OpenVMM's - /// topology code sets for one socket of `vp_count` VPs, and an xAPIC. + /// topology code sets for one socket of `vp_count` VPs without SMT, of + /// the profile's vendor, and an xAPIC. fn vm_leaves(profile: &CpuProfile, vp_count: u32) -> Vec { let mut leaves = vec![ CpuidResult::new(1, [0, vp_count << 16, 0, 0]).masked([0, 0xffff_0000, 0, 0]), - CpuidResult::new(4, [(vp_count - 1) << 26, 0, 0, 0]).masked([0xffff_c000, 0, 0, 0]), x2apic_cpuid(false), ]; + match profile.cpu_vendor() { + CpuVendor::Intel => leaves.push( + CpuidResult::new(4, [(vp_count - 1) << 26, 0, 0, 0]).masked([0xffff_c000, 0, 0, 0]), + ), + CpuVendor::Amd => { + let apic_id_size = vp_count.next_power_of_two().trailing_zeros(); + leaves.push( + CpuidResult::new(0x8000_0008, [0, 0, (vp_count - 1) | apic_id_size << 12, 0]) + .masked([0, 0, 0xf0ff, 0]), + ); + // The L3 cache is the socket's; the others are each VP's. + for subleaf in 0.. { + let eax = profile.lookup(0x8000_001d, subleaf)[0]; + if eax & 0x1f == 0 { + break; + } + let sharing = if (eax >> 5) & 7 == 3 { vp_count - 1 } else { 0 }; + leaves.push( + CpuidResult::new(0x8000_001d, [sharing << 14, 0, 0, 0]) + .indexed(subleaf) + .masked([0x03ff_c000, 0, 0, 0]), + ); + } + leaves.push(CpuidResult::new(0x8000_001e, [0; 4]).masked([!0, 0xffff, 0x7ff, 0])); + } + } let max_basic = profile.lookup(0, 0)[0]; for leaf in VM_OWNED_LEAVES .into_iter() @@ -507,6 +538,11 @@ mod tests { leaves } + /// Returns the host profile of the AMD EPYC 7763 test host on WHP. + fn amd_host_profile() -> CpuProfile { + crate::derive_host_profile(&host_fingerprint("whp", milan_whp_entries())).unwrap() + } + fn identity() -> Vec { vec![CpuidResult::new( 0x4000_0000, @@ -554,6 +590,69 @@ mod tests { ); } + /// An AMD profile leaves AMD's topology fields to the VM: the core count + /// in `0x80000008`, each cache's sharing in `0x8000001D`, and the + /// processor topology in `0x8000001E`. + #[test] + fn completes_an_amd_profile_with_amd_topology_fields() { + let profile = amd_host_profile(); + let effective = profile + .effective_cpuid(&vm_leaves(&profile, 4), &identity()) + .unwrap(); + assert_eq!(effective.lookup(0x8000_0008, 0)[2], 3 | 2 << 12); + // Each VP has its own L1 and L2 caches, and the socket shares the + // L3; the null cache type that ends the leaf has no VM fields. + for (subleaf, sharing) in [(0, 0), (1, 0), (2, 0), (3, 3)] { + let eax = effective.lookup(0x8000_001d, subleaf)[0]; + assert_eq!((eax >> 14) & 0xfff, sharing, "subleaf {subleaf}"); + assert_eq!( + eax & !0x03ff_c000, + profile.lookup(0x8000_001d, subleaf)[0], + "subleaf {subleaf}" + ); + } + assert_eq!(effective.lookup(0x8000_001d, 4), [0; 4]); + assert_eq!(effective.lookup(0x8000_001e, 0), [0; 4]); + // Only the runtime state is left undefined, as on Intel. + let undefined = effective + .results() + .filter(|result| result.mask != [!0; 4]) + .map(|result| (result.function, result.index, result.mask)) + .collect::>(); + assert_eq!( + undefined, + [ + (1, None, [!0, !0, !(1 << 27), !0]), + (7, Some(0), [!0, !0, !(1 << 4), !0]), + (0xd, Some(0), [!0, 0, !0, !0]), + (0xd, Some(1), [!0, 0, !0, !0]), + ] + ); + + // Without the processor topology, the VM leaves bits undefined. + let leaves = vm_leaves(&profile, 4) + .into_iter() + .filter(|leaf| leaf.function != 0x8000_001e) + .collect::>(); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("the VM did not define CPUID 0x8000001e EAX bits 0xffffffff") + ); + } + + /// Intel profiles pin AMD's topology fields, so OpenVMM cannot set them + /// there. + #[test] + fn intel_profiles_pin_amd_topology_fields() { + let profile = profile(ICELAKE); + let mut leaves = vm_leaves(profile, 2); + leaves.push(CpuidResult::new(0x8000_0008, [0, 0, 1, 0]).masked([0, 0, 0xf0ff, 0])); + assert!( + error_message(profile.effective_cpuid(&leaves, &[])) + .contains("CPUID 0x80000008 matches no profile leaf with VM fields") + ); + } + #[test] fn rejects_inputs_that_do_not_complete_the_profile_exactly() { let profile = profile(ICELAKE); diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index 343e5d7ad8b..7d586ef80d6 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -77,6 +77,7 @@ mod surface; #[cfg(test)] mod test_support; mod unlisted; +mod vendor; pub use catalog::AUTO; pub use catalog::HOST; @@ -130,3 +131,4 @@ pub use surface::verify_support_with_unlisted; pub use unlisted::check_unlisted_cpuid; pub use unlisted::unlisted_cpuid_candidates; pub use unlisted::unlisted_cpuid_violations; +pub use vendor::CpuVendor; diff --git a/vmm_core/cpu_profile/src/pinned.rs b/vmm_core/cpu_profile/src/pinned.rs index 0583f9af660..94965c55498 100644 --- a/vmm_core/cpu_profile/src/pinned.rs +++ b/vmm_core/cpu_profile/src/pinned.rs @@ -86,7 +86,7 @@ impl PinnedProfile { .collect(), }, schema: SCHEMA.to_owned(), - vendor: self.generation.vendor.to_owned(), + vendor: self.generation.vendor.cpuid_vendor().to_owned(), xcr0: Hex64(self.xcr0), xsave_components: self.xsave_components.to_vec(), xss: Hex64(self.xss), diff --git a/vmm_core/cpu_profile/src/profile.rs b/vmm_core/cpu_profile/src/profile.rs index b84944bbb4b..b79b0dfb5fb 100644 --- a/vmm_core/cpu_profile/src/profile.rs +++ b/vmm_core/cpu_profile/src/profile.rs @@ -33,6 +33,7 @@ use crate::error::ProfileErrorCode; use crate::fingerprint::IA32_ARCH_CAPABILITIES; use crate::signature::HostCpuSignature; use crate::signature::decode_signature; +use crate::vendor::CpuVendor; use serde::Deserialize; use serde::Serialize; @@ -205,6 +206,16 @@ impl CpuProfile { &self.vendor } + /// Returns the vendor of the profile's CPUs, which decides the layout of + /// the fields that OpenVMM sets per VM ([`vm_owned_bits`]). + pub fn cpu_vendor(&self) -> CpuVendor { + // Validation accepts only the vendors that profiles serve, and a + // pinned profile's vendor is its known generation's, which the + // catalog's tests check. + CpuVendor::from_cpuid_vendor(self.vendor.as_bytes()) + .expect("a valid profile has a vendor that profiles serve") + } + /// Returns the CPU generation the profile serves. pub fn generation(&self) -> &Generation { &self.generation @@ -394,8 +405,8 @@ impl CpuProfile { if self.schema != SCHEMA { return Err(format!("schema {:?} is not {SCHEMA:?}", self.schema)); } - self.validate_identity()?; - self.validate_cpuid()?; + let vendor = self.validate_identity()?; + self.validate_cpuid(vendor)?; self.validate_xsave()?; self.validate_msrs()?; let width = self.lookup(EXTENDED_LEAF_BASE + 8, 0)[0] & 0xff; @@ -408,13 +419,12 @@ impl CpuProfile { Ok(()) } - fn validate_identity(&self) -> Result<(), String> { - // Only Intel profiles exist; AMD profiles wait for an AMD host, and - // their topology fields differ. - let vendor_short = match self.vendor.as_str() { - "GenuineIntel" => "intel", - vendor => return Err(format!("unsupported vendor {vendor:?}")), + /// Checks the profile's identity and returns its vendor. + fn validate_identity(&self) -> Result { + let Some(cpu_vendor) = CpuVendor::from_cpuid_vendor(self.vendor.as_bytes()) else { + return Err(format!("unsupported vendor {:?}", self.vendor)); }; + let vendor_short = cpu_vendor.id(); let name = &self.generation.name; if !is_id_component(name) { return Err(format!("invalid generation name {name:?}")); @@ -457,10 +467,10 @@ impl CpuProfile { signature.signature() )); } - Ok(()) + Ok(cpu_vendor) } - fn validate_cpuid(&self) -> Result<(), String> { + fn validate_cpuid(&self, vendor: CpuVendor) -> Result<(), String> { for pair in self.cpuid.windows(2) { if pair[0].key() >= pair[1].key() { return Err(format!( @@ -493,7 +503,7 @@ impl CpuProfile { )); } let value = entry.values(); - let expected_mask = pinned_mask(leaf, entry.subleaf.map(|s| s.0), value); + let expected_mask = pinned_mask(vendor, leaf, entry.subleaf.map(|s| s.0), value); if entry.masks() != expected_mask { return Err(format!( "{} has mask {:#x?}, expected {expected_mask:#x?}", @@ -535,8 +545,10 @@ impl CpuProfile { return vec![None]; } let last = match leaf { - // Up to and including the first subleaf with a null cache type. - 0x4 => (0..0x40) + // Up to and including the first subleaf with a null cache type: + // Intel's deterministic cache parameters, and AMD's cache + // topology. + 0x4 | 0x8000_001d => (0..0x40) .find(|&subleaf| self.lookup(leaf, subleaf)[0] & 0x1f == 0) .unwrap_or(0x3f), 0xd => { @@ -644,18 +656,32 @@ pub(crate) fn find(entries: &[CpuidLeafValue], leaf: u32, subleaf: u32) -> Optio } /// Returns the bits of CPUID `leaf` that OpenVMM sets from the VM -/// configuration, given the leaf's output `value`: +/// configuration, given the leaf's output `value`, in a profile of `vendor`'s +/// CPUs: /// /// - `CPUID.1:EBX[31:16]`, the logical processor count and initial APIC ID, /// and `CPUID.1:ECX[21]`, x2APIC, which the APIC mode decides; -/// - `CPUID.4:EAX[31:14]`, the core and cache-sharing counts, in every -/// subleaf that describes a cache; and -/// - every bit of the topology leaves in [`VM_OWNED_LEAVES`]. -pub fn vm_owned_bits(leaf: u32, value: [u32; 4]) -> [u32; 4] { - match leaf { - 0x1 => [0, 0xffff_0000, 1 << 21, 0], - 0x4 if value[0] & 0x1f != 0 => [0xffff_c000, 0, 0, 0], - leaf if VM_OWNED_LEAVES.contains(&leaf) => [!0; 4], +/// - every bit of the topology leaves in [`VM_OWNED_LEAVES`]; +/// - on Intel, `CPUID.4:EAX[31:14]`, the core and cache-sharing counts, in +/// every subleaf that describes a cache; and +/// - on AMD, the core count and APIC ID size, `CPUID.0x80000008:ECX[7:0]` and +/// `ECX[15:12]`; the cache-sharing count, `CPUID.0x8000001D:EAX[25:14]`, in +/// every subleaf that describes a cache; and the processor topology of +/// `CPUID.0x8000001E`: the extended APIC ID in `EAX`, the compute unit ID +/// and threads per compute unit in `EBX[15:0]`, and the node ID and nodes +/// per processor in `ECX[10:0]`. +/// +/// Each vendor's fields are VM-owned only in its own profiles: the Intel +/// profiles pin AMD's topology fields, which Intel CPUs leave zero. +pub fn vm_owned_bits(vendor: CpuVendor, leaf: u32, value: [u32; 4]) -> [u32; 4] { + let describes_cache = value[0] & 0x1f != 0; + match (vendor, leaf) { + (_, 0x1) => [0, 0xffff_0000, 1 << 21, 0], + (_, leaf) if VM_OWNED_LEAVES.contains(&leaf) => [!0; 4], + (CpuVendor::Intel, 0x4) if describes_cache => [0xffff_c000, 0, 0, 0], + (CpuVendor::Amd, 0x8000_0008) => [0, 0, 0xf0ff, 0], + (CpuVendor::Amd, 0x8000_001d) if describes_cache => [0x03ff_c000, 0, 0, 0], + (CpuVendor::Amd, 0x8000_001e) => [!0, 0xffff, 0x7ff, 0], _ => [0; 4], } } @@ -674,9 +700,15 @@ pub fn runtime_owned_bits(leaf: u32, subleaf: Option) -> [u32; 4] { } /// Returns the bits of CPUID `leaf` and `subleaf`, with output `value`, that -/// a profile pins: every bit that is neither VM-owned nor runtime-owned. -pub fn pinned_mask(leaf: u32, subleaf: Option, value: [u32; 4]) -> [u32; 4] { - let vm = vm_owned_bits(leaf, value); +/// a profile of `vendor`'s CPUs pins: every bit that is neither VM-owned nor +/// runtime-owned. +pub fn pinned_mask( + vendor: CpuVendor, + leaf: u32, + subleaf: Option, + value: [u32; 4], +) -> [u32; 4] { + let vm = vm_owned_bits(vendor, leaf, value); let runtime = runtime_owned_bits(leaf, subleaf); [0, 1, 2, 3].map(|register| !(vm[register] | runtime[register])) } @@ -820,9 +852,21 @@ mod tests { "is not intel.icelake-sp.v", ); assert_invalid( - decode_edited(|value| value["vendor"] = json!("AuthenticAMD")), + decode_edited(|value| value["vendor"] = json!("HygonGenuine")), "unsupported vendor", ); + // An AMD vendor on an Intel profile's ID and CPUID. + assert_invalid( + decode_edited(|value| value["vendor"] = json!("AuthenticAMD")), + "is not amd.icelake-sp.v", + ); + assert_invalid( + decode_edited(|value| { + value["vendor"] = json!("AuthenticAMD"); + value["id"] = json!("amd.icelake-sp.v1"); + }), + "CPUID leaf 0 reports vendor \"GenuineIntel\", not \"AuthenticAMD\"", + ); assert_invalid( decode_edited(|value| value["generation"]["cpus"][0]["model"] = json!(85)), "is not in generation icelake-sp", @@ -914,20 +958,51 @@ mod tests { #[test] fn masks_leave_the_vm_and_runtime_fields_to_openvmm() { + for vendor in CpuVendor::ALL { + assert_eq!( + pinned_mask(vendor, 1, None, [0; 4]), + [!0, 0x0000_ffff, !(1 << 21 | 1 << 27), !0] + ); + assert_eq!( + pinned_mask(vendor, 7, Some(0), [0; 4]), + [!0, !0, !(1 << 4), !0] + ); + assert_eq!(pinned_mask(vendor, 0xd, Some(1), [0; 4]), [!0, 0, !0, !0]); + assert_eq!(pinned_mask(vendor, 0xd, Some(2), [0; 4]), [!0; 4]); + assert_eq!(pinned_mask(vendor, 0xb, Some(0), [0; 4]), [0; 4]); + } + let intel = CpuVendor::Intel; assert_eq!( - pinned_mask(1, None, [0; 4]), - [!0, 0x0000_ffff, !(1 << 21 | 1 << 27), !0] - ); - assert_eq!( - pinned_mask(4, Some(0), [0x121, 0, 0, 0]), + pinned_mask(intel, 4, Some(0), [0x121, 0, 0, 0]), [0x3fff, !0, !0, !0] ); // The null cache type that ends leaf 4 has no VM fields. - assert_eq!(pinned_mask(4, Some(4), [0; 4]), [!0; 4]); - assert_eq!(pinned_mask(7, Some(0), [0; 4]), [!0, !0, !(1 << 4), !0]); - assert_eq!(pinned_mask(0xd, Some(1), [0; 4]), [!0, 0, !0, !0]); - assert_eq!(pinned_mask(0xd, Some(2), [0; 4]), [!0; 4]); - assert_eq!(pinned_mask(0xb, Some(0), [0; 4]), [0; 4]); + assert_eq!(pinned_mask(intel, 4, Some(4), [0; 4]), [!0; 4]); + // Intel profiles pin AMD's topology fields. + assert_eq!(pinned_mask(intel, 0x8000_0008, None, [0; 4]), [!0; 4]); + assert_eq!( + pinned_mask(intel, 0x8000_001d, Some(0), [0x121, 0, 0, 0]), + [!0; 4] + ); + assert_eq!(pinned_mask(intel, 0x8000_001e, None, [0; 4]), [!0; 4]); + + // AMD profiles leave AMD's to the VM, and pin leaf 4, which AMD + // CPUs leave zero. + let amd = CpuVendor::Amd; + assert_eq!(pinned_mask(amd, 4, Some(0), [0x121, 0, 0, 0]), [!0; 4]); + assert_eq!( + pinned_mask(amd, 0x8000_0008, None, [0; 4]), + [!0, !0, !0xf0ff, !0] + ); + assert_eq!( + pinned_mask(amd, 0x8000_001d, Some(3), [0x163, 0, 0, 0]), + [0xfc00_3fff, !0, !0, !0] + ); + assert_eq!(pinned_mask(amd, 0x8000_001d, Some(4), [0; 4]), [!0; 4]); + assert_eq!( + pinned_mask(amd, 0x8000_001e, None, [0; 4]), + [0, 0xffff_0000, 0xffff_f800, !0] + ); } #[test] diff --git a/vmm_core/cpu_profile/src/surface.rs b/vmm_core/cpu_profile/src/surface.rs index 172d7b06f5f..fe8e186d011 100644 --- a/vmm_core/cpu_profile/src/surface.rs +++ b/vmm_core/cpu_profile/src/surface.rs @@ -145,8 +145,10 @@ pub(crate) enum RegisterClass { /// may not exceed the backend's. Limits(&'static [(u32, u32)]), /// Values that do not constrain the host, or that other checks cover: - /// the vendor and signature (generation check), descriptive cache, TLB, - /// and brand information, and the XSAVE leaf (layout check). + /// the vendor and signature (generation check), AMD's copy of the + /// signature in `0x80000001` EAX, which Intel CPUs leave zero, + /// descriptive cache, TLB, and brand information (AMD's cache topology + /// leaf `0x8000001D` included), and the XSAVE leaf (layout check). Informational, } @@ -156,8 +158,9 @@ pub(crate) fn register_class(leaf: u32, subleaf: u32, register: usize) -> Regist match (leaf, subleaf, register) { (0x0, _, 0) | (0x8000_0000, _, 0) | (0x7, 0, 0) => RegisterClass::Limits(ALL), (0x8000_0008, _, 0) => RegisterClass::Limits(ADDRESS_WIDTHS), - (0x0 | 0x2 | 0x3 | 0x4 | 0xd | 0x18, _, _) - | (0x1, _, 0 | 1) + (0x0 | 0x2 | 0x3 | 0x4 | 0xd | 0x18 | 0x8000_001d, _, _) + | (0x1 | 0x8000_0001, _, 0) + | (0x1, _, 1) | (0x8000_0002..=0x8000_0006, _, _) | (0x8000_0008, _, 2 | 3) => RegisterClass::Informational, _ => RegisterClass::Features, diff --git a/vmm_core/cpu_profile/src/test_support.rs b/vmm_core/cpu_profile/src/test_support.rs index a73ffc7def2..17920241194 100644 --- a/vmm_core/cpu_profile/src/test_support.rs +++ b/vmm_core/cpu_profile/src/test_support.rs @@ -44,15 +44,46 @@ pub(crate) fn fingerprint_with( backend: &str, cpuid: Vec, ) -> CpuFingerprint { - let signature = profile.lookup(1, 0)[0]; - let (family, model, stepping) = decode_signature(profile.vendor().as_bytes(), signature); + let host = host_identity(profile.vendor().as_bytes(), &|leaf| profile.lookup(leaf, 0)); + let mut backend = BackendFingerprint::new(backend, "test", cpuid); + backend.msrs.arch_capabilities = profile + .msr(IA32_ARCH_CAPABILITIES) + .map(|(value, _)| Hex64(value)); + CpuFingerprint::new(tool(), host, backend) +} + +/// Returns the fingerprint of a host whose `backend` reports `cpuid`, of the +/// CPU whose vendor, signature, and brand `cpuid` reports. +pub(crate) fn host_fingerprint(backend: &str, cpuid: Vec) -> CpuFingerprint { + let leaf = |leaf| crate::cpuid::lookup(&cpuid, leaf, 0).unwrap_or_default(); + let [_, ebx, ecx, edx] = leaf(0); + let host = host_identity(&crate::signature::vendor_bytes(ebx, edx, ecx), &leaf); + CpuFingerprint::new( + tool(), + host, + BackendFingerprint::new(backend, "test", cpuid), + ) +} + +fn tool() -> ToolIdentity { + ToolIdentity { + name: "test".to_owned(), + version: "0".to_owned(), + } +} + +/// Returns the identity of a host of `vendor`'s CPU whose signature and +/// brand string `cpuid(leaf)` reports. +fn host_identity(vendor: &[u8], cpuid: &dyn Fn(u32) -> [u32; 4]) -> HostIdentity { + let signature = cpuid(1)[0]; + let (family, model, stepping) = decode_signature(vendor, signature); let brand = (0x8000_0002..=0x8000_0004) - .flat_map(|leaf| profile.lookup(leaf, 0)) + .flat_map(cpuid) .flat_map(u32::to_le_bytes) .collect::>(); - let host = HostIdentity { + HostIdentity { cpu: HostCpu { - vendor: profile.vendor().to_owned(), + vendor: String::from_utf8_lossy(vendor).into_owned(), signature: Hex32(signature), family, model, @@ -74,17 +105,106 @@ pub(crate) fn fingerprint_with( available_clocksources: Vec::new(), }, hypervisor: None, - }; - let mut backend = BackendFingerprint::new(backend, "test", cpuid); - backend.msrs.arch_capabilities = profile - .msr(IA32_ARCH_CAPABILITIES) - .map(|(value, _)| Hex64(value)); - CpuFingerprint::new( - ToolIdentity { - name: "test".to_owned(), - version: "0".to_owned(), - }, - host, - backend, - ) + } +} + +/// The CPUID of an AMD EPYC 7763 (Milan: family 0x19, model 1, stepping 1) +/// as a WHP probe partition presents it on an Azure host, which +/// `--cpu-fingerprint` recorded: each leaf, subleaf, and the registers. WHP +/// offers that host neither the speculation controls of `0x80000008` EBX nor +/// anything in `0x80000021`, and reports no cache sharing in `0x8000001D`. +pub(crate) const MILAN_WHP_CPUID: [(u32, Option, [u32; 4]); 57] = [ + (0x0, None, [0xd, 0x6874_7541, 0x444d_4163, 0x6974_6e65]), + (0x1, None, [0x00a0_0f11, 0x800, 0x76fa_3203, 0x078b_fbff]), + (0x2, None, [0; 4]), + (0x3, None, [0; 4]), + (0x4, Some(0), [0; 4]), + (0x5, None, [0; 4]), + (0x6, None, [0, 0, 1, 0]), + (0x7, Some(0), [0, 0x219c_07a9, 0x0040_0684, 0x10]), + (0x8, None, [0; 4]), + (0x9, None, [0; 4]), + (0xa, None, [0; 4]), + (0xb, Some(0), [0; 4]), + (0xc, None, [0; 4]), + (0xd, Some(0), [0x7, 0x340, 0x340, 0]), + (0xd, Some(1), [0xf, 0x368, 0x1800, 0]), + (0xd, Some(2), [0x100, 0x240, 0, 0]), + (0xd, Some(0xb), [0x10, 0, 1, 0]), + (0xd, Some(0xc), [0x18, 0, 1, 0]), + (0x4000_0000, None, [0; 4]), + ( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + ), + ( + 0x8000_0001, + None, + [0x00a0_0f11, 0x4000_0000, 0x0040_03f3, 0x2fd3_fbff], + ), + ( + 0x8000_0002, + None, + [0x2044_4d41, 0x4359_5045, 0x3637_3720, 0x3436_2033], + ), + ( + 0x8000_0003, + None, + [0x726f_432d, 0x7250_2065, 0x7365_636f, 0x2072_6f73], + ), + ( + 0x8000_0004, + None, + [0x2020_2020, 0x2020_2020, 0x2020_2020, 0x0020_2020], + ), + ( + 0x8000_0005, + None, + [0xff40_ff40, 0xff40_ff40, 0x2008_0140, 0x2008_0140], + ), + ( + 0x8000_0006, + None, + [0x4800_2200, 0x6800_4200, 0x0200_6140, 0x0800_9140], + ), + (0x8000_0007, None, [0; 4]), + (0x8000_0008, None, [0x3030, 0x3000_0015, 0, 0x0001_0000]), + (0x8000_0009, None, [0; 4]), + (0x8000_000a, None, [0; 4]), + (0x8000_000b, None, [0; 4]), + (0x8000_000c, None, [0; 4]), + (0x8000_000d, None, [0; 4]), + (0x8000_000e, None, [0; 4]), + (0x8000_000f, None, [0; 4]), + (0x8000_0010, None, [0; 4]), + (0x8000_0011, None, [0; 4]), + (0x8000_0012, None, [0; 4]), + (0x8000_0013, None, [0; 4]), + (0x8000_0014, None, [0; 4]), + (0x8000_0015, None, [0; 4]), + (0x8000_0016, None, [0; 4]), + (0x8000_0017, None, [0; 4]), + (0x8000_0018, None, [0; 4]), + (0x8000_0019, None, [0; 4]), + (0x8000_001a, None, [0x2, 0, 0, 0]), + (0x8000_001b, None, [0; 4]), + (0x8000_001c, None, [0; 4]), + (0x8000_001d, Some(0), [0x121, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(1), [0x122, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(2), [0x143, 0x01c0_003f, 0x3ff, 0x2]), + (0x8000_001d, Some(3), [0x163, 0x03c0_003f, 0x7fff, 0x1]), + (0x8000_001d, Some(4), [0; 4]), + (0x8000_001e, None, [0; 4]), + (0x8000_001f, None, [0; 4]), + (0x8000_0020, None, [0; 4]), + (0x8000_0021, None, [0; 4]), +]; + +/// Returns [`MILAN_WHP_CPUID`] as fingerprint entries. +pub(crate) fn milan_whp_entries() -> Vec { + MILAN_WHP_CPUID + .iter() + .map(|&(leaf, subleaf, registers)| CpuidEntry::new(leaf, subleaf, registers)) + .collect() } diff --git a/vmm_core/cpu_profile/src/vendor.rs b/vmm_core/cpu_profile/src/vendor.rs new file mode 100644 index 00000000000..20c65164d52 --- /dev/null +++ b/vmm_core/cpu_profile/src/vendor.rs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The CPU vendors that profiles serve. + +use std::fmt; + +/// A CPU vendor that profiles serve. +/// +/// Intel and AMD enumerate their topology, caches, and speculation controls +/// in different CPUID leaves, so the bits that OpenVMM sets per VM +/// ([`vm_owned_bits`](crate::vm_owned_bits)), the derivation policy +/// ([`derive`](mod@crate::derive)), and the CPUID bits of the Hyper-V +/// processor features ([`hv_banks`](crate::hv_banks)) depend on the vendor. +/// Hygon's CPUs, which follow AMD's layout, have no profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum CpuVendor { + /// Intel, `GenuineIntel`. + Intel, + /// AMD, `AuthenticAMD`. + Amd, +} + +impl CpuVendor { + /// Every vendor that profiles serve. + pub const ALL: [Self; 2] = [Self::Intel, Self::Amd]; + + /// Returns the vendor whose 12-byte CPUID vendor string is `vendor`, if + /// profiles serve it. + pub fn from_cpuid_vendor(vendor: &[u8]) -> Option { + Self::ALL + .into_iter() + .find(|known| known.cpuid_vendor().as_bytes() == vendor) + } + + /// Returns the 12-character CPUID vendor string, such as `GenuineIntel`. + pub const fn cpuid_vendor(self) -> &'static str { + match self { + Self::Intel => "GenuineIntel", + Self::Amd => "AuthenticAMD", + } + } + + /// Returns the vendor as profile IDs spell it: `intel` or `amd`. + pub const fn id(self) -> &'static str { + match self { + Self::Intel => "intel", + Self::Amd => "amd", + } + } +} + +impl fmt::Display for CpuVendor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.cpuid_vendor()) + } +} + +#[cfg(test)] +mod tests { + use super::CpuVendor; + use test_with_tracing::test; + + #[test] + fn maps_the_cpuid_vendor_strings_that_profiles_serve() { + assert_eq!( + CpuVendor::from_cpuid_vendor(b"GenuineIntel"), + Some(CpuVendor::Intel) + ); + assert_eq!( + CpuVendor::from_cpuid_vendor(b"AuthenticAMD"), + Some(CpuVendor::Amd) + ); + for vendor in [&b"HygonGenuine"[..], b"CentaurHauls", b"GenuineInte", b""] { + assert_eq!(CpuVendor::from_cpuid_vendor(vendor), None); + } + assert_eq!( + CpuVendor::ALL.map(|vendor| (vendor.id(), vendor.to_string())), + [ + ("intel", "GenuineIntel".to_owned()), + ("amd", "AuthenticAMD".to_owned()) + ] + ); + } +} diff --git a/vmm_core/virt/src/x86/topology.rs b/vmm_core/virt/src/x86/topology.rs index 99bb77650c0..87686f0417c 100644 --- a/vmm_core/virt/src/x86/topology.rs +++ b/vmm_core/virt/src/x86/topology.rs @@ -69,7 +69,8 @@ pub fn per_vp_cpuid_bits(function: u32) -> [u32; 4] { /// Adds appropriately masked leaves for reporting processor topology. /// /// This includes some bits of leaves 01h and 04h, plus all of leaves 0Bh and -/// 1Fh +/// 1Fh, and on AMD processors the core count of leaf 80000008h, the +/// cache-sharing counts of leaf 8000001Dh, and leaf 8000001Eh. pub fn topology_cpuid<'a>( topology: &'a ProcessorTopology, cpuid: CpuidFn<'a>, @@ -126,6 +127,7 @@ pub fn topology_cpuid<'a>( if vendor.is_amd_compatible() { // Add AMD-specific topology leaves here. amd_extended_address_space_sizes_cpuid(topology, leaves); + amd_cache_topology_cpuid(topology, cpuid, leaves); amd_processor_topology_definition_cpuid(topology, leaves); } @@ -290,6 +292,52 @@ fn amd_extended_address_space_sizes_cpuid( ); } +/// Adds the cache-sharing counts of leaf 8000001Dh (Cache Topology +/// Information) for AMD processors, as [`cache_parameters_cpuid`] sets them +/// in leaf 04h for Intel processors: each VP has its own L1 and L2 caches, +/// shared only with its SMT sibling, and the socket shares the level 3 +/// cache. +/// +/// Each subleaf describes one cache, up to the first with a null cache type, +/// and only its `EAX[25:14]`, the number of logical processors sharing the +/// cache less one, changes. Linux derives each CPU's last-level cache from +/// the last cache's count, so without it a guest would see the host's L3 +/// grouping, such as one L3 per core complex. +fn amd_cache_topology_cpuid( + topology: &ProcessorTopology, + cpuid: CpuidFn<'_>, + leaves: &mut Vec, +) { + // AMD's EAX matches leaf 04h's below bit 26, where leaf 04h counts the + // cores per socket and AMD reserves the bits. + const MAX_SHARING_MINUS_ONE: u32 = 0xfff; + let threads_per_core = if topology.smt_enabled() { 2 } else { 1 }; + for i in 0..=255 { + let descriptor = + CacheParametersEax::from(cpuid(CpuidFunction::CacheTopologyDefinition.0, i)[0]); + if descriptor.cache_type() == 0 { + break; + } + let sharing = if descriptor.cache_level() == 3 { + topology.reserved_vps_per_socket() + } else { + threads_per_core + }; + let eax = CacheParametersEax::new() + .with_threads_sharing_cache_minus_one(min(MAX_SHARING_MINUS_ONE, sharing - 1)); + let eax_mask = + CacheParametersEax::new().with_threads_sharing_cache_minus_one(MAX_SHARING_MINUS_ONE); + leaves.push( + CpuidLeaf::new( + CpuidFunction::CacheTopologyDefinition.0, + [eax.into(), 0, 0, 0], + ) + .indexed(i) + .masked([eax_mask.into(), 0, 0, 0]), + ); + } +} + /// Adds leaf 8000001Eh (Processor Topology Definition) for AMD processors. fn amd_processor_topology_definition_cpuid( topology: &ProcessorTopology, @@ -417,6 +465,84 @@ mod tests { } } + /// An AMD CPU with maximum basic leaf 0xd and four caches in leaf + /// 0x8000001D, as an AMD EPYC 7763 reports them through WHP: L1 data, L1 + /// instruction, L2, and L3, with no cache-sharing counts. + fn amd_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + match (leaf, subleaf) { + (0, _) => [ + 0xd, + u32::from_le_bytes(*b"Auth"), + u32::from_le_bytes(*b"cAMD"), + u32::from_le_bytes(*b"enti"), + ], + (0x8000_001d, 0) => [0x121, 0x01c0_003f, 0x3f, 0], + (0x8000_001d, 1) => [0x122, 0x01c0_003f, 0x3f, 0], + (0x8000_001d, 2) => [0x143, 0x01c0_003f, 0x3ff, 2], + (0x8000_001d, 3) => [0x163, 0x03c0_003f, 0x7fff, 1], + _ => [0; 4], + } + } + + /// On AMD, the L1 and L2 caches belong to a core, and the L3 cache to + /// the socket, as leaf 4 reports on Intel: in leaf 0x8000001D, whose + /// subleaves each describe one cache. + #[test] + fn amd_l3_cache_is_shared_by_the_socket() { + for vps in [1, 2, 6, 8] { + for smt in [false, true] { + let topology = TopologyBuilder::new_x86() + .vps_per_socket(vps) + .smt_enabled(smt) + .x2apic(X2ApicState::Supported) + .build(vps) + .unwrap(); + let threads_per_core = if topology.smt_enabled() { 2 } else { 1 }; + let reserved = topology.reserved_vps_per_socket(); + let mut leaves = Vec::new(); + topology_cpuid(&topology, &amd_cpuid, &mut leaves).unwrap(); + let caches: Vec<_> = leaves + .iter() + .filter(|leaf| leaf.function == CpuidFunction::CacheTopologyDefinition.0) + .map(|leaf| leaf.index) + .collect(); + assert_eq!(caches, [Some(0), Some(1), Some(2), Some(3)]); + assert!( + !leaves + .iter() + .any(|leaf| leaf.function == CpuidFunction::CacheParameters.0) + ); + let set = CpuidLeafSet::new(leaves); + for subleaf in 0..4 { + let native = amd_cpuid(0x8000_001d, subleaf); + let result = set.result(0x8000_001d, subleaf, &native); + let context = format!("{vps} VPs, smt {smt}, subleaf {subleaf}"); + // Only the sharing count changes. + assert_eq!(result[1..], native[1..], "{context}"); + assert_eq!( + result[0] & !0x03ff_c000, + native[0] & !0x03ff_c000, + "{context}" + ); + let eax = CacheParametersEax::from(result[0]); + let sharing = if eax.cache_level() == 3 { + reserved + } else { + threads_per_core + }; + assert_eq!( + eax.threads_sharing_cache_minus_one() + 1, + sharing, + "{context}" + ); + } + // The null cache that ends the enumeration keeps its native + // value. + assert_eq!(set.result(0x8000_001d, 4, &[0; 4]), [0; 4]); + } + } + } + #[test] fn per_vp_bits_cover_the_bsp_identity() { let topology = TopologyBuilder::new_x86() diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index e71b45868bd..011e9920074 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -795,21 +795,20 @@ impl ProtoPartition for KvmProtoPartition<'_> { /// However, KVM also passes through AMD PSFD without including it in that /// check. PSFD is architecturally controlled via the SPEC_CTRL MSR, so a /// guest that sees PSFD and infers SPEC_CTRL MSR support (as Hyper-V -/// does) will #GP when writing the MSR. +/// does) will #GP when writing the MSR. AMD CPU profiles keep PSFD under +/// the same condition (`cpu_profile::derive::has_spec_ctrl_control`). /// /// Returns a leaf that strips PSFD when it should not be advertised. fn strip_psfd_leaf(cpuid: &CpuidLeafSet) -> Option { use x86defs::cpuid::ExtendedAddressSpaceSizesEbx; - use x86defs::cpuid::ExtendedFeatureSubleaf0Edx; let leaf7 = cpuid.result(CpuidFunction::ExtendedFeatures.0, 0, &[0; 4]); let leaf80000008 = cpuid.result(CpuidFunction::ExtendedAddressSpaceSizes.0, 0, &[0; 4]); - let edx = ExtendedFeatureSubleaf0Edx::from(leaf7[3]); let ebx = ExtendedAddressSpaceSizesEbx::from(leaf80000008[1]); // Mirror KVM's guest_has_spec_ctrl_msr() check. - let has_spec_ctrl_msr = edx.ibrs() || ebx.ibrs() || ebx.stibp() || ebx.ssbd(); + let has_spec_ctrl_msr = cpu_profile::derive::has_spec_ctrl_control(leaf7[3], leaf80000008[1]); if !has_spec_ctrl_msr && ebx.psfd() { let psfd_mask = ExtendedAddressSpaceSizesEbx::new().with_psfd(true); Some( From 905993c36f2c3127a621cfcdbfdb2800dcefcaad Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 21:13:25 -0700 Subject: [PATCH 287/304] cpu_profile: map the Hyper-V feature banks to AMD's CPUID bits MSHV and WHP derive a partition's processor feature banks from its CPU profile through HV_FEATURES, which mapped each bank bit to Intel's CPUID bit only (microsoft/nvx#396, row 5). AMD enumerates IBRS, IBPB, STIBP, SSBD, and PSFD in 0x80000008 EBX, and SVM in 0x80000001 ECX, so under an AMD profile profile_features read Intel's bits, which AMD profiles leave clear, and cleared those bank bits. The partition would have lost SPEC_CTRL and PRED_CMD while the profile's CPUID still advertised them, and a guest's unchecked SPEC_CTRL write would have raised #GP (G_UNCHECKED_MSR). The AMD-only bits that the banks define were unmapped, so they followed the host instead of the profile. An HvFeature now says where AMD CPUs enumerate its feature (AmdCpuid, read through cpuid_for): IBRS, IBPB, STIBP, and SSBD (mdd) in 0x80000008 EBX bits 14, 12, 15, and 24, PSFD in bit 28, and nested virtualization in SVM, 0x80000001 ECX[2]. The table gains the AMD-only bits: VIRT_SSBD (virt_spec_ctrl), the always-on STIBP preference, IBPB_RET (ibpb_rsb_flush), and BTC_NO in 0x80000008 EBX, and SBPB, IBPB_BRTYPE, SRSO_NO, SRSO_USER_KERNEL_NO, VERW_CLEAR (vrew_clear), and the TSA_L1_NO and TSA_SQ_NO immunities in 0x80000021. Intel profiles pin those bits clear, and the fleet's Intel hosts offer none of their bank bits, so the Intel profiles' banks are unchanged, as the tests of the fleet's hosts check. profile_features takes the bits of the profile's vendor, and restrict_cpuid_to_features, which builds the cheap supported surface from a host's CPUID, those of the vendor that the CPUID reports. The IA32_ARCH_CAPABILITIES bank bits keep Intel's meaning: AMD CPUs enumerate their immunities in CPUID, and SSB_NO's AMD bit, 0x80000008 EBX[26], stays unmapped. One feature controls no CPUID bit on AMD CPUs: intel_prefetch_support, Intel's PREFETCHW, whose bit, 0x80000001 ECX[8], AMD CPUs enumerate as 3DNowPrefetch. WHP presents that bit to AMD guests whatever the feature, which the EPYC 7763 host below does not even offer, so cpuid_for returns None for it on AMD (AmdCpuid::Uncontrolled): an AMD profile leaves the feature as the host offers it, and an AMD host's cheap surface keeps the bit. Mapping it cleared the feature under an AMD profile, which clears the bit, without hiding the bit, which only the profile's CPUID results do. The WHP and MSHV hardware tests that check the table, and the WHP and MSHV tests that check a host's profile against its features, read the CPUID bits of the vendor. On the Azure AMD EPYC 7763 host (Milan; family 0x19, model 1, stepping 1), WHP offers PSFD, BTC_NO, and nested virtualization but no speculation controls (bank 0 0x06020fcb67f79fbf, bank 1 0x200001ed), and features_control_the_mapped_cpuid_bits passes with the AMD bits: clearing psfd_support removes only 0x80000008 EBX[28], and btc_no_support only EBX[29]. Clearing nested_virt_support removes nothing there, because the probe partition presents no SVM. The bits that the host does not offer follow the AMD APM and the TLFS's names, unverified on hardware, and so does every MSHV mapping. Unit tests check that an AMD profile follows its own CPUID on that host's banks, keeping CLZERO and dropping SVM, RDPRU, APERF/MPERF, PSFD, and BTC_NO, which the derivation policy clears there; takes the speculation controls, PSFD from AMD's bit beside them, and the immunities from AMD's bits on a host that offers them, while BTC_NO goes; fails with E_PROFILE_UNSUPPORTED naming each one a host lacks; that a host's cheap surface loses AMD's bits, not Intel's, for the features it lacks; and that intel_prefetch_support controls 0x80000001 ECX[8] on Intel CPUs only. Under the CI-pinned Rust 1.95.0, cpu_profile's 108 unit tests and virt_whp's 35 pass, as do the 16 virt_whp hardware tests on the EPYC 7763; no pinned profile serves that host yet, so the profile tests skip. clippy --all-targets and rustdoc with warnings denied pass for cpu_profile and virt_whp, and for virt_kvm and virt_mshv on x86_64-unknown-linux-gnu, and xtask fmt makes no change. In a Linux container (rust:1.95.0), virt_kvm's 40 and virt_mshv's 76 unit tests pass. Part of microsoft/nvx#396. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01c35bf1-81da-4f98-8380-8a902ac0e5f9 --- vmm_core/cpu_profile/src/hv_banks.rs | 375 ++++++++++++++++-- .../virt_mshv/src/x86_64/profile_features.rs | 59 +-- vmm_core/virt_whp/src/profile_features.rs | 48 ++- 3 files changed, 410 insertions(+), 72 deletions(-) diff --git a/vmm_core/cpu_profile/src/hv_banks.rs b/vmm_core/cpu_profile/src/hv_banks.rs index 80c4762d625..1726c1eaa58 100644 --- a/vmm_core/cpu_profile/src/hv_banks.rs +++ b/vmm_core/cpu_profile/src/hv_banks.rs @@ -8,14 +8,18 @@ //! processor feature bank bit, or XSAVE feature bit, is set, and some of //! those bits also decide which MSRs the guest may use (for example //! `IA32_SPEC_CTRL`). [`HV_FEATURES`] maps each such bit to the CPUID feature -//! bit it controls. [`profile_features`] starts from the features the +//! bit it controls on the CPUs of the profile's vendor: Intel and AMD +//! enumerate the speculation controls, PSFD, and nested virtualization in +//! different leaves. [`profile_features`] starts from the features the //! hypervisor offers and clears every bit whose CPUID feature the profile //! clears. The partition then behaves as the profile's CPUID describes. //! //! Neither backend lets the VMM intercept `IA32_ARCH_CAPABILITIES`: the //! hypervisor answers it, deriving the immunity and capability bits from the //! banks ([`ARCH_CAPABILITIES_BANK_BITS`]). A profile therefore pins those -//! bits through the banks too ([`restrict_banks_to_profile`]). +//! bits through the banks too ([`restrict_banks_to_profile`]). AMD CPUs +//! enumerate their immunities in CPUID instead (`0x80000008` and +//! `0x80000021`), whose bank bits [`HV_FEATURES`] maps. //! //! The layout is the hypervisor's `HV_PARTITION_PROCESSOR_FEATURES` and //! `HV_PARTITION_PROCESSOR_XSAVE_FEATURES`, which MSHV uses directly and WHP's @@ -48,6 +52,7 @@ use crate::fingerprint::IA32_ARCH_CAPABILITIES; use crate::profile::CpuProfile; use crate::surface::SupportedMsr; use crate::surface::TIME_POLICY_BITS; +use crate::vendor::CpuVendor; use hvdef::HvX64PartitionProcessorFeatures as Bank0; use hvdef::HvX64PartitionProcessorFeatures1 as Bank1; use hvdef::HvX64PartitionProcessorXsaveFeatures as XsaveBank; @@ -64,6 +69,8 @@ const X1: u32 = 0x8000_0001; const X7: u32 = 0x8000_0007; /// The extended feature identifiers leaf. const X8: u32 = 0x8000_0008; +/// AMD's extended feature leaf 2. +const X21: u32 = 0x8000_0021; /// The `IA32_ARCH_CAPABILITIES` bits that Hyper-V derives from the processor /// feature banks, as (MSR bit, bank, bank bit). @@ -240,6 +247,22 @@ impl fmt::Display for CpuidBit { } } +/// Where AMD CPUs enumerate the CPUID feature bit of an [`HvFeature`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AmdCpuid { + /// Where Intel CPUs do. + Same, + /// Elsewhere: the speculation controls in `0x80000008` EBX, and SVM in + /// `0x80000001` ECX. + At(CpuidBit), + /// Nowhere that the feature controls. `intel_prefetch_support` is + /// Intel's `PREFETCHW`, whose bit, `0x80000001` ECX bit 8, AMD CPUs + /// enumerate as 3DNowPrefetch, and the hypervisor presents it to AMD + /// guests whatever the feature: a WHP host with an AMD EPYC 7763 that + /// does not offer the feature presents it. + Uncontrolled, +} + /// A processor feature bit and the CPUID feature bit it controls. #[derive(Debug)] pub struct HvFeature { @@ -248,8 +271,11 @@ pub struct HvFeature { pub word: HvFeatureWord, /// The bit, as a mask of [`Self::word`]. pub mask: u64, - /// The CPUID feature bit that a partition presents only with this bit. + /// The CPUID feature bit that a partition presents only with this bit + /// on Intel CPUs, and on AMD CPUs as [`Self::amd`] says. pub cpuid: CpuidBit, + /// Where AMD CPUs enumerate the feature. + pub amd: AmdCpuid, } impl HvFeature { @@ -259,11 +285,33 @@ impl HvFeature { self.setter.trim_start_matches("with_") } + /// Returns the CPUID feature bit that the feature controls on `vendor`'s + /// CPUs, or `None` if it controls none there. + pub fn cpuid_for(&self, vendor: CpuVendor) -> Option { + match (vendor, self.amd) { + (CpuVendor::Intel, _) | (CpuVendor::Amd, AmdCpuid::Same) => Some(self.cpuid), + (CpuVendor::Amd, AmdCpuid::At(bit)) => Some(bit), + (CpuVendor::Amd, AmdCpuid::Uncontrolled) => None, + } + } + /// Returns whether the CPUID bit is one of the time policy bits, which /// the time ABI's CPUID supplies whatever the features. pub fn is_time_policy(&self) -> bool { - let bit = self.cpuid; - TIME_POLICY_BITS.contains(&(bit.leaf, bit.subleaf, bit.register, 1 << bit.bit)) + [CpuVendor::Intel, CpuVendor::Amd] + .into_iter() + .filter_map(|vendor| self.cpuid_for(vendor)) + .any(|bit| { + TIME_POLICY_BITS.contains(&(bit.leaf, bit.subleaf, bit.register, 1 << bit.bit)) + }) + } + + /// Returns how `profile` pins the CPUID bit that the feature controls on + /// the CPUs of the profile's vendor: [`Pinned::Unpinned`] where it + /// controls none. + pub fn pinned_by(&self, profile: &CpuProfile) -> Pinned { + self.cpuid_for(profile.cpu_vendor()) + .map_or(Pinned::Unpinned, |bit| pinned(profile, bit)) } } @@ -274,35 +322,84 @@ macro_rules! feature { word: HvFeatureWord::$word, mask: $ty::new().$with(true).into_bits(), cpuid: CpuidBit::new($leaf, $subleaf, $register, $bit), + amd: AmdCpuid::Same, + } + }; + ( + $word:ident, + $ty:ident, + $with:ident, + $leaf:expr, + $subleaf:expr, + $register:expr, + $bit:expr; + amd uncontrolled + ) => { + HvFeature { + setter: stringify!($with), + word: HvFeatureWord::$word, + mask: $ty::new().$with(true).into_bits(), + cpuid: CpuidBit::new($leaf, $subleaf, $register, $bit), + amd: AmdCpuid::Uncontrolled, + } + }; + ( + $word:ident, + $ty:ident, + $with:ident, + $leaf:expr, + $subleaf:expr, + $register:expr, + $bit:expr; + amd $amd_leaf:expr, + $amd_subleaf:expr, + $amd_register:expr, + $amd_bit:expr + ) => { + HvFeature { + setter: stringify!($with), + word: HvFeatureWord::$word, + mask: $ty::new().$with(true).into_bits(), + cpuid: CpuidBit::new($leaf, $subleaf, $register, $bit), + amd: AmdCpuid::At(CpuidBit::new( + $amd_leaf, + $amd_subleaf, + $amd_register, + $amd_bit, + )), } }; } macro_rules! bank0 { - ($with:ident, $($cpuid:expr),+) => { - feature!(Bank0, Bank0, $with, $($cpuid),+) + ($with:ident, $($cpuid:tt)+) => { + feature!(Bank0, Bank0, $with, $($cpuid)+) }; } macro_rules! bank1 { - ($with:ident, $($cpuid:expr),+) => { - feature!(Bank1, Bank1, $with, $($cpuid),+) + ($with:ident, $($cpuid:tt)+) => { + feature!(Bank1, Bank1, $with, $($cpuid)+) }; } macro_rules! xsave { - ($with:ident, $($cpuid:expr),+) => { - feature!(Xsave, XsaveBank, $with, $($cpuid),+) + ($with:ident, $($cpuid:tt)+) => { + feature!(Xsave, XsaveBank, $with, $($cpuid)+) }; } /// The processor feature bits with a CPUID feature, as leaf, subleaf, -/// register, and bit. The leaves follow the Intel SDM and the AMD APM. The -/// bits that the banks derive into `IA32_ARCH_CAPABILITIES` are -/// [`ARCH_CAPABILITIES_BANK_BITS`]. +/// register, and bit. The leaves follow the Intel SDM and the AMD APM: where +/// the vendors enumerate a feature in different places, `amd` gives AMD's +/// (the speculation controls, PSFD, and SVM), and the features of AMD CPUs +/// alone (VIRT_SSBD, the always-on STIBP hint, `IBPB_RET`, `BTC_NO`, and the +/// SRSO and TSA bits of `0x80000021`) map to AMD's bits, which Intel +/// profiles pin clear. The bits that the banks derive into +/// `IA32_ARCH_CAPABILITIES` are [`ARCH_CAPABILITIES_BANK_BITS`]. /// /// WHP's hardware test `features_control_the_mapped_cpuid_bits` checks the -/// table against what WHP presents on a host. +/// table against what WHP presents on a host, of either vendor. pub const HV_FEATURES: &[HvFeature] = &[ // Bank 0. bank0!(with_sse3_support, 1, 0, ECX, 0), @@ -336,7 +433,7 @@ pub const HV_FEATURES: &[HvFeature] = &[ bank0!(with_dep_x87_fpu_save_support, 7, 0, EBX, 13), bank0!(with_rd_seed_support, 7, 0, EBX, 18), bank0!(with_adx_support, 7, 0, EBX, 19), - bank0!(with_intel_prefetch_support, X1, 0, ECX, 8), + bank0!(with_intel_prefetch_support, X1, 0, ECX, 8; amd uncontrolled), bank0!(with_smap_support, 7, 0, EBX, 20), bank0!(with_hle_support, 7, 0, EBX, 4), bank0!(with_rtm_support, 7, 0, EBX, 11), @@ -346,13 +443,16 @@ pub const HV_FEATURES: &[HvFeature] = &[ bank0!(with_sha_support, 7, 0, EBX, 29), bank0!(with_x87_pointers_saved_support, X8, 0, EBX, 2), bank0!(with_invpcid_support, 7, 0, EBX, 10), - bank0!(with_ibrs_support, 7, 0, EDX, 26), - bank0!(with_stibp_support, 7, 0, EDX, 27), - // CPUID enumerates IBPB with IBRS; WHP derives the bit from IBRS. - bank0!(with_ibpb_support, 7, 0, EDX, 26), - bank0!(with_mdd_support, 7, 0, EDX, 31), + bank0!(with_ibrs_support, 7, 0, EDX, 26; amd X8, 0, EBX, 14), + bank0!(with_stibp_support, 7, 0, EDX, 27; amd X8, 0, EBX, 15), + // CPUID enumerates IBPB with IBRS on Intel; WHP derives the bit from + // IBRS. + bank0!(with_ibpb_support, 7, 0, EDX, 26; amd X8, 0, EBX, 12), + bank0!(with_mdd_support, 7, 0, EDX, 31; amd X8, 0, EBX, 24), bank0!(with_fast_short_rep_mov_support, 7, 0, EDX, 4), bank0!(with_l1d_cache_flush_support, 7, 0, EDX, 28), + // AMD's VIRT_SSBD: SSBD through VIRT_SPEC_CTRL. + bank0!(with_virt_spec_ctrl_support, X8, 0, EBX, 25), bank0!(with_rd_pid_support, 7, 0, ECX, 22), bank0!(with_umip_support, 7, 0, ECX, 2), bank0!(with_mb_clear_support, 7, 0, EDX, 10), @@ -363,9 +463,9 @@ pub const HV_FEATURES: &[HvFeature] = &[ bank1!(with_rdpru_support, X8, 0, EBX, 4), // Also sets the linear address width, 0x80000008 EAX[15:8]. bank1!(with_la57_support, 7, 0, ECX, 16), - // MSHV does not expose VMX through this bit alone. - bank1!(with_nested_virt_support, 1, 0, ECX, 5), - bank1!(with_psfd_support, 7, 2, EDX, 0), + // MSHV does not expose VMX through this bit alone, nor WHP SVM. + bank1!(with_nested_virt_support, 1, 0, ECX, 5; amd X1, 0, ECX, 2), + bank1!(with_psfd_support, 7, 2, EDX, 0; amd X8, 0, EBX, 28), bank1!(with_cet_ss_support, 7, 0, ECX, 7), bank1!(with_cet_ibt_support, 7, 0, EDX, 20), bank1!(with_enqcmd_support, 7, 0, ECX, 29), @@ -380,12 +480,26 @@ pub const HV_FEATURES: &[HvFeature] = &[ bank1!(with_fs_rep_stosb, 7, 1, EAX, 11), bank1!(with_fs_rep_cmpsb, 7, 1, EAX, 12), bank1!(with_tsx_ld_trk_support, 7, 0, EDX, 16), + // AMD's immunity to branch type confusion, IBPB_RET, and always-on STIBP + // preference. + bank1!(with_btc_no_support, X8, 0, EBX, 29), + bank1!(with_ibpb_rsb_flush_support, X8, 0, EBX, 30), + bank1!(with_stibp_always_on_support, X8, 0, EBX, 17), bank1!(with_cmpccxadd_support, 7, 1, EAX, 7), bank1!(with_bhi_dis_support, 7, 2, EDX, 4), bank1!(with_prefetch_i_support, 7, 1, EDX, 14), bank1!(with_sha512_support, 7, 1, EAX, 0), bank1!(with_sm3_support, 7, 1, EAX, 1), bank1!(with_sm4_support, 7, 1, EAX, 2), + // AMD's SBPB, IBPB_BRTYPE, SRSO_NO, SRSO_USER_KERNEL_NO, VERW_CLEAR, and + // TSA immunities. + bank1!(with_sbpb_support, X21, 0, EAX, 27), + bank1!(with_ibpb_br_type_support, X21, 0, EAX, 28), + bank1!(with_srso_no_support, X21, 0, EAX, 29), + bank1!(with_srso_user_kernel_no_support, X21, 0, EAX, 30), + bank1!(with_vrew_clear_support, X21, 0, EAX, 5), + bank1!(with_tsa_l1_no_support, X21, 0, ECX, 2), + bank1!(with_tsa_sq_no_support, X21, 0, ECX, 1), bank1!(with_lass_support, 7, 1, EAX, 6), // XSAVE features. xsave!(with_xsave_support, 1, 0, ECX, 26), @@ -502,7 +616,7 @@ pub fn profile_features( let mut missing = Vec::new(); for feature in HV_FEATURES { let word = features.word_mut(feature.word); - match pinned(profile, feature.cpuid) { + match feature.pinned_by(profile) { Pinned::Clear => *word &= !feature.mask, Pinned::Set if *word & feature.mask == 0 && !feature.is_time_policy() => { missing.push(feature.name().to_owned()); @@ -536,11 +650,14 @@ pub fn profile_features( /// Clears from `cpuid` every CPUID bit that [`HV_FEATURES`] maps to a feature /// `available` lacks, so a host's CPUID and its features describe what a -/// partition can present, without a probe partition. +/// partition can present, without a probe partition. The bits are those of +/// the vendor that `cpuid` reports ([`HvFeature::cpuid_for`]), Intel's for a +/// vendor that profiles do not serve; a feature that controls no bit on that +/// vendor's CPUs clears none. /// /// A CPUID bit that several features control (IBRS and IBPB both control -/// `SPEC_CTRL`) is cleared when any of them is missing. Bits the table does -/// not map are left alone. +/// `SPEC_CTRL` on Intel) is cleared when any of them is missing. Bits the +/// table does not map are left alone. /// /// The function only clears, so the host's own view bounds the result. /// MSHV's root CPUID hides `TSC_ADJUST` (`7.0:EBX[1]`), which a partition with @@ -558,11 +675,18 @@ pub fn profile_features( /// The surface's guest physical address width must come from the hypervisor, /// not from this CPUID (see [`HostCpuSurface`](crate::HostCpuSurface)). pub fn restrict_cpuid_to_features(cpuid: &mut [CpuidEntry], available: HvFeatures) { + let vendor = crate::cpuid::lookup(cpuid, 0, 0) + .and_then(|[_, ebx, ecx, edx]| { + CpuVendor::from_cpuid_vendor(&crate::signature::vendor_bytes(ebx, edx, ecx)) + }) + .unwrap_or(CpuVendor::Intel); for feature in HV_FEATURES { if available.word(feature.word) & feature.mask != 0 { continue; } - let bit = feature.cpuid; + let Some(bit) = feature.cpuid_for(vendor) else { + continue; + }; for entry in cpuid.iter_mut().filter(|entry| { entry.leaf.0 == bit.leaf && entry.subleaf.map_or(0, |subleaf| subleaf.0) == bit.subleaf }) { @@ -785,11 +909,12 @@ mod tests { word.name() ); } - // Every mapped bit follows the profile's CPUID. + // Every mapped bit follows the profile's CPUID, on its vendor's + // bits. for feature in HV_FEATURES { let offered = available.word(feature.word) & feature.mask != 0; let kept = features.word(feature.word) & feature.mask != 0; - match pinned(profile, feature.cpuid) { + match feature.pinned_by(profile) { Pinned::Clear => assert!(!kept, "{host}: {}", feature.name()), Pinned::Set | Pinned::Unpinned => { assert_eq!(kept, offered, "{host}: {}", feature.name()) @@ -967,4 +1092,192 @@ mod tests { } } } + + /// The processor features that WHP offered on the AMD EPYC 7763 (Milan) + /// Azure host that `test_support::MILAN_WHP_CPUID` comes from: no + /// speculation controls, but PSFD, `BTC_NO`, and nested virtualization. + const MILAN_WHP_HOST: HvFeatures = HvFeatures { + banks: [0x0602_0fcb_67f7_9fbf, 0x0000_0000_2000_01ed], + xsave: 0x50_381f, + }; + + /// The CPUID of an AMD host whose banks offer the speculation controls, + /// the immunities, and nested virtualization, as KVM's can on bare + /// metal, with SVM masked by the profile's policy. + fn amd_entries_with_speculation_controls() -> Vec { + let mut entries = crate::test_support::milan_whp_entries(); + for entry in &mut entries { + let mut registers = entry.registers(); + match entry.key() { + // SVM. + (X1, None) => registers[ECX] |= 1 << 2, + // IBPB, IBRS, STIBP, SSBD, VIRT_SSBD, PSFD, and BTC_NO. + (X8, None) => { + registers[EBX] |= + 1 << 12 | 1 << 14 | 1 << 15 | 1 << 24 | 1 << 25 | 1 << 28 | 1 << 29 + } + // SBPB, IBPB_BRTYPE, SRSO_NO, and the TSA immunities. + (X21, None) => { + registers[EAX] |= 1 << 27 | 1 << 28 | 1 << 29; + registers[ECX] |= 1 << 1 | 1 << 2; + } + _ => continue, + } + *entry = CpuidEntry::new(entry.leaf.0, entry.subleaf.map(|s| s.0), registers); + } + entries + } + + /// Returns the host profile of an AMD host whose CPUID is `entries`. + fn amd_profile(entries: Vec) -> CpuProfile { + crate::derive_host_profile(&crate::test_support::host_fingerprint("whp", entries)).unwrap() + } + + #[test] + fn an_amd_profile_derives_on_its_host() { + let profile = amd_profile(crate::test_support::milan_whp_entries()); + let features = profile_features(&profile, MILAN_WHP_HOST).unwrap(); + // CLZERO, which the profile sets in 0x80000008 EBX, stays. + assert!(has(&features, HvFeatureWord::Bank1, "cl_zero_support")); + // SVM, RDPRU, and APERF/MPERF, which the profile clears, go, and so + // do PSFD, which this host offers without a SPEC_CTRL control, and + // BTC_NO: the policy clears both. + for name in [ + "nested_virt_support", + "rdpru_support", + "a_count_m_count_support", + "psfd_support", + "btc_no_support", + ] { + assert!(!has(&features, HvFeatureWord::Bank1, name), "{name}"); + } + for word in HvFeatureWord::ALL { + assert_eq!(features.word(word) & !MILAN_WHP_HOST.word(word), 0); + } + } + + #[test] + fn an_amd_profile_takes_the_speculation_controls_from_amd_bits() { + let profile = amd_profile(amd_entries_with_speculation_controls()); + let bank0 = [ + "ibrs_support", + "stibp_support", + "ibpb_support", + "mdd_support", + "virt_spec_ctrl_support", + ]; + // PSFD stays beside the SPEC_CTRL controls, from AMD's bit: Intel's, + // 7.2 EDX[0], is not the profile's. + let bank1 = [ + "psfd_support", + "sbpb_support", + "ibpb_br_type_support", + "srso_no_support", + "tsa_l1_no_support", + "tsa_sq_no_support", + ]; + let mut available = MILAN_WHP_HOST; + for name in bank0 { + available.banks[0] |= mask(HvFeatureWord::Bank0, name); + } + for name in bank1 { + available.banks[1] |= mask(HvFeatureWord::Bank1, name); + } + // Offered features the profile does not set go, BTC_NO, which the + // policy clears, included. + available.banks[1] |= mask(HvFeatureWord::Bank1, "srso_user_kernel_no_support") + | mask(HvFeatureWord::Bank1, "stibp_always_on_support") + | mask(HvFeatureWord::Bank1, "btc_no_support"); + let features = profile_features(&profile, available).unwrap(); + for name in bank0 { + assert!(has(&features, HvFeatureWord::Bank0, name), "{name}"); + } + for name in bank1 { + assert!(has(&features, HvFeatureWord::Bank1, name), "{name}"); + } + for name in [ + "srso_user_kernel_no_support", + "stibp_always_on_support", + "btc_no_support", + "nested_virt_support", + ] { + assert!(!has(&features, HvFeatureWord::Bank1, name), "{name}"); + } + + // A host that lacks one fails the profile. + let mut lacking = available; + lacking.banks[0] &= !mask(HvFeatureWord::Bank0, "ibrs_support"); + lacking.banks[1] &= !mask(HvFeatureWord::Bank1, "srso_no_support"); + let error = profile_features(&profile, lacking).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); + assert!( + error + .message + .ends_with("they lack ibrs_support, srso_no_support"), + "{error}" + ); + } + + #[test] + fn restricts_an_amd_hosts_cpuid_to_amd_bits() { + let original = amd_entries_with_speculation_controls(); + let mut cpuid = original.clone(); + let mut available = MILAN_WHP_HOST; + available.banks[0] |= mask(HvFeatureWord::Bank0, "stibp_support"); + restrict_cpuid_to_features(&mut cpuid, available); + let x8 = |cpuid: &[CpuidEntry]| crate::cpuid::lookup(cpuid, X8, 0).unwrap()[EBX]; + // IBPB, IBRS, SSBD, and VIRT_SSBD go, STIBP stays, and PSFD and + // BTC_NO, which the host offers, stay. + assert_eq!( + x8(&cpuid), + x8(&original) & !(1 << 12 | 1 << 14 | 1 << 24 | 1 << 25) + ); + // Intel's speculation bits are not AMD's. + assert_eq!( + crate::cpuid::lookup(&cpuid, 7, 0), + crate::cpuid::lookup(&original, 7, 0) + ); + } + + /// Intel's `PREFETCHW` feature controls `0x80000001` ECX bit 8 on Intel + /// CPUs only. WHP presents that bit, 3DNowPrefetch, to AMD guests + /// whatever the feature, which the Milan host does not even offer: an + /// AMD host's CPUID keeps it, and an AMD profile, which clears it, leaves + /// the feature as offered. + #[test] + fn intel_prefetch_controls_no_amd_bit() { + let feature = HV_FEATURES + .iter() + .find(|feature| feature.name() == "intel_prefetch_support") + .unwrap(); + assert_eq!( + feature.cpuid_for(CpuVendor::Intel), + Some(CpuidBit::new(X1, 0, ECX, 8)) + ); + assert_eq!(feature.cpuid_for(CpuVendor::Amd), None); + + let mut cpuid = crate::test_support::milan_whp_entries(); + assert!(!has( + &MILAN_WHP_HOST, + HvFeatureWord::Bank0, + "intel_prefetch_support" + )); + restrict_cpuid_to_features(&mut cpuid, MILAN_WHP_HOST); + assert_ne!( + crate::cpuid::lookup(&cpuid, X1, 0).unwrap()[ECX] & 1 << 8, + 0 + ); + + let profile = amd_profile(crate::test_support::milan_whp_entries()); + assert_eq!(profile.lookup(X1, 0)[ECX] & 1 << 8, 0); + assert!(matches!(feature.pinned_by(&profile), Pinned::Unpinned)); + let mut available = MILAN_WHP_HOST; + available.banks[0] |= mask(HvFeatureWord::Bank0, "intel_prefetch_support"); + let features = profile_features(&profile, available).unwrap(); + assert!(has( + &features, + HvFeatureWord::Bank0, + "intel_prefetch_support" + )); + } } diff --git a/vmm_core/virt_mshv/src/x86_64/profile_features.rs b/vmm_core/virt_mshv/src/x86_64/profile_features.rs index 61f5872258f..d934c2db65a 100644 --- a/vmm_core/virt_mshv/src/x86_64/profile_features.rs +++ b/vmm_core/virt_mshv/src/x86_64/profile_features.rs @@ -237,14 +237,14 @@ mod tests { word.name() ); } - // Every mapped bit follows the profile. + // Every mapped bit follows the profile, on its vendor's bits. for feature in HV_FEATURES .iter() .filter(|feature| !feature.is_time_policy()) { let offered = has(&host, feature.word, feature.mask); let kept = has(&features, feature.word, feature.mask); - match hv_banks::pinned(profile, feature.cpuid) { + match feature.pinned_by(profile) { Pinned::Clear => assert!(!kept, "{id}: {}", feature.name()), Pinned::Set => assert!(kept, "{id}: {}", feature.name()), Pinned::Unpinned => assert_eq!(kept, offered, "{id}: {}", feature.name()), @@ -466,11 +466,18 @@ mod hw { Ok(Probe { vmfd, vp }) } - /// The leaves and subleaves of the mapped features. - fn feature_leaves() -> Vec<(u32, u32)> { + /// The vendor of this host's CPU, whose CPUID bits the features control. + fn host_vendor() -> cpu_profile::CpuVendor { + cpu_profile::CpuVendor::from_cpuid_vendor(&HostCpuSignature::current().vendor()) + .unwrap_or(cpu_profile::CpuVendor::Intel) + } + + /// The leaves and subleaves of the mapped features on `vendor`'s CPUs. + fn feature_leaves(vendor: cpu_profile::CpuVendor) -> Vec<(u32, u32)> { let mut leaves: Vec<_> = HV_FEATURES .iter() - .map(|feature| (feature.cpuid.leaf, feature.cpuid.subleaf)) + .filter_map(|feature| feature.cpuid_for(vendor)) + .map(|cpuid| (cpuid.leaf, cpuid.subleaf)) .collect(); leaves.sort_unstable(); leaves.dedup(); @@ -508,28 +515,31 @@ mod hw { .join(" ") } - /// Whether another feature bit controls the same CPUID bit, so that - /// clearing one of them alone may leave it set. - fn shared(feature: &HvFeature) -> bool { - HV_FEATURES - .iter() - .filter(|other| other.cpuid == feature.cpuid) - .count() - > 1 + /// Whether another feature bit controls the same CPUID bit on `vendor`'s + /// CPUs, so that clearing one of them alone may leave it set. + fn shared(feature: &HvFeature, vendor: cpu_profile::CpuVendor) -> bool { + feature.cpuid_for(vendor).is_some_and(|bit| { + HV_FEATURES + .iter() + .filter(|other| other.cpuid_for(vendor) == Some(bit)) + .count() + > 1 + }) } /// Clears each feature bit the host offers, one at a time, and checks that - /// every mapped one removes its CPUID bit. + /// every mapped one removes its CPUID bit on this host's vendor. #[test] #[ignore = "requires /dev/mshv"] fn features_control_the_mapped_cpuid_bits() { let mshv = Mshv::new().unwrap(); let host = host_features(&mshv).unwrap(); + let vendor = host_vendor(); println!( - "host: bank0 {:#x} bank1 {:#x} xsave {:#x}", + "{vendor}: host: bank0 {:#x} bank1 {:#x} xsave {:#x}", host.banks[0], host.banks[1], host.xsave ); - let leaves = feature_leaves(); + let leaves = feature_leaves(vendor); let base = feature_cpuid(&probe_partition(&mshv, host).unwrap(), &leaves); let mut failures = Vec::new(); for word in HvFeatureWord::ALL { @@ -550,19 +560,20 @@ mod hw { }; let removed = removed(&leaves, &base, &probe); let mut verdict = ""; - if let Some(feature) = feature { + if let Some((feature, cpuid)) = + feature.and_then(|feature| Some((feature, feature.cpuid_for(vendor)?))) + { let index = leaves .iter() - .position(|&key| key == (feature.cpuid.leaf, feature.cpuid.subleaf)) + .position(|&key| key == (cpuid.leaf, cpuid.subleaf)) .unwrap(); - let offered = - base[index][feature.cpuid.register] & (1 << feature.cpuid.bit) != 0; - if offered && !shared(feature) && !removed.contains(&feature.cpuid) { + let offered = base[index][cpuid.register] & (1 << cpuid.bit) != 0; + if offered && !shared(feature, vendor) && !removed.contains(&cpuid) { verdict = " MISMATCH"; failures.push(format!( "{} bit {bit} {name}: expected {} removed {}", word.name(), - feature.cpuid, + cpuid, list(&removed) )); } @@ -810,7 +821,9 @@ mod hw { let differs = (value ^ entry.values()[register]) & mask; for bit in (0..32).filter(|bit| differs & (1 << bit) != 0) { let at = CpuidBit::new(leaf, subleaf.unwrap_or(0), register, bit); - let feature = HV_FEATURES.iter().find(|feature| feature.cpuid == at); + let feature = HV_FEATURES + .iter() + .find(|feature| feature.cpuid_for(profile.cpu_vendor()) == Some(at)); match feature { Some(feature) if !feature.is_time_policy() => failures.push(format!( "{at} ({}): presented {}, pinned {}", diff --git a/vmm_core/virt_whp/src/profile_features.rs b/vmm_core/virt_whp/src/profile_features.rs index b71858745b7..672f91bd21d 100644 --- a/vmm_core/virt_whp/src/profile_features.rs +++ b/vmm_core/virt_whp/src/profile_features.rs @@ -253,6 +253,7 @@ mod whp_tests { //! `cargo test -p virt_whp -- --ignored`. use super::*; + use cpu_profile::CpuVendor; use cpu_profile::HostCpuSignature; use cpu_profile::hv_banks::CpuidBit; use cpu_profile::hv_banks::HV_FEATURES; @@ -364,12 +365,20 @@ mod whp_tests { bits } - /// Whether another feature maps the same CPUID bit (as IBRS and IBPB - /// do): WHP may derive the bit from either. - fn shared(feature: &HvFeature) -> bool { - HV_FEATURES - .iter() - .any(|other| !std::ptr::eq(other, feature) && other.cpuid == feature.cpuid) + /// The vendor of this host's CPU, whose CPUID bits the features control. + fn host_vendor() -> CpuVendor { + CpuVendor::from_cpuid_vendor(&HostCpuSignature::current().vendor()) + .unwrap_or(CpuVendor::Intel) + } + + /// Whether another feature maps the same CPUID bit on `vendor`'s CPUs + /// (as IBRS and IBPB do on Intel's): WHP may derive the bit from either. + fn shared(feature: &HvFeature, vendor: CpuVendor) -> bool { + feature.cpuid_for(vendor).is_some_and(|bit| { + HV_FEATURES + .iter() + .any(|other| !std::ptr::eq(other, feature) && other.cpuid_for(vendor) == Some(bit)) + }) } fn list(bits: &[CpuidBit]) -> String { @@ -383,15 +392,16 @@ mod whp_tests { } /// Clears each feature bit WHP offers, one at a time, and checks that the - /// CPUID bit the table maps to it disappears. Prints what every bit - /// controls on this host. + /// CPUID bit the table maps to it on this host's vendor disappears. + /// Prints what every bit controls on this host. #[test] #[ignore = "requires WHP"] fn features_control_the_mapped_cpuid_bits() { + let vendor = host_vendor(); let available = available(); let base = feature_cpuid(&probe_partition(available).unwrap()); println!( - "available: bank0 {:#x} bank1 {:#x} xsave {:#x}", + "{vendor}: available: bank0 {:#x} bank1 {:#x} xsave {:#x}", available.banks[0], available.banks[1], available.xsave ); let mut failures = Vec::new(); @@ -414,19 +424,20 @@ mod whp_tests { }; let removed = removed(&base, &probe); let mut verdict = ""; - if let Some(feature) = feature { + if let Some((feature, cpuid)) = + feature.and_then(|feature| Some((feature, feature.cpuid_for(vendor)?))) + { let index = FEATURE_LEAVES .iter() - .position(|&key| key == (feature.cpuid.leaf, feature.cpuid.subleaf)) + .position(|&key| key == (cpuid.leaf, cpuid.subleaf)) .unwrap(); - let offered = - base[index][feature.cpuid.register] & (1 << feature.cpuid.bit) != 0; - if offered && !shared(feature) && !removed.contains(&feature.cpuid) { + let offered = base[index][cpuid.register] & (1 << cpuid.bit) != 0; + if offered && !shared(feature, vendor) && !removed.contains(&cpuid) { verdict = " MISMATCH"; failures.push(format!( "{} bit {bit} {name}: expected {} removed {}", bank.name(), - feature.cpuid, + cpuid, list(&removed) )); } @@ -485,9 +496,10 @@ mod whp_tests { let mapped: Vec<_> = bits .iter() .filter(|&&bit| { - HV_FEATURES - .iter() - .any(|feature| feature.cpuid == bit && !feature.is_time_policy()) + HV_FEATURES.iter().any(|feature| { + feature.cpuid_for(profile.cpu_vendor()) == Some(bit) + && !feature.is_time_policy() + }) }) .copied() .collect(); From 933c1de0cb5e622b2792d9bf8986d5bfbc812eb8 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 21:42:51 -0700 Subject: [PATCH 288/304] virt: serve AMD's HWCR and DE_CFG and per-VP 0x8000001E under AMD profiles A Linux guest of an AMD CPU profile reads two AMD configuration MSRs that no backend presented as the time ABI needs them: - HWCR (0xc0010015): bsp_init_amd reads it with an unchecked rdmsr on every AMD CPU whose CPUID has the invariant TSC, which the time ABI's CPUID always sets. A fault fails the guest's G_UNCHECKED_MSR check, and a clear TscFreqSel (bit 24) makes Linux log "[Firmware Bug]: TSC doesn't count with P0 frequency!". WHP's legacy stub reads it as 0, and so does KVM. - DE_CFG (0xc0011029): init_amd sets its LFENCE serialization bit on every CPU whose CPUID does not enumerate it, with safe accessors. WHP does not implement it, so the read raises #GP and Linux silently loses the serializing LFENCE that its RDTSC ordering relies on. virt::time_abi::msr now defines the contract, AMD_CONFIG_MSRS: under an AMD CPU profile, HWCR reads as TscFreqSel only and DE_CFG as LFENCE serialization only, on every VP and backend; a write of the value that a read returns succeeds and changes nothing, and any other write raises #GP. Linux writes neither on a profile's CPU: it sets IRPERF_EN only when CPUID enumerates the instructions-retired counter and CPUID faulting only on request, and the derivation policy clears both. Each backend routes the two MSRs to OpenVMM under an AMD profile only, so Intel partitions keep their handling exactly: - WHP: UnhandledMsrs exits already deliver them; serve_msr answers them ahead of the legacy stubs. - KVM: the MSR filter of an AMD profile also denies them, so the accesses exit to OpenVMM (9 filter ranges, within KVM's 16). - MSHV: an AMD profile also installs MSR intercepts for them. The Guide's Time and CPU compatibility section states the contract. MSHV also presents AMD's processor topology leaf 0x8000001E per VP. Like the extended topology leaves, the hypervisor does not provide its per-VP fields for these partitions, so create_vp registers each VP's own result: the extended APIC ID, the compute unit ID within the socket, and the socket's node ID, computed as virt::x86::topology computes the BSP's and as the WHP and KVM backends already present them. This completes row 6 of the issue, and with the AMD cache sharing of 0x8000001D and the core count of 0x80000008 from the first commit, the guest's AMD topology is consistent on every backend, so profiles keep TOPOEXT instead of hiding it. Tests cover the contract's values and writes, the KVM filter ranges of both vendors, the MSR serving of each backend under both vendors (Intel partitions leave HWCR and DE_CFG to their existing handling), and the MSHV per-VP 0x8000001E values with and without SMT across sockets. Validation, with Rust 1.95.0: - cargo test -p virt -p virt_whp: 65 and 36 passed. - cargo test -p virt_whp -- --ignored --test-threads=1 on an AMD EPYC 7763 (Milan) Azure VM with nested WHP: all 16 hardware tests passed. - cargo test -p virt_kvm -p virt_mshv in a Linux container (rust:1.95.0): 41 and 77 passed; their hardware tests need /dev/kvm and /dev/mshv. - cargo clippy --all-targets -D warnings for virt, virt_whp, and openvmm_core, and for virt_kvm and virt_mshv with --target x86_64-unknown-linux-gnu; rustdoc with -D warnings for all four; cargo xtask fmt --fix. - On the same WHP host, a release build booted the NVX Alpine guest (Linux 6.18.38) on 4 VPs with --cpu-profile host (amd.host.v1): the BSP read HWCR once (0x1000000) and every VP read DE_CFG once (0x2), with no write; dmesg had no Firmware Bug or unchecked MSR access line; nvx-time reported phase=boot status=ok; each CPU's APIC ID matched its initial APIC ID; and the L3 cache was shared across all 4 CPUs. The KVM and MSHV paths are hardware-unverified: no AMD KVM or MSHV host was available. Part of microsoft/nvx#396. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01c35bf1-81da-4f98-8380-8a902ac0e5f9 --- Guide/src/user_guide/openvmm/snapshots.md | 6 +- vmm_core/virt/src/time_abi/msr.rs | 80 +++++- vmm_core/virt_kvm/src/arch/x86_64/mod.rs | 5 +- vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs | 117 +++++++-- vmm_core/virt_mshv/src/x86_64/mod.rs | 15 +- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 230 +++++++++++++++--- vmm_core/virt_whp/src/time_abi.rs | 80 +++++- 7 files changed, 455 insertions(+), 78 deletions(-) diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index aa1a75b8704..cd89bd96e46 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -254,7 +254,11 @@ identity MSRs that return the declared TSC rate (`0x40000022`) and LAPIC rate command line must not set `tsc_early_khz=` or `lapic_timer_hz=` (`E_CMDLINE_CLOCK_TOKEN`). The TSC is invariant, and the LAPIC timer runs in one-shot mode at a fixed rate: 1 GHz on KVM and 200 MHz on MSHV and WHP. -TSC-deadline mode, `IA32_TSC_ADJUST`, and kvmclock are not exposed. +TSC-deadline mode, `IA32_TSC_ADJUST`, and kvmclock are not exposed. Under an +AMD CPU profile, AMD's configuration MSRs read fixed values on every backend: +HWCR (`0xc0010015`) only `TscFreqSel`, so Linux takes the TSC to count at the +P0 frequency, and DE_CFG (`0xc0011029`) only `LFENCE` serialization. Writing +the value read back changes nothing; any other write raises #GP. The guest CPU is a pinned CPU profile: one per CPU generation, the same on every backend. OpenVMM pins `intel.skylake-sp.v1` (Xeon Scalable, first diff --git a/vmm_core/virt/src/time_abi/msr.rs b/vmm_core/virt/src/time_abi/msr.rs index 81668693354..53a9a5c9c09 100644 --- a/vmm_core/virt/src/time_abi/msr.rs +++ b/vmm_core/virt/src/time_abi/msr.rs @@ -5,7 +5,9 @@ //! //! OpenVMM serves the whole identity MSR range itself on every backend. Each //! backend routes accesses in [`IDENTITY_MSR_RANGE`] to one shared -//! [`TimeAbiMsrs`] before any other MSR handling. +//! [`TimeAbiMsrs`] before any other MSR handling. On a partition with an AMD +//! CPU profile, each backend also serves AMD's configuration MSRs +//! ([`AMD_CONFIG_MSRS`]) with fixed values. use super::DeclaredRates; use crate::x86::MsrError; @@ -32,6 +34,58 @@ pub const MSR_APIC_FREQUENCY: u32 = 0x4000_0023; /// `HV_X64_MSR_TSC_INVARIANT_CONTROL`: 0 or 1, partition-wide guest state. pub const MSR_TSC_INVARIANT_CONTROL: u32 = 0x4000_0118; +/// `MSR_K7_HWCR`, AMD's hardware configuration register. +pub const MSR_AMD_HWCR: u32 = 0xc001_0015; +/// HWCR's `TscFreqSel`, bit 24: the TSC counts at the P0 frequency. +pub const HWCR_TSC_FREQ_SEL: u64 = 1 << 24; +/// `MSR_AMD64_DE_CFG`, AMD's decode configuration register. +pub const MSR_AMD_DE_CFG: u32 = 0xc001_1029; +/// DE_CFG's `LFENCE` serialization bit, bit 1: `LFENCE` is dispatch +/// serializing. +pub const DE_CFG_LFENCE_SERIALIZE: u64 = 1 << 1; + +/// The AMD configuration MSRs that a partition with an AMD CPU profile +/// presents on every backend, with the value that every read returns: +/// +/// - HWCR sets only `TscFreqSel`. Linux reads HWCR with an unchecked +/// `rdmsr` on every AMD CPU with an invariant TSC, which the time ABI's +/// CPUID always sets: a fault would fail the guest's `G_UNCHECKED_MSR` +/// check, and a clear `TscFreqSel` makes Linux warn that the TSC does not +/// count at the P0 frequency. +/// - DE_CFG sets only `LFENCE` serialization, which Linux otherwise tries to +/// set itself on a CPU whose CPUID does not enumerate it. +/// +/// A write of the value that a read returns succeeds and changes nothing; +/// any other write raises #GP. Linux writes neither MSR on a CPU whose CPUID +/// clears the instructions-retired counter and CPUID faulting, as the +/// profiles' derivation policy does. +pub const AMD_CONFIG_MSRS: [(u32, u64); 2] = [ + (MSR_AMD_HWCR, HWCR_TSC_FREQ_SEL), + (MSR_AMD_DE_CFG, DE_CFG_LFENCE_SERIALIZE), +]; + +/// Handles a guest read of `msr` on a partition with an AMD CPU profile: +/// the value of one of the [`AMD_CONFIG_MSRS`], or `None` for any other MSR, +/// which the backend handles as before. +pub fn amd_config_msr_read(msr: u32) -> Option { + AMD_CONFIG_MSRS + .iter() + .find(|&&(index, _)| index == msr) + .map(|&(_, value)| value) +} + +/// Handles a guest write of `value` to `msr` on a partition with an AMD CPU +/// profile, with the same contract as [`amd_config_msr_read`]: writing the +/// value that a read returns succeeds, and any other value raises #GP. +pub fn amd_config_msr_write(msr: u32, value: u64) -> Option> { + let fixed = amd_config_msr_read(msr)?; + Some(if value == fixed { + Ok(()) + } else { + Err(MsrError::InvalidAccess) + }) +} + /// The rates were declared before, with different values. #[derive(Debug, Error)] #[error("time ABI rates are already declared as {0:?}")] @@ -242,6 +296,30 @@ mod tests { assert!(msrs.write(vp, 0x4000_0200, 0).is_none()); } + #[test] + fn amd_config_msrs_read_fixed_values_and_accept_only_them() { + assert_eq!(amd_config_msr_read(MSR_AMD_HWCR), Some(1 << 24)); + assert_eq!(amd_config_msr_read(MSR_AMD_DE_CFG), Some(1 << 1)); + for msr in [0xc001_0010, 0xc001_0114, 0x4000_0022, 0x10] { + assert_eq!(amd_config_msr_read(msr), None, "{msr:#x}"); + assert!(amd_config_msr_write(msr, 0).is_none(), "{msr:#x}"); + } + // Linux's msr_set_bit writes nothing when the bit is set already. + assert!(matches!( + amd_config_msr_write(MSR_AMD_HWCR, 1 << 24), + Some(Ok(())) + )); + assert!(matches!( + amd_config_msr_write(MSR_AMD_DE_CFG, 1 << 1), + Some(Ok(())) + )); + // Setting IRPERF_EN or McStatusWrEn, or clearing a fixed bit. + assert!(gp(amd_config_msr_write(MSR_AMD_HWCR, 1 << 24 | 1 << 30))); + assert!(gp(amd_config_msr_write(MSR_AMD_HWCR, 1 << 24 | 1 << 18))); + assert!(gp(amd_config_msr_write(MSR_AMD_HWCR, 0))); + assert!(gp(amd_config_msr_write(MSR_AMD_DE_CFG, 0))); + } + #[test] fn declare_once() { let msrs = TimeAbiMsrs::new(); diff --git a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs index 011e9920074..d0411d96e64 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/mod.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/mod.rs @@ -465,8 +465,8 @@ impl virt::Hypervisor for Kvm { vm.enable_split_irqchip(virt::irqcon::IRQ_LINES as u32)?; vm.enable_x2apic_api()?; vm.enable_unknown_msr_exits()?; - if config.time_abi.is_some() { - time_abi::install_identity_msr_filter(&vm)?; + if let Some(time_abi) = &config.time_abi { + time_abi::install_identity_msr_filter(&vm, time_abi.cpu_profile.cpu_vendor())?; } if let Some(sev) = &sev { @@ -615,6 +615,7 @@ impl ProtoPartition for KvmProtoPartition<'_> { config.msrs.clone(), surface, arch_capabilities, + profile.cpu_vendor(), )?) } None => None, diff --git a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs index 1e49655ae76..0c4f815cbb5 100644 --- a/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs +++ b/vmm_core/virt_kvm/src/arch/x86_64/time_abi.rs @@ -29,7 +29,11 @@ //! and would read `IA32_TSC_DEADLINE` as 0 and ignore writes; KVM serves //! reads of `MSR_IA32_BBL_CR_CTL3`, and OpenVMM stubs the other L2-cache //! MSRs for the PCAT BIOS). KVM checks the filter before its in-kernel -//! Hyper-V MSRs, so those never see a guest access. +//! Hyper-V MSRs, so those never see a guest access. Under an AMD CPU +//! profile, the filter also denies HWCR and DE_CFG, which OpenVMM serves +//! with the time ABI's fixed values +//! ([`AMD_CONFIG_MSRS`](virt::time_abi::msr::AMD_CONFIG_MSRS)) instead of +//! KVM's own (HWCR reading 0). //! - **Invariant TSC.** With `"Hv#1"` and `AccessTscInvariantControls` in //! CPUID, KVM (Linux 6.3 and later) hides the invariant-TSC bit of CPUID //! `0x80000007` until its own copy of `HV_X64_MSR_TSC_INVARIANT_CONTROL` is @@ -61,6 +65,7 @@ use super::KvmPartitionInner; use crate::KvmError; use cpu_profile::CpuProfile; +use cpu_profile::CpuVendor; use parking_lot::Mutex; use std::ops::RangeInclusive; use std::sync::Arc; @@ -79,8 +84,11 @@ use virt::time_abi::TscAnchor; use virt::time_abi::TscSetReport; use virt::time_abi::TscSyncMethod; use virt::time_abi::host::sample_host_time; +use virt::time_abi::msr::AMD_CONFIG_MSRS; use virt::time_abi::msr::IDENTITY_MSR_RANGE; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; +use virt::time_abi::msr::amd_config_msr_read; +use virt::time_abi::msr::amd_config_msr_write; use virt::time_abi::rate::LAPIC_HZ_KVM; use virt::time_abi::surface::SupportedCpuSurface; use virt::time_abi::surface::SupportedMsrValue; @@ -137,10 +145,29 @@ pub(crate) fn identity_msr_filter() -> [kvm::MsrFilterRange; 7] { ] } +/// Returns the MSR filter ranges of a time ABI partition whose CPU profile is +/// of `vendor`: [`identity_msr_filter`], and for AMD also AMD's +/// configuration MSRs ([`AMD_CONFIG_MSRS`]), which KVM would otherwise +/// serve itself (HWCR reading 0, and DE_CFG reading what the VMM last set). +pub(crate) fn time_abi_msr_filter(vendor: CpuVendor) -> Vec { + let mut ranges = identity_msr_filter().to_vec(); + if vendor == CpuVendor::Amd { + ranges.extend( + AMD_CONFIG_MSRS + .iter() + .map(|&(msr, _)| kvm::MsrFilterRange::deny(msr..=msr)), + ); + } + ranges +} + /// Routes the time ABI's MSRs to user space: user-space exits for unknown -/// and filtered MSRs, and the [`identity_msr_filter`] (every other MSR keeps -/// its in-kernel handling). -pub(crate) fn install_identity_msr_filter(vm: &kvm::Partition) -> Result<(), TimeAbiError> { +/// and filtered MSRs, and the [`time_abi_msr_filter`] of a CPU profile of +/// `vendor` (every other MSR keeps its in-kernel handling). +pub(crate) fn install_identity_msr_filter( + vm: &kvm::Partition, + vendor: CpuVendor, +) -> Result<(), TimeAbiError> { for (name, cap) in [ ( "KVM_CAP_X86_USER_SPACE_MSR", @@ -156,7 +183,7 @@ pub(crate) fn install_identity_msr_filter(vm: &kvm::Partition) -> Result<(), Tim } vm.enable_msr_exits(kvm::KVM_MSR_EXIT_REASON_UNKNOWN | kvm::KVM_MSR_EXIT_REASON_FILTER) .map_err(|err| routing_error(format!("cannot enable user-space MSR exits: {err:#}")))?; - vm.set_msr_filter(true, &identity_msr_filter()) + vm.set_msr_filter(true, &time_abi_msr_filter(vendor)) .map_err(|err| routing_error(format!("cannot install the MSR filter: {err:#}")))?; Ok(()) } @@ -387,19 +414,24 @@ pub(crate) struct KvmTimeAbi { /// The `IA32_ARCH_CAPABILITIES` value of every vCPU, from the CPU /// profile ([`profile_arch_capabilities`]). arch_capabilities: Option, + /// Whether the CPU profile is AMD's, so that OpenVMM serves AMD's + /// configuration MSRs, which [`time_abi_msr_filter`] routes to it. + amd_config_msrs: bool, } impl KvmTimeAbi { /// Sets up the vCPUs of a new partition for the time ABI: KVM's /// paravirtual MSRs raise #GP, and KVM's copy of - /// `HV_X64_MSR_TSC_INVARIANT_CONTROL` is probed. `surface` and - /// `arch_capabilities` come from the partition's CPU profile. + /// `HV_X64_MSR_TSC_INVARIANT_CONTROL` is probed. `surface`, + /// `arch_capabilities`, and `vendor` come from the partition's CPU + /// profile. pub(crate) fn new( vm: &kvm::Partition, vcpus: &[u32], msrs: Arc, surface: SupportedCpuSurface, arch_capabilities: Option, + vendor: CpuVendor, ) -> Result { for &vcpu in vcpus { vm.vp(vcpu) @@ -433,6 +465,7 @@ impl KvmTimeAbi { kvm_invariant_control, surface, arch_capabilities, + amd_config_msrs: vendor == CpuVendor::Amd, }) } @@ -466,6 +499,11 @@ impl KvmTimeAbi { tracelimit::info_ratelimited!(vp = vp.index(), msr, "hidden MSR read raises #GP"); return Some(Err(MsrError::InvalidAccess)); } + if self.amd_config_msrs + && let Some(value) = amd_config_msr_read(msr) + { + return Some(Ok(value)); + } self.msrs.read(vp, msr) } @@ -490,6 +528,11 @@ impl KvmTimeAbi { ); return Some(Err(MsrError::InvalidAccess)); } + if self.amd_config_msrs + && let Some(result) = amd_config_msr_write(msr, value) + { + return Some(result); + } self.msrs.write(vp, msr, value) } @@ -755,8 +798,7 @@ mod tests { #[test] fn filter_denies_exactly_the_owned_msrs() { - let filter = identity_msr_filter(); - let owned = [ + let identity = [ (0x4000_0000, 0x4000_01ff), (0x3b, 0x3b), (0x6e0, 0x6e0), @@ -765,12 +807,23 @@ mod tests { (0x118, 0x11b), (0x11e, 0x11e), ]; - assert_eq!(filter.len(), owned.len()); - for (range, (first, last)) in filter.iter().zip(owned) { - assert_eq!(range.base, first); - assert_eq!(range.base + range.nmsrs - 1, last); - assert!(range.read && range.write); - assert!(range.bitmap.iter().all(|&byte| byte == 0)); + let amd = [(0xc001_0015, 0xc001_0015), (0xc001_1029, 0xc001_1029)]; + assert_eq!( + identity_msr_filter().to_vec(), + time_abi_msr_filter(CpuVendor::Intel) + ); + for (vendor, owned) in [ + (CpuVendor::Intel, identity.to_vec()), + (CpuVendor::Amd, [identity.as_slice(), &amd].concat()), + ] { + let filter = time_abi_msr_filter(vendor); + assert_eq!(filter.len(), owned.len(), "{vendor}"); + for (range, (first, last)) in filter.iter().zip(owned) { + assert_eq!(range.base, first, "{vendor}"); + assert_eq!(range.base + range.nmsrs - 1, last, "{vendor}"); + assert!(range.read && range.write, "{vendor}"); + assert!(range.bitmap.iter().all(|&byte| byte == 0), "{vendor}"); + } } } @@ -1165,12 +1218,13 @@ mod tests { assert!(error.message.contains("VP 3: 0x9"), "{}", error.message); } - fn time_abi(msrs: Arc) -> KvmTimeAbi { + fn time_abi(msrs: Arc, vendor: CpuVendor) -> KvmTimeAbi { KvmTimeAbi { msrs, kvm_invariant_control: None, surface: SupportedCpuSurface::default(), arch_capabilities: None, + amd_config_msrs: vendor == CpuVendor::Amd, } } @@ -1190,7 +1244,7 @@ mod tests { apic_hz: LAPIC_HZ_KVM, }) .unwrap(); - let time_abi = time_abi(msrs.clone()); + let time_abi = time_abi(msrs.clone(), CpuVendor::Intel); let vp = VpIndex::new(2); let read = |msr| outcome(time_abi.read_msr(vp, msr)); let write = |msr, value| outcome(time_abi.write_msr(vp, msr, value)); @@ -1217,8 +1271,10 @@ mod tests { assert_eq!(read(msr), Some(Err(())), "{msr:#x}"); assert_eq!(write(msr, 0), Some(Err(())), "{msr:#x}"); } - // Other unknown MSRs keep their existing handling. - for msr in [0x87, 0x8b, 0x117, 0x11c, 0x11f] { + // Other unknown MSRs keep their existing handling, and so do AMD's + // configuration MSRs under an Intel profile, which the filter does + // not route here. + for msr in [0x87, 0x8b, 0x117, 0x11c, 0x11f, 0xc001_0015, 0xc001_1029] { assert_eq!(read(msr), None, "{msr:#x}"); } assert_eq!(write(0x4000_0200, 0), None); @@ -1229,6 +1285,24 @@ mod tests { assert_eq!(read(MSR_TSC_INVARIANT_CONTROL), Some(Ok(1))); } + /// Under an AMD profile, the filter routes HWCR and DE_CFG to OpenVMM, + /// which serves the time ABI's fixed values. + #[test] + fn msr_exits_serve_amd_config_msrs_under_an_amd_profile() { + let time_abi = time_abi(Arc::new(TimeAbiMsrs::new()), CpuVendor::Amd); + let vp = VpIndex::new(1); + let read = |msr| outcome(time_abi.read_msr(vp, msr)); + let write = |msr, value| outcome(time_abi.write_msr(vp, msr, value)); + assert_eq!(read(0xc001_0015), Some(Ok(1 << 24))); + assert_eq!(read(0xc001_1029), Some(Ok(1 << 1))); + assert_eq!(write(0xc001_0015, 1 << 24), Some(Ok(()))); + assert_eq!(write(0xc001_0015, 1 << 24 | 1 << 30), Some(Err(()))); + assert_eq!(write(0xc001_1029, 0), Some(Err(()))); + assert_eq!(read(MSR_VP_INDEX), Some(Ok(1))); + assert_eq!(read(MSR_IA32_TSC_ADJUST), Some(Err(()))); + assert_eq!(read(0xc001_0010), None); + } + #[test] fn cycle_conversion_rounds_up() { assert_eq!(cycles_to_ns_ceil(0, 2_000_000_000), 0); @@ -1260,7 +1334,8 @@ mod kvm_tests { #[ignore = "requires /dev/kvm"] fn identity_msr_filter_installs() { let vm = partition(1); - install_identity_msr_filter(&vm).unwrap(); + install_identity_msr_filter(&vm, CpuVendor::Amd).unwrap(); + install_identity_msr_filter(&vm, CpuVendor::Intel).unwrap(); // Host-initiated accesses bypass the filter. let mut value = [0]; vm.vp(0) @@ -1332,6 +1407,7 @@ mod kvm_tests { msrs.clone(), SupportedCpuSurface::default(), None, + CpuVendor::Intel, ) .unwrap(); if time_abi.kvm_invariant_control.is_none() { @@ -1390,6 +1466,7 @@ mod kvm_tests { Arc::new(TimeAbiMsrs::new()), surface, Some(value), + profile.cpu_vendor(), ) .unwrap(); time_abi.set_arch_capabilities(&vm.vp(0)).unwrap(); diff --git a/vmm_core/virt_mshv/src/x86_64/mod.rs b/vmm_core/virt_mshv/src/x86_64/mod.rs index 72a78acc96b..4227ff21105 100644 --- a/vmm_core/virt_mshv/src/x86_64/mod.rs +++ b/vmm_core/virt_mshv/src/x86_64/mod.rs @@ -172,8 +172,8 @@ impl virt::Hypervisor for LinuxMshv { vmfd.initialize() .map_err(|e| ErrorInner::CreateVMInitFailed(e.into()))?; - if config.time_abi.is_some() { - time_abi::route_identity_msrs(&vmfd)?; + if let Some(time_abi) = &config.time_abi { + time_abi::route_identity_msrs(&vmfd, time_abi.cpu_profile.cpu_vendor())?; } if snp { @@ -824,10 +824,10 @@ impl MshvPartitionInner { /// created here, so the time ABI's synchronized TSC set sees exactly the /// created VPs. /// - /// A time ABI partition's extended topology leaves get this VP's own - /// results here, with its x2APIC ID in EDX: the hypervisor does not - /// provide that ID for these partitions, and accepts a per-VP result only - /// once the VP exists. + /// A time ABI partition's per-VP topology leaves (`0xB`, `0x1F`, and + /// AMD's `0x8000001E`) get this VP's own results here, with its APIC + /// identity: the hypervisor does not provide it for these partitions, + /// and accepts a per-VP result only once the VP exists. fn create_vp(&self, vp_index: VpIndex) -> Result { if let Some(time_abi) = &self.time_abi { time_abi.check_vp_creation(vp_index)?; @@ -840,6 +840,7 @@ impl MshvPartitionInner { .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; if self.time_abi.is_some() { let apic_id = self.vp(vp_index).vp_info.apic_id; + let reserved_vps_per_socket = self.config.processor_topology.reserved_vps_per_socket(); for leaf in self .config .cpuid @@ -850,7 +851,7 @@ impl MshvPartitionInner { register_cpuid_result_for( &self.vmfd, vp_index.index(), - &time_abi::with_x2apic_id(leaf, apic_id), + &time_abi::with_vp_identity(leaf, apic_id, reserved_vps_per_socket), )?; } } diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index a693aa6320d..b8de06273ac 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -16,16 +16,19 @@ //! `HV_X64_MSR_TSC_INVARIANT_CONTROL` to [`TimeAbiMsrs`]. The hypervisor //! raises #GP itself for writes to the three read-only identity MSRs, even //! when they are intercepted, and for every other MSR of the identity range. +//! Under an AMD CPU profile, intercepts also route HWCR and DE_CFG, which +//! OpenVMM serves with the time ABI's fixed values ([`AMD_CONFIG_MSRS`]); +//! no AMD MSHV host has verified them yet. //! - CPUID intercept results present the configured CPUID verbatim: it is the //! CPU profile's complete effective CPUID, and the backend adds nothing to -//! it. The extended topology leaves are registered once per VP with that -//! VP's x2APIC ID, which the hypervisor does not provide for these -//! partitions. Entries the effective CPUID does not list show the -//! hypervisor's own guest view, which reads zero there: the CPUID sweep -//! hardware test checks it, and verification (`E_CPU_UNLISTED`) checks the -//! ones the host enumerates at every boot and restore. The hypervisor -//! reports no hypervisor leaf of its own, which preflight verifies at -//! sentinel leaves. +//! it. The per-VP topology leaves (the extended topology leaves and AMD's +//! `0x8000001E`) are registered once per VP with that VP's APIC identity, +//! which the hypervisor does not provide for these partitions. Entries the +//! effective CPUID does not list show the hypervisor's own guest view, +//! which reads zero there: the CPUID sweep hardware test checks it, and +//! verification (`E_CPU_UNLISTED`) checks the ones the host enumerates at +//! every boot and restore. The hypervisor reports no hypervisor leaf of its +//! own, which preflight verifies at sentinel leaves. //! - The processor feature banks follow the CPU profile (see //! [`profile_features`](super::profile_features)), keep the invariant TSC, //! and hide the TSC-deadline timer, `IA32_TSC_ADJUST`, and APERF/MPERF. @@ -43,6 +46,7 @@ use crate::MshvPartitionInner; use crate::MshvProcessor; use crate::VcpuFdExt; use cpu_profile::CpuProfile; +use cpu_profile::CpuVendor; use cpu_profile::PartitionProfile; use cpu_profile::hv_banks::HvFeatures; use hvdef::HvInterceptAccessType; @@ -75,10 +79,13 @@ use virt::time_abi::TscSyncMethod; use virt::time_abi::host::sample_host_time; use virt::time_abi::identity::IDENTITY_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_MAX_LEAF; +use virt::time_abi::msr::AMD_CONFIG_MSRS; use virt::time_abi::msr::MSR_APIC_FREQUENCY; use virt::time_abi::msr::MSR_TSC_FREQUENCY; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; use virt::time_abi::msr::MSR_VP_INDEX; +use virt::time_abi::msr::amd_config_msr_read; +use virt::time_abi::msr::amd_config_msr_write; use virt::time_abi::surface::SupportedCpuSurface; /// The identity MSRs that OpenVMM serves. The hypervisor raises #GP for @@ -449,10 +456,23 @@ fn msr_index_intercept(msr: u32) -> mshv_bindings::mshv_install_intercept { } } -/// Routes the identity MSRs to OpenVMM. Fails with `E_IDENTITY_ROUTING`, -/// naming the MSR whose intercept the hypervisor refused. -pub(super) fn route_identity_msrs(vmfd: &VmFd) -> Result<(), TimeAbiError> { - install_msr_intercepts(vmfd, &ROUTED_MSRS) +/// Returns the MSRs that a time ABI partition with a CPU profile of `vendor` +/// routes to OpenVMM: the identity MSRs it serves, and under an AMD profile +/// AMD's configuration MSRs, whose fixed values OpenVMM serves too +/// ([`AMD_CONFIG_MSRS`]). +pub(super) fn routed_msrs(vendor: CpuVendor) -> Vec { + let mut msrs = ROUTED_MSRS.to_vec(); + if vendor == CpuVendor::Amd { + msrs.extend(AMD_CONFIG_MSRS.iter().map(|&(msr, _)| msr)); + } + msrs +} + +/// Routes the time ABI's MSRs of a CPU profile of `vendor` to OpenVMM +/// ([`routed_msrs`]). Fails with `E_IDENTITY_ROUTING`, naming the MSR whose +/// intercept the hypervisor refused. +pub(super) fn route_identity_msrs(vmfd: &VmFd, vendor: CpuVendor) -> Result<(), TimeAbiError> { + install_msr_intercepts(vmfd, &routed_msrs(vendor)) } fn install_msr_intercepts(vmfd: &VmFd, msrs: &[u32]) -> Result<(), TimeAbiError> { @@ -491,18 +511,48 @@ pub(super) fn partition_cpuid(config: &CpuidLeafSet) -> CpuidLeafSet { /// Returns whether CPUID leaf `function` carries a field that differs per VP /// and that the hypervisor does not provide for a time ABI partition: the /// extended topology leaves `0xB` and `0x1F`, whose `EDX` is the VP's x2APIC -/// ID. The hypervisor does not implement them for these partitions and reads -/// that `EDX` as 0 on every VP, so each VP gets its own result. Leaf 1's -/// initial APIC ID, the other per-VP field on Intel, is the hypervisor's own. +/// ID, and AMD's processor topology leaf `0x8000001E`, whose extended APIC, +/// compute unit, and node IDs are the VP's. The hypervisor does not +/// implement the extended topology leaves for these partitions and reads +/// that `EDX` as 0 on every VP, so each VP gets its own results; without +/// `0x8000001E`'s, a guest that takes its APIC ID from there would log +/// `APIC ID mismatch` on every AP. Leaf 1's initial APIC ID, the other +/// per-VP field, is the hypervisor's own. pub(super) fn is_per_vp_leaf(function: u32) -> bool { - matches!(function, 0xb | 0x1f) + matches!(function, 0xb | 0x1f | 0x8000_001e) } -/// Returns `leaf` for the VP with x2APIC ID `apic_id`: its `EDX` is that ID. -pub(super) fn with_x2apic_id(leaf: &CpuidLeaf, apic_id: u32) -> CpuidLeaf { +/// Returns `leaf` for the VP with APIC ID `apic_id` in a partition with +/// `reserved_vps_per_socket` APIC IDs per socket, its per-VP fields set as +/// `virt::x86::topology` sets the BSP's: the x2APIC ID in the extended +/// topology leaves' `EDX`, and in `0x8000001E` the extended APIC ID, the +/// compute unit ID within the socket, and the socket's node ID. +pub(super) fn with_vp_identity( + leaf: &CpuidLeaf, + apic_id: u32, + reserved_vps_per_socket: u32, +) -> CpuidLeaf { let mut leaf = *leaf; - leaf.result[3] = apic_id; - leaf.mask[3] = !0; + if leaf.function == 0x8000_001e { + let ebx = x86defs::cpuid::ProcessorTopologyDefinitionEbx::from(leaf.result[1]); + let threads_per_compute_unit = u32::from(ebx.threads_per_compute_unit()) + 1; + let reserved_vps_per_socket = reserved_vps_per_socket.max(1); + leaf.result[0] = apic_id; + leaf.result[1] = ebx + .with_compute_unit_id( + (apic_id % reserved_vps_per_socket / threads_per_compute_unit) as u8, + ) + .into(); + leaf.result[2] = x86defs::cpuid::ProcessorTopologyDefinitionEcx::from(leaf.result[2]) + .with_node_id((apic_id / reserved_vps_per_socket) as u8) + .into(); + } else { + leaf.result[3] = apic_id; + } + let per_vp = virt::x86::topology::per_vp_cpuid_bits(leaf.function); + for (mask, per_vp) in leaf.mask.iter_mut().zip(per_vp) { + *mask |= per_vp; + } leaf } @@ -524,24 +574,43 @@ enum MsrOutcome { Fault, } -/// Serves an intercepted access through the identity MSR handler. -fn serve_identity_msr(msrs: &TimeAbiMsrs, vp: VpIndex, msr: u32, access: MsrAccess) -> MsrOutcome { - let outcome = match access { +/// Serves an intercepted access of a partition whose CPU profile is of +/// `vendor`: under an AMD profile, AMD's configuration MSRs with their fixed +/// values, and otherwise the identity MSRs through their handler. +fn serve_time_abi_msr( + msrs: &TimeAbiMsrs, + vendor: CpuVendor, + vp: VpIndex, + msr: u32, + access: MsrAccess, +) -> MsrOutcome { + let amd = || match access { + MsrAccess::Read => amd_config_msr_read(msr).map(|value| Ok(MsrOutcome::Read(value))), + MsrAccess::Write(value) => { + amd_config_msr_write(msr, value).map(|r| r.map(|()| MsrOutcome::Written)) + } + }; + let identity = || match access { MsrAccess::Read => msrs.read(vp, msr).map(|r| r.map(MsrOutcome::Read)), MsrAccess::Write(value) => msrs .write(vp, msr, value) .map(|r| r.map(|()| MsrOutcome::Written)), }; + let outcome = if vendor == CpuVendor::Amd { + amd().or_else(identity) + } else { + identity() + }; match outcome { Some(Ok(outcome)) => outcome, Some(Err(_)) => MsrOutcome::Fault, None => { - // Only identity MSRs are intercepted, so this is a hypervisor + // Only the routed MSRs are intercepted, so this is a hypervisor // defect; the access still faults as an unknown MSR would. tracelimit::error_ratelimited!( vp = vp.index(), msr, - "MSR intercept outside the time ABI identity range" + "MSR intercept outside the time ABI's routed MSRs" ); MsrOutcome::Fault } @@ -560,7 +629,7 @@ fn gp_fault_event() -> u128 { } impl MshvProcessor<'_> { - /// Completes an intercepted identity MSR access, or raises #GP. + /// Completes an intercepted access to a time ABI MSR, or raises #GP. pub(super) fn handle_time_abi_msr_intercept( &mut self, time_abi: &MshvTimeAbi, @@ -572,13 +641,19 @@ impl MshvProcessor<'_> { } else { MsrAccess::Read }; - let outcome = serve_identity_msr(&time_abi.msrs, self.vpindex, info.msr_number, access); + let outcome = serve_time_abi_msr( + &time_abi.msrs, + time_abi.cpu_profile.cpu_vendor(), + self.vpindex, + info.msr_number, + access, + ); tracing::trace!( vp = self.vpindex.index(), msr = info.msr_number, ?access, ?outcome, - "time ABI identity MSR access" + "time ABI MSR access" ); let next_rip = info.header.rip + u64::from(info.header.instruction_len()); match outcome { @@ -1248,6 +1323,61 @@ mod tests { msrs } + /// Serves an intercepted access under an Intel profile, which routes the + /// identity MSRs only. + fn serve_identity_msr( + msrs: &TimeAbiMsrs, + vp: VpIndex, + msr: u32, + access: MsrAccess, + ) -> MsrOutcome { + serve_time_abi_msr(msrs, CpuVendor::Intel, vp, msr, access) + } + + /// An AMD profile also routes HWCR and DE_CFG, which OpenVMM serves with + /// the time ABI's fixed values. + #[test] + fn amd_profiles_route_and_serve_the_amd_config_msrs() { + assert_eq!(routed_msrs(CpuVendor::Intel), ROUTED_MSRS); + assert_eq!( + routed_msrs(CpuVendor::Amd), + [ + 0x4000_0002, + 0x4000_0022, + 0x4000_0023, + 0x4000_0118, + 0xc001_0015, + 0xc001_1029 + ] + ); + let msrs = declared_msrs(); + let serve = + |msr, access| serve_time_abi_msr(&msrs, CpuVendor::Amd, VpIndex::new(2), msr, access); + assert_eq!( + serve(0xc001_0015, MsrAccess::Read), + MsrOutcome::Read(1 << 24) + ); + assert_eq!( + serve(0xc001_1029, MsrAccess::Read), + MsrOutcome::Read(1 << 1) + ); + assert_eq!( + serve(0xc001_0015, MsrAccess::Write(1 << 24)), + MsrOutcome::Written + ); + assert_eq!( + serve(0xc001_0015, MsrAccess::Write(1 << 24 | 1 << 30)), + MsrOutcome::Fault + ); + assert_eq!(serve(0xc001_1029, MsrAccess::Write(0)), MsrOutcome::Fault); + assert_eq!(serve(MSR_VP_INDEX, MsrAccess::Read), MsrOutcome::Read(2)); + // An Intel profile does not route them, so an intercept faults. + assert_eq!( + serve_identity_msr(&msrs, VpIndex::BSP, 0xc001_0015, MsrAccess::Read), + MsrOutcome::Fault + ); + } + #[test] fn identity_msr_reads_follow_the_abi_table() { let msrs = declared_msrs(); @@ -1405,22 +1535,38 @@ mod tests { } #[test] - fn extended_topology_leaves_take_each_vp_x2apic_id() { - for function in [0xb, 0x1f] { + fn per_vp_topology_leaves_take_each_vp_apic_identity() { + for function in [0xb, 0x1f, 0x8000_001e] { assert!(is_per_vp_leaf(function), "{function:#x}"); } // Leaf 1's initial APIC ID is the hypervisor's own per-VP value. - for function in [0, 1, 4, 0xd, 0x4000_0000, 0x8000_001e] { + for function in [0, 1, 4, 0xd, 0x4000_0000, 0x8000_001d] { assert!(!is_per_vp_leaf(function), "{function:#x}"); } let level = CpuidLeaf::new(0xb, [1, 2, 0x100, 0]) .indexed(0) .masked([!0, !0, !0, 0]); - let leaf = with_x2apic_id(&level, 5); + let leaf = with_vp_identity(&level, 5, 8); assert_eq!( (leaf.function, leaf.index, leaf.result, leaf.mask), (0xb, Some(0), [1, 2, 0x100, 5], [!0; 4]) ); + + // AMD's processor topology, as the partition table holds it: the + // threads per compute unit and the nodes per processor, with the + // per-VP IDs unmasked. With SMT, APIC IDs 2 and 3 share compute unit + // 1; APIC ID 13 is compute unit 2 of socket 1's node. + let amd = + CpuidLeaf::new(0x8000_001e, [0, 0x100, 0, 0]).masked([0, 0xffff_ff00, 0xffff_ff00, !0]); + for (apic_id, expected) in [ + (0, [0, 0x100, 0, 0]), + (3, [3, 0x101, 0, 0]), + (13, [13, 0x102, 1, 0]), + ] { + let leaf = with_vp_identity(&amd, apic_id, 8); + assert_eq!(leaf.result, expected, "APIC ID {apic_id}"); + assert_eq!(leaf.mask, [!0; 4], "APIC ID {apic_id}"); + } } #[test] @@ -2292,11 +2438,13 @@ mod hw { println!("planted {function:#x}.{index:?} = {planted:x?}: {error}"); } - /// Every VP of a time ABI partition reads its own x2APIC ID in `EDX` of - /// the extended topology leaves, which OpenVMM registers per VP because - /// the hypervisor reads it as 0 on every VP, and the table's other - /// registers. Leaf 1's initial APIC ID is the hypervisor's own and must - /// be the VP's too. + /// Every VP of a time ABI partition reads its own APIC identity in the + /// per-VP topology leaves, which OpenVMM registers per VP because the + /// hypervisor reads it as 0 on every VP: the x2APIC ID in `EDX` of the + /// extended topology leaves, and, under an AMD profile, the extended + /// APIC, compute unit, and node IDs of `0x8000001E`; and the table's + /// other registers. Leaf 1's initial APIC ID is the hypervisor's own and + /// must be the VP's too. #[async_test] #[ignore = "requires /dev/mshv"] async fn extended_topology_reports_each_vp_x2apic_id(driver: DefaultDriver) { @@ -2369,7 +2517,11 @@ mod hw { for leaf in &topology_leaves { let index = leaf.index.unwrap_or(0); let actual = vcpufd.get_cpuid_values(leaf.function, index, 0, 0).unwrap(); - let expected = with_x2apic_id(leaf, vp.apic_id); + let expected = with_vp_identity( + leaf, + vp.apic_id, + processor_topology.reserved_vps_per_socket(), + ); assert!( (0..4).all(|r| (actual[r] ^ expected.result[r]) & expected.mask[r] == 0), "VP {} CPUID {:#x}.{index}: {actual:08x?}, expected {:08x?}", @@ -2415,7 +2567,7 @@ mod hw { ); let vmfd = crate::create_vm_with_retry(&mshv, &args).unwrap(); vmfd.initialize().unwrap(); - route_identity_msrs(&vmfd).unwrap(); + route_identity_msrs(&vmfd, CpuVendor::Intel).unwrap(); let error = install_msr_intercepts(&vmfd, &[0x10a]).unwrap_err(); println!("refused intercept: {error}"); assert_eq!(error.code, TimeAbiCode::IdentityRouting); diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 79f5dffc854..e08fdcbfaae 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -37,6 +37,10 @@ //! handling. With their feature bits clear, WHP does not implement //! `IA32_TSC_ADJUST` and `IA32_TSC_DEADLINE` either; they raise #GP, and //! so do the legacy L2-cache MSRs that the backend stubs for the PCAT BIOS. +//! WHP does not implement AMD's HWCR and DE_CFG either: under an AMD CPU +//! profile, OpenVMM serves them with the time ABI's fixed values +//! ([`AMD_CONFIG_MSRS`](virt::time_abi::msr::AMD_CONFIG_MSRS)), ahead of +//! the legacy stubs, which read HWCR as 0. //! - **Rates.** The guest TSC runs at the host rate: OpenVMM never sets the //! `ProcessorClockFrequency` partition property. The offloaded APIC timer //! runs at WHP's fixed 200 MHz (WHP cannot set `InterruptClockFrequency`). @@ -58,6 +62,7 @@ use crate::profile_features::WhpFeatures; use crate::profile_features::profile_features; use crate::profile_features::supported_surface; use cpu_profile::CpuProfile; +use cpu_profile::CpuVendor; use cpu_profile::PartitionProfile; use inspect::Inspect; use std::sync::Arc; @@ -82,6 +87,8 @@ use virt::time_abi::host::sample_host_time; use virt::time_abi::identity::HYPERVISOR_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_CPUID_RANGE; use virt::time_abi::identity::IDENTITY_MAX_LEAF; +use virt::time_abi::msr::amd_config_msr_read; +use virt::time_abi::msr::amd_config_msr_write; use virt::time_abi::surface::SupportedCpuSurface; use virt::x86::topology::per_vp_cpuid_bits; use whp::abi::WHV_CPUID_OUTPUT; @@ -700,10 +707,13 @@ pub(crate) enum MsrOutcome { } /// Serves an MSR access that the time ABI owns: the identity range through -/// `msrs`, and the MSRs the time ABI hides, which raise #GP. Returns `None` -/// for every other MSR, which the backend handles as before. +/// `msrs`, the MSRs the time ABI hides, which raise #GP, and, for a CPU +/// profile of `vendor` AMD, AMD's configuration MSRs +/// ([`virt::time_abi::msr::AMD_CONFIG_MSRS`]). Returns `None` for every +/// other MSR, which the backend handles as before. pub(crate) fn serve_msr( msrs: &TimeAbiMsrs, + vendor: CpuVendor, vp: VpIndex, msr: u32, access: MsrAccess, @@ -720,6 +730,16 @@ pub(crate) fn serve_msr( if LEGACY_L2_CACHE_MSRS.contains(&msr) { return Some(MsrOutcome::Fault); } + if vendor == CpuVendor::Amd { + let outcome = match access { + MsrAccess::Read => amd_config_msr_read(msr).map(MsrOutcome::Read), + MsrAccess::Write(value) => amd_config_msr_write(msr, value) + .map(|result| result.map_or(MsrOutcome::Fault, |()| MsrOutcome::Written)), + }; + if outcome.is_some() { + return outcome; + } + } let outcome = match access { MsrAccess::Read => msrs.read(vp, msr)?.map(MsrOutcome::Read), MsrAccess::Write(value) => msrs.write(vp, msr, value)?.map(|()| MsrOutcome::Written), @@ -1196,7 +1216,13 @@ impl WhpProcessor<'_> { } else { MsrAccess::Read }; - let Some(outcome) = serve_msr(&time_abi.msrs, self.vp.index, info.MsrNumber, access) else { + let Some(outcome) = serve_msr( + &time_abi.msrs, + time_abi.cpu_profile.cpu_vendor(), + self.vp.index, + info.MsrNumber, + access, + ) else { return Ok(false); }; tracing::trace!( @@ -1411,6 +1437,8 @@ mod tests { use super::test_cpuid; use super::*; use virt::time_abi::DeclaredRates; + use virt::time_abi::msr::MSR_AMD_DE_CFG; + use virt::time_abi::msr::MSR_AMD_HWCR; use virt::time_abi::msr::MSR_APIC_FREQUENCY; use virt::time_abi::msr::MSR_TSC_FREQUENCY; use virt::time_abi::msr::MSR_TSC_INVARIANT_CONTROL; @@ -2152,8 +2180,10 @@ mod tests { #[test] fn msr_exits_serve_the_identity_and_hide_the_timer_msrs() { let msrs = declared_msrs(); - let read = |vp, msr| serve_msr(&msrs, VpIndex::new(vp), msr, MsrAccess::Read); - let write = |msr, value| serve_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Write(value)); + let intel = CpuVendor::Intel; + let read = |vp, msr| serve_msr(&msrs, intel, VpIndex::new(vp), msr, MsrAccess::Read); + let write = + |msr, value| serve_msr(&msrs, intel, VpIndex::BSP, msr, MsrAccess::Write(value)); assert_eq!(read(5, MSR_VP_INDEX), Some(MsrOutcome::Read(5))); assert_eq!( read(0, MSR_TSC_FREQUENCY), @@ -2203,19 +2233,53 @@ mod tests { assert_eq!(read(0, msr), Some(MsrOutcome::Fault), "{msr:#x}"); assert_eq!(write(msr, 0), Some(MsrOutcome::Fault), "{msr:#x}"); } - // Every other MSR keeps its existing handling. - for msr in [0x10, 0x1b, 0x3a, 0x6df, 0x6e1, 0x3fff_ffff, 0x4000_0200] { + // Every other MSR keeps its existing handling, AMD's configuration + // MSRs included under an Intel profile. + for msr in [ + 0x10, + 0x1b, + 0x3a, + 0x6df, + 0x6e1, + 0x3fff_ffff, + 0x4000_0200, + MSR_AMD_HWCR, + MSR_AMD_DE_CFG, + ] { assert_eq!(read(0, msr), None, "{msr:#x}"); assert_eq!(write(msr, 0), None, "{msr:#x}"); } } + /// Under an AMD profile, OpenVMM serves HWCR and DE_CFG with the time + /// ABI's fixed values, before the backend's legacy stubs, which read HWCR + /// as 0. + #[test] + fn msr_exits_serve_amd_config_msrs_under_an_amd_profile() { + let msrs = declared_msrs(); + let amd = CpuVendor::Amd; + let read = |msr| serve_msr(&msrs, amd, VpIndex::new(3), msr, MsrAccess::Read); + let write = |msr, value| serve_msr(&msrs, amd, VpIndex::BSP, msr, MsrAccess::Write(value)); + assert_eq!(read(MSR_AMD_HWCR), Some(MsrOutcome::Read(1 << 24))); + assert_eq!(read(MSR_AMD_DE_CFG), Some(MsrOutcome::Read(1 << 1))); + assert_eq!(write(MSR_AMD_HWCR, 1 << 24), Some(MsrOutcome::Written)); + assert_eq!(write(MSR_AMD_HWCR, 1 << 30), Some(MsrOutcome::Fault)); + assert_eq!(write(MSR_AMD_DE_CFG, 0), Some(MsrOutcome::Fault)); + // The identity and the hidden MSRs are served as under any profile. + assert_eq!(read(MSR_VP_INDEX), Some(MsrOutcome::Read(3))); + assert_eq!(read(MSR_IA32_TSC_ADJUST), Some(MsrOutcome::Fault)); + // Other AMD MSRs keep the backend's handling. + for msr in [0xc001_0010, 0xc001_0114, 0xc001_0140] { + assert_eq!(read(msr), None, "{msr:#x}"); + } + } + #[test] fn frequency_reads_fault_before_the_rates_are_declared() { let msrs = TimeAbiMsrs::new(); for msr in [MSR_TSC_FREQUENCY, MSR_APIC_FREQUENCY] { assert_eq!( - serve_msr(&msrs, VpIndex::BSP, msr, MsrAccess::Read), + serve_msr(&msrs, CpuVendor::Intel, VpIndex::BSP, msr, MsrAccess::Read), Some(MsrOutcome::Fault) ); } From 07850d27af783ea8765111741763cb6d326228df Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 22:11:54 -0700 Subject: [PATCH 289/304] cpu_profile: pin an AMD Milan CPU profile, amd.milan.v1 No pinned profile served an AMD CPU, so --cpu-profile auto, the default, failed every AMD host with E_PROFILE_HOST_UNKNOWN (microsoft/nvx#396, plan item 5). The catalog now pins amd.milan.v1, AMD EPYC, third generation (Milan): family 25 (0x19), model 1, steppings 0 to 15. No other generation uses family 25 model 1, so the generation takes every stepping of the Milan die, Milan-X's 2 included, whose CPUID differs in its signature and L3 size: the profile's checks treat 0x80000001 EAX, 0x80000006, and 0x8000001D as descriptive, as the derivation tests check for stepping 2. Genoa (model 17) and the Zen 3 client parts stay without a profile. As intel.alderlake.v1 was, the profile is derived from one fingerprint: openvmm --cpu-fingerprint on WHP on an Azure Standard_D16as_v5 VM with an AMD EPYC 7763 (stepping 1) and nested Hyper-V, surface digest sha256:35a6cf971fd37b3f28176b1164b37efeec4bcb5094b5d8222d581ee1dd3e7307. The derivation policy, unchanged since the first commit of this series (openvmm-cpu-profile-policy/v1 with its AMD rules), derives it with cargo run -p cpu_profile --example derive_profile -- milan 1 ID amd.milan.v1, digest sha256:463ee0368dc01bf56a3ab1157d62b21b51d11b3dcccb46c804864748ef696543. It presents what that host's WHP offers its guests, under the policy: AVX2, SHA, VAES, VPCLMULQDQ, and the x87, SSE, and AVX XSAVE states; TOPOEXT with the cache leaves 0x80000005 and 0x8000001D, whose sharing counts OpenVMM sets per VM, and 0x8000001E, whose IDs it sets per VP; CLZERO; a 48-bit physical address width; the brand string "AMD EPYC Processor (Milan)"; and no pinned MSR, since AMD CPUs have no IA32_ARCH_CAPABILITIES. It has no SVM, RDPRU, SME or SEV (0x8000001F is zero), and, because that WHP offers none, no IBRS, IBPB, STIBP, or SSBD and nothing in 0x80000021. That WHP offers PSFD and BTC_NO, but the policy drops both: PSFD without a SPEC_CTRL control, and BTC_NO, which KVM never enumerates. So no KVM host of the generation fails the profile on either bit, which a KVM surface of the same CPU now checks, and 0x80000008 EBX holds CLZERO and the XSAVE error pointer bit only. Linux guests of the profile mitigate Spectre v2 with retpolines and report SSB, SRSO, and TSA as vulnerable on every host. Milan hosts on KVM and MSHV, whose bare-metal view offers more, should support the profile, but none was available to verify it, nor the AMD paths of those backends; a later revision derived from such hosts could add the speculation controls, and PSFD beside them. pinned_data.rs, regenerated with generate_pinned from the five files in catalog order, gains the new profile's block and changes nothing else but the array's length: the four Intel profiles' constants are byte-identical, and their JSON files and golden digests are unchanged, which the catalog test checks. Tests: - catalog: Milan and Milan-X select amd.milan.v1; Genoa, an Intel CPU, and a Hygon CPU with Milan's signature select nothing; selecting the other vendor's profile fails with E_CPU_GENERATION; the new file has its golden digest. - derive: the pinned profile is the policy's derivation of the EPYC 7763's recorded CPUID (test_support::MILAN_WHP_CPUID), re-deriving every pinned profile, Milan's included, reproduces it, and a KVM host of that CPU, which offers neither PSFD nor BTC_NO, supports it. - check: the --cpu-fingerprint check of that host passes with a partition configured from the profile, though its probe partition presents CET's XSAVE components outside the profile. - The tests that assumed Intel profiles take each profile's vendor: the L3 sharing test reads 0x8000001D on AMD, openvmm_helpers' effective CPUID record test builds AMD's topology fields, and virt_whp's unit tests run amd.milan.v1 too: its CPUID exits add 0x8000001E (9 exits), its brand is its generation's, and the comparison with the profile programming of de39f6aab, which predates AMD profiles, ignores AMD's topology fields. Validation, with Rust 1.95.0: - cargo test: cpu_profile 111, virt 65, virt_whp 36, openvmm_core 98, and openvmm_helpers 98 passed; in a Linux container (rust:1.95.0), cpu_profile, virt, virt_kvm (41), and virt_mshv (77) passed. - On the EPYC 7763 WHP host, all 16 virt_whp hardware tests passed, now with amd.milan.v1 rather than skipping: the profile's feature bank 1 is 0x24, VP 0 presents the profile programming's CPUID at all 390 probes but for AMD's topology fields, and the host's two entries outside the profile (0xd.11 and 0xd.12, CET) read zero on a partition of the profile. - A release build there prints NVX-CPU-PROFILE: status=pass backend=whp generation=milan profile=amd.milan.v1 profile_digest=sha256:463ee036... for --cpu-fingerprint. - clippy --all-targets and rustdoc with warnings denied for cpu_profile, virt, virt_whp, openvmm_core, and openvmm_helpers, and for virt_kvm and virt_mshv on x86_64-unknown-linux-gnu; cargo xtask fmt --fix. Part of microsoft/nvx#396. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 01c35bf1-81da-4f98-8380-8a902ac0e5f9 --- Guide/src/user_guide/openvmm/snapshots.md | 5 +- openvmm/openvmm_helpers/src/snapshot/time.rs | 32 +- .../cpu_profile/profiles/amd.milan.v1.json | 895 ++++++++++++++++++ vmm_core/cpu_profile/src/catalog.rs | 58 +- vmm_core/cpu_profile/src/check.rs | 35 + vmm_core/cpu_profile/src/derive.rs | 67 +- vmm_core/cpu_profile/src/pinned_data.rs | 340 ++++++- vmm_core/virt/src/time_abi/cpuid.rs | 12 +- vmm_core/virt_whp/src/time_abi.rs | 59 +- 9 files changed, 1452 insertions(+), 51 deletions(-) create mode 100644 vmm_core/cpu_profile/profiles/amd.milan.v1.json diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index cd89bd96e46..48bf7c6d6e2 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -264,8 +264,9 @@ The guest CPU is a pinned CPU profile: one per CPU generation, the same on every backend. OpenVMM pins `intel.skylake-sp.v1` (Xeon Scalable, first generation: family 6, model 85, steppings 0 to 4), `intel.icelake-sp.v1` (Xeon Scalable, third generation: 6/106), `intel.emeraldrapids.v1` (Xeon -Scalable, fifth generation: 6/207), and `intel.alderlake.v1` (Core, twelfth -generation: 6/151 and 6/154). `--cpu-profile ` selects one; the default, +Scalable, fifth generation: 6/207), `intel.alderlake.v1` (Core, twelfth +generation: 6/151 and 6/154), and `amd.milan.v1` (AMD EPYC, third generation: +25/1). `--cpu-profile ` selects one; the default, `auto`, selects the profile of the host's generation, and a host without one fails (`E_PROFILE_HOST_UNKNOWN`). On an Intel or AMD development host without one, `--cpu-profile host` boots on a host profile instead: OpenVMM diff --git a/openvmm/openvmm_helpers/src/snapshot/time.rs b/openvmm/openvmm_helpers/src/snapshot/time.rs index 0d1656df913..53da8914521 100644 --- a/openvmm/openvmm_helpers/src/snapshot/time.rs +++ b/openvmm/openvmm_helpers/src/snapshot/time.rs @@ -299,18 +299,44 @@ pub(super) mod tests { const TEST_SIGNATURE: u32 = 0x0006_06a6; /// Returns the effective CPUID of a partition with `vp_count` VPs and - /// `profile`: the topology fields OpenVMM sets for one socket, an xAPIC, - /// and no identity leaves. + /// `profile`: the topology fields OpenVMM sets for one socket, of the + /// profile's vendor, an xAPIC, and no identity leaves. fn test_effective_cpuid( profile: &cpu_profile::CpuProfile, vp_count: u32, ) -> cpu_profile::EffectiveCpuid { + use cpu_profile::CpuVendor; use cpu_profile::CpuidResult; let mut vm = vec![ CpuidResult::new(1, [0, vp_count << 16, 0, 0]).masked([0, 0xffff_0000, 0, 0]), - CpuidResult::new(4, [(vp_count - 1) << 26, 0, 0, 0]).masked([0xffff_c000, 0, 0, 0]), cpu_profile::x2apic_cpuid(false), ]; + match profile.cpu_vendor() { + CpuVendor::Intel => vm.push( + CpuidResult::new(4, [(vp_count - 1) << 26, 0, 0, 0]).masked([0xffff_c000, 0, 0, 0]), + ), + CpuVendor::Amd => { + let apic_id_size = vp_count.next_power_of_two().trailing_zeros(); + vm.push( + CpuidResult::new(0x8000_0008, [0, 0, (vp_count - 1) | apic_id_size << 12, 0]) + .masked([0, 0, 0xf0ff, 0]), + ); + // The L3 cache is the socket's; the others are each VP's. + for subleaf in 0.. { + let eax = profile.lookup(0x8000_001d, subleaf)[0]; + if eax & 0x1f == 0 { + break; + } + let sharing = if (eax >> 5) & 7 == 3 { vp_count - 1 } else { 0 }; + vm.push( + CpuidResult::new(0x8000_001d, [sharing << 14, 0, 0, 0]) + .indexed(subleaf) + .masked([0x03ff_c000, 0, 0, 0]), + ); + } + vm.push(CpuidResult::new(0x8000_001e, [0; 4]).masked([!0, 0xffff, 0x7ff, 0])); + } + } let max_basic = profile.lookup(0, 0)[0]; for leaf in cpu_profile::VM_OWNED_LEAVES .into_iter() diff --git a/vmm_core/cpu_profile/profiles/amd.milan.v1.json b/vmm_core/cpu_profile/profiles/amd.milan.v1.json new file mode 100644 index 00000000000..d91b785a91c --- /dev/null +++ b/vmm_core/cpu_profile/profiles/amd.milan.v1.json @@ -0,0 +1,895 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000000d", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00a00f11", + "0x00000800", + "0xf6da3203", + "0x078bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x219c07a9", + "0x00400604", + "0x00000010" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000007", + "0x00000000", + "0x00000340", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000021", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00a00f11", + "0x00000000", + "0x004002f3", + "0x2fd3fbff" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x20444d41", + "0x43595045", + "0x6f725020", + "0x73736563" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x2820726f", + "0x616c694d", + "0x0000296e", + "0x00000000" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0xff40ff40", + "0xff40ff40", + "0x20080140", + "0x20080140" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x48002200", + "0x68004200", + "0x02006140", + "0x08009140" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffff0f00", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00003030", + "0x00000005", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x01c0003f", + "0x000003ff", + "0x00000002" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x03c0003f", + "0x00007fff", + "0x00000001" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001e", + "mask": [ + "0x00000000", + "0xffff0000", + "0xfffff800", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000020", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000021", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + } + ], + "description": "AMD EPYC, third generation (Milan), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 25, + "model": 1, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "milan" + }, + "id": "amd.milan.v1", + "msrs": [], + "physical_address_width": 48, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "whp", + "hosts": 1, + "surface_digests": [ + "sha256:35a6cf971fd37b3f28176b1164b37efeec4bcb5094b5d8222d581ee1dd3e7307" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "AuthenticAMD", + "xcr0": "0x0000000000000007", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index e6824bf4424..d0fc2c5e0f3 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -399,11 +399,18 @@ mod tests { const ALDER_LAKE: u32 = 0x0009_06a3; const ALDER_LAKE_S: u32 = 0x0009_0672; const TIGER_LAKE: u32 = 0x0008_06c1; + const MILAN: u32 = 0x00a0_0f11; + const MILAN_X: u32 = 0x00a0_0f12; + const GENOA: u32 = 0x00a1_0f11; fn intel(signature: u32) -> HostCpuSignature { HostCpuSignature::new(*b"GenuineIntel", signature) } + fn amd(signature: u32) -> HostCpuSignature { + HostCpuSignature::new(*b"AuthenticAMD", signature) + } + fn code(result: Result) -> ProfileErrorCode { result.unwrap_err().code } @@ -411,7 +418,7 @@ mod tests { /// The pinned JSON files, the source of the static data, and their golden /// digests. Released profiles are immutable: changing a golden digest is /// a deliberate act, here. - const FILES: [(&str, &str, &str); 4] = [ + const FILES: [(&str, &str, &str); 5] = [ ( "intel.skylake-sp.v1", include_str!("../profiles/intel.skylake-sp.v1.json"), @@ -432,6 +439,11 @@ mod tests { include_str!("../profiles/intel.alderlake.v1.json"), "sha256:2dfb6ccfd5a195b1614e6d963615c3372f40806b8f19b0d8425142f8c18a0c2e", ), + ( + "amd.milan.v1", + include_str!("../profiles/amd.milan.v1.json"), + "sha256:463ee0368dc01bf56a3ab1157d62b21b51d11b3dcccb46c804864748ef696543", + ), ]; /// The checks that an OpenVMM start skips: every pinned file is a valid @@ -512,21 +524,30 @@ mod tests { #[test] fn auto_selects_the_host_generation_or_fails() { - for (signature, id, generation) in [ - (SKYLAKE, "intel.skylake-sp.v1", "skylake-sp"), - (ICELAKE, "intel.icelake-sp.v1", "icelake-sp"), - (EMERALDRAPIDS, "intel.emeraldrapids.v1", "emeraldrapids"), - (ALDER_LAKE, "intel.alderlake.v1", "alderlake"), - (ALDER_LAKE_S, "intel.alderlake.v1", "alderlake"), + for (host, id, generation) in [ + (intel(SKYLAKE), "intel.skylake-sp.v1", "skylake-sp"), + (intel(ICELAKE), "intel.icelake-sp.v1", "icelake-sp"), + ( + intel(EMERALDRAPIDS), + "intel.emeraldrapids.v1", + "emeraldrapids", + ), + (intel(ALDER_LAKE), "intel.alderlake.v1", "alderlake"), + (intel(ALDER_LAKE_S), "intel.alderlake.v1", "alderlake"), + (amd(MILAN), "amd.milan.v1", "milan"), + (amd(MILAN_X), "amd.milan.v1", "milan"), ] { - assert_eq!(select_auto(&intel(signature)).unwrap().id(), id); - assert_eq!(select(AUTO, &intel(signature)).unwrap().id(), id); - assert_eq!(generation_of(&intel(signature)), Some(generation)); + assert_eq!(select_auto(&host).unwrap().id(), id); + assert_eq!(select(AUTO, &host).unwrap().id(), id); + assert_eq!(generation_of(&host), Some(generation)); } for host in [ intel(CASCADE_LAKE), intel(TIGER_LAKE), - HostCpuSignature::new(*b"AuthenticAMD", 0x00a1_0f11), + amd(GENOA), + // The vendor decides too: no Intel CPU has Milan's signature. + intel(MILAN), + HostCpuSignature::new(*b"HygonGenuine", MILAN), ] { assert_eq!( code(select_auto(&host)), @@ -598,6 +619,7 @@ mod tests { (6, 151), (6, 154), (6, 207), + (25, 1), (25, 17), ] { for stepping in 0..16 { @@ -638,6 +660,20 @@ mod tests { ProfileErrorCode::ProfileUnknown ); assert_eq!(code(select("", &host)), ProfileErrorCode::ProfileUnknown); + + // Another vendor's generation. + assert_eq!( + select("amd.milan.v1", &amd(MILAN)).unwrap().id(), + "amd.milan.v1" + ); + assert_eq!( + code(select("intel.skylake-sp.v1", &amd(MILAN))), + ProfileErrorCode::CpuGeneration + ); + assert_eq!( + code(select("amd.milan.v1", &host)), + ProfileErrorCode::CpuGeneration + ); } #[test] diff --git a/vmm_core/cpu_profile/src/check.rs b/vmm_core/cpu_profile/src/check.rs index b551793cbc1..fa31222e80f 100644 --- a/vmm_core/cpu_profile/src/check.rs +++ b/vmm_core/cpu_profile/src/check.rs @@ -141,6 +141,8 @@ mod tests { use crate::cpuid::CpuidEntry; use crate::test_support::fingerprint; use crate::test_support::fingerprint_with; + use crate::test_support::host_fingerprint; + use crate::test_support::milan_whp_entries; use crate::test_support::profile; use crate::test_support::profile_entries; use test_with_tracing::test; @@ -337,6 +339,39 @@ mod tests { assert_eq!(error.code, ProfileErrorCode::ProfileUnsupported); } + /// The host that `amd.milan.v1` was derived from supports it: an AMD EPYC + /// 7763 that WHP serves on an Azure host. Its probe partition presents + /// CET's XSAVE components, which a partition configured from the profile + /// does not, as on the 12th generation Intel Core host. + #[test] + fn the_milan_host_supports_its_profile() { + let fingerprint = host_fingerprint("whp", milan_whp_entries()); + let error = check_fingerprint(&fingerprint).result.unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuUnlisted); + assert!( + error + .message + .contains("CPUID 0xd.11 is outside the profile"), + "{error}" + ); + let check = check_fingerprint_with(&fingerprint, |profile| { + assert_eq!(profile.id(), "amd.milan.v1"); + Ok(Some(vec![ + CpuidEntry::new(0xd, Some(11), [0; 4]), + CpuidEntry::new(0xd, Some(12), [0; 4]), + ])) + }); + check.result.as_ref().unwrap(); + assert!( + check.summary_line(&fingerprint).starts_with( + "NVX-CPU-PROFILE: status=pass backend=whp generation=milan \ + profile=amd.milan.v1 profile_digest=sha256:463ee036" + ), + "{}", + check.summary_line(&fingerprint) + ); + } + #[test] fn configures_a_partition_only_when_it_decides_the_check() { let unused = |_: &CpuProfile| -> Result>, ProfileError> { diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs index ccceb5ce053..fc5d7ad1698 100644 --- a/vmm_core/cpu_profile/src/derive.rs +++ b/vmm_core/cpu_profile/src/derive.rs @@ -187,7 +187,7 @@ impl KnownGeneration { } /// The generations profiles exist for. -pub const KNOWN_GENERATIONS: [KnownGeneration; 4] = [ +pub const KNOWN_GENERATIONS: [KnownGeneration; 5] = [ KnownGeneration { vendor: CpuVendor::Intel, name: "skylake-sp", @@ -218,6 +218,15 @@ pub const KNOWN_GENERATIONS: [KnownGeneration; 4] = [ description: "Intel Core, twelfth generation (Alder Lake)", brand: "Intel(R) Core(TM) Processor (Alder Lake)", }, + KnownGeneration { + vendor: CpuVendor::Amd, + name: "milan", + // Every stepping of the Milan die, Milan-X's included (stepping 2): + // no other generation uses family 25 model 1. Genoa is model 17. + cpus: &[(25, 1, [0, 15])], + description: "AMD EPYC, third generation (Milan)", + brand: "AMD EPYC Processor (Milan)", + }, ]; /// Returns the known generation named `name`. @@ -1273,25 +1282,45 @@ mod tests { } /// Returns the profile of AMD's Milan generation that `fingerprints` - /// derive, as a catalog would pin it. + /// derive, as the catalog pins it. fn derive_milan(fingerprints: &[CpuFingerprint]) -> Result { - derive( - &Target { - vendor: CpuVendor::Amd, - generation: Generation { - name: "milan".to_owned(), - cpus: vec![GenerationCpu { - family: 0x19, - model: 1, - steppings: [0, 15], - }], - }, - description: "AMD EPYC (Milan) for a test".to_owned(), - brand: "AMD EPYC Processor (Milan)", - }, - 1, - fingerprints, - ) + derive_profile(generation("milan"), 1, fingerprints) + } + + /// The pinned Milan profile is the policy's profile of the one host it was + /// derived from: an AMD EPYC 7763 that WHP serves on an Azure host, whose + /// CPUID `test_support::MILAN_WHP_CPUID` records. + #[test] + fn the_milan_profile_is_its_hosts_derivation() { + let pinned = profile("amd.milan.v1"); + let derived = derive_milan(&[host_fingerprint("whp", milan_whp_entries())]).unwrap(); + assert_eq!(derived.id(), pinned.id()); + assert_eq!(derived.description(), pinned.description()); + assert_eq!(derived.generation(), pinned.generation()); + assert_eq!(derived.cpuid(), pinned.cpuid()); + assert_eq!(derived.xcr0(), pinned.xcr0()); + assert_eq!(derived.xss(), pinned.xss()); + assert_eq!(derived.xsave_components(), pinned.xsave_components()); + assert_eq!(derived.msrs(), pinned.msrs()); + assert_eq!(brand(pinned), "AMD EPYC Processor (Milan)"); + } + + /// A KVM host of the same CPU supports the Milan profile: KVM never + /// enumerates `BTC_NO`, and OpenVMM's KVM backend withholds PSFD without + /// a `SPEC_CTRL` control, which that host's KVM, nested on Azure, would + /// not offer either; the profile has neither. + #[test] + fn a_kvm_host_of_its_cpu_supports_the_milan_profile() { + let mut entries = milan_whp_entries(); + set(&mut entries, 0x8000_0008, None, 1, |ebx| { + ebx & !(AMD_PSFD | AMD_BTC_NO) + }); + let surface = + crate::HostCpuSurface::from_fingerprint(&host_fingerprint("kvm", entries).backend); + assert_eq!( + crate::support_violations(profile("amd.milan.v1"), &surface), + Vec::::new() + ); } /// The CPUID entry of `profile` at `leaf` and `subleaf`. diff --git a/vmm_core/cpu_profile/src/pinned_data.rs b/vmm_core/cpu_profile/src/pinned_data.rs index 7de7bdd9e3f..3937b699b91 100644 --- a/vmm_core/cpu_profile/src/pinned_data.rs +++ b/vmm_core/cpu_profile/src/pinned_data.rs @@ -15,7 +15,7 @@ use crate::pinned::xsave; /// The pinned profiles, in catalog order. Released profiles are immutable: a /// change is a new revision with a new ID. -pub(crate) const PINNED: [PinnedProfile; 4] = [ +pub(crate) const PINNED: [PinnedProfile; 5] = [ PinnedProfile { id: "intel.skylake-sp.v1", digest: digest("b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492"), @@ -1224,4 +1224,342 @@ pub(crate) const PINNED: [PinnedProfile; 4] = [ &["sha256:8e0184d897cb2641df7306afe86a1d4af5f4740d775719f4be49bcd1e0f15b53"], )], }, + PinnedProfile { + id: "amd.milan.v1", + digest: digest("463ee0368dc01bf56a3ab1157d62b21b51d11b3dcccb46c804864748ef696543"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000000d","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x00a00f11","0x00000800","0xf6da3203","0x078bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x219c07a9","0x00400604","0x00000010"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000007","0x00000000","0x00000340","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000021","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00a00f11","0x00000000","0x004002f3","0x2fd3fbff"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x20444d41","0x43595045","0x6f725020","0x73736563"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x2820726f","0x616c694d","0x0000296e","0x00000000"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0xff40ff40","0xff40ff40","0x20080140","0x20080140"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x48002200","0x68004200","0x02006140","0x08009140"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffff0f00","0xffffffff"],"subleaf":null,"value":["0x00003030","0x00000005","0x00000000","0x00000000"]},{"leaf":"0x80000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x01c0003f","0x000003ff","0x00000002"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x03c0003f","0x00007fff","0x00000001"]},{"leaf":"0x8000001d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001e","mask":["0x00000000","0xffff0000","0xfffff800","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000020","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000021","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]}],"description":"AMD EPYC, third generation (Milan), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":25,"model":1,"steppings":[0,15]}],"name":"milan"},"id":"amd.milan.v1","msrs":[],"physical_address_width":48,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"whp","hosts":1,"surface_digests":["sha256:35a6cf971fd37b3f28176b1164b37efeec4bcb5094b5d8222d581ee1dd3e7307"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"AuthenticAMD","xcr0":"0x0000000000000007","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "AMD EPYC, third generation (Milan), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[4], + cpuid: &[ + leaf( + 0x0, + None, + [0xd, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0xa0_0f11, 0x800, 0xf6da_3203, 0x78b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x0, 0x219c_07a9, 0x40_0604, 0x10], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0x7, 0x0, 0x340, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0xa0_0f11, 0x0, 0x40_02f3, 0x2fd3_fbff], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x2044_4d41, 0x4359_5045, 0x6f72_5020, 0x7373_6563], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x2820_726f, 0x616c_694d, 0x296e, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0xff40_ff40, 0xff40_ff40, 0x2008_0140, 0x2008_0140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x4800_2200, 0x6800_4200, 0x200_6140, 0x800_9140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x3030, 0x5, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_0f00, 0xffff_ffff], + ), + leaf( + 0x8000_0009, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000d, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000e, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0010, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0011, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0012, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0013, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0014, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0015, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0016, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0017, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0018, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0019, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x0), + [0x121, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x2), + [0x143, 0x1c0_003f, 0x3ff, 0x2], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x3), + [0x163, 0x3c0_003f, 0x7fff, 0x1], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001e, + None, + [0x0, 0x0, 0x0, 0x0], + [0x0, 0xffff_0000, 0xffff_f800, 0xffff_ffff], + ), + leaf( + 0x8000_001f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0020, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0021, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0x7, + xss: 0x0, + xsave_components: &[xsave(2, 256, 576, false, false, false)], + physical_address_width: 48, + msrs: &[], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[source( + "whp", + 1, + &["sha256:35a6cf971fd37b3f28176b1164b37efeec4bcb5094b5d8222d581ee1dd3e7307"], + )], + }, ]; diff --git a/vmm_core/virt/src/time_abi/cpuid.rs b/vmm_core/virt/src/time_abi/cpuid.rs index d68cdf81d65..b8950e678f7 100644 --- a/vmm_core/virt/src/time_abi/cpuid.rs +++ b/vmm_core/virt/src/time_abi/cpuid.rs @@ -90,6 +90,7 @@ pub fn backend_cpuid(effective: &EffectiveCpuid) -> CpuidLeafSet { mod tests { use super::*; use crate::time_abi::identity; + use cpu_profile::CpuVendor; use test_with_tracing::test; use vm_topology::processor::TopologyBuilder; use vm_topology::processor::x86::X2ApicState; @@ -135,17 +136,22 @@ mod tests { /// Every vCPU of a time ABI partition has its own L1 and L2 caches, and /// all of them share the L3 cache, as in one socket of the profile's - /// generation. + /// generation: in leaf 4 on Intel, and on AMD in leaf 8000001Dh, which + /// has leaf 4's layout without the core count. #[test] fn pinned_profiles_share_the_l3_cache_across_the_socket() { for profile in cpu_profile::pinned_profiles() { + let (leaf, has_core_count) = match profile.cpu_vendor() { + CpuVendor::Intel => (4, true), + CpuVendor::Amd => (0x8000_001d, false), + }; for vp_count in [1, 2, 8] { let topology = topology(vp_count, X2ApicState::Supported); let socket = topology.reserved_vps_per_socket(); let effective = effective_cpuid(profile, &topology).unwrap(); let mut levels = Vec::new(); for subleaf in 0..16 { - let eax = CacheParametersEax::from(effective.lookup(4, subleaf)[0]); + let eax = CacheParametersEax::from(effective.lookup(leaf, subleaf)[0]); if eax.cache_type() == 0 { break; } @@ -162,7 +168,7 @@ mod tests { ); assert_eq!( eax.cores_per_socket_minus_one(), - socket - 1, + if has_core_count { socket - 1 } else { 0 }, "{}, {vp_count} VPs, subleaf {subleaf}", profile.id() ); diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index e08fdcbfaae..20771c54219 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -1270,6 +1270,7 @@ impl WhpProcessor<'_> { #[cfg(test)] pub(crate) mod test_cpuid { use cpu_profile::CpuProfile; + use cpu_profile::CpuVendor; use cpu_profile::EffectiveCpuid; use virt::CpuidLeaf; use virt::CpuidLeafSet; @@ -1278,11 +1279,12 @@ pub(crate) mod test_cpuid { use vm_topology::processor::x86::ApicMode; use vm_topology::processor::x86::X2ApicState; - /// The pinned CPU profiles. - pub const PROFILES: [&str; 3] = [ + /// Pinned CPU profiles of each vendor. + pub const PROFILES: [&str; 4] = [ "intel.skylake-sp.v1", "intel.icelake-sp.v1", "intel.emeraldrapids.v1", + "amd.milan.v1", ]; /// Returns the topology of a VM with `vp_count` VPs in one socket. @@ -1305,6 +1307,21 @@ pub(crate) mod test_cpuid { .any(|result| result.function == function && result.index == Some(index)) } + /// Returns `value`, a result of `function` that VP 0 of a partition of + /// `profile` presents, without the bits in which core's table and the + /// profile programming of `de39f6aab` deliberately differ: that + /// programming predates AMD profiles, so it presents AMD's core count + /// (`0x80000008` ECX) and cache sharing (`0x8000001D` EAX) as zero. + pub fn without_amd_topology(profile: &CpuProfile, function: u32, value: [u32; 4]) -> [u32; 4] { + let ignored = match function { + 0x8000_0008 | 0x8000_001d if profile.cpu_vendor() == CpuVendor::Amd => { + cpu_profile::vm_owned_bits(CpuVendor::Amd, function, value) + } + _ => [0; 4], + }; + [0, 1, 2, 3].map(|register| value[register] & !ignored[register]) + } + /// Lists every result of `effective` that `presented` does not match /// under the result's masks, as core's `check_presented_cpuid` does. pub fn differences(presented: &CpuidLeafSet, effective: &EffectiveCpuid) -> Vec { @@ -1678,6 +1695,15 @@ mod tests { expected.push(0x1f); } expected.extend(0x4000_0000..=0x4000_0005); + // AMD's processor topology leaf carries each VP's APIC identity. + if partition + .config + .leaves() + .iter() + .any(|leaf| leaf.function == 0x8000_001e) + { + expected.push(0x8000_001e); + } // WHP answers the explicit zero leaves natively, and every other // leaf through CpuidResultList2. assert_eq!(partition.exits, expected, "{profile}"); @@ -1740,6 +1766,10 @@ mod tests { assert_eq!(leaf(1, 0).Mask.Ecx & ECX1_OSXSAVE, 0, "{profile}"); assert_eq!(leaf(0xb, 0).Mask.Edx, 0, "{profile}"); // The brand string is the profile's generic one, not the host's. + let generation = &cpu_profile::pinned(profile).unwrap().generation().name; + let generic = cpu_profile::derive::known_generation(generation) + .unwrap() + .brand; let brand: String = [0x8000_0002, 0x8000_0003, 0x8000_0004] .iter() .flat_map(|&function| { @@ -1755,10 +1785,7 @@ mod tests { .take_while(|&byte| byte != 0) .map(char::from) .collect(); - assert!( - brand.starts_with("Intel(R) Xeon(R) Processor ("), - "{profile}: {brand}" - ); + assert_eq!(brand, generic, "{profile}"); } } @@ -1845,19 +1872,20 @@ mod tests { /// of the CPUID. VP 0 observes exactly the CPUID of the profile /// programming of `de39f6aab` on every leaf and subleaf that either /// lists, on their unlisted subleaves, and past the maximum leaves, on a - /// WHP whose unprogrammed bits read zero. The one deliberate change is the + /// WHP whose unprogrammed bits read zero. The deliberate changes are the /// subleaf that ends each extended topology leaf: core's table lists it, /// and VP 0 presents it as listed, where the profile programming read - /// zero. + /// zero; and AMD's topology fields, which that programming predates + /// ([`test_cpuid::without_amd_topology`]). #[test] fn core_table_presents_what_the_profile_programming_did() { for profile in test_cpuid::PROFILES { + let pinned = cpu_profile::pinned(profile).unwrap(); for vp_count in [1, 8] { let mut new = Partition::new(profile, vp_count, X2ApicState::Supported); new.whp.noise = false; let old = Partition::profile_programmed(profile, vp_count, false); - let probes = - test_cpuid::probes(&new.effective, cpu_profile::pinned(profile).unwrap()); + let probes = test_cpuid::probes(&new.effective, pinned); let differences: Vec = probes .iter() .filter_map(|&(function, index)| { @@ -1868,7 +1896,12 @@ mod tests { format!("{function:#x}.{index}: terminator {ours:08x?}, not {expected:08x?}") }); } - let theirs = normalize_cpuid(function, index, old.observe(0, function, index)); + let ours = test_cpuid::without_amd_topology(pinned, function, ours); + let theirs = test_cpuid::without_amd_topology( + pinned, + function, + normalize_cpuid(function, index, old.observe(0, function, index)), + ); (ours != theirs).then(|| { format!("{function:#x}.{index}: core table {ours:08x?}, profile {theirs:08x?}") }) @@ -2725,7 +2758,7 @@ mod whp_tests { /// one programmed from core's complete table and one with the profile /// programming of `de39f6aab`, present the same CPUID to VP 0, except the /// listed subleaf that ends leaf 0Bh, which the profile programming left - /// zero. + /// zero, and AMD's topology fields, which it predates. #[test] #[ignore = "requires WHP"] fn core_table_presents_what_the_profile_programming_did_on_whp() { @@ -2836,6 +2869,8 @@ mod whp_tests { function, index, ); + let ours = test_cpuid::without_amd_topology(profile, function, ours); + let theirs = test_cpuid::without_amd_topology(profile, function, theirs); if ours != theirs { differences.push(format!( "{function:#x}.{index}: core table {ours:08x?}, profile {theirs:08x?}" From aa9f6c4fa8081818bfb43199d6bca874af701890 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Tue, 6 Oct 2026 11:22:19 -0700 Subject: [PATCH 290/304] virt_mshv: measure --cpu-fingerprint's unlisted CPUID like a cold boot --cpu-fingerprint, and so NVX's doctor H2, checks that a Hyper-V backend presents no non-zero CPUID entry outside the selected profile's tables (E_CPU_UNLISTED). 739d94934 made WHP read those entries on a second probe partition configured from the profile, as a cold boot reads them on its own partition: the fingerprint's probe partition enables every feature that WHP offers, CET's XSAVE components included, which no profile enables, so the check failed CET-capable hosts whose cold boot passes (microsoft/nvx#390, finding 2). MSHV kept the old check, which reads the fingerprint's probe partition, until a CET-capable MSHV host showed whether it needs the same. That partition also enables every processor and XSAVE feature of the host partition, so a host whose hypervisor offers CET would present 0xd.11 and 0xd.12 there and fail H2, although its cold boot passes. No such host is available to show it: the Skylake-SP hosts predate CET, and Azure withholds CET from its Emerald Rapids VMs, whose roots report neither IBT nor shadow stacks. Measure on MSHV what a cold boot measures, as on WHP, so that the check holds whatever the hypervisor offers. LinuxMshv::profile_unlisted_cpuid creates a probe partition as a microVM's time ABI partition is created: partition_create_args for a partition without isolation, x2APIC, or SMT, with the processor feature banks and XSAVE features that profile_features::time_abi_features derives from the profile within what the host partition offers. Its VP 0 reads the host's unlisted_cpuid_candidates with vp0_cpuid_entries, the bulk read of a cold boot's effective CPUID, which the module can now call. MshvProbe's profile_unlisted_cpuid returns that view, so check_fingerprint_with checks it together with the surface's support, as for WHP, and a profile whose features the host partition does not offer fails with E_PROFILE_UNSUPPORTED. KVM keeps the default: it answers those entries from its CPUID table. The Guide's --cpu-fingerprint entry and openvmm_entry's documentation say that both Hyper-V backends check a configured partition. A virt_mshv hardware test, a_partition_of_the_host_profile_reads_zero_outside_it, reads the host profile's candidates on a configured partition, requires them to read zero, and reports what the fingerprint's probe partition presents there. Validation: - On a bare-metal Xeon Silver 4114 (Skylake-SP, 6/85/4) with MSHV on Azure Linux 3.0 (kernel 6.6.137.mshv2-2.azl3), with a musl release build of this tree: the hardware test passes, the 7 host entries outside intel.skylake-sp.v1 (0xf.1, 0x10.1 to 0x10.3, 0x12.1, 0x12.2, and 0x14.1) reading zero on the configured partition, where the fingerprint's probe partition presents none of them either on this host without CET; --cpu-fingerprint passes with intel.skylake-sp.v1; NVX's doctor H1 to H3 pass; and the virt_mshv unit tests pass (77). - From Windows, for x86_64-unknown-linux-gnu with Rust 1.95.0: clippy with warnings denied on virt_mshv and on openvmm_hypervisors with its virt_mshv and virt_kvm features, and rustdoc with warnings denied on both. xtask fmt passes. Part of microsoft/nvx#390. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6844f112-d265-4538-bd05-aadcfe322f84 --- Guide/src/reference/openvmm/management/cli.md | 7 +- openvmm/openvmm_entry/src/cpu_fingerprint.rs | 5 +- openvmm/openvmm_hypervisors/src/mshv.rs | 13 +++ vmm_core/virt_mshv/src/x86_64/fingerprint.rs | 101 +++++++++++++++++- vmm_core/virt_mshv/src/x86_64/time_abi.rs | 2 +- 5 files changed, 121 insertions(+), 7 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index b4676a5be52..d48de5de325 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -534,10 +534,11 @@ guest state and protect the directory with host access controls. the profile does not list, a partition that reads non-zero at such an entry fails with `E_CPU_UNLISTED`, so that no such entry carries host data; the identity range `0x40000000` to `0x4fffffff` and the topology leaves `0xb` - and `0x1f` are exempt. WHP reads VP 0 of a second probe partition + and `0x1f` are exempt. Both read VP 0 of a second probe partition configured from the profile, as a cold boot configures its partition, at - the host's CPUID entries outside the profile; MSHV reads the fingerprint's - probe partition, which enables every feature the backend offers. KVM + the host's CPUID entries outside the profile, rather than the + fingerprint's probe partition, which enables every feature the backend + offers, such as the XSAVE components of CET, which no profile enables. KVM installs the profile as the guest's whole CPUID table and answers every other entry from it (zero, or the architecture's out-of-range and topology results), so the check does not apply. The fingerprint is still written, so diff --git a/openvmm/openvmm_entry/src/cpu_fingerprint.rs b/openvmm/openvmm_entry/src/cpu_fingerprint.rs index 31ad43ad6bf..7f3395e9829 100644 --- a/openvmm/openvmm_entry/src/cpu_fingerprint.rs +++ b/openvmm/openvmm_entry/src/cpu_fingerprint.rs @@ -20,8 +20,9 @@ use std::path::Path; /// code (`E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNSUPPORTED`, or, for MSHV and /// WHP, `E_CPU_UNLISTED`) after the fingerprint is written, so hosts /// of new generations can still be fingerprinted. A backend that can -/// configure a probe partition from the profile, as WHP can, is checked for -/// `E_CPU_UNLISTED` on that partition, as a cold boot checks its own. +/// configure a probe partition from the profile, as MSHV and WHP can, is +/// checked for `E_CPU_UNLISTED` on that partition, as a cold boot checks its +/// own. pub(crate) fn write(path: &Path, hypervisor: Option<&str>) -> anyhow::Result<()> { let backend = openvmm_helpers::hypervisor::fingerprint_backend(hypervisor) .context("failed to fingerprint the hypervisor backend")?; diff --git a/openvmm/openvmm_hypervisors/src/mshv.rs b/openvmm/openvmm_hypervisors/src/mshv.rs index b1638893f50..a7d356a8bdc 100644 --- a/openvmm/openvmm_hypervisors/src/mshv.rs +++ b/openvmm/openvmm_hypervisors/src/mshv.rs @@ -67,6 +67,19 @@ impl hypervisor_resources::HypervisorProbe for MshvProbe { virt_mshv::LinuxMshv::from(std::fs::File::from(fs_err::File::open("/dev/mshv")?)); Ok(mshv.cpu_fingerprint()?) } + + #[cfg(guest_arch = "x86_64")] + fn profile_unlisted_cpuid( + &self, + params: &[(&str, &str)], + profile: &cpu_profile::CpuProfile, + ) -> anyhow::Result>> { + crate::reject_fingerprint_params("mshv", params)?; + anyhow::ensure!(virt_mshv::is_available()?, "MSHV is not available"); + let mshv = + virt_mshv::LinuxMshv::from(std::fs::File::from(fs_err::File::open("/dev/mshv")?)); + Ok(Some(mshv.profile_unlisted_cpuid(profile)?)) + } } #[cfg(test)] diff --git a/vmm_core/virt_mshv/src/x86_64/fingerprint.rs b/vmm_core/virt_mshv/src/x86_64/fingerprint.rs index a98ad86e3ca..48f9ddac5e3 100644 --- a/vmm_core/virt_mshv/src/x86_64/fingerprint.rs +++ b/vmm_core/virt_mshv/src/x86_64/fingerprint.rs @@ -2,20 +2,27 @@ // Licensed under the MIT License. //! The CPU fingerprint of the MSHV backend: the guest CPU surface and time -//! capabilities that the Microsoft hypervisor supports on this host. +//! capabilities that the Microsoft hypervisor supports on this host, and the +//! CPUID entries outside a CPU profile that a partition configured from it +//! presents. +use super::profile_features; +use super::time_abi; use crate::Error; use crate::ErrorInner; use crate::KernelError; use crate::LinuxMshv; use crate::VcpuFdExt; use crate::create_vm_with_retry; +use cpu_profile::CpuProfile; +use cpu_profile::cpuid::CpuidEntry; use cpu_profile::fingerprint::BackendFingerprint; use hvdef::HvPartitionPropertyCode; use hvdef::HvX64RegisterName; use hvdef::hypercall::HvRegisterAssoc; use mshv_ioctls::VcpuFd; use mshv_ioctls::VmFd; +use virt::time_abi::TimeAbiCode; const METHOD: &str = "HvCallGetVpCpuidValues on a probe partition that enables every processor \ and XSAVE feature of the host partition"; @@ -149,6 +156,58 @@ impl LinuxMshv { drop(vmfd); Ok(fingerprint) } + + /// Returns what VP 0 of a transient probe partition configured from + /// `profile` reads at every entry of the host's CPUID outside the + /// profile's tables ([`cpu_profile::unlisted_cpuid_candidates`]), each + /// read at subleaf 0 if subleaf-independent, for the `--cpu-fingerprint` + /// check that those entries read zero (`E_CPU_UNLISTED`). + /// + /// It measures what a time ABI cold boot checks on VP 0 of its partition. + /// The probe partition is created as a microVM's time ABI partition is: + /// without isolation, x2APIC, or SMT, and with the processor feature banks + /// and XSAVE features that derive from the profile within what the host + /// partition offers. So, unlike [`Self::cpu_fingerprint`]'s probe + /// partition with every feature of the host partition, it does not + /// present the XSAVE components of features that no profile enables, such + /// as CET's. VP 0 reads the candidates as a cold boot reads them, with its + /// reset XCR0 and XSS, in one bulk `HvCallGetVpCpuidValues` where the + /// hypervisor accepts it. The probe registers no CPUID results, which + /// cover only the profile's own entries. The partition has no memory, + /// never runs, and is destroyed before this returns. + /// + /// Fails with `E_PROFILE_UNSUPPORTED` if the host partition does not + /// offer the profile's features. + pub fn profile_unlisted_cpuid(&self, profile: &CpuProfile) -> Result, Error> { + let host = profile_features::host_features(&self.mshv)?; + let features = profile_features::time_abi_features(profile, host)?; + let args = time_abi::with_features( + super::partition_create_args(&virt::ProtoPartitionIsolation::None, false, false)?, + features, + ); + let vmfd = create_vm_with_retry(&self.mshv, &args)?; + vmfd.initialize() + .map_err(|e| ErrorInner::CreateVMInitFailed(e.into()))?; + let vp = vmfd + .create_vcpu(0) + .map_err(|e| ErrorInner::CreateVcpu(e.into()))?; + + let candidates = + cpu_profile::unlisted_cpuid_candidates(profile, &profile_features::host_cpuid_table()); + let reads: Vec<(u32, u32)> = candidates + .iter() + .map(|&(leaf, subleaf)| (leaf, subleaf.unwrap_or(0))) + .collect(); + let (values, _) = time_abi::vp0_cpuid_entries(&vp, &reads, TimeAbiCode::CpuUnlisted)?; + + drop(vp); + drop(vmfd); + Ok(candidates + .into_iter() + .zip(values) + .map(|((leaf, subleaf), registers)| CpuidEntry::new(leaf, subleaf, registers)) + .collect()) + } } /// Writes the TSC of the probe virtual processor and reads it back, and @@ -232,4 +291,44 @@ mod tests { assert!(fingerprint.time.tsc_frequency_hz.is_some()); assert!(fingerprint.time.lapic_timer_frequency_hz.is_some()); } + + /// The fingerprint check reads the entries outside the host's profile on + /// a partition configured from the profile, as a cold boot does: they + /// read zero there, even where the probe partition with every feature of + /// the host partition presents host data, such as the CET XSAVE + /// components of a CET-capable host. + #[test] + #[ignore = "requires /dev/mshv"] + fn a_partition_of_the_host_profile_reads_zero_outside_it() { + let profile = match cpu_profile::select_auto(&cpu_profile::HostCpuSignature::current()) { + Ok(profile) => profile, + Err(err) => { + println!("skipped: no profile for this host: {err}"); + return; + } + }; + let mshv = LinuxMshv::new().unwrap(); + let presented = mshv.profile_unlisted_cpuid(profile).unwrap(); + let candidates = cpu_profile::unlisted_cpuid_candidates( + profile, + &super::profile_features::host_cpuid_table(), + ); + assert_eq!( + presented + .iter() + .map(cpuid::CpuidEntry::key) + .collect::>(), + candidates + ); + cpu_profile::check_unlisted_cpuid(profile, &presented).unwrap(); + let fingerprint = mshv.cpu_fingerprint().unwrap(); + let all_features = cpu_profile::unlisted_cpuid_violations(profile, &fingerprint.cpuid); + println!( + "{}: {} host entries outside the profile read zero on its partition: {:x?}; the \ + probe partition with every feature presents {all_features:?}", + profile.id(), + candidates.len(), + candidates + ); + } } diff --git a/vmm_core/virt_mshv/src/x86_64/time_abi.rs b/vmm_core/virt_mshv/src/x86_64/time_abi.rs index b8de06273ac..d03a38eb4eb 100644 --- a/vmm_core/virt_mshv/src/x86_64/time_abi.rs +++ b/vmm_core/virt_mshv/src/x86_64/time_abi.rs @@ -1028,7 +1028,7 @@ impl MshvPartitionInner { /// falling back to one read per entry, with a warning, if the bulk call /// fails. Returns the values and whether the bulk call served them; a read /// that fails fails with `code`. -fn vp0_cpuid_entries( +pub(super) fn vp0_cpuid_entries( bsp: &VcpuFd, entries: &[(u32, u32)], code: TimeAbiCode, From 3951c84158a9b65a2540002682332addd94e42e1 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Tue, 6 Oct 2026 11:41:19 -0700 Subject: [PATCH 291/304] cpu_profile: pin AMD Genoa and Turin CPU profiles No pinned profile served AMD's fourth- and fifth-generation EPYC CPUs, so --cpu-profile auto failed them with E_PROFILE_HOST_UNKNOWN (microsoft/nvx#396). Among them are the CPUs of GitHub's hosted Actions runners, where Copilot cloud agent sessions boot microVMs on KVM. Those runners are Azure VMs with an AMD EPYC 7763 (Milan), 9V74 (Genoa, family 25 model 17), or 9V45 (Turin, family 26 model 2), or with an Intel Xeon Platinum 8573C (Emerald Rapids) or Xeon 6973P-C (Granite Rapids). Before this change, only the Milan and Emerald Rapids runners had a profile. The catalog now pins two more AMD generations: - amd.genoa.v1, AMD EPYC, fourth generation (Genoa): family 25 model 17, steppings 0 to 15, Genoa-X's included. Bergamo and Siena (Zen 4c), Storm Peak, and the Zen 4 client CPUs are other models. - amd.turin.v1, AMD EPYC, fifth generation (Turin): family 26 model 2, steppings 0 to 15. Turin Dense (Zen 5c) and the Zen 5 client CPUs are other models. As for amd.milan.v1 and intel.alderlake.v1, each derives from one fingerprint: openvmm --hypervisor kvm --cpu-fingerprint on a GitHub-hosted runner, an Azure VM with nested virtualization, whose CPUID test_support::GENOA_KVM_CPUID and TURIN_KVM_CPUID record (surface digests sha256:68c11e9ed42a2e64267876eca0d21ba1083d24f36d5b4f9d891a53523646c2aa and sha256:3977cef711857870a034a72b50a56d06d4f45f6e8751fcb32203a89c27e3d501): cargo run -p cpu_profile --example derive_profile -- genoa 1 cargo run -p cpu_profile --example derive_profile -- turin 1 That gives amd.genoa.v1, digest sha256:894ac647399039242eb0f0e6582e91691234446ebc9362576d7c5613f34b966b, and amd.turin.v1, digest sha256:0d9d044922eae3f27442178249f0b60feb0db07efe446e6641a1fcbfbb3a04ff. Each presents what Azure offers that VM's KVM, under the policy: - Genoa: XCR0 0x7, because that KVM offers no AVX-512; AVX2, SHA, VAES, and VPCLMULQDQ. - Turin: XCR0 0xe7, with AVX-512 (F, DQ, CD, BW, VL, IFMA, VBMI, VBMI2, VNNI, BITALG, VPOPCNTDQ, and VP2INTERSECT), AVX-VNNI, and AVX512-BF16. - Both: TOPOEXT with AMD's cache and topology leaves; CLZERO; and LFENCE serialization in 0x80000021 EAX, and the TSA immunities in its ECX, which Azure presents. They have none of AMD's speculation controls, which Azure withholds from both VMs, and, by the policy, neither PSFD, which both KVMs offer without a SPEC_CTRL control, nor BTC_NO. Turin keeps the always-on STIBP and IBPB_RET hints. - Neither pins an MSR: see the rule below. The policy's AMD rules gain one. AMD profiles clear Intel's enumerations of its speculation controls and of IA32_ARCH_CAPABILITIES, CPUID.(7,0):EDX bits 26, 27, 29, and 31 (INTEL_SPECULATION_ENUMERATIONS). KVM sets them on AMD hosts beside AMD's own controls, and emulates the MSR, so they pin no IA32_ARCH_CAPABILITIES value either. Both runners' KVM sets bit 29, and reports the MSR as 0x400000000c000069, so profiles derived from KVM alone would have pinned the bit and a value. No AMD CPU has either, and the Milan host's WHP presents neither, so every Hyper-V host of their generations would have failed them. No pinned profile changes: amd.milan.v1's WHP source has none of the bits, and AMD rules never touch Intel profiles. Because the PSFD rule reads the derived table, Intel's IBRS no longer keeps PSFD in an AMD profile; AMD's IBRS, STIBP, or SSBD do, as KVM's guest_has_spec_ctrl_msr() and the Hyper-V banks' AMD mapping read them. pinned_data.rs, regenerated with generate_pinned from the seven files in catalog order, gains the two profiles' blocks and changes nothing else but the array's length: the five earlier profiles' constants are byte-identical, and so are their files and golden digests, which the catalog test checks. Tests: - derive: the pinned Genoa and Turin profiles are the policy's derivations of their recorded hosts, which support them. An AMD profile derived from a KVM host that enumerates Intel's controls and IA32_ARCH_CAPABILITIES has neither, keeps AMD's controls, and pins no MSR, nor does any pinned AMD profile. Intel's IBRS alone keeps no PSFD. - catalog: Genoa, a Genoa of stepping 2, and Turin select their profiles; Raphael (Ryzen 7000) and Turin Dense select none; selecting Genoa's profile on Turin, or Milan's on Genoa, fails with E_CPU_GENERATION; the new files have their golden digests. - virt_whp: the time ABI tests' sample of the pinned profiles gains Genoa and Turin, so the exit list, the CpuidResultList2 results, VP 0's effective CPUID, each VP's APIC identity, the extended topology terminator, the report's entries outside the profile, the partition capabilities, and core's table against the profile programming of de39f6aab cover them. WHP exits for 9 leaves under each, as under Milan: 1, 0xB, the six hypervisor leaves, and 0x8000001E. The last comparison guarded against a vacuous result by requiring VP 0 to present a nonzero result at half as many probes as the profile has entries. Explicit zero entries make up more of a profile as its maximum leaves grow, and Genoa's 29 nonzero results of 68 entries and Turin's 33 of 72 failed it (Milan's 28 of 53 passed by 2). It, and its WHP hardware test, now require at least as many as the profile's entries with a nonzero value, each of which VP 0 presents nonzero: every sampled profile clears that by 9. - The tests that iterate over the pinned profiles in cpu_profile, virt, virt_whp, virt_kvm, virt_mshv, openvmm_core, and openvmm_helpers now cover Genoa and Turin too. Validation, with this commit and aa9f6c4fa below it: - cargo test with Rust 1.95.0 on Windows: cpu_profile 113, virt 65, virt_whp 36, openvmm_core 98, and openvmm_helpers 98 passed. On the bare-metal hosts below: cpu_profile 113, virt_kvm 41, and virt_mshv 77 passed. clippy and rustdoc for cpu_profile, and clippy for virt_whp, are clean with warnings denied, and xtask fmt passes. On the EPYC 7763 Azure VM below, virt_whp's 16 WHP hardware tests pass on amd.milan.v1, whose core-table comparison presents 28 nonzero results of 19 nonzero entries, with no difference. - On 14 GitHub-hosted runners, with a KVM release build of this commit: - Each Genoa (4 runners), Turin (1), Milan (7), and Emerald Rapids (1) runner passed --cpu-fingerprint and NVX's doctor H1 to H4 on its profile. It also passed the --x-time-abi-verify preflight on its profile and on its host profile, NVX's full default test-microvm suite on its profile, and the guest-boot, guest-identity, smp, smp-snapshot, console-snapshot, and time-abi-conformance scenarios on its host profile. - Across those 13 runners, the suite's 34 runs and the host profile's 9 passed, and the warp probes measured at most 37 ns, with no backward step and no CPU budget overrun. - The Granite Rapids runner, which no profile serves, failed auto with E_PROFILE_HOST_UNKNOWN, and passed the host-profile scenarios. - On the bare-metal Xeon Silver 4114 hosts (Skylake-SP, two with KVM, one with MSHV, one with WHP), and on an EPYC 7763 Azure VM with WHP, with release builds of this commit, intel.skylake-sp.v1 and amd.milan.v1 are unchanged: - doctor H1 to H3, whose H2 runs --cpu-fingerprint, and the preflight pass on each host's profile, and the preflight on its host profile too. - NVX's full default test-microvm suite passes on every host. Part of microsoft/nvx#396. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6844f112-d265-4538-bd05-aadcfe322f84 --- Guide/src/user_guide/openvmm/snapshots.md | 6 +- .../cpu_profile/profiles/amd.genoa.v1.json | 1135 +++++++++++++++ .../cpu_profile/profiles/amd.turin.v1.json | 1223 +++++++++++++++++ vmm_core/cpu_profile/src/catalog.rs | 39 +- vmm_core/cpu_profile/src/derive.rs | 151 +- vmm_core/cpu_profile/src/pinned_data.rs | 887 +++++++++++- vmm_core/cpu_profile/src/test_support.rs | 238 +++- vmm_core/virt_whp/src/time_abi.rs | 24 +- 8 files changed, 3686 insertions(+), 17 deletions(-) create mode 100644 vmm_core/cpu_profile/profiles/amd.genoa.v1.json create mode 100644 vmm_core/cpu_profile/profiles/amd.turin.v1.json diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 48bf7c6d6e2..0c3f1533f22 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -265,8 +265,10 @@ every backend. OpenVMM pins `intel.skylake-sp.v1` (Xeon Scalable, first generation: family 6, model 85, steppings 0 to 4), `intel.icelake-sp.v1` (Xeon Scalable, third generation: 6/106), `intel.emeraldrapids.v1` (Xeon Scalable, fifth generation: 6/207), `intel.alderlake.v1` (Core, twelfth -generation: 6/151 and 6/154), and `amd.milan.v1` (AMD EPYC, third generation: -25/1). `--cpu-profile ` selects one; the default, +generation: 6/151 and 6/154), `amd.milan.v1` (AMD EPYC, third generation: +25/1), `amd.genoa.v1` (AMD EPYC, fourth generation: 25/17), and +`amd.turin.v1` (AMD EPYC, fifth generation: 26/2). `--cpu-profile ` +selects one; the default, `auto`, selects the profile of the host's generation, and a host without one fails (`E_PROFILE_HOST_UNKNOWN`). On an Intel or AMD development host without one, `--cpu-profile host` boots on a host profile instead: OpenVMM diff --git a/vmm_core/cpu_profile/profiles/amd.genoa.v1.json b/vmm_core/cpu_profile/profiles/amd.genoa.v1.json new file mode 100644 index 00000000000..d0a7daf7ffe --- /dev/null +++ b/vmm_core/cpu_profile/profiles/amd.genoa.v1.json @@ -0,0 +1,1135 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001c", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00a10f11", + "0x00000800", + "0xf6da3203", + "0x078bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x219c07a9", + "0x00400604", + "0x00000010" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000007", + "0x00000000", + "0x00000340", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000021", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00a10f11", + "0x00000000", + "0x004002f3", + "0x2fd3fbff" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x20444d41", + "0x43595045", + "0x6f725020", + "0x73736563" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x2820726f", + "0x6f6e6547", + "0x00002961", + "0x00000000" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0xff48ff40", + "0xff48ff40", + "0x20080140", + "0x20080140" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x5c002200", + "0x6c004200", + "0x04006140", + "0x0a009140" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffff0f00", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00003030", + "0x00000005", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x01c0003f", + "0x000007ff", + "0x00000002" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x03c0003f", + "0x00007fff", + "0x00000001" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001e", + "mask": [ + "0x00000000", + "0xffff0000", + "0xfffff800", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000020", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000021", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000204", + "0x00000000", + "0x00000006", + "0x00000000" + ] + } + ], + "description": "AMD EPYC, fourth generation (Genoa), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 25, + "model": 17, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "genoa" + }, + "id": "amd.genoa.v1", + "msrs": [], + "physical_address_width": 48, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "kvm", + "hosts": 1, + "surface_digests": [ + "sha256:68c11e9ed42a2e64267876eca0d21ba1083d24f36d5b4f9d891a53523646c2aa" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "AuthenticAMD", + "xcr0": "0x0000000000000007", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/profiles/amd.turin.v1.json b/vmm_core/cpu_profile/profiles/amd.turin.v1.json new file mode 100644 index 00000000000..b855b347d1e --- /dev/null +++ b/vmm_core/cpu_profile/profiles/amd.turin.v1.json @@ -0,0 +1,1223 @@ +{ + "cpuid": [ + { + "leaf": "0x00000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x0000001c", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x00000001", + "mask": [ + "0xffffffff", + "0x0000ffff", + "0xf7dfffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00b00f21", + "0x00000800", + "0xf6da3203", + "0x078bfbff" + ] + }, + { + "leaf": "0x00000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000004", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffef", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000001", + "0xf1bf07a9", + "0x00405f46", + "0x00000110" + ] + }, + { + "leaf": "0x00000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000030", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x000000e7", + "0x00000000", + "0x00000980", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0x00000000", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x0000000f", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000100", + "0x00000240", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000005", + "value": [ + "0x00000040", + "0x00000340", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000006", + "value": [ + "0x00000200", + "0x00000380", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000007", + "value": [ + "0x00000400", + "0x00000580", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x00000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x0000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000000", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x80000021", + "0x68747541", + "0x444d4163", + "0x69746e65" + ] + }, + { + "leaf": "0x80000001", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00b00f21", + "0x00000000", + "0x004002f3", + "0x2fd3fbff" + ] + }, + { + "leaf": "0x80000002", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x20444d41", + "0x43595045", + "0x6f725020", + "0x73736563" + ] + }, + { + "leaf": "0x80000003", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x2820726f", + "0x69727554", + "0x0000296e", + "0x00000000" + ] + }, + { + "leaf": "0x80000004", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000005", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0xff60ff40", + "0xff60ff40", + "0x300c0140", + "0x20080140" + ] + }, + { + "leaf": "0x80000006", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x40802040", + "0x60804040", + "0x04008140", + "0x0c009140" + ] + }, + { + "leaf": "0x80000007", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000100" + ] + }, + { + "leaf": "0x80000008", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffff0f00", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00003030", + "0x40020005", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000009", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000e", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000000f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000010", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000011", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000012", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000013", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000014", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000015", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000016", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000017", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000018", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000019", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001a", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001b", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001c", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000000", + "value": [ + "0x00000121", + "0x02c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000001", + "value": [ + "0x00000122", + "0x01c0003f", + "0x0000003f", + "0x00000000" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000002", + "value": [ + "0x00000143", + "0x03c0003f", + "0x000003ff", + "0x00000002" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xfc003fff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000003", + "value": [ + "0x00000163", + "0x03c0003f", + "0x00007fff", + "0x00000001" + ] + }, + { + "leaf": "0x8000001d", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": "0x00000004", + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001e", + "mask": [ + "0x00000000", + "0xffff0000", + "0xfffff800", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x8000001f", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000020", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000000", + "0x00000000", + "0x00000000", + "0x00000000" + ] + }, + { + "leaf": "0x80000021", + "mask": [ + "0xffffffff", + "0xffffffff", + "0xffffffff", + "0xffffffff" + ], + "subleaf": null, + "value": [ + "0x00000204", + "0x00000000", + "0x00000006", + "0x00000000" + ] + } + ], + "description": "AMD EPYC, fifth generation (Turin), on every backend; NVX time ABI v1", + "generation": { + "cpus": [ + { + "family": 26, + "model": 2, + "steppings": [ + 0, + 15 + ] + } + ], + "name": "turin" + }, + "id": "amd.turin.v1", + "msrs": [], + "physical_address_width": 48, + "provenance": { + "method": "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + "sources": [ + { + "backend": "kvm", + "hosts": 1, + "surface_digests": [ + "sha256:3977cef711857870a034a72b50a56d06d4f45f6e8751fcb32203a89c27e3d501" + ] + } + ] + }, + "schema": "openvmm-cpu-profile/v1", + "vendor": "AuthenticAMD", + "xcr0": "0x00000000000000e7", + "xsave_components": [ + { + "align64": false, + "index": 2, + "offset": 576, + "size": 256, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 5, + "offset": 832, + "size": 64, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 6, + "offset": 896, + "size": 512, + "supervisor": false, + "xfd": false + }, + { + "align64": false, + "index": 7, + "offset": 1408, + "size": 1024, + "supervisor": false, + "xfd": false + } + ], + "xss": "0x0000000000000000" +} diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index d0fc2c5e0f3..6a371dc22e3 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -402,6 +402,10 @@ mod tests { const MILAN: u32 = 0x00a0_0f11; const MILAN_X: u32 = 0x00a0_0f12; const GENOA: u32 = 0x00a1_0f11; + const GENOA_STEPPING_2: u32 = 0x00a1_0f12; + const RAPHAEL: u32 = 0x00a6_0f12; + const TURIN: u32 = 0x00b0_0f21; + const TURIN_DENSE: u32 = 0x00b1_0f10; fn intel(signature: u32) -> HostCpuSignature { HostCpuSignature::new(*b"GenuineIntel", signature) @@ -418,7 +422,7 @@ mod tests { /// The pinned JSON files, the source of the static data, and their golden /// digests. Released profiles are immutable: changing a golden digest is /// a deliberate act, here. - const FILES: [(&str, &str, &str); 5] = [ + const FILES: [(&str, &str, &str); 7] = [ ( "intel.skylake-sp.v1", include_str!("../profiles/intel.skylake-sp.v1.json"), @@ -444,6 +448,16 @@ mod tests { include_str!("../profiles/amd.milan.v1.json"), "sha256:463ee0368dc01bf56a3ab1157d62b21b51d11b3dcccb46c804864748ef696543", ), + ( + "amd.genoa.v1", + include_str!("../profiles/amd.genoa.v1.json"), + "sha256:894ac647399039242eb0f0e6582e91691234446ebc9362576d7c5613f34b966b", + ), + ( + "amd.turin.v1", + include_str!("../profiles/amd.turin.v1.json"), + "sha256:0d9d044922eae3f27442178249f0b60feb0db07efe446e6641a1fcbfbb3a04ff", + ), ]; /// The checks that an OpenVMM start skips: every pinned file is a valid @@ -536,6 +550,9 @@ mod tests { (intel(ALDER_LAKE_S), "intel.alderlake.v1", "alderlake"), (amd(MILAN), "amd.milan.v1", "milan"), (amd(MILAN_X), "amd.milan.v1", "milan"), + (amd(GENOA), "amd.genoa.v1", "genoa"), + (amd(GENOA_STEPPING_2), "amd.genoa.v1", "genoa"), + (amd(TURIN), "amd.turin.v1", "turin"), ] { assert_eq!(select_auto(&host).unwrap().id(), id); assert_eq!(select(AUTO, &host).unwrap().id(), id); @@ -544,7 +561,8 @@ mod tests { for host in [ intel(CASCADE_LAKE), intel(TIGER_LAKE), - amd(GENOA), + amd(RAPHAEL), + amd(TURIN_DENSE), // The vendor decides too: no Intel CPU has Milan's signature. intel(MILAN), HostCpuSignature::new(*b"HygonGenuine", MILAN), @@ -621,6 +639,9 @@ mod tests { (6, 207), (25, 1), (25, 17), + (25, 97), + (26, 2), + (26, 17), ] { for stepping in 0..16 { // Encode the display family and model as CPUID.1:EAX does. @@ -674,6 +695,20 @@ mod tests { code(select("amd.milan.v1", &host)), ProfileErrorCode::CpuGeneration ); + + // Another generation of the same vendor. + assert_eq!( + select("amd.turin.v1", &amd(TURIN)).unwrap().id(), + "amd.turin.v1" + ); + assert_eq!( + code(select("amd.genoa.v1", &amd(TURIN))), + ProfileErrorCode::CpuGeneration + ); + assert_eq!( + code(select("amd.milan.v1", &amd(GENOA))), + ProfileErrorCode::CpuGeneration + ); } #[test] diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs index fc5d7ad1698..09a49f4c8dd 100644 --- a/vmm_core/cpu_profile/src/derive.rs +++ b/vmm_core/cpu_profile/src/derive.rs @@ -22,7 +22,9 @@ //! PPIN, CPPC, and the like). AMD's speculation controls and immunities //! stay, those of `0x80000021` included, but for what KVM cannot present: //! `BTC_NO` goes, and PSFD stays only beside a `SPEC_CTRL` control -//! ([`has_spec_ctrl_control`]). +//! ([`has_spec_ctrl_control`]). Intel's enumerations of its speculation +//! controls and `IA32_ARCH_CAPABILITIES`, which KVM adds on AMD hosts +//! ([`INTEL_SPECULATION_ENUMERATIONS`]), go too. //! 3. The XSAVE features are the intersection, within [`ALLOWED_XCR0`], and //! the XSAVE leaf is rebuilt from them; every host must agree on the //! layout. A feature whose XSAVE state is not enabled is cleared. @@ -141,6 +143,14 @@ pub const AMD_PSFD: u32 = 1 << 28; /// type confusion. pub const AMD_BTC_NO: u32 = 1 << 29; +/// The bits of `CPUID.(7,0):EDX` that enumerate Intel's speculation controls +/// and `IA32_ARCH_CAPABILITIES`: IBRS and IBPB (26), STIBP (27), +/// `IA32_ARCH_CAPABILITIES` (29), and SSBD (31). KVM sets them on AMD hosts +/// too, and emulates the MSR, but AMD CPUs enumerate their speculation +/// controls in `0x80000008` EBX and have no `IA32_ARCH_CAPABILITIES`, so AMD +/// profiles clear them. +pub const INTEL_SPECULATION_ENUMERATIONS: u32 = 1 << 26 | 1 << 27 | 1 << 29 | 1 << 31; + /// Returns whether a CPUID whose `CPUID.(7,0):EDX` is `leaf7_edx` and whose /// `CPUID.0x80000008:EBX` is `ext8_ebx` enumerates a `SPEC_CTRL` control, as /// KVM's `guest_has_spec_ctrl_msr()` decides whether a guest may access @@ -187,7 +197,7 @@ impl KnownGeneration { } /// The generations profiles exist for. -pub const KNOWN_GENERATIONS: [KnownGeneration; 5] = [ +pub const KNOWN_GENERATIONS: [KnownGeneration; 7] = [ KnownGeneration { vendor: CpuVendor::Intel, name: "skylake-sp", @@ -227,6 +237,25 @@ pub const KNOWN_GENERATIONS: [KnownGeneration; 5] = [ description: "AMD EPYC, third generation (Milan)", brand: "AMD EPYC Processor (Milan)", }, + KnownGeneration { + vendor: CpuVendor::Amd, + name: "genoa", + // Every stepping of the Genoa die, Genoa-X's included: no other + // generation uses family 25 model 17. Bergamo and Siena (Zen 4c), + // Storm Peak, and the Zen 4 client CPUs are other models. + cpus: &[(25, 17, [0, 15])], + description: "AMD EPYC, fourth generation (Genoa)", + brand: "AMD EPYC Processor (Genoa)", + }, + KnownGeneration { + vendor: CpuVendor::Amd, + name: "turin", + // Every stepping of the Turin die (Zen 5): family 26 model 2. Turin + // Dense (Zen 5c) and the Zen 5 client CPUs are other models. + cpus: &[(26, 2, [0, 15])], + description: "AMD EPYC, fifth generation (Turin)", + brand: "AMD EPYC Processor (Turin)", + }, ]; /// Returns the known generation named `name`. @@ -843,6 +872,24 @@ fn apply_policy(vendor: CpuVendor, combined: &Table) -> Result::new(), + "{id}" + ); + } + } + + /// KVM enumerates Intel's speculation controls and + /// `IA32_ARCH_CAPABILITIES` on AMD hosts too, as the Genoa and Turin + /// hosts' KVM does, and emulates the MSR. An AMD profile has none of + /// them and pins no `IA32_ARCH_CAPABILITIES` value, so a profile derived + /// from KVM alone stays one that the Hyper-V backends can serve; AMD's + /// own controls stay. + #[test] + fn an_amd_profile_has_none_of_intels_speculation_enumerations() { + let amd_controls = 1 << 12 | 1 << 14 | 1 << 15 | 1 << 24; + let mut entries = milan_whp_entries(); + set(&mut entries, 0x7, Some(0), 3, |edx| { + edx | INTEL_SPECULATION_ENUMERATIONS + }); + set(&mut entries, 0x8000_0008, None, 1, |ebx| ebx | amd_controls); + let mut kvm = host_fingerprint("kvm", entries); + kvm.backend.msrs.arch_capabilities = Some(Hex64(0x4000_0000_0c00_0069)); + let derived = derive_milan(&[kvm]).unwrap(); + assert_eq!( + derived.lookup(0x7, 0)[3] & INTEL_SPECULATION_ENUMERATIONS, + 0 + ); + assert_eq!(derived.msr(IA32_ARCH_CAPABILITIES), None); + assert_eq!( + derived.lookup(0x8000_0008, 0)[1] & amd_controls, + amd_controls + ); + for pinned in crate::pinned_profiles() + .iter() + .filter(|pinned| pinned.cpu_vendor() == CpuVendor::Amd) + { + assert_eq!( + pinned.lookup(0x7, 0)[3] & INTEL_SPECULATION_ENUMERATIONS, + 0, + "{}", + pinned.id() + ); + assert_eq!(pinned.msr(IA32_ARCH_CAPABILITIES), None, "{}", pinned.id()); + } + } + /// The CPUID entry of `profile` at `leaf` and `subleaf`. fn entry(profile: &CpuProfile, leaf: u32, subleaf: Option) -> &CpuidLeafValue { profile @@ -1482,7 +1616,8 @@ mod tests { /// PSFD is a bit of `SPEC_CTRL`, so an AMD profile keeps it only beside a /// `SPEC_CTRL` control, as KVM's `guest_has_spec_ctrl_msr()` requires of /// a guest's CPUID and OpenVMM's KVM backend of KVM's supported CPUID: - /// Intel's IBRS, or AMD's IBRS, STIBP, or SSBD. IBPB controls another + /// AMD's IBRS, STIBP, or SSBD. Intel's IBRS, which KVM enumerates on AMD + /// hosts too, is no control of an AMD profile, and IBPB controls another /// MSR. The rule reads the derived profile, so controls that the /// backends do not share do not keep PSFD. `BTC_NO`, which KVM never /// offers, always goes. @@ -1499,13 +1634,17 @@ mod tests { // The Milan WHP host offers PSFD and BTC_NO, and no SPEC_CTRL control. let whp = derive_milan(&[host_fingerprint("whp", milan_whp_entries())]).unwrap(); assert_eq!(spec(&whp), 0); - let ibpb = derive_milan(&[host_fingerprint("kvm", with(0x8000_0008, 1, 1 << 12))]).unwrap(); - assert_eq!(spec(&ibpb), 0); + for (leaf, register, bit, name) in + [(0x8000_0008, 1, 12, "AMD IBPB"), (0x7, 3, 26, "Intel IBRS")] + { + let derived = + derive_milan(&[host_fingerprint("kvm", with(leaf, register, 1 << bit))]).unwrap(); + assert_eq!(spec(&derived), 0, "{name}"); + } for (leaf, register, bit, name) in [ (0x8000_0008, 1, 14, "AMD IBRS"), (0x8000_0008, 1, 15, "AMD STIBP"), (0x8000_0008, 1, 24, "AMD SSBD"), - (0x7, 3, 26, "Intel IBRS"), ] { let derived = derive_milan(&[host_fingerprint("kvm", with(leaf, register, 1 << bit))]).unwrap(); diff --git a/vmm_core/cpu_profile/src/pinned_data.rs b/vmm_core/cpu_profile/src/pinned_data.rs index 3937b699b91..d2687e175e6 100644 --- a/vmm_core/cpu_profile/src/pinned_data.rs +++ b/vmm_core/cpu_profile/src/pinned_data.rs @@ -15,7 +15,7 @@ use crate::pinned::xsave; /// The pinned profiles, in catalog order. Released profiles are immutable: a /// change is a new revision with a new ID. -pub(crate) const PINNED: [PinnedProfile; 5] = [ +pub(crate) const PINNED: [PinnedProfile; 7] = [ PinnedProfile { id: "intel.skylake-sp.v1", digest: digest("b36ef861ffec67350a28faff8c446d5a19f78d6d58862378fa9d2282dad87492"), @@ -1562,4 +1562,889 @@ pub(crate) const PINNED: [PinnedProfile; 5] = [ &["sha256:35a6cf971fd37b3f28176b1164b37efeec4bcb5094b5d8222d581ee1dd3e7307"], )], }, + PinnedProfile { + id: "amd.genoa.v1", + digest: digest("894ac647399039242eb0f0e6582e91691234446ebc9362576d7c5613f34b966b"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001c","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x00a10f11","0x00000800","0xf6da3203","0x078bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x219c07a9","0x00400604","0x00000010"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000007","0x00000000","0x00000340","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000021","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00a10f11","0x00000000","0x004002f3","0x2fd3fbff"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x20444d41","0x43595045","0x6f725020","0x73736563"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x2820726f","0x6f6e6547","0x00002961","0x00000000"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0xff48ff40","0xff48ff40","0x20080140","0x20080140"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x5c002200","0x6c004200","0x04006140","0x0a009140"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffff0f00","0xffffffff"],"subleaf":null,"value":["0x00003030","0x00000005","0x00000000","0x00000000"]},{"leaf":"0x80000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x01c0003f","0x000007ff","0x00000002"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x03c0003f","0x00007fff","0x00000001"]},{"leaf":"0x8000001d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001e","mask":["0x00000000","0xffff0000","0xfffff800","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000020","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000021","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000204","0x00000000","0x00000006","0x00000000"]}],"description":"AMD EPYC, fourth generation (Genoa), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":25,"model":17,"steppings":[0,15]}],"name":"genoa"},"id":"amd.genoa.v1","msrs":[],"physical_address_width":48,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"kvm","hosts":1,"surface_digests":["sha256:68c11e9ed42a2e64267876eca0d21ba1083d24f36d5b4f9d891a53523646c2aa"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"AuthenticAMD","xcr0":"0x0000000000000007","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "AMD EPYC, fourth generation (Genoa), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[5], + cpuid: &[ + leaf( + 0x0, + None, + [0x1c, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0xa1_0f11, 0x800, 0xf6da_3203, 0x78b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x0, 0x219c_07a9, 0x40_0604, 0x10], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0x7, 0x0, 0x340, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0xa1_0f11, 0x0, 0x40_02f3, 0x2fd3_fbff], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x2044_4d41, 0x4359_5045, 0x6f72_5020, 0x7373_6563], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x2820_726f, 0x6f6e_6547, 0x2961, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0xff48_ff40, 0xff48_ff40, 0x2008_0140, 0x2008_0140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x5c00_2200, 0x6c00_4200, 0x400_6140, 0xa00_9140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x3030, 0x5, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_0f00, 0xffff_ffff], + ), + leaf( + 0x8000_0009, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000d, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000e, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0010, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0011, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0012, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0013, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0014, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0015, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0016, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0017, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0018, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0019, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x0), + [0x121, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x2), + [0x143, 0x1c0_003f, 0x7ff, 0x2], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x3), + [0x163, 0x3c0_003f, 0x7fff, 0x1], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001e, + None, + [0x0, 0x0, 0x0, 0x0], + [0x0, 0xffff_0000, 0xffff_f800, 0xffff_ffff], + ), + leaf( + 0x8000_001f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0020, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0021, + None, + [0x204, 0x0, 0x6, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0x7, + xss: 0x0, + xsave_components: &[xsave(2, 256, 576, false, false, false)], + physical_address_width: 48, + msrs: &[], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[source( + "kvm", + 1, + &["sha256:68c11e9ed42a2e64267876eca0d21ba1083d24f36d5b4f9d891a53523646c2aa"], + )], + }, + PinnedProfile { + id: "amd.turin.v1", + digest: digest("0d9d044922eae3f27442178249f0b60feb0db07efe446e6641a1fcbfbb3a04ff"), + encoding: r#"{"cpuid":[{"leaf":"0x00000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x0000001c","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x00000001","mask":["0xffffffff","0x0000ffff","0xf7dfffff","0xffffffff"],"subleaf":null,"value":["0x00b00f21","0x00000800","0xf6da3203","0x078bfbff"]},{"leaf":"0x00000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000004","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffef","0xffffffff"],"subleaf":"0x00000000","value":["0x00000001","0xf1bf07a9","0x00405f46","0x00000110"]},{"leaf":"0x00000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000030","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000008","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x000000e7","0x00000000","0x00000980","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0x00000000","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x0000000f","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000100","0x00000240","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000005","value":["0x00000040","0x00000340","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000006","value":["0x00000200","0x00000380","0x00000000","0x00000000"]},{"leaf":"0x0000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000007","value":["0x00000400","0x00000580","0x00000000","0x00000000"]},{"leaf":"0x0000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x00000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x0000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000000","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x80000021","0x68747541","0x444d4163","0x69746e65"]},{"leaf":"0x80000001","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00b00f21","0x00000000","0x004002f3","0x2fd3fbff"]},{"leaf":"0x80000002","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x20444d41","0x43595045","0x6f725020","0x73736563"]},{"leaf":"0x80000003","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x2820726f","0x69727554","0x0000296e","0x00000000"]},{"leaf":"0x80000004","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000005","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0xff60ff40","0xff60ff40","0x300c0140","0x20080140"]},{"leaf":"0x80000006","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x40802040","0x60804040","0x04008140","0x0c009140"]},{"leaf":"0x80000007","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000100"]},{"leaf":"0x80000008","mask":["0xffffffff","0xffffffff","0xffff0f00","0xffffffff"],"subleaf":null,"value":["0x00003030","0x40020005","0x00000000","0x00000000"]},{"leaf":"0x80000009","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000e","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000000f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000010","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000011","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000012","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000013","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000014","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000015","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000016","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000017","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000018","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000019","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001a","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001b","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001c","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000000","value":["0x00000121","0x02c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000001","value":["0x00000122","0x01c0003f","0x0000003f","0x00000000"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000002","value":["0x00000143","0x03c0003f","0x000003ff","0x00000002"]},{"leaf":"0x8000001d","mask":["0xfc003fff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000003","value":["0x00000163","0x03c0003f","0x00007fff","0x00000001"]},{"leaf":"0x8000001d","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":"0x00000004","value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001e","mask":["0x00000000","0xffff0000","0xfffff800","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x8000001f","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000020","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000000","0x00000000","0x00000000","0x00000000"]},{"leaf":"0x80000021","mask":["0xffffffff","0xffffffff","0xffffffff","0xffffffff"],"subleaf":null,"value":["0x00000204","0x00000000","0x00000006","0x00000000"]}],"description":"AMD EPYC, fifth generation (Turin), on every backend; NVX time ABI v1","generation":{"cpus":[{"family":26,"model":2,"steppings":[0,15]}],"name":"turin"},"id":"amd.turin.v1","msrs":[],"physical_address_width":48,"provenance":{"method":"derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces","sources":[{"backend":"kvm","hosts":1,"surface_digests":["sha256:3977cef711857870a034a72b50a56d06d4f45f6e8751fcb32203a89c27e3d501"]}]},"schema":"openvmm-cpu-profile/v1","vendor":"AuthenticAMD","xcr0":"0x00000000000000e7","xsave_components":[{"align64":false,"index":2,"offset":576,"size":256,"supervisor":false,"xfd":false},{"align64":false,"index":5,"offset":832,"size":64,"supervisor":false,"xfd":false},{"align64":false,"index":6,"offset":896,"size":512,"supervisor":false,"xfd":false},{"align64":false,"index":7,"offset":1408,"size":1024,"supervisor":false,"xfd":false}],"xss":"0x0000000000000000"}"#, + description: "AMD EPYC, fifth generation (Turin), on every backend; NVX time ABI v1", + generation: &KNOWN_GENERATIONS[6], + cpuid: &[ + leaf( + 0x0, + None, + [0x1c, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1, + None, + [0xb0_0f21, 0x800, 0xf6da_3203, 0x78b_fbff], + [0xffff_ffff, 0xffff, 0xf7df_ffff, 0xffff_ffff], + ), + leaf( + 0x2, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x3, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x4, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x5, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x6, + None, + [0x4, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x0), + [0x1, 0xf1bf_07a9, 0x40_5f46, 0x110], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffef, 0xffff_ffff], + ), + leaf( + 0x7, + Some(0x1), + [0x30, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x9, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xa, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xc, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x0), + [0xe7, 0x0, 0x980, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x1), + [0xf, 0x0, 0x0, 0x0], + [0xffff_ffff, 0x0, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x2), + [0x100, 0x240, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x5), + [0x40, 0x340, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x6), + [0x200, 0x380, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xd, + Some(0x7), + [0x400, 0x580, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xe, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0xf, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x10, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x11, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x12, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x13, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x14, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x15, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x16, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x17, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x18, + Some(0x0), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x19, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x1c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0001, + None, + [0xb0_0f21, 0x0, 0x40_02f3, 0x2fd3_fbff], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0002, + None, + [0x2044_4d41, 0x4359_5045, 0x6f72_5020, 0x7373_6563], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0003, + None, + [0x2820_726f, 0x6972_7554, 0x296e, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0004, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0005, + None, + [0xff60_ff40, 0xff60_ff40, 0x300c_0140, 0x2008_0140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0006, + None, + [0x4080_2040, 0x6080_4040, 0x400_8140, 0xc00_9140], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0007, + None, + [0x0, 0x0, 0x0, 0x100], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0008, + None, + [0x3030, 0x4002_0005, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_0f00, 0xffff_ffff], + ), + leaf( + 0x8000_0009, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000d, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000e, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_000f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0010, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0011, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0012, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0013, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0014, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0015, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0016, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0017, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0018, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0019, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001a, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001b, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001c, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x0), + [0x121, 0x2c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x1), + [0x122, 0x1c0_003f, 0x3f, 0x0], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x2), + [0x143, 0x3c0_003f, 0x3ff, 0x2], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x3), + [0x163, 0x3c0_003f, 0x7fff, 0x1], + [0xfc00_3fff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001d, + Some(0x4), + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_001e, + None, + [0x0, 0x0, 0x0, 0x0], + [0x0, 0xffff_0000, 0xffff_f800, 0xffff_ffff], + ), + leaf( + 0x8000_001f, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0020, + None, + [0x0, 0x0, 0x0, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + leaf( + 0x8000_0021, + None, + [0x204, 0x0, 0x6, 0x0], + [0xffff_ffff, 0xffff_ffff, 0xffff_ffff, 0xffff_ffff], + ), + ], + xcr0: 0xe7, + xss: 0x0, + xsave_components: &[ + xsave(2, 256, 576, false, false, false), + xsave(5, 64, 832, false, false, false), + xsave(6, 512, 896, false, false, false), + xsave(7, 1024, 1408, false, false, false), + ], + physical_address_width: 48, + msrs: &[], + provenance_method: "derived by cpu_profile::derive under openvmm-cpu-profile-policy/v1: per-register consensus within each backend, then across backends, of the hosts' supported CPU surfaces", + provenance_sources: &[source( + "kvm", + 1, + &["sha256:3977cef711857870a034a72b50a56d06d4f45f6e8751fcb32203a89c27e3d501"], + )], + }, ]; diff --git a/vmm_core/cpu_profile/src/test_support.rs b/vmm_core/cpu_profile/src/test_support.rs index 17920241194..217593e5332 100644 --- a/vmm_core/cpu_profile/src/test_support.rs +++ b/vmm_core/cpu_profile/src/test_support.rs @@ -203,7 +203,243 @@ pub(crate) const MILAN_WHP_CPUID: [(u32, Option, [u32; 4]); 57] = [ /// Returns [`MILAN_WHP_CPUID`] as fingerprint entries. pub(crate) fn milan_whp_entries() -> Vec { - MILAN_WHP_CPUID + entries(&MILAN_WHP_CPUID) +} + +/// The CPUID of an AMD EPYC 9V74 (Genoa: family 0x19, model 0x11, +/// stepping 1) as KVM reports it (`KVM_GET_SUPPORTED_CPUID`) in an Azure VM +/// with nested virtualization, a GitHub-hosted Actions runner, which +/// `--cpu-fingerprint` recorded: each leaf, subleaf, and the registers. +/// That KVM offers only the x87, SSE, and AVX XSAVE states, PSFD without a +/// `SPEC_CTRL` control, and, as KVM does on every AMD host, Intel's +/// `IA32_ARCH_CAPABILITIES` enumeration. +pub(crate) const GENOA_KVM_CPUID: [(u32, Option, [u32; 4]); 71] = [ + (0x0, None, [0x1c, 0x6874_7541, 0x444d_4163, 0x6974_6e65]), + ( + 0x1, + None, + [0x00a1_0f11, 0x0010_0800, 0xf7fa_3203, 0x078b_fbff], + ), + (0x2, None, [0; 4]), + (0x3, None, [0; 4]), + (0x4, Some(0), [0; 4]), + (0x5, None, [0; 4]), + (0x6, None, [0x4, 0, 0, 0]), + (0x7, Some(0), [0, 0x219c_07ab, 0x0040_0604, 0x2000_0010]), + (0x8, None, [0; 4]), + (0x9, None, [0; 4]), + (0xa, None, [0; 4]), + (0xb, Some(0), [0; 4]), + (0xc, None, [0; 4]), + (0xd, Some(0), [0x7, 0x340, 0x340, 0]), + (0xd, Some(1), [0xf, 0x340, 0, 0]), + (0xd, Some(2), [0x100, 0x240, 0, 0]), + (0xe, None, [0; 4]), + (0xf, Some(0), [0; 4]), + (0x10, Some(0), [0; 4]), + (0x11, None, [0; 4]), + (0x12, Some(0), [0; 4]), + (0x13, None, [0; 4]), + (0x14, Some(0), [0; 4]), + (0x15, None, [0; 4]), + (0x16, None, [0; 4]), + (0x17, Some(0), [0; 4]), + (0x18, Some(0), [0; 4]), + (0x19, None, [0; 4]), + (0x1a, None, [0; 4]), + (0x1b, None, [0; 4]), + (0x1c, None, [0; 4]), + ( + 0x4000_0000, + None, + [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d], + ), + (0x4000_0001, None, [0x0100_7efb, 0, 0, 0]), + ( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + ), + ( + 0x8000_0001, + None, + [0x00a1_0f11, 0x4000_0000, 0x0040_03f7, 0x2fd3_fbff], + ), + (0x8000_0002, None, [0; 4]), + (0x8000_0003, None, [0; 4]), + (0x8000_0004, None, [0; 4]), + ( + 0x8000_0005, + None, + [0xff48_ff40, 0xff48_ff40, 0x2008_0140, 0x2008_0140], + ), + ( + 0x8000_0006, + None, + [0x5c00_2200, 0x6c00_4200, 0x0400_6140, 0x0a00_9140], + ), + (0x8000_0007, None, [0, 0, 0, 0x100]), + (0x8000_0008, None, [0x0030_3030, 0x1000_0005, 0x400f, 0]), + (0x8000_0009, None, [0; 4]), + (0x8000_000a, None, [0x1, 0x8, 0, 0x1000_9479]), + (0x8000_000b, None, [0; 4]), + (0x8000_000c, None, [0; 4]), + (0x8000_000d, None, [0; 4]), + (0x8000_000e, None, [0; 4]), + (0x8000_000f, None, [0; 4]), + (0x8000_0010, None, [0; 4]), + (0x8000_0011, None, [0; 4]), + (0x8000_0012, None, [0; 4]), + (0x8000_0013, None, [0; 4]), + (0x8000_0014, None, [0; 4]), + (0x8000_0015, None, [0; 4]), + (0x8000_0016, None, [0; 4]), + (0x8000_0017, None, [0; 4]), + (0x8000_0018, None, [0; 4]), + (0x8000_0019, None, [0; 4]), + (0x8000_001a, None, [0x2, 0, 0, 0]), + (0x8000_001b, None, [0; 4]), + (0x8000_001c, None, [0; 4]), + (0x8000_001d, Some(0), [0x4121, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(1), [0x4122, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(2), [0x4143, 0x01c0_003f, 0x7ff, 0x2]), + ( + 0x8000_001d, + Some(3), + [0x0003_c163, 0x03c0_003f, 0x7fff, 0x1], + ), + (0x8000_001d, Some(4), [0; 4]), + (0x8000_001e, None, [0; 4]), + (0x8000_001f, None, [0; 4]), + (0x8000_0020, None, [0; 4]), + (0x8000_0021, None, [0x204, 0, 0x6, 0]), +]; + +/// Returns [`GENOA_KVM_CPUID`] as fingerprint entries. +pub(crate) fn genoa_kvm_entries() -> Vec { + entries(&GENOA_KVM_CPUID) +} + +/// The CPUID of an AMD EPYC 9V45 (Turin: family 0x1a, model 2, stepping 1) +/// as KVM reports it in an Azure VM with nested virtualization, a +/// GitHub-hosted Actions runner, which `--cpu-fingerprint` recorded. That +/// KVM offers the AVX-512 XSAVE states too, and, as on the Genoa runner, +/// PSFD without a `SPEC_CTRL` control and Intel's +/// `IA32_ARCH_CAPABILITIES` enumeration. +pub(crate) const TURIN_KVM_CPUID: [(u32, Option, [u32; 4]); 75] = [ + (0x0, None, [0x1c, 0x6874_7541, 0x444d_4163, 0x6974_6e65]), + ( + 0x1, + None, + [0x00b0_0f21, 0x0010_0800, 0xf7fa_3203, 0x078b_fbff], + ), + (0x2, None, [0; 4]), + (0x3, None, [0; 4]), + (0x4, Some(0), [0; 4]), + (0x5, None, [0; 4]), + (0x6, None, [0x4, 0, 0, 0]), + (0x7, Some(0), [0x1, 0xf1bf_07ab, 0x0040_5f46, 0x2000_0110]), + (0x7, Some(1), [0x30, 0, 0, 0]), + (0x8, None, [0; 4]), + (0x9, None, [0; 4]), + (0xa, None, [0; 4]), + (0xb, Some(0), [0; 4]), + (0xc, None, [0; 4]), + (0xd, Some(0), [0xe7, 0x980, 0x980, 0]), + (0xd, Some(1), [0xf, 0x980, 0, 0]), + (0xd, Some(2), [0x100, 0x240, 0, 0]), + (0xd, Some(5), [0x40, 0x340, 0, 0]), + (0xd, Some(6), [0x200, 0x380, 0, 0]), + (0xd, Some(7), [0x400, 0x580, 0, 0]), + (0xe, None, [0; 4]), + (0xf, Some(0), [0; 4]), + (0x10, Some(0), [0; 4]), + (0x11, None, [0; 4]), + (0x12, Some(0), [0; 4]), + (0x13, None, [0; 4]), + (0x14, Some(0), [0; 4]), + (0x15, None, [0; 4]), + (0x16, None, [0; 4]), + (0x17, Some(0), [0; 4]), + (0x18, Some(0), [0; 4]), + (0x19, None, [0; 4]), + (0x1a, None, [0; 4]), + (0x1b, None, [0; 4]), + (0x1c, None, [0; 4]), + ( + 0x4000_0000, + None, + [0x4000_0001, 0x4b4d_564b, 0x564b_4d56, 0x4d], + ), + (0x4000_0001, None, [0x0100_7efb, 0, 0, 0]), + ( + 0x8000_0000, + None, + [0x8000_0021, 0x6874_7541, 0x444d_4163, 0x6974_6e65], + ), + ( + 0x8000_0001, + None, + [0x00b0_0f21, 0x4000_0000, 0x0040_03f7, 0x2fd3_fbff], + ), + (0x8000_0002, None, [0; 4]), + (0x8000_0003, None, [0; 4]), + (0x8000_0004, None, [0; 4]), + ( + 0x8000_0005, + None, + [0xff60_ff40, 0xff60_ff40, 0x300c_0140, 0x2008_0140], + ), + ( + 0x8000_0006, + None, + [0x4080_2040, 0x6080_4040, 0x0400_8140, 0x0c00_9140], + ), + (0x8000_0007, None, [0, 0, 0, 0x100]), + (0x8000_0008, None, [0x0030_3030, 0x5002_0005, 0x400f, 0]), + (0x8000_0009, None, [0; 4]), + (0x8000_000a, None, [0x1, 0x8, 0, 0x1000_9479]), + (0x8000_000b, None, [0; 4]), + (0x8000_000c, None, [0; 4]), + (0x8000_000d, None, [0; 4]), + (0x8000_000e, None, [0; 4]), + (0x8000_000f, None, [0; 4]), + (0x8000_0010, None, [0; 4]), + (0x8000_0011, None, [0; 4]), + (0x8000_0012, None, [0; 4]), + (0x8000_0013, None, [0; 4]), + (0x8000_0014, None, [0; 4]), + (0x8000_0015, None, [0; 4]), + (0x8000_0016, None, [0; 4]), + (0x8000_0017, None, [0; 4]), + (0x8000_0018, None, [0; 4]), + (0x8000_0019, None, [0; 4]), + (0x8000_001a, None, [0x2, 0, 0, 0]), + (0x8000_001b, None, [0; 4]), + (0x8000_001c, None, [0; 4]), + (0x8000_001d, Some(0), [0x4121, 0x02c0_003f, 0x3f, 0]), + (0x8000_001d, Some(1), [0x4122, 0x01c0_003f, 0x3f, 0]), + (0x8000_001d, Some(2), [0x4143, 0x03c0_003f, 0x3ff, 0x2]), + ( + 0x8000_001d, + Some(3), + [0x0003_c163, 0x03c0_003f, 0x7fff, 0x1], + ), + (0x8000_001d, Some(4), [0; 4]), + (0x8000_001e, None, [0; 4]), + (0x8000_001f, None, [0; 4]), + (0x8000_0020, None, [0; 4]), + (0x8000_0021, None, [0x204, 0, 0x6, 0]), +]; + +/// Returns [`TURIN_KVM_CPUID`] as fingerprint entries. +pub(crate) fn turin_kvm_entries() -> Vec { + entries(&TURIN_KVM_CPUID) +} + +/// Returns a recorded CPUID `table` as fingerprint entries. +fn entries(table: &[(u32, Option, [u32; 4])]) -> Vec { + table .iter() .map(|&(leaf, subleaf, registers)| CpuidEntry::new(leaf, subleaf, registers)) .collect() diff --git a/vmm_core/virt_whp/src/time_abi.rs b/vmm_core/virt_whp/src/time_abi.rs index 20771c54219..ebf08326545 100644 --- a/vmm_core/virt_whp/src/time_abi.rs +++ b/vmm_core/virt_whp/src/time_abi.rs @@ -1280,11 +1280,13 @@ pub(crate) mod test_cpuid { use vm_topology::processor::x86::X2ApicState; /// Pinned CPU profiles of each vendor. - pub const PROFILES: [&str; 4] = [ + pub const PROFILES: [&str; 6] = [ "intel.skylake-sp.v1", "intel.icelake-sp.v1", "intel.emeraldrapids.v1", "amd.milan.v1", + "amd.genoa.v1", + "amd.turin.v1", ]; /// Returns the topology of a VM with `vp_count` VPs in one socket. @@ -1307,6 +1309,18 @@ pub(crate) mod test_cpuid { .any(|result| result.function == function && result.index == Some(index)) } + /// Returns the number of `profile`'s entries with a nonzero value, each + /// of which VP 0 of a partition of `profile` presents nonzero. The + /// profile's explicit zero entries, whose number grows with its maximum + /// leaves, present zero. + pub fn nonzero_entries(profile: &CpuProfile) -> usize { + profile + .cpuid() + .iter() + .filter(|entry| entry.values() != [0; 4]) + .count() + } + /// Returns `value`, a result of `function` that VP 0 of a partition of /// `profile` presents, without the bits in which core's table and the /// profile programming of `de39f6aab` deliberately differ: that @@ -1917,10 +1931,10 @@ mod tests { .iter() .filter(|&&(function, index)| new.observe(0, function, index) != [0; 4]) .count(); - let entries = cpu_profile::pinned(profile).unwrap().cpuid().len(); + let nonzero = test_cpuid::nonzero_entries(pinned); assert!( - populated >= entries / 2, - "{profile}: {populated} populated results of {entries} profile entries" + populated >= nonzero, + "{profile}: {populated} populated results of {nonzero} nonzero profile entries" ); } } @@ -2888,7 +2902,7 @@ mod whp_tests { programming.results.len() ); assert!(differences.is_empty(), "{differences:#?}"); - assert!(populated >= profile.cpuid().len() / 2); + assert!(populated >= test_cpuid::nonzero_entries(profile)); } #[test] From 762bc1c7a203b16aee752324d6a4ab0bde1a713a Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Tue, 6 Oct 2026 11:40:55 -0700 Subject: [PATCH 292/304] virtiofs: add a per-subtree access policy to microVM shares Part of microsoft/nvx#299 (requirement 4 of microsoft/nvx#290). A sandbox needs exposure controls inside one exported share, as AWF's Cloud Hypervisor backend gets from its staged mount tree: the guest may write only to declared subtrees of a read-write share, and sensitive subtrees are hidden, with explicit exemptions that stay readable. Until now a microVM share had one access mode for all of its contents, and its denied paths could not overlap, so a readable child of a denied directory could not be exposed again. Policy ------ Each share now has three sets of canonical share-relative paths: - Denied paths hide themselves and everything below them (as before). - Allowed paths expose a subtree of a denied path again. The nearest denied or allowed path that contains an allowed path must be a denied path, and the nearest one that contains a denied path must be an allowed path, if any, so a denied path may nest inside an allowed path but no path is redundant. - Writable paths, when present, are the only files and directories of a read-write share that the guest can modify; the rest is read-only. They must not overlap, must be visible, and need a read-write share. A hidden directory on the way from a denied path to an allowed path is traverse-only: the guest can look it up, but only as a directory; a listing shows only the directories that lead to allowed paths; any other name fails with EACCES; and neither the directory nor its entries can be modified. Allowed paths otherwise follow the share's write policy. Enforcement (virtiofs) ---------------------- - `microvm/policy.rs` holds the validated `SubtreePolicy`: the visibility of a path (visible, traverse-only, or hidden) and whether it is writable, from the nearest policy path that contains it. - Every namespace mutation (create, mkdir, mknod, symlink, link, unlink, rmdir, rename, including RENAME_EXCHANGE) checks both its directories and every entry path that it creates, removes, or replaces, so a writable directory cannot remove, replace, or swap out a traverse-only directory and expose its hidden entries. Inode mutations (write, open for writing or truncation, setattr, setxattr, removexattr) require every name that the guest has used for the object to be writable. They fail with EROFS before HostFs touches the host. - A hard link to an object that the guest may not modify, from a writable directory, fails with EXDEV, as a link across mounts does, because it would make the object writable through the new name. - When the share is attached, the objects of hidden denied paths are pinned as before, and traverse-only directories are pinned to their own paths, so a host rename, bind mount, junction, or hard link cannot expose their hidden entries at another name. Attaching fails when a traverse-only path is missing or not a directory, and at run time anything but a directory at a traverse-only path is hidden. - Directory enumeration lists traverse-only entries only when they are directories. When the host filesystem reports an entry's type as unknown, as some filesystems may, HostFs resolves it with lstat. - A successful RENAME_EXCHANGE swaps the names that the guest knows for both objects, in the alias preflight and in the bookkeeping after the host call. HostFs previously treated an exchange as a one-way rename: it dropped the destination's aliases, so the destination's FUSE node kept a path that named the other object, and later operations and write-policy checks used the wrong name. Snapshots --------- - The device state of an attachment with allowed or writable paths uses the new schema version 7, which records the denied, allowed, and writable paths along with the ownership mode. Earlier releases accept only versions 4 to 6 and ignore unknown fields, so they reject that state instead of restoring the share without its policy, which could let the guest write everywhere. Deny-only attachments keep versions 5 and 6, so earlier releases still restore them. - Restore requires the exact saved policy, and refuses a handle open for writing on an object that is no longer writable under it. - The snapshot contract records the allowed and writable paths in two new filesystem fields (mesh 14 and 15), which the restore-time configuration must match. Snapshots that predate them decode with no allowed or writable paths. Configuration and CLI --------------------- - `--mount-allow HOST_PATH` and `--mount-write HOST_PATH` are repeatable and follow `--mount-deny`'s rules: each must exist inside its export, on its filesystem, without symbolic-link or reparse-point components; a relative path resolves in the only `--mount`, and with two shares each path must be absolute and applies to the share that contains it. - `MicrovmFilesystemConfig::with_access_policy` validates the complete policy; errors name the kind of path. The `VirtioFsProfile::Microvm` resource carries the allowed and writable paths to the device. - Policy paths are canonical in lexical (string) order, as the snapshot contract records them. The entry previously sorted denied paths by path components and the device checked component order, so a denied pair such as `a-b` and `a/b` was rejected by one layer or the other. - ttrpc restore now applies the snapshot's denied, allowed, and writable paths. It previously restored the first share without its denied paths. - The entry checks the requested form of every policy path for symbolic-link and reparse-point components before canonicalizing it. It previously inspected only the canonical path, whose links canonicalization had already resolved, so a denied path such as `link/secret` was silently applied to the link's target despite the documented rule. Such a path is now rejected. Guide ----- virtio-fs.md gains an "Access policy" section covering the three options, traverse-only directories, the EROFS and EXDEV errors, and the caveat that host hard links and bind mounts inside an export bypass a path-based policy. cli.md, snapshots.md, and grpc.md describe the options, the snapshot fields, and the restore requirements. Testing ------- - virtiofs: `policy.rs` unit tests for visibility, write narrowing, nesting, pinning, and rejected policies; `policy_tests.rs` drives the FUSE handlers end to end: every mutation outside writable paths fails with EROFS, a hard link of a read-only file fails with EXDEV, a denied parent lists and exposes only its allowed child, a traverse-only directory cannot be removed, renamed, replaced, or swapped with RENAME_EXCHANGE, hidden objects stay hidden after host renames and hard links, and the policy survives save and restore, which rejects a different policy, an older schema, and writable handles outside the writable paths. A successful RENAME_EXCHANGE keeps both nodes on their objects, and a traverse-only entry of unknown type is listed only when the host reports a directory. `profile.rs` covers lexical order. - openvmm_defs: policy validation and its errors. - openvmm_helpers: the contract records and validates the policy. - openvmm_entry: `--mount-allow` and `--mount-write` parsing, attribution to shares, rejection of invalid policies and of policy paths through a symbolic link, and restore matching. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 524d2f17-3ad9-40a2-860f-f21353002e78 --- .../src/reference/devices/virtio/virtio-fs.md | 104 ++- Guide/src/reference/openvmm/management/cli.md | 22 +- .../src/reference/openvmm/management/grpc.md | 9 +- Guide/src/user_guide/openvmm/snapshots.md | 6 +- openvmm/openvmm_defs/src/microvm.rs | 362 ++++++++-- openvmm/openvmm_entry/src/cli_args/microvm.rs | 46 ++ openvmm/openvmm_entry/src/microvm/config.rs | 8 +- .../openvmm_entry/src/microvm/filesystem.rs | 566 ++++++++++++--- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 7 +- .../openvmm_helpers/src/snapshot/microvm.rs | 66 +- .../virtio/virtio_resources/src/fs/microvm.rs | 8 + vm/devices/virtio/virtiofs/src/inode.rs | 44 +- vm/devices/virtio/virtiofs/src/lib.rs | 52 +- .../virtio/virtiofs/src/microvm/device.rs | 16 +- .../virtio/virtiofs/src/microvm/file.rs | 3 +- vm/devices/virtio/virtiofs/src/microvm/fs.rs | 65 +- .../virtio/virtiofs/src/microvm/inode.rs | 81 ++- .../virtio/virtiofs/src/microvm/limits.rs | 9 +- vm/devices/virtio/virtiofs/src/microvm/mod.rs | 3 + .../virtio/virtiofs/src/microvm/policy.rs | 389 +++++++++++ .../virtiofs/src/microvm/policy_tests.rs | 656 ++++++++++++++++++ .../virtio/virtiofs/src/microvm/profile.rs | 143 +++- .../virtio/virtiofs/src/microvm/resolver.rs | 6 + .../virtiofs/src/microvm/saved_state.rs | 20 + .../virtio/virtiofs/src/microvm/state.rs | 92 ++- 25 files changed, 2530 insertions(+), 253 deletions(-) create mode 100644 vm/devices/virtio/virtiofs/src/microvm/policy.rs create mode 100644 vm/devices/virtio/virtiofs/src/microvm/policy_tests.rs diff --git a/Guide/src/reference/devices/virtio/virtio-fs.md b/Guide/src/reference/devices/virtio/virtio-fs.md index 0cfd9df106e..6c8f4046ed2 100644 --- a/Guide/src/reference/devices/virtio/virtio-fs.md +++ b/Guide/src/reference/devices/virtio/virtio-fs.md @@ -78,9 +78,9 @@ other share denies or exposes with a different access mode. On Linux, OpenVMM also compares the filesystem sources that each directory reaches, from `/proc/self/mountinfo`, including the mounts below it, so a bind mount or nested mount can't expose part of one share as, or inside, the other. -With several attachments, each `--mount-deny` must be an absolute host path, -and it applies to the share whose directory contains it. `--mount-owner` -applies to every share. +With several attachments, each `--mount-deny`, `--mount-allow`, and +`--mount-write` must be an absolute host path, and it applies to the share +whose directory contains it. `--mount-owner` applies to every share. For an active cold-boot attachment, the profile adds `virtfs_dir`, `virtfs_tag`, and `virtfs_mode` bootstrap tokens to the kernel command line, @@ -88,10 +88,71 @@ one triplet per share in slot order. The first slot is always discoverable, and the second slot's `virtio_mmio.device=` token follows the control console's. Active attachment policies and their canonical absolute host paths become snapshot-authoritative. -Repeat `--mount-deny` to hide existing host files or directories. OpenVMM -canonicalizes each entry relative to its export and rejects paths outside the -roots, a root itself, overlapping entries, symlink/reparse components, and -nested-mount crossings before opening the device. + +### Access policy + +Three repeatable options refine what the guest can see and modify inside a +share. Each names an existing host file or directory. A relative path is +relative to the host directory of the only `--mount`; with several shares, +the path must be absolute, and it applies to the share whose directory +contains it. + +- `--mount-deny ` hides the path and everything below it. +- `--mount-allow ` exposes the path and everything below it again, + inside a denied path. The nearest denied or allowed path that contains an + allowed path must be a denied path, and the nearest one that contains a + denied path must be an allowed path, if any, so denied paths may nest inside + allowed paths. +- `--mount-write `, on a read-write share, makes the path and + everything below it one of the only parts of the share that the guest can + modify. The rest of the share is read-only. Writable paths must not + overlap or lie in a hidden part of the share. A read-only share rejects + them. + +```bash +openvmm --machine microvm \ + --mount /tmp/gh-aw,path/to/gh-aw,rw \ + --mount-deny path/to/gh-aw/mcp-logs \ + --mount-allow path/to/gh-aw/mcp-logs/payloads \ + --mount-write path/to/gh-aw/agent \ + --kernel path/to/vmlinux --initrd path/to/initramfs.cpio.gz +``` + +A hidden directory on the way from a denied path to an allowed path is +*traverse-only*. The guest can look it up and list it, but the listing shows +only the directories that lead to allowed paths. Every other name in it fails +with `EACCES`, and the guest cannot modify the directory or its entries. In +the example above, the guest sees `mcp-logs` with only `payloads` in it. +Allowed paths follow the share's write policy: here `payloads` is read-only +because it is not inside a writable path, but without `--mount-write` it +would be writable like the rest of the read-write share. + +The guest's modifications of a traverse-only directory or its entries, and +with `--mount-write` of anything outside the writable paths, fail with +`EROFS` before HostFs touches the host: +creating, linking, renaming, or removing an entry; writing, truncating, or +opening a file for writing; and changing attributes or extended attributes. +Moving an entry into or out of a writable path changes a read-only directory, +so it also fails with `EROFS`. A hard link to a read-only file from inside a +writable path would make the file writable, so it fails with `EXDEV`, as a +link across mounts does. An object with several names is writable only if +every name that the guest has used for it is writable. + +OpenVMM canonicalizes each policy path relative to its export and rejects +paths that do not exist, paths outside the roots, a root itself, duplicates, +redundant or contradictory combinations, symlink/reparse components, and +nested-mount crossings before opening the device. HostFs also requires every +traverse-only path to be a directory when it attaches the share. + +```admonish warning +The policy applies to names inside the export. A host hard link or bind +mount that makes a read-only object reachable inside a writable path also +makes that object writable, and one that makes part of a denied path +reachable elsewhere exposes that part. HostFs pins the objects of denied +paths and traverse-only directories, so it rejects those objects themselves +at any other name, but not their contents. Do not place such aliases inside +an export. +``` A read-write attachment lets the guest create symbolic links, so ordinary build tools (package managers, virtual environments, and language @@ -173,13 +234,19 @@ lookups and newly opened directories still observe the live host tree. Restoring a snapshot captured with an active attachment requires a fresh `--mount` argument for every captured share, in the same order, with the exact -same denied-path set, canonical host path, guest target, mode, and -`--mount-owner` mode. Identity validation remains independent: before any vCPU +same denied, allowed, and writable paths, canonical host path, guest target, +mode, and `--mount-owner` mode. Identity validation remains independent: before any vCPU starts, OpenVMM pins the supplied root and validates its saved root and object identities. Missing, moved, replaced, ambiguous, or no-longer-reopenable objects fail restore. A saved symbolic link is revalidated as the link itself, without being followed, and an alias whose ancestor has become a link fails -restore. +restore. A handle that the guest held open for writing must still be on a +writable path. + +The device state of an attachment with allowed or writable paths records its +complete access policy, and a restore must supply the same one. OpenVMM +releases that predate these paths reject that state rather than restore the +share without them. `--mount-owner caller` applies only to guest requests. Capture and restore still revalidate saved names and reopen saved handles as the OpenVMM process. @@ -238,11 +305,16 @@ any ancestor that is a symbolic link or reparse point before each operation. Denied paths are enforced in the server namespace rather than by guest mount layout. Lookup and mutation operations reject denied prefixes, directory enumeration omits their names, and denied root object identities reject -hard-link, junction, and bind-mount aliases. A guest-created link cannot reach -a denied path because the guest resolves it and every resulting host lookup -applies the same policy. Mounting the same virtio-fs tag at another guest path -does not change the policy, and each share's policy applies only to requests -for its own tag. +hard-link, junction, and bind-mount aliases. A traverse-only directory's object +is accepted only at its own path, and only as a directory, so neither a host +rename nor another name for it exposes its hidden entries. A guest-created +link cannot reach a denied path because the guest resolves it and every +resulting host lookup applies the same policy. Writable paths are enforced the +same way: every mutation checks the names that it changes and the object that +it modifies against the policy before the host operation. Mounting the same +virtio-fs tag at another guest path, or remounting it read-write, does not +change the policy, and each share's policy applies only to requests for its +own tag. ```admonish warning On Windows, the check for host-created NT symbolic links and junctions in @@ -260,6 +332,8 @@ false success. - Device implementation: `vm/devices/virtio/virtiofs/` +- Share access policy: + `vm/devices/virtio/virtiofs/src/microvm/policy.rs` - Per-thread filesystem credentials: `vm/devices/support/fs/lxutil/src/unix/credentials.rs` - FUSE session implementation: diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index d48de5de325..6d098c7e08a 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -221,6 +221,23 @@ describes the source definitions. same device, hard-link aliases, symlinks, junctions, and bind-mount aliases cannot re-expose it. + `--mount-allow ` is repeatable and exposes an existing file or + directory inside a `--mount-deny` path again. The hidden directories on the + way to it become traverse-only: the guest can look them up and list the + entries that lead to allowed paths, but sees nothing else in them and cannot + modify them. The nearest denied or allowed path that contains an allowed + path must be a denied path; a denied path may nest inside an allowed path. + + `--mount-write ` is repeatable and limits the guest's writes in a + read-write share to existing files or directories and everything below + them. The rest of the share is read-only, and the guest's modifications + there fail with `EROFS`; a hard link to a read-only file from a writable + path fails with `EXDEV`. Writable paths must not overlap or be hidden by a + denied path, and a read-only share rejects them. `--mount-allow` and + `--mount-write` paths follow the same canonicalization and share + attribution rules as `--mount-deny`. + See [virtio-fs](../../devices/virtio/virtio-fs.md#access-policy). + `--mount-owner ` selects the host identity of the guest's operations. `vmm`, the default, runs them all as OpenVMM. `caller` runs each one as its guest caller's UID and GID, without supplementary groups or @@ -235,8 +252,9 @@ describes the source definitions. Filesystem snapshots contain guest-visible FUSE and queue state, not host directory contents or native handles. An active snapshot requires `--mount` again for every captured share, in capture order, with the exact - canonical host path, guest target, access mode, denied-path set, and - `--mount-owner` mode; the live roots and every saved object identity are + canonical host path, guest target, access mode, denied, allowed, and + writable paths, and `--mount-owner` mode; the live roots and every saved + object identity are also revalidated before vCPUs start. A snapshot captured without `--mount` may remain dormant or bind one new attachment to the first slot. The resumed guest must then explicitly run `mount -t virtiofs microvm ` diff --git a/Guide/src/reference/openvmm/management/grpc.md b/Guide/src/reference/openvmm/management/grpc.md index 3ff78e48174..5a6cbb6b921 100644 --- a/Guide/src/reference/openvmm/management/grpc.md +++ b/Guide/src/reference/openvmm/management/grpc.md @@ -177,9 +177,12 @@ before vCPUs start. The device uses MMIO `0xd0002000`, IRQ 7, and selects `DevicesConfig.virtiofs_config` may bind one HostFs attachment to the fixed microVM slot. Set `tag` to `microvm`, supply `root_path`, and set `guest_mount_target` to an absolute Linux path. `read_write=false` selects the -default read-only policy. Restoring an active attachment requires the exact -same canonical root path, tag, guest target, and access mode, and the live root -must retain its saved identity. A snapshot captured with the slot dormant may +default read-only policy. The management API cannot set an access policy, so a +cold-booted attachment has no denied, allowed, or writable paths. Restoring an +active attachment requires the exact same canonical root path, tag, guest +target, and access mode, and the live root must retain its saved identity; +the snapshot's denied, allowed, and writable paths apply again. A snapshot +captured with the slot dormant may omit the attachment or supply a new one; after `ResumeVM`, the guest explicitly mounts tag `microvm`. The fixed device uses MMIO `0xd0001000`, IRQ 6, one request queue, and no DAX window. The management API binds only the first diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 0c3f1533f22..dcd2de1f6c2 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -345,7 +345,8 @@ For microVM virtio-fs, the manifest always records the fixed, guest-discoverable first slot. A dormant slot has no host attachment or filesystem policy and carries explicit dormant device-private state. An active slot also records the stable attachment ID, exact canonical host path, pinned -root identity, guest mount target, access mode, ownership mode, no-DAX queue +root identity, guest mount target, access mode, denied, allowed, and writable +paths, ownership mode, no-DAX queue policy, and `live-revalidate` restore mode. A second slot is recorded only when a second share was attached, with the same fields for that share. Its device-private payload records FUSE negotiation, namespace IDs and aliases, @@ -355,7 +356,8 @@ never serialized. Restoring active slots requires a fresh `--mount ` attachment for each, in capture -order, with the same canonical host path, target, and mode, and the same +order, with the same canonical host path, target, and mode, the same +`--mount-deny`, `--mount-allow`, and `--mount-write` paths, and the same `--mount-owner` mode. OpenVMM independently validates the roots and every saved object identity before starting a vCPU. A dormant-slot snapshot may restore without an attachment or bind one new attachment to the first slot. diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index f6b2855fdc8..1a942e9eb42 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -369,10 +369,105 @@ pub struct MicrovmFilesystemConfig { pub access: MicrovmFilesystemAccess, /// Canonical host-relative paths hidden by the virtio-fs server. pub denied_paths: Vec, + /// Canonical host-relative paths inside denied paths that the virtio-fs + /// server exposes again. + pub allowed_paths: Vec, + /// Canonical host-relative paths that are the only parts of a read-write + /// filesystem that the guest can modify; none makes all of it writable. + pub writable_paths: Vec, /// Snapshot-authoritative host identity of guest operations. pub owner: MicrovmFilesystemOwner, } +/// A kind of path in the access policy of a microVM filesystem. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MicrovmFilesystemPathKind { + /// A path hidden from the guest. + Denied, + /// A path inside a denied path that the guest can reach again. + Allowed, + /// One of the only paths that the guest can modify. + Writable, +} + +impl std::fmt::Display for MicrovmFilesystemPathKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Denied => "denied", + Self::Allowed => "allowed", + Self::Writable => "writable", + }) + } +} + +/// Returns whether the canonical relative path `inner` equals or is inside the +/// canonical relative path `outer`. +fn policy_path_contains(outer: &str, inner: &str) -> bool { + inner + .strip_prefix(outer) + .is_some_and(|suffix| suffix.is_empty() || suffix.starts_with('/')) +} + +/// Returns whether the nearest denied or allowed path that contains `path` is +/// a denied path (`Some(true)`), an allowed path (`Some(false)`), or neither. +/// `path` itself counts only when `inclusive`. +fn nearest_policy_rule( + path: &str, + denied: &[String], + allowed: &[String], + inclusive: bool, +) -> Option { + denied + .iter() + .map(|rule| (rule, true)) + .chain(allowed.iter().map(|rule| (rule, false))) + .filter(|(rule, _)| (inclusive || *rule != path) && policy_path_contains(rule, path)) + .max_by_key(|(rule, _)| rule.len()) + .map(|(_, denied)| denied) +} + +/// Validates one list of canonical, host-relative policy paths in lexical +/// order, without regard to the filesystem's other policy paths. +pub fn validate_microvm_filesystem_policy_paths( + kind: MicrovmFilesystemPathKind, + paths: &[String], +) -> Result<(), InvalidMicrovmFilesystemConfig> { + if paths.len() > 128 { + return Err(InvalidMicrovmFilesystemConfig::TooManyPolicyPaths(kind)); + } + let total_bytes = paths + .iter() + .try_fold(0usize, |total, path| total.checked_add(path.len())) + .ok_or(InvalidMicrovmFilesystemConfig::PolicyPathsTooLarge(kind))?; + if total_bytes > 16 * 1024 { + return Err(InvalidMicrovmFilesystemConfig::PolicyPathsTooLarge(kind)); + } + for path in paths { + if path.is_empty() + || path.len() > 4096 + || path.starts_with('/') + || path.ends_with('/') + || path.chars().any(|character| { + character.is_whitespace() || matches!(character, '\0' | '\\' | ':') + }) + || path + .split('/') + .any(|component| component.is_empty() || matches!(component, "." | "..")) + { + return Err(InvalidMicrovmFilesystemConfig::InvalidPolicyPath( + kind, + path.clone(), + )); + } + } + if paths.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(InvalidMicrovmFilesystemConfig::NonCanonicalPolicyPaths( + kind, + )); + } + Ok(()) +} + impl MicrovmFilesystemConfig { /// Validates and constructs the microVM filesystem configuration. pub fn new( @@ -399,6 +494,8 @@ impl MicrovmFilesystemConfig { guest_mount_target, access, denied_paths: Vec::new(), + allowed_paths: Vec::new(), + writable_paths: Vec::new(), owner: MicrovmFilesystemOwner::Vmm, }) } @@ -411,50 +508,72 @@ impl MicrovmFilesystemConfig { /// Adds a canonical, non-overlapping denied-path policy. pub fn with_denied_paths( + self, + denied_paths: Vec, + ) -> Result { + self.with_access_policy(denied_paths, Vec::new(), Vec::new()) + } + + /// Adds the complete access policy of the filesystem, as canonical + /// host-relative paths in lexical order. + /// + /// Denied paths hide subtrees, and allowed paths expose subtrees of denied + /// paths again. The nearest denied or allowed path that contains a denied + /// path must be an allowed path, if any, and the nearest one that contains + /// an allowed path must be a denied path. Writable paths, when present, are + /// the only parts of a read-write filesystem that the guest can modify; they + /// must not overlap or be inside a denied path that no allowed path exposes. + pub fn with_access_policy( mut self, denied_paths: Vec, + allowed_paths: Vec, + writable_paths: Vec, ) -> Result { - if denied_paths.len() > 128 { - return Err(InvalidMicrovmFilesystemConfig::TooManyDeniedPaths); - } - let total_bytes = denied_paths - .iter() - .try_fold(0usize, |total, path| total.checked_add(path.len())) - .ok_or(InvalidMicrovmFilesystemConfig::DeniedPathsTooLarge)?; - if total_bytes > 16 * 1024 { - return Err(InvalidMicrovmFilesystemConfig::DeniedPathsTooLarge); - } + validate_microvm_filesystem_policy_paths(MicrovmFilesystemPathKind::Denied, &denied_paths)?; + validate_microvm_filesystem_policy_paths( + MicrovmFilesystemPathKind::Allowed, + &allowed_paths, + )?; + validate_microvm_filesystem_policy_paths( + MicrovmFilesystemPathKind::Writable, + &writable_paths, + )?; for path in &denied_paths { - if path.is_empty() - || path.len() > 4096 - || path.starts_with('/') - || path.ends_with('/') - || path.chars().any(|character| { - character.is_whitespace() || matches!(character, '\0' | '\\' | ':') - }) - || path - .split('/') - .any(|component| component.is_empty() || matches!(component, "." | "..")) + if nearest_policy_rule(path, &denied_paths, &allowed_paths, false) == Some(true) { + return Err(InvalidMicrovmFilesystemConfig::OverlappingDeniedPaths); + } + } + for path in &allowed_paths { + if denied_paths.contains(path) + || nearest_policy_rule(path, &denied_paths, &allowed_paths, false) != Some(true) { - return Err(InvalidMicrovmFilesystemConfig::InvalidDeniedPath( + return Err(InvalidMicrovmFilesystemConfig::MisplacedAllowedPath( path.clone(), )); } } - let mut canonical = denied_paths.clone(); - canonical.sort_unstable(); - if canonical != denied_paths { - return Err(InvalidMicrovmFilesystemConfig::NonCanonicalDeniedPaths); + if self.access.is_read_only() && !writable_paths.is_empty() { + return Err(InvalidMicrovmFilesystemConfig::ReadOnlyWritablePaths); } - for pair in denied_paths.windows(2) { - if pair[1] - .strip_prefix(&pair[0]) - .is_some_and(|suffix| suffix.starts_with('/')) + for (index, path) in writable_paths.iter().enumerate() { + if writable_paths + .iter() + .enumerate() + .any(|(other_index, other)| { + other_index != index && policy_path_contains(other, path) + }) { - return Err(InvalidMicrovmFilesystemConfig::OverlappingDeniedPaths); + return Err(InvalidMicrovmFilesystemConfig::OverlappingWritablePaths); + } + if nearest_policy_rule(path, &denied_paths, &allowed_paths, true) == Some(true) { + return Err(InvalidMicrovmFilesystemConfig::HiddenWritablePath( + path.clone(), + )); } } self.denied_paths = denied_paths; + self.allowed_paths = allowed_paths; + self.writable_paths = writable_paths; Ok(self) } @@ -513,23 +632,39 @@ pub enum InvalidMicrovmFilesystemConfig { "invalid guest mount target '{0}': expected an absolute non-root Linux path without empty, dot, parent, whitespace, backslash, or '=' components" )] InvalidGuestTarget(String), - /// A denied path was not a canonical relative path. + /// A policy path was not a canonical relative path. #[error( - "invalid denied path '{0}': expected a relative path without empty, dot, parent, whitespace, backslash, or ':' components" + "invalid {0} path '{1}': expected a relative path without empty, dot, parent, whitespace, backslash, or ':' components" )] - InvalidDeniedPath(String), - /// The denied-path list exceeded its count bound. - #[error("microVM filesystem permits at most 128 denied paths")] - TooManyDeniedPaths, - /// The denied-path list exceeded its aggregate byte bound. - #[error("microVM filesystem denied paths exceed the 16-KiB aggregate limit")] - DeniedPathsTooLarge, - /// The denied-path list did not preserve canonical lexical order. - #[error("microVM filesystem denied paths are not in canonical order")] - NonCanonicalDeniedPaths, - /// One denied path contained another denied path. - #[error("microVM filesystem denied paths overlap")] + InvalidPolicyPath(MicrovmFilesystemPathKind, String), + /// A policy-path list exceeded its count bound. + #[error("microVM filesystem permits at most 128 {0} paths")] + TooManyPolicyPaths(MicrovmFilesystemPathKind), + /// A policy-path list exceeded its aggregate byte bound. + #[error("microVM filesystem {0} paths exceed the 16-KiB aggregate limit")] + PolicyPathsTooLarge(MicrovmFilesystemPathKind), + /// A policy-path list was not unique and in canonical lexical order. + #[error("microVM filesystem {0} paths are not in canonical order")] + NonCanonicalPolicyPaths(MicrovmFilesystemPathKind), + /// A denied path was inside another denied path, with no allowed path + /// between them. + #[error("microVM filesystem denied paths overlap without an allowed path between them")] OverlappingDeniedPaths, + /// An allowed path was not inside a denied path, or was inside another + /// allowed path with no denied path between them. + #[error( + "microVM filesystem allowed path '{0}' must be inside a denied path, with no other allowed path between them" + )] + MisplacedAllowedPath(String), + /// A read-only filesystem had writable paths. + #[error("a read-only microVM filesystem cannot have writable paths")] + ReadOnlyWritablePaths, + /// One writable path contained another. + #[error("microVM filesystem writable paths overlap")] + OverlappingWritablePaths, + /// A writable path was inside a denied path that no allowed path exposes. + #[error("microVM filesystem writable path '{0}' is hidden by a denied path")] + HiddenWritablePath(String), /// More filesystems were attached than the microVM has virtio-fs slots. #[error("microVM permits at most {} filesystems", MICROVM_FILESYSTEM_SLOTS.len())] TooManyFilesystems, @@ -1910,6 +2045,145 @@ mod tests { ); } + #[test] + fn microvm_filesystem_access_policy_is_canonical_and_consistent() { + use MicrovmFilesystemAccess::ReadOnly; + use MicrovmFilesystemAccess::ReadWrite; + use MicrovmFilesystemPathKind::Allowed; + use MicrovmFilesystemPathKind::Denied; + use MicrovmFilesystemPathKind::Writable; + + fn owned(paths: &[&str]) -> Vec { + paths.iter().map(|path| (*path).to_owned()).collect() + } + let policy = |access, denied: &[&str], allowed: &[&str], writable: &[&str]| { + filesystem("/workspace", access).with_access_policy( + owned(denied), + owned(allowed), + owned(writable), + ) + }; + + // A denied path may nest inside an allowed path, and lexical order + // puts `logs-old` before `logs/...`. + let config = policy( + ReadWrite, + &["logs", "logs-old", "logs/payloads/private"], + &["logs/payloads"], + &["logs/payloads/out", "out"], + ) + .unwrap(); + assert_eq!(config.allowed_paths, ["logs/payloads"]); + assert_eq!(config.writable_paths, ["logs/payloads/out", "out"]); + assert_eq!( + config + .clone() + .with_owner(MicrovmFilesystemOwner::Caller) + .owner, + MicrovmFilesystemOwner::Caller + ); + assert_eq!( + filesystem("/workspace", ReadOnly) + .with_denied_paths(owned(&["secrets"])) + .unwrap() + .denied_paths, + ["secrets"] + ); + + for (access, denied, allowed, writable, expected) in [ + ( + ReadWrite, + vec!["a", "a/b"], + vec![], + vec![], + InvalidMicrovmFilesystemConfig::OverlappingDeniedPaths, + ), + ( + ReadWrite, + vec!["b", "a"], + vec![], + vec![], + InvalidMicrovmFilesystemConfig::NonCanonicalPolicyPaths(Denied), + ), + ( + ReadWrite, + vec!["a"], + vec!["a/c", "a/b"], + vec![], + InvalidMicrovmFilesystemConfig::NonCanonicalPolicyPaths(Allowed), + ), + ( + ReadWrite, + vec![], + vec![], + vec!["out", "out"], + InvalidMicrovmFilesystemConfig::NonCanonicalPolicyPaths(Writable), + ), + ( + ReadWrite, + vec!["a"], + vec!["a/../b"], + vec![], + InvalidMicrovmFilesystemConfig::InvalidPolicyPath(Allowed, "a/../b".to_owned()), + ), + ( + ReadWrite, + vec![], + vec![], + vec!["/out"], + InvalidMicrovmFilesystemConfig::InvalidPolicyPath(Writable, "/out".to_owned()), + ), + ( + ReadWrite, + vec!["a"], + vec!["b"], + vec![], + InvalidMicrovmFilesystemConfig::MisplacedAllowedPath("b".to_owned()), + ), + ( + ReadWrite, + vec!["a"], + vec!["a"], + vec![], + InvalidMicrovmFilesystemConfig::MisplacedAllowedPath("a".to_owned()), + ), + ( + ReadWrite, + vec!["a"], + vec!["a/b", "a/b/c"], + vec![], + InvalidMicrovmFilesystemConfig::MisplacedAllowedPath("a/b/c".to_owned()), + ), + ( + ReadOnly, + vec![], + vec![], + vec!["out"], + InvalidMicrovmFilesystemConfig::ReadOnlyWritablePaths, + ), + ( + ReadWrite, + vec![], + vec![], + vec!["out", "out/cache"], + InvalidMicrovmFilesystemConfig::OverlappingWritablePaths, + ), + ( + ReadWrite, + vec!["a"], + vec!["a/b/c"], + vec!["a/b"], + InvalidMicrovmFilesystemConfig::HiddenWritablePath("a/b".to_owned()), + ), + ] { + assert_eq!(policy(access, &denied, &allowed, &writable), Err(expected)); + } + assert_eq!( + policy(ReadWrite, &[], &[], &vec!["out"; 129]), + Err(InvalidMicrovmFilesystemConfig::TooManyPolicyPaths(Writable)) + ); + } + #[test] fn microvm_discovery_places_later_filesystem_slots_after_the_control_console() { let network: MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 8c435890943..9afdfdc54cb 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -362,6 +362,42 @@ pub struct MicrovmCli { )] pub microvm_mount_deny: Vec, + /// Expose an existing host path inside a `--mount-deny` path again + /// + /// The path and everything below it become visible in the `--mount` + /// whose host directory contains it, subject to the export's write + /// policy. The hidden directories on the way to it become traverse-only: + /// the guest can look them up and list the entries that lead to allowed + /// paths, but sees nothing else in them and cannot modify them. The + /// nearest `--mount-deny` or `--mount-allow` path that contains the path + /// must be a `--mount-deny` path, which may itself lie inside another + /// `--mount-allow` path. A relative path is relative to the host + /// directory of the only `--mount`; with several, the path must be + /// absolute. + #[clap( + long = "mount-allow", + value_name = "HOST_PATH", + requires = "microvm_mount" + )] + pub microvm_mount_allow: Vec, + + /// Limit the guest's writes in a read-write `--mount` to an existing host + /// path + /// + /// Repeat to declare more writable files or directories. With any, they + /// and everything below them are the only parts of the `--mount` whose + /// host directory contains them that the guest can modify; the rest of + /// it is read-only, and the guest's modifications there fail with EROFS. + /// The paths must not overlap or be hidden by `--mount-deny`. A relative + /// path is relative to the host directory of the only `--mount`; with + /// several, the path must be absolute. + #[clap( + long = "mount-write", + value_name = "HOST_PATH", + requires = "microvm_mount" + )] + pub microvm_mount_write: Vec, + /// Select the host identity of the guest's `--mount` operations /// /// `vmm` (the default) performs every operation as OpenVMM. `caller` @@ -535,6 +571,8 @@ impl Options { && self.microvm.allow_endpoint.is_empty() && self.microvm.microvm_mount.is_empty() && self.microvm.microvm_mount_deny.is_empty() + && self.microvm.microvm_mount_allow.is_empty() + && self.microvm.microvm_mount_write.is_empty() && self.microvm.microvm_sandbox_block.is_empty() && self.microvm.microvm_workload_identity.is_none() && self.microvm.microvm_lifecycle.is_none() @@ -780,6 +818,14 @@ impl Options { self.microvm.microvm_mount_deny.len() <= 128, "microVM filesystem permits at most 128 denied paths" ); + anyhow::ensure!( + self.microvm.microvm_mount_allow.len() <= 128, + "microVM filesystem permits at most 128 allowed paths" + ); + anyhow::ensure!( + self.microvm.microvm_mount_write.len() <= 128, + "microVM filesystem permits at most 128 writable paths" + ); openvmm_defs::microvm::validate_microvm_filesystems( &self .microvm diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 3ede267782c..93d77f7e149 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -105,7 +105,11 @@ impl<'a> MicrovmConfigBuilder<'a> { let filesystems = if active { effective_microvm_filesystems( &opt.microvm.microvm_mount, - &opt.microvm.microvm_mount_deny, + super::filesystem::MicrovmFilesystemPolicyPaths { + denied: &opt.microvm.microvm_mount_deny, + allowed: &opt.microvm.microvm_mount_allow, + writable: &opt.microvm.microvm_mount_write, + }, opt.microvm.microvm_mount_owner.map_or( openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm, Into::into, @@ -567,6 +571,8 @@ impl<'a> MicrovmConfigBuilder<'a> { root_identity: filesystem.attachment.identity.clone(), read_only: filesystem.config.access.is_read_only(), denied_paths: filesystem.config.denied_paths.clone(), + allowed_paths: filesystem.config.allowed_paths.clone(), + writable_paths: filesystem.config.writable_paths.clone(), caller_identity: filesystem.config.owner.is_caller(), }, ) diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index 4403caf77fb..f0a0963dedf 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -131,9 +131,14 @@ fn canonical_microvm_filesystem_root( Ok((canonical, identity_kind, identity)) } -fn canonical_microvm_filesystem_denied_paths( +/// Resolves the host paths that one of `--mount-deny`, `--mount-allow`, or +/// `--mount-write` requests in the export root `root_path` to unique, canonical +/// relative paths in lexical order. Each must exist inside the export root, on +/// its filesystem, and be reachable without a symbolic link. +fn canonical_microvm_filesystem_policy_paths( root_path: &Path, requested: &[PathBuf], + kind: openvmm_defs::microvm::MicrovmFilesystemPathKind, ) -> anyhow::Result> { #[cfg(unix)] let root_metadata = fs_err::symlink_metadata(root_path).with_context(|| { @@ -143,99 +148,111 @@ fn canonical_microvm_filesystem_denied_paths( ) })?; let mut relative_paths = Vec::with_capacity(requested.len()); - for denied in requested { + for path in requested { anyhow::ensure!( - !denied.as_os_str().is_empty() - && !denied.components().any(|component| matches!( + !path.as_os_str().is_empty() + && !path.components().any(|component| matches!( component, std::path::Component::CurDir | std::path::Component::ParentDir )), - "microVM denied path is empty or contains a dot or parent component: {}", - denied.display() + "microVM {kind} path is empty or contains a dot or parent component: {}", + path.display() ); - let absolute = if denied.is_absolute() { - denied.clone() + let absolute = if path.is_absolute() { + path.clone() } else { - root_path.join(denied) + root_path.join(path) }; let canonical = fs_err::canonicalize(&absolute).with_context(|| { format!( - "failed to canonicalize microVM denied path {}", + "failed to canonicalize microVM {kind} path {}", absolute.display() ) })?; - let relative = canonical.strip_prefix(root_path).with_context(|| { - format!( - "microVM denied path resolves outside the filesystem export root: {}", - denied.display() - ) - })?; - anyhow::ensure!( - !relative.as_os_str().is_empty(), - "microVM denied path cannot hide the complete filesystem export" - ); - - let mut current = root_path.to_owned(); - for component in relative.components() { - let std::path::Component::Normal(component) = component else { - anyhow::bail!("microVM denied path contains a non-normal component"); - }; - current.push(component); + // Canonicalizing resolves links, so inspect the requested path itself. + let mut current = PathBuf::new(); + for component in absolute.components() { + current.push(component.as_os_str()); + if !matches!(component, std::path::Component::Normal(_)) { + continue; + } let metadata = fs_err::symlink_metadata(¤t).with_context(|| { format!( - "failed to inspect microVM denied path component {}", + "failed to inspect microVM {kind} path component {}", current.display() ) })?; anyhow::ensure!( !metadata.file_type().is_symlink(), - "microVM denied path component is a symbolic link: {}", + "microVM {kind} path component is a symbolic link: {}", current.display() ); #[cfg(windows)] anyhow::ensure!( std::os::windows::fs::MetadataExt::file_attributes(&metadata) & 0x400 == 0, - "microVM denied path component is a reparse point: {}", + "microVM {kind} path component is a reparse point: {}", current.display() ); } + let relative = canonical.strip_prefix(root_path).with_context(|| { + format!( + "microVM {kind} path resolves outside the filesystem export root: {}", + path.display() + ) + })?; + anyhow::ensure!( + !relative.as_os_str().is_empty(), + "microVM {kind} path cannot {} the complete filesystem export", + if kind == openvmm_defs::microvm::MicrovmFilesystemPathKind::Denied { + "hide" + } else { + "be" + } + ); + anyhow::ensure!( + relative + .components() + .all(|component| matches!(component, std::path::Component::Normal(_))), + "microVM {kind} path contains a non-normal component" + ); #[cfg(unix)] { use std::os::unix::fs::MetadataExt as _; - let denied_metadata = fs_err::symlink_metadata(&canonical)?; + let metadata = fs_err::symlink_metadata(&canonical)?; anyhow::ensure!( - denied_metadata.dev() == root_metadata.dev(), - "microVM denied path crosses a nested mount: {}", - denied.display() + metadata.dev() == root_metadata.dev(), + "microVM {kind} path crosses a nested mount: {}", + path.display() ); } relative_paths.push(relative.to_owned()); } - relative_paths.sort_unstable(); - for pair in relative_paths.windows(2) { - anyhow::ensure!( - pair[0] != pair[1] && !pair[1].starts_with(&pair[0]), - "microVM denied paths must be unique and non-overlapping" - ); - } - let encoded = relative_paths + let mut encoded = relative_paths .iter() .map(|path| { path.iter() .map(|component| { component .to_str() - .context("microVM denied path is not valid UTF-8") + .with_context(|| format!("microVM {kind} path is not valid UTF-8")) }) .collect::>>() .map(|components| components.join("/")) }) .collect::>>()?; - openvmm_defs::microvm::MicrovmFilesystemConfig::new( - "/unused".to_owned(), - openvmm_defs::microvm::MicrovmFilesystemAccess::ReadOnly, - )? - .with_denied_paths(encoded.clone())?; + // The snapshot contract records the paths in lexical order. + encoded.sort_unstable(); + for pair in encoded.windows(2) { + anyhow::ensure!( + pair[0] != pair[1], + "microVM {kind} paths must be unique{}", + match kind { + openvmm_defs::microvm::MicrovmFilesystemPathKind::Allowed => "", + _ => " and non-overlapping", + } + ); + } + openvmm_defs::microvm::validate_microvm_filesystem_policy_paths(kind, &encoded)?; Ok(encoded) } @@ -268,27 +285,57 @@ pub(crate) fn microvm_filesystem_attachment( )) } +/// The host paths that `--mount-deny`, `--mount-allow`, and `--mount-write` +/// request, each in the `--mount` whose host directory contains it. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct MicrovmFilesystemPolicyPaths<'a> { + /// Paths hidden from the guest. + pub(crate) denied: &'a [PathBuf], + /// Paths inside denied paths that the guest can reach again. + pub(crate) allowed: &'a [PathBuf], + /// The only paths of a read-write export that the guest can modify. + pub(crate) writable: &'a [PathBuf], +} + /// Builds the filesystem of `requested` in its canonical host root, with the -/// denied paths that `--mount-deny` attributed to it. +/// access policy that `--mount-deny`, `--mount-allow`, and `--mount-write` +/// attributed to it. fn microvm_filesystem_from_root( requested: &cli_args::microvm::MicrovmMountCli, (root_path, attachment): ( String, openvmm_helpers::snapshot::microvm::SnapshotAttachment, ), - denied_paths: &[PathBuf], + policy: MicrovmFilesystemPolicyPaths<'_>, owner: openvmm_defs::microvm::MicrovmFilesystemOwner, ) -> anyhow::Result { + use openvmm_defs::microvm::MicrovmFilesystemPathKind; + if owner.is_caller() { validate_caller_owned_root(Path::new(&root_path))?; } - let denied_paths = - canonical_microvm_filesystem_denied_paths(Path::new(&root_path), denied_paths)?; + let root = Path::new(&root_path); let config = openvmm_defs::microvm::MicrovmFilesystemConfig::new( requested.guest_target.clone(), requested.access, )? - .with_denied_paths(denied_paths)? + .with_access_policy( + canonical_microvm_filesystem_policy_paths( + root, + policy.denied, + MicrovmFilesystemPathKind::Denied, + )?, + canonical_microvm_filesystem_policy_paths( + root, + policy.allowed, + MicrovmFilesystemPathKind::Allowed, + )?, + canonical_microvm_filesystem_policy_paths( + root, + policy.writable, + MicrovmFilesystemPathKind::Writable, + )?, + )? .with_owner(owner); Ok(EffectiveMicrovmFilesystem { config, @@ -517,41 +564,33 @@ mod mount_sources { } } -/// Builds the filesystems of the `--mount` options, which occupy the fixed -/// virtio-fs slots in order. Each `--mount-deny` applies to the export root -/// that contains it; a relative path is relative to the only export root. -fn microvm_filesystems_from_mounts( - requested: &[cli_args::microvm::MicrovmMountCli], - denied_paths: &[PathBuf], - owner: openvmm_defs::microvm::MicrovmFilesystemOwner, -) -> anyhow::Result> { - let slots = &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS; - anyhow::ensure!( - requested.len() <= slots.len(), - openvmm_defs::microvm::InvalidMicrovmFilesystemConfig::TooManyFilesystems - ); - let roots = requested - .iter() - .zip(slots) - .map(|(mount, slot)| microvm_filesystem_attachment(&mount.host_path, slot)) - .collect::>>()?; - validate_microvm_filesystem_roots(&roots)?; - +/// Attributes the host paths of one access-policy option to the export roots +/// that contain them: every path to the only root, which resolves a relative +/// path, or each absolute path to the root that contains it. +fn attribute_microvm_filesystem_policy_paths( + roots: &[( + String, + openvmm_helpers::snapshot::microvm::SnapshotAttachment, + )], + requested: &[PathBuf], + option: &str, + kind: openvmm_defs::microvm::MicrovmFilesystemPathKind, +) -> anyhow::Result>> { let mut attributed = vec![Vec::new(); roots.len()]; match roots.len() { - 0 => anyhow::ensure!(denied_paths.is_empty(), "--mount-deny requires --mount"), - 1 => attributed[0].extend_from_slice(denied_paths), + 0 => anyhow::ensure!(requested.is_empty(), "{option} requires --mount"), + 1 => attributed[0].extend_from_slice(requested), _ => { - for denied in denied_paths { + for path in requested { anyhow::ensure!( - denied.is_absolute(), - "with several --mount options, --mount-deny requires an absolute host path: {}", - denied.display() + path.is_absolute(), + "with several --mount options, {option} requires an absolute host path: {}", + path.display() ); - let canonical = fs_err::canonicalize(denied).with_context(|| { + let canonical = fs_err::canonicalize(path).with_context(|| { format!( - "failed to canonicalize microVM denied path {}", - denied.display() + "failed to canonicalize microVM {kind} path {}", + path.display() ) })?; let index = roots @@ -559,20 +598,75 @@ fn microvm_filesystems_from_mounts( .position(|(root, _)| canonical.starts_with(root)) .with_context(|| { format!( - "microVM denied path resolves outside the filesystem export roots: {}", - denied.display() + "microVM {kind} path resolves outside the filesystem export roots: {}", + path.display() ) })?; - attributed[index].push(denied.clone()); + attributed[index].push(path.clone()); } } } + Ok(attributed) +} + +/// Builds the filesystems of the `--mount` options, which occupy the fixed +/// virtio-fs slots in order. Each `--mount-deny`, `--mount-allow`, and +/// `--mount-write` applies to the export root that contains it; a relative +/// path is relative to the only export root. +fn microvm_filesystems_from_mounts( + requested: &[cli_args::microvm::MicrovmMountCli], + policy: MicrovmFilesystemPolicyPaths<'_>, + owner: openvmm_defs::microvm::MicrovmFilesystemOwner, +) -> anyhow::Result> { + use openvmm_defs::microvm::MicrovmFilesystemPathKind; + + let slots = &openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS; + anyhow::ensure!( + requested.len() <= slots.len(), + openvmm_defs::microvm::InvalidMicrovmFilesystemConfig::TooManyFilesystems + ); + let roots = requested + .iter() + .zip(slots) + .map(|(mount, slot)| microvm_filesystem_attachment(&mount.host_path, slot)) + .collect::>>()?; + validate_microvm_filesystem_roots(&roots)?; + + let denied = attribute_microvm_filesystem_policy_paths( + &roots, + policy.denied, + "--mount-deny", + MicrovmFilesystemPathKind::Denied, + )?; + let allowed = attribute_microvm_filesystem_policy_paths( + &roots, + policy.allowed, + "--mount-allow", + MicrovmFilesystemPathKind::Allowed, + )?; + let writable = attribute_microvm_filesystem_policy_paths( + &roots, + policy.writable, + "--mount-write", + MicrovmFilesystemPathKind::Writable, + )?; let filesystems = requested .iter() .zip(roots) - .zip(&attributed) - .map(|((mount, root), denied)| microvm_filesystem_from_root(mount, root, denied, owner)) + .enumerate() + .map(|(index, (mount, root))| { + microvm_filesystem_from_root( + mount, + root, + MicrovmFilesystemPolicyPaths { + denied: &denied[index], + allowed: &allowed[index], + writable: &writable[index], + }, + owner, + ) + }) .collect::>>()?; openvmm_defs::microvm::validate_microvm_filesystems( &filesystems @@ -667,7 +761,11 @@ pub(crate) fn microvm_filesystem_from_snapshot( mode => anyhow::bail!("snapshot microVM filesystem access mode '{mode}' is unsupported"), }; openvmm_defs::microvm::MicrovmFilesystemConfig::new(saved.guest_mount_target.clone(), access)? - .with_denied_paths(saved.denied_paths.clone()) + .with_access_policy( + saved.denied_paths.clone(), + saved.allowed_paths.clone(), + saved.writable_paths.clone(), + ) .context("snapshot microVM filesystem policy is invalid") .and_then(|config| { Ok(config.with_owner( @@ -706,12 +804,12 @@ pub(crate) fn microvm_filesystem_slot_from_snapshot( /// one `--mount` may bind a new filesystem to it. pub(super) fn effective_microvm_filesystems( requested: &[cli_args::microvm::MicrovmMountCli], - denied_paths: &[PathBuf], + policy: MicrovmFilesystemPolicyPaths<'_>, owner: openvmm_defs::microvm::MicrovmFilesystemOwner, restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, ) -> anyhow::Result> { let Some(restore) = restore else { - return microvm_filesystems_from_mounts(requested, denied_paths, owner); + return microvm_filesystems_from_mounts(requested, policy, owner); }; let has_device = microvm_filesystem_slot_from_snapshot(restore)?; @@ -741,7 +839,7 @@ pub(super) fn effective_microvm_filesystems( requested.len() == 1, "a dormant-slot snapshot can attach only one --mount on restore" ); - return microvm_filesystems_from_mounts(requested, denied_paths, owner); + return microvm_filesystems_from_mounts(requested, policy, owner); } anyhow::ensure!( !requested.is_empty(), @@ -769,7 +867,7 @@ pub(super) fn effective_microvm_filesystems( ); configs.push(config); } - let effective = microvm_filesystems_from_mounts(requested, denied_paths, owner)?; + let effective = microvm_filesystems_from_mounts(requested, policy, owner)?; for ((effective, saved), config) in effective.iter().zip(&saved).zip(&configs) { anyhow::ensure!( !saved.canonical_host_path.is_empty(), @@ -785,7 +883,7 @@ pub(super) fn effective_microvm_filesystems( ); anyhow::ensure!( effective.config == *config, - "restore-time filesystem denied paths do not match the snapshot contract" + "restore-time filesystem access policy (denied, allowed, or writable paths) does not match the snapshot contract" ); } Ok(effective) @@ -802,6 +900,20 @@ mod tests { const VMM: openvmm_defs::microvm::MicrovmFilesystemOwner = openvmm_defs::microvm::MicrovmFilesystemOwner::Vmm; + const DENIED: openvmm_defs::microvm::MicrovmFilesystemPathKind = + openvmm_defs::microvm::MicrovmFilesystemPathKind::Denied; + const NO_POLICY: MicrovmFilesystemPolicyPaths<'static> = MicrovmFilesystemPolicyPaths { + denied: &[], + allowed: &[], + writable: &[], + }; + + fn denied(paths: &[PathBuf]) -> MicrovmFilesystemPolicyPaths<'_> { + MicrovmFilesystemPolicyPaths { + denied: paths, + ..NO_POLICY + } + } /// Resolves at most one `--mount`, like the single-share tests expect. fn effective_microvm_filesystem( @@ -810,8 +922,11 @@ mod tests { owner: openvmm_defs::microvm::MicrovmFilesystemOwner, restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, ) -> anyhow::Result> { - let mut filesystems = - effective_microvm_filesystems(requested, denied_paths, owner, restore)?; + let policy = MicrovmFilesystemPolicyPaths { + denied: denied_paths, + ..Default::default() + }; + let mut filesystems = effective_microvm_filesystems(requested, policy, owner, restore)?; assert!(filesystems.len() <= 1); Ok(filesystems.pop()) } @@ -951,12 +1066,20 @@ mod tests { let outside = tempfile::tempdir().unwrap(); assert!( - canonical_microvm_filesystem_denied_paths(root.path(), &[outside.path().to_owned()]) - .is_err() + canonical_microvm_filesystem_policy_paths( + root.path(), + &[outside.path().to_owned()], + DENIED + ) + .is_err() ); assert!( - canonical_microvm_filesystem_denied_paths(root.path(), &[secrets.clone(), secrets]) - .is_err() + canonical_microvm_filesystem_policy_paths( + root.path(), + &[secrets.clone(), secrets], + DENIED + ) + .is_err() ); } @@ -1280,7 +1403,8 @@ mod tests { ); options.validate_microvm_options().unwrap(); let filesystems = - effective_microvm_filesystems(&options.microvm.microvm_mount, &[], VMM, None).unwrap(); + effective_microvm_filesystems(&options.microvm.microvm_mount, NO_POLICY, VMM, None) + .unwrap(); let summary = filesystems .iter() .map(|filesystem| { @@ -1329,7 +1453,7 @@ mod tests { let mounts = workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"); let filesystems = effective_microvm_filesystems( &mount_options(&mounts, &[]).microvm.microvm_mount, - &[toolcache_secrets.clone(), workspace_secrets.clone()], + denied(&[toolcache_secrets.clone(), workspace_secrets.clone()]), VMM, None, ) @@ -1340,7 +1464,7 @@ mod tests { // With several shares, a relative path names no export root. let error = effective_microvm_filesystems( &mount_options(&mounts, &[]).microvm.microvm_mount, - &[PathBuf::from("secrets")], + denied(&[PathBuf::from("secrets")]), VMM, None, ) @@ -1353,7 +1477,7 @@ mod tests { let outside = tempfile::tempdir().unwrap(); let error = effective_microvm_filesystems( &mount_options(&mounts, &[]).microvm.microvm_mount, - &[outside.path().to_owned()], + denied(&[outside.path().to_owned()]), VMM, None, ) @@ -1376,12 +1500,15 @@ mod tests { (parent.path(), parent.path()), ] { let options = mount_options(&workspace_and_toolcache(first, second, "ro"), &[]); - let error = - match effective_microvm_filesystems(&options.microvm.microvm_mount, &[], VMM, None) - { - Err(error) => error, - Ok(_) => panic!("overlapping host directories were accepted"), - }; + let error = match effective_microvm_filesystems( + &options.microvm.microvm_mount, + NO_POLICY, + VMM, + None, + ) { + Err(error) => error, + Ok(_) => panic!("overlapping host directories were accepted"), + }; assert!(error.to_string().contains("must not overlap"), "{error:#}"); } @@ -1395,7 +1522,8 @@ mod tests { ); assert!(options.validate_microvm_options().is_err()); assert!( - effective_microvm_filesystems(&options.microvm.microvm_mount, &[], VMM, None).is_err() + effective_microvm_filesystems(&options.microvm.microvm_mount, NO_POLICY, VMM, None) + .is_err() ); } @@ -1431,7 +1559,12 @@ mod tests { assert_eq!(contract.microvm_additional_filesystems.len(), 1); let restore = |mounts: &[String]| { let options = mount_options(mounts, &["--restore-snapshot", "snapshot"]); - effective_microvm_filesystems(&options.microvm.microvm_mount, &[], VMM, Some(&contract)) + effective_microvm_filesystems( + &options.microvm.microvm_mount, + NO_POLICY, + VMM, + Some(&contract), + ) }; let mounts = workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"); @@ -1477,7 +1610,7 @@ mod tests { ); let error = match effective_microvm_filesystems( &options.microvm.microvm_mount, - &[], + NO_POLICY, VMM, Some(&contract), ) { @@ -1486,4 +1619,213 @@ mod tests { }; assert!(error.to_string().contains("only one --mount")); } + + /// Resolves the access policy of the `--mount` options in `options`. + fn options_filesystems( + options: &Options, + restore: Option<&openvmm_helpers::snapshot::microvm::SnapshotMachineContract>, + ) -> anyhow::Result> { + effective_microvm_filesystems( + &options.microvm.microvm_mount, + MicrovmFilesystemPolicyPaths { + denied: &options.microvm.microvm_mount_deny, + allowed: &options.microvm.microvm_mount_allow, + writable: &options.microvm.microvm_mount_write, + }, + VMM, + restore, + ) + } + + /// Creates a share with a denied `logs` directory that holds an allowed + /// `logs/payloads` directory, a writable `out` directory, and a writable + /// `build.log` file. + fn policy_share() -> tempfile::TempDir { + let root = tempfile::tempdir().unwrap(); + fs_err::create_dir_all(root.path().join("logs").join("payloads")).unwrap(); + fs_err::create_dir(root.path().join("out")).unwrap(); + fs_err::write(root.path().join("build.log"), b"log").unwrap(); + root + } + + fn policy_options(root: &Path, mode: &str, policy: &[&str]) -> Options { + mount_options(&[format!("/workspace,{},{mode}", root.display())], policy) + } + + #[test] + fn mount_allow_and_mount_write_build_the_access_policy() { + let root = policy_share(); + let options = policy_options( + root.path(), + "rw", + &[ + "--mount-deny", + "logs", + "--mount-allow", + &root + .path() + .join("logs") + .join("payloads") + .display() + .to_string(), + "--mount-write", + "out", + "--mount-write", + "build.log", + ], + ); + let filesystems = options_filesystems(&options, None).unwrap(); + options.validate_microvm_options().unwrap(); + let config = &filesystems[0].config; + assert_eq!(config.denied_paths, ["logs"]); + assert_eq!(config.allowed_paths, ["logs/payloads"]); + assert_eq!(config.writable_paths, ["build.log", "out"]); + + for (mode, policy, expected) in [ + ( + "ro", + &["--mount-write", "out"][..], + "read-only microVM filesystem cannot have writable paths", + ), + ( + "rw", + &["--mount-deny", "logs", "--mount-allow", "out"][..], + "must be inside a denied path", + ), + ( + "rw", + &["--mount-deny", "logs", "--mount-write", "logs/payloads"][..], + "hidden by a denied path", + ), + ( + "rw", + &["--mount-write", "out", "--mount-write", "out"][..], + "writable paths must be unique", + ), + ("rw", &["--mount-write", "."][..], "dot or parent component"), + ( + "rw", + &["--mount-write", "missing"][..], + "failed to canonicalize microVM writable path", + ), + ] { + let options = policy_options(root.path(), mode, policy); + let error = match options_filesystems(&options, None) { + Err(error) => format!("{error:#}"), + Ok(_) => panic!("unsafe access policy {policy:?} was accepted"), + }; + assert!(error.contains(expected), "{error}"); + } + let root_path = root.path().display().to_string(); + let options = policy_options(root.path(), "rw", &["--mount-write", &root_path]); + let error = format!("{:#}", options_filesystems(&options, None).unwrap_err()); + assert!( + error.contains("cannot be the complete filesystem export"), + "{error}" + ); + } + + #[cfg(unix)] + #[test] + fn policy_paths_reject_symbolic_link_components() { + use openvmm_defs::microvm::MicrovmFilesystemPathKind; + + let root = policy_share(); + std::os::unix::fs::symlink("logs", root.path().join("link")).unwrap(); + // Each path resolves inside the export, but only through the link. + for kind in [ + MicrovmFilesystemPathKind::Denied, + MicrovmFilesystemPathKind::Allowed, + MicrovmFilesystemPathKind::Writable, + ] { + for requested in [ + PathBuf::from("link"), + PathBuf::from("link/payloads"), + root.path().join("link").join("payloads"), + ] { + let error = canonical_microvm_filesystem_policy_paths( + root.path(), + std::slice::from_ref(&requested), + kind, + ) + .unwrap_err(); + assert!( + format!("{error:#}").contains("component is a symbolic link"), + "{requested:?}: {error:#}" + ); + } + } + assert_eq!( + canonical_microvm_filesystem_policy_paths( + root.path(), + &[PathBuf::from("logs/payloads")], + MicrovmFilesystemPathKind::Writable, + ) + .unwrap(), + ["logs/payloads"] + ); + } + + #[test] + fn mount_write_applies_to_the_share_that_contains_it() { + let workspace = policy_share(); + let toolcache = tempfile::tempdir().unwrap(); + let mounts = workspace_and_toolcache(workspace.path(), toolcache.path(), "ro"); + let out = workspace.path().join("out").display().to_string(); + let options = mount_options(&mounts, &["--mount-write", &out]); + let filesystems = options_filesystems(&options, None).unwrap(); + assert_eq!(filesystems[0].config.writable_paths, ["out"]); + assert!(filesystems[1].config.writable_paths.is_empty()); + + let options = mount_options(&mounts, &["--mount-write", "out"]); + let error = options_filesystems(&options, None).unwrap_err(); + assert!( + error + .to_string() + .contains("--mount-write requires an absolute host path"), + "{error:#}" + ); + } + + #[test] + fn filesystem_restore_requires_the_same_access_policy() { + let root = policy_share(); + let policy = [ + "--mount-deny", + "logs", + "--mount-allow", + "logs/payloads", + "--mount-write", + "out", + ]; + let saved = options_filesystems(&policy_options(root.path(), "rw", &policy), None) + .unwrap() + .remove(0) + .config; + let contract = filesystems_contract(&[(saved, root.path())]); + let restore = |policy: &[&str]| { + let mut arguments = policy.to_vec(); + arguments.extend(["--restore-snapshot", "snapshot"]); + options_filesystems( + &policy_options(root.path(), "rw", &arguments), + Some(&contract), + ) + }; + restore(&policy).unwrap(); + for changed in [ + &policy[..4], + &["--mount-deny", "logs", "--mount-write", "out"][..], + &[ + "--mount-deny", + "logs", + "--mount-allow", + "logs/payloads", + "--mount-write", + "build.log", + ][..], + ] { + let error = restore(changed).unwrap_err(); + assert!(error.to_string().contains("access policy"), "{error:#}"); + } + } } diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index e2c83a2de58..28769f42df2 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -893,7 +893,10 @@ impl CreateVm { stable_id: "fs:microvm0".to_owned(), root_identity: filesystem.attachment.identity.clone(), read_only: filesystem.config.access.is_read_only(), - denied_paths: Vec::new(), + // The snapshot's access policy holds after a restore. + denied_paths: filesystem.config.denied_paths.clone(), + allowed_paths: filesystem.config.allowed_paths.clone(), + writable_paths: filesystem.config.writable_paths.clone(), caller_identity: filesystem.config.owner.is_caller(), }, } @@ -1050,6 +1053,8 @@ impl CreateVm { root_identity: attachment.identity.clone(), read_only: filesystem_config.access.is_read_only(), denied_paths: Vec::new(), + allowed_paths: Vec::new(), + writable_paths: Vec::new(), caller_identity: filesystem_config.owner.is_caller(), }, } diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index acd9fa97068..f14af80714d 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -250,6 +250,15 @@ pub struct SnapshotMicrovmFilesystem { /// what snapshots that predate this field used, or `caller`. #[mesh(13)] pub owner_mode: String, + /// Canonical host-relative paths inside denied paths that the virtio-fs + /// server exposes again. Snapshots that predate this field have none. + #[mesh(14)] + pub allowed_paths: Vec, + /// Canonical host-relative paths that are the only parts of a read-write + /// filesystem that the guest can modify, or none when all of it is + /// writable, as in snapshots that predate this field. + #[mesh(15)] + pub writable_paths: Vec, } /// Authoritative identity and snapshot policy for a microVM sandbox block. @@ -309,6 +318,8 @@ impl SnapshotMicrovmFilesystem { .to_owned() } }, + allowed_paths: config.allowed_paths.clone(), + writable_paths: config.writable_paths.clone(), } } } @@ -1556,7 +1567,13 @@ pub(super) fn validate_machine_contract_shape( filesystem.guest_mount_target.clone(), access, ) - .and_then(|config| config.with_denied_paths(filesystem.denied_paths.clone())) + .and_then(|config| { + config.with_access_policy( + filesystem.denied_paths.clone(), + filesystem.allowed_paths.clone(), + filesystem.writable_paths.clone(), + ) + }) .context("snapshot filesystem policy is invalid")? .with_owner(snapshot_microvm_filesystem_owner(&filesystem.owner_mode)?); anyhow::ensure!( @@ -2411,6 +2428,13 @@ mod tests { .and_then(|config| config.with_denied_paths(vec!["secrets".to_owned()])) .unwrap() .with_owner(owner); + generated_filesystem_contract_with_config(source_hypervisor, &filesystem) + } + + fn generated_filesystem_contract_with_config( + source_hypervisor: &str, + filesystem: &openvmm_defs::microvm::MicrovmFilesystemConfig, + ) -> SnapshotMachineContract { let command_line = format!( "earlycon=xe9 console=hvc0 reboot=t panic=-1 virtio_mmio.device=0x1000@0xd0001000:6 {}", filesystem.command_line_fragment(&openvmm_defs::microvm::MICROVM_FILESYSTEM_SLOTS[0]) @@ -2422,7 +2446,7 @@ mod tests { None, true, vec![( - &filesystem, + filesystem, Path::new(if cfg!(windows) { r"C:\microvm-share" } else { @@ -3059,6 +3083,44 @@ mod tests { ); } + #[test] + fn microvm_filesystem_access_policy_is_recorded_and_validated() { + let filesystem = openvmm_defs::microvm::MicrovmFilesystemConfig::new( + "/mnt/share".to_owned(), + openvmm_defs::microvm::MicrovmFilesystemAccess::ReadWrite, + ) + .and_then(|config| { + config.with_access_policy( + vec!["logs".to_owned()], + vec!["logs/payloads".to_owned()], + vec!["out".to_owned()], + ) + }) + .unwrap(); + let contract = generated_filesystem_contract_with_config("kvm", &filesystem); + let saved = contract.microvm_filesystem.as_ref().unwrap(); + assert_eq!(saved.denied_paths, ["logs"]); + assert_eq!(saved.allowed_paths, ["logs/payloads"]); + assert_eq!(saved.writable_paths, ["out"]); + validate_machine_contract_shape(&contract, 1024, 1).unwrap(); + + // A policy that the configuration would reject is not a valid + // contract either. + let tampers: [fn(&mut SnapshotMicrovmFilesystem); 2] = [ + |filesystem| filesystem.allowed_paths = vec!["payloads".to_owned()], + |filesystem| filesystem.writable_paths = vec!["logs/secret".to_owned()], + ]; + for tamper in tampers { + let mut contract = contract.clone(); + tamper(contract.microvm_filesystem.as_mut().unwrap()); + let error = validate_machine_contract_shape(&contract, 1024, 1).unwrap_err(); + assert!( + format!("{error:#}").contains("snapshot filesystem policy is invalid"), + "{error:#}" + ); + } + } + #[test] fn validate_microvm_filesystem_contract_rejects_policy_change() { let contract = generated_filesystem_contract("whp"); diff --git a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs index 0d595bb1994..5a3aeede555 100644 --- a/vm/devices/virtio/virtio_resources/src/fs/microvm.rs +++ b/vm/devices/virtio/virtio_resources/src/fs/microvm.rs @@ -16,7 +16,15 @@ pub enum VirtioFsProfile { stable_id: String, root_identity: Vec, read_only: bool, + /// Canonical share-relative paths hidden from the guest. denied_paths: Vec, + /// Canonical share-relative paths inside `denied_paths` that the guest + /// can reach again. + allowed_paths: Vec, + /// Canonical share-relative paths that are the only parts of a + /// read-write share that the guest can modify; none makes the whole + /// share writable. + writable_paths: Vec, /// Perform each guest request as the host UID and GID of its caller, /// with root squashed to the owner of the export root, instead of as /// the VMM. Linux only. diff --git a/vm/devices/virtio/virtiofs/src/inode.rs b/vm/devices/virtio/virtiofs/src/inode.rs index 87554fcae47..e9f5b37484c 100644 --- a/vm/devices/virtio/virtiofs/src/inode.rs +++ b/vm/devices/virtio/virtiofs/src/inode.rs @@ -2,6 +2,7 @@ // Licensed under the MIT License. use crate::file::VirtioFsFile; +use crate::microvm::policy::SubtreePolicy; use crate::util; use fuse::protocol::*; use lx::LxStr; @@ -11,6 +12,7 @@ use lxutil::LxVolume; use lxutil::PathBufExt; use parking_lot::RwLock; use std::collections::BTreeSet; +use std::collections::HashMap; use std::ops::Deref; use std::path::Path; use std::path::PathBuf; @@ -44,8 +46,10 @@ pub(crate) struct VirtioFsVolume { pub(crate) id: u32, pub(crate) readonly: bool, pub(crate) strict_paths: bool, - pub(crate) denied_paths: Vec, - pub(crate) denied_identities: Vec<(u64, u64)>, + pub(crate) policy: SubtreePolicy, + /// Host objects of the policy's hidden paths, by device and inode number, + /// with the only path at which the guest may reach each, if any. + pub(crate) pinned_identities: HashMap<(u64, u64), Option>, } impl VirtioFsVolume { @@ -55,8 +59,8 @@ impl VirtioFsVolume { id, readonly, strict_paths: false, - denied_paths: Vec::new(), - denied_identities: Vec::new(), + policy: SubtreePolicy::default(), + pinned_identities: HashMap::new(), } } @@ -113,7 +117,7 @@ impl VirtioFsInode { /// Create a new inode for the specified path. pub fn new(volume: Arc, path: PathBuf) -> lx::Result<(Self, lx::Stat)> { let stat = volume.lstat(&path)?; - volume.ensure_identity_allowed(&stat)?; + volume.ensure_object_allowed(&path, &stat)?; let inode = Self::with_attr(volume, path, &stat); Ok((inode, stat)) } @@ -145,9 +149,10 @@ impl VirtioFsInode { self.volume.id() } - /// Whether this inode's volume is read-only. + /// Whether this inode is read-only: its volume is, or the volume's access + /// policy does not let the guest modify it through every path it knows. pub fn readonly(&self) -> bool { - self.volume.readonly() + self.volume.readonly() || !self.policy_permits_writes() } /// This inode's own number as reported to the guest: its namespaced inode @@ -496,6 +501,19 @@ impl VirtioFsInode { } } + /// Swaps two alias prefixes after a successful `RENAME_EXCHANGE`. + pub(crate) fn exchange_alias_prefixes(&self, first: &Path, second: &Path) { + let mut aliases = self.aliases.write(); + *aliases = aliases + .iter() + .map(|alias| exchanged_path(alias, first, second)) + .collect(); + drop(aliases); + + let mut path = self.path.write(); + *path = exchanged_path(&path, first, second); + } + /// The key used to deduplicate this inode in the `InodeMap`, so that /// repeated lookups of the same host file return one stable FUSE node id. /// @@ -549,3 +567,15 @@ pub(crate) fn renamed_path(new: &Path, suffix: &Path) -> PathBuf { } path } + +/// Returns `path` after a `RENAME_EXCHANGE` of `first` and `second`, which +/// swaps the objects at and below the two names. +pub(crate) fn exchanged_path(path: &Path, first: &Path, second: &Path) -> PathBuf { + if let Ok(suffix) = path.strip_prefix(first) { + renamed_path(second, suffix) + } else if let Ok(suffix) = path.strip_prefix(second) { + renamed_path(first, suffix) + } else { + path.to_path_buf() + } +} diff --git a/vm/devices/virtio/virtiofs/src/lib.rs b/vm/devices/virtio/virtiofs/src/lib.rs index 0c61f968d15..9f4035e88d9 100644 --- a/vm/devices/virtio/virtiofs/src/lib.rs +++ b/vm/devices/virtio/virtiofs/src/lib.rs @@ -51,6 +51,9 @@ const ENTRY_TIMEOUT: Duration = Duration::from_secs(0); const MAX_GUEST_BUFFER_SIZE: usize = 1024 * 1024; +/// Linux's `RENAME_EXCHANGE` flag for `renameat2`, which swaps two names. +const RENAME_EXCHANGE: u32 = 2; + #[derive(Clone, Copy, Default, Eq, PartialEq)] struct FuseNegotiation { initialized: bool, @@ -277,6 +280,7 @@ impl Fuse for VirtioFs { let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; self.preflight_create_inode(&inode, name, &path)?; self.preflight_file_insert()?; let (new_inode, attr, file) = @@ -311,6 +315,7 @@ impl Fuse for VirtioFs { let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; self.preflight_new_inode_path(&path)?; let (new_inode, attr) = inode.mkdir(name, arg.mode, request.uid(), request.gid())?; let (_, node_id) = self.insert_inode(new_inode)?; @@ -334,6 +339,7 @@ impl Fuse for VirtioFs { let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; self.preflight_new_inode_path(&path)?; let (new_inode, attr) = inode.mknod(name, arg.mode, request.uid(), request.gid(), arg.rdev)?; @@ -360,6 +366,7 @@ impl Fuse for VirtioFs { self.check_writable(&inode)?; microvm::fs::validate_symlink_target(self, target)?; let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; self.preflight_new_inode_path(&path)?; let (new_inode, attr) = inode.symlink(name, target, request.uid(), request.gid())?; @@ -380,6 +387,13 @@ impl Fuse for VirtioFs { let target_inode = self.get_inode(target)?; self.check_writable(&inode)?; let alias = inode.child_path(name)?; + self.check_writable_entry(&inode, &alias)?; + // A new link to an object that the guest may not modify would make it + // writable through the link, so refuse it as if the object were on + // another filesystem. + if target_inode.readonly() { + return Err(lx::Error::EXDEV); + } self.preflight_alias_add(&target_inode, &alias)?; let attr = inode.link(name, &target_inode)?; target_inode.add_alias(alias); @@ -483,13 +497,21 @@ impl Fuse for VirtioFs { return Err(lx::Error::EXDEV); } self.check_writable(&inode)?; + self.check_writable(&new_inode)?; let old_path = inode.child_path(name)?; let new_path = new_inode.child_path(new_name)?; - self.preflight_rename_aliases(inode.volume_id(), &old_path, &new_path)?; + self.check_writable_entry(&inode, &old_path)?; + self.check_writable_entry(&new_inode, &new_path)?; + let exchange = flags & RENAME_EXCHANGE != 0; + self.preflight_rename_aliases(inode.volume_id(), &old_path, &new_path, exchange)?; inode.rename(name, &new_inode, new_name, flags)?; let mut inodes = self.inner.inodes.write(); - inodes.remove_alias_prefix(inode.volume_id(), &new_path); - inodes.rename_alias_prefix(inode.volume_id(), &old_path, &new_path); + if exchange { + inodes.exchange_alias_prefixes(inode.volume_id(), &old_path, &new_path); + } else { + inodes.remove_alias_prefix(inode.volume_id(), &new_path); + inodes.rename_alias_prefix(inode.volume_id(), &old_path, &new_path); + } Ok(()) } @@ -598,6 +620,18 @@ impl VirtioFs { } } + /// Check that the guest may create, remove, or replace the entry at `path` + /// in the directory `parent`, which [`Self::check_writable`] accepted, and + /// return EROFS if not. A writable directory can hold an entry that the + /// volume's access policy protects, such as a traverse-only directory. + fn check_writable_entry(&self, parent: &VirtioFsInode, path: &Path) -> lx::Result<()> { + if parent.volume.path_writable(path) { + Ok(()) + } else { + Err(lx::Error::EROFS) + } + } + /// Check whether the open flags are permitted on a read-only filesystem. fn check_open_readonly(&self, inode: &VirtioFsInode, flags: u32) -> lx::Result<()> { if !inode.readonly() { @@ -705,6 +739,7 @@ impl VirtioFs { let inode = self.get_inode(request.node_id())?; self.check_writable(&inode)?; let path = inode.child_path(name)?; + self.check_writable_entry(&inode, &path)?; inode.unlink(name, flags)?; self.inner .inodes @@ -928,6 +963,17 @@ impl InodeMap { self.rebuild_dedup_keys(); } + /// Swaps the aliases at or below two exchanged paths and refreshes + /// path-keyed deduplication. + pub fn exchange_alias_prefixes(&mut self, volume_id: u32, first: &Path, second: &Path) { + for inode in self.inodes_by_node_id.values.values() { + if inode.volume_id() == volume_id { + inode.exchange_alias_prefixes(first, second); + } + } + self.rebuild_dedup_keys(); + } + fn rebuild_dedup_keys(&mut self) { self.inodes_by_key.clear(); for (&node_id, inode) in &self.inodes_by_node_id.values { diff --git a/vm/devices/virtio/virtiofs/src/microvm/device.rs b/vm/devices/virtio/virtiofs/src/microvm/device.rs index bc22fa5c7bb..278ddff1a15 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/device.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/device.rs @@ -91,15 +91,19 @@ impl VirtioFsDevice { root_identity: Vec, read_only: bool, denied_paths: Vec, + allowed_paths: Vec, + writable_paths: Vec, owner_mode: MicroVmOwnerMode, root_path: impl AsRef, notify_corruption: Option>, ) -> anyhow::Result { - let profile = MicroVmVirtioFsProfile::from_attachment( + let profile = MicroVmVirtioFsProfile::from_attachment_with_policy( stable_id, root_identity, read_only, denied_paths, + allowed_paths, + writable_paths, )? .with_owner_mode(owner_mode)?; let fs = VirtioFs::new_microvm(root_path, profile.clone())?; @@ -298,6 +302,8 @@ mod tests { root_identity, true, Vec::new(), + Vec::new(), + Vec::new(), MicroVmOwnerMode::Vmm, temporary_directory.path(), None, @@ -324,6 +330,8 @@ mod tests { microvm_root_identity(temporary_directory.path()).unwrap(), false, Vec::new(), + Vec::new(), + Vec::new(), MicroVmOwnerMode::Vmm, temporary_directory.path(), None, @@ -378,6 +386,8 @@ mod tests { microvm_root_identity(root.path()).unwrap(), false, Vec::new(), + Vec::new(), + Vec::new(), MicroVmOwnerMode::Vmm, root.path(), None, @@ -396,6 +406,8 @@ mod tests { microvm_root_identity(root.path()).unwrap(), false, Vec::new(), + Vec::new(), + Vec::new(), MicroVmOwnerMode::Vmm, root.path(), None, @@ -468,6 +480,8 @@ mod tests { microvm_root_identity(root.path()).unwrap(), false, Vec::new(), + Vec::new(), + Vec::new(), MicroVmOwnerMode::Vmm, root.path(), None, diff --git a/vm/devices/virtio/virtiofs/src/microvm/file.rs b/vm/devices/virtio/virtiofs/src/microvm/file.rs index 50c02764ead..4451a5f8a80 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/file.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/file.rs @@ -105,7 +105,8 @@ impl VirtioFsFile { } if name != b"." && name != b".." { match self.inode.child_path(&entry.name) { - Ok(_) => {} + Ok(path) if self.inode.volume.entry_listable(&path, entry.file_type) => {} + Ok(_) => return Ok(true), Err(error) if error == lx::Error::EACCES => return Ok(true), Err(error) => return Err(error), } diff --git a/vm/devices/virtio/virtiofs/src/microvm/fs.rs b/vm/devices/virtio/virtiofs/src/microvm/fs.rs index dd7dd241611..10694081f7c 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/fs.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/fs.rs @@ -7,7 +7,6 @@ use super::MAX_FUSE_REQUEST_BYTES; use super::owner::CallerIdentity; use super::profile::MicroVmOwnerMode; use super::profile::MicroVmVirtioFsProfile; -use super::saved_state::CALLER_IDENTITY_SCHEMA_VERSION; use super::saved_state::MAX_ALIAS_BYTES; use super::saved_state::MAX_ALIASES; use super::saved_state::MAX_ALIASES_PER_INODE; @@ -15,7 +14,7 @@ use super::saved_state::MAX_DIRECTORY_BYTES; use super::saved_state::MAX_DIRECTORY_ENTRIES; use super::saved_state::MAX_HANDLES; use super::saved_state::MAX_INODES; -use super::saved_state::SCHEMA_VERSION; +use super::saved_state::SUBTREE_POLICY_SCHEMA_VERSION; use super::saved_state::SavedHandle; use super::saved_state::SavedInode; use super::saved_state::SavedState; @@ -24,6 +23,8 @@ use super::state::fuse_negotiation_from_session; use super::state::reopen_flags; use super::state::saved_identity; use super::state::saved_negotiation; +use super::state::saved_policy_paths; +use super::state::schema_version; use super::state::session_state_from_saved; use super::state::validate_identity; use super::state::validate_microvm_state; @@ -148,23 +149,34 @@ impl VirtioFs { .sandbox(true) .confine_paths(true); let volume = mount_options.new_volume(root_path)?; - let denied_identities = profile - .denied_paths() - .iter() - .map(|path| { - volume - .lstat(path) - .map(|stat| (stat.device_nr, stat.inode_nr)) - }) - .collect::>>()?; + let mut pinned_identities = HashMap::new(); + for (path, reachable_at) in profile.subtree_policy().pinned_paths() { + let stat = volume.lstat(&path)?; + if reachable_at.is_some() && stat.mode & lx::S_IFMT != lx::S_IFDIR { + anyhow::bail!( + "microVM virtio-fs path {} leads to an allowed path but is not a directory", + path.display() + ); + } + // An object pinned at two paths, such as through a bind mount, is + // reachable at neither. + pinned_identities + .entry((stat.device_nr, stat.inode_nr)) + .and_modify(|pinned: &mut Option| { + if *pinned != reachable_at { + *pinned = None; + } + }) + .or_insert(reachable_at); + } let mut inodes = InodeMap::new(false); let volume = Arc::new(VirtioFsVolume::new_with_strict_paths( volume, 0, profile.is_readonly(), true, - profile.denied_paths().to_vec(), - denied_identities, + profile.subtree_policy().clone(), + pinned_identities, )); let (root_inode, root_stat) = VirtioFsInode::new(Arc::clone(&volume), PathBuf::new())?; profile.validate_opened_root(root_path, &root_stat)?; @@ -252,12 +264,13 @@ impl VirtioFs { volume_id: u32, old: &Path, new: &Path, + exchange: bool, ) -> lx::Result<()> { if self.is_microvm() { self.inner .inodes .read() - .preflight_microvm_rename(volume_id, old, new) + .preflight_microvm_rename(volume_id, old, new, exchange) } else { Ok(()) } @@ -400,13 +413,20 @@ impl VirtioFs { *self.inner.negotiation.read() == negotiation, "FUSE session and filesystem negotiation state disagree" ); - let caller_identity = profile.owner_mode() == MicroVmOwnerMode::Caller; - Ok(SavedState { - schema_version: if caller_identity { - CALLER_IDENTITY_SCHEMA_VERSION + let schema_version = schema_version(profile); + let policy = profile.subtree_policy(); + let (denied_paths, allowed_paths, writable_paths) = + if schema_version == SUBTREE_POLICY_SCHEMA_VERSION { + ( + saved_policy_paths(policy.denied_paths()), + saved_policy_paths(policy.allowed_paths()), + saved_policy_paths(policy.writable_paths()), + ) } else { - SCHEMA_VERSION - }, + (Vec::new(), Vec::new(), Vec::new()) + }; + Ok(SavedState { + schema_version, attachment_id: profile.attachment_id().to_owned(), access_mode: match profile.access_mode() { super::profile::MicroVmAccessMode::ReadOnly => 1, @@ -426,7 +446,10 @@ impl VirtioFs { attachment_root_identity: profile.root_identity().to_vec(), maximum_request_size: MAX_FUSE_REQUEST_BYTES as u32, dormant: false, - caller_identity, + caller_identity: profile.owner_mode() == MicroVmOwnerMode::Caller, + denied_paths, + allowed_paths, + writable_paths, }) } diff --git a/vm/devices/virtio/virtiofs/src/microvm/inode.rs b/vm/devices/virtio/virtiofs/src/microvm/inode.rs index e8815fce225..6b12497f495 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/inode.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/inode.rs @@ -3,6 +3,8 @@ //! microVM path confinement and persisted inode reconstruction. +use super::policy::PathVisibility; +use super::policy::SubtreePolicy; use super::saved_state::MAX_PATH_BYTES; use super::state::relative_path_encoded_len; use super::state::validate_relative_path; @@ -10,6 +12,7 @@ use crate::inode::VirtioFsInode; use crate::inode::VirtioFsVolume; use lxutil::LxVolume; use std::collections::BTreeSet; +use std::collections::HashMap; use std::path::Component; use std::path::Path; use std::path::PathBuf; @@ -22,16 +25,16 @@ impl VirtioFsVolume { id: u32, readonly: bool, strict_paths: bool, - denied_paths: Vec, - denied_identities: Vec<(u64, u64)>, + policy: SubtreePolicy, + pinned_identities: HashMap<(u64, u64), Option>, ) -> Self { Self { volume: Arc::new(volume), id, readonly, strict_paths, - denied_paths, - denied_identities, + policy, + pinned_identities, } } @@ -39,26 +42,59 @@ impl VirtioFsVolume { self.strict_paths } + /// Rejects a path that the access policy hides from the guest. pub(crate) fn ensure_path_allowed(&self, path: &Path) -> lx::Result<()> { - if self - .denied_paths - .iter() - .any(|denied| path.starts_with(denied)) - { + if self.policy.visibility(path) == PathVisibility::Hidden { return Err(lx::Error::EACCES); } Ok(()) } - pub(crate) fn ensure_identity_allowed(&self, stat: &lx::Stat) -> lx::Result<()> { - if self - .denied_identities - .contains(&(stat.device_nr, stat.inode_nr)) + /// Rejects an object that the guest reached at `path` but must not see + /// there: a hidden object pinned when the share was attached, which may + /// be reachable only at its own path or not at all, or anything but a + /// directory at a traverse-only path. + pub(crate) fn ensure_object_allowed(&self, path: &Path, stat: &lx::Stat) -> lx::Result<()> { + if let Some(reachable_at) = self.pinned_identities.get(&(stat.device_nr, stat.inode_nr)) { + if reachable_at.as_deref() != Some(path) { + return Err(lx::Error::EACCES); + } + } + if self.policy.visibility(path) == PathVisibility::TraverseOnly + && stat.mode & lx::S_IFMT != lx::S_IFDIR { return Err(lx::Error::EACCES); } Ok(()) } + + /// Returns whether the guest may modify `path`, or an entry at `path`. + pub(crate) fn path_writable(&self, path: &Path) -> bool { + !self.readonly && self.policy.is_writable(path) + } + + /// Returns whether the access policy makes some visible path read-only. + pub(crate) fn restricts_writes(&self) -> bool { + self.policy.restricts_writes() + } + + /// Returns whether a directory entry named by `path` is one that the guest + /// may list: every visible entry, and only the directories among the + /// traverse-only entries. Filesystems may report an entry's type as + /// unknown, so the host resolves it for a traverse-only entry. + pub(crate) fn entry_listable(&self, path: &Path, file_type: u8) -> bool { + match self.policy.visibility(path) { + PathVisibility::Visible => true, + PathVisibility::TraverseOnly => match file_type { + lx::DT_DIR => true, + lx::DT_UNK => self + .lstat(path) + .is_ok_and(|stat| stat.mode & lx::S_IFMT == lx::S_IFDIR), + _ => false, + }, + PathVisibility::Hidden => false, + } + } } impl VirtioFsInode { @@ -76,7 +112,9 @@ impl VirtioFsInode { if lookup_count == 0 { return Err(lx::Error::EINVAL); } - volume.ensure_identity_allowed(stat)?; + for alias in &aliases { + volume.ensure_object_allowed(alias, stat)?; + } let mut inode = Self::with_attr(volume, path, stat); inode.lookup_count = AtomicU64::new(lookup_count); let aliases: BTreeSet<_> = aliases.into_iter().collect(); @@ -108,6 +146,21 @@ impl VirtioFsInode { self.validate_confined_paths(true) } + /// Returns whether the volume's access policy lets the guest modify this + /// object through every path that it knows for the object, so that no + /// alias, such as a hard link, makes a read-only object writable. + pub(crate) fn policy_permits_writes(&self) -> bool { + if !self.volume.restricts_writes() { + return true; + } + let aliases = self.aliases(); + let primary = self.clone_path(); + self.volume.policy.is_writable(&primary) + && aliases + .iter() + .all(|alias| self.volume.policy.is_writable(alias)) + } + fn validate_confined_paths(&self, allow_final_link: bool) -> lx::Result<()> { if !self.volume.strict_paths() { return Ok(()); diff --git a/vm/devices/virtio/virtiofs/src/microvm/limits.rs b/vm/devices/virtio/virtiofs/src/microvm/limits.rs index 49aca0e4383..b853735cb3f 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/limits.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/limits.rs @@ -12,6 +12,7 @@ use super::state::relative_path_encoded_len; use super::state::validate_relative_path; use crate::InodeMap; use crate::inode::VirtioFsInode; +use crate::inode::exchanged_path; use crate::inode::renamed_path; use std::collections::BTreeSet; use std::path::Path; @@ -87,13 +88,19 @@ impl InodeMap { volume_id: u32, old: &Path, new: &Path, + exchange: bool, ) -> lx::Result<()> { Self::validate_microvm_alias_path(new)?; let mut alias_count = 0usize; let mut alias_bytes = 0usize; for inode in self.inodes_by_node_id.values.values() { let mut aliases: BTreeSet<_> = inode.aliases().into_iter().collect(); - if inode.volume_id() == volume_id { + if inode.volume_id() == volume_id && exchange { + aliases = aliases + .iter() + .map(|alias| exchanged_path(alias, old, new)) + .collect(); + } else if inode.volume_id() == volume_id { aliases.retain(|alias| !alias.starts_with(new)); let replacements: Vec<_> = aliases .iter() diff --git a/vm/devices/virtio/virtiofs/src/microvm/mod.rs b/vm/devices/virtio/virtiofs/src/microvm/mod.rs index 994fa200634..006c60ef5d8 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/mod.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/mod.rs @@ -13,6 +13,9 @@ mod limits; pub(crate) mod owner; #[cfg(test)] mod owner_tests; +pub(crate) mod policy; +#[cfg(test)] +mod policy_tests; pub mod profile; pub(crate) mod resolver; pub(crate) mod saved_state; diff --git a/vm/devices/virtio/virtiofs/src/microvm/policy.rs b/vm/devices/virtio/virtiofs/src/microvm/policy.rs new file mode 100644 index 00000000000..fc75e934ca4 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/policy.rs @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! The path-scoped access policy of a microVM share. +//! +//! A share's policy consists of three sets of canonical share-relative paths: +//! +//! - A *denied* path hides itself and everything below it from the guest. +//! - An *allowed* path exposes itself and everything below it again, inside a +//! denied path. +//! - When a read-write share has *writable* paths, they are the only parts of +//! the share that the guest can modify, and the rest of the share is +//! read-only. +//! +//! The nearest denied or allowed path that contains a path decides whether the +//! guest can see it. The nearest one that contains a denied path must be an +//! allowed path, if any, and the nearest one that contains an allowed path +//! must be a denied path, so no path in the policy is redundant. +//! +//! A hidden directory that leads to an allowed path is *traverse-only*: the +//! guest can look it up and list the entries that lead to allowed paths, but +//! it can see nothing else in it, and it can modify neither the directory nor +//! its entries. + +use super::profile::MicroVmProfileError; +use std::path::Path; +use std::path::PathBuf; + +/// How the guest can reach a share-relative path. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PathVisibility { + /// The guest can reach the path, subject to the write policy. + Visible, + /// The path is hidden but leads to an allowed path, so the guest can look + /// it up as a directory and list the entries that lead to allowed paths. + TraverseOnly, + /// The guest cannot reach the path. + Hidden, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Rule { + Deny, + Allow, +} + +/// The validated access policy of a microVM share. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub(crate) struct SubtreePolicy { + denied: Vec, + allowed: Vec, + writable: Vec, +} + +impl SubtreePolicy { + /// Validates how the denied, allowed, and writable paths of a share relate + /// to one another and to its access mode. Each list must already contain + /// unique, canonical share-relative paths. + pub(crate) fn new( + denied: Vec, + allowed: Vec, + writable: Vec, + read_only: bool, + ) -> Result { + let policy = Self { + denied, + allowed, + writable, + }; + for denied in &policy.denied { + if denied.as_os_str().is_empty() || policy.enclosing_rule(denied) == Some(Rule::Deny) { + return Err(MicroVmProfileError::InvalidDeniedPaths); + } + } + for allowed in &policy.allowed { + if policy.denied.contains(allowed) || policy.enclosing_rule(allowed) != Some(Rule::Deny) + { + return Err(MicroVmProfileError::InvalidAllowedPaths); + } + } + if read_only && !policy.writable.is_empty() { + return Err(MicroVmProfileError::InvalidWritablePaths); + } + for (index, writable) in policy.writable.iter().enumerate() { + let overlaps = policy + .writable + .iter() + .enumerate() + .any(|(other_index, other)| other_index != index && writable.starts_with(other)); + if writable.as_os_str().is_empty() + || overlaps + || policy.visibility(writable) != PathVisibility::Visible + { + return Err(MicroVmProfileError::InvalidWritablePaths); + } + } + Ok(policy) + } + + /// Returns the denied paths. + pub(crate) fn denied_paths(&self) -> &[PathBuf] { + &self.denied + } + + /// Returns the allowed paths. + pub(crate) fn allowed_paths(&self) -> &[PathBuf] { + &self.allowed + } + + /// Returns the writable paths; none means that a read-write share is + /// writable everywhere that the guest can see. + pub(crate) fn writable_paths(&self) -> &[PathBuf] { + &self.writable + } + + /// Returns the rule of the nearest denied or allowed path that contains + /// `path`, other than `path` itself. + fn enclosing_rule(&self, path: &Path) -> Option { + self.nearest_rule(path, false) + } + + /// Returns the rule of the nearest denied or allowed path that contains + /// `path`, including `path` itself when `inclusive`. + fn nearest_rule(&self, path: &Path, inclusive: bool) -> Option { + self.denied + .iter() + .map(|rule| (rule, Rule::Deny)) + .chain(self.allowed.iter().map(|rule| (rule, Rule::Allow))) + .filter(|(rule, _)| path.starts_with(rule) && (inclusive || rule.as_path() != path)) + .max_by_key(|(rule, _)| rule.components().count()) + .map(|(_, kind)| kind) + } + + /// Returns how the guest can reach `path`. + pub(crate) fn visibility(&self, path: &Path) -> PathVisibility { + match self.nearest_rule(path, true) { + None | Some(Rule::Allow) => PathVisibility::Visible, + Some(Rule::Deny) + if self + .allowed + .iter() + .any(|allowed| allowed.as_path() != path && allowed.starts_with(path)) => + { + PathVisibility::TraverseOnly + } + Some(Rule::Deny) => PathVisibility::Hidden, + } + } + + /// Returns whether the guest may modify `path` in a read-write share. + pub(crate) fn is_writable(&self, path: &Path) -> bool { + self.visibility(path) == PathVisibility::Visible + && (self.writable.is_empty() + || self + .writable + .iter() + .any(|writable| path.starts_with(writable))) + } + + /// Returns whether some path that the guest can reach is not writable in a + /// read-write share, so that each modification must check its paths. + pub(crate) fn restricts_writes(&self) -> bool { + !self.allowed.is_empty() || !self.writable.is_empty() + } + + /// Returns the hidden paths whose host objects are pinned when the share is + /// attached, each with the only path at which the guest may reach the + /// object: none for a denied path that leads to no allowed path, and its + /// own path for a traverse-only directory. Pinning the objects keeps them + /// hidden when the host makes them reachable through another path, such + /// as a bind mount, a hard link, or a renamed ancestor. + pub(crate) fn pinned_paths(&self) -> Vec<(PathBuf, Option)> { + let mut pinned = Vec::new(); + for denied in &self.denied { + let reachable_at = match self.visibility(denied) { + PathVisibility::Hidden => None, + PathVisibility::Visible | PathVisibility::TraverseOnly => Some(denied.clone()), + }; + pinned.push((denied.clone(), reachable_at)); + } + for allowed in &self.allowed { + let mut prefix = PathBuf::new(); + for component in allowed.components() { + prefix.push(component); + if prefix != *allowed + && self.visibility(&prefix) == PathVisibility::TraverseOnly + && !pinned.iter().any(|(path, _)| *path == prefix) + { + pinned.push((prefix.clone(), Some(prefix.clone()))); + } + } + } + pinned + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(value: &str) -> PathBuf { + value + .split('/') + .filter(|component| !component.is_empty()) + .collect() + } + + fn paths(values: &[&str]) -> Vec { + values.iter().map(|value| path(value)).collect() + } + + fn policy(denied: &[&str], allowed: &[&str], writable: &[&str]) -> SubtreePolicy { + SubtreePolicy::new(paths(denied), paths(allowed), paths(writable), false).unwrap() + } + + fn invalid( + denied: &[&str], + allowed: &[&str], + writable: &[&str], + read_only: bool, + ) -> MicroVmProfileError { + SubtreePolicy::new(paths(denied), paths(allowed), paths(writable), read_only).unwrap_err() + } + + fn visibility(policy: &SubtreePolicy, value: &str) -> PathVisibility { + policy.visibility(&path(value)) + } + + fn writable(policy: &SubtreePolicy, value: &str) -> bool { + policy.is_writable(&path(value)) + } + + #[test] + fn allowed_paths_expose_subtrees_of_denied_paths() { + let policy = policy(&["logs"], &["logs/mcp/payloads"], &[]); + assert_eq!(visibility(&policy, "workspace"), PathVisibility::Visible); + assert_eq!(visibility(&policy, "logs"), PathVisibility::TraverseOnly); + assert_eq!( + visibility(&policy, "logs/mcp"), + PathVisibility::TraverseOnly + ); + assert_eq!( + visibility(&policy, "logs/mcp/gateway"), + PathVisibility::Hidden + ); + assert_eq!(visibility(&policy, "logs/secret"), PathVisibility::Hidden); + assert_eq!( + visibility(&policy, "logs/mcp/payloads"), + PathVisibility::Visible + ); + assert_eq!( + visibility(&policy, "logs/mcp/payloads/session/1"), + PathVisibility::Visible + ); + // A name that only shares a prefix with an allowed path stays hidden. + assert_eq!(visibility(&policy, "logs/mc"), PathVisibility::Hidden); + assert_eq!( + visibility(&policy, "logs/mcp/payloads-old"), + PathVisibility::Hidden + ); + assert!(policy.restricts_writes()); + } + + #[test] + fn denied_paths_nest_inside_allowed_paths() { + let policy = policy(&["tmp", "tmp/sandbox/firewall/logs"], &["tmp/sandbox"], &[]); + assert_eq!(visibility(&policy, "tmp/sandbox"), PathVisibility::Visible); + assert_eq!( + visibility(&policy, "tmp/sandbox/firewall"), + PathVisibility::Visible + ); + assert_eq!( + visibility(&policy, "tmp/sandbox/firewall/logs"), + PathVisibility::Hidden + ); + assert_eq!( + visibility(&policy, "tmp/sandbox/firewall/logs/access.log"), + PathVisibility::Hidden + ); + assert_eq!(visibility(&policy, "tmp/other"), PathVisibility::Hidden); + assert_eq!( + policy.pinned_paths(), + [ + (path("tmp"), Some(path("tmp"))), + (path("tmp/sandbox/firewall/logs"), None), + ] + ); + } + + #[test] + fn writable_paths_narrow_writes_to_their_subtrees() { + let policy = policy(&["out/secrets"], &[], &["build/output.log", "out"]); + assert!(writable(&policy, "out")); + assert!(writable(&policy, "out/nested/file")); + assert!(writable(&policy, "build/output.log")); + assert!(!writable(&policy, "build")); + assert!(!writable(&policy, "build/output.log.tmp")); + assert!(!writable(&policy, "outside")); + assert!(!writable(&policy, "")); + assert!(!writable(&policy, "out/secrets")); + assert!(!writable(&policy, "out/secrets/token")); + assert!(policy.restricts_writes()); + } + + #[test] + fn traverse_only_directories_are_never_writable() { + // Without writable paths, every visible path of a read-write share is + // writable, but a traverse-only directory never is. + let policy = policy(&["logs"], &["logs/payloads"], &[]); + assert!(writable(&policy, "")); + assert!(writable(&policy, "logs/payloads")); + assert!(writable(&policy, "logs/payloads/file")); + assert!(!writable(&policy, "logs")); + assert!(!writable(&policy, "logs/secret")); + } + + #[test] + fn writable_paths_may_contain_allowed_and_denied_paths() { + let narrowed = policy(&["out/logs"], &["out/logs/payloads"], &["out"]); + assert!(writable(&narrowed, "out/file")); + assert!(writable(&narrowed, "out/logs/payloads/file")); + assert!(!writable(&narrowed, "out/logs")); + assert!(!writable(&narrowed, "out/logs/secret")); + + let exemption = policy(&["logs"], &["logs/payloads"], &["logs/payloads"]); + assert!(writable(&exemption, "logs/payloads/file")); + assert!(!writable(&exemption, "workspace")); + } + + #[test] + fn deny_only_policies_do_not_restrict_writes() { + let policy = policy(&["secrets"], &[], &[]); + assert!(!policy.restricts_writes()); + assert_eq!(policy.pinned_paths(), [(path("secrets"), None)]); + assert!(!SubtreePolicy::default().restricts_writes()); + } + + #[test] + fn traverse_only_directories_are_pinned_to_their_paths() { + let policy = policy(&["logs", "other"], &["logs/mcp/payloads"], &[]); + assert_eq!( + policy.pinned_paths(), + [ + (path("logs"), Some(path("logs"))), + (path("other"), None), + (path("logs/mcp"), Some(path("logs/mcp"))), + ] + ); + } + + #[test] + fn redundant_or_contradictory_policies_are_rejected() { + // A denied path directly inside another denied path is redundant. + assert!(matches!( + invalid(&["a", "a/b"], &[], &[], false), + MicroVmProfileError::InvalidDeniedPaths + )); + // An allowed path must be inside a denied path, and not directly + // inside another allowed path. + for (denied, allowed) in [ + (vec![], vec!["a"]), + (vec!["b"], vec!["a"]), + (vec!["a"], vec!["a"]), + (vec!["a"], vec!["a/b", "a/b/c"]), + ] { + assert!(matches!( + invalid(&denied, &allowed, &[], false), + MicroVmProfileError::InvalidAllowedPaths + )); + } + // Writable paths need a read-write share, must not overlap, and must + // be visible. + for (denied, allowed, writable, read_only) in [ + (vec![], vec![], vec!["a"], true), + (vec![], vec![], vec!["a", "a/b"], false), + (vec!["a"], vec![], vec!["a/b"], false), + (vec!["a"], vec![], vec!["a"], false), + (vec!["a"], vec!["a/b/c"], vec!["a/b"], false), + ] { + assert!(matches!( + invalid(&denied, &allowed, &writable, read_only), + MicroVmProfileError::InvalidWritablePaths + )); + } + // The share root itself is not a policy path. + assert!(SubtreePolicy::new(vec![PathBuf::new()], Vec::new(), Vec::new(), false).is_err()); + assert!(SubtreePolicy::new(Vec::new(), Vec::new(), vec![PathBuf::new()], false).is_err()); + } +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/policy_tests.rs b/vm/devices/virtio/virtiofs/src/microvm/policy_tests.rs new file mode 100644 index 00000000000..2bf0384f6e7 --- /dev/null +++ b/vm/devices/virtio/virtiofs/src/microvm/policy_tests.rs @@ -0,0 +1,656 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Tests for the access policy of microVM shares: write narrowing, hidden +//! subtrees, and the allowed paths inside them. + +use super::profile::MICROVM_ATTACHMENT_ID; +use super::profile::MicroVmVirtioFsProfile; +use super::profile::microvm_root_identity; +use super::saved_state::SCHEMA_VERSION; +use super::saved_state::SUBTREE_POLICY_SCHEMA_VERSION; +use super::state::save_dormant_microvm_state; +use super::state::validate_dormant_microvm_state; +use super::state::validate_microvm_state; +use crate::RENAME_EXCHANGE; +use crate::VirtioFs; +use fuse::Fuse; +use fuse::Request; +use fuse::Session; +use fuse::SessionState; +use fuse::protocol::FATTR_MODE; +use fuse::protocol::FATTR_SIZE; +use fuse::protocol::FUSE_LOOKUP; +use fuse::protocol::FUSE_ROOT_ID; +use fuse::protocol::fuse_create_in; +use fuse::protocol::fuse_in_header; +use fuse::protocol::fuse_mkdir_in; +use fuse::protocol::fuse_read_in; +use fuse::protocol::fuse_release_in; +use fuse::protocol::fuse_setattr_in; +use fuse::protocol::fuse_write_in; +use std::path::PathBuf; +use tempfile::TempDir; +use tempfile::tempdir; +use test_with_tracing::test; +use zerocopy::FromZeros; +use zerocopy::IntoBytes; + +fn request(node_id: u64) -> Request { + let header = fuse_in_header { + len: size_of::() as u32, + opcode: FUSE_LOOKUP, + unique: 1, + nodeid: node_id, + uid: 0, + gid: 0, + pid: 0, + padding: 0, + }; + Request::new(header.as_bytes()).unwrap() +} + +fn name(value: &str) -> &lx::LxStr { + lx::LxStr::from_bytes(value.as_bytes()) +} + +fn error(result: lx::Result) -> lx::Error { + match result { + Ok(_) => panic!("operation unexpectedly succeeded"), + Err(error) => error, + } +} + +fn owned(paths: &[&str]) -> Vec { + paths.iter().map(|path| (*path).to_owned()).collect() +} + +fn setattr(valid: u32, update: impl FnOnce(&mut fuse_setattr_in)) -> fuse_setattr_in { + let mut arg = fuse_setattr_in::new_zeroed(); + arg.valid = valid; + update(&mut arg); + arg +} + +/// Looks `path` up one component at a time, as the guest kernel does, and +/// returns its node ID. +fn lookup(fs: &VirtioFs, path: &str) -> lx::Result { + let mut node_id = FUSE_ROOT_ID; + for component in path.split('/') { + node_id = fs.lookup(&request(node_id), name(component))?.nodeid; + } + Ok(node_id) +} + +fn open(fs: &VirtioFs, node_id: u64, flags: i32) -> lx::Result { + Ok(fs.open(&request(node_id), flags as u32)?.fh) +} + +fn release(fs: &VirtioFs, node_id: u64, fh: u64) { + let mut arg = fuse_release_in::new_zeroed(); + arg.fh = fh; + fs.release(&request(node_id), &arg).unwrap(); +} + +/// Creates and opens a file, and returns its node ID and handle. +fn create(fs: &VirtioFs, parent: u64, file_name: &str) -> lx::Result<(u64, u64)> { + let arg = fuse_create_in { + flags: lx::O_RDWR as u32, + mode: lx::S_IFREG | 0o644, + umask: 0, + padding: 0, + }; + let created = fs.create(&request(parent), name(file_name), &arg)?; + Ok((created.entry.nodeid, created.open.fh)) +} + +fn mkdir(fs: &VirtioFs, parent: u64, directory_name: &str) -> lx::Result { + let arg = fuse_mkdir_in { + mode: 0o755, + umask: 0, + }; + Ok(fs + .mkdir(&request(parent), name(directory_name), &arg)? + .nodeid) +} + +fn write(fs: &VirtioFs, node_id: u64, fh: u64, data: &[u8]) -> lx::Result { + let mut arg = fuse_write_in::new_zeroed(); + arg.fh = fh; + arg.size = data.len() as u32; + fs.write(&request(node_id), &arg, data) +} + +/// Returns up to 64 bytes from the start of a file. +fn read_node(fs: &VirtioFs, node_id: u64) -> Vec { + let fh = open(fs, node_id, lx::O_RDONLY).unwrap(); + let mut arg = fuse_read_in::new_zeroed(); + arg.fh = fh; + arg.size = 64; + let data = fs.read(&request(node_id), &arg).unwrap(); + release(fs, node_id, fh); + data +} + +/// Returns the names that the guest lists in a directory, other than `.` and +/// `..`, sorted. +fn list(fs: &VirtioFs, node_id: u64) -> Vec { + let fh = fs + .open_dir(&request(node_id), lx::O_RDONLY as u32) + .unwrap() + .fh; + let mut arg = fuse_read_in::new_zeroed(); + arg.fh = fh; + arg.size = 4096; + let buffer = fs.read_dir(&request(node_id), &arg).unwrap(); + let mut names = Vec::new(); + let mut offset = 0; + while offset < buffer.len() { + // A `fuse_dirent` header is the inode number, the next offset, the + // name length, and the type, followed by the name, padded to eight + // bytes. + let header = &buffer[offset..offset + 24]; + let length = u32::from_le_bytes(header[16..20].try_into().unwrap()) as usize; + let entry = &buffer[offset + 24..offset + 24 + length]; + let entry = String::from_utf8(entry.to_vec()).unwrap(); + if entry != "." && entry != ".." { + names.push(entry); + } + offset += fuse::protocol::fuse_dirent_align(24 + length); + } + release(fs, node_id, fh); + names.sort(); + names +} + +/// A share laid out like the directories that a sandbox policy protects. +/// +/// - `marker` and `build.log` are files, and `out` is a directory with the +/// file `existing`. +/// - `logs` holds the secret file `secret` and the directory `gateway`, and +/// `logs/payloads` holds the file `payload` and the directory `private`. +struct Share { + _directory: TempDir, + root: PathBuf, +} + +impl Share { + fn new() -> Self { + let directory = tempdir().unwrap(); + let root = directory.path().join("share"); + let share = Self { + _directory: directory, + root, + }; + for path in ["out", "logs/gateway", "logs/payloads/private"] { + std::fs::create_dir_all(share.path(path)).unwrap(); + } + for (path, contents) in [ + ("marker", "host"), + ("build.log", "log"), + ("out/existing", "existing"), + ("logs/secret", "secret"), + ("logs/gateway/log", "gateway"), + ("logs/payloads/payload", "payload"), + ("logs/payloads/private/key", "key"), + ] { + std::fs::write(share.path(path), contents).unwrap(); + } + share + } + + fn path(&self, relative: &str) -> PathBuf { + relative + .split('/') + .fold(self.root.clone(), |path, component| path.join(component)) + } + + fn read(&self, relative: &str) -> String { + String::from_utf8(std::fs::read(self.path(relative)).unwrap()).unwrap() + } + + fn entries(&self, relative: &str) -> Vec { + let mut names = std::fs::read_dir(self.path(relative)) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect::>(); + names.sort(); + names + } + + fn profile( + &self, + denied: &[&str], + allowed: &[&str], + writable: &[&str], + ) -> MicroVmVirtioFsProfile { + MicroVmVirtioFsProfile::from_attachment_with_policy( + MICROVM_ATTACHMENT_ID.to_owned(), + microvm_root_identity(&self.root).unwrap(), + false, + owned(denied), + owned(allowed), + owned(writable), + ) + .unwrap() + } + + fn fs(&self, denied: &[&str], allowed: &[&str], writable: &[&str]) -> VirtioFs { + VirtioFs::new_microvm(&self.root, self.profile(denied, allowed, writable)).unwrap() + } + + /// Checks that nothing outside `out` and `build.log` changed. + fn assert_protected_paths_unchanged(&self) { + assert_eq!(self.entries(""), ["build.log", "logs", "marker", "out"]); + assert_eq!(self.read("marker"), "host"); + assert_eq!(self.entries("logs"), ["gateway", "payloads", "secret"]); + assert_eq!(self.read("logs/secret"), "secret"); + assert_eq!(self.read("logs/gateway/log"), "gateway"); + assert_eq!(self.read("logs/payloads/private/key"), "key"); + } +} + +#[test] +fn writes_succeed_only_under_writable_paths() { + let share = Share::new(); + let fs = share.fs(&[], &[], &["build.log", "out"]); + let root = FUSE_ROOT_ID; + let marker = lookup(&fs, "marker").unwrap(); + let out = lookup(&fs, "out").unwrap(); + let existing = lookup(&fs, "out/existing").unwrap(); + + // Outside the writable paths, every mutation fails with EROFS. + assert_eq!(error(create(&fs, root, "new")), lx::Error::EROFS); + assert_eq!(error(mkdir(&fs, root, "new")), lx::Error::EROFS); + assert_eq!( + error(fs.symlink(&request(root), name("link"), name("marker"))), + lx::Error::EROFS + ); + assert_eq!(error(open(&fs, marker, lx::O_RDWR)), lx::Error::EROFS); + assert_eq!( + error(open(&fs, marker, lx::O_WRONLY | lx::O_TRUNC)), + lx::Error::EROFS + ); + assert_eq!( + error(fs.set_attr(&request(marker), &setattr(FATTR_SIZE, |arg| arg.size = 0))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.set_attr( + &request(marker), + &setattr(FATTR_MODE, |arg| arg.mode = 0o600) + )), + lx::Error::EROFS + ); + assert_eq!( + error(fs.set_xattr(&request(marker), name("user.nvx"), b"x", 0)), + lx::Error::EROFS + ); + assert_eq!( + error(fs.remove_xattr(&request(marker), name("user.nvx"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.unlink(&request(root), name("marker"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rmdir(&request(root), name("out"))), + lx::Error::EROFS + ); + // Moving an entry into or out of a writable path changes a read-only + // directory. + assert_eq!( + error(fs.rename(&request(root), name("marker"), out, name("marker"), 0)), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rename(&request(out), name("existing"), root, name("existing"), 0)), + lx::Error::EROFS + ); + // A hard link in a writable path would make a read-only file writable. + assert_eq!( + error(fs.link(&request(out), name("marker-link"), marker)), + lx::Error::EXDEV + ); + assert_eq!( + error(fs.link(&request(root), name("existing-link"), existing)), + lx::Error::EROFS + ); + + // Inside them, the guest can create, modify, rename, link, and remove + // entries. + let (file, fh) = create(&fs, out, "file").unwrap(); + write(&fs, file, fh, b"guest").unwrap(); + release(&fs, file, fh); + let directory = mkdir(&fs, out, "directory").unwrap(); + fs.rename(&request(out), name("file"), directory, name("renamed"), 0) + .unwrap(); + fs.link(&request(out), name("hard"), existing).unwrap(); + fs.unlink(&request(out), name("hard")).unwrap(); + fs.set_attr(&request(existing), &setattr(FATTR_SIZE, |arg| arg.size = 0)) + .unwrap(); + fs.symlink(&request(out), name("link"), name("../marker")) + .unwrap(); + // A writable file can change, but not move, because its directory is + // read-only. + let log = lookup(&fs, "build.log").unwrap(); + let fh = open(&fs, log, lx::O_RDWR).unwrap(); + write(&fs, log, fh, b"guest").unwrap(); + release(&fs, log, fh); + assert_eq!( + error(fs.unlink(&request(root), name("build.log"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rename(&request(root), name("build.log"), out, name("build.log"), 0)), + lx::Error::EROFS + ); + + assert_eq!(share.read("out/directory/renamed"), "guest"); + assert_eq!(share.read("out/existing"), ""); + assert_eq!(share.entries("out"), ["directory", "existing", "link"]); + assert_eq!(share.read("build.log"), "guest"); + share.assert_protected_paths_unchanged(); +} + +#[test] +fn allowed_paths_stay_readable_inside_a_hidden_subtree() { + let share = Share::new(); + let fs = share.fs(&["logs", "logs/payloads/private"], &["logs/payloads"], &[]); + let root = FUSE_ROOT_ID; + + // The denied directory leads to an allowed path, so the guest can list + // and traverse it, but it lists only the way to the allowed path. + assert_eq!(list(&fs, root), ["build.log", "logs", "marker", "out"]); + let logs = lookup(&fs, "logs").unwrap(); + assert_eq!(list(&fs, logs), ["payloads"]); + for hidden in [ + "logs/secret", + "logs/gateway", + "logs/gateway/log", + "logs/missing", + "logs/payloads/private", + "logs/payloads/private/key", + ] { + assert_eq!(error(lookup(&fs, hidden)), lx::Error::EACCES, "{hidden}"); + } + let payloads = lookup(&fs, "logs/payloads").unwrap(); + assert_eq!(list(&fs, payloads), ["payload"]); + let payload = lookup(&fs, "logs/payloads/payload").unwrap(); + assert_eq!(read_node(&fs, payload), b"payload"); + + // The share is read-write and has no writable paths, so the allowed path + // is writable. + let (spilled, fh) = create(&fs, payloads, "spilled").unwrap(); + release(&fs, spilled, fh); + + // Nothing can change the traverse-only directory or its entries. + assert_eq!(error(create(&fs, logs, "new")), lx::Error::EROFS); + assert_eq!(error(mkdir(&fs, logs, "new")), lx::Error::EROFS); + assert_eq!( + error(fs.unlink(&request(logs), name("secret"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rmdir(&request(logs), name("payloads"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rename(&request(logs), name("payloads"), root, name("moved"), 0)), + lx::Error::EROFS + ); + assert_eq!( + error(fs.set_attr(&request(logs), &setattr(FATTR_MODE, |arg| arg.mode = 0o777))), + lx::Error::EROFS + ); + assert_eq!(error(open(&fs, logs, lx::O_RDWR)), lx::Error::EROFS); + // Its writable parent cannot remove, move, or replace it, so another + // directory cannot take its place and expose its hidden entries. + assert_eq!( + error(fs.rmdir(&request(root), name("logs"))), + lx::Error::EROFS + ); + assert_eq!( + error(fs.rename(&request(root), name("logs"), root, name("moved"), 0)), + lx::Error::EROFS + ); + mkdir(&fs, root, "other").unwrap(); + assert_eq!( + error(fs.rename( + &request(root), + name("other"), + root, + name("logs"), + RENAME_EXCHANGE + )), + lx::Error::EROFS + ); + assert_eq!( + error(fs.symlink(&request(root), name("logs"), name("out"))), + lx::Error::EROFS + ); + + assert_eq!( + share.entries("logs/payloads"), + ["payload", "private", "spilled"] + ); + assert_eq!( + share.entries(""), + ["build.log", "logs", "marker", "other", "out"] + ); + std::fs::remove_dir(share.path("other")).unwrap(); + std::fs::remove_file(share.path("logs/payloads/spilled")).unwrap(); + share.assert_protected_paths_unchanged(); +} + +#[test] +fn allowed_paths_follow_the_write_policy() { + let share = Share::new(); + let fs = share.fs(&["logs"], &["logs/payloads"], &["out"]); + let payloads = lookup(&fs, "logs/payloads").unwrap(); + let payload = lookup(&fs, "logs/payloads/payload").unwrap(); + assert_eq!(error(create(&fs, payloads, "spilled")), lx::Error::EROFS); + assert_eq!(error(open(&fs, payload, lx::O_RDWR)), lx::Error::EROFS); + + let fs = share.fs(&["logs"], &["logs/payloads"], &["logs/payloads"]); + let payload = lookup(&fs, "logs/payloads/payload").unwrap(); + let fh = open(&fs, payload, lx::O_RDWR).unwrap(); + write(&fs, payload, fh, b"updated").unwrap(); + release(&fs, payload, fh); + assert_eq!(error(create(&fs, FUSE_ROOT_ID, "new")), lx::Error::EROFS); + assert_eq!(share.read("logs/payloads/payload"), "updated"); +} + +/// Filesystems may report an entry's type as unknown, so HostFs asks the host +/// whether a traverse-only entry is a directory before listing it. +#[test] +fn traverse_only_entries_of_unknown_type_are_resolved_on_the_host() { + let share = Share::new(); + let fs = share.fs(&["logs"], &["logs/payloads/private"], &[]); + let root = fs.get_inode(FUSE_ROOT_ID).unwrap(); + let listable = |path: &str, file_type| { + root.volume + .entry_listable(&path.split('/').collect::(), file_type) + }; + for (path, expected) in [ + ("marker", true), + ("logs", true), + ("logs/secret", false), + ("logs/payloads", true), + ("logs/payloads/payload", false), + ("logs/payloads/private", true), + ] { + assert_eq!(listable(path, lx::DT_UNK), expected, "{path}"); + } + + // A traverse-only path that the host replaced with a file is not listed. + std::fs::rename(share.path("logs/payloads"), share.path("logs/moved")).unwrap(); + std::fs::write(share.path("logs/payloads"), "file").unwrap(); + assert!(!listable("logs/payloads", lx::DT_UNK)); + assert!(!listable("logs/payloads", lx::DT_REG)); +} + +/// A successful exchange swaps the names that the guest knows for the two +/// objects, so each node still reaches its own object. +#[cfg(unix)] +#[test] +fn exchanged_entries_keep_their_nodes() { + let share = Share::new(); + std::fs::create_dir(share.path("out/directory")).unwrap(); + std::fs::write(share.path("out/directory/file"), "nested").unwrap(); + let fs = share.fs(&[], &[], &["out"]); + let out = lookup(&fs, "out").unwrap(); + let existing = lookup(&fs, "out/existing").unwrap(); + let directory = lookup(&fs, "out/directory").unwrap(); + let nested = lookup(&fs, "out/directory/file").unwrap(); + + fs.rename( + &request(out), + name("existing"), + out, + name("directory"), + RENAME_EXCHANGE, + ) + .unwrap(); + assert_eq!(share.read("out/directory"), "existing"); + assert_eq!(share.read("out/existing/file"), "nested"); + + assert_eq!(read_node(&fs, existing), b"existing"); + assert_eq!(read_node(&fs, nested), b"nested"); + assert_eq!(list(&fs, directory), ["file"]); + let fh = open(&fs, nested, lx::O_RDWR | lx::O_TRUNC).unwrap(); + write(&fs, nested, fh, b"guest").unwrap(); + release(&fs, nested, fh); + assert_eq!(share.read("out/existing/file"), "guest"); + assert_eq!(lookup(&fs, "out/directory").unwrap(), existing); + assert_eq!(lookup(&fs, "out/existing").unwrap(), directory); + share.assert_protected_paths_unchanged(); +} + +#[test] +fn hidden_objects_stay_hidden_at_other_paths() { + let share = Share::new(); + let fs = share.fs(&["logs", "marker"], &["logs/payloads"], &[]); + lookup(&fs, "logs/payloads/payload").unwrap(); + + // A hidden file stays hidden through a hard link. + std::fs::hard_link(share.path("marker"), share.path("alias")).unwrap(); + assert_eq!(error(lookup(&fs, "alias")), lx::Error::EACCES); + + // A traverse-only directory that the host moves is reachable only at its + // own path, so its hidden entries stay hidden. + std::fs::rename(share.path("logs"), share.path("moved")).unwrap(); + assert_eq!(error(lookup(&fs, "moved")), lx::Error::EACCES); + assert_eq!(error(lookup(&fs, "moved/secret")), lx::Error::EACCES); + + // Anything but a directory at a traverse-only path is hidden. + std::fs::write(share.path("logs"), "file").unwrap(); + assert_eq!(error(lookup(&fs, "logs")), lx::Error::EACCES); + assert!(!list(&fs, FUSE_ROOT_ID).contains(&"logs".to_owned())); + std::fs::remove_file(share.path("logs")).unwrap(); + std::fs::rename(share.path("moved"), share.path("logs")).unwrap(); + std::fs::remove_file(share.path("alias")).unwrap(); + share.assert_protected_paths_unchanged(); +} + +#[test] +fn attaching_requires_directories_on_the_way_to_allowed_paths() { + let share = Share::new(); + for allowed in ["logs/secret/inner", "logs/missing/inner"] { + let profile = share.profile(&["logs"], &[allowed], &[]); + assert!( + VirtioFs::new_microvm(&share.root, profile).is_err(), + "{allowed}" + ); + } +} + +#[test] +fn access_policy_survives_save_and_restore() { + let share = Share::new(); + let profile = share.profile(&["logs"], &["logs/payloads"], &["out"]); + let source = VirtioFs::new_microvm(&share.root, profile.clone()).unwrap(); + let existing = lookup(&source, "out/existing").unwrap(); + let fh = open(&source, existing, lx::O_RDWR).unwrap(); + let logs = lookup(&source, "logs").unwrap(); + let state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + assert_eq!(state.schema_version, SUBTREE_POLICY_SCHEMA_VERSION); + assert_eq!(state.denied_paths, ["logs"]); + assert_eq!(state.allowed_paths, ["logs/payloads"]); + assert_eq!(state.writable_paths, ["out"]); + + // The restore attachment needs exactly the same policy. + for other in [ + share.profile(&["logs"], &["logs/payloads"], &[]), + share.profile(&["logs"], &[], &["out"]), + share.profile(&[], &[], &[]), + ] { + assert!(validate_microvm_state(&state, &other).is_err()); + } + let destination = VirtioFs::new_microvm(&share.root, profile.clone()).unwrap(); + let session = Session::new(destination.clone()); + destination + .restore_microvm_state(&profile, state, &session) + .unwrap(); + write(&destination, existing, fh, b"restored").unwrap(); + assert_eq!( + error(create(&destination, FUSE_ROOT_ID, "new")), + lx::Error::EROFS + ); + assert_eq!(list(&destination, logs), ["payloads"]); + assert_eq!(share.read("out/existing"), "restored"); +} + +#[test] +fn restore_rejects_a_writable_handle_outside_the_writable_paths() { + let share = Share::new(); + let profile = share.profile(&[], &[], &["out"]); + let source = VirtioFs::new_microvm(&share.root, profile.clone()).unwrap(); + let marker = lookup(&source, "marker").unwrap(); + open(&source, marker, lx::O_RDONLY).unwrap(); + let mut state = source + .save_microvm_state(&profile, SessionState::default()) + .unwrap(); + validate_microvm_state(&state, &profile).unwrap(); + + let handle = state + .handles + .iter_mut() + .find(|handle| handle.node_id == marker) + .unwrap(); + handle.open_flags = lx::O_RDWR as u32 | lx::O_NOFOLLOW as u32; + assert!(validate_microvm_state(&state, &profile).is_err()); +} + +#[test] +fn saved_state_records_only_policies_that_restrict_writes() { + let share = Share::new(); + // A deny-only attachment keeps the earlier schema, so that earlier + // releases, which enforce denied paths, can restore it. + let deny_only = share.profile(&["logs"], &[], &[]); + let source = VirtioFs::new_microvm(&share.root, deny_only.clone()).unwrap(); + let mut state = source + .save_microvm_state(&deny_only, SessionState::default()) + .unwrap(); + assert_eq!(state.schema_version, SCHEMA_VERSION); + assert!(state.denied_paths.is_empty() && state.allowed_paths.is_empty()); + validate_microvm_state(&state, &deny_only).unwrap(); + + // A state without a policy cannot restore an attachment with one, and an + // earlier schema cannot carry one. + let narrowed = share.profile(&[], &[], &["out"]); + assert!(validate_microvm_state(&state, &narrowed).is_err()); + state.writable_paths = owned(&["out"]); + assert!(validate_microvm_state(&state, &deny_only).is_err()); + assert!(validate_microvm_state(&state, &narrowed).is_err()); + + // A dormant slot carries no policy. + let mut dormant = + save_dormant_microvm_state(MICROVM_ATTACHMENT_ID, SessionState::default()).unwrap(); + validate_dormant_microvm_state(&dormant, MICROVM_ATTACHMENT_ID).unwrap(); + dormant.allowed_paths = owned(&["logs/payloads"]); + assert!(validate_dormant_microvm_state(&dormant, MICROVM_ATTACHMENT_ID).is_err()); +} diff --git a/vm/devices/virtio/virtiofs/src/microvm/profile.rs b/vm/devices/virtio/virtiofs/src/microvm/profile.rs index 9ed0b7765af..c3313993c52 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/profile.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/profile.rs @@ -3,6 +3,7 @@ //! The fixed virtio-fs contract used by the microVM profile. +use super::policy::SubtreePolicy; #[cfg(windows)] use anyhow::Context as _; use std::path::Path; @@ -117,6 +118,14 @@ pub enum MicroVmProfileError { /// The denied-path list was not canonical. #[error("microVM virtio-fs denied paths are invalid")] InvalidDeniedPaths, + /// The allowed-path list was not canonical, or an allowed path was not + /// inside a denied path. + #[error("microVM virtio-fs allowed paths are invalid")] + InvalidAllowedPaths, + /// The writable-path list was not canonical, overlapped, was hidden, or + /// belonged to a read-only share. + #[error("microVM virtio-fs writable paths are invalid")] + InvalidWritablePaths, /// Caller ownership needs per-thread filesystem credentials, which only /// Linux provides. #[error("microVM virtio-fs caller ownership requires a Linux host")] @@ -134,7 +143,7 @@ pub struct MicroVmVirtioFsProfile { mount_tag: &'static str, root_identity: Vec, access_mode: MicroVmAccessMode, - denied_paths: Vec, + policy: SubtreePolicy, owner_mode: MicroVmOwnerMode, } @@ -149,13 +158,41 @@ impl MicroVmVirtioFsProfile { root_identity: Vec, read_only: bool, denied_paths: Vec, + ) -> Result { + Self::from_attachment_with_policy( + stable_id, + root_identity, + read_only, + denied_paths, + Vec::new(), + Vec::new(), + ) + } + + /// Builds the profile like [`Self::from_attachment`], with the complete + /// access policy of the share: `allowed_paths` exposes subtrees of + /// `denied_paths` again, and `writable_paths`, when not empty, are the only + /// subtrees of a read-write share that the guest can modify. Each list + /// holds unique, canonical share-relative paths in lexical order. + pub fn from_attachment_with_policy( + stable_id: String, + root_identity: Vec, + read_only: bool, + denied_paths: Vec, + allowed_paths: Vec, + writable_paths: Vec, ) -> Result { let mount_tag = microvm_mount_tag(&stable_id).ok_or(MicroVmProfileError::InvalidStableId)?; if root_identity.is_empty() || root_identity.len() > MAX_ROOT_IDENTITY_SIZE { return Err(MicroVmProfileError::InvalidRootIdentity); } - let denied_paths = Self::parse_denied_paths(denied_paths)?; + let policy = SubtreePolicy::new( + Self::parse_policy_paths(denied_paths, MicroVmProfileError::InvalidDeniedPaths)?, + Self::parse_policy_paths(allowed_paths, MicroVmProfileError::InvalidAllowedPaths)?, + Self::parse_policy_paths(writable_paths, MicroVmProfileError::InvalidWritablePaths)?, + read_only, + )?; Ok(Self { stable_id, mount_tag, @@ -165,7 +202,7 @@ impl MicroVmVirtioFsProfile { } else { MicroVmAccessMode::ReadWrite }, - denied_paths, + policy, owner_mode: MicroVmOwnerMode::Vmm, }) } @@ -279,7 +316,25 @@ impl MicroVmVirtioFsProfile { /// Returns canonical host-relative paths hidden from the guest. pub fn denied_paths(&self) -> &[PathBuf] { - &self.denied_paths + self.policy.denied_paths() + } + + /// Returns canonical host-relative paths that are exposed again inside + /// denied paths. + pub fn allowed_paths(&self) -> &[PathBuf] { + self.policy.allowed_paths() + } + + /// Returns the canonical host-relative paths that are the only writable + /// parts of a read-write share, or nothing when the whole share is + /// writable. + pub fn writable_paths(&self) -> &[PathBuf] { + self.policy.writable_paths() + } + + /// Returns the share's access policy. + pub(crate) fn subtree_policy(&self) -> &SubtreePolicy { + &self.policy } /// Returns the entry-cache lifetime required by the ABI. @@ -287,9 +342,14 @@ impl MicroVmVirtioFsProfile { Duration::ZERO } - fn parse_denied_paths(paths: Vec) -> Result, MicroVmProfileError> { - if paths.len() > 128 { - return Err(MicroVmProfileError::InvalidDeniedPaths); + /// Parses unique, canonical share-relative paths in lexical order, which is + /// the order that the snapshot contract records. + fn parse_policy_paths( + paths: Vec, + error: MicroVmProfileError, + ) -> Result, MicroVmProfileError> { + if paths.len() > 128 || paths.windows(2).any(|pair| pair[0] >= pair[1]) { + return Err(error); } let mut parsed = Vec::with_capacity(paths.len()); for path in paths { @@ -301,27 +361,17 @@ impl MicroVmVirtioFsProfile { character.is_whitespace() || matches!(character, '\0' | '\\' | ':') }) { - return Err(MicroVmProfileError::InvalidDeniedPaths); + return Err(error); } let mut relative = PathBuf::new(); for component in path.split('/') { if component.is_empty() || matches!(component, "." | "..") { - return Err(MicroVmProfileError::InvalidDeniedPaths); + return Err(error); } relative.push(component); } parsed.push(relative); } - let mut canonical = parsed.clone(); - canonical.sort_unstable(); - if canonical != parsed { - return Err(MicroVmProfileError::InvalidDeniedPaths); - } - for pair in parsed.windows(2) { - if pair[1].starts_with(&pair[0]) { - return Err(MicroVmProfileError::InvalidDeniedPaths); - } - } Ok(parsed) } @@ -535,4 +585,59 @@ mod tests { ); } } + + #[test] + fn profile_carries_a_canonical_subtree_policy() { + let root = tempfile::tempdir().unwrap(); + let identity = microvm_root_identity(root.path()).unwrap(); + fn owned(paths: &[&str]) -> Vec { + paths.iter().map(|path| (*path).to_owned()).collect() + } + let with_policy = + |read_only: bool, denied: &[&str], allowed: &[&str], writable: &[&str]| { + MicroVmVirtioFsProfile::from_attachment_with_policy( + MICROVM_ATTACHMENT_ID.to_owned(), + identity.clone(), + read_only, + owned(denied), + owned(allowed), + owned(writable), + ) + }; + + // Paths are in the lexical order of the snapshot contract, in which a + // sibling such as `logs-old` sorts before `logs/...`. + let profile = with_policy( + false, + &["logs", "logs-old"], + &["logs/payloads"], + &["out", "out-cache"], + ) + .unwrap(); + assert_eq!( + profile.denied_paths(), + [PathBuf::from("logs"), PathBuf::from("logs-old")] + ); + assert_eq!( + profile.allowed_paths(), + [["logs", "payloads"].iter().collect::()] + ); + assert_eq!( + profile.writable_paths(), + [PathBuf::from("out"), PathBuf::from("out-cache")] + ); + assert!(profile.subtree_policy().restricts_writes()); + + for (read_only, denied, allowed, writable) in [ + (false, vec!["logs"], vec!["payloads"], vec![]), + (false, vec!["logs"], vec!["logs/b", "logs/a"], vec![]), + (false, vec!["logs"], vec!["logs/../escape"], vec![]), + (true, vec![], vec![], vec!["out"]), + (false, vec![], vec![], vec!["out", "out"]), + (false, vec![], vec![], vec!["out/"]), + (false, vec!["logs"], vec![], vec!["logs/out"]), + ] { + assert!(with_policy(read_only, &denied, &allowed, &writable).is_err()); + } + } } diff --git a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs index 3e197064811..0ff9647fc2d 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/resolver.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/resolver.rs @@ -42,6 +42,8 @@ pub(crate) fn resolve( root_identity, read_only, denied_paths, + allowed_paths, + writable_paths, caller_identity, } => { validate_tag(resource, stable_id)?; @@ -62,6 +64,8 @@ pub(crate) fn resolve( root_identity.clone(), *read_only, denied_paths.clone(), + allowed_paths.clone(), + writable_paths.clone(), if *caller_identity { MicroVmOwnerMode::Caller } else { @@ -122,6 +126,8 @@ mod tests { root_identity, read_only: true, denied_paths: Vec::new(), + allowed_paths: Vec::new(), + writable_paths: Vec::new(), caller_identity: false, }, }, diff --git a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs index 3a165bb4369..979b01c96b9 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/saved_state.rs @@ -16,6 +16,12 @@ pub(crate) const SCHEMA_VERSION: u32 = 5; /// callers. Readers that predate caller ownership ignore `caller_identity` /// and would restore the attachment as the VMM, so they must reject its state. pub(crate) const CALLER_IDENTITY_SCHEMA_VERSION: u32 = 6; +/// The schema version of an attachment whose access policy has allowed or +/// writable paths, which it records along with its denied paths. Readers that +/// predate these paths would restore the attachment without them, and so +/// could let the guest write everywhere in the share, so they must reject its +/// state. The state records the ownership mode in `caller_identity`. +pub(crate) const SUBTREE_POLICY_SCHEMA_VERSION: u32 = 7; pub(crate) const MAX_INODES: usize = 4096; pub(crate) const MAX_HANDLES: usize = 4096; pub(crate) const MAX_PATH_BYTES: usize = 4096; @@ -70,6 +76,20 @@ pub(crate) struct SavedState { /// this field ran every request as the VMM. #[mesh(18)] pub caller_identity: bool, + /// The canonical share-relative paths that the attachment hides, recorded + /// only by version [`SUBTREE_POLICY_SCHEMA_VERSION`] states. + #[mesh(19)] + pub denied_paths: Vec, + /// The canonical share-relative paths that the attachment exposes again + /// inside denied paths, recorded only by version + /// [`SUBTREE_POLICY_SCHEMA_VERSION`] states. + #[mesh(20)] + pub allowed_paths: Vec, + /// The canonical share-relative paths that are the only writable parts of + /// the attachment, recorded only by version + /// [`SUBTREE_POLICY_SCHEMA_VERSION`] states. + #[mesh(21)] + pub writable_paths: Vec, } #[derive(Protobuf)] diff --git a/vm/devices/virtio/virtiofs/src/microvm/state.rs b/vm/devices/virtio/virtiofs/src/microvm/state.rs index 9e60ae365a2..19187f2fb97 100644 --- a/vm/devices/virtio/virtiofs/src/microvm/state.rs +++ b/vm/devices/virtio/virtiofs/src/microvm/state.rs @@ -23,6 +23,7 @@ use super::saved_state::MAX_INODES; use super::saved_state::MAX_PATH_BYTES; use super::saved_state::PREVIOUS_SCHEMA_VERSION; use super::saved_state::SCHEMA_VERSION; +use super::saved_state::SUBTREE_POLICY_SCHEMA_VERSION; use super::saved_state::SavedNegotiation; use super::saved_state::SavedObjectIdentity; use super::saved_state::SavedState; @@ -129,6 +130,32 @@ pub(crate) fn saved_identity(stat: &lx::Stat) -> SavedObjectIdentity { } } +/// Returns the canonical `/`-separated spelling of share-relative policy +/// paths, as the attachment's resource and the snapshot contract spell them. +pub(crate) fn saved_policy_paths(paths: &[PathBuf]) -> Vec { + paths + .iter() + .map(|path| { + path.iter() + .map(|component| component.to_string_lossy()) + .collect::>() + .join("/") + }) + .collect() +} + +/// Returns the oldest schema version that can record the state of `profile`'s +/// attachment, so that releases that cannot enforce its policy reject it. +pub(crate) fn schema_version(profile: &MicroVmVirtioFsProfile) -> u32 { + if profile.subtree_policy().restricts_writes() { + SUBTREE_POLICY_SCHEMA_VERSION + } else if profile.owner_mode() == MicroVmOwnerMode::Caller { + CALLER_IDENTITY_SCHEMA_VERSION + } else { + SCHEMA_VERSION + } +} + pub(crate) fn validate_identity( stat: &lx::Stat, saved: &SavedObjectIdentity, @@ -218,6 +245,9 @@ pub(crate) fn save_dormant_microvm_state( maximum_request_size: MAX_FUSE_REQUEST_BYTES as u32, dormant: true, caller_identity: false, + denied_paths: Vec::new(), + allowed_paths: Vec::new(), + writable_paths: Vec::new(), }) } @@ -247,7 +277,10 @@ pub(crate) fn validate_dormant_microvm_state( && state.inodes.is_empty() && state.handles.is_empty() && state.attachment_root_identity.is_empty() - && !state.caller_identity, + && !state.caller_identity + && state.denied_paths.is_empty() + && state.allowed_paths.is_empty() + && state.writable_paths.is_empty(), "dormant virtio-fs state contains active filesystem policy or objects" ); let session_state = session_state_from_saved(&state.negotiation)?; @@ -265,13 +298,20 @@ pub(crate) fn validate_microvm_state( anyhow::ensure!( matches!( state.schema_version, - PREVIOUS_SCHEMA_VERSION | SCHEMA_VERSION | CALLER_IDENTITY_SCHEMA_VERSION + PREVIOUS_SCHEMA_VERSION + | SCHEMA_VERSION + | CALLER_IDENTITY_SCHEMA_VERSION + | SUBTREE_POLICY_SCHEMA_VERSION ) && !state.dormant, "unsupported virtio-fs state schema version {}", state.schema_version ); anyhow::ensure!( - state.caller_identity == (state.schema_version == CALLER_IDENTITY_SCHEMA_VERSION), + match state.schema_version { + CALLER_IDENTITY_SCHEMA_VERSION => state.caller_identity, + SUBTREE_POLICY_SCHEMA_VERSION => true, + _ => !state.caller_identity, + }, "saved ownership mode does not match the virtio-fs state schema version" ); anyhow::ensure!( @@ -290,6 +330,34 @@ pub(crate) fn validate_microvm_state( state.caller_identity == (profile.owner_mode() == MicroVmOwnerMode::Caller), "saved ownership mode does not match the restore profile" ); + let saved_policy = ( + &state.denied_paths, + &state.allowed_paths, + &state.writable_paths, + ); + if state.schema_version == SUBTREE_POLICY_SCHEMA_VERSION { + let policy = profile.subtree_policy(); + anyhow::ensure!( + policy.restricts_writes() + && saved_policy + == ( + &saved_policy_paths(policy.denied_paths()), + &saved_policy_paths(policy.allowed_paths()), + &saved_policy_paths(policy.writable_paths()), + ), + "saved access policy does not match the restore profile" + ); + } else { + // Earlier states record no access policy, and their attachments had + // neither allowed nor writable paths. + anyhow::ensure!( + !profile.subtree_policy().restricts_writes() + && state.denied_paths.is_empty() + && state.allowed_paths.is_empty() + && state.writable_paths.is_empty(), + "saved access policy does not match the restore profile" + ); + } anyhow::ensure!( state.request_queues == profile.request_queues() && state.shared_memory_size == 0 @@ -342,6 +410,10 @@ pub(crate) fn validate_microvm_state( let mut alias_paths = HashSet::new(); let mut alias_count = 0usize; let mut alias_bytes = 0usize; + // Every alias of each inode is writable, when the access policy restricts + // writes. + let mut writable_inodes = HashSet::new(); + let policy = profile.subtree_policy(); for inode in &state.inodes { anyhow::ensure!( inode.node_id != 0 && inode_ids.insert(inode.node_id), @@ -368,6 +440,7 @@ pub(crate) fn validate_microvm_state( .checked_add(inode.relative_aliases.len()) .context("saved alias count overflow")?; anyhow::ensure!(alias_count <= MAX_ALIASES, "saved alias table is too large"); + let mut writable = true; for alias in &inode.relative_aliases { anyhow::ensure!( alias.len() <= MAX_PATH_BYTES, @@ -393,11 +466,15 @@ pub(crate) fn validate_microvm_state( "non-root inode has a root alias" ); } + writable &= policy.is_writable(&path); anyhow::ensure!( alias_paths.insert(path), "saved aliases are duplicated or ambiguous" ); } + if writable { + writable_inodes.insert(inode.node_id); + } } anyhow::ensure!(root_count == 1, "saved inode table must contain one root"); anyhow::ensure!( @@ -424,12 +501,19 @@ pub(crate) fn validate_microvm_state( "saved handle has an unsupported object kind" ); let reopen_flags = reopen_flags(handle.open_flags)?; + let writable_handle = reopen_flags & lx::O_ACCESS_MASK as u32 != lx::O_RDONLY as u32; if profile.is_readonly() { anyhow::ensure!( - reopen_flags & lx::O_ACCESS_MASK as u32 == lx::O_RDONLY as u32, + !writable_handle, "read-only microVM profile cannot restore a writable handle" ); } + if policy.restricts_writes() { + anyhow::ensure!( + !writable_handle || writable_inodes.contains(&handle.node_id), + "microVM access policy cannot restore a writable handle outside its writable paths" + ); + } anyhow::ensure!( handle.directory_entries.len() <= MAX_DIRECTORY_ENTRIES_PER_HANDLE, "saved directory enumeration is too large" From 3e1f5bd48392eaeb90962b4ae191a908c859a0ab Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Mon, 5 Oct 2026 08:03:47 -0700 Subject: [PATCH 293/304] virt_whp: let WHP answer CPUID leaves 0 and 1 without exits A partition with the Hyper-V guest interface enables CPUID exits without a CPUID exit list, so WHP sends every CPUID instruction to OpenVMM, and each one's latency follows host scheduling. That latency reaches guest code that runs CPUID with interrupts disabled. The mu_msvm UEFI firmware's SynIC timer and SINT handlers send their EOI before handling the interrupt, and on each EOI, EDK2's local APIC library runs five CPUID instructions, for leaves 0 and 1, to check for the APIC base MSR and for TDX. RestoreTPL() then enables interrupts before the handler returns, so when host contention stretches an interrupt past the firmware's 10 ms timer period, the next tick nests on the same stack. The nesting repeats, 0x4d0 bytes per level, until the 128 KiB DXE stack overflows into its guard page and the VM triple faults (microsoft/nvx#368). On a bare-metal Windows Server 2025 WHP host, adding 2.5 ms to each CPUID exit made the issue's Windows Server 2022 UEFI vmm-test triple fault in every run. The firmware stack held 102 nested timer interrupt frames, each interrupted just after RestoreTPL()'s STI, and the faulting address matched the CI failure's. Without the delay, leaves 0 and 1 were 4,875 of VP 0's 6,748 CPUID exits during that test's boot. Keep those leaves in the hypervisor for VTL0 partitions with the in-hypervisor APIC and enlightenments, and without VTL2, nested virtualization, isolation, or the time ABI: - Before partition setup, NativeCpuidLeaves::configure programs CpuidResultList2 with the partition-wide bits that the CPUID table sets in leaf 1: x2APIC support, the hypervisor bit, and the topology's logical processors per package. When the host's maximum basic leaf precedes the TSC frequency leaf, it also raises leaf 0's maximum to that leaf, as the table does. WHP keeps each VP's own initial APIC ID, so configure applies only when every VP's APIC ID equals its index. - It replaces exiting on every leaf with a 39-entry CPUID exit list: the cache, topology, TSC frequency, and address size leaves that the table sets or that the exit handler adjusts for each VP, the Hyper-V interface leaves 0x40000000-0x4000000f, and the virtualization stack leaves 0x40000080-0x4000008f. Other leaves, such as leaf 7, which neither the table nor the exit handler changes, now get the same result from WHP without an exit. - Once the CPUID table is built, NativeCpuidLeaves::verify requires each of its leaves to exit, or to be leaf 0 or 1 with WHP's result already matching the table outside the initial APIC ID, and binding a VP checks that WHP reports the VP's APIC ID in leaf 1. A mismatch fails partition creation with a descriptive error instead of presenting different CPUID results. - If WHP rejects either property, every leaf exits as before. The guest-visible CPUID does not change. The x2APIC leaf moves into a helper, x2apic_leaf, that both the table and the programmed results use. Each exit list entry adds about 25 microseconds to partition setup, so the list leaves the rest of the hypervisor range, which the table does not set, to WHP. On the bare-metal host, a minimal two-VP partition's setup took 1.73 ms instead of 0.80 ms, measured before the list gained leaf 0x8000001d, AMD's cache topology leaf. With this change, VP 0's CPUID exits during the vmm-test's boot fell to 344, none of them for leaves 0 or 1. With 2.5 ms added to each CPUID exit, the vmm-test passed both runs at the firmware's normal stack depth. With 2.5 ms added to every exit, which also triple faulted before, UEFI boot completed. The firmware's nesting defect remains, but OpenVMM no longer exposes these handlers to host scheduling. Unit tests cover the programmed results, the exit list, and verify's rejections, and an ignored test checks the results that a real WHP partition reports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 226731e8-3e38-4396-8109-80c7ab5f79bd --- vmm_core/virt_whp/src/lib.rs | 79 +++- vmm_core/virt_whp/src/native_cpuid.rs | 587 ++++++++++++++++++++++++++ 2 files changed, 656 insertions(+), 10 deletions(-) create mode 100644 vmm_core/virt_whp/src/native_cpuid.rs diff --git a/vmm_core/virt_whp/src/lib.rs b/vmm_core/virt_whp/src/lib.rs index 81577931534..49588c30d92 100644 --- a/vmm_core/virt_whp/src/lib.rs +++ b/vmm_core/virt_whp/src/lib.rs @@ -19,6 +19,8 @@ mod fingerprint; mod hypercalls; mod memory; #[cfg(guest_arch = "x86_64")] +mod native_cpuid; +#[cfg(guest_arch = "x86_64")] mod profile_features; mod regs; mod synic; @@ -175,6 +177,11 @@ struct VtlPartition { hypervisor_enlightened: bool, + /// Set when WHP answers CPUID leaves 0 and 1 without exits. + #[cfg(guest_arch = "x86_64")] + #[inspect(with = "Option::is_some")] + native_cpuid_leaves: Option, + /// The NVX time ABI state this partition was configured with, until the /// partition build moves it to [`WhpPartitionInner`]. #[cfg(guest_arch = "x86_64")] @@ -788,6 +795,17 @@ impl virt::BindProcessor for WhpProcessorBinder { } } + // WHP answers CPUID leaf 1, with this VP's initial APIC ID, without + // an exit. + #[cfg(guest_arch = "x86_64")] + if let Some(native_cpuid_leaves) = &vtlp.native_cpuid_leaves { + native_cpuid_leaves.verify_apic_id( + vp.vp.whp(vtl), + vp_info.base.vp_index.index(), + vp_info.apic_id, + )?; + } + #[cfg(guest_arch = "aarch64")] { let _ = vtlp; @@ -881,6 +899,27 @@ pub enum Error { #[cfg(guest_arch = "x86_64")] #[error(transparent)] TimeAbi(#[from] virt::time_abi::TimeAbiError), + #[cfg(guest_arch = "x86_64")] + #[error("CPUID leaf {0:#x} has a programmed result but does not exit")] + NativeCpuidUnrouted(u32), + #[cfg(guest_arch = "x86_64")] + #[error( + "WHP answers CPUID leaf {function:#x} with {native:08x?} without an exit, but the partition's result is {expected:08x?}" + )] + NativeCpuidMismatch { + function: u32, + native: [u32; 4], + expected: [u32; 4], + }, + #[cfg(guest_arch = "x86_64")] + #[error( + "WHP reports initial APIC ID {native:#x} in CPUID leaf 1 of VP {vp_index}, but its APIC ID is {apic_id:#x}" + )] + NativeCpuidApicId { + vp_index: u32, + native: u32, + apic_id: u32, + }, } trait WhpResultExt { @@ -1174,7 +1213,7 @@ impl WhpPartitionInner { // FUTURE: register cpuid results with the hypervisor, and register // appropriate per-VP results where necessary (or tell the hypervisor // the AMD topology information so that it can provide per-VP results - // accurately). + // accurately). `native_cpuid` does so for leaves 0 and 1. #[cfg(guest_arch = "x86_64")] let cpuid = { use vm_topology::processor::x86::ApicMode; @@ -1183,15 +1222,9 @@ impl WhpPartitionInner { // Report x2apic support. When the APIC is in the hypervisor, the // hypervisor will do this automatically, but it doesn't hurt to do this // again. - let mask = [0, 0, 1 << 21, 0]; - let (value, use_apic_msrs) = match proto_config.processor_topology.apic_mode() { - ApicMode::XApic => ([0; 4], true), - ApicMode::X2ApicSupported | ApicMode::X2ApicEnabled => (mask, false), - }; - cpuid.push( - virt::CpuidLeaf::new(x86defs::cpuid::CpuidFunction::VersionAndFeatures.0, value) - .masked(mask), - ); + let apic_mode = proto_config.processor_topology.apic_mode(); + let use_apic_msrs = matches!(apic_mode, ApicMode::XApic); + cpuid.push(native_cpuid::x2apic_leaf(apic_mode)); // Add in the synthetic hv leaves if necessary. if proto_config.hv_config.is_some() { @@ -1262,6 +1295,11 @@ impl WhpPartitionInner { } }; + #[cfg(guest_arch = "x86_64")] + if let Some(native_cpuid_leaves) = &vtl0.native_cpuid_leaves { + native_cpuid_leaves.verify(&cpuid, |eax, ecx| vtl0.cpuid(eax, ecx))?; + } + let mut vtl0_alias_map_offset = None; let vtl2_emulation = if let Some(vtl2_config) = proto_config .hv_config @@ -1536,6 +1574,8 @@ impl VtlPartition { } let mut hypervisor_enlightened = false; + #[cfg(guest_arch = "x86_64")] + let mut native_cpuid_leaves = None; let mut extended_exits = whp::abi::WHV_EXTENDED_VM_EXITS(0); @@ -1789,6 +1829,23 @@ impl VtlPartition { synth_features, )) .for_op("set synthetic processor features")?; + + // Keep CPUID leaves 0 and 1, which guest interrupt handlers + // can run, in the hypervisor (see `native_cpuid`). Other VTL + // and isolation configurations route more leaves through the + // exit handler. + #[cfg(guest_arch = "x86_64")] + if vtl == Vtl::Vtl0 + && hv_config.vtl2.is_none() + && !nested_virt + && !config.isolation.is_isolated() + && time_abi.is_none() + { + native_cpuid_leaves = native_cpuid::NativeCpuidLeaves::configure( + config.processor_topology, + &mut whp_config, + ); + } } else { with_overlays = hv_config.vtl2.is_none(); } @@ -1882,6 +1939,8 @@ impl VtlPartition { lapic, hypervisor_enlightened, #[cfg(guest_arch = "x86_64")] + native_cpuid_leaves, + #[cfg(guest_arch = "x86_64")] time_abi, }) } diff --git a/vmm_core/virt_whp/src/native_cpuid.rs b/vmm_core/virt_whp/src/native_cpuid.rs new file mode 100644 index 00000000000..25f734cd090 --- /dev/null +++ b/vmm_core/virt_whp/src/native_cpuid.rs @@ -0,0 +1,587 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! WHP-native CPUID leaves 0 and 1 for partitions with the Hyper-V guest +//! interface. +//! +//! Such a partition enables CPUID exits so that OpenVMM can apply the +//! partition's CPUID table. Without an exit list, WHP sends every CPUID to +//! OpenVMM, so each one leaves the hypervisor and its latency follows host +//! scheduling. That latency reaches guest code that runs CPUID with interrupts +//! disabled. The mu_msvm UEFI firmware's SynIC timer and SINT interrupt +//! handlers run CPUID leaves 0 and 1 five times per interrupt, because EDK2's +//! local APIC library probes for TDX and for the APIC base MSR on each EOI. +//! When host contention makes those exits outlast the firmware's 10 ms timer +//! period, the next tick is pending when the handler's `RestoreTPL()` enables +//! interrupts, so it nests on the same stack. The nesting repeats until the +//! firmware's DXE stack overflows. +//! +//! [`NativeCpuidLeaves::configure`] keeps these leaves in the hypervisor. +//! Before setup, it programs the partition-wide bits that the partition's +//! CPUID table sets in leaves 0 and 1 with `CpuidResultList2`, and WHP keeps +//! each VP's initial APIC ID. Every other leaf that the table sets, or that +//! the exit handler adjusts for each VP, stays in the CPUID exit list. After +//! the table is built, [`NativeCpuidLeaves::verify`] checks that the guest +//! sees the same results as through exits. + +use crate::Error; +use crate::WhpResultExt; +use std::ops::RangeInclusive; +use virt::CpuidLeaf; +use virt::CpuidLeafSet; +use virt::x86::topology::per_vp_cpuid_bits; +use vm_topology::processor::ProcessorTopology; +use vm_topology::processor::x86::ApicMode; +use whp::abi::WHV_CPUID_OUTPUT; +use whp::abi::WHV_X64_CPUID_RESULT2; +use whp::abi::WHV_X64_CPUID_RESULT2_FLAGS; +use x86defs::cpuid::CpuidFunction; +use x86defs::cpuid::VersionAndFeaturesEbx; +use x86defs::cpuid::VersionAndFeaturesEcx; + +/// The leaves that WHP answers without an exit. +const NATIVE_LEAVES: [u32; 2] = [ + CpuidFunction::VendorAndMaxFunction.0, + CpuidFunction::VersionAndFeatures.0, +]; + +/// The leaves outside the hypervisor range that still exit: the leaves that a +/// partition's CPUID table sets, other than leaves 0 and 1, and the leaves +/// that the exit handler adjusts for each VP. +const EXIT_LEAVES: [u32; 7] = [ + CpuidFunction::CacheParameters.0, + CpuidFunction::ExtendedTopologyEnumeration.0, + CpuidFunction::CoreCrystalClockInformation.0, + CpuidFunction::V2ExtendedTopologyEnumeration.0, + CpuidFunction::ExtendedAddressSpaceSizes.0, + CpuidFunction::CacheTopologyDefinition.0, + CpuidFunction::ProcessorTopologyDefinition.0, +]; + +/// The hypervisor leaves that still exit: the Hyper-V interface leaves, which +/// the CPUID table masks, and the virtualization stack's leaves, which the VMM +/// adds. Each exit-list entry adds about 25 µs to partition setup, so the rest +/// of the hypervisor range stays in the hypervisor, and +/// [`NativeCpuidLeaves::verify`] rejects a CPUID table that sets one of those +/// leaves. +const EXIT_HYPERVISOR_LEAVES: [RangeInclusive; 2] = [ + hvdef::HV_CPUID_FUNCTION_HV_VENDOR_AND_MAX_FUNCTION..=0x4000_000f, + hvdef::VIRTUALIZATION_STACK_CPUID_VENDOR..=0x4000_008f, +]; + +/// A partition whose CPUID leaves 0 and 1 WHP answers without exits. +#[derive(Debug)] +pub(crate) struct NativeCpuidLeaves { + /// The CPUID exit list, sorted. + exits: Vec, +} + +impl NativeCpuidLeaves { + /// Programs `whp_config` so that WHP answers CPUID leaves 0 and 1 without + /// exits, for a partition with the in-hypervisor APIC and enlightenments. + /// + /// Returns `None`, leaving every leaf to exit, when a VP's APIC ID differs + /// from its index, the host's processor vendor is unknown, or WHP rejects + /// the programming. + pub(crate) fn configure( + topology: &ProcessorTopology, + whp_config: &mut whp::PartitionConfig, + ) -> Option { + // WHP's initial APIC ID is the VP index unless OpenVMM sets it, so + // only then does WHP's leaf 1 report each VP's APIC ID. + if topology + .vps_arch() + .any(|vp| vp.apic_id != vp.base.vp_index.index()) + { + return None; + } + + let results = native_results(topology, &host_cpuid)?; + if let Err(err) = + whp_config.set_property(whp::PartitionProperty::CpuidResultList2(&results)) + { + tracing::info!( + error = &err as &dyn std::error::Error, + "WHP lacks CPUID results; CPUID leaves 0 and 1 exit" + ); + return None; + } + + // The results programmed above match the CPUID table, so leaves 0 and + // 1 stay correct through exits if WHP rejects the exit list. + let exits = exit_leaves(); + if let Err(err) = whp_config.set_property(whp::PartitionProperty::CpuidExitList(&exits)) { + tracing::info!( + error = &err as &dyn std::error::Error, + "WHP rejects the CPUID exit list; CPUID leaves 0 and 1 exit" + ); + return None; + } + + Some(Self { exits }) + } + + /// Checks that every leaf of `cpuid`, the partition's CPUID table, reaches + /// the guest: a leaf exits, or it is leaf 0 or 1 and `native`, WHP's + /// result for VP 0, already matches the table outside the per-VP APIC + /// identity bits. + pub(crate) fn verify( + &self, + cpuid: &CpuidLeafSet, + native: impl Fn(u32, u32) -> [u32; 4], + ) -> Result<(), Error> { + for leaf in cpuid.leaves() { + if self.exits.binary_search(&leaf.function).is_ok() { + continue; + } + if !NATIVE_LEAVES.contains(&leaf.function) { + return Err(Error::NativeCpuidUnrouted(leaf.function)); + } + let index = leaf.index.unwrap_or(0); + let native = native(leaf.function, index); + let expected = cpuid.result(leaf.function, index, &native); + let per_vp = per_vp_cpuid_bits(leaf.function); + if (0..4).any(|i| (native[i] ^ expected[i]) & !per_vp[i] != 0) { + return Err(Error::NativeCpuidMismatch { + function: leaf.function, + native, + expected, + }); + } + } + Ok(()) + } + + /// Checks that WHP reports `apic_id` as the initial APIC ID in VP + /// `vp_index`'s leaf 1, which WHP answers without an exit. + pub(crate) fn verify_apic_id( + &self, + vp: whp::Processor<'_>, + vp_index: u32, + apic_id: u32, + ) -> Result<(), Error> { + let output = vp + .get_cpuid_output(CpuidFunction::VersionAndFeatures.0, 0) + .for_op("get cpuid output")?; + let native = VersionAndFeaturesEbx::from(output.Ebx).initial_apic_id(); + if u32::from(native) != apic_id & 0xff { + return Err(Error::NativeCpuidApicId { + vp_index, + native: native.into(), + apic_id, + }); + } + Ok(()) + } +} + +/// Returns the leaf 1 result that reports `apic_mode`'s x2APIC support. +pub(crate) fn x2apic_leaf(apic_mode: ApicMode) -> CpuidLeaf { + let mask = VersionAndFeaturesEcx::new().with_x2_apic(true).into(); + let value = match apic_mode { + ApicMode::XApic => 0, + ApicMode::X2ApicSupported | ApicMode::X2ApicEnabled => mask, + }; + CpuidLeaf::new(CpuidFunction::VersionAndFeatures.0, [0, 0, value, 0]).masked([0, 0, mask, 0]) +} + +/// Returns the CPUID exit list, sorted. +fn exit_leaves() -> Vec { + let mut exits: Vec = EXIT_LEAVES + .into_iter() + .chain(EXIT_HYPERVISOR_LEAVES.into_iter().flatten()) + .collect(); + exits.sort_unstable(); + exits.dedup(); + exits +} + +/// Returns the `CpuidResultList2` results for leaves 0 and 1: the bits of +/// the partition's CPUID table that are partition-wide and known before +/// setup, where `host` reads the host's CPUID. Returns `None` if the host's +/// processor vendor is unknown. +/// +/// Leaf 1 reports x2APIC support, the hypervisor, and the topology's +/// processors per package; WHP keeps each VP's initial APIC ID. Leaf 0 +/// raises the maximum basic leaf to the TSC frequency leaf when the host's +/// is lower. WHP's maximum leaves are the host's, and [`NativeCpuidLeaves::verify`] +/// checks both results against the table after setup. +fn native_results( + topology: &ProcessorTopology, + host: &dyn Fn(u32, u32) -> [u32; 4], +) -> Option> { + let hypervisor = VersionAndFeaturesEcx::new() + .with_hypervisor_present(true) + .into(); + let mut leaves = vec![ + x2apic_leaf(topology.apic_mode()), + CpuidLeaf::new(CpuidFunction::VersionAndFeatures.0, [0, 0, hypervisor, 0]) + .masked([0, 0, hypervisor, 0]), + ]; + virt::x86::topology::topology_cpuid(topology, host, &mut leaves).ok()?; + let cpuid = CpuidLeafSet::new( + leaves + .into_iter() + .filter(|leaf| leaf.function == CpuidFunction::VersionAndFeatures.0) + .collect(), + ); + + let mut results = Vec::new(); + let tsc_leaf = CpuidFunction::CoreCrystalClockInformation.0; + if host(CpuidFunction::VendorAndMaxFunction.0, 0)[0] < tsc_leaf { + results.push(result( + CpuidFunction::VendorAndMaxFunction.0, + [tsc_leaf, 0, 0, 0], + [!0, 0, 0, 0], + )); + } + for leaf in cpuid.leaves() { + let per_vp = per_vp_cpuid_bits(leaf.function); + let mask: [u32; 4] = std::array::from_fn(|i| leaf.mask[i] & !per_vp[i]); + results.push(result(leaf.function, leaf.result, mask)); + } + Some(results) +} + +/// Returns a partition-wide `CpuidResultList2` result for `function` that +/// sets the bits of `value` in `mask`. +fn result(function: u32, value: [u32; 4], mask: [u32; 4]) -> WHV_X64_CPUID_RESULT2 { + let output = |registers: [u32; 4]| WHV_CPUID_OUTPUT { + Eax: registers[0], + Ebx: registers[1], + Ecx: registers[2], + Edx: registers[3], + }; + WHV_X64_CPUID_RESULT2 { + Function: function, + Index: 0, + VpIndex: 0, + Flags: WHV_X64_CPUID_RESULT2_FLAGS(0), + Output: output(std::array::from_fn(|i| value[i] & mask[i])), + Mask: output(mask), + } +} + +/// Returns the host's CPUID result. +fn host_cpuid(leaf: u32, subleaf: u32) -> [u32; 4] { + // The CPUID instruction exists only on x86-64 hosts, the only hosts of + // x86-64 WHP partitions. + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(target_arch = "x86_64")] + { + let result = core::arch::x86_64::__cpuid_count(leaf, subleaf); + [result.eax, result.ebx, result.ecx, result.edx] + } + // xtask-fmt allow-target-arch cpu-intrinsic + #[cfg(not(target_arch = "x86_64"))] + { + let _ = (leaf, subleaf); + [0; 4] + } +} + +#[cfg(test)] +mod tests { + use super::EXIT_HYPERVISOR_LEAVES; + use super::NATIVE_LEAVES; + use super::NativeCpuidLeaves; + use super::exit_leaves; + use super::native_results; + use super::x2apic_leaf; + use crate::Error; + use virt::CpuidLeaf; + use virt::CpuidLeafSet; + use vm_topology::processor::ProcessorTopology; + use vm_topology::processor::TopologyBuilder; + use vm_topology::processor::x86::X2ApicState; + use whp::abi::WHV_X64_CPUID_RESULT2; + use x86defs::cpuid::VersionAndFeaturesEbx; + use x86defs::cpuid::VersionAndFeaturesEcx; + + const INTEL: [u32; 3] = [0x756e_6547, 0x6c65_746e, 0x4965_6e69]; + const AMD: [u32; 3] = [0x6874_7541, 0x444d_4163, 0x6974_6e65]; + + /// A Skylake-SP host: maximum basic leaf 0x16, GenuineIntel. + fn skylake(leaf: u32, _subleaf: u32) -> [u32; 4] { + match leaf { + 0 => [0x16, INTEL[0], INTEL[1], INTEL[2]], + _ => [0; 4], + } + } + + /// A host whose maximum basic leaf precedes the TSC frequency leaf. + fn haswell(leaf: u32, _subleaf: u32) -> [u32; 4] { + match leaf { + 0 => [0xd, INTEL[0], INTEL[1], INTEL[2]], + _ => [0; 4], + } + } + + /// An AMD host as an AMD EPYC 7763 reports it through WHP: maximum basic + /// leaf 0xd, AuthenticAMD, and four caches in leaf 0x8000001D. + fn milan(leaf: u32, subleaf: u32) -> [u32; 4] { + match (leaf, subleaf) { + (0, _) => [0xd, AMD[0], AMD[1], AMD[2]], + (0x8000_001d, 0) => [0x121, 0x01c0_003f, 0x3f, 0], + (0x8000_001d, 1) => [0x122, 0x01c0_003f, 0x3f, 0], + (0x8000_001d, 2) => [0x143, 0x01c0_003f, 0x3ff, 2], + (0x8000_001d, 3) => [0x163, 0x03c0_003f, 0x7fff, 1], + _ => [0; 4], + } + } + + fn topology(vps: u32, x2apic: X2ApicState) -> ProcessorTopology { + TopologyBuilder::new_x86() + .vps_per_socket(vps) + .x2apic(x2apic) + .build(vps) + .unwrap() + } + + fn output(result: &WHV_X64_CPUID_RESULT2) -> ([u32; 4], [u32; 4]) { + let registers = |o: whp::abi::WHV_CPUID_OUTPUT| [o.Eax, o.Ebx, o.Ecx, o.Edx]; + (registers(result.Output), registers(result.Mask)) + } + + /// The partition's CPUID table as `WhpPartitionInner` builds it on `host` + /// from WHP's leaf 0, plus its hypervisor leaves. + fn table( + topology: &ProcessorTopology, + host: &dyn Fn(u32, u32) -> [u32; 4], + native_max: u32, + ) -> CpuidLeafSet { + let mut leaves = vec![ + x2apic_leaf(topology.apic_mode()), + CpuidLeaf::new(1, [0, 0, 1 << 31, 0]).masked([0, 0, 1 << 31, 0]), + crate::cpu_contract::mask_gpa_pinning_enlightenment(), + CpuidLeaf::new(hvdef::VIRTUALIZATION_STACK_CPUID_VENDOR, [1, 2, 3, 4]), + CpuidLeaf::new(hvdef::VIRTUALIZATION_STACK_CPUID_PROPERTIES, [1, 2, 3, 4]), + ]; + virt::x86::topology::topology_cpuid(topology, host, &mut leaves).unwrap(); + leaves + .extend(virt::x86::tsc::tsc_frequency_cpuid_leaves(2_200_000_000, native_max).unwrap()); + CpuidLeafSet::new(leaves) + } + + /// WHP's result after the programmed results, for a host with + /// `native_max` and a VP with APIC ID `apic_id`. + fn native( + results: &[WHV_X64_CPUID_RESULT2], + native_max: u32, + apic_id: u8, + ) -> impl Fn(u32, u32) -> [u32; 4] { + let results = results.to_vec(); + move |leaf, _subleaf| { + let mut value = match leaf { + 0 => [native_max, INTEL[0], INTEL[1], INTEL[2]], + 1 => [ + 0x50654, + VersionAndFeaturesEbx::new() + .with_initial_apic_id(apic_id) + .with_clflush_line_size(8) + .into(), + VersionAndFeaturesEcx::new().with_sse3(true).into(), + 0x0f8b_fbff, + ], + _ => [0; 4], + }; + for result in results.iter().filter(|r| r.Function == leaf) { + let (output, mask) = output(result); + for i in 0..4 { + value[i] = value[i] & !mask[i] | output[i]; + } + } + value + } + } + + #[test] + fn exit_list_routes_the_table_and_per_vp_leaves() { + let exits = exit_leaves(); + assert!(exits.is_sorted()); + for leaf in NATIVE_LEAVES.into_iter().chain([0x4000_0010, 0x4000_00ff]) { + assert!(exits.binary_search(&leaf).is_err(), "leaf {leaf:#x}"); + } + for leaf in [0x4, 0xb, 0x15, 0x1f, 0x8000_0008, 0x8000_001d, 0x8000_001e] + .into_iter() + .chain(EXIT_HYPERVISOR_LEAVES.into_iter().flatten()) + { + assert!(exits.binary_search(&leaf).is_ok(), "leaf {leaf:#x}"); + } + assert_eq!(exits.len(), 39); + } + + #[test] + fn leaf_1_results_keep_the_per_vp_apic_id() { + let topology = topology(2, X2ApicState::Supported); + let results = native_results(&topology, &skylake).unwrap(); + let [leaf_1] = results.as_slice() else { + panic!("expected only leaf 1: {results:?}"); + }; + let (value, mask) = output(leaf_1); + assert_eq!(leaf_1.Function, 1); + let lps = VersionAndFeaturesEbx::new().with_lps_per_package(0xff); + assert_eq!(mask[1], u32::from(lps)); + assert_eq!( + VersionAndFeaturesEbx::from(value[1]).lps_per_package(), + topology.reserved_vps_per_socket() as u8 + ); + let ecx = VersionAndFeaturesEcx::from(value[2]); + assert!(ecx.x2_apic() && ecx.hypervisor_present()); + assert_eq!( + mask[2], + u32::from( + VersionAndFeaturesEcx::new() + .with_x2_apic(true) + .with_hypervisor_present(true) + ) + ); + assert_eq!([mask[0], mask[3]], [0, 0]); + } + + #[test] + fn xapic_partitions_clear_the_x2apic_bit() { + let results = native_results(&topology(2, X2ApicState::Unsupported), &skylake).unwrap(); + let (value, mask) = output(&results[0]); + assert!(VersionAndFeaturesEcx::from(mask[2]).x2_apic()); + assert!(!VersionAndFeaturesEcx::from(value[2]).x2_apic()); + } + + #[test] + fn leaf_0_reaches_the_tsc_leaf_only_when_the_host_stops_short() { + let topology = topology(2, X2ApicState::Supported); + assert!( + native_results(&topology, &skylake) + .unwrap() + .iter() + .all(|r| r.Function != 0) + ); + let results = native_results(&topology, &haswell).unwrap(); + let leaf_0 = results.iter().find(|r| r.Function == 0).unwrap(); + assert_eq!(output(leaf_0), ([0x15, 0, 0, 0], [!0, 0, 0, 0])); + } + + #[test] + fn verify_accepts_the_programmed_partition() { + for (host, native_max) in [ + (skylake as fn(u32, u32) -> [u32; 4], 0x16), + (haswell, 0xd), + (milan, 0xd), + ] { + let topology = topology(4, X2ApicState::Supported); + let results = native_results(&topology, &host).unwrap(); + let native_leaves = NativeCpuidLeaves { + exits: exit_leaves(), + }; + let reported_max = native(&results, native_max, 0)(0, 0)[0]; + let cpuid = table(&topology, &host, reported_max); + for apic_id in [0, 3] { + native_leaves + .verify(&cpuid, native(&results, native_max, apic_id)) + .unwrap(); + } + } + } + + #[test] + fn verify_rejects_a_table_leaf_that_does_not_exit() { + let topology = topology(2, X2ApicState::Supported); + let native_leaves = NativeCpuidLeaves { + exits: exit_leaves(), + }; + let results = native_results(&topology, &skylake).unwrap(); + for function in [7, 0x4000_0010] { + let cpuid = CpuidLeafSet::new(vec![ + CpuidLeaf::new(function, [0, 1, 0, 0]).masked([0, 1, 0, 0]), + ]); + assert!(matches!( + native_leaves.verify(&cpuid, native(&results, 0x16, 0)), + Err(Error::NativeCpuidUnrouted(f)) if f == function + )); + } + } + + #[test] + fn verify_rejects_a_leaf_1_bit_that_whp_does_not_report() { + let topology = topology(2, X2ApicState::Supported); + let native_leaves = NativeCpuidLeaves { + exits: exit_leaves(), + }; + let cpuid = table(&topology, &skylake, 0x16); + // WHP without the programmed results reports no processors per + // package. + assert!(matches!( + native_leaves.verify(&cpuid, native(&[], 0x16, 0)), + Err(Error::NativeCpuidMismatch { function: 1, .. }) + )); + } + + #[test] + fn verify_rejects_a_maximum_basic_leaf_below_the_tsc_leaf() { + // The host reports leaf 0x16, so leaf 0 is not programmed, but WHP + // reports a lower maximum basic leaf. + let topology = topology(2, X2ApicState::Supported); + let native_leaves = NativeCpuidLeaves { + exits: exit_leaves(), + }; + let results = native_results(&topology, &skylake).unwrap(); + let cpuid = table(&topology, &skylake, 0xd); + assert!(matches!( + native_leaves.verify(&cpuid, native(&results, 0xd, 0)), + Err(Error::NativeCpuidMismatch { function: 0, .. }) + )); + } + + #[test] + fn unknown_vendors_keep_every_leaf_exiting() { + let unknown = |leaf: u32, _subleaf: u32| match leaf { + 0 => [0x16, 1, 2, 3], + _ => [0; 4], + }; + assert!(native_results(&topology(2, X2ApicState::Supported), &unknown).is_none()); + } + + /// Programs a two-VP partition and checks what + /// `WHvGetVirtualProcessorCpuidOutput` returns for leaves 0 and 1. + #[test] + #[ignore = "requires WHP"] + fn whp_answers_the_programmed_leaves() { + let topology = topology(2, X2ApicState::Supported); + let mut config = whp::PartitionConfig::new().unwrap(); + config + .set_property(whp::PartitionProperty::ProcessorCount(2)) + .unwrap(); + config + .set_property(whp::PartitionProperty::LocalApicEmulationMode( + whp::abi::WHvX64LocalApicEmulationModeX2Apic, + )) + .unwrap(); + config + .set_property(whp::PartitionProperty::ExtendedVmExits( + whp::abi::WHV_EXTENDED_VM_EXITS::X64CpuidExit, + )) + .unwrap(); + let native_leaves = NativeCpuidLeaves::configure(&topology, &mut config) + .expect("WHP accepts the CPUID results and the exit list"); + let partition = config.create().unwrap(); + for vp in 0..2 { + partition.create_vp(vp).create().unwrap(); + } + for vp in 0..2 { + let output = partition.vp(vp).get_cpuid_output(1, 0).unwrap(); + let ebx = VersionAndFeaturesEbx::from(output.Ebx); + let ecx = VersionAndFeaturesEcx::from(output.Ecx); + assert_eq!(ebx.initial_apic_id(), vp as u8); + assert_eq!( + ebx.lps_per_package(), + topology.reserved_vps_per_socket() as u8 + ); + assert!(ecx.x2_apic() && ecx.hypervisor_present()); + native_leaves + .verify_apic_id(partition.vp(vp), vp, vp) + .unwrap(); + } + let max = partition.vp(0).get_cpuid_output(0, 0).unwrap().Eax; + assert!(max >= 0x15, "maximum basic leaf {max:#x}"); + } +} From 92205a8cea9a4a458fe07ee3aa3bbbfdaf45097c Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 29 Sep 2026 11:50:02 -0700 Subject: [PATCH 294/304] Add generation-bound microVM snapshots Keep SHA-256 and private-copy behavior as the default, while allowing trusted storage owners to opt into generation identities and explicit scratch materialization policies. Generation restores validate structure, geometry, and storage generation without rescanning block contents; clone and resume policies remain explicit. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/dev_guide/snapshot_format.md | 33 ++- Guide/src/reference/openvmm/management/cli.md | 22 +- Guide/src/user_guide/openvmm/snapshots.md | 21 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 214 +++++++++++++++++- openvmm/openvmm_entry/src/microvm/launch.rs | 29 ++- openvmm/openvmm_entry/src/microvm/restore.rs | 71 ++++-- .../src/storage_builder/microvm.rs | 102 ++++++++- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 3 + .../src/vm_controller/microvm.rs | 12 +- openvmm/openvmm_helpers/src/snapshot/fs.rs | 45 ++++ .../openvmm_helpers/src/snapshot/microvm.rs | 195 +++++++++++++++- .../openvmm_helpers/src/snapshot/publish.rs | 43 +++- .../openvmm_helpers/src/snapshot/restore.rs | 53 ++++- 13 files changed, 773 insertions(+), 70 deletions(-) diff --git a/Guide/src/dev_guide/snapshot_format.md b/Guide/src/dev_guide/snapshot_format.md index 3d658125d73..ea6ae4a84b6 100644 --- a/Guide/src/dev_guide/snapshot_format.md +++ b/Guide/src/dev_guide/snapshot_format.md @@ -37,10 +37,13 @@ The default format is a local machine-state contract, not an authenticated container. Every snapshot receives the same regular-file, no-follow/no-reparse, bounded decoding, exact-length, inventory, and machine-contract validation, but the on-disk format does not authenticate same-length payload changes. -A paired capture records the SHA-256 and exact length of `scratch.img`, because guest -RAM and a mounted writable filesystem must be restored as one exact pair. -Export or transport layers must provide broader integrity and authentication -outside this format. +By default, paired capture records SHA-256 and exact length for `scratch.img`, +because guest RAM and a mounted writable filesystem must be restored as one +exact pair. Version 6 also permits an explicit `generation` identity for all +bound sandbox blocks. Generation identity is a caller-authenticated immutable +storage version, not a byte digest; OpenVMM validates role, size, geometry, and +the shared generation without scanning block contents. Export or transport +layers must provide broader integrity and authentication outside this format. The optional microVM network contract stores a versioned SHA-256 digest of its bound egress policy. Encoding version 2 includes the static prefix, guest MAC, @@ -90,12 +93,22 @@ stronger mode such as a lease, fs-verity, or a verified artifact broker. ## Scratch (`scratch.img`) -The microVM block contract records every fixed role, access mode, geometry, and -immutable read-only layer digest. A paired capture copies the exact opened -writable scratch handle into the same staging directory after device queues -drain, verifies its SHA-256, and publishes it atomically with VM state. Restore -verifies the artifact before worker construction and makes a private copy for -each process, so repeated restores cannot mutate the snapshot. +The microVM block contract records every fixed role, access mode, geometry, +identity kind, identity, and paired-scratch restore mode. A paired capture +copies the exact opened writable scratch handle into the same staging +directory after device queues drain and publishes it atomically with VM state. +`sha256` identity verifies bytes before publication and restore. `generation` +identity instead requires every bound block to carry the same nonzero +caller-authenticated storage generation and performs structural and exact-size +validation without whole-file rehashing. + +Paired scratch defaults to `private-copy`, which preserves existing behavior +and may use a reflink or sparse copy. `copy-on-write` requires a filesystem +reflink and is restricted to reusable clone snapshots. `direct-claimed` +attaches the paired file only for a single-use resume snapshot after its +durable claim; the claimed artifact is then continuation storage rather than +an available snapshot. A missing restore-mode field retains `private-copy` +behavior. A pre-mount capture instead records the `fresh` scratch policy and geometry, contains no `scratch.img`, and requires a new matching scratch file on restore. diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 6d098c7e08a..1e2fcec50a7 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -431,14 +431,26 @@ describes the source definitions. * `--snapshot-tier `: Required for snapshot capture with sandbox blocks. Choose `platform`, `workload-start`, or `instance-checkpoint`. The first two are reusable clone policies; instance checkpoints use single-use resume policy. +* `--snapshot-block-identity `: Select `sha256` (the default) or + `generation`. SHA-256 computes and verifies every bound block. Generation + mode requires `--snapshot-generation-id <32-HEX-DIGITS>` and records that + caller-authenticated immutable storage generation instead of scanning block + bytes. The caller must keep the generation bound to the exact immutable + artifacts supplied at restore. +* `--snapshot-scratch-restore-mode `: Record `private-copy`, + `copy-on-write`, or `direct-claimed` for paired scratch. Private copy is the + default and may fall back to sparse copying. Copy-on-write is valid only for + workload-start clones and fails if the destination filesystem cannot + reflink. Direct-claimed is valid only for single-use instance checkpoints. A committed snapshot contains `manifest.bin`, `state.bin`, `memory.bin`, and optionally the manifest-declared `scratch.img`. Restore rejects unknown files, -symlinks, malformed or oversized data, length or scratch-digest mismatches, and -incompatible machine contracts before starting a vCPU. `memory.bin` uses a -private writable copy-on-write mapping and paired scratch is privately copied, -so clone-policy snapshots can be restored repeatedly without modifying -artifacts. Instance-checkpoint snapshots permit one restore attempt. +symlinks, malformed or oversized data, length or applicable scratch-digest +mismatches, and incompatible machine contracts before starting a vCPU. +`memory.bin` uses a private writable copy-on-write mapping. Paired scratch uses +the materialization policy recorded at capture; clone-policy snapshots never +attach the source artifact writable, while direct attachment requires an +atomic single-use resume claim. Versions 3 through 6 do not embed or validate checksums for `state.bin` or `memory.bin`; legacy version 2 checksum fields are accepted without re-hashing their diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index dcd2de1f6c2..c8444fcb8c1 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -42,6 +42,15 @@ with sandbox blocks additionally requires workload-start snapshots are reusable clones; instance checkpoints are single-use resumes. +Sandbox-block capture defaults to whole-file SHA-256 identity and private +scratch copying. Storage owners may opt into +`--snapshot-block-identity generation --snapshot-generation-id <32-HEX-DIGITS>` +when an authenticated immutable generation already binds the supplied files. +This avoids capture- and restore-time block scans; it does not authenticate +bytes by itself. `--snapshot-scratch-restore-mode` records `private-copy`, +reflink-only `copy-on-write` for workload-start clones, or `direct-claimed` +for instance checkpoints. + ```admonish warning Automatically allocated microVM RAM and the snapshot destination are on the same filesystem so OpenVMM can promote the exact RAM file by hard link. A @@ -231,11 +240,13 @@ from the saved topology. Snapshots do not contain or validate embedded checksums for `state.bin` or `memory.bin`. Restore still requires regular files, bounded manifest and state decoding, exact artifact lengths, and a compatible machine -contract, but same-length payload changes are not detected. Paired -`scratch.img` does have an exact length and SHA-256 identity because it must -match captured guest filesystem state. Protect snapshot directories with host -access controls. Integrity or authentication for export and transport must be -supplied outside the default snapshot format. +contract, but same-length payload changes are not detected. Paired `scratch.img` always +has an exact length and uses either the default SHA-256 identity or an explicit +caller-authenticated storage generation. Generation mode avoids reading the +file solely for verification and therefore relies on the storage owner to keep +that generation immutable and correctly bound. Protect snapshot directories +with host access controls. Integrity or authentication for export and +transport must be supplied outside the default snapshot format. ``` ```admonish note diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 9afdfdc54cb..d6759a1a81b 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -154,6 +154,69 @@ pub enum SnapshotTierCli { InstanceCheckpoint, } +/// Identity policy for sandbox blocks captured in a microVM snapshot. +#[derive(Debug, Copy, Clone, Default, ValueEnum, PartialEq, Eq)] +pub enum SnapshotBlockIdentityCli { + /// Compute and verify whole-file SHA-256 identities. + #[default] + Sha256, + /// Trust the caller-supplied immutable storage generation. + Generation, +} + +/// Restore-time materialization policy for paired scratch. +#[derive(Debug, Copy, Clone, Default, ValueEnum, PartialEq, Eq)] +pub enum SnapshotScratchRestoreModeCli { + /// Create an independent private file, falling back to sparse copying. + #[default] + PrivateCopy, + /// Require a filesystem copy-on-write clone. + CopyOnWrite, + /// Attach the paired scratch file after the resume snapshot is claimed. + DirectClaimed, +} + +impl SnapshotScratchRestoreModeCli { + pub(crate) fn manifest_name(self) -> &'static str { + match self { + Self::PrivateCopy => { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY + } + Self::CopyOnWrite => { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE + } + Self::DirectClaimed => { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED + } + } + } +} + +/// Caller-supplied immutable storage generation. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct SnapshotGenerationIdCli(pub(crate) [u8; 16]); + +impl FromStr for SnapshotGenerationIdCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + anyhow::ensure!( + value.len() == 32 && value.bytes().all(|byte| byte.is_ascii_hexdigit()), + "snapshot generation ID must be exactly 32 hexadecimal characters" + ); + let mut generation = [0_u8; 16]; + for (index, byte) in generation.iter_mut().enumerate() { + *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16) + .context("snapshot generation ID contains invalid hexadecimal")?; + } + anyhow::ensure!( + generation.iter().any(|byte| *byte != 0), + "snapshot generation ID must be nonzero" + ); + Ok(Self(generation)) + } +} + impl SnapshotTierCli { pub(crate) fn manifest_name(self) -> &'static str { match self { @@ -245,6 +308,31 @@ pub struct MicrovmCli { )] pub snapshot_tier: Option, + /// Sandbox-block identity policy for this capture. + /// + /// `sha256` is the default. `generation` avoids whole-file hashing and + /// requires an immutable generation supplied by the storage owner. + #[clap( + long, + value_enum, + value_name = "MODE", + requires = "snapshot_destination" + )] + pub snapshot_block_identity: Option, + + /// Immutable storage generation used by `--snapshot-block-identity generation`. + #[clap(long, value_name = "HEX", requires = "snapshot_destination")] + pub snapshot_generation_id: Option, + + /// Restore-time materialization policy recorded for paired scratch. + #[clap( + long, + value_enum, + value_name = "MODE", + requires = "snapshot_destination" + )] + pub snapshot_scratch_restore_mode: Option, + /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. #[clap(long, value_name = "MILLISECONDS", default_value_t = 5000)] pub snapshot_quiesce_timeout_ms: u64, @@ -580,9 +668,12 @@ impl Options { && self.microvm.restore_processors.is_none() && self.microvm.restore_memory.is_none() && self.microvm.memory_capacity.is_none() + && self.microvm.snapshot_block_identity.is_none() + && self.microvm.snapshot_generation_id.is_none() + && self.microvm.snapshot_scratch_restore_mode.is_none() && self.microvm.microvm_control_console.is_none() && !self.microvm.microvm_control_auth_stdin, - "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-workload-identity, --microvm-lifecycle, --microvm-control-console, --microvm-control-auth-stdin, --restore-processors, --restore-memory, --memory-capacity, and microVM network policy require a microVM machine" + "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-workload-identity, --microvm-lifecycle, --microvm-control-console, --microvm-control-auth-stdin, --restore-processors, --restore-memory, --memory-capacity, snapshot block policy, and microVM network policy require a microVM machine" ); return Ok(()); } @@ -625,6 +716,48 @@ impl Options { != self.microvm.microvm_sandbox_block.is_empty(), "--snapshot-tier is required exactly for microVM snapshot capture with sandbox blocks" ); + let identity = self + .microvm + .snapshot_block_identity + .unwrap_or(SnapshotBlockIdentityCli::Sha256); + anyhow::ensure!( + !self.microvm.microvm_sandbox_block.is_empty() + || self.microvm.snapshot_block_identity.is_none(), + "--snapshot-block-identity requires microVM sandbox blocks" + ); + anyhow::ensure!( + matches!( + (identity, self.microvm.snapshot_generation_id), + (SnapshotBlockIdentityCli::Sha256, None) + | ( + SnapshotBlockIdentityCli::Generation, + Some(SnapshotGenerationIdCli(_)) + ) + ), + "--snapshot-generation-id is required exactly for --snapshot-block-identity generation" + ); + if let Some(mode) = self.microvm.snapshot_scratch_restore_mode { + let tier = self + .microvm + .snapshot_tier + .context("--snapshot-scratch-restore-mode requires --snapshot-tier")?; + anyhow::ensure!( + tier.requires_paired_scratch(), + "--snapshot-scratch-restore-mode requires a paired-scratch snapshot tier" + ); + anyhow::ensure!( + match mode { + SnapshotScratchRestoreModeCli::PrivateCopy => true, + SnapshotScratchRestoreModeCli::CopyOnWrite => { + tier == SnapshotTierCli::WorkloadStart + } + SnapshotScratchRestoreModeCli::DirectClaimed => { + tier == SnapshotTierCli::InstanceCheckpoint + } + }, + "copy-on-write scratch requires workload-start clone semantics, and direct-claimed scratch requires instance-checkpoint resume semantics" + ); + } if let Some(memory_capacity) = self.microvm.memory_capacity { anyhow::ensure!( memory_capacity.0 >= self.memory_size(), @@ -1381,6 +1514,85 @@ mod tests { assert!(zero_timeout.validate_microvm_options().is_err()); } + #[test] + fn test_microvm_snapshot_identity_and_scratch_restore_modes_are_explicit() { + let generation = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "workload-start", + "--snapshot-block-identity", + "generation", + "--snapshot-generation-id", + "00112233445566778899aabbccddeeff", + "--snapshot-scratch-restore-mode", + "copy-on-write", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + generation.validate_microvm_options().unwrap(); + + let missing_generation = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "workload-start", + "--snapshot-block-identity", + "generation", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + assert!(missing_generation.validate_microvm_options().is_err()); + + let direct_clone = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "workload-start", + "--snapshot-scratch-restore-mode", + "direct-claimed", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + assert!(direct_clone.validate_microvm_options().is_err()); + + let resume = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "instance-checkpoint", + "--snapshot-scratch-restore-mode", + "direct-claimed", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ]) + .unwrap(); + resume.validate_microvm_options().unwrap(); + } + #[test] fn test_microvm_command_line_is_owned_and_bounded() { assert_eq!( diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index 1e26f528a23..a408a386f24 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -10,6 +10,8 @@ use super::restore::ExpectedRestoreContract; use super::validate_microvm_filesystem_private_storage; use crate::Options; use crate::cli_args::microvm::MachineProfileCli; +use crate::cli_args::microvm::SnapshotBlockIdentityCli; +use crate::cli_args::microvm::SnapshotScratchRestoreModeCli; use crate::vm_controller::MicrovmController; use anyhow::Context; use chipset_resources::microvm::MicrovmSnapshotBoundaryRequest; @@ -35,6 +37,9 @@ pub(crate) struct MicrovmLaunch { filesystem_slot: bool, filesystems: Vec, snapshot_destination: Option, + snapshot_block_identity: SnapshotBlockIdentityCli, + snapshot_generation_id: Option<[u8; 16]>, + snapshot_scratch_restore_mode: SnapshotScratchRestoreModeCli, snapshot_memory_file: Option, snapshot_memory_handle: Option, restore: MicrovmRestore, @@ -139,6 +144,18 @@ impl MicrovmLaunch { filesystem_slot, filesystems: vm_config.microvm.filesystems.clone(), snapshot_destination, + snapshot_block_identity: opt + .microvm + .snapshot_block_identity + .unwrap_or(SnapshotBlockIdentityCli::Sha256), + snapshot_generation_id: opt + .microvm + .snapshot_generation_id + .map(|generation| generation.0), + snapshot_scratch_restore_mode: opt + .microvm + .snapshot_scratch_restore_mode + .unwrap_or(SnapshotScratchRestoreModeCli::PrivateCopy), snapshot_memory_file, snapshot_memory_handle, restore, @@ -224,9 +241,16 @@ impl MicrovmLaunch { } else { MicrovmSnapshotScratchPolicy::Fresh }; - let sandbox_blocks = crate::storage_builder::microvm::snapshot_block_contract( + let saved_blocks = manifest + .machine_contract + .as_ref() + .context("microVM restore requires a machine contract")? + .microvm_sandbox_blocks + .as_slice(); + let sandbox_blocks = crate::storage_builder::microvm::restore_snapshot_block_contract( &self.resources.sandbox_block_sources, scratch_policy, + saved_blocks, )?; let resources = &self.resources; Ok(Some(( @@ -277,6 +301,9 @@ impl MicrovmLaunch { snapshot_requests, snapshot_destination: self.snapshot_destination, snapshot_tier: opt.microvm.snapshot_tier, + snapshot_block_identity: self.snapshot_block_identity, + snapshot_generation_id: self.snapshot_generation_id, + snapshot_scratch_restore_mode: self.snapshot_scratch_restore_mode, snapshot_quiesce_timeout: Duration::from_millis( opt.microvm.snapshot_quiesce_timeout_ms, ), diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index 4852fece6bc..e7724e596f3 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -278,24 +278,57 @@ pub(crate) fn prepare_restore( let source = snapshot .open_paired_scratch_file()? .context("paired snapshot is missing scratch.img")?; - let parent = snapshot_dir - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .unwrap_or(Path::new(".")); - let temp_dir = tempfile::Builder::new() - .prefix(".openvmm-private-scratch-") - .tempdir_in(parent) - .context("failed to create private restore scratch directory")?; - let private_path = temp_dir - .path() - .join(openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME); - openvmm_helpers::snapshot::restore::copy_verified_file( - &source, - &private_path, - scratch.length, - &scratch.identity, - openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME, - )?; + let restore_mode = if scratch.restore_mode.is_empty() { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY + } else { + scratch.restore_mode.as_str() + }; + let (private_path, temp_dir) = match restore_mode { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED => { + anyhow::ensure!( + manifest.restore_policy + == openvmm_helpers::snapshot::format::SNAPSHOT_RESTORE_POLICY_RESUME, + "direct-claimed scratch requires a resume snapshot" + ); + ( + snapshot_dir.join(openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME), + None, + ) + } + mode => { + let parent = snapshot_dir + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let temp_dir = tempfile::Builder::new() + .prefix(".openvmm-private-scratch-") + .tempdir_in(parent) + .context("failed to create private restore scratch directory")?; + let private_path = temp_dir + .path() + .join(openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME); + match mode { + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY => { + openvmm_helpers::snapshot::restore::copy_paired_scratch_file( + &source, + &private_path, + scratch, + )?; + } + openvmm_helpers::snapshot::microvm::SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE => { + openvmm_helpers::snapshot::restore::clone_paired_scratch_file( + &source, + &private_path, + scratch, + )?; + } + _ => anyhow::bail!( + "snapshot scratch restore mode '{mode}' is unsupported" + ), + } + (private_path, Some(temp_dir)) + } + }; opt.microvm .microvm_sandbox_block .push(cli_args::microvm::MicrovmSandboxBlockCli { @@ -317,7 +350,7 @@ pub(crate) fn prepare_restore( relay: None, }, }); - private_scratch_dir = Some(temp_dir); + private_scratch_dir = temp_dir; } else { anyhow::ensure!( opt.microvm.microvm_sandbox_block.iter().any(|block| { diff --git a/openvmm/openvmm_entry/src/storage_builder/microvm.rs b/openvmm/openvmm_entry/src/storage_builder/microvm.rs index f3fb00e4dcf..5427d73d7c5 100644 --- a/openvmm/openvmm_entry/src/storage_builder/microvm.rs +++ b/openvmm/openvmm_entry/src/storage_builder/microvm.rs @@ -7,6 +7,7 @@ use super::StorageBuilder; use super::VirtioBlkDisk; use crate::VmResources; use crate::cli_args::DiskCliKind; +use crate::cli_args::microvm::SnapshotBlockIdentityCli; use crate::disk_open; use anyhow::Context; use mesh::CellUpdater; @@ -35,9 +36,12 @@ pub(crate) struct MicrovmSandboxBlockSource { pub(crate) file: std::fs::File, } -pub(crate) fn snapshot_block_contract( +pub(crate) fn capture_snapshot_block_contract( sources: &[MicrovmSandboxBlockSource], scratch_policy: chipset_resources::microvm::MicrovmSnapshotScratchPolicy, + identity_mode: SnapshotBlockIdentityCli, + generation_id: Option<[u8; 16]>, + scratch_restore_mode: &str, ) -> anyhow::Result> { sources .iter() @@ -56,30 +60,104 @@ pub(crate) fn snapshot_block_contract( { ("fresh", Vec::new(), String::new()) } else { - ( - "sha256", + let (identity_kind, identity) = match identity_mode { + SnapshotBlockIdentityCli::Sha256 => ( + openvmm_helpers::snapshot::microvm::SNAPSHOT_BLOCK_IDENTITY_SHA256, + openvmm_helpers::snapshot::fs::file_sha256( + &source.file, + source.length, + &format!("microVM {} block", source.role.as_str()), + )?, + ), + SnapshotBlockIdentityCli::Generation => ( + openvmm_helpers::snapshot::microvm::SNAPSHOT_BLOCK_IDENTITY_GENERATION, + generation_id + .context("generation identity requires a snapshot generation ID")? + .to_vec(), + ), + }; + let artifact = if source.role == MicrovmSandboxBlockRole::Scratch { + openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME.to_owned() + } else { + String::new() + }; + (identity_kind, identity, artifact) + }; + Ok( + openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock { + role: source.role.as_str().to_owned(), + read_only: source.read_only, + length: source.length, + identity_kind: identity_kind.to_owned(), + identity, + artifact, + logical_block_size: source.logical_block_size, + physical_block_size: source.physical_block_size, + restore_mode: if source.role == MicrovmSandboxBlockRole::Scratch + && scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + { + scratch_restore_mode.to_owned() + } else { + String::new() + }, + }, + ) + }) + .collect() +} + +pub(crate) fn restore_snapshot_block_contract( + sources: &[MicrovmSandboxBlockSource], + scratch_policy: chipset_resources::microvm::MicrovmSnapshotScratchPolicy, + saved_blocks: &[openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock], +) -> anyhow::Result> { + sources + .iter() + .map(|source| { + let saved = saved_blocks + .iter() + .find(|block| block.role == source.role.as_str()) + .with_context(|| { + format!( + "snapshot is missing the microVM {} block contract", + source.role.as_str() + ) + })?; + let identity = match saved.identity_kind.as_str() { + openvmm_helpers::snapshot::microvm::SNAPSHOT_BLOCK_IDENTITY_SHA256 => { openvmm_helpers::snapshot::fs::file_sha256( &source.file, source.length, &format!("microVM {} block", source.role.as_str()), - )?, - if source.role == MicrovmSandboxBlockRole::Scratch { - openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME.to_owned() - } else { - String::new() - }, - ) + )? + } + openvmm_helpers::snapshot::microvm::SNAPSHOT_BLOCK_IDENTITY_GENERATION + | "unbound" + | "fresh" => saved.identity.clone(), + other => anyhow::bail!( + "snapshot microVM {} block identity kind '{other}' is unsupported", + source.role.as_str() + ), }; Ok( openvmm_helpers::snapshot::microvm::SnapshotMicrovmSandboxBlock { role: source.role.as_str().to_owned(), read_only: source.read_only, length: source.length, - identity_kind: identity_kind.to_owned(), + identity_kind: saved.identity_kind.clone(), identity, - artifact, + artifact: if source.role == MicrovmSandboxBlockRole::Scratch + && scratch_policy + == chipset_resources::microvm::MicrovmSnapshotScratchPolicy::Paired + { + openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME.to_owned() + } else { + String::new() + }, logical_block_size: source.logical_block_size, physical_block_size: source.physical_block_size, + restore_mode: saved.restore_mode.clone(), }, ) }) diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index 28769f42df2..911d9576390 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -1324,6 +1324,9 @@ impl CreateVm { snapshot_requests: self.snapshot_requests, snapshot_destination: self.snapshot_destination, snapshot_tier: None, + snapshot_block_identity: Default::default(), + snapshot_generation_id: None, + snapshot_scratch_restore_mode: Default::default(), snapshot_quiesce_timeout: self.snapshot_quiesce_timeout, source_hypervisor: self.source_hypervisor, effective_command_line: self.effective_command_line, diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index bca9a07af98..c55a2c1a98a 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -29,6 +29,13 @@ pub(crate) struct MicrovmController { pub(crate) snapshot_destination: Option, /// Sandbox capture tier of a guest-requested snapshot. pub(crate) snapshot_tier: Option, + /// Sandbox-block identity policy recorded in a captured snapshot. + pub(crate) snapshot_block_identity: crate::cli_args::microvm::SnapshotBlockIdentityCli, + /// Caller-authenticated immutable storage generation. + pub(crate) snapshot_generation_id: Option<[u8; 16]>, + /// Restore-time materialization policy recorded for paired scratch. + pub(crate) snapshot_scratch_restore_mode: + crate::cli_args::microvm::SnapshotScratchRestoreModeCli, /// Maximum time allowed to quiesce the VM for a guest-requested snapshot. pub(crate) snapshot_quiesce_timeout: Duration, /// Hypervisor backend recorded in captured machine contracts. @@ -288,9 +295,12 @@ impl VmController { (filesystem, root_path.as_path(), attachment.clone()) }) .collect(); - let mut blocks = crate::storage_builder::microvm::snapshot_block_contract( + let mut blocks = crate::storage_builder::microvm::capture_snapshot_block_contract( &self.microvm.resources.sandbox_block_sources, scratch_policy, + self.microvm.snapshot_block_identity, + self.microvm.snapshot_generation_id, + self.microvm.snapshot_scratch_restore_mode.manifest_name(), )?; if self.microvm.snapshot_tier == Some(crate::cli_args::microvm::SnapshotTierCli::Platform) diff --git a/openvmm/openvmm_helpers/src/snapshot/fs.rs b/openvmm/openvmm_helpers/src/snapshot/fs.rs index c74acef44ad..024b8356239 100644 --- a/openvmm/openvmm_helpers/src/snapshot/fs.rs +++ b/openvmm/openvmm_helpers/src/snapshot/fs.rs @@ -397,6 +397,51 @@ pub(super) fn copy_exact( Ok(()) } +#[cfg(target_os = "linux")] +pub(super) fn reflink_exact( + source_file: &std::fs::File, + destination_path: &Path, + expected_length: u64, + source_description: &str, + destination_description: &str, +) -> anyhow::Result<()> { + let source_metadata = source_file + .metadata() + .with_context(|| format!("failed to inspect {source_description}"))?; + anyhow::ensure!( + source_metadata.file_type().is_file() && source_metadata.len() == expected_length, + "{source_description} does not match the expected regular-file generation" + ); + let destination = create_file(destination_path, destination_description)?; + pal::unix::fs::reflink(source_file, &destination).with_context(|| { + format!("{destination_description} requires filesystem reflink support") + })?; + + anyhow::ensure!( + destination + .metadata() + .with_context(|| format!("failed to inspect {destination_description}"))? + .len() + == expected_length, + "{destination_description} length does not match {source_description}", + ); + destination + .sync_all() + .with_context(|| format!("failed to flush {destination_description}"))?; + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +pub(super) fn reflink_exact( + _source_file: &std::fs::File, + _destination_path: &Path, + _expected_length: u64, + _source_description: &str, + destination_description: &str, +) -> anyhow::Result<()> { + anyhow::bail!("{destination_description} copy-on-write cloning is unsupported on this platform") +} + fn size_empty_file(file: &std::fs::File, length: u64, description: &str) -> anyhow::Result<()> { file.set_len(0) .with_context(|| format!("failed to reset {description}"))?; diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index f14af80714d..07d7ca8cc27 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -33,6 +33,20 @@ pub const MICROVM_MEMORY_BLOCK_SIZE_BYTES: u64 = openvmm_defs::microvm::MICROVM_MEMORY_BLOCK_SIZE_BYTES; /// Snapshot contract name for shared-status edge interrupts. pub const MICROVM_SHARED_STATUS_INTERRUPT_MODE: &str = "edge-shared-status"; +/// Clock policy applied when a snapshot is restored. +pub const ADVANCE_BY_HOST_DOWNTIME: &str = "advance_by_host_downtime"; +/// Whole-file SHA-256 sandbox-block identity. +pub const SNAPSHOT_BLOCK_IDENTITY_SHA256: &str = "sha256"; +/// Caller-authenticated immutable storage generation. +pub const SNAPSHOT_BLOCK_IDENTITY_GENERATION: &str = "generation"; +/// Independent private scratch materialization. +pub const SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY: &str = "private-copy"; +/// Filesystem copy-on-write scratch materialization. +pub const SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE: &str = "copy-on-write"; +/// Direct scratch attachment after a single-use resume claim. +pub const SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED: &str = "direct-claimed"; +/// Byte length of a storage generation identity. +pub const SNAPSHOT_GENERATION_ID_SIZE: usize = 16; const MAX_MEMORY_RANGES: usize = 128; const MAX_DEVICES: usize = 64; const MAX_DEVICE_RANGES: usize = 16; @@ -289,6 +303,9 @@ pub struct SnapshotMicrovmSandboxBlock { /// Guest-visible physical block size in bytes. #[mesh(8)] pub physical_block_size: u32, + /// Restore-time materialization policy for paired scratch. + #[mesh(9)] + pub restore_mode: String, } impl SnapshotMicrovmFilesystem { @@ -1678,33 +1695,111 @@ pub(super) fn validate_machine_contract_shape( ); } else { anyhow::ensure!( - block.identity_kind == "sha256", + matches!( + block.identity_kind.as_str(), + SNAPSHOT_BLOCK_IDENTITY_SHA256 | SNAPSHOT_BLOCK_IDENTITY_GENERATION + ), "snapshot paired scratch has an unsupported identity kind" ); - validate_sha256(&block.identity, "scratch block")?; + match block.identity_kind.as_str() { + SNAPSHOT_BLOCK_IDENTITY_SHA256 => { + validate_sha256(&block.identity, "scratch block")?; + } + SNAPSHOT_BLOCK_IDENTITY_GENERATION => { + anyhow::ensure!( + block.identity.len() == SNAPSHOT_GENERATION_ID_SIZE + && block.identity.iter().any(|byte| *byte != 0), + "snapshot scratch generation identity is invalid" + ); + } + _ => unreachable!(), + } } } else { anyhow::ensure!( block.artifact.is_empty() - && matches!(block.identity_kind.as_str(), "sha256" | "unbound"), + && matches!( + block.identity_kind.as_str(), + SNAPSHOT_BLOCK_IDENTITY_SHA256 + | SNAPSHOT_BLOCK_IDENTITY_GENERATION + | "unbound" + ), "snapshot read-only layer '{}' has an invalid identity policy", block.role ); - if block.identity_kind == "sha256" { - validate_sha256(&block.identity, &format!("{} block", block.role))?; - } else { - anyhow::ensure!( - block.identity.is_empty(), - "snapshot unbound layer '{}' carries an identity", - block.role - ); + match block.identity_kind.as_str() { + SNAPSHOT_BLOCK_IDENTITY_SHA256 => { + validate_sha256(&block.identity, &format!("{} block", block.role))?; + } + SNAPSHOT_BLOCK_IDENTITY_GENERATION => { + anyhow::ensure!( + block.identity.len() == SNAPSHOT_GENERATION_ID_SIZE + && block.identity.iter().any(|byte| *byte != 0), + "snapshot layer '{}' generation identity is invalid", + block.role + ); + } + "unbound" => { + anyhow::ensure!( + block.identity.is_empty(), + "snapshot unbound layer '{}' carries an identity", + block.role + ); + } + _ => unreachable!(), } } + if role == openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch + && !block.artifact.is_empty() + { + anyhow::ensure!( + matches!( + block.restore_mode.as_str(), + "" | SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY + | SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE + | SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED + ), + "snapshot scratch restore mode '{}' is unsupported", + block.restore_mode + ); + } else { + anyhow::ensure!( + block.restore_mode.is_empty(), + "snapshot block '{}' unexpectedly carries a restore mode", + block.role + ); + } } anyhow::ensure!( previous_role == Some(openvmm_defs::microvm::MicrovmSandboxBlockRole::Scratch), "microVM snapshot is missing its scratch role" ); + let bound_blocks = contract + .microvm_sandbox_blocks + .iter() + .filter(|block| { + matches!( + block.identity_kind.as_str(), + SNAPSHOT_BLOCK_IDENTITY_SHA256 | SNAPSHOT_BLOCK_IDENTITY_GENERATION + ) + }) + .collect::>(); + if let Some(first) = bound_blocks.first() { + anyhow::ensure!( + bound_blocks + .iter() + .all(|block| block.identity_kind == first.identity_kind), + "snapshot sandbox blocks use mixed identity policies" + ); + if first.identity_kind == SNAPSHOT_BLOCK_IDENTITY_GENERATION { + anyhow::ensure!( + bound_blocks + .iter() + .all(|block| block.identity == first.identity), + "snapshot sandbox blocks do not share one storage generation" + ); + } + } } anyhow::ensure!( @@ -1903,6 +1998,20 @@ pub(super) fn validate_snapshot_tier(manifest: &SnapshotManifest) -> anyhow::Res "snapshot layer identity binding does not match tier '{}'", manifest.snapshot_tier, ); + if let Some(scratch) = paired_scratch_block(manifest) { + match scratch.restore_mode.as_str() { + "" | SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY => {} + SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE => anyhow::ensure!( + manifest.restore_policy == SNAPSHOT_RESTORE_POLICY_CLONE, + "copy-on-write scratch materialization requires clone restore policy" + ), + SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED => anyhow::ensure!( + manifest.restore_policy == SNAPSHOT_RESTORE_POLICY_RESUME, + "direct-claimed scratch materialization requires resume restore policy" + ), + _ => unreachable!("machine-contract shape validation rejects unknown restore modes"), + } + } if manifest.snapshot_tier == SNAPSHOT_TIER_PLATFORM { let control_tty_count = contract .effective_command_line @@ -1993,6 +2102,7 @@ pub(super) fn paired_scratch_manifest(scratch: &[u8]) -> SnapshotManifest { artifact: String::new(), logical_block_size: 512, physical_block_size: 4096, + restore_mode: String::new(), }, SnapshotMicrovmSandboxBlock { role: "scratch".to_owned(), @@ -2003,6 +2113,7 @@ pub(super) fn paired_scratch_manifest(scratch: &[u8]) -> SnapshotManifest { artifact: SCRATCH_FILE_NAME.to_owned(), logical_block_size: 512, physical_block_size: 4096, + restore_mode: SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY.to_owned(), }, ]; contract.set_effective_command_line("console=hvc0 nvx_snapshot_tier=workload-start".to_owned()); @@ -2358,6 +2469,7 @@ mod tests { artifact: String::new(), logical_block_size: 512, physical_block_size: 4096, + restore_mode: String::new(), }, SnapshotMicrovmSandboxBlock { role: "scratch".to_owned(), @@ -2368,6 +2480,7 @@ mod tests { artifact: SCRATCH_FILE_NAME.to_owned(), logical_block_size: 512, physical_block_size: 4096, + restore_mode: SNAPSHOT_SCRATCH_RESTORE_PRIVATE_COPY.to_owned(), }, ], 1, @@ -3634,6 +3747,66 @@ mod tests { validate_manifest_contents(&manifest).unwrap(); } + #[test] + fn generation_identity_and_scratch_restore_policy_are_canonical() { + let mut manifest = paired_scratch_manifest(&[0x5a; 512]); + let generation = vec![0x42; SNAPSHOT_GENERATION_ID_SIZE]; + for block in &mut manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + { + block.identity_kind = SNAPSHOT_BLOCK_IDENTITY_GENERATION.to_owned(); + block.identity.clone_from(&generation); + } + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + .last_mut() + .unwrap() + .restore_mode = SNAPSHOT_SCRATCH_RESTORE_COPY_ON_WRITE.to_owned(); + validate_manifest_contents(&manifest).unwrap(); + + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + .last_mut() + .unwrap() + .restore_mode = SNAPSHOT_SCRATCH_RESTORE_DIRECT_CLAIMED.to_owned(); + assert!(validate_manifest_contents(&manifest).is_err()); + + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + validate_manifest_contents(&manifest).unwrap(); + + manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks[0] + .identity[0] ^= 1; + assert!( + validate_machine_contract_shape( + manifest.machine_contract.as_ref().unwrap(), + manifest.memory_size_bytes, + manifest.vp_count, + ) + .is_err() + ); + } + #[test] fn platform_snapshot_rejects_tenant_command_line() { let scratch = vec![0x5a; 512]; diff --git a/openvmm/openvmm_helpers/src/snapshot/publish.rs b/openvmm/openvmm_helpers/src/snapshot/publish.rs index 1f3385fa21e..029c4eabfa8 100644 --- a/openvmm/openvmm_helpers/src/snapshot/publish.rs +++ b/openvmm/openvmm_helpers/src/snapshot/publish.rs @@ -29,7 +29,6 @@ use super::fs::rename_no_replace; use super::fs::snapshot_parent; use super::fs::sync_directory; use super::fs::validate_directory; -use super::fs::verify_file_digest; use super::fs::verify_hard_link_identity; use super::fs::write_bytes; use super::microvm; @@ -276,10 +275,9 @@ fn stage_snapshot( "scratch backing file", "snapshot scratch", )?; - verify_file_digest( + super::restore::verify_sandbox_block_identity( &open_file_with_length(&scratch_path, scratch.length, SCRATCH_FILE_NAME)?, - scratch.length, - &scratch.identity, + scratch, "scratch.img", )?; scratch_publish.complete( @@ -613,6 +611,43 @@ mod tests { assert!(error.to_string().contains("incomplete")); } + #[test] + fn generation_identity_uses_structure_without_rehashing_scratch() { + let dir = tempfile::tempdir().unwrap(); + let snap_dir = dir.path().join("snap"); + let memory_path = dir.path().join("memory.bin"); + let scratch_path = dir.path().join("scratch.img"); + std::fs::write(&memory_path, vec![0_u8; 1024]).unwrap(); + std::fs::write(&scratch_path, vec![0x5a_u8; 1024]).unwrap(); + let memory_file = std::fs::File::open(memory_path).unwrap(); + let scratch_file = std::fs::File::open(scratch_path).unwrap(); + let mut manifest = paired_scratch_manifest(&[0x5a; 1024]); + for block in &mut manifest + .machine_contract + .as_mut() + .unwrap() + .microvm_sandbox_blocks + { + block.identity_kind = microvm::SNAPSHOT_BLOCK_IDENTITY_GENERATION.to_owned(); + block.identity = vec![0x42; microvm::SNAPSHOT_GENERATION_ID_SIZE]; + } + + write_snapshot_from_memory_and_scratch_files( + &snap_dir, + &manifest, + b"state", + &memory_file, + Some(&scratch_file), + ) + .unwrap(); + + let read_manifest = read_snapshot_manifest(&snap_dir).unwrap(); + std::fs::write(snap_dir.join(SCRATCH_FILE_NAME), vec![0xa5_u8; 1024]).unwrap(); + open_paired_scratch_file(&snap_dir, &read_manifest) + .unwrap() + .unwrap(); + } + #[test] fn paired_scratch_digest_mismatch_is_not_published() { let dir = tempfile::tempdir().unwrap(); diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs index 85365d93b96..ee8c380e30f 100644 --- a/openvmm/openvmm_helpers/src/snapshot/restore.rs +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -23,6 +23,7 @@ use super::fs::copy_exact; use super::fs::open_file_with_length; use super::fs::opened_file_generation; use super::fs::read_bounded_open_file; +use super::fs::reflink_exact; use super::fs::verify_file_digest; use super::microvm; use anyhow::Context; @@ -312,10 +313,24 @@ fn open_paired_scratch_file_in_directory( }; let file = directory.open_file_with_length(SCRATCH_FILE_NAME, scratch.length, SCRATCH_FILE_NAME)?; - verify_file_digest(&file, scratch.length, &scratch.identity, SCRATCH_FILE_NAME)?; + verify_sandbox_block_identity(&file, scratch, SCRATCH_FILE_NAME)?; Ok(Some(file)) } +pub(super) fn verify_sandbox_block_identity( + file: &std::fs::File, + block: µvm::SnapshotMicrovmSandboxBlock, + description: &str, +) -> anyhow::Result<()> { + match block.identity_kind.as_str() { + microvm::SNAPSHOT_BLOCK_IDENTITY_SHA256 => { + verify_file_digest(file, block.length, &block.identity, description) + } + microvm::SNAPSHOT_BLOCK_IDENTITY_GENERATION => Ok(()), + other => anyhow::bail!("{description} has unsupported identity kind '{other}'"), + } +} + /// Copies a verified immutable artifact to a new private path. pub fn copy_verified_file( source: &std::fs::File, @@ -341,6 +356,42 @@ pub fn copy_verified_file( ) } +/// Materializes paired scratch as an independent private file. +pub fn copy_paired_scratch_file( + source: &std::fs::File, + destination: &Path, + block: µvm::SnapshotMicrovmSandboxBlock, +) -> anyhow::Result<()> { + verify_sandbox_block_identity(source, block, SCRATCH_FILE_NAME)?; + copy_exact( + source, + destination, + block.length, + SCRATCH_FILE_NAME, + "private scratch copy", + )?; + let copy = open_file_with_length(destination, block.length, "private scratch copy")?; + verify_sandbox_block_identity(©, block, "private scratch copy") +} + +/// Materializes paired scratch with a required filesystem copy-on-write clone. +pub fn clone_paired_scratch_file( + source: &std::fs::File, + destination: &Path, + block: µvm::SnapshotMicrovmSandboxBlock, +) -> anyhow::Result<()> { + verify_sandbox_block_identity(source, block, SCRATCH_FILE_NAME)?; + reflink_exact( + source, + destination, + block.length, + SCRATCH_FILE_NAME, + "copy-on-write scratch clone", + )?; + let clone = open_file_with_length(destination, block.length, "copy-on-write scratch clone")?; + verify_sandbox_block_identity(&clone, block, "copy-on-write scratch clone") +} + /// Atomically consumes a single-use resume snapshot. /// /// Call this after artifact and configuration validation and before constructing From b2bfbf21a4129da482f7c6c86648e9edad65587d Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 29 Sep 2026 12:18:55 -0700 Subject: [PATCH 295/304] Bind claimed scratch by file identity Create the direct-resume scratch path from the already opened artifact handle so pathname replacement cannot substitute another generation before device construction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- openvmm/openvmm_entry/src/microvm/restore.rs | 21 ++++++++++++---- .../openvmm_helpers/src/snapshot/restore.rs | 24 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index e7724e596f3..c2bb263e8b4 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -290,10 +290,23 @@ pub(crate) fn prepare_restore( == openvmm_helpers::snapshot::format::SNAPSHOT_RESTORE_POLICY_RESUME, "direct-claimed scratch requires a resume snapshot" ); - ( - snapshot_dir.join(openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME), - None, - ) + let parent = snapshot_dir + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let temp_dir = tempfile::Builder::new() + .prefix(".openvmm-claimed-scratch-") + .tempdir_in(parent) + .context("failed to create claimed restore scratch directory")?; + let private_path = temp_dir + .path() + .join(openvmm_helpers::snapshot::format::SCRATCH_FILE_NAME); + openvmm_helpers::snapshot::restore::link_claimed_paired_scratch_file( + &source, + &private_path, + scratch, + )?; + (private_path, Some(temp_dir)) } mode => { let parent = snapshot_dir diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs index ee8c380e30f..10758066cef 100644 --- a/openvmm/openvmm_helpers/src/snapshot/restore.rs +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -20,11 +20,13 @@ use super::fs::OpenedFileGeneration; use super::fs::OpenedSnapshotDirectory; use super::fs::allocated_file_bytes; use super::fs::copy_exact; +use super::fs::create_hard_link_from_handle; use super::fs::open_file_with_length; use super::fs::opened_file_generation; use super::fs::read_bounded_open_file; use super::fs::reflink_exact; use super::fs::verify_file_digest; +use super::fs::verify_hard_link_identity; use super::microvm; use anyhow::Context; use std::collections::HashSet; @@ -392,6 +394,28 @@ pub fn clone_paired_scratch_file( verify_sandbox_block_identity(&clone, block, "copy-on-write scratch clone") } +/// Creates a private path to the exact claimed paired-scratch generation. +pub fn link_claimed_paired_scratch_file( + source: &std::fs::File, + destination: &Path, + block: µvm::SnapshotMicrovmSandboxBlock, +) -> anyhow::Result<()> { + verify_sandbox_block_identity(source, block, SCRATCH_FILE_NAME)?; + let parent = destination + .parent() + .context("claimed scratch destination has no parent")?; + let name = destination + .file_name() + .and_then(|name| name.to_str()) + .context("claimed scratch destination name is invalid")?; + let directory = OpenedSnapshotDirectory::open_for_publication(parent)?; + create_hard_link_from_handle(source, &directory.file, name) + .context("failed to link claimed scratch generation")?; + let linked = open_file_with_length(destination, block.length, "claimed scratch link")?; + verify_hard_link_identity(source, &linked, block.length)?; + verify_sandbox_block_identity(&linked, block, "claimed scratch link") +} + /// Atomically consumes a single-use resume snapshot. /// /// Call this after artifact and configuration validation and before constructing From 0eccda8952dcaf5ef211551d1c1400ed73c4e1be Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 8 Oct 2026 02:01:59 -0300 Subject: [PATCH 296/304] Validate snapshot scratch restore support Reject copy-on-write scratch capture where restore cannot materialize it, document direct-claimed hard-link requirements, and cover clone isolation plus exact claimed-file identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 8 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 14 ++++ .../openvmm_helpers/src/snapshot/restore.rs | 80 +++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 1e2fcec50a7..88d6d58813b 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -439,9 +439,11 @@ describes the source definitions. artifacts supplied at restore. * `--snapshot-scratch-restore-mode `: Record `private-copy`, `copy-on-write`, or `direct-claimed` for paired scratch. Private copy is the - default and may fall back to sparse copying. Copy-on-write is valid only for - workload-start clones and fails if the destination filesystem cannot - reflink. Direct-claimed is valid only for single-use instance checkpoints. + default and may fall back to sparse copying. Copy-on-write is available only + on Linux, is valid only for workload-start clones, and fails if the + destination filesystem cannot reflink. Direct-claimed is valid only for + single-use instance checkpoints and requires the snapshot filesystem to + support exact-file hard links; it does not fall back to copying. A committed snapshot contains `manifest.bin`, `state.bin`, `memory.bin`, and optionally the manifest-declared `scratch.img`. Restore rejects unknown files, diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index d6759a1a81b..2d4de42fb78 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -741,6 +741,11 @@ impl Options { .microvm .snapshot_tier .context("--snapshot-scratch-restore-mode requires --snapshot-tier")?; + #[cfg(not(target_os = "linux"))] + anyhow::ensure!( + mode != SnapshotScratchRestoreModeCli::CopyOnWrite, + "--snapshot-scratch-restore-mode copy-on-write is unsupported on this platform" + ); anyhow::ensure!( tier.requires_paired_scratch(), "--snapshot-scratch-restore-mode requires a paired-scratch snapshot tier" @@ -1536,7 +1541,16 @@ mod tests { "scratch:mem:1M", ]) .unwrap(); + #[cfg(target_os = "linux")] generation.validate_microvm_options().unwrap(); + #[cfg(not(target_os = "linux"))] + assert!( + generation + .validate_microvm_options() + .unwrap_err() + .to_string() + .contains("copy-on-write is unsupported on this platform") + ); let missing_generation = Options::try_parse_from([ "openvmm", diff --git a/openvmm/openvmm_helpers/src/snapshot/restore.rs b/openvmm/openvmm_helpers/src/snapshot/restore.rs index 10758066cef..3aba7c70446 100644 --- a/openvmm/openvmm_helpers/src/snapshot/restore.rs +++ b/openvmm/openvmm_helpers/src/snapshot/restore.rs @@ -505,6 +505,86 @@ mod tests { use super::super::tests::test_manifest; use super::*; use std::io::Read; + use std::io::Seek; + use std::io::SeekFrom; + use std::io::Write; + + #[cfg(target_os = "linux")] + #[test] + fn copy_on_write_scratch_clone_is_independent() { + let scratch = vec![0x5a; 512]; + let manifest = paired_scratch_manifest(&scratch); + let block = microvm::paired_scratch_block(&manifest).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let source_path = dir.path().join("source-scratch.img"); + let destination_path = dir.path().join("cloned-scratch.img"); + std::fs::write(&source_path, &scratch).unwrap(); + let source = std::fs::File::open(&source_path).unwrap(); + + if let Err(error) = clone_paired_scratch_file(&source, &destination_path, block) { + let error = format!("{error:#}"); + assert!( + error.contains("requires filesystem reflink support"), + "{error}" + ); + return; + } + + let mut clone = std::fs::OpenOptions::new() + .write(true) + .open(&destination_path) + .unwrap(); + clone.write_all(b"clone").unwrap(); + clone.sync_all().unwrap(); + + assert_eq!(&std::fs::read(&source_path).unwrap()[..5], b"ZZZZZ"); + assert_eq!(&std::fs::read(&destination_path).unwrap()[..5], b"clone"); + } + + #[cfg(any(target_os = "linux", windows))] + #[test] + fn direct_claimed_scratch_links_exact_claimed_generation() { + let scratch = vec![0x5a; 512]; + let mut manifest = paired_scratch_manifest(&scratch); + manifest.snapshot_tier = SNAPSHOT_TIER_INSTANCE_CHECKPOINT.to_owned(); + manifest.restore_policy = SNAPSHOT_RESTORE_POLICY_RESUME.to_owned(); + manifest + .machine_contract + .as_mut() + .unwrap() + .set_effective_command_line( + "console=hvc0 nvx_snapshot_tier=instance-checkpoint".to_owned(), + ); + let block = microvm::paired_scratch_block(&manifest).unwrap(); + let dir = tempfile::tempdir().unwrap(); + let snapshot_dir = dir.path().join("snapshot"); + let claimed_dir = dir.path().join("claimed"); + std::fs::create_dir(&snapshot_dir).unwrap(); + std::fs::create_dir(&claimed_dir).unwrap(); + let source_path = snapshot_dir.join(SCRATCH_FILE_NAME); + let destination_path = claimed_dir.join(SCRATCH_FILE_NAME); + std::fs::write(&source_path, &scratch).unwrap(); + let source = std::fs::File::open(&source_path).unwrap(); + + claim_snapshot_for_restore(&snapshot_dir, &manifest).unwrap(); + assert!( + claim_snapshot_for_restore(&snapshot_dir, &manifest) + .unwrap_err() + .to_string() + .contains("already been claimed") + ); + link_claimed_paired_scratch_file(&source, &destination_path, block).unwrap(); + + let mut claimed = std::fs::OpenOptions::new() + .write(true) + .open(&destination_path) + .unwrap(); + claimed.seek(SeekFrom::Start(0)).unwrap(); + claimed.write_all(b"claim").unwrap(); + claimed.sync_all().unwrap(); + + assert_eq!(&std::fs::read(&source_path).unwrap()[..5], b"claim"); + } #[test] fn resume_snapshot_claim_is_single_use() { From 96e2363b1126989e19fdf178203640f74cc60a56 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 8 Oct 2026 03:04:45 -0300 Subject: [PATCH 297/304] openvmm_core: fall back from --cpu-profile auto to a host profile A cold boot with --cpu-profile auto, the default, failed with E_PROFILE_HOST_UNKNOWN on every CPU that no pinned profile serves, such as Cascade Lake, Sapphire Rapids, Granite Rapids, Tiger Lake, Meteor Lake, and AMD's Ryzen CPUs, including the Granite Rapids runners of Copilot cloud agent sessions. --cpu-profile host booted such Intel and AMD hosts, but only after the user opted in following the failure (microsoft/nvx#434). select_on_host now falls back there. Where no pinned profile's generation covers an Intel or AMD host CPU (the new cpu_profile::pinned_profile_serves), auto derives the host profile as --cpu-profile host does: the VM worker fingerprints the backend, derives .host.v1 under the pinned profiles' policy, and checks its generation. A CPU of another vendor, such as Hygon's, still fails with E_PROFILE_HOST_UNKNOWN, and so does a CPU that pinned profiles of more than one generation serve, a catalog defect, whose error still names --cpu-profile host. auto does not fall back where the backend does not support the CPU's pinned profile (E_PROFILE_UNSUPPORTED): that reveals a profile or hypervisor problem on a served generation, and the error still names --cpu-profile host. Explicit profile IDs, --cpu-profile host, restores, which take the snapshot's profile, and --cpu-fingerprint, whose check never falls back, are unchanged. The fallback is not silent. Each cold boot that takes it logs one warning at the default log level, from the VM worker, so wherever auto is selected, the management endpoint included. It starts with the stable marker NVX-CPU-PROFILE-FALLBACK:, names the host CPU (vendor, display family/model/stepping, and brand string), the host profile and its digest, and the limits of a host profile, including what that cold boot's fingerprint and derivation took, and asks the user to attach the CPU's fingerprint (openvmm --hypervisor --cpu-fingerprint fingerprint.json) to NVX's CPU profile request form, through a link that prefills the form's title and CPU fields. An explicit --cpu-profile host warns with the same profile, digest, and limits, and with the same request where no pinned profile serves the CPU. Where the fallback cannot boot, its errors keep their codes and say that auto fell back: a failed fingerprint or derivation, such as a backend that lacks a CPU feature that the time ABI requires (E_PROFILE_UNSUPPORTED), through a typed context, and a CPU profile failure of the partition (E_PROFILE_UNSUPPORTED, E_PROFILE_TIME_BITS, E_CPU_SURFACE, or E_CPU_UNLISTED), through the cold boot's hint, which no longer suggests --cpu-profile host there and does not repeat the fallback's context. The host-cpu-unknown time ABI test hook makes auto treat the host CPU as one that no pinned profile serves, so that CI's runners, which pinned profiles serve, exercise the fallback. Like every hook, it sets TEST_HOOKS in the restore packet and the time samples. Unit tests cover the selection on Intel, AMD, and Hygon CPUs with and without the hook, the fallback and its warning, the explicit host profile's warning with and without the request, the request link's encoding, a fallback that cannot boot (a failed fingerprint, a derivation without RDTSCP, and a later E_PROFILE_UNSUPPORTED), the restore of a fallback's snapshot under any hooks, the catalog predicate, and the hook's parsing. The Guide documents the fallback and its warning in the CLI reference and the snapshots page. On a Core i9-12900H with WHP, which intel.alderlake.v1 serves, the hook made a cold boot fall back to intel.host.v1 with the warning; its guest passed the time ABI checks, and its snapshot restored on intel.host.v1 with auto. The package-scoped clippy, documentation, and unit-test gates (cpu_profile, virt, openvmm_core, openvmm_entry, and openvmm_helpers) and xtask fmt pass. Part of microsoft/nvx#434. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 27 +- Guide/src/user_guide/openvmm/snapshots.md | 36 +- openvmm/openvmm_core/src/worker/dispatch.rs | 18 +- .../src/worker/dispatch/time_abi.rs | 953 +++++++++++++++--- openvmm/openvmm_entry/src/cli_args/microvm.rs | 6 +- vmm_core/cpu_profile/src/catalog.rs | 27 +- vmm_core/cpu_profile/src/derive.rs | 8 +- vmm_core/cpu_profile/src/lib.rs | 17 +- vmm_core/virt/src/time_abi/backend.rs | 1 + vmm_core/virt/src/time_abi/mod.rs | 18 +- 10 files changed, 930 insertions(+), 181 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 88d6d58813b..6c063e3751c 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -414,12 +414,19 @@ describes the source definitions. gate acknowledgement after restore. The default is 60000 milliseconds. * `--cpu-profile `: Select the microVM's pinned CPU profile, `auto` (the default) for the profile of the host's CPU generation, or `host` to - derive a development profile from this host's backend fingerprint for a - host that no pinned profile serves. `auto` never selects a host profile. A - host without a profile, or a profile of another generation, fails before the - partition is created. On an Intel or AMD host, `auto`'s errors name - `--cpu-profile host` when no pinned profile serves the host and when the - backend does not support the pinned profile (`E_PROFILE_UNSUPPORTED`). A + derive a development profile from this host's backend fingerprint. Where no + pinned profile serves an Intel or AMD host's CPU, `auto` falls back to that + host profile and logs a warning at the default log level at every such cold + boot. The warning starts with `NVX-CPU-PROFILE-FALLBACK:`, names the CPU and + the host profile with its digest, says what that cold boot's fingerprint + took, and asks for the CPU's fingerprint + (`--cpu-fingerprint`) so that a pinned profile can serve it. A CPU of + another vendor that no pinned profile serves (`E_PROFILE_HOST_UNKNOWN`), or + a profile of another generation (`E_CPU_GENERATION`), fails before the + partition is created. On an Intel or AMD host, `auto`'s error names + `--cpu-profile host` when the backend does not support the pinned profile + of the host's generation (`E_PROFILE_UNSUPPORTED`), and says that `auto` + fell back when the host cannot boot the host profile instead. A restore uses the snapshot's profile and rejects any other; `host` also names a host profile that the snapshot recorded. See [Time and CPU compatibility](../../../user_guide/openvmm/snapshots.md#time-and-cpu-compatibility). @@ -551,8 +558,9 @@ guest state and protect the directory with host access controls. `digest` covers the whole document. OpenVMM then checks the fingerprint against the pinned CPU profile of the - host's generation (the profile that `--cpu-profile auto` would select) and - prints one line to standard error, for example: + host's generation (the pinned profile that `--cpu-profile auto` selects; + the check never falls back to a host profile) and prints one line to + standard error, for example: ```text NVX-CPU-PROFILE: status=pass backend=kvm generation=icelake-sp profile=intel.icelake-sp.v1 profile_digest=sha256:… surface_digest=sha256:… host_invariant_tsc=yes @@ -574,7 +582,8 @@ guest state and protect the directory with host access controls. installs the profile as the guest's whole CPUID table and answers every other entry from it (zero, or the architecture's out-of-range and topology results), so the check does not apply. The fingerprint is still written, so - hosts of new generations can be fingerprinted to derive their profiles. + hosts of new generations can be fingerprinted to derive their profiles: the + warning of `--cpu-profile auto`'s fallback asks for it. ```bash openvmm --hypervisor kvm --cpu-fingerprint host.json diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index c8444fcb8c1..1b5b6f1f0db 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -279,20 +279,27 @@ Scalable, fifth generation: 6/207), `intel.alderlake.v1` (Core, twelfth generation: 6/151 and 6/154), `amd.milan.v1` (AMD EPYC, third generation: 25/1), `amd.genoa.v1` (AMD EPYC, fourth generation: 25/17), and `amd.turin.v1` (AMD EPYC, fifth generation: 26/2). `--cpu-profile ` -selects one; the default, -`auto`, selects the profile of the host's generation, and a host without one -fails (`E_PROFILE_HOST_UNKNOWN`). On an Intel or AMD development host without -one, `--cpu-profile host` boots on a host profile instead: OpenVMM -fingerprints the backend on this host, as `--cpu-fingerprint` does, and -applies the pinned profiles' derivation policy to it. A host profile, +selects one; the default, `auto`, selects the profile of the host's +generation. An Intel or AMD host without one boots on a host profile +instead: `auto` falls back to the profile that `--cpu-profile host` +selects, for which OpenVMM fingerprints the backend on this host, as +`--cpu-fingerprint` does, and applies the pinned profiles' derivation policy +to it. Every cold boot that falls back logs a warning at the default log +level that starts with `NVX-CPU-PROFILE-FALLBACK:`, names the CPU and the +host profile with its digest, says what that cold boot's fingerprint took, +and asks for the CPU's fingerprint so that a +pinned profile can serve it. A host of another vendor without a profile +fails (`E_PROFILE_HOST_UNKNOWN`). A host profile, `intel.host.v1` or `amd.host.v1`, serves only the host's CPU model and -stepping, is not pinned, and can change with the host's microcode, firmware, -or hypervisor. Every boot checks that the backend +stepping, is not pinned, can change with the host's microcode, firmware, +or hypervisor, and costs a fingerprint of the backend at every cold boot. +Every boot checks that the backend supports the profile (`E_PROFILE_UNSUPPORTED`) and that VP 0 observes its -effective CPUID (`E_CPU_SURFACE`). On an Intel or AMD host, `auto`'s errors -name `--cpu-profile host` both where no pinned profile serves the host and -where the backend does not support the one that does, because a host profile -derives from what the backend supports. A snapshot records the profile and the +effective CPUID (`E_CPU_SURFACE`); where `auto` fell back, such a failure +says so. On an Intel or AMD host, `auto`'s error names `--cpu-profile host` +where the backend does not support the pinned profile of the host's +generation, because a host profile derives from what the backend supports. +A snapshot records the profile and the effective CPUID. Restore requires the same profile, pinned in this OpenVMM with the same digest, or, for a host profile, the snapshot's own copy of it, which must match the recorded digest; a host of the profile's generation; and an @@ -472,7 +479,8 @@ immediately with a clear error if any active device does not support it. supported. - MicroVM restore requires the backend, CPU generation, and CPU profile of the capture, and an OpenVMM build that computes the same effective CPUID. - A host profile's snapshot restores only on a host of the capture host's CPU - model and stepping. + A host profile's snapshot, including one of a cold boot whose `auto` fell + back to a host profile, restores only on a host of the capture host's CPU + model and stepping whose backend supports the profile. Snapshots from manifest versions before 6, or from a build that computes a different effective CPUID, must be recaptured. diff --git a/openvmm/openvmm_core/src/worker/dispatch.rs b/openvmm/openvmm_core/src/worker/dispatch.rs index 6f974b6b657..029513f037e 100644 --- a/openvmm/openvmm_core/src/worker/dispatch.rs +++ b/openvmm/openvmm_core/src/worker/dispatch.rs @@ -365,17 +365,26 @@ impl Worker for VmWorker { .shared_memory .map(|fd| restore_params.shared_memory_backing(fd)); - // A cold boot whose built-in CPU profile this host cannot boot names - // the host profile where one could boot instead. + // A cold boot whose CPU profile this host cannot boot names the host + // profile where one could boot instead, or says that `auto` fell back + // to one. #[cfg(guest_arch = "x86_64")] let hint = { let requested = manifest .microvm .time_abi .as_ref() - .map(|time_abi| time_abi.cpu_profile.clone()); + .map(|time_abi| (time_abi.cpu_profile.clone(), time_abi.hooks.clone())); let restoring = parameters.saved_state.is_some(); - move |err| time_abi::hint_cold_boot(err, requested.as_deref(), restoring) + move |err| { + time_abi::hint_cold_boot( + err, + requested + .as_ref() + .map(|(cpu_profile, hooks)| (cpu_profile.as_str(), hooks)), + restoring, + ) + } }; #[cfg(not(guest_arch = "x86_64"))] let hint = |err: anyhow::Error| err; @@ -1294,6 +1303,7 @@ impl InitializedVm { ¶meters.cpu_profile, &cfg.microvm.hypervisor_id, cfg.microvm.restored_host_profile.as_ref(), + ¶meters.hooks, )?; let effective_cpuid = Arc::new(virt::time_abi::cpuid::effective_cpuid( &profile, diff --git a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs index e03a61cc301..201b832caa9 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/time_abi.rs @@ -17,6 +17,7 @@ use cpu_profile::HostCpuSignature; use cpu_profile::PartitionProfile; use cpu_profile::ProfileError; use cpu_profile::ProfileErrorCode; +use cpu_profile::fingerprint::CpuFingerprint; use futures_concurrency::future::Join; use inspect::Inspect; use inspect::InspectMut; @@ -103,32 +104,53 @@ pub(super) fn partition_config( /// Selects the CPU profile of a new partition before it is created. /// /// `requested` is `--cpu-profile` on cold boot (`auto`, `host`, or an ID), -/// or the profile a snapshot recorded on restore. An ID or `auto` selects a -/// pinned profile whose generation contains the host CPU -/// (`E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNKNOWN`, `E_CPU_GENERATION`). -/// `host` derives a host profile from the `hypervisor` backend's fingerprint -/// of this host. A host profile's ID selects the profile of `restored`, the -/// record of the snapshot being restored, which carries the profile's only -/// copy ([`restored_host_profile`]). +/// or the profile a snapshot recorded on restore. An ID selects a pinned +/// profile whose generation contains the host CPU (`E_PROFILE_UNKNOWN`, +/// `E_CPU_GENERATION`), and `auto` the pinned profile of the host CPU's +/// generation. On a CPU that no pinned profile serves, `auto` falls back to +/// the host profile that `host` selects where host profiles serve the CPU, +/// and warns ([`fall_back`]), and fails elsewhere +/// (`E_PROFILE_HOST_UNKNOWN`). The `host-cpu-unknown` test hook in `hooks` +/// makes `auto` treat any CPU so. `host` derives a host profile from the +/// `hypervisor` backend's fingerprint of this host. A host profile's ID +/// selects the profile of `restored`, the record of the snapshot being +/// restored, which carries the profile's only copy +/// ([`restored_host_profile`]). pub(super) fn select_cpu_profile( requested: &str, hypervisor: &str, restored: Option<&SnapshotCpuProfile>, + hooks: &TimeAbiTestHooks, ) -> anyhow::Result { let host = host_cpu()?; if requested == cpu_profile::HOST { - let profile = PartitionProfile::host(host_profile(hypervisor)?)?; - cpu_profile::check_generation(&profile, &host)?; + let started = std::time::Instant::now(); + let fingerprint = host_fingerprint(hypervisor)?; + let profile = host_partition_profile(&fingerprint, &host)?; tracing::warn!( - cpu_profile = profile.id(), - profile_digest = %profile.digest_string(), - hypervisor, - "using a host CPU profile: it is not pinned, and its snapshots restore only on a host \ - of the same CPU model and stepping that supports it" + "{}", + host_profile_warning( + HostProfileOrigin::Requested, + &fingerprint, + &profile, + started.elapsed(), + !cpu_profile::pinned_profile_serves(&host), + ) ); return Ok(profile); } - Ok(select_on_host(requested, &host, restored)?) + match select_on_host(requested, &host, restored, hooks)? { + Selection::Profile(profile) => Ok(profile), + Selection::HostFallback => fall_back(&host, || host_fingerprint(hypervisor)), + } +} + +/// What [`select_on_host`] selects. +enum Selection { + /// A pinned profile, or the host profile of the snapshot being restored. + Profile(PartitionProfile), + /// `auto`'s fallback to a host profile, which [`fall_back`] derives. + HostFallback, } /// Selects the CPU profile `requested` on the host CPU `host`, as @@ -137,7 +159,8 @@ fn select_on_host( requested: &str, host: &HostCpuSignature, restored: Option<&SnapshotCpuProfile>, -) -> Result { + hooks: &TimeAbiTestHooks, +) -> Result { if cpu_profile::is_host_profile_id(requested) { let record = restored .filter(|record| record.id == requested) @@ -156,18 +179,230 @@ fn select_on_host( &record.profile, )?)?; cpu_profile::check_generation(&profile, host)?; - return Ok(profile); + return Ok(Selection::Profile(profile)); } - cpu_profile::select(requested, host).map_err(|mut err| { - if requested == cpu_profile::AUTO - && err.code == ProfileErrorCode::ProfileHostUnknown - && cpu_profile::supports_host_profiles(host) - { - err.message.push_str("; "); - err.message.push_str(HOST_PROFILE_HINT); + if requested == cpu_profile::AUTO && falls_back(host, hooks) { + return Ok(Selection::HostFallback); + } + // Where `auto` still fails with `E_PROFILE_HOST_UNKNOWN` on a CPU that + // host profiles serve, pinned profiles of more than one generation serve + // it, a catalog defect, which `--cpu-profile host` avoids. + cpu_profile::select(requested, host) + .map(Selection::Profile) + .map_err(|mut err| { + if requested == cpu_profile::AUTO + && err.code == ProfileErrorCode::ProfileHostUnknown + && cpu_profile::supports_host_profiles(host) + { + err.message.push_str("; "); + err.message.push_str(HOST_PROFILE_HINT); + } + err + }) +} + +/// Returns whether a cold boot with `--cpu-profile auto` falls back to a host +/// profile on the host CPU `host`: no pinned profile serves the CPU, or the +/// `host-cpu-unknown` test hook in `hooks` treats it so, and host profiles +/// serve it. +fn falls_back(host: &HostCpuSignature, hooks: &TimeAbiTestHooks) -> bool { + (hooks.host_cpu_unknown || !cpu_profile::pinned_profile_serves(host)) + && cpu_profile::supports_host_profiles(host) +} + +/// Falls back from `--cpu-profile auto` to a host profile on the host CPU +/// `host`: derives the profile from this host's fingerprint, which +/// `fingerprint` takes, as `--cpu-profile host` does, and warns at the +/// default log level with [`FALLBACK_MARKER`] ([`host_profile_warning`]), +/// naming what the fingerprint and the derivation cost. +/// +/// A failure, the fingerprint's included, says that `auto` fell back +/// ([`FellBack`]), and keeps its code. +fn fall_back( + host: &HostCpuSignature, + fingerprint: impl FnOnce() -> anyhow::Result, +) -> anyhow::Result { + let started = std::time::Instant::now(); + fingerprint() + .and_then(|fingerprint| { + let profile = host_partition_profile(&fingerprint, host)?; + tracing::warn!( + "{}", + host_profile_warning( + HostProfileOrigin::Fallback, + &fingerprint, + &profile, + started.elapsed(), + true, + ) + ); + Ok(profile) + }) + .map_err(|err| err.context(FellBack::new(host))) +} + +/// The context of a failed cold boot whose `--cpu-profile auto` fell back to +/// a host profile ([`fall_back`]), so that its error says so. +#[derive(Debug)] +struct FellBack { + /// The host CPU, as [`describe_signature`] names it. + cpu: String, +} + +impl FellBack { + fn new(host: &HostCpuSignature) -> Self { + Self { + cpu: describe_signature(host), } - err - }) + } +} + +impl std::fmt::Display for FellBack { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "--cpu-profile auto fell back to a host CPU profile, because no built-in CPU profile \ + serves this host's CPU, {}, but this host cannot boot one", + self.cpu + ) + } +} + +/// The marker that leads the warning of every cold boot whose +/// `--cpu-profile auto` falls back to a host profile ([`fall_back`]), which +/// NVX's tools and log scrapers match. +const FALLBACK_MARKER: &str = "NVX-CPU-PROFILE-FALLBACK:"; + +/// NVX's issue form that requests a built-in CPU profile for a CPU. +const CPU_PROFILE_REQUEST_URL: &str = + "https://github.com/microsoft/nvx/issues/new?template=cpu-profile.yml"; + +/// How a cold boot came to use a host profile. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum HostProfileOrigin { + /// `--cpu-profile auto` fell back to it. + Fallback, + /// `--cpu-profile host` selected it. + Requested, +} + +/// Returns the warning of a cold boot on `profile`, a host profile that +/// `origin` selected from `fingerprint`, this host's fingerprint: the host +/// CPU, the profile and its digest, and the limits of a host profile, +/// including `cost`, what this cold boot's fingerprint and derivation took. +/// With `invite`, for a CPU that no pinned profile serves, it also asks the +/// user to request a built-in profile for the CPU with the CPU's +/// fingerprint, through NVX's issue form, which the link prefills with the +/// CPU. +fn host_profile_warning( + origin: HostProfileOrigin, + fingerprint: &CpuFingerprint, + profile: &PartitionProfile, + cost: Duration, + invite: bool, +) -> String { + let cpu = describe_cpu(&fingerprint.host.cpu); + let backend = &fingerprint.backend.name; + let selected = format!( + "{} (sha256:{}), a profile derived from this host's {backend} backend for development", + profile.id(), + hex(&profile.record().digest) + ); + let mut warning = match origin { + HostProfileOrigin::Fallback => format!( + "{FALLBACK_MARKER} no built-in CPU profile serves this host's CPU, {cpu}, so \ + --cpu-profile auto fell back to {selected}." + ), + HostProfileOrigin::Requested if invite => format!( + "--cpu-profile host selected {selected}; no built-in CPU profile serves this \ + host's CPU, {cpu}." + ), + HostProfileOrigin::Requested => format!("--cpu-profile host selected {selected}."), + }; + warning.push_str(&format!( + "\nIt is not pinned: a microcode, firmware, hypervisor, or OS update can change it. \ + Each cold boot fingerprints the backend first, which took {:.1} ms here, and its \ + snapshots restore only on hosts of the same CPU model and stepping whose hypervisor \ + supports it.", + cost.as_secs_f64() * 1000.0 + )); + if invite { + warning.push_str(&format!( + "\nHelp NVX add a built-in profile for this CPU: run\n \ + openvmm --hypervisor {backend} --cpu-fingerprint fingerprint.json\n\ + and attach fingerprint.json to a CPU profile request:\n {}", + cpu_profile_request_url(&fingerprint.host.cpu) + )); + } + warning +} + +/// Returns the link to NVX's CPU profile request form for the host CPU `cpu`, +/// which prefills its title and CPU fields. +fn cpu_profile_request_url(cpu: &cpu_profile::host::HostCpu) -> String { + let mut url = format!( + "{CPU_PROFILE_REQUEST_URL}&title={}&signature={}", + encode_query_value(&format!("CPU profile: {}", describe_cpu(cpu))), + encode_query_value(&cpu_signature( + &cpu.vendor, + cpu.family, + cpu.model, + cpu.stepping + )) + ); + if !cpu.brand.is_empty() { + url.push_str("&cpu="); + url.push_str(&encode_query_value(&cpu.brand)); + } + url +} + +/// Percent-encodes `value` for a URL query: every byte but ASCII letters, +/// digits, `-`, `.`, `_`, and `~`. +fn encode_query_value(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +/// Returns `bytes` in lowercase hexadecimal. +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +/// Names a CPU's signature as NVX does: its vendor string and its display +/// family, model, and stepping, such as `GenuineIntel 6/173/1`. +fn cpu_signature(vendor: &str, family: u32, model: u32, stepping: u32) -> String { + format!("{vendor} {family}/{model}/{stepping}") +} + +/// Names a host CPU as NVX does: its signature ([`cpu_signature`]) and its +/// brand string, if any, such as +/// `GenuineIntel 6/173/1 (Intel(R) Xeon(R) 6973P-C)`. +fn describe_cpu(cpu: &cpu_profile::host::HostCpu) -> String { + let signature = cpu_signature(&cpu.vendor, cpu.family, cpu.model, cpu.stepping); + if cpu.brand.is_empty() { + signature + } else { + format!("{signature} ({})", cpu.brand) + } +} + +/// Names the host CPU `host` by its signature ([`cpu_signature`]), without +/// the brand string, which a signature lacks. +fn describe_signature(host: &HostCpuSignature) -> String { + cpu_signature( + &host.vendor_str(), + host.family(), + host.model(), + host.stepping(), + ) } /// What a failed cold boot with `--cpu-profile auto` suggests where host @@ -175,13 +410,29 @@ fn select_on_host( const HOST_PROFILE_HINT: &str = "--cpu-profile host boots on a profile derived from this host, for development"; -/// Names `--cpu-profile host` in `err`, the failure of a VM worker that cold -/// booted with `--cpu-profile requested` on the host CPU `host`, where a host -/// profile could boot instead: `auto` selected a built-in profile that this -/// host's backend does not support (`E_PROFILE_UNSUPPORTED`), and host -/// profiles serve the CPU's vendor. A host profile derives from what the -/// backend supports. As for `E_PROFILE_HOST_UNKNOWN` -/// ([`select_cpu_profile`]), an explicit profile ID and `host` get no hint. +/// Returns whether `code` is a CPU profile failure of a cold boot, after the +/// selection: the backend lacks the profile, or does not present it. +fn is_profile_failure(code: TimeAbiCode) -> bool { + matches!( + code, + TimeAbiCode::ProfileUnsupported + | TimeAbiCode::ProfileTimeBits + | TimeAbiCode::CpuSurface + | TimeAbiCode::CpuUnlisted + ) +} + +/// Explains `err`, the failure of a VM worker that cold booted with +/// `--cpu-profile requested` and the test hooks `hooks` on the host CPU +/// `host`, where its CPU profile failed. +/// +/// Where `auto` fell back to a host profile ([`falls_back`]), a CPU profile +/// failure says so ([`FellBack`]), unless the fallback itself already did. +/// Elsewhere, `auto` names `--cpu-profile host` where a host profile could +/// boot instead: `auto` selected a built-in profile that this host's backend +/// does not support (`E_PROFILE_UNSUPPORTED`), and host profiles serve the +/// CPU's vendor. A host profile derives from what the backend supports. An +/// explicit profile ID and `host` get no hint. /// /// The hint is a context of `err`, which keeps the backend's error chain: /// the code, where the backend found the shortfall, and what it lacks. @@ -189,11 +440,19 @@ fn hint_host_profile( err: anyhow::Error, requested: &str, host: &HostCpuSignature, + hooks: &TimeAbiTestHooks, ) -> anyhow::Error { - if requested != cpu_profile::AUTO - || !cpu_profile::supports_host_profiles(host) - || leading_code(&err) != Some(TimeAbiCode::ProfileUnsupported) - { + if requested != cpu_profile::AUTO { + return err; + } + let code = leading_code(&err); + if falls_back(host, hooks) { + if err.downcast_ref::().is_some() || !code.is_some_and(is_profile_failure) { + return err; + } + return err.context(FellBack::new(host)); + } + if !cpu_profile::supports_host_profiles(host) || code != Some(TimeAbiCode::ProfileUnsupported) { return err; } err.context(format!( @@ -204,15 +463,17 @@ fn hint_host_profile( /// Returns `err`, the failure of a VM worker, with the hint of /// [`hint_host_profile`] if the worker cold booted a time ABI microVM with -/// `--cpu-profile requested` on this host. A restore (`restoring`), which -/// takes the snapshot's profile, gets none. +/// the `--cpu-profile` and test hooks of `requested` on this host. A restore +/// (`restoring`), which takes the snapshot's profile, gets none. pub(super) fn hint_cold_boot( err: anyhow::Error, - requested: Option<&str>, + requested: Option<(&str, &TimeAbiTestHooks)>, restoring: bool, ) -> anyhow::Error { match (requested, restoring, host_cpu()) { - (Some(requested), false, Ok(host)) => hint_host_profile(err, requested, &host), + (Some((requested, hooks)), false, Ok(host)) => { + hint_host_profile(err, requested, &host, hooks) + } _ => err, } } @@ -227,25 +488,35 @@ fn leading_code(err: &anyhow::Error) -> Option { }) } -/// Derives the host profile of this host and the `hypervisor` backend: the -/// backend's CPU fingerprint, as `--cpu-fingerprint` takes it, under the -/// profiles' derivation policy. -fn host_profile(hypervisor: &str) -> anyhow::Result { +/// Fingerprints this host and the `hypervisor` backend, as +/// `--cpu-fingerprint` does, for a host profile. +fn host_fingerprint(hypervisor: &str) -> anyhow::Result { let probe = hypervisor_resources::probe_by_name(hypervisor) .with_context(|| format!("the {hypervisor} backend cannot be fingerprinted"))?; let backend = probe .cpu_fingerprint(&[]) .with_context(|| format!("failed to fingerprint the {hypervisor} backend"))?; let host = cpu_profile::host::HostIdentity::collect().context("failed to identify the host")?; - let fingerprint = cpu_profile::fingerprint::CpuFingerprint::new( + Ok(CpuFingerprint::new( cpu_profile::fingerprint::ToolIdentity { name: "openvmm".to_owned(), version: env!("CARGO_PKG_VERSION").to_owned(), }, host, backend, - ); - Ok(cpu_profile::derive_host_profile(&fingerprint)?) + )) +} + +/// Derives the host profile of `fingerprint`, this host's fingerprint, under +/// the profiles' derivation policy, and checks that the host CPU `host` is in +/// its generation. +fn host_partition_profile( + fingerprint: &CpuFingerprint, + host: &HostCpuSignature, +) -> anyhow::Result { + let profile = PartitionProfile::host(cpu_profile::derive_host_profile(fingerprint)?)?; + cpu_profile::check_generation(&profile, host)?; + Ok(profile) } /// Returns the CPU profile record of a snapshot that the worker restores, if @@ -774,17 +1045,49 @@ mod tests { /// The signature of the test profile's CPU, an Ice Lake-SP Xeon. const TEST_SIGNATURE: u32 = 0x0006_06a6; - /// Returns whether `message` ends with the `--cpu-profile host` hint, as - /// `auto`'s `E_PROFILE_HOST_UNKNOWN` message does on a host that host - /// profiles serve. - fn ends_with_hint(message: &str) -> bool { - message.ends_with(&format!("; {HOST_PROFILE_HINT}")) - } + /// The signature of a Granite Rapids Xeon, family 6 model 173 stepping 1, + /// which no pinned profile serves. + const GRANITE_RAPIDS: u32 = 0x000a_06d1; + + /// The brand string of the Granite Rapids test CPU. + const GRANITE_RAPIDS_BRAND: &str = "Intel(R) Xeon(R) 6973P-C"; + + /// No test hook. + const NO_HOOKS: TimeAbiTestHooks = TimeAbiTestHooks { + force_utc_downtime: false, + boot_id_mismatch: false, + dest_rate_offset_ppm: 0, + downtime_add_s: 0, + utc_offset_ms: 0, + sample_delay_us: 0, + host_cpu_unknown: false, + }; + + /// The `host-cpu-unknown` test hook. + const HOST_CPU_UNKNOWN: TimeAbiTestHooks = TimeAbiTestHooks { + host_cpu_unknown: true, + ..NO_HOOKS + }; fn test_profile() -> &'static CpuProfile { cpu_profile::pinned(TEST_PROFILE).unwrap() } + /// Returns the profile of `selection`, which must not be a fallback. + fn selected( + selection: Result, + ) -> Result { + selection.map(|selection| match selection { + Selection::Profile(profile) => profile, + Selection::HostFallback => panic!("the selection fell back to a host profile"), + }) + } + + /// Returns whether `selection` is `auto`'s fallback to a host profile. + fn is_fallback(selection: &Result) -> bool { + matches!(selection, Ok(Selection::HostFallback)) + } + /// Returns the partition profile of the pinned `profile`. fn partition_profile(profile: &CpuProfile) -> PartitionProfile { PartitionProfile::pinned(profile.id()).unwrap() @@ -1095,68 +1398,332 @@ mod tests { "intel.cascadelake.v1", "intel.host.v1", ] { - assert_eq!( - code(select_cpu_profile(requested, "kvm", None)), - "E_PROFILE_UNKNOWN" - ); + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + assert_eq!( + code(select_cpu_profile(requested, "kvm", None, hooks)), + "E_PROFILE_UNKNOWN" + ); + } } // `auto` selects the pinned profile of the host's generation, if any, - // and otherwise names the opt-in host profile where one can serve the - // host; a pinned ID selects its profile only in its generation. + // and otherwise falls back to a host profile where one can serve the + // host. The `nosuch` backend, which cannot be fingerprinted, stops a + // fallback at its first step, which says that `auto` fell back. let host = host_cpu().unwrap(); - match select_cpu_profile("auto", "kvm", None) { - Ok(profile) => assert!(cpu_profile::check_generation(&profile, &host).is_ok()), - Err(err) => { - let message = format!("{err:#}"); - assert!( - message.starts_with("[E_PROFILE_HOST_UNKNOWN] "), - "{message}" - ); - assert_eq!( - ends_with_hint(&message), - cpu_profile::supports_host_profiles(&host), - "{message}" - ); - } + let fell_back = |result: anyhow::Result| { + assert_eq!( + format!("{:#}", result.unwrap_err()), + format!( + "{}: the nosuch backend cannot be fingerprinted", + FellBack::new(&host) + ) + ); + }; + let auto = select_cpu_profile(cpu_profile::AUTO, "nosuch", None, &NO_HOOKS); + if cpu_profile::pinned_profile_serves(&host) { + let profile = auto.unwrap(); + assert!(!profile.is_host()); + cpu_profile::check_generation(&profile, &host).unwrap(); + } else if cpu_profile::supports_host_profiles(&host) { + fell_back(auto); + } else { + assert_eq!(code(auto), "E_PROFILE_HOST_UNKNOWN"); } + // The `host-cpu-unknown` hook makes `auto` fall back on any CPU that + // host profiles serve. + let hooked = select_cpu_profile(cpu_profile::AUTO, "nosuch", None, &HOST_CPU_UNKNOWN); + if cpu_profile::supports_host_profiles(&host) { + fell_back(hooked); + } else { + assert_eq!(code(hooked), "E_PROFILE_HOST_UNKNOWN"); + } + + // A pinned ID selects its profile only in its generation, whatever + // the hooks. for profile in cpu_profile::pinned_profiles() { - let selected = select_cpu_profile(profile.id(), "kvm", None); - if cpu_profile::check_generation(profile, &host).is_ok() { - assert_eq!(selected.unwrap().id(), profile.id()); - } else { - assert_eq!(code(selected), "E_CPU_GENERATION"); + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + let selected = select_cpu_profile(profile.id(), "kvm", None, hooks); + if cpu_profile::check_generation(profile, &host).is_ok() { + assert_eq!(selected.unwrap().id(), profile.id()); + } else { + assert_eq!(code(selected), "E_CPU_GENERATION"); + } } } } - /// `auto` names `--cpu-profile host` only where a host profile can boot: - /// on Intel and AMD CPUs that no pinned profile serves. + /// `auto` falls back to a host profile only where one can boot: on Intel + /// and AMD CPUs that no pinned profile serves, or that the + /// `host-cpu-unknown` test hook treats so. Another vendor's CPU still + /// fails, without naming `--cpu-profile host`, and an explicit profile + /// never falls back. #[test] - fn auto_names_host_profiles_only_where_they_serve() { + fn auto_falls_back_to_host_profiles_only_where_they_serve() { let tiger_lake = HostCpuSignature::new(*b"GenuineIntel", 0x0008_06c1); + let granite_rapids = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); let zen3 = HostCpuSignature::new(*b"AuthenticAMD", 0x00a2_0f12); + let icelake = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); + let milan = HostCpuSignature::new(*b"AuthenticAMD", 0x00a0_0f11); let hygon = HostCpuSignature::new(*b"HygonGenuine", 0x0090_0f01); - for (host, hint) in [(tiger_lake, true), (zen3, true), (hygon, false)] { - let error = select_on_host(cpu_profile::AUTO, &host, None).unwrap_err(); + let select = |requested, host, hooks| select_on_host(requested, host, None, hooks); + + for host in [&tiger_lake, &granite_rapids, &zen3] { + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + assert!(falls_back(host, hooks), "{host}"); + assert!(is_fallback(&select(cpu_profile::AUTO, host, hooks))); + } + } + for (host, id) in [(&icelake, TEST_PROFILE), (&milan, "amd.milan.v1")] { + assert!(!falls_back(host, &NO_HOOKS), "{host}"); + assert_eq!( + selected(select(cpu_profile::AUTO, host, &NO_HOOKS)) + .unwrap() + .id(), + id + ); + assert!(falls_back(host, &HOST_CPU_UNKNOWN), "{host}"); + assert!(is_fallback(&select( + cpu_profile::AUTO, + host, + &HOST_CPU_UNKNOWN + ))); + } + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + assert!(!falls_back(&hygon, hooks)); + let error = selected(select(cpu_profile::AUTO, &hygon, hooks)).unwrap_err(); assert_eq!(error.code, ProfileErrorCode::ProfileHostUnknown, "{error}"); - assert_eq!(ends_with_hint(&error.message), hint, "{error}"); + assert!(!error.message.contains("--cpu-profile host"), "{error}"); + + let error = selected(select(TEST_PROFILE, &tiger_lake, hooks)).unwrap_err(); + assert_eq!(error.code, ProfileErrorCode::CpuGeneration, "{error}"); + assert!(!error.message.contains("--cpu-profile host"), "{error}"); assert_eq!( - error.message.contains("--cpu-profile host"), - hint, - "{error}" + selected(select(TEST_PROFILE, &icelake, hooks)) + .unwrap() + .id(), + TEST_PROFILE ); } - // An explicit profile on another generation gets no hint. - let error = select_on_host(TEST_PROFILE, &tiger_lake, None).unwrap_err(); - assert_eq!(error.code, ProfileErrorCode::CpuGeneration, "{error}"); - assert!(!error.message.contains("--cpu-profile host"), "{error}"); + } + + /// `auto`'s fallback derives the host profile that `--cpu-profile host` + /// derives, checks its generation, and warns with the stable marker, the + /// host CPU, the profile and its digest, the limits of a host profile, + /// and an invitation to request a built-in profile through NVX's issue + /// form, which the link prefills with the CPU. + #[test] + fn auto_falls_back_to_a_host_profile_and_warns() { + let host = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); + let fingerprint = test_fingerprint("kvm", GRANITE_RAPIDS, GRANITE_RAPIDS_BRAND); + let profile = fall_back(&host, || Ok(fingerprint.clone())).unwrap(); + assert!(profile.is_host()); + assert_eq!(profile.id(), "intel.host.v1"); + assert_eq!( + *profile, + cpu_profile::derive_host_profile(&fingerprint).unwrap() + ); + cpu_profile::check_generation(&profile, &host).unwrap(); + + let digest = profile.digest_string(); + let warning = host_profile_warning( + HostProfileOrigin::Fallback, + &fingerprint, + &profile, + Duration::from_micros(12_345), + true, + ); + let lines: Vec<_> = warning.lines().collect(); + assert_eq!( + lines[0], + format!( + "NVX-CPU-PROFILE-FALLBACK: no built-in CPU profile serves this host's CPU, \ + GenuineIntel 6/173/1 (Intel(R) Xeon(R) 6973P-C), so --cpu-profile auto fell \ + back to intel.host.v1 ({digest}), a profile derived from this host's kvm \ + backend for development." + ) + ); + assert_eq!( + lines[1], + "It is not pinned: a microcode, firmware, hypervisor, or OS update can change it. \ + Each cold boot fingerprints the backend first, which took 12.3 ms here, and its \ + snapshots restore only on hosts of the same CPU model and stepping whose \ + hypervisor supports it." + ); + assert_eq!( + lines[2..], + [ + "Help NVX add a built-in profile for this CPU: run", + " openvmm --hypervisor kvm --cpu-fingerprint fingerprint.json", + "and attach fingerprint.json to a CPU profile request:", + " https://github.com/microsoft/nvx/issues/new?template=cpu-profile.yml\ + &title=CPU%20profile%3A%20GenuineIntel%206%2F173%2F1%20%28Intel%28R%29%20Xeon\ + %28R%29%206973P-C%29&signature=GenuineIntel%206%2F173%2F1\ + &cpu=Intel%28R%29%20Xeon%28R%29%206973P-C", + ] + ); + } + + /// An explicit `--cpu-profile host` warns without the fallback's marker, + /// and invites a request for a built-in profile only on a CPU that no + /// pinned profile serves. + #[test] + fn explicit_host_profiles_invite_requests_only_where_no_profile_serves() { + let fingerprint = test_fingerprint("whp", GRANITE_RAPIDS, GRANITE_RAPIDS_BRAND); + let host = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); + let profile = host_partition_profile(&fingerprint, &host).unwrap(); + let digest = profile.digest_string(); + let invited = host_profile_warning( + HostProfileOrigin::Requested, + &fingerprint, + &profile, + Duration::from_micros(4_960), + true, + ); + assert!( + invited.starts_with(&format!( + "--cpu-profile host selected intel.host.v1 ({digest}), a profile derived from \ + this host's whp backend for development; no built-in CPU profile serves this \ + host's CPU, GenuineIntel 6/173/1 (Intel(R) Xeon(R) 6973P-C).\nIt is not pinned" + )), + "{invited}" + ); + assert!(!invited.contains(FALLBACK_MARKER), "{invited}"); + assert!( + invited.contains("\n openvmm --hypervisor whp --cpu-fingerprint fingerprint.json\n"), + "{invited}" + ); + assert!( + invited.contains(&format!("\n {CPU_PROFILE_REQUEST_URL}&title=")), + "{invited}" + ); + + let (served, _) = test_host_profile("whp"); + let served = PartitionProfile::host(served).unwrap(); + let fingerprint = test_fingerprint("whp", TEST_SIGNATURE, ""); + let warning = host_profile_warning( + HostProfileOrigin::Requested, + &fingerprint, + &served, + Duration::from_micros(4_960), + false, + ); + assert_eq!( + warning.lines().collect::>(), + [ + format!( + "--cpu-profile host selected intel.host.v1 ({}), a profile derived from \ + this host's whp backend for development.", + served.digest_string() + ) + .as_str(), + "It is not pinned: a microcode, firmware, hypervisor, or OS update can change \ + it. Each cold boot fingerprints the backend first, which took 5.0 ms here, and \ + its snapshots restore only on hosts of the same CPU model and stepping whose \ + hypervisor supports it.", + ] + ); + } + + #[test] + fn cpu_profile_requests_prefill_the_cpu() { + assert_eq!( + encode_query_value("az-AZ_09.~ /(R)&=?#%+\u{e9}"), + "az-AZ_09.~%20%2F%28R%29%26%3D%3F%23%25%2B%C3%A9" + ); + // A CPU without a brand string prefills only its signature. + let fingerprint = test_fingerprint("mshv", GRANITE_RAPIDS, ""); + assert_eq!(describe_cpu(&fingerprint.host.cpu), "GenuineIntel 6/173/1"); + assert_eq!( + cpu_profile_request_url(&fingerprint.host.cpu), + format!( + "{CPU_PROFILE_REQUEST_URL}&title=CPU%20profile%3A%20GenuineIntel%206%2F173%2F1\ + &signature=GenuineIntel%206%2F173%2F1" + ) + ); + assert_eq!( + describe_signature(&HostCpuSignature::new(*b"AuthenticAMD", 0x00a6_0f12)), + "AuthenticAMD 25/97/2" + ); + } + + /// A fallback that cannot boot says that `auto` fell back, keeps the + /// failure's code, and names no `--cpu-profile host`, which it already + /// tried: a failed fingerprint or derivation, such as a backend that + /// lacks a CPU feature that the time ABI requires (`E_PROFILE_UNSUPPORTED`), + /// and a backend that cannot boot the derived profile. + #[test] + fn a_fallback_that_cannot_boot_says_that_auto_fell_back() { + let host = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); + let fell_back = FellBack::new(&host).to_string(); + assert_eq!( + fell_back, + "--cpu-profile auto fell back to a host CPU profile, because no built-in CPU \ + profile serves this host's CPU, GenuineIntel 6/173/1, but this host cannot boot one" + ); + + let no_rdtscp = + test_fingerprint_with("whp", GRANITE_RAPIDS, GRANITE_RAPIDS_BRAND, |cpuid| { + for entry in cpuid.iter_mut() { + if entry.key() == (0x8000_0001, None) { + let mut registers = entry.registers(); + registers[3] &= !(1 << 27); + *entry = cpu_profile::cpuid::CpuidEntry::new(0x8000_0001, None, registers); + } + } + }); + let err = fall_back(&host, || Ok(no_rdtscp)).unwrap_err(); + let message = format!("{err:#}"); + assert!( + message.starts_with(&format!("{fell_back}: [E_PROFILE_UNSUPPORTED] ")), + "{message}" + ); + assert!(message.contains("RDTSCP"), "{message}"); + assert_eq!(leading_code(&err), Some(TimeAbiCode::ProfileUnsupported)); + assert!(!message.contains(HOST_PROFILE_HINT), "{message}"); + + let err = fall_back(&host, || { + Err(anyhow::anyhow!("failed to fingerprint the kvm backend")) + }) + .unwrap_err(); + assert_eq!( + format!("{err:#}"), + format!("{fell_back}: failed to fingerprint the kvm backend") + ); + + // The cold boot's hint does not repeat what the fallback said. + let wrapped = err.context("failed to create the partition"); + let hinted = format!( + "{:#}", + hint_host_profile(wrapped, cpu_profile::AUTO, &host, &NO_HOOKS) + ); + assert_eq!(hinted.matches(&fell_back).count(), 1, "{hinted}"); + + // A backend that cannot boot the profile that `auto` fell back to. + let unsupported = || { + anyhow::Error::new(TimeAbiError::new( + TimeAbiCode::ProfileUnsupported, + "the kvm backend does not support CPU profile intel.host.v1", + )) + .context("failed to create the prototype partition") + }; + let icelake = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); + for (host, hooks) in [(&host, &NO_HOOKS), (&icelake, &HOST_CPU_UNKNOWN)] { + let err = hint_host_profile(unsupported(), cpu_profile::AUTO, host, hooks); + let message = format!("{err:#}"); + assert_eq!( + message, + format!("{}: {:#}", FellBack::new(host), unsupported()) + ); + assert_eq!(leading_code(&err), Some(TimeAbiCode::ProfileUnsupported)); + assert!(!message.contains(HOST_PROFILE_HINT), "{message}"); + } } /// A cold boot with `auto` names `--cpu-profile host` where the backend /// does not support the built-in profile that `auto` selected, on Intel - /// and AMD CPUs only, as it does where no built-in profile serves the - /// host. The hint keeps the backend's error chain. + /// and AMD CPUs only, and says that `auto` fell back where it fell back + /// to a host profile. The hint keeps the backend's error chain. #[test] fn auto_names_host_profiles_where_the_backend_lacks_the_profile() { let unsupported = || { @@ -1168,9 +1735,10 @@ mod tests { }; let icelake = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); let milan = HostCpuSignature::new(*b"AuthenticAMD", 0x00a0_0f11); + let tiger_lake = HostCpuSignature::new(*b"GenuineIntel", 0x0008_06c1); let hygon = HostCpuSignature::new(*b"HygonGenuine", 0x0090_0f01); for host in [&icelake, &milan] { - let err = hint_host_profile(unsupported(), cpu_profile::AUTO, host); + let err = hint_host_profile(unsupported(), cpu_profile::AUTO, host, &NO_HOOKS); let message = format!("{err:#}"); assert!(message.starts_with("this host cannot boot the built-in CPU profile that --cpu-profile auto selected; --cpu-profile host boots"), "{message}"); assert!( @@ -1183,12 +1751,31 @@ mod tests { "{message}" ); } + // Where `auto` fell back, every CPU profile failure says so instead. + for code in [ + TimeAbiCode::ProfileUnsupported, + TimeAbiCode::ProfileTimeBits, + TimeAbiCode::CpuSurface, + TimeAbiCode::CpuUnlisted, + ] { + let err = anyhow::Error::new(TimeAbiError::new(code, "intel.host.v1 failed")); + let message = format!( + "{:#}", + hint_host_profile(err, cpu_profile::AUTO, &tiger_lake, &NO_HOOKS) + ); + assert!( + message.starts_with(&FellBack::new(&tiger_lake).to_string()), + "{message}" + ); + assert!(!message.contains(HOST_PROFILE_HINT), "{message}"); + } // Another vendor's CPU, an explicit profile, `host`, and other - // failures get no hint. + // failures get no hint, after a fallback too. for (requested, host, err) in [ (cpu_profile::AUTO, &hygon, unsupported()), (TEST_PROFILE, &icelake, unsupported()), (cpu_profile::HOST, &milan, unsupported()), + (cpu_profile::HOST, &tiger_lake, unsupported()), ( cpu_profile::AUTO, &milan, @@ -1200,12 +1787,30 @@ mod tests { &milan, anyhow::anyhow!("no time ABI code"), ), + ( + cpu_profile::AUTO, + &tiger_lake, + anyhow::anyhow!("no time ABI code"), + ), + ( + cpu_profile::AUTO, + &tiger_lake, + anyhow::Error::new(TimeAbiError::new( + TimeAbiCode::TscSyncUnsupported, + "no synchronized TSC set", + )), + ), ] { - let message = format!("{:#}", hint_host_profile(err, requested, host)); - assert!(!message.contains(HOST_PROFILE_HINT), "{message}"); + let before = format!("{err:#}"); + let message = format!("{:#}", hint_host_profile(err, requested, host, &NO_HOOKS)); + assert_eq!(message, before); } // Neither does a restore, nor a VM without the time ABI. - for (requested, restoring) in [(Some(cpu_profile::AUTO), true), (None, false)] { + for (requested, restoring) in [ + (Some((cpu_profile::AUTO, &NO_HOOKS)), true), + (Some((cpu_profile::AUTO, &HOST_CPU_UNKNOWN)), true), + (None, false), + ] { let message = format!("{:#}", hint_cold_boot(unsupported(), requested, restoring)); assert_eq!(message, format!("{:#}", unsupported())); } @@ -1213,33 +1818,46 @@ mod tests { #[test] fn a_host_profile_needs_a_backend_fingerprint() { - let error = select_cpu_profile(cpu_profile::HOST, "nosuch", None).unwrap_err(); - assert_eq!( - format!("{error:#}"), - "the nosuch backend cannot be fingerprinted" - ); + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + let error = select_cpu_profile(cpu_profile::HOST, "nosuch", None, hooks).unwrap_err(); + assert_eq!( + format!("{error:#}"), + "the nosuch backend cannot be fingerprinted" + ); + } } - /// Returns the host profile that `--cpu-profile host` derives on a - /// `backend` host of the test profile's CPU whose surface is the test - /// profile's, and a snapshot's record of it. - fn test_host_profile(backend: &str) -> (CpuProfile, SnapshotCpuProfile) { - let cpuid = test_profile() + /// Returns the fingerprint of a `backend` host of the GenuineIntel CPU + /// with `signature` and `brand`, whose surface is the test profile's + /// after `edit` changes its CPUID. + fn test_fingerprint_with( + backend: &str, + signature: u32, + brand: &str, + edit: impl FnOnce(&mut Vec), + ) -> CpuFingerprint { + let mut cpuid: Vec<_> = test_profile() .cpuid() .iter() .map(|entry| { let (leaf, subleaf) = entry.key(); - cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, entry.values()) + let mut registers = entry.values(); + if (leaf, subleaf) == (1, None) { + registers[0] = signature; + } + cpu_profile::cpuid::CpuidEntry::new(leaf, subleaf, registers) }) .collect(); + edit(&mut cpuid); + let host = HostCpuSignature::new(*b"GenuineIntel", signature); let host = cpu_profile::host::HostIdentity { cpu: cpu_profile::host::HostCpu { vendor: "GenuineIntel".to_owned(), - signature: cpu_profile::Hex32(TEST_SIGNATURE), - family: 6, - model: 106, - stepping: 6, - brand: String::new(), + signature: cpu_profile::Hex32(signature), + family: host.family(), + model: host.model(), + stepping: host.stepping(), + brand: brand.to_owned(), microcode: Vec::new(), invariant_tsc: true, tsc_deadline: true, @@ -1255,22 +1873,41 @@ mod tests { }, hypervisor: None, }; - let fingerprint = cpu_profile::fingerprint::CpuFingerprint::new( + CpuFingerprint::new( cpu_profile::fingerprint::ToolIdentity { name: "test".to_owned(), version: "0".to_owned(), }, host, cpu_profile::fingerprint::BackendFingerprint::new(backend, "test", cpuid), - ); - let profile = cpu_profile::derive_host_profile(&fingerprint).unwrap(); - let record = SnapshotCpuProfile { + ) + } + + /// Returns the fingerprint of a `backend` host of the GenuineIntel CPU + /// with `signature` and `brand`, whose surface is the test profile's. + fn test_fingerprint(backend: &str, signature: u32, brand: &str) -> CpuFingerprint { + test_fingerprint_with(backend, signature, brand, |_| {}) + } + + /// Returns a snapshot's record of the host profile `profile`, captured + /// on a CPU with `signature`. + fn host_profile_record(profile: &CpuProfile, signature: u32) -> SnapshotCpuProfile { + SnapshotCpuProfile { id: profile.id().to_owned(), sha256: profile.digest().to_vec(), profile: profile.encode(), effective_cpuid: Vec::new(), - capture_cpu_signature: TEST_SIGNATURE, - }; + capture_cpu_signature: signature, + } + } + + /// Returns the host profile that `--cpu-profile host` derives on a + /// `backend` host of the test profile's CPU whose surface is the test + /// profile's, and a snapshot's record of it. + fn test_host_profile(backend: &str) -> (CpuProfile, SnapshotCpuProfile) { + let fingerprint = test_fingerprint(backend, TEST_SIGNATURE, ""); + let profile = cpu_profile::derive_host_profile(&fingerprint).unwrap(); + let record = host_profile_record(&profile, TEST_SIGNATURE); (profile, record) } @@ -1278,13 +1915,19 @@ mod tests { fn restore_takes_a_host_profile_from_the_snapshot() { let (profile, record) = test_host_profile("whp"); let host = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE); + let select = |requested: &str, host, restored| { + selected(select_on_host(requested, host, restored, &NO_HOOKS)) + }; assert_eq!(restored_host_profile(&record), Some(record.clone())); - let selected = select_on_host(profile.id(), &host, Some(&record)).unwrap(); - assert!(selected.is_host()); - assert_eq!(*selected, profile); - assert_eq!(selected.record().encoding, record.profile.as_slice()); + let selected_profile = select(profile.id(), &host, Some(&record)).unwrap(); + assert!(selected_profile.is_host()); + assert_eq!(*selected_profile, profile); + assert_eq!( + selected_profile.record().encoding, + record.profile.as_slice() + ); assert_eq!( - selected.record().digest.as_slice(), + selected_profile.record().digest.as_slice(), record.sha256.as_slice() ); @@ -1292,26 +1935,26 @@ mod tests { // ID, here derived from another backend's fingerprint. let (other, other_record) = test_host_profile("kvm"); assert_eq!(other.id(), profile.id()); - let reselected = select_on_host(other.id(), &host, Some(&other_record)).unwrap(); + let reselected = select(other.id(), &host, Some(&other_record)).unwrap(); assert_eq!(*reselected, other); - assert_ne!(reselected.record().digest, selected.record().digest); + assert_ne!(reselected.record().digest, selected_profile.record().digest); let error_code = |result: Result| result.unwrap_err().code; // Only the snapshot of a host profile carries it. assert_eq!( - error_code(select_on_host(profile.id(), &host, None)), + error_code(select(profile.id(), &host, None)), ProfileErrorCode::ProfileUnknown ); let mut renamed = record.clone(); renamed.id = "intel.host.v2".to_owned(); assert_eq!( - error_code(select_on_host(profile.id(), &host, Some(&renamed))), + error_code(select(profile.id(), &host, Some(&renamed))), ProfileErrorCode::ProfileUnknown ); // A host profile serves only its own CPU model and stepping. let elsewhere = HostCpuSignature::new(*b"GenuineIntel", TEST_SIGNATURE + 1); assert_eq!( - error_code(select_on_host(profile.id(), &elsewhere, Some(&record))), + error_code(select(profile.id(), &elsewhere, Some(&record))), ProfileErrorCode::CpuGeneration ); // A pinned profile's document under the host profile's ID. @@ -1320,15 +1963,53 @@ mod tests { swapped.profile = pinned.encoding.to_vec(); swapped.sha256 = pinned.digest.to_vec(); assert_eq!( - error_code(select_on_host(profile.id(), &host, Some(&swapped))), + error_code(select(profile.id(), &host, Some(&swapped))), ProfileErrorCode::ProfileDigest ); // A pinned profile's snapshot needs no record: its ID selects it. - let mut pinned_snapshot = swapped; + let mut pinned_snapshot = swapped.clone(); pinned_snapshot.id = TEST_PROFILE.to_owned(); assert_eq!(restored_host_profile(&pinned_snapshot), None); - let selected = select_on_host(TEST_PROFILE, &host, None).unwrap(); - assert!(!selected.is_host()); - assert_eq!(selected.record(), pinned); + let pinned_profile = select(TEST_PROFILE, &host, None).unwrap(); + assert!(!pinned_profile.is_host()); + assert_eq!(pinned_profile.record(), pinned); + } + + /// A snapshot of a cold boot whose `auto` fell back records the host + /// profile, which a restore takes from the snapshot, as it takes any + /// host profile's, with any test hooks: a restore never falls back, and + /// serves only the capture host's CPU model and stepping. + #[test] + fn restore_takes_a_fallback_profile_from_the_snapshot() { + let host = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS); + let fingerprint = test_fingerprint("kvm", GRANITE_RAPIDS, GRANITE_RAPIDS_BRAND); + let fallback = fall_back(&host, || Ok(fingerprint)).unwrap(); + let record = host_profile_record(&fallback, GRANITE_RAPIDS); + assert_eq!(fallback.record().encoding, record.profile.as_slice()); + assert_eq!( + fallback.record().digest.as_slice(), + record.sha256.as_slice() + ); + assert_eq!(restored_host_profile(&record), Some(record.clone())); + for hooks in [&NO_HOOKS, &HOST_CPU_UNKNOWN] { + let restored = + selected(select_on_host(fallback.id(), &host, Some(&record), hooks)).unwrap(); + assert!(restored.is_host()); + assert_eq!(*restored, *fallback); + assert_eq!(restored.record(), fallback.record()); + + let stepping_2 = HostCpuSignature::new(*b"GenuineIntel", GRANITE_RAPIDS + 1); + assert_eq!( + selected(select_on_host( + fallback.id(), + &stepping_2, + Some(&record), + hooks + )) + .unwrap_err() + .code, + ProfileErrorCode::CpuGeneration + ); + } } } diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 2d4de42fb78..124331e0add 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -531,8 +531,10 @@ pub struct MicrovmCli { pub microvm_control_auth_timeout_ms: u64, /// microVM CPU profile: a pinned profile ID, `auto` (the default) to - /// select the host's profile, or `host` to derive a development profile - /// from this host; a restore must name the snapshot's profile + /// select the host's profile, falling back with a warning to a development + /// profile derived from this host where no pinned profile serves its CPU, + /// or `host` to derive that development profile; a restore must name the + /// snapshot's profile #[clap(long = "cpu-profile", value_name = "ID", hide = true)] pub cpu_profile: Option, diff --git a/vmm_core/cpu_profile/src/catalog.rs b/vmm_core/cpu_profile/src/catalog.rs index 6a371dc22e3..aec93046179 100644 --- a/vmm_core/cpu_profile/src/catalog.rs +++ b/vmm_core/cpu_profile/src/catalog.rs @@ -28,9 +28,12 @@ pub const AUTO: &str = "auto"; /// The `--cpu-profile` value that selects a host profile: a profile that the /// VM worker derives from the backend's fingerprint of this host /// ([`derive_host_profile`](crate::derive::derive_host_profile)) and that -/// only its partition holds ([`PartitionProfile::host`]). It is opt-in, for -/// development hosts that no pinned profile serves: [`AUTO`] never selects -/// it. +/// only its partition holds ([`PartitionProfile::host`]). It is for +/// development hosts that no pinned profile serves. [`select`] never selects +/// it, but the VM worker falls back to it from [`AUTO`] on a CPU that no +/// pinned profile serves ([`pinned_profile_serves`]) and that host profiles +/// serve ([`supports_host_profiles`](crate::derive::supports_host_profiles)), +/// and warns. pub const HOST: &str = "host"; /// Each pinned profile, built from its static data on first use, so that a @@ -230,6 +233,17 @@ pub fn generation_of(host: &HostCpuSignature) -> Option<&'static str> { .map(|profile| profile.generation().name.as_str()) } +/// Returns whether a pinned profile serves the host CPU: whether the +/// generation of any pinned profile covers it. Unlike [`generation_of`], it +/// holds for a CPU that profiles of more than one generation cover, which +/// [`select_auto`] rejects as a catalog defect. The VM worker falls back from +/// [`AUTO`] to a host profile only on a CPU that no pinned profile serves. +/// Unlike [`select_auto`], which builds the profiles that cover the host, it +/// builds no profile: it reads only the pinned entries' generations. +pub fn pinned_profile_serves(host: &HostCpuSignature) -> bool { + PINNED.iter().any(|pinned| covers(pinned.generation, host)) +} + /// Selects the profile of the host CPU's generation: its latest pinned /// revision. Only the profiles whose generation covers the host are built. /// @@ -303,8 +317,9 @@ fn select_auto_in<'a>( /// Selects the pinned profile for `--cpu-profile spec`, [`AUTO`] or a pinned /// ID, and checks that the host CPU is in its generation. A host profile has -/// no ID to select it by: the VM worker derives it for [`HOST`], or takes a -/// restored snapshot's ([`PartitionProfile::host`]). +/// no ID to select it by: the VM worker derives it for [`HOST`], and for +/// [`AUTO`] on a CPU that no pinned profile serves, or takes a restored +/// snapshot's ([`PartitionProfile::host`]). /// /// Fails with `E_PROFILE_HOST_UNKNOWN`, `E_PROFILE_UNKNOWN` for an ID that is /// not pinned, or `E_CPU_GENERATION`. @@ -557,6 +572,7 @@ mod tests { assert_eq!(select_auto(&host).unwrap().id(), id); assert_eq!(select(AUTO, &host).unwrap().id(), id); assert_eq!(generation_of(&host), Some(generation)); + assert!(pinned_profile_serves(&host), "{host}"); } for host in [ intel(CASCADE_LAKE), @@ -572,6 +588,7 @@ mod tests { ProfileErrorCode::ProfileHostUnknown ); assert_eq!(generation_of(&host), None); + assert!(!pinned_profile_serves(&host), "{host}"); } let error = select_auto(&intel(CASCADE_LAKE)).unwrap_err(); assert!( diff --git a/vmm_core/cpu_profile/src/derive.rs b/vmm_core/cpu_profile/src/derive.rs index 09a49f4c8dd..fa8a50be069 100644 --- a/vmm_core/cpu_profile/src/derive.rs +++ b/vmm_core/cpu_profile/src/derive.rs @@ -352,14 +352,16 @@ pub fn derive_profile( } /// Returns whether host profiles serve the CPU `host`, so that -/// `--cpu-profile host` can boot on it: [`derive_host_profile`] derives them -/// for the CPUs of every vendor that profiles serve, Intel's and AMD's. +/// `--cpu-profile host`, and `auto` on a CPU that no pinned profile serves, +/// can boot on it: [`derive_host_profile`] derives them for the CPUs of every +/// vendor that profiles serve, Intel's and AMD's. pub fn supports_host_profiles(host: &HostCpuSignature) -> bool { CpuVendor::from_cpuid_vendor(&host.vendor()).is_some() } /// Derives the host profile of the host and backend that `fingerprint` -/// describes, for `--cpu-profile host`: the profile that the derivation +/// describes, for `--cpu-profile host` and for `auto`'s fallback on a CPU +/// that no pinned profile serves: the profile that the derivation /// policy gives for this one fingerprint, with the ID /// `.host.v1`, the generation [`HOST_GENERATION`] limited to the /// host's family, model, and stepping, and a generic brand string of the diff --git a/vmm_core/cpu_profile/src/lib.rs b/vmm_core/cpu_profile/src/lib.rs index 7d586ef80d6..687096ff9ee 100644 --- a/vmm_core/cpu_profile/src/lib.rs +++ b/vmm_core/cpu_profile/src/lib.rs @@ -14,13 +14,15 @@ //! leaves; OpenVMM builds it in `virt::time_abi::cpuid::effective_cpuid`. //! //! - **Selection:** [`select`] maps `--cpu-profile ` and the host -//! CPU ([`HostCpuSignature`]) to a pinned profile. `--cpu-profile host` -//! ([`HOST`]) is the opt-in exception, for development hosts that no -//! pinned profile serves ([`supports_host_profiles`]): the VM worker -//! derives a host profile from the backend's fingerprint of this host -//! ([`derive_host_profile`]). Either way, the partition holds its profile -//! in a [`PartitionProfile`], which core hands to the backend, so each VM -//! of a process can have its own host profile. +//! CPU ([`HostCpuSignature`]) to a pinned profile. A host profile serves a +//! development host instead: the VM worker derives one from the backend's +//! fingerprint of this host ([`derive_host_profile`]) for +//! `--cpu-profile host` ([`HOST`]), and falls back to one from `auto` on a +//! CPU that no pinned profile serves ([`pinned_profile_serves`]) and that +//! host profiles serve ([`supports_host_profiles`]), with a warning. +//! Either way, the partition holds its profile in a [`PartitionProfile`], +//! which core hands to the backend, so each VM of a process can have its +//! own host profile. //! - **Verification:** each backend reports the CPU surface it supports //! ([`HostCpuSurface`]), and [`verify_support`] checks that it covers the //! profile. No CPUID entry outside the profile's tables carries host data: @@ -88,6 +90,7 @@ pub use catalog::host_profile_for_restore; pub use catalog::is_host_profile_id; pub use catalog::pinned; pub use catalog::pinned_for_restore; +pub use catalog::pinned_profile_serves; pub use catalog::pinned_profiles; pub use catalog::pinned_record; pub use catalog::select; diff --git a/vmm_core/virt/src/time_abi/backend.rs b/vmm_core/virt/src/time_abi/backend.rs index 2721eb20c90..13e9f08887a 100644 --- a/vmm_core/virt/src/time_abi/backend.rs +++ b/vmm_core/virt/src/time_abi/backend.rs @@ -269,6 +269,7 @@ mod tests { downtime_add_s: 0, utc_offset_ms: 0, sample_delay_us: 0, + host_cpu_unknown: false, }; #[test] diff --git a/vmm_core/virt/src/time_abi/mod.rs b/vmm_core/virt/src/time_abi/mod.rs index 8acd2bb5cde..ecab204d9c9 100644 --- a/vmm_core/virt/src/time_abi/mod.rs +++ b/vmm_core/virt/src/time_abi/mod.rs @@ -255,6 +255,11 @@ pub struct TimeAbiTestHooks { /// microseconds. #[mesh(6)] pub sample_delay_us: u32, + /// On a cold boot with `--cpu-profile auto`, treat the host CPU as one + /// that no pinned CPU profile serves, so that `auto` falls back to a host + /// profile on a host that a pinned profile serves. + #[mesh(7)] + pub host_cpu_unknown: bool, } /// The largest accepted `dest-rate-offset-ppm` magnitude. @@ -306,11 +311,14 @@ impl TimeAbiTestHooks { } seen.push(name); match name { - "force-utc-downtime" | "boot-id-mismatch" if argument.is_some() => { + "force-utc-downtime" | "boot-id-mismatch" | "host-cpu-unknown" + if argument.is_some() => + { return Err(invalid(value, "the hook takes no value")); } "force-utc-downtime" => hooks.force_utc_downtime = true, "boot-id-mismatch" => hooks.boot_id_mismatch = true, + "host-cpu-unknown" => hooks.host_cpu_unknown = true, "dest-rate-offset-ppm" => { hooks.dest_rate_offset_ppm = number( value, @@ -561,6 +569,7 @@ mod tests { "downtime-add-s=2592001", "utc-offset-ms=-5000", "sample-delay-us=3000", + "host-cpu-unknown", ]) .unwrap(); assert_eq!( @@ -572,10 +581,15 @@ mod tests { downtime_add_s: 2_592_001, utc_offset_ms: -5000, sample_delay_us: 3000, + host_cpu_unknown: true, } ); assert!(hooks.active()); assert!(!TimeAbiTestHooks::parse::<&str>(&[]).unwrap().active()); + // The CPU profile hook alone is a test hook too. + let host_cpu_unknown = TimeAbiTestHooks::parse(&["host-cpu-unknown"]).unwrap(); + assert!(host_cpu_unknown.host_cpu_unknown); + assert!(host_cpu_unknown.active()); } #[test] @@ -583,6 +597,8 @@ mod tests { for value in [ "unknown", "force-utc-downtime=1", + "host-cpu-unknown=1", + "host-cpu-unknown=", "dest-rate-offset-ppm", "dest-rate-offset-ppm=", "dest-rate-offset-ppm=x", From f12dcf5e8f2ea60cc15899dd16852320530670bd Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Mon, 5 Oct 2026 10:49:10 -0700 Subject: [PATCH 298/304] Permit RAM-backed microVM overlays without scratch Allow exactly one read-only distro block with the explicit nvx_overlay_upper=ramfs kernel token, and reject any other nvx_overlay_upper token, including a bare or repeated one. Keep existing scratch layouts unchanged and reject snapshot or restore for the scratchless profile. Document the layout and its restrictions in the Guide. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 11 +- Guide/src/user_guide/openvmm/run.md | 17 +- openvmm/openvmm_defs/src/microvm.rs | 206 +++++++++++++----- openvmm/openvmm_entry/src/cli_args/microvm.rs | 100 ++++++++- 4 files changed, 271 insertions(+), 63 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 6c063e3751c..02e054b5d68 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -74,7 +74,16 @@ describes the source definitions. `microvm` uses one socket and one die, with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. Guest-requested snapshot capture and new-process restore are available for - blockless and fixed-block machines on Linux/KVM, Linux/MSHV, and Windows/WHP. + blockless and fixed-block machines on Linux/KVM, Linux/MSHV, and Windows/WHP, + except for the RAM-backed overlay layout. + + A non-empty `--microvm-sandbox-block` topology must end with the writable + `scratch` block unless the command line carries exactly one + `nvx_overlay_upper=ramfs` token. That RAM-backed overlay layout, for a guest + that keeps its overlay upper in RAM, requires exactly one read-only `distro` + block and no other block, and it rejects `--snapshot-destination` and + `--restore-snapshot`. Any other `nvx_overlay_upper` token, including a bare + or repeated one, is rejected. ```admonish warning title="microVM migration" The canonical `microvm` spelling now selects the contract formerly exposed diff --git a/Guide/src/user_guide/openvmm/run.md b/Guide/src/user_guide/openvmm/run.md index 3b833300fb7..dfa3d97fd4a 100644 --- a/Guide/src/user_guide/openvmm/run.md +++ b/Guide/src/user_guide/openvmm/run.md @@ -51,7 +51,8 @@ locations: Use `--microvm-sandbox-block ROLE:DISK`, in the order shown. Lower-layer roles require the normal disk `,ro` option and a non-empty topology must end -with `scratch`; ordinary `--virtio-blk` is intentionally rejected. +with `scratch`, except for the RAM-backed overlay layout described below; +ordinary `--virtio-blk` is intentionally rejected. For example: ```shell @@ -69,6 +70,20 @@ accepts the read-only layer arguments again and creates a private scratch copy from that artifact. A pre-mount request may select fresh-scratch policy instead, in which case restore requires a new writable scratch file of matching size. +A guest that keeps its overlay upper in RAM can boot from one read-only image +without scratch. Pass exactly one `nvx_overlay_upper=ramfs` token on the kernel +command line and exactly one read-only `distro` block: + +```shell +openvmm --machine microvm --kernel vmlinux --initrd initramfs.cpio.gz \ + --cmdline "nvx_overlay_upper=ramfs" \ + --microvm-sandbox-block distro:file:distro.erofs,ro +``` + +OpenVMM rejects any other `nvx_overlay_upper` token, including a bare or +repeated one, and any other block in this layout. The layout supports neither +`--snapshot-destination` nor `--restore-snapshot`. + ### microVM deterministic SMP `--machine microvm --processors N` selects the microVM machine. diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 1a942e9eb42..be459da22cc 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -819,7 +819,25 @@ fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { Ok(()) } -fn validate_microvm_sandbox_blocks(blocks: &[MicrovmSandboxBlockConfig]) -> anyhow::Result<()> { +/// Returns whether the guest explicitly requests a RAM-backed overlay upper. +/// The opt-in permits a read-only distro block without a writable scratch block. +pub fn ramfs_overlay_requested(cmdline: &str) -> anyhow::Result { + // A bare key has no value, so it fails like any value other than ramfs. + let mut values = cmdline.split_ascii_whitespace().filter_map(|token| { + let (name, value) = token.split_once('=').unwrap_or((token, "")); + (name == "nvx_overlay_upper").then_some(value) + }); + match (values.next(), values.next()) { + (None, None) => Ok(false), + (Some("ramfs"), None) => Ok(true), + _ => anyhow::bail!("microVM nvx_overlay_upper must appear once and equal ramfs"), + } +} + +fn validate_microvm_sandbox_blocks( + blocks: &[MicrovmSandboxBlockConfig], + ramfs_overlay: bool, +) -> anyhow::Result<()> { anyhow::ensure!( blocks.len() <= MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES.len(), "microVM permits at most three read-only layers and one writable scratch device" @@ -842,7 +860,14 @@ fn validate_microvm_sandbox_blocks(blocks: &[MicrovmSandboxBlockConfig]) -> anyh ); } } - if !blocks.is_empty() { + if ramfs_overlay { + anyhow::ensure!( + blocks.len() == 1 + && blocks[0].role == MicrovmSandboxBlockRole::Distro + && blocks[0].read_only, + "microVM RAM-backed overlay requires exactly one read-only distro block" + ); + } else if !blocks.is_empty() { anyhow::ensure!( blocks .last() @@ -881,7 +906,7 @@ pub fn append_microvm_virtio_discovery( has_control_console: bool, blocks: &[MicrovmSandboxBlockConfig], ) -> anyhow::Result<()> { - validate_microvm_sandbox_blocks(blocks)?; + validate_microvm_sandbox_blocks(blocks, ramfs_overlay_requested(cmdline)?)?; anyhow::ensure!( !cmdline.split_ascii_whitespace().any(|token| { if has_control_console { @@ -1040,7 +1065,10 @@ fn validate_microvm_command_line( has_network == config.microvm.network.is_some(), "microVM virtio-net device and static network identity must be configured together" ); - validate_microvm_sandbox_blocks(&config.microvm.sandbox_blocks)?; + validate_microvm_sandbox_blocks( + &config.microvm.sandbox_blocks, + ramfs_overlay_requested(cmdline)?, + )?; anyhow::ensure!( block_count == config.microvm.sandbox_blocks.len(), "microVM sandbox block roles do not match the virtio-blk device inventory" @@ -1666,7 +1694,6 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> !has_control_console || has_console, "microVM control console requires the boot console" ); - validate_microvm_sandbox_blocks(&config.microvm.sandbox_blocks)?; anyhow::ensure!( block_count == config.microvm.sandbox_blocks.len(), "microVM sandbox block roles do not match the virtio-blk device inventory" @@ -1881,7 +1908,7 @@ mod tests { read_only: false, }, ]; - validate_microvm_sandbox_blocks(&blocks).unwrap(); + validate_microvm_sandbox_blocks(&blocks, false).unwrap(); let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); append_microvm_virtio_discovery(&mut cmdline, None, false, &[], false, false, &blocks) @@ -2273,69 +2300,128 @@ mod tests { #[test] fn microvm_sandbox_block_validation_rejects_invalid_layouts() { assert!( - validate_microvm_sandbox_blocks(&[MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Scratch, - read_only: true, - }]) + validate_microvm_sandbox_blocks( + &[MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: true, + }], + false + ) .is_err() ); assert!( - validate_microvm_sandbox_blocks(&[ - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Runtime, - read_only: true, - }, - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Distro, - read_only: true, - }, - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Scratch, - read_only: false, - }, - ]) + validate_microvm_sandbox_blocks( + &[ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Runtime, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ], + false + ) .is_err() ); assert!( - validate_microvm_sandbox_blocks(&[ - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Distro, - read_only: true, - }, - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Distro, - read_only: true, - }, - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Scratch, - read_only: false, - }, - ]) + validate_microvm_sandbox_blocks( + &[ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ], + false + ) .is_err() ); assert!( - validate_microvm_sandbox_blocks(&[ - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Distro, - read_only: true, - }, - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Runtime, - read_only: true, - }, - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Custom, - read_only: true, - }, - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Scratch, - read_only: false, - }, - MicrovmSandboxBlockConfig { - role: MicrovmSandboxBlockRole::Scratch, - read_only: false, - }, - ]) + validate_microvm_sandbox_blocks( + &[ + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Runtime, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Custom, + read_only: true, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }, + ], + false + ) + .is_err() + ); + } + + #[test] + fn ramfs_overlay_allows_only_a_read_only_distro_without_scratch() { + let distro = MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Distro, + read_only: true, + }; + let scratch = MicrovmSandboxBlockConfig { + role: MicrovmSandboxBlockRole::Scratch, + read_only: false, + }; + assert!(!ramfs_overlay_requested("quiet").unwrap()); + assert!(ramfs_overlay_requested("quiet nvx_overlay_upper=ramfs").unwrap()); + assert!(ramfs_overlay_requested("nvx_overlay_upper=disk").is_err()); + assert!(ramfs_overlay_requested("nvx_overlay_upper=").is_err()); + assert!(ramfs_overlay_requested("quiet nvx_overlay_upper").is_err()); + assert!( + ramfs_overlay_requested("nvx_overlay_upper=ramfs nvx_overlay_upper=ramfs").is_err() + ); + assert!(ramfs_overlay_requested("nvx_overlay_upper=ramfs nvx_overlay_upper").is_err()); + assert!(ramfs_overlay_requested("nvx_overlay_upper nvx_overlay_upper=ramfs").is_err()); + assert!(validate_microvm_sandbox_blocks(&[distro], false).is_err()); + validate_microvm_sandbox_blocks(&[distro], true).unwrap(); + assert!(validate_microvm_sandbox_blocks(&[], true).is_err()); + assert!(validate_microvm_sandbox_blocks(&[scratch], true).is_err()); + assert!(validate_microvm_sandbox_blocks(&[distro, scratch], true).is_err()); + + let mut cmdline = format!("{MICROVM_BASE_COMMAND_LINE} nvx_overlay_upper=ramfs"); + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], false, false, &[distro]) + .unwrap(); + assert!(cmdline.contains("virtio_mmio.device=0x1000@0xd0003000:4")); + assert!(!cmdline.contains("virtio_mmio.device=0x1000@0xd0006000:11")); + + let mut cmdline = format!("{MICROVM_BASE_COMMAND_LINE} nvx_overlay_upper"); + assert!( + append_microvm_virtio_discovery( + &mut cmdline, + None, + false, + &[], + false, + false, + &[distro, scratch] + ) .is_err() ); } diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 124331e0add..5c9b15f32a3 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -684,6 +684,14 @@ impl Options { cfg!(guest_arch = "x86_64"), "microVM requires an x86-64 guest" ); + let ramfs_overlay = + openvmm_defs::microvm::ramfs_overlay_requested(&self.cmdline.join(" "))?; + if ramfs_overlay { + anyhow::ensure!( + self.microvm.snapshot_destination.is_none() && self.restore_snapshot.is_none(), + "microVM RAM-backed overlay does not support snapshot or restore" + ); + } anyhow::ensure!( openvmm_defs::microvm::microvm_processor_count_supported(self.processors), "microVM does not support {} vCPUs", @@ -1006,7 +1014,16 @@ impl Options { ); } } - if !self.microvm.microvm_sandbox_block.is_empty() && self.restore_snapshot.is_none() { + if ramfs_overlay { + anyhow::ensure!( + self.microvm.microvm_sandbox_block.len() == 1 + && self.microvm.microvm_sandbox_block[0].role + == MicrovmSandboxBlockRole::Distro + && self.microvm.microvm_sandbox_block[0].disk.read_only, + "microVM RAM-backed overlay requires exactly one read-only distro block" + ); + } else if !self.microvm.microvm_sandbox_block.is_empty() && self.restore_snapshot.is_none() + { anyhow::ensure!( self.microvm .microvm_sandbox_block @@ -1450,6 +1467,87 @@ mod tests { } } + #[test] + fn test_microvm_ramfs_overlay_requires_one_distro_and_no_snapshot() { + let valid = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "quiet nvx_overlay_upper=ramfs", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + ]) + .unwrap(); + valid.validate_microvm_options().unwrap(); + + for args in [ + vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper=ramfs", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper=ramfs nvx_overlay_upper=ramfs", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper=ramfs nvx_overlay_upper", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--microvm-sandbox-block", + "scratch:mem:1M", + ], + vec![ + "openvmm", + "--machine", + "microvm", + "--cmdline", + "nvx_overlay_upper=ramfs", + "--microvm-sandbox-block", + "distro:mem:1M,ro", + "--snapshot-destination", + "snapshot", + "--snapshot-tier", + "workload-start", + ], + ] { + let options = Options::try_parse_from(args).unwrap(); + assert!(options.validate_microvm_options().is_err()); + } + } + #[test] fn test_microvm_snapshot_tier_is_explicit() { for tier in ["platform", "workload-start", "instance-checkpoint"] { From 3796991d52ce07d88b5dba25e4d1e8deaff6fd95 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 9 Oct 2026 10:16:28 -0300 Subject: [PATCH 299/304] net_backend_resources: select egress rules by protocol without a port microVM L3/L4 egress rules took two forms: IPv4[/PREFIX], which matches every IPv4 protocol, and IPv4[/PREFIX]:tcp|udp:PORT, which matches one TCP or UDP destination port. A rule could not select one protocol on every port, nor ICMP at all, so policies such as "allow TCP to 192.0.2.0/24 on any port" or "deny ICMP to 192.0.2.7" could not be expressed (microsoft/nvx#277). EgressRule now also parses IPv4[/PREFIX]:tcp, IPv4[/PREFIX]:udp, and IPv4[/PREFIX]:icmp. A TCP or UDP rule without a port matches every destination port of its protocol. An ICMP rule matches every ICMP message and takes no port: IPv4[/PREFIX]:icmp:PORT fails with the new ParseEgressRuleError::IcmpPort. EgressTransport gains Icmp, and EgressRule::port is zero for every rule without a port. "any" is not a native protocol: a caller expresses any protocol on one port as one TCP rule and one UDP rule. A rule matches a packet whose destination its prefix contains and, unless the rule is address-only, whose IPv4 protocol is the rule's and, for a port-specific rule, whose TCP or UDP destination port is the rule's. Deny rules still take precedence over allow rules, and the default action still decides the traffic that matches neither list. Every IPv4 fragment carries its protocol, but later fragments carry no transport header. Rules without a port therefore classify fragments by their protocol, and only a port-specific rule still makes a policy reject every fragment, as any TCP or UDP rule did before. Policies of address-only and port-specific rules behave as before, and ICMP and other protocols match only address-only rules unless an ICMP rule selects them. allows_gateway_dns counts a protocol-wide TCP or UDP rule for the gateway as it counts a rule for port 53, and does not count an ICMP rule. ARP for a destination stays denied only by an address-only deny rule, because a protocol-wide deny rule leaves the destination's other protocols reachable. The canonical encoding gives ICMP the transport byte 3 and port-free TCP and UDP rules the port 0, so the snapshot policy digest tells every new rule apart, while the bytes of existing policies, and their encoding version, are unchanged. openvmm_entry's --network-egress-allow and --network-egress-deny document the new forms. The CLI's L3/L4 rule test was nested inside another test function and never ran; it now runs, beside a new test of the protocol-wide forms and of the forms that are rejected before VM resources are opened. Unit tests cover the parser's new forms and errors, each protocol selector as an allow rule and as a deny rule that overrides an allow rule, a protocol-wide rule composed with a port rule in both directions, fragments under port-free and port-specific policies, the canonical encoding of each form, and the gateway DNS and ARP decisions. The Guide documents the forms in the CLI reference and on the Consomme backend page. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/backends/consomme.md | 5 +- Guide/src/reference/openvmm/management/cli.md | 16 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 222 ++++++--- .../net/net_backend_resources/src/egress.rs | 437 +++++++++++++++--- 4 files changed, 538 insertions(+), 142 deletions(-) diff --git a/Guide/src/reference/backends/consomme.md b/Guide/src/reference/backends/consomme.md index 1d3067f5130..51112d384ac 100644 --- a/Guide/src/reference/backends/consomme.md +++ b/Guide/src/reference/backends/consomme.md @@ -54,8 +54,9 @@ allowed guest-initiated flow are not treated as new inbound connections. Ingress `allow` is rejected before VM resources are opened because portable NAT does not expose arbitrary guest listeners. -The generic egress rule form accepts destination IPv4 addresses or CIDRs with -an optional TCP or UDP destination port. Deny rules take precedence over allow +The generic egress rule form accepts destination IPv4 addresses or CIDRs, +optionally restricted to TCP, UDP, or ICMP, and for TCP or UDP optionally to +one destination port. Deny rules take precedence over allow rules, and the explicit directional default handles traffic that matches neither list. Filtering and malformed-packet rejection occur before Consomme creates a host socket. Port-specific policies reject IPv4 fragments rather diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 02e054b5d68..4764291e77f 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -146,15 +146,19 @@ describes the source definitions. rule combinations are rejected at the same validation boundary. `--network-egress-allow ` and `--network-egress-deny ` provide - the generic L3/L4 policy form. A rule is either `IPv4[/PREFIX]` or - `IPv4[/PREFIX]:tcp:PORT` / `IPv4[/PREFIX]:udp:PORT`. These flags require an + the generic L3/L4 policy form. A rule is `IPv4[/PREFIX]`, which matches + every IPv4 protocol; `IPv4[/PREFIX]:tcp`, `IPv4[/PREFIX]:udp`, or + `IPv4[/PREFIX]:icmp`, which match one protocol on every destination port; or + `IPv4[/PREFIX]:tcp:PORT` / `IPv4[/PREFIX]:udp:PORT`, which match one TCP or + UDP destination port. ICMP rules take no port. These flags require an explicit `--network-egress` default, accept at most 256 rules in each list, and cannot be mixed with the legacy `--allow-host`, `--block-host`, or `--allow-endpoint` forms. Deny rules are evaluated before allow rules. - Address-only rules apply to every IPv4 protocol; port-specific policies - reject fragmented IPv4 traffic because later fragments do not carry a - verifiable transport header. Parsing, canonicalization, and contradictory - option checks complete before VM resources are opened. + Address-only rules apply to every IPv4 protocol, and protocol rules apply to + every fragment of their protocol; policies with a port-specific rule reject + fragmented IPv4 traffic because later fragments do not carry a verifiable + transport header. Parsing, canonicalization, and contradictory option checks + complete before VM resources are opened. `--host-loopback ` controls host-local access. The portable profile cannot provide generic bidirectional host-loopback connectivity: diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 5c9b15f32a3..f50abb433fe 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -376,17 +376,18 @@ pub struct MicrovmCli { #[clap(long, value_enum, value_name = "ACTION")] pub network_ingress: Option, - /// Permit matching IPv4 destinations, optionally restricted by TCP/UDP port. + /// Permit matching IPv4 destinations, optionally restricted to TCP, UDP, + /// or ICMP, and for TCP or UDP optionally to one destination port. #[clap( long = "network-egress-allow", - value_name = "IPv4[/PREFIX][:tcp|udp:PORT]" + value_name = "IPv4[/PREFIX][:tcp[:PORT]|:udp[:PORT]|:icmp]" )] pub network_egress_allow: Vec, /// Deny matching IPv4 destinations before evaluating allow rules. #[clap( long = "network-egress-deny", - value_name = "IPv4[/PREFIX][:tcp|udp:PORT]" + value_name = "IPv4[/PREFIX][:tcp[:PORT]|:udp[:PORT]|:icmp]" )] pub network_egress_deny: Vec, @@ -2252,6 +2253,150 @@ mod tests { assert!(standard.validate_microvm_options().is_err()); } + #[test] + fn test_microvm_l3_l4_rules_are_explicit_and_composable() { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-egress-allow", + "192.0.2.0/24:tcp:443", + "--network-egress-allow", + "192.0.2.7:udp:53", + "--network-egress-deny", + "192.0.2.9:tcp:443", + ]) + .unwrap(); + options.validate_microvm_options().unwrap(); + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = options.microvm_egress_policy(&network).unwrap(); + let net_backend_resources::egress::EgressPolicyMode::Rules { + default_action, + allow, + deny, + } = policy.mode() + else { + panic!("expected rule-based egress policy") + }; + assert_eq!( + *default_action, + net_backend_resources::egress::EgressAction::Deny + ); + assert_eq!((allow.len(), deny.len()), (2, 1)); + + let missing_default = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress-allow", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(missing_default.validate_microvm_options().is_err()); + + let mixed_legacy = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--network-egress-allow", + "192.0.2.0/24", + "--allow-host", + "192.0.2.0/24", + ]) + .unwrap(); + assert!(mixed_legacy.validate_microvm_options().is_err()); + } + + #[test] + fn test_microvm_protocol_wide_egress_rules_parse_before_resources() { + use net_backend_resources::egress::EgressRule; + use net_backend_resources::egress::EgressTransport; + + let network_options = [ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + ]; + let options = Options::try_parse_from(network_options.into_iter().chain([ + "--network-egress-allow", + "198.51.100.0/24:udp", + "--network-egress-allow", + "198.51.100.0/24:tcp", + "--network-egress-allow", + "198.51.100.0/24:icmp", + "--network-egress-deny", + "198.51.100.7:tcp:443", + "--network-egress-deny", + "198.51.100.7:icmp", + ])) + .unwrap(); + options.validate_microvm_options().unwrap(); + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = options.microvm_egress_policy(&network).unwrap(); + let net_backend_resources::egress::EgressPolicyMode::Rules { allow, deny, .. } = + policy.mode() + else { + panic!("expected rule-based egress policy") + }; + let selectors = |rules: &[EgressRule]| { + rules + .iter() + .map(|rule| (rule.transport(), rule.port())) + .collect::>() + }; + assert_eq!( + selectors(allow), + [ + (EgressTransport::Tcp, 0), + (EgressTransport::Udp, 0), + (EgressTransport::Icmp, 0), + ] + ); + assert_eq!( + selectors(deny), + [(EgressTransport::Tcp, 443), (EgressTransport::Icmp, 0)] + ); + + for rule in [ + "192.0.2.1:icmp:8", + "192.0.2.1:any", + "192.0.2.1:any:443", + "192.0.2.1:tcp:0", + ] { + assert!( + Options::try_parse_from( + network_options + .into_iter() + .chain(["--network-egress-allow", rule]) + ) + .is_err(), + "{rule}" + ); + } + } + #[test] fn test_microvm_workload_identity_is_non_root_and_not_restorable() { for identity in ["0:1", "1:0", "root:1", "1:root", "1", "1:2:3"] { @@ -2267,77 +2412,6 @@ mod tests { ); } - #[test] - fn test_microvm_l3_l4_rules_are_explicit_and_composable() { - let options = Options::try_parse_from([ - "openvmm", - "--machine", - "microvm", - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-egress-allow", - "192.0.2.0/24:tcp:443", - "--network-egress-allow", - "192.0.2.7:udp:53", - "--network-egress-deny", - "192.0.2.9:tcp:443", - ]) - .unwrap(); - options.validate_microvm_options().unwrap(); - let network: openvmm_defs::microvm::MicrovmNetworkConfig = - "10.0.0.2/24".parse().unwrap(); - let policy = options.microvm_egress_policy(&network).unwrap(); - let net_backend_resources::egress::EgressPolicyMode::Rules { - default_action, - allow, - deny, - } = policy.mode() - else { - panic!("expected rule-based egress policy") - }; - assert_eq!( - *default_action, - net_backend_resources::egress::EgressAction::Deny - ); - assert_eq!((allow.len(), deny.len()), (2, 1)); - - let missing_default = Options::try_parse_from([ - "openvmm", - "--machine", - "microvm", - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress-allow", - "192.0.2.0/24", - ]) - .unwrap(); - assert!(missing_default.validate_microvm_options().is_err()); - - let mixed_legacy = Options::try_parse_from([ - "openvmm", - "--machine", - "microvm", - "--net", - "10.0.0.2/24", - "--network-profile", - "portable", - "--network-egress", - "deny", - "--network-egress-allow", - "192.0.2.0/24", - "--allow-host", - "192.0.2.0/24", - ]) - .unwrap(); - assert!(mixed_legacy.validate_microvm_options().is_err()); - } - fn check_microvm_host_loopback_policy_and_explicit_forwards() { let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); diff --git a/vm/devices/net/net_backend_resources/src/egress.rs b/vm/devices/net/net_backend_resources/src/egress.rs index 1e5661c2458..d6d8d54222c 100644 --- a/vm/devices/net/net_backend_resources/src/egress.rs +++ b/vm/devices/net/net_backend_resources/src/egress.rs @@ -159,13 +159,16 @@ pub enum EgressAction { pub enum EgressTransport { /// Match every IPv4 protocol and port. Any, - /// Match one TCP destination port. + /// Match TCP on one destination port, or on every port. Tcp, - /// Match one UDP destination port. + /// Match UDP on one destination port, or on every port. Udp, + /// Match every ICMP message. + Icmp, } -/// A canonical IPv4 destination rule with an optional TCP or UDP port. +/// A canonical IPv4 destination rule with an optional protocol and TCP or UDP +/// port. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] pub struct EgressRule { destination: Ipv4Cidr, @@ -184,36 +187,52 @@ impl EgressRule { self.transport } - /// Returns the restricted port, or zero for an address-only rule. + /// Returns the restricted TCP or UDP destination port, or zero for a rule + /// without a port: an address-only rule, a TCP or UDP rule for every + /// port, or an ICMP rule. pub fn port(self) -> u16 { self.port } - fn matches(self, destination: Ipv4Addr, transport: Option<(EgressTransport, u16)>) -> bool { + fn matches(self, destination: Ipv4Addr, packet: Option) -> bool { if !self.destination.contains(destination) { return false; } match self.transport { EgressTransport::Any => true, - expected => { - transport.is_some_and(|(actual, port)| actual == expected && port == self.port) - } + expected => packet.is_some_and(|packet| { + packet.transport == expected && (self.port == 0 || packet.port == Some(self.port)) + }), } } } +/// The transport of one IPv4 packet, as far as rule evaluation can classify it. +#[derive(Clone, Copy)] +struct PacketTransport { + /// TCP, UDP, or ICMP, and never `Any`, which only rules use. + transport: EgressTransport, + /// Destination port of an unfragmented TCP or UDP packet. + port: Option, +} + /// Error returned when parsing an L3/L4 egress rule. #[derive(Debug, Error, PartialEq, Eq)] pub enum ParseEgressRuleError { - /// The rule did not use the supported address or address/protocol/port form. - #[error("expected or ::")] + /// The rule did not use a supported address, protocol, or port form. + #[error( + "expected , :, or ::" + )] InvalidFormat, /// The destination prefix is invalid. #[error(transparent)] InvalidDestination(#[from] ParseIpv4CidrError), - /// The transport is not TCP or UDP. - #[error("invalid egress transport '{0}'; expected tcp or udp")] + /// The transport is not TCP, UDP, or ICMP. + #[error("invalid egress transport '{0}'; expected tcp, udp, or icmp")] InvalidTransport(String), + /// ICMP has no destination ports to select. + #[error("ICMP egress rules do not take a port")] + IcmpPort, /// The port is not a `u16`. #[error("invalid egress port '{0}'")] InvalidPort(String), @@ -227,28 +246,38 @@ impl FromStr for EgressRule { fn from_str(value: &str) -> Result { let fields = value.split(':').collect::>(); - let (destination, transport, port) = match fields.as_slice() { - [destination] => (destination.parse()?, EgressTransport::Any, 0), - [destination, protocol, port] => { - let transport = match *protocol { - "tcp" => EgressTransport::Tcp, - "udp" => EgressTransport::Udp, - other => { - return Err(ParseEgressRuleError::InvalidTransport(other.to_owned())); - } - }; + let (destination, protocol, port) = match fields.as_slice() { + [destination] => (*destination, None, None), + [destination, protocol] => (*destination, Some(*protocol), None), + [destination, protocol, port] => (*destination, Some(*protocol), Some(*port)), + _ => return Err(ParseEgressRuleError::InvalidFormat), + }; + let transport = match protocol { + None => EgressTransport::Any, + Some("tcp") => EgressTransport::Tcp, + Some("udp") => EgressTransport::Udp, + Some("icmp") => EgressTransport::Icmp, + Some(other) => { + return Err(ParseEgressRuleError::InvalidTransport(other.to_owned())); + } + }; + let port = match port { + None => 0, + Some(_) if transport == EgressTransport::Icmp => { + return Err(ParseEgressRuleError::IcmpPort); + } + Some(port) => { let port = port .parse::() - .map_err(|_| ParseEgressRuleError::InvalidPort((*port).to_owned()))?; + .map_err(|_| ParseEgressRuleError::InvalidPort(port.to_owned()))?; if port == 0 { return Err(ParseEgressRuleError::ZeroPort); } - (destination.parse()?, transport, port) + port } - _ => return Err(ParseEgressRuleError::InvalidFormat), }; Ok(Self { - destination, + destination: destination.parse()?, transport, port, }) @@ -601,13 +630,17 @@ impl EgressPolicy { !rules.iter().any(|rule| rule.contains(self.gateway_ipv4)) } EgressPolicyMode::TcpEndpoints(_) => false, - EgressPolicyMode::Rules { .. } => { - self.rules_allow_destination(self.gateway_ipv4, Some((EgressTransport::Tcp, 53))) - || self.rules_allow_destination( + EgressPolicyMode::Rules { .. } => [EgressTransport::Tcp, EgressTransport::Udp] + .into_iter() + .any(|transport| { + self.rules_allow_destination( self.gateway_ipv4, - Some((EgressTransport::Udp, 53)), + Some(PacketTransport { + transport, + port: Some(53), + }), ) - } + }), } } @@ -625,7 +658,7 @@ impl EgressPolicy { fn rules_allow_destination( &self, destination: Ipv4Addr, - transport: Option<(EgressTransport, u16)>, + packet: Option, ) -> bool { let EgressPolicyMode::Rules { default_action, @@ -635,13 +668,10 @@ impl EgressPolicy { else { return false; }; - if deny.iter().any(|rule| rule.matches(destination, transport)) { + if deny.iter().any(|rule| rule.matches(destination, packet)) { return false; } - if allow - .iter() - .any(|rule| rule.matches(destination, transport)) - { + if allow.iter().any(|rule| rule.matches(destination, packet)) { return true; } *default_action == EgressAction::Allow @@ -671,14 +701,11 @@ impl EgressPolicy { *default_action == EgressAction::Allow } - fn rules_have_transport_restrictions(&self) -> bool { + fn rules_have_port_restrictions(&self) -> bool { matches!( &self.mode, EgressPolicyMode::Rules { allow, deny, .. } - if allow - .iter() - .chain(deny) - .any(|rule| rule.transport != EgressTransport::Any) + if allow.iter().chain(deny).any(|rule| rule.port != 0) ) } @@ -921,16 +948,16 @@ impl EgressPolicy { } } EgressPolicyMode::Rules { .. } => { - let transport = rule_transport( + let packet = rule_transport( frame, frame_length, offset, header_length, total_length, fragments, - self.rules_have_transport_restrictions(), + self.rules_have_port_restrictions(), )?; - if self.rules_allow_destination(destination, transport) { + if self.rules_allow_destination(destination, packet) { Ok(()) } else { Err(EgressDenied::DestinationDenied) @@ -1083,6 +1110,7 @@ fn append_rules(bytes: &mut Vec, rules: &[EgressRule]) { EgressTransport::Any => 0, EgressTransport::Tcp => 1, EgressTransport::Udp => 2, + EgressTransport::Icmp => 3, }); bytes.extend_from_slice(&rule.port.to_be_bytes()); } @@ -1173,8 +1201,8 @@ fn rule_transport( ip_header_length: usize, ip_total_length: usize, fragments: u16, - has_transport_restrictions: bool, -) -> Result, EgressDenied> { + has_port_restrictions: bool, +) -> Result, EgressDenied> { validate_transport( frame, frame_length, @@ -1183,19 +1211,20 @@ fn rule_transport( ip_total_length, fragments, )?; - if fragments != 0 { - return if has_transport_restrictions { - Err(EgressDenied::FragmentDenied) - } else { - Ok(None) - }; + // Later fragments carry no transport header, so only port-free rules can + // classify a fragment, by its IPv4 protocol. + if fragments != 0 && has_port_restrictions { + return Err(EgressDenied::FragmentDenied); } - let transport_offset = ip_offset + ip_header_length; - Ok(match frame[ip_offset + 9] { - 6 => Some((EgressTransport::Tcp, read_u16(frame, transport_offset + 2))), - 17 => Some((EgressTransport::Udp, read_u16(frame, transport_offset + 2))), - _ => None, - }) + let transport = match frame[ip_offset + 9] { + 1 => EgressTransport::Icmp, + 6 => EgressTransport::Tcp, + 17 => EgressTransport::Udp, + _ => return Ok(None), + }; + let port = (fragments == 0 && transport != EgressTransport::Icmp) + .then(|| read_u16(frame, ip_offset + ip_header_length + 2)); + Ok(Some(PacketTransport { transport, port })) } #[cfg(test)] @@ -1270,6 +1299,44 @@ mod tests { frame } + fn icmp_echo_frame(destination: Ipv4Addr) -> Vec { + let mut frame = vec![0u8; 14 + 20 + 8]; + frame[6..12].copy_from_slice(&GUEST_MAC_BYTES); + frame[12..14].copy_from_slice(&0x0800u16.to_be_bytes()); + let ip = &mut frame[14..]; + ip[0] = 0x45; + ip[2..4].copy_from_slice(&28u16.to_be_bytes()); + ip[6..8].copy_from_slice(&0x4000u16.to_be_bytes()); + ip[8] = 64; + ip[9] = 1; + ip[12..16].copy_from_slice(&GUEST_IPV4.octets()); + ip[16..20].copy_from_slice(&destination.octets()); + ip[20] = 8; + set_ipv4_checksum(&mut frame, 14); + frame + } + + /// Rewrites the IPv4 flags and fragment offset of `frame`. + fn fragmented(mut frame: Vec, flags_and_offset: u16) -> Vec { + frame[20..22].copy_from_slice(&flags_and_offset.to_be_bytes()); + set_ipv4_checksum(&mut frame, 14); + frame + } + + fn rule_policy(default_action: EgressAction, allow: &[&str], deny: &[&str]) -> EgressPolicy { + let parse = |rules: &[&str]| { + rules + .iter() + .map(|rule| rule.parse().unwrap()) + .collect::>() + }; + bind(EgressPolicyMode::Rules { + default_action, + allow: parse(allow), + deny: parse(deny), + }) + } + fn arp_request( target: Ipv4Addr, ethernet_destination: [u8; 6], @@ -1606,7 +1673,7 @@ mod tests { for invalid in [ "192.0.2.0/24:icmp:8", "192.0.2.0/24:tcp:0", - "192.0.2.0/24:tcp", + "192.0.2.0/24:any", "192.0.2.0/24:tcp:443:extra", ] { assert!(invalid.parse::().is_err(), "{invalid}"); @@ -1661,6 +1728,256 @@ mod tests { assert!(policy.validate().is_ok()); } + #[test] + fn protocol_wide_rules_parse_canonically() { + for (rule, transport) in [ + ("192.0.2.9/24:tcp", EgressTransport::Tcp), + ("192.0.2.9/24:udp", EgressTransport::Udp), + ("192.0.2.9/24:icmp", EgressTransport::Icmp), + ] { + assert_eq!( + rule.parse::(), + Ok(EgressRule { + destination: "192.0.2.0/24".parse().unwrap(), + transport, + port: 0, + }), + "{rule}" + ); + } + for (rule, error) in [ + ("192.0.2.0/24:icmp:8", ParseEgressRuleError::IcmpPort), + ("192.0.2.0/24:icmp:", ParseEgressRuleError::IcmpPort), + ( + "192.0.2.0/24:any", + ParseEgressRuleError::InvalidTransport("any".to_owned()), + ), + ( + "192.0.2.0/24:any:443", + ParseEgressRuleError::InvalidTransport("any".to_owned()), + ), + ( + "192.0.2.0/24:", + ParseEgressRuleError::InvalidTransport(String::new()), + ), + ( + "192.0.2.0/24:tcp:", + ParseEgressRuleError::InvalidPort(String::new()), + ), + ("192.0.2.0/24:udp:0", ParseEgressRuleError::ZeroPort), + ( + "192.0.2.0/24:udp:65536", + ParseEgressRuleError::InvalidPort("65536".to_owned()), + ), + ( + "192.0.2.0/24:tcp:443:extra", + ParseEgressRuleError::InvalidFormat, + ), + ] { + assert_eq!(rule.parse::(), Err(error), "{rule}"); + } + } + + #[test] + fn protocol_selectors_match_only_their_traffic_in_allow_and_deny_rules() { + let target = Ipv4Addr::new(192, 0, 2, 7); + let neighbor = Ipv4Addr::new(192, 0, 2, 8); + let probes = [ + ("tcp:443", tcp_frame(target, 443)), + ("tcp:8443", tcp_frame(target, 8443)), + ("udp:443", udp_frame(target, 443)), + ("udp:8443", udp_frame(target, 8443)), + ("icmp", icmp_echo_frame(target)), + ]; + let neighbors = [ + tcp_frame(neighbor, 443), + udp_frame(neighbor, 443), + icmp_echo_frame(neighbor), + ]; + // Each selector lists the probes it matches. NVX lowers protocol `any` + // with port 443 to the TCP and UDP rules of the last selector. + let selectors: [(&[&str], &[&str]); 5] = [ + (&["192.0.2.7:tcp"], &["tcp:443", "tcp:8443"]), + (&["192.0.2.7:udp"], &["udp:443", "udp:8443"]), + (&["192.0.2.7:icmp"], &["icmp"]), + ( + &["192.0.2.7"], + &["tcp:443", "tcp:8443", "udp:443", "udp:8443", "icmp"], + ), + ( + &["192.0.2.7:tcp:443", "192.0.2.7:udp:443"], + &["tcp:443", "udp:443"], + ), + ]; + for (selector, matched) in selectors { + let allow = rule_policy(EgressAction::Deny, selector, &[]); + // The deny rules must win over an allow rule that covers every probe. + let deny = rule_policy(EgressAction::Deny, &["192.0.2.0/24"], selector); + assert!(allow.validate().is_ok()); + assert!(deny.validate().is_ok()); + for (name, frame) in &probes { + let (allowed, denied) = if matched.contains(name) { + (Ok(()), Err(EgressDenied::DestinationDenied)) + } else { + (Err(EgressDenied::DestinationDenied), Ok(())) + }; + assert_eq!( + allow.authorize_frame(frame, frame.len()), + allowed, + "allow {selector:?}: {name}" + ); + assert_eq!( + deny.authorize_frame(frame, frame.len()), + denied, + "deny {selector:?}: {name}" + ); + } + for frame in &neighbors { + assert_eq!( + allow.authorize_frame(frame, frame.len()), + Err(EgressDenied::DestinationDenied), + "allow {selector:?}: neighbor" + ); + deny.authorize_frame(frame, frame.len()).unwrap(); + } + } + } + + #[test] + fn protocol_wide_and_port_rules_compose_with_deny_precedence() { + let target = Ipv4Addr::new(192, 0, 2, 7); + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.0/24:tcp"], + &["192.0.2.7:tcp:443"], + ); + let denied = tcp_frame(target, 443); + assert_eq!( + policy.authorize_frame(&denied, denied.len()), + Err(EgressDenied::DestinationDenied) + ); + let allowed = tcp_frame(target, 8443); + policy.authorize_frame(&allowed, allowed.len()).unwrap(); + let udp = udp_frame(target, 8443); + assert_eq!( + policy.authorize_frame(&udp, udp.len()), + Err(EgressDenied::DestinationDenied) + ); + + let policy = rule_policy( + EgressAction::Allow, + &["192.0.2.7:tcp:443"], + &["192.0.2.0/24:tcp"], + ); + for port in [443, 8443] { + let tcp = tcp_frame(target, port); + assert_eq!( + policy.authorize_frame(&tcp, tcp.len()), + Err(EgressDenied::DestinationDenied), + "{port}" + ); + } + let udp = udp_frame(target, 443); + policy.authorize_frame(&udp, udp.len()).unwrap(); + let icmp = icmp_echo_frame(target); + policy.authorize_frame(&icmp, icmp.len()).unwrap(); + } + + #[test] + fn port_free_rules_classify_fragments_by_ipv4_protocol() { + const FIRST_FRAGMENT: u16 = 0x2000; + const LATER_FRAGMENT: u16 = 0x00b9; + let target = Ipv4Addr::new(192, 0, 2, 7); + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.7:udp", "192.0.2.7:icmp"], + &["192.0.2.7:tcp"], + ); + for flags in [FIRST_FRAGMENT, LATER_FRAGMENT] { + for frame in [ + fragmented(udp_frame(target, 53), flags), + fragmented(icmp_echo_frame(target), flags), + ] { + policy.authorize_frame(&frame, frame.len()).unwrap(); + } + let tcp = fragmented(tcp_frame(target, 443), flags); + assert_eq!( + policy.authorize_frame(&tcp, tcp.len()), + Err(EgressDenied::DestinationDenied) + ); + } + + // A port-specific rule cannot classify a fragment of any protocol. + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.7:udp", "192.0.2.7:icmp", "192.0.2.7:tcp:443"], + &[], + ); + for frame in [ + fragmented(udp_frame(target, 53), FIRST_FRAGMENT), + fragmented(icmp_echo_frame(target), LATER_FRAGMENT), + ] { + assert_eq!( + policy.authorize_frame(&frame, frame.len()), + Err(EgressDenied::FragmentDenied) + ); + } + let unfragmented = udp_frame(target, 53); + policy + .authorize_frame(&unfragmented, unfragmented.len()) + .unwrap(); + } + + #[test] + fn protocol_wide_rules_have_distinct_canonical_encodings() { + // Each rule is encoded as its network, prefix length, transport, and port. + let encodings = [ + ("192.0.2.0/24", [192, 0, 2, 0, 24, 0, 0, 0]), + ("192.0.2.0/24:tcp", [192, 0, 2, 0, 24, 1, 0, 0]), + ("192.0.2.0/24:udp", [192, 0, 2, 0, 24, 2, 0, 0]), + ("192.0.2.0/24:icmp", [192, 0, 2, 0, 24, 3, 0, 0]), + ("192.0.2.0/24:tcp:443", [192, 0, 2, 0, 24, 1, 1, 187]), + ] + .map(|(rule, record)| { + let policy = rule_policy(EgressAction::Deny, &[rule], &[]); + assert!(policy.validate().is_ok(), "{rule}"); + let bytes = policy.canonical_bytes(); + assert!( + bytes.windows(record.len()).any(|window| window == record), + "{rule}" + ); + bytes + }); + for (index, encoding) in encodings.iter().enumerate() { + assert!(!encodings[index + 1..].contains(encoding)); + } + } + + #[test] + fn protocol_wide_rules_preserve_gateway_dns_and_arp_semantics() { + assert!(rule_policy(EgressAction::Deny, &["10.0.0.1:udp"], &[]).allows_gateway_dns()); + assert!(rule_policy(EgressAction::Deny, &["10.0.0.1:tcp"], &[]).allows_gateway_dns()); + assert!(!rule_policy(EgressAction::Deny, &["10.0.0.1:icmp"], &[]).allows_gateway_dns()); + assert!( + !rule_policy(EgressAction::Allow, &[], &["10.0.0.1:tcp", "10.0.0.1:udp"]) + .allows_gateway_dns() + ); + + // A protocol-wide deny leaves the destination's other protocols + // reachable, so the guest may still resolve it. + let on_link = Ipv4Addr::new(10, 0, 0, 9); + let policy = rule_policy(EgressAction::Deny, &["10.0.0.9"], &["10.0.0.9:tcp"]); + let arp = arp_request(on_link, ETHERNET_BROADCAST, ETHERNET_UNSPECIFIED); + policy.authorize_frame(&arp, arp.len()).unwrap(); + let tcp = tcp_frame(on_link, 443); + assert_eq!( + policy.authorize_frame(&tcp, tcp.len()), + Err(EgressDenied::DestinationDenied) + ); + let icmp = icmp_echo_frame(on_link); + policy.authorize_frame(&icmp, icmp.len()).unwrap(); + } + #[test] fn host_loopback_denial_preserves_only_the_exact_proxy() { let proxy: TcpEndpoint = "10.0.0.1:8443".parse().unwrap(); From f4566bab0a30324de0a2ac292a65d0081ba319e0 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Fri, 9 Oct 2026 14:06:04 -0300 Subject: [PATCH 300/304] net_backend_resources: select egress rules by destination-port range microVM L3/L4 egress rules could select one TCP or UDP destination port, or every port of a protocol, but not a range of ports. A policy that admits or blocks a span of ports, such as MXC's network.egress selector {"protocol": "tcp", "port": 8000, "endPort": 8010}, needed one rule per port, and each rule list accepts at most 256 rules, so a range of more than 256 ports could not be expressed at all (microsoft/nvx#278). EgressRule now also parses IPv4[/PREFIX]:tcp:FIRST-LAST and IPv4[/PREFIX]:udp:FIRST-LAST, which match every destination port from FIRST through LAST, both included. Both ports must be in 1 through 65535. A range that ends below its first port fails with the new ParseEgressRuleError::ReversedPortRange, and one that lacks its first or last port, or names more than two, with InvalidPortRange. A range of one port is that port's rule, so FIRST-FIRST and FIRST parse, deduplicate, and encode alike. The new EgressRule::end_port returns the last port of a range, the port of a single-port rule, and zero for a rule without a port. A port-specific rule matches an unfragmented TCP or UDP packet of its protocol whose destination port lies in its range. Deny rules still take precedence over allow rules, so a deny rule for one port inside an allowed range blocks that port alone. A range restricts ports, so, like a single-port rule, it makes a policy reject IPv4 fragments, and allows_gateway_dns counts a TCP or UDP range for the gateway that contains port 53. The canonical encoding gives a TCP range the transport byte 4 and a UDP range the transport byte 5, followed by its first and last ports, so the snapshot policy digest of every encoding version tells a range apart from its first port and from every other rule, while the bytes of existing policies, and their encoding version, are unchanged. EgressPolicy::bind now also rejects rules that parsing cannot produce, such as a reversed range or an ICMP rule with a port, with NonCanonicalRules, so validate catches them after a policy crosses a boundary. openvmm_entry's --network-egress-allow and --network-egress-deny document the range form, and the Guide documents it in the CLI reference and on the Consomme backend page. Unit tests cover the parser's range forms and errors; each range's first and last ports, a port inside it, and the ports next to it, for TCP and UDP, in allow rules and in deny rules that override an allow rule; a denied port inside an allowed range; fragments and gateway DNS under ranges; the canonical encoding of ranges in every encoding version; the rejection of rule forms that parsing cannot produce; and the CLI's parsing of ranges, and rejection of malformed ones, before VM resources are opened. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/backends/consomme.md | 10 +- Guide/src/reference/openvmm/management/cli.md | 12 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 78 +++- .../net/net_backend_resources/src/egress.rs | 426 ++++++++++++++++-- 4 files changed, 485 insertions(+), 41 deletions(-) diff --git a/Guide/src/reference/backends/consomme.md b/Guide/src/reference/backends/consomme.md index 51112d384ac..73f92c7f463 100644 --- a/Guide/src/reference/backends/consomme.md +++ b/Guide/src/reference/backends/consomme.md @@ -56,11 +56,11 @@ NAT does not expose arbitrary guest listeners. The generic egress rule form accepts destination IPv4 addresses or CIDRs, optionally restricted to TCP, UDP, or ICMP, and for TCP or UDP optionally to -one destination port. Deny rules take precedence over allow -rules, and the explicit directional default handles traffic that matches -neither list. Filtering and malformed-packet rejection occur before Consomme -creates a host socket. Port-specific policies reject IPv4 fragments rather -than allowing later fragments to bypass transport checks. +one destination port or to an inclusive range of them. Deny rules take +precedence over allow rules, and the explicit directional default handles +traffic that matches neither list. Filtering and malformed-packet rejection +occur before Consomme creates a host socket. Port-specific policies reject IPv4 +fragments rather than allowing later fragments to bypass transport checks. The portable profile does not provide generic bidirectional host-loopback connectivity. Explicit `--host-loopback allow` without any diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 4764291e77f..025c499ea70 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -147,10 +147,14 @@ describes the source definitions. `--network-egress-allow ` and `--network-egress-deny ` provide the generic L3/L4 policy form. A rule is `IPv4[/PREFIX]`, which matches - every IPv4 protocol; `IPv4[/PREFIX]:tcp`, `IPv4[/PREFIX]:udp`, or - `IPv4[/PREFIX]:icmp`, which match one protocol on every destination port; or - `IPv4[/PREFIX]:tcp:PORT` / `IPv4[/PREFIX]:udp:PORT`, which match one TCP or - UDP destination port. ICMP rules take no port. These flags require an + every IPv4 protocol; `IPv4[/PREFIX]:tcp` or `IPv4[/PREFIX]:udp`, which match + every destination port of one protocol; `IPv4[/PREFIX]:icmp`, which matches + every ICMP message; `IPv4[/PREFIX]:tcp:PORT` / `IPv4[/PREFIX]:udp:PORT`, + which match one TCP or UDP destination port; or + `IPv4[/PREFIX]:tcp:FIRST-LAST` / `IPv4[/PREFIX]:udp:FIRST-LAST`, which match + the inclusive range of TCP or UDP destination ports from `FIRST` through + `LAST`. Ports are `1` through `65535`, and a range whose `LAST` is below its + `FIRST` is rejected. ICMP rules take no port. These flags require an explicit `--network-egress` default, accept at most 256 rules in each list, and cannot be mixed with the legacy `--allow-host`, `--block-host`, or `--allow-endpoint` forms. Deny rules are evaluated before allow rules. diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index f50abb433fe..a4dfa710ff1 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -377,17 +377,18 @@ pub struct MicrovmCli { pub network_ingress: Option, /// Permit matching IPv4 destinations, optionally restricted to TCP, UDP, - /// or ICMP, and for TCP or UDP optionally to one destination port. + /// or ICMP, and for TCP or UDP optionally to one destination port or to + /// an inclusive range of them. #[clap( long = "network-egress-allow", - value_name = "IPv4[/PREFIX][:tcp[:PORT]|:udp[:PORT]|:icmp]" + value_name = "IPv4[/PREFIX][:tcp[:PORT[-PORT]]|:udp[:PORT[-PORT]]|:icmp]" )] pub network_egress_allow: Vec, /// Deny matching IPv4 destinations before evaluating allow rules. #[clap( long = "network-egress-deny", - value_name = "IPv4[/PREFIX][:tcp[:PORT]|:udp[:PORT]|:icmp]" + value_name = "IPv4[/PREFIX][:tcp[:PORT[-PORT]]|:udp[:PORT[-PORT]]|:icmp]" )] pub network_egress_deny: Vec, @@ -2397,6 +2398,77 @@ mod tests { } } + #[test] + fn test_microvm_egress_port_ranges_parse_before_resources() { + use net_backend_resources::egress::EgressRule; + use net_backend_resources::egress::EgressTransport; + + let network_options = [ + "openvmm", + "--machine", + "microvm", + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + ]; + let options = Options::try_parse_from(network_options.into_iter().chain([ + "--network-egress-allow", + "198.51.100.0/24:udp:5000-5010", + "--network-egress-allow", + "198.51.100.0/24:tcp:8000-8010", + "--network-egress-allow", + "198.51.100.0/24:tcp:443-443", + "--network-egress-deny", + "198.51.100.7:tcp:8005", + ])) + .unwrap(); + options.validate_microvm_options().unwrap(); + let network: openvmm_defs::microvm::MicrovmNetworkConfig = "10.0.0.2/24".parse().unwrap(); + let policy = options.microvm_egress_policy(&network).unwrap(); + let net_backend_resources::egress::EgressPolicyMode::Rules { allow, deny, .. } = + policy.mode() + else { + panic!("expected rule-based egress policy") + }; + let selectors = |rules: &[EgressRule]| { + rules + .iter() + .map(|rule| (rule.transport(), rule.port(), rule.end_port())) + .collect::>() + }; + assert_eq!( + selectors(allow), + [ + (EgressTransport::Tcp, 443, 443), + (EgressTransport::Tcp, 8000, 8010), + (EgressTransport::Udp, 5000, 5010), + ] + ); + assert_eq!(selectors(deny), [(EgressTransport::Tcp, 8005, 8005)]); + + for rule in [ + "192.0.2.1:tcp:8010-8000", + "192.0.2.1:tcp:-8010", + "192.0.2.1:udp:8000-", + "192.0.2.1:tcp:0-8010", + "192.0.2.1:udp:8000-65536", + "192.0.2.1:icmp:1-2", + ] { + assert!( + Options::try_parse_from( + network_options + .into_iter() + .chain(["--network-egress-deny", rule]) + ) + .is_err(), + "{rule}" + ); + } + } + #[test] fn test_microvm_workload_identity_is_non_root_and_not_restorable() { for identity in ["0:1", "1:0", "root:1", "1:root", "1", "1:2:3"] { diff --git a/vm/devices/net/net_backend_resources/src/egress.rs b/vm/devices/net/net_backend_resources/src/egress.rs index d6d8d54222c..2c483836a43 100644 --- a/vm/devices/net/net_backend_resources/src/egress.rs +++ b/vm/devices/net/net_backend_resources/src/egress.rs @@ -159,21 +159,24 @@ pub enum EgressAction { pub enum EgressTransport { /// Match every IPv4 protocol and port. Any, - /// Match TCP on one destination port, or on every port. + /// Match TCP on one destination port, on an inclusive range of them, or + /// on every port. Tcp, - /// Match UDP on one destination port, or on every port. + /// Match UDP on one destination port, on an inclusive range of them, or + /// on every port. Udp, /// Match every ICMP message. Icmp, } /// A canonical IPv4 destination rule with an optional protocol and TCP or UDP -/// port. +/// destination port or inclusive range of destination ports. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, MeshPayload)] pub struct EgressRule { destination: Ipv4Cidr, transport: EgressTransport, port: u16, + end_port: u16, } impl EgressRule { @@ -187,13 +190,32 @@ impl EgressRule { self.transport } - /// Returns the restricted TCP or UDP destination port, or zero for a rule - /// without a port: an address-only rule, a TCP or UDP rule for every - /// port, or an ICMP rule. + /// Returns the restricted TCP or UDP destination port, which is the first + /// port of a range, or zero for a rule without a port: an address-only + /// rule, a TCP or UDP rule for every port, or an ICMP rule. pub fn port(self) -> u16 { self.port } + /// Returns the last restricted TCP or UDP destination port of the + /// inclusive range that starts at [`Self::port`], which equals + /// [`Self::port`] for a single port, or zero for a rule without a port. + pub fn end_port(self) -> u16 { + self.end_port + } + + /// Returns whether the rule has a form that parsing produces: only a TCP + /// or UDP rule restricts ports, and its range is nonzero and not reversed. + fn has_canonical_ports(self) -> bool { + match self.transport { + EgressTransport::Tcp | EgressTransport::Udp => { + (self.port, self.end_port) == (0, 0) + || (self.port != 0 && self.port <= self.end_port) + } + EgressTransport::Any | EgressTransport::Icmp => (self.port, self.end_port) == (0, 0), + } + } + fn matches(self, destination: Ipv4Addr, packet: Option) -> bool { if !self.destination.contains(destination) { return false; @@ -201,7 +223,11 @@ impl EgressRule { match self.transport { EgressTransport::Any => true, expected => packet.is_some_and(|packet| { - packet.transport == expected && (self.port == 0 || packet.port == Some(self.port)) + packet.transport == expected + && (self.port == 0 + || packet + .port + .is_some_and(|port| (self.port..=self.end_port).contains(&port))) }), } } @@ -221,7 +247,7 @@ struct PacketTransport { pub enum ParseEgressRuleError { /// The rule did not use a supported address, protocol, or port form. #[error( - "expected , :, or ::" + "expected , :, or ::" )] InvalidFormat, /// The destination prefix is invalid. @@ -233,12 +259,23 @@ pub enum ParseEgressRuleError { /// ICMP has no destination ports to select. #[error("ICMP egress rules do not take a port")] IcmpPort, - /// The port is not a `u16`. + /// The port, or a port of a range, is not a `u16`. #[error("invalid egress port '{0}'")] InvalidPort(String), /// Port zero is not a connectable destination. #[error("egress rule port must be nonzero")] ZeroPort, + /// The port range lacks its first or last port, or has more than two. + #[error("invalid egress port range '{0}'; expected -")] + InvalidPortRange(String), + /// The port range ends below its first port. + #[error("egress port range {first}-{last} ends below its first port")] + ReversedPortRange { + /// First port of the range. + first: u16, + /// Last port of the range. + last: u16, + }, } impl FromStr for EgressRule { @@ -246,10 +283,10 @@ impl FromStr for EgressRule { fn from_str(value: &str) -> Result { let fields = value.split(':').collect::>(); - let (destination, protocol, port) = match fields.as_slice() { + let (destination, protocol, ports) = match fields.as_slice() { [destination] => (*destination, None, None), [destination, protocol] => (*destination, Some(*protocol), None), - [destination, protocol, port] => (*destination, Some(*protocol), Some(*port)), + [destination, protocol, ports] => (*destination, Some(*protocol), Some(*ports)), _ => return Err(ParseEgressRuleError::InvalidFormat), }; let transport = match protocol { @@ -261,29 +298,48 @@ impl FromStr for EgressRule { return Err(ParseEgressRuleError::InvalidTransport(other.to_owned())); } }; - let port = match port { - None => 0, + let (port, end_port) = match ports { + None => (0, 0), Some(_) if transport == EgressTransport::Icmp => { return Err(ParseEgressRuleError::IcmpPort); } - Some(port) => { - let port = port - .parse::() - .map_err(|_| ParseEgressRuleError::InvalidPort(port.to_owned()))?; - if port == 0 { - return Err(ParseEgressRuleError::ZeroPort); - } - port - } + Some(ports) => parse_port_range(ports)?, }; Ok(Self { destination: destination.parse()?, transport, port, + end_port, }) } } +/// Parses `PORT`, or the inclusive range `FIRST-LAST`, into its first and last +/// ports. A range of one port is that port. +fn parse_port_range(value: &str) -> Result<(u16, u16), ParseEgressRuleError> { + let parse = |text: &str| { + let port = text + .parse::() + .map_err(|_| ParseEgressRuleError::InvalidPort(text.to_owned()))?; + if port == 0 { + return Err(ParseEgressRuleError::ZeroPort); + } + Ok(port) + }; + let Some((first, last)) = value.split_once('-') else { + let port = parse(value)?; + return Ok((port, port)); + }; + if first.is_empty() || last.is_empty() || last.contains('-') { + return Err(ParseEgressRuleError::InvalidPortRange(value.to_owned())); + } + let (first, last) = (parse(first)?, parse(last)?); + if last < first { + return Err(ParseEgressRuleError::ReversedPortRange { first, last }); + } + Ok((first, last)) +} + /// Run-scoped egress policy mode. #[derive(Clone, Debug, PartialEq, Eq, MeshPayload)] pub enum EgressPolicyMode { @@ -385,7 +441,8 @@ pub enum InvalidEgressPolicy { /// Reason the address is invalid. reason: InvalidEndpointAddress, }, - /// A decoded policy did not preserve canonical rule order and uniqueness. + /// A decoded policy did not preserve canonical rule forms, order, and + /// uniqueness. #[error("egress policy rules are not canonical")] NonCanonicalRules, /// A decoded policy carried next hops that do not match its bound rules. @@ -460,6 +517,13 @@ impl EgressPolicy { endpoints.dedup(); } EgressPolicyMode::Rules { allow, deny, .. } => { + if !allow + .iter() + .chain(deny.iter()) + .all(|rule| rule.has_canonical_ports()) + { + return Err(InvalidEgressPolicy::NonCanonicalRules); + } allow.sort_unstable(); allow.dedup(); deny.sort_unstable(); @@ -1106,13 +1170,22 @@ fn append_rules(bytes: &mut Vec, rules: &[EgressRule]) { for rule in rules { bytes.extend_from_slice(&rule.destination.network.octets()); bytes.push(rule.destination.prefix_length); - bytes.push(match rule.transport { - EgressTransport::Any => 0, - EgressTransport::Tcp => 1, - EgressTransport::Udp => 2, - EgressTransport::Icmp => 3, - }); + // A range of TCP or UDP ports encodes as transport 4 or 5 and appends + // its last port, so it differs from every other rule while their + // encodings stay unchanged. + let (transport, end_port) = match rule.transport { + EgressTransport::Any => (0, None), + EgressTransport::Tcp if rule.end_port == rule.port => (1, None), + EgressTransport::Udp if rule.end_port == rule.port => (2, None), + EgressTransport::Icmp => (3, None), + EgressTransport::Tcp => (4, Some(rule.end_port)), + EgressTransport::Udp => (5, Some(rule.end_port)), + }; + bytes.push(transport); bytes.extend_from_slice(&rule.port.to_be_bytes()); + if let Some(end_port) = end_port { + bytes.extend_from_slice(&end_port.to_be_bytes()); + } } } @@ -1664,6 +1737,7 @@ mod tests { destination: "192.0.2.0/24".parse().unwrap(), transport: EgressTransport::Tcp, port: 443, + end_port: 443, } ); assert_eq!( @@ -1741,6 +1815,7 @@ mod tests { destination: "192.0.2.0/24".parse().unwrap(), transport, port: 0, + end_port: 0, }), "{rule}" ); @@ -1978,6 +2053,299 @@ mod tests { policy.authorize_frame(&icmp, icmp.len()).unwrap(); } + #[test] + fn port_ranges_parse_canonically() { + for (rule, transport, port, end_port) in [ + ( + "192.0.2.9/24:tcp:8000-8010", + EgressTransport::Tcp, + 8000, + 8010, + ), + ( + "192.0.2.9/24:udp:5000-5010", + EgressTransport::Udp, + 5000, + 5010, + ), + ("192.0.2.9/24:tcp:1-65535", EgressTransport::Tcp, 1, 65535), + ( + "192.0.2.9/24:udp:65534-65535", + EgressTransport::Udp, + 65534, + 65535, + ), + // A range of one port is that port's rule. + ( + "192.0.2.9/24:tcp:8000-8000", + EgressTransport::Tcp, + 8000, + 8000, + ), + ("192.0.2.9/24:tcp:8000", EgressTransport::Tcp, 8000, 8000), + ] { + assert_eq!( + rule.parse::(), + Ok(EgressRule { + destination: "192.0.2.0/24".parse().unwrap(), + transport, + port, + end_port, + }), + "{rule}" + ); + } + for (rule, error) in [ + ( + "192.0.2.0/24:tcp:8010-8000", + ParseEgressRuleError::ReversedPortRange { + first: 8010, + last: 8000, + }, + ), + ( + "192.0.2.0/24:tcp:-8010", + ParseEgressRuleError::InvalidPortRange("-8010".to_owned()), + ), + ( + "192.0.2.0/24:udp:8000-", + ParseEgressRuleError::InvalidPortRange("8000-".to_owned()), + ), + ( + "192.0.2.0/24:tcp:-", + ParseEgressRuleError::InvalidPortRange("-".to_owned()), + ), + ( + "192.0.2.0/24:tcp:8000-8005-8010", + ParseEgressRuleError::InvalidPortRange("8000-8005-8010".to_owned()), + ), + ("192.0.2.0/24:tcp:0-8010", ParseEgressRuleError::ZeroPort), + ("192.0.2.0/24:udp:1-0", ParseEgressRuleError::ZeroPort), + ( + "192.0.2.0/24:udp:8000-65536", + ParseEgressRuleError::InvalidPort("65536".to_owned()), + ), + ( + "192.0.2.0/24:tcp:http-8010", + ParseEgressRuleError::InvalidPort("http".to_owned()), + ), + ("192.0.2.0/24:icmp:1-2", ParseEgressRuleError::IcmpPort), + ( + "192.0.2.0/24:tcp:8000-8010:extra", + ParseEgressRuleError::InvalidFormat, + ), + ] { + assert_eq!(rule.parse::(), Err(error), "{rule}"); + } + } + + #[test] + fn port_ranges_match_their_boundaries_in_allow_and_deny_rules() { + let target = Ipv4Addr::new(192, 0, 2, 7); + // The range's first and last ports, a port inside it, and the ports + // next to it, for TCP and UDP. + let probes = [7999, 8000, 8005, 8010, 8011] + .into_iter() + .flat_map(|port| { + [ + (("tcp", port), tcp_frame(target, port)), + (("udp", port), udp_frame(target, port)), + ] + }) + .collect::>(); + let inside = |transport| [(transport, 8000), (transport, 8005), (transport, 8010)]; + // Each selector lists the probes it matches. NVX lowers protocol `any` + // with a port range to the TCP and UDP ranges of the last selector. + let selectors: [(&[&str], Vec<(&str, u16)>); 3] = [ + (&["192.0.2.7:tcp:8000-8010"], inside("tcp").to_vec()), + (&["192.0.2.7:udp:8000-8010"], inside("udp").to_vec()), + ( + &["192.0.2.7:tcp:8000-8010", "192.0.2.7:udp:8000-8010"], + [inside("tcp"), inside("udp")].concat(), + ), + ]; + for (selector, matched) in selectors { + let allow = rule_policy(EgressAction::Deny, selector, &[]); + // The deny rules must win over an allow rule that covers every probe. + let deny = rule_policy(EgressAction::Deny, &["192.0.2.0/24"], selector); + assert!(allow.validate().is_ok()); + assert!(deny.validate().is_ok()); + for (probe, frame) in &probes { + let (allowed, denied) = if matched.contains(probe) { + (Ok(()), Err(EgressDenied::DestinationDenied)) + } else { + (Err(EgressDenied::DestinationDenied), Ok(())) + }; + assert_eq!( + allow.authorize_frame(frame, frame.len()), + allowed, + "allow {selector:?}: {probe:?}" + ); + assert_eq!( + deny.authorize_frame(frame, frame.len()), + denied, + "deny {selector:?}: {probe:?}" + ); + } + } + + // A deny rule for one port inside an allowed range blocks that port alone. + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.7:tcp:8000-8010"], + &["192.0.2.7:tcp:8005"], + ); + for (port, allowed) in [ + (7999, false), + (8000, true), + (8004, true), + (8005, false), + (8006, true), + (8010, true), + (8011, false), + ] { + let frame = tcp_frame(target, port); + assert_eq!( + policy.authorize_frame(&frame, frame.len()).is_ok(), + allowed, + "{port}" + ); + } + } + + #[test] + fn port_ranges_preserve_fragment_and_gateway_dns_semantics() { + // A range restricts ports, so it cannot classify a fragment of any + // protocol. + let fragment = fragmented(udp_frame(Ipv4Addr::new(192, 0, 2, 7), 53), 0x2000); + let policy = rule_policy( + EgressAction::Deny, + &["192.0.2.7:udp", "192.0.2.7:tcp:8000-8010"], + &[], + ); + assert_eq!( + policy.authorize_frame(&fragment, fragment.len()), + Err(EgressDenied::FragmentDenied) + ); + + let cases: [(&[&str], &[&str], bool); 5] = [ + (&["10.0.0.1:udp:1-1024"], &[], true), + (&["10.0.0.1:tcp:53-80"], &[], true), + (&["10.0.0.1:tcp:1-52"], &[], false), + (&["10.0.0.1:udp:54-1024"], &[], false), + (&["10.0.0.1:udp:1-1024"], &["10.0.0.1:udp:53"], false), + ]; + for (allow, deny, allows_dns) in cases { + assert_eq!( + rule_policy(EgressAction::Deny, allow, deny).allows_gateway_dns(), + allows_dns, + "allow {allow:?}, deny {deny:?}" + ); + } + } + + #[test] + fn port_ranges_have_distinct_canonical_encodings() { + // A range encodes as its network, prefix length, transport 4 for TCP + // or 5 for UDP, and its first and last ports. + let encodings = [ + ( + "192.0.2.0/24:tcp:8000-8010", + vec![192, 0, 2, 0, 24, 4, 0x1f, 0x40, 0x1f, 0x4a], + ), + ( + "192.0.2.0/24:udp:8000-8010", + vec![192, 0, 2, 0, 24, 5, 0x1f, 0x40, 0x1f, 0x4a], + ), + ( + "192.0.2.0/24:tcp:8000-8001", + vec![192, 0, 2, 0, 24, 4, 0x1f, 0x40, 0x1f, 0x41], + ), + ( + "192.0.2.0/24:tcp:8000", + vec![192, 0, 2, 0, 24, 1, 0x1f, 0x40], + ), + ( + "192.0.2.0/24:udp:8000", + vec![192, 0, 2, 0, 24, 2, 0x1f, 0x40], + ), + ("192.0.2.0/24:tcp", vec![192, 0, 2, 0, 24, 1, 0, 0]), + ] + .map(|(rule, record)| { + let policy = rule_policy(EgressAction::Deny, &[rule], &[]); + assert!(policy.validate().is_ok(), "{rule}"); + let bytes = policy.canonical_bytes(); + assert!( + bytes.windows(record.len()).any(|window| window == record), + "{rule}" + ); + bytes + }); + for (index, encoding) in encodings.iter().enumerate() { + assert!(!encodings[index + 1..].contains(encoding)); + } + + // A range of one port encodes as that port, and a snapshot of either + // encoding version cannot be restored with a range in place of its + // first port. + let canonical = |rule: &str| rule_policy(EgressAction::Deny, &[rule], &[]); + assert_eq!( + canonical("192.0.2.0/24:tcp:8000-8000").canonical_bytes(), + canonical("192.0.2.0/24:tcp:8000").canonical_bytes() + ); + for version in [ + LEGACY_EGRESS_POLICY_ENCODING_VERSION, + PREVIOUS_EGRESS_POLICY_ENCODING_VERSION, + EGRESS_POLICY_ENCODING_VERSION, + ] { + assert_ne!( + canonical("192.0.2.0/24:tcp:8000").canonical_bytes_for_version(version), + canonical("192.0.2.0/24:tcp:8000-8010").canonical_bytes_for_version(version), + "{version}" + ); + } + } + + #[test] + fn decoded_policy_must_preserve_canonical_rule_forms() { + let destination = "192.0.2.7".parse().unwrap(); + for (transport, port, end_port) in [ + (EgressTransport::Tcp, 8010, 8000), + (EgressTransport::Udp, 0, 8000), + (EgressTransport::Tcp, 8000, 0), + (EgressTransport::Icmp, 8, 8), + (EgressTransport::Any, 443, 443), + ] { + let rule = EgressRule { + destination, + transport, + port, + end_port, + }; + let mut policy = rule_policy(EgressAction::Deny, &["192.0.2.7:tcp:8000-8010"], &[]); + let EgressPolicyMode::Rules { deny, .. } = &mut policy.mode else { + panic!("expected rule-based egress policy") + }; + deny.push(rule); + assert_eq!( + policy.validate(), + Err(InvalidEgressPolicy::NonCanonicalRules), + "{rule:?}" + ); + assert_eq!( + EgressPolicy::bind( + GUEST_IPV4, + PREFIX_LENGTH, + MacAddress::new(GUEST_MAC_BYTES), + GATEWAY_IPV4, + policy.mode.clone(), + ), + Err(InvalidEgressPolicy::NonCanonicalRules), + "{rule:?}" + ); + } + } + #[test] fn host_loopback_denial_preserves_only_the_exact_proxy() { let proxy: TcpEndpoint = "10.0.0.1:8443".parse().unwrap(); From f5c8dab03fbf6543a30ac1fb0573353aa4b6a9cc Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Mon, 5 Oct 2026 23:25:48 -0700 Subject: [PATCH 301/304] virtio_blk: add bind-once image slots Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Cargo.lock | 4 + vm/devices/virtio/virtio/src/device.rs | 14 + .../virtio/virtio/src/transport/core.rs | 12 +- .../virtio/virtio/src/transport/task.rs | 33 +- vm/devices/virtio/virtio_blk/Cargo.toml | 4 + .../virtio_blk/src/integration_tests.rs | 36 ++- vm/devices/virtio/virtio_blk/src/lib.rs | 290 +++++++++++++++++- vm/devices/virtio/virtio_blk/src/resolver.rs | 24 ++ vm/devices/virtio/virtio_resources/Cargo.toml | 1 + vm/devices/virtio/virtio_resources/src/lib.rs | 43 +++ 10 files changed, 447 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 24d91b01f6a..20bcf435626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10087,16 +10087,19 @@ dependencies = [ "async-channel", "async-trait", "disk_backend", + "disk_file", "disklayer_ram", "futures", "guestmem", "inspect", "inspect_counters", + "mesh", "pal_async", "pal_event", "parking_lot", "scsi_buffers", "task_control", + "tempfile", "test_with_tracing", "tracelimit", "unicycle", @@ -10211,6 +10214,7 @@ dependencies = [ "mesh", "net_backend_resources", "serial_core", + "thiserror 2.0.16", "unix_socket", "vm_resource", ] diff --git a/vm/devices/virtio/virtio/src/device.rs b/vm/devices/virtio/virtio/src/device.rs index 71a371c3bbf..67eabec9322 100644 --- a/vm/devices/virtio/virtio/src/device.rs +++ b/vm/devices/virtio/virtio/src/device.rs @@ -17,6 +17,8 @@ use saved_state::DeviceStateValidator; use std::future::Future; use std::pin::Pin; use std::sync::Arc; +use std::task::Context; +use std::task::Poll; use vmcore::save_restore::RestoreError; use vmcore::save_restore::SaveError; use vmcore::save_restore::SavedStateBlob; @@ -156,6 +158,12 @@ pub trait VirtioDevice: InspectMut + Send { fn device_state_validator(&self) -> DeviceStateValidator { saved_state::default_device_state_validator(self.supports_save_restore()) } + + /// Polls host-only device control and reports whether guest-visible + /// configuration changed. + fn poll_config_change(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Pending + } } /// Object-safe wrapper for [`VirtioDevice`]. @@ -226,6 +234,8 @@ pub trait DynVirtioDevice: InspectMut + Send { /// Return immutable validation for device-private saved state. fn device_state_validator(&self) -> DeviceStateValidator; + + fn poll_config_change(&mut self, cx: &mut Context<'_>) -> Poll>; } impl DynVirtioDevice for T { @@ -313,4 +323,8 @@ impl DynVirtioDevice for T { fn device_state_validator(&self) -> DeviceStateValidator { VirtioDevice::device_state_validator(self) } + + fn poll_config_change(&mut self, cx: &mut Context<'_>) -> Poll> { + VirtioDevice::poll_config_change(self, cx) + } } diff --git a/vm/devices/virtio/virtio/src/transport/core.rs b/vm/devices/virtio/virtio/src/transport/core.rs index 937f6a8095b..c248f049412 100644 --- a/vm/devices/virtio/virtio/src/transport/core.rs +++ b/vm/devices/virtio/virtio/src/transport/core.rs @@ -19,6 +19,7 @@ use crate::queue::QueueState; use crate::spec::VirtioDeviceFeatures; use crate::spec::VirtioDeviceStatus; use chipset_device::io::deferred::DeferredWrite; +use futures::StreamExt; use guestmem::DoorbellRegistration; use guestmem::GuestMemory; use inspect::Inspect; @@ -71,6 +72,8 @@ pub(crate) struct VirtioTransportCore { pub device_sender: mesh::Sender, #[inspect(skip)] pub _device_task: Task<()>, + #[inspect(skip)] + pub config_change_recv: mesh::Receiver<()>, pub state: TransportState, pub device_feature: VirtioDeviceFeatures, #[inspect(hex)] @@ -138,13 +141,15 @@ impl VirtioTransportCore { let restore = restore::TransportRestore::new(&*device, traits.max_queues); let (sender, receiver) = mesh::channel(); + let (config_change_send, config_change_recv) = mesh::channel(); let _device_task = driver.spawn("virtio-device-task", async move { - run_device_task(device, receiver).await; + run_device_task(device, receiver, config_change_send).await; }); Ok(Self { device_sender: sender, _device_task, + config_change_recv, state: TransportState::Ready, device_feature, device_feature_select: 0, @@ -195,6 +200,7 @@ impl VirtioTransportCore { // Immutable / long-lived — not reset. device_sender: _, _device_task: _, + config_change_recv: _, device_feature: _, supports_save_restore: _, guest_memory: _, @@ -357,6 +363,10 @@ impl VirtioTransportCore { pub fn poll_device(&mut self, ops: &mut dyn TransportOps, cx: &mut std::task::Context<'_>) { self.poll_waker = Some(cx.waker().clone()); + while let Poll::Ready(Some(())) = self.config_change_recv.poll_next_unpin(cx) { + self.update_config_generation(ops); + } + if let Poll::Ready(result) = self.state.poll(cx) { // Complete the deferred STATUS write before applying the // result, since apply_transport_result may call reset_status diff --git a/vm/devices/virtio/virtio/src/transport/task.rs b/vm/devices/virtio/virtio/src/transport/task.rs index 516a705294e..ac64a0d3182 100644 --- a/vm/devices/virtio/virtio/src/transport/task.rs +++ b/vm/devices/virtio/virtio/src/transport/task.rs @@ -21,6 +21,7 @@ use chipset_device::io::deferred::DeferredWrite; use chipset_device::io::deferred::defer_read; use chipset_device::io::deferred::defer_write; use futures::StreamExt; +use futures::future::poll_fn; use inspect::Inspect; use mesh::rpc::FailableRpc; use mesh::rpc::PendingRpc; @@ -276,6 +277,7 @@ impl DeviceTask { pub async fn run_device_task( device: Box, mut recv: mesh::Receiver, + config_change_send: mesh::Sender<()>, ) { let traits = device.traits(); let mut task = DeviceTask { @@ -285,7 +287,36 @@ pub async fn run_device_task( device, }; - while let Some(cmd) = recv.next().await { + loop { + enum Event { + Command(Option), + ConfigChange(anyhow::Result), + } + + let event = poll_fn(|cx| { + if let Poll::Ready(result) = task.device.poll_config_change(cx) { + return Poll::Ready(Event::ConfigChange(result)); + } + recv.poll_next_unpin(cx).map(Event::Command) + }) + .await; + + let cmd = match event { + Event::Command(Some(cmd)) => cmd, + Event::Command(None) => break, + Event::ConfigChange(Ok(true)) => { + config_change_send.send(()); + continue; + } + Event::ConfigChange(Ok(false)) => continue, + Event::ConfigChange(Err(error)) => { + tracelimit::error_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "virtio host control failed" + ); + continue; + } + }; let Some(cmd) = task.restore_before_config(cmd) else { continue; }; diff --git a/vm/devices/virtio/virtio_blk/Cargo.toml b/vm/devices/virtio/virtio_blk/Cargo.toml index 1f233a2c1dc..0ecba5fbbd1 100644 --- a/vm/devices/virtio/virtio_blk/Cargo.toml +++ b/vm/devices/virtio/virtio_blk/Cargo.toml @@ -10,10 +10,13 @@ rust-version.workspace = true vm_resource.workspace = true disk_backend.workspace = true +disk_file.workspace = true guestmem.workspace = true inspect.workspace = true inspect_counters.workspace = true +mesh.workspace = true pal_async.workspace = true +parking_lot.workspace = true scsi_buffers.workspace = true task_control.workspace = true tracelimit.workspace = true @@ -34,6 +37,7 @@ pal_async.workspace = true pal_event.workspace = true parking_lot.workspace = true test_with_tracing.workspace = true +tempfile.workspace = true [lints] workspace = true diff --git a/vm/devices/virtio/virtio_blk/src/integration_tests.rs b/vm/devices/virtio/virtio_blk/src/integration_tests.rs index b9ae321b598..86a31e74dea 100644 --- a/vm/devices/virtio/virtio_blk/src/integration_tests.rs +++ b/vm/devices/virtio/virtio_blk/src/integration_tests.rs @@ -95,15 +95,30 @@ impl TestHarness { disk: Disk, read_only: bool, ) -> Self { - let mem = GuestMemory::allocate(TOTAL_MEM_SIZE); - - init_avail_ring(&mem, AVAIL_ADDR); - init_used_ring(&mem, USED_ADDR); - let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(device_driver.clone())); let device = VirtioBlkDevice::new(&driver_source, disk, read_only); + Self::with_device(driver, device) + } + + fn empty_image_slot( + driver: &DefaultDriver, + ) -> (Self, mesh::Sender) { + let driver_source = VmTaskDriverSource::new(SingleDriverBackend::new(driver.clone())); + let (requests, receiver) = mesh::channel(); + ( + Self::with_device( + driver, + VirtioBlkDevice::new_image_slot(&driver_source, true, receiver), + ), + requests, + ) + } + fn with_device(driver: &DefaultDriver, device: VirtioBlkDevice) -> Self { + let mem = GuestMemory::allocate(TOTAL_MEM_SIZE); + init_avail_ring(&mem, AVAIL_ADDR); + init_used_ring(&mem, USED_ADDR); let queue_event = Event::new(); let interrupt_event = Event::new(); @@ -731,6 +746,17 @@ async fn write_then_read_roundtrip(driver: DefaultDriver) { assert_eq!(status, VIRTIO_BLK_S_OK); } +#[async_test] +async fn empty_image_slot_read_fails(driver: DefaultDriver) { + let (mut harness, _requests) = TestHarness::empty_image_slot(&driver); + harness.enable().await; + + let data_gpa = harness.post_read_request(0, 0, 512); + let (used_id, used_len) = harness.wait_for_used().await; + assert_eq!((used_id, used_len), (0, 1)); + assert_eq!(harness.read_status(data_gpa + 512), VIRTIO_BLK_S_IOERR); +} + /// Read from a sector that was never written — should succeed with zeroes. #[async_test] async fn read_unwritten_sector_returns_zeroes(driver: DefaultDriver) { diff --git a/vm/devices/virtio/virtio_blk/src/lib.rs b/vm/devices/virtio/virtio_blk/src/lib.rs index 639b3cfefb9..05a4cd3cfb0 100644 --- a/vm/devices/virtio/virtio_blk/src/lib.rs +++ b/vm/devices/virtio/virtio_blk/src/lib.rs @@ -12,6 +12,7 @@ mod integration_tests; use anyhow::Context as _; use disk_backend::Disk; +use disk_file::FileDisk; use futures::StreamExt; use guestmem::GuestMemory; use guestmem::ranges::PagedRange; @@ -19,10 +20,12 @@ use inspect::Inspect; use inspect::InspectMut; use inspect_counters::Counter; use pal_async::wait::PolledWait; +use parking_lot::RwLock; use scsi_buffers::RequestBuffers; use std::future::Future; use std::future::poll_fn; use std::pin::Pin; +use std::sync::Arc; use std::task::Context; use std::task::Poll; use task_control::AsyncRun; @@ -68,6 +71,22 @@ pub struct VirtioBlkDevice { read_only: bool, supports_discard: bool, config: VirtioBlkConfig, + media: Arc>, + image_slot: Option, +} + +#[derive(Inspect)] +struct MediaState { + #[inspect(with = "Option::is_some")] + disk: Option, + identity: Option, +} + +#[derive(Inspect)] +struct ImageSlot { + active: bool, + #[inspect(skip)] + requests: mesh::Receiver, } /// Persistent worker state. Survives across enable/disable cycles. @@ -79,7 +98,7 @@ pub struct VirtioBlkDevice { /// the queue state is removed. #[derive(InspectMut)] struct BlkWorker { - disk: Disk, + media: Arc>, read_only: bool, stats: WorkerStats, #[inspect(with = "FuturesUnordered::len")] @@ -205,11 +224,11 @@ impl AsyncRun for BlkWorker { match event { Event::NewWork(Ok(work)) => { - let disk = self.disk.clone(); + let disk = self.media.read().disk.clone(); let mem = state.memory.clone(); let read_only = self.read_only; self.ios.push(Box::pin(async move { - process_request(&disk, &mem, read_only, work).await + process_request(disk.as_ref(), &mem, read_only, work).await })); } Event::NewWork(Err(err)) => { @@ -299,9 +318,13 @@ impl VirtioBlkDevice { let supports_discard = disk.unmap_behavior() != disk_backend::UnmapBehavior::Ignored; + let media = Arc::new(RwLock::new(MediaState { + disk: Some(disk), + identity: None, + })); Self { worker: TaskControl::new(BlkWorker { - disk, + media: media.clone(), read_only, stats: WorkerStats::default(), ios: FuturesUnordered::new(), @@ -310,8 +333,134 @@ impl VirtioBlkDevice { read_only, supports_discard, config, + media, + image_slot: None, + } + } + + /// Creates a fixed-geometry image slot with no media. + pub fn new_image_slot( + driver_source: &VmTaskDriverSource, + active: bool, + requests: mesh::Receiver, + ) -> Self { + let media = Arc::new(RwLock::new(MediaState { + disk: None, + identity: None, + })); + let config = VirtioBlkConfig { + capacity: 0, + size_max: 0, + seg_max: DEFAULT_SEG_MAX, + geometry: VirtioBlkGeometry { + cylinders: 0, + heads: 0, + sectors: 0, + }, + blk_size: 512, + topology: VirtioBlkTopology { + physical_block_exp: 3, + alignment_offset: 0, + min_io_size: 1, + opt_io_size: 0, + }, + writeback: 1, + unused0: 0, + num_queues: 1, + max_discard_sectors: 0, + max_discard_seg: 0, + discard_sector_alignment: 0, + max_write_zeroes_sectors: 0, + max_write_zeroes_seg: 0, + write_zeroes_may_unmap: 0, + unused1: [0; 3], + _padding: [0; 4], + }; + Self { + worker: TaskControl::new(BlkWorker { + media: media.clone(), + read_only: true, + stats: WorkerStats::default(), + ios: FuturesUnordered::new(), + }), + driver: driver_source.simple(), + read_only: true, + supports_discard: false, + config, + media, + image_slot: Some(ImageSlot { active, requests }), } } + + fn image_slot_state(&self) -> virtio_resources::blk::ImageSlotState { + let media = self.media.read(); + virtio_resources::blk::ImageSlotState { + identity: media.identity.clone(), + capacity_sectors: media.disk.as_ref().map_or(0, |disk| { + disk.sector_count() * (disk.sector_size() as u64 / 512) + }), + } + } + + fn bind_image_slot( + &self, + request: virtio_resources::blk::BindImageSlotRequest, + ) -> Result + { + use virtio_resources::blk::BindImageSlotError; + + if request.identity.is_empty() || request.identity.len() > 1024 { + return Err(BindImageSlotError::InvalidMedia( + "identity must contain 1 to 1024 bytes".to_owned(), + )); + } + if request.logical_block_size != 512 || request.physical_block_size != 4096 { + return Err(BindImageSlotError::GeometryMismatch); + } + let metadata = request + .media + .metadata() + .map_err(|error| BindImageSlotError::InvalidMedia(error.to_string()))?; + if !metadata.file_type().is_file() + || request.length == 0 + || !request + .length + .is_multiple_of(request.logical_block_size as u64) + || metadata.len() != request.length + { + return Err(BindImageSlotError::InvalidMedia( + "media must be a nonempty aligned regular file".to_owned(), + )); + } + + let mut media = self.media.write(); + if let Some(identity) = &media.identity { + return if identity == &request.identity { + Ok(virtio_resources::blk::ImageSlotState { + identity: Some(identity.clone()), + capacity_sectors: media.disk.as_ref().map_or(0, Disk::sector_count), + }) + } else { + Err(BindImageSlotError::AlreadyBound) + }; + } + let disk = Disk::new(FileDisk::with_metadata( + request.media, + disk_file::Metadata { + disk_size: request.length, + sector_size: request.logical_block_size, + physical_sector_size: request.physical_block_size, + read_only: true, + }, + )) + .map_err(|error| BindImageSlotError::InvalidMedia(error.to_string()))?; + media.disk = Some(disk); + media.identity = Some(request.identity); + Ok(virtio_resources::blk::ImageSlotState { + identity: media.identity.clone(), + capacity_sectors: request.length / 512, + }) + } } impl VirtioDevice for VirtioBlkDevice { @@ -332,7 +481,11 @@ impl VirtioDevice for VirtioBlkDevice { } DeviceTraits { - device_id: virtio::spec::VirtioDeviceType::BLK, + device_id: if self.image_slot.as_ref().is_some_and(|slot| !slot.active) { + virtio::spec::VirtioDeviceType(0) + } else { + virtio::spec::VirtioDeviceType::BLK + }, device_features: VirtioDeviceFeatures::new() .with_device_specific_low(features) .with_ring_event_idx(true) @@ -349,7 +502,11 @@ impl VirtioDevice for VirtioBlkDevice { // The transport reads the device config space as a sequence of u32s. // We serialize VirtioBlkConfig to bytes and return the requested // 4-byte window. Three cases: - let config_bytes = self.config.as_bytes(); + let mut config = self.config.clone(); + if self.image_slot.is_some() { + config.capacity = self.image_slot_state().capacity_sectors; + } + let config_bytes = config.as_bytes(); let offset = offset as usize; if offset + 4 <= config_bytes.len() { // Normal case: full u32 within bounds. @@ -426,6 +583,28 @@ impl VirtioDevice for VirtioBlkDevice { fn supports_save_restore(&self) -> bool { true } + + fn poll_config_change(&mut self, cx: &mut Context<'_>) -> Poll> { + let Some(slot) = &mut self.image_slot else { + return Poll::Pending; + }; + let request = match slot.requests.poll_next_unpin(cx) { + Poll::Ready(Some(request)) => request, + Poll::Ready(None) | Poll::Pending => return Poll::Pending, + }; + match request { + virtio_resources::blk::ImageSlotRequest::Bind(rpc) => { + let was_empty = self.image_slot_state().identity.is_none(); + rpc.handle_sync(|request| self.bind_image_slot(request)); + let changed = was_empty && self.image_slot_state().identity.is_some(); + Poll::Ready(Ok(changed)) + } + virtio_resources::blk::ImageSlotRequest::Query(rpc) => { + rpc.handle_sync(|()| self.image_slot_state()); + Poll::Ready(Ok(false)) + } + } + } } /// Process a single virtio-blk request. @@ -434,11 +613,25 @@ impl VirtioDevice for VirtioBlkDevice { /// write the used ring entry. This keeps completion in the main loop, /// which simplifies future queue API changes. async fn process_request( - disk: &Disk, + disk: Option<&Disk>, mem: &GuestMemory, read_only: bool, work: VirtioQueueCallbackWork, ) -> IoCompletion { + let Some(disk) = disk else { + if let Err(err) = write_status_byte(mem, &work, VIRTIO_BLK_S_IOERR) { + tracelimit::error_ratelimited!( + error = &err as &dyn std::error::Error, + "failed to write error status byte" + ); + } + return IoCompletion { + work, + bytes_written: 1, + stat: IoStat::Error, + bounced: false, + }; + }; match process_request_inner(disk, mem, read_only, &work).await { Ok((bytes_written, stat, bounced)) => { if let Err(err) = write_status_byte(mem, &work, VIRTIO_BLK_S_OK) { @@ -748,6 +941,11 @@ fn write_status_byte( #[cfg(test)] mod tests { use super::*; + use mesh::rpc::RpcSend; + use std::task::Context; + use std::task::Waker; + use test_with_tracing::test; + use vmcore::vm_task::SingleDriverBackend; // ---- copy_regions tests ---- @@ -801,4 +999,82 @@ mod tests { guest.read_at(0x9000, &mut buf).unwrap(); assert!(buf.iter().all(|&b| b == 0x33)); } + + fn image_slot(active: bool) -> VirtioBlkDevice { + let driver = pal_async::DefaultPool::new().driver(); + let source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let (_requests, receiver) = mesh::channel(); + VirtioBlkDevice::new_image_slot(&source, active, receiver) + } + + fn bind_request( + identity: &str, + length: u64, + logical_block_size: u32, + ) -> virtio_resources::blk::BindImageSlotRequest { + let file = tempfile::tempfile().unwrap(); + file.set_len(length).unwrap(); + virtio_resources::blk::BindImageSlotRequest { + media: file, + identity: identity.to_owned(), + length, + logical_block_size, + physical_block_size: 4096, + } + } + + #[test] + fn inactive_image_slot_is_a_placeholder() { + let device = image_slot(false); + assert_eq!(device.traits().device_id.0, 0); + assert_eq!(device.image_slot_state().capacity_sectors, 0); + } + + #[test] + fn image_slot_bind_once_is_idempotent_by_identity() { + let device = image_slot(true); + let state = device + .bind_image_slot(bind_request("image-a", 4096, 512)) + .unwrap(); + assert_eq!(state.capacity_sectors, 8); + assert_eq!(state.identity.as_deref(), Some("image-a")); + + let state = device + .bind_image_slot(bind_request("image-a", 4096, 512)) + .unwrap(); + assert_eq!(state.capacity_sectors, 8); + assert!(matches!( + device.bind_image_slot(bind_request("image-b", 4096, 512)), + Err(virtio_resources::blk::BindImageSlotError::AlreadyBound) + )); + } + + #[test] + fn image_slot_rejects_geometry_mismatch() { + let device = image_slot(true); + assert!(matches!( + device.bind_image_slot(bind_request("image-a", 4096, 4096)), + Err(virtio_resources::blk::BindImageSlotError::GeometryMismatch) + )); + } + + #[test] + fn image_slot_bind_reports_configuration_change() { + let driver = pal_async::DefaultPool::new().driver(); + let source = VmTaskDriverSource::new(SingleDriverBackend::new(driver)); + let (requests, receiver) = mesh::channel(); + let mut device = VirtioBlkDevice::new_image_slot(&source, true, receiver); + let response = requests.call( + virtio_resources::blk::ImageSlotRequest::Bind, + bind_request("image-a", 4096, 512), + ); + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!( + device.poll_config_change(&mut context), + Poll::Ready(Ok(true)) + )); + let state = futures::executor::block_on(response).unwrap().unwrap(); + assert_eq!(state.capacity_sectors, 8); + assert_eq!(futures::executor::block_on(device.read_registers_u32(0)), 8); + } } diff --git a/vm/devices/virtio/virtio_blk/src/resolver.rs b/vm/devices/virtio/virtio_blk/src/resolver.rs index 98c865be134..2dadbd50ba2 100644 --- a/vm/devices/virtio/virtio_blk/src/resolver.rs +++ b/vm/devices/virtio/virtio_blk/src/resolver.rs @@ -9,6 +9,7 @@ use disk_backend::resolve::ResolveDiskParameters; use virtio::resolve::ResolvedVirtioDevice; use virtio::resolve::VirtioResolveInput; use virtio_resources::blk::VirtioBlkHandle; +use virtio_resources::blk::VirtioBlkImageSlotHandle; use vm_resource::AsyncResolveResource; use vm_resource::ResourceResolver; use vm_resource::declare_static_async_resolver; @@ -20,6 +21,7 @@ pub struct VirtioBlkResolver; declare_static_async_resolver! { VirtioBlkResolver, (VirtioDeviceHandle, VirtioBlkHandle), + (VirtioDeviceHandle, VirtioBlkImageSlotHandle), } #[async_trait] @@ -46,3 +48,25 @@ impl AsyncResolveResource for VirtioBlkReso Ok(VirtioBlkDevice::new(input.driver_source, disk.0, resource.read_only).into()) } } + +#[async_trait] +impl AsyncResolveResource for VirtioBlkResolver { + type Output = ResolvedVirtioDevice; + type Error = anyhow::Error; + + async fn resolve( + &self, + _resolver: &ResourceResolver, + resource: VirtioBlkImageSlotHandle, + input: VirtioResolveInput<'_>, + ) -> Result { + Ok( + VirtioBlkDevice::new_image_slot( + input.driver_source, + resource.active, + resource.requests, + ) + .into(), + ) + } +} diff --git a/vm/devices/virtio/virtio_resources/Cargo.toml b/vm/devices/virtio/virtio_resources/Cargo.toml index 6aa40421685..e9d79ec08f8 100644 --- a/vm/devices/virtio/virtio_resources/Cargo.toml +++ b/vm/devices/virtio/virtio_resources/Cargo.toml @@ -13,6 +13,7 @@ unix_socket = { workspace = true, features = ["mesh"] } vm_resource.workspace = true mesh.workspace = true +thiserror.workspace = true [lints] workspace = true diff --git a/vm/devices/virtio/virtio_resources/src/lib.rs b/vm/devices/virtio/virtio_resources/src/lib.rs index 7be8b798491..fada105c185 100644 --- a/vm/devices/virtio/virtio_resources/src/lib.rs +++ b/vm/devices/virtio/virtio_resources/src/lib.rs @@ -118,6 +118,8 @@ pub mod rng { pub mod blk { use mesh::MeshPayload; + use mesh::rpc::Rpc; + use std::fs::File; use vm_resource::Resource; use vm_resource::ResourceId; use vm_resource::kind::DiskHandleKind; @@ -132,6 +134,47 @@ pub mod blk { impl ResourceId for VirtioBlkHandle { const ID: &'static str = "virtio-blk"; } + + #[derive(Debug, MeshPayload)] + pub struct BindImageSlotRequest { + pub media: File, + pub identity: String, + pub length: u64, + pub logical_block_size: u32, + pub physical_block_size: u32, + } + + #[derive(Clone, Debug, MeshPayload, PartialEq, Eq)] + pub struct ImageSlotState { + pub identity: Option, + pub capacity_sectors: u64, + } + + #[derive(Clone, Debug, MeshPayload, thiserror::Error, PartialEq, Eq)] + pub enum BindImageSlotError { + #[error("image slot is already bound to a different identity")] + AlreadyBound, + #[error("image slot media geometry does not match the slot")] + GeometryMismatch, + #[error("image slot media is invalid: {0}")] + InvalidMedia(String), + } + + #[derive(MeshPayload)] + pub enum ImageSlotRequest { + Bind(Rpc>), + Query(Rpc<(), ImageSlotState>), + } + + #[derive(MeshPayload)] + pub struct VirtioBlkImageSlotHandle { + pub active: bool, + pub requests: mesh::Receiver, + } + + impl ResourceId for VirtioBlkImageSlotHandle { + const ID: &'static str = "virtio-blk-image-slot"; + } } pub mod net { From 0a3dc07e95f168c237654fdb5cf187505a793880 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Wed, 7 Oct 2026 13:11:19 -0700 Subject: [PATCH 302/304] microvm: add bind-once read-only image slots A microVM may declare four bind-once read-only virtio-blk image slots, which select microVM ABI 3. A VM without slots keeps ABI 2, its topology, discovery tokens, and snapshot format. Slots image0-image3 use MMIO 0xd0008000-0xd000b000, ISA edge IRQs 1/13/14/15, and shared-status words 0x20-0x2c. The first slot reuses the second virtio-fs slot's window and status word, and the second slot its interrupt, so a VM with image slots attaches at most one filesystem. --microvm-image-slot-boot-count activates a prefix of the slots at cold boot. A restore may activate a longer prefix with --restore-image-slots; the target travels in restore packet version 4 as flag bit 4 and header byte 7, and holds the post-restore gate until the guest acknowledges. The management RPC carries the same target. A host-control endpoint (--microvm-host-control), authenticated with the control-console capability, queries the slots and binds a validated regular file into an empty slot once. A clean close at a frame boundary ends a session normally. Machine validation admits exactly the declared image-slot transports, and the snapshot contract records the slot capacity and the boot prefix. Capture is rejected while a slot is bound. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/SUMMARY.md | 1 + Guide/src/reference/openvmm/management/cli.md | 38 +- .../src/reference/openvmm/management/grpc.md | 5 + .../management/host_control_protocol.md | 36 ++ Guide/src/user_guide/openvmm/snapshots.md | 21 +- .../src/worker/dispatch/microvm.rs | 15 + openvmm/openvmm_defs/src/microvm.rs | 432 +++++++++++++++-- openvmm/openvmm_entry/src/cli_args/microvm.rs | 165 ++++++- openvmm/openvmm_entry/src/microvm/config.rs | 99 +++- openvmm/openvmm_entry/src/microvm/console.rs | 82 +++- .../openvmm_entry/src/microvm/filesystem.rs | 1 + .../openvmm_entry/src/microvm/host_control.rs | 440 ++++++++++++++++++ openvmm/openvmm_entry/src/microvm/launch.rs | 3 + openvmm/openvmm_entry/src/microvm/mod.rs | 14 + openvmm/openvmm_entry/src/microvm/network.rs | 1 + openvmm/openvmm_entry/src/microvm/restore.rs | 111 ++++- .../openvmm_entry/src/serial_io/microvm.rs | 52 ++- openvmm/openvmm_entry/src/storage_builder.rs | 2 + .../src/storage_builder/microvm.rs | 37 ++ openvmm/openvmm_entry/src/ttrpc/microvm.rs | 132 +++++- openvmm/openvmm_entry/src/ttrpc/mod.rs | 2 +- openvmm/openvmm_entry/src/vm_controller.rs | 33 +- .../src/vm_controller/microvm.rs | 133 +++++- .../openvmm_helpers/src/snapshot/microvm.rs | 212 ++++++++- .../src/vmservice.proto | 10 + vm/devices/chipset/src/microvm.rs | 4 + .../chipset_resources/src/microvm_time.rs | 86 +++- 27 files changed, 2018 insertions(+), 149 deletions(-) create mode 100644 Guide/src/reference/openvmm/management/host_control_protocol.md create mode 100644 openvmm/openvmm_entry/src/microvm/host_control.rs diff --git a/Guide/src/SUMMARY.md b/Guide/src/SUMMARY.md index d4f602bc8c6..90a3578e0cc 100644 --- a/Guide/src/SUMMARY.md +++ b/Guide/src/SUMMARY.md @@ -89,6 +89,7 @@ - [Configuration and Management](./reference/openvmm/management.md) - [CLI](./reference/openvmm/management/cli.md) - [Control-session Protocol](./reference/openvmm/management/control_session_protocol.md) + - [Host-control Protocol](./reference/openvmm/management/host_control_protocol.md) - [Interactive Console](./reference/openvmm/management/interactive_console.md) - [gRPC / ttrpc](./reference/openvmm/management/grpc.md) - [Graphical Console](./reference/openvmm/graphical_console.md) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index 025c499ea70..c511769ff4e 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -49,7 +49,8 @@ describes the source definitions. One virtio-fs slot is exposed at MMIO `0xd0001000`, IRQ 6 and remains dormant when `--mount` is omitted; `--mount` binds HostFs to it, and a - second `--mount` adds a second slot at MMIO `0xd0008000`, IRQ 13; + second `--mount` adds a second slot at MMIO `0xd0008000`, IRQ 13, unless + image slots claim them; one optional `--virtio-console ` is exposed at MMIO `0xd0002000`, IRQ 7 as the boot/log console (`hvc1`); and `--microvm-sandbox-block` exposes fixed distro, runtime, custom, and scratch slots starting at MMIO @@ -71,6 +72,25 @@ describes the source definitions. boot-before-control discovery order and rejects user overrides that could change it. + `--microvm-image-slots` selects microVM ABI 3 and declares four permanent + read-only image-slot transports named `image0` through `image3`. Their MMIO + windows are `0xd0008000` through `0xd000b000`, their edge-triggered IRQs are + 1, 13, 14, and 15, and their shared interrupt-status words are at offsets + `0x20` through `0x2c` in the existing shared-status page. The first slot + uses the second virtio-fs slot's window and status word, and the second slot + its IRQ 13, so a machine with image slots accepts at most one `--mount`. The + `microvm_image_slots=4` discovery token records the fixed capacity. + `--microvm-image-slot-boot-count ` selects the active prefix and + defaults to one. Active slots are empty virtio-blk devices with 512-byte + logical blocks; inactive slots report virtio device ID 0. A machine that + omits `--microvm-image-slots` remains ABI 2 with the original device + topology. + + Image slots require `--microvm-host-control listen=` and + `--microvm-control-auth-stdin`. The host-control service uses the same + capability and local peer identity as the control console. See + [Host-control Protocol](./host_control_protocol.md). + `microvm` uses one socket and one die, with one core per vCPU, no SMT, xAPIC mode, and contiguous APIC IDs from 0. Guest-requested snapshot capture and new-process restore are available for @@ -88,7 +108,8 @@ describes the source definitions. ```admonish warning title="microVM migration" The canonical `microvm` spelling now selects the contract formerly exposed as `microvm-v2`; the `microvm-v2` selector and the former ABI-v1 behavior are - removed. Snapshot ABI and boot layout remain numeric value 2. + removed. Machines without image slots retain snapshot ABI 2. Declaring + image slots selects snapshot ABI 3; the boot-layout version is unchanged. ``` * `--net `: With `--machine microvm`, attach one virtio-net NIC at MMIO `0xd0000000`. KVM and MSHV use IRQ 10; WHP uses IRQ 5. Prefixes @@ -200,7 +221,8 @@ describes the source definitions. * `--mount `: With `--machine microvm`, attach one no-DAX HostFs device at MMIO `0xd0001000`, IRQ 6, with tag `microvm`. Repeat it once to attach a second device at MMIO `0xd0008000`, IRQ 13, with - tag `microvm1`; each share has its own guest target, access mode, and + tag `microvm1`, unless `--microvm-image-slots` claims that window and IRQ; + each share has its own guest target, access mode, and denied paths. The default mode is read-only; `rw` must be explicit. The guest target must be an absolute, non-root Linux path without dot, parent, empty, whitespace, backslash, or `=` components. Guest targets and host @@ -405,8 +427,9 @@ describes the source definitions. * `--restore-entropy`: Accepted for compatibility; it has no effect. Every microVM restore makes restore packet version 4 available on the private portb restore channel. The packet carries 64 bytes of fresh entropy, the - online-VP target, the memory-expansion ranges, the downtime, and the TSC - rate deviation; the guest must consume it and explicitly reseed its RNG. + online-VP and image-slot targets, the memory-expansion ranges, the downtime, + and the TSC rate deviation; the guest must consume it and explicitly reseed + its RNG. [Snapshots](../../../user_guide/openvmm/snapshots.md) documents its format, the portb status bits, and the time sample. Every microVM portb device also reports generation-ID support in status bit @@ -427,6 +450,11 @@ describes the source definitions. validating the full saved VP inventory; that reduced-prefix process cannot be saved again. MSHV restores without this option, and KVM and WHP restores, instantiate the full VP capacity. +* `--restore-image-slots `: For an image-slot (ABI 3) microVM snapshot, + activate the contiguous slot prefix `image0` through `image{COUNT-1}` before + restore readiness. `COUNT` must satisfy `boot-active <= COUNT <= 4`. This + option sets the restore packet's image-slot target and implies the + post-restore gate. Snapshots without image slots reject it. * `--restore-gate-timeout-ms `: Bound microVM guest repair and gate acknowledgement after restore. The default is 60000 milliseconds. * `--cpu-profile `: Select the microVM's pinned CPU profile, `auto` diff --git a/Guide/src/reference/openvmm/management/grpc.md b/Guide/src/reference/openvmm/management/grpc.md index 5a6cbb6b921..71f2a23a3c1 100644 --- a/Guide/src/reference/openvmm/management/grpc.md +++ b/Guide/src/reference/openvmm/management/grpc.md @@ -158,6 +158,11 @@ restore flow over both transports: rejected for snapshots without a RAM capacity contract. A nonzero value sets the memory-target flag of restore packet version 4, with the expansion ranges (none at the base size); zero leaves the flag clear. +* `restore_image_slot_count` requests restore-time activation of the + contiguous image-slot prefix, as for `--restore-image-slots`. Zero preserves + the captured boot prefix. A nonzero value is valid only for an image-slot + snapshot, sets the restore packet's image-slot target, and implies the + post-restore gate. * `restore_gate_timeout_ms` bounds gated guest repair. Zero selects the 60-second default; a nonzero value is valid only with `restore_path`. * `restore_ready_path` names an existing Unix domain socket on Linux or a diff --git a/Guide/src/reference/openvmm/management/host_control_protocol.md b/Guide/src/reference/openvmm/management/host_control_protocol.md new file mode 100644 index 00000000000..23834e48820 --- /dev/null +++ b/Guide/src/reference/openvmm/management/host_control_protocol.md @@ -0,0 +1,36 @@ +# Host-control Protocol + +The microVM host-control service is a host-only, reconnectable endpoint for +versioned runtime operations. Linux uses an owner-only Unix socket in an +owner-only directory. Windows uses a named pipe restricted to the local +system and the OpenVMM process owner. OpenVMM also verifies the connected +peer's UID or SID. + +The endpoint is selected with `--microvm-host-control listen=`. +`--microvm-control-auth-stdin` supplies one exact, nonzero 32-byte capability +through a prepared one-way pipe. The same capability authenticates the guest +control console when both endpoints are enabled. It is never accepted through +argv or the environment. + +After connecting, the client writes the raw 32-byte capability. Authentication +must complete within the configured control-auth timeout. Requests and +responses then use a little-endian `u32` byte length followed by UTF-8 JSON. +Frames are limited to 64 KiB, and an idle connection closes after 60 seconds. +Every request contains `version: 1`, a caller-selected `request_id`, and an +`operation`. Responses repeat the protocol version and request ID and contain +either `status: "ok"` or a stable error code and message. + +Protocol version 1 defines: + +- `query_image_slots`: returns all four slots as `inactive`, `empty`, or + `bound`, including the recorded identity and capacity. +- `bind_image_slot`: supplies `slot`, `path`, and `identity`. OpenVMM opens the + path itself as a read-only, non-symlink regular file. The active empty slot + accepts exactly one 512-byte-aligned image, updates capacity, and raises a + virtio configuration-change interrupt. Repeating the same identity is + idempotent. A different identity, inactive slot, invalid geometry, or + lifecycle transition fails explicitly. + +There is no unbind, eject, rebind, activation, device-add, or device-remove +operation. Media remains open until the VM exits. Snapshot capture and other +lifecycle transitions exclude binding. diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index 1b5b6f1f0db..bb15421fa70 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -192,9 +192,10 @@ from the data port. Its 32-byte little-endian header holds: - the magic `OVR` and version 4; - flags: bit 0, the downtime came from host UTC; bit 1, a memory target; bit 2, the guest must acknowledge the restore through port `0x605`; bit 3, - a test hook is active; -- the online-VP target (0 for none), the memory-range count, and a reserved - zero byte; + a test hook is active; bit 4, an image-slot target; +- the online-VP target (0 for none), the memory-range count, and the active + image-slot target (0 for none; flag bit 4 is set exactly when it is + nonzero); - the generation counter (`u32`), which is 0 at cold boot and one more than the snapshot's at each restore; - the TSC rate deviation (`i32`, ppm scaled by 2^16); @@ -236,6 +237,20 @@ Snapshots without the explicit capture-time `maxcpus` opt-in reject a restore target. This is not a post-readiness hotplug API and cannot add VPs absent from the saved topology. +MicroVM ABI 3 snapshots record a fixed capacity of four image slots and the +active cold-boot prefix. Capture is accepted only while every active slot is +empty; a bound slot rejects capture before guest execution is quiesced. +`--restore-image-slots ` may activate a larger contiguous prefix before +restore readiness and must satisfy +`boot-active <= COUNT <= 4`. The restore packet carries the target in header +byte 7 and sets flag bit 4; no status bit advertises it. An +explicit target always holds the restore gate, so the packet also sets flag +bit 2: the guest verifies the active prefix and acknowledges, even when no new +slot becomes active. +Restored slots always begin empty, and no slot can be activated after +readiness. ABI 2 snapshots have no image-slot fields, and their restore packet +leaves header byte 7 zero. + ```admonish warning Snapshots do not contain or validate embedded checksums for `state.bin` or `memory.bin`. Restore still requires regular files, bounded diff --git a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs index 420daafb8b8..8add589a93a 100644 --- a/openvmm/openvmm_core/src/worker/dispatch/microvm.rs +++ b/openvmm/openvmm_core/src/worker/dispatch/microvm.rs @@ -66,6 +66,7 @@ impl From for MicrovmManifest { network: _, filesystems: _, sandbox_blocks, + image_slots: _, filesystem_bootstrap: _, memory_capacity, snapshot_memory_ranges, @@ -476,6 +477,7 @@ fn virtio_mmio_config( sandbox_blocks: &[openvmm_defs::microvm::MicrovmSandboxBlockConfig], sandbox_block_index: &mut usize, filesystem_index: &mut usize, + image_slot_index: &mut usize, chipset_mmio: ChipsetMmioRanges, ) -> anyhow::Result<(u64, u64, u32, u64, VirtioMmioInterruptMode)> { let (start, irq) = match id { @@ -506,6 +508,15 @@ fn virtio_mmio_config( *sandbox_block_index += 1; (block.role.mmio_base(), block.role.irq()) } + "virtio-blk-image-slot" => { + let index = *image_slot_index; + let base = *openvmm_defs::microvm::MICROVM_IMAGE_SLOT_MMIO_BASES + .get(index) + .context("microVM has too many image-slot devices")?; + let irq = openvmm_defs::microvm::MICROVM_IMAGE_SLOT_IRQS[index]; + *image_slot_index += 1; + (base, irq) + } _ => anyhow::bail!("unsupported microVM virtio device '{id}' reached worker construction"), }; let len = openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN; @@ -526,6 +537,7 @@ fn virtio_mmio_config( .context("microVM block slot has no role")?; !openvmm_defs::microvm::microvm_sandbox_block_features(block.role) } + "virtio-blk-image-slot" => !openvmm_defs::microvm::microvm_image_slot_features(), _ => 1 << 34, }; let interrupt_mode = VirtioMmioInterruptMode::SharedStatus { @@ -541,6 +553,7 @@ pub(super) struct VirtioMmioSlots<'a> { sandbox_blocks: &'a [openvmm_defs::microvm::MicrovmSandboxBlockConfig], sandbox_block_index: usize, filesystem_index: usize, + image_slot_index: usize, chipset_mmio: ChipsetMmioRanges, } @@ -553,6 +566,7 @@ impl<'a> VirtioMmioSlots<'a> { sandbox_blocks, sandbox_block_index: 0, filesystem_index: 0, + image_slot_index: 0, chipset_mmio, } } @@ -572,6 +586,7 @@ impl<'a> VirtioMmioSlots<'a> { self.sandbox_blocks, &mut self.sandbox_block_index, &mut self.filesystem_index, + &mut self.image_slot_index, self.chipset_mmio, )?; let id = format!("{id}-{mmio_start}"); diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index be459da22cc..070082a5fd2 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -23,6 +23,10 @@ use vmotherboard::options::BaseChipsetManifest; /// The persisted microVM ABI version. pub const MICROVM_ABI_VERSION_2: u32 = 2; +/// MicroVM ABI with four bind-once image slots. +pub const MICROVM_ABI_VERSION_3: u32 = 3; +/// Fixed image-slot capacity of microVM ABI 3. +pub const MICROVM_IMAGE_SLOT_CAPACITY: u8 = 4; /// Linux memory-block granularity used for microVM restore-time expansion. pub const MICROVM_MEMORY_BLOCK_SIZE_BYTES: u64 = 128 * 1024 * 1024; @@ -49,6 +53,16 @@ pub const MICROVM_VIRTIO_CONSOLE_MMIO_BASE: u64 = 0xd000_2000; pub const MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE: u64 = 0xd000_7000; /// Fixed MMIO base of the second microVM virtio-fs slot. pub const MICROVM_VIRTIO_FS1_MMIO_BASE: u64 = 0xd000_8000; +/// Fixed microVM image-slot MMIO bases in slot order. +/// +/// The first image slot uses the second virtio-fs slot's window, so a VM with +/// image slots attaches at most one filesystem. +pub const MICROVM_IMAGE_SLOT_MMIO_BASES: [u64; MICROVM_IMAGE_SLOT_CAPACITY as usize] = [ + MICROVM_VIRTIO_FS1_MMIO_BASE, + 0xd000_9000, + 0xd000_a000, + 0xd000_b000, +]; /// Fixed microVM virtio transport window length. pub const MICROVM_VIRTIO_MMIO_LEN: u64 = 0x1000; /// Fixed distro virtio-blk interrupt. @@ -70,12 +84,18 @@ pub const MICROVM_VIRTIO_FS_IRQ: u32 = 6; /// Fixed interrupt of the second microVM virtio-fs slot. /// /// The MP table routes only ISA interrupts, and no other device of any -/// backend uses IRQ 13. +/// backend uses IRQ 13 except the second image slot, which never coexists +/// with this slot. pub const MICROVM_VIRTIO_FS1_IRQ: u32 = 13; /// Fixed microVM virtio-net interrupt on KVM. pub const MICROVM_VIRTIO_NET_KVM_IRQ: u32 = 10; /// Fixed microVM virtio-net interrupt on WHP. pub const MICROVM_VIRTIO_NET_WHP_IRQ: u32 = 5; +/// Fixed image-slot interrupts in slot order. +/// +/// The second image slot uses the second virtio-fs slot's interrupt. +pub const MICROVM_IMAGE_SLOT_IRQS: [u32; MICROVM_IMAGE_SLOT_CAPACITY as usize] = + [1, MICROVM_VIRTIO_FS1_IRQ, 14, 15]; /// Exact microVM virtio-net feature mask: MAC and virtio version 1. pub const MICROVM_VIRTIO_NET_FEATURES: u64 = (1 << 5) | (1 << 32); /// Exact microVM virtio-fs feature mask: indirect descriptors, event index, @@ -169,6 +189,10 @@ pub const MICROVM_VIRTIO_SCRATCH_BLK_STATUS_OFFSET: u64 = 0x18; pub const MICROVM_VIRTIO_CONTROL_CONSOLE_STATUS_OFFSET: u64 = 0x1c; /// Shared-status offset for the second virtio-fs slot. pub const MICROVM_VIRTIO_FS1_STATUS_OFFSET: u64 = 0x20; +/// Shared-status offsets for image slots in slot order. The first image slot +/// shares the second virtio-fs slot's window and word. +pub const MICROVM_IMAGE_SLOT_STATUS_OFFSETS: [u64; MICROVM_IMAGE_SLOT_CAPACITY as usize] = + [MICROVM_VIRTIO_FS1_STATUS_OFFSET, 0x24, 0x28, 0x2c]; /// Returns the shared interrupt-status word for a fixed virtio-mmio slot. pub const fn microvm_virtio_status_gpa(mmio_base: u64) -> Option { @@ -181,7 +205,11 @@ pub const fn microvm_virtio_status_gpa(mmio_base: u64) -> Option { 0xd000_5000 => MICROVM_VIRTIO_CUSTOM_BLK_STATUS_OFFSET, 0xd000_6000 => MICROVM_VIRTIO_SCRATCH_BLK_STATUS_OFFSET, MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE => MICROVM_VIRTIO_CONTROL_CONSOLE_STATUS_OFFSET, + // Also the first image slot's window and word. MICROVM_VIRTIO_FS1_MMIO_BASE => MICROVM_VIRTIO_FS1_STATUS_OFFSET, + 0xd000_9000 => MICROVM_IMAGE_SLOT_STATUS_OFFSETS[1], + 0xd000_a000 => MICROVM_IMAGE_SLOT_STATUS_OFFSETS[2], + 0xd000_b000 => MICROVM_IMAGE_SLOT_STATUS_OFFSETS[3], _ => return None, }; Some(MICROVM_SHARED_STATUS_PAGE_GPA + offset) @@ -270,6 +298,44 @@ pub const fn microvm_sandbox_block_features(role: MicrovmSandboxBlockRole) -> u6 } } +/// Returns the fixed read-only feature mask of an active image slot. +pub const fn microvm_image_slot_features() -> u64 { + microvm_sandbox_block_features(MicrovmSandboxBlockRole::Distro) +} + +/// Returns the stable image-slot identity. +pub fn microvm_image_slot_name(index: u8) -> anyhow::Result { + anyhow::ensure!( + index < MICROVM_IMAGE_SLOT_CAPACITY, + "microVM image slot index {index} exceeds capacity {MICROVM_IMAGE_SLOT_CAPACITY}" + ); + Ok(format!("image{index}")) +} + +/// Guest-visible activation contract for microVM ABI 3 image slots. +#[derive(MeshPayload, Debug, Clone, Copy, PartialEq, Eq)] +pub struct MicrovmImageSlotsConfig { + /// Number of slots active on the captured cold boot. + pub boot_count: u8, + /// Number of slots instantiated for this launch. + pub active_count: u8, +} + +impl MicrovmImageSlotsConfig { + /// Validates the boot and launch activation counts. + pub fn validate(self) -> anyhow::Result<()> { + anyhow::ensure!( + (1..=MICROVM_IMAGE_SLOT_CAPACITY).contains(&self.boot_count), + "microVM image-slot boot count must be between 1 and {MICROVM_IMAGE_SLOT_CAPACITY}" + ); + anyhow::ensure!( + (self.boot_count..=MICROVM_IMAGE_SLOT_CAPACITY).contains(&self.active_count), + "microVM image-slot active count must be between the boot count and {MICROVM_IMAGE_SLOT_CAPACITY}" + ); + Ok(()) + } +} + /// The immutable role and access mode of a microVM sandbox block device. #[derive(MeshPayload, Clone, Copy, Debug, PartialEq, Eq)] pub struct MicrovmSandboxBlockConfig { @@ -798,8 +864,32 @@ fn validate_microvm_virtio_reservations() -> anyhow::Result<()> { anyhow::ensure!(end <= *next, "microVM virtio MMIO reservations overlap"); } } + // The first image slot reuses the second virtio-fs slot's window; the + // others follow every fixed reservation. + anyhow::ensure!( + MICROVM_IMAGE_SLOT_MMIO_BASES[0] == MICROVM_VIRTIO_FS1_MMIO_BASE + && MICROVM_IMAGE_SLOT_STATUS_OFFSETS[0] == MICROVM_VIRTIO_FS1_STATUS_OFFSET, + "the first microVM image slot must reuse the second virtio-fs slot's window" + ); + let fixed_end = bases.iter().max().copied().unwrap_or_default() + MICROVM_VIRTIO_MMIO_LEN; + for (index, base) in MICROVM_IMAGE_SLOT_MMIO_BASES.iter().copied().enumerate() { + let end = base + .checked_add(MICROVM_VIRTIO_MMIO_LEN) + .ok_or_else(|| anyhow::anyhow!("microVM image-slot MMIO reservation overflows"))?; + anyhow::ensure!( + base >= 0xc000_0000 && end <= 0x1_0000_0000, + "microVM image-slot MMIO reservation {index} is outside the fixed aperture" + ); + anyhow::ensure!( + index == 0 || base >= fixed_end, + "microVM image-slot MMIO reservation {index} overlaps a fixed reservation" + ); + if let Some(next) = MICROVM_IMAGE_SLOT_MMIO_BASES.get(index + 1) { + anyhow::ensure!(end <= *next, "microVM image-slot MMIO reservations overlap"); + } + } let mut previous_status_gpa = None; - for base in bases { + for base in bases.iter().chain(&MICROVM_IMAGE_SLOT_MMIO_BASES[1..]) { let status_gpa = microvm_virtio_status_gpa(*base).ok_or_else(|| { anyhow::anyhow!("microVM virtio MMIO slot {base:#x} has no shared-status word") })?; @@ -896,7 +986,8 @@ pub fn microvm_filesystem_slot_count(first_slot: bool, attached: usize) -> anyho /// /// `filesystem_slot` reserves the first virtio-fs slot, and `filesystems` /// occupy the virtio-fs slots in order. A virtio-fs slot after the first is -/// discovered only with a filesystem attached. +/// discovered only with a filesystem attached. `image_slots` adds the image +/// slots, which use the second virtio-fs slot's window and interrupt. pub fn append_microvm_virtio_discovery( cmdline: &mut String, network: Option<(&MicrovmNetworkConfig, u32, bool)>, @@ -905,8 +996,12 @@ pub fn append_microvm_virtio_discovery( has_console: bool, has_control_console: bool, blocks: &[MicrovmSandboxBlockConfig], + image_slots: Option, ) -> anyhow::Result<()> { validate_microvm_sandbox_blocks(blocks, ramfs_overlay_requested(cmdline)?)?; + if let Some(image_slots) = image_slots { + image_slots.validate()?; + } anyhow::ensure!( !cmdline.split_ascii_whitespace().any(|token| { if has_control_console { @@ -919,6 +1014,10 @@ pub fn append_microvm_virtio_discovery( ); let filesystem_slots = microvm_filesystem_slot_count(filesystem_slot, filesystems.len())?; validate_microvm_filesystems(filesystems)?; + anyhow::ensure!( + image_slots.is_none() || filesystem_slots <= 1, + "microVM image slots use the second virtio-fs slot's window and interrupt" + ); use std::fmt::Write as _; if let Some((_, irq, _)) = network { @@ -969,6 +1068,19 @@ pub fn append_microvm_virtio_discovery( { write!(cmdline, " {}", slot.discovery_token())?; } + if image_slots.is_some() { + for (index, base) in MICROVM_IMAGE_SLOT_MMIO_BASES.iter().enumerate() { + write!( + cmdline, + " virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{base:#x}:{}", + MICROVM_IMAGE_SLOT_IRQS[index] + )?; + } + write!( + cmdline, + " microvm_image_slots={MICROVM_IMAGE_SLOT_CAPACITY}" + )?; + } if let Some((network, _, gateway_dns)) = network { write!( cmdline, @@ -1009,6 +1121,10 @@ fn validate_microvm_filesystem_devices(config: &Config) -> anyhow::Result filesystem_count <= filesystems.len().max(1), "microVM exposes a virtio-fs slot after the first only with a filesystem attached" ); + anyhow::ensure!( + config.microvm.image_slots.is_none() || filesystem_count <= 1, + "microVM image slots use the second virtio-fs slot's window and interrupt" + ); validate_microvm_filesystems(filesystems)?; anyhow::ensure!( !config.microvm.filesystem_bootstrap || !filesystems.is_empty(), @@ -1056,6 +1172,11 @@ fn validate_microvm_command_line( .iter() .filter(|(_, device)| device.id() == "virtio-blk") .count(); + let image_slot_count = config + .virtio_devices + .iter() + .filter(|(_, device)| device.id() == "virtio-blk-image-slot") + .count(); let has_network = config .virtio_devices .iter() @@ -1073,6 +1194,18 @@ fn validate_microvm_command_line( block_count == config.microvm.sandbox_blocks.len(), "microVM sandbox block roles do not match the virtio-blk device inventory" ); + if let Some(image_slots) = config.microvm.image_slots { + image_slots.validate()?; + anyhow::ensure!( + image_slot_count == MICROVM_IMAGE_SLOT_CAPACITY as usize, + "microVM ABI 3 requires exactly {MICROVM_IMAGE_SLOT_CAPACITY} image-slot transports" + ); + } else { + anyhow::ensure!( + image_slot_count == 0, + "microVM ABI 2 does not permit image-slot transports" + ); + } anyhow::ensure!( !has_control_console || has_console, "microVM control console requires the boot console" @@ -1098,6 +1231,7 @@ fn validate_microvm_command_line( "virtfs_dir=", "virtfs_tag=", "virtfs_mode=", + "microvm_image_slots=", ] { let count = if prefix == "virtio_mmio.device=" && has_control_console { tokens @@ -1120,6 +1254,7 @@ fn validate_microvm_command_line( 0 } } + "microvm_image_slots=" => usize::from(config.microvm.image_slots.is_some()), _ => 1, }; anyhow::ensure!( @@ -1182,6 +1317,17 @@ fn validate_microvm_command_line( .skip(1) .map(MicrovmFilesystemSlot::discovery_token), ); + if config.microvm.image_slots.is_some() { + expected_discovery.extend( + MICROVM_IMAGE_SLOT_MMIO_BASES + .iter() + .zip(MICROVM_IMAGE_SLOT_IRQS) + .map(|(base, irq)| { + format!("virtio_mmio.device={MICROVM_VIRTIO_MMIO_LEN:#x}@{base:#x}:{irq}") + }), + ); + expected_discovery.push(format!("microvm_image_slots={MICROVM_IMAGE_SLOT_CAPACITY}")); + } if let Some(network) = &config.microvm.network { expected_discovery.extend( network @@ -1357,7 +1503,8 @@ fn build_microvm_command_line_inner( || (reserve_control_console && (kernel_parameter_name_matches(token, "nvx_control_tty") || kernel_parameter_name_matches(token, "driver_async_probe") - || kernel_parameter_name_matches(token, "virtio_mmio.device"))) + || kernel_parameter_name_matches(token, "virtio_mmio.device") + || kernel_parameter_name_matches(token, "microvm_image_slots"))) }) { anyhow::bail!( "microVM kernel command line cannot override profile-owned configuration" @@ -1402,6 +1549,8 @@ pub struct MicrovmConfig { pub filesystems: Vec, /// Stable sandbox block-device roles in virtio-blk device order. pub sandbox_blocks: Vec, + /// Fixed-capacity image slots, when ABI 3 is selected. + pub image_slots: Option, /// Whether the effective command line bootstraps every attached microVM /// filesystem. pub filesystem_bootstrap: bool, @@ -1490,6 +1639,76 @@ fn validate_machine_load_mode( } } +struct MicrovmVirtioInventory { + has_network: bool, + has_console: bool, + has_control_console: bool, + block_count: usize, +} + +/// Classifies the virtio-mmio inventory. The fixed transports and, when +/// declared, the image-slot transports are the only permitted devices. The +/// first image slot uses the second virtio-fs slot's window, and virtio-fs +/// devices are counted against their slots separately. +fn microvm_virtio_inventory<'a>( + devices: impl ExactSizeIterator, + image_slots_declared: bool, +) -> anyhow::Result { + let image_slot_capacity = if image_slots_declared { + usize::from(MICROVM_IMAGE_SLOT_CAPACITY) + } else { + 0 + }; + let transport_capacity = if image_slots_declared { + MICROVM_VIRTIO_MMIO_BASES.len() - 1 + image_slot_capacity + } else { + MICROVM_VIRTIO_MMIO_BASES.len() + }; + anyhow::ensure!( + devices.len() <= transport_capacity, + "microVM has too many virtio devices" + ); + let mut inventory = MicrovmVirtioInventory { + has_network: false, + has_console: false, + has_control_console: false, + block_count: 0, + }; + let mut image_slot_count = 0; + for (bus, id) in devices { + anyhow::ensure!( + bus == VirtioBus::Mmio, + "microVM permits only virtio-mmio devices" + ); + match id { + "virtio-net" => anyhow::ensure!( + !std::mem::replace(&mut inventory.has_network, true), + "microVM permits only one virtio-net device" + ), + // Counted against the fixed slots by the filesystem validation. + "virtiofs" => {} + "virtio-console" => anyhow::ensure!( + !std::mem::replace(&mut inventory.has_console, true), + "microVM permits only one virtio-console device" + ), + MICROVM_VIRTIO_CONTROL_CONSOLE_ID => { + anyhow::ensure!( + !std::mem::replace(&mut inventory.has_control_console, true), + "microVM permits only one control console" + ); + } + "virtio-blk" => inventory.block_count += 1, + "virtio-blk-image-slot" if image_slots_declared => image_slot_count += 1, + id => anyhow::bail!("microVM does not permit virtio device '{id}'"), + } + } + anyhow::ensure!( + image_slot_count == image_slot_capacity, + "microVM requires exactly {image_slot_capacity} image-slot transports" + ); + Ok(inventory) +} + /// Validates the microVM machine contract. Standard-machine configurations are unchanged. pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> anyhow::Result<()> { let MachineProfile::Microvm = config.machine_profile else { @@ -1506,6 +1725,10 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> config.microvm.sandbox_blocks.is_empty(), "microVM sandbox block roles require the microVM profile" ); + anyhow::ensure!( + config.microvm.image_slots.is_none(), + "microVM image slots require the microVM profile" + ); anyhow::ensure!( config.microvm.memory_capacity.is_none() && config.microvm.snapshot_memory_ranges.is_empty() @@ -1656,40 +1879,18 @@ pub fn validate_machine_config(config: &Config, hypervisor_id: Option<&str>) -> "microVM does not support kernel NIC or VPCI resources" ); - anyhow::ensure!( - config.virtio_devices.len() <= MICROVM_VIRTIO_MMIO_BASES.len(), - "microVM has too many virtio devices" - ); - let mut has_network = false; - let mut has_console = false; - let mut has_control_console = false; - let mut block_count = 0; - for (bus, device) in &config.virtio_devices { - anyhow::ensure!( - *bus == VirtioBus::Mmio, - "microVM permits only virtio-mmio devices" - ); - match device.id() { - "virtio-net" => anyhow::ensure!( - !std::mem::replace(&mut has_network, true), - "microVM permits only one virtio-net device" - ), - // Counted against the fixed slots below. - "virtiofs" => {} - "virtio-console" => anyhow::ensure!( - !std::mem::replace(&mut has_console, true), - "microVM permits only one virtio-console device" - ), - MICROVM_VIRTIO_CONTROL_CONSOLE_ID => { - anyhow::ensure!( - !std::mem::replace(&mut has_control_console, true), - "microVM permits only one control console" - ); - } - "virtio-blk" => block_count += 1, - id => anyhow::bail!("microVM does not permit virtio device '{id}'"), - } - } + let MicrovmVirtioInventory { + has_network, + has_console, + has_control_console, + block_count, + } = microvm_virtio_inventory( + config + .virtio_devices + .iter() + .map(|(bus, device)| (*bus, device.id())), + config.microvm.image_slots.is_some(), + )?; anyhow::ensure!( !has_control_console || has_console, "microVM control console requires the boot console" @@ -1839,7 +2040,8 @@ mod tests { validate_microvm_control_console_command_line(&tokens, false).unwrap(); let mut cmdline = cmdline; - append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, false, &[]).unwrap(); + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, false, &[], None) + .unwrap(); assert!(build_microvm_control_command_line(&user_args, true).is_err()); } @@ -1860,7 +2062,7 @@ mod tests { let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); cmdline.push_str(" virtio-mmio.device=0x1000@0xc0000000:1"); assert!( - append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, true, &[]) + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, true, &[], None) .is_err() ); } @@ -1911,8 +2113,17 @@ mod tests { validate_microvm_sandbox_blocks(&blocks, false).unwrap(); let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); - append_microvm_virtio_discovery(&mut cmdline, None, false, &[], false, false, &blocks) - .unwrap(); + append_microvm_virtio_discovery( + &mut cmdline, + None, + false, + &[], + false, + false, + &blocks, + None, + ) + .unwrap(); assert_eq!( cmdline, format!( @@ -1928,7 +2139,8 @@ mod tests { #[test] fn microvm_control_console_slot_is_stable() { let mut cmdline = MICROVM_CONSOLE_COMMAND_LINE.to_owned(); - append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, true, &[]).unwrap(); + append_microvm_virtio_discovery(&mut cmdline, None, false, &[], true, true, &[], None) + .unwrap(); assert_eq!( cmdline, format!( @@ -1944,6 +2156,7 @@ mod tests { fn microvm_block_irqs_avoid_rtc_and_are_edge_triggered() { assert_eq!(MICROVM_VIRTIO_CONTROL_CONSOLE_IRQ, 3); assert_eq!(MICROVM_VIRTIO_RUNTIME_BLK_IRQ, 12); + assert_eq!(MICROVM_IMAGE_SLOT_IRQS, [1, 13, 14, 15]); assert!(MICROVM_LEVEL_TRIGGERED_IRQS.is_empty()); } @@ -1959,6 +2172,10 @@ mod tests { (MICROVM_VIRTIO_SANDBOX_BLOCK_MMIO_BASES[3], 0x3_0018), (MICROVM_VIRTIO_CONTROL_CONSOLE_MMIO_BASE, 0x3_001c), (MICROVM_VIRTIO_FS1_MMIO_BASE, 0x3_0020), + (MICROVM_IMAGE_SLOT_MMIO_BASES[0], 0x3_0020), + (MICROVM_IMAGE_SLOT_MMIO_BASES[1], 0x3_0024), + (MICROVM_IMAGE_SLOT_MMIO_BASES[2], 0x3_0028), + (MICROVM_IMAGE_SLOT_MMIO_BASES[3], 0x3_002c), ]; for (mmio_base, expected_gpa) in slots { assert_eq!(microvm_virtio_status_gpa(mmio_base), Some(expected_gpa)); @@ -1967,7 +2184,7 @@ mod tests { expected_gpa < MICROVM_SHARED_STATUS_PAGE_GPA + MICROVM_SHARED_STATUS_PAGE_SIZE ); } - assert_eq!(microvm_virtio_status_gpa(0xd000_9000), None); + assert_eq!(microvm_virtio_status_gpa(0xd000_c000), None); } #[test] @@ -2227,6 +2444,7 @@ mod tests { true, true, &[], + None, ) .unwrap(); assert_eq!( @@ -2255,6 +2473,7 @@ mod tests { false, false, &[], + None, ) .unwrap(); assert_eq!( @@ -2274,7 +2493,8 @@ mod tests { &filesystems, false, false, - &[] + &[], + None ) .is_err() ); @@ -2291,7 +2511,8 @@ mod tests { &overlapping, false, false, - &[] + &[], + None ) .is_err() ); @@ -2406,8 +2627,17 @@ mod tests { assert!(validate_microvm_sandbox_blocks(&[distro, scratch], true).is_err()); let mut cmdline = format!("{MICROVM_BASE_COMMAND_LINE} nvx_overlay_upper=ramfs"); - append_microvm_virtio_discovery(&mut cmdline, None, false, &[], false, false, &[distro]) - .unwrap(); + append_microvm_virtio_discovery( + &mut cmdline, + None, + false, + &[], + false, + false, + &[distro], + None, + ) + .unwrap(); assert!(cmdline.contains("virtio_mmio.device=0x1000@0xd0003000:4")); assert!(!cmdline.contains("virtio_mmio.device=0x1000@0xd0006000:11")); @@ -2420,7 +2650,111 @@ mod tests { &[], false, false, - &[distro, scratch] + &[distro, scratch], + None, + ) + .is_err() + ); + } + + #[test] + fn microvm_virtio_inventory_admits_declared_image_slots() { + const FIXED: [&str; 8] = [ + "virtio-net", + "virtiofs", + "virtio-console", + MICROVM_VIRTIO_CONTROL_CONSOLE_ID, + "virtio-blk", + "virtio-blk", + "virtio-blk", + "virtio-blk", + ]; + const SLOT: &str = "virtio-blk-image-slot"; + let inventory = |ids: &[&'static str], declared: bool| { + microvm_virtio_inventory(ids.iter().map(|id| (VirtioBus::Mmio, *id)), declared) + }; + + let full = [&FIXED[..], &[SLOT; 4]].concat(); + let classified = inventory(&full, true).unwrap(); + assert!(classified.has_network); + assert!(classified.has_console && classified.has_control_console); + assert_eq!(classified.block_count, 4); + assert!(inventory(&["virtio-console", SLOT, SLOT, SLOT, SLOT], true).is_ok()); + assert!(inventory(&FIXED, false).is_ok()); + // Without image slots, the second virtio-fs slot's window is free. + assert!(inventory(&[&FIXED[..], &["virtiofs"]].concat(), false).is_ok()); + + assert!(inventory(&["virtio-console", SLOT, SLOT, SLOT, SLOT], false).is_err()); + assert!(inventory(&["virtio-console", SLOT, SLOT, SLOT], true).is_err()); + assert!(inventory(&["virtio-console"], true).is_err()); + assert!(inventory(&[&FIXED[..], &[SLOT; 5]].concat(), true).is_err()); + // The first image slot uses the second virtio-fs slot's window. + assert!(inventory(&[&full[..], &["virtiofs"]].concat(), true).is_err()); + assert!(inventory(&[&FIXED[..], &["virtio-blk", "virtio-blk"]].concat(), false).is_err()); + assert!(inventory(&["virtio-console", "virtio-rng"], false).is_err()); + assert!( + microvm_virtio_inventory([(VirtioBus::Pci, "virtio-console")].into_iter(), false) + .is_err() + ); + } + + #[test] + fn microvm_image_slots_share_the_second_filesystem_slot() { + assert_eq!( + MICROVM_IMAGE_SLOT_MMIO_BASES[0], + MICROVM_FILESYSTEM_SLOTS[1].mmio_base + ); + assert_eq!(MICROVM_IMAGE_SLOT_IRQS[1], MICROVM_FILESYSTEM_SLOTS[1].irq); + assert_eq!( + microvm_virtio_status_gpa(MICROVM_IMAGE_SLOT_MMIO_BASES[0]), + microvm_virtio_status_gpa(MICROVM_VIRTIO_FS1_MMIO_BASE) + ); + validate_microvm_virtio_reservations().unwrap(); + + let image_slots = Some(MicrovmImageSlotsConfig { + boot_count: 1, + active_count: 1, + }); + let filesystems = [ + filesystem("/workspace", MicrovmFilesystemAccess::ReadWrite), + filesystem("/opt/hostedtoolcache", MicrovmFilesystemAccess::ReadOnly), + ]; + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery( + &mut cmdline, + None, + true, + &filesystems[..1], + false, + false, + &[], + image_slots, + ) + .unwrap(); + assert_eq!( + cmdline, + format!( + "{MICROVM_BASE_COMMAND_LINE} virtio_mmio.device=0x1000@0xd0001000:6 \ + virtio_mmio.device=0x1000@0xd0008000:1 \ + virtio_mmio.device=0x1000@0xd0009000:13 \ + virtio_mmio.device=0x1000@0xd000a000:14 \ + virtio_mmio.device=0x1000@0xd000b000:15 \ + microvm_image_slots=4 \ + virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw" + ) + ); + + let mut cmdline = MICROVM_BASE_COMMAND_LINE.to_owned(); + assert!( + append_microvm_virtio_discovery( + &mut cmdline, + None, + true, + &filesystems, + false, + false, + &[], + image_slots, ) .is_err() ); diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index a4dfa710ff1..630e9e35c66 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -111,6 +111,26 @@ pub struct MicrovmLoopbackForwardCli { pub(crate) guest_port: u16, } +/// Host-only microVM control endpoint. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MicrovmHostControlCli { + pub(crate) path: PathBuf, +} + +impl FromStr for MicrovmHostControlCli { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let path = value + .strip_prefix("listen=") + .filter(|path| !path.is_empty()) + .context("expected listen=")?; + Ok(Self { + path: PathBuf::from(path), + }) + } +} + impl FromStr for MicrovmLoopbackForwardCli { type Err = anyhow::Error; @@ -273,6 +293,18 @@ impl From for MachineProfile { /// Options of the microVM machine profile. #[derive(clap::Args)] pub struct MicrovmCli { + /// Declare the four bind-once read-only image slots of microVM ABI 3. + #[clap(long)] + pub microvm_image_slots: bool, + + /// Number of image slots active at cold boot. + #[clap(long, value_name = "COUNT", requires = "microvm_image_slots")] + pub microvm_image_slot_boot_count: Option, + + /// Number of image slots active for this restore launch. + #[clap(long, value_name = "COUNT", requires = "restore_snapshot")] + pub restore_image_slots: Option, + /// Accepted for compatibility: every microVM restore exposes restore packet /// version 4, which carries fresh entropy, through the private portb restore /// channel. @@ -518,11 +550,14 @@ pub struct MicrovmCli { #[clap( long, hide = true, - requires("microvm_control_console"), conflicts_with_all = ["rpc", "ttrpc", "grpc", "relay_console_path", "write_saved_state_proto", "cpu_fingerprint", "paused"] )] pub microvm_control_auth_stdin: bool, + /// authenticated host-only control endpoint + #[clap(long, value_name = "listen=PATH")] + pub microvm_host_control: Option, + /// maximum time for a control-console client to authenticate #[clap( long = "microvm-control-auth-timeout-ms", @@ -670,14 +705,18 @@ impl Options { && self.microvm.microvm_lifecycle.is_none() && self.microvm.microvm_report.is_none() && self.microvm.restore_processors.is_none() + && !self.microvm.microvm_image_slots + && self.microvm.microvm_image_slot_boot_count.is_none() + && self.microvm.restore_image_slots.is_none() && self.microvm.restore_memory.is_none() && self.microvm.memory_capacity.is_none() && self.microvm.snapshot_block_identity.is_none() && self.microvm.snapshot_generation_id.is_none() && self.microvm.snapshot_scratch_restore_mode.is_none() && self.microvm.microvm_control_console.is_none() + && self.microvm.microvm_host_control.is_none() && !self.microvm.microvm_control_auth_stdin, - "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-workload-identity, --microvm-lifecycle, --microvm-control-console, --microvm-control-auth-stdin, --restore-processors, --restore-memory, --memory-capacity, snapshot block policy, and microVM network policy require a microVM machine" + "--network-profile, --net-tap, --mount, --microvm-sandbox-block, --microvm-image-slots, --microvm-image-slot-boot-count, --microvm-workload-identity, --microvm-lifecycle, --microvm-control-console, --microvm-host-control, --microvm-control-auth-stdin, --restore-processors, --restore-image-slots, --restore-memory, --memory-capacity, snapshot block policy, and microVM network policy require a microVM machine" ); return Ok(()); } @@ -699,6 +738,38 @@ impl Options { "microVM does not support {} vCPUs", self.processors ); + if self.microvm.microvm_image_slots { + openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: self.microvm.microvm_image_slot_boot_count.unwrap_or(1), + active_count: self.microvm.microvm_image_slot_boot_count.unwrap_or(1), + } + .validate()?; + anyhow::ensure!( + self.microvm.microvm_host_control.is_some(), + "microVM image slots require --microvm-host-control" + ); + anyhow::ensure!( + self.microvm.microvm_mount.len() <= 1, + "--microvm-image-slots uses the second virtio-fs slot's window; pass at most one --mount" + ); + } + if let Some(count) = self.microvm.restore_image_slots { + anyhow::ensure!( + (1..=openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY).contains(&count), + "restore image-slot count must be between 1 and {}", + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY + ); + } + anyhow::ensure!( + !self.microvm.microvm_control_auth_stdin + || self.microvm.microvm_control_console.is_some() + || self.microvm.microvm_host_control.is_some(), + "--microvm-control-auth-stdin requires a control console or host-control endpoint" + ); + anyhow::ensure!( + self.microvm.microvm_host_control.is_none() || self.microvm.microvm_control_auth_stdin, + "live microVM host control requires --microvm-control-auth-stdin" + ); anyhow::ensure!( self.numa.is_none() && self.numa_distance.is_none(), "microVM does not support custom NUMA topology" @@ -910,7 +981,8 @@ impl Options { ); if matches!(control_console, SerialConfigCli::None) { anyhow::ensure!( - !self.microvm.microvm_control_auth_stdin, + !self.microvm.microvm_control_auth_stdin + || self.microvm.microvm_host_control.is_some(), "--microvm-control-auth-stdin is not used with a disconnected control console" ); } else { @@ -925,8 +997,9 @@ impl Options { } } else { anyhow::ensure!( - !self.microvm.microvm_control_auth_stdin, - "--microvm-control-auth-stdin requires --microvm-control-console" + !self.microvm.microvm_control_auth_stdin + || self.microvm.microvm_host_control.is_some(), + "--microvm-control-auth-stdin requires --microvm-control-console or --microvm-host-control" ); } if self.microvm.microvm_lifecycle == Some(MicrovmLifecycleCli::Managed) { @@ -1294,6 +1367,67 @@ mod tests { assert!(Options::try_parse_from(["openvmm", "--machine", "unknown"]).is_err()); } + #[test] + fn parses_microvm_image_slot_options() { + let options = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-image-slots", + "--microvm-image-slot-boot-count", + "4", + "--microvm-host-control", + "listen=slot-control", + "--microvm-control-auth-stdin", + ]) + .unwrap(); + assert!(options.microvm.microvm_image_slots); + assert_eq!(options.microvm.microvm_image_slot_boot_count, Some(4)); + assert_eq!( + options.microvm.microvm_host_control.as_ref().unwrap().path, + PathBuf::from("slot-control") + ); + options.validate_microvm_options().unwrap(); + + let invalid = Options::try_parse_from([ + "openvmm", + "--machine", + "microvm", + "--microvm-image-slots", + "--microvm-image-slot-boot-count", + "0", + "--microvm-host-control", + "listen=slot-control", + "--microvm-control-auth-stdin", + ]) + .unwrap(); + assert!(invalid.validate_microvm_options().is_err()); + + // The first image slot uses the second virtio-fs slot's window. + let slots_with_mounts = |mounts: &[&'static str]| { + let mut args = vec![ + "openvmm", + "--machine", + "microvm", + "--microvm-image-slots", + "--microvm-host-control", + "listen=slot-control", + "--microvm-control-auth-stdin", + ]; + for mount in mounts { + args.extend(["--mount", mount]); + } + Options::try_parse_from(args).unwrap() + }; + slots_with_mounts(&["/workspace,work,rw"]) + .validate_microvm_options() + .unwrap(); + let err = slots_with_mounts(&["/workspace,work,rw", "/opt/hostedtoolcache,tools"]) + .validate_microvm_options() + .unwrap_err(); + assert!(err.to_string().contains("at most one --mount"), "{err:#}"); + } + #[test] fn test_microvm_processor_validation() { for processors in [1, 2, 4, 8] { @@ -1735,8 +1869,17 @@ mod tests { read_only: false, }, ]; - append_microvm_virtio_discovery(&mut with_devices, None, false, &[], true, false, &blocks) - .unwrap(); + append_microvm_virtio_discovery( + &mut with_devices, + None, + false, + &[], + true, + false, + &blocks, + None, + ) + .unwrap(); assert_eq!( with_devices, format!( @@ -1752,6 +1895,7 @@ mod tests { true, true, &[], + None, ) .unwrap(); assert_eq!( @@ -1779,6 +1923,7 @@ mod tests { false, false, &[], + None, ) .unwrap(); assert_eq!( @@ -1800,6 +1945,7 @@ mod tests { false, false, &[], + None, ) .unwrap(); assert!(with_whp_network.ends_with("virtnet_dns=10.0.0.1")); @@ -1818,6 +1964,7 @@ mod tests { false, false, &[], + None, ) .unwrap(); assert_eq!( @@ -2119,7 +2266,9 @@ mod tests { ] { assert!(Options::try_parse_from(args.into_iter().chain(extra)).is_err()); } - assert!(Options::try_parse_from(["openvmm", "--microvm-control-auth-stdin"]).is_err()); + let auth_only = + Options::try_parse_from(["openvmm", "--microvm-control-auth-stdin"]).unwrap(); + assert!(auth_only.validate_microvm_options().is_err()); assert!( Options::try_parse_from([ "openvmm", diff --git a/openvmm/openvmm_entry/src/microvm/config.rs b/openvmm/openvmm_entry/src/microvm/config.rs index 93d77f7e149..1c48186aeba 100644 --- a/openvmm/openvmm_entry/src/microvm/config.rs +++ b/openvmm/openvmm_entry/src/microvm/config.rs @@ -6,10 +6,12 @@ use super::MicrovmResources; use super::MicrovmRestore; use super::console::ConsoleEndpoint; +use super::console::MicrovmControlAuthentication; use super::console::effective_microvm_console; use super::console::effective_microvm_control_console; use super::console::microvm_console_attachments_share_endpoint; use super::console::microvm_console_socket_cleanup; +use super::console::microvm_control_authentication; use super::console::microvm_control_broker_config; use super::console::validate_microvm_console_attachment_namespace; use super::filesystem::EffectiveMicrovmFilesystem; @@ -70,11 +72,14 @@ pub(crate) struct MicrovmConfigBuilder<'a> { network: Option, filesystem_slot: bool, filesystems: Vec, + image_slots: Option, gateway_dns: bool, console: Option, control_console: Option, control_broker_config: Option, + control_authentication: Option, control_console_backend: Option>, + host_control: Option, portb: Option<(Resource, OutputCompletion)>, resources: MicrovmResources, } @@ -119,6 +124,46 @@ impl<'a> MicrovmConfigBuilder<'a> { } else { Vec::new() }; + let image_slots = if active { + if let Some(contract) = restore_machine_contract { + if contract.microvm_image_slot_capacity == 0 { + anyhow::ensure!( + opt.microvm.restore_image_slots.is_none(), + "snapshot does not declare restore-time image-slot activation support" + ); + None + } else { + let active_count = opt + .microvm + .restore_image_slots + .unwrap_or(contract.boot_active_image_slot_count); + let config = openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: contract.boot_active_image_slot_count, + active_count, + }; + config.validate()?; + Some(config) + } + } else if opt.microvm.microvm_image_slots { + let boot_count = opt.microvm.microvm_image_slot_boot_count.unwrap_or(1); + Some(openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count, + active_count: boot_count, + }) + } else { + None + } + } else { + None + }; + anyhow::ensure!( + image_slots.is_none() || opt.microvm.microvm_host_control.is_some(), + "microVM image slots require --microvm-host-control" + ); + anyhow::ensure!( + image_slots.is_none() || filesystems.len() <= 1, + "microVM image slots use the second virtio-fs slot's window; attach at most one filesystem" + ); if opt.microvm.snapshot_destination.is_some() { for filesystem in &filesystems { tracing::warn!( @@ -157,10 +202,21 @@ impl<'a> MicrovmConfigBuilder<'a> { } else { None }; + let live_control_console = control_console + .as_ref() + .is_some_and(|(config, _, _)| !matches!(config, SerialConfigCli::None)); + let control_authentication = + if active && (live_control_console || opt.microvm.microvm_host_control.is_some()) { + Some(microvm_control_authentication(opt)?) + } else { + None + }; opt.validate_control_stdin_console(console.as_ref().map(|(config, _, _)| config))?; let control_broker_config = control_console .as_ref() - .map(|(endpoint, _, _)| microvm_control_broker_config(opt, endpoint)) + .map(|(endpoint, _, _)| { + microvm_control_broker_config(opt, endpoint, control_authentication.as_ref()) + }) .transpose()?; anyhow::ensure!( control_console.is_none() || console.is_some(), @@ -218,11 +274,14 @@ impl<'a> MicrovmConfigBuilder<'a> { network, filesystem_slot, filesystems, + image_slots, gateway_dns, console, control_console, control_broker_config, + control_authentication, control_console_backend: None, + host_control: None, portb: None, resources, }) @@ -357,6 +416,19 @@ impl<'a> MicrovmConfigBuilder<'a> { _ => unreachable!("microVM control console backend was validated"), }; } + if let Some(host_control) = &self.opt.microvm.microvm_host_control { + let authentication = self + .control_authentication + .clone() + .context("microVM host control is missing shared authentication")?; + self.host_control = Some(super::host_control::MicrovmHostControlServer::bind( + &host_control.path, + serial_driver, + authentication, + )?); + self.resources.host_control_socket_cleanup = + microvm_console_socket_cleanup(host_control.path.clone())?; + } Ok(virtio_console_backend) } @@ -388,6 +460,7 @@ impl<'a> MicrovmConfigBuilder<'a> { ) .await?; } + storage.add_microvm_image_slots(self.image_slots)?; Ok(()) } @@ -403,9 +476,10 @@ impl<'a> MicrovmConfigBuilder<'a> { let (drain, output_drain) = MicrovmOutputDrain::new(Some(output_completion)); self.resources.output_drain = Some(drain); let (generation_id, time_abi_restore) = if self.restore.machine_contract.is_some() { - let (generation_id, base) = self - .restore - .time_abi_restore_packet(opt.microvm.restore_processors)?; + let (generation_id, base) = self.restore.time_abi_restore_packet( + opt.microvm.restore_processors, + opt.microvm.restore_image_slots, + )?; ( generation_id, Some(self.resources.time_abi_restore_packet(base)), @@ -676,6 +750,10 @@ impl<'a> MicrovmConfigBuilder<'a> { .map(|contract| contract.microvm_filesystem.is_some()) .unwrap_or_else(|| !microvm_filesystems.is_empty()); cfg.microvm.filesystems = microvm_filesystems; + anyhow::ensure!( + cfg.microvm.image_slots == self.image_slots, + "microVM image-slot configuration changed during storage construction" + ); cfg.microvm.memory_capacity = restore_machine_contract .and_then(|contract| { (contract.memory_expansion_version != 0).then_some(contract.memory_capacity_bytes) @@ -786,13 +864,26 @@ impl<'a> MicrovmConfigBuilder<'a> { has_console, has_control_console, &cfg.microvm.sandbox_blocks, + cfg.microvm.image_slots, )?; } openvmm_defs::microvm::validate_machine_config(cfg, requested_hypervisor)?; let sandbox_block_sources = std::mem::take(&mut resources.microvm.sandbox_block_sources); + let image_slot_requests = std::mem::take(&mut resources.microvm.image_slot_requests); + if let Some(host_control) = self.host_control.as_mut() { + host_control.set_image_slots( + image_slot_requests.clone(), + self.resources.image_slot_transition.clone(), + ); + } + self.resources.host_control = self + .host_control + .take() + .map(|server| std::sync::Arc::new(futures::lock::Mutex::new(server))); resources.microvm = MicrovmResources { sandbox_block_sources, + image_slot_requests, ..std::mem::take(&mut self.resources) }; Ok(()) diff --git a/openvmm/openvmm_entry/src/microvm/console.rs b/openvmm/openvmm_entry/src/microvm/console.rs index b77e459a46f..317d292ccd9 100644 --- a/openvmm/openvmm_entry/src/microvm/console.rs +++ b/openvmm/openvmm_entry/src/microvm/console.rs @@ -698,27 +698,37 @@ fn random_nonzero_bytes(description: &'static str) -> anyhow::Re } } -pub(super) fn microvm_control_broker_config( +#[derive(Clone)] +pub(crate) struct MicrovmControlAuthentication { + pub(super) capability: [u8; 32], + pub(super) expected_peer_identity: serial_core::LocalPeerIdentity, + pub(super) auth_timeout_ms: u64, +} + +pub(super) fn microvm_control_authentication( opt: &Options, - endpoint: &SerialConfigCli, -) -> anyhow::Result { - let capability = if matches!(endpoint, SerialConfigCli::None) { - random_nonzero_bytes("disconnected control-console capability")? - } else { - anyhow::ensure!( - opt.microvm.microvm_control_auth_stdin, - "live microVM control console requires --microvm-control-auth-stdin" - ); - #[cfg(any(target_os = "linux", windows))] - { - serial_io::microvm::read_control_capability_from_stdin() - .context("failed to read control-console authentication capability from stdin")? - } - #[cfg(not(any(target_os = "linux", windows)))] - { - anyhow::bail!("secure live microVM control consoles are unavailable on this platform") - } - }; +) -> anyhow::Result { + anyhow::ensure!( + opt.microvm.microvm_control_auth_stdin, + "live microVM control endpoints require --microvm-control-auth-stdin" + ); + #[cfg(any(target_os = "linux", windows))] + let capability = serial_io::microvm::read_control_capability_from_stdin() + .context("failed to read microVM control authentication capability from stdin")?; + #[cfg(not(any(target_os = "linux", windows)))] + anyhow::bail!("secure live microVM control endpoints are unavailable on this platform"); + + microvm_control_authentication_from_capability( + capability, + opt.microvm.microvm_control_auth_timeout_ms, + ) +} + +pub(crate) fn microvm_control_authentication_from_capability( + capability: [u8; 32], + auth_timeout_ms: u64, +) -> anyhow::Result { + anyhow::ensure!(capability != [0; 32], "control capability must not be zero"); #[cfg(target_os = "linux")] let expected_peer_identity = serial_core::LocalPeerIdentity::UnixUid(pal::unix::effective_user_id()); @@ -733,12 +743,38 @@ pub(super) fn microvm_control_broker_config( #[cfg(not(any(target_os = "linux", windows)))] let expected_peer_identity = serial_core::LocalPeerIdentity::Unsupported; + Ok(MicrovmControlAuthentication { + capability, + expected_peer_identity, + auth_timeout_ms, + }) +} + +pub(super) fn microvm_control_broker_config( + opt: &Options, + endpoint: &SerialConfigCli, + authentication: Option<&MicrovmControlAuthentication>, +) -> anyhow::Result { + let capability = if matches!(endpoint, SerialConfigCli::None) { + random_nonzero_bytes("disconnected control-console capability")? + } else { + authentication + .context("live microVM control console is missing shared authentication")? + .capability + }; + let expected_peer_identity = authentication + .map(|authentication| authentication.expected_peer_identity.clone()) + .unwrap_or(serial_core::LocalPeerIdentity::Unsupported); + Ok( virtio_resources::console::control::VirtioControlConsoleBrokerConfig { instance_id: random_nonzero_bytes("control-console instance ID")?, capability, expected_peer_identity, - auth_timeout_ms: opt.microvm.microvm_control_auth_timeout_ms, + auth_timeout_ms: authentication + .map_or(opt.microvm.microvm_control_auth_timeout_ms, |auth| { + auth.auth_timeout_ms + }), }, ) } @@ -1107,8 +1143,8 @@ mod tests { "none", ]) .unwrap(); - let first = microvm_control_broker_config(&options, &SerialConfigCli::None).unwrap(); - let second = microvm_control_broker_config(&options, &SerialConfigCli::None).unwrap(); + let first = microvm_control_broker_config(&options, &SerialConfigCli::None, None).unwrap(); + let second = microvm_control_broker_config(&options, &SerialConfigCli::None, None).unwrap(); assert_ne!(first.instance_id, [0; 16]); assert_ne!(second.instance_id, [0; 16]); assert_ne!(first.instance_id, second.instance_id); diff --git a/openvmm/openvmm_entry/src/microvm/filesystem.rs b/openvmm/openvmm_entry/src/microvm/filesystem.rs index f0a0963dedf..cf804e536e7 100644 --- a/openvmm/openvmm_entry/src/microvm/filesystem.rs +++ b/openvmm/openvmm_entry/src/microvm/filesystem.rs @@ -978,6 +978,7 @@ mod tests { false, false, &[], + None, ) .unwrap(); let mut state_unit_names = [ diff --git a/openvmm/openvmm_entry/src/microvm/host_control.rs b/openvmm/openvmm_entry/src/microvm/host_control.rs new file mode 100644 index 00000000000..4d264c7bc45 --- /dev/null +++ b/openvmm/openvmm_entry/src/microvm/host_control.rs @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +//! Authenticated host-only control for the microVM profile. + +use super::console::MicrovmControlAuthentication; +use anyhow::Context; +use futures::AsyncReadExt; +use futures::AsyncWriteExt; +use mesh::CancelContext; +use mesh::rpc::RpcSend; +use pal_async::DefaultDriver; +use serde::Deserialize; +use serde::Serialize; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; + +const PROTOCOL_VERSION: u32 = 1; +const MAX_FRAME_SIZE: usize = 64 * 1024; +const IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +pub(crate) struct MicrovmHostControlServer { + io: Box, + authentication: MicrovmControlAuthentication, + slots: Vec>>, + transition: Arc, +} + +#[derive(Deserialize)] +struct Request { + version: u32, + request_id: u64, + #[serde(flatten)] + operation: Operation, +} + +#[derive(Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum Operation { + QueryImageSlots, + BindImageSlot { + slot: u8, + path: String, + identity: String, + }, +} + +#[derive(Serialize)] +struct Response { + version: u32, + request_id: u64, + #[serde(flatten)] + result: ResponseResult, +} + +#[derive(Serialize)] +#[serde(tag = "status", rename_all = "snake_case")] +enum ResponseResult { + Ok { result: ResponseBody }, + Error { code: &'static str, message: String }, +} + +#[derive(Serialize)] +#[serde(tag = "operation", rename_all = "snake_case")] +enum ResponseBody { + QueryImageSlots { slots: Vec }, + BindImageSlot { slot: u8, capacity_sectors: u64 }, +} + +#[derive(Serialize)] +struct SlotStatus { + slot: u8, + name: String, + state: &'static str, + identity: Option, + capacity_sectors: u64, +} + +struct TransitionGuard(Arc); + +impl Drop for TransitionGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl MicrovmHostControlServer { + pub(crate) fn bind( + path: &Path, + driver: &DefaultDriver, + authentication: MicrovmControlAuthentication, + ) -> anyhow::Result { + Ok(Self { + io: crate::serial_io::microvm::bind_host_control(path, driver) + .with_context(|| format!("failed to bind host control {}", path.display()))?, + authentication, + slots: Vec::new(), + transition: Arc::new(AtomicBool::new(false)), + }) + } + + pub(crate) fn set_image_slots( + &mut self, + slots: Vec>>, + transition: Arc, + ) { + self.slots = slots; + self.transition = transition; + } + + pub(crate) async fn run(&mut self) -> anyhow::Result<()> { + loop { + futures::future::poll_fn(|cx| self.io.poll_connect(cx)) + .await + .context("failed to accept a host-control connection")?; + if let Err(error) = self.serve_connection().await { + tracelimit::warn_ratelimited!( + error = error.as_ref() as &dyn std::error::Error, + "host-control connection failed" + ); + } + self.io + .disconnect_current() + .context("failed to reset the host-control listener")?; + } + } + + async fn serve_connection(&mut self) -> anyhow::Result<()> { + let peer = self + .io + .local_peer_identity() + .context("failed to identify the host-control peer")? + .context("host-control endpoint did not authenticate its local peer")?; + anyhow::ensure!( + peer == self.authentication.expected_peer_identity, + "host-control peer identity does not match the OpenVMM process owner" + ); + + let mut capability = [0; 32]; + CancelContext::new() + .with_timeout(Duration::from_millis(self.authentication.auth_timeout_ms)) + .until_cancelled(self.io.read_exact(&mut capability)) + .await + .context("host-control authentication timed out")? + .context("failed to read host-control capability")?; + anyhow::ensure!( + capability_matches(&capability, &self.authentication.capability), + "host-control capability is invalid" + ); + + loop { + let Some(request) = self.read_request().await? else { + return Ok(()); + }; + let response = self.handle_request(request).await; + self.write_response(&response).await?; + } + } + + async fn read_request(&mut self) -> anyhow::Result> { + let Some(length) = CancelContext::new() + .with_timeout(IDLE_TIMEOUT) + .until_cancelled(read_frame_length(&mut self.io)) + .await + .context("host-control connection was idle")? + .context("failed to read host-control frame length")? + else { + return Ok(None); + }; + let length = length as usize; + anyhow::ensure!( + (1..=MAX_FRAME_SIZE).contains(&length), + "host-control frame length is invalid" + ); + let mut payload = vec![0; length]; + self.io + .read_exact(&mut payload) + .await + .context("failed to read host-control frame")?; + serde_json::from_slice(&payload) + .map(Some) + .context("host-control request is invalid JSON") + } + + async fn write_response(&mut self, response: &Response) -> anyhow::Result<()> { + let payload = + serde_json::to_vec(response).context("failed to encode host-control response")?; + let length = u32::try_from(payload.len()).context("host-control response is too large")?; + self.io + .write_all(&length.to_le_bytes()) + .await + .context("failed to write host-control frame length")?; + self.io + .write_all(&payload) + .await + .context("failed to write host-control response")?; + self.io + .flush() + .await + .context("failed to flush host-control response") + } + + async fn handle_request(&mut self, request: Request) -> Response { + let result = if request.version != PROTOCOL_VERSION { + Err(( + "unsupported_version", + format!( + "host-control protocol version {} is unsupported", + request.version + ), + )) + } else { + match request.operation { + Operation::QueryImageSlots => self.query_image_slots().await, + Operation::BindImageSlot { + slot, + path, + identity, + } => self.bind_image_slot(slot, Path::new(&path), identity).await, + } + }; + Response { + version: PROTOCOL_VERSION, + request_id: request.request_id, + result: match result { + Ok(result) => ResponseResult::Ok { result }, + Err((code, message)) => ResponseResult::Error { code, message }, + }, + } + } + + async fn query_image_slots(&mut self) -> Result { + let mut slots = Vec::with_capacity(self.slots.len()); + for (index, requests) in self.slots.iter().enumerate() { + let (state, identity, capacity_sectors) = if let Some(requests) = requests { + let state = requests + .call(virtio_resources::blk::ImageSlotRequest::Query, ()) + .await + .map_err(|error| ("device_unavailable", error.to_string()))?; + ( + if state.identity.is_some() { + "bound" + } else { + "empty" + }, + state.identity, + state.capacity_sectors, + ) + } else { + ("inactive", None, 0) + }; + slots.push(SlotStatus { + slot: index as u8, + name: openvmm_defs::microvm::microvm_image_slot_name(index as u8) + .map_err(|error| ("invalid_slot", error.to_string()))?, + state, + identity, + capacity_sectors, + }); + } + Ok(ResponseBody::QueryImageSlots { slots }) + } + + async fn bind_image_slot( + &mut self, + slot: u8, + path: &Path, + identity: String, + ) -> Result { + let transition = self.transition.clone(); + transition + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| { + ( + "busy", + "image binding is excluded by a lifecycle transition".to_owned(), + ) + })?; + let _guard = TransitionGuard(transition); + let requests = self + .slots + .get(slot as usize) + .ok_or_else(|| ("invalid_slot", format!("image slot {slot} is out of range")))? + .as_ref() + .ok_or_else(|| ("inactive_slot", format!("image slot {slot} is inactive")))?; + let current = requests + .call(virtio_resources::blk::ImageSlotRequest::Query, ()) + .await + .map_err(|error| ("device_unavailable", error.to_string()))?; + if let Some(current_identity) = current.identity { + return if current_identity == identity { + Ok(ResponseBody::BindImageSlot { + slot, + capacity_sectors: current.capacity_sectors, + }) + } else { + Err(( + "already_bound", + format!("image slot {slot} is already bound to a different identity"), + )) + }; + } + let media = + open_read_only_media(path).map_err(|error| ("invalid_media", format!("{error:#}")))?; + let length = media + .metadata() + .map_err(|error| ("invalid_media", error.to_string()))? + .len(); + let state = requests + .call( + virtio_resources::blk::ImageSlotRequest::Bind, + virtio_resources::blk::BindImageSlotRequest { + media, + identity, + length, + logical_block_size: 512, + physical_block_size: 4096, + }, + ) + .await + .map_err(|error| ("device_unavailable", error.to_string()))? + .map_err(|error| match error { + virtio_resources::blk::BindImageSlotError::AlreadyBound => { + ("already_bound", error.to_string()) + } + virtio_resources::blk::BindImageSlotError::GeometryMismatch => { + ("geometry_mismatch", error.to_string()) + } + virtio_resources::blk::BindImageSlotError::InvalidMedia(_) => { + ("invalid_media", error.to_string()) + } + })?; + Ok(ResponseBody::BindImageSlot { + slot, + capacity_sectors: state.capacity_sectors, + }) + } +} + +fn capability_matches(left: &[u8; 32], right: &[u8; 32]) -> bool { + left.iter() + .zip(right) + .fold(0_u8, |difference, (left, right)| { + difference | (left ^ right) + }) + == 0 +} + +/// Reads a little-endian frame length. Returns `None` when the peer closed the connection +/// at a frame boundary, which is how a client ends a session. +async fn read_frame_length( + io: &mut (impl futures::AsyncRead + Unpin), +) -> std::io::Result> { + let mut length = [0; 4]; + let mut filled = 0; + while filled < length.len() { + let count = match io.read(&mut length[filled..]).await { + Ok(count) => count, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + }; + if count == 0 { + return if filled == 0 { + Ok(None) + } else { + Err(std::io::ErrorKind::UnexpectedEof.into()) + }; + } + filled += count; + } + Ok(Some(u32::from_le_bytes(length))) +} + +fn open_read_only_media(path: &Path) -> anyhow::Result { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt as _; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; + use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; + const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; + options + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .share_mode(FILE_SHARE_READ | FILE_SHARE_DELETE); + } + let file = options + .open(path) + .with_context(|| format!("failed to open image media {}", path.display()))?; + let metadata = file + .metadata() + .with_context(|| format!("failed to inspect image media {}", path.display()))?; + anyhow::ensure!( + metadata.file_type().is_file(), + "image media is not a regular file: {}", + path.display() + ); + anyhow::ensure!( + metadata.len() != 0 && metadata.len().is_multiple_of(512), + "image media length must be nonzero and 512-byte aligned" + ); + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::read_frame_length; + use futures::executor::block_on; + use futures::io::Cursor; + + #[test] + fn close_at_frame_boundary_ends_the_session() { + let mut io = Cursor::new(Vec::new()); + assert_eq!(block_on(read_frame_length(&mut io)).unwrap(), None); + } + + #[test] + fn frame_length_is_little_endian() { + let mut io = Cursor::new(vec![0x34, 0x12, 0, 0, 0xff]); + assert_eq!(block_on(read_frame_length(&mut io)).unwrap(), Some(0x1234)); + assert_eq!(io.position(), 4); + } + + #[test] + fn close_inside_frame_length_is_an_error() { + let mut io = Cursor::new(vec![1, 0]); + assert_eq!( + block_on(read_frame_length(&mut io)).unwrap_err().kind(), + std::io::ErrorKind::UnexpectedEof + ); + } +} diff --git a/openvmm/openvmm_entry/src/microvm/launch.rs b/openvmm/openvmm_entry/src/microvm/launch.rs index a408a386f24..11de921200e 100644 --- a/openvmm/openvmm_entry/src/microvm/launch.rs +++ b/openvmm/openvmm_entry/src/microvm/launch.rs @@ -36,6 +36,7 @@ pub(crate) struct MicrovmLaunch { network: Option, filesystem_slot: bool, filesystems: Vec, + image_slots: Option, snapshot_destination: Option, snapshot_block_identity: SnapshotBlockIdentityCli, snapshot_generation_id: Option<[u8; 16]>, @@ -143,6 +144,7 @@ impl MicrovmLaunch { network: vm_config.microvm.network.clone(), filesystem_slot, filesystems: vm_config.microvm.filesystems.clone(), + image_slots: vm_config.microvm.image_slots, snapshot_destination, snapshot_block_identity: opt .microvm @@ -313,6 +315,7 @@ impl MicrovmLaunch { network: self.network, filesystem_slot: self.filesystem_slot, filesystems: self.filesystems, + image_slots: self.image_slots, snapshot_memory_file: self.snapshot_memory_file, _private_scratch_dir: self.restore.private_scratch_dir, } diff --git a/openvmm/openvmm_entry/src/microvm/mod.rs b/openvmm/openvmm_entry/src/microvm/mod.rs index 85a93092610..0b905adcee6 100644 --- a/openvmm/openvmm_entry/src/microvm/mod.rs +++ b/openvmm/openvmm_entry/src/microvm/mod.rs @@ -6,6 +6,7 @@ mod config; mod console; mod filesystem; +mod host_control; mod launch; mod network; pub(crate) mod output; @@ -29,6 +30,7 @@ pub(crate) use console::microvm_console_attachment_from_cli; pub(crate) use console::microvm_console_attachment_from_snapshot; #[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use console::microvm_console_socket_cleanup; +pub(crate) use console::microvm_control_authentication_from_capability; #[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use console::validate_microvm_console_attachment_namespace; #[cfg(any(feature = "ttrpc", feature = "grpc"))] @@ -38,6 +40,7 @@ pub(crate) use filesystem::microvm_filesystem_from_snapshot; #[cfg(any(feature = "ttrpc", feature = "grpc"))] pub(crate) use filesystem::microvm_filesystem_slot_from_snapshot; pub(crate) use filesystem::validate_microvm_filesystem_private_storage; +pub(crate) use host_control::MicrovmHostControlServer; pub(crate) use launch::MicrovmLaunch; pub(crate) use restore::ExpectedRestoreContract; pub(crate) use restore::MicrovmRestore; @@ -58,6 +61,8 @@ use net_backend_resources::egress::EgressPolicy; use openvmm_helpers::snapshot::microvm::SnapshotAttachment; use output::MicrovmOutputDrain; use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; /// Host-side microVM resources produced while building the VM configuration /// and consumed by the snapshot, restore, and teardown paths. @@ -69,6 +74,15 @@ pub(crate) struct MicrovmResources { pub(crate) snapshot_requests: Option>, /// Snapshot sources of the fixed-role sandbox blocks, in role order. pub(crate) sandbox_block_sources: Vec, + /// Host control channels for active image slots; inactive entries are absent. + pub(crate) image_slot_requests: + Vec>>, + /// Excludes image binding while capture or another lifecycle transition begins. + pub(crate) image_slot_transition: Arc, + /// Authenticated versioned host-control service. + pub(crate) host_control: Option>>, + /// Removes the host-control Unix socket on teardown. + pub(crate) host_control_socket_cleanup: Option, /// Snapshot identity of the boot virtio-console endpoint. pub(crate) console_attachment: Option, /// Removes the boot console Unix socket on teardown. diff --git a/openvmm/openvmm_entry/src/microvm/network.rs b/openvmm/openvmm_entry/src/microvm/network.rs index 7446b7166a0..f254966712d 100644 --- a/openvmm/openvmm_entry/src/microvm/network.rs +++ b/openvmm/openvmm_entry/src/microvm/network.rs @@ -215,6 +215,7 @@ pub(crate) mod tests { false, false, &[], + None, ) .unwrap(); openvmm_helpers::snapshot::microvm::microvm_machine_contract( diff --git a/openvmm/openvmm_entry/src/microvm/restore.rs b/openvmm/openvmm_entry/src/microvm/restore.rs index c2bb263e8b4..ceddebed341 100644 --- a/openvmm/openvmm_entry/src/microvm/restore.rs +++ b/openvmm/openvmm_entry/src/microvm/restore.rs @@ -122,6 +122,7 @@ impl MicrovmRestore { pub(crate) fn time_abi_restore_packet( &self, restore_online_vp_count: Option, + restore_active_image_slot_count: Option, ) -> anyhow::Result<([u8; 16], RestorePacketBase)> { let (time, _) = self .time_abi_records() @@ -129,6 +130,7 @@ impl MicrovmRestore { restore_packet_base( time, restore_online_vp_count, + restore_active_image_slot_count, self.memory_target_requested, &self.memory_ranges, self.gate_required, @@ -140,10 +142,12 @@ impl MicrovmRestore { /// ABI restore of a snapshot with the time contract `time`, and the /// generation ID they imply: restore step 7 of the specification. The /// generation counter is the snapshot's plus one, and `ack_required` makes -/// the guest acknowledge the restore through port `0x605`. +/// the guest acknowledge the restore through port `0x605`. An explicit +/// image-slot target occupies header byte 7. pub(crate) fn restore_packet_base( time: &SnapshotTimeContract, restore_online_vp_count: Option, + restore_active_image_slot_count: Option, memory_target_requested: bool, memory_ranges: &[SnapshotMemoryExpansionRange], ack_required: bool, @@ -167,6 +171,7 @@ pub(crate) fn restore_packet_base( .transpose() .context("restore-online VP count does not fit in u8")? .unwrap_or(0), + image_slot_target: restore_active_image_slot_count.unwrap_or(0), memory_target: memory_target_requested, ack_required, generation, @@ -234,6 +239,16 @@ pub(crate) fn prepare_restore( )?; restore_gate_required = true; } + if let Some(restore_image_slots) = opt.microvm.restore_image_slots { + openvmm_helpers::snapshot::microvm::validate_restore_active_image_slot_count( + manifest, + restore_image_slots, + )?; + // The restore packet's image-slot target directs the guest to + // verify the active prefix and acknowledge, even when no slot + // becomes active. + restore_gate_required = true; + } let restore_memory_size = opt .microvm .restore_memory @@ -497,24 +512,32 @@ pub(crate) fn validate_restore_contract( } else { Vec::new() }; - let mut expected_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( - expected_hypervisor, - openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, - effective_command_line.to_owned(), - network, - filesystem_slot, - filesystems, - console_attachment.cloned(), - control_console_attachment.cloned(), - sandbox_blocks, - expected_vp_count, - expected_memory_size, - (saved_contract.memory_expansion_version != 0) - .then_some(saved_contract.memory_capacity_bytes), - saved_contract.state_unit_names.clone(), - contract, - cpu_profile, - )?; + let image_slots = (saved_contract.microvm_image_slot_capacity != 0).then_some( + openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: saved_contract.boot_active_image_slot_count, + active_count: saved_contract.boot_active_image_slot_count, + }, + ); + let mut expected_contract = + openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_image_slots( + expected_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + effective_command_line.to_owned(), + network, + filesystem_slot, + filesystems, + console_attachment.cloned(), + control_console_attachment.cloned(), + sandbox_blocks, + image_slots, + expected_vp_count, + expected_memory_size, + (saved_contract.memory_expansion_version != 0) + .then_some(saved_contract.memory_capacity_bytes), + saved_contract.state_unit_names.clone(), + contract, + cpu_profile, + )?; align_legacy_network_policy_contract(saved_contract, &mut expected_contract); align_restore_console_listener_contract(saved_contract, &mut expected_contract); openvmm_helpers::snapshot::microvm::validate_microvm_machine_contract( @@ -676,7 +699,7 @@ pub(crate) mod tests { #[test] fn time_abi_restore_packet_carries_the_controller_fields() { let restore = time_abi_restore(6); - let (generation_id, base) = restore.time_abi_restore_packet(Some(4)).unwrap(); + let (generation_id, base) = restore.time_abi_restore_packet(Some(4), None).unwrap(); assert_eq!(base.generation, 7); assert_eq!(base.online_vp_count, 4); assert!(base.memory_target && base.ack_required); @@ -688,13 +711,14 @@ pub(crate) mod tests { }] ); assert_eq!(generation_id, microvm_generation_id(&base.entropy)); - let (_, other) = restore.time_abi_restore_packet(None).unwrap(); + assert_eq!(base.image_slot_target, 0); + let (_, other) = restore.time_abi_restore_packet(None, None).unwrap(); assert_eq!(other.online_vp_count, 0); assert_ne!(other.entropy, base.entropy); - assert!(restore.time_abi_restore_packet(Some(3)).is_err()); + assert!(restore.time_abi_restore_packet(Some(3), None).is_err()); let err = time_abi_restore(u32::MAX) - .time_abi_restore_packet(None) + .time_abi_restore_packet(None, None) .unwrap_err(); assert!( format!("{err:#}").contains("[E_GENERATION_EXHAUSTED]"), @@ -702,8 +726,47 @@ pub(crate) mod tests { ); assert!( MicrovmRestore::default() - .time_abi_restore_packet(None) + .time_abi_restore_packet(None, None) .is_err() ); } + + #[test] + fn time_abi_restore_packet_carries_the_image_slot_target() { + let restore = time_abi_restore(6); + let (_, base) = restore.time_abi_restore_packet(None, Some(3)).unwrap(); + assert_eq!(base.image_slot_target, 3); + assert_eq!(base.online_vp_count, 0); + assert!(base.ack_required); + let packet = chipset_resources::microvm_time::RestorePacketV4 { + base, + time: RestoreTimeRecord { + downtime_ns: 0, + downtime_utc: false, + rate_deviation: 0, + test_hooks: false, + }, + utc_ns: 0, + } + .encode() + .unwrap(); + assert_ne!( + packet[4] & chipset_resources::microvm_time::FLAG_IMAGE_SLOT_TARGET, + 0 + ); + assert_eq!(packet[7], 3); + + assert!( + restore + .time_abi_restore_packet( + None, + Some(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY + 1) + ) + .is_err() + ); + assert_eq!( + chipset_resources::microvm_time::MAX_IMAGE_SLOT_TARGET, + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY + ); + } } diff --git a/openvmm/openvmm_entry/src/serial_io/microvm.rs b/openvmm/openvmm_entry/src/serial_io/microvm.rs index 833d1c609c9..0cc8bddb283 100644 --- a/openvmm/openvmm_entry/src/serial_io/microvm.rs +++ b/openvmm/openvmm_entry/src/serial_io/microvm.rs @@ -18,6 +18,11 @@ use vm_resource::kind::SerialBackendHandle; #[cfg(target_os = "linux")] pub fn bind_control_serial(path: &Path) -> io::Result> { + Ok(OpenSocketSerialConfig::from(bind_control_listener(path)?).into_resource()) +} + +#[cfg(target_os = "linux")] +fn bind_control_listener(path: &Path) -> io::Result { use std::os::unix::fs::FileTypeExt; use std::os::unix::fs::MetadataExt; use std::os::unix::fs::PermissionsExt; @@ -69,14 +74,20 @@ pub fn bind_control_serial(path: &Path) -> io::Result io::Result> { - use pal::windows::security::LocalSecurityDescriptor; use serial_socket::windows::OpenWindowsPipeSerialConfig; + Ok(OpenWindowsPipeSerialConfig::from(bind_control_pipe(path)?).into_resource()) +} + +#[cfg(windows)] +fn bind_control_pipe(path: &Path) -> io::Result { + use pal::windows::security::LocalSecurityDescriptor; + const NAMED_PIPE_PREFIX: &str = "//./pipe/"; let normalized = path.to_string_lossy().replace('\\', "/"); @@ -104,7 +115,7 @@ pub fn bind_control_serial(path: &Path) -> io::Result io::Result io::Result> { + Ok(Box::new(serial_socket::net::SocketSerialBackend::new( + Box::new(driver.clone()), + OpenSocketSerialConfig::from(bind_control_listener(path)?), + )?)) +} + +#[cfg(windows)] +pub fn bind_host_control( + path: &Path, + driver: &pal_async::DefaultDriver, +) -> io::Result> { + Ok(Box::new( + serial_socket::windows::WindowsPipeSerialBackend::new( + Box::new(driver.clone()), + serial_socket::windows::OpenWindowsPipeSerialConfig::from(bind_control_pipe(path)?), + )?, + )) +} + +#[cfg(not(any(target_os = "linux", windows)))] +pub fn bind_host_control( + _path: &Path, + _driver: &pal_async::DefaultDriver, +) -> io::Result> { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure host-control endpoints are only supported on Linux and Windows", + )) +} + /// Consumes a one-way pipe containing exactly one nonzero 32-byte control capability. #[cfg(any(unix, windows))] pub fn read_control_capability(mut file: File) -> io::Result<[u8; 32]> { diff --git a/openvmm/openvmm_entry/src/storage_builder.rs b/openvmm/openvmm_entry/src/storage_builder.rs index cfd6e6508ca..08eb74621f5 100644 --- a/openvmm/openvmm_entry/src/storage_builder.rs +++ b/openvmm/openvmm_entry/src/storage_builder.rs @@ -135,6 +135,7 @@ pub(super) struct StorageBuilder { underhill_scsi_luns: Vec, underhill_nvme_luns: Vec, vtl0_virtio_blk_disks: Vec, + microvm_image_slots: Option, openhcl_vtl: Option, } @@ -201,6 +202,7 @@ impl StorageBuilder { underhill_scsi_luns: Vec::new(), underhill_nvme_luns: Vec::new(), vtl0_virtio_blk_disks: Vec::new(), + microvm_image_slots: None, openhcl_vtl, } } diff --git a/openvmm/openvmm_entry/src/storage_builder/microvm.rs b/openvmm/openvmm_entry/src/storage_builder/microvm.rs index 5427d73d7c5..4b0e5440672 100644 --- a/openvmm/openvmm_entry/src/storage_builder/microvm.rs +++ b/openvmm/openvmm_entry/src/storage_builder/microvm.rs @@ -18,6 +18,7 @@ use openvmm_defs::microvm::MicrovmSandboxBlockConfig; use openvmm_defs::microvm::MicrovmSandboxBlockRole; use std::time::Duration; use virtio_resources::blk::VirtioBlkHandle; +use virtio_resources::blk::VirtioBlkImageSlotHandle; use vm_resource::IntoResource; use vm_resource::Resource; @@ -165,6 +166,22 @@ pub(crate) fn restore_snapshot_block_contract( } impl StorageBuilder { + /// Declares the fixed image-slot capacity for the microVM profile. + pub fn add_microvm_image_slots( + &mut self, + image_slots: Option, + ) -> anyhow::Result<()> { + if let Some(image_slots) = image_slots { + image_slots.validate()?; + } + anyhow::ensure!( + self.microvm_image_slots.is_none(), + "microVM image slots were configured more than once" + ); + self.microvm_image_slots = image_slots; + Ok(()) + } + /// Adds a fixed-role microVM sandbox block device. pub async fn add_microvm_sandbox_block( &mut self, @@ -338,6 +355,26 @@ impl StorageBuilder { .into_resource(), )); } + config.microvm.image_slots = self.microvm_image_slots; + resources.microvm.image_slot_requests.clear(); + if let Some(image_slots) = self.microvm_image_slots { + for index in 0..openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY { + let (requests, receiver) = mesh::channel(); + let active = index < image_slots.active_count; + config.virtio_devices.push(( + VirtioBus::Mmio, + VirtioBlkImageSlotHandle { + active, + requests: receiver, + } + .into_resource(), + )); + resources + .microvm + .image_slot_requests + .push(active.then_some(requests)); + } + } Ok(()) } } diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index 911d9576390..c1d117a1e48 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -75,6 +75,10 @@ pub(super) struct CreateVm { restored_filesystem: Option, snapshot_destination: Option, restore_online_vp_count: Option, + image_slots: Option, + restore_active_image_slot_count: Option, + host_control_path: Option, + host_control_capability: Option<[u8; 32]>, restore_gate_timeout: Duration, snapshot_quiesce_timeout: Duration, memory_capacity_bytes: u64, @@ -121,9 +125,46 @@ impl CreateVm { restore_gate_timeout_ms, memory_capacity_bytes, restore_memory_bytes, + image_slots, + image_slot_boot_count, + restore_image_slot_count, + host_control_path, + host_control_capability, } = microvm_snapshot.unwrap_or_default(); let restore_online_vp_count = (restore_processor_count != 0).then_some(restore_processor_count); + let image_slot_boot_count = u8::try_from(image_slot_boot_count) + .context("image_slot_boot_count does not fit in u8")?; + let restore_active_image_slot_count = (restore_image_slot_count != 0) + .then(|| u8::try_from(restore_image_slot_count)) + .transpose() + .context("restore_image_slot_count does not fit in u8")?; + let mut image_slots = + image_slots.then_some(openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: if image_slot_boot_count == 0 { + 1 + } else { + image_slot_boot_count + }, + active_count: if image_slot_boot_count == 0 { + 1 + } else { + image_slot_boot_count + }, + }); + if let Some(image_slots) = image_slots { + image_slots.validate()?; + } + let host_control_path = + (!host_control_path.is_empty()).then(|| PathBuf::from(host_control_path)); + let host_control_capability = + if host_control_capability.is_empty() { + None + } else { + Some(host_control_capability.try_into().map_err(|_| { + anyhow::anyhow!("host_control_capability must be exactly 32 bytes") + })?) + }; let restore_gate_timeout = Duration::from_millis(if restore_gate_timeout_ms == 0 { 60_000 } else { @@ -158,6 +199,22 @@ impl CreateVm { restore_online_vp_count.is_none() || restore_path.is_some(), "restore_processor_count requires restore_path" ); + anyhow::ensure!( + restore_active_image_slot_count.is_none() || restore_path.is_some(), + "restore_image_slot_count requires restore_path" + ); + anyhow::ensure!( + image_slots.is_none() || restore_path.is_none(), + "image_slots configures cold boot and cannot be combined with restore_path" + ); + anyhow::ensure!( + host_control_path.is_some() == host_control_capability.is_some(), + "host_control_path and host_control_capability must be supplied together" + ); + anyhow::ensure!( + image_slots.is_none() || host_control_path.is_some(), + "image slots require host control" + ); anyhow::ensure!( restore_gate_timeout_ms == 0 || restore_path.is_some(), "restore_gate_timeout_ms requires restore_path" @@ -230,6 +287,27 @@ impl CreateVm { restore_online_vp_count, )?; } + if machine_contract.microvm_image_slot_capacity != 0 { + let active_count = restore_active_image_slot_count + .unwrap_or(machine_contract.boot_active_image_slot_count); + openvmm_helpers::snapshot::microvm::validate_restore_active_image_slot_count( + manifest, + active_count, + )?; + anyhow::ensure!( + host_control_path.is_some(), + "restoring image slots requires host control" + ); + image_slots = Some(openvmm_defs::microvm::MicrovmImageSlotsConfig { + boot_count: machine_contract.boot_active_image_slot_count, + active_count, + }); + } else { + anyhow::ensure!( + restore_active_image_slot_count.is_none(), + "snapshot does not declare image slots" + ); + } let machine_contract = machine_contract.clone(); Some(AuthoritativeRestore { path, @@ -333,6 +411,10 @@ impl CreateVm { restored_filesystem: None, snapshot_destination, restore_online_vp_count, + image_slots, + restore_active_image_slot_count, + host_control_path, + host_control_capability, restore_gate_timeout, snapshot_quiesce_timeout, memory_capacity_bytes, @@ -743,6 +825,7 @@ impl CreateVm { let (generation_id, base) = crate::microvm::restore_packet_base( restore_time.contract(), self.restore_online_vp_count, + self.restore_active_image_slot_count, restore.restore_memory_target_requested, &restore.restore_memory_ranges, gate_required, @@ -1137,7 +1220,11 @@ impl CreateVm { /// Finalizes the fixed virtio-mmio device inventory and discovery command /// line. - pub(super) fn finish_devices(&self, config: &mut Config) -> anyhow::Result<()> { + pub(super) fn finish_devices( + &mut self, + config: &mut Config, + driver: &pal_async::DefaultDriver, + ) -> anyhow::Result<()> { if self.filesystem_slot && !config .virtio_devices @@ -1156,6 +1243,42 @@ impl CreateVm { .into_resource(), )); } + config.microvm.image_slots = self.image_slots; + if let Some(image_slots) = self.image_slots { + self.resources.image_slot_requests.clear(); + for index in 0..openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY { + let (requests, receiver) = mesh::channel(); + let active = index < image_slots.active_count; + config.virtio_devices.push(( + openvmm_defs::config::VirtioBus::Mmio, + virtio_resources::blk::VirtioBlkImageSlotHandle { + active, + requests: receiver, + } + .into_resource(), + )); + self.resources + .image_slot_requests + .push(active.then_some(requests)); + } + } + if let (Some(path), Some(capability)) = ( + self.host_control_path.as_deref(), + self.host_control_capability, + ) { + let authentication = + crate::microvm::microvm_control_authentication_from_capability(capability, 5000)?; + let mut server = + crate::microvm::MicrovmHostControlServer::bind(path, driver, authentication)?; + server.set_image_slots( + self.resources.image_slot_requests.clone(), + self.resources.image_slot_transition.clone(), + ); + self.resources.host_control = + Some(std::sync::Arc::new(futures::lock::Mutex::new(server))); + self.resources.host_control_socket_cleanup = + crate::microvm::microvm_console_socket_cleanup(path.to_owned())?; + } if self.active && self.authoritative_restore.is_none() { let has_console = config @@ -1180,6 +1303,7 @@ impl CreateVm { device.id() == openvmm_defs::microvm::MICROVM_VIRTIO_CONTROL_CONSOLE_ID }), &config.microvm.sandbox_blocks, + config.microvm.image_slots, )?; } Ok(()) @@ -1303,10 +1427,11 @@ impl CreateVm { } /// Returns whether a restore arms the post-restore input gate, which the - /// guest acknowledges through port `0x605`: with a restore-time VP or RAM - /// target. + /// guest acknowledges through port `0x605`: with a restore-time VP, RAM, + /// or image-slot target. fn restore_gate_required(&self) -> bool { self.restore_online_vp_count.is_some() + || self.restore_active_image_slot_count.is_some() || self .authoritative_restore .as_ref() @@ -1334,6 +1459,7 @@ impl CreateVm { network: None, filesystem_slot: self.filesystem_slot, filesystems: self.filesystems, + image_slots: self.image_slots, snapshot_memory_file: self.snapshot_memory_file, _private_scratch_dir: None, }, diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index 9c4157541bc..dc705b7ac82 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -1328,7 +1328,7 @@ impl VmService { } } - microvm.finish_devices(&mut config)?; + microvm.finish_devices(&mut config, &self.driver)?; if let Some(hvsocket_config) = req_config.hvsocket_config { let vmbus = config diff --git a/openvmm/openvmm_entry/src/vm_controller.rs b/openvmm/openvmm_entry/src/vm_controller.rs index 61ec2f93502..89d3abd8fe1 100644 --- a/openvmm/openvmm_entry/src/vm_controller.rs +++ b/openvmm/openvmm_entry/src/vm_controller.rs @@ -195,10 +195,17 @@ impl VmController { VncWorker(WorkerEvent), Halt(HaltReason), SnapshotRequest(chipset_resources::microvm::MicrovmSnapshotScratchPolicy), + HostControl(anyhow::Result<()>), } let mut quit = false; let mut rpc_closed = false; + // The host-control service persists across iterations: rebuilding it + // whenever another event wins would cancel a session mid-request. + let mut host_control = Box::pin( + futures::stream::iter(self.microvm.resources.host_control.clone()) + .then(|server| async move { server.lock().await.run().await }), + ); loop { let event = { let rpc = pin!(async { @@ -220,8 +227,16 @@ impl VmController { futures::stream::iter(self.microvm.snapshot_requests.as_mut()) .flatten() .map(Event::SnapshotRequest); - - (rpc.into_stream(), vm, vnc, halt, snapshot_request) + let host_control = (&mut host_control).map(Event::HostControl); + + ( + rpc.into_stream(), + vm, + vnc, + halt, + snapshot_request, + host_control, + ) .merge() .next() .await @@ -358,6 +373,20 @@ impl VmController { break; } } + Event::HostControl(result) => { + let error = result + .err() + .unwrap_or_else(|| anyhow::anyhow!("host-control service stopped")); + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "microVM host-control service failed" + ); + self.vm_worker.stop(); + quit = true; + event_send.send(VmControllerEvent::ExitFailed { + error: format!("microVM host-control service failed: {error:#}"), + }); + } } } diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index c55a2c1a98a..331e5e271d1 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -11,6 +11,9 @@ use mesh::rpc::RpcSend; use openvmm_defs::rpc::SnapshotQuiesceError; use openvmm_defs::rpc::VmRpc; use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use std::time::Duration; /// MicroVM state owned by the VM controller. @@ -51,6 +54,8 @@ pub(crate) struct MicrovmController { /// Guest-visible policies of the active microVM filesystems, in virtio-fs /// slot order. pub(crate) filesystems: Vec, + /// Fixed image-slot activation contract. + pub(crate) image_slots: Option, /// Automatic RAM backing created for snapshot capture. pub(crate) snapshot_memory_file: Option, /// Private copy of a paired scratch image, kept alive for the VM lifetime. @@ -97,6 +102,34 @@ pub(super) enum GuestSnapshotAction { Terminate { exit_code: i32 }, } +struct ImageSlotTransitionGuard(Arc); + +impl Drop for ImageSlotTransitionGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +async fn ensure_image_slot_channels_empty( + slots: &[Option>], +) -> anyhow::Result<()> { + for (index, requests) in slots.iter().enumerate() { + let Some(requests) = requests else { + continue; + }; + let state = requests + .call(virtio_resources::blk::ImageSlotRequest::Query, ()) + .await + .with_context(|| format!("image slot {index} did not answer"))?; + anyhow::ensure!( + state.identity.is_none(), + "image slot {index} is bound to '{}'", + state.identity.as_deref().unwrap_or_default() + ); + } + Ok(()) +} + async fn guest_exit_event( code: i32, drain: Option, @@ -128,12 +161,34 @@ impl VmController { &mut self, scratch_policy: chipset_resources::microvm::MicrovmSnapshotScratchPolicy, ) -> GuestSnapshotAction { + let _image_slot_guard = if self.microvm.image_slots.is_some() { + let transition = self.microvm.resources.image_slot_transition.clone(); + if transition + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + tracelimit::warn_ratelimited!( + "ignoring microVM snapshot request because an image-slot transition is active" + ); + return self.release_snapshot_boundary_without_capture().await; + } + Some(ImageSlotTransitionGuard(transition)) + } else { + None + }; let Some(destination) = self.microvm.snapshot_destination.clone() else { tracelimit::warn_ratelimited!( "ignoring microVM snapshot request because no destination is configured" ); return self.release_snapshot_boundary_without_capture().await; }; + if let Err(error) = self.ensure_image_slots_empty().await { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "microVM snapshot preflight rejected bound image slots; guest continues" + ); + return self.release_snapshot_boundary_without_capture().await; + } let preflight = (|| -> anyhow::Result<()> { anyhow::ensure!( @@ -310,23 +365,25 @@ impl VmController { block.identity.clear(); } } - let machine_contract = openvmm_helpers::snapshot::microvm::microvm_machine_contract( - &self.microvm.source_hypervisor, - openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, - command_line, - network, - self.microvm.filesystem_slot, - filesystems, - self.microvm.resources.console_attachment.clone(), - self.microvm.resources.control_console_attachment.clone(), - blocks, - self.processors, - self.memory, - self.microvm.memory_capacity, - response.state_unit_names, - response.time.time, - response.time.cpu_profile, - )?; + let machine_contract = + openvmm_helpers::snapshot::microvm::microvm_machine_contract_with_image_slots( + &self.microvm.source_hypervisor, + openvmm_helpers::snapshot::microvm::MICROVM_BOOT_LAYOUT_VERSION, + command_line, + network, + self.microvm.filesystem_slot, + filesystems, + self.microvm.resources.console_attachment.clone(), + self.microvm.resources.control_console_attachment.clone(), + blocks, + self.microvm.image_slots, + self.processors, + self.memory, + self.microvm.memory_capacity, + response.state_unit_names, + response.time.time, + response.time.cpu_profile, + )?; let manifest = openvmm_helpers::snapshot::SnapshotManifest { version: openvmm_helpers::snapshot::MANIFEST_VERSION, created_at: std::time::SystemTime::now().into(), @@ -471,6 +528,14 @@ impl VmController { "committed snapshot console socket could not be removed" ); } + if let Some(cleanup) = self.microvm.resources.host_control_socket_cleanup.take() + && let Err(error) = cleanup.remove_if_owned() + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "committed snapshot host-control socket could not be removed" + ); + } if let Some(cleanup) = self.microvm.resources.control_console_socket_cleanup.take() && let Err(cleanup_error) = cleanup.remove_if_owned() @@ -480,6 +545,14 @@ impl VmController { "committed snapshot control console socket could not be removed" ); } + if let Some(cleanup) = self.microvm.resources.host_control_socket_cleanup.take() + && let Err(cleanup_error) = cleanup.remove_if_owned() + { + tracing::error!( + error = cleanup_error.as_ref() as &dyn std::error::Error, + "committed snapshot host-control socket could not be removed" + ); + } tracing::error!( error = error.as_ref() as &dyn std::error::Error, path = %destination.display(), @@ -499,6 +572,10 @@ impl VmController { } } + async fn ensure_image_slots_empty(&self) -> anyhow::Result<()> { + ensure_image_slot_channels_empty(&self.microvm.resources.image_slot_requests).await + } + async fn rollback_failed_guest_snapshot( &mut self, error: anyhow::Error, @@ -581,4 +658,26 @@ mod tests { if error.contains("failed to drain microVM console output") )); } + + #[test] + fn bound_image_slot_rejects_capture_preflight() { + block_on(async { + let (requests, mut receiver) = mesh::channel(); + let slots = vec![Some(requests)]; + let (result, ()) = futures::join!(ensure_image_slot_channels_empty(&slots), async { + match receiver.recv().await.unwrap() { + virtio_resources::blk::ImageSlotRequest::Query(rpc) => { + rpc.handle_sync(|()| virtio_resources::blk::ImageSlotState { + identity: Some("image-a".to_owned()), + capacity_sectors: 8, + }); + } + virtio_resources::blk::ImageSlotRequest::Bind(_) => { + panic!("capture preflight must only query image slots") + } + } + }); + assert!(result.unwrap_err().to_string().contains("is bound")); + }); + } } diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 07d7ca8cc27..191fcee6f87 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -485,6 +485,12 @@ pub struct SnapshotMachineContract { /// which requires a filesystem in the first slot. #[mesh(33)] pub microvm_additional_filesystems: Vec, + /// Fixed image-slot capacity, or zero for ABI 2. + #[mesh(34)] + pub microvm_image_slot_capacity: u32, + /// Image slots active on the captured cold boot. + #[mesh(35)] + pub boot_active_image_slot_count: u8, } impl SnapshotMachineContract { @@ -556,6 +562,41 @@ pub fn validate_restore_online_vp_count( .as_ref() .context("snapshot is missing the authoritative machine contract")?; validate_supported_microvm_contract(contract)?; + match contract.microvm_abi_version { + openvmm_defs::microvm::MICROVM_ABI_VERSION_2 => { + anyhow::ensure!( + contract.microvm_image_slot_capacity == 0 + && contract.boot_active_image_slot_count == 0, + "snapshot ABI 2 must not declare image slots" + ); + } + openvmm_defs::microvm::MICROVM_ABI_VERSION_3 => { + anyhow::ensure!( + contract.microvm_image_slot_capacity + == u32::from(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY), + "snapshot ABI 3 image-slot capacity is invalid" + ); + anyhow::ensure!( + (1..=openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY) + .contains(&contract.boot_active_image_slot_count), + "snapshot ABI 3 boot-active image-slot count is invalid" + ); + anyhow::ensure!( + contract + .effective_command_line + .split_ascii_whitespace() + .any(|token| { + token + == format!( + "microvm_image_slots={}", + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY + ) + }), + "snapshot ABI 3 image-slot discovery token is invalid" + ); + } + _ => unreachable!("supported microVM ABI was validated above"), + } anyhow::ensure!( contract.boot_online_vp_count != 0, "snapshot does not declare restore-time VP activation support" @@ -577,6 +618,34 @@ pub fn validate_restore_online_vp_count( Ok(()) } +/// Validates a restore-time active image-slot target against ABI 3. +pub fn validate_restore_active_image_slot_count( + manifest: &SnapshotManifest, + restore_active_image_slot_count: u8, +) -> anyhow::Result<()> { + let contract = manifest + .machine_contract + .as_ref() + .context("snapshot is missing the authoritative machine contract")?; + validate_supported_microvm_contract(contract)?; + anyhow::ensure!( + contract.microvm_image_slot_capacity + == u32::from(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY), + "snapshot does not declare restore-time image-slot activation support" + ); + anyhow::ensure!( + restore_active_image_slot_count >= contract.boot_active_image_slot_count, + "restore image-slot count {restore_active_image_slot_count} is below boot-active count {}", + contract.boot_active_image_slot_count + ); + anyhow::ensure!( + u32::from(restore_active_image_slot_count) <= contract.microvm_image_slot_capacity, + "restore image-slot count {restore_active_image_slot_count} exceeds capacity {}", + contract.microvm_image_slot_capacity + ); + Ok(()) +} + /// Validates and selects the expansion ranges for a restore-time RAM target. pub fn validate_restore_memory_target( manifest: &SnapshotManifest, @@ -780,6 +849,54 @@ pub fn microvm_machine_contract( state_unit_names: Vec, time: openvmm_defs::time_abi::SnapshotTimeContract, cpu_profile: openvmm_defs::time_abi::SnapshotCpuProfile, +) -> anyhow::Result { + microvm_machine_contract_with_image_slots( + source_hypervisor, + boot_layout_version, + effective_command_line, + network, + filesystem_slot, + filesystems, + console_attachment, + control_console_attachment, + sandbox_blocks, + None, + processor_count, + memory_size, + memory_capacity, + state_unit_names, + time, + cpu_profile, + ) +} + +/// Builds the authoritative microVM machine contract with optional ABI 3 +/// image slots, which use the second virtio-fs slot's window and interrupt. +pub fn microvm_machine_contract_with_image_slots( + source_hypervisor: &str, + boot_layout_version: u32, + effective_command_line: String, + network: Option<( + &openvmm_defs::microvm::MicrovmNetworkConfig, + &net_backend_resources::egress::EgressPolicy, + SnapshotAttachment, + )>, + filesystem_slot: bool, + filesystems: Vec<( + &openvmm_defs::microvm::MicrovmFilesystemConfig, + &Path, + SnapshotAttachment, + )>, + console_attachment: Option, + control_console_attachment: Option, + sandbox_blocks: Vec, + image_slots: Option, + processor_count: u32, + memory_size: u64, + memory_capacity: Option, + state_unit_names: Vec, + time: openvmm_defs::time_abi::SnapshotTimeContract, + cpu_profile: openvmm_defs::time_abi::SnapshotCpuProfile, ) -> anyhow::Result { anyhow::ensure!( matches!(source_hypervisor, "kvm" | "mshv" | "whp"), @@ -1193,10 +1310,61 @@ pub fn microvm_machine_contract( devices.push(microvm_filesystem_device(slot, devices.len())); } attachments.extend(filesystem_attachments); + if let Some(image_slots) = image_slots { + image_slots.validate()?; + anyhow::ensure!( + filesystem_slots.len() <= 1, + "microVM image slots use the second virtio-fs slot's window and interrupt" + ); + let tokens = effective_command_line + .split_ascii_whitespace() + .collect::>(); + anyhow::ensure!( + tokens.contains( + format!( + "microvm_image_slots={}", + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY + ) + .as_str() + ), + "microVM image-slot discovery token is missing" + ); + for (index, (base, irq)) in openvmm_defs::microvm::MICROVM_IMAGE_SLOT_MMIO_BASES + .iter() + .zip(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_IRQS) + .enumerate() + { + let discovery = format!( + "virtio_mmio.device={:#x}@{base:#x}:{irq}", + openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN, + ); + anyhow::ensure!( + tokens.contains(discovery.as_str()), + "microVM image slot {index} is missing from the effective command line" + ); + let features = openvmm_defs::microvm::microvm_image_slot_features(); + devices.push(SnapshotDevice { + stable_id: openvmm_defs::microvm::microvm_image_slot_name(index as u8)?, + state_unit_name: format!("virtio-blk-image-slot-{base}"), + kind: "virtio-blk-image-slot".to_owned(), + order: devices.len() as u32, + ranges: vec![mmio(*base, openvmm_defs::microvm::MICROVM_VIRTIO_MMIO_LEN)], + irq: Some(irq), + transport: "virtio-mmio".to_owned(), + feature_banks: vec![features as u32, (features >> 32) as u32], + queue_count: 1, + queue_max_sizes: vec![256], + }); + } + } let mut contract = SnapshotMachineContract { machine_profile: "microvm".to_owned(), - microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + microvm_abi_version: if image_slots.is_some() { + openvmm_defs::microvm::MICROVM_ABI_VERSION_3 + } else { + openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + }, source_hypervisor: source_hypervisor.to_owned(), effective_command_line: String::new(), effective_command_line_sha256: Vec::new(), @@ -1225,6 +1393,10 @@ pub fn microvm_machine_contract( memory_expansion_ranges, time: Some(time), cpu_profile: Some(cpu_profile), + microvm_image_slot_capacity: image_slots + .map(|_| openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY.into()) + .unwrap_or(0), + boot_active_image_slot_count: image_slots.map_or(0, |slots| slots.boot_count), }; contract.set_effective_command_line(effective_command_line); validate_machine_contract_shape(&contract, memory_size, processor_count)?; @@ -1237,6 +1409,7 @@ pub fn requires_post_restore_gate(manifest: &SnapshotManifest) -> bool { matches!( contract.microvm_abi_version, openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + | openvmm_defs::microvm::MICROVM_ABI_VERSION_3 ) }) && !manifest.snapshot_tier.is_empty() } @@ -1257,10 +1430,15 @@ pub fn validate_supported_microvm_contract( contract: &SnapshotMachineContract, ) -> anyhow::Result<()> { anyhow::ensure!( - contract.microvm_abi_version == openvmm_defs::microvm::MICROVM_ABI_VERSION_2, - "snapshot microVM ABI version {} is unsupported; this OpenVMM supports version {}", + matches!( + contract.microvm_abi_version, + openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + | openvmm_defs::microvm::MICROVM_ABI_VERSION_3 + ), + "snapshot microVM ABI version {} is unsupported; this OpenVMM supports versions {} and {}", contract.microvm_abi_version, openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + openvmm_defs::microvm::MICROVM_ABI_VERSION_3, ); anyhow::ensure!( contract.boot_layout_version == MICROVM_BOOT_LAYOUT_VERSION, @@ -1301,6 +1479,11 @@ pub fn validate_microvm_machine_contract( contract.microvm_filesystem_slot_version == expected.microvm_filesystem_slot_version, "snapshot microVM filesystem slot capability doesn't match the requested machine" ); + anyhow::ensure!( + contract.microvm_image_slot_capacity == expected.microvm_image_slot_capacity + && contract.boot_active_image_slot_count == expected.boot_active_image_slot_count, + "snapshot microVM image-slot contract doesn't match the requested machine" + ); anyhow::ensure!( contract.source_hypervisor == expected.source_hypervisor, "snapshot source hypervisor '{}' doesn't match expected '{}'", @@ -2196,6 +2379,8 @@ pub(super) fn test_machine_contract() -> SnapshotMachineContract { memory_expansion_ranges: Vec::new(), time: Some(super::time::tests::test_time_contract()), cpu_profile: Some(super::time::tests::test_cpu_profile()), + microvm_image_slot_capacity: 0, + boot_active_image_slot_count: 0, }; contract.set_effective_command_line("console=hvc0".to_owned()); contract @@ -2231,6 +2416,7 @@ mod tests { for target in [2, 4, 8] { validate_restore_online_vp_count(&manifest, target).unwrap(); } + for target in [1, 3, 16] { assert!(validate_restore_online_vp_count(&manifest, target).is_err()); } @@ -2249,6 +2435,24 @@ mod tests { assert!(error.to_string().contains("does not declare")); } + #[test] + fn restore_image_slot_count_is_bounded_by_template() { + let mut manifest = test_manifest(); + let mut contract = test_machine_contract(); + contract.microvm_abi_version = openvmm_defs::microvm::MICROVM_ABI_VERSION_3; + contract.microvm_image_slot_capacity = + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY.into(); + contract.boot_active_image_slot_count = 2; + manifest.machine_contract = Some(contract); + + for target in [2, 3, 4] { + validate_restore_active_image_slot_count(&manifest, target).unwrap(); + } + for target in [0, 1, 5] { + assert!(validate_restore_active_image_slot_count(&manifest, target).is_err()); + } + } + fn microvm_console_attachment() -> SnapshotAttachment { SnapshotAttachment { stable_id: "console:microvm-virtio0".to_owned(), @@ -2734,6 +2938,7 @@ mod tests { false, false, &[], + None, ) .unwrap(); command_line @@ -3689,6 +3894,7 @@ mod tests { read_only: false, }, ], + None, ) .unwrap(); command_line diff --git a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto index 1e4317a8683..9d68f11c708 100644 --- a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto +++ b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto @@ -786,6 +786,16 @@ message MicrovmSnapshotConfig { uint64 memory_capacity_bytes = 8; // Per-launch total RAM target for restore. Zero selects the snapshot base. uint64 restore_memory_bytes = 9; + // Declare the fixed four-slot ABI 3 topology on a cold boot. + bool image_slots = 10; + // Cold-boot active image-slot count. Zero selects one when image_slots is true. + uint32 image_slot_boot_count = 11; + // Restore active image-slot count. Zero preserves the captured boot count. + uint32 restore_image_slot_count = 12; + // Host-only control endpoint path. + string host_control_path = 13; + // Exact 32-byte host-control capability carried by the authenticated management RPC. + bytes host_control_capability = 14; } message CreateVMRequest { diff --git a/vm/devices/chipset/src/microvm.rs b/vm/devices/chipset/src/microvm.rs index c9edcaaf135..516537d3868 100644 --- a/vm/devices/chipset/src/microvm.rs +++ b/vm/devices/chipset/src/microvm.rs @@ -1270,6 +1270,7 @@ mod tests { let base = RestorePacketBase { online_vp_count: 0, + image_slot_target: 0, memory_target: false, ack_required: false, generation: 8, @@ -1378,6 +1379,7 @@ mod tests { ] { let base = RestorePacketBase { online_vp_count, + image_slot_target: 0, memory_target, ack_required: true, generation: 7, @@ -1514,6 +1516,7 @@ mod tests { fn time_abi_portb_serves_restore_packet_v4() { let base = RestorePacketBase { online_vp_count: 2, + image_slot_target: 0, memory_target: false, ack_required: true, generation: 7, @@ -1586,6 +1589,7 @@ mod tests { chipset_resources::microvm::MicrovmRestorePacketSource { base: RestorePacketBase { online_vp_count: 2, + image_slot_target: 0, memory_target: false, ack_required: false, generation: 1, diff --git a/vm/devices/chipset_resources/src/microvm_time.rs b/vm/devices/chipset_resources/src/microvm_time.rs index ae195e2ecdb..23a9d3d7926 100644 --- a/vm/devices/chipset_resources/src/microvm_time.rs +++ b/vm/devices/chipset_resources/src/microvm_time.rs @@ -35,8 +35,15 @@ pub const FLAG_MEMORY_TARGET: u8 = 1 << 1; pub const FLAG_ACK_REQUIRED: u8 = 1 << 2; /// A test hook altered time ABI behavior. pub const FLAG_TEST_HOOKS: u8 = 1 << 3; -const RESTORE_PACKET_FLAGS: u8 = - FLAG_DOWNTIME_UTC | FLAG_MEMORY_TARGET | FLAG_ACK_REQUIRED | FLAG_TEST_HOOKS; +/// An image-slot target occupies header byte 7. +pub const FLAG_IMAGE_SLOT_TARGET: u8 = 1 << 4; +const RESTORE_PACKET_FLAGS: u8 = FLAG_DOWNTIME_UTC + | FLAG_MEMORY_TARGET + | FLAG_ACK_REQUIRED + | FLAG_TEST_HOOKS + | FLAG_IMAGE_SLOT_TARGET; +/// The largest image-slot target. +pub const MAX_IMAGE_SLOT_TARGET: u8 = 4; /// The portb selector of the restore packet. pub const RESTORE_PACKET_SELECT: u8 = 0xa5; @@ -71,6 +78,12 @@ pub enum RestorePacketError { /// The online VP target is not 0, 1, 2, 4, or 8. #[error("invalid online VP target {0}")] OnlineVpCount(u8), + /// The image-slot target is above [`MAX_IMAGE_SLOT_TARGET`]. + #[error("invalid image-slot target {0}")] + ImageSlotTarget(u8), + /// An image-slot target without [`FLAG_ACK_REQUIRED`]. + #[error("image-slot target {0} requires an acknowledgement")] + ImageSlotTargetUnacknowledged(u8), /// Memory ranges without a memory target, or too many ranges. #[error("invalid memory range count {0}")] MemoryRanges(usize), @@ -104,6 +117,9 @@ pub struct RestoreMemoryRange { pub struct RestorePacketBase { /// The online VP target: 0 for none, else 1, 2, 4, or 8. pub online_vp_count: u8, + /// The active image-slot target: 0 for none, else 1 to + /// [`MAX_IMAGE_SLOT_TARGET`]. + pub image_slot_target: u8, /// Whether an explicit RAM target was requested. pub memory_target: bool, /// Whether the guest must acknowledge through port `0x605`. @@ -122,6 +138,15 @@ impl RestorePacketBase { if !VALID_ONLINE_VP_COUNTS.contains(&self.online_vp_count) { return Err(RestorePacketError::OnlineVpCount(self.online_vp_count)); } + if self.image_slot_target > MAX_IMAGE_SLOT_TARGET { + return Err(RestorePacketError::ImageSlotTarget(self.image_slot_target)); + } + // The guest verifies the active prefix before it acknowledges. + if self.image_slot_target != 0 && !self.ack_required { + return Err(RestorePacketError::ImageSlotTargetUnacknowledged( + self.image_slot_target, + )); + } if self.ranges.len() > MAX_RESTORE_RANGES || (!self.memory_target && !self.ranges.is_empty()) { @@ -178,6 +203,7 @@ impl RestorePacketV4 { (self.base.memory_target, FLAG_MEMORY_TARGET), (self.base.ack_required, FLAG_ACK_REQUIRED), (self.time.test_hooks, FLAG_TEST_HOOKS), + (self.base.image_slot_target != 0, FLAG_IMAGE_SLOT_TARGET), ] { if set { flags |= flag; @@ -190,7 +216,7 @@ impl RestorePacketV4 { flags, self.base.online_vp_count, self.base.ranges.len() as u8, - 0, + self.base.image_slot_target, ]); bytes.extend_from_slice(&self.base.generation.to_le_bytes()); bytes.extend_from_slice(&self.time.rate_deviation.to_le_bytes()); @@ -218,7 +244,10 @@ impl RestorePacketV4 { return Err(RestorePacketError::Version(header[3])); } let flags = header[4]; - if flags & !RESTORE_PACKET_FLAGS != 0 || header[7] != 0 { + // The image-slot flag and header byte 7 must agree. + if flags & !RESTORE_PACKET_FLAGS != 0 + || (flags & FLAG_IMAGE_SLOT_TARGET != 0) != (header[7] != 0) + { return Err(RestorePacketError::Reserved); } let range_count = usize::from(header[6]); @@ -247,6 +276,7 @@ impl RestorePacketV4 { let packet = Self { base: RestorePacketBase { online_vp_count: header[5], + image_slot_target: header[7], memory_target: flags & FLAG_MEMORY_TARGET != 0, ack_required: flags & FLAG_ACK_REQUIRED != 0, generation: u32_at(8), @@ -327,6 +357,7 @@ mod tests { RestorePacketV4 { base: RestorePacketBase { online_vp_count: 2, + image_slot_target: 0, memory_target: false, ack_required: true, generation: 7, @@ -347,6 +378,7 @@ mod tests { RestorePacketV4 { base: RestorePacketBase { online_vp_count: 0, + image_slot_target: 0, memory_target: true, ack_required: false, generation: 1, @@ -410,6 +442,36 @@ mod tests { assert_eq!(RestorePacketV4::decode(&bytes).unwrap(), packet); } + #[test] + fn golden_packet_with_image_slot_target() { + let mut packet = packet_without_ranges(); + packet.base.image_slot_target = 3; + let mut expected = vec![ + b'O', b'V', b'R', 4, 0x15, 2, 0, 3, // header + 7, 0, 0, 0, // generation + 0xfe, 0xff, 0xff, 0xff, // rate deviation + 0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01, // downtime + 0x88, 0x77, 0x66, 0x55, 0x44, 0x33, 0x22, 0x11, // UTC + ]; + expected.extend(entropy()); + let bytes = packet.encode().unwrap(); + assert_eq!(bytes, expected); + assert_eq!(RestorePacketV4::decode(&bytes).unwrap(), packet); + + let mut bytes = bytes; + bytes[7] = MAX_IMAGE_SLOT_TARGET + 1; + assert_eq!( + RestorePacketV4::decode(&bytes).unwrap_err(), + RestorePacketError::ImageSlotTarget(MAX_IMAGE_SLOT_TARGET + 1) + ); + bytes[4] &= !FLAG_ACK_REQUIRED; + bytes[7] = 3; + assert_eq!( + RestorePacketV4::decode(&bytes).unwrap_err(), + RestorePacketError::ImageSlotTargetUnacknowledged(3) + ); + } + #[test] fn decode_rejects_malformed_packets() { let good = packet_with_ranges().encode().unwrap(); @@ -420,6 +482,8 @@ mod tests { }; assert_eq!(mutate(0, b'X'), RestorePacketError::Magic); assert_eq!(mutate(3, 3), RestorePacketError::Version(3)); + assert_eq!(mutate(4, 0x2a), RestorePacketError::Reserved); + // An image-slot flag without a target, and a target without the flag. assert_eq!(mutate(4, 0x1a), RestorePacketError::Reserved); assert_eq!(mutate(7, 1), RestorePacketError::Reserved); assert_eq!(mutate(5, 3), RestorePacketError::OnlineVpCount(3)); @@ -456,6 +520,20 @@ mod tests { RestorePacketError::MemoryRanges(2) ); + let mut packet = packet_without_ranges(); + packet.base.image_slot_target = MAX_IMAGE_SLOT_TARGET + 1; + assert_eq!( + packet.encode().unwrap_err(), + RestorePacketError::ImageSlotTarget(MAX_IMAGE_SLOT_TARGET + 1) + ); + + let mut packet = packet_with_ranges(); + packet.base.image_slot_target = 1; + assert_eq!( + packet.encode().unwrap_err(), + RestorePacketError::ImageSlotTargetUnacknowledged(1) + ); + let mut packet = packet_without_ranges(); packet.base.generation = 0; assert_eq!(packet.encode().unwrap_err(), RestorePacketError::Generation); From 4eb094876664d0ae73b471dc855d64fee465c1b5 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Thu, 8 Oct 2026 10:29:40 -0700 Subject: [PATCH 303/304] microvm: fold image slots into ABI 2 Keep image slots optional within ABI 2 instead of allocating a separate ABI version. Validate the slot-specific snapshot metadata and discovery independently, including platform-tier snapshots, while preserving the no-slot device layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Guide/src/reference/openvmm/management/cli.md | 15 +- Guide/src/user_guide/openvmm/snapshots.md | 8 +- openvmm/openvmm_defs/src/microvm.rs | 14 +- openvmm/openvmm_entry/src/cli_args/microvm.rs | 2 +- .../openvmm_helpers/src/snapshot/microvm.rs | 251 +++++++++++++----- .../src/vmservice.proto | 2 +- 6 files changed, 209 insertions(+), 83 deletions(-) diff --git a/Guide/src/reference/openvmm/management/cli.md b/Guide/src/reference/openvmm/management/cli.md index c511769ff4e..10dd4b33c86 100644 --- a/Guide/src/reference/openvmm/management/cli.md +++ b/Guide/src/reference/openvmm/management/cli.md @@ -72,8 +72,8 @@ describes the source definitions. boot-before-control discovery order and rejects user overrides that could change it. - `--microvm-image-slots` selects microVM ABI 3 and declares four permanent - read-only image-slot transports named `image0` through `image3`. Their MMIO + `--microvm-image-slots` declares four permanent, read-only image-slot + transports named `image0` through `image3` in microVM ABI 2. Their MMIO windows are `0xd0008000` through `0xd000b000`, their edge-triggered IRQs are 1, 13, 14, and 15, and their shared interrupt-status words are at offsets `0x20` through `0x2c` in the existing shared-status page. The first slot @@ -83,8 +83,8 @@ describes the source definitions. `--microvm-image-slot-boot-count ` selects the active prefix and defaults to one. Active slots are empty virtio-blk devices with 512-byte logical blocks; inactive slots report virtio device ID 0. A machine that - omits `--microvm-image-slots` remains ABI 2 with the original device - topology. + omits `--microvm-image-slots` keeps the original device topology without + reserving image-slot transports. Image slots require `--microvm-host-control listen=` and `--microvm-control-auth-stdin`. The host-control service uses the same @@ -108,8 +108,9 @@ describes the source definitions. ```admonish warning title="microVM migration" The canonical `microvm` spelling now selects the contract formerly exposed as `microvm-v2`; the `microvm-v2` selector and the former ABI-v1 behavior are - removed. Machines without image slots retain snapshot ABI 2. Declaring - image slots selects snapshot ABI 3; the boot-layout version is unchanged. + removed. Image slots extend ABI 2 in place; snapshots with or without them + use ABI 2 and the same boot-layout version. Development snapshots from + before this extension are not guaranteed to remain compatible. ``` * `--net `: With `--machine microvm`, attach one virtio-net NIC at MMIO `0xd0000000`. KVM and MSHV use IRQ 10; WHP uses IRQ 5. Prefixes @@ -450,7 +451,7 @@ describes the source definitions. validating the full saved VP inventory; that reduced-prefix process cannot be saved again. MSHV restores without this option, and KVM and WHP restores, instantiate the full VP capacity. -* `--restore-image-slots `: For an image-slot (ABI 3) microVM snapshot, +* `--restore-image-slots `: For a microVM snapshot with image slots, activate the contiguous slot prefix `image0` through `image{COUNT-1}` before restore readiness. `COUNT` must satisfy `boot-active <= COUNT <= 4`. This option sets the restore packet's image-slot target and implies the diff --git a/Guide/src/user_guide/openvmm/snapshots.md b/Guide/src/user_guide/openvmm/snapshots.md index bb15421fa70..6d045be37bb 100644 --- a/Guide/src/user_guide/openvmm/snapshots.md +++ b/Guide/src/user_guide/openvmm/snapshots.md @@ -237,8 +237,8 @@ Snapshots without the explicit capture-time `maxcpus` opt-in reject a restore target. This is not a post-readiness hotplug API and cannot add VPs absent from the saved topology. -MicroVM ABI 3 snapshots record a fixed capacity of four image slots and the -active cold-boot prefix. Capture is accepted only while every active slot is +MicroVM ABI 2 snapshots with image slots record a fixed capacity of four and +the active cold-boot prefix. Capture is accepted only while every active slot is empty; a bound slot rejects capture before guest execution is quiesced. `--restore-image-slots ` may activate a larger contiguous prefix before restore readiness and must satisfy @@ -248,8 +248,8 @@ explicit target always holds the restore gate, so the packet also sets flag bit 2: the guest verifies the active prefix and acknowledges, even when no new slot becomes active. Restored slots always begin empty, and no slot can be activated after -readiness. ABI 2 snapshots have no image-slot fields, and their restore packet -leaves header byte 7 zero. +readiness. Snapshots without image slots record zero capacity and boot-active +count, and their restore packet leaves header byte 7 zero. ```admonish warning Snapshots do not contain or validate embedded checksums for diff --git a/openvmm/openvmm_defs/src/microvm.rs b/openvmm/openvmm_defs/src/microvm.rs index 070082a5fd2..acf13ae32f5 100644 --- a/openvmm/openvmm_defs/src/microvm.rs +++ b/openvmm/openvmm_defs/src/microvm.rs @@ -21,11 +21,9 @@ use net_backend_resources::mac_address::MacAddress; use std::fmt::Write as _; use vmotherboard::options::BaseChipsetManifest; -/// The persisted microVM ABI version. +/// The persisted microVM ABI version, including optional image slots. pub const MICROVM_ABI_VERSION_2: u32 = 2; -/// MicroVM ABI with four bind-once image slots. -pub const MICROVM_ABI_VERSION_3: u32 = 3; -/// Fixed image-slot capacity of microVM ABI 3. +/// Fixed image-slot capacity when enabled. pub const MICROVM_IMAGE_SLOT_CAPACITY: u8 = 4; /// Linux memory-block granularity used for microVM restore-time expansion. pub const MICROVM_MEMORY_BLOCK_SIZE_BYTES: u64 = 128 * 1024 * 1024; @@ -312,7 +310,7 @@ pub fn microvm_image_slot_name(index: u8) -> anyhow::Result { Ok(format!("image{index}")) } -/// Guest-visible activation contract for microVM ABI 3 image slots. +/// Guest-visible activation contract for optional microVM image slots. #[derive(MeshPayload, Debug, Clone, Copy, PartialEq, Eq)] pub struct MicrovmImageSlotsConfig { /// Number of slots active on the captured cold boot. @@ -1198,12 +1196,12 @@ fn validate_microvm_command_line( image_slots.validate()?; anyhow::ensure!( image_slot_count == MICROVM_IMAGE_SLOT_CAPACITY as usize, - "microVM ABI 3 requires exactly {MICROVM_IMAGE_SLOT_CAPACITY} image-slot transports" + "microVM requires exactly {MICROVM_IMAGE_SLOT_CAPACITY} configured image-slot transports" ); } else { anyhow::ensure!( image_slot_count == 0, - "microVM ABI 2 does not permit image-slot transports" + "microVM does not permit image-slot transports without image-slot configuration" ); } anyhow::ensure!( @@ -1549,7 +1547,7 @@ pub struct MicrovmConfig { pub filesystems: Vec, /// Stable sandbox block-device roles in virtio-blk device order. pub sandbox_blocks: Vec, - /// Fixed-capacity image slots, when ABI 3 is selected. + /// Optional fixed-capacity image slots within microVM ABI 2. pub image_slots: Option, /// Whether the effective command line bootstraps every attached microVM /// filesystem. diff --git a/openvmm/openvmm_entry/src/cli_args/microvm.rs b/openvmm/openvmm_entry/src/cli_args/microvm.rs index 630e9e35c66..8d46acdb5d1 100644 --- a/openvmm/openvmm_entry/src/cli_args/microvm.rs +++ b/openvmm/openvmm_entry/src/cli_args/microvm.rs @@ -293,7 +293,7 @@ impl From for MachineProfile { /// Options of the microVM machine profile. #[derive(clap::Args)] pub struct MicrovmCli { - /// Declare the four bind-once read-only image slots of microVM ABI 3. + /// Declare four optional bind-once read-only image slots in microVM ABI 2. #[clap(long)] pub microvm_image_slots: bool, diff --git a/openvmm/openvmm_helpers/src/snapshot/microvm.rs b/openvmm/openvmm_helpers/src/snapshot/microvm.rs index 191fcee6f87..53168bc9bff 100644 --- a/openvmm/openvmm_helpers/src/snapshot/microvm.rs +++ b/openvmm/openvmm_helpers/src/snapshot/microvm.rs @@ -485,10 +485,10 @@ pub struct SnapshotMachineContract { /// which requires a filesystem in the first slot. #[mesh(33)] pub microvm_additional_filesystems: Vec, - /// Fixed image-slot capacity, or zero for ABI 2. + /// Fixed image-slot capacity when declared, or zero without image slots. #[mesh(34)] pub microvm_image_slot_capacity: u32, - /// Image slots active on the captured cold boot. + /// Image slots active on the captured cold boot, or zero without slots. #[mesh(35)] pub boot_active_image_slot_count: u8, } @@ -552,6 +552,47 @@ fn microvm_boot_online_vp_count( Ok(boot_online_vp_count.unwrap_or(0)) } +fn validate_microvm_image_slot_contract(contract: &SnapshotMachineContract) -> anyhow::Result<()> { + let capacity = u32::from(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY); + match contract.microvm_image_slot_capacity { + 0 => anyhow::ensure!( + contract.boot_active_image_slot_count == 0, + "snapshot without image slots has a boot-active image-slot count" + ), + declared if declared == capacity => { + anyhow::ensure!( + (1..=openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY) + .contains(&contract.boot_active_image_slot_count), + "snapshot boot-active image-slot count is invalid" + ); + anyhow::ensure!( + contract.microvm_additional_filesystems.is_empty(), + "snapshot image slots cannot coexist with the second virtio-fs slot" + ); + } + declared => anyhow::bail!( + "snapshot image-slot capacity {declared} is unsupported; expected 0 or {capacity}" + ), + } + let mut tokens = contract + .effective_command_line + .split_ascii_whitespace() + .filter(|token| token.starts_with("microvm_image_slots=")); + if contract.microvm_image_slot_capacity == 0 { + anyhow::ensure!( + tokens.next().is_none(), + "snapshot without image slots has a discovery token" + ); + } else { + let discovery = format!("microvm_image_slots={capacity}"); + anyhow::ensure!( + tokens.next() == Some(discovery.as_str()) && tokens.next().is_none(), + "snapshot image-slot discovery token does not match the declared capacity" + ); + } + Ok(()) +} + /// Validates a restore-time online VP target against an opt-in snapshot contract. pub fn validate_restore_online_vp_count( manifest: &SnapshotManifest, @@ -562,41 +603,7 @@ pub fn validate_restore_online_vp_count( .as_ref() .context("snapshot is missing the authoritative machine contract")?; validate_supported_microvm_contract(contract)?; - match contract.microvm_abi_version { - openvmm_defs::microvm::MICROVM_ABI_VERSION_2 => { - anyhow::ensure!( - contract.microvm_image_slot_capacity == 0 - && contract.boot_active_image_slot_count == 0, - "snapshot ABI 2 must not declare image slots" - ); - } - openvmm_defs::microvm::MICROVM_ABI_VERSION_3 => { - anyhow::ensure!( - contract.microvm_image_slot_capacity - == u32::from(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY), - "snapshot ABI 3 image-slot capacity is invalid" - ); - anyhow::ensure!( - (1..=openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY) - .contains(&contract.boot_active_image_slot_count), - "snapshot ABI 3 boot-active image-slot count is invalid" - ); - anyhow::ensure!( - contract - .effective_command_line - .split_ascii_whitespace() - .any(|token| { - token - == format!( - "microvm_image_slots={}", - openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY - ) - }), - "snapshot ABI 3 image-slot discovery token is invalid" - ); - } - _ => unreachable!("supported microVM ABI was validated above"), - } + validate_microvm_image_slot_contract(contract)?; anyhow::ensure!( contract.boot_online_vp_count != 0, "snapshot does not declare restore-time VP activation support" @@ -618,7 +625,7 @@ pub fn validate_restore_online_vp_count( Ok(()) } -/// Validates a restore-time active image-slot target against ABI 3. +/// Validates a restore-time active image-slot target against the snapshot. pub fn validate_restore_active_image_slot_count( manifest: &SnapshotManifest, restore_active_image_slot_count: u8, @@ -628,6 +635,7 @@ pub fn validate_restore_active_image_slot_count( .as_ref() .context("snapshot is missing the authoritative machine contract")?; validate_supported_microvm_contract(contract)?; + validate_microvm_image_slot_contract(contract)?; anyhow::ensure!( contract.microvm_image_slot_capacity == u32::from(openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY), @@ -870,7 +878,7 @@ pub fn microvm_machine_contract( ) } -/// Builds the authoritative microVM machine contract with optional ABI 3 +/// Builds the authoritative microVM ABI 2 machine contract with optional /// image slots, which use the second virtio-fs slot's window and interrupt. pub fn microvm_machine_contract_with_image_slots( source_hypervisor: &str, @@ -1360,11 +1368,7 @@ pub fn microvm_machine_contract_with_image_slots( let mut contract = SnapshotMachineContract { machine_profile: "microvm".to_owned(), - microvm_abi_version: if image_slots.is_some() { - openvmm_defs::microvm::MICROVM_ABI_VERSION_3 - } else { - openvmm_defs::microvm::MICROVM_ABI_VERSION_2 - }, + microvm_abi_version: openvmm_defs::microvm::MICROVM_ABI_VERSION_2, source_hypervisor: source_hypervisor.to_owned(), effective_command_line: String::new(), effective_command_line_sha256: Vec::new(), @@ -1406,11 +1410,7 @@ pub fn microvm_machine_contract_with_image_slots( /// Returns whether restore must hold external device input until guest repair completes. pub fn requires_post_restore_gate(manifest: &SnapshotManifest) -> bool { manifest.machine_contract.as_ref().is_some_and(|contract| { - matches!( - contract.microvm_abi_version, - openvmm_defs::microvm::MICROVM_ABI_VERSION_2 - | openvmm_defs::microvm::MICROVM_ABI_VERSION_3 - ) + contract.microvm_abi_version == openvmm_defs::microvm::MICROVM_ABI_VERSION_2 }) && !manifest.snapshot_tier.is_empty() } @@ -1430,15 +1430,10 @@ pub fn validate_supported_microvm_contract( contract: &SnapshotMachineContract, ) -> anyhow::Result<()> { anyhow::ensure!( - matches!( - contract.microvm_abi_version, - openvmm_defs::microvm::MICROVM_ABI_VERSION_2 - | openvmm_defs::microvm::MICROVM_ABI_VERSION_3 - ), - "snapshot microVM ABI version {} is unsupported; this OpenVMM supports versions {} and {}", + contract.microvm_abi_version == openvmm_defs::microvm::MICROVM_ABI_VERSION_2, + "snapshot microVM ABI version {} is unsupported; this OpenVMM supports version {}", contract.microvm_abi_version, openvmm_defs::microvm::MICROVM_ABI_VERSION_2, - openvmm_defs::microvm::MICROVM_ABI_VERSION_3, ); anyhow::ensure!( contract.boot_layout_version == MICROVM_BOOT_LAYOUT_VERSION, @@ -1571,6 +1566,7 @@ pub(super) fn validate_machine_contract_shape( vp_count: u32, ) -> anyhow::Result<()> { validate_supported_microvm_contract(contract)?; + validate_microvm_image_slot_contract(contract)?; anyhow::ensure!( contract.virtio_interrupt_mode == MICROVM_SHARED_STATUS_INTERRUPT_MODE && contract.virtio_shared_status_page_gpa @@ -2243,6 +2239,7 @@ fn platform_command_line_token_is_invariant(token: &str) -> bool { | "nvx_sandbox=1" | "nvx_config=0xd0010000,65536" | "nvx_snapshot_tier=platform" + | "microvm_image_slots=4" ) || token.starts_with("nr_cpus=") || token == openvmm_defs::microvm::MICROVM_CONTROL_TTY_COMMAND_LINE || [ @@ -2439,10 +2436,14 @@ mod tests { fn restore_image_slot_count_is_bounded_by_template() { let mut manifest = test_manifest(); let mut contract = test_machine_contract(); - contract.microvm_abi_version = openvmm_defs::microvm::MICROVM_ABI_VERSION_3; contract.microvm_image_slot_capacity = openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY.into(); contract.boot_active_image_slot_count = 2; + contract.set_effective_command_line("console=hvc0 microvm_image_slots=4".to_owned()); + assert_eq!( + contract.microvm_abi_version, + openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + ); manifest.machine_contract = Some(contract); for target in [2, 3, 4] { @@ -2453,6 +2454,127 @@ mod tests { } } + #[test] + fn generated_image_slots_use_abi_two() { + use openvmm_defs::microvm::{ + MICROVM_BASE_COMMAND_LINE, MICROVM_IMAGE_SLOT_MMIO_BASES, MicrovmImageSlotsConfig, + append_microvm_virtio_discovery, + }; + + for image_slots in [ + None, + Some(MicrovmImageSlotsConfig { + boot_count: 1, + active_count: 1, + }), + ] { + let mut command_line = MICROVM_BASE_COMMAND_LINE.to_owned(); + append_microvm_virtio_discovery( + &mut command_line, + None, + false, + &[], + false, + false, + &[], + image_slots, + ) + .unwrap(); + let mut state_unit_names = [ + "partition", + "vmtime", + "pic", + "ioapic", + "pit", + "rtc", + "microvm-portb", + "microvm-shutdown", + "microvm-snapshot-request", + ] + .map(str::to_owned) + .to_vec(); + if image_slots.is_some() { + state_unit_names.extend( + MICROVM_IMAGE_SLOT_MMIO_BASES + .iter() + .map(|base| format!("virtio-blk-image-slot-{base}")), + ); + } + + let contract = microvm_machine_contract_with_image_slots( + "whp", + MICROVM_BOOT_LAYOUT_VERSION, + command_line, + None, + false, + Vec::new(), + None, + None, + Vec::new(), + image_slots, + 1, + 1024, + None, + state_unit_names, + crate::snapshot::time::tests::test_time_contract(), + crate::snapshot::time::tests::test_cpu_profile(), + ) + .unwrap(); + assert_eq!( + contract.microvm_abi_version, + openvmm_defs::microvm::MICROVM_ABI_VERSION_2 + ); + assert_eq!( + contract.microvm_image_slot_capacity, + if image_slots.is_some() { 4 } else { 0 } + ); + assert_eq!( + contract.boot_active_image_slot_count, + image_slots.map_or(0, |slots| slots.boot_count) + ); + assert_eq!( + contract + .devices + .iter() + .filter(|device| device.kind == "virtio-blk-image-slot") + .count(), + if image_slots.is_some() { 4 } else { 0 } + ); + } + } + + #[test] + fn abi_two_image_slots_require_canonical_metadata() { + let mut contract = test_machine_contract(); + validate_microvm_image_slot_contract(&contract).unwrap(); + + contract.boot_active_image_slot_count = 1; + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.microvm_image_slot_capacity = + openvmm_defs::microvm::MICROVM_IMAGE_SLOT_CAPACITY.into(); + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.set_effective_command_line("console=hvc0 microvm_image_slots=4".to_owned()); + validate_microvm_image_slot_contract(&contract).unwrap(); + assert!(platform_command_line_token_is_invariant( + "microvm_image_slots=4" + )); + assert!(!platform_command_line_token_is_invariant( + "microvm_image_slots=3" + )); + + contract.boot_active_image_slot_count = 0; + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.boot_active_image_slot_count = 5; + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.boot_active_image_slot_count = 1; + contract.set_effective_command_line( + "console=hvc0 microvm_image_slots=4 microvm_image_slots=4".to_owned(), + ); + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + contract.microvm_image_slot_capacity = 0; + assert!(validate_microvm_image_slot_contract(&contract).is_err()); + } + fn microvm_console_attachment() -> SnapshotAttachment { SnapshotAttachment { stable_id: "console:microvm-virtio0".to_owned(), @@ -3563,11 +3685,16 @@ mod tests { fn validate_microvm_machine_contract_rejects_abi() { let mut manifest = test_manifest(); let expected = test_machine_contract(); - let mut contract = expected.clone(); - contract.microvm_abi_version = 1; - manifest.machine_contract = Some(contract); - let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); - assert!(err.to_string().contains("ABI version 1 is unsupported")); + for version in [1, 3] { + let mut contract = expected.clone(); + contract.microvm_abi_version = version; + manifest.machine_contract = Some(contract); + let err = validate_microvm_machine_contract(&manifest, &expected).unwrap_err(); + assert!( + err.to_string() + .contains(&format!("ABI version {version} is unsupported")) + ); + } } #[test] diff --git a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto index 9d68f11c708..2a663410d5c 100644 --- a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto +++ b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto @@ -786,7 +786,7 @@ message MicrovmSnapshotConfig { uint64 memory_capacity_bytes = 8; // Per-launch total RAM target for restore. Zero selects the snapshot base. uint64 restore_memory_bytes = 9; - // Declare the fixed four-slot ABI 3 topology on a cold boot. + // Declare the fixed four-slot microVM ABI 2 topology on a cold boot. bool image_slots = 10; // Cold-boot active image-slot count. Zero selects one when image_slots is true. uint32 image_slot_boot_count = 11; From 67b641d849619513e86128d718767b8b82bf8136 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Fri, 9 Oct 2026 11:01:31 -0700 Subject: [PATCH 304/304] microvm: harden host-control framing, logging, and cleanup Address review findings in the image-slot host-control service: - Redact the host-control capability when the management RPC logs a CreateVM request. The capability authenticates the endpoint. - Apply the idle timeout to a request frame's payload as well as its length, so an authenticated client that stalls inside a frame cannot hold the single-connection endpoint. - Keep responses within the 64 KiB frame limit. A response that would exceed it, such as an error that echoes a long path, becomes a bounded response_too_large error for the same request. - Reject a management-RPC image_slot_boot_count without image_slots, as the command line already does. - After a committed snapshot, remove the host-control socket with the console sockets and fail the source process if that removal fails, instead of leaving it to the cleanup guard. Drop the unreachable duplicate removal from the error path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../management/host_control_protocol.md | 5 +- .../openvmm_entry/src/microvm/host_control.rs | 111 ++++++++++++++++-- openvmm/openvmm_entry/src/ttrpc/microvm.rs | 65 ++++++++++ openvmm/openvmm_entry/src/ttrpc/mod.rs | 7 +- .../src/vm_controller/microvm.rs | 17 +-- .../src/vmservice.proto | 3 +- 6 files changed, 190 insertions(+), 18 deletions(-) diff --git a/Guide/src/reference/openvmm/management/host_control_protocol.md b/Guide/src/reference/openvmm/management/host_control_protocol.md index 23834e48820..84bbf6daf74 100644 --- a/Guide/src/reference/openvmm/management/host_control_protocol.md +++ b/Guide/src/reference/openvmm/management/host_control_protocol.md @@ -15,7 +15,10 @@ argv or the environment. After connecting, the client writes the raw 32-byte capability. Authentication must complete within the configured control-auth timeout. Requests and responses then use a little-endian `u32` byte length followed by UTF-8 JSON. -Frames are limited to 64 KiB, and an idle connection closes after 60 seconds. +Frames in both directions are limited to 64 KiB; a response that would exceed +the limit is replaced by a `response_too_large` error. A connection closes +after 60 seconds without a new request or while a request frame is incomplete. +The management RPC never logs the capability. Every request contains `version: 1`, a caller-selected `request_id`, and an `operation`. Responses repeat the protocol version and request ID and contain either `status: "ok"` or a stable error code and message. diff --git a/openvmm/openvmm_entry/src/microvm/host_control.rs b/openvmm/openvmm_entry/src/microvm/host_control.rs index 4d264c7bc45..dd7dc6bdfb2 100644 --- a/openvmm/openvmm_entry/src/microvm/host_control.rs +++ b/openvmm/openvmm_entry/src/microvm/host_control.rs @@ -175,19 +175,14 @@ impl MicrovmHostControlServer { (1..=MAX_FRAME_SIZE).contains(&length), "host-control frame length is invalid" ); - let mut payload = vec![0; length]; - self.io - .read_exact(&mut payload) - .await - .context("failed to read host-control frame")?; + let payload = read_frame_payload(&mut self.io, length, IDLE_TIMEOUT).await?; serde_json::from_slice(&payload) .map(Some) .context("host-control request is invalid JSON") } async fn write_response(&mut self, response: &Response) -> anyhow::Result<()> { - let payload = - serde_json::to_vec(response).context("failed to encode host-control response")?; + let payload = encode_response(response)?; let length = u32::try_from(payload.len()).context("host-control response is too large")?; self.io .write_all(&length.to_le_bytes()) @@ -374,6 +369,41 @@ async fn read_frame_length( Ok(Some(u32::from_le_bytes(length))) } +/// Reads a frame payload. A peer that stalls inside a frame is treated like an idle +/// peer, so it cannot hold the single-connection endpoint. +async fn read_frame_payload( + io: &mut (impl futures::AsyncRead + Unpin), + length: usize, + timeout: Duration, +) -> anyhow::Result> { + let mut payload = vec![0; length]; + CancelContext::new() + .with_timeout(timeout) + .until_cancelled(io.read_exact(&mut payload)) + .await + .context("host-control frame stalled")? + .context("failed to read host-control frame")?; + Ok(payload) +} + +/// Encodes a response within the frame limit. Error messages can echo request +/// input, so a response that would exceed the limit becomes a bounded error. +fn encode_response(response: &Response) -> anyhow::Result> { + let payload = serde_json::to_vec(response).context("failed to encode host-control response")?; + if payload.len() <= MAX_FRAME_SIZE { + return Ok(payload); + } + serde_json::to_vec(&Response { + version: PROTOCOL_VERSION, + request_id: response.request_id, + result: ResponseResult::Error { + code: "response_too_large", + message: format!("host-control response exceeds the {MAX_FRAME_SIZE}-byte frame limit"), + }, + }) + .context("failed to encode host-control response") +} + fn open_read_only_media(path: &Path) -> anyhow::Result { let mut options = std::fs::OpenOptions::new(); options.read(true); @@ -412,9 +442,76 @@ fn open_read_only_media(path: &Path) -> anyhow::Result { #[cfg(test)] mod tests { + use super::IDLE_TIMEOUT; + use super::MAX_FRAME_SIZE; + use super::PROTOCOL_VERSION; + use super::Response; + use super::ResponseResult; + use super::encode_response; use super::read_frame_length; + use super::read_frame_payload; use futures::executor::block_on; use futures::io::Cursor; + use std::pin::Pin; + use std::task::Context; + use std::task::Poll; + use std::time::Duration; + + fn error_response(request_id: u64, message: String) -> Response { + Response { + version: PROTOCOL_VERSION, + request_id, + result: ResponseResult::Error { + code: "invalid_media", + message, + }, + } + } + + #[test] + fn oversized_responses_become_bounded_errors() { + let payload = encode_response(&error_response(7, "x".repeat(MAX_FRAME_SIZE))).unwrap(); + assert!(payload.len() <= MAX_FRAME_SIZE); + let value: serde_json::Value = serde_json::from_slice(&payload).unwrap(); + assert_eq!(value["request_id"], 7); + assert_eq!(value["status"], "error"); + assert_eq!(value["code"], "response_too_large"); + + let payload = encode_response(&error_response(8, "short".to_owned())).unwrap(); + let value: serde_json::Value = serde_json::from_slice(&payload).unwrap(); + assert_eq!(value["request_id"], 8); + assert_eq!(value["code"], "invalid_media"); + assert_eq!(value["message"], "short"); + } + + #[test] + fn stalled_frame_payload_times_out() { + struct Stalled; + + impl futures::AsyncRead for Stalled { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + _buf: &mut [u8], + ) -> Poll> { + Poll::Pending + } + } + + let error = block_on(read_frame_payload( + &mut Stalled, + 4, + Duration::from_millis(10), + )) + .unwrap_err(); + assert!(format!("{error:#}").contains("host-control frame stalled")); + + let mut io = Cursor::new(b"{}".to_vec()); + assert_eq!( + block_on(read_frame_payload(&mut io, 2, IDLE_TIMEOUT)).unwrap(), + b"{}" + ); + } #[test] fn close_at_frame_boundary_ends_the_session() { diff --git a/openvmm/openvmm_entry/src/ttrpc/microvm.rs b/openvmm/openvmm_entry/src/ttrpc/microvm.rs index c1d117a1e48..02527322165 100644 --- a/openvmm/openvmm_entry/src/ttrpc/microvm.rs +++ b/openvmm/openvmm_entry/src/ttrpc/microvm.rs @@ -64,6 +64,25 @@ struct RestoredFilesystem { attachment: openvmm_helpers::snapshot::microvm::SnapshotAttachment, } +/// Formats a create request without its host-control capability, which +/// authenticates a host-only endpoint and must never reach logs. +pub(super) struct RedactedCreateVmRequest<'a>(pub(super) &'a vmservice::CreateVmRequest); + +impl std::fmt::Debug for RedactedCreateVmRequest<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut request = self.0.clone(); + let redacted = request + .microvm_snapshot + .as_mut() + .is_some_and(|config| !std::mem::take(&mut config.host_control_capability).is_empty()); + std::fmt::Debug::fmt(&request, f)?; + if redacted { + f.write_str(" (host_control_capability redacted)")?; + } + Ok(()) + } +} + /// MicroVM state accumulated while a management-RPC VM is being created. pub(super) struct CreateVm { active: bool, @@ -135,6 +154,10 @@ impl CreateVm { (restore_processor_count != 0).then_some(restore_processor_count); let image_slot_boot_count = u8::try_from(image_slot_boot_count) .context("image_slot_boot_count does not fit in u8")?; + anyhow::ensure!( + image_slots || image_slot_boot_count == 0, + "image_slot_boot_count requires image_slots" + ); let restore_active_image_slot_count = (restore_image_slot_count != 0) .then(|| u8::try_from(restore_image_slot_count)) .transpose() @@ -1792,4 +1815,46 @@ mod tests { )); } } + + #[test] + fn create_request_debug_redacts_the_host_control_capability() { + let request = vmservice::CreateVmRequest { + microvm_snapshot: Some(vmservice::MicrovmSnapshotConfig { + host_control_path: "host-control".to_owned(), + host_control_capability: vec![0x5a; 32], + ..Default::default() + }), + ..Default::default() + }; + let capability = format!("{:?}", vec![0x5a_u8; 32]); + assert!(format!("{request:?}").contains(&capability)); + let redacted = format!("{:?}", RedactedCreateVmRequest(&request)); + assert!(!redacted.contains(&capability)); + assert!(redacted.contains("host-control")); + assert!(redacted.contains("host_control_capability redacted")); + + let request = vmservice::CreateVmRequest::default(); + assert_eq!( + format!("{:?}", RedactedCreateVmRequest(&request)), + format!("{request:?}") + ); + } + + #[test] + fn image_slot_boot_count_requires_image_slots() { + let error = CreateVm::new(vmservice::CreateVmRequest { + microvm_snapshot: Some(vmservice::MicrovmSnapshotConfig { + image_slot_boot_count: 2, + ..Default::default() + }), + ..Default::default() + }) + .err() + .expect("a boot count without image slots must be rejected"); + assert!( + error + .to_string() + .contains("image_slot_boot_count requires image_slots") + ); + } } diff --git a/openvmm/openvmm_entry/src/ttrpc/mod.rs b/openvmm/openvmm_entry/src/ttrpc/mod.rs index dc705b7ac82..24ced839b03 100644 --- a/openvmm/openvmm_entry/src/ttrpc/mod.rs +++ b/openvmm/openvmm_entry/src/ttrpc/mod.rs @@ -729,7 +729,12 @@ enum HandleAction { impl VmService { async fn handle(&mut self, ctx: mesh::CancelContext, request: vmservice::Vm) -> HandleAction { - tracing::debug!(?request, "request"); + if let vmservice::Vm::CreateVm(create, _) = &request { + let request = microvm::RedactedCreateVmRequest(create); + tracing::debug!(?request, "create VM request"); + } else { + tracing::debug!(?request, "request"); + } match request { vmservice::Vm::CreateVm(request, response) => { response.send(map_grpc(self.create_vm(request).await)) diff --git a/openvmm/openvmm_entry/src/vm_controller/microvm.rs b/openvmm/openvmm_entry/src/vm_controller/microvm.rs index 331e5e271d1..938062388ef 100644 --- a/openvmm/openvmm_entry/src/vm_controller/microvm.rs +++ b/openvmm/openvmm_entry/src/vm_controller/microvm.rs @@ -510,6 +510,15 @@ impl VmController { ); return GuestSnapshotAction::Terminate { exit_code: 1 }; } + if let Some(cleanup) = self.microvm.resources.host_control_socket_cleanup.take() + && let Err(error) = cleanup.remove_if_owned() + { + tracing::error!( + error = error.as_ref() as &dyn std::error::Error, + "snapshot committed but the source host-control socket could not be removed" + ); + return GuestSnapshotAction::Terminate { exit_code: 1 }; + } tracing::info!( path = %destination.display(), "microVM snapshot committed; terminating source process" @@ -545,14 +554,6 @@ impl VmController { "committed snapshot control console socket could not be removed" ); } - if let Some(cleanup) = self.microvm.resources.host_control_socket_cleanup.take() - && let Err(cleanup_error) = cleanup.remove_if_owned() - { - tracing::error!( - error = cleanup_error.as_ref() as &dyn std::error::Error, - "committed snapshot host-control socket could not be removed" - ); - } tracing::error!( error = error.as_ref() as &dyn std::error::Error, path = %destination.display(), diff --git a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto index 2a663410d5c..f693335fafb 100644 --- a/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto +++ b/openvmm/openvmm_ttrpc_vmservice/src/vmservice.proto @@ -788,7 +788,8 @@ message MicrovmSnapshotConfig { uint64 restore_memory_bytes = 9; // Declare the fixed four-slot microVM ABI 2 topology on a cold boot. bool image_slots = 10; - // Cold-boot active image-slot count. Zero selects one when image_slots is true. + // Cold-boot active image-slot count. Zero selects one when image_slots is + // true; a nonzero count requires image_slots. uint32 image_slot_boot_count = 11; // Restore active image-slot count. Zero preserves the captured boot count. uint32 restore_image_slot_count = 12;