From 11b430db23400ecd05878af8bdff067c5c8f3180 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:05:39 -0800 Subject: [PATCH 1/7] 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 d740dd5403a6dee2176994afa5ede0a2f147ff7a Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:07:55 -0800 Subject: [PATCH 2/7] host: hand owned cold-launch commands to callers Move checked launch values and owned resources into VmmCommand without reopening them. Add standard owned-handle conversions and the compiled external cold-launch consumer, retaining the current merged preparation and spawn behavior. Expose the existing status-bearing launch wait for host-only callers and retain timeout, actual status and repeated-wait coverage. Public API snapshots will be generated during remote qualification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6789d491-7d13-4929-bc64-4edfec93ba60 --- nvx-rs/Cargo.toml | 5 + nvx-rs/README.md | 23 ++- nvx-rs/examples/external-launch.rs | 174 ++++++++++++++++++++++ nvx-rs/src/host/platform/windows/spawn.rs | 10 +- nvx-rs/src/host/spawn.rs | 24 ++- nvx-rs/src/host/vm.rs | 132 +++++++++++++++- nvx-rs/src/host/vm/tests.rs | 99 +++++++++++- nvx-rs/tests/checked_handoff.rs | 49 ++++++ 8 files changed, 495 insertions(+), 21 deletions(-) create mode 100644 nvx-rs/examples/external-launch.rs diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index 804ce2173..131b63937 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -132,6 +132,11 @@ path = "src/bin/nvx-rs.rs" required-features = ["cli"] doc = false +[[example]] +name = "external-launch" +path = "examples/external-launch.rs" +required-features = ["host"] + # The guest binaries. Their unit tests live in the library, so the binaries have no test harness. [[bin]] name = "nvx-console-pending" diff --git a/nvx-rs/README.md b/nvx-rs/README.md index cd9c6f7ca..689f59d99 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, owned cold-launch command, verified detached launch, 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 | @@ -517,7 +517,7 @@ these names before creating any output, including the log. `Prepared::capability()` and `outputs()` return what the caller may journal before a spawn. `log()` returns the same opened log, without reopening its -path. `inherit(file)` moves an already opened resource into the preparation's +path. `inherit(resource)` moves an already opened resource into the preparation's inheritance allowlist and keeps it open. `arguments()` returns the whole configuration's OpenVMM arguments. Creation makes caller-relative runtime and block-image paths absolute without resolving links or `..` components, @@ -529,6 +529,25 @@ directory and runtime inputs stable through creation and a later spawn. Dropping a successful preparation closes its resources but leaves its output paths owned by the caller. +`Prepared::into_command()` moves the complete launch into `VmmCommand`: its +program, arguments, working directory, capability, outputs, inherited resources, +opened log and Windows breakaway setting. It neither reopens nor duplicates +resources, renders again nor rediscovers paths. `InheritedHandle` owns its +resource, converts from and back to `OwnedFd` on Unix or `OwnedHandle` on Windows, +and also accepts an opened `File`. The command documents the caller's spawn +contract, including standard streams, allowlisted inheritance, Linux detachment +and umask, Windows log job and breakaway, and cleanup of a failed child. + +The compiled [external-launch example](examples/external-launch.rs) consumes +this command, uses the existing public low-level spawn primitive without a +sandbox store, checks the cold guest's boot marker and shell response, then asks +it to exit cleanly. It requires the VMM's actual exit status 0 and a successful +outcome with complete teardown. Build with +`cargo build --locked --example external-launch` and supply the backend, OpenVMM, +kernel, initramfs, new VM directory, and the three artifact SHA-256 digests from +their installation metadata, in that order. Artifact paths may be relative to +the caller's directory. + `vm::spawn(prepared)` consumes that preparation and returns `Launched` with its verified `process`, the same `outputs`, and the same `capability`. The log and inherited resources are passed as already opened objects, never reopened diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs new file mode 100644 index 000000000..3a10480c1 --- /dev/null +++ b/nvx-rs/examples/external-launch.rs @@ -0,0 +1,174 @@ +//! A caller-owned cold launch, using the existing low-level spawn primitive without a store. +//! +//! Run the compiled example with a backend, OpenVMM, kernel, initramfs, new VM directory, and +//! the three artifact SHA-256 digests from their installation metadata, in that order. + +use std::error::Error; +use std::io::{self, Write}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use nvx::Hypervisor; +use nvx::activation::{MachineIdMode, View}; +use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; +use nvx::host::platform::Transport; +use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; +use nvx::host::supervisor::{self, Inherited}; +use nvx::host::{artifacts, report, session, vm}; + +fn main() -> Result<(), Box> { + let args: Vec<_> = std::env::args_os().collect(); + if args.len() != 9 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "usage: external-launch BACKEND OPENVMM KERNEL INITRAMFS VM-DIR \ + OPENVMM-SHA256 KERNEL-SHA256 INITRAMFS-SHA256", + ) + .into()); + } + let hypervisor = match args[1].to_str() { + Some("kvm") => Hypervisor::Kvm, + Some("mshv") => Hypervisor::Mshv, + Some("whp") => Hypervisor::Whp, + _ => return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid backend").into()), + }; + nvx::host::runtime::probe_hypervisor(hypervisor)?; + let identity = |index: usize| { + ArtifactIdentity::sha256(args[index].to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "artifact digest is not Unicode", + ) + })?) + .map_err(Box::::from) + }; + let runtime = Runtime { + openvmm: artifacts::absolute(Path::new(&args[2]))?, + kernel: artifacts::absolute(Path::new(&args[3]))?, + initramfs: artifacts::absolute(Path::new(&args[4]))?, + identity: RuntimeIdentity::new(identity(6)?, identity(7)?, identity(8)?), + hypervisor, + cpu_profile: Some("auto".to_owned()), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let config = VmConfig { + processors: 1, + memory_mib: 256, + root: RootConfig::Blockless, + view: View { + hostname: b"external".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: "quiet loglevel=0".to_owned(), + }), + }; + vm::create_private_dir(Path::new(&args[5]))?; + let dir = vm::VmDir::open(Path::new(&args[5]))?; + let vm::VmmCommand { + program, + arguments, + working_dir, + capability, + outputs, + inherit, + log, + breakaway_from_job, + } = vm::create(&runtime, &config, None, &dir)?.into_command(); + let command = supervisor::VmmCommand { + program, + arguments, + working_dir, + breakaway_from_job, + inherit: inherit.iter().map(Inherited::new).collect(), + }; + let mut launched = supervisor::spawn(&command, capability.as_bytes(), log)?; + let result = (|| -> Result<(), Box> { + let process = launched.process()?; + let deadline = Instant::now() + Duration::from_secs(60); + let console = outputs.console.as_deref().ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidInput, "no prepared boot console") + })?; + let mut console = session::connect( + console, + process.reference().pid, + process.reference().start_time, + deadline, + )?; + read_until(console.as_mut(), b"NVX-GUEST-BOOT-OK:", deadline)?; + console.write_all( + b"printf 'NVX-EXTERNAL-LAUNCH-OK\\n'; /sbin/nvx-exit 0\n", + Some(deadline.saturating_duration_since(Instant::now())), + )?; + read_until(console.as_mut(), b"\nNVX-EXTERNAL-LAUNCH-OK", deadline)?; + if !process.wait(deadline)? { + return Err(io::Error::new(io::ErrorKind::TimedOut, "VM did not exit").into()); + } + match launched.wait_timeout(Duration::ZERO)? { + Some(0) => {} + Some(status) => { + return Err(io::Error::other(format!("VMM exited with status {status}")).into()); + } + None => return Err(io::Error::other("VMM exit status is unavailable").into()), + } + let outcome = report::read(&outputs.outcome) + .ok_or_else(|| io::Error::other("missing or invalid VM outcome"))?; + if outcome.category.as_deref() != Some("success") + || outcome.status_code != Some(0) + || outcome.teardown_complete != Some(true) + { + return Err( + io::Error::other(format!("VM did not shut down cleanly: {outcome:?}")).into(), + ); + } + Ok(()) + })(); + if let Err(error) = result { + if !launched.kill_and_wait() { + return Err( + io::Error::other(format!("{error}; could not reap the launched VM")).into(), + ); + } + return Err(error); + } + launched.detach(); + println!( + "external cold launch completed; outcome={}", + outputs.outcome.display() + ); + Ok(()) +} + +/// Prints console output through a marker, retaining only enough bytes to match across reads. +fn read_until(console: &mut dyn Transport, marker: &[u8], deadline: Instant) -> io::Result<()> { + let mut pending = Vec::new(); + let mut buffer = [0; 4096]; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "console marker not received", + )); + } + let count = console.read(&mut buffer, Some(remaining))?; + if count == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "boot console closed", + )); + } + io::stdout().write_all(&buffer[..count])?; + pending.extend_from_slice(&buffer[..count]); + if pending.windows(marker.len()).any(|bytes| bytes == marker) { + return Ok(()); + } + if pending.len() > marker.len() { + pending.drain(..pending.len() - marker.len()); + } + } +} diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index e89348406..dfe398380 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -11,26 +11,27 @@ use std::{mem, ptr}; #[cfg(feature = "cli")] use windows_sys::Win32::Foundation::{ - DUPLICATE_SAME_ACCESS, DuplicateHandle, INVALID_HANDLE_VALUE, WAIT_TIMEOUT, + DUPLICATE_SAME_ACCESS, DuplicateHandle, INVALID_HANDLE_VALUE, }; use windows_sys::Win32::Foundation::{ GetHandleInformation, HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, + WAIT_TIMEOUT, }; #[cfg(feature = "cli")] use windows_sys::Win32::System::Console::{ GetStdHandle, STD_ERROR_HANDLE, STD_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, }; +#[cfg(feature = "cli")] +use windows_sys::Win32::System::Threading::GetCurrentProcess; use windows_sys::Win32::System::Threading::{ CREATE_BREAKAWAY_FROM_JOB, CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW, DeleteProcThreadAttributeList, - EXTENDED_STARTUPINFO_PRESENT, INFINITE, InitializeProcThreadAttributeList, + EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess, INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_JOB_LIST, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }; -#[cfg(feature = "cli")] -use windows_sys::Win32::System::Threading::{GetCurrentProcess, GetExitCodeProcess}; #[cfg(feature = "cli")] use super::process::refused_termination; @@ -99,7 +100,6 @@ impl LaunchedProcess { /// Waits up to `timeout` for the process to exit, and returns its exit status if it has, /// as `Popen.wait` reads it with `GetExitCodeProcess`. - #[cfg(feature = "cli")] pub(crate) fn wait_timeout(&self, timeout: Duration) -> io::Result> { let milliseconds = u32::try_from(timeout.as_millis()).unwrap_or(INFINITE - 1); // SAFETY: CreateProcessW returned the handle with full access. diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index fd71827ab..f767b756a 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -10,7 +10,6 @@ use std::path::PathBuf; use std::process::{Child, Command, Stdio}; #[cfg(not(windows))] use std::thread; -#[cfg(feature = "cli")] use std::time::Duration; #[cfg(not(windows))] use std::time::Instant; @@ -173,8 +172,9 @@ fn spawn_inner( } /// A VMM that [`spawn`] started, which the caller has not released yet: on Unix, its child -/// process, which the caller has not reaped, so that its ID names no other process; on Windows, -/// its process handle. +/// process; on Windows, its process handle. On Unix the child's ID names no other process until +/// [`wait_timeout`](Self::wait_timeout) reaps it; hold it with [`process`](Self::process) before +/// waiting to keep a verified handle after it exits. /// /// Record it with [`Launch::record_process`](crate::host::store::Launch::record_process), or hold /// it with [`process`](Self::process). Then release it with [`detach`](Self::detach), or end it @@ -311,8 +311,10 @@ impl Launched { /// Waits up to `timeout` for the process to exit, and returns its status if it has, as /// `Popen.wait` and `Popen.poll` report it: the exit code, or on Unix the negated number of /// the signal that ended the process. - #[cfg(feature = "cli")] - pub(crate) fn wait_timeout(&mut self, timeout: Duration) -> io::Result> { + /// + /// On Unix a successful wait reaps the child; call [`process`](Self::process) before it to + /// retain a verified process handle. Later waits return the same status. + pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result> { #[cfg(windows)] return Ok(self.process.wait_timeout(timeout)?.map(i64::from)); #[cfg(not(windows))] @@ -333,7 +335,7 @@ impl Launched { } /// Reaps the child if it has exited, and returns its status if it has. - #[cfg(all(feature = "cli", not(windows)))] + #[cfg(not(windows))] fn try_reap(&mut self) -> io::Result> { let status = self.child.try_wait()?; self.reaped |= status.is_some(); @@ -376,11 +378,17 @@ impl Launched { } /// Returns `Popen.returncode` for `status`: the exit code, or the negated signal number. -#[cfg(all(feature = "cli", not(windows)))] +#[cfg(not(windows))] fn status_value(status: std::process::ExitStatus) -> i64 { + #[cfg(unix)] use std::os::unix::process::ExitStatusExt; - match (status.code(), status.signal()) { + #[cfg(unix)] + let signal = status.signal(); + #[cfg(not(unix))] + let signal: Option = None; + + match (status.code(), signal) { (Some(code), _) => i64::from(code), (None, Some(signal)) => -i64::from(signal), (None, None) => 0, diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 1429a7a26..d52014658 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -27,7 +27,7 @@ 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::runtime::{Runtime, RuntimeIdentity}; use crate::host::snapshot::Snapshot; -use crate::host::spawn::{self, VmmCommand}; +use crate::host::spawn; use crate::host::transport::{self, HostConnection}; use crate::host::{platform::inherit::Inherited, process::Process, process::ProcessRef}; use crate::protocol::CAPABILITY_LEN; @@ -285,6 +285,110 @@ pub fn reattach( }) } +/// An owned descriptor on Unix. +#[cfg(unix)] +type Owned = std::os::fd::OwnedFd; +/// An owned handle on Windows. +#[cfg(windows)] +type Owned = std::os::windows::io::OwnedHandle; +/// An owned file on hosts without descriptors or handles. +#[cfg(not(any(unix, windows)))] +type Owned = File; + +/// An already opened resource that a VMM inherits, owned until dropped or transferred. +/// +/// Conversions move the same descriptor or handle; they neither duplicate nor reopen it. +/// Borrowed raw descriptors and handles cannot transfer ownership into this type. +#[derive(Debug)] +pub struct InheritedHandle { + /// The owned platform resource. + handle: Owned, +} + +impl From for InheritedHandle { + fn from(file: File) -> Self { + Self { + handle: file.into(), + } + } +} + +#[cfg(unix)] +impl From for InheritedHandle { + fn from(handle: std::os::fd::OwnedFd) -> Self { + Self { handle } + } +} + +#[cfg(unix)] +impl From for std::os::fd::OwnedFd { + fn from(resource: InheritedHandle) -> Self { + resource.handle + } +} + +#[cfg(unix)] +impl std::os::fd::AsFd for InheritedHandle { + fn as_fd(&self) -> std::os::fd::BorrowedFd<'_> { + std::os::fd::AsFd::as_fd(&self.handle) + } +} + +#[cfg(windows)] +impl From for InheritedHandle { + fn from(handle: std::os::windows::io::OwnedHandle) -> Self { + Self { handle } + } +} + +#[cfg(windows)] +impl From for std::os::windows::io::OwnedHandle { + fn from(resource: InheritedHandle) -> Self { + resource.handle + } +} + +#[cfg(windows)] +impl std::os::windows::io::AsHandle for InheritedHandle { + fn as_handle(&self) -> std::os::windows::io::BorrowedHandle<'_> { + std::os::windows::io::AsHandle::as_handle(&self.handle) + } +} + +/// A complete cold launch handed to the caller's launcher, with all its resources owned. +/// +/// The launcher supplies the capability on stdin through a pipe closed to writers before +/// spawning, and uses the opened log for stdout and stderr. It allows only the standard streams +/// and `inherit`, under their original descriptor or handle numbers, into the child. +/// +/// Follow [`spawn`]'s launch contract: detach on Linux, clear only the child's owner umask bits, +/// and keep allowed descriptors close-on-exec in the caller; on Windows, use an explicit handle +/// list, restore temporary inheritability, start without a console in the log-named job, +/// refuse a taken or untrusted job name, and honor `breakaway_from_job`. Windows callers +/// coordinate their other spawns with the inheritance window. If setup fails after spawning, +/// terminate and reap only that child before returning an error. The caller journals recovery +/// data and keeps the VM directory and runtime inputs stable; this value starts no process or +/// listener. +#[derive(Debug)] +pub struct VmmCommand { + /// The checked OpenVMM executable. + pub program: PathBuf, + /// The complete, checked arguments, without the executable. + pub arguments: Vec, + /// The VM directory in which OpenVMM runs. + pub working_dir: PathBuf, + /// The control credential, never written to disk by the library. + pub capability: Capability, + /// The already chosen output files and endpoints. + pub outputs: VmOutputs, + /// Owned resources allowed into the child besides its standard streams. + pub inherit: Vec, + /// The same private log that preparation opened. + pub log: File, + /// Whether Windows leaves the caller's job, which must permit breakaway. + pub breakaway_from_job: bool, +} + /// A checked cold launch, with its capability, chosen outputs, and already opened log. /// /// Creation starts no process and binds no listener. The caller keeps the VM directory and @@ -307,7 +411,7 @@ pub struct Prepared { /// The complete arguments, checked against the actual runtime and output paths. arguments: Vec, /// Already opened resources added by the caller, retained until the preparation is dropped. - inherit: Vec, + inherit: Vec, } impl Prepared { @@ -324,8 +428,8 @@ impl Prepared { /// Adds an already opened resource to the inheritance allowlist, transferring ownership. /// /// The resource is not reopened, and remains open for the lifetime of this preparation. - pub fn inherit(&mut self, file: File) { - self.inherit.push(file); + pub fn inherit(&mut self, resource: impl Into) { + self.inherit.push(resource.into()); } /// Returns the same opened log that creation made, for example to take a liveness claim. @@ -337,6 +441,24 @@ impl Prepared { pub fn arguments(&self) -> &[OsString] { &self.arguments } + + /// Transfers the complete checked launch to the caller's launcher. + /// + /// Moves the same opened log and inherited resources, without cloning, reopening, rendering + /// arguments again or rediscovering paths. Starts no process or listener. The caller follows + /// [`VmmCommand`]'s launch contract and may retain its capability and outputs for recovery. + pub fn into_command(self) -> VmmCommand { + VmmCommand { + program: self.program, + arguments: self.arguments, + working_dir: self.working_dir, + capability: self.capability, + outputs: self.outputs, + inherit: self.inherit, + log: self.log, + breakaway_from_job: self.windows_breakaway, + } + } } /// Creates an inspectable cold launch in `dir`, without starting a process or binding endpoints. @@ -537,7 +659,7 @@ fn spawn_verified( arguments, inherit, } = prepared; - let command = VmmCommand { + let command = spawn::VmmCommand { program, arguments, working_dir, diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index 05af49bd0..d9bb2cb24 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -723,11 +723,108 @@ fn the_log_and_inherited_resources_stay_open_without_reopening_their_paths() { prepared.inherit(file); fs::rename(&input, &moved_input).unwrap(); fs::write(&input, b"replacement input").unwrap(); - prepared.inherit[0].write_all(b"original input").unwrap(); + inherited_file(prepared.inherit.pop().unwrap()) + .write_all(b"original input") + .unwrap(); assert_eq!(fs::read(&moved_input).unwrap(), b"original input"); assert_eq!(fs::read(&input).unwrap(), b"replacement input"); } +fn inherited_file(resource: InheritedHandle) -> File { + #[cfg(any(unix, windows))] + return File::from(Owned::from(resource)); + #[cfg(not(any(unix, windows)))] + resource.handle +} + +#[test] +fn a_command_moves_the_checked_launch_and_the_same_opened_resources() { + use std::io::Write; + + let (_directory, vm) = vm_dir(); + let mut runtime = cold_runtime(); + runtime.windows_breakaway = true; + let mut prepared = create(&runtime, &cold_config(), None, &vm).unwrap(); + let program = absolute(&runtime.openvmm).unwrap(); + let arguments = prepared.arguments().as_ptr(); + let capability = *prepared.capability().as_bytes(); + let outputs = prepared.outputs().clone(); + let original_log = vm.path().join("original-log"); + fs::rename(&outputs.log, &original_log).unwrap(); + fs::write(&outputs.log, b"replacement log").unwrap(); + let input = vm.path().join("input"); + let original_input = vm.path().join("original-input"); + prepared.inherit(File::create(&input).unwrap()); + let resources = prepared.inherit.as_ptr(); + fs::rename(&input, &original_input).unwrap(); + fs::write(&input, b"replacement input").unwrap(); + #[cfg(unix)] + let log_handle = std::os::fd::AsRawFd::as_raw_fd(prepared.log()); + #[cfg(windows)] + let log_handle = std::os::windows::io::AsRawHandle::as_raw_handle(prepared.log()); + runtime.openvmm = "different-program".into(); + runtime.windows_breakaway = false; + + let mut command = prepared.into_command(); + assert_eq!(command.program, program); + assert_eq!(command.working_dir, vm.path()); + assert!(command.breakaway_from_job); + assert_eq!(command.arguments.as_ptr(), arguments); + assert_eq!(command.inherit.as_ptr(), resources); + assert_eq!(command.capability.as_bytes(), &capability); + assert_eq!(command.outputs, outputs); + #[cfg(unix)] + assert_eq!(std::os::fd::AsRawFd::as_raw_fd(&command.log), log_handle); + #[cfg(windows)] + assert_eq!( + std::os::windows::io::AsRawHandle::as_raw_handle(&command.log), + log_handle + ); + command.log.write_all(b"original log").unwrap(); + inherited_file(command.inherit.pop().unwrap()) + .write_all(b"original input") + .unwrap(); + assert_eq!(fs::read(original_log).unwrap(), b"original log"); + assert_eq!(fs::read(original_input).unwrap(), b"original input"); + assert_eq!(fs::read(&outputs.log).unwrap(), b"replacement log"); + assert_eq!(fs::read(input).unwrap(), b"replacement input"); + drop(command); + assert!(outputs.log.exists()); + assert!(!outputs.outcome.exists()); +} + +#[cfg(unix)] +#[test] +fn an_owned_descriptor_moves_into_and_out_of_an_inherited_handle() { + use std::io::Write; + use std::os::fd::{AsFd, AsRawFd, OwnedFd}; + + let file = tempfile::tempfile().unwrap(); + let number = file.as_raw_fd(); + let descriptor: OwnedFd = file.into(); + let resource: InheritedHandle = descriptor.into(); + assert_eq!(resource.as_fd().as_raw_fd(), number); + let descriptor: OwnedFd = resource.into(); + assert_eq!(descriptor.as_raw_fd(), number); + File::from(descriptor).write_all(b"still open").unwrap(); +} + +#[cfg(windows)] +#[test] +fn an_owned_windows_handle_moves_into_and_out_of_an_inherited_handle() { + use std::io::Write; + use std::os::windows::io::{AsHandle, AsRawHandle, OwnedHandle}; + + let file = tempfile::tempfile().unwrap(); + let number = file.as_raw_handle(); + let handle: OwnedHandle = file.into(); + let resource: InheritedHandle = handle.into(); + assert_eq!(resource.as_handle().as_raw_handle(), number); + let handle: OwnedHandle = resource.into(); + assert_eq!(handle.as_raw_handle(), number); + File::from(handle).write_all(b"still open").unwrap(); +} + #[test] fn creation_checks_the_complete_actual_command_line_at_the_windows_boundary() { let (_directory, vm) = vm_dir(); diff --git a/nvx-rs/tests/checked_handoff.rs b/nvx-rs/tests/checked_handoff.rs index b07a9e340..0875857f2 100644 --- a/nvx-rs/tests/checked_handoff.rs +++ b/nvx-rs/tests/checked_handoff.rs @@ -179,6 +179,55 @@ fn fixture_arguments(name: &str) -> Vec { .to_vec() } +/// A VMM fixture that exits with the requested status once the caller lets it go. +#[test] +#[ignore = "fixture that the exit-status test runs as a VMM"] +fn exit_status_fixture() { + let directory = env::current_dir().unwrap(); + if !directory.join(VMM_MARKER).exists() { + return; + } + let mut capability = [0; CAPABILITY_LEN]; + io::stdin().read_exact(&mut capability).unwrap(); + assert_eq!(capability, CAPABILITY); + let deadline = Instant::now() + VMM_LIFETIME; + while !directory.join(GO_MARKER).exists() { + if directory.join(EXIT_MARKER).exists() || Instant::now() >= deadline { + return; + } + thread::sleep(Duration::from_millis(5)); + } + let status = fs::read_to_string(directory.join("exit-status")).unwrap(); + std::process::exit(status.parse().unwrap()); +} + +#[test] +fn launched_waits_return_and_retain_the_actual_exit_status() { + for status in [0, 17] { + let mut vmm = VmmDirectory::new(); + fs::write(vmm.path().join("exit-status"), status.to_string()).unwrap(); + let command = VmmCommand::new( + env::current_exe().unwrap(), + fixture_arguments("exit_status_fixture"), + vmm.path(), + ); + let log = File::create(vmm.path().join("vmm.log")).unwrap(); + vmm.launched = Some(supervisor::spawn(&command, &CAPABILITY, log).unwrap()); + assert_eq!( + vmm.launched + .as_mut() + .unwrap() + .wait_timeout(Duration::ZERO) + .unwrap(), + None + ); + File::create(vmm.path().join(GO_MARKER)).unwrap(); + let launched = vmm.launched.as_mut().unwrap(); + assert_eq!(launched.wait_timeout(TIMEOUT).unwrap(), Some(status)); + assert_eq!(launched.wait_timeout(Duration::ZERO).unwrap(), Some(status)); + } +} + /// Returns the arguments that pass a VMM fixture the `--microvm-sandbox-block` values of /// `prepared`. fn block_arguments(prepared: &PreparedLaunch) -> Vec { From 123e93ed1c3fd696156b92448cf2efd547cc7f99 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:29:06 -0800 Subject: [PATCH 3/7] host: reconcile the external example and API snapshots Initialize the merged cold-boot capture option explicitly for the external launcher. Record the platform public API snapshots generated from the private source checkpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6789d491-7d13-4929-bc64-4edfec93ba60 --- nvx-rs/examples/external-launch.rs | 1 + nvx-rs/public-api/linux.txt | 12 ++++++++++++ nvx-rs/public-api/windows.txt | 12 ++++++++++++ 3 files changed, 25 insertions(+) diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index 3a10480c1..b62daeb04 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -65,6 +65,7 @@ fn main() -> Result<(), Box> { console: ConsoleAttachment::Listener, boot: Some(Boot { command_line: "quiet loglevel=0".to_owned(), + capture: None, }), }; vm::create_private_dir(Path::new(&args[5]))?; diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 6a422c570..9dca9ba3e 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3259,6 +3259,7 @@ method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process +method nvx::host::supervisor::Launched::wait_timeout enum nvx::host::supervisor::Reattached variant nvx::host::supervisor::Reattached::Exited field nvx::host::supervisor::Reattached::Exited::record @@ -3749,6 +3750,7 @@ fn nvx::host::transport::connect_verified mod nvx::host::vm struct nvx::host::vm::Capability method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::InheritedHandle struct nvx::host::vm::Launched field nvx::host::vm::Launched::capability field nvx::host::vm::Launched::outputs @@ -3757,6 +3759,7 @@ struct nvx::host::vm::Prepared method nvx::host::vm::Prepared::arguments method nvx::host::vm::Prepared::capability method nvx::host::vm::Prepared::inherit +method nvx::host::vm::Prepared::into_command method nvx::host::vm::Prepared::log method nvx::host::vm::Prepared::outputs struct nvx::host::vm::Reattached @@ -3776,6 +3779,15 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +struct nvx::host::vm::VmmCommand +field nvx::host::vm::VmmCommand::arguments +field nvx::host::vm::VmmCommand::breakaway_from_job +field nvx::host::vm::VmmCommand::capability +field nvx::host::vm::VmmCommand::inherit +field nvx::host::vm::VmmCommand::log +field nvx::host::vm::VmmCommand::outputs +field nvx::host::vm::VmmCommand::program +field nvx::host::vm::VmmCommand::working_dir fn nvx::host::vm::create fn nvx::host::vm::create_private_dir fn nvx::host::vm::reattach diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 9aa7bf0f8..335bf218b 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1844,6 +1844,7 @@ method nvx::host::supervisor::Launched::detach method nvx::host::supervisor::Launched::id method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process +method nvx::host::supervisor::Launched::wait_timeout enum nvx::host::supervisor::Reattached variant nvx::host::supervisor::Reattached::Exited field nvx::host::supervisor::Reattached::Exited::record @@ -2334,6 +2335,7 @@ fn nvx::host::transport::connect_verified mod nvx::host::vm struct nvx::host::vm::Capability method nvx::host::vm::Capability::as_bytes +struct nvx::host::vm::InheritedHandle struct nvx::host::vm::Launched field nvx::host::vm::Launched::capability field nvx::host::vm::Launched::outputs @@ -2342,6 +2344,7 @@ struct nvx::host::vm::Prepared method nvx::host::vm::Prepared::arguments method nvx::host::vm::Prepared::capability method nvx::host::vm::Prepared::inherit +method nvx::host::vm::Prepared::into_command method nvx::host::vm::Prepared::log method nvx::host::vm::Prepared::outputs struct nvx::host::vm::Reattached @@ -2361,6 +2364,15 @@ field nvx::host::vm::VmOutputs::console field nvx::host::vm::VmOutputs::endpoint field nvx::host::vm::VmOutputs::log field nvx::host::vm::VmOutputs::outcome +struct nvx::host::vm::VmmCommand +field nvx::host::vm::VmmCommand::arguments +field nvx::host::vm::VmmCommand::breakaway_from_job +field nvx::host::vm::VmmCommand::capability +field nvx::host::vm::VmmCommand::inherit +field nvx::host::vm::VmmCommand::log +field nvx::host::vm::VmmCommand::outputs +field nvx::host::vm::VmmCommand::program +field nvx::host::vm::VmmCommand::working_dir fn nvx::host::vm::create fn nvx::host::vm::create_private_dir fn nvx::host::vm::reattach From dc669fae0b56615a17650c6e46849f85be474275 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 02:29:08 -0800 Subject: [PATCH 4/7] 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 5/7] 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 797d300557e54644f0a8e66ba729de0dcf1884fb Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 03:43:25 -0800 Subject: [PATCH 6/7] 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 df8ddc4899adceadf42a53f5b3677e1f084a7e75 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:27:02 -0800 Subject: [PATCH 7/7] host: check snapshot restore compatibility Check requested VM geometry, captured attachments, runtime identity and restore-time eligibility against retained snapshot metadata. Expose the checked NIC address without freezing the restore network policy or reopening snapshot entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6789d491-7d13-4929-bc64-4edfec93ba60 --- nvx-rs/README.md | 30 +- nvx-rs/public-api/linux.txt | 2 + nvx-rs/public-api/windows.txt | 2 + nvx-rs/src/host/snapshot.rs | 213 +++++++++- nvx-rs/src/host/snapshot/key.rs | 15 +- nvx-rs/src/host/snapshot/manifest.rs | 13 +- nvx-rs/src/host/snapshot/tests.rs | 572 +++++++++++++++++++++++++++ 7 files changed, 831 insertions(+), 16 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index c432b148f..e1f81e226 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) | Strict private snapshot directories, verified keys and manifests, time eligibility, and the input of a future VM restore | +| [`nvx::host::snapshot`](#nvxhostsnapshot) | Strict private snapshot directories, verified metadata, VM compatibility, captured NIC addresses, 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 | @@ -586,6 +586,34 @@ 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. +Opening also checks that a captured NIC address fits `NicConfig`, refusing +its network, gateway and broadcast addresses with `Io`, and caches that +checked value for the infallible `nic` accessor. + +`Snapshot::accepts(&config, &runtime, &now)` checks a layered restore against +the captured boot resources and capacity. Processor counts are 1, 2, 4 or 8; +an encoded boot count of zero means all capacity processors are already +online. Memory is between boot and capacity, in multiples of 128 MiB, with +checked conversion from MiB to bytes. The image fits the placeholder and +keeps its logical and physical block sizes; a scratch upper matches the +dormant scratch's complete geometry. These checks read the requested disks' +real metadata, not the snapshot entries again. + +NIC presence must match, and an explicitly named address equals the captured +one; an unnamed address inherits it. `Snapshot::nic` returns only the captured +presence and address as an `Option`, never a network policy. +Each restore supplies its own policy. The boot console retains its captured +attachment policy: `recreate-listener` for a listener, or +`discard-while-disconnected` for a disconnected console. A mismatched, +missing or unknown policy is refused. These facts come from the same +validated manifest parse; the control listener and console device do not +prove that attachment. The runtime uses the same backend and captured CPU +profile, and has the same runtime identity. An omitted profile or `auto` +names the snapshot's profile; `host` also names a captured host-derived +profile. Requested disks must be regular files; on Linux, the geometry +check refuses a FIFO without waiting for its writer. +Compatibility refusals are `Unsupported`; an ineligible time contract at +the supplied `HostTime` is `Unavailable`. The caller keeps the directory and every entry private and immutable while using the snapshot; opening checks them once and retains no file handles diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index f134e992e..9d9e2710e 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -3082,8 +3082,10 @@ 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::accepts method nvx::host::snapshot::Snapshot::key method nvx::host::snapshot::Snapshot::manifest +method nvx::host::snapshot::Snapshot::nic method nvx::host::snapshot::Snapshot::open struct nvx::host::snapshot::SnapshotKey const nvx::host::snapshot::SnapshotKey::MAX_LEN diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 375201d56..2a7450f57 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1668,8 +1668,10 @@ 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::accepts method nvx::host::snapshot::Snapshot::key method nvx::host::snapshot::Snapshot::manifest +method nvx::host::snapshot::Snapshot::nic method nvx::host::snapshot::Snapshot::open struct nvx::host::snapshot::SnapshotKey const nvx::host::snapshot::SnapshotKey::MAX_LEN diff --git a/nvx-rs/src/host/snapshot.rs b/nvx-rs/src/host/snapshot.rs index 7fcb88f6e..f51ecbab2 100644 --- a/nvx-rs/src/host/snapshot.rs +++ b/nvx-rs/src/host/snapshot.rs @@ -7,6 +7,7 @@ //! [`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`]. +//! [`Snapshot::accepts`] checks a requested VM against that verified metadata. //! //! # Canonical encoding //! @@ -40,9 +41,14 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use crate::host::config::{ + ConsoleAttachment, NicConfig, PROCESSOR_COUNTS, RootConfig, UpperConfig, VmConfig, +}; use crate::host::paths::{absolute, normalized}; +use crate::host::runtime::Runtime; use crate::host::store::regular; -use crate::host::{filesystem, platform}; +use crate::host::{filesystem, platform, sandbox, seal}; +use crate::net::Ipv4Cidr; use crate::{Error, Result}; pub use self::eligibility::{Downtime, DowntimeSource, Eligibility, HostTime, MAX_DOWNTIME_NS}; @@ -74,6 +80,8 @@ pub struct Snapshot { key: SnapshotKey, /// The strictly parsed metadata of OpenVMM's snapshot. manifest: SnapshotManifest, + /// The captured NIC's checked address, without a network policy. + nic: Option, } impl Snapshot { @@ -91,8 +99,8 @@ impl Snapshot { /// 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. + /// entry, length, key relationship or captured NIC address, 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])?; @@ -128,7 +136,26 @@ impl Snapshot { .map_err(|error| entry_error(&path, error))?; let key = SnapshotKey::from_bytes(&bytes)?; key.check_manifest(&manifest)?; - Ok(Self { dir, key, manifest }) + let nic = manifest + .machine + .nic + .as_ref() + .map(|nic| { + Ipv4Cidr::new(nic.ipv4, nic.prefix_length) + .map(|guest_network| NicConfig { guest_network }) + .map_err(|problem| { + Error::io(format!( + "the snapshot's captured NIC address is invalid: {problem}" + )) + }) + }) + .transpose()?; + Ok(Self { + dir, + key, + manifest, + nic, + }) } /// Returns the verified snapshot key. @@ -140,6 +167,184 @@ impl Snapshot { pub fn manifest(&self) -> &SnapshotManifest { &self.manifest } + + /// Returns the captured NIC's presence and checked address, without a network policy. + /// + /// An unnamed restore address inherits this value. This accessor reads no snapshot entry. + pub fn nic(&self) -> Option { + self.nic + } + + /// Checks whether this snapshot can restore `config` under `runtime` at `now`. + /// + /// Requires a layered root, processors from the effective boot count to capacity in + /// counts of 1, 2, 4 or 8, and memory from boot to capacity in multiples of 128 MiB. + /// An encoded boot processor count of zero means the entire capacity is already online. + /// The image must fit the placeholder with the same logical and physical block sizes; + /// a scratch upper must have the dormant scratch's complete geometry. + /// + /// NIC presence must match. A named address must equal the captured address; an unnamed + /// one inherits it. Network policies are not compared. The boot console must keep its + /// captured attachment policy: `recreate-listener` for a listener, or + /// `discard-while-disconnected` for a disconnected console. Neither its device nor the + /// control console proves this attachment. The runtime must use the captured backend and + /// CPU profile, and have the same runtime identity. An omitted profile or `auto` names + /// the captured profile; `host` also names it when that profile is host-derived. + /// + /// Reads only the requested image and scratch metadata, never the snapshot entries again. + /// The caller keeps the snapshot private and immutable, as [`open`](Self::open) requires. + /// + /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a compatibility + /// refusal, with [`ErrorKind::Unavailable`](crate::ErrorKind::Unavailable) for an ineligible + /// time contract, and with [`ErrorKind::Io`](crate::ErrorKind::Io) for disk I/O failures. + pub fn accepts(&self, config: &VmConfig, runtime: &Runtime, now: &HostTime) -> Result<()> { + config.check()?; + let RootConfig::Layered(root) = &config.root else { + return Err(Error::unsupported( + "a snapshot restore requires a layered root", + )); + }; + let capacity = self.key.processors(); + let boot = match self.key.boot_processors() { + 0 => capacity, + boot => boot, + }; + if !PROCESSOR_COUNTS.contains(&config.processors) + || config.processors < boot + || config.processors > capacity + { + return Err(Error::unsupported(format!( + "snapshot processors must be 1, 2, 4 or 8, from boot {boot} to capacity \ + {capacity}, not {}", + config.processors + ))); + } + let memory = config.memory_mib.checked_mul(1024 * 1024).ok_or_else(|| { + Error::unsupported("snapshot memory in MiB overflows its byte length") + })?; + if memory < self.key.memory() || memory > self.key.memory_capacity() { + return Err(Error::unsupported(format!( + "snapshot memory must be from boot {} to capacity {} bytes, not {memory}", + self.key.memory(), + self.key.memory_capacity() + ))); + } + if !config.memory_mib.is_multiple_of(128) { + return Err(Error::unsupported( + "snapshot memory must be aligned to 128 MiB", + )); + } + let image = restore_geometry(&root.image, "image")?; + let placeholder = self.key.distro(); + if image.length > placeholder.length { + return Err(Error::unsupported(format!( + "snapshot image length {} exceeds the placeholder's {} bytes", + image.length, placeholder.length + ))); + } + if image.logical_block_size != placeholder.logical_block_size + || image.physical_block_size != placeholder.physical_block_size + { + return Err(Error::unsupported(format!( + "snapshot image block sizes {image:?} differ from the placeholder's \ + {placeholder:?}" + ))); + } + if let UpperConfig::Scratch { path, .. } = &root.upper { + let scratch = restore_geometry(path, "scratch")?; + if scratch != self.key.scratch() { + return Err(Error::unsupported(format!( + "snapshot scratch geometry {scratch:?} differs from the dormant scratch's {:?}", + self.key.scratch() + ))); + } + } + match (&config.network, self.nic()) { + (None, Some(_)) | (Some(_), None) => { + return Err(Error::unsupported( + "snapshot NIC presence differs from the requested VM's", + )); + } + (Some(network), Some(captured)) => { + if network + .guest_network + .is_some_and(|address| address != captured.guest_network) + { + return Err(Error::unsupported(format!( + "snapshot NIC address must remain {}", + captured.guest_network + ))); + } + } + (None, None) => {} + } + let console = self + .manifest + .attachments + .iter() + .find(|attachment| attachment.stable_id == "console:microvm-virtio0"); + let policy = match config.console { + ConsoleAttachment::Listener => "recreate-listener", + ConsoleAttachment::Disconnected => "discard-while-disconnected", + }; + if console.is_none_or(|attachment| attachment.reconnect_policy != policy) { + return Err(Error::unsupported( + "snapshot boot console attachment policy does not match the requested console", + )); + } + if runtime.hypervisor != self.key.hypervisor() { + return Err(Error::unsupported( + "snapshot backend differs from the runtime's hypervisor", + )); + } + let profile = self.key.cpu_profile(); + if !key::cpu_profile_matches( + &self.manifest.machine.cpu, + runtime.cpu_profile.as_deref().unwrap_or("auto"), + ) { + return Err(Error::unsupported(format!( + "snapshot CPU profile must remain {profile}" + ))); + } + if &runtime.identity != self.key.runtime() { + return Err(Error::unsupported(format!( + "snapshot runtime identity differs in {:?}", + self.key.runtime().differences(&runtime.identity) + ))); + } + let eligibility = self.manifest.time().eligibility(now); + if !eligibility.is_eligible() { + return Err(Error::unavailable(format!( + "snapshot time contract is {eligibility}" + ))); + } + Ok(()) + } +} + +/// Reads a requested regular disk's geometry without waiting for a Linux FIFO writer. +fn restore_geometry(path: &Path, description: &str) -> Result { + let file = seal::open_read(path).map_err(|error| { + Error::io(format!( + "cannot open snapshot restore {description} {}", + path.display() + )) + .with_source(error) + })?; + let metadata = file.metadata().map_err(|error| { + Error::io(format!( + "cannot examine snapshot restore {description} {}", + path.display() + )) + .with_source(error) + })?; + if !metadata.is_file() { + return Err(Error::unsupported(format!( + "the snapshot restore {description} {} is not a regular file", + path.display() + ))); + } + sandbox::block_geometry(&file, path, description) } /// Names the failing snapshot entry while retaining its I/O error. diff --git a/nvx-rs/src/host/snapshot/key.rs b/nvx-rs/src/host/snapshot/key.rs index 8ab84cc1d..e0c29175c 100644 --- a/nvx-rs/src/host/snapshot/key.rs +++ b/nvx-rs/src/host/snapshot/key.rs @@ -281,6 +281,15 @@ fn cpu_profile_problem(profile: &str) -> Option { }) } +/// Returns whether a CPU-profile request names the captured effective profile. +pub(super) fn cpu_profile_matches(profile: &CpuProfile, requested: &str) -> bool { + match requested { + "auto" => true, + "host" => profile.generation == HOST_GENERATION, + requested => profile.id() == requested, + } +} + /// Returns the guest's network on `nic`, its IPv4 address and prefix length, in the canonical form /// of a [`SandboxNetwork`]. fn guest_network_of(nic: &Nic) -> String { @@ -548,11 +557,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() { - "auto" => true, - "host" => machine.cpu.generation == HOST_GENERATION, - requested => profile == requested, - }; + let selected = cpu_profile_matches(&machine.cpu, &class.cpu_profile); if !selected { return mismatch( format!("its CPU profile is {profile}"), diff --git a/nvx-rs/src/host/snapshot/manifest.rs b/nvx-rs/src/host/snapshot/manifest.rs index d21fe338f..d6f45bafc 100644 --- a/nvx-rs/src/host/snapshot/manifest.rs +++ b/nvx-rs/src/host/snapshot/manifest.rs @@ -648,6 +648,8 @@ pub struct SnapshotManifest { time: TimeContract, /// The machine. pub(super) machine: Machine, + /// The captured host attachments, retained by the same strict manifest parse. + pub(super) attachments: Vec, } pub(in crate::host) use SnapshotManifest as TemplateManifest; @@ -766,9 +768,10 @@ impl SnapshotManifest { "has the invalid creation time of {seconds} seconds and {nanos} nanoseconds" )) })?; - let Some(contract) = raw.contract else { + let Some(mut contract) = raw.contract else { return Err(invalid("has no machine contract")); }; + let attachments = std::mem::take(&mut contract.attachments); let (machine, time) = check_contract(contract, raw.vp_count, raw.memory_size)?; Ok(Self { created_at, @@ -776,16 +779,14 @@ impl SnapshotManifest { state_len: raw.state_size, time, machine, + attachments, }) } } /// A host attachment of a snapshot's machine contract, `openvmm.snapshot.SnapshotAttachment`: an -/// endpoint outside the VM, such as the backend of a console, which a restore reconnects, as far -/// as `nvx-rs run` reads it to restore the snapshot. -// A template's manifest checks the form of its attachments alone, so only the CLI reads what this -// holds. -#[cfg_attr(not(feature = "cli"), allow(dead_code))] +/// endpoint outside the VM, such as the backend of a console, whose identity and reconnect +/// policy snapshot compatibility and `nvx-rs run` read for a restore. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(crate) struct Attachment { /// The stable ID of the device that it attaches, such as `console:microvm-virtio0`. diff --git a/nvx-rs/src/host/snapshot/tests.rs b/nvx-rs/src/host/snapshot/tests.rs index c73fe2710..7fb45bb31 100644 --- a/nvx-rs/src/host/snapshot/tests.rs +++ b/nvx-rs/src/host/snapshot/tests.rs @@ -14,10 +14,18 @@ use super::{ OPENVMM_DIR, Snapshot, SnapshotKey, SnapshotManifest, }; use crate::ErrorKind; +use crate::activation::{Journal, MachineIdMode, OverlayMode, View}; +use crate::host::config::{ + ConsoleAttachment, LayeredRoot, NetworkConfig, NicConfig, RootConfig, UpperConfig, VmConfig, +}; use crate::host::network::{Access, NetworkPolicy}; +use crate::host::process::BootId; +use crate::host::runtime::{LaunchOptions, Runtime}; use crate::host::sandbox::SandboxNetwork; use crate::host::store::{ArtifactIdentity, RuntimeIdentity}; use crate::hypervisor::Hypervisor; +use crate::net::Ipv4Cidr; +use crate::protocol::root::RootImage; /// Bytes in a MiB. pub(in crate::host) const MIB: u64 = 1024 * 1024; @@ -1392,3 +1400,567 @@ fn snapshot_description_needs_no_data_read_access() { ); assert_eq!(Snapshot::open(&fixture.dir).unwrap().key(), &fixture.key); } + +struct RestoreFixture { + snapshot: SnapshotFixture, + config: VmConfig, + runtime: Runtime, + now: super::HostTime, +} + +impl RestoreFixture { + fn new(nic: bool) -> Self { + let mut snapshot = SnapshotFixture::new(); + snapshot.spec = if nic { + let mut spec = ManifestSpec::with_network(); + spec.network().ipv4 = [10, 42, 0, 2]; + spec.network().gateway = [10, 42, 0, 1]; + spec + } else { + ManifestSpec::template() + }; + snapshot.spec.vp_count = 4; + snapshot.spec.contract().topology = (1, 1, 4, 1, vec![0, 1, 2, 3]); + snapshot.spec.contract().boot_online_vp_count = 2; + let image = snapshot.root.path().join("image.raw"); + let scratch = snapshot.root.path().join("scratch.raw"); + for (index, path, length) in [(0, &image, MIB), (1, &scratch, 2 * MIB)] { + let file = crate::host::store::files::create_new(path).unwrap(); + crate::host::platform::make_sparse(&file).unwrap(); + file.set_len(length).unwrap(); + let geometry = crate::host::sandbox::block_geometry(&file, path, "fixture").unwrap(); + let block = &mut snapshot.spec.contract().blocks[index]; + block.length = if index == 0 { 4 * MIB } else { length }; + block.logical_block_size = geometry.logical_block_size; + block.physical_block_size = geometry.physical_block_size; + } + let config = VmConfig { + processors: 2, + memory_mib: 512, + root: RootConfig::Layered(LayeredRoot { + image, + format: RootImage::erofs(None), + gpt_first_partition_bound: None, + upper: UpperConfig::Scratch { + path: scratch, + journal: Journal::Forbidden, + }, + overlay: OverlayMode::TrustedLowers, + }), + view: View { + hostname: b"restored".to_vec(), + machine_id: MachineIdMode::Generated, + }, + network: nic.then(|| NetworkConfig { + guest_network: None, + policy: NetworkPolicy::new(Access::Deny), + }), + filesystem: None, + console: ConsoleAttachment::Disconnected, + boot: None, + }; + let runtime = Runtime { + openvmm: snapshot.root.path().join("unread-openvmm"), + kernel: snapshot.root.path().join("unread-kernel"), + initramfs: snapshot.root.path().join("unread-initramfs"), + identity: class().runtime, + hypervisor: Hypervisor::Kvm, + cpu_profile: Some("intel.icelake-sp.v1".to_owned()), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let now = super::HostTime { + host_id: [1; 16], + boot_id: BootId::from_bytes([2; 16]), + clock: super::HostClock::LinuxBoottime, + monotonic_ns: 123_000_001_000, + utc_ns: 1_700_000_000_000_001_000, + }; + let mut fixture = Self { + snapshot, + config, + runtime, + now, + }; + fixture.write_metadata(); + fixture + } + + fn write_metadata(&mut self) { + let manifest = SnapshotManifest::parse(&self.snapshot.spec.encode()).unwrap(); + let mut class = class(); + class.processors = self.snapshot.spec.vp_count; + class.boot_processors = match manifest.machine.boot_processors { + 0 => class.processors, + count => count, + }; + class.distro = manifest.machine.distro; + class.scratch = manifest.machine.scratch; + class.network = self + .snapshot + .spec + .contract + .as_ref() + .unwrap() + .network + .as_ref() + .map(|network| { + SandboxNetwork::new( + &format!( + "{}/{}", + std::net::Ipv4Addr::from(network.ipv4), + network.prefix_length + ), + NetworkPolicy::new(Access::Allow), + ) + .unwrap() + }); + self.snapshot.key = SnapshotKey::new(&class, &manifest).unwrap(); + crate::host::store::files::write_private( + &self.snapshot.dir.join(KEY_FILE), + &self.snapshot.key.to_bytes(), + ) + .unwrap(); + crate::host::store::files::write_private( + &self.snapshot.dir.join(OPENVMM_DIR).join(MANIFEST_NAME), + &self.snapshot.spec.encode(), + ) + .unwrap(); + } + + fn opened(&self) -> Snapshot { + Snapshot::open(&self.snapshot.dir).unwrap() + } + + fn refused(&self, field: &str) { + let error = self + .opened() + .accepts(&self.config, &self.runtime, &self.now) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{field}: {error}"); + assert!(error.message().contains(field), "{field}: {error}"); + } +} + +#[test] +fn snapshot_accepts_matching_real_disk_geometry_and_every_resource_boundary() { + for nic in [false, true] { + let mut fixture = RestoreFixture::new(nic); + let snapshot = fixture.opened(); + for processors in [2, 4] { + for memory_mib in (512..=2048).step_by(128) { + fixture.config.processors = processors; + fixture.config.memory_mib = memory_mib; + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } + } + let RootConfig::Layered(root) = &mut fixture.config.root else { + unreachable!() + }; + fs::OpenOptions::new() + .write(true) + .open(&root.image) + .unwrap() + .set_len(4 * MIB) + .unwrap(); + root.upper = UpperConfig::Ram { + max_memory_percent: 50, + }; + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } +} + +#[test] +fn snapshot_accepts_refuses_a_blockless_root_and_each_processor_mismatch() { + let mut fixture = RestoreFixture::new(false); + fixture.config.root = RootConfig::Blockless; + fixture.refused("layered root"); + for processors in [1, 8, 3] { + let mut fixture = RestoreFixture::new(false); + fixture.config.processors = processors; + fixture.refused("processors"); + } +} + +#[test] +fn snapshot_accepts_treats_zero_boot_processors_as_all_capacity_online() { + let mut fixture = RestoreFixture::new(false); + fixture.snapshot.spec.contract().boot_online_vp_count = 0; + fixture.write_metadata(); + assert_eq!(fixture.opened().key().boot_processors(), 0); + fixture.refused("processors"); + fixture.config.processors = 4; + fixture + .opened() + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); +} + +#[test] +fn snapshot_accepts_refuses_each_memory_mismatch_without_rounding_or_overflow() { + for memory_mib in [384, 2176, 513] { + let mut fixture = RestoreFixture::new(false); + fixture.config.memory_mib = memory_mib; + fixture.refused("memory"); + } + for memory_mib in [u64::MAX, (1 << 44) + 512] { + let mut fixture = RestoreFixture::new(false); + fixture.config.memory_mib = memory_mib; + fixture.refused("overflows"); + } +} + +#[test] +fn snapshot_accepts_reports_missing_image_and_scratch_files_as_io() { + for scratch in [false, true] { + let fixture = RestoreFixture::new(false); + let snapshot = fixture.opened(); + let RootConfig::Layered(root) = &fixture.config.root else { + unreachable!() + }; + let (path, description) = if scratch { + let UpperConfig::Scratch { path, .. } = &root.upper else { + unreachable!() + }; + (path, "scratch") + } else { + (&root.image, "image") + }; + fs::remove_file(path).unwrap(); + let error = snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{description}: {error}"); + assert!( + error.message().contains(description), + "{description}: {error}" + ); + } +} + +#[test] +fn snapshot_accepts_refuses_an_image_longer_than_its_placeholder() { + let fixture = RestoreFixture::new(false); + let RootConfig::Layered(root) = &fixture.config.root else { + unreachable!() + }; + fs::OpenOptions::new() + .write(true) + .open(&root.image) + .unwrap() + .set_len(4 * MIB + 512) + .unwrap(); + fixture.refused("image length"); +} + +#[test] +fn snapshot_accepts_refuses_each_image_block_size_mismatch() { + for logical in [true, false] { + let mut fixture = RestoreFixture::new(false); + let block = &mut fixture.snapshot.spec.contract().blocks[0]; + if logical { + block.logical_block_size *= 2; + } else { + block.physical_block_size *= 2; + } + fixture.write_metadata(); + fixture.refused("image block sizes"); + } +} + +#[test] +fn snapshot_accepts_refuses_each_scratch_geometry_mismatch() { + for field in 0..3 { + let mut fixture = RestoreFixture::new(false); + let block = &mut fixture.snapshot.spec.contract().blocks[1]; + match field { + 0 => block.length *= 2, + 1 => block.logical_block_size *= 2, + 2 => block.physical_block_size *= 2, + _ => unreachable!(), + } + fixture.write_metadata(); + fixture.refused("scratch geometry"); + } +} + +#[test] +fn snapshot_accepts_refuses_both_nic_presence_mismatches_and_each_named_address_mismatch() { + for captured in [false, true] { + let mut fixture = RestoreFixture::new(captured); + fixture.config.network = (!captured).then(|| NetworkConfig { + guest_network: None, + policy: NetworkPolicy::new(Access::Deny), + }); + fixture.refused("NIC presence"); + } + for address in ["10.42.0.3/24", "10.42.0.2/25"] { + let mut fixture = RestoreFixture::new(true); + fixture.config.network.as_mut().unwrap().guest_network = + Some(Ipv4Cidr::parse(address).unwrap()); + fixture.refused("NIC address"); + } +} + +#[test] +fn snapshot_accepts_inherits_the_captured_address_and_does_not_compare_policies() { + let mut fixture = RestoreFixture::new(true); + let snapshot = fixture.opened(); + for address in [None, Some(Ipv4Cidr::parse("10.42.0.2/24").unwrap())] { + for access in [Access::Deny, Access::Allow] { + fixture.config.network = Some(NetworkConfig { + guest_network: address, + policy: NetworkPolicy::new(access), + }); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } + } +} + +#[test] +fn snapshot_nic_returns_only_the_captured_presence_and_address_without_rereading() { + for captured in [false, true] { + let fixture = RestoreFixture::new(captured); + let snapshot = fixture.opened(); + let expected = captured.then(|| NicConfig { + guest_network: Ipv4Cidr::parse("10.42.0.2/24").unwrap(), + }); + assert_eq!(snapshot.nic(), expected); + fs::rename( + &fixture.snapshot.dir, + fixture.snapshot.root.path().join("saved"), + ) + .unwrap(); + assert_eq!(snapshot.nic(), expected); + } +} + +#[test] +fn snapshot_open_refuses_captured_network_gateway_and_broadcast_addresses_as_io() { + use crate::host::store::files::write_private; + + for address in [[10, 42, 0, 0], [10, 42, 0, 1], [10, 42, 0, 255]] { + let mut fixture = RestoreFixture::new(true); + let mut bytes = fixture.snapshot.key.to_bytes(); + let positions: Vec<_> = bytes + .windows(4) + .enumerate() + .filter_map(|(index, bytes)| (bytes == [10, 42, 0, 2]).then_some(index)) + .collect(); + assert_eq!( + positions.len(), + 1, + "the fixture has one encoded NIC address" + ); + bytes[positions[0]..positions[0] + 4].copy_from_slice(&address); + fixture.snapshot.spec.network().ipv4 = address; + let manifest = SnapshotManifest::parse(&fixture.snapshot.spec.encode()).unwrap(); + let key = SnapshotKey::from_bytes(&bytes).unwrap(); + key.check_manifest(&manifest).unwrap(); + write_private(&fixture.snapshot.dir.join(KEY_FILE), &bytes).unwrap(); + write_private( + &fixture.snapshot.dir.join(OPENVMM_DIR).join(MANIFEST_NAME), + &fixture.snapshot.spec.encode(), + ) + .unwrap(); + let error = Snapshot::open(&fixture.snapshot.dir).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io, "{error}"); + assert!(error.message().contains("captured NIC address"), "{error}"); + } +} + +#[test] +fn snapshot_accepts_requires_the_boot_console_attachment_not_the_control_listener_or_device() { + let mut fixture = RestoreFixture::new(false); + fixture.config.console = ConsoleAttachment::Listener; + fixture.refused("boot console"); + let console = &mut fixture.snapshot.spec.contract().attachments[0]; + console.reconnect_policy = "recreate-listener".to_owned(); + console.identity_kind = "unix-socket".to_owned(); + console.identity = b"/captured/boot-console".to_vec(); + fixture.write_metadata(); + let snapshot = fixture.opened(); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + fixture.config.console = ConsoleAttachment::Disconnected; + fixture.refused("boot console"); +} + +#[test] +fn snapshot_accepts_refuses_missing_and_unknown_boot_console_policies() { + for missing in [false, true] { + let mut fixture = RestoreFixture::new(false); + if missing { + fixture + .snapshot + .spec + .contract() + .attachments + .retain(|attachment| attachment.stable_id != "console:microvm-virtio0"); + } else { + fixture.snapshot.spec.contract().attachments[0].reconnect_policy = "unknown".to_owned(); + } + fixture.write_metadata(); + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + fixture.config.console = console; + fixture.refused("boot console"); + } + } +} + +#[test] +fn snapshot_accepts_refuses_a_different_backend_and_each_cpu_profile_mismatch() { + let mut fixture = RestoreFixture::new(false); + fixture.runtime.hypervisor = Hypervisor::Mshv; + fixture.refused("backend"); + for profile in ["host", "amd.genoa.v2"] { + let mut fixture = RestoreFixture::new(false); + fixture.runtime.cpu_profile = Some(profile.to_owned()); + fixture.refused("CPU profile"); + } +} + +#[test] +fn snapshot_accepts_the_default_auto_and_matching_host_cpu_profile_requests() { + for captured in ["intel.icelake-sp.v1", "intel.host.v1"] { + let mut fixture = RestoreFixture::new(false); + fixture.snapshot.spec.cpu().id = captured.to_owned(); + fixture.write_metadata(); + let snapshot = fixture.opened(); + for requested in [None, Some("auto"), Some(captured)] { + fixture.runtime.cpu_profile = requested.map(str::to_owned); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } + if captured == "intel.host.v1" { + fixture.runtime.cpu_profile = Some("host".to_owned()); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); + } + } +} + +#[cfg(target_os = "linux")] +#[test] +fn snapshot_accepts_refuses_nonregular_disks_without_waiting_for_a_fifo_writer() { + use std::sync::mpsc; + use std::time::Duration; + + for scratch in [false, true] { + for fifo in [false, true] { + let fixture = RestoreFixture::new(false); + let snapshot = fixture.opened(); + let RootConfig::Layered(root) = &fixture.config.root else { + unreachable!() + }; + let path = if scratch { + let UpperConfig::Scratch { path, .. } = &root.upper else { + unreachable!() + }; + path + } else { + &root.image + }; + fs::remove_file(path).unwrap(); + if fifo { + nix::unistd::mkfifo(path, nix::sys::stat::Mode::S_IRUSR).unwrap(); + } else { + fs::create_dir(path).unwrap(); + } + let config = fixture.config.clone(); + let runtime = fixture.runtime.clone(); + let now = fixture.now; + let (send, receive) = mpsc::channel(); + let worker = std::thread::spawn(move || { + send.send(snapshot.accepts(&config, &runtime, &now)) + .unwrap(); + }); + let error = receive + .recv_timeout(Duration::from_secs(5)) + .expect("disk geometry must not wait for a FIFO writer") + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); + assert!(error.message().contains("regular file"), "{error}"); + worker.join().unwrap(); + } + } +} + +#[test] +fn snapshot_accepts_refuses_every_runtime_identity_part_independently() { + let changes: [fn(&mut RuntimeIdentity); 6] = [ + |identity| identity.openvmm = artifact('d'), + |identity| identity.kernel = artifact('d'), + |identity| identity.initramfs = artifact('d'), + |identity| identity.boot_contract += 1, + |identity| identity.control_contract.push_str("-other"), + |identity| identity.control_session += 1, + ]; + for change in changes { + let mut fixture = RestoreFixture::new(false); + change(&mut fixture.runtime.identity); + fixture.refused("runtime identity"); + } +} + +#[test] +fn snapshot_accepts_reports_early_and_expired_time_as_unavailable() { + let fixture = RestoreFixture::new(false); + let snapshot = fixture.opened(); + let anchor = snapshot.manifest().time(); + for monotonic_ns in [ + anchor.capture_monotonic_ns - 1, + anchor.capture_monotonic_ns + super::MAX_DOWNTIME_NS, + ] { + let now = super::HostTime { + monotonic_ns, + ..fixture.now + }; + let error = snapshot + .accepts(&fixture.config, &fixture.runtime, &now) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Unavailable, "{error}"); + } + let now = super::HostTime { + monotonic_ns: anchor.capture_monotonic_ns + super::MAX_DOWNTIME_NS - 200, + ..fixture.now + }; + snapshot + .accepts(&fixture.config, &fixture.runtime, &now) + .unwrap(); + let now = super::HostTime { + host_id: [3; 16], + utc_ns: anchor.capture_utc_ns - 1, + ..fixture.now + }; + assert_eq!( + snapshot + .accepts(&fixture.config, &fixture.runtime, &now) + .unwrap_err() + .kind(), + ErrorKind::Unavailable + ); +} + +#[test] +fn snapshot_accepts_never_reopens_snapshot_paths_or_reads_runtime_artifacts() { + let fixture = RestoreFixture::new(true); + let snapshot = fixture.opened(); + fs::rename( + &fixture.snapshot.dir, + fixture.snapshot.root.path().join("saved"), + ) + .unwrap(); + snapshot + .accepts(&fixture.config, &fixture.runtime, &fixture.now) + .unwrap(); +}