From 11b430db23400ecd05878af8bdff067c5c8f3180 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:05:39 -0800 Subject: [PATCH 01/25] host: reconcile verified stop outcomes onto feature Port the preserved SL2.10 implementation and coupled callers, API snapshots, docs and real-process tests onto feature c52c7101 without changing legacy lifecycle policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- doc/usage.md | 1 + nvx-rs/README.md | 24 ++- nvx-rs/public-api/linux.txt | 8 + nvx-rs/public-api/windows.txt | 8 + nvx-rs/src/error.rs | 9 + nvx-rs/src/error/tests.rs | 1 + nvx-rs/src/host/instance/locked.rs | 3 +- nvx-rs/src/host/platform/windows/tests.rs | 15 +- nvx-rs/src/host/process.rs | 18 +- nvx-rs/src/host/process/tests.rs | 3 +- nvx-rs/src/host/process/tests/linux.rs | 5 +- nvx-rs/src/host/report.rs | 25 ++- nvx-rs/src/host/report/tests.rs | 55 ++++- nvx-rs/src/host/template/clone/records.rs | 4 +- .../src/host/template/clone/tests/launch.rs | 17 +- .../src/host/template/clone/tests/restore.rs | 2 +- .../src/host/template/store/leases/tests.rs | 8 +- nvx-rs/src/host/vm.rs | 74 +++++++ nvx-rs/src/host/vm/tests.rs | 189 +++++++++++++++++- nvx-rs/src/host/vm/tests/reattach.rs | 2 +- nvx-rs/tests/checked_handoff.rs | 2 + nvx-rs/tests/cli.rs | 9 +- nvx-rs/tests/cli/download.rs | 16 ++ nvx-rs/tests/cli/lifecycle.rs | 15 +- nvx-rs/tests/fake_openvmm.rs | 7 +- nvx-rs/tests/store.rs | 11 +- nvx-rs/tests/supervisor.rs | 42 ++-- .../src/bin/runtime-adapter-host.rs | 16 +- tests/runtime-adapter/tests/vm_clone.rs | 14 +- 29 files changed, 530 insertions(+), 73 deletions(-) diff --git a/doc/usage.md b/doc/usage.md index a0e64ec5d..12eb06dd9 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -110,6 +110,7 @@ each code for callers of the `nvx` library. | `template-ineligible` | 1 | The restore window of a template's generation, which the time contract of its capture sets, excludes the host's time, so no clone may restore it now. | | `template-tombstoned` | 1 | A tombstone quarantines the template's generation, which takes no new clones. | | `exited` | 1 | OpenVMM exited, as when a start fails, whose message names the category and status of OpenVMM's outcome report when it has one. | +| `still-running` | 1 | A stop could not confirm the VM process's exit, so the caller keeps its verified process. | | `exec-refused` | 1 | The guest agent refused the workload, as for a working directory that it cannot use. | | `endpoint-closed` | 1 | OpenVMM closed the control endpoint, as it does when the VM shuts down. | | `protocol` | 1 | The control session failed otherwise, as when the guest breaks the protocol or the session is reset. | diff --git a/nvx-rs/README.md b/nvx-rs/README.md index cd9c6f7ca..3019c6ebc 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -124,7 +124,7 @@ changes only when the module's purpose does. | [`nvx::Hypervisor`](#nvxhypervisor) | KVM, MSHV, or WHP, with its serde form behind the `serde` feature | | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | -| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold preparation, verified detached launch, and caller-saved reattachment | +| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold preparation, verified detached launch, confirmed stop, and caller-saved reattachment | | [`nvx::host::snapshot`](#nvxhostsnapshot) | Snapshot compatibility keys, strict manifests, restore-time eligibility, and the opaque input of a future VM restore | | [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity | | [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener | @@ -500,6 +500,17 @@ or reading runtime files. `Reattached` holds both the process and authenticated connection. Failures retain their original kinds and sources and leave the saved values unchanged. +`stop(process, outputs, graceful, grace, deadline)` waits through the verified +process, first after the caller's graceful request, then after forced termination +if needed. It returns `Stopped` only after confirmed exit. `self_exited` records +confirmation before a forced attempt, and `forced` records that attempt, not +proof from kill success. An unconfirmed exit is `StillRunning`, and the caller +keeps the process. The callback receives the bounded grace deadline and must +respect it; its boolean result is never exit proof. + +`Stopped::report` retains the outcome-report read's result separately, so report +failure never hides confirmed exit. + `vm::create(&runtime, &config, None, &dir)` creates an inspectable cold `Prepared` launch without starting a process or binding a listener. It requires `config.boot`, refuses a snapshot input, rechecks the directory's privacy, and @@ -976,6 +987,10 @@ On Windows, the duplicate retains the original handle's access rights. Duplication failures have kind `Io` and retain their original I/O source. No watcher thread, future or callback is created by the library. +`Process::wait(deadline)` returns `Some(Exited)` only after verified exit, or +`None` when the deadline passes without confirmation. `Exited` cannot be made +by a caller. + `Process::bytes_read` reports how many bytes the process has read through the operating system's read calls, whether the host's cache served them or not: `rchar` of `/proc//io` on Linux, which it reads only while the process @@ -1422,6 +1437,11 @@ The authenticated control session, which carries NVX's control protocol, `NVXC`, one frame per DATA record of a `HostConnection`; Linux and Windows primitives; and OpenVMM's outcome report. +`report::read` returns `Ok(None)` for a missing report, and an `Io` error for an +unreadable or malformed report, retaining the underlying error. It keeps today's +summary of the outcome category, status code and teardown steps, not free-form +fields. + A `Transport` to an OpenVMM control endpoint reads and writes with deadlines, and tells, through `Transport::unread`, what the endpoint has not read yet of what it wrote, where the platform tells: on Linux, what `SIOCOUTQ` reports of @@ -3055,6 +3075,7 @@ optional cause. | `Unavailable` | A missing or unusable artifact, hypervisor, or release, a guest image without a required control feature, or an unsupported host | | `Unsupported` | A request that NVX refuses: a value that it does not accept, such as a malformed or out-of-range one, options that it cannot combine, or a request that it cannot represent or enforce, such as a policy beyond a limit of OpenVMM or the guest agent | | `Exited` | The VM's OpenVMM process exited | +| `StillRunning` | A stop could not confirm the VM process's exit, so the caller keeps its verified process | | `Io` | A host I/O failure, or state that NVX recorded on the host and cannot read | | `NotFound` | State that an operation names and that does not exist on the host, such as the store of a sandbox that was never created or that a removal deleted | | `Protocol` | A control session that failed, or a request that the guest agent refused | @@ -3087,6 +3108,7 @@ usage errors, which no library error has. | `unavailable` | `Unavailable` | A dependency is missing or unusable, other than as a finer code says: install metadata, the hypervisor, a release, a distro layer or scratch image that the request names, or a host that NVX does not support | | `unsupported` | `Unsupported` | NVX refuses the request: options that it cannot combine, a value that it does not accept, or a policy that it cannot represent or enforce. The message names the option or field | | `exited` | `Exited` | OpenVMM exited, as when a start fails, whose message names the category and status of OpenVMM's outcome report when it has one | +| `still-running` | `StillRunning` | A stop could not confirm the VM process's exit, so the caller keeps its verified process | | `io` | `Io` | A host I/O operation failed, or state that NVX recorded on the host is unreadable or malformed | | `not-found` | `NotFound` | Other state that the operation names does not exist on the host | | `protocol` | `Protocol` | The control session failed otherwise: the peer violated the protocol, reset the session, or failed authentication | diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 6a422c570..2d90a5759 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -58,6 +58,7 @@ variant nvx::ErrorCode::SandboxNotRunning variant nvx::ErrorCode::SandboxProvisioned variant nvx::ErrorCode::SandboxRunning variant nvx::ErrorCode::ScratchClaimed +variant nvx::ErrorCode::StillRunning variant nvx::ErrorCode::TemplateIneligible variant nvx::ErrorCode::TemplateTombstoned variant nvx::ErrorCode::TimedOut @@ -73,6 +74,7 @@ variant nvx::ErrorKind::NotFound variant nvx::ErrorKind::Other variant nvx::ErrorKind::Protocol variant nvx::ErrorKind::RuntimeMismatch +variant nvx::ErrorKind::StillRunning variant nvx::ErrorKind::TimedOut variant nvx::ErrorKind::Unavailable variant nvx::ErrorKind::Unsupported @@ -2445,6 +2447,7 @@ method nvx::host::process::BootId::as_bytes method nvx::host::process::BootId::current method nvx::host::process::BootId::from_bytes type nvx::host::process::ExitHandle +struct nvx::host::process::Exited struct nvx::host::process::Process method nvx::host::process::Process::bytes_read method nvx::host::process::Process::exit_handle @@ -3768,6 +3771,10 @@ field nvx::host::vm::SavedVm::endpoint field nvx::host::vm::SavedVm::process field nvx::host::vm::SavedVm::runtime field nvx::host::vm::SavedVm::vm_dir +struct nvx::host::vm::Stopped +field nvx::host::vm::Stopped::forced +field nvx::host::vm::Stopped::report +field nvx::host::vm::Stopped::self_exited struct nvx::host::vm::VmDir method nvx::host::vm::VmDir::open method nvx::host::vm::VmDir::path @@ -3780,6 +3787,7 @@ fn nvx::host::vm::create fn nvx::host::vm::create_private_dir fn nvx::host::vm::reattach fn nvx::host::vm::spawn +fn nvx::host::vm::stop fn nvx::host::vm::verify mod nvx::net struct nvx::net::Cidr diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 9aa7bf0f8..c1e2bc8cf 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -58,6 +58,7 @@ variant nvx::ErrorCode::SandboxNotRunning variant nvx::ErrorCode::SandboxProvisioned variant nvx::ErrorCode::SandboxRunning variant nvx::ErrorCode::ScratchClaimed +variant nvx::ErrorCode::StillRunning variant nvx::ErrorCode::TemplateIneligible variant nvx::ErrorCode::TemplateTombstoned variant nvx::ErrorCode::TimedOut @@ -73,6 +74,7 @@ variant nvx::ErrorKind::NotFound variant nvx::ErrorKind::Other variant nvx::ErrorKind::Protocol variant nvx::ErrorKind::RuntimeMismatch +variant nvx::ErrorKind::StillRunning variant nvx::ErrorKind::TimedOut variant nvx::ErrorKind::Unavailable variant nvx::ErrorKind::Unsupported @@ -1031,6 +1033,7 @@ method nvx::host::process::BootId::as_bytes method nvx::host::process::BootId::current method nvx::host::process::BootId::from_bytes type nvx::host::process::ExitHandle +struct nvx::host::process::Exited struct nvx::host::process::Process method nvx::host::process::Process::bytes_read method nvx::host::process::Process::exit_handle @@ -2353,6 +2356,10 @@ field nvx::host::vm::SavedVm::endpoint field nvx::host::vm::SavedVm::process field nvx::host::vm::SavedVm::runtime field nvx::host::vm::SavedVm::vm_dir +struct nvx::host::vm::Stopped +field nvx::host::vm::Stopped::forced +field nvx::host::vm::Stopped::report +field nvx::host::vm::Stopped::self_exited struct nvx::host::vm::VmDir method nvx::host::vm::VmDir::open method nvx::host::vm::VmDir::path @@ -2365,6 +2372,7 @@ fn nvx::host::vm::create fn nvx::host::vm::create_private_dir fn nvx::host::vm::reattach fn nvx::host::vm::spawn +fn nvx::host::vm::stop fn nvx::host::vm::verify mod nvx::net struct nvx::net::Cidr diff --git a/nvx-rs/src/error.rs b/nvx-rs/src/error.rs index 989538bab..e98ae8830 100644 --- a/nvx-rs/src/error.rs +++ b/nvx-rs/src/error.rs @@ -19,6 +19,8 @@ pub enum ErrorKind { Unsupported, /// The OpenVMM process of a VM exited. Exited, + /// The exit of a VM's process could not be confirmed; it may still run. + StillRunning, /// A host I/O operation failed, or state that NVX recorded on the host is unreadable or /// malformed. Io, @@ -44,6 +46,7 @@ impl ErrorKind { Self::Unavailable => "unavailable", Self::Unsupported => "unsupported", Self::Exited => "exited", + Self::StillRunning => "still-running", Self::Io => "io", Self::NotFound => "not-found", Self::Protocol => "protocol", @@ -88,6 +91,8 @@ pub enum ErrorCode { Unsupported, /// `exited`: the OpenVMM process of a VM exited, as when a start fails. Exited, + /// `still-running`: the exit of a VM's process could not be confirmed. + StillRunning, /// `io`: a host I/O operation failed, or state that NVX recorded on the host is unreadable or /// malformed. Io, @@ -170,6 +175,7 @@ impl ErrorCode { Self::Unavailable, Self::Unsupported, Self::Exited, + Self::StillRunning, Self::Io, Self::NotFound, Self::Protocol, @@ -205,6 +211,7 @@ impl ErrorCode { Self::Unavailable => ErrorKind::Unavailable.as_str(), Self::Unsupported => ErrorKind::Unsupported.as_str(), Self::Exited => ErrorKind::Exited.as_str(), + Self::StillRunning => ErrorKind::StillRunning.as_str(), Self::Io => ErrorKind::Io.as_str(), Self::NotFound => ErrorKind::NotFound.as_str(), Self::Protocol => ErrorKind::Protocol.as_str(), @@ -248,6 +255,7 @@ impl ErrorCode { | Self::ArgumentMissing | Self::ArgumentInvalid => ErrorKind::Unsupported, Self::Exited | Self::SandboxExited => ErrorKind::Exited, + Self::StillRunning => ErrorKind::StillRunning, Self::Io | Self::FormatUnsupported => ErrorKind::Io, Self::NotFound | Self::SandboxNotProvisioned => ErrorKind::NotFound, Self::Protocol | Self::ExecRefused | Self::EndpointClosed => ErrorKind::Protocol, @@ -278,6 +286,7 @@ impl From for ErrorCode { ErrorKind::Unavailable => Self::Unavailable, ErrorKind::Unsupported => Self::Unsupported, ErrorKind::Exited => Self::Exited, + ErrorKind::StillRunning => Self::StillRunning, ErrorKind::Io => Self::Io, ErrorKind::NotFound => Self::NotFound, ErrorKind::Protocol => Self::Protocol, diff --git a/nvx-rs/src/error/tests.rs b/nvx-rs/src/error/tests.rs index eb87d7dc0..a893e72b7 100644 --- a/nvx-rs/src/error/tests.rs +++ b/nvx-rs/src/error/tests.rs @@ -52,6 +52,7 @@ fn every_kind_has_a_code_of_its_own_spelling() { ErrorKind::Unavailable, ErrorKind::Unsupported, ErrorKind::Exited, + ErrorKind::StillRunning, ErrorKind::Io, ErrorKind::NotFound, ErrorKind::Protocol, diff --git a/nvx-rs/src/host/instance/locked.rs b/nvx-rs/src/host/instance/locked.rs index fca2a638b..2cbee7f48 100644 --- a/nvx-rs/src/host/instance/locked.rs +++ b/nvx-rs/src/host/instance/locked.rs @@ -216,7 +216,8 @@ impl LockedInstance { }; Ok(Stopped { forced, - report: report::read(&self.instance.report_path()), + // This legacy wrapper keeps its best-effort report policy. + report: report::read(&self.instance.report_path()).ok().flatten(), }) } diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 44dd7359a..7da84f781 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -16,7 +16,8 @@ use windows_sys::Win32::Foundation::{ use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; use windows_sys::Win32::System::JobObjects::CreateJobObjectW; use windows_sys::Win32::System::Threading::{ - CreateEventW, GetCurrentProcess, INFINITE, OpenProcess, PROCESS_DUP_HANDLE, TerminateProcess, + CreateEventW, GetCurrentProcess, INFINITE, OpenProcess, PROCESS_DUP_HANDLE, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, TerminateProcess, }; pub(crate) use super::endpoint::tests::{accept_control, control_server}; @@ -38,6 +39,16 @@ pub(crate) fn wait_exit_handle(process: OwnedHandle, deadline: Instant) -> io::R .map(|exited| exited.expect("Windows process handles always provide an exit event")) } +impl ProcessHandle { + pub(crate) fn wait_only(pid: u32) -> Self { + Self { + process: open_handle(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE) + .unwrap(), + terminate: false, + } + } +} + thread_local! { /// What a spawn of this thread runs in its window, once its handles are inheritable and /// before it creates its process. @@ -1084,7 +1095,7 @@ fn a_launch_whose_vmm_exited_ends_while_a_process_spawned_in_its_window_holds_it let vmm = launch.record_process(&launched).unwrap(); launched.detach(); assert!( - vmm.wait(Instant::now() + WINDOW_TIMEOUT).unwrap(), + vmm.wait(Instant::now() + WINDOW_TIMEOUT).unwrap().is_some(), "the stand-in did not exit" ); // The VMM has exited, so the launch ends, whatever the other process holds. diff --git a/nvx-rs/src/host/process.rs b/nvx-rs/src/host/process.rs index 32047f8d6..100abe93d 100644 --- a/nvx-rs/src/host/process.rs +++ b/nvx-rs/src/host/process.rs @@ -188,6 +188,12 @@ pub struct Process { handle: platform::ProcessHandle, } +/// Proof that a verified process exited, produced only by [`Process::wait`]. +/// +/// It proves exit, not the process's exit status, and cannot be constructed by a caller. +#[derive(Debug)] +pub struct Exited(()); + impl Process { /// Finds the process that `reference` names, and returns `None` if it has exited. /// @@ -241,13 +247,17 @@ impl Process { }) } - /// Waits until the process exits or `deadline` passes, and returns whether it exited. + /// Waits until the process exits or `deadline` passes, returning exit proof or `None`. /// /// A process exits with its last thread. The wait sleeps until the exit event of the /// process, or with [`WaitMethod::Polling`], checks its identity at a short interval. An /// error means that the wait cannot determine whether the process exited. - pub fn wait(&self, deadline: Instant) -> io::Result { - self.wait_bool(deadline) + pub fn wait(&self, deadline: Instant) -> io::Result> { + Ok(if self.wait_bool(deadline)? { + Some(Exited(())) + } else { + None + }) } /// Keeps the boolean wait result for the legacy lifecycle callers. @@ -262,7 +272,7 @@ impl Process { /// Returns whether the process has exited, without waiting. pub fn has_exited(&self) -> io::Result { - self.wait(Instant::now()) + self.wait(Instant::now()).map(|exited| exited.is_some()) } /// Ends the process at once, unless it has exited or is exiting: with `SIGKILL` on Linux, and diff --git a/nvx-rs/src/host/process/tests.rs b/nvx-rs/src/host/process/tests.rs index ebc305ffa..a90c83102 100644 --- a/nvx-rs/src/host/process/tests.rs +++ b/nvx-rs/src/host/process/tests.rs @@ -275,7 +275,7 @@ fn hosts_without_pidfds_poll_and_never_signal() { let still_running = process.has_exited(); child.kill().unwrap(); child.wait().unwrap(); - assert!(!running.unwrap()); + assert!(running.unwrap().is_none()); assert_eq!(refused.unwrap_err().kind(), io::ErrorKind::Unsupported); assert!(!still_running.unwrap()); // Once the child is reaped, its ID names no process, or another one. @@ -283,6 +283,7 @@ fn hosts_without_pidfds_poll_and_never_signal() { process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); } diff --git a/nvx-rs/src/host/process/tests/linux.rs b/nvx-rs/src/host/process/tests/linux.rs index 4c9177b53..a2a0cf72f 100644 --- a/nvx-rs/src/host/process/tests/linux.rs +++ b/nvx-rs/src/host/process/tests/linux.rs @@ -152,13 +152,14 @@ fn a_process_runs_until_its_last_thread_exits() { .unwrap() .expect("a process whose main thread exited still runs"); assert!( - !process + process .wait(Instant::now() + Duration::from_millis(100)) .unwrap() + .is_none() ); child.0.kill().unwrap(); child.0.wait().unwrap(); - assert!(process.wait(Instant::now()).unwrap()); + assert!(process.wait(Instant::now()).unwrap().is_some()); } #[test] diff --git a/nvx-rs/src/host/report.rs b/nvx-rs/src/host/report.rs index 48f86fec4..f07bf2ee4 100644 --- a/nvx-rs/src/host/report.rs +++ b/nvx-rs/src/host/report.rs @@ -9,6 +9,8 @@ use std::path::Path; use serde_json::Value; +use crate::{Error, Result}; + /// Summary of an outcome report. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Report { @@ -20,9 +22,24 @@ pub struct Report { pub teardown_complete: Option, } -/// Reads the report at `path`, or returns `None` if it is missing or not JSON. -pub fn read(path: &Path) -> Option { - let report: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?; +/// Reads the report at `path`, or returns `Ok(None)` if it is missing. +/// +/// An unreadable or malformed report fails with [`ErrorKind::Io`](crate::ErrorKind::Io), +/// retaining the underlying filesystem or JSON error. +pub fn read(path: &Path) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err( + Error::io(format!("cannot read outcome report {}", path.display())) + .with_source(error), + ); + } + }; + let report: Value = serde_json::from_slice(&bytes).map_err(|error| { + Error::io(format!("cannot parse outcome report {}", path.display())).with_source(error) + })?; let mut summary = Report::default(); if let Some(outcome) = report.get("outcome") { summary.category = outcome @@ -35,7 +52,7 @@ pub fn read(path: &Path) -> Option { let complete = teardown.values().all(|step| step.as_bool() == Some(true)); summary.teardown_complete = Some(complete); } - Some(summary) + Ok(Some(summary)) } #[cfg(test)] diff --git a/nvx-rs/src/host/report/tests.rs b/nvx-rs/src/host/report/tests.rs index 127fd5b2e..961377aa4 100644 --- a/nvx-rs/src/host/report/tests.rs +++ b/nvx-rs/src/host/report/tests.rs @@ -11,7 +11,7 @@ fn reports_are_summarized_without_free_form_fields() { ) .unwrap(); assert_eq!( - read(&path).unwrap(), + read(&path).unwrap().unwrap(), Report { category: Some("success".to_owned()), status_code: Some(0), @@ -20,13 +20,60 @@ fn reports_are_summarized_without_free_form_fields() { ); fs::write(&path, r#"{"teardown":{"workers":true,"memory":false}}"#).unwrap(); assert_eq!( - read(&path).unwrap(), + read(&path).unwrap().unwrap(), Report { teardown_complete: Some(false), ..Report::default() } ); - assert!(read(&directory.path().join("missing.json")).is_none()); +} + +#[test] +fn missing_reports_are_distinct_from_malformed_or_unreadable_reports() { + let directory = tempfile::tempdir().unwrap(); + assert!( + read(&directory.path().join("missing.json")) + .unwrap() + .is_none() + ); + let path = directory.path().join("outcome.json"); fs::write(&path, b"{ truncated").unwrap(); - assert!(read(&path).is_none()); + let malformed = read(&path).unwrap_err(); + assert_eq!(malformed.kind(), crate::ErrorKind::Io); + assert!( + std::error::Error::source(&malformed) + .unwrap() + .is::() + ); + let unreadable = read(directory.path()).unwrap_err(); + assert_eq!(unreadable.kind(), crate::ErrorKind::Io); + assert!( + std::error::Error::source(&unreadable) + .unwrap() + .is::() + ); +} + +#[cfg(windows)] +#[test] +fn a_report_opened_without_read_sharing_returns_its_io_error() { + use std::os::windows::fs::OpenOptionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("outcome.json"); + fs::write(&path, b"{}").unwrap(); + let held = fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(&path) + .unwrap(); + let error = read(&path).unwrap_err(); + assert_eq!(error.kind(), crate::ErrorKind::Io); + assert!( + std::error::Error::source(&error) + .unwrap() + .is::() + ); + drop(held); + assert_eq!(read(&path).unwrap(), Some(Report::default())); } diff --git a/nvx-rs/src/host/template/clone/records.rs b/nvx-rs/src/host/template/clone/records.rs index ce9752576..dc5fbc696 100644 --- a/nvx-rs/src/host/template/clone/records.rs +++ b/nvx-rs/src/host/template/clone/records.rs @@ -296,7 +296,7 @@ impl Recorder { /// report only once the clone's VM ended, or its teardown did. pub(super) fn observed(&self) -> Option<&Report> { self.observed - .get_or_init(|| report::read(&self.report)) + .get_or_init(|| report::read(&self.report).ok().flatten()) .as_ref() } @@ -1119,7 +1119,7 @@ impl CloneRecords { // The launch removed the report of an earlier VM of the sandbox before it wrote the first // of the clone's records, which a progress past the plan shows. let report = (progress > Progress::Planned) - .then(|| report::read(&sandbox.outcome_path())) + .then(|| report::read(&sandbox.outcome_path()).ok().flatten()) .flatten(); Ok(Some(Self { lease, diff --git a/nvx-rs/src/host/template/clone/tests/launch.rs b/nvx-rs/src/host/template/clone/tests/launch.rs index c58043a4c..7ec87eec6 100644 --- a/nvx-rs/src/host/template/clone/tests/launch.rs +++ b/nvx-rs/src/host/template/clone/tests/launch.rs @@ -365,14 +365,14 @@ impl VmmDir { .unwrap() .expect("the VMM runs until the test ends it"); self.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } /// Waits until the VMM, which exits on its own, as its [plan](Self::plan) asks, has exited. pub(super) fn wait_exit(&self) { let reference = self.reference.as_ref().expect("the test knows the VMM"); if let Some(process) = Process::open(reference).unwrap() { - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } @@ -413,7 +413,9 @@ impl Drop for VmmDir { let _ = fs::write(self.path().join(EXIT_MARKER), b""); if let Some(reference) = &self.reference && let Ok(Some(process)) = Process::open(reference) - && !process.wait(Instant::now() + TIMEOUT).unwrap_or(false) + && !process + .wait(Instant::now() + TIMEOUT) + .is_ok_and(|exited| exited.is_some()) { let _ = process.kill(); let _ = process.wait(Instant::now() + TIMEOUT); @@ -1567,7 +1569,7 @@ fn a_clone_whose_launch_failed_before_it_began_launches_no_more_until_its_releas // clone's: an inspection takes none, and the launch removes it before it records anything. let report = acquired.locked.outcome_path(); fs::write(&report, guest_exit(195)).unwrap(); - assert!(crate::host::report::read(&report).is_some()); + assert!(crate::host::report::read(&report).unwrap().is_some()); let records = CloneRecords::read(&acquired.locked).unwrap().unwrap(); assert_eq!( (records.progress, records.report), @@ -2323,7 +2325,7 @@ fn a_launch_whose_caller_crashes_once_its_vmm_started_ends_the_vmm_as_reattach_r } => assert_eq!(terminated, reference), other => panic!("unexpected reattach: {other:?}"), } - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(!exists(&crashed.socket())); crashed.releases(&mut locked); } @@ -2356,7 +2358,10 @@ fn a_restore_whose_caller_crashes_before_its_commit_ends_its_vmm_as_reattach_rec } => assert_eq!(terminated, reference, "{step}"), other => panic!("unexpected reattach at {step}: {other:?}"), } - assert!(process.wait(Instant::now() + TIMEOUT).unwrap(), "{step}"); + assert!( + process.wait(Instant::now() + TIMEOUT).unwrap().is_some(), + "{step}" + ); assert!(!exists(&crashed.socket()), "{step}"); crashed.releases(&mut locked); } diff --git a/nvx-rs/src/host/template/clone/tests/restore.rs b/nvx-rs/src/host/template/clone/tests/restore.rs index 0a33cb1dd..61282c561 100644 --- a/nvx-rs/src/host/template/clone/tests/restore.rs +++ b/nvx-rs/src/host/template/clone/tests/restore.rs @@ -422,7 +422,7 @@ fn vmm_with(plan: &str) -> VmmDir { fn assert_vmm_ended(vmm: &VmmDir) { let reference = vmm.reference.as_ref().unwrap(); if let Some(process) = Process::open(reference).unwrap() { - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } diff --git a/nvx-rs/src/host/template/store/leases/tests.rs b/nvx-rs/src/host/template/store/leases/tests.rs index 1205e4ba3..eaeed151c 100644 --- a/nvx-rs/src/host/template/store/leases/tests.rs +++ b/nvx-rs/src/host/template/store/leases/tests.rs @@ -2460,7 +2460,9 @@ impl Drop for VmmDir { let _ = fs::write(self.path().join(EXIT_MARKER), b""); if let Some(reference) = &self.reference && let Ok(Some(process)) = Process::open(reference) - && !process.wait(Instant::now() + TIMEOUT).unwrap_or(false) + && !process + .wait(Instant::now() + TIMEOUT) + .is_ok_and(|exited| exited.is_some()) { let _ = process.kill(); let _ = process.wait(Instant::now() + TIMEOUT); @@ -2639,7 +2641,7 @@ fn a_clone_that_crashes_after_its_spawn_keeps_its_lease_until_its_unrecorded_vmm crashed.refuses_release(&mut locked); // Once the VMM exits, the claim that it released proves it. crashed.vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); match reattach_until_determinate(&mut locked) { Reattached::Exited { record: None, @@ -2669,7 +2671,7 @@ fn a_clone_that_crashes_after_recording_its_vmm_keeps_its_lease_until_reattach_e } => assert_eq!(terminated, reference), other => panic!("unexpected reattach: {other:?}"), } - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); crashed.releases(&mut locked); } diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 1429a7a26..72e724aa6 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -16,6 +16,7 @@ use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io; use std::path::{Path, PathBuf}; +use std::time::Instant; use crate::host::config::{ConsoleAttachment, RootConfig, UpperConfig, VmConfig}; use crate::host::filesystem; @@ -25,6 +26,7 @@ use crate::host::platform; use crate::host::platform::{check_store_dir as check_dir, create_store_dir as create_dir}; #[cfg(windows)] use crate::host::platform::{check_vm_dir as check_dir, create_vm_dir as create_dir}; +use crate::host::report::{self, Report}; use crate::host::runtime::{Runtime, RuntimeIdentity}; use crate::host::snapshot::Snapshot; use crate::host::spawn::{self, VmmCommand}; @@ -522,6 +524,78 @@ pub fn spawn(prepared: Prepared) -> Result { spawn_verified(prepared, spawn::Launched::process) } +/// A confirmed VM exit, with the outcome-report read kept separate from exit proof. +#[derive(Debug)] +pub struct Stopped { + /// Exit was confirmed before any forced termination was attempted. + pub self_exited: bool, + /// Forced termination through the verified process was attempted. + pub forced: bool, + /// The outcome summary, missing report, or report-read error after confirmed exit. + pub report: Result>, +} + +/// Stops a VM, returning only after its verified process has exited. +/// +/// Unless exit is already confirmed, calls `graceful` with `grace`, bounded by `deadline`, +/// then waits until that grace deadline. The callback must respect the supplied deadline; +/// its boolean request result is not exit proof. If exit is still unconfirmed, attempts +/// forced termination and waits until `deadline`. A failed termination is harmless only +/// when that final wait confirms exit. +/// +/// An unconfirmed exit fails with [`ErrorKind::StillRunning`](crate::ErrorKind::StillRunning), +/// retaining host errors. The caller keeps the process in that case. +/// Report-read errors have kind [`ErrorKind::Io`](crate::ErrorKind::Io) +/// inside [`Stopped::report`], never hiding confirmed exit. +pub fn stop( + process: &Process, + outputs: &VmOutputs, + graceful: impl FnOnce(Instant) -> bool, + grace: Instant, + deadline: Instant, +) -> Result { + let failure = |kill_error: Option<&io::Error>| { + let mut message = format!( + "cannot confirm exit of VM process {}", + process.reference().pid + ); + if let Some(error) = kill_error { + message.push_str(&format!("; forced termination failed: {error}")); + } + Error::new(crate::ErrorKind::StillRunning, message) + }; + let wait = |until| { + process + .wait(until) + .map_err(|error| failure(None).with_source(error)) + }; + let self_exited = if wait(Instant::now())?.is_some() { + true + } else { + let grace = grace.min(deadline); + graceful(grace); + wait(grace)?.is_some() + }; + if !self_exited { + let kill_error = process.kill().err(); + let exited = process + .wait(deadline) + .map_err(|error| failure(kill_error.as_ref()).with_source(error))?; + if exited.is_none() { + let error = failure(kill_error.as_ref()); + return Err(match kill_error { + Some(source) => error.with_source(source), + None => error, + }); + } + } + Ok(Stopped { + self_exited, + forced: !self_exited, + report: report::read(&outputs.outcome), + }) +} + /// Keeps the child owned until its process identity is verified, or its exit is confirmed. fn spawn_verified( prepared: Prepared, diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index 05af49bd0..b637a7532 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -1098,6 +1098,7 @@ fn spawn_caller_helper() { .process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); panic!("cannot record the helper's child: {error}"); } @@ -1181,6 +1182,7 @@ fn an_unrelated_process_started_in_the_library_spawn_window_gets_no_vm_claim() { .process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); assert!( platform::tests::claim_released_within(&log, identity, Duration::from_secs(10)), @@ -1282,10 +1284,193 @@ fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { ) .unwrap(); read_until(&mut *console, b"NVX-SPAWN-COLD-OK"); - assert!(launched.0.process.wait(deadline).unwrap()); + assert!(launched.0.process.wait(deadline).unwrap().is_some()); assert!(reopened.has_exited().unwrap()); - let outcome = crate::host::report::read(&launched.0.outputs.outcome).unwrap(); + let outcome = crate::host::report::read(&launched.0.outputs.outcome) + .unwrap() + .unwrap(); assert_eq!(outcome.category.as_deref(), Some("success")); assert_eq!(outcome.status_code, Some(0)); println!("NVX-LIBRARY-COLD-SPAWN-EXIT-OK: {}", reference.pid); } + +#[cfg(any(target_os = "linux", windows))] +struct StopChild { + child: std::process::Child, + process: Process, +} + +#[cfg(any(target_os = "linux", windows))] +impl StopChild { + fn start() -> Self { + use std::io::{BufRead, BufReader}; + + #[cfg(windows)] + let mut command = { + let mut command = Command::new( + PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join("System32\\cmd.exe"), + ); + command.args(["/d", "/q", "/c", "echo ready & set /p wait="]); + command + }; + #[cfg(target_os = "linux")] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "printf 'ready\\n'; read -r wait"]); + command + }; + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = crate::host::tests::spawn_locked(&mut command).unwrap(); + let mut ready = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut ready) + .unwrap(); + assert_eq!(ready.trim_end(), "ready"); + let reference = crate::host::process::ProcessRef { + pid: child.id(), + start_time: platform::process_start_time(child.id()).unwrap().unwrap(), + boot: crate::host::process::BootId::current().unwrap(), + }; + let process = Process::open(&reference).unwrap().unwrap(); + Self { child, process } + } +} + +#[cfg(any(target_os = "linux", windows))] +impl Drop for StopChild { + fn drop(&mut self) { + if self.child.try_wait().unwrap().is_none() { + self.child.kill().unwrap(); + } + self.child.wait().unwrap(); + } +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn stop_preserves_report_results_after_a_real_self_exit() { + for report in ["missing", "valid", "malformed", "unreadable"] { + let (_directory, vm) = vm_dir(); + let outputs = vm.outputs(false).unwrap(); + match report { + "missing" => {} + "valid" => fs::write( + &outputs.outcome, + br#"{"outcome":{"category":"success","status_code":0},"teardown":{"workers":true}}"#, + ).unwrap(), + "malformed" => fs::write(&outputs.outcome, b"{ truncated").unwrap(), + "unreadable" => fs::create_dir(&outputs.outcome).unwrap(), + _ => unreachable!(), + } + let mut child = StopChild::start(); + let deadline = Instant::now() + Duration::from_secs(10); + let stopped = stop( + &child.process, + &outputs, + |supplied| { + assert_eq!(supplied, deadline); + drop(child.child.stdin.take()); + false + }, + deadline, + deadline, + ) + .unwrap(); + assert!(stopped.self_exited); + assert!(!stopped.forced); + assert!(child.process.wait(Instant::now()).unwrap().is_some()); + assert!(child.child.try_wait().unwrap().is_some()); + let already_exited = stop( + &child.process, + &outputs, + |_| panic!("an exited process needs no graceful request"), + Instant::now(), + Instant::now(), + ) + .unwrap(); + assert!(already_exited.self_exited); + assert!(!already_exited.forced); + match report { + "missing" => assert!(stopped.report.unwrap().is_none()), + "valid" => assert_eq!( + stopped.report.unwrap(), + Some(Report { + category: Some("success".to_owned()), + status_code: Some(0), + teardown_complete: Some(true), + }) + ), + _ => { + let error = stopped.report.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert!(std::error::Error::source(&error).is_some()); + } + } + } +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn stop_forces_only_its_verified_process_after_grace() { + let (_directory, vm) = vm_dir(); + let outputs = vm.outputs(false).unwrap(); + let mut child = StopChild::start(); + let mut other = StopChild::start(); + let grace = Instant::now() + Duration::from_millis(80); + let deadline = grace + Duration::from_secs(10); + let stopped = stop( + &child.process, + &outputs, + |supplied| { + assert_eq!(supplied, grace); + true + }, + grace, + deadline, + ) + .unwrap(); + assert!(!stopped.self_exited); + assert!(stopped.forced); + assert!(stopped.report.unwrap().is_none()); + assert!(child.process.wait(Instant::now()).unwrap().is_some()); + assert!(child.child.try_wait().unwrap().is_some()); + assert!(other.process.wait(Instant::now()).unwrap().is_none()); + assert!(other.child.try_wait().unwrap().is_none()); +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn stop_returns_still_running_without_exit_confirmation_at_its_deadline() { + let (_directory, vm) = vm_dir(); + let outputs = vm.outputs(false).unwrap(); + fs::write(&outputs.outcome, b"{ truncated").unwrap(); + let mut child = StopChild::start(); + #[cfg(target_os = "linux")] + let handle = platform::ProcessHandle::without_pidfd(); + #[cfg(windows)] + let handle = platform::ProcessHandle::wait_only(child.child.id()); + let process = Process::held(child.process.reference().clone(), handle); + let grace = Instant::now(); + let deadline = grace + Duration::from_millis(80); + let error = stop(&process, &outputs, |_| false, grace, deadline).unwrap_err(); + assert!(Instant::now() >= deadline); + assert_eq!(error.kind(), ErrorKind::StillRunning); + assert_eq!(error.code().as_str(), "still-running"); + let cause = std::error::Error::source(&error) + .unwrap() + .downcast_ref::() + .unwrap(); + assert_eq!( + cause.kind(), + if cfg!(windows) { + io::ErrorKind::PermissionDenied + } else { + io::ErrorKind::Unsupported + } + ); + assert!(process.wait(Instant::now()).unwrap().is_none()); + assert!(child.child.try_wait().unwrap().is_none()); +} diff --git a/nvx-rs/src/host/vm/tests/reattach.rs b/nvx-rs/src/host/vm/tests/reattach.rs index 88a95d005..0611675b6 100644 --- a/nvx-rs/src/host/vm/tests/reattach.rs +++ b/nvx-rs/src/host/vm/tests/reattach.rs @@ -244,7 +244,7 @@ fn reattaching_returns_the_held_owned_child_and_authenticated_connection() { b"response" ); owner.wait(); - assert!(attached.process.wait(deadline).unwrap()); + assert!(attached.process.wait(deadline).unwrap().is_some()); } #[test] diff --git a/nvx-rs/tests/checked_handoff.rs b/nvx-rs/tests/checked_handoff.rs index b07a9e340..8f1c7b07b 100644 --- a/nvx-rs/tests/checked_handoff.rs +++ b/nvx-rs/tests/checked_handoff.rs @@ -703,6 +703,7 @@ fn a_prepared_launch_hands_the_vmm_its_checked_images_alone() { .unwrap() .wait(Instant::now() + TIMEOUT) .unwrap() + .is_some() ); launched.kill_and_wait(); let scratch = fs::read_to_string(staged.scratch()).unwrap(); @@ -788,6 +789,7 @@ fn a_vmm_keeps_the_scratch_images_claim_once_its_launcher_is_gone() { .unwrap() .wait(Instant::now() + TIMEOUT) .unwrap() + .is_some() ); launched.kill_and_wait(); drop(check_written().unwrap()); diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index f3b64de5b..01191d000 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -1007,9 +1007,10 @@ fn a_stop_under_another_identity_never_ends_a_process_that_its_record_does_not_n ); assert_eq!(runtime_record(&root, "state"), None); assert!( - !process + process .wait(Instant::now() + Duration::from_millis(200)) - .unwrap(), + .unwrap() + .is_none(), "{field}" ); process.kill().unwrap(); @@ -1017,6 +1018,7 @@ fn a_stop_under_another_identity_never_ends_a_process_that_its_record_does_not_n process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); } assert_eq!(transition(&root, "deprovision", "state", &[]), succeeded()); @@ -1153,6 +1155,7 @@ fn stop_reports_its_timeout_and_leaves_the_vm_running() { process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); assert_eq!( transition(&root, "deprovision", "unanswered", &[]), @@ -1173,6 +1176,7 @@ fn stop_reports_its_timeout_and_leaves_the_vm_running() { process .wait(Instant::now() + Duration::from_secs(30)) .unwrap() + .is_some() ); assert_eq!( transition(&root, "stop", "slow", &[]), @@ -1194,6 +1198,7 @@ fn a_sandbox_whose_openvmm_exited_reports_it_once_and_starts_again() { process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); }; crash(); diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index be959f532..f9d758cea 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -171,6 +171,22 @@ fn provision_and_deprovision_need_no_artifact_root() { let executable = elsewhere .path() .join(Path::new(NVX_RS).file_name().unwrap()); + #[cfg(unix)] + { + // Concurrent forks of this test process must inherit no writable executable. + let copied = Command::new("cp") + .arg("-p") + .arg(NVX_RS) + .arg(&executable) + .output() + .unwrap(); + assert!( + copied.status.success(), + "cannot copy the executable: {}", + String::from_utf8_lossy(&copied.stderr) + ); + } + #[cfg(windows)] fs::copy(NVX_RS, &executable).unwrap(); let nvx_rs = |arguments: &[String]| { let output = Command::new(&executable) diff --git a/nvx-rs/tests/cli/lifecycle.rs b/nvx-rs/tests/cli/lifecycle.rs index 7078aba9e..d82a0f548 100644 --- a/nvx-rs/tests/cli/lifecycle.rs +++ b/nvx-rs/tests/cli/lifecycle.rs @@ -604,7 +604,8 @@ fn check_lifecycle(case: &Configuration) { assert!( process .wait(Instant::now() + Duration::from_secs(10)) - .unwrap(), + .unwrap() + .is_some(), "{context}: OpenVMM runs on" ); assert_eq!(entries(&state), STOPPED, "{context}"); @@ -694,7 +695,8 @@ fn lifecycle_of_a_sandbox_whose_upper_is_in_ram() { assert!( process .wait(Instant::now() + Duration::from_secs(10)) - .unwrap(), + .unwrap() + .is_some(), "{context}: OpenVMM runs on" ); assert_eq!(entries(&state), STOPPED, "{context}"); @@ -737,7 +739,8 @@ fn stops_keep_an_outcome_report_that_the_caller_reads_whatever_its_umask() { assert!( process .wait(Instant::now() + Duration::from_secs(10)) - .unwrap(), + .unwrap() + .is_some(), "{context}: OpenVMM runs on" ); assert_eq!(entries(&state), STOPPED, "{context}"); @@ -1437,9 +1440,10 @@ fn recycled_process_id(root: &Root) { assert_refused(root, &outcome, "sandbox-exited", &["stale", "not running"]); assert!(!path.exists()); assert!( - !openvmm + openvmm .wait(Instant::now() + Duration::from_millis(200)) - .unwrap(), + .unwrap() + .is_none(), "the stale record's process was signaled" ); openvmm.kill().unwrap(); @@ -1447,6 +1451,7 @@ fn recycled_process_id(root: &Root) { openvmm .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); let outcome = operate(root, "stop", "state", &[]); assert_refused(root, &outcome, "sandbox-not-running", &["not running"]); diff --git a/nvx-rs/tests/fake_openvmm.rs b/nvx-rs/tests/fake_openvmm.rs index c667dbe84..b112f951b 100644 --- a/nvx-rs/tests/fake_openvmm.rs +++ b/nvx-rs/tests/fake_openvmm.rs @@ -478,7 +478,7 @@ fn outcome_reports_of_another_runtime_are_never_read() { .unwrap(); if exits_first { process.kill().unwrap(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } let stopped = locked.terminate(mismatched, TIMEOUT).unwrap(); assert_eq!( @@ -528,11 +528,12 @@ fn records_that_name_another_process_never_end_it() { drop(locked); assert!(records_nothing(&instance)); assert!( - !process + process .wait(Instant::now() + Duration::from_millis(200)) .unwrap() + .is_none() ); process.kill().unwrap(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } diff --git a/nvx-rs/tests/store.rs b/nvx-rs/tests/store.rs index 39d55d2e4..9602cce68 100644 --- a/nvx-rs/tests/store.rs +++ b/nvx-rs/tests/store.rs @@ -183,7 +183,9 @@ fn kill(pid: u32, start_time: u64) -> io::Result { return Ok(true); }; process.kill()?; - process.wait(Instant::now() + RECOVERY_TIMEOUT) + process + .wait(Instant::now() + RECOVERY_TIMEOUT) + .map(|exited| exited.is_some()) } /// A VMM that a crashed orchestrator left behind, which is killed when the test ends if it still @@ -533,7 +535,12 @@ fn a_committed_launch_names_its_vm_for_another_build() { .expect("the recorded VMM runs"); assert!(!reattached.has_exited().unwrap()); drop(vmm); - assert!(reattached.wait(Instant::now() + RECOVERY_TIMEOUT).unwrap()); + assert!( + reattached + .wait(Instant::now() + RECOVERY_TIMEOUT) + .unwrap() + .is_some() + ); other.lock(None).unwrap().clear_runtime().unwrap(); assert!(other.runtime().unwrap().is_none()); } diff --git a/nvx-rs/tests/supervisor.rs b/nvx-rs/tests/supervisor.rs index be2fb01a0..45b2058ab 100644 --- a/nvx-rs/tests/supervisor.rs +++ b/nvx-rs/tests/supervisor.rs @@ -450,7 +450,9 @@ impl Drop for VmmDirectory { let _ = File::create(self.path().join(EXIT_MARKER)); if let Some(reference) = &self.reference && let Ok(Some(process)) = Process::open(reference) - && !process.wait(Instant::now() + TIMEOUT).unwrap_or(false) + && !process + .wait(Instant::now() + TIMEOUT) + .is_ok_and(|exited| exited.is_some()) { let _ = process.kill(); let _ = process.wait(Instant::now() + TIMEOUT); @@ -613,7 +615,13 @@ fn long_stores_launch_with_a_short_working_directory() { let mut vmm = VmmDirectory::new(); let mut locked = store.lock(None).unwrap(); let running = launch_vmm(&mut locked, &mut vmm); - assert!(!running.process().wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!( + running + .process() + .wait(Instant::now() + SHORT_WAIT) + .unwrap() + .is_none() + ); let stopped = running .stop(&mut locked, Instant::now() + TIMEOUT, None, |_, _| { vmm.end(); @@ -671,15 +679,15 @@ fn a_child_that_exits_wakes_its_waiter() { assert_eq!(process.reference().boot, BootId::current().unwrap()); assert_eq!(process.wait_method(), WaitMethod::Event); assert!(!process.has_exited().unwrap()); - assert!(!process.wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!(process.wait(Instant::now() + SHORT_WAIT).unwrap().is_none()); // The reference finds the same process through a handle of its own. let found = open_running(process.reference()); assert_eq!(found.reference(), process.reference()); let started = Instant::now(); vmm.end(); - assert!(process.wait(started + TIMEOUT).unwrap()); - assert!(found.wait(started + TIMEOUT).unwrap()); + assert!(process.wait(started + TIMEOUT).unwrap().is_some()); + assert!(found.wait(started + TIMEOUT).unwrap().is_some()); assert!(started.elapsed() < TIMEOUT); assert_ended(&process); // Ending a process that exited does nothing. @@ -698,12 +706,12 @@ fn a_process_that_is_not_a_child_wakes_its_waiters() { launcher.finish(); let process = open_running(&reference); assert_eq!(process.wait_method(), WaitMethod::Event); - assert!(!process.wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!(process.wait(Instant::now() + SHORT_WAIT).unwrap().is_none()); let started = Instant::now(); let waiters: Vec = thread::scope(|scope| { let waiters: Vec<_> = (0..2) - .map(|_| scope.spawn(|| process.wait(started + TIMEOUT).unwrap())) + .map(|_| scope.spawn(|| process.wait(started + TIMEOUT).unwrap().is_some())) .collect(); vmm.end(); waiters @@ -740,10 +748,10 @@ fn a_restarted_launcher_finds_its_vmm_again_and_ends_it() { vmm.record(&reference); let process = open_running(&reference); assert_eq!(process.wait_method(), WaitMethod::Event); - assert!(!process.wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!(process.wait(Instant::now() + SHORT_WAIT).unwrap().is_none()); let started = Instant::now(); process.kill().unwrap(); - assert!(process.wait(started + TIMEOUT).unwrap()); + assert!(process.wait(started + TIMEOUT).unwrap().is_some()); assert_ended(&process); } @@ -783,7 +791,7 @@ fn a_reused_process_id_is_never_signaled() { assert!(!process.has_exited().unwrap()); open_running(&reference); vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(launched.kill_and_wait()); } @@ -814,7 +822,7 @@ fn references_that_name_no_process_are_never_opened() { let launched = spawn_vmm(vmm.path(), false); let process = launched.process().unwrap(); vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(launched.kill_and_wait()); assert!(Process::open(process.reference()).unwrap().is_none()); assert!(process.has_exited().unwrap()); @@ -877,7 +885,7 @@ fn an_unrecorded_vmm_that_serves_no_endpoint_is_never_signaled() { assert!(store.journal().unwrap().is_some()); // Once the VMM exits, the claim that it released proves it. vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); match reattach_until_determinate(&mut locked) { Reattached::Exited { record: None, @@ -1264,7 +1272,7 @@ fn check_vmm(reader: PipeReader, writer: &PipeWriter, others: &Mutex> } } vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(launched.kill_and_wait()); } @@ -1493,7 +1501,7 @@ fn a_vmm_in_its_orchestrators_job_ends_with_it() { // The VMM did not break away, so closing the job ends it with its orchestrator. drop(job); orchestrator.wait(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); let mut locked = store.lock(Some(Instant::now() + TIMEOUT)).unwrap(); match supervisor::reattach(&mut locked, &runtime()).unwrap() { Reattached::Exited { @@ -1517,10 +1525,10 @@ fn a_job_that_ends_its_vmm_wakes_the_waiters() { vmm.record(&reference); // The VMM did not break away, so the job holds it too, and closing the job ends it. let process = open_running(&reference); - assert!(!process.wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!(process.wait(Instant::now() + SHORT_WAIT).unwrap().is_none()); let started = Instant::now(); drop(job); - assert!(process.wait(started + TIMEOUT).unwrap()); + assert!(process.wait(started + TIMEOUT).unwrap().is_some()); assert_ended(&process); launcher.wait(); } @@ -1587,7 +1595,7 @@ fn a_process_that_this_user_may_not_terminate_is_waited_for_but_never_signaled() // Its creator ends it, which wakes the waiter, and ending a process that exited does nothing. child.0.kill().unwrap(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); process.kill().unwrap(); assert_ended(&process); } diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index 449ceb1f0..844c9a4f0 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -2053,6 +2053,7 @@ fn launch_failed( let exited = starting .process() .wait(Instant::now() + STOP_TIMEOUT) + .map(|exited| exited.is_some()) .map_err(|error| format!("cannot wait for the VM to exit: {error}"))?; starting.abandon().map_err(|error| describe(&error))?; println!("stopped={}", if exited { "graceful" } else { "forced" }); @@ -2078,6 +2079,7 @@ fn table_failed( Ok(_) => starting .process() .wait(Instant::now() + STOP_TIMEOUT) + .map(|exited| exited.is_some()) .map_err(|error| format!("cannot wait for the VM to exit: {error}"))?, Err(_) => false, }; @@ -3625,12 +3627,14 @@ fn hold(locked: &mut LockedStore, runtime: &Runtime) -> Result Result<(), String> { - let outcome = report::read(&store.outcome_path()).ok_or_else(|| { - format!( - "the VM left no outcome report {}", - store.outcome_path().display() - ) - })?; + let outcome = report::read(&store.outcome_path()) + .map_err(|error| error.to_string())? + .ok_or_else(|| { + format!( + "the VM left no outcome report {}", + store.outcome_path().display() + ) + })?; print_outcome(&outcome); if ( outcome.category.as_deref(), diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index 654fad486..cbc20e665 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -2321,7 +2321,7 @@ impl CloneControl for Early { .send(&self.early, Some(deadline)) .map_err(io::Error::other)?; let answer = session.receive(Some(deadline)); - self.exited = self.vmm.wait(deadline)?; + self.exited = self.vmm.wait(deadline)?.is_some(); Err(match answer { Ok(message) => io::Error::other(format!("the clone answered {message:?}")), Err(error) => io::Error::other(error), @@ -3910,7 +3910,9 @@ fn a_host_program_of_another_runtime_ends_the_clone_through_its_verified_identit assert!(!ended.contains_key("epoch"), "{ended:?}"); assert!(sandbox.stderr().contains("differs in initramfs")); assert!( - vmm.wait(Instant::now() + Duration::from_secs(10)).unwrap(), + vmm.wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + .is_some(), "the clone's VMM still runs" ); assert!(sandbox.record().is_none()); @@ -3948,7 +3950,9 @@ fn a_clone_whose_vm_exits_once_a_stop_found_it_fails_before_its_lease_is_release assert_eq!(sandbox.paused(&mut stopping, &pause), "found"); vmm.kill().unwrap(); assert!( - vmm.wait(Instant::now() + Duration::from_secs(10)).unwrap(), + vmm.wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + .is_some(), "the clone's VMM still runs" ); fs::remove_file(&pause).unwrap(); @@ -5313,7 +5317,9 @@ fn a_run_whose_clone_s_vm_exits_during_its_execution_ends_with_the_clone_s_failu let vmm = sandbox.vmm().expect("the clone's VMM runs"); vmm.kill().unwrap(); assert!( - vmm.wait(Instant::now() + Duration::from_secs(10)).unwrap(), + vmm.wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + .is_some(), "the clone's VMM still runs" ); From 99d004572b8ffad55c48bd7885278457297cbda6 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:11:03 -0800 Subject: [PATCH 02/25] host: journal CLI launches and persist saved VM records Port the scoped SL3.2b donor cf485b72 onto verified feature c52c7101 without its historical producer history. Persist prepared capability and outputs before spawn, the matching process before commit, and every SavedVm field in the private runtime record. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/files.rs | 51 +++ nvx-rs/src/host/cli/store/launch.rs | 80 +++++ nvx-rs/src/host/cli/store/mod.rs | 7 + nvx-rs/src/host/cli/store/record.rs | 159 +++++++++ nvx-rs/src/host/cli/store/tests.rs | 2 + nvx-rs/src/host/cli/store/tests/launch.rs | 382 ++++++++++++++++++++++ 6 files changed, 681 insertions(+) create mode 100644 nvx-rs/src/host/cli/store/files.rs create mode 100644 nvx-rs/src/host/cli/store/launch.rs create mode 100644 nvx-rs/src/host/cli/store/record.rs create mode 100644 nvx-rs/src/host/cli/store/tests/launch.rs diff --git a/nvx-rs/src/host/cli/store/files.rs b/nvx-rs/src/host/cli/store/files.rs new file mode 100644 index 000000000..9050cf281 --- /dev/null +++ b/nvx-rs/src/host/cli/store/files.rs @@ -0,0 +1,51 @@ +//! Private, regular, non-link files, atomically replaced and synced under the state lock. + +use std::fs; +use std::io; + +use super::StateFile; +use super::locked::LockedState; +use crate::host::platform; +use crate::host::store::{files, regular}; +use crate::{Error, Result}; + +/// Bounds a fixed record's paths and identities without reading an unbounded corrupt file. +const MAX_RECORD_BYTES: u64 = 1024 * 1024; + +pub(super) fn read(state: &LockedState, name: StateFile) -> Result>> { + let path = state.path(name); + let failed = |error| Error::io(format!("cannot read {}", path.display())).with_source(error); + let file = match regular::open_regular(&path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(failed(error)), + }; + if let Some(problem) = platform::private_file_problem(&file).map_err(failed)? { + return Err(Error::io(format!( + "the sandbox file {} {problem}", + path.display() + ))); + } + regular::read_at_most(file, MAX_RECORD_BYTES, "a sandbox record") + .map(Some) + .map_err(failed) +} + +pub(super) fn replace(state: &LockedState, name: StateFile, bytes: &[u8]) -> Result<()> { + if bytes.len() as u64 > MAX_RECORD_BYTES { + return Err(Error::io("the sandbox record exceeds its size bound")); + } + // Refuse an existing unsafe entry rather than replacing it or following its target. + read(state, name)?; + files::write_private(&state.path(name), bytes)?; + files::sync_dir(state.dir()) +} + +pub(super) fn remove(state: &LockedState, name: StateFile) -> Result<()> { + read(state, name)?; + let path = state.path(name); + fs::remove_file(&path).map_err(|error| { + Error::io(format!("cannot remove {}", path.display())).with_source(error) + })?; + files::sync_dir(state.dir()) +} diff --git a/nvx-rs/src/host/cli/store/launch.rs b/nvx-rs/src/host/cli/store/launch.rs new file mode 100644 index 000000000..d0a5c04e6 --- /dev/null +++ b/nvx-rs/src/host/cli/store/launch.rs @@ -0,0 +1,80 @@ +//! Locked launch transitions, without spawning, recovery, or process termination. + +use super::StateFile; +use super::files; +use super::locked::LockedState; +use super::record::{LaunchJournal, RuntimeRecord}; +use crate::host::process::ProcessRef; +use crate::host::runtime::RuntimeIdentity; +use crate::host::vm::{Prepared, SavedVm}; +use crate::{Error, Result}; + +/// A launch whose lock stays borrowed until commit or until its journal is left behind. +#[derive(Debug)] +pub(crate) struct Launch<'a> { + locked: &'a mut LockedState, + journal: LaunchJournal, +} + +impl LockedState { + /// Durably publishes the capability and complete prepared journal before the caller spawns. + pub(crate) fn begin_launch( + &mut self, + prepared: &Prepared, + runtime: &RuntimeIdentity, + ) -> Result> { + if self.journal()?.is_some() || self.runtime()?.is_some() { + return Err(Error::other( + "the sandbox already has a runtime record or an interrupted launch", + )); + } + let journal = LaunchJournal::prepared(self, prepared, runtime)?; + files::replace(self, StateFile::Capability, &journal.capability)?; + self.write_record(StateFile::Journal, &journal)?; + Ok(Launch { + locked: self, + journal, + }) + } +} + +impl Launch<'_> { + /// Records only the caller's spawned process, durably, before changing the in-memory state. + pub(crate) fn record_process(&mut self, process: &ProcessRef) -> Result<()> { + if self.journal.process.is_some() { + return Err(Error::other("the launch has recorded its process already")); + } + let journal = LaunchJournal { + process: Some(process.clone()), + ..self.journal.clone() + }; + self.locked.write_record(StateFile::Journal, &journal)?; + self.journal = journal; + Ok(()) + } + + /// Publishes the exact saved VM, only after its matching process was journaled. + /// + /// The runtime record is the commit. A failed journal removal keeps that committed record + /// and reports the failure; it never rolls back the commit or signals the VM. + pub(crate) fn commit(self, saved: &SavedVm) -> Result<()> { + if self.journal.process.as_ref() != Some(&saved.process) + || self.journal.vm_dir != saved.vm_dir.path() + || self.journal.endpoint != saved.endpoint + || self.journal.capability != saved.capability + || self.journal.runtime != saved.runtime + { + return Err(Error::other( + "the saved VM does not match the launch and its journaled process", + )); + } + self.locked + .write_record(StateFile::Runtime, &RuntimeRecord::from(saved))?; + files::remove(self.locked, StateFile::Journal).map_err(|error| { + Error::io(format!( + "the VM runtime was committed but its launch journal could not be removed: {error}" + )) + .with_source(error) + }) + } +} diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index 6f0c6479f..16bcb2432 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -26,6 +26,10 @@ //! and the Administrators group, as one under the user's profile does. An existing directory that //! others may access is refused rather than restricted, since they could have placed files in it, //! or still hold them open. The store creates its files readable only by the current user. +//! +//! A locked launch writes its prepared capability and outputs before spawn, records the spawned +//! process durably, and publishes the caller's exact saved VM only at commit. Dropping an +//! uncommitted launch keeps its journal; these transitions do not recover or signal a process. #![cfg_attr( not(test), expect( @@ -34,8 +38,11 @@ ) )] +mod files; +mod launch; mod locked; mod names; +mod record; use std::io; use std::path::{Path, PathBuf}; diff --git a/nvx-rs/src/host/cli/store/record.rs b/nvx-rs/src/host/cli/store/record.rs new file mode 100644 index 000000000..d84718801 --- /dev/null +++ b/nvx-rs/src/host/cli/store/record.rs @@ -0,0 +1,159 @@ +//! The fixed launch journal and the caller-saved VM's runtime record. + +use std::fmt; +use std::path::PathBuf; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use super::StateFile; +use super::files; +use super::locked::LockedState; +use crate::host::process::ProcessRef; +use crate::host::runtime::RuntimeIdentity; +use crate::host::vm::{Prepared, SavedVm, VmDir, VmOutputs}; +use crate::protocol::CAPABILITY_LEN; +use crate::{Error, Result}; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LaunchJournal { + pub(super) vm_dir: PathBuf, + pub(super) capability: [u8; CAPABILITY_LEN], + pub(super) log: PathBuf, + pub(super) outcome: PathBuf, + pub(super) endpoint: String, + pub(super) console: Option, + pub(super) runtime: RuntimeIdentity, + pub(super) process: Option, +} + +impl fmt::Debug for LaunchJournal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LaunchJournal") + .field("vm_dir", &self.vm_dir) + .field("log", &self.log) + .field("outcome", &self.outcome) + .field("endpoint", &self.endpoint) + .field("console", &self.console) + .field("runtime", &self.runtime) + .field("process", &self.process) + .finish_non_exhaustive() + } +} + +impl LaunchJournal { + pub(super) fn prepared( + state: &LockedState, + prepared: &Prepared, + runtime: &RuntimeIdentity, + ) -> Result { + runtime.check()?; + let vm_dir = VmDir::open(state.dir())?; + let VmOutputs { + log, + outcome, + endpoint, + console, + } = prepared.outputs(); + if *log != vm_dir.path().join(StateFile::Log.name()) + || *outcome != vm_dir.path().join(StateFile::Outcome.name()) + { + return Err(Error::other( + "the prepared launch belongs to another sandbox directory", + )); + } + Ok(Self { + vm_dir: vm_dir.path().to_path_buf(), + capability: *prepared.capability().as_bytes(), + log: log.clone(), + outcome: outcome.clone(), + endpoint: endpoint.clone(), + console: console.clone(), + runtime: runtime.clone(), + process: None, + }) + } + + pub(crate) fn process(&self) -> Option<&ProcessRef> { + self.process.as_ref() + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct RuntimeRecord { + vm_dir: PathBuf, + endpoint: String, + capability: [u8; CAPABILITY_LEN], + process: ProcessRef, + runtime: RuntimeIdentity, +} + +impl From<&SavedVm> for RuntimeRecord { + fn from(saved: &SavedVm) -> Self { + Self { + vm_dir: saved.vm_dir.path().to_path_buf(), + endpoint: saved.endpoint.clone(), + capability: saved.capability, + process: saved.process.clone(), + runtime: saved.runtime.clone(), + } + } +} + +impl LockedState { + pub(crate) fn journal(&self) -> Result> { + self.read_record(StateFile::Journal) + } + + pub(crate) fn runtime(&self) -> Result> { + let Some(record) = self.read_record::(StateFile::Runtime)? else { + return Ok(None); + }; + let vm_dir = VmDir::open(self.dir())?; + if record.vm_dir != vm_dir.path() { + return Err(Error::io( + "the runtime record belongs to another sandbox directory", + )); + } + Ok(Some(SavedVm { + vm_dir, + endpoint: record.endpoint, + capability: record.capability, + process: record.process, + runtime: record.runtime, + })) + } + + pub(crate) fn capability(&self) -> Result> { + files::read(self, StateFile::Capability)? + .map(|bytes| { + bytes.try_into().map_err(|_| { + Error::io(format!( + "the sandbox capability must hold {CAPABILITY_LEN} bytes" + )) + }) + }) + .transpose() + } + + fn read_record(&self, name: StateFile) -> Result> { + files::read(self, name)? + .map(|bytes| { + serde_json::from_slice(&bytes).map_err(|error| { + Error::io(format!("cannot parse {}", self.path(name).display())) + .with_source(error) + }) + }) + .transpose() + } + + pub(super) fn write_record(&self, name: StateFile, record: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec(record).map_err(|error| { + Error::io(format!("cannot serialize {}", self.path(name).display())).with_source(error) + })?; + files::replace(self, name, &bytes) + } +} diff --git a/nvx-rs/src/host/cli/store/tests.rs b/nvx-rs/src/host/cli/store/tests.rs index bb36e8418..f28a33df9 100644 --- a/nvx-rs/src/host/cli/store/tests.rs +++ b/nvx-rs/src/host/cli/store/tests.rs @@ -8,6 +8,8 @@ use super::*; use crate::host::cli::deadline::Deadline; use crate::host::store::{JOURNAL_NAME, RUNTIME_NAME, SandboxStore}; +mod launch; + /// How long a test waits for a lock that it released to be free. /// /// A process that a concurrent test spawns holds a copy of each descriptor of the test process diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs new file mode 100644 index 000000000..aff660e14 --- /dev/null +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -0,0 +1,382 @@ +use std::error::Error as _; + +use super::*; +use crate::ErrorKind; +use crate::activation::{MachineIdMode, View}; +use crate::host::cli::store::{files, record}; +use crate::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; +use crate::host::process::{BootId, ProcessRef}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; +use crate::host::vm::{self, Prepared, SavedVm, VmDir}; +use crate::hypervisor::Hypervisor; +use crate::protocol::CAPABILITY_LEN; + +fn prepared(state: &StateDir, console: ConsoleAttachment) -> (Prepared, RuntimeIdentity) { + let identity = |text| ArtifactIdentity::new(text).unwrap(); + let runtime = Runtime { + openvmm: "missing-openvmm".into(), + kernel: "missing-kernel".into(), + initramfs: "missing-initramfs".into(), + identity: RuntimeIdentity { + openvmm: identity("vmm-revision"), + kernel: identity("kernel-revision"), + initramfs: identity("initramfs-revision"), + boot_contract: u64::MAX, + control_contract: "saved-control-revision".to_owned(), + control_session: u64::MAX - 1, + }, + hypervisor: if cfg!(windows) { + Hypervisor::Whp + } else { + Hypervisor::Mshv + }, + cpu_profile: None, + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let config = VmConfig { + processors: 1, + memory_mib: 256, + root: RootConfig::Blockless, + view: View { + hostname: b"journal-test".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console, + boot: Some(Boot { + command_line: "nvx_entrypoint=/bin/sh".to_owned(), + capture: None, + }), + }; + let vm_dir = VmDir::open(state.dir()).unwrap(); + ( + vm::create(&runtime, &config, None, &vm_dir).unwrap(), + runtime.identity, + ) +} + +fn saved(state: &StateDir, prepared: &Prepared, runtime: RuntimeIdentity) -> SavedVm { + SavedVm { + vm_dir: VmDir::open(state.dir()).unwrap(), + endpoint: prepared.outputs().endpoint.clone(), + capability: *prepared.capability().as_bytes(), + process: ProcessRef { + pid: u32::MAX, + start_time: u64::MAX, + boot: BootId::from_bytes([0xa5; 16]), + }, + runtime, + } +} + +fn assert_saved(actual: &SavedVm, expected: &SavedVm) { + assert_eq!(actual.vm_dir, expected.vm_dir); + assert_eq!(actual.endpoint, expected.endpoint); + assert_eq!(actual.capability, expected.capability); + assert_eq!(actual.process, expected.process); + assert_eq!(actual.runtime, expected.runtime); +} + +fn read_file(locked: &locked::LockedState, name: StateFile) -> crate::Result { + match name { + StateFile::Journal => locked.journal().map(|record| record.is_some()), + StateFile::Runtime => locked.runtime().map(|record| record.is_some()), + StateFile::Capability => locked.capability().map(|record| record.is_some()), + _ => unreachable!(), + } +} + +#[test] +fn the_pre_spawn_barrier_and_commit_preserve_every_prepared_and_saved_value() { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, console); + let saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let spawn_hook = || { + let bytes = fs::read(state.path(StateFile::Journal)).unwrap(); + let journal: record::LaunchJournal = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(journal.vm_dir, saved.vm_dir.path()); + assert_eq!(journal.capability, saved.capability); + assert_eq!(journal.log, prepared.outputs().log); + assert_eq!(journal.outcome, prepared.outputs().outcome); + assert_eq!(journal.endpoint, prepared.outputs().endpoint); + assert_eq!(journal.console, prepared.outputs().console); + assert_eq!(journal.runtime, runtime); + assert_eq!(journal.process(), None); + assert!(!format!("{journal:?}").contains(&format!("{:?}", saved.capability))); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + saved.capability + ); + for name in [StateFile::Journal, StateFile::Capability] { + let path = state.path(name); + let metadata = fs::symlink_metadata(&path).unwrap(); + assert!(metadata.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(metadata.permissions().mode() & 0o7777, 0o600); + } + let file = fs::File::open(path).unwrap(); + assert_eq!(platform::private_file_problem(&file).unwrap(), None); + } + assert!(!state.path(StateFile::Runtime).exists()); + }; + spawn_hook(); + launch.record_process(&saved.process).unwrap(); + let journal: record::LaunchJournal = + serde_json::from_slice(&fs::read(state.path(StateFile::Journal)).unwrap()).unwrap(); + assert_eq!(journal.process(), Some(&saved.process)); + assert!(!state.path(StateFile::Runtime).exists()); + launch.commit(&saved).unwrap(); + assert!(!state.path(StateFile::Journal).exists()); + assert_saved(&locked.runtime().unwrap().unwrap(), &saved); + assert_eq!(locked.capability().unwrap(), Some(saved.capability)); + drop(locked); + drop(prepared); + let reopened = StateDir::open(state.dir()) + .unwrap() + .lock(Deadline::after(10.0)) + .unwrap(); + assert_saved(&reopened.runtime().unwrap().unwrap(), &saved); + for name in [StateFile::Runtime, StateFile::Capability] { + let path = state.path(name); + let metadata = fs::symlink_metadata(&path).unwrap(); + assert!(metadata.is_file()); + assert!(!crate::host::filesystem::is_reparse_point(&metadata)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(metadata.permissions().mode() & 0o7777, 0o600); + } + let file = fs::File::open(path).unwrap(); + assert_eq!(platform::private_file_problem(&file).unwrap(), None); + } + } +} + +#[test] +fn an_interruption_keeps_the_journal_and_capability_without_publishing_runtime() { + for spawned in [false, true] { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Listener); + let saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + { + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + if spawned { + launch.record_process(&saved.process).unwrap(); + } + } + drop(locked); + let mut reopened = state.lock(Deadline::after(10.0)).unwrap(); + let journal = reopened.journal().unwrap().unwrap(); + assert_eq!(journal.process(), spawned.then_some(&saved.process)); + assert_eq!(journal.capability, saved.capability); + assert_eq!(reopened.capability().unwrap(), Some(saved.capability)); + assert!(reopened.runtime().unwrap().is_none()); + let before = fs::read(state.path(StateFile::Journal)).unwrap(); + assert!(reopened.begin_launch(&prepared, &runtime).is_err()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); + } +} + +#[test] +fn a_commit_needs_the_same_saved_values_and_an_already_journaled_process() { + for mismatch in 0..6 { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + if mismatch != 0 { + launch.record_process(&saved.process).unwrap(); + assert!(launch.record_process(&saved.process).is_err()); + } + let original = fs::read(state.path(StateFile::Journal)).unwrap(); + match mismatch { + 0 => {} + 1 => saved.process.start_time -= 1, + 2 => saved.endpoint.push('x'), + 3 => saved.capability[0] ^= 1, + 4 => saved.runtime.control_session -= 1, + 5 => { + let other = state.dir().join("other-vm"); + vm::create_private_dir(&other).unwrap(); + saved.vm_dir = VmDir::open(&other).unwrap(); + } + _ => unreachable!(), + } + assert!(launch.commit(&saved).is_err()); + assert!(!state.path(StateFile::Runtime).exists()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), original); + } +} + +#[test] +fn preparation_for_another_directory_and_failed_record_writes_do_not_publish_runtime() { + let (_directory, state) = state(); + let (_other_directory, other) = super::state(); + let (other_prepared, runtime) = prepared(&other, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(locked.begin_launch(&other_prepared, &runtime).is_err()); + assert!(!state.path(StateFile::Journal).exists()); + assert!(!state.path(StateFile::Capability).exists()); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let saved = saved(&state, &prepared, runtime.clone()); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let journal = state.path(StateFile::Journal); + let original = journal.with_extension("original"); + fs::rename(&journal, &original).unwrap(); + fs::create_dir(&journal).unwrap(); + let error = launch.record_process(&saved.process).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert!(error.source().is_some()); + fs::remove_dir(&journal).unwrap(); + fs::rename(&original, &journal).unwrap(); + assert!(launch.commit(&saved).is_err()); + assert!(!state.path(StateFile::Runtime).exists()); + assert!(locked.journal().unwrap().unwrap().process().is_none()); +} + +#[test] +fn a_failed_runtime_publication_keeps_the_spawned_journal() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + launch.record_process(&saved.process).unwrap(); + let before = fs::read(state.path(StateFile::Journal)).unwrap(); + fs::create_dir(state.path(StateFile::Runtime)).unwrap(); + let error = launch.commit(&saved).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert!(error.source().is_some()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); + assert_eq!( + fs::read_dir(state.path(StateFile::Runtime)) + .unwrap() + .count(), + 0 + ); +} + +#[test] +fn a_failed_journal_removal_reports_that_runtime_already_committed() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + launch.record_process(&saved.process).unwrap(); + fs::remove_file(state.path(StateFile::Journal)).unwrap(); + fs::create_dir(state.path(StateFile::Journal)).unwrap(); + let error = launch.commit(&saved).unwrap_err(); + assert!(error.message().contains("runtime was committed"), "{error}"); + assert!(error.source().unwrap().source().is_some()); + assert_saved(&locked.runtime().unwrap().unwrap(), &saved); + assert!(state.path(StateFile::Journal).is_dir()); +} + +#[test] +fn malformed_records_and_capabilities_keep_their_parse_errors() { + let (_directory, state) = state(); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + for name in [StateFile::Journal, StateFile::Runtime] { + files::replace(&locked, name, b"{malformed").unwrap(); + let error = read_file(&locked, name).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert!( + error + .source() + .unwrap() + .downcast_ref::() + .is_some(), + "{error}" + ); + } + files::replace(&locked, StateFile::Capability, &[0x5a; CAPABILITY_LEN - 1]).unwrap(); + assert_eq!(locked.capability().unwrap_err().kind(), ErrorKind::Io); +} + +#[test] +fn records_and_capabilities_refuse_non_regular_entries_and_links_before_read_or_replace() { + for name in [ + StateFile::Journal, + StateFile::Runtime, + StateFile::Capability, + ] { + let (directory, state) = state(); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + let path = state.path(name); + fs::create_dir(&path).unwrap(); + assert!(read_file(&locked, name).is_err()); + assert!(files::replace(&locked, name, b"replacement").is_err()); + assert_eq!(fs::read_dir(&path).unwrap().count(), 0); + fs::remove_dir(&path).unwrap(); + let target = directory.path().join("target"); + fs::write(&target, b"preserved target").unwrap(); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(&target, &path); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_file(&target, &path); + match linked { + Ok(()) => { + assert!(read_file(&locked, name).is_err()); + assert!(files::replace(&locked, name, b"replacement").is_err()); + assert_eq!(fs::read(&target).unwrap(), b"preserved target"); + fs::remove_file(&path).unwrap(); + } + #[cfg(windows)] + Err(error) if error.raw_os_error() == Some(1314) => { + eprintln!("skipped the symbolic link without Developer Mode or privilege: {error}"); + } + Err(error) => panic!("cannot create the test symbolic link: {error}"), + } + #[cfg(target_os = "linux")] + { + use nix::sys::stat::Mode; + nix::unistd::mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); + assert!(read_file(&locked, name).is_err()); + assert!(files::replace(&locked, name, b"replacement").is_err()); + } + #[cfg(windows)] + { + let target = directory.path().join("junction-target"); + fs::create_dir(&target).unwrap(); + junction(&path, &target); + assert!(read_file(&locked, name).is_err()); + assert!(files::replace(&locked, name, b"replacement").is_err()); + assert_eq!(fs::read_dir(target).unwrap().count(), 0); + } + } +} + +#[cfg(windows)] +#[test] +fn records_and_capabilities_that_other_users_may_access_are_refused_without_replacement() { + let (_directory, state) = state(); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + for name in [ + StateFile::Journal, + StateFile::Runtime, + StateFile::Capability, + ] { + files::replace(&locked, name, b"preserved shared contents").unwrap(); + let path = state.path(name); + allow_everyone(&path, ""); + let before = fs::read(&path).unwrap(); + for error in [ + read_file(&locked, name).unwrap_err(), + files::replace(&locked, name, b"replacement").unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Io); + assert!(error.message().contains("allows other users"), "{error}"); + } + assert_eq!(fs::read(path).unwrap(), before); + } +} From 1cb779e8daf8716f1d650feeb176a10a8b1bf83f Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:14:23 -0800 Subject: [PATCH 03/25] guest: serve snapshot capture requests from init Add the rootless Fresh capture entry and handle snapshot outcomes on the shared control session. Refuse rooted capture before killing workloads and preserve the current helper supervision and nonblocking transport. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dee059e7-7968-43d8-82d0-abd68b922eed --- nvx-rs/src/guest/init.rs | 253 +++++++++++++- nvx-rs/src/guest/init/tests.rs | 3 +- nvx-rs/src/guest/init/tests/capture.rs | 210 ++++++++++++ nvx-rs/src/guest/managed_agent/dispatch.rs | 65 +++- .../src/guest/managed_agent/dispatch/tests.rs | 134 +++++++- nvx-rs/src/guest/managed_agent/exec.rs | 21 +- nvx-rs/src/guest/managed_agent/session.rs | 146 +++++++- .../src/guest/managed_agent/session/tests.rs | 313 ++++++++++++++++++ nvx-rs/src/guest/snapshot/repair.rs | 2 +- nvx-rs/src/guest/snapshot/repair/tests.rs | 5 + nvx-rs/src/protocol/app.rs | 11 +- nvx-rs/src/protocol/mod.rs | 9 +- scripts/test-layout-allowlist.json | 2 +- 13 files changed, 1147 insertions(+), 27 deletions(-) create mode 100644 nvx-rs/src/guest/init/tests/capture.rs create mode 100644 nvx-rs/src/guest/managed_agent/session/tests.rs diff --git a/nvx-rs/src/guest/init.rs b/nvx-rs/src/guest/init.rs index ec2e871c2..bd326adf1 100644 --- a/nvx-rs/src/guest/init.rs +++ b/nvx-rs/src/guest/init.rs @@ -35,14 +35,20 @@ //! //! Outside PID 1, `nvx-init` changes nothing: it fails with status 2 and a message. -use std::ffi::{CStr, CString}; +use std::ffi::{CStr, CString, OsStr}; use std::io::{self, Read}; use std::os::fd::BorrowedFd; +use std::os::unix::ffi::OsStrExt; use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; use std::process::{ExitCode, ExitStatus as ProcessStatus}; use std::time::{Duration, Instant}; +use crate::protocol::cmdline; +use crate::protocol::root::{DISTRO_MMIO_BASE, SCRATCH_MMIO_BASE}; + use super::parse::parse_decimal; +use super::snapshot::{self, CaptureFailure, Captured, Devices, Disks, Repaired}; use super::status::ExitStatus; use super::sys::{ self, ChildStatus, Errno, FileDescriptor, Mode, OpenFlags, Pollable, ProcessId, Signal, @@ -113,10 +119,6 @@ impl Helpers { /// Kills every remaining userspace process but PID 1 and the registered helpers, repeating /// until none remains, including descendants that left their execution's process group. - #[cfg_attr( - not(test), - expect(dead_code, reason = "SL7.7a calls the capture sweep") - )] pub(crate) fn sweep(self) -> Result<(), Errno> { if sys::current_process().raw() != INIT_PROCESS { return Err(Errno::EPERM); @@ -124,6 +126,23 @@ impl Helpers { self.sweep_with(ProcessId::live_userspace, sys::kill_process) } + /// A capture without permission to kill must leave no unregistered process running. + fn prepare_capture(self, kill: bool) -> Result<(), Errno> { + if kill { + return self.sweep(); + } + if sys::current_process().raw() != INIT_PROCESS { + return Err(Errno::EPERM); + } + if ProcessId::live_userspace()? + .into_iter() + .any(|process| process.raw() != INIT_PROCESS && self.name(process).is_none()) + { + return Err(Errno::EBUSY); + } + Ok(()) + } + /// Runs the narrow process-table sweep with the supplied walk and kill operations. fn sweep_with( self, @@ -150,6 +169,144 @@ impl Helpers { } } +/// The capture request's one pre-request deadline, including workload cleanup. +const CAPTURE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Init's capture inputs and outcome, retained across the existing control session's reset. +pub(crate) struct Capture { + /// The only processes that capture cleanup spares besides PID 1. + helpers: Helpers, + /// This boot has not assembled a root. + fresh: bool, + /// Request ID, kill flag and the original pre-request deadline. + pending: Option<(u64, bool, Instant)>, + /// The repair whose RESET must be acknowledged before later activation. + repaired: Option, + /// A fatal capture disposition, whose status PID 1 must preserve. + shutdown: Option, +} + +/// Preparation refused the request, or the acknowledged snapshot call returned. +pub(crate) enum CaptureStep { + /// Workload preparation failed before the request was acknowledged. + Refused { + /// The original capture request. + request_id: u64, + /// The preparation failure. + error: Errno, + }, + /// The acknowledged, synchronous snapshot call returned. + Returned { + /// The original capture request, still on the same session. + request_id: u64, + /// The snapshot library's unchanged outcome and disposition. + result: Result, + }, +} + +impl Capture { + /// Retains init's helpers, without constructing any space. + fn new(helpers: Helpers, fresh: bool) -> Self { + Self { + helpers, + fresh, + pending: None, + repaired: None, + shutdown: None, + } + } + + /// Keeps one pending request and anchors its deadline before cleanup. + pub(crate) fn queue(&mut self, request_id: u64, kill: bool) -> bool { + if self.pending.is_some() || self.repaired.is_some() || self.shutdown.is_some() { + return false; + } + self.pending = Some((request_id, kill, Instant::now() + CAPTURE_TIMEOUT)); + true + } + + /// Whether the active execution must hand back a capture request. + pub(crate) fn pending(&self) -> bool { + self.pending.is_some() + } + + /// Drops an old epoch's request, not the repaired state. + pub(crate) fn reset(&mut self) { + self.pending = None; + } + + /// Whether this session must admit no root-dependent operation. + pub(crate) fn rootless(&self) -> bool { + self.fresh || self.repaired.is_some() + } + + /// Keeps the original repair for later activation, which this slice does not perform. + pub(crate) fn restored(&mut self, repaired: Repaired) { + self.repaired = Some(repaired); + } + + /// Records the fatal status before the loop returns directly to PID 1. + pub(crate) fn must_end(&mut self, status: u8) { + self.shutdown = Some(status); + } + + /// The snapshot library's fatal status, if capture ended the loop. + pub(crate) fn shutdown_status(&self) -> Option { + self.shutdown + } + + /// Performs the existing repeated sweep after group termination. + pub(crate) fn sweep(&self) -> Result<(), Errno> { + self.helpers.sweep() + } + + /// Prepares workloads, acknowledges the request, then calls the snapshot library once. + pub(crate) fn perform( + &mut self, + acknowledge: impl FnOnce(u64) -> Result<(), Errno>, + ) -> Result, Errno> { + let helpers = self.helpers; + self.perform_with( + |kill| helpers.prepare_capture(kill), + acknowledge, + snapshot::capture, + ) + } + + /// The transport is invoked only before the one synchronous snapshot call. + pub(crate) fn perform_with( + &mut self, + prepare: impl FnOnce(bool) -> Result<(), Errno>, + acknowledge: impl FnOnce(u64) -> Result<(), Errno>, + capture: impl FnOnce(&Devices, Disks, Instant) -> Result, + ) -> Result, Errno> { + let Some((request_id, kill, deadline)) = self.pending.take() else { + return Ok(None); + }; + if let Err(error) = prepare(kill) { + return Ok(Some(CaptureStep::Refused { request_id, error })); + } + acknowledge(request_id)?; + let devices = Devices { + distro: DISTRO_MMIO_BASE, + scratch: SCRATCH_MMIO_BASE, + filesystem_tag: "microvm".to_owned(), + }; + Ok(Some(CaptureStep::Returned { + request_id, + result: capture(&devices, Disks::Fresh, deadline), + })) + } +} + +/// Reports capture detail without driving the control transport or starting a helper. +pub(crate) fn report_capture(failure: impl std::fmt::Display) { + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-CAPTURE: {failure}\n").as_bytes(), + ); +} + /// Whether execution requires an account in the shared root's databases. #[cfg_attr( not(test), @@ -840,6 +997,11 @@ impl Runtime { Pollable::Signals(self.system.signals.as_ref().expect("listening")) } + /// Hands the same helper registry to the shared loop's capture caller. + pub(crate) fn capture(&self, fresh: bool) -> Capture { + Capture::new(self.helpers, fresh) + } + /// Hands terminal statuses to the active execution; a helper failure wins over shutdown. pub(crate) fn service( &mut self, @@ -867,7 +1029,7 @@ pub fn main() -> ExitCode { let arguments = sys::args(); match Runtime::new(arguments.get(1..).unwrap_or_default()) { Ok(mut runtime) => { - let result = serve_layered(&mut runtime); + let result = serve_boot(&mut runtime); finish(&mut runtime.system, result); } Err(failure) => finish(&mut RealSystem { signals: None }, Err(failure)), @@ -875,9 +1037,21 @@ pub fn main() -> ExitCode { ExitStatus::Failure.into() } -fn serve_layered(runtime: &mut Runtime) -> Result<(), String> { +fn serve_boot(runtime: &mut Runtime) -> Result { let command_line = std::fs::read("/proc/cmdline") .map_err(|error| format!("cannot read the boot command line: {error}"))?; + if let Some(control) = fresh_capture_control(&command_line)? { + let control = super::terminal::Terminal::open(&control) + .map_err(|error| format!("cannot open capture control: {error}"))?; + control + .configure_raw() + .map_err(|error| format!("cannot configure capture control: {error}"))?; + runtime.system.report(b"NVX-INIT-CAPTURE: waiting\n"); + return super::managed_agent::AgentConfig::serve_capture( + runtime, + control.into_descriptor(), + ); + } let boot = boot::cold_boot(&command_line).map_err(|error| error.to_string())?; runtime.system.report( format!( @@ -895,10 +1069,71 @@ fn serve_layered(runtime: &mut Runtime) -> Result<(), String> { ) } +/// Selects only the fixed rootless Fresh entry; malformed capture tokens never fall back. +fn fresh_capture_control(command_line: &[u8]) -> Result, String> { + let tokens: Vec<_> = cmdline::init_tokens(command_line).collect(); + let parameter = |name: &[u8]| { + let mut matching = tokens.iter().filter(|token| token.name == name); + let Some(token) = matching.next() else { + return Ok(None); + }; + if matching.next().is_some() || token.value.is_none() { + return Err(format!( + "{} is repeated or has no value", + name.escape_ascii() + )); + } + Ok(token.value) + }; + match parameter(b"nvx_snapshot_tier")? { + None => return Ok(None), + Some(b"platform") => {} + Some(_) => return Err("nvx_snapshot_tier must be platform".to_owned()), + } + if parameter(b"nvx_sandbox")? != Some(b"1") { + return Err("a Fresh capture needs nvx_sandbox=1".to_owned()); + } + for token in &tokens { + if matches!( + token.name, + b"nvx_layer" + | b"nvx_scratch" + | b"nvx_overlay_upper" + | b"nvx_overlay_mode" + | b"nvx_scratch_journal" + | b"nvx_first_partition_bound" + | b"nvx_ram_upper_percent" + | b"nvx_hostname" + | b"nvx_machine_id" + | b"nvx_maps" + | b"nvx_share" + | b"nvx_identity" + | b"nvx_workload_account" + | b"nvx_entrypoint" + | b"nvx_arg" + | b"nvx_env" + | b"nvx_cwd" + | b"nvx_lifecycle" + | b"nvx_workload_uid" + | b"nvx_workload_gid" + ) || token.name.starts_with(b"virtfs_") + { + return Err(format!( + "a Fresh capture cannot carry {}", + token.name.escape_ascii() + )); + } + } + let control = parameter(b"nvx_control_tty")? + .filter(|value| !value.is_empty() && value.iter().all(u8::is_ascii_alphanumeric)) + .ok_or("a Fresh capture needs nvx_control_tty naming a device in /dev")?; + Ok(Some(Path::new("/dev").join(OsStr::from_bytes(control)))) +} + /// Keeps diagnostics, sync and direct power-off with the original PID-1 owner. -fn finish(system: &mut impl System, result: Result<(), String>) { +fn finish(system: &mut impl System, result: Result) { let status = match result { - Ok(()) => 0, + Ok(status) => status, Err(failure) => { system.report(format!("NVX-INIT-ERROR: {failure}\n").as_bytes()); FAILURE diff --git a/nvx-rs/src/guest/init/tests.rs b/nvx-rs/src/guest/init/tests.rs index 3a017f74a..706bf18a7 100644 --- a/nvx-rs/src/guest/init/tests.rs +++ b/nvx-rs/src/guest/init/tests.rs @@ -7,6 +7,7 @@ //! PID 1 theirs, and the check of the real priority needs the privileges of a guest's PID 1. No //! check stops a VM: each records the status with which PID 1 would. +mod capture; mod exec; pub(super) use exec::prepare_child; @@ -25,7 +26,7 @@ use crate::guest::sys::{ForkResult, wait}; /// shutdown; layered boot itself is exercised by the boot tests. fn init(system: &mut impl Waiting, arguments: &[CString]) { let result = serve(system, arguments); - finish(system, result); + finish(system, result.map(|()| 0)); } fn serve(system: &mut impl Waiting, arguments: &[CString]) -> Result<(), String> { diff --git a/nvx-rs/src/guest/init/tests/capture.rs b/nvx-rs/src/guest/init/tests/capture.rs new file mode 100644 index 000000000..1fddd18c4 --- /dev/null +++ b/nvx-rs/src/guest/init/tests/capture.rs @@ -0,0 +1,210 @@ +use std::cell::{Cell, RefCell}; + +use super::*; +use crate::guest::snapshot::{CaptureCause, CaptureStage, Disposition}; + +impl Capture { + /// Supplies only a helper registry; no unit check can sweep the host through this fixture. + pub(crate) fn fixture(fresh: bool) -> Self { + Self::new( + Helpers { + time: pid(42), + announcer: Some(pid(43)), + }, + fresh, + ) + } +} + +#[test] +fn capture_preparation_and_acknowledgement_precede_one_synchronous_call() { + let mut state = Capture::fixture(true); + assert!(state.queue(17, true)); + assert!(!state.queue(18, true)); + let calls = RefCell::new(Vec::new()); + let deadline = state.pending.unwrap().2; + let returned = state + .perform_with( + |kill| { + assert!(kill); + calls.borrow_mut().push("kill-and-sweep"); + Ok(()) + }, + |request_id| { + assert_eq!(request_id, 17); + calls.borrow_mut().push("ack"); + Ok(()) + }, + |devices, disks, actual_deadline| { + assert_eq!(*calls.borrow(), ["kill-and-sweep", "ack"]); + assert_eq!(devices.distro, DISTRO_MMIO_BASE); + assert_eq!(devices.scratch, SCRATCH_MMIO_BASE); + assert_eq!(devices.filesystem_tag, "microvm"); + assert_eq!(disks, Disks::Fresh); + assert_eq!(actual_deadline, deadline); + calls.borrow_mut().push("capture"); + Ok(Captured::Continued) + }, + ) + .unwrap(); + assert!(matches!( + returned, + Some(CaptureStep::Returned { + request_id: 17, + result: Ok(Captured::Continued) + }) + )); + assert_eq!(*calls.borrow(), ["kill-and-sweep", "ack", "capture"]); + assert!( + state + .perform_with( + |_| panic!("no second preparation"), + |_| panic!("no second acknowledgement"), + |_, _, _| panic!("no second snapshot call"), + ) + .unwrap() + .is_none() + ); +} + +#[test] +fn refused_preparation_or_failed_acknowledgement_never_calls_snapshot() { + for refusal in [Errno::EBUSY, Errno::EIO] { + let mut state = Capture::fixture(true); + assert!(state.queue(19, false)); + assert!(matches!( + state + .perform_with( + |kill| { + assert!(!kill); + Err(refusal) + }, + |_| panic!("a refused request is not acknowledged"), + |_, _, _| panic!("a refused request does not capture"), + ) + .unwrap(), + Some(CaptureStep::Refused { request_id: 19, error }) if error == refusal + )); + } + let mut state = Capture::fixture(true); + assert!(state.queue(20, false)); + assert_eq!( + state + .perform_with( + |_| Ok(()), + |_| Err(Errno::EPIPE), + |_, _, _| panic!("a failed acknowledgement does not capture"), + ) + .err(), + Some(Errno::EPIPE) + ); +} + +#[test] +fn an_old_epoch_drops_its_pending_capture_without_restarting_it() { + let mut state = Capture::fixture(false); + assert!(state.queue(21, true)); + state.reset(); + let called = Cell::new(false); + assert!( + state + .perform_with( + |_| { + called.set(true); + Ok(()) + }, + |_| panic!("no stale acknowledgement"), + |_, _, _| panic!("no stale capture"), + ) + .unwrap() + .is_none() + ); + assert!(!called.get()); +} + +#[test] +fn fresh_capture_selects_the_rootless_control_entry_without_root_tokens() { + assert_eq!( + fresh_capture_control( + b"nvx_sandbox=1 nvx_snapshot_tier=platform nvx_control_tty=ttyS1 quiet" + ) + .unwrap(), + Some(PathBuf::from("/dev/ttyS1")) + ); + assert_eq!( + fresh_capture_control(b"nvx_sandbox=1 nvx_control_tty=ttyS1").unwrap(), + None + ); + for extra in [ + "nvx_layer=distro,0xd0003000,erofs", + "nvx_scratch=0xd0006000,ext4", + "nvx_overlay_upper=ramfs", + "nvx_hostname=626f78", + "nvx_machine_id=image", + "nvx_maps=0", + "nvx_share=0", + "nvx_identity=1000:1000", + "nvx_workload_account=create", + "nvx_entrypoint=/bin/work", + "nvx_arg=work", + "nvx_env=HOME=/work", + "nvx_cwd=/work", + "virtfs_tag=microvm", + "virtfs_dir=/work", + ] { + let command_line = + format!("nvx_sandbox=1 nvx_snapshot_tier=platform nvx_control_tty=ttyS1 {extra}"); + assert!( + fresh_capture_control(command_line.as_bytes()).is_err(), + "{extra}" + ); + } + for command_line in [ + "nvx_snapshot_tier=platform nvx_control_tty=ttyS1", + "nvx_sandbox=1 nvx_snapshot_tier=paired nvx_control_tty=ttyS1", + "nvx_sandbox=1 nvx_snapshot_tier=platform nvx_snapshot_tier=platform nvx_control_tty=ttyS1", + "nvx_sandbox=1 nvx_snapshot_tier=platform", + "nvx_sandbox=1 nvx_snapshot_tier=platform nvx_control_tty=../ttyS1", + "nvx_sandbox=1 nvx_snapshot_tier=platform nvx_control_tty=ttyS1 nvx_control_tty=ttyS2", + ] { + assert!( + fresh_capture_control(command_line.as_bytes()).is_err(), + "{command_line}" + ); + } +} + +#[test] +fn fatal_capture_status_goes_directly_to_pid1_shutdown_without_starting_work() { + for status in [0, 41, 194] { + let mut capture = Capture::fixture(true); + assert!(capture.queue(22, false)); + let returned = capture + .perform_with( + |_| Ok(()), + |_| Ok(()), + |_, _, _| { + Err(CaptureFailure { + stage: CaptureStage::Capture, + cause: CaptureCause::Io(io::Error::other("capture cannot resume")), + disposition: Disposition::MustEnd { status }, + }) + }, + ) + .unwrap(); + let Some(CaptureStep::Returned { + result: Err(failure), + .. + }) = returned + else { + panic!("the fatal disposition is retained"); + }; + let Disposition::MustEnd { status: actual } = failure.disposition else { + panic!("a fatal capture cannot resume"); + }; + capture.must_end(actual); + let mut system = Fake::default(); + finish(&mut system, Ok(capture.shutdown_status().unwrap())); + assert_eq!(system.calls, [Call::ShutDown(status)]); + } +} diff --git a/nvx-rs/src/guest/managed_agent/dispatch.rs b/nvx-rs/src/guest/managed_agent/dispatch.rs index b94c61b97..53a071d65 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch.rs @@ -4,8 +4,8 @@ use crate::guest::init::Runtime; use crate::guest::mappings::{Mapper, MappingTable, SourceRoot}; use crate::guest::sys::{self, Errno, FileDescriptor}; use crate::protocol::{ - self, APP_CANCEL, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, APP_READY, APP_STOP, APP_STOPPED, - GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, + self, APP_CANCEL, APP_CAPTURE, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, APP_READY, APP_STOP, + APP_STOPPED, GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, }; use super::diagnostics; @@ -52,13 +52,43 @@ impl AgentConfig { runtime: &mut Runtime, control: FileDescriptor, (table, mapper, export): (MappingTable, Mapper, Option), - ) -> Result<(), String> { - let mut system = RealSystem::new(Paths::default()); - let mut session = Session::with_runtime(control, runtime)?; - let mut mappings = HostMappings::layered(table, mapper, export) + ) -> Result { + let mappings = HostMappings::layered(table, mapper, export) .map_err(|error| format!("invalid layered mappings: {}", error.desc()))?; + self.serve_init(runtime, control, mappings, false) + } + + /// The Fresh loop has no root, mapper, account resolution or workload admission. + pub(crate) fn serve_capture( + runtime: &mut Runtime, + control: FileDescriptor, + ) -> Result { + let config = Self { + rootfs: c"-".to_owned(), + hostname: c"".to_owned(), + uid: c"0".to_owned(), + gid: c"0".to_owned(), + user: c"".to_owned(), + home: c"".to_owned(), + direct: true, + entry: None, + }; + config.serve_init(runtime, control, HostMappings::default(), true) + } + + /// Borrows the one init runtime for either boot entry and returns its actual exit status. + fn serve_init( + &self, + runtime: &mut Runtime, + control: FileDescriptor, + mut mappings: HostMappings, + fresh: bool, + ) -> Result { + let capture = runtime.capture(fresh); + let mut system = RealSystem::new(Paths::default()); + let mut session = Session::with_runtime(control, runtime)?.with_capture(capture); run(&mut system, &mut session, self, &mut mappings) - .map(|Outcome::Stopped| ()) + .map(|Outcome::Stopped| session.capture_status().unwrap_or_default()) .map_err(|error| { session .runtime_failure() @@ -67,6 +97,11 @@ impl AgentConfig { } } +/// Preserves the existing receive-stage diagnostic for a restored capture's RESET. +pub(crate) fn report_failure_for_capture(failure: ReceiveFailure) -> Errno { + report_failure(&mut RealSystem::new(Paths::default()), failure) +} + /// Terminal outcome of the long-lived dispatch loop. #[derive(Debug)] pub(crate) enum Outcome { @@ -164,7 +199,20 @@ fn handle_data( return Err(failure.into_error()); } }; + if session.rootless() && !matches!(request.kind, APP_CAPTURE | APP_STOP) { + session.send_error( + request.request_id, + Errno::ENOTSUP as i32, + "unsupported-operation", + )?; + return Ok(None); + } match request.kind { + APP_CAPTURE => { + if session.queue_capture(request, false)? { + return session.finish_capture(); + } + } APP_PING => { if request.payload.is_empty() { session @@ -198,6 +246,9 @@ fn handle_data( } }; exec::run(system, session, config, request.request_id, &execution)?; + if session.capture_pending() { + return session.finish_capture(); + } } APP_CANCEL => { // The targeted workload has already reported its outcome. diff --git a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs index b16ead8d7..9efb42f47 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs @@ -16,6 +16,7 @@ use crate::protocol::{ use super::super::seam::Paths; use super::super::seam::tests::{TestSystem, attach_calls}; use super::*; +use crate::guest::init::Capture; use crate::guest::mappings::Access; use crate::guest::sys::tests::{ CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_SETGID, CAP_SETUID, holds_capability, @@ -73,11 +74,23 @@ impl Harness { Self::spawn_with(system, config, mappings) } - fn spawn_with(mut system: TestSystem, config: AgentConfig, mut mappings: HostMappings) -> Self { + fn spawn_with(system: TestSystem, config: AgentConfig, mappings: HostMappings) -> Self { + Self::spawn_with_capture(system, config, mappings, None) + } + + fn spawn_with_capture( + mut system: TestSystem, + config: AgentConfig, + mut mappings: HostMappings, + capture: Option, + ) -> Self { let (host, guest) = UnixStream::pair().unwrap(); let descriptor = FileDescriptor::from_owned(guest.into()); let thread = thread::spawn(move || { let mut session = Session::new(descriptor); + if let Some(capture) = capture { + session = session.with_capture(capture); + } let outcome = run(&mut system, &mut session, &config, &mut mappings); (outcome, system) }); @@ -313,6 +326,48 @@ fn agent_advertises_the_control_features_of_its_mode_and_stops() { } } +#[test] +fn fresh_session_refuses_root_requests_and_routes_capture_before_stopping() { + let config = AgentConfig::for_test(None); + let mappings = HostMappings::announced_for(&config, 0); + let mut harness = Harness::spawn_with_capture( + TestSystem::new(Paths::default()), + config, + mappings, + Some(Capture::fixture(true)), + ); + harness.reset(1, 7); + for (kind, payload) in [ + (APP_EXEC, exec_payload(None)), + (APP_MAPS, maps_payload(0, &[], None)), + (APP_PING, Vec::new()), + ] { + assert_eq!( + harness.request(kind, &payload), + response( + APP_ERROR, + 42, + Errno::ENOTSUP as i32, + b"unsupported-operation" + ) + ); + } + assert_eq!( + harness.request(APP_CAPTURE, &[0]), + response(APP_ERROR, 42, Errno::EPERM as i32, b"capture-failed") + ); + harness.host.set_nonblocking(true).unwrap(); + assert_eq!( + harness.host.read(&mut [0]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + harness.host.set_nonblocking(false).unwrap(); + let system = harness.stop(); + assert_eq!(system.spawn_calls, 0); + assert!(system.kernel.calls().is_empty()); + assert!(system.diagnostics.is_empty()); +} + #[test] fn agent_refuses_feature_payloads_and_unannounced_mapping_tables() { let mut harness = Harness::new(true); @@ -2119,6 +2174,83 @@ fn a_running_workload_refuses_other_requests_and_ends_when_cancelled_or_late() - Ok(()) } +#[test] +fn rooted_capture_refuses_both_kill_flags_and_leaves_the_exec_group_running() -> TestResult { + if super::super::tests::isolate_process_test( + "NVX_ROOTED_CAPTURE_REFUSAL_TEST_CHILD", + "guest::managed_agent::dispatch::tests::rooted_capture_refuses_both_kill_flags_and_leaves_the_exec_group_running", + ) { + return Ok(()); + } + let _process = super::super::tests::PROCESS_TEST_LOCK.lock()?; + let (_temporary, mut system) = TestSystem::with_direct(); + system.direct_starter = Some(super::super::seam::tests::start_as_privileged); + let config = AgentConfig::for_test(None); + let mappings = HostMappings::announced_for(&config, 0); + let mut harness = + Harness::spawn_with_capture(system, config, mappings, Some(Capture::fixture(false))); + harness.reset(1, 7); + harness.send( + 42, + APP_EXEC, + &workload_payload(&["/bin/sh", "-c", "sleep 30 & echo \"$$ $!\"; wait"], None)?, + ); + let mut output = Vec::new(); + while !output.ends_with(b"\n") { + let reply = harness.next_response(); + assert_eq!( + (reply.kind, reply.request_id, reply.status), + (APP_STDOUT, 42, 0) + ); + output.extend_from_slice(&reply.payload); + } + let group: Vec<_> = std::str::from_utf8(&output)? + .split_whitespace() + .map(|id| { + id.parse::() + .map(|id| sys::ProcessId::from_positive(id).unwrap()) + }) + .collect::>()?; + assert_eq!(group.len(), 2); + for (request_id, kill) in [(43, 0), (44, 1)] { + assert_eq!( + harness.request_all(request_id, APP_CAPTURE, &[kill]), + vec![response( + APP_ERROR, + request_id, + Errno::ENOTSUP as i32, + b"unsupported-operation" + )] + ); + let live = sys::ProcessId::live_userspace()?; + assert!(group.iter().all(|process| live.contains(process))); + harness.host.set_nonblocking(true)?; + assert_eq!( + harness.host.read(&mut [0]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + harness.host.set_nonblocking(false)?; + } + assert_eq!( + harness.request_all(42, APP_CANCEL, &[]), + vec![response(APP_EXIT, 42, 137, b"cancelled")] + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + let live = sys::ProcessId::live_userspace()?; + if group.iter().all(|process| !live.contains(process)) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the cancelled process group did not exit" + ); + thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(harness.stop().diagnostics.is_empty()); + Ok(()) +} + #[test] fn refused_records_report_the_stage_at_which_the_c_agent_reported_them() { let line = diff --git a/nvx-rs/src/guest/managed_agent/exec.rs b/nvx-rs/src/guest/managed_agent/exec.rs index 69be440e1..4ebb5fa57 100644 --- a/nvx-rs/src/guest/managed_agent/exec.rs +++ b/nvx-rs/src/guest/managed_agent/exec.rs @@ -8,8 +8,8 @@ use crate::guest::process::Child; use crate::guest::spawn::{self as sandbox_spawn, Barrier}; use crate::guest::sys::{self, Errno, FileDescriptor, ProcessId, c_string}; use crate::protocol::{ - self, APP_CANCEL, APP_EXIT, APP_STDERR, APP_STDOUT, BUSY, CWD_FAILED, GuestExecPayload, - LAUNCH_FAILED, OUTER_DATA, + self, APP_CANCEL, APP_CAPTURE, APP_EXIT, APP_STDERR, APP_STDOUT, BUSY, CWD_FAILED, + GuestExecPayload, LAUNCH_FAILED, OUTER_DATA, }; use super::AgentConfig; @@ -96,6 +96,12 @@ pub(crate) fn run( workload.cancel(); } Ok(ExecControl::Cancel) => {} + Ok(ExecControl::Capture) => { + workload.terminate(); + session.sweep_capture().inspect_err(|error| { + super::diagnostics::error(system, "exec-capture-sweep", *error as i32); + })?; + } Ok(ExecControl::SessionLost) => { state.session_lost = true; workload.disconnect(); @@ -811,6 +817,8 @@ enum ExecControl { Cancel, /// A reset detached this workload's session. SessionLost, + /// Init accepted a capture that requires group termination and the repeated sweep. + Capture, } /// Processes one control-console record while a workload runs. @@ -846,6 +854,15 @@ fn handle_control( }, ); } + if request.kind == APP_CAPTURE && session.has_capture() { + return session.queue_capture(request, true).map(|accepted| { + if accepted { + ExecControl::Capture + } else { + ExecControl::None + } + }); + } session.send_error(request.request_id, Errno::EBUSY as i32, BUSY)?; Ok(ExecControl::None) } diff --git a/nvx-rs/src/guest/managed_agent/session.rs b/nvx-rs/src/guest/managed_agent/session.rs index 99111a654..2256e29fe 100644 --- a/nvx-rs/src/guest/managed_agent/session.rs +++ b/nvx-rs/src/guest/managed_agent/session.rs @@ -2,8 +2,9 @@ use std::cell::{Cell, RefCell}; use std::io::{self, Read, Write}; use std::process::ExitStatus; -use crate::guest::init::Runtime; +use crate::guest::init::{self, Capture, CaptureStep, Runtime}; use crate::guest::nvxs::{GuestConnection, GuestConnectionError}; +use crate::guest::snapshot::{Captured, Disposition}; use crate::guest::sys::{self, Errno, FileDescriptor, Pollable}; use crate::protocol::nvxs::{GuestEvent, RecordError, RecordType, SessionError}; use crate::protocol::{self, APP_ERROR, OUTER_MAX_PAYLOAD, OUTER_RECEIVE_WINDOW}; @@ -13,6 +14,8 @@ use crate::protocol::{self, APP_ERROR, OUTER_MAX_PAYLOAD, OUTER_RECEIVE_WINDOW}; pub(crate) struct Session<'r> { /// The guest's NVXS connection on the owned control-console descriptor. connection: GuestConnection>, + /// Present only for nvx-init, never the old managed agent. + capture: Option, /// Error of the connection's latest failure, which a connection that a failure ended reports /// again. last_error: Option, @@ -204,10 +207,141 @@ impl<'r> Session<'r> { status: Cell::new(None), outcome: RefCell::new(Ok(false)), }), + capture: None, last_error: None, } } + /// Enables only init's capture path on this existing session. + pub(crate) fn with_capture(mut self, capture: Capture) -> Self { + self.capture = Some(capture); + self + } + + /// Whether the serving caller is init rather than the old agent. + pub(crate) fn has_capture(&self) -> bool { + self.capture.is_some() + } + + /// Whether root-dependent requests must remain refused. + pub(crate) fn rootless(&self) -> bool { + self.capture.as_ref().is_some_and(Capture::rootless) + } + + /// Whether execution cleanup must return the accepted capture request. + pub(crate) fn capture_pending(&self) -> bool { + self.capture.as_ref().is_some_and(Capture::pending) + } + + /// The fatal capture status that the PID-1 caller must use. + pub(crate) fn capture_status(&self) -> Option { + self.capture.as_ref().and_then(Capture::shutdown_status) + } + + /// Refuses rooted callers before validating the flag or reserving workload cleanup. + pub(crate) fn queue_capture( + &mut self, + request: protocol::AppFrame<'_>, + active: bool, + ) -> Result { + if !self.rootless() { + self.send_error( + request.request_id, + Errno::ENOTSUP as i32, + "unsupported-operation", + )?; + return Ok(false); + } + let kill = match protocol::decode_capture_payload(request.payload) { + Ok(kill) => kill, + Err(_) => { + self.send_error( + request.request_id, + Errno::EINVAL as i32, + protocol::INVALID_REQUEST, + )?; + return Ok(false); + } + }; + if (active && !kill) + || !self + .capture + .as_mut() + .expect("init owns capture") + .queue(request.request_id, kill) + { + self.send_error(request.request_id, Errno::EBUSY as i32, protocol::BUSY)?; + return Ok(false); + } + Ok(true) + } + + /// Calls init's existing repeated sweep, never a host-side process sweep. + pub(crate) fn sweep_capture(&self) -> Result<(), Errno> { + self.capture.as_ref().ok_or(Errno::EPERM)?.sweep() + } + + /// Acknowledges, captures synchronously, and handles the result on this connection. + pub(crate) fn finish_capture(&mut self) -> Result, Errno> { + self.finish_capture_with(|capture, session| { + capture.perform(|request_id| session.send_app(protocol::APP_READY, request_id, 0, &[])) + }) + } + + /// Keeps capture state across the operation and the existing session's RESET. + fn finish_capture_with( + &mut self, + operation: impl FnOnce(&mut Capture, &mut Self) -> Result, Errno>, + ) -> Result, Errno> { + let Some(mut capture) = self.capture.take() else { + return Ok(None); + }; + let result = (|| { + match operation(&mut capture, self)? { + None => {} + Some(CaptureStep::Refused { request_id, error }) => { + init::report_capture(error); + let category = if error == Errno::EBUSY { + protocol::BUSY + } else { + "capture-failed" + }; + self.send_error(request_id, error as i32, category)?; + } + Some(CaptureStep::Returned { request_id, result }) => match result { + Ok(Captured::Continued) => { + self.send_error(request_id, Errno::EAGAIN as i32, "capture-continued")?; + } + Ok(Captured::Restored(repaired)) => { + capture.restored(repaired); + match self.next_event() { + Ok(Event::Reset) => self.acknowledge()?, + Ok(Event::Data(_)) => return Err(Errno::EPROTO), + Err(failure) => { + return Err(super::dispatch::report_failure_for_capture(failure)); + } + } + } + Err(failure) => { + init::report_capture(&failure); + match failure.disposition { + Disposition::Resumed => { + self.send_error(request_id, Errno::EIO as i32, "capture-failed")?; + } + Disposition::MustEnd { status } => { + capture.must_end(status); + return Ok(Some(super::dispatch::Outcome::Stopped)); + } + } + } + }, + } + Ok(None) + })(); + self.capture = Some(capture); + result + } + /// Borrows init's sole collector for the lifetime of this control session. pub(crate) fn with_runtime( descriptor: FileDescriptor, @@ -282,7 +416,12 @@ impl<'r> Session<'r> { /// Blocks until the broker's next record completes, and returns what the session accepted. pub(crate) fn next_event(&mut self) -> Result { match self.connection.next_event() { - Ok(GuestEvent::Reset(_)) => Ok(Event::Reset), + Ok(GuestEvent::Reset(_)) => { + if let Some(capture) = &mut self.capture { + capture.reset(); + } + Ok(Event::Reset) + } Ok(GuestEvent::Data(payload)) => Ok(Event::Data(payload)), Err(error) => Err(self.receive_failure(error)), } @@ -449,3 +588,6 @@ fn data_failure(kind: DataFailureKind, status: i32) -> DataFailure { error: Errno::EPROTO, } } + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/guest/managed_agent/session/tests.rs b/nvx-rs/src/guest/managed_agent/session/tests.rs new file mode 100644 index 000000000..c91d12028 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/session/tests.rs @@ -0,0 +1,313 @@ +use std::os::unix::net::UnixStream; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use super::*; +use crate::guest::snapshot::{CaptureCause, CaptureFailure, CaptureStage}; +use crate::protocol::{ + APP_CAPTURE, APP_READY, OUTER_ACK, OUTER_CREDIT, OUTER_DATA, OUTER_GUEST_ATTACH, + OUTER_HEADER_LEN, OUTER_RESET, OuterHeader, decode_app, decode_outer_header, encode_outer, +}; + +const INSTANCE: [u8; 16] = [7; 16]; + +struct Peer { + stream: UnixStream, + epoch: u64, + host_sequence: u64, + guest_sequence: u64, +} + +impl Peer { + fn read(&mut self) -> (OuterHeader, Vec) { + let mut bytes = [0; OUTER_HEADER_LEN]; + self.stream.read_exact(&mut bytes).unwrap(); + let header = decode_outer_header(&bytes).unwrap(); + let mut payload = vec![0; header.length]; + self.stream.read_exact(&mut payload).unwrap(); + assert_eq!(header.sequence, self.guest_sequence); + self.guest_sequence += 1; + (header, payload) + } + + fn app(&mut self, kind: u8, id: u64, status: i32, payload: &[u8]) { + let (header, bytes) = self.read(); + assert_eq!(header.record_type, OUTER_DATA); + assert_eq!(header.epoch, self.epoch); + let frame = decode_app(&bytes).unwrap(); + assert_eq!( + (frame.kind, frame.request_id, frame.status, frame.payload), + (kind, id, status, payload) + ); + } + + fn send(&mut self, kind: u8, id: u64, payload: &[u8]) { + let bytes = protocol::encode_app(kind, id, 0, payload); + self.stream + .write_all( + &encode_outer( + OUTER_DATA, + &INSTANCE, + self.epoch, + self.host_sequence, + &bytes, + ) + .unwrap(), + ) + .unwrap(); + self.host_sequence += 1; + } + + fn credit(&mut self) { + let (header, _) = self.read(); + assert_eq!(header.record_type, OUTER_CREDIT); + assert_eq!(header.epoch, self.epoch); + } +} + +fn connected(fresh: bool) -> (Session<'static>, Peer) { + let (host, guest) = UnixStream::pair().unwrap(); + let mut session = Session::new(FileDescriptor::from_owned(guest.into())) + .with_capture(Capture::fixture(fresh)); + let mut peer = Peer { + stream: host, + epoch: 1, + host_sequence: 8, + guest_sequence: 0, + }; + session.send_attach().unwrap(); + let (header, _) = peer.read(); + assert_eq!(header.record_type, OUTER_GUEST_ATTACH); + peer.stream + .write_all(&encode_outer(OUTER_RESET, &INSTANCE, 1, 7, &[]).unwrap()) + .unwrap(); + assert!(matches!( + session + .next_event() + .unwrap_or_else(|_| panic!("valid initial RESET")), + Event::Reset + )); + session.acknowledge().unwrap(); + peer.guest_sequence = 0; + let (header, _) = peer.read(); + assert_eq!(header.record_type, OUTER_ACK); + (session, peer) +} + +fn queue( + session: &mut Session<'_>, + peer: &mut Peer, + id: u64, + payload: &[u8], + active: bool, +) -> bool { + peer.send(APP_CAPTURE, id, payload); + let Event::Data(bytes) = session + .next_event() + .unwrap_or_else(|_| panic!("valid request DATA")) + else { + panic!("the capture request is DATA"); + }; + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid frame")); + let accepted = session.queue_capture(request, active).unwrap(); + peer.credit(); + accepted +} + +#[test] +fn capture_refuses_active_execution_without_kill_and_validates_the_existing_flag() { + for (payload, status, category) in [ + (b"\0".as_slice(), Errno::EBUSY, protocol::BUSY), + (b"\x02".as_slice(), Errno::EINVAL, protocol::INVALID_REQUEST), + (b"".as_slice(), Errno::EINVAL, protocol::INVALID_REQUEST), + ] { + let (mut session, mut peer) = connected(true); + assert!(!queue(&mut session, &mut peer, 31, payload, true)); + assert!(!session.capture_pending()); + peer.app(APP_ERROR, 31, status as i32, category.as_bytes()); + } + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 32, &[1], true)); + assert!(session.capture_pending()); +} + +#[test] +fn rooted_capture_refuses_both_kill_flags_without_acknowledgement_or_pending_cleanup() { + for active in [false, true] { + for kill in [0, 1] { + let (mut session, mut peer) = connected(false); + assert!(!queue(&mut session, &mut peer, 37, &[kill], active)); + assert!(!session.capture_pending()); + assert!(session.finish_capture().unwrap().is_none()); + peer.app( + APP_ERROR, + 37, + Errno::ENOTSUP as i32, + b"unsupported-operation", + ); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } + } +} + +#[test] +fn capture_acknowledges_then_sends_nothing_until_return_and_reports_on_the_same_session() { + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 33, &[0], false)); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let result = session.finish_capture_with(|capture, session| { + capture.perform_with( + |_| Ok(()), + |id| session.send_app(APP_READY, id, 0, &[]), + |_, _, _| { + entered_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + Ok(Captured::Continued) + }, + ) + }); + (session, result) + }); + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + peer.app(APP_READY, 33, 0, &[]); + peer.stream + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let mut byte = [0]; + let error = peer.stream.read(&mut byte).unwrap_err(); + assert!(matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + )); + peer.stream.set_read_timeout(None).unwrap(); + release_tx.send(()).unwrap(); + peer.app(APP_ERROR, 33, Errno::EAGAIN as i32, b"capture-continued"); + let (session, result) = worker.join().unwrap(); + assert!(result.unwrap().is_none()); + assert!(session.capture_status().is_none()); +} + +#[test] +fn resumed_capture_reports_on_the_same_epoch_without_attaching_again() { + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 34, &[0], false)); + assert!( + session + .finish_capture_with(|capture, session| { + capture.perform_with( + |_| Ok(()), + |id| session.send_app(APP_READY, id, 0, &[]), + |_, _, _| { + Err(CaptureFailure { + stage: CaptureStage::Mounts, + cause: CaptureCause::Io(io::Error::other("capture preparation failed")), + disposition: Disposition::Resumed, + }) + }, + ) + }) + .unwrap() + .is_none() + ); + peer.app(APP_READY, 34, 0, &[]); + peer.app(APP_ERROR, 34, Errno::EIO as i32, b"capture-failed"); + assert!(session.capture_status().is_none()); +} + +#[test] +fn restored_capture_consumes_reset_and_acknowledges_the_existing_connection() { + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 35, &[0], false)); + let worker = thread::spawn(move || { + let result = session.finish_capture_with(|capture, session| { + capture.perform_with( + |_| Ok(()), + |id| session.send_app(APP_READY, id, 0, &[]), + |_, _, _| { + Ok(Captured::Restored( + crate::guest::snapshot::repair::tests::repaired_fixture(), + )) + }, + ) + }); + (session, result) + }); + peer.app(APP_READY, 35, 0, &[]); + peer.stream + .write_all(&encode_outer(OUTER_RESET, &[8; 16], 2, 4, &[]).unwrap()) + .unwrap(); + peer.epoch = 2; + peer.guest_sequence = 0; + let (header, payload) = peer.read(); + assert_eq!((header.record_type, header.epoch), (OUTER_ACK, 2)); + assert_eq!( + protocol::decode_ack_payload(&payload).unwrap(), + OUTER_RECEIVE_WINDOW + ); + let (mut session, result) = worker.join().unwrap(); + assert!(result.unwrap().is_none()); + assert!(session.rootless()); + assert!(session.capture_status().is_none()); + assert_eq!(session.connection.session().identity().unwrap().epoch, 2); + let bytes = protocol::encode_app(APP_CAPTURE, 38, 0, &[0]); + assert!( + !session + .queue_capture(decode_app(&bytes).unwrap(), false) + .unwrap() + ); + assert!(!session.capture_pending()); + peer.app( + APP_ERROR, + 38, + Errno::EBUSY as i32, + protocol::BUSY.as_bytes(), + ); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); +} + +#[test] +fn must_end_returns_the_exact_status_without_a_report_or_more_control_work() { + for status in [0, 41, 194] { + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 36, &[0], false)); + let returned = session + .finish_capture_with(|capture, session| { + capture.perform_with( + |_| Ok(()), + |id| session.send_app(APP_READY, id, 0, &[]), + |_, _, _| { + Err(CaptureFailure { + stage: CaptureStage::Capture, + cause: CaptureCause::Io(io::Error::other("capture must end")), + disposition: Disposition::MustEnd { status }, + }) + }, + ) + }) + .unwrap(); + assert!(matches!( + returned, + Some(super::super::dispatch::Outcome::Stopped) + )); + assert_eq!(session.capture_status(), Some(status)); + peer.app(APP_READY, 36, 0, &[]); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } +} diff --git a/nvx-rs/src/guest/snapshot/repair.rs b/nvx-rs/src/guest/snapshot/repair.rs index 83aba2e5a..4c6b51fbd 100644 --- a/nvx-rs/src/guest/snapshot/repair.rs +++ b/nvx-rs/src/guest/snapshot/repair.rs @@ -2,7 +2,7 @@ //! process: [`Repair`], and [`repair`], which runs one once, as the [module](super) describes. #[cfg(test)] -mod tests; +pub(crate) mod tests; use std::collections::BTreeMap; use std::fmt; diff --git a/nvx-rs/src/guest/snapshot/repair/tests.rs b/nvx-rs/src/guest/snapshot/repair/tests.rs index 8b76d1f11..e63a09212 100644 --- a/nvx-rs/src/guest/snapshot/repair/tests.rs +++ b/nvx-rs/src/guest/snapshot/repair/tests.rs @@ -269,6 +269,11 @@ fn capture_repaired(restore: Restore, system: impl RepairSystem + 'static) -> Re repaired } +/// Returns genuine repaired data for the capture caller's outcome/session regression. +pub(crate) fn repaired_fixture() -> Repaired { + capture_repaired(restore(), FakeSystem::new(&Calls::default())) +} + /// Returns the calls of a repair once its memory target is active: the listing of the memory /// blocks, whose online ones it counts, the reseed, the drop of the seed, which zeroed it before /// the finish, and the finish, with the acknowledgement. diff --git a/nvx-rs/src/protocol/app.rs b/nvx-rs/src/protocol/app.rs index 824c19d1d..c6645486f 100644 --- a/nvx-rs/src/protocol/app.rs +++ b/nvx-rs/src/protocol/app.rs @@ -52,7 +52,11 @@ pub const APP_FEATURES: u8 = 5; pub const APP_MAPS: u8 = 6; /// Request that nvx-init capture the VM after optionally killing its executions. #[cfg_attr( - not(any(test, feature = "host")), + not(any( + test, + feature = "host", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect(dead_code, reason = "SL7.7a makes nvx-init handle capture requests") )] pub(crate) const APP_CAPTURE: u8 = 7; @@ -160,7 +164,10 @@ pub(crate) fn encode_capture_payload(kill_executions: bool) -> [u8; 1] { /// Decodes the capture request's sole kill-executions flag. #[cfg_attr( - not(test), + not(any( + test, + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect(dead_code, reason = "SL7.7a makes nvx-init handle capture requests") )] pub(crate) fn decode_capture_payload(payload: &[u8]) -> Result { diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 65df05c17..907ff555c 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -56,6 +56,13 @@ pub mod status; mod tests; pub mod view; +#[cfg(any( + feature = "host", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use app::APP_CAPTURE; +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] +pub(crate) use app::decode_capture_payload; pub use app::{ APP_CANCEL, APP_ERROR, APP_EXEC, APP_EXIT, APP_FEATURES, APP_HEADER_LEN, APP_MAGIC, APP_MAPS, APP_PING, APP_READY, APP_STDERR, APP_STDOUT, APP_STOP, APP_STOPPED, APP_VERSION, AppFrame, @@ -63,7 +70,7 @@ pub use app::{ MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, decode_app, encode_app, }; #[cfg(feature = "host")] -pub(crate) use app::{APP_CAPTURE, encode_capture_payload, workload}; +pub(crate) use app::{encode_capture_payload, workload}; pub use boot::{MAPS_TOKEN, WORKLOAD_ACCOUNT_TOKEN, parse_host_mapping_count}; pub use exec::{ EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_EXTENDED, EXEC_INHERIT_DEFAULT_ENV, diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 5b7e6aa10..045dc828c 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -5,7 +5,7 @@ "nvx-rs/src/guest/cgroup.rs": 2, "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, - "nvx-rs/src/guest/init.rs": 7, + "nvx-rs/src/guest/init.rs": 6, "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, "nvx-rs/src/guest/sys/entry.rs": 3, From dc669fae0b56615a17650c6e46849f85be474275 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 02:29:08 -0800 Subject: [PATCH 04/25] tests: keep adapter sandbox names short below data roots Use the existing PID and atomic create-directory retry to retain unique fixture directories without a long diagnostic prefix. Keep the scenario name in creation errors. Capture and restore control sockets now fit approved data-volume temp roots without changing production limits, assertions, timeouts or skipped tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- tests/runtime-adapter/tests/common/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/runtime-adapter/tests/common/mod.rs b/tests/runtime-adapter/tests/common/mod.rs index 3c3540923..797d3044a 100644 --- a/tests/runtime-adapter/tests/common/mod.rs +++ b/tests/runtime-adapter/tests/common/mod.rs @@ -154,11 +154,11 @@ impl Sandbox { pub(crate) fn new(name: &str) -> Self { let pid = std::process::id(); let root = (0..) - .map(|attempt| env::temp_dir().join(format!("runtime-adapter-{name}-{pid}-{attempt}"))) + .map(|attempt| env::temp_dir().join(format!("ra-{pid}-{attempt}"))) .find(|root| match fs::create_dir(root) { Ok(()) => true, Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false, - Err(error) => panic!("cannot create {}: {error}", root.display()), + Err(error) => panic!("cannot create the {name} sandbox {}: {error}", root.display()), }) .expect("a directory name is free"); Self { From adeb1d4778229fd23b64c2be6cd762df352de382 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 02:39:32 -0800 Subject: [PATCH 05/25] tests: format the compact sandbox creation diagnostic Apply the pinned rustfmt wrapping requested by the final-head review; fixture semantics and production source remain unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- tests/runtime-adapter/tests/common/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/runtime-adapter/tests/common/mod.rs b/tests/runtime-adapter/tests/common/mod.rs index 797d3044a..dc5e0f914 100644 --- a/tests/runtime-adapter/tests/common/mod.rs +++ b/tests/runtime-adapter/tests/common/mod.rs @@ -158,7 +158,10 @@ impl Sandbox { .find(|root| match fs::create_dir(root) { Ok(()) => true, Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false, - Err(error) => panic!("cannot create the {name} sandbox {}: {error}", root.display()), + Err(error) => panic!( + "cannot create the {name} sandbox {}: {error}", + root.display() + ), }) .expect("a directory name is free"); Self { From 13bbbc81c983c0bdfc66ddd149b146c05165ec29 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 02:53:28 -0800 Subject: [PATCH 06/25] host: verify CLI launch liveness from its log and endpoint Port only SL3.3a onto coordinator-qualified SL3.2b 99d00457. Retain the prepared log claim and identity, hold and recheck the endpoint owner, and require the Linux log match. Preserve Windows job/handle fallback primitives and the donor fork-race fix without adding recovery or signaling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/liveness.rs | 81 ++++++ nvx-rs/src/host/cli/store/mod.rs | 4 + nvx-rs/src/host/cli/store/record.rs | 8 + nvx-rs/src/host/cli/store/tests.rs | 1 + nvx-rs/src/host/cli/store/tests/launch.rs | 5 +- nvx-rs/src/host/cli/store/tests/liveness.rs | 269 ++++++++++++++++++++ 6 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 nvx-rs/src/host/cli/store/liveness.rs create mode 100644 nvx-rs/src/host/cli/store/tests/liveness.rs diff --git a/nvx-rs/src/host/cli/store/liveness.rs b/nvx-rs/src/host/cli/store/liveness.rs new file mode 100644 index 000000000..90af6ef29 --- /dev/null +++ b/nvx-rs/src/host/cli/store/liveness.rs @@ -0,0 +1,81 @@ +//! Bounded launch proofs, without recovery, record removal, or process termination. + +use std::io; + +use super::StateFile; +use super::locked::LockedState; +use super::record::LaunchJournal; +use crate::host::platform; +use crate::host::process::{BootId, Process, ProcessRef}; +use crate::{Error, Result}; + +impl LockedState { + /// Returns whether a launch process may still run. Only a released claim proves exit; + /// failure to examine it proves neither, and preserves the original error. + pub(crate) fn claim_held(&self, journal: &LaunchJournal) -> Result { + let path = self.path(StateFile::Log); + if journal.vm_dir != self.dir() || journal.log != path { + return Err(Error::io( + "the launch journal belongs to another sandbox directory", + )); + } + platform::log_claim_held(&path, journal.log_identity) + .map_err(|error| Error::io("cannot check the launch log claim").with_source(error)) + } + + /// Holds the server of the recorded endpoint, rechecks its ownership, and on Linux proves + /// that this very process also holds the launch's log. An error is not proof of exit. + pub(crate) fn endpoint_owner(&self, journal: &LaunchJournal) -> Result { + if !self.claim_held(journal)? { + return Err(Error::io("the launch log claim is released")); + } + let process = + hold_endpoint_server(|| platform::endpoint_server_pid(&journal.endpoint), find)?; + let reference = process.reference(); + let matches = + platform::serves_launch(reference.pid, reference.start_time, journal.log_identity) + .map_err(|error| { + Error::io("cannot verify the endpoint owner's log").with_source(error) + })?; + if !matches { + return Err(Error::io( + "the endpoint owner does not hold the launch log as its standard output", + )); + } + Ok(process) + } +} + +/// Opens only the process whose start time was just read, never another that reused its ID. +fn find(pid: u32) -> io::Result> { + let Some(start_time) = platform::process_start_time(pid)? else { + return Ok(None); + }; + Process::open(&ProcessRef { + pid, + start_time, + boot: BootId::current()?, + }) +} + +/// Adapted from the old store's owner check: the second endpoint lookup happens only after +/// the process is held. The callbacks keep replacement races testable without test-only hooks. +pub(super) fn hold_endpoint_server( + mut server_pid: impl FnMut() -> io::Result>, + hold: impl FnOnce(u32) -> io::Result>, +) -> Result { + let pid = server_pid() + .map_err(|error| Error::io("cannot examine the control endpoint").with_source(error))? + .ok_or_else(|| Error::io("no process serves the control endpoint"))?; + let process = hold(pid) + .map_err(|error| Error::io("cannot hold the control endpoint owner").with_source(error))? + .ok_or_else(|| Error::io("the control endpoint owner exited before it could be held"))?; + let again = server_pid() + .map_err(|error| Error::io("cannot recheck the control endpoint").with_source(error))?; + if again != Some(pid) { + return Err(Error::io( + "the control endpoint changed while its owner was held", + )); + } + Ok(process) +} diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index 16bcb2432..ac58708e0 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -30,6 +30,9 @@ //! A locked launch writes its prepared capability and outputs before spawn, records the spawned //! process durably, and publishes the caller's exact saved VM only at commit. Dropping an //! uncommitted launch keeps its journal; these transitions do not recover or signal a process. +//! Its journal also identifies the inherited log claim: held means a launch process may run, +//! and released proves exit. An endpoint owner is held and rechecked before it is returned; +//! Linux additionally verifies that this process holds that launch's log. #![cfg_attr( not(test), expect( @@ -40,6 +43,7 @@ mod files; mod launch; +mod liveness; mod locked; mod names; mod record; diff --git a/nvx-rs/src/host/cli/store/record.rs b/nvx-rs/src/host/cli/store/record.rs index d84718801..fa85fdf18 100644 --- a/nvx-rs/src/host/cli/store/record.rs +++ b/nvx-rs/src/host/cli/store/record.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use super::StateFile; use super::files; use super::locked::LockedState; +use crate::host::platform; use crate::host::process::ProcessRef; use crate::host::runtime::RuntimeIdentity; use crate::host::vm::{Prepared, SavedVm, VmDir, VmOutputs}; @@ -21,6 +22,7 @@ pub(crate) struct LaunchJournal { pub(super) vm_dir: PathBuf, pub(super) capability: [u8; CAPABILITY_LEN], pub(super) log: PathBuf, + pub(super) log_identity: (u64, u64), pub(super) outcome: PathBuf, pub(super) endpoint: String, pub(super) console: Option, @@ -34,6 +36,7 @@ impl fmt::Debug for LaunchJournal { .debug_struct("LaunchJournal") .field("vm_dir", &self.vm_dir) .field("log", &self.log) + .field("log_identity", &self.log_identity) .field("outcome", &self.outcome) .field("endpoint", &self.endpoint) .field("console", &self.console) @@ -64,10 +67,15 @@ impl LaunchJournal { "the prepared launch belongs to another sandbox directory", )); } + platform::claim_log(prepared.log()) + .map_err(|error| Error::io("cannot claim the prepared VM log").with_source(error))?; + let log_identity = platform::opened_file_identity(prepared.log()) + .map_err(|error| Error::io("cannot identify the prepared VM log").with_source(error))?; Ok(Self { vm_dir: vm_dir.path().to_path_buf(), capability: *prepared.capability().as_bytes(), log: log.clone(), + log_identity, outcome: outcome.clone(), endpoint: endpoint.clone(), console: console.clone(), diff --git a/nvx-rs/src/host/cli/store/tests.rs b/nvx-rs/src/host/cli/store/tests.rs index f28a33df9..5db8c5adb 100644 --- a/nvx-rs/src/host/cli/store/tests.rs +++ b/nvx-rs/src/host/cli/store/tests.rs @@ -9,6 +9,7 @@ use crate::host::cli::deadline::Deadline; use crate::host::store::{JOURNAL_NAME, RUNTIME_NAME, SandboxStore}; mod launch; +mod liveness; /// How long a test waits for a lock that it released to be free. /// diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs index aff660e14..5fd38d31a 100644 --- a/nvx-rs/src/host/cli/store/tests/launch.rs +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -11,7 +11,10 @@ use crate::host::vm::{self, Prepared, SavedVm, VmDir}; use crate::hypervisor::Hypervisor; use crate::protocol::CAPABILITY_LEN; -fn prepared(state: &StateDir, console: ConsoleAttachment) -> (Prepared, RuntimeIdentity) { +pub(super) fn prepared( + state: &StateDir, + console: ConsoleAttachment, +) -> (Prepared, RuntimeIdentity) { let identity = |text| ArtifactIdentity::new(text).unwrap(); let runtime = Runtime { openvmm: "missing-openvmm".into(), diff --git a/nvx-rs/src/host/cli/store/tests/liveness.rs b/nvx-rs/src/host/cli/store/tests/liveness.rs new file mode 100644 index 000000000..0ac403e01 --- /dev/null +++ b/nvx-rs/src/host/cli/store/tests/liveness.rs @@ -0,0 +1,269 @@ +use std::error::Error as _; +use std::io; +#[cfg(any(target_os = "linux", windows))] +use std::process::{Child, Command, Stdio}; + +use super::launch::prepared; +use super::*; +use crate::host::cli::store::liveness::hold_endpoint_server; +use crate::host::config::ConsoleAttachment; +use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::spawn::{self, VmmCommand}; + +const HELPER: &str = "host::cli::store::tests::liveness::log_holder_helper"; + +fn own_process() -> Process { + let pid = std::process::id(); + Process::open(&ProcessRef { + pid, + start_time: platform::process_start_time(pid).unwrap().unwrap(), + boot: BootId::current().unwrap(), + }) + .unwrap() + .unwrap() +} + +struct Running(Option); + +impl Drop for Running { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.kill_and_confirm_exit().unwrap(); + } + } +} + +#[cfg(any(target_os = "linux", windows))] +struct Other(Child); + +#[cfg(any(target_os = "linux", windows))] +impl Drop for Other { + fn drop(&mut self) { + self.0.kill().unwrap(); + self.0.wait().unwrap(); + } +} + +#[cfg(any(target_os = "linux", windows))] +fn helper_command(state: &StateDir) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", HELPER, "--ignored", "--nocapture"]) + .current_dir(state.dir()) + .stdin(Stdio::null()); + command +} + +#[test] +fn a_claim_identifies_the_opened_log_and_last_close_proves_exit() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let identity = platform::opened_file_identity(prepared.log()).unwrap(); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + assert_eq!(journal.log_identity, identity); + assert!(locked.claim_held(&journal).unwrap()); + let copy = prepared.log().try_clone().unwrap(); + drop(prepared); + assert!(locked.claim_held(&journal).unwrap()); + drop(copy); + assert!(released(|| !locked.claim_held(&journal).unwrap())); + assert!(locked.journal().unwrap().is_some()); + assert!(locked.capability().unwrap().is_some()); +} + +#[test] +fn replacing_the_log_or_sandbox_never_proves_the_launch_exited() { + let (_directory, state) = state(); + let (_other_directory, other) = super::state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let elsewhere = other.lock(Deadline::after(10.0)).unwrap(); + assert!(elsewhere.claim_held(&journal).is_err()); + drop(prepared); + fs::rename(state.path(StateFile::Log), state.dir().join("old-log")).unwrap(); + fs::write(state.path(StateFile::Log), b"another launch").unwrap(); + let error = locked.claim_held(&journal).unwrap_err(); + assert!(error.source().is_some()); + assert!( + error + .to_string() + .contains("cannot check the launch log claim") + ); + assert!(locked.endpoint_owner(&journal).is_err()); + assert!(locked.journal().unwrap().is_some()); +} + +#[test] +fn the_spawned_process_inherits_the_preparations_claim() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let command = VmmCommand::new( + std::env::current_exe().unwrap(), + ["--exact", HELPER, "--ignored", "--nocapture"] + .map(Into::into) + .to_vec(), + state.dir(), + ); + let child = Running(Some( + spawn::spawn_vm( + &command, + prepared.capability().as_bytes(), + prepared.log().try_clone().unwrap(), + ) + .unwrap(), + )); + drop(prepared); + assert!(released(|| state.dir().join("holder-ready").exists())); + assert!(locked.claim_held(&journal).unwrap()); + #[cfg(target_os = "linux")] + { + let owner = locked.endpoint_owner(&journal).unwrap(); + assert_eq!(owner.reference().pid, child.0.as_ref().unwrap().id()); + assert!(!owner.has_exited().unwrap()); + } + drop(child); + assert!(released(|| !locked.claim_held(&journal).unwrap())); +} + +#[cfg(target_os = "linux")] +#[test] +fn a_conflicting_lock_prevents_publishing_a_launch() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let other = fs::File::open(prepared.outputs().log.clone()).unwrap(); + other.try_lock().unwrap(); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let error = locked.begin_launch(&prepared, &runtime).unwrap_err(); + assert!(error.source().is_some()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_endpoint_owner_without_the_claimed_log_is_rejected_without_signaling_it() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let mut other = Other( + helper_command(&state) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + assert!(released(|| state.dir().join("holder-ready").exists())); + assert!(locked.claim_held(&journal).unwrap()); + let error = locked.endpoint_owner(&journal).err().unwrap(); + assert!(error.to_string().contains("does not hold the launch log")); + assert!(other.0.try_wait().unwrap().is_none()); + drop(prepared); + assert!(released(|| !locked.claim_held(&journal).unwrap())); + assert!(locked.endpoint_owner(&journal).is_err()); + assert!(other.0.try_wait().unwrap().is_none()); +} + +#[cfg(windows)] +#[test] +fn without_a_log_job_inherited_handles_remain_the_claim() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let mut command = helper_command(&state); + command + .stdout(Stdio::from(prepared.log().try_clone().unwrap())) + .stderr(Stdio::from(prepared.log().try_clone().unwrap())); + let child = Other(spawn_locked(&mut command).unwrap()); + drop(command); + drop(prepared); + assert!(released(|| state.dir().join("holder-ready").exists())); + assert!(locked.claim_held(&journal).unwrap()); + drop(child); + assert!(released(|| !locked.claim_held(&journal).unwrap())); +} + +#[test] +fn endpoint_ownership_is_rechecked_only_after_the_server_is_held() { + let pid = std::process::id(); + let held = std::cell::Cell::new(false); + let mut lookups = 0; + let owner = hold_endpoint_server( + || { + lookups += 1; + assert_eq!(held.get(), lookups == 2); + Ok(Some(pid)) + }, + |found| { + assert_eq!(found, pid); + held.set(true); + Ok(Some(own_process())) + }, + ) + .unwrap(); + assert_eq!(owner.reference().pid, pid); + assert_eq!(lookups, 2); + for replacement in [None, Some(pid.wrapping_add(1))] { + let mut lookups = 0; + let error = hold_endpoint_server( + || { + lookups += 1; + Ok(if lookups == 1 { Some(pid) } else { replacement }) + }, + |_| Ok(Some(own_process())), + ) + .err() + .unwrap(); + assert!(error.to_string().contains("endpoint changed")); + assert_eq!(lookups, 2); + } +} + +#[test] +fn missing_owners_and_lookup_or_hold_errors_are_not_exit_proofs() { + assert!(hold_endpoint_server(|| Ok(None), |_| panic!("no owner was found")).is_err()); + assert!(hold_endpoint_server(|| Ok(Some(1)), |_| Ok(None)).is_err()); + for phase in 0..3 { + let mut lookups = 0; + let error = hold_endpoint_server( + || { + lookups += 1; + if (phase == 0 && lookups == 1) || (phase == 2 && lookups == 2) { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(Some(std::process::id())) + } + }, + |_| { + if phase == 1 { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(Some(own_process())) + } + }, + ) + .err() + .unwrap(); + let source = error.source().unwrap().downcast_ref::().unwrap(); + assert_eq!(source.kind(), io::ErrorKind::PermissionDenied); + } +} + +#[test] +#[ignore = "invoked as an owned subprocess by the launch-claim tests"] +fn log_holder_helper() { + #[cfg(target_os = "linux")] + let _listener = std::os::unix::net::UnixListener::bind("control.sock").unwrap(); + fs::write("holder-ready", b"").unwrap(); + thread::sleep(Duration::from_secs(30)); +} From 92ac710d5ed88e0d14c4f36b2a7aafa9f7573f13 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 03:41:53 -0800 Subject: [PATCH 07/25] host: reconcile recorded process opening onto qualified stop Return Running or opaque exit proof after checking the recorded identity. Preserve indeterminate errors and current VM/transport verification policies. Keep the existing exit-handle and recovery tests alongside opening proof cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- nvx-rs/README.md | 20 +++--- nvx-rs/public-api/linux.txt | 5 ++ nvx-rs/public-api/windows.txt | 5 ++ nvx-rs/src/host/process.rs | 35 ++++++---- nvx-rs/src/host/process/tests.rs | 64 ++++++++++++++++++- nvx-rs/src/host/process/tests/linux.rs | 14 ++-- nvx-rs/src/host/restore/tests.rs | 10 +-- nvx-rs/src/host/store/locked.rs | 9 ++- nvx-rs/src/host/store/vmm.rs | 8 ++- nvx-rs/src/host/supervisor/reattach.rs | 16 ++++- .../src/host/template/clone/tests/launch.rs | 32 ++++++---- .../src/host/template/clone/tests/restore.rs | 9 ++- .../src/host/template/store/leases/tests.rs | 12 ++-- nvx-rs/src/host/transport.rs | 9 ++- nvx-rs/src/host/transport/tests.rs | 11 ++-- nvx-rs/src/host/vm.rs | 17 +++-- nvx-rs/src/host/vm/tests.rs | 18 ++++-- nvx-rs/tests/cli.rs | 19 +++--- nvx-rs/tests/cli/lifecycle.rs | 13 ++-- nvx-rs/tests/fake_openvmm.rs | 20 +++--- nvx-rs/tests/store.rs | 10 +-- nvx-rs/tests/supervisor.rs | 23 ++++--- nvx-rs/tests/template_capture.rs | 4 +- tests/runtime-adapter/tests/common/mod.rs | 7 +- tests/runtime-adapter/tests/vm_clone.rs | 9 +-- tests/runtime-adapter/tests/vm_steps.rs | 8 +-- 26 files changed, 286 insertions(+), 121 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 3019c6ebc..f9e4f884a 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -970,13 +970,15 @@ compatibility wrapper. Verified process references. A `ProcessRef` names one process by its ID, its start time, and the `BootId` of the host boot during which it started. -`Process::open` checks a reference and holds its process through a pidfd on -Linux or a process handle on Windows, whether or not the caller started it, so -that waits sleep until the process exits and termination never reaches a process -that reuses its ID. A reference from an earlier boot names an exited process, -and a process whose identity this user cannot check, such as another user's, -makes `Process::open` fail rather than report an exit. Linux hosts without -pidfds poll the reference instead, and cannot terminate the process. +`Process::open` returns `Opened::Running(Process)` only after checking the +recorded boot, ID, and start time. It holds the process through a pidfd on +Linux or a process handle on Windows, whether or not the caller started it, +so that waits and termination never reach a process that reuses its ID. +`Opened::Exited(Exited)` proves that the recorded boot ended or no running +process matches both its ID and start time. A process whose identity this +user cannot check makes `Process::open` fail rather than report an exit. +Linux hosts without pidfds poll the reference instead, and cannot terminate +the process. On Linux and Windows, `Process::exit_handle()` returns `Result>`: an independently owned duplicate of the pidfd @@ -988,8 +990,8 @@ Duplication failures have kind `Io` and retain their original I/O source. No watcher thread, future or callback is created by the library. `Process::wait(deadline)` returns `Some(Exited)` only after verified exit, or -`None` when the deadline passes without confirmation. `Exited` cannot be made -by a caller. +`None` when the deadline passes without confirmation. `Exited` proves exit, +not an exit status, and cannot be made by a caller. `Process::bytes_read` reports how many bytes the process has read through the operating system's read calls, whether the host's cache served them or not: diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 2d90a5759..edc5c8d46 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -2448,6 +2448,11 @@ method nvx::host::process::BootId::current method nvx::host::process::BootId::from_bytes type nvx::host::process::ExitHandle struct nvx::host::process::Exited +enum nvx::host::process::Opened +variant nvx::host::process::Opened::Exited +field nvx::host::process::Opened::Exited::0 +variant nvx::host::process::Opened::Running +field nvx::host::process::Opened::Running::0 struct nvx::host::process::Process method nvx::host::process::Process::bytes_read method nvx::host::process::Process::exit_handle diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index c1e2bc8cf..e45f6cacf 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1034,6 +1034,11 @@ method nvx::host::process::BootId::current method nvx::host::process::BootId::from_bytes type nvx::host::process::ExitHandle struct nvx::host::process::Exited +enum nvx::host::process::Opened +variant nvx::host::process::Opened::Exited +field nvx::host::process::Opened::Exited::0 +variant nvx::host::process::Opened::Running +field nvx::host::process::Opened::Running::0 struct nvx::host::process::Process method nvx::host::process::Process::bytes_read method nvx::host::process::Process::exit_handle diff --git a/nvx-rs/src/host/process.rs b/nvx-rs/src/host/process.rs index 100abe93d..a24ed8776 100644 --- a/nvx-rs/src/host/process.rs +++ b/nvx-rs/src/host/process.rs @@ -2,10 +2,11 @@ //! //! A [`ProcessRef`] names one process by its ID, its start time, and the [`BootId`] of the host //! boot during which it started, which together never name another process. [`Process::open`] -//! checks a reference against the host and holds the process that it names through a handle of -//! the operating system: a pidfd on Linux, and a process handle on Windows. Waits and termination -//! go through that handle, so they reach the referenced process, whether or not the caller -//! started it, and never another process that reuses its ID. A process that does not match its +//! checks a reference against the host and returns [`Opened::Running`] with the process held +//! through a handle of the operating system, or [`Opened::Exited`] with proof that the recorded +//! process exited. The handle is a pidfd on Linux, and a process handle on Windows. Waits and +//! termination go through that handle, so they reach the referenced process, whether or not the +//! caller started it, and never another process that reuses its ID. A process that does not match its //! reference is never signaled, and neither is one that a reference from an earlier boot names: //! every process of that boot has exited. //! @@ -188,14 +189,23 @@ pub struct Process { handle: platform::ProcessHandle, } -/// Proof that a verified process exited, produced only by [`Process::wait`]. +/// The result of checking a recorded process reference against this host. +#[derive(Debug)] +pub enum Opened { + /// The process matched the recorded boot, ID, and start time; the caller owns its `Process`. + Running(Process), + /// The recorded process exited: its boot ended, or no running process matches its identity. + Exited(Exited), +} + +/// Proof that a verified process exited, produced only by [`Process::wait`] or [`Process::open`]. /// /// It proves exit, not the process's exit status, and cannot be constructed by a caller. #[derive(Debug)] pub struct Exited(()); impl Process { - /// Finds the process that `reference` names, and returns `None` if it has exited. + /// Finds the process that `reference` names, or returns proof that it has exited. /// /// The process has exited when no process has its ID, when the process that has it started /// at another time, and when the reference comes from another boot. @@ -207,12 +217,15 @@ impl Process { /// other users. Fails with [`io::ErrorKind::InvalidInput`] for a process ID that the host /// never assigns, such as 0, and with [`io::ErrorKind::Unsupported`] on hosts other than /// Linux and Windows. - pub fn open(reference: &ProcessRef) -> io::Result> { + pub fn open(reference: &ProcessRef) -> io::Result { let handle = open_reference(reference, BootId::current, platform::open_process)?; - Ok(handle.map(|handle| Self { - reference: reference.clone(), - handle, - })) + Ok(match handle { + Some(handle) => Opened::Running(Self { + reference: reference.clone(), + handle, + }), + None => Opened::Exited(Exited(())), + }) } /// Returns the process that `handle` holds, whose identity the caller verified to be diff --git a/nvx-rs/src/host/process/tests.rs b/nvx-rs/src/host/process/tests.rs index a90c83102..85f4b54d8 100644 --- a/nvx-rs/src/host/process/tests.rs +++ b/nvx-rs/src/host/process/tests.rs @@ -182,6 +182,21 @@ fn sleeper() -> std::process::Command { #[cfg(any(target_os = "linux", windows))] struct ExitChild(std::process::Child); +#[cfg(any(target_os = "linux", windows))] +impl ExitChild { + fn spawn() -> Self { + Self(crate::host::tests::spawn_locked(&mut sleeper()).unwrap()) + } + + fn reference(&self) -> ProcessRef { + ProcessRef { + pid: self.0.id(), + start_time: platform::process_start_time(self.0.id()).unwrap().unwrap(), + boot: BootId::current().unwrap(), + } + } +} + #[cfg(any(target_os = "linux", windows))] impl Drop for ExitChild { fn drop(&mut self) { @@ -224,7 +239,9 @@ fn exit_handles_are_independently_owned_and_exist_only_for_exit_events() { start_time: platform::process_start_time(child.0.id()).unwrap().unwrap(), boot: BootId::current().unwrap(), }; - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the child runs"); + }; let handle = process.exit_handle().unwrap(); assert_eq!(handle.is_some(), process.wait_method() == WaitMethod::Event); if handle.is_some() { @@ -239,6 +256,47 @@ fn exit_handles_are_independently_owned_and_exist_only_for_exit_events() { } } +#[cfg(any(target_os = "linux", windows))] +#[test] +fn opening_a_recorded_child_returns_running_only_for_its_identity() { + let mut child = ExitChild::spawn(); + let reference = child.reference(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the recorded child runs"); + }; + assert_eq!(process.reference(), &reference); + assert!(!process.has_exited().unwrap()); + let mut other_boot = *reference.boot.as_bytes(); + other_boot[0] ^= 1; + for exited in [ + ProcessRef { + boot: BootId::from_bytes(other_boot), + ..reference.clone() + }, + ProcessRef { + start_time: reference.start_time + 1, + ..reference.clone() + }, + ] { + assert!(matches!(Process::open(&exited).unwrap(), Opened::Exited(_))); + } + assert!(child.0.try_wait().unwrap().is_none()); + assert!(!process.has_exited().unwrap()); +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn opening_a_reaped_recorded_child_proves_exit() { + let mut child = ExitChild::spawn(); + let reference = child.reference(); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + assert!(matches!( + Process::open(&reference).unwrap(), + Opened::Exited(_) + )); +} + #[cfg(any(target_os = "linux", windows))] #[test] fn polling_follows_a_real_process_until_it_exits() { @@ -347,7 +405,9 @@ fn processes_count_the_bytes_that_their_read_calls_read() { start_time: platform::process_start_time(child.id()).unwrap().unwrap(), boot: BootId::current().unwrap(), }; - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the reader runs"); + }; let counted = process.bytes_read(); let mut stdin = child.stdin.take().unwrap(); stdin.write_all(b"x\n").unwrap(); diff --git a/nvx-rs/src/host/process/tests/linux.rs b/nvx-rs/src/host/process/tests/linux.rs index a2a0cf72f..50bb942b0 100644 --- a/nvx-rs/src/host/process/tests/linux.rs +++ b/nvx-rs/src/host/process/tests/linux.rs @@ -144,13 +144,14 @@ fn a_process_runs_until_its_last_thread_exits() { thread::sleep(POLL_INTERVAL); } assert_eq!(platform::process_start_time(pid).unwrap(), Some(start_time)); - let process = Process::open(&ProcessRef { + let Opened::Running(process) = Process::open(&ProcessRef { pid, start_time, boot: BootId::current().unwrap(), }) - .unwrap() - .expect("a process whose main thread exited still runs"); + .unwrap() else { + panic!("a process whose main thread exited still runs"); + }; assert!( process .wait(Instant::now() + Duration::from_millis(100)) @@ -197,13 +198,16 @@ fn a_process_whose_command_name_is_not_utf8_can_be_examined() { start_time: platform::process_start_time(pid).unwrap().unwrap(), boot: BootId::current().unwrap(), }; - assert!(Process::open(&reference).unwrap().is_some()); + assert!(matches!( + Process::open(&reference).unwrap(), + Opened::Running(_) + )); // A reference to a process that exited and whose ID this one reused names no process. let stale = ProcessRef { start_time: reference.start_time + 1, ..reference }; - assert!(Process::open(&stale).unwrap().is_none()); + assert!(matches!(Process::open(&stale).unwrap(), Opened::Exited(_))); child.0.kill().unwrap(); child.0.wait().unwrap(); } diff --git a/nvx-rs/src/host/restore/tests.rs b/nvx-rs/src/host/restore/tests.rs index b302590b0..f163d92da 100644 --- a/nvx-rs/src/host/restore/tests.rs +++ b/nvx-rs/src/host/restore/tests.rs @@ -12,7 +12,7 @@ use crate::activation::{ OverlayMode, ReadyActivation, RestoreSection, TransactionId, Upper, View, }; use crate::host::platform::inherit::spawn_command; -use crate::host::process::BootId; +use crate::host::process::{BootId, Opened}; use crate::host::vm::create_private_dir; use crate::protocol::restore_ready::parse_ready_event; @@ -25,13 +25,15 @@ fn vm_directory() -> (tempfile::TempDir, VmDir) { } fn held_process(pid: u32) -> Process { - Process::open(&ProcessRef { + let Opened::Running(process) = Process::open(&ProcessRef { pid, start_time: platform::process_start_time(pid).unwrap().unwrap(), boot: BootId::current().unwrap(), }) - .unwrap() - .unwrap() + .unwrap() else { + panic!("the fixture process runs"); + }; + process } /// A separate process, held before it may connect, and alive until the test ends it. diff --git a/nvx-rs/src/host/store/locked.rs b/nvx-rs/src/host/store/locked.rs index 91d1dd322..67e6d269a 100644 --- a/nvx-rs/src/host/store/locked.rs +++ b/nvx-rs/src/host/store/locked.rs @@ -20,7 +20,7 @@ use super::{CLAIM_RELEASE_WAIT, LOCK_RETRY_INTERVAL, SandboxStore, record, store use crate::error::{Error, ErrorCode, Result}; use crate::host::filesystem::FileIdentity; use crate::host::platform; -use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; use crate::protocol::CAPABILITY_LEN; /// The store of a sandbox whose lock this holds, through which its lifecycle transitions change @@ -357,7 +357,12 @@ impl LockedStore { /// Ends the launch that `journal` records, and removes its files once nothing of it runs. pub(super) fn end_launch(&self, journal: &LaunchJournal) -> Result { let recovery = match journal.process() { - Some(process) => self.end_identified(journal, process, Process::open), + Some(process) => self.end_identified(journal, process, |reference| { + Ok(match Process::open(reference)? { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + }) + }), None => self.end_unidentified(journal), }; if !matches!(recovery, Recovery::Indeterminate(_)) { diff --git a/nvx-rs/src/host/store/vmm.rs b/nvx-rs/src/host/store/vmm.rs index 4453d2fa6..f4a66918c 100644 --- a/nvx-rs/src/host/store/vmm.rs +++ b/nvx-rs/src/host/store/vmm.rs @@ -5,7 +5,7 @@ use std::io; use std::time::Instant; use crate::host::platform; -use crate::host::process::{BootId, KILL_TIMEOUT, Process, ProcessRef}; +use crate::host::process::{BootId, KILL_TIMEOUT, Opened, Process, ProcessRef}; /// Finds the live process `pid` of the current boot and holds it, or returns `None` if it has /// exited. @@ -16,10 +16,14 @@ pub(super) fn find(pid: u32) -> io::Result> { let Some(start_time) = platform::process_start_time(pid)? else { return Ok(None); }; - Process::open(&ProcessRef { + let opened = Process::open(&ProcessRef { pid, start_time, boot: BootId::current()?, + })?; + Ok(match opened { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, }) } diff --git a/nvx-rs/src/host/supervisor/reattach.rs b/nvx-rs/src/host/supervisor/reattach.rs index 5563c4269..c04037bf9 100644 --- a/nvx-rs/src/host/supervisor/reattach.rs +++ b/nvx-rs/src/host/supervisor/reattach.rs @@ -5,7 +5,7 @@ use std::io; use super::vmm::Vmm; use crate::error::Result; -use crate::host::process::{Process, ProcessRef}; +use crate::host::process::{Opened, Process, ProcessRef}; use crate::host::store::{ IdentityCheck, IdentityPart, LockedStore, Recovery, RuntimeIdentity, RuntimeRecord, SandboxStore, @@ -65,7 +65,7 @@ pub enum Reattached { /// can. Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for an invalid /// `runtime`, and as [`LockedStore::recover`] does for inconsistent records. pub fn reattach(locked: &mut LockedStore, runtime: &RuntimeIdentity) -> Result { - reattach_with(locked, runtime, Process::open, |_| Ok(())) + reattach_reporting(locked, runtime, |_| Ok(())) } /// Reattaches as [`reattach`] does, and calls `exited` with the runtime record of a VMM that it @@ -77,7 +77,17 @@ pub(crate) fn reattach_reporting( runtime: &RuntimeIdentity, exited: impl FnOnce(&RuntimeRecord) -> Result<()>, ) -> Result { - reattach_with(locked, runtime, Process::open, exited) + reattach_with( + locked, + runtime, + |reference| { + Ok(match Process::open(reference)? { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + }) + }, + exited, + ) } /// Reattaches as [`reattach_reporting`] does, holding the recorded VMM with `open`, which is diff --git a/nvx-rs/src/host/template/clone/tests/launch.rs b/nvx-rs/src/host/template/clone/tests/launch.rs index 7ec87eec6..ccd088aa8 100644 --- a/nvx-rs/src/host/template/clone/tests/launch.rs +++ b/nvx-rs/src/host/template/clone/tests/launch.rs @@ -18,7 +18,7 @@ use super::*; use crate::activation::{ReadyActivation, StoreGeneration, TransactionId}; use crate::host::artifacts; use crate::host::platform::Transport; -use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; use crate::host::snapshot::tests::artifact; use crate::host::store::files::{sync_dir, write_private}; use crate::host::store::{ @@ -361,9 +361,9 @@ impl VmmDir { /// before it asks, since a process that has exited no longer opens. pub(super) fn end_and_wait(&self) { let reference = self.reference.as_ref().expect("the test knows the VMM"); - let process = Process::open(reference) - .unwrap() - .expect("the VMM runs until the test ends it"); + let Opened::Running(process) = Process::open(reference).unwrap() else { + panic!("the VMM runs until the test ends it"); + }; self.end(); assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } @@ -371,7 +371,7 @@ impl VmmDir { /// Waits until the VMM, which exits on its own, as its [plan](Self::plan) asks, has exited. pub(super) fn wait_exit(&self) { let reference = self.reference.as_ref().expect("the test knows the VMM"); - if let Some(process) = Process::open(reference).unwrap() { + if let Opened::Running(process) = Process::open(reference).unwrap() { assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } @@ -412,7 +412,7 @@ impl Drop for VmmDir { fn drop(&mut self) { let _ = fs::write(self.path().join(EXIT_MARKER), b""); if let Some(reference) = &self.reference - && let Ok(Some(process)) = Process::open(reference) + && let Ok(Opened::Running(process)) = Process::open(reference) && !process .wait(Instant::now() + TIMEOUT) .is_ok_and(|exited| exited.is_some()) @@ -2301,7 +2301,9 @@ fn a_reattach_takes_the_report_of_a_vmm_whose_launch_a_recovery_of_the_sandbox_e fn a_launch_whose_caller_crashes_once_its_vmm_started_ends_the_vmm_as_reattach_recovers_it() { let crashed = Crashed::at("launched"); let reference = crashed.vmm.reference.clone().unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the crashed caller's VMM runs"); + }; // The VMM listens beside the snapshot on Linux, as it has once it tried to send its event. crashed.vmm.started(); assert_eq!(exists(&crashed.socket()), cfg!(target_os = "linux")); @@ -2338,7 +2340,9 @@ fn a_restore_whose_caller_crashes_before_its_commit_ends_its_vmm_as_reattach_rec for step in ["activating", "activated", "starting", "started"] { let crashed = Crashed::at(step); let reference = crashed.vmm.reference.clone().unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the crashed caller's VMM runs"); + }; // The caller read the VMM's restore-ready event, whose flush waited for that on Windows. assert!(crashed.vmm.started().ends_with(" true"), "{step}"); assert!(crashed.vmm.flushed(), "{step}"); @@ -3663,7 +3667,10 @@ fn a_reattach_that_cannot_record_a_failure_ends_and_forgets_nothing_of_the_clone .journal() .unwrap() .expect("the reattach kept the journal"); - assert!(Process::open(journal.process().unwrap()).unwrap().is_some()); + assert!(matches!( + Process::open(journal.process().unwrap()).unwrap(), + Opened::Running(_) + )); crashed.refuses_release(&mut locked); // Once the record can be written, the reattach ends the launch, and records its failure. fs::remove_dir(&path).unwrap(); @@ -3719,7 +3726,7 @@ fn a_reattach_that_cannot_record_a_failure_ends_and_forgets_nothing_of_the_clone .unwrap() .expect("the reattach kept the record"); assert!( - Process::open(record.process()).unwrap().is_some(), + matches!(Process::open(record.process()).unwrap(), Opened::Running(_)), "{evidence:?}" ); crashed.refuses_release(&mut locked); @@ -3775,7 +3782,10 @@ fn a_reattach_makes_the_record_of_an_earlier_failure_durable_before_it_ends_the_ .journal() .unwrap() .expect("the reattach kept the journal"); - assert!(Process::open(journal.process().unwrap()).unwrap().is_some()); + assert!(matches!( + Process::open(journal.process().unwrap()).unwrap(), + Opened::Running(_) + )); crashed.refuses_release(&mut locked); // One that can syncs the sandbox's directory before it ends the launch. let synced = RefCell::new(Vec::new()); diff --git a/nvx-rs/src/host/template/clone/tests/restore.rs b/nvx-rs/src/host/template/clone/tests/restore.rs index 61282c561..54d8f45eb 100644 --- a/nvx-rs/src/host/template/clone/tests/restore.rs +++ b/nvx-rs/src/host/template/clone/tests/restore.rs @@ -20,7 +20,7 @@ use super::launch::{ use super::*; use crate::activation::{ReadyActivation, RestoreSection, TransactionId}; use crate::host::platform::Transport; -use crate::host::process::{BootId, Process}; +use crate::host::process::{BootId, Opened, Process}; use crate::host::store::{CLONE_ACTIVATION_NAME, Recovery}; use crate::host::supervisor::{Starting, StopOutcome}; use crate::host::template::clone::launch::Hooks; @@ -421,7 +421,7 @@ fn vmm_with(plan: &str) -> VmmDir { /// Checks that the VMM of `vmm` has exited. fn assert_vmm_ended(vmm: &VmmDir) { let reference = vmm.reference.as_ref().unwrap(); - if let Some(process) = Process::open(reference).unwrap() { + if let Opened::Running(process) = Process::open(reference).unwrap() { assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } @@ -1193,7 +1193,10 @@ fn a_launch_that_cannot_be_ended_keeps_its_records_and_its_lease_until_a_reattac assert_eq!(refusal.code(), ErrorCode::SandboxRunning, "{refusal}"); let records = CloneRecords::read(locked).unwrap().unwrap(); assert_eq!(records.journal.unwrap().process(), Some(&reference)); - assert!(Process::open(&reference).unwrap().is_some()); + assert!(matches!( + Process::open(&reference).unwrap(), + Opened::Running(_) + )); // A reattach ends the launch through its VMM's verified identity: the record keeps the // failure's stage, evidence, and original outcome, with the teardown that resolved it. let mut control = FakeControl::default(); diff --git a/nvx-rs/src/host/template/store/leases/tests.rs b/nvx-rs/src/host/template/store/leases/tests.rs index eaeed151c..2a8b6898d 100644 --- a/nvx-rs/src/host/template/store/leases/tests.rs +++ b/nvx-rs/src/host/template/store/leases/tests.rs @@ -15,7 +15,7 @@ use serde_json::{Value, json}; use super::*; use crate::activation::TemplateReference; -use crate::host::process::{Process, ProcessRef}; +use crate::host::process::{Opened, Process, ProcessRef}; use crate::host::snapshot::tests::{ManifestSpec, artifact, network_class}; use crate::host::store::files::write_private; use crate::host::store::{ @@ -2459,7 +2459,7 @@ impl Drop for VmmDir { fn drop(&mut self) { let _ = fs::write(self.path().join(EXIT_MARKER), b""); if let Some(reference) = &self.reference - && let Ok(Some(process)) = Process::open(reference) + && let Ok(Opened::Running(process)) = Process::open(reference) && !process .wait(Instant::now() + TIMEOUT) .is_ok_and(|exited| exited.is_some()) @@ -2625,7 +2625,9 @@ fn a_clone_that_crashes_before_its_spawn_keeps_its_lease_until_its_launch_ends() fn a_clone_that_crashes_after_its_spawn_keeps_its_lease_until_its_unrecorded_vmm_exits() { let crashed = Crashed::after("after-spawn"); let reference = crashed.vmm.reference.clone().unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the unrecorded VMM runs"); + }; let mut locked = crashed.lock(); assert_eq!( locked.journal().unwrap().unwrap().state(), @@ -2656,7 +2658,9 @@ fn a_clone_that_crashes_after_its_spawn_keeps_its_lease_until_its_unrecorded_vmm fn a_clone_that_crashes_after_recording_its_vmm_keeps_its_lease_until_reattach_ends_it() { let crashed = Crashed::after("after-identity"); let reference = crashed.vmm.reference.clone().unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the recorded VMM runs"); + }; let mut locked = crashed.lock(); let journal = locked.journal().unwrap().unwrap(); assert_eq!(journal.launch(), crashed.lease.launch()); diff --git a/nvx-rs/src/host/transport.rs b/nvx-rs/src/host/transport.rs index 5ab40b105..bd171ded9 100644 --- a/nvx-rs/src/host/transport.rs +++ b/nvx-rs/src/host/transport.rs @@ -47,7 +47,7 @@ use std::thread; use std::time::{Duration, Instant}; use super::platform; -use super::process::{Process, ProcessRef}; +use super::process::{Opened, Process, ProcessRef}; use crate::protocol::CAPABILITY_LEN; use crate::protocol::nvxs::{ ErrorCode, HostEvent, HostSession, Identity, RecordType, SessionError, @@ -76,9 +76,12 @@ pub fn connect_verified( reference: &ProcessRef, deadline: Instant, ) -> Result> { - let process = Process::open(reference) + let process = match Process::open(reference) .map_err(|error| Error::io("cannot verify the endpoint's VM process").with_source(error))? - .ok_or_else(exited)?; + { + Opened::Running(process) => process, + Opened::Exited(_) => return Err(exited()), + }; let mut unavailable = None; loop { check_running(&process)?; diff --git a/nvx-rs/src/host/transport/tests.rs b/nvx-rs/src/host/transport/tests.rs index 2136f8fbb..eb449723e 100644 --- a/nvx-rs/src/host/transport/tests.rs +++ b/nvx-rs/src/host/transport/tests.rs @@ -166,13 +166,10 @@ fn another_process_serving_the_endpoint_is_refused() { assert!(source.to_string().contains(&std::process::id().to_string())); assert!(source.to_string().contains(&process.pid.to_string())); } - assert!( - !Process::open(&process) - .unwrap() - .unwrap() - .has_exited() - .unwrap() - ); + let Opened::Running(held) = Process::open(&process).unwrap() else { + panic!("the unrelated VM process still runs"); + }; + assert!(!held.has_exited().unwrap()); } } diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 72e724aa6..142ca7c0c 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -31,7 +31,10 @@ use crate::host::runtime::{Runtime, RuntimeIdentity}; use crate::host::snapshot::Snapshot; use crate::host::spawn::{self, VmmCommand}; use crate::host::transport::{self, HostConnection}; -use crate::host::{platform::inherit::Inherited, process::Process, process::ProcessRef}; +use crate::host::{ + platform::inherit::Inherited, + process::{Opened, Process, ProcessRef}, +}; use crate::protocol::CAPABILITY_LEN; use crate::{Error, Result}; @@ -252,12 +255,16 @@ pub fn verify(saved: &SavedVm, runtime: &RuntimeIdentity) -> Result { ), )); } - Process::open(&saved.process) + match Process::open(&saved.process) .map_err(|error| Error::io("cannot verify the saved VM process").with_source(error))? - .ok_or_else(|| { + { + Opened::Running(process) => Ok(process), + Opened::Exited(_) => { let message = "the saved VM process exited"; - Error::exited(message).with_source(io::Error::new(io::ErrorKind::NotFound, message)) - }) + Err(Error::exited(message) + .with_source(io::Error::new(io::ErrorKind::NotFound, message))) + } + } } /// A verified held VM process and its authenticated control connection. diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index b637a7532..8903967cb 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -11,6 +11,8 @@ use crate::host::config::{ Boot, LayeredRoot, PROCESS_COMMAND_LINE_LIMIT, RootConfig, UpperConfig, process_command_line_units, }; +#[cfg(any(target_os = "linux", windows))] +use crate::host::process::Opened; use crate::host::runtime::{ArtifactIdentity, LaunchOptions, RuntimeIdentity}; use crate::hypervisor::Hypervisor; use crate::protocol::root::RootImage; @@ -901,7 +903,9 @@ fn spawn_returns_the_same_outputs_and_capability_with_a_verified_process() { assert_eq!(launched.0.capability.as_bytes(), &capability); let reference = launched.0.process.reference(); assert_ne!(reference.pid, std::process::id()); - let reopened = Process::open(reference).unwrap().unwrap(); + let Opened::Running(reopened) = Process::open(reference).unwrap() else { + panic!("the recorded VMM runs"); + }; assert_eq!(reopened.reference(), reference); assert!(!reopened.has_exited().unwrap()); assert!(!format!("{:?}", launched.0).contains(&format!("{capability:?}"))); @@ -1073,7 +1077,9 @@ fn a_library_vm_outlives_the_caller_that_spawned_it_without_retaining_its_output ); let reference = serde_json::from_slice(&fs::read(vm.join("caller-process.json")).unwrap()).unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the caller's recorded VMM runs"); + }; let launched = RunningVm(Launched { process, outputs: VmDir::open(&vm).unwrap().outputs(false).unwrap(), @@ -1233,7 +1239,9 @@ fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { let launched = RunningVm(spawn(prepared).unwrap()); assert_eq!(launched.0.capability.as_bytes(), &capability); let reference = launched.0.process.reference(); - let reopened = Process::open(reference).unwrap().unwrap(); + let Opened::Running(reopened) = Process::open(reference).unwrap() else { + panic!("the recorded VMM runs"); + }; assert_eq!(reopened.reference(), reference); let deadline = Instant::now() + Duration::from_secs(60); let mut console = loop { @@ -1334,7 +1342,9 @@ impl StopChild { start_time: platform::process_start_time(child.id()).unwrap().unwrap(), boot: crate::host::process::BootId::current().unwrap(), }; - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the owned child runs"); + }; Self { child, process } } } diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index 01191d000..0c32142f2 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -51,7 +51,7 @@ use std::process::{Command, Output, Stdio}; use std::thread; use std::time::{Duration, Instant}; -use nvx::host::process::{Process, ProcessRef}; +use nvx::host::process::{Opened, Process, ProcessRef}; use serde_json::{Value, json}; use self::harness::{FAKE_OPENVMM, NVX_RS, Root, canonical}; @@ -800,7 +800,10 @@ fn runtime_record(root: &Root, state: &str) -> Option { fn recorded_process(root: &Root, state: &str) -> Option { let record = runtime_record(root, state)?; let reference: ProcessRef = serde_json::from_value(record["process"].clone()).unwrap(); - Process::open(&reference).unwrap() + match Process::open(&reference).unwrap() { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + } } /// Returns the names in the state directory `state` below the root, sorted. @@ -1057,9 +1060,9 @@ fn a_start_that_was_interrupted_is_ended_under_another_identity_too() { // The CLI dies, as a crash would, and leaves its OpenVMM running. start.kill().unwrap(); start.wait().unwrap(); - let process = Process::open(&reference) - .unwrap() - .expect("the OpenVMM of the start runs"); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the OpenVMM of the start runs"); + }; // Under another runtime identity, the next transition ends the start, whose OpenVMM it // verifies through the journal. @@ -2059,9 +2062,9 @@ fn a_start_that_ends_before_openvmm_opens_the_images_leaves_it_the_checked_ones( // The CLI dies, as a crash would, before OpenVMM has opened its images, and leaves it running. start.kill().unwrap(); start.wait().unwrap(); - let process = Process::open(&reference) - .unwrap() - .expect("the OpenVMM of the start runs"); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the OpenVMM of the start runs"); + }; let blocks = root .path .join("build") diff --git a/nvx-rs/tests/cli/lifecycle.rs b/nvx-rs/tests/cli/lifecycle.rs index d82a0f548..ce2ac6b58 100644 --- a/nvx-rs/tests/cli/lifecycle.rs +++ b/nvx-rs/tests/cli/lifecycle.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; use nvx::contract::{ BOOT_CONTRACT_VERSION, CONTROL_CONTRACT_REVISION, CONTROL_SESSION_PROTOCOL_VERSION, }; -use nvx::host::process::{Process, ProcessRef}; +use nvx::host::process::{Opened, Process, ProcessRef}; use nvx::host::sandbox::distro::DistroBinding; use nvx::host::sandbox::scratch::ScratchBinding; use nvx::host::sandbox::{LayerRole, SandboxLayer}; @@ -153,9 +153,9 @@ fn assert_runtime_record(root: &Root, state: &str, record: &Value) -> (String, P "{record}" ); let reference: ProcessRef = serde_json::from_value(record["process"].clone()).unwrap(); - let process = Process::open(&reference) - .unwrap() - .unwrap_or_else(|| panic!("the OpenVMM of the record does not run: {record}")); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the OpenVMM of the record does not run: {record}"); + }; let endpoint = record["endpoint"].as_str().unwrap().to_owned(); assert_endpoint(root, state, &endpoint); let digest = |index: usize| format!("sha256:{}", PROVENANCE[index].2); @@ -1432,7 +1432,10 @@ fn recycled_process_id(root: &Root) { let path = root.below("state/runtime.json"); let mut record = json(&path); let reference: ProcessRef = serde_json::from_value(record["process"].clone()).unwrap(); - let openvmm = Unrecorded(Process::open(&reference).unwrap().expect("OpenVMM runs")); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("OpenVMM runs"); + }; + let openvmm = Unrecorded(process); let start_time = record["process"]["startTime"].as_u64().unwrap(); record["process"]["startTime"] = json!(start_time + 1); fs::write(&path, serde_json::to_vec_pretty(&record).unwrap()).unwrap(); diff --git a/nvx-rs/tests/fake_openvmm.rs b/nvx-rs/tests/fake_openvmm.rs index b112f951b..3444797cd 100644 --- a/nvx-rs/tests/fake_openvmm.rs +++ b/nvx-rs/tests/fake_openvmm.rs @@ -20,7 +20,7 @@ use std::time::{Duration, Instant}; use nvx::host::instance::{Found, Instance, Running, Started, Stopped, WorkloadOutcome}; use nvx::host::launch::{LaunchSpec, PROCESS_COMMAND_LINE_LIMIT}; -use nvx::host::process::{Process, ProcessRef}; +use nvx::host::process::{Opened, Process, ProcessRef}; use nvx::host::store::{ArtifactIdentity, CallerFiles, IdentityPart, RuntimeIdentity}; use nvx::host::supervisor::spawn_command; use nvx::protocol::{Workload, WorkloadEnvironment}; @@ -425,9 +425,10 @@ fn a_restarted_orchestrator_reattaches_to_its_vm() { let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { panic!("a VM of another runtime identity was not reported"); }; - let process = Process::open(mismatched.vmm().record().process()) - .unwrap() - .expect("the VM runs"); + let Opened::Running(process) = Process::open(mismatched.vmm().record().process()).unwrap() + else { + panic!("the VM runs"); + }; for timeout in [Duration::ZERO, Duration::MAX] { let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { panic!("a VM of another runtime identity was not reported"); @@ -467,9 +468,10 @@ fn outcome_reports_of_another_runtime_are_never_read() { let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { panic!("a VM of another runtime identity was not reported"); }; - let process = Process::open(mismatched.vmm().record().process()) - .unwrap() - .expect("the VM runs"); + let Opened::Running(process) = Process::open(mismatched.vmm().record().process()).unwrap() + else { + panic!("the VM runs"); + }; fs::write( instance.report_path(), r#"{"schema_version":1,"outcome":{"operation":"managed","category":"success", @@ -517,7 +519,9 @@ fn records_that_name_another_process_never_end_it() { let mut record: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); let reference: ProcessRef = serde_json::from_value(record["process"].clone()).unwrap(); - let process = Process::open(&reference).unwrap().expect("the VM runs"); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the VM runs"); + }; tamper(&mut record); fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap(); // Under another runtime identity too, the VM is found stopped, and its record is cleared, diff --git a/nvx-rs/tests/store.rs b/nvx-rs/tests/store.rs index 9602cce68..ea22e24ed 100644 --- a/nvx-rs/tests/store.rs +++ b/nvx-rs/tests/store.rs @@ -20,7 +20,7 @@ use std::time::{Duration, Instant}; use nvx::ErrorKind; use nvx::host::platform; -use nvx::host::process::{BootId, Process, ProcessRef}; +use nvx::host::process::{BootId, Opened, Process, ProcessRef}; use nvx::host::store::{ ArtifactIdentity, IdentityCheck, IdentityPart, JOURNAL_NAME, Launch, LaunchId, LaunchState, LockedStore, RUNTIME_NAME, Recovery, RuntimeIdentity, SandboxStore, @@ -179,7 +179,7 @@ fn kill(pid: u32, start_time: u64) -> io::Result { start_time, boot: BootId::current()?, }; - let Some(process) = Process::open(&reference)? else { + let Opened::Running(process) = Process::open(&reference)? else { return Ok(true); }; process.kill()?; @@ -530,9 +530,9 @@ fn a_committed_launch_names_its_vm_for_another_build() { IdentityCheck::Mismatched(vec![IdentityPart::Initramfs]) ); // The record's verified reference holds the VMM, whichever process started it. - let reattached = Process::open(found.process()) - .unwrap() - .expect("the recorded VMM runs"); + let Opened::Running(reattached) = Process::open(found.process()).unwrap() else { + panic!("the recorded VMM runs"); + }; assert!(!reattached.has_exited().unwrap()); drop(vmm); assert!( diff --git a/nvx-rs/tests/supervisor.rs b/nvx-rs/tests/supervisor.rs index 45b2058ab..f5a29252a 100644 --- a/nvx-rs/tests/supervisor.rs +++ b/nvx-rs/tests/supervisor.rs @@ -30,7 +30,7 @@ use std::time::{Duration, Instant}; use nvx::ErrorKind; use nvx::host::launch::MAX_TIMEOUT; -use nvx::host::process::{BootId, Process, ProcessRef, WaitMethod}; +use nvx::host::process::{BootId, Opened, Process, ProcessRef, WaitMethod}; #[cfg(windows)] use nvx::host::store::FORMAT; use nvx::host::store::{ @@ -449,7 +449,7 @@ impl Drop for VmmDirectory { fn drop(&mut self) { let _ = File::create(self.path().join(EXIT_MARKER)); if let Some(reference) = &self.reference - && let Ok(Some(process)) = Process::open(reference) + && let Ok(Opened::Running(process)) = Process::open(reference) && !process .wait(Instant::now() + TIMEOUT) .is_ok_and(|exited| exited.is_some()) @@ -572,16 +572,20 @@ impl Drop for Launcher { /// Opens the process that `reference` names, which must still run. fn open_running(reference: &ProcessRef) -> Process { - Process::open(reference) - .unwrap() - .unwrap_or_else(|| panic!("{reference:?} does not run")) + let Opened::Running(process) = Process::open(reference).unwrap() else { + panic!("{reference:?} does not run"); + }; + process } /// Checks that the process that `process` holds has exited, and that no process matches its /// reference. fn assert_ended(process: &Process) { assert!(process.has_exited().unwrap()); - assert!(Process::open(process.reference()).unwrap().is_none()); + assert!(matches!( + Process::open(process.reference()).unwrap(), + Opened::Exited(_) + )); } #[test] @@ -782,7 +786,7 @@ fn a_reused_process_id_is_never_signaled() { }, ] { assert!( - Process::open(&impostor).unwrap().is_none(), + matches!(Process::open(&impostor).unwrap(), Opened::Exited(_)), "{impostor:?} names a running process" ); } @@ -824,7 +828,10 @@ fn references_that_name_no_process_are_never_opened() { vmm.end(); assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(launched.kill_and_wait()); - assert!(Process::open(process.reference()).unwrap().is_none()); + assert!(matches!( + Process::open(process.reference()).unwrap(), + Opened::Exited(_) + )); assert!(process.has_exited().unwrap()); } diff --git a/nvx-rs/tests/template_capture.rs b/nvx-rs/tests/template_capture.rs index 0036b1c10..d03074966 100644 --- a/nvx-rs/tests/template_capture.rs +++ b/nvx-rs/tests/template_capture.rs @@ -23,7 +23,7 @@ use std::thread; use std::time::{Duration, Instant}; use nvx::host::platform; -use nvx::host::process::{BootId, Process, ProcessRef}; +use nvx::host::process::{BootId, Opened, Process, ProcessRef}; use nvx::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding, PolicyVerdict}; use nvx::host::sandbox::{LayerRole, SandboxDistro, SandboxLayer, SandboxPlan, SandboxUpper}; #[cfg(target_os = "linux")] @@ -528,7 +528,7 @@ impl Orphan { impl Drop for Orphan { fn drop(&mut self) { - if let Ok(Some(process)) = Process::open(&self.0) { + if let Ok(Opened::Running(process)) = Process::open(&self.0) { let _ = process.kill(); let _ = process.wait(Instant::now() + TIMEOUT); } diff --git a/tests/runtime-adapter/tests/common/mod.rs b/tests/runtime-adapter/tests/common/mod.rs index dc5e0f914..fef951c26 100644 --- a/tests/runtime-adapter/tests/common/mod.rs +++ b/tests/runtime-adapter/tests/common/mod.rs @@ -30,7 +30,7 @@ use std::time::{Duration, Instant}; use nvx::Hypervisor; use nvx::host::artifacts::{ArtifactDir, Artifacts, Guest}; -use nvx::host::process::Process; +use nvx::host::process::{Opened, Process}; use nvx::host::store::{LOG_NAME, RuntimeRecord, SandboxStore}; use nvx::host::supervisor::{self, Reattached, StopOutcome}; use nvx::host::template::{CAPTURES_NAME, Store, TRANSACTION_NAME}; @@ -407,7 +407,10 @@ impl Sandbox { /// Returns the VMM of the sandbox's runtime record, if the record names one that runs. pub(crate) fn vmm(&self) -> Option { - Process::open(self.record()?.process()).unwrap() + match Process::open(self.record()?.process()).unwrap() { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + } } /// Ends the VM that the sandbox's store records, whatever its runtime identity: reattach ends diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index cbc20e665..46f447124 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -228,7 +228,7 @@ use nvx::exec::{Key, Limits, Timers, Window}; use nvx::host::artifacts::Artifacts; use nvx::host::filesystem::GUEST_EXPORT; use nvx::host::platform::Transport; -use nvx::host::process::{BootId, Process}; +use nvx::host::process::{BootId, Opened, Process}; use nvx::host::sandbox::SandboxNetwork; use nvx::host::store::{CallerFiles, LOG_NAME, SandboxStore}; use nvx::host::template::{ @@ -2379,9 +2379,10 @@ fn refuses_early(template: &Template, early: Message) { let restoring = planned .launch(&mut acquired, &runtime.artifacts.openvmm, &runtime.identity) .unwrap(); - let vmm = Process::open(restoring.starting().process().reference()) - .unwrap() - .expect("the clone's VMM runs"); + let Opened::Running(vmm) = Process::open(restoring.starting().process().reference()).unwrap() + else { + panic!("the clone's VMM runs"); + }; let mut control = Early { early: early.clone(), vmm, diff --git a/tests/runtime-adapter/tests/vm_steps.rs b/tests/runtime-adapter/tests/vm_steps.rs index bc4821d7d..9ff0fea3c 100644 --- a/tests/runtime-adapter/tests/vm_steps.rs +++ b/tests/runtime-adapter/tests/vm_steps.rs @@ -20,7 +20,7 @@ use std::path::Path; use std::time::Instant; use nvx::ErrorCode; -use nvx::host::process::Process; +use nvx::host::process::{Opened, Process}; use nvx::host::store::{LaunchState, SandboxStore}; use runtime_adapter::hook::{Action, HOOK_EXIT, STEP_HOOK, Step, StepHook}; use runtime_adapter::staged::Staged; @@ -205,9 +205,9 @@ fn the_hook_pauses_a_launch_at_each_of_its_steps() { .unwrap() .expect("the launch has its journal"); assert_eq!(journal.state(), LaunchState::Spawned, "at {step}"); - let vmm = Process::open(journal.process().unwrap()) - .unwrap() - .expect("the VMM runs"); + let Opened::Running(vmm) = Process::open(journal.process().unwrap()).unwrap() else { + panic!("the VMM runs"); + }; assert!(!vmm.has_exited().unwrap(), "at {step}"); assert_eq!(store.runtime().unwrap(), None, "at {step}"); } From 797d300557e54644f0a8e66ba729de0dcf1884fb Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 03:43:25 -0800 Subject: [PATCH 08/25] host: open strict private snapshot directories Validate the fixed snapshot layout, private regular files, bounded metadata and key/manifest agreement without reading snapshot.json. Retain the caller immutability contract and cover Linux/Windows refusal cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6789d491-7d13-4929-bc64-4edfec93ba60 --- nvx-rs/README.md | 18 +- nvx-rs/public-api/linux.txt | 6 + nvx-rs/public-api/windows.txt | 6 + nvx-rs/src/host/snapshot.rs | 189 +++++++++++- nvx-rs/src/host/snapshot/key.rs | 13 + nvx-rs/src/host/snapshot/key/tests.rs | 23 ++ nvx-rs/src/host/snapshot/tests.rs | 408 +++++++++++++++++++++++++- nvx-rs/src/host/vm/tests.rs | 3 +- scripts/test-layout-allowlist.json | 1 + 9 files changed, 657 insertions(+), 10 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 3019c6ebc..c432b148f 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -125,7 +125,7 @@ changes only when the module's purpose does. | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | | [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold preparation, verified detached launch, confirmed stop, and caller-saved reattachment | -| [`nvx::host::snapshot`](#nvxhostsnapshot) | Snapshot compatibility keys, strict manifests, restore-time eligibility, and the opaque input of a future VM restore | +| [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshot directories, verified keys and manifests, time eligibility, and the input of a future VM restore | | [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity | | [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | @@ -579,9 +579,19 @@ The key's canonical encoding and identity use the snapshot names and omit and no reader or migration of the prior encoding is supported. `CaptureClass` still records the legacy capture inputs, including its guest build. -`Snapshot` is an opaque input of a future restore. `vm::create` currently -refuses `Some(snapshot)` with `ErrorKind::Unsupported`; only cold boots are -supported. +`Snapshot::open` checks exactly `key.bin`, `snapshot.json` and +`openvmm/{manifest.bin,memory.bin,state.bin}`. Directories are private, files +are regular and private to their owner, artifact lengths equal the strict +manifest's, and the canonical key is consistent with that manifest. +`KEY_FILE`, `DESCRIPTION_FILE` and `OPENVMM_DIR` name the root entries. +The description must exist, but its contents are never read, parsed or +trusted. `Snapshot::key` and `manifest` expose the verified metadata. + +The caller keeps the directory and every entry private and immutable while +using the snapshot; opening checks them once and retains no file handles +or lock. `Snapshot` remains an opaque input of a future restore: +`vm::create` still refuses `Some(snapshot)` with `ErrorKind::Unsupported`; +only cold boots are supported. ### `nvx::host::config` diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 2d90a5759..f134e992e 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3035,6 +3035,7 @@ method nvx::host::snapshot::CaptureClass::to_bytes struct nvx::host::snapshot::ClassIdentity method nvx::host::snapshot::ClassIdentity::as_bytes method nvx::host::snapshot::ClassIdentity::from_bytes +const nvx::host::snapshot::DESCRIPTION_FILE struct nvx::host::snapshot::Downtime field nvx::host::snapshot::Downtime::nanos field nvx::host::snapshot::Downtime::source @@ -3072,13 +3073,18 @@ method nvx::host::snapshot::Invariants::iter method nvx::host::snapshot::Invariants::len method nvx::host::snapshot::Invariants::new method nvx::host::snapshot::Invariants::with +const nvx::host::snapshot::KEY_FILE struct nvx::host::snapshot::KeyIdentity method nvx::host::snapshot::KeyIdentity::as_bytes method nvx::host::snapshot::KeyIdentity::from_bytes const nvx::host::snapshot::MANIFEST_NAME const nvx::host::snapshot::MAX_DOWNTIME_NS const nvx::host::snapshot::MAX_MANIFEST_LEN +const nvx::host::snapshot::OPENVMM_DIR struct nvx::host::snapshot::Snapshot +method nvx::host::snapshot::Snapshot::key +method nvx::host::snapshot::Snapshot::manifest +method nvx::host::snapshot::Snapshot::open struct nvx::host::snapshot::SnapshotKey const nvx::host::snapshot::SnapshotKey::MAX_LEN const nvx::host::snapshot::SnapshotKey::VERSION diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index c1e2bc8cf..375201d56 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1621,6 +1621,7 @@ method nvx::host::snapshot::CaptureClass::to_bytes struct nvx::host::snapshot::ClassIdentity method nvx::host::snapshot::ClassIdentity::as_bytes method nvx::host::snapshot::ClassIdentity::from_bytes +const nvx::host::snapshot::DESCRIPTION_FILE struct nvx::host::snapshot::Downtime field nvx::host::snapshot::Downtime::nanos field nvx::host::snapshot::Downtime::source @@ -1658,13 +1659,18 @@ method nvx::host::snapshot::Invariants::iter method nvx::host::snapshot::Invariants::len method nvx::host::snapshot::Invariants::new method nvx::host::snapshot::Invariants::with +const nvx::host::snapshot::KEY_FILE struct nvx::host::snapshot::KeyIdentity method nvx::host::snapshot::KeyIdentity::as_bytes method nvx::host::snapshot::KeyIdentity::from_bytes const nvx::host::snapshot::MANIFEST_NAME const nvx::host::snapshot::MAX_DOWNTIME_NS const nvx::host::snapshot::MAX_MANIFEST_LEN +const nvx::host::snapshot::OPENVMM_DIR struct nvx::host::snapshot::Snapshot +method nvx::host::snapshot::Snapshot::key +method nvx::host::snapshot::Snapshot::manifest +method nvx::host::snapshot::Snapshot::open struct nvx::host::snapshot::SnapshotKey const nvx::host::snapshot::SnapshotKey::MAX_LEN const nvx::host::snapshot::SnapshotKey::VERSION diff --git a/nvx-rs/src/host/snapshot.rs b/nvx-rs/src/host/snapshot.rs index 599a0fb53..7fcb88f6e 100644 --- a/nvx-rs/src/host/snapshot.rs +++ b/nvx-rs/src/host/snapshot.rs @@ -1,4 +1,8 @@ -//! Snapshot metadata and the opaque input of a future VM restore. +//! Strict snapshot directories, compatibility keys, manifests and restore-time eligibility. +//! +//! [`Snapshot::open`] checks the one snapshot format without reading its informational +//! description. The caller keeps the directory and its entries private and immutable while +//! using the opened snapshot. //! //! [`SnapshotKey`] derives a compatibility key from a capture's class and manifest. //! [`SnapshotManifest::read`] reads the manifest that OpenVMM wrote, and @@ -31,15 +35,192 @@ pub(super) mod manifest; pub(crate) mod tests; mod wire; +use std::ffi::OsString; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +use crate::host::paths::{absolute, normalized}; +use crate::host::store::regular; +use crate::host::{filesystem, platform}; +use crate::{Error, Result}; + pub use self::eligibility::{Downtime, DowntimeSource, Eligibility, HostTime, MAX_DOWNTIME_NS}; pub use self::key::{CaptureClass, ClassIdentity, Invariants, KeyIdentity, SnapshotKey}; pub use self::manifest::{ BlockGeometry, HostClock, MANIFEST_NAME, MAX_MANIFEST_LEN, SnapshotManifest, TimeContract, }; -/// An opaque snapshot input. Creating a VM from a snapshot is not supported yet. +/// The canonical snapshot key. +pub const KEY_FILE: &str = "key.bin"; +/// The informational description, whose contents [`Snapshot::open`] never reads. +pub const DESCRIPTION_FILE: &str = "snapshot.json"; +/// The directory of OpenVMM's manifest, RAM and saved state. +pub const OPENVMM_DIR: &str = "openvmm"; + +/// A checked snapshot directory and its verified metadata, for a future VM restore. +/// +/// The caller must keep the directory and its entries private and immutable while using this +/// value. Opening checks them once; it neither watches them nor holds their files open. #[derive(Debug)] pub struct Snapshot { - /// Reserved for snapshot metadata. - pub(crate) _private: (), + /// The absolute directory path, with its components preserved rather than its links resolved. + #[cfg_attr( + not(test), + expect(dead_code, reason = "SL2.15 uses the snapshot directory (#1103)") + )] + pub(crate) dir: PathBuf, + /// The bounded canonical key checked against the manifest. + key: SnapshotKey, + /// The strictly parsed metadata of OpenVMM's snapshot. + manifest: SnapshotManifest, +} + +impl Snapshot { + /// Opens the one strict snapshot format without changing any entry. + /// + /// `dir` must be a private directory containing exactly [`KEY_FILE`], [`DESCRIPTION_FILE`] + /// and [`OPENVMM_DIR`]. The latter must be a private directory containing exactly + /// [`MANIFEST_NAME`], `memory.bin` and `state.bin`. Every file must be regular, not a link or + /// reparse point, and private to its owner. The manifest is read by + /// [`SnapshotManifest::read`], its artifact lengths must match, and the bounded canonical + /// key must be consistent with it. The description is required but its contents are never + /// read, parsed or trusted. + /// + /// The caller must keep the directory and its entries private and immutable while using + /// the returned snapshot. This check retains no open file handles or lock. + /// + /// Fails with [`ErrorKind::Io`](crate::ErrorKind::Io) for an unreadable or invalid directory, + /// entry, length or key relationship, and otherwise as [`SnapshotManifest::read`] and + /// [`SnapshotKey::from_bytes`] do. + pub fn open(dir: &Path) -> Result { + let dir = absolute(&normalized(dir)).map_err(|error| entry_error(dir, error))?; + check_directory(&dir, &[KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR])?; + let openvmm = dir.join(OPENVMM_DIR); + check_directory(&openvmm, &[MANIFEST_NAME, "memory.bin", "state.bin"])?; + for path in [ + dir.join(KEY_FILE), + dir.join(DESCRIPTION_FILE), + openvmm.join(MANIFEST_NAME), + ] { + check_file(&path)?; + } + let manifest = SnapshotManifest::read(&openvmm)?; + for (name, expected) in [ + ("memory.bin", manifest.memory_len()), + ("state.bin", manifest.state_len()), + ] { + let path = openvmm.join(name); + let found = check_file(&path)?.len(); + if found != expected { + return Err(Error::io(format!( + "the snapshot artifact {} is {found} bytes long, not the {expected} bytes \ + that its manifest records", + path.display() + ))); + } + } + let path = dir.join(KEY_FILE); + let bytes = regular::open_regular(&path) + .and_then(|file| { + regular::read_at_most(file, SnapshotKey::MAX_LEN as u64, "a snapshot key") + }) + .map_err(|error| entry_error(&path, error))?; + let key = SnapshotKey::from_bytes(&bytes)?; + key.check_manifest(&manifest)?; + Ok(Self { dir, key, manifest }) + } + + /// Returns the verified snapshot key. + pub fn key(&self) -> &SnapshotKey { + &self.key + } + + /// Returns the strictly parsed OpenVMM manifest. + pub fn manifest(&self) -> &SnapshotManifest { + &self.manifest + } +} + +/// Names the failing snapshot entry while retaining its I/O error. +fn entry_error(path: &Path, error: io::Error) -> Error { + Error::io(format!("cannot examine snapshot entry {}", path.display())).with_source(error) +} + +/// Checks one private directory and its exact fixed entry-name inventory. +fn check_directory(path: &Path, expected: &[&str]) -> Result<()> { + if let Some(problem) = + platform::check_snapshot_dir(path).map_err(|error| entry_error(path, error))? + { + return Err(Error::io(format!( + "the snapshot directory {} {problem}", + path.display() + ))); + } + let mut found = fs::read_dir(path) + .and_then(|entries| { + entries + .map(|entry| Ok(entry?.file_name())) + .collect::>>() + }) + .map_err(|error| entry_error(path, error))?; + let mut expected: Vec = expected.iter().map(|name| OsString::from(*name)).collect(); + found.sort(); + expected.sort(); + if found != expected { + return Err(Error::io(format!( + "the snapshot directory {} holds {found:?}, not exactly {expected:?}", + path.display() + ))); + } + Ok(()) +} + +/// Checks a regular private file from metadata alone, without reading its contents. +fn check_file(path: &Path) -> Result { + #[cfg(windows)] + let file = { + use std::os::windows::fs::OpenOptionsExt; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, READ_CONTROL, + }; + + fs::OpenOptions::new() + .access_mode(READ_CONTROL | FILE_READ_ATTRIBUTES) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) + .open(path) + .map_err(|error| entry_error(path, error))? + }; + #[cfg(windows)] + let metadata = file.metadata().map_err(|error| entry_error(path, error))?; + #[cfg(not(windows))] + let metadata = fs::symlink_metadata(path).map_err(|error| entry_error(path, error))?; + if !metadata.is_file() || filesystem::is_reparse_point(&metadata) { + return Err(Error::io(format!( + "the snapshot entry {} is not a regular file, or is a link or reparse point", + path.display() + ))); + } + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + + if metadata.uid() != nix::unistd::Uid::effective().as_raw() || metadata.mode() & 0o077 != 0 + { + return Err(Error::io(format!( + "the snapshot file {} is not private to the current user", + path.display() + ))); + } + } + #[cfg(windows)] + if let Some(problem) = + platform::private_file_problem(&file).map_err(|error| entry_error(path, error))? + { + return Err(Error::io(format!( + "the snapshot file {} {problem}", + path.display() + ))); + } + Ok(metadata) } diff --git a/nvx-rs/src/host/snapshot/key.rs b/nvx-rs/src/host/snapshot/key.rs index 9f0c77169..8ab84cc1d 100644 --- a/nvx-rs/src/host/snapshot/key.rs +++ b/nvx-rs/src/host/snapshot/key.rs @@ -634,6 +634,19 @@ impl SnapshotKey { self.contracts == Contracts::CURRENT } + /// Checks the manifest's complete machine, the current key contracts and the filesystem tag. + pub(super) fn check_manifest(&self, manifest: &SnapshotManifest) -> Result<()> { + if self.machine != manifest.machine + || !self.speaks_current_contracts() + || self.filesystem_tag != MOUNT_TAG + { + return Err(Error::io( + "the snapshot key is not consistent with its manifest and this format's contracts", + )); + } + Ok(()) + } + /// Returns the backend. pub fn hypervisor(&self) -> Hypervisor { self.machine.hypervisor diff --git a/nvx-rs/src/host/snapshot/key/tests.rs b/nvx-rs/src/host/snapshot/key/tests.rs index c7fce6252..0ee47de27 100644 --- a/nvx-rs/src/host/snapshot/key/tests.rs +++ b/nvx-rs/src/host/snapshot/key/tests.rs @@ -709,6 +709,29 @@ fn key_refusal(bytes: &[u8], code: ErrorCode) -> String { error.message().to_owned() } +#[cfg(any(target_os = "linux", windows))] +#[test] +fn snapshot_open_refuses_other_key_contracts_and_filesystem_tags() { + use crate::host::snapshot::{KEY_FILE, Snapshot}; + use crate::host::store::files::write_private; + + let changes: [fn(&mut SnapshotKey); 2] = [ + |key| key.contracts.snapshot += 1, + |key| key.filesystem_tag.push_str("-other"), + ]; + for change in changes { + let fixture = support::SnapshotFixture::new(); + let mut key = Snapshot::open(&fixture.dir).unwrap().key().clone(); + change(&mut key); + let bytes = key.to_bytes(); + assert!(SnapshotKey::from_bytes(&bytes).is_ok()); + write_private(&fixture.dir.join(KEY_FILE), &bytes).unwrap(); + let error = Snapshot::open(&fixture.dir).unwrap_err(); + assert_eq!(error.code(), ErrorCode::Io, "{error}"); + assert!(error.message().contains("not consistent"), "{error}"); + } +} + #[test] fn other_encodings_of_a_key_are_refused() { let key = network_key(); diff --git a/nvx-rs/src/host/snapshot/tests.rs b/nvx-rs/src/host/snapshot/tests.rs index 3772e3ac5..c73fe2710 100644 --- a/nvx-rs/src/host/snapshot/tests.rs +++ b/nvx-rs/src/host/snapshot/tests.rs @@ -4,9 +4,16 @@ //! that the tests check, and [`ManifestSpec`] holds each field of a manifest, so that a test //! changes one field and encodes the others as they were. +use std::fs; +use std::path::{Path, PathBuf}; + use sha2::{Digest, Sha256}; -use super::{BlockGeometry, CaptureClass, Invariants}; +use super::{ + BlockGeometry, CaptureClass, DESCRIPTION_FILE, Invariants, KEY_FILE, MANIFEST_NAME, + OPENVMM_DIR, Snapshot, SnapshotKey, SnapshotManifest, +}; +use crate::ErrorKind; use crate::host::network::{Access, NetworkPolicy}; use crate::host::sandbox::SandboxNetwork; use crate::host::store::{ArtifactIdentity, RuntimeIdentity}; @@ -986,3 +993,402 @@ pub(in crate::host) fn network_class() -> CaptureClass { ..class() } } + +pub(in crate::host) struct SnapshotFixture { + root: tempfile::TempDir, + pub(in crate::host) dir: PathBuf, + spec: ManifestSpec, + key: SnapshotKey, +} + +impl SnapshotFixture { + pub(in crate::host) fn new() -> Self { + use crate::host::platform; + use crate::host::store::files::{create_new, write_private}; + + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join("snapshot"); + assert_eq!(platform::create_owner_only_dir(&dir).unwrap(), None); + let openvmm = dir.join(OPENVMM_DIR); + assert_eq!(platform::create_owner_only_dir(&openvmm).unwrap(), None); + let spec = ManifestSpec::template(); + let manifest = SnapshotManifest::parse(&spec.encode()).unwrap(); + let key = SnapshotKey::new(&class(), &manifest).unwrap(); + write_private(&dir.join(KEY_FILE), &key.to_bytes()).unwrap(); + write_private(&dir.join(DESCRIPTION_FILE), b"not JSON").unwrap(); + write_private(&openvmm.join(MANIFEST_NAME), &spec.encode()).unwrap(); + for (name, length) in [ + ("memory.bin", spec.memory_size), + ("state.bin", spec.state_size), + ] { + let file = create_new(&openvmm.join(name)).unwrap(); + platform::make_sparse(&file).unwrap(); + file.set_len(length).unwrap(); + } + Self { + root, + dir, + spec, + key, + } + } + + fn files(&self) -> [PathBuf; 5] { + let openvmm = self.dir.join(OPENVMM_DIR); + [ + self.dir.join(KEY_FILE), + self.dir.join(DESCRIPTION_FILE), + openvmm.join(MANIFEST_NAME), + openvmm.join("memory.bin"), + openvmm.join("state.bin"), + ] + } +} + +fn refused(path: &Path) { + let error = Snapshot::open(path).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); +} + +#[test] +fn snapshot_open_checks_the_complete_directory_and_preserves_its_metadata() { + let fixture = SnapshotFixture::new(); + let snapshot = Snapshot::open(&fixture.dir).unwrap(); + assert_eq!(snapshot.dir, fixture.dir); + assert_eq!(snapshot.key(), &fixture.key); + assert_eq!( + snapshot.manifest(), + &SnapshotManifest::parse(&fixture.spec.encode()).unwrap() + ); + for suffix in ["", std::path::MAIN_SEPARATOR_STR, "."] { + let path = if suffix == "." { + fixture.dir.join(".") + } else { + let mut path = fixture.dir.as_os_str().to_os_string(); + path.push(suffix); + PathBuf::from(path) + }; + assert_eq!(Snapshot::open(&path).unwrap().key(), &fixture.key); + } +} + +#[test] +fn snapshot_open_refuses_every_missing_entry() { + for index in 0..6 { + let fixture = SnapshotFixture::new(); + let missing = if index == 5 { + fixture.dir.join(OPENVMM_DIR) + } else { + fixture.files()[index].clone() + }; + fs::rename(&missing, fixture.root.path().join("missing")).unwrap(); + refused(&fixture.dir); + } + let fixture = SnapshotFixture::new(); + refused(&fixture.root.path().join("absent")); +} + +#[test] +fn snapshot_open_refuses_extra_entries_and_alternate_names() { + use crate::host::store::files::write_private; + + for nested in [false, true] { + let fixture = SnapshotFixture::new(); + let dir = if nested { + fixture.dir.join(OPENVMM_DIR) + } else { + fixture.dir.clone() + }; + write_private(&dir.join("extra"), b"extra").unwrap(); + refused(&fixture.dir); + } + let fixture = SnapshotFixture::new(); + fs::rename(fixture.dir.join(KEY_FILE), fixture.dir.join("KEY.BIN")).unwrap(); + refused(&fixture.dir); +} + +#[test] +fn snapshot_open_refuses_every_directory_in_place_of_a_regular_file() { + for index in 0..5 { + let fixture = SnapshotFixture::new(); + let path = &fixture.files()[index]; + fs::remove_file(path).unwrap(); + fs::create_dir(path).unwrap(); + refused(&fixture.dir); + } +} + +#[test] +fn snapshot_open_refuses_files_in_place_of_its_directories() { + use crate::host::store::files::write_private; + + let fixture = SnapshotFixture::new(); + let file = fixture.root.path().join("file"); + write_private(&file, b"file").unwrap(); + refused(&file); + fs::rename( + fixture.dir.join(OPENVMM_DIR), + fixture.root.path().join("saved"), + ) + .unwrap(); + write_private(&fixture.dir.join(OPENVMM_DIR), b"file").unwrap(); + refused(&fixture.dir); +} + +#[test] +fn snapshot_open_refuses_both_artifact_length_mismatches() { + for name in ["memory.bin", "state.bin"] { + for difference in [-1i64, 1] { + let fixture = SnapshotFixture::new(); + let path = fixture.dir.join(OPENVMM_DIR).join(name); + let file = fs::OpenOptions::new().write(true).open(path).unwrap(); + let length = file.metadata().unwrap().len(); + file.set_len(length.checked_add_signed(difference).unwrap()) + .unwrap(); + refused(&fixture.dir); + } + } +} + +#[test] +fn snapshot_open_uses_the_strict_manifest_and_bounded_key_decoders() { + use crate::host::store::files::write_private; + + for bytes in [vec![0x80], vec![0; super::MAX_MANIFEST_LEN as usize + 1]] { + let fixture = SnapshotFixture::new(); + write_private(&fixture.dir.join(OPENVMM_DIR).join(MANIFEST_NAME), &bytes).unwrap(); + assert!(Snapshot::open(&fixture.dir).is_err()); + } + let fixture = SnapshotFixture::new(); + let mut trailing = fixture.key.to_bytes(); + trailing.push(0); + let mut version = fixture.key.to_bytes(); + version[0] += 1; + for bytes in [ + vec![0], + trailing, + version, + vec![0; SnapshotKey::MAX_LEN + 1], + ] { + write_private(&fixture.dir.join(KEY_FILE), &bytes).unwrap(); + assert!(Snapshot::open(&fixture.dir).is_err()); + } +} + +#[test] +fn snapshot_open_refuses_a_key_from_another_manifest_machine() { + use crate::host::store::files::write_private; + + let mutations: [fn(&mut ManifestSpec); 4] = [ + |spec| spec.cpu().digest[0] ^= 1, + |spec| spec.contract().command_line.push_str(" extra"), + |spec| spec.contract().devices[0].state_unit.push_str("-other"), + |spec| spec.time().tsc_hz += 1, + ]; + for mutate in mutations { + let fixture = SnapshotFixture::new(); + let mut spec = fixture.spec.clone(); + mutate(&mut spec); + let manifest = SnapshotManifest::parse(&spec.encode()).unwrap(); + let key = SnapshotKey::new(&class(), &manifest).unwrap(); + write_private(&fixture.dir.join(KEY_FILE), &key.to_bytes()).unwrap(); + refused(&fixture.dir); + } +} + +#[test] +fn snapshot_description_contents_do_not_affect_opening() { + use crate::host::store::files::write_private; + + let fixture = SnapshotFixture::new(); + for contents in [b"".as_slice(), b"{broken JSON", &[0xff, 0, 0x80]] { + write_private(&fixture.dir.join(DESCRIPTION_FILE), contents).unwrap(); + assert_eq!(Snapshot::open(&fixture.dir).unwrap().key(), &fixture.key); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn snapshot_open_refuses_every_shared_file_and_both_shared_directories() { + use std::os::unix::fs::PermissionsExt; + + for index in 0..5 { + for mode in [0o640, 0o602, 0o610] { + let fixture = SnapshotFixture::new(); + let path = &fixture.files()[index]; + fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); + refused(&fixture.dir); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + mode + ); + } + } + for nested in [false, true] { + let fixture = SnapshotFixture::new(); + let path = if nested { + fixture.dir.join(OPENVMM_DIR) + } else { + fixture.dir.clone() + }; + fs::set_permissions(&path, fs::Permissions::from_mode(0o750)).unwrap(); + refused(&fixture.dir); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o750 + ); + } +} + +#[cfg(target_os = "linux")] +#[test] +#[ignore = "requires root to change only the owned snapshot fixture's entry owners"] +fn snapshot_open_refuses_entries_owned_by_another_user() { + use nix::unistd::{Uid, chown}; + + assert!(Uid::effective().is_root()); + for index in 0..7 { + let fixture = SnapshotFixture::new(); + let path = match index { + 5 => fixture.dir.clone(), + 6 => fixture.dir.join(OPENVMM_DIR), + _ => fixture.files()[index].clone(), + }; + chown(&path, Some(Uid::from_raw(1)), None).unwrap(); + refused(&fixture.dir); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn snapshot_open_refuses_file_links_fifos_and_sockets_without_reading_them() { + use std::os::unix::fs::symlink; + use std::os::unix::net::UnixListener; + + for index in 0..5 { + let fixture = SnapshotFixture::new(); + let path = &fixture.files()[index]; + let saved = fixture.root.path().join("saved"); + fs::rename(path, &saved).unwrap(); + symlink(&saved, path).unwrap(); + refused(&fixture.dir); + fs::remove_file(path).unwrap(); + nix::unistd::mkfifo(path, nix::sys::stat::Mode::S_IRUSR).unwrap(); + refused(&fixture.dir); + fs::remove_file(path).unwrap(); + let _socket = UnixListener::bind(path).unwrap(); + refused(&fixture.dir); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn snapshot_open_refuses_directory_links_even_with_trailing_separators() { + use std::os::unix::fs::symlink; + + let fixture = SnapshotFixture::new(); + let link = fixture.root.path().join("link"); + symlink(&fixture.dir, &link).unwrap(); + for suffix in ["", "/", "/.", "/./"] { + let mut path = link.as_os_str().to_os_string(); + path.push(suffix); + refused(Path::new(&path)); + } + let saved = fixture.root.path().join("saved"); + let openvmm = fixture.dir.join(OPENVMM_DIR); + fs::rename(&openvmm, &saved).unwrap(); + symlink(&saved, &openvmm).unwrap(); + refused(&fixture.dir); +} + +#[cfg(target_os = "linux")] +#[test] +fn snapshot_description_needs_no_read_permission() { + use std::os::unix::fs::PermissionsExt; + + let fixture = SnapshotFixture::new(); + let path = fixture.dir.join(DESCRIPTION_FILE); + fs::set_permissions(&path, fs::Permissions::from_mode(0o200)).unwrap(); + if !nix::unistd::Uid::effective().is_root() { + assert_eq!( + fs::read(&path).unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + } + assert_eq!(Snapshot::open(&fixture.dir).unwrap().key(), &fixture.key); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o200 + ); +} + +#[cfg(windows)] +#[test] +fn snapshot_open_refuses_every_shared_file_and_both_shared_directory_acls() { + use crate::host::platform::tests::{access, set_access, user_sid}; + + let sid = user_sid(); + let shared = format!("D:P(A;;FA;;;{sid})(A;;FR;;;WD)"); + for index in 0..5 { + let fixture = SnapshotFixture::new(); + let path = &fixture.files()[index]; + set_access(path, &shared).unwrap(); + let before = access(path).unwrap(); + refused(&fixture.dir); + assert_eq!(access(path).unwrap(), before); + } + for nested in [false, true] { + let fixture = SnapshotFixture::new(); + let path = if nested { + fixture.dir.join(OPENVMM_DIR) + } else { + fixture.dir.clone() + }; + set_access(&path, &shared).unwrap(); + let before = access(&path).unwrap(); + refused(&fixture.dir); + assert_eq!(access(&path).unwrap(), before); + } +} + +#[cfg(windows)] +#[test] +fn snapshot_open_refuses_directory_junctions_and_every_reparse_file_entry() { + use crate::host::tests::junction; + + let fixture = SnapshotFixture::new(); + let link = fixture.root.path().join("link"); + junction(&link, &fixture.dir); + for path in [link.clone(), link.join("."), link.join("")] { + refused(&path); + } + let saved = fixture.root.path().join("saved"); + let openvmm = fixture.dir.join(OPENVMM_DIR); + fs::rename(&openvmm, &saved).unwrap(); + junction(&openvmm, &saved); + refused(&fixture.dir); + for index in 0..5 { + let fixture = SnapshotFixture::new(); + let path = &fixture.files()[index]; + fs::remove_file(path).unwrap(); + junction(path, fixture.root.path()); + refused(&fixture.dir); + fs::remove_dir(path).unwrap(); + } +} + +#[cfg(windows)] +#[test] +fn snapshot_description_needs_no_data_read_access() { + use crate::host::platform::tests::{set_access, user_sid}; + + let fixture = SnapshotFixture::new(); + let path = fixture.dir.join(DESCRIPTION_FILE); + let sid = user_sid(); + set_access(&path, &format!("D:P(D;;0x1;;;{sid})(A;;FA;;;{sid})")).unwrap(); + assert_eq!( + fs::read(&path).unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + assert_eq!(Snapshot::open(&fixture.dir).unwrap().key(), &fixture.key); +} diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index b637a7532..be5e3c799 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -494,7 +494,8 @@ fn a_missing_boot_and_a_snapshot_are_refused_before_any_output_is_created() { let runtime = cold_runtime(); let mut config = cold_config(); config.boot = None; - let snapshot = Snapshot { _private: () }; + let fixture = crate::host::snapshot::tests::SnapshotFixture::new(); + let snapshot = Snapshot::open(&fixture.dir).unwrap(); for (from, config, expected) in [ (None, &config, "Boot"), (Some(&snapshot), &cold_config(), "Snapshot"), diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 5b7e6aa10..adf7c96c6 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -17,6 +17,7 @@ "nvx-rs/src/host/restore.rs": 3, "nvx-rs/src/host/sandbox/plan.rs": 5, "nvx-rs/src/host/session.rs": 1, + "nvx-rs/src/host/snapshot.rs": 1, "nvx-rs/src/host/vm.rs": 5, "nvx-rs/src/protocol/app.rs": 6 } From 87528167b284851b5e4b7b4df057868b5e2c9904 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 03:53:05 -0800 Subject: [PATCH 09/25] guest: retain restored ingress as an init helper Port only SL7.5d onto the qualified capture parent. Fork the fixed announcer before switching roots, preserving PID 1 priority and fatal helper supervision. Reset inherited signals without introducing a restore caller or lifecycle machinery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dee059e7-7968-43d8-82d0-abd68b922eed --- nvx-rs/src/guest/init.rs | 60 +++++ nvx-rs/src/guest/init/tests.rs | 1 + nvx-rs/src/guest/init/tests/restore.rs | 313 +++++++++++++++++++++++++ nvx-rs/src/guest/sys/mod.rs | 2 + nvx-rs/src/guest/sys/spawn.rs | 2 +- scripts/test-layout-allowlist.json | 2 +- 6 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 nvx-rs/src/guest/init/tests/restore.rs diff --git a/nvx-rs/src/guest/init.rs b/nvx-rs/src/guest/init.rs index bd326adf1..15c760f1a 100644 --- a/nvx-rs/src/guest/init.rs +++ b/nvx-rs/src/guest/init.rs @@ -37,6 +37,7 @@ use std::ffi::{CStr, CString, OsStr}; use std::io::{self, Read}; +use std::net::Ipv4Addr; use std::os::fd::BorrowedFd; use std::os::unix::ffi::OsStrExt; use std::os::unix::process::ExitStatusExt; @@ -44,6 +45,7 @@ use std::path::{Path, PathBuf}; use std::process::{ExitCode, ExitStatus as ProcessStatus}; use std::time::{Duration, Instant}; +use crate::activation::Publication; use crate::protocol::cmdline; use crate::protocol::root::{DISTRO_MMIO_BASE, SCRATCH_MMIO_BASE}; @@ -992,6 +994,64 @@ impl Runtime { }) } + /// Starts ingress from the applied root's publication and gateway, before its root switch. + /// The fork keeps the initramfs root and PID 1's priority; the sole collector owns its exit. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL7.7b starts the restored announcer before the root switch" + ) + )] + pub(crate) fn restore_announcer( + &mut self, + publication: Publication, + gateway: Option, + ) -> crate::Result<()> { + if publication == Publication::Unpublished { + return Ok(()); + } + if self.helpers.announcer.is_some() { + return Err(crate::Error::unsupported( + "an announcer is already registered", + )); + } + let gateway = gateway + .ok_or_else(|| crate::Error::unsupported("published listeners require a gateway"))?; + let program = super::ingress::PROGRAM.to_owned(); + #[cfg(test)] + let program = tests::restore::announcer_program(program); + let arguments = [ + c"nvx-ingress".to_owned(), + CString::new(gateway.to_string()).expect("an IPv4 address has no NUL"), + ]; + sys::check_released().map_err(|error| { + crate::Error::io("cannot fork the restored announcer with port permission") + .with_source(error) + })?; + let fork = sys::fork().map_err(|error| { + crate::Error::io("cannot fork the restored announcer") + .with_source(io::Error::from(error)) + })?; + if let sys::ForkResult::Parent(process) = fork { + self.helpers.announcer = Some(process); + return Ok(()); + } + if let Err(error) = sys::reset_signals() { + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-ERROR: cannot reset announcer signals: {error}\n").as_bytes(), + ); + sys::exit_immediately(FAILURE); + } + let error = sys::exec(&program, &arguments, None); + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-ERROR: cannot exec /sbin/nvx-ingress: {error}\n").as_bytes(), + ); + sys::exit_immediately(FAILURE); + } + /// Poll alongside control input so a blocked peer cannot hide a helper's end. pub(crate) fn wake(&self) -> Pollable<'_> { Pollable::Signals(self.system.signals.as_ref().expect("listening")) diff --git a/nvx-rs/src/guest/init/tests.rs b/nvx-rs/src/guest/init/tests.rs index 706bf18a7..66e525181 100644 --- a/nvx-rs/src/guest/init/tests.rs +++ b/nvx-rs/src/guest/init/tests.rs @@ -9,6 +9,7 @@ mod capture; mod exec; +pub(super) mod restore; pub(super) use exec::prepare_child; use std::collections::VecDeque; diff --git a/nvx-rs/src/guest/init/tests/restore.rs b/nvx-rs/src/guest/init/tests/restore.rs new file mode 100644 index 000000000..86cf3a741 --- /dev/null +++ b/nvx-rs/src/guest/init/tests/restore.rs @@ -0,0 +1,313 @@ +//! The restore-only announcer step and its existing helper supervision. + +use std::cell::RefCell; +use std::io::{Seek, SeekFrom, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::*; + +thread_local! { + static PROGRAM: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn announcer_program(program: CString) -> CString { + PROGRAM.with_borrow(|replacement| replacement.clone().unwrap_or(program)) +} + +fn with_program(path: &Path, run: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + PROGRAM.replace(self.0.take()); + } + } + let _restore = Restore(PROGRAM.replace(Some(sys::path_c_string(path).unwrap()))); + run() +} + +fn runtime() -> Runtime { + Runtime { + system: RealSystem { signals: None }, + helpers: Helpers { + time: pid(i32::MAX), + announcer: None, + }, + terminating: false, + } +} + +struct Child { + process: ProcessId, + descriptor: FileDescriptor, + collected: bool, +} + +impl Child { + fn new(process: ProcessId) -> Self { + Self { + process, + descriptor: sys::open_pidfd(process).unwrap(), + collected: false, + } + } +} + +impl Drop for Child { + fn drop(&mut self) { + if !self.collected { + let _ = sys::kill_through_pidfd(self.descriptor.borrowed(), false); + let _ = sys::wait(self.process); + } + } +} + +fn script(directory: &Path, body: &str) -> std::path::PathBuf { + let path = directory.join("announcer"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + path +} + +fn until(mut ready: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + while !ready() { + assert!(Instant::now() < deadline, "the announcer did not advance"); + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn collect_failure(runtime: &mut Runtime, child: &mut Child) -> String { + assert!(sys::readable_or_hung_up(&[child.descriptor.borrowed()], 5000).unwrap()[0]); + let error = runtime + .service(|_, _| panic!("the restored announcer was treated as a workload")) + .unwrap_err(); + child.collected = true; + error +} + +fn blocked(process: ProcessId) -> u64 { + let status = std::fs::read_to_string(format!("/proc/{}/status", process.raw())).unwrap(); + let mask = status + .lines() + .find_map(|line| line.strip_prefix("SigBlk:")) + .unwrap(); + u64::from_str_radix(mask.trim(), 16).unwrap() +} + +#[test] +fn unpublished_restores_start_nothing_even_without_a_gateway() { + let mut runtime = runtime(); + for gateway in [None, Some(Ipv4Addr::new(10, 88, 0, 1))] { + runtime + .restore_announcer(Publication::Unpublished, gateway) + .unwrap(); + assert_eq!(runtime.helpers.announcer, None); + } +} + +#[test] +fn a_missing_gateway_or_an_existing_announcer_starts_no_replacement() { + let mut runtime = runtime(); + assert_eq!( + runtime + .restore_announcer(Publication::Published, None) + .unwrap_err() + .kind(), + crate::ErrorKind::Unsupported + ); + assert_eq!(runtime.helpers.announcer, None); + runtime.helpers.announcer = Some(pid(7)); + assert_eq!( + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap_err() + .kind(), + crate::ErrorKind::Unsupported + ); + assert_eq!(runtime.helpers.announcer, Some(pid(7))); +} + +#[test] +fn the_restored_child_gets_only_the_gateway_and_joins_fatal_helper_supervision() { + let name = "guest::init::tests::restore::the_restored_child_gets_only_the_gateway_and_joins_fatal_helper_supervision"; + if isolate_process_test("NVX_INIT_RESTORED_CHILD_TEST", name) { + return; + } + let directory = tempfile::tempdir().unwrap(); + in_child(|| { + let record = directory.path().join("arguments"); + let program = script( + directory.path(), + &format!( + "printf '%s\\n' \"$#\" \"$1\" \"$PPID\" > '{}'\nexec sleep 30", + record.display() + ), + ); + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + with_program(&program, || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + let expected = format!("1\n10.88.0.1\n{}\n", sys::current_process().raw()); + until(|| std::fs::read_to_string(&record).is_ok_and(|record| record == expected)); + assert_eq!(nice_of(child.process), nice_of(sys::current_process())); + assert_eq!( + std::fs::read(format!("/proc/{}/oom_score_adj", child.process.raw())).unwrap(), + std::fs::read("/proc/self/oom_score_adj").unwrap() + ); + sys::kill_through_pidfd(child.descriptor.borrowed(), false).unwrap(); + let error = collect_failure(&mut runtime, &mut child); + assert_eq!( + error, + format!( + "the announcer, process {}, was killed by signal 9", + child.process.raw() + ) + ); + let mut system = Fake::default(); + finish(&mut system, Err(error.clone())); + assert_eq!(system.calls, [failure(&error), Call::ShutDown(FAILURE)]); + }); +} + +#[test] +fn a_failed_exec_remains_a_registered_fatal_helper() { + let name = "guest::init::tests::restore::a_failed_exec_remains_a_registered_fatal_helper"; + if isolate_process_test("NVX_INIT_RESTORED_EXEC_FAILURE_TEST", name) { + return; + } + in_child(|| { + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + with_program(Path::new("/nonexistent/nvx-ingress"), || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + assert_eq!( + collect_failure(&mut runtime, &mut child), + format!( + "the announcer, process {}, exited with status 125", + child.process.raw() + ) + ); + }); +} + +#[test] +fn the_restored_announcer_unblocks_pid_1_signals_before_exec() { + let name = + "guest::init::tests::restore::the_restored_announcer_unblocks_pid_1_signals_before_exec"; + if isolate_process_test("NVX_INIT_RESTORED_SIGNALS_TEST", name) { + return; + } + in_child(|| { + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + assert_ne!(blocked(sys::current_process()), 0); + with_program(Path::new("/bin/false"), || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + assert!(sys::readable_or_hung_up(&[child.descriptor.borrowed()], 5000).unwrap()[0]); + assert_eq!(blocked(child.process), 0); + assert_eq!( + collect_failure(&mut runtime, &mut child), + format!( + "the announcer, process {}, exited with status 1", + child.process.raw() + ) + ); + }); +} + +#[test] +fn a_refused_fork_keeps_the_helper_registry_unchanged() { + let mut runtime = runtime(); + let error = runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap_err(); + assert_eq!(error.kind(), crate::ErrorKind::Io); + assert_eq!(runtime.helpers.announcer, None); +} + +#[test] +#[ignore = "requires a guest's root, CAP_SYS_NICE, CAP_SYS_RESOURCE, and CAP_SYS_CHROOT"] +fn the_restored_helper_keeps_the_pre_switch_root_and_pid_1_priority() { + let name = "guest::init::tests::restore::the_restored_helper_keeps_the_pre_switch_root_and_pid_1_priority"; + if std::env::var_os("NVX_INIT_RESTORED_PRIORITY_TEST").is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", name, "--ignored", "--nocapture"]) + .env("NVX_INIT_RESTORED_PRIORITY_TEST", "1") + .status() + .unwrap(); + assert!(status.success()); + return; + } + assert!(holds_capability(CAP_SYS_NICE).unwrap()); + assert!(holds_capability(CAP_SYS_RESOURCE).unwrap()); + let directory = tempfile::tempdir().unwrap(); + in_child(|| { + let root = directory.path().join("new-root"); + std::fs::create_dir(&root).unwrap(); + let marker = directory.path().join("old-root-marker"); + std::fs::write(&marker, b"the helper kept the initramfs root\n").unwrap(); + let ready = directory.path().join("ready"); + let gate = directory.path().join("gate"); + let output = directory.path().join("observed"); + let mut gate_file = std::fs::File::create(&gate).unwrap(); + let mut output_file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(&output) + .unwrap(); + let program = script( + directory.path(), + &format!( + "printf ready > '{}'\nwhile test ! -s '{}'; do sleep 0.01; done\ncat '{}' > '{}'\nexec sleep 30", + ready.display(), + gate.display(), + marker.display(), + output.display() + ), + ); + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + runtime.system.raise_priority(None).unwrap(); + with_program(&program, || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + until(|| ready.is_file()); + assert_eq!(nice_of(child.process), -10); + assert_eq!( + std::fs::read_to_string(format!("/proc/{}/oom_score_adj", child.process.raw())) + .unwrap() + .trim(), + "-1000" + ); + nix::unistd::chroot(&root).unwrap(); + std::env::set_current_dir("/").unwrap(); + gate_file.write_all(b"continue\n").unwrap(); + let mut observed = String::new(); + until(|| { + output_file.seek(SeekFrom::Start(0)).unwrap(); + observed.clear(); + output_file.read_to_string(&mut observed).unwrap(); + observed == "the helper kept the initramfs root\n" + }); + sys::kill_through_pidfd(child.descriptor.borrowed(), false).unwrap(); + assert!(collect_failure(&mut runtime, &mut child).contains("the announcer")); + }); +} diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index 328797ec7..6e0d5e80d 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -147,6 +147,8 @@ pub(crate) use sock_diag::{ INET_DIAG_BUFFER_LENGTH, InetDiagMessage, decode_inet_diag_dump, dump_ipv6_tcp_sockets, }; pub(crate) use socket::{UdpSocket, connect_datagram}; +#[cfg(target_arch = "x86_64")] +pub(crate) use spawn::reset_signals; pub(crate) use spawn::{ChildSpec, PreparedChild, REPORT_LENGTH, enter_root}; pub(crate) use terminal::{configure_raw, drain}; pub(crate) use timer::Timer; diff --git a/nvx-rs/src/guest/sys/spawn.rs b/nvx-rs/src/guest/sys/spawn.rs index 00116653c..7cf0590f0 100644 --- a/nvx-rs/src/guest/sys/spawn.rs +++ b/nvx-rs/src/guest/sys/spawn.rs @@ -985,7 +985,7 @@ struct KernelSigaction { /// /// The dispositions come first, so that a signal that arrived while blocked takes its default /// action once unblocked, and never runs a handler of the caller's. -fn reset_signals() -> Result<(), Errno> { +pub(crate) fn reset_signals() -> Result<(), Errno> { let default = KernelSigaction::default(); for signal in 1..=SIGNALS { if signal == nix::libc::SIGKILL || signal == nix::libc::SIGSTOP { diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 045dc828c..ca8ea1991 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -5,7 +5,7 @@ "nvx-rs/src/guest/cgroup.rs": 2, "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, - "nvx-rs/src/guest/init.rs": 6, + "nvx-rs/src/guest/init.rs": 8, "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, "nvx-rs/src/guest/sys/entry.rs": 3, From b134e702dc97feb620dfa642d0dac1fa46c33246 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:39:15 -0800 Subject: [PATCH 10/25] integration: adapt CLI liveness to qualified process opening proofs Compose the existing CLI liveness contract with SL2.10b Opened results. Preserve held-running versus proved-exited distinction and propagate lookup failures. Update only its existing own-process test helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/liveness.rs | 17 +++++++++++------ nvx-rs/src/host/cli/store/tests/liveness.rs | 10 ++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/nvx-rs/src/host/cli/store/liveness.rs b/nvx-rs/src/host/cli/store/liveness.rs index 90af6ef29..4b584f836 100644 --- a/nvx-rs/src/host/cli/store/liveness.rs +++ b/nvx-rs/src/host/cli/store/liveness.rs @@ -6,7 +6,7 @@ use super::StateFile; use super::locked::LockedState; use super::record::LaunchJournal; use crate::host::platform; -use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; use crate::{Error, Result}; impl LockedState { @@ -51,11 +51,16 @@ fn find(pid: u32) -> io::Result> { let Some(start_time) = platform::process_start_time(pid)? else { return Ok(None); }; - Process::open(&ProcessRef { - pid, - start_time, - boot: BootId::current()?, - }) + Ok( + match Process::open(&ProcessRef { + pid, + start_time, + boot: BootId::current()?, + })? { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + }, + ) } /// Adapted from the old store's owner check: the second endpoint lookup happens only after diff --git a/nvx-rs/src/host/cli/store/tests/liveness.rs b/nvx-rs/src/host/cli/store/tests/liveness.rs index 0ac403e01..e9bb3f683 100644 --- a/nvx-rs/src/host/cli/store/tests/liveness.rs +++ b/nvx-rs/src/host/cli/store/tests/liveness.rs @@ -7,20 +7,22 @@ use super::launch::prepared; use super::*; use crate::host::cli::store::liveness::hold_endpoint_server; use crate::host::config::ConsoleAttachment; -use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; use crate::host::spawn::{self, VmmCommand}; const HELPER: &str = "host::cli::store::tests::liveness::log_holder_helper"; fn own_process() -> Process { let pid = std::process::id(); - Process::open(&ProcessRef { + let Opened::Running(process) = Process::open(&ProcessRef { pid, start_time: platform::process_start_time(pid).unwrap().unwrap(), boot: BootId::current().unwrap(), }) - .unwrap() - .unwrap() + .unwrap() else { + panic!("this process runs"); + }; + process } struct Running(Option); From a81a6c8d874b00e57046b8fcd8657e2a2cbdd2fe Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:40:32 -0800 Subject: [PATCH 11/25] host: keep immutable named CLI snapshots Port only SL3.7a commits 5a5cebc4034d39ef0fe9c32b2e8c13d750d2aea9 and 593a17d04f3a245ed8257b320b36943c79bc9c9d onto the actual preserved-parent reader/CLI input join 4662351ccc22198ffe16b6935d157bee14a163c0. Validate and publish whole private snapshots without replacement, keep the exclusive per-name lock outside each immutable entry, and record only the restored sandbox's own source name. Reuse existing CLI privacy/locking helpers. No capture, restore, index, shared-lock mode or blockless driver is added. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- nvx-rs/src/host/cli/mod.rs | 41 +++- nvx-rs/src/host/cli/sandbox/state.rs | 76 ++++--- nvx-rs/src/host/cli/sandbox/state/tests.rs | 35 +++ nvx-rs/src/host/cli/snapshots.rs | 121 +++++++++++ nvx-rs/src/host/cli/snapshots/tests.rs | 238 +++++++++++++++++++++ nvx-rs/src/host/cli/store/mod.rs | 1 + scripts/test-layout-allowlist.json | 2 + 7 files changed, 473 insertions(+), 41 deletions(-) create mode 100644 nvx-rs/src/host/cli/sandbox/state/tests.rs create mode 100644 nvx-rs/src/host/cli/snapshots.rs create mode 100644 nvx-rs/src/host/cli/snapshots/tests.rs diff --git a/nvx-rs/src/host/cli/mod.rs b/nvx-rs/src/host/cli/mod.rs index d72dd8d8a..4af6a3051 100644 --- a/nvx-rs/src/host/cli/mod.rs +++ b/nvx-rs/src/host/cli/mod.rs @@ -47,16 +47,55 @@ mod run; mod sandbox; mod session; mod shell; +mod snapshots; mod store; use std::borrow::Cow; use std::ffi::OsString; -use std::path::Path; +use std::path::{Path, PathBuf}; use self::args::{Parser, Receiver, Spec, Stop, Syntax}; use crate::ErrorCode; use crate::host::platform; +/// Returns the name of a temporary file next to `path`: `.NAME.UUID.tmp`, with a fresh UUID4 in +/// hexadecimal. +pub(crate) fn temporary_path(path: &Path) -> Result { + let Some(name) = path.file_name() else { + return Err(Exit::error( + ErrorCode::Other, + format!("{} has an empty name", path.display()), + )); + }; + let mut temporary = OsString::from("."); + temporary.push(name); + temporary.push(format!(".{}.tmp", uuid4_hex()?)); + Ok(path.with_file_name(temporary)) +} + +/// Returns 32 lowercase hexadecimal digits of 122 random bits, with the version and variant bits +/// of a version-4 UUID. +fn uuid4_hex() -> Result { + let mut bytes = random_bytes::<16>()?; + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + Ok(hex(&bytes)) +} + +/// Returns `N` random bytes from the operating system. +pub(crate) fn random_bytes() -> Result<[u8; N]> { + let mut bytes = [0u8; N]; + getrandom::fill(&mut bytes).map_err(|error| { + Exit::error(ErrorCode::Io, format!("cannot read random bytes: {error}")) + })?; + Ok(bytes) +} + +/// Returns `bytes` in lowercase hexadecimal. +pub(crate) fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + /// The commands, in the order in which usage messages list them. const COMMANDS: [&str; 5] = ["artifacts", "download", "egress", "run", "sandbox"]; /// The options of the top-level parser. diff --git a/nvx-rs/src/host/cli/sandbox/state.rs b/nvx-rs/src/host/cli/sandbox/state.rs index d9fbe5b73..2dff2c014 100644 --- a/nvx-rs/src/host/cli/sandbox/state.rs +++ b/nvx-rs/src/host/cli/sandbox/state.rs @@ -30,10 +30,9 @@ //! [`Config::network`] reads back for `start` to render. It replaces the network options that //! `nvx.py` recorded, which no reader reads: `start` refuses a configuration without it. -use std::ffi::OsString; use std::fs::OpenOptions; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize}; @@ -44,6 +43,7 @@ use super::{MAX_MEMORY_MIB, path_string}; use crate::ErrorCode; use crate::host::cli::json::{Object, pretty_serde, read_object}; use crate::host::cli::output::LINE_SEPARATOR; +use crate::host::cli::snapshots::Name as SnapshotName; use crate::host::cli::{Exit, Result, io_error, io_message}; use crate::host::filesystem::ExportPlan; use crate::host::paths; @@ -53,6 +53,11 @@ use crate::host::sandbox::scratch::ScratchBinding; use crate::host::sandbox::{SandboxLaunch, SandboxNetwork, SandboxSettings, SandboxUpper}; use crate::protocol::root::RootUpper; +pub(crate) use crate::host::cli::{hex, random_bytes, temporary_path}; + +#[cfg(test)] +mod tests; + /// The configuration that earlier builds wrote for a sandbox without shares, whose `mount` is /// `null`, which `start` still reads. pub(crate) const CONFIG_FORMAT: u64 = 1; @@ -69,44 +74,6 @@ pub(crate) const CONFIG_FORMATS: [u64; 3] = /// What messages call a configuration. const DESCRIPTION: &str = "sandbox configuration"; -/// Returns the name of a temporary file next to `path`: `.NAME.UUID.tmp`, with a fresh UUID4 in -/// hexadecimal. -pub(crate) fn temporary_path(path: &Path) -> Result { - let Some(name) = path.file_name() else { - return Err(Exit::error( - ErrorCode::Other, - format!("{} has an empty name", path.display()), - )); - }; - let mut temporary = OsString::from("."); - temporary.push(name); - temporary.push(format!(".{}.tmp", uuid4_hex()?)); - Ok(path.with_file_name(temporary)) -} - -/// Returns 32 lowercase hexadecimal digits of 122 random bits, with the version and variant bits -/// of a version-4 UUID. -pub(crate) fn uuid4_hex() -> Result { - let mut bytes = random_bytes::<16>()?; - bytes[6] = (bytes[6] & 0x0f) | 0x40; - bytes[8] = (bytes[8] & 0x3f) | 0x80; - Ok(hex(&bytes)) -} - -/// Returns `N` random bytes from the operating system. -pub(crate) fn random_bytes() -> Result<[u8; N]> { - let mut bytes = [0u8; N]; - getrandom::fill(&mut bytes).map_err(|error| { - Exit::error(ErrorCode::Io, format!("cannot read random bytes: {error}")) - })?; - Ok(bytes) -} - -/// Returns `bytes` in lowercase hexadecimal. -pub(crate) fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - /// Atomically replaces `path` with the JSON `text` and a line end, with the line separator of the /// host for each `\n`, in a temporary file of mode 0600 that then replaces `path`. pub(crate) fn write_json(path: &Path, text: &str) -> Result { @@ -280,6 +247,9 @@ struct Record<'a> { scratch_binding: Option<&'a ScratchBinding>, /// The export plan of the shares, or `None` without shares. export: Option<&'a ExportPlan>, + /// The immutable named snapshot that supplies this sandbox's restore view. + #[serde(skip_serializing_if = "Option::is_none")] + source_snapshot: Option<&'a SnapshotName>, } /// Returns the JSON text of the configuration that `provision` records for `launch` and the other @@ -291,6 +261,17 @@ pub(crate) fn serialize_launch( distro: &RecordedDistro, scratch: Option<&RecordedScratch>, config: &ProvisionConfig<'_>, +) -> Result { + serialize_launch_with_snapshot(launch, distro, scratch, config, None) +} + +/// Records a restored sandbox's source alongside its own launch, without a snapshot index. +pub(crate) fn serialize_launch_with_snapshot( + launch: &SandboxLaunch, + distro: &RecordedDistro, + scratch: Option<&RecordedScratch>, + config: &ProvisionConfig<'_>, + source_snapshot: Option<&SnapshotName>, ) -> Result { let layer = &distro.layer; let settings = launch.settings(); @@ -314,6 +295,7 @@ pub(crate) fn serialize_launch( distro_binding: &distro.binding, scratch_binding: scratch.map(|scratch| &scratch.binding), export: launch.export(), + source_snapshot, }; pretty_serde(&record, DESCRIPTION) } @@ -375,9 +357,22 @@ pub(crate) struct Config { /// The sandbox's network, `null` for none, if recorded. #[serde(default, deserialize_with = "present")] network: Option, + /// The source of a restored sandbox, absent for a cold launch. + source_snapshot: Option, } impl Config { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL3.8a locks a restored sandbox's source snapshot" + ) + )] + pub(crate) fn source_snapshot(&self) -> Option<&SnapshotName> { + self.source_snapshot.as_ref() + } + /// Returns the fields of `object`, a configuration that [`read_config`] read, which fails as /// `sandbox configuration is malformed` without a field that it requires, or with a value of /// another type. @@ -403,6 +398,7 @@ impl Config { ), )); } + serde_json::from_value(Value::Object(object)).map_err(|_| malformed()) } diff --git a/nvx-rs/src/host/cli/sandbox/state/tests.rs b/nvx-rs/src/host/cli/sandbox/state/tests.rs new file mode 100644 index 000000000..6ec46cf9b --- /dev/null +++ b/nvx-rs/src/host/cli/sandbox/state/tests.rs @@ -0,0 +1,35 @@ +use super::*; +use crate::host::sandbox::layer::LayerRole; + +#[test] +fn restored_sandbox_records_round_trip_only_their_own_snapshot_name() { + let directory = tempfile::tempdir().unwrap(); + let image = directory.path().join("distro.erofs"); + std::fs::write(&image, b"distro layer\n").unwrap(); + let layer = SandboxLayer::new( + LayerRole::Distro, + image.to_str().unwrap(), + "11111111-1111-1111-1111-111111111111", + ); + let binding = DistroBinding::verify(&layer).unwrap(); + let launch = + SandboxLaunch::new(vec![layer], SandboxUpper::RAM, SandboxSettings::default()).unwrap(); + let distro = RecordedDistro::new(&launch, binding).unwrap(); + let options = ProvisionConfig { + hypervisor: "whp", + memory_mib: 128, + cmdline: "", + }; + for source in [None, Some("My.snapshot-1"), Some("other")] { + let name = source.map(|name| SnapshotName::try_from(name.to_owned()).unwrap()); + let text = serialize_launch_with_snapshot(&launch, &distro, None, &options, name.as_ref()) + .unwrap(); + let object: Object = serde_json::from_str(&text).unwrap(); + assert_eq!(object.contains_key("source_snapshot"), source.is_some()); + let record = Config::new(object.clone()).unwrap(); + assert_eq!(record.source_snapshot(), name.as_ref()); + let mut malformed = object; + malformed.insert("source_snapshot".to_owned(), Value::from("../other")); + assert!(Config::new(malformed).is_err()); + } +} diff --git a/nvx-rs/src/host/cli/snapshots.rs b/nvx-rs/src/host/cli/snapshots.rs new file mode 100644 index 000000000..044c8fd6c --- /dev/null +++ b/nvx-rs/src/host/cli/snapshots.rs @@ -0,0 +1,121 @@ +//! Immutable named snapshots and their external, exclusive locks. +//! +//! Names map to hexadecimal directory names under `snapshots/`. Each published directory +//! contains only the strict snapshot's three entries; its lock stays under `locks/`. +//! Completed private directories under `staging/` publish with one non-replacing rename. +#![cfg_attr( + not(test), + expect(dead_code, reason = "SL3.7b and SL3.8a use the named snapshot store") +)] + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use super::deadline::Deadline; +use super::store::{LockedState, StateDir}; +use super::{Exit, Result, hex, io_error, temporary_path}; +use crate::host::snapshot::Snapshot; +use crate::host::{platform, store}; +use crate::{Error, ErrorCode}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(try_from = "String")] +pub(crate) struct Name(String); + +impl TryFrom for Name { + type Error = Error; + + fn try_from(value: String) -> crate::Result { + if !(1..=64).contains(&value.len()) + || !value.as_bytes()[0].is_ascii_alphanumeric() + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte)) + { + return Err(Error::unsupported( + "a snapshot name must be 1 to 64 ASCII letters, digits, dots, underscores or \ + hyphens, starting with a letter or digit", + )); + } + Ok(Self(value)) + } +} + +#[derive(Debug)] +pub(crate) struct Store { + root: StateDir, + entries: StateDir, + locks: StateDir, + staging: StateDir, +} + +impl Store { + pub(crate) fn create(root: &Path) -> Result { + let root = StateDir::create(&std::path::absolute(root).map_err(Exit::from_os_error)?)?; + Ok(Self { + entries: StateDir::create(&root.dir().join("snapshots"))?, + locks: StateDir::create(&root.dir().join("locks"))?, + staging: StateDir::create(&root.dir().join("staging"))?, + root, + }) + } + + fn check(&self) -> Result { + for directory in [&self.root, &self.entries, &self.locks, &self.staging] { + StateDir::open(directory.dir())?; + } + Ok(()) + } + + pub(crate) fn lock(&self, name: &Name, deadline: Deadline) -> Result> { + self.check()?; + // Hex preserves case distinctions and avoids reserved host filenames. + let directory = hex(name.0.as_bytes()); + let lock = StateDir::create(&self.locks.dir().join(&directory))?.lock(deadline)?; + Ok(LockedSnapshot { + store: self, + dir: self.entries.dir().join(directory), + _lock: lock, + }) + } +} + +#[derive(Debug)] +pub(crate) struct LockedSnapshot<'a> { + store: &'a Store, + dir: PathBuf, + _lock: LockedState, +} + +impl LockedSnapshot<'_> { + pub(crate) fn stage(&self) -> Result { + self.store.check()?; + StateDir::create(&temporary_path(&self.store.staging.dir().join("snapshot"))?) + } + + pub(crate) fn open(&self) -> Result { + self.store.check()?; + Snapshot::open(&self.dir).map_err(Into::into) + } + + pub(crate) fn publish(&self, staged: StateDir) -> Result { + self.store.check()?; + if staged.dir().parent() != Some(self.store.staging.dir()) { + return Err(Exit::error( + ErrorCode::Io, + "only a private staging directory can publish a named snapshot", + )); + } + Snapshot::open(staged.dir())?; + store::files::sync_dir(staged.dir())?; + platform::rename_no_replace(staged.dir(), &self.dir) + .map_err(|error| io_error(ErrorCode::Io, &error, &self.dir))?; + store::files::sync_dir(self.store.entries.dir())?; + store::files::sync_dir(self.store.staging.dir())?; + self.open() + } +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/cli/snapshots/tests.rs b/nvx-rs/src/host/cli/snapshots/tests.rs new file mode 100644 index 000000000..4b75fa374 --- /dev/null +++ b/nvx-rs/src/host/cli/snapshots/tests.rs @@ -0,0 +1,238 @@ +use std::fs; +use std::path::Path; + +use super::*; +use crate::host::snapshot::tests::SnapshotFixture; +use crate::host::snapshot::{DESCRIPTION_FILE, KEY_FILE, MANIFEST_NAME, OPENVMM_DIR}; +use crate::host::tests::released; + +fn name(value: &str) -> Name { + Name::try_from(value.to_owned()).unwrap() +} + +fn staged(locked: &LockedSnapshot<'_>) -> StateDir { + let fixture = SnapshotFixture::new(); + let staged = locked.stage().unwrap(); + for entry in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + fs::rename(fixture.dir.join(entry), staged.dir().join(entry)).unwrap(); + } + staged +} + +fn link_directory(link: &Path, target: &Path) { + #[cfg(unix)] + std::os::unix::fs::symlink(target, link).unwrap(); + #[cfg(windows)] + crate::host::tests::junction(link, target); +} + +#[test] +fn names_are_bounded_portable_components_with_distinct_mappings() { + for invalid in [ + "", + ".", + "..", + ".hidden", + "../escape", + "a/b", + "a\\b", + "a:b", + "a b", + "a\nb", + "snapshot\u{e9}", + ] { + assert!(Name::try_from(invalid.to_owned()).is_err(), "{invalid:?}"); + } + assert!(Name::try_from("a".repeat(65)).is_err()); + assert!(Name::try_from("a".repeat(64)).is_ok()); + let root = tempfile::tempdir().unwrap(); + let store = Store::create(&root.path().join("store")).unwrap(); + let mut mapped = Vec::new(); + for valid in ["a", "CON", "con.", "A", "name.1-2_3"] { + let held = store.lock(&name(valid), Deadline::after(0.0)).unwrap(); + assert_eq!(held.dir.parent(), Some(store.entries.dir())); + assert!(!held.dir.exists()); + assert!(!held._lock.dir().starts_with(&held.dir)); + mapped.push( + held.dir + .file_name() + .unwrap() + .to_str() + .unwrap() + .to_ascii_lowercase(), + ); + } + mapped.sort(); + mapped.dedup(); + assert_eq!(mapped.len(), 5); +} + +#[test] +fn publication_is_strict_whole_and_cannot_replace_or_move_a_published_snapshot() { + let root = tempfile::tempdir().unwrap(); + let store = Store::create(&root.path().join("store")).unwrap(); + let held = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let pending = staged(&held); + fs::write(pending.dir().join("log"), b"not a snapshot entry").unwrap(); + assert!(held.publish(pending.clone()).is_err()); + assert!(pending.dir().exists()); + assert!(!held.dir.exists()); + fs::remove_file(pending.dir().join("log")).unwrap(); + let expected = Snapshot::open(pending.dir()).unwrap(); + let key = fs::read(pending.dir().join(KEY_FILE)).unwrap(); + let published = held.publish(pending.clone()).unwrap(); + assert!(!pending.dir().exists()); + assert_eq!(published.key(), expected.key()); + assert_eq!(published.manifest(), expected.manifest()); + let mut entries: Vec<_> = fs::read_dir(&held.dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + entries.sort(); + assert_eq!(entries, [KEY_FILE, OPENVMM_DIR, DESCRIPTION_FILE]); + let files = [ + held.dir.join(KEY_FILE), + held.dir.join(DESCRIPTION_FILE), + held.dir.join(OPENVMM_DIR).join(MANIFEST_NAME), + held.dir.join(OPENVMM_DIR).join("memory.bin"), + held.dir.join(OPENVMM_DIR).join("state.bin"), + ]; + let unchanged = || { + files + .iter() + .map(|path| { + let metadata = fs::metadata(path).unwrap(); + ( + platform::file_id(path).unwrap(), + metadata.len(), + metadata.modified().unwrap(), + ) + }) + .collect::>() + }; + let before = unchanged(); + let replacement = staged(&held); + assert!(held.publish(replacement.clone()).is_err()); + assert!(replacement.dir().exists()); + assert!(held.publish(StateDir::open(&held.dir).unwrap()).is_err()); + assert_eq!(fs::read(held.dir.join(KEY_FILE)).unwrap(), key); + assert_eq!( + fs::read(held.dir.join(DESCRIPTION_FILE)).unwrap(), + b"not JSON" + ); + assert_eq!(held.open().unwrap().key(), published.key()); + assert_eq!(unchanged(), before); +} + +#[test] +fn invalid_or_linked_directories_never_publish_or_open() { + let root = tempfile::tempdir().unwrap(); + let store = Store::create(&root.path().join("store")).unwrap(); + let held = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let pending = staged(&held); + crate::host::store::files::write_private(&pending.dir().join(KEY_FILE), b"bad key").unwrap(); + assert!(held.publish(pending.clone()).is_err()); + assert!(!held.dir.exists()); + let valid = staged(&held); + let expected = Snapshot::open(valid.dir()).unwrap(); + let identity = platform::file_id(valid.dir()).unwrap(); + let key = fs::read(valid.dir().join(KEY_FILE)).unwrap(); + let linked = held.stage().unwrap(); + fs::remove_dir(linked.dir()).unwrap(); + link_directory(linked.dir(), valid.dir()); + assert!(held.publish(linked).is_err()); + assert!(!held.dir.exists()); + link_directory(&held.dir, valid.dir()); + assert!(held.open().is_err()); + assert!(held.publish(staged(&held)).is_err()); + assert_eq!(platform::file_id(valid.dir()).unwrap(), identity); + assert_eq!(fs::read(valid.dir().join(KEY_FILE)).unwrap(), key); + assert_eq!(Snapshot::open(valid.dir()).unwrap().key(), expected.key()); + let alias = root.path().join("alias"); + link_directory(&alias, store.root.dir()); + assert!(Store::create(&alias).is_err()); +} + +#[test] +fn exclusive_snapshot_locks_wait_and_other_names_remain_independent() { + let root = tempfile::tempdir().unwrap(); + let store = Store::create(&root.path().join("store")).unwrap(); + let first = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let other_handle = Store::create(store.root.dir()).unwrap(); + assert!(matches!( + other_handle.lock(&name("first"), Deadline::after(0.0)), + Err(Exit::Error { + code: ErrorCode::SandboxBusy, + .. + }) + )); + let second = store.lock(&name("second"), Deadline::after(0.0)).unwrap(); + assert_ne!(first._lock.dir(), second._lock.dir()); + drop(first); + assert!(released(|| { + other_handle + .lock(&name("first"), Deadline::after(0.0)) + .is_ok() + })); + assert_eq!(fs::read_dir(store.entries.dir()).unwrap().count(), 0); +} + +#[cfg(target_os = "linux")] +#[test] +fn shared_directories_and_linked_locks_are_refused_without_restricting_them() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let root = tempfile::tempdir().unwrap(); + let shared = root.path().join("shared"); + fs::create_dir(&shared).unwrap(); + fs::set_permissions(&shared, fs::Permissions::from_mode(0o755)).unwrap(); + assert!(Store::create(&shared).is_err()); + assert_eq!( + fs::metadata(&shared).unwrap().permissions().mode() & 0o777, + 0o755 + ); + let store = Store::create(&root.path().join("store")).unwrap(); + let held = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let pending = staged(&held); + fs::set_permissions(pending.dir(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(held.publish(pending).is_err()); + let lock = held._lock.path(super::super::store::StateFile::Lock); + drop(held); + fs::remove_file(&lock).unwrap(); + let target = root.path().join("target"); + fs::write(&target, b"unchanged").unwrap(); + symlink(&target, &lock).unwrap(); + assert!(store.lock(&name("first"), Deadline::after(0.0)).is_err()); + assert_eq!(fs::read(&target).unwrap(), b"unchanged"); +} + +#[cfg(windows)] +#[test] +fn shared_directory_acls_are_refused_before_publication() { + use std::process::{Command, Stdio}; + + let allow_everyone = |path: &Path| { + let status = crate::host::tests::spawn_locked( + Command::new("icacls") + .arg(path) + .args(["/grant", "*S-1-1-0:(OI)(CI)(F)"]) + .stdout(Stdio::null()), + ) + .unwrap() + .wait() + .unwrap(); + assert!(status.success()); + }; + let root = tempfile::tempdir().unwrap(); + let shared = StateDir::create(&root.path().join("shared")).unwrap(); + allow_everyone(shared.dir()); + assert!(Store::create(shared.dir()).is_err()); + assert!(platform::check_store_dir(shared.dir()).unwrap().is_some()); + let store = Store::create(&root.path().join("store")).unwrap(); + let held = store.lock(&name("first"), Deadline::after(0.0)).unwrap(); + let pending = staged(&held); + allow_everyone(&pending.dir().join(OPENVMM_DIR)); + assert!(held.publish(pending.clone()).is_err()); + assert!(pending.dir().exists()); + assert!(!held.dir.exists()); +} diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index 16bcb2432..30a520a57 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -47,6 +47,7 @@ mod record; use std::io; use std::path::{Path, PathBuf}; +pub(crate) use self::locked::LockedState; pub(crate) use self::names::StateFile; use crate::ErrorCode; use crate::host::cli::{Exit, Result}; diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index adf7c96c6..8860914da 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -11,6 +11,8 @@ "nvx-rs/src/guest/sys/entry.rs": 3, "nvx-rs/src/guest/sys/spawn.rs": 7, "nvx-rs/src/guest/time/system.rs": 3, + "nvx-rs/src/host/cli/sandbox/state.rs": 1, + "nvx-rs/src/host/cli/snapshots.rs": 1, "nvx-rs/src/host/cli/store/mod.rs": 1, "nvx-rs/src/host/network/address.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, From cc4ded03fc2c0d4860eebb86d98b913aac174fca Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:44:52 -0800 Subject: [PATCH 12/25] cli: recover interrupted launches under the sandbox lock Use verified endpoint and log claims for unrecorded launches and boot/PID/start identities for recorded processes. Require confirmed exit before removing capability and journal; retain indeterminate evidence and its refusal cause. Keep committed-runtime recovery separate and bounded claim rechecks intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/launch.rs | 123 ++++++- nvx-rs/src/host/cli/store/locked.rs | 11 +- nvx-rs/src/host/cli/store/mod.rs | 3 + nvx-rs/src/host/cli/store/tests.rs | 1 + nvx-rs/src/host/cli/store/tests/launch.rs | 17 +- nvx-rs/src/host/cli/store/tests/recovery.rs | 362 ++++++++++++++++++++ 6 files changed, 505 insertions(+), 12 deletions(-) create mode 100644 nvx-rs/src/host/cli/store/tests/recovery.rs diff --git a/nvx-rs/src/host/cli/store/launch.rs b/nvx-rs/src/host/cli/store/launch.rs index d0a5c04e6..d3cdb0100 100644 --- a/nvx-rs/src/host/cli/store/launch.rs +++ b/nvx-rs/src/host/cli/store/launch.rs @@ -1,13 +1,17 @@ -//! Locked launch transitions, without spawning, recovery, or process termination. +//! Locked launch transitions and bounded recovery of an interrupted launch. + +use std::thread; +use std::time::{Duration, Instant}; use super::StateFile; use super::files; use super::locked::LockedState; use super::record::{LaunchJournal, RuntimeRecord}; -use crate::host::process::ProcessRef; +use crate::host::cli::deadline::RETRY_INTERVAL; +use crate::host::process::{BootId, KILL_TIMEOUT, Opened, Process, ProcessRef}; use crate::host::runtime::RuntimeIdentity; use crate::host::vm::{Prepared, SavedVm}; -use crate::{Error, Result}; +use crate::{Error, ErrorKind, Result}; /// A launch whose lock stays borrowed until commit or until its journal is left behind. #[derive(Debug)] @@ -17,6 +21,77 @@ pub(crate) struct Launch<'a> { } impl LockedState { + /// Resolves only an uncommitted journal, before the next command can mutate state. + pub(crate) fn recover(&mut self, deadline: Instant) -> Result<()> { + if self.runtime()?.is_some() { + return Ok(()); + } + let Some(journal) = self.journal()? else { + return Ok(()); + }; + if journal.vm_dir != self.dir() || journal.log != self.path(StateFile::Log) { + return Err(Error::io( + "the launch journal belongs to another sandbox directory", + )); + } + match journal.process() { + Some(reference) => self.end_recorded(&journal, reference, deadline)?, + None if !self.released_claim(&journal, deadline)? => { + let process = self.endpoint_owner(&journal)?; + end_process(&process, deadline)?; + } + None => {} + } + if self.capability()?.is_some() { + files::remove(self, StateFile::Capability)?; + } + // Keep the journal until cleanup has finished, including after a partial removal. + files::remove(self, StateFile::Journal) + } + + /// Ends only the recorded process, or accepts an independently proved release of its claim. + fn end_recorded( + &self, + journal: &LaunchJournal, + reference: &ProcessRef, + deadline: Instant, + ) -> Result<()> { + let boot = BootId::current().map_err(|error| { + Error::io("cannot identify the current host boot").with_source(error) + })?; + if boot != reference.boot { + return Ok(()); + } + let unheld = match Process::open(reference) { + Ok(Opened::Running(process)) => return end_process(&process, deadline), + Ok(Opened::Exited(_)) => Error::new( + ErrorKind::StillRunning, + "the recorded VM exited, but a process may still hold its launch log", + ), + Err(error) => Error::io("cannot verify the recorded VM process").with_source(error), + }; + if self.released_claim(journal, deadline)? { + Ok(()) + } else { + Err(unheld) + } + } + + /// Rechecks transient fork-inherited descriptors for at most one second. + fn released_claim(&self, journal: &LaunchJournal, deadline: Instant) -> Result { + let until = deadline.min(Instant::now() + Duration::from_secs(1)); + loop { + if !self.claim_held(journal)? { + return Ok(true); + } + let left = until.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Ok(false); + } + thread::sleep(left.min(RETRY_INTERVAL)); + } + } + /// Durably publishes the capability and complete prepared journal before the caller spawns. pub(crate) fn begin_launch( &mut self, @@ -38,6 +113,48 @@ impl LockedState { } } +/// Ends a held, verified process and requires confirmation of its exit before the deadline. +fn end_process(process: &Process, deadline: Instant) -> Result<()> { + let failed = |message: &str| { + Error::new( + ErrorKind::StillRunning, + format!( + "cannot confirm exit of VM process {}: {message}", + process.reference().pid + ), + ) + }; + if process + .wait(Instant::now()) + .map_err(|error| failed("the exit check failed").with_source(error))? + .is_some() + { + return Ok(()); + } + let until = deadline.min(Instant::now() + KILL_TIMEOUT); + if until <= Instant::now() { + return Err(failed("the recovery deadline passed")); + } + let kill_error = process.kill().err(); + let exited = process.wait(until).map_err(|error| { + let mut failure = failed("the exit wait failed"); + if let Some(kill_error) = &kill_error { + failure = failed(&format!( + "termination failed: {kill_error}; the exit wait failed" + )); + } + failure.with_source(error) + })?; + if exited.is_some() { + return Ok(()); + } + let failure = failed("the verified process has not exited"); + Err(match kill_error { + Some(error) => failure.with_source(error), + None => failure, + }) +} + impl Launch<'_> { /// Records only the caller's spawned process, durably, before changing the in-memory state. pub(crate) fn record_process(&mut self, process: &ProcessRef) -> Result<()> { diff --git a/nvx-rs/src/host/cli/store/locked.rs b/nvx-rs/src/host/cli/store/locked.rs index 68737e290..35bf46ffb 100644 --- a/nvx-rs/src/host/cli/store/locked.rs +++ b/nvx-rs/src/host/cli/store/locked.rs @@ -54,6 +54,9 @@ impl StateDir { /// access, who could hold its lock. A lock file that another transition removed or replaced /// after this opened it locks nothing, so the lock is taken again: at once the first time, /// and after a pause, until the deadline, when that happens again right after. + /// + /// Once held, the lock recovers an interrupted launch before returning. Recovery can end a + /// verified VM, but retains indeterminate records and reports the cause of its refusal. pub(crate) fn lock(&self, deadline: Deadline) -> Result { let path = self.path(StateFile::Lock); let mut retried_at_once = false; @@ -71,10 +74,14 @@ impl StateDir { } } if names_locked_file(&path, &file)? { - return Ok(LockedState { + let mut locked = LockedState { state: self.clone(), lock: file, - }); + }; + locked + .recover(deadline.instant()) + .map_err(crate::host::cli::sandbox::error_with_cause)?; + return Ok(locked); } if retried_at_once && !deadline.pause(RETRY_INTERVAL)? { return Err(busy()); diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index ac58708e0..43f2197df 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -33,6 +33,9 @@ //! Its journal also identifies the inherited log claim: held means a launch process may run, //! and released proves exit. An endpoint owner is held and rechecked before it is returned; //! Linux additionally verifies that this process holds that launch's log. +//! Taking the next lock recovers an interrupted, uncommitted launch before allowing mutation: +//! it ends only a verified process, removes its journal and capability only after proved exit, +//! and refuses indeterminate recovery without changing that evidence. #![cfg_attr( not(test), expect( diff --git a/nvx-rs/src/host/cli/store/tests.rs b/nvx-rs/src/host/cli/store/tests.rs index 5db8c5adb..4dabc42a6 100644 --- a/nvx-rs/src/host/cli/store/tests.rs +++ b/nvx-rs/src/host/cli/store/tests.rs @@ -10,6 +10,7 @@ use crate::host::store::{JOURNAL_NAME, RUNTIME_NAME, SandboxStore}; mod launch; mod liveness; +mod recovery; /// How long a test waits for a lock that it released to be free. /// diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs index 5fd38d31a..8712ea4ff 100644 --- a/nvx-rs/src/host/cli/store/tests/launch.rs +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -175,16 +175,19 @@ fn an_interruption_keeps_the_journal_and_capability_without_publishing_runtime() launch.record_process(&saved.process).unwrap(); } } + let before = fs::read(state.path(StateFile::Journal)).unwrap(); + assert!(locked.begin_launch(&prepared, &runtime).is_err()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); drop(locked); - let mut reopened = state.lock(Deadline::after(10.0)).unwrap(); - let journal = reopened.journal().unwrap().unwrap(); + let journal: record::LaunchJournal = + serde_json::from_slice(&fs::read(state.path(StateFile::Journal)).unwrap()).unwrap(); assert_eq!(journal.process(), spawned.then_some(&saved.process)); assert_eq!(journal.capability, saved.capability); - assert_eq!(reopened.capability().unwrap(), Some(saved.capability)); - assert!(reopened.runtime().unwrap().is_none()); - let before = fs::read(state.path(StateFile::Journal)).unwrap(); - assert!(reopened.begin_launch(&prepared, &runtime).is_err()); - assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + saved.capability + ); + assert!(!state.path(StateFile::Runtime).exists()); } } diff --git a/nvx-rs/src/host/cli/store/tests/recovery.rs b/nvx-rs/src/host/cli/store/tests/recovery.rs new file mode 100644 index 000000000..fae235449 --- /dev/null +++ b/nvx-rs/src/host/cli/store/tests/recovery.rs @@ -0,0 +1,362 @@ +use super::launch::prepared; +use super::*; +use crate::host::cli::store::files; +use crate::host::config::ConsoleAttachment; + +#[test] +fn the_next_lock_cleans_a_pre_spawn_journal_only_after_its_claim_is_released() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + drop(locked); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + let started = Instant::now(); + assert!(state.lock(Deadline::after(0.05)).is_err()); + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + drop(prepared); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + assert!(state.path(StateFile::Log).exists()); +} + +#[test] +fn a_transient_inherited_log_descriptor_is_rechecked_before_refusing_recovery() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let inherited = prepared.log().try_clone().unwrap(); + drop(prepared); + drop(locked); + let release = thread::spawn(move || { + thread::sleep(Duration::from_millis(80)); + drop(inherited); + }); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + release.join().unwrap(); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); +} + +#[test] +fn an_interrupted_cleanup_with_no_capability_can_finish_idempotently() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + files::remove(&locked, StateFile::Capability).unwrap(); + drop(prepared); + locked + .recover(Instant::now() + Duration::from_secs(10)) + .unwrap(); + assert!(locked.journal().unwrap().is_none()); + locked.recover(Instant::now()).unwrap(); +} + +#[test] +fn replaced_logs_and_foreign_journal_paths_keep_all_evidence() { + for foreign in [false, true] { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + drop(prepared); + if foreign { + let mut journal = locked.journal().unwrap().unwrap(); + journal.vm_dir = state.dir().join("another-vm"); + locked.write_record(StateFile::Journal, &journal).unwrap(); + } else { + fs::rename(state.path(StateFile::Log), state.dir().join("old-log")).unwrap(); + fs::write(state.path(StateFile::Log), b"another log").unwrap(); + } + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + assert!(locked.recover(Instant::now()).is_err()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + drop(locked); + let (_, message) = error(state.lock(Deadline::after(0.0)).unwrap_err()); + let cause = if foreign { + "another sandbox directory" + } else { + "is not the claimed log" + }; + assert!(message.contains(cause), "{message}"); + } +} + +#[cfg(any(target_os = "linux", windows))] +mod process { + #[cfg(target_os = "linux")] + use std::process::{Child, Command, Stdio}; + + use super::*; + use crate::host::process::{BootId, Process, ProcessRef}; + use crate::host::spawn::{self, VmmCommand}; + use crate::host::vm::{Prepared, SavedVm, VmDir}; + + const HELPER: &str = "host::cli::store::tests::recovery::process::launch_helper"; + + struct Running(Option); + + impl Running { + fn process(&self) -> Process { + self.0.as_ref().unwrap().process().unwrap() + } + } + + impl Drop for Running { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.kill_and_confirm_exit().unwrap(); + } + } + } + + #[cfg(target_os = "linux")] + struct Other(Child); + + #[cfg(target_os = "linux")] + impl Drop for Other { + fn drop(&mut self) { + if self.0.try_wait().unwrap().is_none() { + self.0.kill().unwrap(); + } + self.0.wait().unwrap(); + } + } + + fn running(state: &StateDir, prepared: &Prepared) -> Running { + fs::write( + state.dir().join("recovery-endpoint"), + &prepared.outputs().endpoint, + ) + .unwrap(); + let command = VmmCommand::new( + std::env::current_exe().unwrap(), + ["--exact", HELPER, "--ignored", "--nocapture"] + .map(Into::into) + .to_vec(), + state.dir(), + ); + let child = Running(Some( + spawn::spawn_vm( + &command, + prepared.capability().as_bytes(), + prepared.log().try_clone().unwrap(), + ) + .unwrap(), + )); + assert!(released(|| state.dir().join("recovery-ready").exists())); + child + } + + #[test] + fn an_unrecorded_vm_requires_both_endpoint_and_log_proofs_before_it_is_ended() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let child = running(&state, &prepared); + let process = child.process(); + drop(prepared); + drop(locked); + assert!(!process.has_exited().unwrap()); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(process.wait(Instant::now()).unwrap().is_some()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + } + + #[cfg(target_os = "linux")] + #[test] + fn an_endpoint_owner_without_the_log_is_not_signaled_and_its_journal_stays() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + fs::write( + state.dir().join("recovery-endpoint"), + &prepared.outputs().endpoint, + ) + .unwrap(); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", HELPER, "--ignored", "--nocapture"]) + .current_dir(state.dir()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut other = Other(crate::host::tests::spawn_locked(&mut command).unwrap()); + assert!(released(|| state.dir().join("recovery-ready").exists())); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + assert!( + locked + .recover(Instant::now() + Duration::from_secs(2)) + .is_err() + ); + assert!(other.0.try_wait().unwrap().is_none()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + } + + #[test] + fn a_recorded_process_is_ended_only_after_a_matching_identity_and_confirmed_exit() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + launch.record_process(process.reference()).unwrap(); + drop(launch); + drop(prepared); + assert!(locked.recover(Instant::now()).is_err()); + assert!(!process.has_exited().unwrap()); + assert!(locked.journal().unwrap().is_some()); + assert!(locked.capability().unwrap().is_some()); + locked + .recover(Instant::now() + Duration::from_secs(10)) + .unwrap(); + assert!(process.wait(Instant::now()).unwrap().is_some()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + } + + #[test] + fn incorrect_recorded_ids_and_start_times_never_signal_a_live_process_or_drop_its_claim() { + for mismatch in 0..3 { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + let mut reference = process.reference().clone(); + match mismatch { + 0 => reference.pid = 0, + 1 => { + reference.pid = std::process::id(); + reference.start_time = platform::process_start_time(reference.pid) + .unwrap() + .unwrap() + + 1; + } + 2 => reference.start_time += 1, + _ => unreachable!(), + } + launch.record_process(&reference).unwrap(); + drop(launch); + drop(prepared); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + assert!( + locked + .recover(Instant::now() + Duration::from_secs(2)) + .is_err() + ); + assert!(!process.has_exited().unwrap()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + } + } + + #[test] + fn an_earlier_boot_proves_exit_without_signaling_a_process_from_this_boot() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + let mut boot = *BootId::current().unwrap().as_bytes(); + boot[0] ^= 1; + launch + .record_process(&ProcessRef { + boot: BootId::from_bytes(boot), + ..process.reference().clone() + }) + .unwrap(); + drop(launch); + drop(prepared); + locked.recover(Instant::now()).unwrap(); + assert!(!process.has_exited().unwrap()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + } + + #[test] + fn a_committed_runtime_is_not_recovered_through_its_leftover_journal() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + launch.record_process(process.reference()).unwrap(); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + launch + .commit(&SavedVm { + vm_dir: VmDir::open(state.dir()).unwrap(), + endpoint: prepared.outputs().endpoint.clone(), + capability: *prepared.capability().as_bytes(), + process: process.reference().clone(), + runtime, + }) + .unwrap(); + files::replace(&locked, StateFile::Journal, &journal).unwrap(); + let before = fs::read(state.path(StateFile::Runtime)).unwrap(); + drop(prepared); + drop(locked); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(!process.has_exited().unwrap()); + assert_eq!(fs::read(state.path(StateFile::Runtime)).unwrap(), before); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert!(locked.capability().unwrap().is_some()); + } + + #[test] + #[ignore = "invoked as an owned subprocess by interrupted-launch recovery tests"] + fn launch_helper() { + let endpoint = fs::read_to_string("recovery-endpoint").unwrap(); + #[cfg(target_os = "linux")] + let _server = std::os::unix::net::UnixListener::bind(endpoint).unwrap(); + #[cfg(windows)] + let server = platform::tests::control_server(&endpoint); + fs::write("recovery-ready", b"").unwrap(); + #[cfg(target_os = "linux")] + thread::sleep(Duration::from_secs(30)); + #[cfg(windows)] + { + use std::io::Read; + + loop { + platform::tests::accept_control(&server); + match (&server).read(&mut [0]) { + Ok(0) => {} + Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => {} + result => panic!("unexpected endpoint probe: {result:?}"), + } + platform::tests::disconnect_control(&server); + } + } + } +} From 573f0badf25cb00b22de85eda4a1b15bc75f9d7e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:49:24 -0800 Subject: [PATCH 13/25] guest: activate repaired init sessions Integrate only the restored activation caller on qualified capture and announcer inputs. Preserve bounded signal-aware control I/O, retain one activation across resets, and keep postactivation captures refused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dee059e7-7968-43d8-82d0-abd68b922eed --- nvx-rs/README.md | 10 +- nvx-rs/src/guest/init.rs | 30 ++- nvx-rs/src/guest/init/boot.rs | 190 +++++++++++++- nvx-rs/src/guest/init/boot/tests.rs | 213 ++++++++++++++++ nvx-rs/src/guest/init/tests/capture.rs | 45 ++++ nvx-rs/src/guest/managed_agent/dispatch.rs | 28 +- .../src/guest/managed_agent/dispatch/tests.rs | 135 ++++++++++ nvx-rs/src/guest/managed_agent/session.rs | 240 +++++++++++++++--- .../src/guest/managed_agent/session/tests.rs | 232 +++++++++++++++++ nvx-rs/src/protocol/app.rs | 10 +- nvx-rs/src/protocol/mod.rs | 11 +- scripts/test-layout-allowlist.json | 2 +- 12 files changed, 1086 insertions(+), 60 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index cd9c6f7ca..01bce1e2b 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -3193,7 +3193,15 @@ disk's block device. Each binary's command line, exit statuses, and byte output are compatibility contracts; the [`nvx::guest`](src/guest/mod.rs) documentation describes each -program. Guests run them as static `x86_64-unknown-linux-musl` executables. On +program. After a restored platform capture, `nvx-init` keeps one activation +across control-session replacements, with its deadline anchored to the repair +acknowledgement. It applies the restore's root and network, mounts the runtime, +starts and retains an announcer only for published listeners, switches once, +then completes and reports Ready. The host supplies the complete mapping table +before the first valid EXEC starts the restored workload; later executions and +byte-identical activation retries reuse the retained state. + +Guests run them as static `x86_64-unknown-linux-musl` executables. On Linux, with the musl target and a C compiler driver, `cc`, `nvx.py` builds them: ```bash diff --git a/nvx-rs/src/guest/init.rs b/nvx-rs/src/guest/init.rs index 15c760f1a..61d940e9d 100644 --- a/nvx-rs/src/guest/init.rs +++ b/nvx-rs/src/guest/init.rs @@ -58,6 +58,7 @@ use super::sys::{ }; mod boot; +pub(crate) use boot::{RestoreHandoff, Restored}; /// The process ID of the init process. const INIT_PROCESS: i32 = 1; @@ -183,7 +184,7 @@ pub(crate) struct Capture { /// Request ID, kill flag and the original pre-request deadline. pending: Option<(u64, bool, Instant)>, /// The repair whose RESET must be acknowledged before later activation. - repaired: Option, + pub(crate) restored: Option, /// A fatal capture disposition, whose status PID 1 must preserve. shutdown: Option, } @@ -213,14 +214,14 @@ impl Capture { helpers, fresh, pending: None, - repaired: None, + restored: None, shutdown: None, } } /// Keeps one pending request and anchors its deadline before cleanup. pub(crate) fn queue(&mut self, request_id: u64, kill: bool) -> bool { - if self.pending.is_some() || self.repaired.is_some() || self.shutdown.is_some() { + if self.pending.is_some() || self.restored.is_some() || self.shutdown.is_some() { return false; } self.pending = Some((request_id, kill, Instant::now() + CAPTURE_TIMEOUT)); @@ -239,12 +240,14 @@ impl Capture { /// Whether this session must admit no root-dependent operation. pub(crate) fn rootless(&self) -> bool { - self.fresh || self.repaired.is_some() + self.restored.as_ref().map_or(self.fresh, |restored| { + restored.activation.established().is_none() + }) } - /// Keeps the original repair for later activation, which this slice does not perform. + /// Starts one activation from the repair's original acknowledgement. pub(crate) fn restored(&mut self, repaired: Repaired) { - self.repaired = Some(repaired); + self.restored = Some(Restored::new(&repaired, Instant::now())); } /// Records the fatal status before the loop returns directly to PID 1. @@ -309,6 +312,14 @@ pub(crate) fn report_capture(failure: impl std::fmt::Display) { ); } +/// Reports an activation failure before PID 1 ends the VM. +pub(crate) fn report_activation(failure: impl std::fmt::Display) { + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-ACTIVATION: {failure}\n").as_bytes(), + ); +} + /// Whether execution requires an account in the shared root's databases. #[cfg_attr( not(test), @@ -996,13 +1007,6 @@ impl Runtime { /// Starts ingress from the applied root's publication and gateway, before its root switch. /// The fork keeps the initramfs root and PID 1's priority; the sole collector owns its exit. - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "SL7.7b starts the restored announcer before the root switch" - ) - )] pub(crate) fn restore_announcer( &mut self, publication: Publication, diff --git a/nvx-rs/src/guest/init/boot.rs b/nvx-rs/src/guest/init/boot.rs index 2e66abc53..62c427890 100644 --- a/nvx-rs/src/guest/init/boot.rs +++ b/nvx-rs/src/guest/init/boot.rs @@ -1,13 +1,17 @@ -//! The fixed cold-boot sequence of the VM's shared layered root. +//! The fixed cold-boot and restored-space setup of the VM's shared layered root. use std::ffi::{CString, OsStr}; use std::net::IpAddr; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::activation::{self, View}; use crate::gpt::Expected; +use crate::guest::activation::{ + self as guest_activation, Activation, ActivationAction, ActivationInput, ActivationState, + Applied, RootTemplate, +}; use crate::guest::mappings::{Access, Mapper, MappingTable, Reserved, SourceRoot, TargetRoot}; use crate::guest::root::{self, Assembly, Device, Root, ShareChild, ShareKind}; use crate::guest::sys::FileDescriptor; @@ -323,5 +327,187 @@ fn share_child(value: &[u8]) -> Result { }) } +/// One repaired activation and the space it owns across control epochs. +pub(crate) struct Restored { + pub(crate) activation: Activation, + payload: Option>, + _applied: Option, +} + +/// The account and open mapping handles handed to the shared dispatcher after activation. +pub(crate) struct RestoreHandoff { + pub(crate) workload: protocol::workload::WorkloadIdentity, + pub(crate) user: CString, + pub(crate) home: CString, + pub(crate) hostname: CString, + pub(crate) table: MappingTable, + pub(crate) mapper: Mapper, + pub(crate) export: Option, +} + +impl Restored { + /// Anchors the activation once, even when the next session attaches later. + pub(crate) fn new(repaired: &crate::guest::snapshot::Repaired, now: Instant) -> Self { + Self { + activation: Activation::new(repaired, Duration::from_secs(30), now), + payload: None, + _applied: None, + } + } + + /// Takes the first bootstrap reset or checks the same deadline on a replacement session. + pub(crate) fn reset(&mut self, now: Instant) -> Result<()> { + if self.activation.state() == ActivationState::Bootstrap { + self.activation + .input(now, ActivationInput::Reset) + .map_err(activation_error)?; + } else { + self.activation.tick(now).map_err(activation_error)?; + } + Ok(()) + } + + /// Compares the complete retry payload before delegating record replay to the state machine. + pub(crate) fn accept( + &mut self, + payload: &[u8], + now: Instant, + ) -> Result<(ActivationAction, protocol::workload::WorkloadIdentity)> { + if self + .payload + .as_deref() + .is_some_and(|accepted| accepted != payload) + { + return Err(Error::unsupported( + "the activate request changed after acceptance", + )); + } + let (_, workload) = protocol::decode_activate_payload(payload).map_err(protocol_error)?; + let action = self + .activation + .input(now, ActivationInput::Activate(&payload[9..])) + .map_err(activation_error)?; + if matches!(action, ActivationAction::Apply(_)) { + self.payload = Some(payload.to_vec()); + } + Ok((action, workload)) + } + + /// Applies and switches one restored space, retaining its root and handing over its mapper. + pub(crate) fn apply( + &mut self, + record: &activation::ActivationRecord, + workload: protocol::workload::WorkloadIdentity, + mut announce: impl FnMut(activation::Publication, Option) -> Result<()>, + ) -> Result { + let command_line = std::fs::read("/proc/cmdline").map_err(activation_error)?; + let template = RootTemplate { + runtime: "/run/nvx".into(), + distro: Device { + mmio: DISTRO_MMIO_BASE, + sectors: None, + }, + scratch: Device { + mmio: SCRATCH_MMIO_BASE, + sectors: None, + }, + timeout: Duration::from_secs(30), + filesystem_tag: "microvm".into(), + gateway: cmdline::gateway(&command_line).map_err(protocol_error)?, + }; + let (applied, handoff) = restore_in_order( + self.activation.deadline(), + || { + guest_activation::apply(record, &template, self.activation.deadline()) + .map_err(activation_error) + }, + |applied| { + applied + .root_mut() + .mount_runtime(Some(Path::new("/run/nvx/time"))) + .map_err(activation_error)?; + let mut target = + TargetRoot::open(applied.root().path()).map_err(activation_error)?; + target + .add_mount(&applied.root().path().join("run")) + .map_err(activation_error)?; + let (user, home) = account::resolve( + applied.root().path(), + (workload.uid, workload.gid), + workload.create_account, + )?; + Ok(RestoreHandoff { + workload, + user, + home, + hostname: CString::new(applied.hostname()).map_err(activation_error)?, + table: u32::try_from(record.configuration().mappings) + .ok() + .and_then(MappingTable::new) + .ok_or_else(|| { + Error::unsupported("the restored mapping count is invalid") + })?, + mapper: Mapper::new(target, Reserved::container_root()), + export: applied.take_export(), + }) + }, + |applied| { + if applied.publication() == activation::Publication::Published { + announce(applied.publication(), applied.gateway())?; + } + Ok(()) + }, + |applied| applied.root_mut().switch().map_err(activation_error), + |applied| applied.teardown().map_err(activation_error), + )?; + self._applied = Some(applied); + Ok(handoff) + } +} + +/// Closes prepared handles before tearing down a space whose setup failed. +fn restore_in_order( + deadline: Option, + apply: impl FnOnce() -> Result, + mount_runtime: impl FnOnce(&mut T) -> Result, + announce: impl FnOnce(&T) -> Result<()>, + switch: impl FnOnce(&mut T) -> Result<()>, + teardown: impl FnOnce(T) -> Result<()>, +) -> Result<(T, R)> { + let check = || { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + Err(Error::io( + "the activation deadline expired before the next space step", + )) + } else { + Ok(()) + } + }; + check()?; + let mut applied = apply()?; + let result = check() + .and_then(|()| mount_runtime(&mut applied)) + .and_then(|retained| { + check()?; + announce(&applied)?; + check()?; + switch(&mut applied)?; + Ok(retained) + }); + match result { + Ok(retained) => Ok((applied, retained)), + Err(error) => match teardown(applied) { + Ok(()) => Err(error), + Err(cleanup) => { + Err(Error::io(format!("{error}; cleanup failed: {cleanup}")).with_source(error)) + } + }, + } +} + +fn activation_error(error: impl std::error::Error + Send + Sync + 'static) -> Error { + Error::io(format!("failed to activate the restored root: {error}")).with_source(error) +} + #[cfg(test)] mod tests; diff --git a/nvx-rs/src/guest/init/boot/tests.rs b/nvx-rs/src/guest/init/boot/tests.rs index 8ad287155..6ec9fef60 100644 --- a/nvx-rs/src/guest/init/boot/tests.rs +++ b/nvx-rs/src/guest/init/boot/tests.rs @@ -9,6 +9,219 @@ const CLI: &str = "nvx_identity=1000:1001 nvx_control_tty=hvc2"; const EXPORT: &str = "virtfs_dir=/run/nvx/hostfs/root virtfs_tag=hostfs virtfs_mode=rw virtfs_aggregate=1"; +#[test] +fn restored_sessions_keep_the_deadline_of_the_actual_repair_acknowledgement() { + let repaired = crate::guest::snapshot::repair::tests::repaired_fixture(); + let acknowledged = repaired.acknowledged_at().unwrap(); + let mut restored = Restored::new(&repaired, acknowledged + Duration::from_secs(12)); + let deadline = acknowledged + Duration::from_secs(30); + assert_eq!(restored.activation.deadline(), Some(deadline)); + restored + .reset(acknowledged + Duration::from_secs(13)) + .unwrap(); + restored + .reset(acknowledged + Duration::from_secs(20)) + .unwrap(); + assert_eq!(restored.activation.deadline(), Some(deadline)); + let (_, payload) = super::super::Capture::activation_fixture(); + assert!(matches!( + restored + .accept(&payload, acknowledged + Duration::from_secs(21)) + .unwrap() + .0, + ActivationAction::Apply(_) + )); + restored + .reset(acknowledged + Duration::from_secs(29)) + .unwrap(); + assert!(matches!( + restored + .accept(&payload, acknowledged + Duration::from_secs(29)) + .unwrap() + .0, + ActivationAction::Join + )); + assert_eq!(restored.activation.deadline(), Some(deadline)); + assert!( + restored + .reset(deadline) + .unwrap_err() + .to_string() + .contains("deadline") + ); + assert_eq!(restored.activation.state(), ActivationState::Failed); +} + +#[test] +fn restored_resource_steps_finish_before_completion_and_ready() { + use crate::activation::{Network, Publication, ReadyActivation}; + use std::cell::RefCell; + let (mut capture, mut payload) = super::super::Capture::activation_fixture(); + let (record, _) = protocol::decode_activate_payload(&payload).unwrap(); + let mut configuration = record.configuration().clone(); + configuration.network = Some(Network { + dns: Some("10.88.0.53".parse().unwrap()), + publication: Publication::Published, + proxy: Some("http://10.88.0.1:3128".into()), + }); + let record = + activation::ActivationRecord::new(record.restore().copied(), configuration).unwrap(); + payload.truncate(9); + payload.extend(record.encode()); + let restored = capture.restored.as_mut().unwrap(); + let (ActivationAction::Apply(accepted), _) = restored.accept(&payload, Instant::now()).unwrap() + else { + panic!("one accepted activation"); + }; + let calls = RefCell::new(Vec::new()); + let (retained, ()) = restore_in_order( + None, + || { + calls.borrow_mut().push("apply"); + Ok(accepted.configuration().clone()) + }, + |space| { + calls.borrow_mut().push("mount-runtime"); + assert_eq!( + space.network.as_ref().unwrap().dns, + Some("10.88.0.53".parse().unwrap()) + ); + assert_eq!( + space.upper, + activation::Upper::Tmpfs { + max_memory_percent: 63 + } + ); + Ok(()) + }, + |space| { + assert_eq!( + space.network.as_ref().unwrap().publication, + Publication::Published + ); + calls.borrow_mut().push("owned-announcer"); + Ok(()) + }, + |_| { + calls.borrow_mut().push("switch"); + Ok(()) + }, + |_| panic!("a successful switched root is retained"), + ) + .unwrap(); + assert_eq!(retained.network, accepted.configuration().network); + let ready = restored + .activation + .complete(Instant::now(), Ok(())) + .unwrap(); + calls.borrow_mut().push("complete"); + assert_eq!(ready, ReadyActivation::of(&accepted)); + calls.borrow_mut().push("ready"); + assert_eq!( + *calls.borrow(), + [ + "apply", + "mount-runtime", + "owned-announcer", + "switch", + "complete", + "ready" + ] + ); + assert!(matches!( + restored.accept(&payload, Instant::now()).unwrap().0, + ActivationAction::Ready(_) + )); +} + +#[test] +fn restored_setup_drops_prepared_handles_before_cleanup_and_preserves_both_errors() { + use std::cell::RefCell; + struct Handle<'a>(&'a RefCell>); + impl Drop for Handle<'_> { + fn drop(&mut self) { + self.0.borrow_mut().push("close-mapper"); + } + } + let calls = RefCell::new(Vec::new()); + let error = restore_in_order( + None, + || { + calls.borrow_mut().push("apply"); + Ok(()) + }, + |_| { + calls.borrow_mut().push("mount-runtime"); + Ok(Handle(&calls)) + }, + |_| { + calls.borrow_mut().push("announcer-failed"); + Err(Error::io("announcer failed")) + }, + |_| panic!("failed setup cannot switch"), + |_| { + calls.borrow_mut().push("teardown"); + Err(Error::io("teardown failed")) + }, + ) + .err() + .unwrap(); + assert_eq!( + *calls.borrow(), + [ + "apply", + "mount-runtime", + "announcer-failed", + "close-mapper", + "teardown" + ] + ); + assert!(error.to_string().contains("announcer failed")); + assert!(error.to_string().contains("teardown failed")); + assert!( + std::error::Error::source(&error) + .unwrap() + .to_string() + .contains("announcer failed") + ); +} + +#[test] +fn an_expired_activation_cannot_begin_or_advance_to_another_space_step() { + assert!( + restore_in_order::<(), ()>( + Some(Instant::now()), + || panic!("an expired activation cannot apply"), + |_| panic!("cannot mount"), + |_| panic!("cannot announce"), + |_| panic!("cannot switch"), + |_| panic!("nothing was applied"), + ) + .is_err() + ); + let deadline = Instant::now() + Duration::from_secs(1); + let applied = std::cell::Cell::new(false); + let cleaned = std::cell::Cell::new(false); + let error = restore_in_order::<(), ()>( + Some(deadline), + || { + applied.set(true); + std::thread::sleep(deadline.saturating_duration_since(Instant::now())); + Ok(()) + }, + |_| panic!("an application that used its deadline cannot mount runtime"), + |_| panic!("cannot announce"), + |_| panic!("cannot switch"), + |_| { + cleaned.set(true); + Ok(()) + }, + ) + .unwrap_err(); + assert_eq!(cleaned.get(), applied.get()); + assert!(error.to_string().contains("deadline")); +} + fn tokens(extra: &str) -> Tokens { Tokens::parse(format!("{ROOT} {VIEW} {CLI} {extra}").as_bytes()).unwrap() } diff --git a/nvx-rs/src/guest/init/tests/capture.rs b/nvx-rs/src/guest/init/tests/capture.rs index 1fddd18c4..f71d1b97e 100644 --- a/nvx-rs/src/guest/init/tests/capture.rs +++ b/nvx-rs/src/guest/init/tests/capture.rs @@ -14,6 +14,51 @@ impl Capture { fresh, ) } + + pub(crate) fn activation_fixture() -> (Self, Vec) { + use crate::activation::{ + ActivationRecord, Configuration, ExpectedResources, Image, MachineIdMode, OverlayMode, + RestoreSection, TransactionId, Upper, View, + }; + let repaired = crate::guest::snapshot::repair::tests::repaired_fixture(); + let record = ActivationRecord::new( + Some(RestoreSection { + transaction: TransactionId::from_bytes([0x22; TransactionId::LEN]), + }), + Configuration { + image: Image::Erofs { uuid: None }, + upper: Upper::Tmpfs { + max_memory_percent: 63, + }, + overlay: OverlayMode::UntrustedLowers, + export: None, + mappings: 0, + network: None, + view: View { + hostname: b"restored".to_vec(), + machine_id: MachineIdMode::Image, + }, + resources: ExpectedResources { + cpus: repaired.cpus(), + memory: repaired.memory(), + }, + }, + ) + .unwrap(); + let mut capture = Self::fixture(true); + capture.restored(repaired); + capture + .restored + .as_mut() + .unwrap() + .reset(Instant::now()) + .unwrap(); + let mut payload = 1000u32.to_le_bytes().to_vec(); + payload.extend_from_slice(&1001u32.to_le_bytes()); + payload.push(1); + payload.extend(record.encode()); + (capture, payload) + } } #[test] diff --git a/nvx-rs/src/guest/managed_agent/dispatch.rs b/nvx-rs/src/guest/managed_agent/dispatch.rs index 53a071d65..ad13b8437 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch.rs @@ -4,8 +4,9 @@ use crate::guest::init::Runtime; use crate::guest::mappings::{Mapper, MappingTable, SourceRoot}; use crate::guest::sys::{self, Errno, FileDescriptor}; use crate::protocol::{ - self, APP_CANCEL, APP_CAPTURE, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, APP_READY, APP_STOP, - APP_STOPPED, GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, + self, APP_ACTIVATE, APP_CANCEL, APP_CAPTURE, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, + APP_READY, APP_STOP, APP_STOPPED, GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, + UNSUPPORTED_OPERATION, }; use super::diagnostics; @@ -126,6 +127,7 @@ pub(crate) fn run( Err(error) }; } + let mut restored = None; loop { let payload = match session.next_event() { Ok(Event::Reset) => { @@ -146,7 +148,7 @@ pub(crate) fn run( // more specific stage. Its status is the error that ends the session, which // `control-session` reports too: `EPROTO` where C's `errno` was incidental, as after a // validation failure. - match handle_data(system, session, config, mappings, &payload) { + match handle_data(system, session, config, &mut restored, mappings, &payload) { Ok(Some(outcome)) => return Ok(outcome), Ok(None) => {} Err(_) if session.stopping() => return Ok(Outcome::Stopped), @@ -189,6 +191,7 @@ fn handle_data( system: &mut impl System, session: &mut Session, config: &AgentConfig, + restored: &mut Option, mappings: &mut HostMappings, payload: &[u8], ) -> Result, Errno> { @@ -199,7 +202,7 @@ fn handle_data( return Err(failure.into_error()); } }; - if session.rootless() && !matches!(request.kind, APP_CAPTURE | APP_STOP) { + if session.rootless() && !matches!(request.kind, APP_ACTIVATE | APP_CAPTURE | APP_STOP) { session.send_error( request.request_id, Errno::ENOTSUP as i32, @@ -207,7 +210,23 @@ fn handle_data( )?; return Ok(None); } + let config = restored.as_ref().unwrap_or(config); match request.kind { + APP_ACTIVATE if session.has_restored() => { + if let Some(handoff) = session.activate(request)? { + *mappings = HostMappings::layered(handoff.table, handoff.mapper, handoff.export)?; + *restored = Some(AgentConfig { + rootfs: c"-".to_owned(), + hostname: handoff.hostname, + uid: CString::new(handoff.workload.uid.to_string()).expect("decimal UID"), + gid: CString::new(handoff.workload.gid.to_string()).expect("decimal GID"), + user: handoff.user, + home: handoff.home, + direct: true, + entry: None, + }); + } + } APP_CAPTURE => { if session.queue_capture(request, false)? { return session.finish_capture(); @@ -245,6 +264,7 @@ fn handle_data( return Ok(None); } }; + session.start_activation()?; exec::run(system, session, config, request.request_id, &execution)?; if session.capture_pending() { return session.finish_capture(); diff --git a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs index 9efb42f47..a94523509 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs @@ -440,6 +440,141 @@ fn mappings_mount_in_order_and_unblock_workloads_through_dispatch() { assert_eq!(system.spawn_calls, 0); } +#[test] +fn init_refuses_activation_without_a_restore_and_keeps_serving() { + for fresh in [true, false] { + let config = AgentConfig::for_test(None); + let mut mappings = HostMappings::announced_for(&config, 0); + let mut system = TestSystem::new(Paths::default()); + let (host, guest) = UnixStream::pair().unwrap(); + let thread = thread::spawn(move || { + let mut session = Session::new(FileDescriptor::from_owned(guest.into())) + .with_capture(crate::guest::init::Capture::fixture(fresh)); + let outcome = run(&mut system, &mut session, &config, &mut mappings); + (outcome, system) + }); + let mut harness = Harness { + host, + thread, + epoch: 0, + host_sequence: 0, + guest_sequence: 0, + }; + assert_eq!(harness.read_outer().0.record_type, OUTER_GUEST_ATTACH); + for epoch in [1, 2] { + harness.reset(epoch, 7); + assert_eq!( + harness.request(APP_ACTIVATE, b"misrouted"), + response( + APP_ERROR, + 42, + Errno::ENOTSUP as i32, + b"unsupported-operation" + ) + ); + } + if !fresh { + assert_eq!( + harness.request(APP_PING, &[]), + response(APP_READY, 42, 0, b"") + ); + } + let system = harness.stop(); + assert!(system.diagnostics.is_empty()); + assert_eq!(system.spawn_calls, 0); + } +} + +#[test] +fn restored_ready_maps_and_first_exec_share_the_existing_start_transition() -> TestResult { + use crate::guest::activation::{ActivationAction, ActivationState}; + use crate::guest::init::Capture; + if super::super::tests::isolate_process_test( + "NVX_RESTORED_DISPATCH_TEST", + "guest::managed_agent::dispatch::tests::restored_ready_maps_and_first_exec_share_the_existing_start_transition", + ) { + return Ok(()); + } + let _process = super::super::tests::PROCESS_TEST_LOCK.lock()?; + let (temporary, mut system) = TestSystem::with_host_export(); + system.direct_starter = Some(super::super::seam::tests::start_as_privileged); + let target = temporary.path().canonicalize()?.join("guest"); + let first = maps_payload( + 0, + &[(b"0/src", target.join("src").as_os_str().as_bytes(), 0)], + None, + ); + let second = maps_payload( + 1, + &[(b"1", target.join("out").as_os_str().as_bytes(), 0)], + None, + ); + let config = AgentConfig::for_test(None); + let mut mappings = HostMappings::announced_for(&config, 2); + let (mut capture, payload) = Capture::activation_fixture(); + let activation = capture.restored.as_mut().unwrap(); + assert!(matches!( + activation.accept(&payload, std::time::Instant::now())?.0, + ActivationAction::Apply(_) + )); + let ready = activation + .activation + .complete(std::time::Instant::now(), Ok(()))? + .encode(); + let (host, guest) = UnixStream::pair()?; + let thread = thread::spawn(move || { + let mut session = + Session::new(FileDescriptor::from_owned(guest.into())).with_capture(capture); + let outcome = run(&mut system, &mut session, &config, &mut mappings); + assert_eq!( + session.activation_state_for_test(), + Some(ActivationState::Started) + ); + (outcome, system) + }); + let mut harness = Harness { + host, + thread, + epoch: 0, + host_sequence: 0, + guest_sequence: 0, + }; + assert_eq!(harness.read_outer().0.record_type, OUTER_GUEST_ATTACH); + harness.reset(1, 7); + assert_eq!( + harness.request(APP_ACTIVATE, &payload), + response(APP_READY, 42, 0, &ready) + ); + let execution = workload_payload(&["/bin/sh", "-c", "printf restored"], None)?; + for maps in [&first, &second] { + assert_eq!( + harness.request(APP_EXEC, &execution), + response(APP_ERROR, 42, Errno::EAGAIN as i32, b"mappings-incomplete"), + ); + assert_eq!( + harness.request(APP_MAPS, maps), + response(APP_READY, 42, 0, b"") + ); + } + assert_eq!( + stdout_from(harness.request_all(43, APP_EXEC, &execution), 43, 0), + b"restored" + ); + harness.reset(2, 4); + assert_eq!( + harness.request(APP_ACTIVATE, &payload), + response(APP_READY, 42, 0, &ready) + ); + assert_eq!( + stdout_from(harness.request_all(44, APP_EXEC, &execution), 44, 0), + b"restored" + ); + let system = harness.stop(); + assert!(system.diagnostics.is_empty()); + assert_eq!(system.kernel.moves().len(), 2); + Ok(()) +} + #[test] fn mapping_sequence_errors_are_reported_through_dispatch() { let (temporary, system) = TestSystem::with_host_export(); diff --git a/nvx-rs/src/guest/managed_agent/session.rs b/nvx-rs/src/guest/managed_agent/session.rs index 2256e29fe..c4d618ed0 100644 --- a/nvx-rs/src/guest/managed_agent/session.rs +++ b/nvx-rs/src/guest/managed_agent/session.rs @@ -1,8 +1,10 @@ use std::cell::{Cell, RefCell}; use std::io::{self, Read, Write}; use std::process::ExitStatus; +use std::time::{Duration, Instant}; -use crate::guest::init::{self, Capture, CaptureStep, Runtime}; +use crate::guest::activation::{ActivationAction, ActivationState}; +use crate::guest::init::{self, Capture, CaptureStep, RestoreHandoff, Restored, Runtime}; use crate::guest::nvxs::{GuestConnection, GuestConnectionError}; use crate::guest::snapshot::{Captured, Disposition}; use crate::guest::sys::{self, Errno, FileDescriptor, Pollable}; @@ -28,9 +30,23 @@ struct Control<'r> { leader: Cell, status: Cell>, outcome: RefCell>, + /// Bounds partial I/O until the restored activation's Ready has been written. + deadline: Cell>, } impl Control<'_> { + fn remaining(&self) -> Result, Errno> { + self.deadline + .get() + .map(|deadline| { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(Errno::ETIMEDOUT) + }) + .transpose() + } + fn service_leader(&self) -> Result<(), Errno> { if self.service(|id, status| { // Keep the first leader status across partial I/O, not unrelated orphans. @@ -65,24 +81,32 @@ impl Read for Control<'_> { if bytes.is_empty() { return Read::read(&mut self.descriptor, bytes); } - let Some(runtime) = &self.runtime else { + if self.runtime.is_none() && self.deadline.get().is_none() { return Read::read(&mut self.descriptor, bytes); - }; + } loop { - self.service_leader()?; - let signals = sys::poll( - [Pollable::File(&self.descriptor), runtime.borrow().wake()], - None, - )?[1]; - if signals.error { - return Err(Errno::EIO.into()); + if self.runtime.is_some() { + self.service_leader()?; } - if !signals.readable { - // A control-only wake includes HUP, which PollEvents does not expose. - match self.descriptor.read(bytes) { - Err(Errno::EAGAIN) => {} - result => return result.map_err(io::Error::from), + let timeout = self.remaining()?; + if let Some(runtime) = &self.runtime { + let signals = sys::poll( + [Pollable::File(&self.descriptor), runtime.borrow().wake()], + timeout, + )?[1]; + if signals.error { + return Err(Errno::EIO.into()); } + if signals.readable { + continue; + } + } else { + sys::poll([Pollable::File(&self.descriptor)], timeout)?; + } + self.remaining()?; + match self.descriptor.read(bytes) { + Err(Errno::EAGAIN) => {} + result => return result.map_err(io::Error::from), } } } @@ -90,31 +114,42 @@ impl Read for Control<'_> { impl Write for Control<'_> { fn write(&mut self, bytes: &[u8]) -> io::Result { - let Some(runtime) = &self.runtime else { + if self.runtime.is_none() && self.deadline.get().is_none() { return Write::write(&mut self.descriptor, bytes); - }; + } if bytes.is_empty() { return Write::write(&mut self.descriptor, bytes); } loop { - self.service_leader()?; + self.remaining()?; + if self.runtime.is_some() { + self.service_leader()?; + } match self.descriptor.write(bytes) { - Err(Errno::EAGAIN) => loop { - let events = sys::poll( - [ - Pollable::Writable(&self.descriptor), - runtime.borrow().wake(), - ], - None, - )?; - if events[1].error { - return Err(Errno::EIO.into()); - } - self.service_leader()?; - if events[0].writable || !events[1].readable { - break; + Err(Errno::EAGAIN) => { + if let Some(runtime) = &self.runtime { + loop { + let events = sys::poll( + [ + Pollable::Writable(&self.descriptor), + runtime.borrow().wake(), + ], + self.remaining()?, + )?; + self.remaining()?; + if events[1].error { + return Err(Errno::EIO.into()); + } + self.service_leader()?; + if events[0].writable || !events[1].readable { + break; + } + } + } else { + sys::poll([Pollable::Writable(&self.descriptor)], self.remaining()?)?; + self.remaining()?; } - }, + } result => return result.map_err(io::Error::from), } } @@ -206,6 +241,7 @@ impl<'r> Session<'r> { leader: Cell::new(0), status: Cell::new(None), outcome: RefCell::new(Ok(false)), + deadline: Cell::new(None), }), capture: None, last_error: None, @@ -223,6 +259,12 @@ impl<'r> Session<'r> { self.capture.is_some() } + pub(crate) fn has_restored(&self) -> bool { + self.capture + .as_ref() + .is_some_and(|capture| capture.restored.is_some()) + } + /// Whether root-dependent requests must remain refused. pub(crate) fn rootless(&self) -> bool { self.capture.as_ref().is_some_and(Capture::rootless) @@ -314,8 +356,17 @@ impl<'r> Session<'r> { } Ok(Captured::Restored(repaired)) => { capture.restored(repaired); + let restored = capture.restored.as_mut().expect("the repair was retained"); + self.descriptor().set_nonblocking()?; + self.connection + .get_ref() + .deadline + .set(restored.activation.deadline()); match self.next_event() { - Ok(Event::Reset) => self.acknowledge()?, + Ok(Event::Reset) => { + restored.reset(Instant::now()).map_err(activation_failure)?; + self.acknowledge()?; + } Ok(Event::Data(_)) => return Err(Errno::EPROTO), Err(failure) => { return Err(super::dispatch::report_failure_for_capture(failure)); @@ -342,6 +393,117 @@ impl<'r> Session<'r> { result } + /// Runs init's restored space setup or returns its cached activation result. + pub(crate) fn activate( + &mut self, + request: protocol::AppFrame<'_>, + ) -> Result, Errno> { + self.activate_with(request, |restored, session, record, workload| { + let runtime = session + .connection + .get_ref() + .runtime + .as_ref() + .ok_or_else(|| crate::Error::unsupported("activation needs the init runtime"))?; + restored.apply(record, workload, |publication, gateway| { + runtime.borrow_mut().restore_announcer(publication, gateway) + }) + }) + } + + /// Keeps one application and its result while replacement sessions join before Ready. + fn activate_with( + &mut self, + request: protocol::AppFrame<'_>, + apply: impl FnOnce( + &mut Restored, + &mut Self, + &crate::activation::ActivationRecord, + protocol::workload::WorkloadIdentity, + ) -> crate::Result, + ) -> Result, Errno> { + let mut capture = self.capture.take().ok_or(Errno::ENOTSUP)?; + let result = (|| { + let restored = capture.restored.as_mut().ok_or(Errno::EPROTO)?; + let (action, workload) = restored + .accept(request.payload, Instant::now()) + .map_err(activation_failure)?; + let (retained, ready, replies) = match action { + ActivationAction::Apply(record) => { + let retained = match apply(restored, self, &record, workload) { + Ok(retained) => retained, + Err(error) => { + init::report_activation(&error); + return Err(activation_failure( + restored + .activation + .complete(Instant::now(), Err(error.into())) + .expect_err("a failed application cannot become ready"), + )); + } + }; + let mut replies = vec![request.request_id]; + while sys::readable_or_hung_up(&[self.descriptor().borrowed()], 0)?[0] { + match self + .next_event() + .map_err(super::dispatch::report_failure_for_capture)? + { + Event::Reset => { + restored.reset(Instant::now()).map_err(activation_failure)?; + self.acknowledge()?; + replies.clear(); + } + Event::Data(bytes) => { + let retry = self + .accept_request(&bytes) + .map_err(DataFailure::into_error)?; + if retry.kind != protocol::APP_ACTIVATE + || !matches!( + restored + .accept(retry.payload, Instant::now()) + .map_err(activation_failure)? + .0, + ActivationAction::Join + ) + { + return Err(Errno::EPROTO); + } + replies.push(retry.request_id); + } + } + } + let ready = restored + .activation + .complete(Instant::now(), Ok(())) + .map_err(activation_failure)?; + (Some(retained), ready, replies) + } + ActivationAction::Ready(ready) => (None, ready, vec![request.request_id]), + _ => return Err(Errno::EPROTO), + }; + for id in replies { + self.send_app(protocol::APP_READY, id, 0, &ready.encode())?; + } + self.connection.get_ref().deadline.set(None); + Ok(retained) + })(); + self.capture = Some(capture); + result + } + + /// Takes the first valid mapped EXEC as the start transition, never starting twice. + pub(crate) fn start_activation(&mut self) -> Result<(), Errno> { + if let Some(restored) = self + .capture + .as_mut() + .and_then(|capture| capture.restored.as_mut()) + && restored.activation.state() != ActivationState::Started + { + restored.activation.start().map_err(activation_failure)?; + } + Ok(()) + } + /// Borrows init's sole collector for the lifetime of this control session. pub(crate) fn with_runtime( descriptor: FileDescriptor, @@ -419,6 +581,11 @@ impl<'r> Session<'r> { Ok(GuestEvent::Reset(_)) => { if let Some(capture) = &mut self.capture { capture.reset(); + if let Some(restored) = &mut capture.restored { + restored + .reset(Instant::now()) + .map_err(|error| ReceiveFailure::Read(activation_failure(error)))?; + } } Ok(Event::Reset) } @@ -517,6 +684,11 @@ impl<'r> Session<'r> { } } +fn activation_failure(error: impl std::fmt::Display) -> Errno { + init::report_activation(error); + Errno::EPROTO +} + /// Returns the stage at which the C agent reported a record that the session refused, or `None` /// for one that it reported as a failure to read. /// diff --git a/nvx-rs/src/guest/managed_agent/session/tests.rs b/nvx-rs/src/guest/managed_agent/session/tests.rs index c91d12028..14e4722b4 100644 --- a/nvx-rs/src/guest/managed_agent/session/tests.rs +++ b/nvx-rs/src/guest/managed_agent/session/tests.rs @@ -12,6 +12,16 @@ use crate::protocol::{ const INSTANCE: [u8; 16] = [7; 16]; +impl Session<'_> { + pub(crate) fn activation_state_for_test(&self) -> Option { + self.capture + .as_ref()? + .restored + .as_ref() + .map(|state| state.activation.state()) + } +} + struct Peer { stream: UnixStream, epoch: u64, @@ -20,6 +30,15 @@ struct Peer { } impl Peer { + fn reset(&mut self, epoch: u64, sequence: u64) { + self.stream + .write_all(&encode_outer(OUTER_RESET, &INSTANCE, epoch, sequence, &[]).unwrap()) + .unwrap(); + self.epoch = epoch; + self.host_sequence = sequence + 1; + self.guest_sequence = 0; + } + fn read(&mut self) -> (OuterHeader, Vec) { let mut bytes = [0; OUTER_HEADER_LEN]; self.stream.read_exact(&mut bytes).unwrap(); @@ -95,6 +114,219 @@ fn connected(fresh: bool) -> (Session<'static>, Peer) { (session, peer) } +fn restored() -> (Session<'static>, Peer, Vec) { + let (mut session, peer) = connected(true); + let (capture, payload) = Capture::activation_fixture(); + let deadline = capture.restored.as_ref().unwrap().activation.deadline(); + session.capture = Some(capture); + session.descriptor().set_nonblocking().unwrap(); + session.connection.get_ref().deadline.set(deadline); + (session, peer, payload) +} + +fn activate_bytes(session: &mut Session<'_>, peer: &mut Peer, id: u64, payload: &[u8]) -> Vec { + peer.send(protocol::APP_ACTIVATE, id, payload); + let Event::Data(bytes) = session + .next_event() + .unwrap_or_else(|_| panic!("activate DATA")) + else { + panic!("the activate is DATA"); + }; + bytes +} + +#[test] +fn a_replacement_session_joins_one_activation_and_caches_ready_without_reapplying() { + let (mut session, mut peer, payload) = restored(); + let deadline = session.connection.get_ref().deadline.get(); + let (record, _) = protocol::decode_activate_payload(&payload).unwrap(); + let ready = crate::activation::ReadyActivation::of(&record).encode(); + let bytes = activate_bytes(&mut session, &mut peer, 61, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid activate")); + peer.credit(); + let retained = session + .activate_with(request, |restored, _, accepted, workload| { + assert_eq!(restored.activation.state(), ActivationState::Activating); + assert_eq!(restored.activation.deadline(), deadline); + assert_eq!(accepted, &record); + assert_eq!( + (workload.uid, workload.gid, workload.create_account), + (1000, 1001, true) + ); + peer.reset(2, 4); + peer.send(protocol::APP_ACTIVATE, 62, &payload); + Ok("one applied space") + }) + .unwrap(); + assert_eq!(retained, Some("one applied space")); + assert_eq!(peer.read().0.record_type, OUTER_ACK); + peer.credit(); + peer.app(APP_READY, 62, 0, &ready); + assert!(!session.rootless()); + assert_eq!(session.connection.get_ref().deadline.get(), None); + let bytes = activate_bytes(&mut session, &mut peer, 63, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid retry")); + peer.credit(); + assert!( + session + .activate_with::<()>(request, |_, _, _, _| panic!( + "must not apply or switch twice" + )) + .unwrap() + .is_none() + ); + peer.app(APP_READY, 63, 0, &ready); + session.start_activation().unwrap(); + session.start_activation().unwrap(); + assert_eq!( + session.activation_state_for_test(), + Some(ActivationState::Started) + ); +} + +#[test] +fn postactivation_capture_stays_refused_without_pending_cleanup() { + let (mut session, mut peer, payload) = restored(); + let state = session.capture.as_mut().unwrap().restored.as_mut().unwrap(); + assert!(matches!( + state.accept(&payload, Instant::now()).unwrap().0, + ActivationAction::Apply(_) + )); + state.activation.complete(Instant::now(), Ok(())).unwrap(); + session.start_activation().unwrap(); + for kill in [0, 1] { + assert!(!queue(&mut session, &mut peer, 71, &[kill], false)); + assert!(!session.capture_pending()); + assert!(session.finish_capture().unwrap().is_none()); + peer.app( + APP_ERROR, + 71, + Errno::ENOTSUP as i32, + b"unsupported-operation", + ); + } +} + +#[test] +fn changed_record_or_workload_bytes_during_application_fail_without_ready() { + for changed_identity in [false, true] { + let (mut session, mut peer, payload) = restored(); + let mut changed = payload.clone(); + if changed_identity { + changed[0] ^= 1; + } else { + let (record, _) = protocol::decode_activate_payload(&payload).unwrap(); + let mut configuration = record.configuration().clone(); + configuration.view.hostname = b"changed".to_vec(); + let record = + crate::activation::ActivationRecord::new(record.restore().copied(), configuration) + .unwrap(); + changed.truncate(9); + changed.extend(record.encode()); + } + let bytes = activate_bytes(&mut session, &mut peer, 64, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid activate")); + peer.credit(); + let result = session.activate_with(request, |_, _, _, _| { + peer.send(protocol::APP_ACTIVATE, 65, &changed); + Ok(()) + }); + assert_eq!(result.err(), Some(Errno::EPROTO)); + peer.credit(); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + assert!(session.rootless()); + } +} + +#[test] +fn failed_application_never_reports_ready_or_admits_root_dependent_work() { + let (mut session, mut peer, payload) = restored(); + let bytes = activate_bytes(&mut session, &mut peer, 66, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid activate")); + peer.credit(); + assert_eq!( + session + .activate_with::<()>(request, |_, _, _, _| Err(crate::Error::io( + "runtime mounts failed" + ))) + .err(), + Some(Errno::EPROTO) + ); + assert!(session.rootless()); + assert_eq!( + session.activation_state_for_test(), + Some(ActivationState::Failed) + ); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); +} + +fn short_deadline(session: &mut Session<'_>) -> Instant { + let now = Instant::now(); + let repaired = crate::guest::snapshot::repair::tests::repaired_fixture(); + let state = session.capture.as_mut().unwrap().restored.as_mut().unwrap(); + state.activation = crate::guest::activation::Activation::with_resources( + crate::activation::ExpectedResources { + cpus: repaired.cpus(), + memory: repaired.memory(), + }, + Some(now - Duration::from_millis(100)), + Duration::from_millis(300), + now, + ); + state.reset(now).unwrap(); + let deadline = state.activation.deadline().unwrap(); + session.connection.get_ref().deadline.set(Some(deadline)); + deadline +} + +#[test] +fn reconnect_and_partial_input_wait_only_until_the_original_activation_deadline() { + let (mut session, mut peer, _) = restored(); + let deadline = short_deadline(&mut session); + peer.reset(2, 4); + assert!(matches!(session.next_event(), Ok(Event::Reset))); + session.acknowledge().unwrap(); + assert_eq!(peer.read().0.record_type, OUTER_ACK); + assert_eq!(session.connection.get_ref().deadline.get(), Some(deadline)); + let partial = encode_outer(OUTER_DATA, &INSTANCE, 2, 5, &[0; 24]).unwrap(); + peer.stream.write_all(&partial[..11]).unwrap(); + assert!(matches!( + session.next_event(), + Err(ReceiveFailure::Read(Errno::ETIMEDOUT)) + )); + assert!(Instant::now() >= deadline); +} + +#[test] +fn an_unread_output_stream_cannot_extend_the_activation_deadline() { + let (mut session, _peer, _) = restored(); + let deadline = short_deadline(&mut session); + let payload = vec![0; OUTER_MAX_PAYLOAD - protocol::APP_HEADER_LEN]; + let error = loop { + if let Err(error) = session.send_app(APP_READY, 67, 0, &payload) { + break error; + } + }; + assert_eq!(error, Errno::ETIMEDOUT); + assert!(Instant::now() >= deadline); +} + fn queue( session: &mut Session<'_>, peer: &mut Peer, diff --git a/nvx-rs/src/protocol/app.rs b/nvx-rs/src/protocol/app.rs index c6645486f..2feb2c3ff 100644 --- a/nvx-rs/src/protocol/app.rs +++ b/nvx-rs/src/protocol/app.rs @@ -62,7 +62,10 @@ pub const APP_MAPS: u8 = 6; pub(crate) const APP_CAPTURE: u8 = 7; /// Request that nvx-init activate a restored VM with its record and workload identity. #[cfg_attr( - not(test), + not(any( + test, + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect( dead_code, reason = "SL4.3 sends activation requests to nvx-init in SL7.7b" @@ -204,7 +207,10 @@ pub(crate) fn encode_activate_payload( /// Decodes the persistent workload identity and the activation record that follows it. #[cfg_attr( - not(test), + not(any( + test, + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect(dead_code, reason = "SL7.7b makes nvx-init decode activation requests") )] pub(crate) fn decode_activate_payload( diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 907ff555c..c66dfd348 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -61,16 +61,21 @@ pub mod view; all(feature = "guest", target_os = "linux", target_arch = "x86_64") ))] pub(crate) use app::APP_CAPTURE; +#[cfg(feature = "host")] +pub(crate) use app::encode_capture_payload; +#[cfg(any( + feature = "host", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use app::workload; #[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] -pub(crate) use app::decode_capture_payload; +pub(crate) use app::{APP_ACTIVATE, decode_activate_payload, decode_capture_payload}; pub use app::{ APP_CANCEL, APP_ERROR, APP_EXEC, APP_EXIT, APP_FEATURES, APP_HEADER_LEN, APP_MAGIC, APP_MAPS, APP_PING, APP_READY, APP_STDERR, APP_STDOUT, APP_STOP, APP_STOPPED, APP_VERSION, AppFrame, BUSY, CWD_FAILED, ExitCategory, INVALID_REQUEST, LAUNCH_FAILED, MAPPING_FAILED, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, decode_app, encode_app, }; -#[cfg(feature = "host")] -pub(crate) use app::{encode_capture_payload, workload}; pub use boot::{MAPS_TOKEN, WORKLOAD_ACCOUNT_TOKEN, parse_host_mapping_count}; pub use exec::{ EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_EXTENDED, EXEC_INHERIT_DEFAULT_ENV, diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index ca8ea1991..5b7e6aa10 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -5,7 +5,7 @@ "nvx-rs/src/guest/cgroup.rs": 2, "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, - "nvx-rs/src/guest/init.rs": 8, + "nvx-rs/src/guest/init.rs": 7, "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, "nvx-rs/src/guest/sys/entry.rs": 3, From 8921c45162e38761f1dc5beb51042ee2e7218803 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 05:14:02 -0800 Subject: [PATCH 14/25] test: reconnect the owned Windows recovery control fixture Port the required single-instance named-pipe disconnect helper and CLI-gated test re-exports. Repeated endpoint-owner verification must reconnect after each finished client; no production API or assertion changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/platform/tests.rs | 2 ++ nvx-rs/src/host/platform/windows/endpoint/tests.rs | 13 +++++++++++++ nvx-rs/src/host/platform/windows/tests.rs | 2 ++ 3 files changed, 17 insertions(+) diff --git a/nvx-rs/src/host/platform/tests.rs b/nvx-rs/src/host/platform/tests.rs index be5b7967a..37392e1ba 100644 --- a/nvx-rs/src/host/platform/tests.rs +++ b/nvx-rs/src/host/platform/tests.rs @@ -2,6 +2,8 @@ pub(crate) use super::linux::tests::{ check_identity, owner_only_problem, start_time_from_stat, store_dir_problem, }; +#[cfg(all(windows, feature = "cli"))] +pub(crate) use super::windows::tests::disconnect_control; #[cfg(windows)] pub(crate) use super::windows::tests::set_access; #[cfg(windows)] diff --git a/nvx-rs/src/host/platform/windows/endpoint/tests.rs b/nvx-rs/src/host/platform/windows/endpoint/tests.rs index 42e283d69..4d882574a 100644 --- a/nvx-rs/src/host/platform/windows/endpoint/tests.rs +++ b/nvx-rs/src/host/platform/windows/endpoint/tests.rs @@ -36,6 +36,19 @@ pub(crate) fn accept_control(server: &File) -> i32 { connected } +#[cfg(feature = "cli")] +pub(crate) fn disconnect_control(server: &File) { + use std::os::windows::io::{AsHandle, AsRawHandle}; + + use windows_sys::Win32::System::Pipes::DisconnectNamedPipe; + + // SAFETY: the owned synchronous pipe finished its previous client. + assert_ne!( + unsafe { DisconnectNamedPipe(server.as_handle().as_raw_handle()) }, + 0 + ); +} + #[test] fn a_duplicated_transport_keeps_the_verified_connection_without_reconnecting() { let endpoint = format!( diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 7da84f781..a3aa27b6e 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -20,6 +20,8 @@ use windows_sys::Win32::System::Threading::{ PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, TerminateProcess, }; +#[cfg(feature = "cli")] +pub(crate) use super::endpoint::tests::disconnect_control; pub(crate) use super::endpoint::tests::{accept_control, control_server}; use super::identity::status_error; use super::log_claim::{local_directory, log_job_id, log_job_name, log_job_processes, object_name}; From 206b3f15da77eb60fc2d7d3d3d82edd39d6d8138 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 06:03:02 -0800 Subject: [PATCH 15/25] test(nvx-rs): exercise restored init with native readiness Bind the established private restore-ready listener in a scoped in-crate live fixture. Keep production APIs and guards unchanged; exercise real activation, MAPS/EXEC, replay, helper publication and the original deadline under explicit host admission. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dee059e7-7968-43d8-82d0-abd68b922eed --- nvx-rs/src/host/restore/tests.rs | 2 + nvx-rs/src/host/restore/tests/live_init.rs | 516 +++++++++++++++++++++ 2 files changed, 518 insertions(+) create mode 100644 nvx-rs/src/host/restore/tests/live_init.rs diff --git a/nvx-rs/src/host/restore/tests.rs b/nvx-rs/src/host/restore/tests.rs index b302590b0..c840fd558 100644 --- a/nvx-rs/src/host/restore/tests.rs +++ b/nvx-rs/src/host/restore/tests.rs @@ -1,6 +1,8 @@ #![cfg(any(target_os = "linux", windows))] #![cfg_attr(windows, allow(unsafe_code))] +mod live_init; + use std::io::{BufRead, BufReader, Write}; use std::process::{Child, Command, Stdio}; use std::time::Instant; diff --git a/nvx-rs/src/host/restore/tests/live_init.rs b/nvx-rs/src/host/restore/tests/live_init.rs new file mode 100644 index 000000000..c8e33508d --- /dev/null +++ b/nvx-rs/src/host/restore/tests/live_init.rs @@ -0,0 +1,516 @@ +use std::error::Error; +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::io; +use std::net::{SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::Hypervisor; +use crate::activation::{ + ActivationRecord, Configuration, ExpectedResources, Export, ExportKind, Image, MachineIdMode, + Network, OverlayMode, Publication, ReadyActivation, RestoreSection, TransactionId, Upper, View, +}; +use crate::host::instance::generate_capability; +use crate::host::nvxs::HostConnection; +use crate::host::session; +use crate::host::supervisor::{Launched, VmmCommand, spawn}; +use crate::host::template::TemplateManifest; +use crate::protocol::{ + APP_ERROR, APP_EXEC, APP_EXIT, APP_MAPS, APP_READY, APP_STDERR, APP_STDOUT, APP_STOP, + APP_STOPPED, HostMap, Workload, WorkloadEnvironment, decode_app, encode_app, + encode_exec_payload, encode_maps_payloads, +}; + +type AnyError = Box; +const ACTIVATE: u8 = 8; +const CAPTURE: u8 = 7; + +struct OwnedVm(Option); +impl Drop for OwnedVm { + fn drop(&mut self) { + if let Some(vm) = self.0.take() { + if vm + .process() + .and_then(|process| process.has_exited()) + .is_ok_and(|done| done) + { + vm.detach(); + } else if !vm.kill_and_wait() { + eprintln!("OWNED_RESTORE_TEARDOWN_FAILED"); + } + } + } +} + +fn require(ok: bool, message: impl Into) -> Result<(), AnyError> { + if ok { + Ok(()) + } else { + Err(message.into().into()) + } +} + +fn backend() -> Hypervisor { + if cfg!(windows) { + Hypervisor::Whp + } else { + Hypervisor::Mshv + } +} + +fn private_directory(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new().mode(0o700).create(path) + } + #[cfg(windows)] + { + fs::create_dir(path) + } +} + +fn ready( + connection: &mut HostConnection, + id: u64, + expected: &[u8], + by: Instant, +) -> Result<(), AnyError> { + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + (frame.kind, frame.request_id, frame.status, frame.payload) == (APP_READY, id, 0, expected), + format!("unexpected Ready: {frame:?}"), + ) +} + +fn workload(script: &str) -> Result, AnyError> { + Ok(encode_exec_payload(&Workload { + argv: vec!["/bin/sh".into(), "-eu".into(), "-c".into(), script.into()], + timeout_ms: 10_000, + cwd: Some("/"), + environment: WorkloadEnvironment::Default, + })?) +} + +fn execute( + connection: &mut HostConnection, + id: u64, + script: &str, + by: Instant, +) -> Result { + connection.send(&encode_app(APP_EXEC, id, 0, &workload(script)?), Some(by))?; + let mut output = Vec::new(); + loop { + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.request_id == id, + format!("wrong execution response: {frame:?}"), + )?; + match frame.kind { + APP_STDOUT => output.extend_from_slice(frame.payload), + APP_STDERR => eprint!("{}", String::from_utf8_lossy(frame.payload)), + APP_EXIT => { + require( + frame.status == 0 && frame.payload == b"exit", + format!("execution failed: {frame:?}"), + )?; + return Ok(String::from_utf8(output)?); + } + _ => return Err(format!("unexpected execution frame: {frame:?}").into()), + } + } +} + +fn run() -> Result<(), AnyError> { + let grant = std::env::var("NVX_SL77B_LIVE_GRANT")?; + require( + !grant.is_empty(), + "live fixture requires explicit named host admission", + )?; + let mut args = vec![OsString::from("nvx-live-restored-init")]; + for key in [ + "NVX_SL77B_LIVE_NATIVE", + "NVX_SL77B_LIVE_SNAPSHOT", + "NVX_SL77B_LIVE_DISTRO", + "NVX_SL77B_LIVE_CONSOLE", + "NVX_SL77B_LIVE_ROOT", + "NVX_SL77B_LIVE_RESULT", + "NVX_SL77B_LIVE_CASE", + ] { + args.push( + std::env::var_os(key).ok_or_else(|| format!("missing live fixture input {key}"))?, + ); + } + require( + args.len() == 8, + "usage: restore VMM SNAPSHOT DISTRO ORIGINAL_CONSOLE ROOT RESULT CASE", + )?; + let vmm = PathBuf::from(&args[1]); + let snapshot = PathBuf::from(&args[2]); + let distro = PathBuf::from(&args[3]); + let original_console = PathBuf::from(&args[4]); + let root = PathBuf::from(&args[5]); + let result = PathBuf::from(&args[6]); + let case = args[7].to_str().ok_or("non-Unicode case")?; + require( + matches!(case, "published" | "unpublished" | "changed" | "deadline"), + "invalid fixture case", + )?; + for path in [&vmm, &snapshot, &distro, &original_console, &root, &result] { + require(path.is_absolute(), "all fixture paths must be absolute")?; + } + let manifest = TemplateManifest::read(&snapshot)?; + require( + manifest.hypervisor() == backend() && manifest.memory_len() == 128 << 20, + "wrong backend/memory snapshot", + )?; + require( + !root.exists() && !result.exists(), + "preserve existing fixture effects", + )?; + private_directory(&root)?; + let export = root.join("export"); + fs::create_dir(&export)?; + fs::write(export.join("proof"), b"CURRENT_573F_HOST_MAP\n")?; + let endpoint = if cfg!(windows) { + format!( + "//./pipe/openvmm-microvm-sl77b-live-{}-{case}", + std::process::id() + ) + } else { + snapshot + .parent() + .ok_or("snapshot has no private namespace parent")? + .join(format!("nvxs-{case}")) + .to_str() + .ok_or("non-Unicode endpoint")? + .to_owned() + }; + let address: SocketAddr = fs::read_to_string(&original_console)?.trim().parse()?; + require(address.ip().is_loopback(), "console must remain loopback")?; + let listener = TcpListener::bind(address)?; + listener.set_nonblocking(true)?; + let console = root.join("restored-console.log"); + let console_output = console.clone(); + let reader = thread::spawn(move || -> io::Result<()> { + let by = Instant::now() + Duration::from_secs(30); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock && Instant::now() < by => { + thread::sleep(Duration::from_millis(25)); + } + Err(error) => return Err(error), + } + }; + stream.set_nonblocking(false)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(console_output)?; + io::copy(&mut stream, &mut output)?; + Ok(()) + }); + let report = root.join("outcome.json"); + let (_temporary, directory) = super::vm_directory(); + let mut listener = super::ReadyListener::bind(&directory)?; + let acknowledgement = listener.endpoint().to_owned(); + let log_file = root.join("openvmm.log"); + let log = OpenOptions::new() + .write(true) + .create_new(true) + .open(&log_file)?; + let options: Vec = vec![ + "--machine".into(), + "microvm".into(), + "--microvm-sandbox-block".into(), + format!("distro:file:{},ro", distro.display()).into(), + "--restore-absent-scratch".into(), + "--single-process".into(), + "--hypervisor".into(), + if cfg!(windows) { "whp" } else { "mshv" }.into(), + "--cpu-profile".into(), + "auto".into(), + "--processors".into(), + "1".into(), + "--restore-snapshot".into(), + snapshot.as_os_str().to_owned(), + "--restore-memory".into(), + "128M".into(), + "--restore-ready-path".into(), + acknowledgement.into(), + "--restore-network-policy".into(), + "--network-profile".into(), + "portable".into(), + "--network-egress".into(), + "deny".into(), + "--network-ingress".into(), + "deny".into(), + "--host-loopback".into(), + "deny".into(), + "--mount-aggregate".into(), + "/run/nvx/hostfs/root".into(), + "--mount-child".into(), + format!("0,{},ro", export.display()).into(), + "--virtio-console".into(), + format!("connect=tcp:{address}").into(), + "--microvm-control-console".into(), + format!("listen={endpoint}").into(), + "--microvm-control-auth-stdin".into(), + "--microvm-report".into(), + report.as_os_str().to_owned(), + ]; + let capability = generate_capability()?; + let command = VmmCommand::new(vmm, options, &root); + let mut owned = OwnedVm(Some(spawn(&command, &capability, log)?)); + let running = owned.0.as_ref().ok_or("missing owned VM")?; + let process = running.process()?; + let pid = running.id(); + fs::write( + root.join("process.txt"), + format!( + "pid={pid}\nreference={:?}\ncase={case}\n", + process.reference() + ), + )?; + println!( + "OWNED_RESTORE_PID={pid} REFERENCE={:?} CASE={case}", + process.reference() + ); + let by = Instant::now() + Duration::from_secs(90); + let expected_event = b"OPENVMM_RESTORE_READY_V1\n"; + let mut event = Vec::new(); + while event.len() < expected_event.len() { + require(Instant::now() < by, "native readiness deadline expired")?; + let open = listener.receive(&process, &mut event, 64, Duration::from_millis(50))?; + require( + open || event.len() == expected_event.len(), + "native readiness closed before its event", + )?; + } + require( + event == expected_event, + "native restore-ready event differs", + )?; + super::parse_ready_event(&event)?; + fs::write(root.join("native-ready.receipt"), &event)?; + println!("NATIVE_READY_AUTHENTICATED_PID={pid}"); + let native_ready = Instant::now(); + if case == "deadline" { + thread::sleep(Duration::from_secs(8)); + } + let transport = session::connect(&endpoint, pid, process.reference().start_time, by)?; + let mut connection = HostConnection::attach(transport, &capability, by)?; + println!( + "RESTORED_HOST_SESSION_EPOCH={}", + connection.identity().epoch + ); + let attached = Instant::now(); + let expected_status; + if case == "deadline" { + require( + process.wait(Instant::now() + Duration::from_secs(40))?, + "activation deadline did not end VM", + )?; + require( + native_ready.elapsed() >= Duration::from_secs(27) + && native_ready.elapsed() < Duration::from_secs(38) + && attached.elapsed() < Duration::from_secs(27), + "activation deadline renewed at the deliberately delayed host attachment", + )?; + println!( + "ORIGINAL_ACK_DEADLINE_ELAPSED_MS={}", + native_ready.elapsed().as_millis() + ); + expected_status = 125; + } else { + let published = case != "unpublished"; + let transaction = TransactionId::from_bytes([0x77; 16]); + let record = ActivationRecord::new( + Some(RestoreSection { transaction }), + Configuration { + image: Image::Erofs { uuid: None }, + upper: Upper::Tmpfs { + max_memory_percent: 50, + }, + overlay: OverlayMode::UntrustedLowers, + export: Some(Export { + kind: ExportKind::Aggregate, + }), + mappings: 1, + network: Some(Network { + dns: Some("10.88.0.53".parse()?), + publication: if published { + Publication::Published + } else { + Publication::Unpublished + }, + proxy: None, + }), + view: View { + hostname: b"restored-573f".to_vec(), + machine_id: MachineIdMode::Generated, + }, + resources: ExpectedResources { + cpus: 1, + memory: 128 << 20, + }, + }, + )?; + let mut activation = Vec::new(); + activation.extend_from_slice(&1000u32.to_le_bytes()); + activation.extend_from_slice(&1001u32.to_le_bytes()); + activation.push(1); + activation.extend(record.encode()); + fs::write(root.join("activate.bin"), &activation)?; + let expected_ready = ReadyActivation { + transaction: Some(transaction), + } + .encode(); + connection.send(&encode_app(ACTIVATE, 100, 0, &activation), Some(by))?; + ready(&mut connection, 100, &expected_ready, by)?; + println!("ACTIVATION_READY_PUBLICATION={published} OWN_DNS=10.88.0.53"); + if case == "changed" { + activation[0] ^= 1; + connection.send(&encode_app(ACTIVATE, 101, 0, &activation), Some(by))?; + require( + process.wait(Instant::now() + Duration::from_secs(15))?, + "changed identity replay did not end VM", + )?; + expected_status = 125; + println!("CHANGED_IDENTITY_REPLAY_FATAL"); + } else { + connection.send(&encode_app(APP_EXEC, 102, 0, &workload("true")?), Some(by))?; + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.kind == APP_ERROR + && frame.status == 11 + && frame.payload == b"mappings-incomplete", + format!("EXEC bypassed MAPS gate: {frame:?}"), + )?; + let maps = [HostMap { + source: "0".into(), + target: "/mnt/restore-map".into(), + read_only: true, + }]; + for payload in encode_maps_payloads(&maps, 32768)? { + connection.send(&encode_app(APP_MAPS, 103, 0, &payload), Some(by))?; + ready(&mut connection, 103, &[], by)?; + } + let script = format!( + r#" +test "$(id -u)" = 1000 +test "$(id -g)" = 1001 +test "$(hostname)" = restored-573f +grep -q 'nameserver 10.88.0.53' /etc/resolv.conf +test "$(cat /mnt/restore-map/proof)" = CURRENT_573F_HOST_MAP +test ! -e /sbin/nvx-ingress +count=0 +for p in /proc/[0-9]*; do + test "$(cat "$p/comm" 2>/dev/null || true)" = nvx-ingress || continue + count=$((count + 1)) + grep -q '^PPid:[[:space:]]*1$' "$p/status" + test "$(cat "$p/oom_score_adj")" = -1000 + test "$(tr '\000' ' ' <"$p/cmdline")" = 'nvx-ingress 10.88.0.1 ' + test "$(awk '{{print $19}}' "$p/stat")" = -10 + printf 'REAL_RESTORED_ANNOUNCER pid=%s argv=' "${{p##*/}}" + tr '\000' ' ' <"$p/cmdline" + printf '\n' +done +test "$count" = {} +printf 'PRESERVED_573F_UPPER\n' >/tmp/restore-sentinel +printf 'MACHINE_ID=' +cat /etc/machine-id +printf '\n' +printf 'REAL_READY_MAPS_EXEC_OK\n' +"#, + u8::from(published) + ); + let first = execute(&mut connection, 104, &script, by)?; + print!("{first}"); + let machine = first + .lines() + .find(|line| line.starts_with("MACHINE_ID=")) + .ok_or("missing generated machine ID")? + .to_owned(); + for kill in [0, 1] { + connection.send( + &encode_app(CAPTURE, 105 + u64::from(kill), 0, &[kill]), + Some(by), + )?; + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.kind == APP_ERROR && frame.status == 95, + "activated restore accepted capture", + )?; + } + drop(connection); + let transport = session::connect(&endpoint, pid, process.reference().start_time, by)?; + connection = HostConnection::attach(transport, &capability, by)?; + connection.send(&encode_app(ACTIVATE, 107, 0, &activation), Some(by))?; + ready(&mut connection, 107, &expected_ready, by)?; + let replay = execute( + &mut connection, + 108, + r#" +test "$(cat /tmp/restore-sentinel)" = PRESERVED_573F_UPPER +test "$(cat /mnt/restore-map/proof)" = CURRENT_573F_HOST_MAP +printf 'MACHINE_ID=' +cat /etc/machine-id +printf '\n' +printf 'RECONNECT_REPLAY_PRESERVED_UPPER_AND_MAPS\n' +"#, + by, + )?; + require( + replay.lines().any(|line| line == machine), + "retry regenerated the machine ID", + )?; + print!("{replay}"); + connection.send(&encode_app(APP_STOP, 109, 0, &[]), Some(by))?; + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.kind == APP_STOPPED && frame.status == 0, + "STOP not acknowledged", + )?; + require( + process.wait(Instant::now() + Duration::from_secs(30))?, + "STOP did not tear down restore", + )?; + expected_status = 0; + } + } + drop(connection); + reader.join().map_err(|_| "console reader panicked")??; + let outcome = crate::host::report::read(&report).ok_or("missing native outcome")?; + require( + outcome.status_code == Some(expected_status) && outcome.teardown_complete == Some(true), + format!("wrong restore outcome: {outcome:?}"), + )?; + let logs = fs::read_to_string(&console)?; + let switches = logs.matches("NVX-INIT-ROOT: switched").count(); + require( + switches == usize::from(case != "deadline"), + format!("unexpected root switch count {switches}"), + )?; + let text = format!( + "RESTORE_OK case={case} pid={pid} status={expected_status} switches={switches} teardown=true\n" + ); + fs::write(result, &text)?; + print!("{text}"); + owned.0.take().ok_or("missing owned restore")?.detach(); + Ok(()) +} + +#[test] +#[ignore = "requires explicit named host admission and current-source live restore fixture inputs"] +fn live_restored_init_activation() -> Result<(), AnyError> { + run() +} From 71f3c50b0dab5718d7eaab853313b13c6a9d8ff5 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 10:41:23 -0800 Subject: [PATCH 16/25] host: prepare blockless capture launches Prepare Fresh captures through the existing VM creation path with private sparse disks, checked geometry, platform-only arguments and a caller-persisted capture context. Leave capture completion and snapshot publication to their separate operations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 095bfb08-a5d0-4331-b2b2-dd76d2fbadd8 --- nvx-rs/README.md | 40 ++- nvx-rs/src/host/config/mod.rs | 177 ++++++++++-- nvx-rs/src/host/config/tests.rs | 118 +++++++- nvx-rs/src/host/snapshot.rs | 28 +- nvx-rs/src/host/vm.rs | 157 +++++++++-- nvx-rs/src/host/vm/tests.rs | 425 ++++++++++++++++++++++++++++- scripts/test-layout-allowlist.json | 2 +- 7 files changed, 886 insertions(+), 61 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 8c0198d71..cdd2bb891 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -124,7 +124,7 @@ changes only when the module's purpose does. | [`nvx::Hypervisor`](#nvxhypervisor) | KVM, MSHV, or WHP, with its serde form behind the `serde` feature | | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | -| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold preparation, verified detached launch, confirmed stop, and caller-saved reattachment | +| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold and capture preparation, verified detached launch, confirmed stop, and caller-saved reattachment | | [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshot directories, verified keys and manifests, time eligibility, and the input of a future VM restore | | [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity | | [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener | @@ -515,11 +515,23 @@ failure never hides confirmed exit. `Prepared` launch without starting a process or binding a listener. It requires `config.boot`, refuses a snapshot input, rechecks the directory's privacy, and creates a new private log. Existing log, outcome and Linux endpoint entries -are refused rather than replaced. Only the log is created before a spawn; -OpenVMM creates the remaining outputs when it runs. A failed preparation -closes and removes only its new log, preserving all pre-existing entries and +are refused rather than replaced. An ordinary cold boot creates only the log +before a spawn; OpenVMM creates its remaining outputs when it runs. A failed preparation +closes and removes only its new files, preserving all pre-existing entries and caller-owned inputs, and reports a cleanup failure with the original error. +With `boot.capture`, creation requires a blockless root and refuses every +filesystem export. It creates private sparse `capture-image.raw` and +`capture-scratch.raw` files, verifies their actual complete geometries, and +targets `/openvmm`. A configured NIC keeps its resolved address but +uses the complete deny-all policy, whatever its configured policy. +`Prepared::capture_context()` exposes a cloneable, serializable +`snapshot::CaptureContext` with the checked capture configuration and disk +geometries, NIC and console devices, runtime identity, hypervisor and CPU +profile. The caller chooses whether and how to persist it. Creation writes +no context file, `key.bin` or `snapshot.json`, and does not finish or publish +a capture. + On Linux, the generated control and boot-console socket names must be valid Unicode and shorter than 108 bytes both as supplied and with the directory's ancestor links resolved. Supplied names must hold no comma or equals sign, @@ -593,6 +605,11 @@ or lock. `Snapshot` remains an opaque input of a future restore: `vm::create` still refuses `Some(snapshot)` with `ErrorKind::Unsupported`; only cold boots are supported. +`CaptureContext` records a prepared capture's checked configuration and +effective devices independently of runtime paths or network policy. +`vm::Prepared::capture_context` returns it for the caller to clone and +serialize; NVX owns no capture-context store or persistence format. + ### `nvx::host::config` `VmConfig`: the configuration of a VM as one value, its processors, its memory @@ -670,12 +687,15 @@ The renderer emits neither `--microvm-lifecycle` nor `--microvm-workload-identity`. Callers inspect the complete arguments through `Prepared::arguments()`. -Both argument entry points refuse a `Boot` with a capture as `Unsupported` -rather than silently omit its devices and resource capacities. -They also refuse non-Unicode export child roots instead of lossily rendering -a different host path. -`VmConfig::check` and `kernel_command_line` retain their existing behavior for -valid capture configurations. +For a capture, `CaptureConfig` supplies the processor capacity, the boot-online +processor limit, and the boot memory and its capacity; the ordinary VM's cold +resource counts need not match. Boot memory must be aligned to 128 MiB. +The kernel text omits root, view, mapping and workload tokens and admits only +OpenVMM's named platform allowlist. Caller-supplied tier, processor, console, +device and network tokens are refused by name even when allowlisted: the +builder owns them. OpenVMM adds exactly one `nvx_snapshot_tier=platform` from +`--snapshot-tier platform`; callers may not supply or duplicate it. +Non-Unicode export child roots are still refused rather than lossily rendered. `VmConfig::check_command_line()` checks the configuration's known arguments on every host, before any host change, with 2048 UTF-16 code units reserved for the diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 1e0e25b6a..a9a136c3b 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -129,7 +129,7 @@ impl UpperConfig { } /// Where the output of a VM's boot console goes. The console's device exists either way. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum ConsoleAttachment { /// OpenVMM listens for a client of the console. Listener, @@ -148,7 +148,7 @@ pub struct NetworkConfig { } /// The NIC that a VM has, with the static address that it settled on. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct NicConfig { /// The NIC's static address. pub guest_network: Ipv4Cidr, @@ -160,6 +160,8 @@ pub struct Boot { /// The caller's own kernel command-line tokens, to which NVX adds those of what it lowers. /// The root's tokens, `nvx_hostname`, `nvx_machine_id`, `nvx_maps` and `virtfs_*` are reserved; /// every other token, including `nvx_entrypoint`, `nvx_arg` and `nvx_share`, is caller-owned. + /// A capture permits only OpenVMM's platform allowlist and refuses caller tokens whose + /// tier, processor, console, device or network settings the builder supplies. pub command_line: String, /// What a capture of the VM adds, if the VM may be captured. pub capture: Option, @@ -169,7 +171,7 @@ pub struct Boot { /// the disks, the processors and the memory that the VM boots with and that a restore may grow /// it to, and the caller's invariant settings. The capture VM's own configuration gives the /// rest, such as its NIC, its console, and its export. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct CaptureConfig { /// How the snapshot treats the disks. pub disks: DiskCapture, @@ -184,7 +186,7 @@ pub struct CaptureConfig { } /// How a capture's snapshot treats the disks. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[non_exhaustive] pub enum DiskCapture { /// A fresh capture of a blockless VM, to which the capture attaches a placeholder of the @@ -198,7 +200,7 @@ pub enum DiskCapture { } /// A resource at boot, and the capacity to which a restore may grow it. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct Capacity { /// The resource at boot. pub boot: u64, @@ -207,7 +209,7 @@ pub struct Capacity { } /// The length and block sizes of a sandbox block device. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub struct BlockGeometry { /// The length that the device presents, in bytes: nonzero, and a multiple of the logical /// block size. @@ -314,6 +316,24 @@ impl Invariants { } } +impl serde::Serialize for Invariants { + fn serialize(&self, serializer: S) -> Result { + serde::Serialize::serialize(&self.0, serializer) + } +} + +impl<'de> serde::Deserialize<'de> for Invariants { + fn deserialize>(deserializer: D) -> Result { + let settings = as serde::Deserialize>::deserialize(deserializer)?; + settings + .into_iter() + .try_fold(Self::new(), |settings, (name, value)| { + settings.with(&name, &value) + }) + .map_err(serde::de::Error::custom) + } +} + /// Explains why `name` and `value` are not an invariant setting, if they are not. pub(crate) fn check_setting(name: &str, value: &str) -> Result<(), String> { let name_valid = (1..=Invariants::MAX_NAME_LEN).contains(&name.len()) @@ -397,12 +417,17 @@ impl VmConfig { /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) when /// [`check`](Self::check) refuses the configuration or when it has no [`Boot`]. A restore /// receives its view through its activation record, not a kernel command line. + /// A capture renders only accepted platform tokens and its boot-online processor count; + /// OpenVMM adds the single tier token from the capture's `--snapshot-tier platform` option. pub fn kernel_command_line(&self) -> Result { self.check()?; let boot = self .boot .as_ref() .ok_or_else(|| Error::unsupported("a kernel command line needs a cold boot's Boot"))?; + if let Some(capture) = &boot.capture { + return self.capture_command_line(boot, capture); + } let mut tokens = Vec::new(); if let RootConfig::Layered(root) = &self.root { let (upper, journal, max_memory_percent) = match &root.upper { @@ -436,7 +461,58 @@ impl VmConfig { Ok(tokens.join(" ")) } - /// Renders a non-capture cold boot's complete OpenVMM arguments from this configuration and + /// Renders a blockless capture without root, view, mapping or workload tokens. + fn capture_command_line(&self, boot: &Boot, capture: &CaptureConfig) -> Result { + if self.root != RootConfig::Blockless { + return Err(Error::unsupported("a fresh capture needs a blockless root")); + } + if self.filesystem.is_some() { + return Err(Error::unsupported( + "a fresh capture refuses any filesystem export", + )); + } + if !capture.memory.boot.is_multiple_of(MEMORY_BLOCK_MIB) { + return Err(Error::unsupported(format!( + "a fresh capture's boot memory of {} MiB is not a multiple of {MEMORY_BLOCK_MIB} MiB", + capture.memory.boot + ))); + } + for token in boot.command_line.split_ascii_whitespace() { + let name = token.split_once('=').map_or(token, |(name, _)| name); + if !platform_command_line_token_is_invariant(token) + || [ + "earlycon", + "console", + "virtio_mmio.device", + "virtnet_ip", + "virtnet_mask", + "virtnet_gw", + "virtnet_dns", + "virtnet_ip6", + "virtnet_gw6", + "nvx_snapshot_tier", + "nr_cpus", + "maxcpus", + "nvx_control_tty", + ] + .contains(&name) + { + return Err(Error::unsupported(format!( + "Boot.command_line uses the forbidden platform capture token {name:?}" + ))); + } + } + let mut command_line = boot.command_line.clone(); + if capture.processors.boot < capture.processors.capacity { + if !command_line.is_empty() { + command_line.push(' '); + } + command_line.push_str(&format!("maxcpus={}", capture.processors.boot)); + } + Ok(command_line) + } + + /// Renders a cold boot's OpenVMM arguments from this configuration and /// `runtime`. /// /// `endpoint`, `report` and `console_endpoint` are already chosen output names, not launch @@ -451,9 +527,11 @@ impl VmConfig { /// [`crate::host::vm::create`] calls this internal whole-config renderer. Neither managed /// lifecycle nor workload-identity flags are rendered; the caller owns its guest tokens. /// `runtime.windows_breakaway` is process-launch behavior, not an OpenVMM argument. + /// A capture's resources and tier are rendered here; creation adds its checked block paths + /// and snapshot destination, then rechecks the complete command line. /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused - /// configuration, no [`Boot`], a capture-bearing boot, empty or NUL-containing required names + /// configuration, no [`Boot`], empty or NUL-containing required names /// or runtime paths, serial-option delimiters in listener names, duplicate listener names, /// non-Unicode export child roots, block paths that are not Unicode, hold /// disk-option delimiters or @@ -508,13 +586,17 @@ impl VmConfig { } }; let mut arguments = self.device_arguments()?; + let capture = self.boot.as_ref().and_then(|boot| boot.capture.as_ref()); arguments.extend(boot_arguments( runtime.hypervisor, - self.memory_mib, + capture.map_or(self.memory_mib, |capture| capture.memory.boot), &runtime.kernel, &runtime.initramfs, &command_line, )); + if let Some(capture) = capture { + arguments.extend(capture.openvmm_arguments()); + } arguments.extend(console_arguments(&console, endpoint, report)); if let Some(profile) = &runtime.cpu_profile { if profile.is_empty() { @@ -547,7 +629,7 @@ impl VmConfig { Ok(arguments) } - /// Checks the configuration's part of a non-capture cold boot's OpenVMM command line on every + /// Checks the configuration's part of a cold boot's OpenVMM command line on every /// host, without reading or changing the host, with 2048 UTF-16 code units reserved for the /// runtime's files, launch options, and the VM's output paths. /// @@ -558,7 +640,7 @@ impl VmConfig { /// A listener's backend prefix is counted here; its endpoint name stays in the reserve. /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a refused - /// configuration or no [`Boot`], a capture-bearing boot, an unlowerable network policy, a + /// configuration or no [`Boot`], an unlowerable non-capture network policy, a /// NUL-containing argument, a non-Unicode export child root or block path, one that OpenVMM's /// disk syntax cannot hold literally, or one whose /// extension selects a non-raw backend, kernel text with double quotes or the standalone `--` @@ -566,9 +648,14 @@ impl VmConfig { pub fn check_command_line(&self) -> Result<()> { let command_line = self.control_kernel_command_line()?; let mut arguments = self.device_arguments()?; + let capture = self.boot.as_ref().and_then(|boot| boot.capture.as_ref()); arguments.extend([ "--memory".into(), - format!("{}M", self.memory_mib).into(), + format!( + "{}M", + capture.map_or(self.memory_mib, |capture| capture.memory.boot) + ) + .into(), "--cmdline".into(), command_line.into(), "--virtio-console".into(), @@ -578,22 +665,16 @@ impl VmConfig { } .into(), ]); + if let Some(capture) = capture { + arguments.extend(capture.openvmm_arguments()); + } check_argument_nuls(&arguments)?; fit_arguments(&arguments, OPENVMM_COMMAND_LINE_RESERVE) } - /// Renders a non-capture boot's kernel text that OpenVMM's control console accepts. + /// Renders a boot's kernel text that OpenVMM's control console accepts. fn control_kernel_command_line(&self) -> Result { let command_line = self.kernel_command_line()?; - if self - .boot - .as_ref() - .is_some_and(|boot| boot.capture.is_some()) - { - return Err(Error::unsupported( - "cold-boot argument rendering does not support capture configurations", - )); - } if command_line.contains('"') || command_line .split_ascii_whitespace() @@ -609,11 +690,17 @@ impl VmConfig { /// Renders only the configuration's devices; no runtime or output name is needed. fn device_arguments(&self) -> Result> { + let capture = self.boot.as_ref().and_then(|boot| boot.capture.as_ref()); let mut arguments = vec![ "--machine".into(), "microvm".into(), "--processors".into(), - self.processors.to_string().into(), + capture + .map_or(u64::from(self.processors), |capture| { + capture.processors.capacity + }) + .to_string() + .into(), ]; if let RootConfig::Layered(root) = &self.root { let scratch = match &root.upper { @@ -648,7 +735,11 @@ impl VmConfig { || DEFAULT_GUEST_NETWORK.to_owned(), |address| address.to_string(), ); - let mut nic = network::openvmm_arguments(Some(&network.policy), &guest_network)?; + let mut nic = if capture.is_some() { + network::template_arguments(&guest_network) + } else { + network::openvmm_arguments(Some(&network.policy), &guest_network)? + }; if nic.is_empty() { // A configured NIC remains present even under the deny-all posture. nic = network::template_arguments(&guest_network); @@ -747,6 +838,34 @@ impl VmConfig { } } +/// OpenVMM's named platform snapshot command-line allowlist. +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" + | "nvx_control_tty=hvc2" + ) || token.starts_with("nr_cpus=") + || token.starts_with("maxcpus=") + || [ + "virtio_mmio.device=", + "virtnet_ip=", + "virtnet_mask=", + "virtnet_gw=", + "virtnet_dns=", + "virtnet_ip6=", + "virtnet_gw6=", + ] + .iter() + .any(|prefix| token.starts_with(prefix)) +} + impl LayeredRoot { /// Checks the root's fields, as [`VmConfig::check`] describes. fn check(&self) -> Result<()> { @@ -785,6 +904,16 @@ impl LayeredRoot { } impl CaptureConfig { + /// The capacity and platform tier shared by capture argument checks and rendering. + fn openvmm_arguments(&self) -> [OsString; 4] { + [ + "--memory-capacity".into(), + format!("{}M", self.memory.capacity).into(), + "--snapshot-tier".into(), + "platform".into(), + ] + } + /// Checks the capture's settings without reading the host. /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for processors online diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index ac8f140c8..3c3668fd2 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -346,6 +346,26 @@ fn block_geometries_hold_whole_blocks_of_power_of_two_sizes() { } } +#[test] +fn capture_invariants_serde_cannot_bypass_their_checked_form() { + let invariants = Invariants::new().with("isolation.mode", "strict").unwrap(); + assert_eq!( + serde_json::from_str::(&serde_json::to_string(&invariants).unwrap()).unwrap(), + invariants + ); + for settings in [ + BTreeMap::from([("Invalid".to_owned(), "value".to_owned())]), + BTreeMap::from([("valid".to_owned(), "\n".to_owned())]), + (0..=Invariants::MAX_SETTINGS) + .map(|index| (format!("setting{index}"), String::new())) + .collect(), + ] { + assert!( + serde_json::from_value::(serde_json::to_value(settings).unwrap()).is_err() + ); + } +} + /// Returns a fresh capture of 1 processor online of 2, and of 256 MiB of memory of 512. fn capture() -> CaptureConfig { let image = BlockGeometry { @@ -1120,11 +1140,11 @@ fn values<'a>(arguments: &'a [OsString], option: &str) -> Vec<&'a OsStr> { } #[test] -fn cold_boot_arguments_refuse_capture_configurations() { - let config = captured(); +fn capture_arguments_accept_platform_tokens_and_refuse_nonplatform_tokens() { + let mut config = captured(); config.check().unwrap(); - config.kernel_command_line().unwrap(); for result in [ + config.kernel_command_line().map(|_| ()), config.check_command_line(), config .openvmm_arguments( @@ -1137,7 +1157,97 @@ fn cold_boot_arguments_refuse_capture_configurations() { ] { let error = result.unwrap_err(); assert_eq!(error.kind(), ErrorKind::Unsupported); - assert!(error.to_string().contains("capture")); + assert!(error.to_string().contains("quiet"), "{error}"); + } + config.boot.as_mut().unwrap().command_line = "nvx_sandbox=1".to_owned(); + config.check_command_line().unwrap(); + let arguments = config + .openvmm_arguments( + &runtime(), + "/missing/control", + Path::new("/missing/report"), + Some("/missing/console"), + ) + .unwrap(); + assert_eq!( + values(&arguments, "--cmdline"), + [OsStr::new("nvx_sandbox=1 maxcpus=1")] + ); + assert_eq!(values(&arguments, "--processors"), [OsStr::new("2")]); + assert_eq!(values(&arguments, "--memory"), [OsStr::new("256M")]); + assert_eq!( + values(&arguments, "--memory-capacity"), + [OsStr::new("512M")] + ); + assert_eq!( + values(&arguments, "--snapshot-tier"), + [OsStr::new("platform")] + ); +} + +#[test] +fn capture_command_lines_keep_only_unowned_platform_tokens() { + for token in [ + "", + "reboot=t", + "panic=-1", + "nvx_sandbox=1", + "nvx_config=0xd0010000,65536", + ] { + let mut config = captured(); + config.boot.as_mut().unwrap().command_line = token.to_owned(); + let expected = if token.is_empty() { + "maxcpus=1".to_owned() + } else { + format!("{token} maxcpus=1") + }; + assert_eq!(config.kernel_command_line().unwrap(), expected); + config.check_command_line().unwrap(); + } +} + +#[test] +#[allow(unreachable_pub, clippy::missing_docs_in_private_items)] +fn capture_platform_allowlist_matches_openvmms_named_contract() { + mod openvmm_defs { + pub(super) mod microvm { + include!("../../../tests/vectors/openvmm/microvm_control_tty_command_line.rs"); + } + } + include!("../../../tests/vectors/openvmm/platform_command_line_token_is_invariant.rs"); + + for token in [ + "earlycon=xe9", + "console=hvc0", + "console=hvc1", + "reboot=t", + "panic=-1", + "nvx_sandbox=1", + "nvx_config=0xd0010000,65536", + "nvx_snapshot_tier=platform", + "nr_cpus=4", + "maxcpus=1", + "nvx_control_tty=hvc2", + "virtio_mmio.device=0x1000@0xd0000000:1", + "virtnet_ip=10.42.0.2", + "virtnet_mask=255.255.255.0", + "virtnet_gw=10.42.0.1", + "virtnet_dns=10.42.0.1", + "virtnet_ip6=fd00::2", + "virtnet_gw6=fd00::1", + "nvx_snapshot_tier=workload", + "nvx_entrypoint=/bin/sh", + "nvx_hostname=tenant", + "virtfs_tag=tenant", + "nvx_maps=1", + "quiet", + "custom=tenant", + ] { + assert_eq!( + super::platform_command_line_token_is_invariant(token), + platform_command_line_token_is_invariant(token), + "{token}" + ); } } diff --git a/nvx-rs/src/host/snapshot.rs b/nvx-rs/src/host/snapshot.rs index 7fcb88f6e..8aceeb575 100644 --- a/nvx-rs/src/host/snapshot.rs +++ b/nvx-rs/src/host/snapshot.rs @@ -40,10 +40,14 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; + +use crate::host::config::{CaptureConfig, ConsoleAttachment, NicConfig}; use crate::host::paths::{absolute, normalized}; +use crate::host::runtime::RuntimeIdentity; use crate::host::store::regular; use crate::host::{filesystem, platform}; -use crate::{Error, Result}; +use crate::{Error, Hypervisor, Result}; pub use self::eligibility::{Downtime, DowntimeSource, Eligibility, HostTime, MAX_DOWNTIME_NS}; pub use self::key::{CaptureClass, ClassIdentity, Invariants, KeyIdentity, SnapshotKey}; @@ -58,6 +62,28 @@ pub const DESCRIPTION_FILE: &str = "snapshot.json"; /// The directory of OpenVMM's manifest, RAM and saved state. pub const OPENVMM_DIR: &str = "openvmm"; +/// The checked configuration and effective devices of a blockless platform capture. +/// +/// [`crate::host::vm::Prepared::capture_context`] returns it after verifying the actual sparse +/// disk geometries. Callers choose whether and how to persist it; creation writes no context, +/// key or snapshot description into the VM directory. Runtime paths and network policy are +/// not part of the context. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CaptureContext { + /// Capture resources, invariants and verified image and scratch geometries. + pub(crate) capture: CaptureConfig, + /// The attached NIC's resolved address, without the capture's deny-all policy. + pub(crate) nic: Option, + /// The boot console's effective attachment. + pub(crate) console: ConsoleAttachment, + /// The identities of the runtime artifacts and their contracts. + pub(crate) runtime: RuntimeIdentity, + /// The backend selected for the capture. + pub(crate) hypervisor: Hypervisor, + /// The requested CPU profile, or OpenVMM's default. + pub(crate) cpu_profile: Option, +} + /// A checked snapshot directory and its verified metadata, for a future VM restore. /// /// The caller must keep the directory and its entries private and immutable while using this diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 142ca7c0c..7b02d7721 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -18,7 +18,10 @@ use std::io; use std::path::{Path, PathBuf}; use std::time::Instant; -use crate::host::config::{ConsoleAttachment, RootConfig, UpperConfig, VmConfig}; +use crate::host::config::{ + BlockGeometry, CaptureConfig, ConsoleAttachment, DEFAULT_GUEST_NETWORK, DiskCapture, NicConfig, + RootConfig, UpperConfig, VmConfig, fit_process_command_line, reject_disk_path, +}; use crate::host::filesystem; use crate::host::paths::{absolute, normalized}; use crate::host::platform; @@ -28,13 +31,14 @@ use crate::host::platform::{check_store_dir as check_dir, create_store_dir as cr use crate::host::platform::{check_vm_dir as check_dir, create_vm_dir as create_dir}; use crate::host::report::{self, Report}; use crate::host::runtime::{Runtime, RuntimeIdentity}; -use crate::host::snapshot::Snapshot; +use crate::host::snapshot::{CaptureContext, Snapshot}; use crate::host::spawn::{self, VmmCommand}; use crate::host::transport::{self, HostConnection}; use crate::host::{ platform::inherit::Inherited, process::{Opened, Process, ProcessRef}, }; +use crate::net::Ipv4Cidr; use crate::protocol::CAPABILITY_LEN; use crate::{Error, Result}; @@ -49,10 +53,6 @@ const CONSOLE: &str = "console.sock"; /// The restore-ready socket on Linux. const RESTORE_READY: &str = "restore-ready.sock"; /// OpenVMM's snapshot files: a capture's destination and a restore's snapshot view. -#[cfg_attr( - not(test), - expect(dead_code, reason = "SL2.12b and SL2.13 use it (#1102)") -)] const OPENVMM: &str = "openvmm"; /// A finished capture's key. #[cfg_attr( @@ -67,16 +67,8 @@ const KEY: &str = "key.bin"; )] const DESCRIPTION: &str = "snapshot.json"; /// The sparse placeholder image that a capture VM attaches. -#[cfg_attr( - not(test), - expect(dead_code, reason = "SL2.13's capture creates it (#1102)") -)] const CAPTURE_IMAGE: &str = "capture-image.raw"; /// The dormant scratch that a capture VM attaches. -#[cfg_attr( - not(test), - expect(dead_code, reason = "SL2.13's capture creates it (#1102)") -)] const CAPTURE_SCRATCH: &str = "capture-scratch.raw"; /// The prefix OpenVMM requires of its Windows console pipes. const OPENVMM_PIPE: &str = "openvmm-microvm"; @@ -317,6 +309,8 @@ pub struct Prepared { arguments: Vec, /// Already opened resources added by the caller, retained until the preparation is dropped. inherit: Vec, + /// The checked context of a capture, absent for an ordinary cold launch. + capture_context: Option, } impl Prepared { @@ -346,6 +340,13 @@ impl Prepared { pub fn arguments(&self) -> &[OsString] { &self.arguments } + + /// Returns the checked capture context, if this launch prepares a blockless capture. + /// + /// The caller chooses whether and how to persist it; creation writes no context file. + pub fn capture_context(&self) -> Option<&CaptureContext> { + self.capture_context.as_ref() + } } /// Creates an inspectable cold launch in `dir`, without starting a process or binding endpoints. @@ -357,8 +358,12 @@ impl Prepared { /// /// The log is created anew, privately, and kept open in [`Prepared`]. Other outputs are reserved /// names that OpenVMM creates when it runs: existing entries at those names are refused, not -/// replaced. A failure closes and removes only the log that this invocation created, preserving +/// replaced. A failure closes and removes only files that this invocation created, preserving /// every pre-existing entry and the caller's inputs. The capability is never written to disk. +/// With `boot.capture`, the root must be blockless and no export is accepted. Creation also +/// prepares sparse image and scratch files, verifies their actual geometry, and retains the +/// checked capture context. OpenVMM captures to `openvmm/` under this VM directory, with a +/// configured NIC under deny-all. No key, description or context file is written. /// /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a restore, a missing /// boot, a refused configuration, unrepresentable Linux socket names, or a complete Windows @@ -380,6 +385,11 @@ pub fn create( if config.boot.is_none() { return Err(Error::unsupported("creating a cold VM needs Boot")); } + let capture = config.boot.as_ref().and_then(|boot| boot.capture.as_ref()); + if capture.is_some() { + config.kernel_command_line()?; + runtime.identity.check()?; + } VmDir::open(dir.path())?; platform::check_working_dir(dir.path())?; let outputs = dir.outputs(config.console == ConsoleAttachment::Listener)?; @@ -436,6 +446,7 @@ pub fn create( )) .with_source(error) })?; + let mut created = Vec::new(); let checked = (|| { #[cfg(unix)] { @@ -458,16 +469,68 @@ pub fn create( } } let capability = Capability(crate::host::instance::generate_capability()?); - let arguments = config.openvmm_arguments( + let mut arguments = config.openvmm_arguments( &runtime, &outputs.endpoint, &outputs.outcome, outputs.console.as_deref(), )?; - Ok((capability, arguments)) + let capture_context = if let Some(capture) = capture { + let image_path = dir.path.join(CAPTURE_IMAGE); + let scratch_path = dir.path.join(CAPTURE_SCRATCH); + let destination = dir.path.join(OPENVMM); + for path in [&image_path, &scratch_path, &destination] { + unused_output(path)?; + } + for path in [&image_path, &scratch_path] { + if path.to_str().is_none() { + return Err(Error::unsupported( + "capture block paths must be Unicode for OpenVMM", + )); + } + reject_disk_path(path, &path.display().to_string())?; + } + arguments.extend([ + "--microvm-sandbox-block".into(), + format!("distro:file:{},ro", image_path.display()).into(), + "--microvm-sandbox-block".into(), + format!("scratch:file:{}", scratch_path.display()).into(), + "--snapshot-destination".into(), + destination.into_os_string(), + ]); + fit_process_command_line(runtime.openvmm.as_os_str(), &arguments, 0)?; + let DiskCapture::Fresh { image, scratch } = capture.disks; + let image = capture_disk(&image_path, image, &mut created)?; + let scratch = capture_disk(&scratch_path, scratch, &mut created)?; + let nic = config + .network + .as_ref() + .map(|network| { + network.guest_network.map_or_else( + || Ipv4Cidr::parse(DEFAULT_GUEST_NETWORK).map_err(Error::unsupported), + Ok, + ) + }) + .transpose()? + .map(|guest_network| NicConfig { guest_network }); + Some(CaptureContext { + capture: CaptureConfig { + disks: DiskCapture::Fresh { image, scratch }, + ..capture.clone() + }, + nic, + console: config.console, + runtime: runtime.identity.clone(), + hypervisor: runtime.hypervisor, + cpu_profile: runtime.cpu_profile.clone(), + }) + } else { + None + }; + Ok((capability, arguments, capture_context)) })(); match checked { - Ok((capability, arguments)) => Ok(Prepared { + Ok((capability, arguments, capture_context)) => Ok(Prepared { program: runtime.openvmm.clone(), working_dir: dir.path().to_owned(), windows_breakaway: runtime.windows_breakaway, @@ -476,9 +539,24 @@ pub fn create( log, arguments, inherit: Vec::new(), + capture_context, }), - Err(error) => { + Err(mut error) => { drop(log); + for path in created.iter().rev() { + if let Err(cleanup) = fs::remove_file(path) + && cleanup.kind() != io::ErrorKind::NotFound + { + error = Error::new( + error.kind(), + format!( + "{error}; cannot remove created capture file {}: {cleanup}", + path.display() + ), + ) + .with_source(error); + } + } match fs::remove_file(&outputs.log) { Ok(()) => Err(error), Err(cleanup) if cleanup.kind() == io::ErrorKind::NotFound => Err(error), @@ -617,6 +695,7 @@ fn spawn_verified( log, arguments, inherit, + capture_context: _, } = prepared; let command = VmmCommand { program, @@ -648,6 +727,46 @@ fn spawn_verified( }) } +/// Creates one private sparse capture disk and verifies the geometry OpenVMM presents. +fn capture_disk( + path: &Path, + expected: BlockGeometry, + created: &mut Vec, +) -> Result { + let failed = |error| { + Error::io(format!("cannot create capture disk {}", path.display())).with_source(error) + }; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let file = options.open(path).map_err(failed)?; + created.push(path.to_owned()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + file.set_permissions(fs::Permissions::from_mode(0o600)) + .map_err(failed)?; + } + if let Some(problem) = platform::private_file_problem(&file).map_err(failed)? { + return Err(Error::io(format!( + "the capture disk {} {problem}", + path.display() + ))); + } + platform::make_sparse(&file).map_err(failed)?; + file.set_len(expected.length).map_err(failed)?; + let actual = crate::host::sandbox::block_geometry(&file, path, "capture disk")?; + if actual != expected { + return Err(Error::unsupported(format!( + "capture disk {} has actual geometry {actual:?}, not requested {expected:?}", + path.display() + ))); + } + Ok(actual) +} + /// Refuses an existing output entry without opening, replacing, or removing it. fn unused_output(path: &Path) -> Result<()> { let error = match fs::symlink_metadata(path) { diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index 97b4b5ca6..2c7728f4d 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -1,3 +1,4 @@ +use std::ffi::OsStr; use std::fs; #[cfg(any(target_os = "linux", windows))] use std::process::{Command, Stdio}; @@ -8,9 +9,10 @@ use super::*; use crate::ErrorKind; use crate::activation::{Journal, MachineIdMode, OverlayMode, View}; use crate::host::config::{ - Boot, LayeredRoot, PROCESS_COMMAND_LINE_LIMIT, RootConfig, UpperConfig, - process_command_line_units, + Boot, Capacity, Invariants, LayeredRoot, NetworkConfig, PROCESS_COMMAND_LINE_LIMIT, RootConfig, + UpperConfig, process_command_line_units, }; +use crate::host::network::{Access, HostLoopback, NetworkPolicy}; #[cfg(any(target_os = "linux", windows))] use crate::host::process::Opened; use crate::host::runtime::{ArtifactIdentity, LaunchOptions, RuntimeIdentity}; @@ -454,6 +456,424 @@ fn cold_runtime() -> Runtime { } } +fn capture_config(vm: &VmDir) -> VmConfig { + #[cfg(target_os = "linux")] + let physical_block_size = { + use std::os::unix::fs::MetadataExt; + + u32::try_from(fs::metadata(vm.path()).unwrap().blksize()).unwrap() + }; + #[cfg(windows)] + let physical_block_size = 4096; + #[cfg(not(any(target_os = "linux", windows)))] + let physical_block_size = 512; + let image = BlockGeometry { + length: 16 << 20, + logical_block_size: 512, + physical_block_size, + }; + VmConfig { + processors: 2, + boot: Some(Boot { + command_line: "nvx_sandbox=1".to_owned(), + capture: Some(CaptureConfig { + disks: DiskCapture::Fresh { + image, + scratch: BlockGeometry { + length: 8 << 20, + ..image + }, + }, + processors: Capacity { + boot: 2, + capacity: 2, + }, + memory: Capacity { + boot: 256, + capacity: 512, + }, + invariants: Invariants::new().with("isolation.mode", "strict").unwrap(), + }), + }), + ..cold_config() + } +} + +fn argument_values<'a>(arguments: &'a [OsString], option: &str) -> Vec<&'a OsStr> { + arguments + .windows(2) + .filter(|pair| pair[0] == option) + .map(|pair| pair[1].as_os_str()) + .collect() +} + +#[test] +fn capture_creation_records_sparse_disks_devices_and_a_complete_serde_context() { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + for nic in [ + None, + Some(None), + Some(Some(Ipv4Cidr::parse("10.42.0.2/24").unwrap())), + ] { + let (_directory, vm) = vm_dir(); + let mut runtime = cold_runtime(); + runtime.identity = RuntimeIdentity::new( + ArtifactIdentity::new("capture-openvmm").unwrap(), + ArtifactIdentity::new("capture-kernel").unwrap(), + ArtifactIdentity::new("capture-initramfs").unwrap(), + ); + runtime.cpu_profile = nic.map(|_| "amd.genoa.v1".to_owned()); + let mut policy = NetworkPolicy::new(Access::Allow); + policy.ingress = Access::Allow; + policy.host_loopback = HostLoopback::Allow; + let config = VmConfig { + console, + network: nic.map(|guest_network| NetworkConfig { + guest_network, + policy, + }), + ..capture_config(&vm) + }; + let prepared = create(&runtime, &config, None, &vm).unwrap(); + let context = prepared.capture_context().unwrap(); + assert_eq!( + context.capture, + *config.boot.as_ref().unwrap().capture.as_ref().unwrap() + ); + assert_eq!(context.console, console); + assert_eq!(context.runtime, runtime.identity); + assert_eq!(context.hypervisor, runtime.hypervisor); + assert_eq!(context.cpu_profile, runtime.cpu_profile); + assert_eq!( + context.nic, + nic.map(|address| NicConfig { + guest_network: address + .unwrap_or_else(|| Ipv4Cidr::parse(DEFAULT_GUEST_NETWORK).unwrap()), + }) + ); + let serialized = serde_json::to_string(context).unwrap(); + assert_eq!( + serde_json::from_str::(&serialized).unwrap(), + context.clone() + ); + assert!(!serialized.contains("policy")); + let DiskCapture::Fresh { image, scratch } = context.capture.disks; + for (name, geometry) in [(CAPTURE_IMAGE, image), (CAPTURE_SCRATCH, scratch)] { + let path = vm.path().join(name); + let file = File::open(&path).unwrap(); + let metadata = file.metadata().unwrap(); + assert_eq!(metadata.len(), geometry.length); + assert_eq!(platform::private_file_problem(&file).unwrap(), None); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::MetadataExt; + + assert!(metadata.blocks() * 512 < metadata.len()); + assert_eq!(metadata.mode() & 0o777, 0o600); + assert_eq!(u64::from(geometry.physical_block_size), metadata.blksize()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + + assert_ne!(metadata.file_attributes() & 0x200, 0, "not a sparse file"); + } + } + let arguments = prepared.arguments(); + assert_eq!( + argument_values(arguments, "--snapshot-tier"), + [OsStr::new("platform")] + ); + assert_eq!( + argument_values(arguments, "--snapshot-destination"), + [vm.path().join(OPENVMM).as_os_str()] + ); + assert_eq!( + argument_values(arguments, "--memory-capacity"), + [OsStr::new("512M")] + ); + assert_eq!( + argument_values(arguments, "--microvm-sandbox-block"), + [ + OsStr::new(&format!( + "distro:file:{},ro", + vm.path().join(CAPTURE_IMAGE).display() + )), + OsStr::new(&format!( + "scratch:file:{}", + vm.path().join(CAPTURE_SCRATCH).display() + )), + ] + ); + assert_eq!( + argument_values(arguments, "--cmdline"), + [OsStr::new("nvx_sandbox=1")] + ); + if let Some(nic) = context.nic { + assert_eq!( + argument_values(arguments, "--net"), + [OsStr::new(&nic.guest_network.to_string())] + ); + for option in ["--network-egress", "--network-ingress", "--host-loopback"] { + assert_eq!(argument_values(arguments, option), [OsStr::new("deny")]); + } + } else { + assert!(argument_values(arguments, "--net").is_empty()); + } + for option in [ + "--network-egress-allow", + "--network-egress-deny", + "--host-loopback-forward", + "--proxy", + ] { + assert!(argument_values(arguments, option).is_empty(), "{option}"); + } + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 3); + for name in [OPENVMM, KEY, DESCRIPTION] { + assert!(!vm.path().join(name).exists(), "{name}"); + } + } + } +} + +#[test] +fn capture_creation_refuses_nonplatform_tokens_by_name_without_leaving_outputs() { + for token in [ + "quiet", + "loglevel=0", + "nvx_entrypoint=/bin/sh", + "nvx_arg=tenant", + "nvx_share=/tenant", + "nvx_workload_account=create", + "nvx_layer=erofs", + "nvx_hostname=tenant", + "nvx_maps=1", + "virtfs_tag=tenant", + "custom=tenant", + "earlycon=xe9", + "console=hvc1", + "virtio_mmio.device=0x1000@0xd0000000:1", + "virtnet_ip=10.42.0.2", + "virtnet_mask=255.255.255.0", + "virtnet_gw=10.42.0.1", + "virtnet_dns=10.42.0.1", + "virtnet_ip6=fd00::2", + "virtnet_gw6=fd00::1", + "nvx_snapshot_tier=platform", + "nvx_snapshot_tier=platform nvx_snapshot_tier=platform", + "nr_cpus=2", + "maxcpus=2", + "nvx_control_tty=hvc2", + ] { + let (_directory, vm) = vm_dir(); + let mut config = capture_config(&vm); + config.boot.as_mut().unwrap().command_line = token.to_owned(); + let error = create(&cold_runtime(), &config, None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!( + error.to_string().contains(token.split('=').next().unwrap()), + "{error}" + ); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); + } +} + +#[test] +fn capture_resources_not_the_vms_cold_resources_determine_the_launch() { + for (boot, capacity) in [(1, 4), (2, 2)] { + let (_directory, vm) = vm_dir(); + let mut config = capture_config(&vm); + config.processors = 3; + config.memory_mib = 17; + let capture = config.boot.as_mut().unwrap().capture.as_mut().unwrap(); + capture.processors = Capacity { boot, capacity }; + capture.memory.boot = 128; + let expected = capture.clone(); + let prepared = create(&cold_runtime(), &config, None, &vm).unwrap(); + assert_eq!(prepared.capture_context().unwrap().capture, expected); + assert_eq!( + argument_values(prepared.arguments(), "--processors"), + [OsStr::new(&capacity.to_string())] + ); + assert_eq!( + argument_values(prepared.arguments(), "--memory"), + [OsStr::new("128M")] + ); + assert_eq!( + argument_values(prepared.arguments(), "--memory-capacity"), + [OsStr::new("512M")] + ); + assert_eq!( + argument_values(prepared.arguments(), "--cmdline"), + [OsStr::new(if boot < capacity { + "nvx_sandbox=1 maxcpus=1" + } else { + "nvx_sandbox=1" + })] + ); + } +} + +#[test] +fn capture_creation_refuses_unaligned_boot_memory_before_creating_outputs() { + let (_directory, vm) = vm_dir(); + let mut config = capture_config(&vm); + config + .boot + .as_mut() + .unwrap() + .capture + .as_mut() + .unwrap() + .memory + .boot = 129; + config.check().unwrap(); + let error = create(&cold_runtime(), &config, None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!( + error.to_string().contains("boot memory of 129 MiB"), + "{error}" + ); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); +} + +#[test] +fn capture_creation_refuses_layered_roots_and_exports_without_creating_outputs() { + use crate::host::filesystem::{Bind, ExportChild, ExportPlan, Owner}; + + let (_directory, vm) = vm_dir(); + let base = capture_config(&vm); + let layered = VmConfig { + root: RootConfig::Layered(LayeredRoot { + image: vm.path().join("caller-image.erofs"), + format: RootImage::erofs(None), + gpt_first_partition_bound: None, + upper: UpperConfig::Ram { + max_memory_percent: 50, + }, + overlay: OverlayMode::TrustedLowers, + }), + ..base.clone() + }; + let exported = VmConfig { + filesystem: Some(ExportPlan { + children: vec![ExportChild { + root: vm.path().to_owned(), + file: false, + writable: false, + denied: Vec::new(), + denied_identities: Vec::new(), + allowed: Vec::new(), + write: Vec::new(), + }], + binds: vec![Bind { + child: 0, + source: String::new(), + target: "/mnt/shared".to_owned(), + read_only: true, + identity: None, + }], + owner: Owner::Vmm, + }), + ..base + }; + for (config, refusal) in [(layered, "blockless"), (exported, "export")] { + config.check().unwrap(); + let error = create(&cold_runtime(), &config, None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains(refusal), "{error}"); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); + } +} + +#[test] +fn capture_geometry_mismatches_roll_back_only_the_new_files() { + for scratch_disk in [false, true] { + for logical in [false, true] { + let (_directory, vm) = vm_dir(); + let input = vm.path().join("caller-input"); + fs::write(&input, b"preserved").unwrap(); + let mut config = capture_config(&vm); + let capture = config.boot.as_mut().unwrap().capture.as_mut().unwrap(); + let DiskCapture::Fresh { image, scratch } = &mut capture.disks; + let geometry = if scratch_disk { scratch } else { image }; + if logical { + geometry.logical_block_size = 1024; + geometry.physical_block_size = geometry.physical_block_size.max(1024); + } else { + geometry.physical_block_size *= 2; + } + let error = create(&cold_runtime(), &config, None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.to_string().contains("actual geometry"), "{error}"); + assert!( + error.to_string().contains(if scratch_disk { + CAPTURE_SCRATCH + } else { + CAPTURE_IMAGE + }), + "{error}" + ); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 1); + assert_eq!(fs::read(input).unwrap(), b"preserved"); + } + } +} + +#[test] +fn capture_creation_never_replaces_existing_disks_or_snapshot_destinations() { + for name in [CAPTURE_IMAGE, CAPTURE_SCRATCH, OPENVMM] { + let (_directory, vm) = vm_dir(); + let existing = vm.path().join(name); + fs::write(&existing, b"preserved").unwrap(); + let error = create(&cold_runtime(), &capture_config(&vm), None, &vm).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert!(error.to_string().contains(name), "{error}"); + assert_eq!(fs::read(&existing).unwrap(), b"preserved"); + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 1); + } +} + +#[test] +fn capture_creation_checks_the_complete_command_line_including_disk_and_destination_paths() { + let (_directory, vm) = vm_dir(); + let config = capture_config(&vm); + let runtime = cold_runtime(); + let prepared = create(&runtime, &config, None, &vm).unwrap(); + let length = process_command_line_units(runtime.openvmm.as_os_str(), prepared.arguments()); + drop(prepared); + for name in [LOG, CAPTURE_IMAGE, CAPTURE_SCRATCH] { + fs::remove_file(vm.path().join(name)).unwrap(); + } + for extra in [0, 1] { + let mut runtime = runtime.clone(); + runtime + .openvmm + .as_mut_os_string() + .push("x".repeat(PROCESS_COMMAND_LINE_LIMIT - length + extra)); + let result = create(&runtime, &config, None, &vm); + if extra == 0 { + let prepared = result.unwrap(); + assert_eq!( + process_command_line_units(runtime.openvmm.as_os_str(), prepared.arguments()), + PROCESS_COMMAND_LINE_LIMIT + ); + drop(prepared); + for name in [LOG, CAPTURE_IMAGE, CAPTURE_SCRATCH] { + fs::remove_file(vm.path().join(name)).unwrap(); + } + } else { + let error = result.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!( + error.to_string().contains("Windows process command line"), + "{error}" + ); + } + assert_eq!(fs::read_dir(vm.path()).unwrap().count(), 0); + } +} + #[test] fn cold_creation_is_inspectable_without_a_process_listener_or_capability_file() { for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { @@ -464,6 +884,7 @@ fn cold_creation_is_inspectable_without_a_process_listener_or_capability_file() ..cold_config() }; let prepared = create(&runtime, &config, None, &vm).unwrap(); + assert!(prepared.capture_context().is_none()); let outputs = prepared.outputs(); assert_eq!(outputs.log, vm.path().join("openvmm.log")); assert_eq!( diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 399c715f3..ab7b22a0e 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -18,7 +18,7 @@ "nvx-rs/src/host/sandbox/plan.rs": 5, "nvx-rs/src/host/session.rs": 1, "nvx-rs/src/host/snapshot.rs": 1, - "nvx-rs/src/host/vm.rs": 5, + "nvx-rs/src/host/vm.rs": 2, "nvx-rs/src/protocol/app.rs": 6 } } From 531673149e99ae8187fca0356a2267e76c4bd1c0 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 10:46:59 -0800 Subject: [PATCH 17/25] host: compile the capture allowlist reference as an item Keep the OpenVMM source excerpt in a test-only module so its function definition is included in item context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 095bfb08-a5d0-4331-b2b2-dd76d2fbadd8 --- nvx-rs/src/host/config/tests.rs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index 3c3668fd2..f668618ad 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -1209,12 +1209,18 @@ fn capture_command_lines_keep_only_unowned_platform_tokens() { #[test] #[allow(unreachable_pub, clippy::missing_docs_in_private_items)] fn capture_platform_allowlist_matches_openvmms_named_contract() { - mod openvmm_defs { - pub(super) mod microvm { - include!("../../../tests/vectors/openvmm/microvm_control_tty_command_line.rs"); + mod openvmm { + mod openvmm_defs { + pub(super) mod microvm { + include!("../../../tests/vectors/openvmm/microvm_control_tty_command_line.rs"); + } + } + include!("../../../tests/vectors/openvmm/platform_command_line_token_is_invariant.rs"); + + pub(super) fn admits(token: &str) -> bool { + platform_command_line_token_is_invariant(token) } } - include!("../../../tests/vectors/openvmm/platform_command_line_token_is_invariant.rs"); for token in [ "earlycon=xe9", @@ -1245,7 +1251,7 @@ fn capture_platform_allowlist_matches_openvmms_named_contract() { ] { assert_eq!( super::platform_command_line_token_is_invariant(token), - platform_command_line_token_is_invariant(token), + openvmm::admits(token), "{token}" ); } From 8df64c298811d6b642910460cc843450c7a1e271 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 10:57:03 -0800 Subject: [PATCH 18/25] host: record the capture preparation API Regenerate both platform snapshots for the opaque capture context and the prepared-launch accessor. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 095bfb08-a5d0-4331-b2b2-dd76d2fbadd8 --- nvx-rs/public-api/linux.txt | 2 ++ nvx-rs/public-api/windows.txt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index e52582295..356ee33b1 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3037,6 +3037,7 @@ field nvx::host::snapshot::CaptureClass::processors field nvx::host::snapshot::CaptureClass::runtime field nvx::host::snapshot::CaptureClass::scratch method nvx::host::snapshot::CaptureClass::to_bytes +struct nvx::host::snapshot::CaptureContext struct nvx::host::snapshot::ClassIdentity method nvx::host::snapshot::ClassIdentity::as_bytes method nvx::host::snapshot::ClassIdentity::from_bytes @@ -3770,6 +3771,7 @@ field nvx::host::vm::Launched::process struct nvx::host::vm::Prepared method nvx::host::vm::Prepared::arguments method nvx::host::vm::Prepared::capability +method nvx::host::vm::Prepared::capture_context method nvx::host::vm::Prepared::inherit method nvx::host::vm::Prepared::log method nvx::host::vm::Prepared::outputs diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index fcb344468..812a5a3d4 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1623,6 +1623,7 @@ field nvx::host::snapshot::CaptureClass::processors field nvx::host::snapshot::CaptureClass::runtime field nvx::host::snapshot::CaptureClass::scratch method nvx::host::snapshot::CaptureClass::to_bytes +struct nvx::host::snapshot::CaptureContext struct nvx::host::snapshot::ClassIdentity method nvx::host::snapshot::ClassIdentity::as_bytes method nvx::host::snapshot::ClassIdentity::from_bytes @@ -2355,6 +2356,7 @@ field nvx::host::vm::Launched::process struct nvx::host::vm::Prepared method nvx::host::vm::Prepared::arguments method nvx::host::vm::Prepared::capability +method nvx::host::vm::Prepared::capture_context method nvx::host::vm::Prepared::inherit method nvx::host::vm::Prepared::log method nvx::host::vm::Prepared::outputs From 4ca3e62da07723368edec89ced8d02f2cd3fbae4 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 11:06:28 -0800 Subject: [PATCH 19/25] host: keep the capture fixture warning-free on Windows The fixture directory is needed only for Linux physical-block metadata. Mark the parameter accordingly on the other supported hosts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 095bfb08-a5d0-4331-b2b2-dd76d2fbadd8 --- nvx-rs/src/host/vm/tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index 2c7728f4d..2ff425c72 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -456,12 +456,12 @@ fn cold_runtime() -> Runtime { } } -fn capture_config(vm: &VmDir) -> VmConfig { +fn capture_config(_vm: &VmDir) -> VmConfig { #[cfg(target_os = "linux")] let physical_block_size = { use std::os::unix::fs::MetadataExt; - u32::try_from(fs::metadata(vm.path()).unwrap().blksize()).unwrap() + u32::try_from(fs::metadata(_vm.path()).unwrap().blksize()).unwrap() }; #[cfg(windows)] let physical_block_size = 4096; From d56d3d343977178fd15bbd526a77a97603edbbac Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 11:08:16 -0800 Subject: [PATCH 20/25] guest: route new sandbox boots through nvx-init Install through the existing fixed guest-binary list and preserve the legacy-token and blockless routes. Pass the known helper PIDs and move the runtime adapter overlay to the new init path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10c9dff8-5c75-4c96-9739-3896adfb0c71 --- guest/common/init | 30 ++++- scripts/test_nvx_tools.py | 162 +++++++++++++++++++++++ tests/runtime-adapter/README.md | 9 +- tests/runtime-adapter/src/initramfs.rs | 10 +- tests/runtime-adapter/src/worker.rs | 2 +- tests/runtime-adapter/tests/initramfs.rs | 7 +- 6 files changed, 203 insertions(+), 17 deletions(-) diff --git a/guest/common/init b/guest/common/init index 322333176..38e818f7e 100644 --- a/guest/common/init +++ b/guest/common/init @@ -35,6 +35,13 @@ case "$cmdline" in *" nvx_sandbox=1 "*) sandbox_mode=true ;; *) sandbox_mode=false ;; esac +new_init=false +if [ "$sandbox_mode" = true ]; then + case "$cmdline" in + *" nvx_lifecycle="*) ;; + *) new_init=true ;; + esac +fi fatal() { echo "$*" >&2 @@ -189,7 +196,7 @@ for token in $cmdline; do esac done set +f -if [ -n "$nvx_exec" ]; then +if [ -n "$nvx_exec" ] && [ "$new_init" = false ]; then case "$nvx_exec" in /*) ;; *) fatal "nvx_exec must be an absolute initramfs path" ;; @@ -206,6 +213,7 @@ if [ "$sandbox_mode" = false ]; then mount_virtfs fi # virt-net: if --net attached a NIC, configure the only non-loopback interface. +announcer_pid= nip=$(sed -n 's/.*virtnet_ip=\([^ ]*\).*/\1/p' /proc/cmdline) if [ -n "$nip" ]; then nmask=$(sed -n 's/.*virtnet_mask=\([^ ]*\).*/\1/p' /proc/cmdline) @@ -289,6 +297,7 @@ if [ -n "$nip" ]; then allow | host-loopback) if [ -n "$ngw" ] && [ -x /sbin/nvx-ingress ]; then /sbin/nvx-ingress "$ngw" str: return source[start : source.index("\n}\n", start) + 3] +class GuestInitRoutingTests(unittest.TestCase): + INIT = Path(__file__).parents[1] / "guest" / "common" / "init" + + def setUp(self): + shell = _posix_shell() + if shell is None: + self.skipTest("POSIX shell is unavailable") + self.shell = shell + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.pid = self.root / "daemon.pid" + self.announcer = self.root / "announcer.pid" + (self.root / "net" / "eth0").mkdir(parents=True) + (self.root / "os-release").write_text("ID=alpine\n", encoding="utf-8") + (self.root / "alpine-release").write_text("test\n", encoding="utf-8") + self.helpers: dict[str, Path] = {} + for name, script in { + "nvx-time": f"printf '4242\\n' >{shlex.quote(self.pid.as_posix())}\n", + "nvx-init": 'printf "new-init"; printf " <%s>" "$@"; echo\n', + "nvx-init-agent": 'printf "old-init"; printf " <%s>" "$@"; echo\n', + "nvx-ingress": ( + f'printf "%s\\n" "$$" >{shlex.quote(self.announcer.as_posix())}\n' + ), + "nvx-hostmount": "echo hostmount\n", + "shell": "echo blockless-shell\n", + }.items(): + path = self.root / name + path.write_text(f"#!/bin/sh\n{script}", encoding="utf-8") + path.chmod(0o755) + self.helpers[name] = path + + def _run(self, cmdline: str) -> subprocess.CompletedProcess[str]: + command_line = self.root / "cmdline" + command_line.write_text(cmdline, encoding="utf-8") + source = self.INIT.read_text(encoding="utf-8") + replacements = { + "/sbin/nvx-init-agent": self.helpers["nvx-init-agent"], + "/sbin/nvx-init": self.helpers["nvx-init"], + "/sbin/nvx-time": self.helpers["nvx-time"], + "/sbin/nvx-ingress": self.helpers["nvx-ingress"], + "/sbin/nvx-hostmount": self.helpers["nvx-hostmount"], + "/proc/cmdline": command_line, + "/proc/sys/kernel/modules_disabled": self.root / "modules_disabled", + "/sys/class/net": self.root / "net", + "/run/nvx/time/daemon.pid": self.pid, + "/etc/os-release": self.root / "os-release", + "/etc/alpine-release": self.root / "alpine-release", + "exec /bin/sh": self.helpers["shell"], + } + for original, replacement in replacements.items(): + target = shlex.quote(replacement.as_posix()) + if original == "exec /bin/sh": + target = f"exec {target}" + source = source.replace(original, target) + source = source.replace("/sbin/nvx-exit", "nvx_exit") + source = source.replace("/sbin/nvx-snapshot", "nvx_snapshot") + return subprocess.run( + [self.shell, "-s"], + input=( + "mount() { :; }\nifconfig() { :; }\nroute() { :; }\n" + 'nvx_exit() { exit "$1"; }\n' + "nvx_snapshot() { echo blockless-snapshot; }\n" + f"{source}" + ), + text=True, + capture_output=True, + check=False, + timeout=40, + ) + + def test_tokenless_layered_and_fresh_sandboxes_use_new_init(self): + for tokens in ( + "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs", + "nvx_snapshot_tier=platform", + "nvx_snapshot_tier=platform nvx_mode=managed", + ): + with self.subTest(tokens=tokens): + result = self._run(f"console=hvc0 nvx_sandbox=1 {tokens}") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "new-init <4242>\n") + + def test_old_lifecycle_tokens_keep_the_old_layered_init(self): + for lifecycle in ("managed", "one-shot", ""): + with self.subTest(lifecycle=lifecycle): + result = self._run( + "nvx_sandbox=1 nvx_layer=distro,0xd0003000,erofs " + f"nvx_overlay_upper=ramfs nvx_lifecycle={lifecycle}" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith("old-init"), result.stdout) + self.assertNotIn("new-init", result.stdout) + + def test_exec_override_cannot_bypass_the_new_sandbox_init(self): + result = self._run( + "nvx_sandbox=1 nvx_snapshot_tier=platform nvx_exec=/bin/true" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "new-init <4242>\n") + for tokens in ("", "nvx_sandbox=1 nvx_lifecycle=managed"): + with self.subTest(tokens=tokens): + result = self._run(f"{tokens} nvx_exec=/bin/true") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + + def test_blockless_platform_and_snapshot_vms_keep_their_routes(self): + for tokens in ("", "nvx_snapshot_tier=platform", "shellsnap snap"): + with self.subTest(tokens=tokens): + result = self._run(f"console=hvc0 {tokens}") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("hostmount\n", result.stdout) + self.assertIn("blockless-shell\n", result.stdout) + self.assertNotIn("new-init", result.stdout) + self.assertNotIn("old-init", result.stdout) + if tokens == "shellsnap snap": + self.assertEqual(result.stdout.count("blockless-snapshot\n"), 2) + self.assertIn("SNAP-RESTORE-OK\n", result.stdout) + + def test_new_init_receives_the_announcers_process_id_when_present(self): + for ingress in ("allow", "host-loopback"): + with self.subTest(ingress=ingress): + result = self._run( + "nvx_sandbox=1 nvx_snapshot_tier=platform " + "virtnet_ip=172.16.0.2 virtnet_gw=172.16.0.1 " + f"virtnet_ingress={ingress}" + ) + self.assertEqual(result.returncode, 0, result.stderr) + pid = self.announcer.read_text(encoding="utf-8").strip() + self.assertGreater(int(pid), 1) + self.assertIn(f"new-init <4242> <{pid}>\n", result.stdout) + + def test_new_init_waits_for_the_time_daemons_deferred_publication(self): + temporary = self.pid.with_name("daemon.pid.tmp") + self.helpers["nvx-time"].write_text( + "#!/bin/sh\n" + f"(sleep 0.1; printf '4242\\n' >{shlex.quote(temporary.as_posix())}; " + f"mv {shlex.quote(temporary.as_posix())} " + f"{shlex.quote(self.pid.as_posix())}) &\n", + encoding="utf-8", + ) + result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "new-init <4242>\n") + + def test_missing_helpers_or_selected_init_fail_without_fallback(self): + self.helpers["nvx-time"].write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + self.pid.mkdir() + result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") + self.assertEqual(result.returncode, 125, result.stdout) + self.assertIn("time daemon process ID is unavailable", result.stderr) + self.assertNotIn("new-init", result.stdout) + + self.helpers["nvx-init"].unlink() + result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") + self.assertEqual(result.returncode, 125, result.stdout) + self.assertIn("selected guest cannot act as sandbox control", result.stderr) + self.assertNotIn("old-init", result.stdout) + result = self._run("nvx_sandbox=1 nvx_lifecycle=managed") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith("old-init"), result.stdout) + + class GuestInitWorkloadIdentityTests(unittest.TestCase): """The shell /init's workload account and identity checks, as a one-shot launch that asks for an account with nvx_workload_account=create runs diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index d700bda5f..d5517f86e 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1116,8 +1116,9 @@ NVX's initramfs hands a VM of the managed lifecycle to NVX's managed agent: its `/init` runs `exec /sbin/nvx-managed-agent /dev/hvc2 - nvx-managed UID GID NAME HOME` as PID 1. A VM with a layered root, whose kernel command line has -`nvx_sandbox=1`, goes to NVX's init agent instead, which assembles the root: -`/init` runs `exec /sbin/nvx-init-agent`, without arguments. +`nvx_sandbox=1` and no old OpenVMM lifecycle token, goes to NVX's init instead: +`/init` runs `exec /sbin/nvx-init TIME_DAEMON [ANNOUNCER]`, with the helpers' +process IDs. Old-token layered launches keep `/sbin/nvx-init-agent`. `initramfs::write` copies that initramfs, and appends to the copy a second archive, in the `newc` format, which Linux unpacks over the first: @@ -1128,8 +1129,8 @@ archive, in the `newc` format, which Linux unpacks over the first: - a symbolic link from `/sbin/nvx-managed-agent` to that script; - a script, `/sbin/runtime-adapter-layered`, that runs `runtime-adapter-guest init /dev/hvc2 layered`, which names the control tty - itself, since `/init` passes NVX's init agent none; -- a symbolic link from `/sbin/nvx-init-agent` to that script, so that NVX's + itself, and ignores the helper arguments; +- a symbolic link from `/sbin/nvx-init` to that script, so that NVX's init agent never assembles the root in the adapter's VMs; - for the capture of a template whose clones delay their repair, for tests, the delay's file, `/etc/runtime-adapter-repair-delay`, in the `/etc` directory of diff --git a/tests/runtime-adapter/src/initramfs.rs b/tests/runtime-adapter/src/initramfs.rs index 67384d158..f79127902 100644 --- a/tests/runtime-adapter/src/initramfs.rs +++ b/tests/runtime-adapter/src/initramfs.rs @@ -7,11 +7,11 @@ //! exec /sbin/nvx-managed-agent /dev/hvc2 - nvx-managed UID GID NAME HOME //! ``` //! -//! and a VM with a layered root, whose kernel command line has `nvx_sandbox=1`, to NVX's init -//! agent, which assembles the root, without arguments: +//! and a VM with a layered root, whose kernel command line has `nvx_sandbox=1` and no old +//! OpenVMM lifecycle token, to NVX's init, with its helpers' process IDs: //! //! ```text -//! exec /sbin/nvx-init-agent +//! exec /sbin/nvx-init TIME_DAEMON [ANNOUNCER] //! ``` //! //! [`write`](fn@write) copies that initramfs, and appends to the copy a second archive, in the @@ -23,7 +23,7 @@ //! - a symbolic link from [`MANAGED_AGENT`] to that script; //! - a script, at [`LAYERED_LAUNCHER`], that runs the guest agent as //! `runtime-adapter-guest init /dev/hvc2 layered`, PID 1 of a VM with a layered root, which -//! names the control tty, [`CONTROL_TTY`], itself, since `/init` passes NVX's init agent none; +//! names the control tty, [`CONTROL_TTY`], itself, and ignores the helper arguments; //! - and a symbolic link from [`INIT_AGENT`] to that script, so that NVX's init agent never //! assembles the root in the adapter's VMs; //! - for the capture of a template whose clones delay their repair, for tests, the delay's file, @@ -53,7 +53,7 @@ pub const MANAGED_AGENT: &str = "/sbin/nvx-managed-agent"; /// NVX's init agent. pub const LAYERED_LAUNCHER: &str = "/sbin/runtime-adapter-layered"; /// Path of NVX's init agent, which `/init` runs in a VM with a layered root. -pub const INIT_AGENT: &str = "/sbin/nvx-init-agent"; +pub const INIT_AGENT: &str = "/sbin/nvx-init"; /// The VM's control tty, which `/init` requires of both lifecycles that NVX's agents serve. pub const CONTROL_TTY: &str = "/dev/hvc2"; diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index c2eab36c9..dd80fe88c 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -4,7 +4,7 @@ //! //! # PID 1 and the worker //! -//! In a VM with a layered root, NVX's `/init` runs `/sbin/nvx-init-agent`, which the adapter's +//! In a VM with a tokenless layered root, NVX's `/init` runs `/sbin/nvx-init`, which the adapter's //! initramfs replaces with `runtime-adapter-guest init /dev/hvc2 layered`; see the host side's //! `initramfs` module. That PID 1, [`supervise`], stays minimal: //! diff --git a/tests/runtime-adapter/tests/initramfs.rs b/tests/runtime-adapter/tests/initramfs.rs index 22eea4537..8ad14aed9 100644 --- a/tests/runtime-adapter/tests/initramfs.rs +++ b/tests/runtime-adapter/tests/initramfs.rs @@ -103,7 +103,7 @@ fn expected(agent: &[u8]) -> Vec { 0o100_755, initramfs::layered_launcher_script().as_bytes(), ), - entry(INIT_AGENT, 5, 0o120_777, LAYERED_LAUNCHER.as_bytes()), + entry("/sbin/nvx-init", 5, 0o120_777, LAYERED_LAUNCHER.as_bytes()), ] } @@ -151,15 +151,14 @@ fn the_launchers_run_the_guest_agent_on_the_control_tty() { ); assert_eq!(MANAGED_AGENT, "/sbin/nvx-managed-agent"); - // It passes NVX's init agent nothing, so the layered launcher names the control tty, which - // `/init` requires of both lifecycles. + // The layered launcher ignores the helper PIDs and names its control tty itself. let layered = runtime_adapter::GUEST_LAYERED_MODE; assert_eq!( initramfs::layered_launcher_script(), format!("#!/bin/sh\nexec {GUEST_AGENT} {init} /dev/hvc2 {layered}\n") ); assert_eq!(CONTROL_TTY, "/dev/hvc2"); - assert_eq!(INIT_AGENT, "/sbin/nvx-init-agent"); + assert_eq!(INIT_AGENT, "/sbin/nvx-init"); } #[test] From 2a0451d4ed1e27445bcd4615e7c0878c291edca5 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 11:36:26 -0800 Subject: [PATCH 21/25] host: finish exited captures into snapshot entries Validate the proved-exited capture outcome, strict OpenVMM artifacts and checked capture context. Write retryable private key and description files without moving or cleaning VM-directory entries. The Draft keeps SnapshotKey::time and its encoding/identity semantics pending the explicit user decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b718df7e-13ad-474d-b3c5-b0f93a7e18e6 --- nvx-rs/README.md | 11 +- nvx-rs/public-api/linux.txt | 3 + nvx-rs/public-api/windows.txt | 3 + nvx-rs/src/host/snapshot.rs | 33 +- nvx-rs/src/host/snapshot/capture.rs | 230 +++++++++ nvx-rs/src/host/snapshot/capture/tests.rs | 468 ++++++++++++++++++ nvx-rs/src/host/snapshot/key.rs | 112 ++++- nvx-rs/src/host/template/store/capture.rs | 139 +----- .../src/host/template/store/capture/tests.rs | 6 + nvx-rs/src/host/template/store/tests.rs | 2 +- nvx-rs/src/host/vm.rs | 2 +- 11 files changed, 835 insertions(+), 174 deletions(-) create mode 100644 nvx-rs/src/host/snapshot/capture.rs create mode 100644 nvx-rs/src/host/snapshot/capture/tests.rs diff --git a/nvx-rs/README.md b/nvx-rs/README.md index cdd2bb891..1991080a8 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -125,7 +125,7 @@ changes only when the module's purpose does. | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | | [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold and capture preparation, verified detached launch, confirmed stop, and caller-saved reattachment | -| [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshot directories, verified keys and manifests, time eligibility, and the input of a future VM restore | +| [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshots, post-exit capture finishing, verified keys and manifests, and time eligibility | | [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity | | [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener | | [`nvx::host::artifacts`](#nvxhostartifacts) | Locating the OpenVMM executable and the guest artifacts that a VM runs, and their runtime identity | @@ -610,6 +610,15 @@ effective devices independently of runtime paths or network policy. `vm::Prepared::capture_context` returns it for the caller to clone and serialize; NVX owns no capture-context store or persistence format. +`finish_capture(&VmDir, &CaptureContext, &Exited)` requires the capture VM's +proved exit and successful outcome, checks the OpenVMM snapshot against the +context, and atomically writes `key.bin` and informational `snapshot.json` +beside `openvmm/`. It returns `CapturedSnapshot { key }`. The caller moves +only those three entries into its own private, immutable snapshot directory; +logs, outcomes and other VM files remain where they are. A failure can be +corrected and retried while the VM directory and capture context stay intact, +including when one metadata file was already written. + ### `nvx::host::config` `VmConfig`: the configuration of a VM as one value, its processors, its memory diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 356ee33b1..c2b99bf2b 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3038,6 +3038,8 @@ field nvx::host::snapshot::CaptureClass::runtime field nvx::host::snapshot::CaptureClass::scratch method nvx::host::snapshot::CaptureClass::to_bytes struct nvx::host::snapshot::CaptureContext +struct nvx::host::snapshot::CapturedSnapshot +field nvx::host::snapshot::CapturedSnapshot::key struct nvx::host::snapshot::ClassIdentity method nvx::host::snapshot::ClassIdentity::as_bytes method nvx::host::snapshot::ClassIdentity::from_bytes @@ -3136,6 +3138,7 @@ field nvx::host::snapshot::TimeContract::host_id field nvx::host::snapshot::TimeContract::lineage field nvx::host::snapshot::TimeContract::tsc_frequency_hz field nvx::host::snapshot::TimeContract::tsc_tolerance_ppm +fn nvx::host::snapshot::finish_capture mod nvx::host::store struct nvx::host::store::ArtifactIdentity method nvx::host::store::ArtifactIdentity::as_str diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 812a5a3d4..dab3edfb6 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1624,6 +1624,8 @@ field nvx::host::snapshot::CaptureClass::runtime field nvx::host::snapshot::CaptureClass::scratch method nvx::host::snapshot::CaptureClass::to_bytes struct nvx::host::snapshot::CaptureContext +struct nvx::host::snapshot::CapturedSnapshot +field nvx::host::snapshot::CapturedSnapshot::key struct nvx::host::snapshot::ClassIdentity method nvx::host::snapshot::ClassIdentity::as_bytes method nvx::host::snapshot::ClassIdentity::from_bytes @@ -1722,6 +1724,7 @@ field nvx::host::snapshot::TimeContract::host_id field nvx::host::snapshot::TimeContract::lineage field nvx::host::snapshot::TimeContract::tsc_frequency_hz field nvx::host::snapshot::TimeContract::tsc_tolerance_ppm +fn nvx::host::snapshot::finish_capture mod nvx::host::store struct nvx::host::store::ArtifactIdentity method nvx::host::store::ArtifactIdentity::as_str diff --git a/nvx-rs/src/host/snapshot.rs b/nvx-rs/src/host/snapshot.rs index 8aceeb575..b518c4c13 100644 --- a/nvx-rs/src/host/snapshot.rs +++ b/nvx-rs/src/host/snapshot.rs @@ -4,6 +4,9 @@ //! description. The caller keeps the directory and its entries private and immutable while //! using the opened snapshot. //! +//! [`finish_capture`] validates an exited capture and writes its key and informational +//! description without publishing or removing any VM-directory entry. +//! //! [`SnapshotKey`] derives a compatibility key from a capture's class and manifest. //! [`SnapshotManifest::read`] reads the manifest that OpenVMM wrote, and //! [`TimeContract::eligibility`] checks its restore window at a [`HostTime`]. @@ -28,6 +31,7 @@ //! in `tests/vectors/snapshot-key-v1.txt` fix version 1 of a key's encoding and of a class's. mod canonical; +mod capture; pub(super) mod eligibility; pub(super) mod key; pub(super) mod manifest; @@ -49,6 +53,8 @@ use crate::host::store::regular; use crate::host::{filesystem, platform}; use crate::{Error, Hypervisor, Result}; +pub use self::capture::{CapturedSnapshot, finish_capture}; +pub(super) use self::capture::{check_exit, check_snapshot}; pub use self::eligibility::{Downtime, DowntimeSource, Eligibility, HostTime, MAX_DOWNTIME_NS}; pub use self::key::{CaptureClass, ClassIdentity, Invariants, KeyIdentity, SnapshotKey}; pub use self::manifest::{ @@ -62,6 +68,9 @@ pub const DESCRIPTION_FILE: &str = "snapshot.json"; /// The directory of OpenVMM's manifest, RAM and saved state. pub const OPENVMM_DIR: &str = "openvmm"; +/// OpenVMM's exact platform-snapshot inventory, in name order. +pub(super) const SNAPSHOT_ARTIFACTS: [&str; 3] = [MANIFEST_NAME, "memory.bin", "state.bin"]; + /// The checked configuration and effective devices of a blockless platform capture. /// /// [`crate::host::vm::Prepared::capture_context`] returns it after verifying the actual sparse @@ -122,30 +131,10 @@ impl Snapshot { pub fn open(dir: &Path) -> Result { let dir = absolute(&normalized(dir)).map_err(|error| entry_error(dir, error))?; check_directory(&dir, &[KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR])?; - let openvmm = dir.join(OPENVMM_DIR); - check_directory(&openvmm, &[MANIFEST_NAME, "memory.bin", "state.bin"])?; - for path in [ - dir.join(KEY_FILE), - dir.join(DESCRIPTION_FILE), - openvmm.join(MANIFEST_NAME), - ] { + for path in [dir.join(KEY_FILE), dir.join(DESCRIPTION_FILE)] { check_file(&path)?; } - let manifest = SnapshotManifest::read(&openvmm)?; - for (name, expected) in [ - ("memory.bin", manifest.memory_len()), - ("state.bin", manifest.state_len()), - ] { - let path = openvmm.join(name); - let found = check_file(&path)?.len(); - if found != expected { - return Err(Error::io(format!( - "the snapshot artifact {} is {found} bytes long, not the {expected} bytes \ - that its manifest records", - path.display() - ))); - } - } + let manifest = check_snapshot(&dir.join(OPENVMM_DIR))?; let path = dir.join(KEY_FILE); let bytes = regular::open_regular(&path) .and_then(|file| { diff --git a/nvx-rs/src/host/snapshot/capture.rs b/nvx-rs/src/host/snapshot/capture.rs new file mode 100644 index 000000000..9415981c7 --- /dev/null +++ b/nvx-rs/src/host/snapshot/capture.rs @@ -0,0 +1,230 @@ +//! Post-exit capture validation and the two metadata files beside OpenVMM's snapshot. + +use std::fs; +use std::io; +use std::path::Path; + +use serde::Deserialize; +use serde_json::json; + +use super::{ + CaptureContext, DESCRIPTION_FILE, KEY_FILE, MANIFEST_NAME, OPENVMM_DIR, SNAPSHOT_ARTIFACTS, + SnapshotKey, SnapshotManifest, +}; +use crate::host::platform; +use crate::host::process::Exited; +use crate::host::store::files::{sync_dir, write_private}; +use crate::host::store::regular::{open_regular, read_at_most}; +use crate::host::vm::{OUTCOME, VmDir}; +use crate::{Error, ErrorKind, Result}; + +/// Longest outcome report of a capture's VM that the capture reads, in bytes. +const MAX_REPORT_LEN: u64 = 1024 * 1024; + +/// The metadata of a finished capture, whose files the caller may now publish. +#[derive(Debug)] +pub struct CapturedSnapshot { + /// The checked key written to [`KEY_FILE`]. + pub key: SnapshotKey, +} + +/// Finishes a capture after its VM has provably exited. +/// +/// `exited` must prove the exit of the VM that produced `dir` and `context`; the caller keeps +/// that association. Exit proof alone does not establish success: the outcome report must +/// record a successful capture, and the private OpenVMM snapshot must pass the existing strict +/// manifest, artifact and capture-context checks. +/// +/// Writes [`KEY_FILE`] and the informational [`DESCRIPTION_FILE`] beside [`OPENVMM_DIR`]. +/// The caller moves only those three entries into its own private snapshot directory and keeps +/// them immutable while using it. No other VM-directory entry is moved, removed or published. +/// A failure may be corrected and retried while the directory and capture context remain intact; +/// either metadata file may already have been replaced. +/// +/// Fails with [`ErrorKind::Exited`] for an absent or unsuccessful capture outcome, with +/// [`ErrorKind::Io`] for invalid files or a context/manifest mismatch, and otherwise as +/// [`SnapshotManifest::read`] and the checked context's configuration do. +/// +/// A caller cannot finish without exit proof: +/// +/// ```compile_fail +/// use nvx::host::{snapshot::{CaptureContext, finish_capture}, vm::VmDir}; +/// +/// fn without_exit(dir: &VmDir, context: &CaptureContext) { +/// finish_capture(dir, context); +/// } +/// ``` +pub fn finish_capture( + dir: &VmDir, + context: &CaptureContext, + _exited: &Exited, +) -> Result { + VmDir::open(dir.path())?; + check_exit(&dir.path().join(OUTCOME))?; + let manifest = check_snapshot(&dir.path().join(OPENVMM_DIR))?; + let key = SnapshotKey::from_capture(context, &manifest)?; + let time = manifest.time(); + let description = json!({ + "key": key.identity().to_string(), + "runtime": key.runtime(), + "hypervisor": key.hypervisor(), + "cpuProfile": key.cpu_profile(), + "capture": context.capture, + "nic": context.nic, + "console": context.console, + "createdAt": manifest.created_at(), + "openvmmVersion": manifest.openvmm_version(), + "memoryLen": manifest.memory_len(), + "stateLen": manifest.state_len(), + "time": { + "tscFrequencyHz": time.tsc_frequency_hz, + "tscTolerancePpm": time.tsc_tolerance_ppm, + "apicFrequencyHz": time.apic_frequency_hz, + "captureTsc": time.capture_tsc, + "captureUtcNs": time.capture_utc_ns, + "captureMonotonicNs": time.capture_monotonic_ns, + "hostClock": time.host_clock.as_str(), + "hostId": time.host_id.iter().map(|byte| format!("{byte:02x}")).collect::(), + "bootId": time.boot_id, + "lineage": time.lineage, + "cutToAnchorNs": time.cut_to_anchor_ns, + }, + }); + let description = serde_json::to_vec_pretty(&description).map_err(|error| { + Error::other("cannot encode the snapshot description").with_source(error) + })?; + write_private(&dir.path().join(KEY_FILE), &key.to_bytes())?; + write_private(&dir.path().join(DESCRIPTION_FILE), &description)?; + sync_dir(dir.path())?; + Ok(CapturedSnapshot { key }) +} + +/// The outcome report of a VM, as OpenVMM writes it, of which a capture reads the outcome. +#[derive(Deserialize)] +struct OutcomeReport { + /// The outcome of the VM. + outcome: Outcome, +} + +/// The outcome of a VM, as its outcome report records it. +#[derive(Deserialize)] +struct Outcome { + /// The operation of the VM: `capture` for a VM that OpenVMM captures. + operation: String, + /// The category of the outcome: `success` for a capture that OpenVMM published. + category: String, + /// The status code of the outcome: 0 for a capture that OpenVMM published. + status_code: i64, +} + +/// Checks that the outcome report records a capture that OpenVMM published successfully. +pub(in crate::host) fn check_exit(path: &Path) -> Result<()> { + let description = format!("the outcome report {}", path.display()); + let file = match open_regular(path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(Error::exited(format!( + "the VM of the capture ended without {description}" + ))); + } + Err(error) => { + return Err(Error::io(format!("cannot read {description}")).with_source(error)); + } + }; + let bytes = read_at_most(file, MAX_REPORT_LEN, "an outcome report") + .map_err(|error| Error::io(format!("cannot read {description}")).with_source(error))?; + let report: OutcomeReport = serde_json::from_slice(&bytes) + .map_err(|error| Error::io(format!("{description} is malformed")).with_source(error))?; + let Outcome { + operation, + category, + status_code, + } = report.outcome; + if operation != "capture" || category != "success" || status_code != 0 { + return Err(Error::exited(format!( + "the VM of the capture ended without a capture: {description} records the outcome \ + {category} with status {status_code} of the operation {operation}" + ))); + } + Ok(()) +} + +/// Checks the private OpenVMM directory, strict manifest and exact regular private artifacts. +pub(in crate::host) fn check_snapshot(snapshot: &Path) -> Result { + let what = "the snapshot of the capture"; + let problem = platform::check_snapshot_dir(snapshot).map_err(|error| { + Error::io(format!("cannot open {what} {}", snapshot.display())).with_source(error) + })?; + if let Some(problem) = problem { + return Err(Error::io(format!( + "{what} {} {problem}", + snapshot.display() + ))); + } + let manifest = SnapshotManifest::read(snapshot).map_err(|error| { + if error.kind() != ErrorKind::NotFound { + return error; + } + let (_, message, source) = error.into_parts(); + let error = Error::io(message); + match source { + Some(source) => error.with_source(source), + None => error, + } + })?; + let mut names = fs::read_dir(snapshot) + .and_then(|entries| { + entries + .map(|entry| { + entry?.file_name().into_string().map_err(|name| { + io::Error::other(format!("the snapshot entry {name:?} is not Unicode")) + }) + }) + .collect::>>() + }) + .map_err(|error| super::entry_error(snapshot, error))?; + names.sort(); + if names != SNAPSHOT_ARTIFACTS { + return Err(Error::io(format!( + "{what} {} holds {}, not exactly the manifest, RAM, and saved state of a platform-tier \ + snapshot", + snapshot.display(), + names.join(", ") + ))); + } + for (name, length) in [ + (MANIFEST_NAME, None), + ("memory.bin", Some(manifest.memory_len())), + ("state.bin", Some(manifest.state_len())), + ] { + let path = snapshot.join(name); + let refused = |problem: String| { + Error::io(format!( + "the artifact {} of {what} {problem}", + path.display() + )) + }; + let file = + open_regular(&path).map_err(|error| refused(format!("cannot be used: {error}")))?; + if let Some(problem) = platform::private_file_problem(&file) + .map_err(|error| refused(format!("cannot be checked: {error}")))? + { + return Err(refused(problem)); + } + let found = file + .metadata() + .map_err(|error| refused(format!("cannot be examined: {error}")))? + .len(); + if let Some(length) = length + && found != length + { + return Err(refused(format!( + "is {found} bytes long, not the {length} bytes that its manifest records" + ))); + } + } + Ok(manifest) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/snapshot/capture/tests.rs b/nvx-rs/src/host/snapshot/capture/tests.rs new file mode 100644 index 000000000..732b147ba --- /dev/null +++ b/nvx-rs/src/host/snapshot/capture/tests.rs @@ -0,0 +1,468 @@ +//! Finishing emulated capture output with genuine OS exit proof, without launching a VM. + +#![cfg(any(target_os = "linux", windows))] + +use std::io::Read; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +use super::*; +use crate::Hypervisor; +use crate::host::config::{Capacity, CaptureConfig, ConsoleAttachment, DiskCapture, NicConfig}; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; +use crate::host::snapshot::Snapshot; +use crate::host::snapshot::tests::{MIB, ManifestSpec, SnapshotFixture, class}; +use crate::host::vm::create_private_dir; + +/// A producer kept alive through its stdin until its parent records its real identity. +#[test] +fn exit_helper() { + if std::env::var_os("NVX_FINISH_CAPTURE_EXIT_HELPER").is_some() { + std::io::stdin().read_to_end(&mut Vec::new()).unwrap(); + } +} + +/// Reaps only the child that this test owns, including when a proof assertion fails. +struct OwnedChild(Child); + +impl Drop for OwnedChild { + fn drop(&mut self) { + if self.0.try_wait().unwrap().is_none() { + self.0.kill().unwrap(); + } + self.0.wait().unwrap(); + } +} + +/// Waits for an owned emulated producer's actual exit, never forging a process reference. +fn exited() -> Exited { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", "host::snapshot::capture::tests::exit_helper"]) + .env("NVX_FINISH_CAPTURE_EXIT_HELPER", "1") + .stdin(Stdio::piped()) + .stdout(Stdio::null()); + let mut child = OwnedChild(crate::host::tests::spawn_locked(&mut command).unwrap()); + let reference = ProcessRef { + pid: child.0.id(), + start_time: platform::process_start_time(child.0.id()).unwrap().unwrap(), + boot: BootId::current().unwrap(), + }; + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the owned producer is waiting for its parent"); + }; + assert!(process.wait(Instant::now()).unwrap().is_none()); + drop(child.0.stdin.take()); + let proof = process + .wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + .expect("the owned producer exited"); + assert!(child.0.wait().unwrap().success()); + proof +} + +/// A private VM directory containing OpenVMM's output but neither NVX metadata file. +struct Fixture { + /// Keeps the sparse snapshot's private temporary directory alive. + _snapshot: SnapshotFixture, + /// The caller-owned VM directory. + dir: VmDir, + /// The checked inputs that the emulated capture used. + context: CaptureContext, +} + +impl Fixture { + /// Uses the existing strict snapshot fixture and removes only its already-finished metadata. + fn new() -> Self { + let snapshot = SnapshotFixture::new(); + for name in [KEY_FILE, DESCRIPTION_FILE] { + fs::remove_file(snapshot.dir.join(name)).unwrap(); + } + let dir = VmDir::open(&snapshot.dir).unwrap(); + let class = class(); + let context = CaptureContext { + capture: CaptureConfig { + disks: DiskCapture::Fresh { + image: class.distro, + scratch: class.scratch, + }, + processors: Capacity { + boot: u64::from(class.boot_processors), + capacity: u64::from(class.processors), + }, + memory: Capacity { + boot: class.memory / MIB, + capacity: class.memory_capacity / MIB, + }, + invariants: class.invariants, + }, + nic: None, + console: ConsoleAttachment::Disconnected, + runtime: class.runtime, + hypervisor: class.hypervisor, + cpu_profile: Some(class.cpu_profile), + }; + let fixture = Self { + _snapshot: snapshot, + dir, + context, + }; + fixture.outcome("capture", "success", 0); + fixture + } + + /// Writes OpenVMM's capture outcome without any launch state. + fn outcome(&self, operation: &str, category: &str, status: i64) { + write_private( + &self.dir.path().join(OUTCOME), + &serde_json::to_vec(&json!({ + "outcome": { + "operation": operation, + "category": category, + "status_code": status, + }, + })) + .unwrap(), + ) + .unwrap(); + } + + /// Returns one of OpenVMM's artifact paths. + fn artifact(&self, name: &str) -> std::path::PathBuf { + self.dir.path().join(OPENVMM_DIR).join(name) + } + + /// Calls the public finalizer with the fixture's checked inputs. + fn finish(&self, exited: &Exited) -> Result { + finish_capture(&self.dir, &self.context, exited) + } + + /// Checks that validation has not written either metadata file. + fn unfinished(&self) { + assert!(!self.dir.path().join(KEY_FILE).exists()); + assert!(!self.dir.path().join(DESCRIPTION_FILE).exists()); + } +} + +#[test] +fn finish_writes_matching_metadata_and_only_its_three_entries_are_published() { + let mut fixture = Fixture::new(); + fixture.context.capture.invariants = super::super::Invariants::new() + .with("isolation.test", "kept") + .unwrap(); + let proof = exited(); + let outcome = fs::read(fixture.dir.path().join(OUTCOME)).unwrap(); + for (name, bytes) in [ + ("openvmm.log", b"VM log".as_slice()), + ("caller.txt", b"caller data"), + ] { + write_private(&fixture.dir.path().join(name), bytes).unwrap(); + } + let captured = fixture.finish(&proof).unwrap(); + let manifest = SnapshotManifest::read(&fixture.dir.path().join(OPENVMM_DIR)).unwrap(); + let key = + SnapshotKey::from_bytes(&fs::read(fixture.dir.path().join(KEY_FILE)).unwrap()).unwrap(); + assert_eq!(key, captured.key); + assert_eq!(key.runtime(), &fixture.context.runtime); + assert_eq!(key.invariants(), &fixture.context.capture.invariants); + key.check_manifest(&manifest).unwrap(); + let description: Value = + serde_json::from_slice(&fs::read(fixture.dir.path().join(DESCRIPTION_FILE)).unwrap()) + .unwrap(); + assert_eq!(description["key"], key.identity().to_string()); + assert_eq!( + description["runtime"], + serde_json::to_value(key.runtime()).unwrap() + ); + assert_eq!( + description["capture"], + serde_json::to_value(&fixture.context.capture).unwrap() + ); + assert_eq!(description["memoryLen"], manifest.memory_len()); + assert_eq!(description["stateLen"], manifest.state_len()); + assert_eq!( + description["time"]["captureTsc"], + manifest.time().capture_tsc + ); + assert_eq!( + description["time"]["captureUtcNs"], + manifest.time().capture_utc_ns + ); + assert_eq!( + description["time"]["bootId"], + manifest.time().boot_id.to_string() + ); + let published = fixture.dir.path().parent().unwrap().join("published"); + create_private_dir(&published).unwrap(); + for name in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + fs::rename(fixture.dir.path().join(name), published.join(name)).unwrap(); + } + assert_eq!(Snapshot::open(&published).unwrap().key(), &captured.key); + assert_eq!(fs::read_dir(&published).unwrap().count(), 3); + assert_eq!(fs::read(fixture.dir.path().join(OUTCOME)).unwrap(), outcome); + assert_eq!( + fs::read(fixture.dir.path().join("openvmm.log")).unwrap(), + b"VM log" + ); + assert_eq!( + fs::read(fixture.dir.path().join("caller.txt")).unwrap(), + b"caller data" + ); + assert!(Snapshot::open(fixture.dir.path()).is_err()); +} + +#[test] +fn finish_refuses_absent_and_unsuccessful_capture_outcomes() { + let proof = exited(); + let fixture = Fixture::new(); + fs::remove_file(fixture.dir.path().join(OUTCOME)).unwrap(); + assert_eq!( + fixture.finish(&proof).unwrap_err().kind(), + ErrorKind::Exited + ); + fixture.unfinished(); + for (operation, category, status) in [ + ("run", "success", 0), + ("capture", "guest-exit", 3), + ("capture", "vmm-failure", 1), + ("capture", "success", 1), + ("capture", "failure", 0), + ] { + fixture.outcome(operation, category, status); + assert_eq!( + fixture.finish(&proof).unwrap_err().kind(), + ErrorKind::Exited + ); + fixture.unfinished(); + } +} + +#[test] +fn finish_uses_the_bounded_regular_outcome_reader() { + let proof = exited(); + let fixture = Fixture::new(); + let path = fixture.dir.path().join(OUTCOME); + for bytes in [ + b"not JSON".to_vec(), + br#"{"outcome":{"category":"success","status_code":0}}"#.to_vec(), + vec![b' '; MAX_REPORT_LEN as usize + 1], + ] { + write_private(&path, &bytes).unwrap(); + assert_eq!(fixture.finish(&proof).unwrap_err().kind(), ErrorKind::Io); + fixture.unfinished(); + } + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + assert_eq!(fixture.finish(&proof).unwrap_err().kind(), ErrorKind::Io); + fixture.unfinished(); +} + +#[test] +fn finish_refuses_every_missing_or_extra_openvmm_artifact() { + let proof = exited(); + for missing in [ + None, + Some(MANIFEST_NAME), + Some("memory.bin"), + Some("state.bin"), + ] { + let fixture = Fixture::new(); + match missing { + Some(name) => fs::remove_file(fixture.artifact(name)).unwrap(), + None => fs::rename( + fixture.dir.path().join(OPENVMM_DIR), + fixture.dir.path().join("saved-openvmm"), + ) + .unwrap(), + } + assert_eq!(fixture.finish(&proof).unwrap_err().kind(), ErrorKind::Io); + fixture.unfinished(); + } + let fixture = Fixture::new(); + write_private(&fixture.artifact("extra"), b"unexpected").unwrap(); + assert_eq!(fixture.finish(&proof).unwrap_err().kind(), ErrorKind::Io); + fixture.unfinished(); +} + +#[test] +fn finish_refuses_irregular_artifacts_and_both_recorded_length_mismatches() { + let proof = exited(); + for name in SNAPSHOT_ARTIFACTS { + let fixture = Fixture::new(); + fs::remove_file(fixture.artifact(name)).unwrap(); + fs::create_dir(fixture.artifact(name)).unwrap(); + assert_eq!(fixture.finish(&proof).unwrap_err().kind(), ErrorKind::Io); + fixture.unfinished(); + } + for name in ["memory.bin", "state.bin"] { + for difference in [-1, 1] { + let fixture = Fixture::new(); + let file = fs::OpenOptions::new() + .write(true) + .open(fixture.artifact(name)) + .unwrap(); + let length = file.metadata().unwrap().len(); + file.set_len(length.checked_add_signed(difference).unwrap()) + .unwrap(); + drop(file); + assert_eq!(fixture.finish(&proof).unwrap_err().kind(), ErrorKind::Io); + fixture.unfinished(); + } + } +} + +#[test] +fn finish_keeps_the_strict_manifest_checks() { + let proof = exited(); + let fixture = Fixture::new(); + for bytes in [ + vec![0x80], + vec![0; super::super::MAX_MANIFEST_LEN as usize + 1], + ] { + write_private(&fixture.artifact(MANIFEST_NAME), &bytes).unwrap(); + assert_eq!(fixture.finish(&proof).unwrap_err().kind(), ErrorKind::Io); + fixture.unfinished(); + } + let mut spec = ManifestSpec::template(); + spec.tier = "workload".to_owned(); + write_private(&fixture.artifact(MANIFEST_NAME), &spec.encode()).unwrap(); + assert_eq!(fixture.finish(&proof).unwrap_err().kind(), ErrorKind::Io); + fixture.unfinished(); +} + +#[test] +fn finish_checks_the_context_against_the_effective_capture() { + let proof = exited(); + let changes: &[fn(&mut CaptureContext)] = &[ + |context| context.hypervisor = Hypervisor::Mshv, + |context| context.cpu_profile = Some("amd.zen4.v1".to_owned()), + |context| context.capture.processors.capacity = 4, + |context| context.capture.processors.boot = 1, + |context| context.capture.memory.boot = 256, + |context| context.capture.memory.capacity = 1024, + |context| { + let DiskCapture::Fresh { image, .. } = &mut context.capture.disks; + image.length *= 2; + }, + |context| { + let DiskCapture::Fresh { scratch, .. } = &mut context.capture.disks; + scratch.physical_block_size *= 2; + }, + |context| { + context.nic = Some(NicConfig { + guest_network: crate::net::Ipv4Cidr::parse("10.0.0.2/24").unwrap(), + }); + }, + |context| context.runtime.control_contract.clear(), + |context| context.capture.memory.capacity = u64::MAX - 127, + ]; + for change in changes { + let mut fixture = Fixture::new(); + change(&mut fixture.context); + assert!(fixture.finish(&proof).is_err()); + fixture.unfinished(); + } +} + +#[test] +fn a_correctable_failure_and_a_partial_metadata_write_can_both_be_retried() { + let proof = exited(); + let fixture = Fixture::new(); + let state = fixture.artifact("state.bin"); + let length = fs::metadata(&state).unwrap().len(); + fs::OpenOptions::new() + .write(true) + .open(&state) + .unwrap() + .set_len(length - 1) + .unwrap(); + assert!(fixture.finish(&proof).is_err()); + fixture.unfinished(); + fs::OpenOptions::new() + .write(true) + .open(&state) + .unwrap() + .set_len(length) + .unwrap(); + let description = fixture.dir.path().join(DESCRIPTION_FILE); + fs::create_dir(&description).unwrap(); + assert!(fixture.finish(&proof).is_err()); + assert!(fixture.dir.path().join(KEY_FILE).is_file()); + assert!(description.is_dir()); + fs::remove_dir(&description).unwrap(); + let captured = fixture.finish(&proof).unwrap(); + assert_eq!(fixture.finish(&proof).unwrap().key, captured.key); + assert!(Snapshot::open(fixture.dir.path()).is_err()); + let published = fixture.dir.path().parent().unwrap().join("published"); + create_private_dir(&published).unwrap(); + for name in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + fs::rename(fixture.dir.path().join(name), published.join(name)).unwrap(); + } + assert_eq!(Snapshot::open(&published).unwrap().key(), &captured.key); + let names = fs::read_dir(fixture.dir.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect::>(); + assert_eq!(names, [std::ffi::OsString::from(OUTCOME)]); +} + +#[cfg(target_os = "linux")] +#[test] +fn finish_refuses_shared_snapshot_entries_links_fifos_and_sockets() { + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::os::unix::net::UnixListener; + + let proof = exited(); + for name in SNAPSHOT_ARTIFACTS { + let fixture = Fixture::new(); + let path = fixture.artifact(name); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + assert!(fixture.finish(&proof).is_err()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let saved = fixture.dir.path().join("saved"); + fs::rename(&path, &saved).unwrap(); + symlink(&saved, &path).unwrap(); + assert!(fixture.finish(&proof).is_err()); + fs::remove_file(&path).unwrap(); + nix::unistd::mkfifo(&path, nix::sys::stat::Mode::S_IRUSR).unwrap(); + assert!(fixture.finish(&proof).is_err()); + fs::remove_file(&path).unwrap(); + let _socket = UnixListener::bind(&path).unwrap(); + assert!(fixture.finish(&proof).is_err()); + fixture.unfinished(); + } + let fixture = Fixture::new(); + fs::set_permissions( + fixture.dir.path().join(OPENVMM_DIR), + fs::Permissions::from_mode(0o750), + ) + .unwrap(); + assert!(fixture.finish(&proof).is_err()); + fixture.unfinished(); +} + +#[cfg(windows)] +#[test] +fn finish_refuses_shared_snapshot_acls_and_reparse_artifacts() { + use crate::host::platform::tests::{set_access, user_sid}; + + let proof = exited(); + let shared = format!("D:P(A;;FA;;;{})(A;;FR;;;WD)", user_sid()); + for name in SNAPSHOT_ARTIFACTS { + let fixture = Fixture::new(); + let path = fixture.artifact(name); + set_access(&path, &shared).unwrap(); + assert!(fixture.finish(&proof).is_err()); + fs::remove_file(&path).unwrap(); + crate::host::tests::junction(&path, fixture.dir.path()); + assert!(fixture.finish(&proof).is_err()); + fixture.unfinished(); + fs::remove_dir(&path).unwrap(); + } + let fixture = Fixture::new(); + set_access(&fixture.dir.path().join(OPENVMM_DIR), &shared).unwrap(); + assert!(fixture.finish(&proof).is_err()); + fixture.unfinished(); +} diff --git a/nvx-rs/src/host/snapshot/key.rs b/nvx-rs/src/host/snapshot/key.rs index 8ab84cc1d..1445db90e 100644 --- a/nvx-rs/src/host/snapshot/key.rs +++ b/nvx-rs/src/host/snapshot/key.rs @@ -6,6 +6,7 @@ use std::net::{Ipv4Addr, Ipv6Addr}; use sha2::{Digest, Sha256}; +use super::CaptureContext; use super::canonical::{CanonicalError, Reader, Writer}; use super::manifest::{ AddressSpace, BlockGeometry, CPUID_ENTRY_LEN, CpuProfile, Device, DeviceRange, @@ -17,7 +18,7 @@ use super::manifest::{ use crate::activation; use crate::contract; use crate::error::{Error, Result}; -use crate::host::config::check_setting; +use crate::host::config::{DiskCapture, check_setting}; use crate::host::platform::parse_hex32; use crate::host::sandbox::SandboxNetwork; use crate::host::sandbox::mount::MOUNT_TAG; @@ -493,6 +494,32 @@ pub struct SnapshotKey { pub(in crate::host) use SnapshotKey as TemplateKey; +/// The checked inputs shared by a legacy capture class and a caller-owned capture context. +struct CaptureInputs<'a> { + /// The runtime's artifact and contract identities. + runtime: &'a RuntimeIdentity, + /// The requested backend. + hypervisor: Hypervisor, + /// The requested CPU profile. + cpu_profile: &'a str, + /// The processor capacity. + processors: u64, + /// The processors online at boot. + boot_processors: u64, + /// The RAM at boot, in bytes. + memory: u64, + /// The RAM capacity, in bytes. + memory_capacity: u64, + /// The placeholder image's geometry. + distro: &'a BlockGeometry, + /// The dormant scratch's geometry. + scratch: &'a BlockGeometry, + /// The resolved NIC address, without a network policy. + guest_network: Option, + /// The caller's invariant settings. + invariants: &'a Invariants, +} + impl SnapshotKey { /// Version of the canonical encoding of a key, its first two bytes. /// @@ -532,6 +559,65 @@ impl SnapshotKey { /// requested. pub fn new(class: &CaptureClass, manifest: &SnapshotManifest) -> Result { class.check()?; + let attached = !class.network_arguments()?.is_empty(); + Self::from_inputs( + CaptureInputs { + runtime: &class.runtime, + hypervisor: class.hypervisor, + cpu_profile: &class.cpu_profile, + processors: u64::from(class.processors), + boot_processors: u64::from(class.boot_processors), + memory: class.memory, + memory_capacity: class.memory_capacity, + distro: &class.distro, + scratch: &class.scratch, + guest_network: class + .network + .as_ref() + .filter(|_| attached) + .map(|network| network.guest_network().to_owned()), + invariants: &class.invariants, + }, + manifest, + ) + } + + /// Derives the key from the checked creation context and OpenVMM's validated manifest. + pub(super) fn from_capture( + context: &CaptureContext, + manifest: &SnapshotManifest, + ) -> Result { + context.capture.check()?; + context.runtime.check()?; + let cpu_profile = context.cpu_profile.as_deref().unwrap_or("auto"); + if let Some(problem) = cpu_profile_problem(cpu_profile) { + return Err(Error::unsupported(problem)); + } + let bytes = |mib: u64| { + mib.checked_mul(1024 * 1024) + .ok_or_else(|| Error::unsupported("capture memory in MiB overflows bytes")) + }; + let DiskCapture::Fresh { image, scratch } = &context.capture.disks; + Self::from_inputs( + CaptureInputs { + runtime: &context.runtime, + hypervisor: context.hypervisor, + cpu_profile, + processors: context.capture.processors.capacity, + boot_processors: context.capture.processors.boot, + memory: bytes(context.capture.memory.boot)?, + memory_capacity: bytes(context.capture.memory.capacity)?, + distro: image, + scratch, + guest_network: context.nic.map(|nic| nic.guest_network.to_string()), + invariants: &context.capture.invariants, + }, + manifest, + ) + } + + /// Checks the manifest's machine against the capture's inputs and takes its effective shape. + fn from_inputs(class: CaptureInputs<'_>, manifest: &SnapshotManifest) -> Result { let machine = &manifest.machine; let mismatch = |found: String, requested: String| { Err(Error::io(format!( @@ -548,7 +634,7 @@ impl SnapshotKey { let profile = machine.cpu.id(); // `auto` selects the built-in profile of the host's CPU, or falls back to a profile that // the host derives, whose generation is `host`, when no built-in profile serves it. - let selected = match class.cpu_profile.as_str() { + let selected = match class.cpu_profile { "auto" => true, "host" => machine.cpu.generation == HOST_GENERATION, requested => profile == requested, @@ -556,18 +642,23 @@ impl SnapshotKey { if !selected { return mismatch( format!("its CPU profile is {profile}"), - class.cpu_profile.clone(), + class.cpu_profile.to_owned(), ); } let boot_processors = match machine.boot_processors { 0 => machine.processors, count => count, }; - if (machine.processors, boot_processors) != (class.processors, class.boot_processors) { + if (u64::from(machine.processors), u64::from(boot_processors)) + != (class.processors, class.boot_processors) + { let describe = |processors, online| format!("{processors} processors, {online} online at boot"); return mismatch( - format!("it has {}", describe(machine.processors, boot_processors)), + format!( + "it has {}", + describe(u64::from(machine.processors), u64::from(boot_processors)) + ), describe(class.processors, class.boot_processors), ); } @@ -583,8 +674,8 @@ impl SnapshotKey { ); } for (role, found, requested) in [ - ("distro", &machine.distro, &class.distro), - ("scratch", &machine.scratch, &class.scratch), + ("distro", &machine.distro, class.distro), + ("scratch", &machine.scratch, class.scratch), ] { if found != requested { return mismatch( @@ -593,12 +684,7 @@ impl SnapshotKey { ); } } - let attached = !class.network_arguments()?.is_empty(); - let requested_network = class - .network - .as_ref() - .filter(|_| attached) - .map(|network| network.guest_network().to_owned()); + let requested_network = class.guest_network; // A network that `SandboxNetwork` accepts has the canonical form that this writes. let found_network = machine.nic.as_ref().map(guest_network_of); if found_network != requested_network { diff --git a/nvx-rs/src/host/template/store/capture.rs b/nvx-rs/src/host/template/store/capture.rs index 74a519049..660312d3e 100644 --- a/nvx-rs/src/host/template/store/capture.rs +++ b/nvx-rs/src/host/template/store/capture.rs @@ -26,8 +26,9 @@ use crate::host::instance::generate_capability; use crate::host::platform; use crate::host::sandbox::distro::{CheckedDistro, DistroBinding, DistroFormat}; use crate::host::sandbox::{LayerRole, SandboxLayer, SandboxPlan}; +use crate::host::snapshot::{check_exit, check_snapshot}; use crate::host::store::files::{create_new, remove_if_present, sync_dir, write_private}; -use crate::host::store::regular::{identity_of, open_regular, read_at_most}; +use crate::host::store::regular::{identity_of, open_regular}; use crate::host::store::{ JOURNAL_NAME, LOCK_NAME, LOG_NAME, LaunchId, LockedStore, OUTCOME_NAME, Recovery, SOCKET_NAME, SandboxStore, @@ -35,7 +36,7 @@ use crate::host::store::{ use crate::host::supervisor::{self, VmmCommand}; use crate::host::template::eligibility::HostTime; use crate::host::template::key::{CaptureClass, ClassIdentity, KeyIdentity, TemplateKey}; -use crate::host::template::manifest::{MANIFEST_NAME, TemplateManifest, TimeContract}; +use crate::host::template::manifest::{TemplateManifest, TimeContract}; use crate::host::template::plan::{SCRATCH_NAME, TemplatePlan, TemplateResources}; /// Name of the directory of a store's captures, in which each capture class has a slot. @@ -46,15 +47,6 @@ pub const TRANSACTION_NAME: &str = "transaction"; /// Name of the record of a capture, in its transaction and then in its generation's payload. pub const CAPTURE_RECORD_NAME: &str = "capture.json"; -/// Name of the saved state of a snapshot. -const STATE_NAME: &str = "state.bin"; -/// Name of the RAM of a snapshot. -const MEMORY_NAME: &str = "memory.bin"; -/// The artifacts of a platform-tier snapshot, in the order of their names: its manifest, its RAM, -/// and its saved state. -pub(in crate::host::template) const SNAPSHOT_ARTIFACTS: [&str; 3] = - [MANIFEST_NAME, MEMORY_NAME, STATE_NAME]; - /// Prefix of the name of the file that backs the RAM of a capture's VM, which OpenVMM creates /// beside the snapshot's destination, and removes as the VM ends. const MEMORY_FILE_PREFIX: &str = ".openvmm-microvm-memory-"; @@ -73,9 +65,6 @@ pub(in crate::host::template) const SOCKET_PATH_LIMIT: usize = 108; /// Longest record of a capture, in bytes. const MAX_CAPTURE_RECORD_LEN: u64 = 4 * 1024; -/// Longest outcome report of a capture's VM that the capture reads, in bytes. -const MAX_REPORT_LEN: u64 = 1024 * 1024; - /// Interval at which a rename that Windows refuses, while another process holds a file below the /// renamed directory open, is tried again, until the capture's deadline. const RENAME_RETRY_INTERVAL: Duration = Duration::from_millis(25); @@ -1114,128 +1103,6 @@ fn check_scratch(template: &TemplatePlan, created: (u64, u128)) -> Result<()> { Ok(()) } -/// The outcome report of a VM, as OpenVMM writes it, of which a capture reads the outcome. -#[derive(Deserialize)] -struct OutcomeReport { - /// The outcome of the VM. - outcome: Outcome, -} - -/// The outcome of a VM, as its outcome report records it. -#[derive(Deserialize)] -struct Outcome { - /// The operation of the VM: `capture` for a VM that OpenVMM captures. - operation: String, - /// The category of the outcome: `success` for a capture that OpenVMM published. - category: String, - /// The status code of the outcome: 0 for a capture that OpenVMM published. - status_code: i64, -} - -/// Checks the source's exit through the outcome report of the capture's VM at `path`: that -/// OpenVMM wrote one, for a capture that succeeded, as it does once it has published the snapshot -/// and torn the VM down, and before it exits. The launch removed the report of the slot's previous -/// VM, as [`LockedStore::begin_launch`] does, and OpenVMM publishes its report without replacing -/// a file, so the report is the VM's own. -fn check_exit(path: &Path) -> Result<()> { - let description = format!("the outcome report {}", path.display()); - let file = match open_regular(path) { - Ok(file) => file, - Err(error) if error.kind() == io::ErrorKind::NotFound => { - return Err(Error::exited(format!( - "the VM of the capture ended without {description}" - ))); - } - Err(error) => { - return Err(Error::io(format!("cannot read {description}")).with_source(error)); - } - }; - let bytes = read_at_most(file, MAX_REPORT_LEN, "an outcome report") - .map_err(|error| Error::io(format!("cannot read {description}")).with_source(error))?; - let report: OutcomeReport = serde_json::from_slice(&bytes) - .map_err(|error| Error::io(format!("{description} is malformed")).with_source(error))?; - let Outcome { - operation, - category, - status_code, - } = report.outcome; - if operation != "capture" || category != "success" || status_code != 0 { - return Err(Error::exited(format!( - "the VM of the capture ended without a capture: {description} records the outcome \ - {category} with status {status_code} of the operation {operation}" - ))); - } - Ok(()) -} - -/// Checks the snapshot at `snapshot` that OpenVMM published: a directory private to the current -/// user, as [`platform::check_snapshot_dir`] checks it, whose manifest -/// [`TemplateManifest::read`] reads, and whose artifacts are exactly its manifest, RAM, and saved -/// state, each a regular file private to the current user, of the length that the manifest -/// records. Reads no artifact but the manifest. Returns the manifest. -fn check_snapshot(snapshot: &Path) -> Result { - let what = "the snapshot of the capture"; - // A snapshot that is missing once its capture reported it is lost, not merely absent. - let problem = platform::check_snapshot_dir(snapshot).map_err(|error| { - Error::io(format!("cannot open {what} {}", snapshot.display())).with_source(error) - })?; - refuse_problem(snapshot, what, problem)?; - // So is a manifest that is missing from the snapshot. - let manifest = TemplateManifest::read(snapshot).map_err(|error| { - if error.kind() != ErrorKind::NotFound { - return error; - } - let (_, message, source) = error.into_parts(); - let error = Error::io(message); - match source { - Some(source) => error.with_source(source), - None => error, - } - })?; - let mut names = entries(snapshot)?; - names.sort(); - if names != SNAPSHOT_ARTIFACTS { - return Err(Error::io(format!( - "{what} {} holds {}, not exactly the manifest, RAM, and saved state of a platform-tier \ - snapshot", - snapshot.display(), - names.join(", ") - ))); - } - for (name, length) in [ - (MANIFEST_NAME, None), - (MEMORY_NAME, Some(manifest.memory_len())), - (STATE_NAME, Some(manifest.state_len())), - ] { - let path = snapshot.join(name); - let refused = |problem: String| { - Error::io(format!( - "the artifact {} of {what} {problem}", - path.display() - )) - }; - let file = - open_regular(&path).map_err(|error| refused(format!("cannot be used: {error}")))?; - if let Some(problem) = platform::private_file_problem(&file) - .map_err(|error| refused(format!("cannot be checked: {error}")))? - { - return Err(refused(problem)); - } - let found = file - .metadata() - .map_err(|error| refused(format!("cannot be examined: {error}")))? - .len(); - if let Some(length) = length - && found != length - { - return Err(refused(format!( - "is {found} bytes long, not the {length} bytes that its manifest records" - ))); - } - } - Ok(manifest) -} - /// Removes from the capture's transaction `transaction` what the template does not keep: the lock /// file of its sandbox store, which the capture closed once its VM had ended, the dormant scratch /// device's file, the file that backed the VM's RAM, which OpenVMM removes as the VM ends unless diff --git a/nvx-rs/src/host/template/store/capture/tests.rs b/nvx-rs/src/host/template/store/capture/tests.rs index b05b912d1..42b20b1d9 100644 --- a/nvx-rs/src/host/template/store/capture/tests.rs +++ b/nvx-rs/src/host/template/store/capture/tests.rs @@ -15,6 +15,7 @@ use super::*; use crate::host::sandbox::distro::{DISTRO_POLICY_REVISION, PolicyVerdict}; use crate::host::sandbox::{SandboxDistro, SandboxUpper}; use crate::host::snapshot::tests::{MIB, ManifestSpec, class, network_class}; +use crate::host::snapshot::{MANIFEST_NAME, SNAPSHOT_ARTIFACTS}; use crate::host::store::{CallerFiles, JOURNAL_NAME, LaunchId}; use crate::host::template::eligibility::MAX_DOWNTIME_NS; use crate::host::template::store::Lease; @@ -30,6 +31,11 @@ const GIB: u64 = 1024 * MIB; /// Nanoseconds in a second. const SECOND: u64 = 1_000_000_000; +/// Name of the saved state in the emulated snapshot. +const STATE_NAME: &str = "state.bin"; +/// Name of the RAM in the emulated snapshot. +const MEMORY_NAME: &str = "memory.bin"; + /// The identity of the placeholder. const IDENTITY: &str = "11111111-1111-1111-1111-111111111111"; diff --git a/nvx-rs/src/host/template/store/tests.rs b/nvx-rs/src/host/template/store/tests.rs index 59d2cb740..f38b612af 100644 --- a/nvx-rs/src/host/template/store/tests.rs +++ b/nvx-rs/src/host/template/store/tests.rs @@ -13,7 +13,7 @@ use crate::host::template::key::CaptureClass; use crate::host::template::manifest::TemplateManifest; #[cfg(any(target_os = "linux", windows))] -pub(in crate::host::template) use super::capture::SNAPSHOT_ARTIFACTS; +pub(in crate::host::template) use crate::host::snapshot::SNAPSHOT_ARTIFACTS; impl Store { /// Publishes a generation of a template, whose fixed time contract names a host and boot of diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 7b02d7721..89a38ddf0 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -45,7 +45,7 @@ use crate::{Error, Result}; /// OpenVMM's log of a launch. const LOG: &str = "openvmm.log"; /// OpenVMM's outcome report of a launch. -const OUTCOME: &str = "outcome.json"; +pub(super) const OUTCOME: &str = "outcome.json"; /// The control socket on Linux. const CONTROL: &str = "control.sock"; /// The guest's boot-console socket on Linux. From ce7fa067408f285772ddf125397345f3b1e46ba5 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 11:58:47 -0800 Subject: [PATCH 22/25] Reconcile joined init test seam inventory Retain the published route input's seventh init seam for its restored announcer test hook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c688b057-b972-4b90-9ecd-91187b92311f --- scripts/test-layout-allowlist.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 04aecafa6..347e17ac4 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -5,7 +5,7 @@ "nvx-rs/src/guest/cgroup.rs": 2, "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, - "nvx-rs/src/guest/init.rs": 6, + "nvx-rs/src/guest/init.rs": 7, "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, "nvx-rs/src/guest/sys/entry.rs": 3, From d4d89270d45945c7a88486f5f238922a5c6ed095 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 12:06:12 -0800 Subject: [PATCH 23/25] host: retain capture time without changing compatibility identity Persist and strictly decode the complete per-snapshot time contract; hash only the unchanged compatibility prefix and reject key/manifest time mismatches. Keep the single Fresh encoding in place without a version bump or backward reader. Apply owner-only artifact validation at the snapshot boundary while preserving the legacy template validator and its existing callers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b718df7e-13ad-474d-b3c5-b0f93a7e18e6 --- nvx-rs/README.md | 10 +- nvx-rs/public-api/linux.txt | 2 + nvx-rs/public-api/windows.txt | 2 + nvx-rs/src/host/snapshot.rs | 18 ++- nvx-rs/src/host/snapshot/canonical.rs | 4 +- nvx-rs/src/host/snapshot/capture.rs | 6 +- nvx-rs/src/host/snapshot/capture/tests.rs | 1 + nvx-rs/src/host/snapshot/key.rs | 132 +++++++++++++++++----- nvx-rs/src/host/snapshot/key/tests.rs | 68 ++++++++++- nvx-rs/src/host/snapshot/manifest.rs | 6 +- nvx-rs/src/host/snapshot/tests.rs | 27 +++++ nvx-rs/tests/vectors/README.md | 6 +- nvx-rs/tests/vectors/snapshot-key-v1.txt | 68 +++++------ 13 files changed, 269 insertions(+), 81 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 1991080a8..6e46c8a0a 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -581,8 +581,8 @@ responsive shell input, and a verified clean exit; it downloads no artifact. ### `nvx::host::snapshot` `SnapshotKey` and `SnapshotManifest` own the compatibility key and strict -OpenVMM manifest reader. `SnapshotManifest::time` exposes the capture's -`TimeContract`, whose `eligibility` checks its restore window at a `HostTime`; +OpenVMM manifest reader. `SnapshotManifest::time` and `SnapshotKey::time` expose +the capture's `TimeContract`, whose `eligibility` checks its restore window at a `HostTime`; `HostTime::now` samples the host's clock and identities. The existing `host::template` names remain aliases of these same types. @@ -591,6 +591,12 @@ The key's canonical encoding and identity use the snapshot names and omit and no reader or migration of the prior encoding is supported. `CaptureClass` still records the legacy capture inputs, including its guest build. +The persisted key includes the complete per-snapshot time contract and must +agree with the manifest's time. Its compatibility identity excludes capture +anchors, host and boot identities, lineage and cut-to-anchor time: captures +at different times can have different key bytes and the same `KeyIdentity`. +The declared rates and time ABI remain compatibility inputs. + `Snapshot::open` checks exactly `key.bin`, `snapshot.json` and `openvmm/{manifest.bin,memory.bin,state.bin}`. Directories are private, files are regular and private to their owner, artifact lengths equal the strict diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index c2b99bf2b..913b6ffe4 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3115,6 +3115,7 @@ method nvx::host::snapshot::SnapshotKey::processors method nvx::host::snapshot::SnapshotKey::runtime method nvx::host::snapshot::SnapshotKey::scratch method nvx::host::snapshot::SnapshotKey::speaks_current_contracts +method nvx::host::snapshot::SnapshotKey::time method nvx::host::snapshot::SnapshotKey::to_bytes struct nvx::host::snapshot::SnapshotManifest method nvx::host::snapshot::SnapshotManifest::created_at @@ -3693,6 +3694,7 @@ method nvx::host::template::TemplateKey::processors method nvx::host::template::TemplateKey::runtime method nvx::host::template::TemplateKey::scratch method nvx::host::template::TemplateKey::speaks_current_contracts +method nvx::host::template::TemplateKey::time method nvx::host::template::TemplateKey::to_bytes struct nvx::host::template::TemplateManifest method nvx::host::template::TemplateManifest::created_at diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index dab3edfb6..e60eb3617 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1701,6 +1701,7 @@ method nvx::host::snapshot::SnapshotKey::processors method nvx::host::snapshot::SnapshotKey::runtime method nvx::host::snapshot::SnapshotKey::scratch method nvx::host::snapshot::SnapshotKey::speaks_current_contracts +method nvx::host::snapshot::SnapshotKey::time method nvx::host::snapshot::SnapshotKey::to_bytes struct nvx::host::snapshot::SnapshotManifest method nvx::host::snapshot::SnapshotManifest::created_at @@ -2278,6 +2279,7 @@ method nvx::host::template::TemplateKey::processors method nvx::host::template::TemplateKey::runtime method nvx::host::template::TemplateKey::scratch method nvx::host::template::TemplateKey::speaks_current_contracts +method nvx::host::template::TemplateKey::time method nvx::host::template::TemplateKey::to_bytes struct nvx::host::template::TemplateManifest method nvx::host::template::TemplateManifest::created_at diff --git a/nvx-rs/src/host/snapshot.rs b/nvx-rs/src/host/snapshot.rs index b518c4c13..55591aeb7 100644 --- a/nvx-rs/src/host/snapshot.rs +++ b/nvx-rs/src/host/snapshot.rs @@ -14,9 +14,10 @@ //! # Canonical encoding //! //! A key and a class each have one canonical encoding, which [`SnapshotKey::to_bytes`] and -//! [`CaptureClass::to_bytes`] write, and whose SHA-256 digest, after a domain of its own, is its -//! identity, a [`KeyIdentity`] or a [`ClassIdentity`]. The encoding starts with its version, and -//! each value in it has exactly one encoding: +//! [`CaptureClass::to_bytes`] write. A key's [`KeyIdentity`] digests its compatibility prefix, +//! excluding the appended capture-time anchors and identities; a [`ClassIdentity`] digests the +//! complete class encoding. Both use SHA-256 after a domain of their own. Each encoding starts +//! with its version, and each value in it has exactly one encoding: //! //! - An integer is its fixed number of little-endian bytes: one for a `u8`, two for a `u16`, four //! for a `u32`, and eight for a `u64`. @@ -134,7 +135,7 @@ impl Snapshot { for path in [dir.join(KEY_FILE), dir.join(DESCRIPTION_FILE)] { check_file(&path)?; } - let manifest = check_snapshot(&dir.join(OPENVMM_DIR))?; + let manifest = check_openvmm(&dir.join(OPENVMM_DIR))?; let path = dir.join(KEY_FILE); let bytes = regular::open_regular(&path) .and_then(|file| { @@ -157,6 +158,15 @@ impl Snapshot { } } +/// Adds the snapshot format's per-file owner-only checks to the legacy capture checks. +fn check_openvmm(path: &Path) -> Result { + let manifest = check_snapshot(path)?; + for name in SNAPSHOT_ARTIFACTS { + check_file(&path.join(name))?; + } + Ok(manifest) +} + /// Names the failing snapshot entry while retaining its I/O error. fn entry_error(path: &Path, error: io::Error) -> Error { Error::io(format!("cannot examine snapshot entry {}", path.display())).with_source(error) diff --git a/nvx-rs/src/host/snapshot/canonical.rs b/nvx-rs/src/host/snapshot/canonical.rs index e2f5aa52a..c300c8cd4 100644 --- a/nvx-rs/src/host/snapshot/canonical.rs +++ b/nvx-rs/src/host/snapshot/canonical.rs @@ -1,5 +1,5 @@ -//! The canonical encoding of a template's compatibility key and of a capture class: the bytes -//! whose SHA-256 digest is their identity. +//! The canonical encoding of a snapshot key and of a capture class. The key's compatibility +//! prefix, and the complete class encoding, are the bytes whose SHA-256 digest is their identity. //! //! Every value has exactly one encoding, and the reader refuses any other: //! diff --git a/nvx-rs/src/host/snapshot/capture.rs b/nvx-rs/src/host/snapshot/capture.rs index 9415981c7..6d2132cc7 100644 --- a/nvx-rs/src/host/snapshot/capture.rs +++ b/nvx-rs/src/host/snapshot/capture.rs @@ -9,7 +9,7 @@ use serde_json::json; use super::{ CaptureContext, DESCRIPTION_FILE, KEY_FILE, MANIFEST_NAME, OPENVMM_DIR, SNAPSHOT_ARTIFACTS, - SnapshotKey, SnapshotManifest, + SnapshotKey, SnapshotManifest, check_openvmm, }; use crate::host::platform; use crate::host::process::Exited; @@ -61,7 +61,7 @@ pub fn finish_capture( ) -> Result { VmDir::open(dir.path())?; check_exit(&dir.path().join(OUTCOME))?; - let manifest = check_snapshot(&dir.path().join(OPENVMM_DIR))?; + let manifest = check_openvmm(&dir.path().join(OPENVMM_DIR))?; let key = SnapshotKey::from_capture(context, &manifest)?; let time = manifest.time(); let description = json!({ @@ -149,7 +149,7 @@ pub(in crate::host) fn check_exit(path: &Path) -> Result<()> { Ok(()) } -/// Checks the private OpenVMM directory, strict manifest and exact regular private artifacts. +/// Preserves the legacy capture's directory, strict manifest, regular artifact and length checks. pub(in crate::host) fn check_snapshot(snapshot: &Path) -> Result { let what = "the snapshot of the capture"; let problem = platform::check_snapshot_dir(snapshot).map_err(|error| { diff --git a/nvx-rs/src/host/snapshot/capture/tests.rs b/nvx-rs/src/host/snapshot/capture/tests.rs index 732b147ba..93b534f66 100644 --- a/nvx-rs/src/host/snapshot/capture/tests.rs +++ b/nvx-rs/src/host/snapshot/capture/tests.rs @@ -165,6 +165,7 @@ fn finish_writes_matching_metadata_and_only_its_three_entries_are_published() { let key = SnapshotKey::from_bytes(&fs::read(fixture.dir.path().join(KEY_FILE)).unwrap()).unwrap(); assert_eq!(key, captured.key); + assert_eq!(key.time(), manifest.time()); assert_eq!(key.runtime(), &fixture.context.runtime); assert_eq!(key.invariants(), &fixture.context.capture.invariants); key.check_manifest(&manifest).unwrap(); diff --git a/nvx-rs/src/host/snapshot/key.rs b/nvx-rs/src/host/snapshot/key.rs index 1445db90e..313a8ae01 100644 --- a/nvx-rs/src/host/snapshot/key.rs +++ b/nvx-rs/src/host/snapshot/key.rs @@ -10,16 +10,17 @@ use super::CaptureContext; use super::canonical::{CanonicalError, Reader, Writer}; use super::manifest::{ AddressSpace, BlockGeometry, CPUID_ENTRY_LEN, CpuProfile, Device, DeviceRange, - FILESYSTEM_SLOT_VERSION, Interrupts, Ipv6Identity, MAX_COMMAND_LINE_LEN, MAX_CPUID_ENTRIES, - MAX_DEVICE_RANGES, MAX_DEVICE_WORDS, MAX_DEVICES, MAX_MEMORY_RANGES, MAX_PROCESSORS, - MAX_TEXT_LEN, Machine, Memory, MemoryRange, Nic, PROCESSOR_COUNTS, PolicyIdentity, Rates, - SnapshotManifest, Topology, parse_profile_id, + FILESYSTEM_SLOT_VERSION, HostClock, Interrupts, Ipv6Identity, MAX_COMMAND_LINE_LEN, + MAX_CPUID_ENTRIES, MAX_DEVICE_RANGES, MAX_DEVICE_WORDS, MAX_DEVICES, MAX_MEMORY_RANGES, + MAX_PROCESSORS, MAX_TEXT_LEN, Machine, Memory, MemoryRange, Nic, PROCESSOR_COUNTS, + PolicyIdentity, Rates, SnapshotManifest, TimeContract, Topology, parse_profile_id, }; use crate::activation; use crate::contract; use crate::error::{Error, Result}; use crate::host::config::{DiskCapture, check_setting}; use crate::host::platform::parse_hex32; +use crate::host::process::BootId; use crate::host::sandbox::SandboxNetwork; use crate::host::sandbox::mount::MOUNT_TAG; use crate::host::store::{ArtifactIdentity, MAX_IDENTITY_LEN, RuntimeIdentity}; @@ -204,8 +205,9 @@ macro_rules! identity { } identity!( - /// The identity of a [`SnapshotKey`]: the SHA-256 digest of its canonical encoding, after the - /// domain `nvx snapshot key` and a NUL, so that it names a key of one encoding version alone. + /// The compatibility identity of a [`SnapshotKey`]: the SHA-256 digest of its canonical + /// compatibility prefix, after the domain `nvx snapshot key` and a NUL. The persisted + /// capture-time anchors do not contribute to it. /// /// Its text form, which `Display` writes and `FromStr` parses, is 64 lowercase hexadecimal /// digits; parsing also accepts uppercase ones. It converts to and from the @@ -293,9 +295,9 @@ fn guest_network_of(nic: &Nic) -> String { /// /// A capture whose manifest matches its class makes a template whose [`SnapshotKey`] combines /// them: [`SnapshotKey::new`] checks that the machine of the manifest is the class's, and takes -/// the rest of the key from it. Captures of one class make templates of one key, unless something -/// that the class does not fix changed between them, such as the host's CPU, which its effective -/// CPUID and its rates follow. +/// the rest of the key from it. Captures of one class have one compatibility identity unless +/// something that the class does not fix changes, such as the host's CPU, which its effective +/// CPUID and its rates follow. Each key also retains its own capture's time contract. /// /// Its [`identity`](Self::identity) names the class: the SHA-256 digest of its canonical encoding, /// which holds every field, and the versions of the contracts that the key holds. @@ -464,12 +466,12 @@ impl CaptureClass { /// the template replaces with its clone's own; and the template's [`Invariants`], its other /// invariant isolation and resource settings. /// -/// A capture's own metadata is not part of it, so the generations of one key differ in it: when -/// the capture was taken, its time contract's anchor, host, boot, and lineage counter, and the -/// lengths of its artifacts. [`SnapshotManifest`] keeps them. Nothing of a clone is part of it -/// either: its image, upper, export, network policy and settings, identity, and activation -/// targets. So a template with a NIC serves the clones of every network policy at its guest's -/// address. +/// The complete capture [`TimeContract`] is retained and encoded with the key, including its +/// anchor, host, boot and lineage counter, but those per-capture fields are not part of the +/// compatibility identity. The capture's creation time and artifact lengths remain in +/// [`SnapshotManifest`]. Nothing of a clone is part of the key: its image, upper, export, network +/// policy and settings, identity, or activation targets. So a template with a NIC serves the +/// clones of every network policy at its guest's address. /// /// The key comes from metadata alone: the artifacts' identities that their install metadata /// records, the class's inputs, and the capture's manifest. It never reads or digests a binary, @@ -477,7 +479,8 @@ impl CaptureClass { /// makes templates of a new key: the templates of the old one are not recertified. /// /// Its [`identity`](Self::identity) is the SHA-256 digest of its -/// [canonical encoding](Self::to_bytes), after a domain that no other digest of this crate shares. +/// canonical compatibility prefix, after a domain that no other digest of this crate shares. +/// [`to_bytes`](Self::to_bytes) appends the per-capture time fields to that prefix. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SnapshotKey { /// The runtime: OpenVMM, the guest kernel and initramfs, and the boot and control contracts. @@ -490,6 +493,8 @@ pub struct SnapshotKey { filesystem_tag: String, /// The invariant settings beyond the machine. invariants: Invariants, + /// The complete time contract of this particular capture. + time: TimeContract, } pub(in crate::host) use SnapshotKey as TemplateKey; @@ -523,8 +528,8 @@ struct CaptureInputs<'a> { impl SnapshotKey { /// Version of the canonical encoding of a key, its first two bytes. /// - /// Version 1 omits the guest build and holds the snapshot's NIC, if it has one, with the - /// identity of the complete + /// Version 1 omits the guest build, appends the complete capture-time contract, and holds + /// the snapshot's NIC, if it has one, with the identity of the complete /// deny-all policy under which the capture attached it, but no network policy of its clones. /// It has not been released, so a template store that an earlier build created is not /// supported: delete it and create a new one. There is no migration. @@ -700,12 +705,18 @@ impl SnapshotKey { machine: machine.clone(), filesystem_tag: MOUNT_TAG.to_owned(), invariants: class.invariants.clone(), + time: manifest.time().clone(), }) } - /// Returns the identity of the key. + /// Returns the compatibility identity, excluding this capture's time anchors and identities. + /// + /// The rates and time ABI remain compatibility inputs. Different capture times may have + /// different [`to_bytes`](Self::to_bytes) with the same identity. pub fn identity(&self) -> KeyIdentity { - KeyIdentity(digest(KEY_DOMAIN, &self.to_bytes())) + let mut writer = Writer::default(); + self.write_compatibility(&mut writer); + KeyIdentity(digest(KEY_DOMAIN, &writer.finish())) } /// Returns the runtime: the identities of OpenVMM, the guest kernel, and the guest initramfs, @@ -723,6 +734,7 @@ impl SnapshotKey { /// Checks the manifest's complete machine, the current key contracts and the filesystem tag. pub(super) fn check_manifest(&self, manifest: &SnapshotManifest) -> Result<()> { if self.machine != manifest.machine + || &self.time != manifest.time() || !self.speaks_current_contracts() || self.filesystem_tag != MOUNT_TAG { @@ -738,6 +750,14 @@ impl SnapshotKey { self.machine.hypervisor } + /// Returns this snapshot's complete capture-time contract, as recorded by its manifest. + /// + /// Its capture anchors, host, boot and lineage are persisted, but do not change the + /// compatibility [`identity`](Self::identity). + pub fn time(&self) -> &TimeContract { + &self.time + } + /// Returns the ID of the CPU profile, `VENDOR.GENERATION.vREVISION`. pub fn cpu_profile(&self) -> String { self.machine.cpu.id() @@ -818,8 +838,10 @@ impl SnapshotKey { &self.invariants } - /// Returns the canonical encoding of the key, which its [`identity`](Self::identity) digests, - /// and [`from_bytes`](Self::from_bytes) reads back. + /// Returns the canonical persisted encoding, which [`from_bytes`](Self::from_bytes) reads. + /// + /// [`identity`](Self::identity) digests the compatibility prefix, items 1 through 8 below, + /// excluding item 9's per-capture time fields. /// /// Version 1 holds, in order, each value encoded as the [module](super) describes: /// @@ -851,18 +873,28 @@ impl SnapshotKey { /// the gateway's IPv4 address, 4 bytes, the optional IPv6 identity, with the guest's /// address, 16 bytes, its prefix length, a `u8`, and the gateway's address, 16 bytes, and /// the network policy's mode, a text, digest, 32 bytes, whether it is required, a boolean, - /// and encoding version, a `u32`; and the invariant settings, a list of names and values, - /// each a text, in the order of their names. + /// and encoding version, a `u32`. + /// 8. The invariant settings, a list of names and values, each a text, in name order. + /// 9. The capture-specific time contract: the capture TSC, UTC and monotonic anchors, + /// `u64`s; the host clock, a text; the host and boot identities, 16 bytes each; the lineage, + /// a `u32`; and the optional cut-to-anchor interval, a `u64`. The complete contract's rates + /// are already encoded in item 3. pub fn to_bytes(&self) -> Vec { let mut writer = Writer::default(); - writer.u16(Self::VERSION); - write_runtime(&mut writer, &self.runtime); - self.contracts.write(&mut writer); - write_machine(&mut writer, &self.machine, &self.filesystem_tag); - self.invariants.write(&mut writer); + self.write_compatibility(&mut writer); + write_time(&mut writer, &self.time); writer.finish() } + /// Writes exactly the compatibility prefix that the key's identity digests. + fn write_compatibility(&self, writer: &mut Writer) { + writer.u16(Self::VERSION); + write_runtime(writer, &self.runtime); + self.contracts.write(writer); + write_machine(writer, &self.machine, &self.filesystem_tag); + self.invariants.write(writer); + } + /// Reads a key from its canonical encoding, as [`to_bytes`](Self::to_bytes) writes it. /// /// The bytes must be the key's one encoding, of version [`VERSION`](Self::VERSION), with no @@ -898,12 +930,14 @@ impl SnapshotKey { let contracts = Contracts::read(reader)?; let (machine, filesystem_tag) = read_machine(reader)?; let invariants = Invariants::read(reader)?; + let time = read_time(reader, machine.rates)?; Ok(Self { runtime, contracts, machine, filesystem_tag, invariants, + time, }) }; let key = read(&mut reader).map_err(malformed)?; @@ -912,6 +946,46 @@ impl SnapshotKey { } } +/// Appends the capture-specific fields; the complete contract's rates are in the machine prefix. +fn write_time(writer: &mut Writer, time: &TimeContract) { + writer.u64(time.capture_tsc); + writer.u64(time.capture_utc_ns); + writer.u64(time.capture_monotonic_ns); + writer.text(time.host_clock.as_str()); + writer.fixed(&time.host_id); + writer.fixed(time.boot_id.as_bytes()); + writer.u32(time.lineage); + writer.option(time.cut_to_anchor_ns.as_ref(), |writer, nanos| { + writer.u64(*nanos) + }); +} + +/// Reads the full capture contract using the compatibility prefix's already-checked rates. +fn read_time(reader: &mut Reader<'_>, rates: Rates) -> Result { + let capture_tsc = reader.u64("the capture TSC")?; + let capture_utc_ns = reader.u64("the capture UTC anchor")?; + let capture_monotonic_ns = reader.u64("the capture monotonic anchor")?; + let clock = reader.text(MAX_TEXT_LEN, "the capture host clock")?; + let host_clock = HostClock::parse(clock).ok_or_else(|| { + CanonicalError::new(format!("the capture host clock {clock:?} is unknown")) + })?; + Ok(TimeContract { + tsc_frequency_hz: rates.tsc_hz, + tsc_tolerance_ppm: rates.tolerance_ppm, + apic_frequency_hz: rates.apic_hz, + capture_tsc, + capture_utc_ns, + capture_monotonic_ns, + host_clock, + host_id: reader.fixed("the capturing host identity")?, + boot_id: BootId::from_bytes(reader.fixed("the capture boot identity")?), + lineage: reader.u32("the capture lineage")?, + cut_to_anchor_ns: reader.option("the cut-to-anchor interval", |reader| { + reader.u64("the cut-to-anchor interval") + })?, + }) +} + /// Writes the machine `machine`, whose filesystem slot has the tag `tag`, as /// [`SnapshotKey::to_bytes`] describes it. fn write_machine(writer: &mut Writer, machine: &Machine, tag: &str) { diff --git a/nvx-rs/src/host/snapshot/key/tests.rs b/nvx-rs/src/host/snapshot/key/tests.rs index 0ee47de27..99c639dcf 100644 --- a/nvx-rs/src/host/snapshot/key/tests.rs +++ b/nvx-rs/src/host/snapshot/key/tests.rs @@ -13,6 +13,21 @@ fn manifest(spec: &ManifestSpec) -> SnapshotManifest { SnapshotManifest::parse(&spec.encode()).unwrap() } +/// The specified suffix length, independently of the key writer, for locating the identity prefix. +fn capture_time_len(time: &TimeContract) -> usize { + 3 * 8 + + 4 + + time.host_clock.as_str().len() + + 2 * 16 + + 4 + + 1 + + if time.cut_to_anchor_ns.is_some() { + 8 + } else { + 0 + } +} + /// Returns the key of the template that a capture of `class` made, whose manifest `spec` /// encodes. fn key(class: &CaptureClass, spec: &ManifestSpec) -> SnapshotKey { @@ -349,6 +364,50 @@ fn the_guest_build_is_not_part_of_a_snapshot_key() { ); } +#[test] +fn complete_capture_time_round_trips_without_changing_compatibility_identity() { + let base = network_key(); + for cut in [None, Some(0), Some(u64::MAX)] { + let mut spec = ManifestSpec::with_network(); + let time = spec.time(); + time.capture_tsc = u64::MAX; + time.capture_utc_ns = u64::MAX - 1; + time.capture_monotonic_ns = u64::MAX - 2; + time.host_clock = "windows-interrupt-time".to_owned(); + time.host_id = vec![0x31; 16]; + time.boot_id = vec![0x72; 16]; + time.lineage = u32::MAX; + time.cut_to_anchor_ns = cut; + let manifest = manifest(&spec); + let key = SnapshotKey::new(&network_class(), &manifest).unwrap(); + assert_eq!(key.time(), manifest.time()); + assert_ne!(key.to_bytes(), base.to_bytes()); + assert_eq!(key.identity(), base.identity()); + let decoded = SnapshotKey::from_bytes(&key.to_bytes()).unwrap(); + assert_eq!(decoded, key); + assert_eq!(decoded.time(), manifest.time()); + } +} + +#[test] +fn canonical_keys_require_capture_time_and_a_known_host_clock() { + let key = network_key(); + let mut prefix = Writer::default(); + key.write_compatibility(&mut prefix); + let prefix = prefix.finish(); + assert_eq!( + SnapshotKey::from_bytes(&prefix).unwrap_err().kind(), + crate::ErrorKind::Io + ); + let mut bytes = key.to_bytes(); + // Three anchors and the clock text's length precede its bytes. + bytes[prefix.len() + 3 * 8 + 4] = b'?'; + let error = SnapshotKey::from_bytes(&bytes).unwrap_err(); + assert_eq!(error.kind(), crate::ErrorKind::Io); + assert!(error.message().contains("host clock"), "{error}"); + assert!(error.message().contains("unknown"), "{error}"); +} + #[test] fn a_key_holds_what_its_capture_made() { let made = network_key(); @@ -670,7 +729,7 @@ fn keys_read_back_from_their_canonical_encoding() { assert_eq!(read.to_bytes(), bytes); let mut digest = Sha256::new(); digest.update(b"nvx snapshot key\0"); - digest.update(&bytes); + digest.update(&bytes[..bytes.len() - capture_time_len(made.time())]); assert_eq!( made.identity().as_bytes(), &<[u8; 32]>::from(digest.finalize()) @@ -849,12 +908,13 @@ fn keys_of_machines_that_no_manifest_describes_are_refused() { .unwrap() .with("b", "2") .unwrap(); - let mut bytes = key(&class, &ManifestSpec::with_network()).to_bytes(); - let len = bytes.len(); + let made = key(&class, &ManifestSpec::with_network()); + let mut bytes = made.to_bytes(); + let end = bytes.len() - capture_time_len(made.time()); let tail = [ 1, 0, 0, 0, b'b', 1, 0, 0, 0, b'2', 1, 0, 0, 0, b'a', 1, 0, 0, 0, b'1', ]; - bytes[len - tail.len()..].copy_from_slice(&tail); + bytes[end - tail.len()..end].copy_from_slice(&tail); assert_eq!( key_refusal(&bytes, ErrorCode::Io), "the snapshot key is malformed: the invariant settings are not in the strict order of \ diff --git a/nvx-rs/src/host/snapshot/manifest.rs b/nvx-rs/src/host/snapshot/manifest.rs index d21fe338f..328b5b7f3 100644 --- a/nvx-rs/src/host/snapshot/manifest.rs +++ b/nvx-rs/src/host/snapshot/manifest.rs @@ -146,9 +146,9 @@ impl HostClock { } } -/// The time contract of a capture: the rates that it declares, which its template's key holds, -/// and the anchor at which it was captured, with the identities of the host and the boot that -/// captured it, which the generations of one key differ in. +/// The complete time contract of a capture, retained by its manifest and snapshot key. Its +/// rates contribute to the key's compatibility identity; its capture anchor, host, boot and +/// lineage do not, so captures with different anchors can share that identity. /// /// A restore uses the anchor to measure how long the capture has been down: on the same host /// boot, by the host's monotonic clock, and otherwise by UTC. diff --git a/nvx-rs/src/host/snapshot/tests.rs b/nvx-rs/src/host/snapshot/tests.rs index c73fe2710..e7ba35892 100644 --- a/nvx-rs/src/host/snapshot/tests.rs +++ b/nvx-rs/src/host/snapshot/tests.rs @@ -1207,6 +1207,33 @@ fn snapshot_description_contents_do_not_affect_opening() { } } +#[test] +fn snapshot_open_refuses_a_compatible_key_with_another_capture_time() { + use crate::host::store::files::write_private; + + let mutations: &[fn(&mut ManifestSpec)] = &[ + |spec| spec.time().capture_tsc += 1, + |spec| spec.time().capture_utc_ns += 1, + |spec| spec.time().capture_monotonic_ns += 1, + |spec| spec.time().host_clock = "windows-interrupt-time".to_owned(), + |spec| spec.time().host_id[0] ^= 1, + |spec| spec.time().boot_id[0] ^= 1, + |spec| spec.time().lineage += 1, + |spec| spec.time().cut_to_anchor_ns = None, + |spec| spec.time().cut_to_anchor_ns = Some(0), + ]; + for mutate in mutations { + let fixture = SnapshotFixture::new(); + let mut spec = fixture.spec.clone(); + mutate(&mut spec); + let manifest = SnapshotManifest::parse(&spec.encode()).unwrap(); + let key = SnapshotKey::new(&class(), &manifest).unwrap(); + assert_eq!(key.identity(), fixture.key.identity()); + write_private(&fixture.dir.join(KEY_FILE), &key.to_bytes()).unwrap(); + refused(&fixture.dir); + } +} + #[cfg(target_os = "linux")] #[test] fn snapshot_open_refuses_every_shared_file_and_both_shared_directories() { diff --git a/nvx-rs/tests/vectors/README.md b/nvx-rs/tests/vectors/README.md index 23aec531a..9aaefe167 100644 --- a/nvx-rs/tests/vectors/README.md +++ b/nvx-rs/tests/vectors/README.md @@ -73,8 +73,10 @@ the key that the vector's bytes decode to. of a class have not been released, so a template store that an earlier build created is not supported, and is deleted and created again, since nothing migrates it. The snapshot key also drops the obsolete guest-build field and - uses the `nvx snapshot key` hash domain in place: no snapshot outlives a - deployment, and the prior key encoding is not supported. + uses the `nvx snapshot key` hash domain in place, and now appends the full + capture-time contract while its compatibility identity still digests only + the preceding compatibility fields: no snapshot outlives a deployment, + and the prior key encoding is not supported. - The bytes come from the protocol's definition, never from the code that they check, and so do the values of each vector. diff --git a/nvx-rs/tests/vectors/snapshot-key-v1.txt b/nvx-rs/tests/vectors/snapshot-key-v1.txt index 99ad0a9af..46fef1809 100644 --- a/nvx-rs/tests/vectors/snapshot-key-v1.txt +++ b/nvx-rs/tests/vectors/snapshot-key-v1.txt @@ -4,9 +4,9 @@ # rules that the vectors follow. # # KEY|NAME|BYTES|IDENTITY -# The canonical encoding of a key, and its identity: the SHA-256 digest of "nvx snapshot key", a -# NUL byte, and the encoding. A decoder must decode the bytes, and an encoder must encode the key -# that they hold to the same bytes. +# The canonical persisted key, and its compatibility identity: the SHA-256 digest of +# "nvx snapshot key", a NUL byte, and the compatibility prefix before the capture-time fields. +# A decoder must decode the full key, and an encoder must reproduce its bytes. # CLASS|NAME|BYTES|IDENTITY # The canonical encoding of a class, and its identity: the SHA-256 digest of "nvx capture class", # a NUL byte, and the encoding. @@ -25,20 +25,24 @@ # nvx-microvm-v2-control-v2, control session 1, snapshot format 6, device-state schema 1, time # ABI 1, restore packet 4, and activation contract 1. # Capture classes alone also hold the guest build guest-build-1. +# TEMPLATE, NETWORK and HOST_PROFILE append capture TSC 12345678, UTC 1700000000000000000 ns, +# monotonic 123000000000 ns, clock linux-boottime, host and boot IDs of 16 bytes of 01 and 02, +# lineage 3 and cut-to-anchor Some(200). MINIMAL uses zero anchors, zero host/boot IDs, lineage 0 +# and no cut-to-anchor interval on linux-boottime. Their rates remain in the compatibility prefix. # The key of the template of a KVM microVM of 2 processors, all online at boot, and 512 MiB # of RAM, of a capacity of 2 GiB, with a distro of 1 GiB, a scratch of 64 MiB, the dormant # filesystem slot, a console, a control console, and no NIC, on the CPU profile # intel.icelake-sp.v1, whose digest is 32 bytes of 5a and whose effective CPUID is the bytes # 00 to 57: the capture of tests/fixtures/template/platform-kvm. -KEY|TEMPLATE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d05000000696e74656c0a0000006963656c616b652d7370010000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a58000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f50515253545556570100000001000000020000000100000002000000000000000100000000752b7d0000000000c2eb0b00000000fa000000020000000000000000000020000000000000008000000000000000080000000001000000000000000000000000000020000000000100000000000020000000000000006000000000530100006561726c79636f6e3d78653920636f6e736f6c653d68766331207265626f6f743d742070616e69633d2d31206e76785f73616e64626f783d31206e76785f636f6e6669673d307864303031303030302c3635353336206e725f637075733d32206e76785f736e617073686f745f746965723d706c6174666f726d2076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030313030303a362076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030323030303a372076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030333030303a342076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030363030303a31312076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030373030303a33206e76785f636f6e74726f6c5f7474793d6876633217f80f8858431ca75fcc7ca694ea68637e017ede02db15e18d0ed4581067858d000000400000000000020000001000000000000400000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000001000000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000003000000767030040000007663707509000000706172746974696f6e00000000000000000000000000000000000000000006000000766d74696d6505000000636c6f636b06000000766d74696d65000000000000000000000000000000000000000000030000007069630300000070696303000000706963000000000200000004000000706d696f2000000000000000020000000000000004000000706d696fa00000000000000002000000000000000000000000000000000000000006000000696f6170696306000000696f6170696306000000696f617069630000000001000000040000006d6d696f0000c0fe00000000001000000000000000000000000000000000000000050000006c61706963050000006c6170696309000000706172746974696f6e0000000001000000040000006d6d696f0000e0fe00000000001000000000000000000000000000000000000000030000007069740300000070697403000000706974000000000100000004000000706d696f400000000000000004000000000000000100000000000000000000000000000000030000007274630300000072746303000000727463000000000100000004000000706d696f7000000000000000020000000000000001080000000000000000000000000000000d0000006d6963726f766d2d706f72746205000000706f7274620d0000006d6963726f766d2d706f727462000000000100000004000000706d696fe900000000000000020000000000000000000000000000000000000000100000006d6963726f766d2d73687574646f776e0800000073687574646f776e100000006d6963726f766d2d73687574646f776e000000000100000004000000706d696f0406000000000000010000000000000000000000000000000000000000180000006d6963726f766d2d736e617073686f742d7265717565737410000000736e617073686f742d72657175657374180000006d6963726f766d2d736e617073686f742d72657175657374000000000100000004000000706d696f05060000000000000100000000000000000000000000000000000000000b00000066733a6d6963726f766d300900000076697274696f2d66731300000076697274696f66732d333438393636353032340b00000076697274696f2d6d6d696f01000000040000006d6d696f001000d000000000001000000000000001060000000200000000000030030000000200000002000000000100000001000017000000636f6e736f6c653a6d6963726f766d2d76697274696f300e00000076697274696f2d636f6e736f6c651900000076697274696f2d636f6e736f6c652d333438393636393132300b00000076697274696f2d6d6d696f01000000040000006d6d696f002000d000000000001000000000000001070000000200000001000030030000000200000002000000000100000001000012000000626c6b3a73616e64626f783a64697374726f0a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393637333231360b00000076697274696f2d6d6d696f01000000040000006d6d696f003000d0000000000010000000000000010400000002000000640600300300000001000000010000000001000013000000626c6b3a73616e64626f783a736372617463680a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393638353530340b00000076697274696f2d6d6d696f01000000040000006d6d696f006000d0000000000010000000000000010b00000002000000440600300300000001000000010000000001000018000000636f6e736f6c653a6d6963726f766d2d636f6e74726f6c301600000076697274696f2d636f6e74726f6c2d636f6e736f6c652100000076697274696f2d636f6e74726f6c2d636f6e736f6c652d333438393638393630300b00000076697274696f2d6d6d696f01000000040000006d6d696f007000d000000000001000000000000001030000000200000001000030030000000200000002000000000100000001000001000000070000006d6963726f766d0000000000|dc593980266a3bc96c177fbfb8fea08e958dc54609cdb07c988a33992d09aa32 +KEY|TEMPLATE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d05000000696e74656c0a0000006963656c616b652d7370010000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a58000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f50515253545556570100000001000000020000000100000002000000000000000100000000752b7d0000000000c2eb0b00000000fa000000020000000000000000000020000000000000008000000000000000080000000001000000000000000000000000000020000000000100000000000020000000000000006000000000530100006561726c79636f6e3d78653920636f6e736f6c653d68766331207265626f6f743d742070616e69633d2d31206e76785f73616e64626f783d31206e76785f636f6e6669673d307864303031303030302c3635353336206e725f637075733d32206e76785f736e617073686f745f746965723d706c6174666f726d2076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030313030303a362076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030323030303a372076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030333030303a342076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030363030303a31312076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030373030303a33206e76785f636f6e74726f6c5f7474793d6876633217f80f8858431ca75fcc7ca694ea68637e017ede02db15e18d0ed4581067858d000000400000000000020000001000000000000400000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000001000000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000003000000767030040000007663707509000000706172746974696f6e00000000000000000000000000000000000000000006000000766d74696d6505000000636c6f636b06000000766d74696d65000000000000000000000000000000000000000000030000007069630300000070696303000000706963000000000200000004000000706d696f2000000000000000020000000000000004000000706d696fa00000000000000002000000000000000000000000000000000000000006000000696f6170696306000000696f6170696306000000696f617069630000000001000000040000006d6d696f0000c0fe00000000001000000000000000000000000000000000000000050000006c61706963050000006c6170696309000000706172746974696f6e0000000001000000040000006d6d696f0000e0fe00000000001000000000000000000000000000000000000000030000007069740300000070697403000000706974000000000100000004000000706d696f400000000000000004000000000000000100000000000000000000000000000000030000007274630300000072746303000000727463000000000100000004000000706d696f7000000000000000020000000000000001080000000000000000000000000000000d0000006d6963726f766d2d706f72746205000000706f7274620d0000006d6963726f766d2d706f727462000000000100000004000000706d696fe900000000000000020000000000000000000000000000000000000000100000006d6963726f766d2d73687574646f776e0800000073687574646f776e100000006d6963726f766d2d73687574646f776e000000000100000004000000706d696f0406000000000000010000000000000000000000000000000000000000180000006d6963726f766d2d736e617073686f742d7265717565737410000000736e617073686f742d72657175657374180000006d6963726f766d2d736e617073686f742d72657175657374000000000100000004000000706d696f05060000000000000100000000000000000000000000000000000000000b00000066733a6d6963726f766d300900000076697274696f2d66731300000076697274696f66732d333438393636353032340b00000076697274696f2d6d6d696f01000000040000006d6d696f001000d000000000001000000000000001060000000200000000000030030000000200000002000000000100000001000017000000636f6e736f6c653a6d6963726f766d2d76697274696f300e00000076697274696f2d636f6e736f6c651900000076697274696f2d636f6e736f6c652d333438393636393132300b00000076697274696f2d6d6d696f01000000040000006d6d696f002000d000000000001000000000000001070000000200000001000030030000000200000002000000000100000001000012000000626c6b3a73616e64626f783a64697374726f0a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393637333231360b00000076697274696f2d6d6d696f01000000040000006d6d696f003000d0000000000010000000000000010400000002000000640600300300000001000000010000000001000013000000626c6b3a73616e64626f783a736372617463680a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393638353530340b00000076697274696f2d6d6d696f01000000040000006d6d696f006000d0000000000010000000000000010b00000002000000440600300300000001000000010000000001000018000000636f6e736f6c653a6d6963726f766d2d636f6e74726f6c301600000076697274696f2d636f6e74726f6c2d636f6e736f6c652100000076697274696f2d636f6e74726f6c2d636f6e736f6c652d333438393638393630300b00000076697274696f2d6d6d696f01000000040000006d6d696f007000d000000000001000000000000001030000000200000001000030030000000200000002000000000100000001000001000000070000006d6963726f766d00000000004e61bc000000000000002a36fe9c9717000e5fa31c0000000e0000006c696e75782d626f6f7474696d6501010101010101010101010101010101020202020202020202020202020202020300000001c800000000000000|dc593980266a3bc96c177fbfb8fea08e958dc54609cdb07c988a33992d09aa32 # The same with a fixed NIC at 10.0.0.2/24, with its IPv6 identity, under an allow-list # policy whose digest is 32 bytes of 07, and the invariant settings isolation.seccomp=strict # and limits.pids=512. -KEY|NETWORK|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d05000000696e74656c0a0000006963656c616b652d7370010000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a58000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f50515253545556570100000001000000020000000100000002000000000000000100000000752b7d0000000000c2eb0b00000000fa000000020000000000000000000020000000000000008000000000000000080000000001000000000000000000000000000020000000000100000000000020000000000000006000000000f20100006561726c79636f6e3d78653920636f6e736f6c653d68766331207265626f6f743d742070616e69633d2d31206e76785f73616e64626f783d31206e76785f636f6e6669673d307864303031303030302c3635353336206e725f637075733d32206e76785f736e617073686f745f746965723d706c6174666f726d2076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030303030303a31302076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030313030303a362076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030323030303a372076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030333030303a342076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030363030303a31312076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030373030303a33206e76785f636f6e74726f6c5f7474793d6876633220766972746e65745f69703d31302e302e302e3220766972746e65745f6d61736b3d3235352e3235352e3235352e3020766972746e65745f67773d31302e302e302e3120766972746e65745f6970363d666430303a3a6130303a322f31323020766972746e65745f6777363d666430303a3a6130303a315e7d49c200c9d60e74547b549917b665501635035894c15c2d7780210f95caf0000000400000000000020000001000000000000400000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000001100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000003000000767030040000007663707509000000706172746974696f6e00000000000000000000000000000000000000000006000000766d74696d6505000000636c6f636b06000000766d74696d65000000000000000000000000000000000000000000030000007069630300000070696303000000706963000000000200000004000000706d696f2000000000000000020000000000000004000000706d696fa00000000000000002000000000000000000000000000000000000000006000000696f6170696306000000696f6170696306000000696f617069630000000001000000040000006d6d696f0000c0fe00000000001000000000000000000000000000000000000000050000006c61706963050000006c6170696309000000706172746974696f6e0000000001000000040000006d6d696f0000e0fe00000000001000000000000000000000000000000000000000030000007069740300000070697403000000706974000000000100000004000000706d696f400000000000000004000000000000000100000000000000000000000000000000030000007274630300000072746303000000727463000000000100000004000000706d696f7000000000000000020000000000000001080000000000000000000000000000000d0000006d6963726f766d2d706f72746205000000706f7274620d0000006d6963726f766d2d706f727462000000000100000004000000706d696fe900000000000000020000000000000000000000000000000000000000100000006d6963726f766d2d73687574646f776e0800000073687574646f776e100000006d6963726f766d2d73687574646f776e000000000100000004000000706d696f0406000000000000010000000000000000000000000000000000000000180000006d6963726f766d2d736e617073686f742d7265717565737410000000736e617073686f742d72657175657374180000006d6963726f766d2d736e617073686f742d72657175657374000000000100000004000000706d696f05060000000000000100000000000000000000000000000000000000000c0000006e65743a6d6963726f766d300a00000076697274696f2d6e65741500000076697274696f2d6e65742d333438393636303932380b00000076697274696f2d6d6d696f01000000040000006d6d696f000000d0000000000010000000000000010a000000020000002000000001000000020000000200000000010000000100000b00000066733a6d6963726f766d300900000076697274696f2d66731300000076697274696f66732d333438393636353032340b00000076697274696f2d6d6d696f01000000040000006d6d696f001000d000000000001000000000000001060000000200000000000030030000000200000002000000000100000001000017000000636f6e736f6c653a6d6963726f766d2d76697274696f300e00000076697274696f2d636f6e736f6c651900000076697274696f2d636f6e736f6c652d333438393636393132300b00000076697274696f2d6d6d696f01000000040000006d6d696f002000d000000000001000000000000001070000000200000001000030030000000200000002000000000100000001000012000000626c6b3a73616e64626f783a64697374726f0a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393637333231360b00000076697274696f2d6d6d696f01000000040000006d6d696f003000d0000000000010000000000000010400000002000000640600300300000001000000010000000001000013000000626c6b3a73616e64626f783a736372617463680a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393638353530340b00000076697274696f2d6d6d696f01000000040000006d6d696f006000d0000000000010000000000000010b00000002000000440600300300000001000000010000000001000018000000636f6e736f6c653a6d6963726f766d2d636f6e74726f6c301600000076697274696f2d636f6e74726f6c2d636f6e736f6c652100000076697274696f2d636f6e74726f6c2d636f6e736f6c652d333438393638393630300b00000076697274696f2d6d6d696f01000000040000006d6d696f007000d000000000001000000000000001030000000200000001000030030000000200000002000000000100000001000001000000070000006d6963726f766d0108000000706f727461626c655254000000025254000000010a000002180a00000101fd00000000000000000000000a00000278fd00000000000000000000000a0000010a000000616c6c6f772d6c69737407070707070707070707070707070707070707070707070707070707070707070104000000020000001100000069736f6c6174696f6e2e736563636f6d70060000007374726963740b0000006c696d6974732e7069647303000000353132|af73b6bf51095c0c2ab84fb114bfa63d3e0dc1fd2b6f56f9a729391e19c762f9 +KEY|NETWORK|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d05000000696e74656c0a0000006963656c616b652d7370010000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a58000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f50515253545556570100000001000000020000000100000002000000000000000100000000752b7d0000000000c2eb0b00000000fa000000020000000000000000000020000000000000008000000000000000080000000001000000000000000000000000000020000000000100000000000020000000000000006000000000f20100006561726c79636f6e3d78653920636f6e736f6c653d68766331207265626f6f743d742070616e69633d2d31206e76785f73616e64626f783d31206e76785f636f6e6669673d307864303031303030302c3635353336206e725f637075733d32206e76785f736e617073686f745f746965723d706c6174666f726d2076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030303030303a31302076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030313030303a362076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030323030303a372076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030333030303a342076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030363030303a31312076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030373030303a33206e76785f636f6e74726f6c5f7474793d6876633220766972746e65745f69703d31302e302e302e3220766972746e65745f6d61736b3d3235352e3235352e3235352e3020766972746e65745f67773d31302e302e302e3120766972746e65745f6970363d666430303a3a6130303a322f31323020766972746e65745f6777363d666430303a3a6130303a315e7d49c200c9d60e74547b549917b665501635035894c15c2d7780210f95caf0000000400000000000020000001000000000000400000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000001100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000003000000767030040000007663707509000000706172746974696f6e00000000000000000000000000000000000000000006000000766d74696d6505000000636c6f636b06000000766d74696d65000000000000000000000000000000000000000000030000007069630300000070696303000000706963000000000200000004000000706d696f2000000000000000020000000000000004000000706d696fa00000000000000002000000000000000000000000000000000000000006000000696f6170696306000000696f6170696306000000696f617069630000000001000000040000006d6d696f0000c0fe00000000001000000000000000000000000000000000000000050000006c61706963050000006c6170696309000000706172746974696f6e0000000001000000040000006d6d696f0000e0fe00000000001000000000000000000000000000000000000000030000007069740300000070697403000000706974000000000100000004000000706d696f400000000000000004000000000000000100000000000000000000000000000000030000007274630300000072746303000000727463000000000100000004000000706d696f7000000000000000020000000000000001080000000000000000000000000000000d0000006d6963726f766d2d706f72746205000000706f7274620d0000006d6963726f766d2d706f727462000000000100000004000000706d696fe900000000000000020000000000000000000000000000000000000000100000006d6963726f766d2d73687574646f776e0800000073687574646f776e100000006d6963726f766d2d73687574646f776e000000000100000004000000706d696f0406000000000000010000000000000000000000000000000000000000180000006d6963726f766d2d736e617073686f742d7265717565737410000000736e617073686f742d72657175657374180000006d6963726f766d2d736e617073686f742d72657175657374000000000100000004000000706d696f05060000000000000100000000000000000000000000000000000000000c0000006e65743a6d6963726f766d300a00000076697274696f2d6e65741500000076697274696f2d6e65742d333438393636303932380b00000076697274696f2d6d6d696f01000000040000006d6d696f000000d0000000000010000000000000010a000000020000002000000001000000020000000200000000010000000100000b00000066733a6d6963726f766d300900000076697274696f2d66731300000076697274696f66732d333438393636353032340b00000076697274696f2d6d6d696f01000000040000006d6d696f001000d000000000001000000000000001060000000200000000000030030000000200000002000000000100000001000017000000636f6e736f6c653a6d6963726f766d2d76697274696f300e00000076697274696f2d636f6e736f6c651900000076697274696f2d636f6e736f6c652d333438393636393132300b00000076697274696f2d6d6d696f01000000040000006d6d696f002000d000000000001000000000000001070000000200000001000030030000000200000002000000000100000001000012000000626c6b3a73616e64626f783a64697374726f0a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393637333231360b00000076697274696f2d6d6d696f01000000040000006d6d696f003000d0000000000010000000000000010400000002000000640600300300000001000000010000000001000013000000626c6b3a73616e64626f783a736372617463680a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393638353530340b00000076697274696f2d6d6d696f01000000040000006d6d696f006000d0000000000010000000000000010b00000002000000440600300300000001000000010000000001000018000000636f6e736f6c653a6d6963726f766d2d636f6e74726f6c301600000076697274696f2d636f6e74726f6c2d636f6e736f6c652100000076697274696f2d636f6e74726f6c2d636f6e736f6c652d333438393638393630300b00000076697274696f2d6d6d696f01000000040000006d6d696f007000d000000000001000000000000001030000000200000001000030030000000200000002000000000100000001000001000000070000006d6963726f766d0108000000706f727461626c655254000000025254000000010a000002180a00000101fd00000000000000000000000a00000278fd00000000000000000000000a0000010a000000616c6c6f772d6c69737407070707070707070707070707070707070707070707070707070707070707070104000000020000001100000069736f6c6174696f6e2e736563636f6d70060000007374726963740b0000006c696d6974732e70696473030000003531324e61bc000000000000002a36fe9c9717000e5fa31c0000000e0000006c696e75782d626f6f7474696d6501010101010101010101010101010101020202020202020202020202020202020300000001c800000000000000|af73b6bf51095c0c2ab84fb114bfa63d3e0dc1fd2b6f56f9a729391e19c762f9 # TEMPLATE on the host profile intel.host.v1. -KEY|HOST_PROFILE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d05000000696e74656c04000000686f7374010000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a58000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f50515253545556570100000001000000020000000100000002000000000000000100000000752b7d0000000000c2eb0b00000000fa000000020000000000000000000020000000000000008000000000000000080000000001000000000000000000000000000020000000000100000000000020000000000000006000000000530100006561726c79636f6e3d78653920636f6e736f6c653d68766331207265626f6f743d742070616e69633d2d31206e76785f73616e64626f783d31206e76785f636f6e6669673d307864303031303030302c3635353336206e725f637075733d32206e76785f736e617073686f745f746965723d706c6174666f726d2076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030313030303a362076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030323030303a372076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030333030303a342076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030363030303a31312076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030373030303a33206e76785f636f6e74726f6c5f7474793d6876633217f80f8858431ca75fcc7ca694ea68637e017ede02db15e18d0ed4581067858d000000400000000000020000001000000000000400000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000001000000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000003000000767030040000007663707509000000706172746974696f6e00000000000000000000000000000000000000000006000000766d74696d6505000000636c6f636b06000000766d74696d65000000000000000000000000000000000000000000030000007069630300000070696303000000706963000000000200000004000000706d696f2000000000000000020000000000000004000000706d696fa00000000000000002000000000000000000000000000000000000000006000000696f6170696306000000696f6170696306000000696f617069630000000001000000040000006d6d696f0000c0fe00000000001000000000000000000000000000000000000000050000006c61706963050000006c6170696309000000706172746974696f6e0000000001000000040000006d6d696f0000e0fe00000000001000000000000000000000000000000000000000030000007069740300000070697403000000706974000000000100000004000000706d696f400000000000000004000000000000000100000000000000000000000000000000030000007274630300000072746303000000727463000000000100000004000000706d696f7000000000000000020000000000000001080000000000000000000000000000000d0000006d6963726f766d2d706f72746205000000706f7274620d0000006d6963726f766d2d706f727462000000000100000004000000706d696fe900000000000000020000000000000000000000000000000000000000100000006d6963726f766d2d73687574646f776e0800000073687574646f776e100000006d6963726f766d2d73687574646f776e000000000100000004000000706d696f0406000000000000010000000000000000000000000000000000000000180000006d6963726f766d2d736e617073686f742d7265717565737410000000736e617073686f742d72657175657374180000006d6963726f766d2d736e617073686f742d72657175657374000000000100000004000000706d696f05060000000000000100000000000000000000000000000000000000000b00000066733a6d6963726f766d300900000076697274696f2d66731300000076697274696f66732d333438393636353032340b00000076697274696f2d6d6d696f01000000040000006d6d696f001000d000000000001000000000000001060000000200000000000030030000000200000002000000000100000001000017000000636f6e736f6c653a6d6963726f766d2d76697274696f300e00000076697274696f2d636f6e736f6c651900000076697274696f2d636f6e736f6c652d333438393636393132300b00000076697274696f2d6d6d696f01000000040000006d6d696f002000d000000000001000000000000001070000000200000001000030030000000200000002000000000100000001000012000000626c6b3a73616e64626f783a64697374726f0a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393637333231360b00000076697274696f2d6d6d696f01000000040000006d6d696f003000d0000000000010000000000000010400000002000000640600300300000001000000010000000001000013000000626c6b3a73616e64626f783a736372617463680a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393638353530340b00000076697274696f2d6d6d696f01000000040000006d6d696f006000d0000000000010000000000000010b00000002000000440600300300000001000000010000000001000018000000636f6e736f6c653a6d6963726f766d2d636f6e74726f6c301600000076697274696f2d636f6e74726f6c2d636f6e736f6c652100000076697274696f2d636f6e74726f6c2d636f6e736f6c652d333438393638393630300b00000076697274696f2d6d6d696f01000000040000006d6d696f007000d000000000001000000000000001030000000200000001000030030000000200000002000000000100000001000001000000070000006d6963726f766d0000000000|35f14fb1b596b299a43751b7827b7e7b88bc5cd330ff0d8eae2ee40b3d7e0fc2 +KEY|HOST_PROFILE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d05000000696e74656c04000000686f7374010000005a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a58000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f50515253545556570100000001000000020000000100000002000000000000000100000000752b7d0000000000c2eb0b00000000fa000000020000000000000000000020000000000000008000000000000000080000000001000000000000000000000000000020000000000100000000000020000000000000006000000000530100006561726c79636f6e3d78653920636f6e736f6c653d68766331207265626f6f743d742070616e69633d2d31206e76785f73616e64626f783d31206e76785f636f6e6669673d307864303031303030302c3635353336206e725f637075733d32206e76785f736e617073686f745f746965723d706c6174666f726d2076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030313030303a362076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030323030303a372076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030333030303a342076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030363030303a31312076697274696f5f6d6d696f2e6465766963653d30783130303040307864303030373030303a33206e76785f636f6e74726f6c5f7474793d6876633217f80f8858431ca75fcc7ca694ea68637e017ede02db15e18d0ed4581067858d000000400000000000020000001000000000000400000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000001000000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000003000000767030040000007663707509000000706172746974696f6e00000000000000000000000000000000000000000006000000766d74696d6505000000636c6f636b06000000766d74696d65000000000000000000000000000000000000000000030000007069630300000070696303000000706963000000000200000004000000706d696f2000000000000000020000000000000004000000706d696fa00000000000000002000000000000000000000000000000000000000006000000696f6170696306000000696f6170696306000000696f617069630000000001000000040000006d6d696f0000c0fe00000000001000000000000000000000000000000000000000050000006c61706963050000006c6170696309000000706172746974696f6e0000000001000000040000006d6d696f0000e0fe00000000001000000000000000000000000000000000000000030000007069740300000070697403000000706974000000000100000004000000706d696f400000000000000004000000000000000100000000000000000000000000000000030000007274630300000072746303000000727463000000000100000004000000706d696f7000000000000000020000000000000001080000000000000000000000000000000d0000006d6963726f766d2d706f72746205000000706f7274620d0000006d6963726f766d2d706f727462000000000100000004000000706d696fe900000000000000020000000000000000000000000000000000000000100000006d6963726f766d2d73687574646f776e0800000073687574646f776e100000006d6963726f766d2d73687574646f776e000000000100000004000000706d696f0406000000000000010000000000000000000000000000000000000000180000006d6963726f766d2d736e617073686f742d7265717565737410000000736e617073686f742d72657175657374180000006d6963726f766d2d736e617073686f742d72657175657374000000000100000004000000706d696f05060000000000000100000000000000000000000000000000000000000b00000066733a6d6963726f766d300900000076697274696f2d66731300000076697274696f66732d333438393636353032340b00000076697274696f2d6d6d696f01000000040000006d6d696f001000d000000000001000000000000001060000000200000000000030030000000200000002000000000100000001000017000000636f6e736f6c653a6d6963726f766d2d76697274696f300e00000076697274696f2d636f6e736f6c651900000076697274696f2d636f6e736f6c652d333438393636393132300b00000076697274696f2d6d6d696f01000000040000006d6d696f002000d000000000001000000000000001070000000200000001000030030000000200000002000000000100000001000012000000626c6b3a73616e64626f783a64697374726f0a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393637333231360b00000076697274696f2d6d6d696f01000000040000006d6d696f003000d0000000000010000000000000010400000002000000640600300300000001000000010000000001000013000000626c6b3a73616e64626f783a736372617463680a00000076697274696f2d626c6b1500000076697274696f2d626c6b2d333438393638353530340b00000076697274696f2d6d6d696f01000000040000006d6d696f006000d0000000000010000000000000010b00000002000000440600300300000001000000010000000001000018000000636f6e736f6c653a6d6963726f766d2d636f6e74726f6c301600000076697274696f2d636f6e74726f6c2d636f6e736f6c652100000076697274696f2d636f6e74726f6c2d636f6e736f6c652d333438393638393630300b00000076697274696f2d6d6d696f01000000040000006d6d696f007000d000000000001000000000000001030000000200000001000030030000000200000002000000000100000001000001000000070000006d6963726f766d00000000004e61bc000000000000002a36fe9c9717000e5fa31c0000000e0000006c696e75782d626f6f7474696d6501010101010101010101010101010101020202020202020202020202020202020300000001c800000000000000|35f14fb1b596b299a43751b7827b7e7b88bc5cd330ff0d8eae2ee40b3d7e0fc2 # A machine of one processor and one device, and no NIC, which the invalid vectors change. -KEY|MINIMAL|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000|9adf5b7b4c5509968914b0cae4664263c768bab0e8e010616b562b0023331d64 +KEY|MINIMAL|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000|9adf5b7b4c5509968914b0cae4664263c768bab0e8e010616b562b0023331d64 # The class of the capture of TEMPLATE: the CPU profile auto, and 2 processors, all online at # boot. @@ -48,53 +52,53 @@ CLASS|TEMPLATE|0100470000007368613235363a616161616161616161616161616161616161616 CLASS|NETWORK|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d763201000000000000000d00000067756573742d6275696c642d3106000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d040000006175746f02000000020000000000002000000000000000800000000000000040000000000002000000100000000000040000000000020000000200000a000000050000002d2d6e65740b00000031302e302e302e322f3234110000002d2d6e6574776f726b2d70726f66696c6508000000706f727461626c65100000002d2d6e6574776f726b2d6567726573730400000064656e79110000002d2d6e6574776f726b2d696e67726573730400000064656e790f0000002d2d686f73742d6c6f6f706261636b0400000064656e79020000001100000069736f6c6174696f6e2e736563636f6d70060000007374726963740b0000006c696d6974732e7069647303000000353132|2f32edae7e9eb532625af600232c68aa78b92e02c8116cb49f857f13ff5fcc7c # MINIMAL in other versions of the encoding. -INVALID_KEY_VERSION|VERSION_0|0000470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 -INVALID_KEY_VERSION|VERSION_2|0200470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 -INVALID_KEY_VERSION|VERSION_65535|ffff470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY_VERSION|VERSION_0|0000470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_KEY_VERSION|VERSION_2|0200470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 +INVALID_KEY_VERSION|VERSION_65535|ffff470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # No bytes. INVALID_KEY|EMPTY| # MINIMAL without its last byte. -INVALID_KEY|TRUNCATED|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000 +INVALID_KEY|TRUNCATED|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d65000000000000000000000000000000000000000000000000000000000000000000000000 # MINIMAL and a byte after it. -INVALID_KEY|TRAILING_BYTE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d000000000000 +INVALID_KEY|TRAILING_BYTE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d650000000000000000000000000000000000000000000000000000000000000000000000000000 # A command line whose digest does not match it. -INVALID_KEY|COMMAND_LINE_DIGEST|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663300000000000000000000000000000000000000000000000000000000000000000001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|COMMAND_LINE_DIGEST|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663300000000000000000000000000000000000000000000000000000000000000000001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A backend that does not exist. -INVALID_KEY|UNKNOWN_BACKEND|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d76320100000000000000060000000000000001000000000000000100000000000000040000000000000001000000000000000300000078656e03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|UNKNOWN_BACKEND|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d76320100000000000000060000000000000001000000000000000100000000000000040000000000000001000000000000000300000078656e03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A device range of an address space that does not exist. -INVALID_KEY|UNKNOWN_ADDRESS_SPACE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e000000000100000004000000706f7274200000000000000002000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|UNKNOWN_ADDRESS_SPACE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e000000000100000004000000706f7274200000000000000002000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # An interrupt delivery mode that is not UTF-8. -INVALID_KEY|INTERRUPT_MODE_NOT_UTF8|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000013000000656467652d7368617265642dff737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|INTERRUPT_MODE_NOT_UTF8|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000013000000656467652d7368617265642dff737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A NIC whose presence tag is 2. -INVALID_KEY|PRESENCE_TAG_2|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0200000000 +INVALID_KEY|PRESENCE_TAG_2|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d02000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A NIC whose policy's enforcement is 2. -INVALID_KEY|BOOLEAN_2|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0108000000706f727461626c655254000000025254000000010a000002180a00000101fd00000000000000000000000a00000278fd00000000000000000000000a0000010a000000616c6c6f772d6c6973740707070707070707070707070707070707070707070707070707070707070707020400000000000000 +INVALID_KEY|BOOLEAN_2|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0108000000706f727461626c655254000000025254000000010a000002180a00000101fd00000000000000000000000a00000278fd00000000000000000000000a0000010a000000616c6c6f772d6c69737407070707070707070707070707070707070707070707070707070707070707070204000000000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # Invariant settings out of the order of their names. -INVALID_KEY|UNSORTED_SETTINGS|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00020000000b0000006c696d6974732e70696473030000003531321100000069736f6c6174696f6e2e736563636f6d7006000000737472696374 +INVALID_KEY|UNSORTED_SETTINGS|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00020000000b0000006c696d6974732e70696473030000003531321100000069736f6c6174696f6e2e736563636f6d70060000007374726963740000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # An invariant setting named twice. -INVALID_KEY|DUPLICATE_SETTINGS|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00020000000b0000006c696d6974732e70696473030000003531320b0000006c696d6974732e7069647303000000353132 +INVALID_KEY|DUPLICATE_SETTINGS|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00020000000b0000006c696d6974732e70696473030000003531320b0000006c696d6974732e70696473030000003531320000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # An invariant setting whose name holds an uppercase letter. -INVALID_KEY|UPPERCASE_SETTING|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00010000000b0000004c696d6974732e7069647303000000353132 +INVALID_KEY|UPPERCASE_SETTING|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00010000000b0000004c696d6974732e70696473030000003531320000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # More APIC IDs than a microVM has processors. -INVALID_KEY|TOO_MANY_APIC_IDS|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b010000000100000001000000010000000900000000000000010000000200000003000000040000000500000006000000070000000800000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|TOO_MANY_APIC_IDS|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b010000000100000001000000010000000900000000000000010000000200000003000000040000000500000006000000070000000800000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # 3 processors, which a microVM cannot have. -INVALID_KEY|THREE_PROCESSORS|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b010000000100000003000000010000000300000000000000010000000200000000ca9a3b0000000000e1f505000000000000000003000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|THREE_PROCESSORS|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b010000000100000003000000010000000300000000000000010000000200000000ca9a3b0000000000e1f505000000000000000003000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # RAM ranges that do not hold the RAM. -INVALID_KEY|MEMORY_RANGES|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000400000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|MEMORY_RANGES|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000400000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A machine without devices. -INVALID_KEY|NO_DEVICES|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|NO_DEVICES|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A device ID twice. -INVALID_KEY|DUPLICATE_DEVICE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000200000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000009000000706172746974696f6e05000000636c6f636b06000000766d74696d6500000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|DUPLICATE_DEVICE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000200000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000009000000706172746974696f6e05000000636c6f636b06000000766d74696d6500000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A filesystem slot that is not the dormant slot of version 1. -INVALID_KEY|SLOT_VERSION_0|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000000000000070000006d6963726f766d0000000000 +INVALID_KEY|SLOT_VERSION_0|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000000000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A filesystem slot without a tag. -INVALID_KEY|EMPTY_TAG|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000000000000000000000 +INVALID_KEY|EMPTY_TAG|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A distro whose logical block size is 256. -INVALID_KEY|DISTRO_BLOCK_SIZE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000010000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|DISTRO_BLOCK_SIZE|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000010000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # An OpenVMM identity that holds a space. -INVALID_KEY|ARTIFACT_WITH_SPACE|0100470000007368613235363a20616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|ARTIFACT_WITH_SPACE|0100470000007368613235363a20616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # A CPU profile of revision 0. -INVALID_KEY|CPU_PROFILE_REVISION_0|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610000000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|CPU_PROFILE_REVISION_0|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610000000011111111111111111111111111111111111111111111111111111111111111112c000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 # An effective CPUID that is not whole entries of 44 bytes. -INVALID_KEY|CPUID_LENGTH|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112b000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d0000000000 +INVALID_KEY|CPUID_LENGTH|0100470000007368613235363a61616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161616161470000007368613235363a62626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262626262470000007368613235363a636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363636363630300000000000000190000006e76782d6d6963726f766d2d76322d636f6e74726f6c2d7632010000000000000006000000000000000100000000000000010000000000000004000000000000000100000000000000030000006b766d03000000616d640500000067656e6f610100000011111111111111111111111111111111111111111111111111111111111111112b000000000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a01000000010000000100000001000000010000000000000000ca9a3b0000000000e1f505000000000000000001000000000000000000000800000000000000080000000000000008000000000100000000000000000000000000000800000000000000000c000000636f6e736f6c653d687663308c7f813b328f96bdb26c4e97b6f5c874cbd968e8664778addc56a7c81ea55113001000000000000000020000000200000010000000000000000200000002000012000000656467652d7368617265642d737461747573000003000000000000100000000000000100000009000000706172746974696f6e09000000706172746974696f6e09000000706172746974696f6e00000000000000000000000000000000000000000001000000070000006d6963726f766d00000000000000000000000000000000000000000000000000000000000e0000006c696e75782d626f6f7474696d6500000000000000000000000000000000000000000000000000000000000000000000000000 From e1f7611bda05723c8fc5bc77609aa6e779aa227d Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 12:06:43 -0800 Subject: [PATCH 24/25] Reconcile restored-init fixture with typed host results Use verified-exit presence and propagate report parsing errors in the inherited ignored native fixture. Do not execute or qualify native restore here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c688b057-b972-4b90-9ecd-91187b92311f --- nvx-rs/src/host/restore/tests/live_init.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nvx-rs/src/host/restore/tests/live_init.rs b/nvx-rs/src/host/restore/tests/live_init.rs index c8e33508d..56b1ed0e1 100644 --- a/nvx-rs/src/host/restore/tests/live_init.rs +++ b/nvx-rs/src/host/restore/tests/live_init.rs @@ -313,7 +313,7 @@ fn run() -> Result<(), AnyError> { let expected_status; if case == "deadline" { require( - process.wait(Instant::now() + Duration::from_secs(40))?, + process.wait(Instant::now() + Duration::from_secs(40))?.is_some(), "activation deadline did not end VM", )?; require( @@ -378,7 +378,7 @@ fn run() -> Result<(), AnyError> { activation[0] ^= 1; connection.send(&encode_app(ACTIVATE, 101, 0, &activation), Some(by))?; require( - process.wait(Instant::now() + Duration::from_secs(15))?, + process.wait(Instant::now() + Duration::from_secs(15))?.is_some(), "changed identity replay did not end VM", )?; expected_status = 125; @@ -481,7 +481,7 @@ printf 'RECONNECT_REPLAY_PRESERVED_UPPER_AND_MAPS\n' "STOP not acknowledged", )?; require( - process.wait(Instant::now() + Duration::from_secs(30))?, + process.wait(Instant::now() + Duration::from_secs(30))?.is_some(), "STOP did not tear down restore", )?; expected_status = 0; @@ -489,7 +489,7 @@ printf 'RECONNECT_REPLAY_PRESERVED_UPPER_AND_MAPS\n' } drop(connection); reader.join().map_err(|_| "console reader panicked")??; - let outcome = crate::host::report::read(&report).ok_or("missing native outcome")?; + let outcome = crate::host::report::read(&report)?.ok_or("missing native outcome")?; require( outcome.status_code == Some(expected_status) && outcome.teardown_complete == Some(true), format!("wrong restore outcome: {outcome:?}"), From b70d7e98de7f2ef2cb14875e00075f7149d44803 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 12:11:02 -0800 Subject: [PATCH 25/25] Format typed exit checks in joined fixture Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c688b057-b972-4b90-9ecd-91187b92311f --- nvx-rs/src/host/restore/tests/live_init.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/nvx-rs/src/host/restore/tests/live_init.rs b/nvx-rs/src/host/restore/tests/live_init.rs index 56b1ed0e1..9b958ede6 100644 --- a/nvx-rs/src/host/restore/tests/live_init.rs +++ b/nvx-rs/src/host/restore/tests/live_init.rs @@ -313,7 +313,9 @@ fn run() -> Result<(), AnyError> { let expected_status; if case == "deadline" { require( - process.wait(Instant::now() + Duration::from_secs(40))?.is_some(), + process + .wait(Instant::now() + Duration::from_secs(40))? + .is_some(), "activation deadline did not end VM", )?; require( @@ -378,7 +380,9 @@ fn run() -> Result<(), AnyError> { activation[0] ^= 1; connection.send(&encode_app(ACTIVATE, 101, 0, &activation), Some(by))?; require( - process.wait(Instant::now() + Duration::from_secs(15))?.is_some(), + process + .wait(Instant::now() + Duration::from_secs(15))? + .is_some(), "changed identity replay did not end VM", )?; expected_status = 125; @@ -481,7 +485,9 @@ printf 'RECONNECT_REPLAY_PRESERVED_UPPER_AND_MAPS\n' "STOP not acknowledged", )?; require( - process.wait(Instant::now() + Duration::from_secs(30))?.is_some(), + process + .wait(Instant::now() + Duration::from_secs(30))? + .is_some(), "STOP did not tear down restore", )?; expected_status = 0;