From 11b430db23400ecd05878af8bdff067c5c8f3180 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:05:39 -0800 Subject: [PATCH 01/30] host: reconcile verified stop outcomes onto feature Port the preserved SL2.10 implementation and coupled callers, API snapshots, docs and real-process tests onto feature c52c7101 without changing legacy lifecycle policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- doc/usage.md | 1 + nvx-rs/README.md | 24 ++- nvx-rs/public-api/linux.txt | 8 + nvx-rs/public-api/windows.txt | 8 + nvx-rs/src/error.rs | 9 + nvx-rs/src/error/tests.rs | 1 + nvx-rs/src/host/instance/locked.rs | 3 +- nvx-rs/src/host/platform/windows/tests.rs | 15 +- nvx-rs/src/host/process.rs | 18 +- nvx-rs/src/host/process/tests.rs | 3 +- nvx-rs/src/host/process/tests/linux.rs | 5 +- nvx-rs/src/host/report.rs | 25 ++- nvx-rs/src/host/report/tests.rs | 55 ++++- nvx-rs/src/host/template/clone/records.rs | 4 +- .../src/host/template/clone/tests/launch.rs | 17 +- .../src/host/template/clone/tests/restore.rs | 2 +- .../src/host/template/store/leases/tests.rs | 8 +- nvx-rs/src/host/vm.rs | 74 +++++++ nvx-rs/src/host/vm/tests.rs | 189 +++++++++++++++++- nvx-rs/src/host/vm/tests/reattach.rs | 2 +- nvx-rs/tests/checked_handoff.rs | 2 + nvx-rs/tests/cli.rs | 9 +- nvx-rs/tests/cli/download.rs | 16 ++ nvx-rs/tests/cli/lifecycle.rs | 15 +- nvx-rs/tests/fake_openvmm.rs | 7 +- nvx-rs/tests/store.rs | 11 +- nvx-rs/tests/supervisor.rs | 42 ++-- .../src/bin/runtime-adapter-host.rs | 16 +- tests/runtime-adapter/tests/vm_clone.rs | 14 +- 29 files changed, 530 insertions(+), 73 deletions(-) diff --git a/doc/usage.md b/doc/usage.md index a0e64ec5d..12eb06dd9 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -110,6 +110,7 @@ each code for callers of the `nvx` library. | `template-ineligible` | 1 | The restore window of a template's generation, which the time contract of its capture sets, excludes the host's time, so no clone may restore it now. | | `template-tombstoned` | 1 | A tombstone quarantines the template's generation, which takes no new clones. | | `exited` | 1 | OpenVMM exited, as when a start fails, whose message names the category and status of OpenVMM's outcome report when it has one. | +| `still-running` | 1 | A stop could not confirm the VM process's exit, so the caller keeps its verified process. | | `exec-refused` | 1 | The guest agent refused the workload, as for a working directory that it cannot use. | | `endpoint-closed` | 1 | OpenVMM closed the control endpoint, as it does when the VM shuts down. | | `protocol` | 1 | The control session failed otherwise, as when the guest breaks the protocol or the session is reset. | diff --git a/nvx-rs/README.md b/nvx-rs/README.md index cd9c6f7ca..3019c6ebc 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -124,7 +124,7 @@ changes only when the module's purpose does. | [`nvx::Hypervisor`](#nvxhypervisor) | KVM, MSHV, or WHP, with its serde form behind the `serde` feature | | [`nvx::exec`](#nvxexec) | The execution engine: the streams, credit, and outcome of an execution's two sides, without I/O, for any framing | | [`nvx::host::runtime`](#nvxhostruntime) | `Runtime`: what every launch shares, the runtime's files and their identity, the hypervisor and its probe, and the launch options | -| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold preparation, verified detached launch, and caller-saved reattachment | +| [`nvx::host::vm`](#nvxhostvm) | A private VM directory, cold preparation, verified detached launch, confirmed stop, and caller-saved reattachment | | [`nvx::host::snapshot`](#nvxhostsnapshot) | Snapshot compatibility keys, strict manifests, restore-time eligibility, and the opaque input of a future VM restore | | [`nvx::host::config`](#nvxhostconfig) | `VmConfig` and `CaptureConfig`: pure configuration, cold-boot kernel/OpenVMM arguments, portable length checks, and capture identity | | [`nvx::host::restore`](#nvxhostrestore) | A caller-driven restore to Ready without I/O or a clock, and its peer-verified restore-ready listener | @@ -500,6 +500,17 @@ or reading runtime files. `Reattached` holds both the process and authenticated connection. Failures retain their original kinds and sources and leave the saved values unchanged. +`stop(process, outputs, graceful, grace, deadline)` waits through the verified +process, first after the caller's graceful request, then after forced termination +if needed. It returns `Stopped` only after confirmed exit. `self_exited` records +confirmation before a forced attempt, and `forced` records that attempt, not +proof from kill success. An unconfirmed exit is `StillRunning`, and the caller +keeps the process. The callback receives the bounded grace deadline and must +respect it; its boolean result is never exit proof. + +`Stopped::report` retains the outcome-report read's result separately, so report +failure never hides confirmed exit. + `vm::create(&runtime, &config, None, &dir)` creates an inspectable cold `Prepared` launch without starting a process or binding a listener. It requires `config.boot`, refuses a snapshot input, rechecks the directory's privacy, and @@ -976,6 +987,10 @@ On Windows, the duplicate retains the original handle's access rights. Duplication failures have kind `Io` and retain their original I/O source. No watcher thread, future or callback is created by the library. +`Process::wait(deadline)` returns `Some(Exited)` only after verified exit, or +`None` when the deadline passes without confirmation. `Exited` cannot be made +by a caller. + `Process::bytes_read` reports how many bytes the process has read through the operating system's read calls, whether the host's cache served them or not: `rchar` of `/proc//io` on Linux, which it reads only while the process @@ -1422,6 +1437,11 @@ The authenticated control session, which carries NVX's control protocol, `NVXC`, one frame per DATA record of a `HostConnection`; Linux and Windows primitives; and OpenVMM's outcome report. +`report::read` returns `Ok(None)` for a missing report, and an `Io` error for an +unreadable or malformed report, retaining the underlying error. It keeps today's +summary of the outcome category, status code and teardown steps, not free-form +fields. + A `Transport` to an OpenVMM control endpoint reads and writes with deadlines, and tells, through `Transport::unread`, what the endpoint has not read yet of what it wrote, where the platform tells: on Linux, what `SIOCOUTQ` reports of @@ -3055,6 +3075,7 @@ optional cause. | `Unavailable` | A missing or unusable artifact, hypervisor, or release, a guest image without a required control feature, or an unsupported host | | `Unsupported` | A request that NVX refuses: a value that it does not accept, such as a malformed or out-of-range one, options that it cannot combine, or a request that it cannot represent or enforce, such as a policy beyond a limit of OpenVMM or the guest agent | | `Exited` | The VM's OpenVMM process exited | +| `StillRunning` | A stop could not confirm the VM process's exit, so the caller keeps its verified process | | `Io` | A host I/O failure, or state that NVX recorded on the host and cannot read | | `NotFound` | State that an operation names and that does not exist on the host, such as the store of a sandbox that was never created or that a removal deleted | | `Protocol` | A control session that failed, or a request that the guest agent refused | @@ -3087,6 +3108,7 @@ usage errors, which no library error has. | `unavailable` | `Unavailable` | A dependency is missing or unusable, other than as a finer code says: install metadata, the hypervisor, a release, a distro layer or scratch image that the request names, or a host that NVX does not support | | `unsupported` | `Unsupported` | NVX refuses the request: options that it cannot combine, a value that it does not accept, or a policy that it cannot represent or enforce. The message names the option or field | | `exited` | `Exited` | OpenVMM exited, as when a start fails, whose message names the category and status of OpenVMM's outcome report when it has one | +| `still-running` | `StillRunning` | A stop could not confirm the VM process's exit, so the caller keeps its verified process | | `io` | `Io` | A host I/O operation failed, or state that NVX recorded on the host is unreadable or malformed | | `not-found` | `NotFound` | Other state that the operation names does not exist on the host | | `protocol` | `Protocol` | The control session failed otherwise: the peer violated the protocol, reset the session, or failed authentication | diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 6a422c570..2d90a5759 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -58,6 +58,7 @@ variant nvx::ErrorCode::SandboxNotRunning variant nvx::ErrorCode::SandboxProvisioned variant nvx::ErrorCode::SandboxRunning variant nvx::ErrorCode::ScratchClaimed +variant nvx::ErrorCode::StillRunning variant nvx::ErrorCode::TemplateIneligible variant nvx::ErrorCode::TemplateTombstoned variant nvx::ErrorCode::TimedOut @@ -73,6 +74,7 @@ variant nvx::ErrorKind::NotFound variant nvx::ErrorKind::Other variant nvx::ErrorKind::Protocol variant nvx::ErrorKind::RuntimeMismatch +variant nvx::ErrorKind::StillRunning variant nvx::ErrorKind::TimedOut variant nvx::ErrorKind::Unavailable variant nvx::ErrorKind::Unsupported @@ -2445,6 +2447,7 @@ method nvx::host::process::BootId::as_bytes method nvx::host::process::BootId::current method nvx::host::process::BootId::from_bytes type nvx::host::process::ExitHandle +struct nvx::host::process::Exited struct nvx::host::process::Process method nvx::host::process::Process::bytes_read method nvx::host::process::Process::exit_handle @@ -3768,6 +3771,10 @@ field nvx::host::vm::SavedVm::endpoint field nvx::host::vm::SavedVm::process field nvx::host::vm::SavedVm::runtime field nvx::host::vm::SavedVm::vm_dir +struct nvx::host::vm::Stopped +field nvx::host::vm::Stopped::forced +field nvx::host::vm::Stopped::report +field nvx::host::vm::Stopped::self_exited struct nvx::host::vm::VmDir method nvx::host::vm::VmDir::open method nvx::host::vm::VmDir::path @@ -3780,6 +3787,7 @@ fn nvx::host::vm::create fn nvx::host::vm::create_private_dir fn nvx::host::vm::reattach fn nvx::host::vm::spawn +fn nvx::host::vm::stop fn nvx::host::vm::verify mod nvx::net struct nvx::net::Cidr diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 9aa7bf0f8..c1e2bc8cf 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -58,6 +58,7 @@ variant nvx::ErrorCode::SandboxNotRunning variant nvx::ErrorCode::SandboxProvisioned variant nvx::ErrorCode::SandboxRunning variant nvx::ErrorCode::ScratchClaimed +variant nvx::ErrorCode::StillRunning variant nvx::ErrorCode::TemplateIneligible variant nvx::ErrorCode::TemplateTombstoned variant nvx::ErrorCode::TimedOut @@ -73,6 +74,7 @@ variant nvx::ErrorKind::NotFound variant nvx::ErrorKind::Other variant nvx::ErrorKind::Protocol variant nvx::ErrorKind::RuntimeMismatch +variant nvx::ErrorKind::StillRunning variant nvx::ErrorKind::TimedOut variant nvx::ErrorKind::Unavailable variant nvx::ErrorKind::Unsupported @@ -1031,6 +1033,7 @@ method nvx::host::process::BootId::as_bytes method nvx::host::process::BootId::current method nvx::host::process::BootId::from_bytes type nvx::host::process::ExitHandle +struct nvx::host::process::Exited struct nvx::host::process::Process method nvx::host::process::Process::bytes_read method nvx::host::process::Process::exit_handle @@ -2353,6 +2356,10 @@ field nvx::host::vm::SavedVm::endpoint field nvx::host::vm::SavedVm::process field nvx::host::vm::SavedVm::runtime field nvx::host::vm::SavedVm::vm_dir +struct nvx::host::vm::Stopped +field nvx::host::vm::Stopped::forced +field nvx::host::vm::Stopped::report +field nvx::host::vm::Stopped::self_exited struct nvx::host::vm::VmDir method nvx::host::vm::VmDir::open method nvx::host::vm::VmDir::path @@ -2365,6 +2372,7 @@ fn nvx::host::vm::create fn nvx::host::vm::create_private_dir fn nvx::host::vm::reattach fn nvx::host::vm::spawn +fn nvx::host::vm::stop fn nvx::host::vm::verify mod nvx::net struct nvx::net::Cidr diff --git a/nvx-rs/src/error.rs b/nvx-rs/src/error.rs index 989538bab..e98ae8830 100644 --- a/nvx-rs/src/error.rs +++ b/nvx-rs/src/error.rs @@ -19,6 +19,8 @@ pub enum ErrorKind { Unsupported, /// The OpenVMM process of a VM exited. Exited, + /// The exit of a VM's process could not be confirmed; it may still run. + StillRunning, /// A host I/O operation failed, or state that NVX recorded on the host is unreadable or /// malformed. Io, @@ -44,6 +46,7 @@ impl ErrorKind { Self::Unavailable => "unavailable", Self::Unsupported => "unsupported", Self::Exited => "exited", + Self::StillRunning => "still-running", Self::Io => "io", Self::NotFound => "not-found", Self::Protocol => "protocol", @@ -88,6 +91,8 @@ pub enum ErrorCode { Unsupported, /// `exited`: the OpenVMM process of a VM exited, as when a start fails. Exited, + /// `still-running`: the exit of a VM's process could not be confirmed. + StillRunning, /// `io`: a host I/O operation failed, or state that NVX recorded on the host is unreadable or /// malformed. Io, @@ -170,6 +175,7 @@ impl ErrorCode { Self::Unavailable, Self::Unsupported, Self::Exited, + Self::StillRunning, Self::Io, Self::NotFound, Self::Protocol, @@ -205,6 +211,7 @@ impl ErrorCode { Self::Unavailable => ErrorKind::Unavailable.as_str(), Self::Unsupported => ErrorKind::Unsupported.as_str(), Self::Exited => ErrorKind::Exited.as_str(), + Self::StillRunning => ErrorKind::StillRunning.as_str(), Self::Io => ErrorKind::Io.as_str(), Self::NotFound => ErrorKind::NotFound.as_str(), Self::Protocol => ErrorKind::Protocol.as_str(), @@ -248,6 +255,7 @@ impl ErrorCode { | Self::ArgumentMissing | Self::ArgumentInvalid => ErrorKind::Unsupported, Self::Exited | Self::SandboxExited => ErrorKind::Exited, + Self::StillRunning => ErrorKind::StillRunning, Self::Io | Self::FormatUnsupported => ErrorKind::Io, Self::NotFound | Self::SandboxNotProvisioned => ErrorKind::NotFound, Self::Protocol | Self::ExecRefused | Self::EndpointClosed => ErrorKind::Protocol, @@ -278,6 +286,7 @@ impl From for ErrorCode { ErrorKind::Unavailable => Self::Unavailable, ErrorKind::Unsupported => Self::Unsupported, ErrorKind::Exited => Self::Exited, + ErrorKind::StillRunning => Self::StillRunning, ErrorKind::Io => Self::Io, ErrorKind::NotFound => Self::NotFound, ErrorKind::Protocol => Self::Protocol, diff --git a/nvx-rs/src/error/tests.rs b/nvx-rs/src/error/tests.rs index eb87d7dc0..a893e72b7 100644 --- a/nvx-rs/src/error/tests.rs +++ b/nvx-rs/src/error/tests.rs @@ -52,6 +52,7 @@ fn every_kind_has_a_code_of_its_own_spelling() { ErrorKind::Unavailable, ErrorKind::Unsupported, ErrorKind::Exited, + ErrorKind::StillRunning, ErrorKind::Io, ErrorKind::NotFound, ErrorKind::Protocol, diff --git a/nvx-rs/src/host/instance/locked.rs b/nvx-rs/src/host/instance/locked.rs index fca2a638b..2cbee7f48 100644 --- a/nvx-rs/src/host/instance/locked.rs +++ b/nvx-rs/src/host/instance/locked.rs @@ -216,7 +216,8 @@ impl LockedInstance { }; Ok(Stopped { forced, - report: report::read(&self.instance.report_path()), + // This legacy wrapper keeps its best-effort report policy. + report: report::read(&self.instance.report_path()).ok().flatten(), }) } diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 44dd7359a..7da84f781 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -16,7 +16,8 @@ use windows_sys::Win32::Foundation::{ use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; use windows_sys::Win32::System::JobObjects::CreateJobObjectW; use windows_sys::Win32::System::Threading::{ - CreateEventW, GetCurrentProcess, INFINITE, OpenProcess, PROCESS_DUP_HANDLE, TerminateProcess, + CreateEventW, GetCurrentProcess, INFINITE, OpenProcess, PROCESS_DUP_HANDLE, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, TerminateProcess, }; pub(crate) use super::endpoint::tests::{accept_control, control_server}; @@ -38,6 +39,16 @@ pub(crate) fn wait_exit_handle(process: OwnedHandle, deadline: Instant) -> io::R .map(|exited| exited.expect("Windows process handles always provide an exit event")) } +impl ProcessHandle { + pub(crate) fn wait_only(pid: u32) -> Self { + Self { + process: open_handle(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE) + .unwrap(), + terminate: false, + } + } +} + thread_local! { /// What a spawn of this thread runs in its window, once its handles are inheritable and /// before it creates its process. @@ -1084,7 +1095,7 @@ fn a_launch_whose_vmm_exited_ends_while_a_process_spawned_in_its_window_holds_it let vmm = launch.record_process(&launched).unwrap(); launched.detach(); assert!( - vmm.wait(Instant::now() + WINDOW_TIMEOUT).unwrap(), + vmm.wait(Instant::now() + WINDOW_TIMEOUT).unwrap().is_some(), "the stand-in did not exit" ); // The VMM has exited, so the launch ends, whatever the other process holds. diff --git a/nvx-rs/src/host/process.rs b/nvx-rs/src/host/process.rs index 32047f8d6..100abe93d 100644 --- a/nvx-rs/src/host/process.rs +++ b/nvx-rs/src/host/process.rs @@ -188,6 +188,12 @@ pub struct Process { handle: platform::ProcessHandle, } +/// Proof that a verified process exited, produced only by [`Process::wait`]. +/// +/// It proves exit, not the process's exit status, and cannot be constructed by a caller. +#[derive(Debug)] +pub struct Exited(()); + impl Process { /// Finds the process that `reference` names, and returns `None` if it has exited. /// @@ -241,13 +247,17 @@ impl Process { }) } - /// Waits until the process exits or `deadline` passes, and returns whether it exited. + /// Waits until the process exits or `deadline` passes, returning exit proof or `None`. /// /// A process exits with its last thread. The wait sleeps until the exit event of the /// process, or with [`WaitMethod::Polling`], checks its identity at a short interval. An /// error means that the wait cannot determine whether the process exited. - pub fn wait(&self, deadline: Instant) -> io::Result { - self.wait_bool(deadline) + pub fn wait(&self, deadline: Instant) -> io::Result> { + Ok(if self.wait_bool(deadline)? { + Some(Exited(())) + } else { + None + }) } /// Keeps the boolean wait result for the legacy lifecycle callers. @@ -262,7 +272,7 @@ impl Process { /// Returns whether the process has exited, without waiting. pub fn has_exited(&self) -> io::Result { - self.wait(Instant::now()) + self.wait(Instant::now()).map(|exited| exited.is_some()) } /// Ends the process at once, unless it has exited or is exiting: with `SIGKILL` on Linux, and diff --git a/nvx-rs/src/host/process/tests.rs b/nvx-rs/src/host/process/tests.rs index ebc305ffa..a90c83102 100644 --- a/nvx-rs/src/host/process/tests.rs +++ b/nvx-rs/src/host/process/tests.rs @@ -275,7 +275,7 @@ fn hosts_without_pidfds_poll_and_never_signal() { let still_running = process.has_exited(); child.kill().unwrap(); child.wait().unwrap(); - assert!(!running.unwrap()); + assert!(running.unwrap().is_none()); assert_eq!(refused.unwrap_err().kind(), io::ErrorKind::Unsupported); assert!(!still_running.unwrap()); // Once the child is reaped, its ID names no process, or another one. @@ -283,6 +283,7 @@ fn hosts_without_pidfds_poll_and_never_signal() { process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); } diff --git a/nvx-rs/src/host/process/tests/linux.rs b/nvx-rs/src/host/process/tests/linux.rs index 4c9177b53..a2a0cf72f 100644 --- a/nvx-rs/src/host/process/tests/linux.rs +++ b/nvx-rs/src/host/process/tests/linux.rs @@ -152,13 +152,14 @@ fn a_process_runs_until_its_last_thread_exits() { .unwrap() .expect("a process whose main thread exited still runs"); assert!( - !process + process .wait(Instant::now() + Duration::from_millis(100)) .unwrap() + .is_none() ); child.0.kill().unwrap(); child.0.wait().unwrap(); - assert!(process.wait(Instant::now()).unwrap()); + assert!(process.wait(Instant::now()).unwrap().is_some()); } #[test] diff --git a/nvx-rs/src/host/report.rs b/nvx-rs/src/host/report.rs index 48f86fec4..f07bf2ee4 100644 --- a/nvx-rs/src/host/report.rs +++ b/nvx-rs/src/host/report.rs @@ -9,6 +9,8 @@ use std::path::Path; use serde_json::Value; +use crate::{Error, Result}; + /// Summary of an outcome report. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Report { @@ -20,9 +22,24 @@ pub struct Report { pub teardown_complete: Option, } -/// Reads the report at `path`, or returns `None` if it is missing or not JSON. -pub fn read(path: &Path) -> Option { - let report: Value = serde_json::from_slice(&fs::read(path).ok()?).ok()?; +/// Reads the report at `path`, or returns `Ok(None)` if it is missing. +/// +/// An unreadable or malformed report fails with [`ErrorKind::Io`](crate::ErrorKind::Io), +/// retaining the underlying filesystem or JSON error. +pub fn read(path: &Path) -> Result> { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err( + Error::io(format!("cannot read outcome report {}", path.display())) + .with_source(error), + ); + } + }; + let report: Value = serde_json::from_slice(&bytes).map_err(|error| { + Error::io(format!("cannot parse outcome report {}", path.display())).with_source(error) + })?; let mut summary = Report::default(); if let Some(outcome) = report.get("outcome") { summary.category = outcome @@ -35,7 +52,7 @@ pub fn read(path: &Path) -> Option { let complete = teardown.values().all(|step| step.as_bool() == Some(true)); summary.teardown_complete = Some(complete); } - Some(summary) + Ok(Some(summary)) } #[cfg(test)] diff --git a/nvx-rs/src/host/report/tests.rs b/nvx-rs/src/host/report/tests.rs index 127fd5b2e..961377aa4 100644 --- a/nvx-rs/src/host/report/tests.rs +++ b/nvx-rs/src/host/report/tests.rs @@ -11,7 +11,7 @@ fn reports_are_summarized_without_free_form_fields() { ) .unwrap(); assert_eq!( - read(&path).unwrap(), + read(&path).unwrap().unwrap(), Report { category: Some("success".to_owned()), status_code: Some(0), @@ -20,13 +20,60 @@ fn reports_are_summarized_without_free_form_fields() { ); fs::write(&path, r#"{"teardown":{"workers":true,"memory":false}}"#).unwrap(); assert_eq!( - read(&path).unwrap(), + read(&path).unwrap().unwrap(), Report { teardown_complete: Some(false), ..Report::default() } ); - assert!(read(&directory.path().join("missing.json")).is_none()); +} + +#[test] +fn missing_reports_are_distinct_from_malformed_or_unreadable_reports() { + let directory = tempfile::tempdir().unwrap(); + assert!( + read(&directory.path().join("missing.json")) + .unwrap() + .is_none() + ); + let path = directory.path().join("outcome.json"); fs::write(&path, b"{ truncated").unwrap(); - assert!(read(&path).is_none()); + let malformed = read(&path).unwrap_err(); + assert_eq!(malformed.kind(), crate::ErrorKind::Io); + assert!( + std::error::Error::source(&malformed) + .unwrap() + .is::() + ); + let unreadable = read(directory.path()).unwrap_err(); + assert_eq!(unreadable.kind(), crate::ErrorKind::Io); + assert!( + std::error::Error::source(&unreadable) + .unwrap() + .is::() + ); +} + +#[cfg(windows)] +#[test] +fn a_report_opened_without_read_sharing_returns_its_io_error() { + use std::os::windows::fs::OpenOptionsExt; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("outcome.json"); + fs::write(&path, b"{}").unwrap(); + let held = fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(&path) + .unwrap(); + let error = read(&path).unwrap_err(); + assert_eq!(error.kind(), crate::ErrorKind::Io); + assert!( + std::error::Error::source(&error) + .unwrap() + .is::() + ); + drop(held); + assert_eq!(read(&path).unwrap(), Some(Report::default())); } diff --git a/nvx-rs/src/host/template/clone/records.rs b/nvx-rs/src/host/template/clone/records.rs index ce9752576..dc5fbc696 100644 --- a/nvx-rs/src/host/template/clone/records.rs +++ b/nvx-rs/src/host/template/clone/records.rs @@ -296,7 +296,7 @@ impl Recorder { /// report only once the clone's VM ended, or its teardown did. pub(super) fn observed(&self) -> Option<&Report> { self.observed - .get_or_init(|| report::read(&self.report)) + .get_or_init(|| report::read(&self.report).ok().flatten()) .as_ref() } @@ -1119,7 +1119,7 @@ impl CloneRecords { // The launch removed the report of an earlier VM of the sandbox before it wrote the first // of the clone's records, which a progress past the plan shows. let report = (progress > Progress::Planned) - .then(|| report::read(&sandbox.outcome_path())) + .then(|| report::read(&sandbox.outcome_path()).ok().flatten()) .flatten(); Ok(Some(Self { lease, diff --git a/nvx-rs/src/host/template/clone/tests/launch.rs b/nvx-rs/src/host/template/clone/tests/launch.rs index c58043a4c..7ec87eec6 100644 --- a/nvx-rs/src/host/template/clone/tests/launch.rs +++ b/nvx-rs/src/host/template/clone/tests/launch.rs @@ -365,14 +365,14 @@ impl VmmDir { .unwrap() .expect("the VMM runs until the test ends it"); self.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } /// Waits until the VMM, which exits on its own, as its [plan](Self::plan) asks, has exited. pub(super) fn wait_exit(&self) { let reference = self.reference.as_ref().expect("the test knows the VMM"); if let Some(process) = Process::open(reference).unwrap() { - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } @@ -413,7 +413,9 @@ impl Drop for VmmDir { let _ = fs::write(self.path().join(EXIT_MARKER), b""); if let Some(reference) = &self.reference && let Ok(Some(process)) = Process::open(reference) - && !process.wait(Instant::now() + TIMEOUT).unwrap_or(false) + && !process + .wait(Instant::now() + TIMEOUT) + .is_ok_and(|exited| exited.is_some()) { let _ = process.kill(); let _ = process.wait(Instant::now() + TIMEOUT); @@ -1567,7 +1569,7 @@ fn a_clone_whose_launch_failed_before_it_began_launches_no_more_until_its_releas // clone's: an inspection takes none, and the launch removes it before it records anything. let report = acquired.locked.outcome_path(); fs::write(&report, guest_exit(195)).unwrap(); - assert!(crate::host::report::read(&report).is_some()); + assert!(crate::host::report::read(&report).unwrap().is_some()); let records = CloneRecords::read(&acquired.locked).unwrap().unwrap(); assert_eq!( (records.progress, records.report), @@ -2323,7 +2325,7 @@ fn a_launch_whose_caller_crashes_once_its_vmm_started_ends_the_vmm_as_reattach_r } => assert_eq!(terminated, reference), other => panic!("unexpected reattach: {other:?}"), } - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(!exists(&crashed.socket())); crashed.releases(&mut locked); } @@ -2356,7 +2358,10 @@ fn a_restore_whose_caller_crashes_before_its_commit_ends_its_vmm_as_reattach_rec } => assert_eq!(terminated, reference, "{step}"), other => panic!("unexpected reattach at {step}: {other:?}"), } - assert!(process.wait(Instant::now() + TIMEOUT).unwrap(), "{step}"); + assert!( + process.wait(Instant::now() + TIMEOUT).unwrap().is_some(), + "{step}" + ); assert!(!exists(&crashed.socket()), "{step}"); crashed.releases(&mut locked); } diff --git a/nvx-rs/src/host/template/clone/tests/restore.rs b/nvx-rs/src/host/template/clone/tests/restore.rs index 0a33cb1dd..61282c561 100644 --- a/nvx-rs/src/host/template/clone/tests/restore.rs +++ b/nvx-rs/src/host/template/clone/tests/restore.rs @@ -422,7 +422,7 @@ fn vmm_with(plan: &str) -> VmmDir { fn assert_vmm_ended(vmm: &VmmDir) { let reference = vmm.reference.as_ref().unwrap(); if let Some(process) = Process::open(reference).unwrap() { - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } diff --git a/nvx-rs/src/host/template/store/leases/tests.rs b/nvx-rs/src/host/template/store/leases/tests.rs index 1205e4ba3..eaeed151c 100644 --- a/nvx-rs/src/host/template/store/leases/tests.rs +++ b/nvx-rs/src/host/template/store/leases/tests.rs @@ -2460,7 +2460,9 @@ impl Drop for VmmDir { let _ = fs::write(self.path().join(EXIT_MARKER), b""); if let Some(reference) = &self.reference && let Ok(Some(process)) = Process::open(reference) - && !process.wait(Instant::now() + TIMEOUT).unwrap_or(false) + && !process + .wait(Instant::now() + TIMEOUT) + .is_ok_and(|exited| exited.is_some()) { let _ = process.kill(); let _ = process.wait(Instant::now() + TIMEOUT); @@ -2639,7 +2641,7 @@ fn a_clone_that_crashes_after_its_spawn_keeps_its_lease_until_its_unrecorded_vmm crashed.refuses_release(&mut locked); // Once the VMM exits, the claim that it released proves it. crashed.vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); match reattach_until_determinate(&mut locked) { Reattached::Exited { record: None, @@ -2669,7 +2671,7 @@ fn a_clone_that_crashes_after_recording_its_vmm_keeps_its_lease_until_reattach_e } => assert_eq!(terminated, reference), other => panic!("unexpected reattach: {other:?}"), } - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); crashed.releases(&mut locked); } diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 1429a7a26..72e724aa6 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -16,6 +16,7 @@ use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io; use std::path::{Path, PathBuf}; +use std::time::Instant; use crate::host::config::{ConsoleAttachment, RootConfig, UpperConfig, VmConfig}; use crate::host::filesystem; @@ -25,6 +26,7 @@ use crate::host::platform; use crate::host::platform::{check_store_dir as check_dir, create_store_dir as create_dir}; #[cfg(windows)] use crate::host::platform::{check_vm_dir as check_dir, create_vm_dir as create_dir}; +use crate::host::report::{self, Report}; use crate::host::runtime::{Runtime, RuntimeIdentity}; use crate::host::snapshot::Snapshot; use crate::host::spawn::{self, VmmCommand}; @@ -522,6 +524,78 @@ pub fn spawn(prepared: Prepared) -> Result { spawn_verified(prepared, spawn::Launched::process) } +/// A confirmed VM exit, with the outcome-report read kept separate from exit proof. +#[derive(Debug)] +pub struct Stopped { + /// Exit was confirmed before any forced termination was attempted. + pub self_exited: bool, + /// Forced termination through the verified process was attempted. + pub forced: bool, + /// The outcome summary, missing report, or report-read error after confirmed exit. + pub report: Result>, +} + +/// Stops a VM, returning only after its verified process has exited. +/// +/// Unless exit is already confirmed, calls `graceful` with `grace`, bounded by `deadline`, +/// then waits until that grace deadline. The callback must respect the supplied deadline; +/// its boolean request result is not exit proof. If exit is still unconfirmed, attempts +/// forced termination and waits until `deadline`. A failed termination is harmless only +/// when that final wait confirms exit. +/// +/// An unconfirmed exit fails with [`ErrorKind::StillRunning`](crate::ErrorKind::StillRunning), +/// retaining host errors. The caller keeps the process in that case. +/// Report-read errors have kind [`ErrorKind::Io`](crate::ErrorKind::Io) +/// inside [`Stopped::report`], never hiding confirmed exit. +pub fn stop( + process: &Process, + outputs: &VmOutputs, + graceful: impl FnOnce(Instant) -> bool, + grace: Instant, + deadline: Instant, +) -> Result { + let failure = |kill_error: Option<&io::Error>| { + let mut message = format!( + "cannot confirm exit of VM process {}", + process.reference().pid + ); + if let Some(error) = kill_error { + message.push_str(&format!("; forced termination failed: {error}")); + } + Error::new(crate::ErrorKind::StillRunning, message) + }; + let wait = |until| { + process + .wait(until) + .map_err(|error| failure(None).with_source(error)) + }; + let self_exited = if wait(Instant::now())?.is_some() { + true + } else { + let grace = grace.min(deadline); + graceful(grace); + wait(grace)?.is_some() + }; + if !self_exited { + let kill_error = process.kill().err(); + let exited = process + .wait(deadline) + .map_err(|error| failure(kill_error.as_ref()).with_source(error))?; + if exited.is_none() { + let error = failure(kill_error.as_ref()); + return Err(match kill_error { + Some(source) => error.with_source(source), + None => error, + }); + } + } + Ok(Stopped { + self_exited, + forced: !self_exited, + report: report::read(&outputs.outcome), + }) +} + /// Keeps the child owned until its process identity is verified, or its exit is confirmed. fn spawn_verified( prepared: Prepared, diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index 05af49bd0..b637a7532 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -1098,6 +1098,7 @@ fn spawn_caller_helper() { .process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); panic!("cannot record the helper's child: {error}"); } @@ -1181,6 +1182,7 @@ fn an_unrelated_process_started_in_the_library_spawn_window_gets_no_vm_claim() { .process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); assert!( platform::tests::claim_released_within(&log, identity, Duration::from_secs(10)), @@ -1282,10 +1284,193 @@ fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { ) .unwrap(); read_until(&mut *console, b"NVX-SPAWN-COLD-OK"); - assert!(launched.0.process.wait(deadline).unwrap()); + assert!(launched.0.process.wait(deadline).unwrap().is_some()); assert!(reopened.has_exited().unwrap()); - let outcome = crate::host::report::read(&launched.0.outputs.outcome).unwrap(); + let outcome = crate::host::report::read(&launched.0.outputs.outcome) + .unwrap() + .unwrap(); assert_eq!(outcome.category.as_deref(), Some("success")); assert_eq!(outcome.status_code, Some(0)); println!("NVX-LIBRARY-COLD-SPAWN-EXIT-OK: {}", reference.pid); } + +#[cfg(any(target_os = "linux", windows))] +struct StopChild { + child: std::process::Child, + process: Process, +} + +#[cfg(any(target_os = "linux", windows))] +impl StopChild { + fn start() -> Self { + use std::io::{BufRead, BufReader}; + + #[cfg(windows)] + let mut command = { + let mut command = Command::new( + PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join("System32\\cmd.exe"), + ); + command.args(["/d", "/q", "/c", "echo ready & set /p wait="]); + command + }; + #[cfg(target_os = "linux")] + let mut command = { + let mut command = Command::new("sh"); + command.args(["-c", "printf 'ready\\n'; read -r wait"]); + command + }; + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let mut child = crate::host::tests::spawn_locked(&mut command).unwrap(); + let mut ready = String::new(); + BufReader::new(child.stdout.take().unwrap()) + .read_line(&mut ready) + .unwrap(); + assert_eq!(ready.trim_end(), "ready"); + let reference = crate::host::process::ProcessRef { + pid: child.id(), + start_time: platform::process_start_time(child.id()).unwrap().unwrap(), + boot: crate::host::process::BootId::current().unwrap(), + }; + let process = Process::open(&reference).unwrap().unwrap(); + Self { child, process } + } +} + +#[cfg(any(target_os = "linux", windows))] +impl Drop for StopChild { + fn drop(&mut self) { + if self.child.try_wait().unwrap().is_none() { + self.child.kill().unwrap(); + } + self.child.wait().unwrap(); + } +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn stop_preserves_report_results_after_a_real_self_exit() { + for report in ["missing", "valid", "malformed", "unreadable"] { + let (_directory, vm) = vm_dir(); + let outputs = vm.outputs(false).unwrap(); + match report { + "missing" => {} + "valid" => fs::write( + &outputs.outcome, + br#"{"outcome":{"category":"success","status_code":0},"teardown":{"workers":true}}"#, + ).unwrap(), + "malformed" => fs::write(&outputs.outcome, b"{ truncated").unwrap(), + "unreadable" => fs::create_dir(&outputs.outcome).unwrap(), + _ => unreachable!(), + } + let mut child = StopChild::start(); + let deadline = Instant::now() + Duration::from_secs(10); + let stopped = stop( + &child.process, + &outputs, + |supplied| { + assert_eq!(supplied, deadline); + drop(child.child.stdin.take()); + false + }, + deadline, + deadline, + ) + .unwrap(); + assert!(stopped.self_exited); + assert!(!stopped.forced); + assert!(child.process.wait(Instant::now()).unwrap().is_some()); + assert!(child.child.try_wait().unwrap().is_some()); + let already_exited = stop( + &child.process, + &outputs, + |_| panic!("an exited process needs no graceful request"), + Instant::now(), + Instant::now(), + ) + .unwrap(); + assert!(already_exited.self_exited); + assert!(!already_exited.forced); + match report { + "missing" => assert!(stopped.report.unwrap().is_none()), + "valid" => assert_eq!( + stopped.report.unwrap(), + Some(Report { + category: Some("success".to_owned()), + status_code: Some(0), + teardown_complete: Some(true), + }) + ), + _ => { + let error = stopped.report.unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert!(std::error::Error::source(&error).is_some()); + } + } + } +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn stop_forces_only_its_verified_process_after_grace() { + let (_directory, vm) = vm_dir(); + let outputs = vm.outputs(false).unwrap(); + let mut child = StopChild::start(); + let mut other = StopChild::start(); + let grace = Instant::now() + Duration::from_millis(80); + let deadline = grace + Duration::from_secs(10); + let stopped = stop( + &child.process, + &outputs, + |supplied| { + assert_eq!(supplied, grace); + true + }, + grace, + deadline, + ) + .unwrap(); + assert!(!stopped.self_exited); + assert!(stopped.forced); + assert!(stopped.report.unwrap().is_none()); + assert!(child.process.wait(Instant::now()).unwrap().is_some()); + assert!(child.child.try_wait().unwrap().is_some()); + assert!(other.process.wait(Instant::now()).unwrap().is_none()); + assert!(other.child.try_wait().unwrap().is_none()); +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn stop_returns_still_running_without_exit_confirmation_at_its_deadline() { + let (_directory, vm) = vm_dir(); + let outputs = vm.outputs(false).unwrap(); + fs::write(&outputs.outcome, b"{ truncated").unwrap(); + let mut child = StopChild::start(); + #[cfg(target_os = "linux")] + let handle = platform::ProcessHandle::without_pidfd(); + #[cfg(windows)] + let handle = platform::ProcessHandle::wait_only(child.child.id()); + let process = Process::held(child.process.reference().clone(), handle); + let grace = Instant::now(); + let deadline = grace + Duration::from_millis(80); + let error = stop(&process, &outputs, |_| false, grace, deadline).unwrap_err(); + assert!(Instant::now() >= deadline); + assert_eq!(error.kind(), ErrorKind::StillRunning); + assert_eq!(error.code().as_str(), "still-running"); + let cause = std::error::Error::source(&error) + .unwrap() + .downcast_ref::() + .unwrap(); + assert_eq!( + cause.kind(), + if cfg!(windows) { + io::ErrorKind::PermissionDenied + } else { + io::ErrorKind::Unsupported + } + ); + assert!(process.wait(Instant::now()).unwrap().is_none()); + assert!(child.child.try_wait().unwrap().is_none()); +} diff --git a/nvx-rs/src/host/vm/tests/reattach.rs b/nvx-rs/src/host/vm/tests/reattach.rs index 88a95d005..0611675b6 100644 --- a/nvx-rs/src/host/vm/tests/reattach.rs +++ b/nvx-rs/src/host/vm/tests/reattach.rs @@ -244,7 +244,7 @@ fn reattaching_returns_the_held_owned_child_and_authenticated_connection() { b"response" ); owner.wait(); - assert!(attached.process.wait(deadline).unwrap()); + assert!(attached.process.wait(deadline).unwrap().is_some()); } #[test] diff --git a/nvx-rs/tests/checked_handoff.rs b/nvx-rs/tests/checked_handoff.rs index b07a9e340..8f1c7b07b 100644 --- a/nvx-rs/tests/checked_handoff.rs +++ b/nvx-rs/tests/checked_handoff.rs @@ -703,6 +703,7 @@ fn a_prepared_launch_hands_the_vmm_its_checked_images_alone() { .unwrap() .wait(Instant::now() + TIMEOUT) .unwrap() + .is_some() ); launched.kill_and_wait(); let scratch = fs::read_to_string(staged.scratch()).unwrap(); @@ -788,6 +789,7 @@ fn a_vmm_keeps_the_scratch_images_claim_once_its_launcher_is_gone() { .unwrap() .wait(Instant::now() + TIMEOUT) .unwrap() + .is_some() ); launched.kill_and_wait(); drop(check_written().unwrap()); diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index f3b64de5b..01191d000 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -1007,9 +1007,10 @@ fn a_stop_under_another_identity_never_ends_a_process_that_its_record_does_not_n ); assert_eq!(runtime_record(&root, "state"), None); assert!( - !process + process .wait(Instant::now() + Duration::from_millis(200)) - .unwrap(), + .unwrap() + .is_none(), "{field}" ); process.kill().unwrap(); @@ -1017,6 +1018,7 @@ fn a_stop_under_another_identity_never_ends_a_process_that_its_record_does_not_n process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); } assert_eq!(transition(&root, "deprovision", "state", &[]), succeeded()); @@ -1153,6 +1155,7 @@ fn stop_reports_its_timeout_and_leaves_the_vm_running() { process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); assert_eq!( transition(&root, "deprovision", "unanswered", &[]), @@ -1173,6 +1176,7 @@ fn stop_reports_its_timeout_and_leaves_the_vm_running() { process .wait(Instant::now() + Duration::from_secs(30)) .unwrap() + .is_some() ); assert_eq!( transition(&root, "stop", "slow", &[]), @@ -1194,6 +1198,7 @@ fn a_sandbox_whose_openvmm_exited_reports_it_once_and_starts_again() { process .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); }; crash(); diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index be959f532..f9d758cea 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -171,6 +171,22 @@ fn provision_and_deprovision_need_no_artifact_root() { let executable = elsewhere .path() .join(Path::new(NVX_RS).file_name().unwrap()); + #[cfg(unix)] + { + // Concurrent forks of this test process must inherit no writable executable. + let copied = Command::new("cp") + .arg("-p") + .arg(NVX_RS) + .arg(&executable) + .output() + .unwrap(); + assert!( + copied.status.success(), + "cannot copy the executable: {}", + String::from_utf8_lossy(&copied.stderr) + ); + } + #[cfg(windows)] fs::copy(NVX_RS, &executable).unwrap(); let nvx_rs = |arguments: &[String]| { let output = Command::new(&executable) diff --git a/nvx-rs/tests/cli/lifecycle.rs b/nvx-rs/tests/cli/lifecycle.rs index 7078aba9e..d82a0f548 100644 --- a/nvx-rs/tests/cli/lifecycle.rs +++ b/nvx-rs/tests/cli/lifecycle.rs @@ -604,7 +604,8 @@ fn check_lifecycle(case: &Configuration) { assert!( process .wait(Instant::now() + Duration::from_secs(10)) - .unwrap(), + .unwrap() + .is_some(), "{context}: OpenVMM runs on" ); assert_eq!(entries(&state), STOPPED, "{context}"); @@ -694,7 +695,8 @@ fn lifecycle_of_a_sandbox_whose_upper_is_in_ram() { assert!( process .wait(Instant::now() + Duration::from_secs(10)) - .unwrap(), + .unwrap() + .is_some(), "{context}: OpenVMM runs on" ); assert_eq!(entries(&state), STOPPED, "{context}"); @@ -737,7 +739,8 @@ fn stops_keep_an_outcome_report_that_the_caller_reads_whatever_its_umask() { assert!( process .wait(Instant::now() + Duration::from_secs(10)) - .unwrap(), + .unwrap() + .is_some(), "{context}: OpenVMM runs on" ); assert_eq!(entries(&state), STOPPED, "{context}"); @@ -1437,9 +1440,10 @@ fn recycled_process_id(root: &Root) { assert_refused(root, &outcome, "sandbox-exited", &["stale", "not running"]); assert!(!path.exists()); assert!( - !openvmm + openvmm .wait(Instant::now() + Duration::from_millis(200)) - .unwrap(), + .unwrap() + .is_none(), "the stale record's process was signaled" ); openvmm.kill().unwrap(); @@ -1447,6 +1451,7 @@ fn recycled_process_id(root: &Root) { openvmm .wait(Instant::now() + Duration::from_secs(10)) .unwrap() + .is_some() ); let outcome = operate(root, "stop", "state", &[]); assert_refused(root, &outcome, "sandbox-not-running", &["not running"]); diff --git a/nvx-rs/tests/fake_openvmm.rs b/nvx-rs/tests/fake_openvmm.rs index c667dbe84..b112f951b 100644 --- a/nvx-rs/tests/fake_openvmm.rs +++ b/nvx-rs/tests/fake_openvmm.rs @@ -478,7 +478,7 @@ fn outcome_reports_of_another_runtime_are_never_read() { .unwrap(); if exits_first { process.kill().unwrap(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } let stopped = locked.terminate(mismatched, TIMEOUT).unwrap(); assert_eq!( @@ -528,11 +528,12 @@ fn records_that_name_another_process_never_end_it() { drop(locked); assert!(records_nothing(&instance)); assert!( - !process + process .wait(Instant::now() + Duration::from_millis(200)) .unwrap() + .is_none() ); process.kill().unwrap(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } diff --git a/nvx-rs/tests/store.rs b/nvx-rs/tests/store.rs index 39d55d2e4..9602cce68 100644 --- a/nvx-rs/tests/store.rs +++ b/nvx-rs/tests/store.rs @@ -183,7 +183,9 @@ fn kill(pid: u32, start_time: u64) -> io::Result { return Ok(true); }; process.kill()?; - process.wait(Instant::now() + RECOVERY_TIMEOUT) + process + .wait(Instant::now() + RECOVERY_TIMEOUT) + .map(|exited| exited.is_some()) } /// A VMM that a crashed orchestrator left behind, which is killed when the test ends if it still @@ -533,7 +535,12 @@ fn a_committed_launch_names_its_vm_for_another_build() { .expect("the recorded VMM runs"); assert!(!reattached.has_exited().unwrap()); drop(vmm); - assert!(reattached.wait(Instant::now() + RECOVERY_TIMEOUT).unwrap()); + assert!( + reattached + .wait(Instant::now() + RECOVERY_TIMEOUT) + .unwrap() + .is_some() + ); other.lock(None).unwrap().clear_runtime().unwrap(); assert!(other.runtime().unwrap().is_none()); } diff --git a/nvx-rs/tests/supervisor.rs b/nvx-rs/tests/supervisor.rs index be2fb01a0..45b2058ab 100644 --- a/nvx-rs/tests/supervisor.rs +++ b/nvx-rs/tests/supervisor.rs @@ -450,7 +450,9 @@ impl Drop for VmmDirectory { let _ = File::create(self.path().join(EXIT_MARKER)); if let Some(reference) = &self.reference && let Ok(Some(process)) = Process::open(reference) - && !process.wait(Instant::now() + TIMEOUT).unwrap_or(false) + && !process + .wait(Instant::now() + TIMEOUT) + .is_ok_and(|exited| exited.is_some()) { let _ = process.kill(); let _ = process.wait(Instant::now() + TIMEOUT); @@ -613,7 +615,13 @@ fn long_stores_launch_with_a_short_working_directory() { let mut vmm = VmmDirectory::new(); let mut locked = store.lock(None).unwrap(); let running = launch_vmm(&mut locked, &mut vmm); - assert!(!running.process().wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!( + running + .process() + .wait(Instant::now() + SHORT_WAIT) + .unwrap() + .is_none() + ); let stopped = running .stop(&mut locked, Instant::now() + TIMEOUT, None, |_, _| { vmm.end(); @@ -671,15 +679,15 @@ fn a_child_that_exits_wakes_its_waiter() { assert_eq!(process.reference().boot, BootId::current().unwrap()); assert_eq!(process.wait_method(), WaitMethod::Event); assert!(!process.has_exited().unwrap()); - assert!(!process.wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!(process.wait(Instant::now() + SHORT_WAIT).unwrap().is_none()); // The reference finds the same process through a handle of its own. let found = open_running(process.reference()); assert_eq!(found.reference(), process.reference()); let started = Instant::now(); vmm.end(); - assert!(process.wait(started + TIMEOUT).unwrap()); - assert!(found.wait(started + TIMEOUT).unwrap()); + assert!(process.wait(started + TIMEOUT).unwrap().is_some()); + assert!(found.wait(started + TIMEOUT).unwrap().is_some()); assert!(started.elapsed() < TIMEOUT); assert_ended(&process); // Ending a process that exited does nothing. @@ -698,12 +706,12 @@ fn a_process_that_is_not_a_child_wakes_its_waiters() { launcher.finish(); let process = open_running(&reference); assert_eq!(process.wait_method(), WaitMethod::Event); - assert!(!process.wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!(process.wait(Instant::now() + SHORT_WAIT).unwrap().is_none()); let started = Instant::now(); let waiters: Vec = thread::scope(|scope| { let waiters: Vec<_> = (0..2) - .map(|_| scope.spawn(|| process.wait(started + TIMEOUT).unwrap())) + .map(|_| scope.spawn(|| process.wait(started + TIMEOUT).unwrap().is_some())) .collect(); vmm.end(); waiters @@ -740,10 +748,10 @@ fn a_restarted_launcher_finds_its_vmm_again_and_ends_it() { vmm.record(&reference); let process = open_running(&reference); assert_eq!(process.wait_method(), WaitMethod::Event); - assert!(!process.wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!(process.wait(Instant::now() + SHORT_WAIT).unwrap().is_none()); let started = Instant::now(); process.kill().unwrap(); - assert!(process.wait(started + TIMEOUT).unwrap()); + assert!(process.wait(started + TIMEOUT).unwrap().is_some()); assert_ended(&process); } @@ -783,7 +791,7 @@ fn a_reused_process_id_is_never_signaled() { assert!(!process.has_exited().unwrap()); open_running(&reference); vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(launched.kill_and_wait()); } @@ -814,7 +822,7 @@ fn references_that_name_no_process_are_never_opened() { let launched = spawn_vmm(vmm.path(), false); let process = launched.process().unwrap(); vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(launched.kill_and_wait()); assert!(Process::open(process.reference()).unwrap().is_none()); assert!(process.has_exited().unwrap()); @@ -877,7 +885,7 @@ fn an_unrecorded_vmm_that_serves_no_endpoint_is_never_signaled() { assert!(store.journal().unwrap().is_some()); // Once the VMM exits, the claim that it released proves it. vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); match reattach_until_determinate(&mut locked) { Reattached::Exited { record: None, @@ -1264,7 +1272,7 @@ fn check_vmm(reader: PipeReader, writer: &PipeWriter, others: &Mutex> } } vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(launched.kill_and_wait()); } @@ -1493,7 +1501,7 @@ fn a_vmm_in_its_orchestrators_job_ends_with_it() { // The VMM did not break away, so closing the job ends it with its orchestrator. drop(job); orchestrator.wait(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); let mut locked = store.lock(Some(Instant::now() + TIMEOUT)).unwrap(); match supervisor::reattach(&mut locked, &runtime()).unwrap() { Reattached::Exited { @@ -1517,10 +1525,10 @@ fn a_job_that_ends_its_vmm_wakes_the_waiters() { vmm.record(&reference); // The VMM did not break away, so the job holds it too, and closing the job ends it. let process = open_running(&reference); - assert!(!process.wait(Instant::now() + SHORT_WAIT).unwrap()); + assert!(process.wait(Instant::now() + SHORT_WAIT).unwrap().is_none()); let started = Instant::now(); drop(job); - assert!(process.wait(started + TIMEOUT).unwrap()); + assert!(process.wait(started + TIMEOUT).unwrap().is_some()); assert_ended(&process); launcher.wait(); } @@ -1587,7 +1595,7 @@ fn a_process_that_this_user_may_not_terminate_is_waited_for_but_never_signaled() // Its creator ends it, which wakes the waiter, and ending a process that exited does nothing. child.0.kill().unwrap(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap()); + assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); process.kill().unwrap(); assert_ended(&process); } diff --git a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs index 449ceb1f0..844c9a4f0 100644 --- a/tests/runtime-adapter/src/bin/runtime-adapter-host.rs +++ b/tests/runtime-adapter/src/bin/runtime-adapter-host.rs @@ -2053,6 +2053,7 @@ fn launch_failed( let exited = starting .process() .wait(Instant::now() + STOP_TIMEOUT) + .map(|exited| exited.is_some()) .map_err(|error| format!("cannot wait for the VM to exit: {error}"))?; starting.abandon().map_err(|error| describe(&error))?; println!("stopped={}", if exited { "graceful" } else { "forced" }); @@ -2078,6 +2079,7 @@ fn table_failed( Ok(_) => starting .process() .wait(Instant::now() + STOP_TIMEOUT) + .map(|exited| exited.is_some()) .map_err(|error| format!("cannot wait for the VM to exit: {error}"))?, Err(_) => false, }; @@ -3625,12 +3627,14 @@ fn hold(locked: &mut LockedStore, runtime: &Runtime) -> Result Result<(), String> { - let outcome = report::read(&store.outcome_path()).ok_or_else(|| { - format!( - "the VM left no outcome report {}", - store.outcome_path().display() - ) - })?; + let outcome = report::read(&store.outcome_path()) + .map_err(|error| error.to_string())? + .ok_or_else(|| { + format!( + "the VM left no outcome report {}", + store.outcome_path().display() + ) + })?; print_outcome(&outcome); if ( outcome.category.as_deref(), diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index 654fad486..cbc20e665 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -2321,7 +2321,7 @@ impl CloneControl for Early { .send(&self.early, Some(deadline)) .map_err(io::Error::other)?; let answer = session.receive(Some(deadline)); - self.exited = self.vmm.wait(deadline)?; + self.exited = self.vmm.wait(deadline)?.is_some(); Err(match answer { Ok(message) => io::Error::other(format!("the clone answered {message:?}")), Err(error) => io::Error::other(error), @@ -3910,7 +3910,9 @@ fn a_host_program_of_another_runtime_ends_the_clone_through_its_verified_identit assert!(!ended.contains_key("epoch"), "{ended:?}"); assert!(sandbox.stderr().contains("differs in initramfs")); assert!( - vmm.wait(Instant::now() + Duration::from_secs(10)).unwrap(), + vmm.wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + .is_some(), "the clone's VMM still runs" ); assert!(sandbox.record().is_none()); @@ -3948,7 +3950,9 @@ fn a_clone_whose_vm_exits_once_a_stop_found_it_fails_before_its_lease_is_release assert_eq!(sandbox.paused(&mut stopping, &pause), "found"); vmm.kill().unwrap(); assert!( - vmm.wait(Instant::now() + Duration::from_secs(10)).unwrap(), + vmm.wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + .is_some(), "the clone's VMM still runs" ); fs::remove_file(&pause).unwrap(); @@ -5313,7 +5317,9 @@ fn a_run_whose_clone_s_vm_exits_during_its_execution_ends_with_the_clone_s_failu let vmm = sandbox.vmm().expect("the clone's VMM runs"); vmm.kill().unwrap(); assert!( - vmm.wait(Instant::now() + Duration::from_secs(10)).unwrap(), + vmm.wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + .is_some(), "the clone's VMM still runs" ); From 99d004572b8ffad55c48bd7885278457297cbda6 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:11:03 -0800 Subject: [PATCH 02/30] host: journal CLI launches and persist saved VM records Port the scoped SL3.2b donor cf485b72 onto verified feature c52c7101 without its historical producer history. Persist prepared capability and outputs before spawn, the matching process before commit, and every SavedVm field in the private runtime record. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/files.rs | 51 +++ nvx-rs/src/host/cli/store/launch.rs | 80 +++++ nvx-rs/src/host/cli/store/mod.rs | 7 + nvx-rs/src/host/cli/store/record.rs | 159 +++++++++ nvx-rs/src/host/cli/store/tests.rs | 2 + nvx-rs/src/host/cli/store/tests/launch.rs | 382 ++++++++++++++++++++++ 6 files changed, 681 insertions(+) create mode 100644 nvx-rs/src/host/cli/store/files.rs create mode 100644 nvx-rs/src/host/cli/store/launch.rs create mode 100644 nvx-rs/src/host/cli/store/record.rs create mode 100644 nvx-rs/src/host/cli/store/tests/launch.rs diff --git a/nvx-rs/src/host/cli/store/files.rs b/nvx-rs/src/host/cli/store/files.rs new file mode 100644 index 000000000..9050cf281 --- /dev/null +++ b/nvx-rs/src/host/cli/store/files.rs @@ -0,0 +1,51 @@ +//! Private, regular, non-link files, atomically replaced and synced under the state lock. + +use std::fs; +use std::io; + +use super::StateFile; +use super::locked::LockedState; +use crate::host::platform; +use crate::host::store::{files, regular}; +use crate::{Error, Result}; + +/// Bounds a fixed record's paths and identities without reading an unbounded corrupt file. +const MAX_RECORD_BYTES: u64 = 1024 * 1024; + +pub(super) fn read(state: &LockedState, name: StateFile) -> Result>> { + let path = state.path(name); + let failed = |error| Error::io(format!("cannot read {}", path.display())).with_source(error); + let file = match regular::open_regular(&path) { + Ok(file) => file, + Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(failed(error)), + }; + if let Some(problem) = platform::private_file_problem(&file).map_err(failed)? { + return Err(Error::io(format!( + "the sandbox file {} {problem}", + path.display() + ))); + } + regular::read_at_most(file, MAX_RECORD_BYTES, "a sandbox record") + .map(Some) + .map_err(failed) +} + +pub(super) fn replace(state: &LockedState, name: StateFile, bytes: &[u8]) -> Result<()> { + if bytes.len() as u64 > MAX_RECORD_BYTES { + return Err(Error::io("the sandbox record exceeds its size bound")); + } + // Refuse an existing unsafe entry rather than replacing it or following its target. + read(state, name)?; + files::write_private(&state.path(name), bytes)?; + files::sync_dir(state.dir()) +} + +pub(super) fn remove(state: &LockedState, name: StateFile) -> Result<()> { + read(state, name)?; + let path = state.path(name); + fs::remove_file(&path).map_err(|error| { + Error::io(format!("cannot remove {}", path.display())).with_source(error) + })?; + files::sync_dir(state.dir()) +} diff --git a/nvx-rs/src/host/cli/store/launch.rs b/nvx-rs/src/host/cli/store/launch.rs new file mode 100644 index 000000000..d0a5c04e6 --- /dev/null +++ b/nvx-rs/src/host/cli/store/launch.rs @@ -0,0 +1,80 @@ +//! Locked launch transitions, without spawning, recovery, or process termination. + +use super::StateFile; +use super::files; +use super::locked::LockedState; +use super::record::{LaunchJournal, RuntimeRecord}; +use crate::host::process::ProcessRef; +use crate::host::runtime::RuntimeIdentity; +use crate::host::vm::{Prepared, SavedVm}; +use crate::{Error, Result}; + +/// A launch whose lock stays borrowed until commit or until its journal is left behind. +#[derive(Debug)] +pub(crate) struct Launch<'a> { + locked: &'a mut LockedState, + journal: LaunchJournal, +} + +impl LockedState { + /// Durably publishes the capability and complete prepared journal before the caller spawns. + pub(crate) fn begin_launch( + &mut self, + prepared: &Prepared, + runtime: &RuntimeIdentity, + ) -> Result> { + if self.journal()?.is_some() || self.runtime()?.is_some() { + return Err(Error::other( + "the sandbox already has a runtime record or an interrupted launch", + )); + } + let journal = LaunchJournal::prepared(self, prepared, runtime)?; + files::replace(self, StateFile::Capability, &journal.capability)?; + self.write_record(StateFile::Journal, &journal)?; + Ok(Launch { + locked: self, + journal, + }) + } +} + +impl Launch<'_> { + /// Records only the caller's spawned process, durably, before changing the in-memory state. + pub(crate) fn record_process(&mut self, process: &ProcessRef) -> Result<()> { + if self.journal.process.is_some() { + return Err(Error::other("the launch has recorded its process already")); + } + let journal = LaunchJournal { + process: Some(process.clone()), + ..self.journal.clone() + }; + self.locked.write_record(StateFile::Journal, &journal)?; + self.journal = journal; + Ok(()) + } + + /// Publishes the exact saved VM, only after its matching process was journaled. + /// + /// The runtime record is the commit. A failed journal removal keeps that committed record + /// and reports the failure; it never rolls back the commit or signals the VM. + pub(crate) fn commit(self, saved: &SavedVm) -> Result<()> { + if self.journal.process.as_ref() != Some(&saved.process) + || self.journal.vm_dir != saved.vm_dir.path() + || self.journal.endpoint != saved.endpoint + || self.journal.capability != saved.capability + || self.journal.runtime != saved.runtime + { + return Err(Error::other( + "the saved VM does not match the launch and its journaled process", + )); + } + self.locked + .write_record(StateFile::Runtime, &RuntimeRecord::from(saved))?; + files::remove(self.locked, StateFile::Journal).map_err(|error| { + Error::io(format!( + "the VM runtime was committed but its launch journal could not be removed: {error}" + )) + .with_source(error) + }) + } +} diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index 6f0c6479f..16bcb2432 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -26,6 +26,10 @@ //! and the Administrators group, as one under the user's profile does. An existing directory that //! others may access is refused rather than restricted, since they could have placed files in it, //! or still hold them open. The store creates its files readable only by the current user. +//! +//! A locked launch writes its prepared capability and outputs before spawn, records the spawned +//! process durably, and publishes the caller's exact saved VM only at commit. Dropping an +//! uncommitted launch keeps its journal; these transitions do not recover or signal a process. #![cfg_attr( not(test), expect( @@ -34,8 +38,11 @@ ) )] +mod files; +mod launch; mod locked; mod names; +mod record; use std::io; use std::path::{Path, PathBuf}; diff --git a/nvx-rs/src/host/cli/store/record.rs b/nvx-rs/src/host/cli/store/record.rs new file mode 100644 index 000000000..d84718801 --- /dev/null +++ b/nvx-rs/src/host/cli/store/record.rs @@ -0,0 +1,159 @@ +//! The fixed launch journal and the caller-saved VM's runtime record. + +use std::fmt; +use std::path::PathBuf; + +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; + +use super::StateFile; +use super::files; +use super::locked::LockedState; +use crate::host::process::ProcessRef; +use crate::host::runtime::RuntimeIdentity; +use crate::host::vm::{Prepared, SavedVm, VmDir, VmOutputs}; +use crate::protocol::CAPABILITY_LEN; +use crate::{Error, Result}; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct LaunchJournal { + pub(super) vm_dir: PathBuf, + pub(super) capability: [u8; CAPABILITY_LEN], + pub(super) log: PathBuf, + pub(super) outcome: PathBuf, + pub(super) endpoint: String, + pub(super) console: Option, + pub(super) runtime: RuntimeIdentity, + pub(super) process: Option, +} + +impl fmt::Debug for LaunchJournal { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LaunchJournal") + .field("vm_dir", &self.vm_dir) + .field("log", &self.log) + .field("outcome", &self.outcome) + .field("endpoint", &self.endpoint) + .field("console", &self.console) + .field("runtime", &self.runtime) + .field("process", &self.process) + .finish_non_exhaustive() + } +} + +impl LaunchJournal { + pub(super) fn prepared( + state: &LockedState, + prepared: &Prepared, + runtime: &RuntimeIdentity, + ) -> Result { + runtime.check()?; + let vm_dir = VmDir::open(state.dir())?; + let VmOutputs { + log, + outcome, + endpoint, + console, + } = prepared.outputs(); + if *log != vm_dir.path().join(StateFile::Log.name()) + || *outcome != vm_dir.path().join(StateFile::Outcome.name()) + { + return Err(Error::other( + "the prepared launch belongs to another sandbox directory", + )); + } + Ok(Self { + vm_dir: vm_dir.path().to_path_buf(), + capability: *prepared.capability().as_bytes(), + log: log.clone(), + outcome: outcome.clone(), + endpoint: endpoint.clone(), + console: console.clone(), + runtime: runtime.clone(), + process: None, + }) + } + + pub(crate) fn process(&self) -> Option<&ProcessRef> { + self.process.as_ref() + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct RuntimeRecord { + vm_dir: PathBuf, + endpoint: String, + capability: [u8; CAPABILITY_LEN], + process: ProcessRef, + runtime: RuntimeIdentity, +} + +impl From<&SavedVm> for RuntimeRecord { + fn from(saved: &SavedVm) -> Self { + Self { + vm_dir: saved.vm_dir.path().to_path_buf(), + endpoint: saved.endpoint.clone(), + capability: saved.capability, + process: saved.process.clone(), + runtime: saved.runtime.clone(), + } + } +} + +impl LockedState { + pub(crate) fn journal(&self) -> Result> { + self.read_record(StateFile::Journal) + } + + pub(crate) fn runtime(&self) -> Result> { + let Some(record) = self.read_record::(StateFile::Runtime)? else { + return Ok(None); + }; + let vm_dir = VmDir::open(self.dir())?; + if record.vm_dir != vm_dir.path() { + return Err(Error::io( + "the runtime record belongs to another sandbox directory", + )); + } + Ok(Some(SavedVm { + vm_dir, + endpoint: record.endpoint, + capability: record.capability, + process: record.process, + runtime: record.runtime, + })) + } + + pub(crate) fn capability(&self) -> Result> { + files::read(self, StateFile::Capability)? + .map(|bytes| { + bytes.try_into().map_err(|_| { + Error::io(format!( + "the sandbox capability must hold {CAPABILITY_LEN} bytes" + )) + }) + }) + .transpose() + } + + fn read_record(&self, name: StateFile) -> Result> { + files::read(self, name)? + .map(|bytes| { + serde_json::from_slice(&bytes).map_err(|error| { + Error::io(format!("cannot parse {}", self.path(name).display())) + .with_source(error) + }) + }) + .transpose() + } + + pub(super) fn write_record(&self, name: StateFile, record: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec(record).map_err(|error| { + Error::io(format!("cannot serialize {}", self.path(name).display())).with_source(error) + })?; + files::replace(self, name, &bytes) + } +} diff --git a/nvx-rs/src/host/cli/store/tests.rs b/nvx-rs/src/host/cli/store/tests.rs index bb36e8418..f28a33df9 100644 --- a/nvx-rs/src/host/cli/store/tests.rs +++ b/nvx-rs/src/host/cli/store/tests.rs @@ -8,6 +8,8 @@ use super::*; use crate::host::cli::deadline::Deadline; use crate::host::store::{JOURNAL_NAME, RUNTIME_NAME, SandboxStore}; +mod launch; + /// How long a test waits for a lock that it released to be free. /// /// A process that a concurrent test spawns holds a copy of each descriptor of the test process diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs new file mode 100644 index 000000000..aff660e14 --- /dev/null +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -0,0 +1,382 @@ +use std::error::Error as _; + +use super::*; +use crate::ErrorKind; +use crate::activation::{MachineIdMode, View}; +use crate::host::cli::store::{files, record}; +use crate::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; +use crate::host::process::{BootId, ProcessRef}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; +use crate::host::vm::{self, Prepared, SavedVm, VmDir}; +use crate::hypervisor::Hypervisor; +use crate::protocol::CAPABILITY_LEN; + +fn prepared(state: &StateDir, console: ConsoleAttachment) -> (Prepared, RuntimeIdentity) { + let identity = |text| ArtifactIdentity::new(text).unwrap(); + let runtime = Runtime { + openvmm: "missing-openvmm".into(), + kernel: "missing-kernel".into(), + initramfs: "missing-initramfs".into(), + identity: RuntimeIdentity { + openvmm: identity("vmm-revision"), + kernel: identity("kernel-revision"), + initramfs: identity("initramfs-revision"), + boot_contract: u64::MAX, + control_contract: "saved-control-revision".to_owned(), + control_session: u64::MAX - 1, + }, + hypervisor: if cfg!(windows) { + Hypervisor::Whp + } else { + Hypervisor::Mshv + }, + cpu_profile: None, + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let config = VmConfig { + processors: 1, + memory_mib: 256, + root: RootConfig::Blockless, + view: View { + hostname: b"journal-test".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console, + boot: Some(Boot { + command_line: "nvx_entrypoint=/bin/sh".to_owned(), + capture: None, + }), + }; + let vm_dir = VmDir::open(state.dir()).unwrap(); + ( + vm::create(&runtime, &config, None, &vm_dir).unwrap(), + runtime.identity, + ) +} + +fn saved(state: &StateDir, prepared: &Prepared, runtime: RuntimeIdentity) -> SavedVm { + SavedVm { + vm_dir: VmDir::open(state.dir()).unwrap(), + endpoint: prepared.outputs().endpoint.clone(), + capability: *prepared.capability().as_bytes(), + process: ProcessRef { + pid: u32::MAX, + start_time: u64::MAX, + boot: BootId::from_bytes([0xa5; 16]), + }, + runtime, + } +} + +fn assert_saved(actual: &SavedVm, expected: &SavedVm) { + assert_eq!(actual.vm_dir, expected.vm_dir); + assert_eq!(actual.endpoint, expected.endpoint); + assert_eq!(actual.capability, expected.capability); + assert_eq!(actual.process, expected.process); + assert_eq!(actual.runtime, expected.runtime); +} + +fn read_file(locked: &locked::LockedState, name: StateFile) -> crate::Result { + match name { + StateFile::Journal => locked.journal().map(|record| record.is_some()), + StateFile::Runtime => locked.runtime().map(|record| record.is_some()), + StateFile::Capability => locked.capability().map(|record| record.is_some()), + _ => unreachable!(), + } +} + +#[test] +fn the_pre_spawn_barrier_and_commit_preserve_every_prepared_and_saved_value() { + for console in [ConsoleAttachment::Listener, ConsoleAttachment::Disconnected] { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, console); + let saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let spawn_hook = || { + let bytes = fs::read(state.path(StateFile::Journal)).unwrap(); + let journal: record::LaunchJournal = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(journal.vm_dir, saved.vm_dir.path()); + assert_eq!(journal.capability, saved.capability); + assert_eq!(journal.log, prepared.outputs().log); + assert_eq!(journal.outcome, prepared.outputs().outcome); + assert_eq!(journal.endpoint, prepared.outputs().endpoint); + assert_eq!(journal.console, prepared.outputs().console); + assert_eq!(journal.runtime, runtime); + assert_eq!(journal.process(), None); + assert!(!format!("{journal:?}").contains(&format!("{:?}", saved.capability))); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + saved.capability + ); + for name in [StateFile::Journal, StateFile::Capability] { + let path = state.path(name); + let metadata = fs::symlink_metadata(&path).unwrap(); + assert!(metadata.is_file()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(metadata.permissions().mode() & 0o7777, 0o600); + } + let file = fs::File::open(path).unwrap(); + assert_eq!(platform::private_file_problem(&file).unwrap(), None); + } + assert!(!state.path(StateFile::Runtime).exists()); + }; + spawn_hook(); + launch.record_process(&saved.process).unwrap(); + let journal: record::LaunchJournal = + serde_json::from_slice(&fs::read(state.path(StateFile::Journal)).unwrap()).unwrap(); + assert_eq!(journal.process(), Some(&saved.process)); + assert!(!state.path(StateFile::Runtime).exists()); + launch.commit(&saved).unwrap(); + assert!(!state.path(StateFile::Journal).exists()); + assert_saved(&locked.runtime().unwrap().unwrap(), &saved); + assert_eq!(locked.capability().unwrap(), Some(saved.capability)); + drop(locked); + drop(prepared); + let reopened = StateDir::open(state.dir()) + .unwrap() + .lock(Deadline::after(10.0)) + .unwrap(); + assert_saved(&reopened.runtime().unwrap().unwrap(), &saved); + for name in [StateFile::Runtime, StateFile::Capability] { + let path = state.path(name); + let metadata = fs::symlink_metadata(&path).unwrap(); + assert!(metadata.is_file()); + assert!(!crate::host::filesystem::is_reparse_point(&metadata)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!(metadata.permissions().mode() & 0o7777, 0o600); + } + let file = fs::File::open(path).unwrap(); + assert_eq!(platform::private_file_problem(&file).unwrap(), None); + } + } +} + +#[test] +fn an_interruption_keeps_the_journal_and_capability_without_publishing_runtime() { + for spawned in [false, true] { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Listener); + let saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + { + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + if spawned { + launch.record_process(&saved.process).unwrap(); + } + } + drop(locked); + let mut reopened = state.lock(Deadline::after(10.0)).unwrap(); + let journal = reopened.journal().unwrap().unwrap(); + assert_eq!(journal.process(), spawned.then_some(&saved.process)); + assert_eq!(journal.capability, saved.capability); + assert_eq!(reopened.capability().unwrap(), Some(saved.capability)); + assert!(reopened.runtime().unwrap().is_none()); + let before = fs::read(state.path(StateFile::Journal)).unwrap(); + assert!(reopened.begin_launch(&prepared, &runtime).is_err()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); + } +} + +#[test] +fn a_commit_needs_the_same_saved_values_and_an_already_journaled_process() { + for mismatch in 0..6 { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + if mismatch != 0 { + launch.record_process(&saved.process).unwrap(); + assert!(launch.record_process(&saved.process).is_err()); + } + let original = fs::read(state.path(StateFile::Journal)).unwrap(); + match mismatch { + 0 => {} + 1 => saved.process.start_time -= 1, + 2 => saved.endpoint.push('x'), + 3 => saved.capability[0] ^= 1, + 4 => saved.runtime.control_session -= 1, + 5 => { + let other = state.dir().join("other-vm"); + vm::create_private_dir(&other).unwrap(); + saved.vm_dir = VmDir::open(&other).unwrap(); + } + _ => unreachable!(), + } + assert!(launch.commit(&saved).is_err()); + assert!(!state.path(StateFile::Runtime).exists()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), original); + } +} + +#[test] +fn preparation_for_another_directory_and_failed_record_writes_do_not_publish_runtime() { + let (_directory, state) = state(); + let (_other_directory, other) = super::state(); + let (other_prepared, runtime) = prepared(&other, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(locked.begin_launch(&other_prepared, &runtime).is_err()); + assert!(!state.path(StateFile::Journal).exists()); + assert!(!state.path(StateFile::Capability).exists()); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let saved = saved(&state, &prepared, runtime.clone()); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let journal = state.path(StateFile::Journal); + let original = journal.with_extension("original"); + fs::rename(&journal, &original).unwrap(); + fs::create_dir(&journal).unwrap(); + let error = launch.record_process(&saved.process).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert!(error.source().is_some()); + fs::remove_dir(&journal).unwrap(); + fs::rename(&original, &journal).unwrap(); + assert!(launch.commit(&saved).is_err()); + assert!(!state.path(StateFile::Runtime).exists()); + assert!(locked.journal().unwrap().unwrap().process().is_none()); +} + +#[test] +fn a_failed_runtime_publication_keeps_the_spawned_journal() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + launch.record_process(&saved.process).unwrap(); + let before = fs::read(state.path(StateFile::Journal)).unwrap(); + fs::create_dir(state.path(StateFile::Runtime)).unwrap(); + let error = launch.commit(&saved).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert!(error.source().is_some()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); + assert_eq!( + fs::read_dir(state.path(StateFile::Runtime)) + .unwrap() + .count(), + 0 + ); +} + +#[test] +fn a_failed_journal_removal_reports_that_runtime_already_committed() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let saved = saved(&state, &prepared, runtime.clone()); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + launch.record_process(&saved.process).unwrap(); + fs::remove_file(state.path(StateFile::Journal)).unwrap(); + fs::create_dir(state.path(StateFile::Journal)).unwrap(); + let error = launch.commit(&saved).unwrap_err(); + assert!(error.message().contains("runtime was committed"), "{error}"); + assert!(error.source().unwrap().source().is_some()); + assert_saved(&locked.runtime().unwrap().unwrap(), &saved); + assert!(state.path(StateFile::Journal).is_dir()); +} + +#[test] +fn malformed_records_and_capabilities_keep_their_parse_errors() { + let (_directory, state) = state(); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + for name in [StateFile::Journal, StateFile::Runtime] { + files::replace(&locked, name, b"{malformed").unwrap(); + let error = read_file(&locked, name).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Io); + assert!( + error + .source() + .unwrap() + .downcast_ref::() + .is_some(), + "{error}" + ); + } + files::replace(&locked, StateFile::Capability, &[0x5a; CAPABILITY_LEN - 1]).unwrap(); + assert_eq!(locked.capability().unwrap_err().kind(), ErrorKind::Io); +} + +#[test] +fn records_and_capabilities_refuse_non_regular_entries_and_links_before_read_or_replace() { + for name in [ + StateFile::Journal, + StateFile::Runtime, + StateFile::Capability, + ] { + let (directory, state) = state(); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + let path = state.path(name); + fs::create_dir(&path).unwrap(); + assert!(read_file(&locked, name).is_err()); + assert!(files::replace(&locked, name, b"replacement").is_err()); + assert_eq!(fs::read_dir(&path).unwrap().count(), 0); + fs::remove_dir(&path).unwrap(); + let target = directory.path().join("target"); + fs::write(&target, b"preserved target").unwrap(); + #[cfg(unix)] + let linked = std::os::unix::fs::symlink(&target, &path); + #[cfg(windows)] + let linked = std::os::windows::fs::symlink_file(&target, &path); + match linked { + Ok(()) => { + assert!(read_file(&locked, name).is_err()); + assert!(files::replace(&locked, name, b"replacement").is_err()); + assert_eq!(fs::read(&target).unwrap(), b"preserved target"); + fs::remove_file(&path).unwrap(); + } + #[cfg(windows)] + Err(error) if error.raw_os_error() == Some(1314) => { + eprintln!("skipped the symbolic link without Developer Mode or privilege: {error}"); + } + Err(error) => panic!("cannot create the test symbolic link: {error}"), + } + #[cfg(target_os = "linux")] + { + use nix::sys::stat::Mode; + nix::unistd::mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); + assert!(read_file(&locked, name).is_err()); + assert!(files::replace(&locked, name, b"replacement").is_err()); + } + #[cfg(windows)] + { + let target = directory.path().join("junction-target"); + fs::create_dir(&target).unwrap(); + junction(&path, &target); + assert!(read_file(&locked, name).is_err()); + assert!(files::replace(&locked, name, b"replacement").is_err()); + assert_eq!(fs::read_dir(target).unwrap().count(), 0); + } + } +} + +#[cfg(windows)] +#[test] +fn records_and_capabilities_that_other_users_may_access_are_refused_without_replacement() { + let (_directory, state) = state(); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + for name in [ + StateFile::Journal, + StateFile::Runtime, + StateFile::Capability, + ] { + files::replace(&locked, name, b"preserved shared contents").unwrap(); + let path = state.path(name); + allow_everyone(&path, ""); + let before = fs::read(&path).unwrap(); + for error in [ + read_file(&locked, name).unwrap_err(), + files::replace(&locked, name, b"replacement").unwrap_err(), + ] { + assert_eq!(error.kind(), ErrorKind::Io); + assert!(error.message().contains("allows other users"), "{error}"); + } + assert_eq!(fs::read(path).unwrap(), before); + } +} From 1cb779e8daf8716f1d650feeb176a10a8b1bf83f Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 01:14:23 -0800 Subject: [PATCH 03/30] guest: serve snapshot capture requests from init Add the rootless Fresh capture entry and handle snapshot outcomes on the shared control session. Refuse rooted capture before killing workloads and preserve the current helper supervision and nonblocking transport. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dee059e7-7968-43d8-82d0-abd68b922eed --- nvx-rs/src/guest/init.rs | 253 +++++++++++++- nvx-rs/src/guest/init/tests.rs | 3 +- nvx-rs/src/guest/init/tests/capture.rs | 210 ++++++++++++ nvx-rs/src/guest/managed_agent/dispatch.rs | 65 +++- .../src/guest/managed_agent/dispatch/tests.rs | 134 +++++++- nvx-rs/src/guest/managed_agent/exec.rs | 21 +- nvx-rs/src/guest/managed_agent/session.rs | 146 +++++++- .../src/guest/managed_agent/session/tests.rs | 313 ++++++++++++++++++ nvx-rs/src/guest/snapshot/repair.rs | 2 +- nvx-rs/src/guest/snapshot/repair/tests.rs | 5 + nvx-rs/src/protocol/app.rs | 11 +- nvx-rs/src/protocol/mod.rs | 9 +- scripts/test-layout-allowlist.json | 2 +- 13 files changed, 1147 insertions(+), 27 deletions(-) create mode 100644 nvx-rs/src/guest/init/tests/capture.rs create mode 100644 nvx-rs/src/guest/managed_agent/session/tests.rs diff --git a/nvx-rs/src/guest/init.rs b/nvx-rs/src/guest/init.rs index ec2e871c2..bd326adf1 100644 --- a/nvx-rs/src/guest/init.rs +++ b/nvx-rs/src/guest/init.rs @@ -35,14 +35,20 @@ //! //! Outside PID 1, `nvx-init` changes nothing: it fails with status 2 and a message. -use std::ffi::{CStr, CString}; +use std::ffi::{CStr, CString, OsStr}; use std::io::{self, Read}; use std::os::fd::BorrowedFd; +use std::os::unix::ffi::OsStrExt; use std::os::unix::process::ExitStatusExt; +use std::path::{Path, PathBuf}; use std::process::{ExitCode, ExitStatus as ProcessStatus}; use std::time::{Duration, Instant}; +use crate::protocol::cmdline; +use crate::protocol::root::{DISTRO_MMIO_BASE, SCRATCH_MMIO_BASE}; + use super::parse::parse_decimal; +use super::snapshot::{self, CaptureFailure, Captured, Devices, Disks, Repaired}; use super::status::ExitStatus; use super::sys::{ self, ChildStatus, Errno, FileDescriptor, Mode, OpenFlags, Pollable, ProcessId, Signal, @@ -113,10 +119,6 @@ impl Helpers { /// Kills every remaining userspace process but PID 1 and the registered helpers, repeating /// until none remains, including descendants that left their execution's process group. - #[cfg_attr( - not(test), - expect(dead_code, reason = "SL7.7a calls the capture sweep") - )] pub(crate) fn sweep(self) -> Result<(), Errno> { if sys::current_process().raw() != INIT_PROCESS { return Err(Errno::EPERM); @@ -124,6 +126,23 @@ impl Helpers { self.sweep_with(ProcessId::live_userspace, sys::kill_process) } + /// A capture without permission to kill must leave no unregistered process running. + fn prepare_capture(self, kill: bool) -> Result<(), Errno> { + if kill { + return self.sweep(); + } + if sys::current_process().raw() != INIT_PROCESS { + return Err(Errno::EPERM); + } + if ProcessId::live_userspace()? + .into_iter() + .any(|process| process.raw() != INIT_PROCESS && self.name(process).is_none()) + { + return Err(Errno::EBUSY); + } + Ok(()) + } + /// Runs the narrow process-table sweep with the supplied walk and kill operations. fn sweep_with( self, @@ -150,6 +169,144 @@ impl Helpers { } } +/// The capture request's one pre-request deadline, including workload cleanup. +const CAPTURE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Init's capture inputs and outcome, retained across the existing control session's reset. +pub(crate) struct Capture { + /// The only processes that capture cleanup spares besides PID 1. + helpers: Helpers, + /// This boot has not assembled a root. + fresh: bool, + /// Request ID, kill flag and the original pre-request deadline. + pending: Option<(u64, bool, Instant)>, + /// The repair whose RESET must be acknowledged before later activation. + repaired: Option, + /// A fatal capture disposition, whose status PID 1 must preserve. + shutdown: Option, +} + +/// Preparation refused the request, or the acknowledged snapshot call returned. +pub(crate) enum CaptureStep { + /// Workload preparation failed before the request was acknowledged. + Refused { + /// The original capture request. + request_id: u64, + /// The preparation failure. + error: Errno, + }, + /// The acknowledged, synchronous snapshot call returned. + Returned { + /// The original capture request, still on the same session. + request_id: u64, + /// The snapshot library's unchanged outcome and disposition. + result: Result, + }, +} + +impl Capture { + /// Retains init's helpers, without constructing any space. + fn new(helpers: Helpers, fresh: bool) -> Self { + Self { + helpers, + fresh, + pending: None, + repaired: None, + shutdown: None, + } + } + + /// Keeps one pending request and anchors its deadline before cleanup. + pub(crate) fn queue(&mut self, request_id: u64, kill: bool) -> bool { + if self.pending.is_some() || self.repaired.is_some() || self.shutdown.is_some() { + return false; + } + self.pending = Some((request_id, kill, Instant::now() + CAPTURE_TIMEOUT)); + true + } + + /// Whether the active execution must hand back a capture request. + pub(crate) fn pending(&self) -> bool { + self.pending.is_some() + } + + /// Drops an old epoch's request, not the repaired state. + pub(crate) fn reset(&mut self) { + self.pending = None; + } + + /// Whether this session must admit no root-dependent operation. + pub(crate) fn rootless(&self) -> bool { + self.fresh || self.repaired.is_some() + } + + /// Keeps the original repair for later activation, which this slice does not perform. + pub(crate) fn restored(&mut self, repaired: Repaired) { + self.repaired = Some(repaired); + } + + /// Records the fatal status before the loop returns directly to PID 1. + pub(crate) fn must_end(&mut self, status: u8) { + self.shutdown = Some(status); + } + + /// The snapshot library's fatal status, if capture ended the loop. + pub(crate) fn shutdown_status(&self) -> Option { + self.shutdown + } + + /// Performs the existing repeated sweep after group termination. + pub(crate) fn sweep(&self) -> Result<(), Errno> { + self.helpers.sweep() + } + + /// Prepares workloads, acknowledges the request, then calls the snapshot library once. + pub(crate) fn perform( + &mut self, + acknowledge: impl FnOnce(u64) -> Result<(), Errno>, + ) -> Result, Errno> { + let helpers = self.helpers; + self.perform_with( + |kill| helpers.prepare_capture(kill), + acknowledge, + snapshot::capture, + ) + } + + /// The transport is invoked only before the one synchronous snapshot call. + pub(crate) fn perform_with( + &mut self, + prepare: impl FnOnce(bool) -> Result<(), Errno>, + acknowledge: impl FnOnce(u64) -> Result<(), Errno>, + capture: impl FnOnce(&Devices, Disks, Instant) -> Result, + ) -> Result, Errno> { + let Some((request_id, kill, deadline)) = self.pending.take() else { + return Ok(None); + }; + if let Err(error) = prepare(kill) { + return Ok(Some(CaptureStep::Refused { request_id, error })); + } + acknowledge(request_id)?; + let devices = Devices { + distro: DISTRO_MMIO_BASE, + scratch: SCRATCH_MMIO_BASE, + filesystem_tag: "microvm".to_owned(), + }; + Ok(Some(CaptureStep::Returned { + request_id, + result: capture(&devices, Disks::Fresh, deadline), + })) + } +} + +/// Reports capture detail without driving the control transport or starting a helper. +pub(crate) fn report_capture(failure: impl std::fmt::Display) { + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-CAPTURE: {failure}\n").as_bytes(), + ); +} + /// Whether execution requires an account in the shared root's databases. #[cfg_attr( not(test), @@ -840,6 +997,11 @@ impl Runtime { Pollable::Signals(self.system.signals.as_ref().expect("listening")) } + /// Hands the same helper registry to the shared loop's capture caller. + pub(crate) fn capture(&self, fresh: bool) -> Capture { + Capture::new(self.helpers, fresh) + } + /// Hands terminal statuses to the active execution; a helper failure wins over shutdown. pub(crate) fn service( &mut self, @@ -867,7 +1029,7 @@ pub fn main() -> ExitCode { let arguments = sys::args(); match Runtime::new(arguments.get(1..).unwrap_or_default()) { Ok(mut runtime) => { - let result = serve_layered(&mut runtime); + let result = serve_boot(&mut runtime); finish(&mut runtime.system, result); } Err(failure) => finish(&mut RealSystem { signals: None }, Err(failure)), @@ -875,9 +1037,21 @@ pub fn main() -> ExitCode { ExitStatus::Failure.into() } -fn serve_layered(runtime: &mut Runtime) -> Result<(), String> { +fn serve_boot(runtime: &mut Runtime) -> Result { let command_line = std::fs::read("/proc/cmdline") .map_err(|error| format!("cannot read the boot command line: {error}"))?; + if let Some(control) = fresh_capture_control(&command_line)? { + let control = super::terminal::Terminal::open(&control) + .map_err(|error| format!("cannot open capture control: {error}"))?; + control + .configure_raw() + .map_err(|error| format!("cannot configure capture control: {error}"))?; + runtime.system.report(b"NVX-INIT-CAPTURE: waiting\n"); + return super::managed_agent::AgentConfig::serve_capture( + runtime, + control.into_descriptor(), + ); + } let boot = boot::cold_boot(&command_line).map_err(|error| error.to_string())?; runtime.system.report( format!( @@ -895,10 +1069,71 @@ fn serve_layered(runtime: &mut Runtime) -> Result<(), String> { ) } +/// Selects only the fixed rootless Fresh entry; malformed capture tokens never fall back. +fn fresh_capture_control(command_line: &[u8]) -> Result, String> { + let tokens: Vec<_> = cmdline::init_tokens(command_line).collect(); + let parameter = |name: &[u8]| { + let mut matching = tokens.iter().filter(|token| token.name == name); + let Some(token) = matching.next() else { + return Ok(None); + }; + if matching.next().is_some() || token.value.is_none() { + return Err(format!( + "{} is repeated or has no value", + name.escape_ascii() + )); + } + Ok(token.value) + }; + match parameter(b"nvx_snapshot_tier")? { + None => return Ok(None), + Some(b"platform") => {} + Some(_) => return Err("nvx_snapshot_tier must be platform".to_owned()), + } + if parameter(b"nvx_sandbox")? != Some(b"1") { + return Err("a Fresh capture needs nvx_sandbox=1".to_owned()); + } + for token in &tokens { + if matches!( + token.name, + b"nvx_layer" + | b"nvx_scratch" + | b"nvx_overlay_upper" + | b"nvx_overlay_mode" + | b"nvx_scratch_journal" + | b"nvx_first_partition_bound" + | b"nvx_ram_upper_percent" + | b"nvx_hostname" + | b"nvx_machine_id" + | b"nvx_maps" + | b"nvx_share" + | b"nvx_identity" + | b"nvx_workload_account" + | b"nvx_entrypoint" + | b"nvx_arg" + | b"nvx_env" + | b"nvx_cwd" + | b"nvx_lifecycle" + | b"nvx_workload_uid" + | b"nvx_workload_gid" + ) || token.name.starts_with(b"virtfs_") + { + return Err(format!( + "a Fresh capture cannot carry {}", + token.name.escape_ascii() + )); + } + } + let control = parameter(b"nvx_control_tty")? + .filter(|value| !value.is_empty() && value.iter().all(u8::is_ascii_alphanumeric)) + .ok_or("a Fresh capture needs nvx_control_tty naming a device in /dev")?; + Ok(Some(Path::new("/dev").join(OsStr::from_bytes(control)))) +} + /// Keeps diagnostics, sync and direct power-off with the original PID-1 owner. -fn finish(system: &mut impl System, result: Result<(), String>) { +fn finish(system: &mut impl System, result: Result) { let status = match result { - Ok(()) => 0, + Ok(status) => status, Err(failure) => { system.report(format!("NVX-INIT-ERROR: {failure}\n").as_bytes()); FAILURE diff --git a/nvx-rs/src/guest/init/tests.rs b/nvx-rs/src/guest/init/tests.rs index 3a017f74a..706bf18a7 100644 --- a/nvx-rs/src/guest/init/tests.rs +++ b/nvx-rs/src/guest/init/tests.rs @@ -7,6 +7,7 @@ //! PID 1 theirs, and the check of the real priority needs the privileges of a guest's PID 1. No //! check stops a VM: each records the status with which PID 1 would. +mod capture; mod exec; pub(super) use exec::prepare_child; @@ -25,7 +26,7 @@ use crate::guest::sys::{ForkResult, wait}; /// shutdown; layered boot itself is exercised by the boot tests. fn init(system: &mut impl Waiting, arguments: &[CString]) { let result = serve(system, arguments); - finish(system, result); + finish(system, result.map(|()| 0)); } fn serve(system: &mut impl Waiting, arguments: &[CString]) -> Result<(), String> { diff --git a/nvx-rs/src/guest/init/tests/capture.rs b/nvx-rs/src/guest/init/tests/capture.rs new file mode 100644 index 000000000..1fddd18c4 --- /dev/null +++ b/nvx-rs/src/guest/init/tests/capture.rs @@ -0,0 +1,210 @@ +use std::cell::{Cell, RefCell}; + +use super::*; +use crate::guest::snapshot::{CaptureCause, CaptureStage, Disposition}; + +impl Capture { + /// Supplies only a helper registry; no unit check can sweep the host through this fixture. + pub(crate) fn fixture(fresh: bool) -> Self { + Self::new( + Helpers { + time: pid(42), + announcer: Some(pid(43)), + }, + fresh, + ) + } +} + +#[test] +fn capture_preparation_and_acknowledgement_precede_one_synchronous_call() { + let mut state = Capture::fixture(true); + assert!(state.queue(17, true)); + assert!(!state.queue(18, true)); + let calls = RefCell::new(Vec::new()); + let deadline = state.pending.unwrap().2; + let returned = state + .perform_with( + |kill| { + assert!(kill); + calls.borrow_mut().push("kill-and-sweep"); + Ok(()) + }, + |request_id| { + assert_eq!(request_id, 17); + calls.borrow_mut().push("ack"); + Ok(()) + }, + |devices, disks, actual_deadline| { + assert_eq!(*calls.borrow(), ["kill-and-sweep", "ack"]); + assert_eq!(devices.distro, DISTRO_MMIO_BASE); + assert_eq!(devices.scratch, SCRATCH_MMIO_BASE); + assert_eq!(devices.filesystem_tag, "microvm"); + assert_eq!(disks, Disks::Fresh); + assert_eq!(actual_deadline, deadline); + calls.borrow_mut().push("capture"); + Ok(Captured::Continued) + }, + ) + .unwrap(); + assert!(matches!( + returned, + Some(CaptureStep::Returned { + request_id: 17, + result: Ok(Captured::Continued) + }) + )); + assert_eq!(*calls.borrow(), ["kill-and-sweep", "ack", "capture"]); + assert!( + state + .perform_with( + |_| panic!("no second preparation"), + |_| panic!("no second acknowledgement"), + |_, _, _| panic!("no second snapshot call"), + ) + .unwrap() + .is_none() + ); +} + +#[test] +fn refused_preparation_or_failed_acknowledgement_never_calls_snapshot() { + for refusal in [Errno::EBUSY, Errno::EIO] { + let mut state = Capture::fixture(true); + assert!(state.queue(19, false)); + assert!(matches!( + state + .perform_with( + |kill| { + assert!(!kill); + Err(refusal) + }, + |_| panic!("a refused request is not acknowledged"), + |_, _, _| panic!("a refused request does not capture"), + ) + .unwrap(), + Some(CaptureStep::Refused { request_id: 19, error }) if error == refusal + )); + } + let mut state = Capture::fixture(true); + assert!(state.queue(20, false)); + assert_eq!( + state + .perform_with( + |_| Ok(()), + |_| Err(Errno::EPIPE), + |_, _, _| panic!("a failed acknowledgement does not capture"), + ) + .err(), + Some(Errno::EPIPE) + ); +} + +#[test] +fn an_old_epoch_drops_its_pending_capture_without_restarting_it() { + let mut state = Capture::fixture(false); + assert!(state.queue(21, true)); + state.reset(); + let called = Cell::new(false); + assert!( + state + .perform_with( + |_| { + called.set(true); + Ok(()) + }, + |_| panic!("no stale acknowledgement"), + |_, _, _| panic!("no stale capture"), + ) + .unwrap() + .is_none() + ); + assert!(!called.get()); +} + +#[test] +fn fresh_capture_selects_the_rootless_control_entry_without_root_tokens() { + assert_eq!( + fresh_capture_control( + b"nvx_sandbox=1 nvx_snapshot_tier=platform nvx_control_tty=ttyS1 quiet" + ) + .unwrap(), + Some(PathBuf::from("/dev/ttyS1")) + ); + assert_eq!( + fresh_capture_control(b"nvx_sandbox=1 nvx_control_tty=ttyS1").unwrap(), + None + ); + for extra in [ + "nvx_layer=distro,0xd0003000,erofs", + "nvx_scratch=0xd0006000,ext4", + "nvx_overlay_upper=ramfs", + "nvx_hostname=626f78", + "nvx_machine_id=image", + "nvx_maps=0", + "nvx_share=0", + "nvx_identity=1000:1000", + "nvx_workload_account=create", + "nvx_entrypoint=/bin/work", + "nvx_arg=work", + "nvx_env=HOME=/work", + "nvx_cwd=/work", + "virtfs_tag=microvm", + "virtfs_dir=/work", + ] { + let command_line = + format!("nvx_sandbox=1 nvx_snapshot_tier=platform nvx_control_tty=ttyS1 {extra}"); + assert!( + fresh_capture_control(command_line.as_bytes()).is_err(), + "{extra}" + ); + } + for command_line in [ + "nvx_snapshot_tier=platform nvx_control_tty=ttyS1", + "nvx_sandbox=1 nvx_snapshot_tier=paired nvx_control_tty=ttyS1", + "nvx_sandbox=1 nvx_snapshot_tier=platform nvx_snapshot_tier=platform nvx_control_tty=ttyS1", + "nvx_sandbox=1 nvx_snapshot_tier=platform", + "nvx_sandbox=1 nvx_snapshot_tier=platform nvx_control_tty=../ttyS1", + "nvx_sandbox=1 nvx_snapshot_tier=platform nvx_control_tty=ttyS1 nvx_control_tty=ttyS2", + ] { + assert!( + fresh_capture_control(command_line.as_bytes()).is_err(), + "{command_line}" + ); + } +} + +#[test] +fn fatal_capture_status_goes_directly_to_pid1_shutdown_without_starting_work() { + for status in [0, 41, 194] { + let mut capture = Capture::fixture(true); + assert!(capture.queue(22, false)); + let returned = capture + .perform_with( + |_| Ok(()), + |_| Ok(()), + |_, _, _| { + Err(CaptureFailure { + stage: CaptureStage::Capture, + cause: CaptureCause::Io(io::Error::other("capture cannot resume")), + disposition: Disposition::MustEnd { status }, + }) + }, + ) + .unwrap(); + let Some(CaptureStep::Returned { + result: Err(failure), + .. + }) = returned + else { + panic!("the fatal disposition is retained"); + }; + let Disposition::MustEnd { status: actual } = failure.disposition else { + panic!("a fatal capture cannot resume"); + }; + capture.must_end(actual); + let mut system = Fake::default(); + finish(&mut system, Ok(capture.shutdown_status().unwrap())); + assert_eq!(system.calls, [Call::ShutDown(status)]); + } +} diff --git a/nvx-rs/src/guest/managed_agent/dispatch.rs b/nvx-rs/src/guest/managed_agent/dispatch.rs index b94c61b97..53a071d65 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch.rs @@ -4,8 +4,8 @@ use crate::guest::init::Runtime; use crate::guest::mappings::{Mapper, MappingTable, SourceRoot}; use crate::guest::sys::{self, Errno, FileDescriptor}; use crate::protocol::{ - self, APP_CANCEL, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, APP_READY, APP_STOP, APP_STOPPED, - GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, + self, APP_CANCEL, APP_CAPTURE, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, APP_READY, APP_STOP, + APP_STOPPED, GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, }; use super::diagnostics; @@ -52,13 +52,43 @@ impl AgentConfig { runtime: &mut Runtime, control: FileDescriptor, (table, mapper, export): (MappingTable, Mapper, Option), - ) -> Result<(), String> { - let mut system = RealSystem::new(Paths::default()); - let mut session = Session::with_runtime(control, runtime)?; - let mut mappings = HostMappings::layered(table, mapper, export) + ) -> Result { + let mappings = HostMappings::layered(table, mapper, export) .map_err(|error| format!("invalid layered mappings: {}", error.desc()))?; + self.serve_init(runtime, control, mappings, false) + } + + /// The Fresh loop has no root, mapper, account resolution or workload admission. + pub(crate) fn serve_capture( + runtime: &mut Runtime, + control: FileDescriptor, + ) -> Result { + let config = Self { + rootfs: c"-".to_owned(), + hostname: c"".to_owned(), + uid: c"0".to_owned(), + gid: c"0".to_owned(), + user: c"".to_owned(), + home: c"".to_owned(), + direct: true, + entry: None, + }; + config.serve_init(runtime, control, HostMappings::default(), true) + } + + /// Borrows the one init runtime for either boot entry and returns its actual exit status. + fn serve_init( + &self, + runtime: &mut Runtime, + control: FileDescriptor, + mut mappings: HostMappings, + fresh: bool, + ) -> Result { + let capture = runtime.capture(fresh); + let mut system = RealSystem::new(Paths::default()); + let mut session = Session::with_runtime(control, runtime)?.with_capture(capture); run(&mut system, &mut session, self, &mut mappings) - .map(|Outcome::Stopped| ()) + .map(|Outcome::Stopped| session.capture_status().unwrap_or_default()) .map_err(|error| { session .runtime_failure() @@ -67,6 +97,11 @@ impl AgentConfig { } } +/// Preserves the existing receive-stage diagnostic for a restored capture's RESET. +pub(crate) fn report_failure_for_capture(failure: ReceiveFailure) -> Errno { + report_failure(&mut RealSystem::new(Paths::default()), failure) +} + /// Terminal outcome of the long-lived dispatch loop. #[derive(Debug)] pub(crate) enum Outcome { @@ -164,7 +199,20 @@ fn handle_data( return Err(failure.into_error()); } }; + if session.rootless() && !matches!(request.kind, APP_CAPTURE | APP_STOP) { + session.send_error( + request.request_id, + Errno::ENOTSUP as i32, + "unsupported-operation", + )?; + return Ok(None); + } match request.kind { + APP_CAPTURE => { + if session.queue_capture(request, false)? { + return session.finish_capture(); + } + } APP_PING => { if request.payload.is_empty() { session @@ -198,6 +246,9 @@ fn handle_data( } }; exec::run(system, session, config, request.request_id, &execution)?; + if session.capture_pending() { + return session.finish_capture(); + } } APP_CANCEL => { // The targeted workload has already reported its outcome. diff --git a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs index b16ead8d7..9efb42f47 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs @@ -16,6 +16,7 @@ use crate::protocol::{ use super::super::seam::Paths; use super::super::seam::tests::{TestSystem, attach_calls}; use super::*; +use crate::guest::init::Capture; use crate::guest::mappings::Access; use crate::guest::sys::tests::{ CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_SETGID, CAP_SETUID, holds_capability, @@ -73,11 +74,23 @@ impl Harness { Self::spawn_with(system, config, mappings) } - fn spawn_with(mut system: TestSystem, config: AgentConfig, mut mappings: HostMappings) -> Self { + fn spawn_with(system: TestSystem, config: AgentConfig, mappings: HostMappings) -> Self { + Self::spawn_with_capture(system, config, mappings, None) + } + + fn spawn_with_capture( + mut system: TestSystem, + config: AgentConfig, + mut mappings: HostMappings, + capture: Option, + ) -> Self { let (host, guest) = UnixStream::pair().unwrap(); let descriptor = FileDescriptor::from_owned(guest.into()); let thread = thread::spawn(move || { let mut session = Session::new(descriptor); + if let Some(capture) = capture { + session = session.with_capture(capture); + } let outcome = run(&mut system, &mut session, &config, &mut mappings); (outcome, system) }); @@ -313,6 +326,48 @@ fn agent_advertises_the_control_features_of_its_mode_and_stops() { } } +#[test] +fn fresh_session_refuses_root_requests_and_routes_capture_before_stopping() { + let config = AgentConfig::for_test(None); + let mappings = HostMappings::announced_for(&config, 0); + let mut harness = Harness::spawn_with_capture( + TestSystem::new(Paths::default()), + config, + mappings, + Some(Capture::fixture(true)), + ); + harness.reset(1, 7); + for (kind, payload) in [ + (APP_EXEC, exec_payload(None)), + (APP_MAPS, maps_payload(0, &[], None)), + (APP_PING, Vec::new()), + ] { + assert_eq!( + harness.request(kind, &payload), + response( + APP_ERROR, + 42, + Errno::ENOTSUP as i32, + b"unsupported-operation" + ) + ); + } + assert_eq!( + harness.request(APP_CAPTURE, &[0]), + response(APP_ERROR, 42, Errno::EPERM as i32, b"capture-failed") + ); + harness.host.set_nonblocking(true).unwrap(); + assert_eq!( + harness.host.read(&mut [0]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + harness.host.set_nonblocking(false).unwrap(); + let system = harness.stop(); + assert_eq!(system.spawn_calls, 0); + assert!(system.kernel.calls().is_empty()); + assert!(system.diagnostics.is_empty()); +} + #[test] fn agent_refuses_feature_payloads_and_unannounced_mapping_tables() { let mut harness = Harness::new(true); @@ -2119,6 +2174,83 @@ fn a_running_workload_refuses_other_requests_and_ends_when_cancelled_or_late() - Ok(()) } +#[test] +fn rooted_capture_refuses_both_kill_flags_and_leaves_the_exec_group_running() -> TestResult { + if super::super::tests::isolate_process_test( + "NVX_ROOTED_CAPTURE_REFUSAL_TEST_CHILD", + "guest::managed_agent::dispatch::tests::rooted_capture_refuses_both_kill_flags_and_leaves_the_exec_group_running", + ) { + return Ok(()); + } + let _process = super::super::tests::PROCESS_TEST_LOCK.lock()?; + let (_temporary, mut system) = TestSystem::with_direct(); + system.direct_starter = Some(super::super::seam::tests::start_as_privileged); + let config = AgentConfig::for_test(None); + let mappings = HostMappings::announced_for(&config, 0); + let mut harness = + Harness::spawn_with_capture(system, config, mappings, Some(Capture::fixture(false))); + harness.reset(1, 7); + harness.send( + 42, + APP_EXEC, + &workload_payload(&["/bin/sh", "-c", "sleep 30 & echo \"$$ $!\"; wait"], None)?, + ); + let mut output = Vec::new(); + while !output.ends_with(b"\n") { + let reply = harness.next_response(); + assert_eq!( + (reply.kind, reply.request_id, reply.status), + (APP_STDOUT, 42, 0) + ); + output.extend_from_slice(&reply.payload); + } + let group: Vec<_> = std::str::from_utf8(&output)? + .split_whitespace() + .map(|id| { + id.parse::() + .map(|id| sys::ProcessId::from_positive(id).unwrap()) + }) + .collect::>()?; + assert_eq!(group.len(), 2); + for (request_id, kill) in [(43, 0), (44, 1)] { + assert_eq!( + harness.request_all(request_id, APP_CAPTURE, &[kill]), + vec![response( + APP_ERROR, + request_id, + Errno::ENOTSUP as i32, + b"unsupported-operation" + )] + ); + let live = sys::ProcessId::live_userspace()?; + assert!(group.iter().all(|process| live.contains(process))); + harness.host.set_nonblocking(true)?; + assert_eq!( + harness.host.read(&mut [0]).unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + harness.host.set_nonblocking(false)?; + } + assert_eq!( + harness.request_all(42, APP_CANCEL, &[]), + vec![response(APP_EXIT, 42, 137, b"cancelled")] + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + loop { + let live = sys::ProcessId::live_userspace()?; + if group.iter().all(|process| !live.contains(process)) { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the cancelled process group did not exit" + ); + thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(harness.stop().diagnostics.is_empty()); + Ok(()) +} + #[test] fn refused_records_report_the_stage_at_which_the_c_agent_reported_them() { let line = diff --git a/nvx-rs/src/guest/managed_agent/exec.rs b/nvx-rs/src/guest/managed_agent/exec.rs index 69be440e1..4ebb5fa57 100644 --- a/nvx-rs/src/guest/managed_agent/exec.rs +++ b/nvx-rs/src/guest/managed_agent/exec.rs @@ -8,8 +8,8 @@ use crate::guest::process::Child; use crate::guest::spawn::{self as sandbox_spawn, Barrier}; use crate::guest::sys::{self, Errno, FileDescriptor, ProcessId, c_string}; use crate::protocol::{ - self, APP_CANCEL, APP_EXIT, APP_STDERR, APP_STDOUT, BUSY, CWD_FAILED, GuestExecPayload, - LAUNCH_FAILED, OUTER_DATA, + self, APP_CANCEL, APP_CAPTURE, APP_EXIT, APP_STDERR, APP_STDOUT, BUSY, CWD_FAILED, + GuestExecPayload, LAUNCH_FAILED, OUTER_DATA, }; use super::AgentConfig; @@ -96,6 +96,12 @@ pub(crate) fn run( workload.cancel(); } Ok(ExecControl::Cancel) => {} + Ok(ExecControl::Capture) => { + workload.terminate(); + session.sweep_capture().inspect_err(|error| { + super::diagnostics::error(system, "exec-capture-sweep", *error as i32); + })?; + } Ok(ExecControl::SessionLost) => { state.session_lost = true; workload.disconnect(); @@ -811,6 +817,8 @@ enum ExecControl { Cancel, /// A reset detached this workload's session. SessionLost, + /// Init accepted a capture that requires group termination and the repeated sweep. + Capture, } /// Processes one control-console record while a workload runs. @@ -846,6 +854,15 @@ fn handle_control( }, ); } + if request.kind == APP_CAPTURE && session.has_capture() { + return session.queue_capture(request, true).map(|accepted| { + if accepted { + ExecControl::Capture + } else { + ExecControl::None + } + }); + } session.send_error(request.request_id, Errno::EBUSY as i32, BUSY)?; Ok(ExecControl::None) } diff --git a/nvx-rs/src/guest/managed_agent/session.rs b/nvx-rs/src/guest/managed_agent/session.rs index 99111a654..2256e29fe 100644 --- a/nvx-rs/src/guest/managed_agent/session.rs +++ b/nvx-rs/src/guest/managed_agent/session.rs @@ -2,8 +2,9 @@ use std::cell::{Cell, RefCell}; use std::io::{self, Read, Write}; use std::process::ExitStatus; -use crate::guest::init::Runtime; +use crate::guest::init::{self, Capture, CaptureStep, Runtime}; use crate::guest::nvxs::{GuestConnection, GuestConnectionError}; +use crate::guest::snapshot::{Captured, Disposition}; use crate::guest::sys::{self, Errno, FileDescriptor, Pollable}; use crate::protocol::nvxs::{GuestEvent, RecordError, RecordType, SessionError}; use crate::protocol::{self, APP_ERROR, OUTER_MAX_PAYLOAD, OUTER_RECEIVE_WINDOW}; @@ -13,6 +14,8 @@ use crate::protocol::{self, APP_ERROR, OUTER_MAX_PAYLOAD, OUTER_RECEIVE_WINDOW}; pub(crate) struct Session<'r> { /// The guest's NVXS connection on the owned control-console descriptor. connection: GuestConnection>, + /// Present only for nvx-init, never the old managed agent. + capture: Option, /// Error of the connection's latest failure, which a connection that a failure ended reports /// again. last_error: Option, @@ -204,10 +207,141 @@ impl<'r> Session<'r> { status: Cell::new(None), outcome: RefCell::new(Ok(false)), }), + capture: None, last_error: None, } } + /// Enables only init's capture path on this existing session. + pub(crate) fn with_capture(mut self, capture: Capture) -> Self { + self.capture = Some(capture); + self + } + + /// Whether the serving caller is init rather than the old agent. + pub(crate) fn has_capture(&self) -> bool { + self.capture.is_some() + } + + /// Whether root-dependent requests must remain refused. + pub(crate) fn rootless(&self) -> bool { + self.capture.as_ref().is_some_and(Capture::rootless) + } + + /// Whether execution cleanup must return the accepted capture request. + pub(crate) fn capture_pending(&self) -> bool { + self.capture.as_ref().is_some_and(Capture::pending) + } + + /// The fatal capture status that the PID-1 caller must use. + pub(crate) fn capture_status(&self) -> Option { + self.capture.as_ref().and_then(Capture::shutdown_status) + } + + /// Refuses rooted callers before validating the flag or reserving workload cleanup. + pub(crate) fn queue_capture( + &mut self, + request: protocol::AppFrame<'_>, + active: bool, + ) -> Result { + if !self.rootless() { + self.send_error( + request.request_id, + Errno::ENOTSUP as i32, + "unsupported-operation", + )?; + return Ok(false); + } + let kill = match protocol::decode_capture_payload(request.payload) { + Ok(kill) => kill, + Err(_) => { + self.send_error( + request.request_id, + Errno::EINVAL as i32, + protocol::INVALID_REQUEST, + )?; + return Ok(false); + } + }; + if (active && !kill) + || !self + .capture + .as_mut() + .expect("init owns capture") + .queue(request.request_id, kill) + { + self.send_error(request.request_id, Errno::EBUSY as i32, protocol::BUSY)?; + return Ok(false); + } + Ok(true) + } + + /// Calls init's existing repeated sweep, never a host-side process sweep. + pub(crate) fn sweep_capture(&self) -> Result<(), Errno> { + self.capture.as_ref().ok_or(Errno::EPERM)?.sweep() + } + + /// Acknowledges, captures synchronously, and handles the result on this connection. + pub(crate) fn finish_capture(&mut self) -> Result, Errno> { + self.finish_capture_with(|capture, session| { + capture.perform(|request_id| session.send_app(protocol::APP_READY, request_id, 0, &[])) + }) + } + + /// Keeps capture state across the operation and the existing session's RESET. + fn finish_capture_with( + &mut self, + operation: impl FnOnce(&mut Capture, &mut Self) -> Result, Errno>, + ) -> Result, Errno> { + let Some(mut capture) = self.capture.take() else { + return Ok(None); + }; + let result = (|| { + match operation(&mut capture, self)? { + None => {} + Some(CaptureStep::Refused { request_id, error }) => { + init::report_capture(error); + let category = if error == Errno::EBUSY { + protocol::BUSY + } else { + "capture-failed" + }; + self.send_error(request_id, error as i32, category)?; + } + Some(CaptureStep::Returned { request_id, result }) => match result { + Ok(Captured::Continued) => { + self.send_error(request_id, Errno::EAGAIN as i32, "capture-continued")?; + } + Ok(Captured::Restored(repaired)) => { + capture.restored(repaired); + match self.next_event() { + Ok(Event::Reset) => self.acknowledge()?, + Ok(Event::Data(_)) => return Err(Errno::EPROTO), + Err(failure) => { + return Err(super::dispatch::report_failure_for_capture(failure)); + } + } + } + Err(failure) => { + init::report_capture(&failure); + match failure.disposition { + Disposition::Resumed => { + self.send_error(request_id, Errno::EIO as i32, "capture-failed")?; + } + Disposition::MustEnd { status } => { + capture.must_end(status); + return Ok(Some(super::dispatch::Outcome::Stopped)); + } + } + } + }, + } + Ok(None) + })(); + self.capture = Some(capture); + result + } + /// Borrows init's sole collector for the lifetime of this control session. pub(crate) fn with_runtime( descriptor: FileDescriptor, @@ -282,7 +416,12 @@ impl<'r> Session<'r> { /// Blocks until the broker's next record completes, and returns what the session accepted. pub(crate) fn next_event(&mut self) -> Result { match self.connection.next_event() { - Ok(GuestEvent::Reset(_)) => Ok(Event::Reset), + Ok(GuestEvent::Reset(_)) => { + if let Some(capture) = &mut self.capture { + capture.reset(); + } + Ok(Event::Reset) + } Ok(GuestEvent::Data(payload)) => Ok(Event::Data(payload)), Err(error) => Err(self.receive_failure(error)), } @@ -449,3 +588,6 @@ fn data_failure(kind: DataFailureKind, status: i32) -> DataFailure { error: Errno::EPROTO, } } + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/guest/managed_agent/session/tests.rs b/nvx-rs/src/guest/managed_agent/session/tests.rs new file mode 100644 index 000000000..c91d12028 --- /dev/null +++ b/nvx-rs/src/guest/managed_agent/session/tests.rs @@ -0,0 +1,313 @@ +use std::os::unix::net::UnixStream; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use super::*; +use crate::guest::snapshot::{CaptureCause, CaptureFailure, CaptureStage}; +use crate::protocol::{ + APP_CAPTURE, APP_READY, OUTER_ACK, OUTER_CREDIT, OUTER_DATA, OUTER_GUEST_ATTACH, + OUTER_HEADER_LEN, OUTER_RESET, OuterHeader, decode_app, decode_outer_header, encode_outer, +}; + +const INSTANCE: [u8; 16] = [7; 16]; + +struct Peer { + stream: UnixStream, + epoch: u64, + host_sequence: u64, + guest_sequence: u64, +} + +impl Peer { + fn read(&mut self) -> (OuterHeader, Vec) { + let mut bytes = [0; OUTER_HEADER_LEN]; + self.stream.read_exact(&mut bytes).unwrap(); + let header = decode_outer_header(&bytes).unwrap(); + let mut payload = vec![0; header.length]; + self.stream.read_exact(&mut payload).unwrap(); + assert_eq!(header.sequence, self.guest_sequence); + self.guest_sequence += 1; + (header, payload) + } + + fn app(&mut self, kind: u8, id: u64, status: i32, payload: &[u8]) { + let (header, bytes) = self.read(); + assert_eq!(header.record_type, OUTER_DATA); + assert_eq!(header.epoch, self.epoch); + let frame = decode_app(&bytes).unwrap(); + assert_eq!( + (frame.kind, frame.request_id, frame.status, frame.payload), + (kind, id, status, payload) + ); + } + + fn send(&mut self, kind: u8, id: u64, payload: &[u8]) { + let bytes = protocol::encode_app(kind, id, 0, payload); + self.stream + .write_all( + &encode_outer( + OUTER_DATA, + &INSTANCE, + self.epoch, + self.host_sequence, + &bytes, + ) + .unwrap(), + ) + .unwrap(); + self.host_sequence += 1; + } + + fn credit(&mut self) { + let (header, _) = self.read(); + assert_eq!(header.record_type, OUTER_CREDIT); + assert_eq!(header.epoch, self.epoch); + } +} + +fn connected(fresh: bool) -> (Session<'static>, Peer) { + let (host, guest) = UnixStream::pair().unwrap(); + let mut session = Session::new(FileDescriptor::from_owned(guest.into())) + .with_capture(Capture::fixture(fresh)); + let mut peer = Peer { + stream: host, + epoch: 1, + host_sequence: 8, + guest_sequence: 0, + }; + session.send_attach().unwrap(); + let (header, _) = peer.read(); + assert_eq!(header.record_type, OUTER_GUEST_ATTACH); + peer.stream + .write_all(&encode_outer(OUTER_RESET, &INSTANCE, 1, 7, &[]).unwrap()) + .unwrap(); + assert!(matches!( + session + .next_event() + .unwrap_or_else(|_| panic!("valid initial RESET")), + Event::Reset + )); + session.acknowledge().unwrap(); + peer.guest_sequence = 0; + let (header, _) = peer.read(); + assert_eq!(header.record_type, OUTER_ACK); + (session, peer) +} + +fn queue( + session: &mut Session<'_>, + peer: &mut Peer, + id: u64, + payload: &[u8], + active: bool, +) -> bool { + peer.send(APP_CAPTURE, id, payload); + let Event::Data(bytes) = session + .next_event() + .unwrap_or_else(|_| panic!("valid request DATA")) + else { + panic!("the capture request is DATA"); + }; + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid frame")); + let accepted = session.queue_capture(request, active).unwrap(); + peer.credit(); + accepted +} + +#[test] +fn capture_refuses_active_execution_without_kill_and_validates_the_existing_flag() { + for (payload, status, category) in [ + (b"\0".as_slice(), Errno::EBUSY, protocol::BUSY), + (b"\x02".as_slice(), Errno::EINVAL, protocol::INVALID_REQUEST), + (b"".as_slice(), Errno::EINVAL, protocol::INVALID_REQUEST), + ] { + let (mut session, mut peer) = connected(true); + assert!(!queue(&mut session, &mut peer, 31, payload, true)); + assert!(!session.capture_pending()); + peer.app(APP_ERROR, 31, status as i32, category.as_bytes()); + } + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 32, &[1], true)); + assert!(session.capture_pending()); +} + +#[test] +fn rooted_capture_refuses_both_kill_flags_without_acknowledgement_or_pending_cleanup() { + for active in [false, true] { + for kill in [0, 1] { + let (mut session, mut peer) = connected(false); + assert!(!queue(&mut session, &mut peer, 37, &[kill], active)); + assert!(!session.capture_pending()); + assert!(session.finish_capture().unwrap().is_none()); + peer.app( + APP_ERROR, + 37, + Errno::ENOTSUP as i32, + b"unsupported-operation", + ); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } + } +} + +#[test] +fn capture_acknowledges_then_sends_nothing_until_return_and_reports_on_the_same_session() { + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 33, &[0], false)); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let result = session.finish_capture_with(|capture, session| { + capture.perform_with( + |_| Ok(()), + |id| session.send_app(APP_READY, id, 0, &[]), + |_, _, _| { + entered_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + Ok(Captured::Continued) + }, + ) + }); + (session, result) + }); + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + peer.app(APP_READY, 33, 0, &[]); + peer.stream + .set_read_timeout(Some(Duration::from_millis(100))) + .unwrap(); + let mut byte = [0]; + let error = peer.stream.read(&mut byte).unwrap_err(); + assert!(matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + )); + peer.stream.set_read_timeout(None).unwrap(); + release_tx.send(()).unwrap(); + peer.app(APP_ERROR, 33, Errno::EAGAIN as i32, b"capture-continued"); + let (session, result) = worker.join().unwrap(); + assert!(result.unwrap().is_none()); + assert!(session.capture_status().is_none()); +} + +#[test] +fn resumed_capture_reports_on_the_same_epoch_without_attaching_again() { + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 34, &[0], false)); + assert!( + session + .finish_capture_with(|capture, session| { + capture.perform_with( + |_| Ok(()), + |id| session.send_app(APP_READY, id, 0, &[]), + |_, _, _| { + Err(CaptureFailure { + stage: CaptureStage::Mounts, + cause: CaptureCause::Io(io::Error::other("capture preparation failed")), + disposition: Disposition::Resumed, + }) + }, + ) + }) + .unwrap() + .is_none() + ); + peer.app(APP_READY, 34, 0, &[]); + peer.app(APP_ERROR, 34, Errno::EIO as i32, b"capture-failed"); + assert!(session.capture_status().is_none()); +} + +#[test] +fn restored_capture_consumes_reset_and_acknowledges_the_existing_connection() { + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 35, &[0], false)); + let worker = thread::spawn(move || { + let result = session.finish_capture_with(|capture, session| { + capture.perform_with( + |_| Ok(()), + |id| session.send_app(APP_READY, id, 0, &[]), + |_, _, _| { + Ok(Captured::Restored( + crate::guest::snapshot::repair::tests::repaired_fixture(), + )) + }, + ) + }); + (session, result) + }); + peer.app(APP_READY, 35, 0, &[]); + peer.stream + .write_all(&encode_outer(OUTER_RESET, &[8; 16], 2, 4, &[]).unwrap()) + .unwrap(); + peer.epoch = 2; + peer.guest_sequence = 0; + let (header, payload) = peer.read(); + assert_eq!((header.record_type, header.epoch), (OUTER_ACK, 2)); + assert_eq!( + protocol::decode_ack_payload(&payload).unwrap(), + OUTER_RECEIVE_WINDOW + ); + let (mut session, result) = worker.join().unwrap(); + assert!(result.unwrap().is_none()); + assert!(session.rootless()); + assert!(session.capture_status().is_none()); + assert_eq!(session.connection.session().identity().unwrap().epoch, 2); + let bytes = protocol::encode_app(APP_CAPTURE, 38, 0, &[0]); + assert!( + !session + .queue_capture(decode_app(&bytes).unwrap(), false) + .unwrap() + ); + assert!(!session.capture_pending()); + peer.app( + APP_ERROR, + 38, + Errno::EBUSY as i32, + protocol::BUSY.as_bytes(), + ); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); +} + +#[test] +fn must_end_returns_the_exact_status_without_a_report_or_more_control_work() { + for status in [0, 41, 194] { + let (mut session, mut peer) = connected(true); + assert!(queue(&mut session, &mut peer, 36, &[0], false)); + let returned = session + .finish_capture_with(|capture, session| { + capture.perform_with( + |_| Ok(()), + |id| session.send_app(APP_READY, id, 0, &[]), + |_, _, _| { + Err(CaptureFailure { + stage: CaptureStage::Capture, + cause: CaptureCause::Io(io::Error::other("capture must end")), + disposition: Disposition::MustEnd { status }, + }) + }, + ) + }) + .unwrap(); + assert!(matches!( + returned, + Some(super::super::dispatch::Outcome::Stopped) + )); + assert_eq!(session.capture_status(), Some(status)); + peer.app(APP_READY, 36, 0, &[]); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } +} diff --git a/nvx-rs/src/guest/snapshot/repair.rs b/nvx-rs/src/guest/snapshot/repair.rs index 83aba2e5a..4c6b51fbd 100644 --- a/nvx-rs/src/guest/snapshot/repair.rs +++ b/nvx-rs/src/guest/snapshot/repair.rs @@ -2,7 +2,7 @@ //! process: [`Repair`], and [`repair`], which runs one once, as the [module](super) describes. #[cfg(test)] -mod tests; +pub(crate) mod tests; use std::collections::BTreeMap; use std::fmt; diff --git a/nvx-rs/src/guest/snapshot/repair/tests.rs b/nvx-rs/src/guest/snapshot/repair/tests.rs index 8b76d1f11..e63a09212 100644 --- a/nvx-rs/src/guest/snapshot/repair/tests.rs +++ b/nvx-rs/src/guest/snapshot/repair/tests.rs @@ -269,6 +269,11 @@ fn capture_repaired(restore: Restore, system: impl RepairSystem + 'static) -> Re repaired } +/// Returns genuine repaired data for the capture caller's outcome/session regression. +pub(crate) fn repaired_fixture() -> Repaired { + capture_repaired(restore(), FakeSystem::new(&Calls::default())) +} + /// Returns the calls of a repair once its memory target is active: the listing of the memory /// blocks, whose online ones it counts, the reseed, the drop of the seed, which zeroed it before /// the finish, and the finish, with the acknowledgement. diff --git a/nvx-rs/src/protocol/app.rs b/nvx-rs/src/protocol/app.rs index 824c19d1d..c6645486f 100644 --- a/nvx-rs/src/protocol/app.rs +++ b/nvx-rs/src/protocol/app.rs @@ -52,7 +52,11 @@ pub const APP_FEATURES: u8 = 5; pub const APP_MAPS: u8 = 6; /// Request that nvx-init capture the VM after optionally killing its executions. #[cfg_attr( - not(any(test, feature = "host")), + not(any( + test, + feature = "host", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect(dead_code, reason = "SL7.7a makes nvx-init handle capture requests") )] pub(crate) const APP_CAPTURE: u8 = 7; @@ -160,7 +164,10 @@ pub(crate) fn encode_capture_payload(kill_executions: bool) -> [u8; 1] { /// Decodes the capture request's sole kill-executions flag. #[cfg_attr( - not(test), + not(any( + test, + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect(dead_code, reason = "SL7.7a makes nvx-init handle capture requests") )] pub(crate) fn decode_capture_payload(payload: &[u8]) -> Result { diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 65df05c17..907ff555c 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -56,6 +56,13 @@ pub mod status; mod tests; pub mod view; +#[cfg(any( + feature = "host", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use app::APP_CAPTURE; +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] +pub(crate) use app::decode_capture_payload; pub use app::{ APP_CANCEL, APP_ERROR, APP_EXEC, APP_EXIT, APP_FEATURES, APP_HEADER_LEN, APP_MAGIC, APP_MAPS, APP_PING, APP_READY, APP_STDERR, APP_STDOUT, APP_STOP, APP_STOPPED, APP_VERSION, AppFrame, @@ -63,7 +70,7 @@ pub use app::{ MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, decode_app, encode_app, }; #[cfg(feature = "host")] -pub(crate) use app::{APP_CAPTURE, encode_capture_payload, workload}; +pub(crate) use app::{encode_capture_payload, workload}; pub use boot::{MAPS_TOKEN, WORKLOAD_ACCOUNT_TOKEN, parse_host_mapping_count}; pub use exec::{ EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_EXTENDED, EXEC_INHERIT_DEFAULT_ENV, diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 5b7e6aa10..045dc828c 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -5,7 +5,7 @@ "nvx-rs/src/guest/cgroup.rs": 2, "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, - "nvx-rs/src/guest/init.rs": 7, + "nvx-rs/src/guest/init.rs": 6, "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, "nvx-rs/src/guest/sys/entry.rs": 3, From dc669fae0b56615a17650c6e46849f85be474275 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 02:29:08 -0800 Subject: [PATCH 04/30] tests: keep adapter sandbox names short below data roots Use the existing PID and atomic create-directory retry to retain unique fixture directories without a long diagnostic prefix. Keep the scenario name in creation errors. Capture and restore control sockets now fit approved data-volume temp roots without changing production limits, assertions, timeouts or skipped tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- tests/runtime-adapter/tests/common/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/runtime-adapter/tests/common/mod.rs b/tests/runtime-adapter/tests/common/mod.rs index 3c3540923..797d3044a 100644 --- a/tests/runtime-adapter/tests/common/mod.rs +++ b/tests/runtime-adapter/tests/common/mod.rs @@ -154,11 +154,11 @@ impl Sandbox { pub(crate) fn new(name: &str) -> Self { let pid = std::process::id(); let root = (0..) - .map(|attempt| env::temp_dir().join(format!("runtime-adapter-{name}-{pid}-{attempt}"))) + .map(|attempt| env::temp_dir().join(format!("ra-{pid}-{attempt}"))) .find(|root| match fs::create_dir(root) { Ok(()) => true, Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false, - Err(error) => panic!("cannot create {}: {error}", root.display()), + Err(error) => panic!("cannot create the {name} sandbox {}: {error}", root.display()), }) .expect("a directory name is free"); Self { From adeb1d4778229fd23b64c2be6cd762df352de382 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 02:39:32 -0800 Subject: [PATCH 05/30] tests: format the compact sandbox creation diagnostic Apply the pinned rustfmt wrapping requested by the final-head review; fixture semantics and production source remain unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- tests/runtime-adapter/tests/common/mod.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/runtime-adapter/tests/common/mod.rs b/tests/runtime-adapter/tests/common/mod.rs index 797d3044a..dc5e0f914 100644 --- a/tests/runtime-adapter/tests/common/mod.rs +++ b/tests/runtime-adapter/tests/common/mod.rs @@ -158,7 +158,10 @@ impl Sandbox { .find(|root| match fs::create_dir(root) { Ok(()) => true, Err(error) if error.kind() == io::ErrorKind::AlreadyExists => false, - Err(error) => panic!("cannot create the {name} sandbox {}: {error}", root.display()), + Err(error) => panic!( + "cannot create the {name} sandbox {}: {error}", + root.display() + ), }) .expect("a directory name is free"); Self { From 13bbbc81c983c0bdfc66ddd149b146c05165ec29 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 02:53:28 -0800 Subject: [PATCH 06/30] host: verify CLI launch liveness from its log and endpoint Port only SL3.3a onto coordinator-qualified SL3.2b 99d00457. Retain the prepared log claim and identity, hold and recheck the endpoint owner, and require the Linux log match. Preserve Windows job/handle fallback primitives and the donor fork-race fix without adding recovery or signaling. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/liveness.rs | 81 ++++++ nvx-rs/src/host/cli/store/mod.rs | 4 + nvx-rs/src/host/cli/store/record.rs | 8 + nvx-rs/src/host/cli/store/tests.rs | 1 + nvx-rs/src/host/cli/store/tests/launch.rs | 5 +- nvx-rs/src/host/cli/store/tests/liveness.rs | 269 ++++++++++++++++++++ 6 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 nvx-rs/src/host/cli/store/liveness.rs create mode 100644 nvx-rs/src/host/cli/store/tests/liveness.rs diff --git a/nvx-rs/src/host/cli/store/liveness.rs b/nvx-rs/src/host/cli/store/liveness.rs new file mode 100644 index 000000000..90af6ef29 --- /dev/null +++ b/nvx-rs/src/host/cli/store/liveness.rs @@ -0,0 +1,81 @@ +//! Bounded launch proofs, without recovery, record removal, or process termination. + +use std::io; + +use super::StateFile; +use super::locked::LockedState; +use super::record::LaunchJournal; +use crate::host::platform; +use crate::host::process::{BootId, Process, ProcessRef}; +use crate::{Error, Result}; + +impl LockedState { + /// Returns whether a launch process may still run. Only a released claim proves exit; + /// failure to examine it proves neither, and preserves the original error. + pub(crate) fn claim_held(&self, journal: &LaunchJournal) -> Result { + let path = self.path(StateFile::Log); + if journal.vm_dir != self.dir() || journal.log != path { + return Err(Error::io( + "the launch journal belongs to another sandbox directory", + )); + } + platform::log_claim_held(&path, journal.log_identity) + .map_err(|error| Error::io("cannot check the launch log claim").with_source(error)) + } + + /// Holds the server of the recorded endpoint, rechecks its ownership, and on Linux proves + /// that this very process also holds the launch's log. An error is not proof of exit. + pub(crate) fn endpoint_owner(&self, journal: &LaunchJournal) -> Result { + if !self.claim_held(journal)? { + return Err(Error::io("the launch log claim is released")); + } + let process = + hold_endpoint_server(|| platform::endpoint_server_pid(&journal.endpoint), find)?; + let reference = process.reference(); + let matches = + platform::serves_launch(reference.pid, reference.start_time, journal.log_identity) + .map_err(|error| { + Error::io("cannot verify the endpoint owner's log").with_source(error) + })?; + if !matches { + return Err(Error::io( + "the endpoint owner does not hold the launch log as its standard output", + )); + } + Ok(process) + } +} + +/// Opens only the process whose start time was just read, never another that reused its ID. +fn find(pid: u32) -> io::Result> { + let Some(start_time) = platform::process_start_time(pid)? else { + return Ok(None); + }; + Process::open(&ProcessRef { + pid, + start_time, + boot: BootId::current()?, + }) +} + +/// Adapted from the old store's owner check: the second endpoint lookup happens only after +/// the process is held. The callbacks keep replacement races testable without test-only hooks. +pub(super) fn hold_endpoint_server( + mut server_pid: impl FnMut() -> io::Result>, + hold: impl FnOnce(u32) -> io::Result>, +) -> Result { + let pid = server_pid() + .map_err(|error| Error::io("cannot examine the control endpoint").with_source(error))? + .ok_or_else(|| Error::io("no process serves the control endpoint"))?; + let process = hold(pid) + .map_err(|error| Error::io("cannot hold the control endpoint owner").with_source(error))? + .ok_or_else(|| Error::io("the control endpoint owner exited before it could be held"))?; + let again = server_pid() + .map_err(|error| Error::io("cannot recheck the control endpoint").with_source(error))?; + if again != Some(pid) { + return Err(Error::io( + "the control endpoint changed while its owner was held", + )); + } + Ok(process) +} diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index 16bcb2432..ac58708e0 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -30,6 +30,9 @@ //! A locked launch writes its prepared capability and outputs before spawn, records the spawned //! process durably, and publishes the caller's exact saved VM only at commit. Dropping an //! uncommitted launch keeps its journal; these transitions do not recover or signal a process. +//! Its journal also identifies the inherited log claim: held means a launch process may run, +//! and released proves exit. An endpoint owner is held and rechecked before it is returned; +//! Linux additionally verifies that this process holds that launch's log. #![cfg_attr( not(test), expect( @@ -40,6 +43,7 @@ mod files; mod launch; +mod liveness; mod locked; mod names; mod record; diff --git a/nvx-rs/src/host/cli/store/record.rs b/nvx-rs/src/host/cli/store/record.rs index d84718801..fa85fdf18 100644 --- a/nvx-rs/src/host/cli/store/record.rs +++ b/nvx-rs/src/host/cli/store/record.rs @@ -9,6 +9,7 @@ use serde::{Deserialize, Serialize}; use super::StateFile; use super::files; use super::locked::LockedState; +use crate::host::platform; use crate::host::process::ProcessRef; use crate::host::runtime::RuntimeIdentity; use crate::host::vm::{Prepared, SavedVm, VmDir, VmOutputs}; @@ -21,6 +22,7 @@ pub(crate) struct LaunchJournal { pub(super) vm_dir: PathBuf, pub(super) capability: [u8; CAPABILITY_LEN], pub(super) log: PathBuf, + pub(super) log_identity: (u64, u64), pub(super) outcome: PathBuf, pub(super) endpoint: String, pub(super) console: Option, @@ -34,6 +36,7 @@ impl fmt::Debug for LaunchJournal { .debug_struct("LaunchJournal") .field("vm_dir", &self.vm_dir) .field("log", &self.log) + .field("log_identity", &self.log_identity) .field("outcome", &self.outcome) .field("endpoint", &self.endpoint) .field("console", &self.console) @@ -64,10 +67,15 @@ impl LaunchJournal { "the prepared launch belongs to another sandbox directory", )); } + platform::claim_log(prepared.log()) + .map_err(|error| Error::io("cannot claim the prepared VM log").with_source(error))?; + let log_identity = platform::opened_file_identity(prepared.log()) + .map_err(|error| Error::io("cannot identify the prepared VM log").with_source(error))?; Ok(Self { vm_dir: vm_dir.path().to_path_buf(), capability: *prepared.capability().as_bytes(), log: log.clone(), + log_identity, outcome: outcome.clone(), endpoint: endpoint.clone(), console: console.clone(), diff --git a/nvx-rs/src/host/cli/store/tests.rs b/nvx-rs/src/host/cli/store/tests.rs index f28a33df9..5db8c5adb 100644 --- a/nvx-rs/src/host/cli/store/tests.rs +++ b/nvx-rs/src/host/cli/store/tests.rs @@ -9,6 +9,7 @@ use crate::host::cli::deadline::Deadline; use crate::host::store::{JOURNAL_NAME, RUNTIME_NAME, SandboxStore}; mod launch; +mod liveness; /// How long a test waits for a lock that it released to be free. /// diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs index aff660e14..5fd38d31a 100644 --- a/nvx-rs/src/host/cli/store/tests/launch.rs +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -11,7 +11,10 @@ use crate::host::vm::{self, Prepared, SavedVm, VmDir}; use crate::hypervisor::Hypervisor; use crate::protocol::CAPABILITY_LEN; -fn prepared(state: &StateDir, console: ConsoleAttachment) -> (Prepared, RuntimeIdentity) { +pub(super) fn prepared( + state: &StateDir, + console: ConsoleAttachment, +) -> (Prepared, RuntimeIdentity) { let identity = |text| ArtifactIdentity::new(text).unwrap(); let runtime = Runtime { openvmm: "missing-openvmm".into(), diff --git a/nvx-rs/src/host/cli/store/tests/liveness.rs b/nvx-rs/src/host/cli/store/tests/liveness.rs new file mode 100644 index 000000000..0ac403e01 --- /dev/null +++ b/nvx-rs/src/host/cli/store/tests/liveness.rs @@ -0,0 +1,269 @@ +use std::error::Error as _; +use std::io; +#[cfg(any(target_os = "linux", windows))] +use std::process::{Child, Command, Stdio}; + +use super::launch::prepared; +use super::*; +use crate::host::cli::store::liveness::hold_endpoint_server; +use crate::host::config::ConsoleAttachment; +use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::spawn::{self, VmmCommand}; + +const HELPER: &str = "host::cli::store::tests::liveness::log_holder_helper"; + +fn own_process() -> Process { + let pid = std::process::id(); + Process::open(&ProcessRef { + pid, + start_time: platform::process_start_time(pid).unwrap().unwrap(), + boot: BootId::current().unwrap(), + }) + .unwrap() + .unwrap() +} + +struct Running(Option); + +impl Drop for Running { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.kill_and_confirm_exit().unwrap(); + } + } +} + +#[cfg(any(target_os = "linux", windows))] +struct Other(Child); + +#[cfg(any(target_os = "linux", windows))] +impl Drop for Other { + fn drop(&mut self) { + self.0.kill().unwrap(); + self.0.wait().unwrap(); + } +} + +#[cfg(any(target_os = "linux", windows))] +fn helper_command(state: &StateDir) -> Command { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", HELPER, "--ignored", "--nocapture"]) + .current_dir(state.dir()) + .stdin(Stdio::null()); + command +} + +#[test] +fn a_claim_identifies_the_opened_log_and_last_close_proves_exit() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let identity = platform::opened_file_identity(prepared.log()).unwrap(); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + assert_eq!(journal.log_identity, identity); + assert!(locked.claim_held(&journal).unwrap()); + let copy = prepared.log().try_clone().unwrap(); + drop(prepared); + assert!(locked.claim_held(&journal).unwrap()); + drop(copy); + assert!(released(|| !locked.claim_held(&journal).unwrap())); + assert!(locked.journal().unwrap().is_some()); + assert!(locked.capability().unwrap().is_some()); +} + +#[test] +fn replacing_the_log_or_sandbox_never_proves_the_launch_exited() { + let (_directory, state) = state(); + let (_other_directory, other) = super::state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let elsewhere = other.lock(Deadline::after(10.0)).unwrap(); + assert!(elsewhere.claim_held(&journal).is_err()); + drop(prepared); + fs::rename(state.path(StateFile::Log), state.dir().join("old-log")).unwrap(); + fs::write(state.path(StateFile::Log), b"another launch").unwrap(); + let error = locked.claim_held(&journal).unwrap_err(); + assert!(error.source().is_some()); + assert!( + error + .to_string() + .contains("cannot check the launch log claim") + ); + assert!(locked.endpoint_owner(&journal).is_err()); + assert!(locked.journal().unwrap().is_some()); +} + +#[test] +fn the_spawned_process_inherits_the_preparations_claim() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let command = VmmCommand::new( + std::env::current_exe().unwrap(), + ["--exact", HELPER, "--ignored", "--nocapture"] + .map(Into::into) + .to_vec(), + state.dir(), + ); + let child = Running(Some( + spawn::spawn_vm( + &command, + prepared.capability().as_bytes(), + prepared.log().try_clone().unwrap(), + ) + .unwrap(), + )); + drop(prepared); + assert!(released(|| state.dir().join("holder-ready").exists())); + assert!(locked.claim_held(&journal).unwrap()); + #[cfg(target_os = "linux")] + { + let owner = locked.endpoint_owner(&journal).unwrap(); + assert_eq!(owner.reference().pid, child.0.as_ref().unwrap().id()); + assert!(!owner.has_exited().unwrap()); + } + drop(child); + assert!(released(|| !locked.claim_held(&journal).unwrap())); +} + +#[cfg(target_os = "linux")] +#[test] +fn a_conflicting_lock_prevents_publishing_a_launch() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let other = fs::File::open(prepared.outputs().log.clone()).unwrap(); + other.try_lock().unwrap(); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let error = locked.begin_launch(&prepared, &runtime).unwrap_err(); + assert!(error.source().is_some()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_endpoint_owner_without_the_claimed_log_is_rejected_without_signaling_it() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let mut other = Other( + helper_command(&state) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .unwrap(), + ); + assert!(released(|| state.dir().join("holder-ready").exists())); + assert!(locked.claim_held(&journal).unwrap()); + let error = locked.endpoint_owner(&journal).err().unwrap(); + assert!(error.to_string().contains("does not hold the launch log")); + assert!(other.0.try_wait().unwrap().is_none()); + drop(prepared); + assert!(released(|| !locked.claim_held(&journal).unwrap())); + assert!(locked.endpoint_owner(&journal).is_err()); + assert!(other.0.try_wait().unwrap().is_none()); +} + +#[cfg(windows)] +#[test] +fn without_a_log_job_inherited_handles_remain_the_claim() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let journal = locked.journal().unwrap().unwrap(); + let mut command = helper_command(&state); + command + .stdout(Stdio::from(prepared.log().try_clone().unwrap())) + .stderr(Stdio::from(prepared.log().try_clone().unwrap())); + let child = Other(spawn_locked(&mut command).unwrap()); + drop(command); + drop(prepared); + assert!(released(|| state.dir().join("holder-ready").exists())); + assert!(locked.claim_held(&journal).unwrap()); + drop(child); + assert!(released(|| !locked.claim_held(&journal).unwrap())); +} + +#[test] +fn endpoint_ownership_is_rechecked_only_after_the_server_is_held() { + let pid = std::process::id(); + let held = std::cell::Cell::new(false); + let mut lookups = 0; + let owner = hold_endpoint_server( + || { + lookups += 1; + assert_eq!(held.get(), lookups == 2); + Ok(Some(pid)) + }, + |found| { + assert_eq!(found, pid); + held.set(true); + Ok(Some(own_process())) + }, + ) + .unwrap(); + assert_eq!(owner.reference().pid, pid); + assert_eq!(lookups, 2); + for replacement in [None, Some(pid.wrapping_add(1))] { + let mut lookups = 0; + let error = hold_endpoint_server( + || { + lookups += 1; + Ok(if lookups == 1 { Some(pid) } else { replacement }) + }, + |_| Ok(Some(own_process())), + ) + .err() + .unwrap(); + assert!(error.to_string().contains("endpoint changed")); + assert_eq!(lookups, 2); + } +} + +#[test] +fn missing_owners_and_lookup_or_hold_errors_are_not_exit_proofs() { + assert!(hold_endpoint_server(|| Ok(None), |_| panic!("no owner was found")).is_err()); + assert!(hold_endpoint_server(|| Ok(Some(1)), |_| Ok(None)).is_err()); + for phase in 0..3 { + let mut lookups = 0; + let error = hold_endpoint_server( + || { + lookups += 1; + if (phase == 0 && lookups == 1) || (phase == 2 && lookups == 2) { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(Some(std::process::id())) + } + }, + |_| { + if phase == 1 { + Err(io::Error::from(io::ErrorKind::PermissionDenied)) + } else { + Ok(Some(own_process())) + } + }, + ) + .err() + .unwrap(); + let source = error.source().unwrap().downcast_ref::().unwrap(); + assert_eq!(source.kind(), io::ErrorKind::PermissionDenied); + } +} + +#[test] +#[ignore = "invoked as an owned subprocess by the launch-claim tests"] +fn log_holder_helper() { + #[cfg(target_os = "linux")] + let _listener = std::os::unix::net::UnixListener::bind("control.sock").unwrap(); + fs::write("holder-ready", b"").unwrap(); + thread::sleep(Duration::from_secs(30)); +} From 92ac710d5ed88e0d14c4f36b2a7aafa9f7573f13 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 03:41:53 -0800 Subject: [PATCH 07/30] host: reconcile recorded process opening onto qualified stop Return Running or opaque exit proof after checking the recorded identity. Preserve indeterminate errors and current VM/transport verification policies. Keep the existing exit-handle and recovery tests alongside opening proof cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0c8e42ad-4102-451f-96f0-0a4ae0483d1a --- nvx-rs/README.md | 20 +++--- nvx-rs/public-api/linux.txt | 5 ++ nvx-rs/public-api/windows.txt | 5 ++ nvx-rs/src/host/process.rs | 35 ++++++---- nvx-rs/src/host/process/tests.rs | 64 ++++++++++++++++++- nvx-rs/src/host/process/tests/linux.rs | 14 ++-- nvx-rs/src/host/restore/tests.rs | 10 +-- nvx-rs/src/host/store/locked.rs | 9 ++- nvx-rs/src/host/store/vmm.rs | 8 ++- nvx-rs/src/host/supervisor/reattach.rs | 16 ++++- .../src/host/template/clone/tests/launch.rs | 32 ++++++---- .../src/host/template/clone/tests/restore.rs | 9 ++- .../src/host/template/store/leases/tests.rs | 12 ++-- nvx-rs/src/host/transport.rs | 9 ++- nvx-rs/src/host/transport/tests.rs | 11 ++-- nvx-rs/src/host/vm.rs | 17 +++-- nvx-rs/src/host/vm/tests.rs | 18 ++++-- nvx-rs/tests/cli.rs | 19 +++--- nvx-rs/tests/cli/lifecycle.rs | 13 ++-- nvx-rs/tests/fake_openvmm.rs | 20 +++--- nvx-rs/tests/store.rs | 10 +-- nvx-rs/tests/supervisor.rs | 23 ++++--- nvx-rs/tests/template_capture.rs | 4 +- tests/runtime-adapter/tests/common/mod.rs | 7 +- tests/runtime-adapter/tests/vm_clone.rs | 9 +-- tests/runtime-adapter/tests/vm_steps.rs | 8 +-- 26 files changed, 286 insertions(+), 121 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 3019c6ebc..f9e4f884a 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -970,13 +970,15 @@ compatibility wrapper. Verified process references. A `ProcessRef` names one process by its ID, its start time, and the `BootId` of the host boot during which it started. -`Process::open` checks a reference and holds its process through a pidfd on -Linux or a process handle on Windows, whether or not the caller started it, so -that waits sleep until the process exits and termination never reaches a process -that reuses its ID. A reference from an earlier boot names an exited process, -and a process whose identity this user cannot check, such as another user's, -makes `Process::open` fail rather than report an exit. Linux hosts without -pidfds poll the reference instead, and cannot terminate the process. +`Process::open` returns `Opened::Running(Process)` only after checking the +recorded boot, ID, and start time. It holds the process through a pidfd on +Linux or a process handle on Windows, whether or not the caller started it, +so that waits and termination never reach a process that reuses its ID. +`Opened::Exited(Exited)` proves that the recorded boot ended or no running +process matches both its ID and start time. A process whose identity this +user cannot check makes `Process::open` fail rather than report an exit. +Linux hosts without pidfds poll the reference instead, and cannot terminate +the process. On Linux and Windows, `Process::exit_handle()` returns `Result>`: an independently owned duplicate of the pidfd @@ -988,8 +990,8 @@ Duplication failures have kind `Io` and retain their original I/O source. No watcher thread, future or callback is created by the library. `Process::wait(deadline)` returns `Some(Exited)` only after verified exit, or -`None` when the deadline passes without confirmation. `Exited` cannot be made -by a caller. +`None` when the deadline passes without confirmation. `Exited` proves exit, +not an exit status, and cannot be made by a caller. `Process::bytes_read` reports how many bytes the process has read through the operating system's read calls, whether the host's cache served them or not: diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 2d90a5759..edc5c8d46 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -2448,6 +2448,11 @@ method nvx::host::process::BootId::current method nvx::host::process::BootId::from_bytes type nvx::host::process::ExitHandle struct nvx::host::process::Exited +enum nvx::host::process::Opened +variant nvx::host::process::Opened::Exited +field nvx::host::process::Opened::Exited::0 +variant nvx::host::process::Opened::Running +field nvx::host::process::Opened::Running::0 struct nvx::host::process::Process method nvx::host::process::Process::bytes_read method nvx::host::process::Process::exit_handle diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index c1e2bc8cf..e45f6cacf 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1034,6 +1034,11 @@ method nvx::host::process::BootId::current method nvx::host::process::BootId::from_bytes type nvx::host::process::ExitHandle struct nvx::host::process::Exited +enum nvx::host::process::Opened +variant nvx::host::process::Opened::Exited +field nvx::host::process::Opened::Exited::0 +variant nvx::host::process::Opened::Running +field nvx::host::process::Opened::Running::0 struct nvx::host::process::Process method nvx::host::process::Process::bytes_read method nvx::host::process::Process::exit_handle diff --git a/nvx-rs/src/host/process.rs b/nvx-rs/src/host/process.rs index 100abe93d..a24ed8776 100644 --- a/nvx-rs/src/host/process.rs +++ b/nvx-rs/src/host/process.rs @@ -2,10 +2,11 @@ //! //! A [`ProcessRef`] names one process by its ID, its start time, and the [`BootId`] of the host //! boot during which it started, which together never name another process. [`Process::open`] -//! checks a reference against the host and holds the process that it names through a handle of -//! the operating system: a pidfd on Linux, and a process handle on Windows. Waits and termination -//! go through that handle, so they reach the referenced process, whether or not the caller -//! started it, and never another process that reuses its ID. A process that does not match its +//! checks a reference against the host and returns [`Opened::Running`] with the process held +//! through a handle of the operating system, or [`Opened::Exited`] with proof that the recorded +//! process exited. The handle is a pidfd on Linux, and a process handle on Windows. Waits and +//! termination go through that handle, so they reach the referenced process, whether or not the +//! caller started it, and never another process that reuses its ID. A process that does not match its //! reference is never signaled, and neither is one that a reference from an earlier boot names: //! every process of that boot has exited. //! @@ -188,14 +189,23 @@ pub struct Process { handle: platform::ProcessHandle, } -/// Proof that a verified process exited, produced only by [`Process::wait`]. +/// The result of checking a recorded process reference against this host. +#[derive(Debug)] +pub enum Opened { + /// The process matched the recorded boot, ID, and start time; the caller owns its `Process`. + Running(Process), + /// The recorded process exited: its boot ended, or no running process matches its identity. + Exited(Exited), +} + +/// Proof that a verified process exited, produced only by [`Process::wait`] or [`Process::open`]. /// /// It proves exit, not the process's exit status, and cannot be constructed by a caller. #[derive(Debug)] pub struct Exited(()); impl Process { - /// Finds the process that `reference` names, and returns `None` if it has exited. + /// Finds the process that `reference` names, or returns proof that it has exited. /// /// The process has exited when no process has its ID, when the process that has it started /// at another time, and when the reference comes from another boot. @@ -207,12 +217,15 @@ impl Process { /// other users. Fails with [`io::ErrorKind::InvalidInput`] for a process ID that the host /// never assigns, such as 0, and with [`io::ErrorKind::Unsupported`] on hosts other than /// Linux and Windows. - pub fn open(reference: &ProcessRef) -> io::Result> { + pub fn open(reference: &ProcessRef) -> io::Result { let handle = open_reference(reference, BootId::current, platform::open_process)?; - Ok(handle.map(|handle| Self { - reference: reference.clone(), - handle, - })) + Ok(match handle { + Some(handle) => Opened::Running(Self { + reference: reference.clone(), + handle, + }), + None => Opened::Exited(Exited(())), + }) } /// Returns the process that `handle` holds, whose identity the caller verified to be diff --git a/nvx-rs/src/host/process/tests.rs b/nvx-rs/src/host/process/tests.rs index a90c83102..85f4b54d8 100644 --- a/nvx-rs/src/host/process/tests.rs +++ b/nvx-rs/src/host/process/tests.rs @@ -182,6 +182,21 @@ fn sleeper() -> std::process::Command { #[cfg(any(target_os = "linux", windows))] struct ExitChild(std::process::Child); +#[cfg(any(target_os = "linux", windows))] +impl ExitChild { + fn spawn() -> Self { + Self(crate::host::tests::spawn_locked(&mut sleeper()).unwrap()) + } + + fn reference(&self) -> ProcessRef { + ProcessRef { + pid: self.0.id(), + start_time: platform::process_start_time(self.0.id()).unwrap().unwrap(), + boot: BootId::current().unwrap(), + } + } +} + #[cfg(any(target_os = "linux", windows))] impl Drop for ExitChild { fn drop(&mut self) { @@ -224,7 +239,9 @@ fn exit_handles_are_independently_owned_and_exist_only_for_exit_events() { start_time: platform::process_start_time(child.0.id()).unwrap().unwrap(), boot: BootId::current().unwrap(), }; - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the child runs"); + }; let handle = process.exit_handle().unwrap(); assert_eq!(handle.is_some(), process.wait_method() == WaitMethod::Event); if handle.is_some() { @@ -239,6 +256,47 @@ fn exit_handles_are_independently_owned_and_exist_only_for_exit_events() { } } +#[cfg(any(target_os = "linux", windows))] +#[test] +fn opening_a_recorded_child_returns_running_only_for_its_identity() { + let mut child = ExitChild::spawn(); + let reference = child.reference(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the recorded child runs"); + }; + assert_eq!(process.reference(), &reference); + assert!(!process.has_exited().unwrap()); + let mut other_boot = *reference.boot.as_bytes(); + other_boot[0] ^= 1; + for exited in [ + ProcessRef { + boot: BootId::from_bytes(other_boot), + ..reference.clone() + }, + ProcessRef { + start_time: reference.start_time + 1, + ..reference.clone() + }, + ] { + assert!(matches!(Process::open(&exited).unwrap(), Opened::Exited(_))); + } + assert!(child.0.try_wait().unwrap().is_none()); + assert!(!process.has_exited().unwrap()); +} + +#[cfg(any(target_os = "linux", windows))] +#[test] +fn opening_a_reaped_recorded_child_proves_exit() { + let mut child = ExitChild::spawn(); + let reference = child.reference(); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + assert!(matches!( + Process::open(&reference).unwrap(), + Opened::Exited(_) + )); +} + #[cfg(any(target_os = "linux", windows))] #[test] fn polling_follows_a_real_process_until_it_exits() { @@ -347,7 +405,9 @@ fn processes_count_the_bytes_that_their_read_calls_read() { start_time: platform::process_start_time(child.id()).unwrap().unwrap(), boot: BootId::current().unwrap(), }; - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the reader runs"); + }; let counted = process.bytes_read(); let mut stdin = child.stdin.take().unwrap(); stdin.write_all(b"x\n").unwrap(); diff --git a/nvx-rs/src/host/process/tests/linux.rs b/nvx-rs/src/host/process/tests/linux.rs index a2a0cf72f..50bb942b0 100644 --- a/nvx-rs/src/host/process/tests/linux.rs +++ b/nvx-rs/src/host/process/tests/linux.rs @@ -144,13 +144,14 @@ fn a_process_runs_until_its_last_thread_exits() { thread::sleep(POLL_INTERVAL); } assert_eq!(platform::process_start_time(pid).unwrap(), Some(start_time)); - let process = Process::open(&ProcessRef { + let Opened::Running(process) = Process::open(&ProcessRef { pid, start_time, boot: BootId::current().unwrap(), }) - .unwrap() - .expect("a process whose main thread exited still runs"); + .unwrap() else { + panic!("a process whose main thread exited still runs"); + }; assert!( process .wait(Instant::now() + Duration::from_millis(100)) @@ -197,13 +198,16 @@ fn a_process_whose_command_name_is_not_utf8_can_be_examined() { start_time: platform::process_start_time(pid).unwrap().unwrap(), boot: BootId::current().unwrap(), }; - assert!(Process::open(&reference).unwrap().is_some()); + assert!(matches!( + Process::open(&reference).unwrap(), + Opened::Running(_) + )); // A reference to a process that exited and whose ID this one reused names no process. let stale = ProcessRef { start_time: reference.start_time + 1, ..reference }; - assert!(Process::open(&stale).unwrap().is_none()); + assert!(matches!(Process::open(&stale).unwrap(), Opened::Exited(_))); child.0.kill().unwrap(); child.0.wait().unwrap(); } diff --git a/nvx-rs/src/host/restore/tests.rs b/nvx-rs/src/host/restore/tests.rs index b302590b0..f163d92da 100644 --- a/nvx-rs/src/host/restore/tests.rs +++ b/nvx-rs/src/host/restore/tests.rs @@ -12,7 +12,7 @@ use crate::activation::{ OverlayMode, ReadyActivation, RestoreSection, TransactionId, Upper, View, }; use crate::host::platform::inherit::spawn_command; -use crate::host::process::BootId; +use crate::host::process::{BootId, Opened}; use crate::host::vm::create_private_dir; use crate::protocol::restore_ready::parse_ready_event; @@ -25,13 +25,15 @@ fn vm_directory() -> (tempfile::TempDir, VmDir) { } fn held_process(pid: u32) -> Process { - Process::open(&ProcessRef { + let Opened::Running(process) = Process::open(&ProcessRef { pid, start_time: platform::process_start_time(pid).unwrap().unwrap(), boot: BootId::current().unwrap(), }) - .unwrap() - .unwrap() + .unwrap() else { + panic!("the fixture process runs"); + }; + process } /// A separate process, held before it may connect, and alive until the test ends it. diff --git a/nvx-rs/src/host/store/locked.rs b/nvx-rs/src/host/store/locked.rs index 91d1dd322..67e6d269a 100644 --- a/nvx-rs/src/host/store/locked.rs +++ b/nvx-rs/src/host/store/locked.rs @@ -20,7 +20,7 @@ use super::{CLAIM_RELEASE_WAIT, LOCK_RETRY_INTERVAL, SandboxStore, record, store use crate::error::{Error, ErrorCode, Result}; use crate::host::filesystem::FileIdentity; use crate::host::platform; -use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; use crate::protocol::CAPABILITY_LEN; /// The store of a sandbox whose lock this holds, through which its lifecycle transitions change @@ -357,7 +357,12 @@ impl LockedStore { /// Ends the launch that `journal` records, and removes its files once nothing of it runs. pub(super) fn end_launch(&self, journal: &LaunchJournal) -> Result { let recovery = match journal.process() { - Some(process) => self.end_identified(journal, process, Process::open), + Some(process) => self.end_identified(journal, process, |reference| { + Ok(match Process::open(reference)? { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + }) + }), None => self.end_unidentified(journal), }; if !matches!(recovery, Recovery::Indeterminate(_)) { diff --git a/nvx-rs/src/host/store/vmm.rs b/nvx-rs/src/host/store/vmm.rs index 4453d2fa6..f4a66918c 100644 --- a/nvx-rs/src/host/store/vmm.rs +++ b/nvx-rs/src/host/store/vmm.rs @@ -5,7 +5,7 @@ use std::io; use std::time::Instant; use crate::host::platform; -use crate::host::process::{BootId, KILL_TIMEOUT, Process, ProcessRef}; +use crate::host::process::{BootId, KILL_TIMEOUT, Opened, Process, ProcessRef}; /// Finds the live process `pid` of the current boot and holds it, or returns `None` if it has /// exited. @@ -16,10 +16,14 @@ pub(super) fn find(pid: u32) -> io::Result> { let Some(start_time) = platform::process_start_time(pid)? else { return Ok(None); }; - Process::open(&ProcessRef { + let opened = Process::open(&ProcessRef { pid, start_time, boot: BootId::current()?, + })?; + Ok(match opened { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, }) } diff --git a/nvx-rs/src/host/supervisor/reattach.rs b/nvx-rs/src/host/supervisor/reattach.rs index 5563c4269..c04037bf9 100644 --- a/nvx-rs/src/host/supervisor/reattach.rs +++ b/nvx-rs/src/host/supervisor/reattach.rs @@ -5,7 +5,7 @@ use std::io; use super::vmm::Vmm; use crate::error::Result; -use crate::host::process::{Process, ProcessRef}; +use crate::host::process::{Opened, Process, ProcessRef}; use crate::host::store::{ IdentityCheck, IdentityPart, LockedStore, Recovery, RuntimeIdentity, RuntimeRecord, SandboxStore, @@ -65,7 +65,7 @@ pub enum Reattached { /// can. Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for an invalid /// `runtime`, and as [`LockedStore::recover`] does for inconsistent records. pub fn reattach(locked: &mut LockedStore, runtime: &RuntimeIdentity) -> Result { - reattach_with(locked, runtime, Process::open, |_| Ok(())) + reattach_reporting(locked, runtime, |_| Ok(())) } /// Reattaches as [`reattach`] does, and calls `exited` with the runtime record of a VMM that it @@ -77,7 +77,17 @@ pub(crate) fn reattach_reporting( runtime: &RuntimeIdentity, exited: impl FnOnce(&RuntimeRecord) -> Result<()>, ) -> Result { - reattach_with(locked, runtime, Process::open, exited) + reattach_with( + locked, + runtime, + |reference| { + Ok(match Process::open(reference)? { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + }) + }, + exited, + ) } /// Reattaches as [`reattach_reporting`] does, holding the recorded VMM with `open`, which is diff --git a/nvx-rs/src/host/template/clone/tests/launch.rs b/nvx-rs/src/host/template/clone/tests/launch.rs index 7ec87eec6..ccd088aa8 100644 --- a/nvx-rs/src/host/template/clone/tests/launch.rs +++ b/nvx-rs/src/host/template/clone/tests/launch.rs @@ -18,7 +18,7 @@ use super::*; use crate::activation::{ReadyActivation, StoreGeneration, TransactionId}; use crate::host::artifacts; use crate::host::platform::Transport; -use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; use crate::host::snapshot::tests::artifact; use crate::host::store::files::{sync_dir, write_private}; use crate::host::store::{ @@ -361,9 +361,9 @@ impl VmmDir { /// before it asks, since a process that has exited no longer opens. pub(super) fn end_and_wait(&self) { let reference = self.reference.as_ref().expect("the test knows the VMM"); - let process = Process::open(reference) - .unwrap() - .expect("the VMM runs until the test ends it"); + let Opened::Running(process) = Process::open(reference).unwrap() else { + panic!("the VMM runs until the test ends it"); + }; self.end(); assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } @@ -371,7 +371,7 @@ impl VmmDir { /// Waits until the VMM, which exits on its own, as its [plan](Self::plan) asks, has exited. pub(super) fn wait_exit(&self) { let reference = self.reference.as_ref().expect("the test knows the VMM"); - if let Some(process) = Process::open(reference).unwrap() { + if let Opened::Running(process) = Process::open(reference).unwrap() { assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } @@ -412,7 +412,7 @@ impl Drop for VmmDir { fn drop(&mut self) { let _ = fs::write(self.path().join(EXIT_MARKER), b""); if let Some(reference) = &self.reference - && let Ok(Some(process)) = Process::open(reference) + && let Ok(Opened::Running(process)) = Process::open(reference) && !process .wait(Instant::now() + TIMEOUT) .is_ok_and(|exited| exited.is_some()) @@ -2301,7 +2301,9 @@ fn a_reattach_takes_the_report_of_a_vmm_whose_launch_a_recovery_of_the_sandbox_e fn a_launch_whose_caller_crashes_once_its_vmm_started_ends_the_vmm_as_reattach_recovers_it() { let crashed = Crashed::at("launched"); let reference = crashed.vmm.reference.clone().unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the crashed caller's VMM runs"); + }; // The VMM listens beside the snapshot on Linux, as it has once it tried to send its event. crashed.vmm.started(); assert_eq!(exists(&crashed.socket()), cfg!(target_os = "linux")); @@ -2338,7 +2340,9 @@ fn a_restore_whose_caller_crashes_before_its_commit_ends_its_vmm_as_reattach_rec for step in ["activating", "activated", "starting", "started"] { let crashed = Crashed::at(step); let reference = crashed.vmm.reference.clone().unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the crashed caller's VMM runs"); + }; // The caller read the VMM's restore-ready event, whose flush waited for that on Windows. assert!(crashed.vmm.started().ends_with(" true"), "{step}"); assert!(crashed.vmm.flushed(), "{step}"); @@ -3663,7 +3667,10 @@ fn a_reattach_that_cannot_record_a_failure_ends_and_forgets_nothing_of_the_clone .journal() .unwrap() .expect("the reattach kept the journal"); - assert!(Process::open(journal.process().unwrap()).unwrap().is_some()); + assert!(matches!( + Process::open(journal.process().unwrap()).unwrap(), + Opened::Running(_) + )); crashed.refuses_release(&mut locked); // Once the record can be written, the reattach ends the launch, and records its failure. fs::remove_dir(&path).unwrap(); @@ -3719,7 +3726,7 @@ fn a_reattach_that_cannot_record_a_failure_ends_and_forgets_nothing_of_the_clone .unwrap() .expect("the reattach kept the record"); assert!( - Process::open(record.process()).unwrap().is_some(), + matches!(Process::open(record.process()).unwrap(), Opened::Running(_)), "{evidence:?}" ); crashed.refuses_release(&mut locked); @@ -3775,7 +3782,10 @@ fn a_reattach_makes_the_record_of_an_earlier_failure_durable_before_it_ends_the_ .journal() .unwrap() .expect("the reattach kept the journal"); - assert!(Process::open(journal.process().unwrap()).unwrap().is_some()); + assert!(matches!( + Process::open(journal.process().unwrap()).unwrap(), + Opened::Running(_) + )); crashed.refuses_release(&mut locked); // One that can syncs the sandbox's directory before it ends the launch. let synced = RefCell::new(Vec::new()); diff --git a/nvx-rs/src/host/template/clone/tests/restore.rs b/nvx-rs/src/host/template/clone/tests/restore.rs index 61282c561..54d8f45eb 100644 --- a/nvx-rs/src/host/template/clone/tests/restore.rs +++ b/nvx-rs/src/host/template/clone/tests/restore.rs @@ -20,7 +20,7 @@ use super::launch::{ use super::*; use crate::activation::{ReadyActivation, RestoreSection, TransactionId}; use crate::host::platform::Transport; -use crate::host::process::{BootId, Process}; +use crate::host::process::{BootId, Opened, Process}; use crate::host::store::{CLONE_ACTIVATION_NAME, Recovery}; use crate::host::supervisor::{Starting, StopOutcome}; use crate::host::template::clone::launch::Hooks; @@ -421,7 +421,7 @@ fn vmm_with(plan: &str) -> VmmDir { /// Checks that the VMM of `vmm` has exited. fn assert_vmm_ended(vmm: &VmmDir) { let reference = vmm.reference.as_ref().unwrap(); - if let Some(process) = Process::open(reference).unwrap() { + if let Opened::Running(process) = Process::open(reference).unwrap() { assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); } } @@ -1193,7 +1193,10 @@ fn a_launch_that_cannot_be_ended_keeps_its_records_and_its_lease_until_a_reattac assert_eq!(refusal.code(), ErrorCode::SandboxRunning, "{refusal}"); let records = CloneRecords::read(locked).unwrap().unwrap(); assert_eq!(records.journal.unwrap().process(), Some(&reference)); - assert!(Process::open(&reference).unwrap().is_some()); + assert!(matches!( + Process::open(&reference).unwrap(), + Opened::Running(_) + )); // A reattach ends the launch through its VMM's verified identity: the record keeps the // failure's stage, evidence, and original outcome, with the teardown that resolved it. let mut control = FakeControl::default(); diff --git a/nvx-rs/src/host/template/store/leases/tests.rs b/nvx-rs/src/host/template/store/leases/tests.rs index eaeed151c..2a8b6898d 100644 --- a/nvx-rs/src/host/template/store/leases/tests.rs +++ b/nvx-rs/src/host/template/store/leases/tests.rs @@ -15,7 +15,7 @@ use serde_json::{Value, json}; use super::*; use crate::activation::TemplateReference; -use crate::host::process::{Process, ProcessRef}; +use crate::host::process::{Opened, Process, ProcessRef}; use crate::host::snapshot::tests::{ManifestSpec, artifact, network_class}; use crate::host::store::files::write_private; use crate::host::store::{ @@ -2459,7 +2459,7 @@ impl Drop for VmmDir { fn drop(&mut self) { let _ = fs::write(self.path().join(EXIT_MARKER), b""); if let Some(reference) = &self.reference - && let Ok(Some(process)) = Process::open(reference) + && let Ok(Opened::Running(process)) = Process::open(reference) && !process .wait(Instant::now() + TIMEOUT) .is_ok_and(|exited| exited.is_some()) @@ -2625,7 +2625,9 @@ fn a_clone_that_crashes_before_its_spawn_keeps_its_lease_until_its_launch_ends() fn a_clone_that_crashes_after_its_spawn_keeps_its_lease_until_its_unrecorded_vmm_exits() { let crashed = Crashed::after("after-spawn"); let reference = crashed.vmm.reference.clone().unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the unrecorded VMM runs"); + }; let mut locked = crashed.lock(); assert_eq!( locked.journal().unwrap().unwrap().state(), @@ -2656,7 +2658,9 @@ fn a_clone_that_crashes_after_its_spawn_keeps_its_lease_until_its_unrecorded_vmm fn a_clone_that_crashes_after_recording_its_vmm_keeps_its_lease_until_reattach_ends_it() { let crashed = Crashed::after("after-identity"); let reference = crashed.vmm.reference.clone().unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the recorded VMM runs"); + }; let mut locked = crashed.lock(); let journal = locked.journal().unwrap().unwrap(); assert_eq!(journal.launch(), crashed.lease.launch()); diff --git a/nvx-rs/src/host/transport.rs b/nvx-rs/src/host/transport.rs index 5ab40b105..bd171ded9 100644 --- a/nvx-rs/src/host/transport.rs +++ b/nvx-rs/src/host/transport.rs @@ -47,7 +47,7 @@ use std::thread; use std::time::{Duration, Instant}; use super::platform; -use super::process::{Process, ProcessRef}; +use super::process::{Opened, Process, ProcessRef}; use crate::protocol::CAPABILITY_LEN; use crate::protocol::nvxs::{ ErrorCode, HostEvent, HostSession, Identity, RecordType, SessionError, @@ -76,9 +76,12 @@ pub fn connect_verified( reference: &ProcessRef, deadline: Instant, ) -> Result> { - let process = Process::open(reference) + let process = match Process::open(reference) .map_err(|error| Error::io("cannot verify the endpoint's VM process").with_source(error))? - .ok_or_else(exited)?; + { + Opened::Running(process) => process, + Opened::Exited(_) => return Err(exited()), + }; let mut unavailable = None; loop { check_running(&process)?; diff --git a/nvx-rs/src/host/transport/tests.rs b/nvx-rs/src/host/transport/tests.rs index 2136f8fbb..eb449723e 100644 --- a/nvx-rs/src/host/transport/tests.rs +++ b/nvx-rs/src/host/transport/tests.rs @@ -166,13 +166,10 @@ fn another_process_serving_the_endpoint_is_refused() { assert!(source.to_string().contains(&std::process::id().to_string())); assert!(source.to_string().contains(&process.pid.to_string())); } - assert!( - !Process::open(&process) - .unwrap() - .unwrap() - .has_exited() - .unwrap() - ); + let Opened::Running(held) = Process::open(&process).unwrap() else { + panic!("the unrelated VM process still runs"); + }; + assert!(!held.has_exited().unwrap()); } } diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 72e724aa6..142ca7c0c 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -31,7 +31,10 @@ use crate::host::runtime::{Runtime, RuntimeIdentity}; use crate::host::snapshot::Snapshot; use crate::host::spawn::{self, VmmCommand}; use crate::host::transport::{self, HostConnection}; -use crate::host::{platform::inherit::Inherited, process::Process, process::ProcessRef}; +use crate::host::{ + platform::inherit::Inherited, + process::{Opened, Process, ProcessRef}, +}; use crate::protocol::CAPABILITY_LEN; use crate::{Error, Result}; @@ -252,12 +255,16 @@ pub fn verify(saved: &SavedVm, runtime: &RuntimeIdentity) -> Result { ), )); } - Process::open(&saved.process) + match Process::open(&saved.process) .map_err(|error| Error::io("cannot verify the saved VM process").with_source(error))? - .ok_or_else(|| { + { + Opened::Running(process) => Ok(process), + Opened::Exited(_) => { let message = "the saved VM process exited"; - Error::exited(message).with_source(io::Error::new(io::ErrorKind::NotFound, message)) - }) + Err(Error::exited(message) + .with_source(io::Error::new(io::ErrorKind::NotFound, message))) + } + } } /// A verified held VM process and its authenticated control connection. diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index b637a7532..8903967cb 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -11,6 +11,8 @@ use crate::host::config::{ Boot, LayeredRoot, PROCESS_COMMAND_LINE_LIMIT, RootConfig, UpperConfig, process_command_line_units, }; +#[cfg(any(target_os = "linux", windows))] +use crate::host::process::Opened; use crate::host::runtime::{ArtifactIdentity, LaunchOptions, RuntimeIdentity}; use crate::hypervisor::Hypervisor; use crate::protocol::root::RootImage; @@ -901,7 +903,9 @@ fn spawn_returns_the_same_outputs_and_capability_with_a_verified_process() { assert_eq!(launched.0.capability.as_bytes(), &capability); let reference = launched.0.process.reference(); assert_ne!(reference.pid, std::process::id()); - let reopened = Process::open(reference).unwrap().unwrap(); + let Opened::Running(reopened) = Process::open(reference).unwrap() else { + panic!("the recorded VMM runs"); + }; assert_eq!(reopened.reference(), reference); assert!(!reopened.has_exited().unwrap()); assert!(!format!("{:?}", launched.0).contains(&format!("{capability:?}"))); @@ -1073,7 +1077,9 @@ fn a_library_vm_outlives_the_caller_that_spawned_it_without_retaining_its_output ); let reference = serde_json::from_slice(&fs::read(vm.join("caller-process.json")).unwrap()).unwrap(); - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the caller's recorded VMM runs"); + }; let launched = RunningVm(Launched { process, outputs: VmDir::open(&vm).unwrap().outputs(false).unwrap(), @@ -1233,7 +1239,9 @@ fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { let launched = RunningVm(spawn(prepared).unwrap()); assert_eq!(launched.0.capability.as_bytes(), &capability); let reference = launched.0.process.reference(); - let reopened = Process::open(reference).unwrap().unwrap(); + let Opened::Running(reopened) = Process::open(reference).unwrap() else { + panic!("the recorded VMM runs"); + }; assert_eq!(reopened.reference(), reference); let deadline = Instant::now() + Duration::from_secs(60); let mut console = loop { @@ -1334,7 +1342,9 @@ impl StopChild { start_time: platform::process_start_time(child.id()).unwrap().unwrap(), boot: crate::host::process::BootId::current().unwrap(), }; - let process = Process::open(&reference).unwrap().unwrap(); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the owned child runs"); + }; Self { child, process } } } diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index 01191d000..0c32142f2 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -51,7 +51,7 @@ use std::process::{Command, Output, Stdio}; use std::thread; use std::time::{Duration, Instant}; -use nvx::host::process::{Process, ProcessRef}; +use nvx::host::process::{Opened, Process, ProcessRef}; use serde_json::{Value, json}; use self::harness::{FAKE_OPENVMM, NVX_RS, Root, canonical}; @@ -800,7 +800,10 @@ fn runtime_record(root: &Root, state: &str) -> Option { fn recorded_process(root: &Root, state: &str) -> Option { let record = runtime_record(root, state)?; let reference: ProcessRef = serde_json::from_value(record["process"].clone()).unwrap(); - Process::open(&reference).unwrap() + match Process::open(&reference).unwrap() { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + } } /// Returns the names in the state directory `state` below the root, sorted. @@ -1057,9 +1060,9 @@ fn a_start_that_was_interrupted_is_ended_under_another_identity_too() { // The CLI dies, as a crash would, and leaves its OpenVMM running. start.kill().unwrap(); start.wait().unwrap(); - let process = Process::open(&reference) - .unwrap() - .expect("the OpenVMM of the start runs"); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the OpenVMM of the start runs"); + }; // Under another runtime identity, the next transition ends the start, whose OpenVMM it // verifies through the journal. @@ -2059,9 +2062,9 @@ fn a_start_that_ends_before_openvmm_opens_the_images_leaves_it_the_checked_ones( // The CLI dies, as a crash would, before OpenVMM has opened its images, and leaves it running. start.kill().unwrap(); start.wait().unwrap(); - let process = Process::open(&reference) - .unwrap() - .expect("the OpenVMM of the start runs"); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the OpenVMM of the start runs"); + }; let blocks = root .path .join("build") diff --git a/nvx-rs/tests/cli/lifecycle.rs b/nvx-rs/tests/cli/lifecycle.rs index d82a0f548..ce2ac6b58 100644 --- a/nvx-rs/tests/cli/lifecycle.rs +++ b/nvx-rs/tests/cli/lifecycle.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; use nvx::contract::{ BOOT_CONTRACT_VERSION, CONTROL_CONTRACT_REVISION, CONTROL_SESSION_PROTOCOL_VERSION, }; -use nvx::host::process::{Process, ProcessRef}; +use nvx::host::process::{Opened, Process, ProcessRef}; use nvx::host::sandbox::distro::DistroBinding; use nvx::host::sandbox::scratch::ScratchBinding; use nvx::host::sandbox::{LayerRole, SandboxLayer}; @@ -153,9 +153,9 @@ fn assert_runtime_record(root: &Root, state: &str, record: &Value) -> (String, P "{record}" ); let reference: ProcessRef = serde_json::from_value(record["process"].clone()).unwrap(); - let process = Process::open(&reference) - .unwrap() - .unwrap_or_else(|| panic!("the OpenVMM of the record does not run: {record}")); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the OpenVMM of the record does not run: {record}"); + }; let endpoint = record["endpoint"].as_str().unwrap().to_owned(); assert_endpoint(root, state, &endpoint); let digest = |index: usize| format!("sha256:{}", PROVENANCE[index].2); @@ -1432,7 +1432,10 @@ fn recycled_process_id(root: &Root) { let path = root.below("state/runtime.json"); let mut record = json(&path); let reference: ProcessRef = serde_json::from_value(record["process"].clone()).unwrap(); - let openvmm = Unrecorded(Process::open(&reference).unwrap().expect("OpenVMM runs")); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("OpenVMM runs"); + }; + let openvmm = Unrecorded(process); let start_time = record["process"]["startTime"].as_u64().unwrap(); record["process"]["startTime"] = json!(start_time + 1); fs::write(&path, serde_json::to_vec_pretty(&record).unwrap()).unwrap(); diff --git a/nvx-rs/tests/fake_openvmm.rs b/nvx-rs/tests/fake_openvmm.rs index b112f951b..3444797cd 100644 --- a/nvx-rs/tests/fake_openvmm.rs +++ b/nvx-rs/tests/fake_openvmm.rs @@ -20,7 +20,7 @@ use std::time::{Duration, Instant}; use nvx::host::instance::{Found, Instance, Running, Started, Stopped, WorkloadOutcome}; use nvx::host::launch::{LaunchSpec, PROCESS_COMMAND_LINE_LIMIT}; -use nvx::host::process::{Process, ProcessRef}; +use nvx::host::process::{Opened, Process, ProcessRef}; use nvx::host::store::{ArtifactIdentity, CallerFiles, IdentityPart, RuntimeIdentity}; use nvx::host::supervisor::spawn_command; use nvx::protocol::{Workload, WorkloadEnvironment}; @@ -425,9 +425,10 @@ fn a_restarted_orchestrator_reattaches_to_its_vm() { let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { panic!("a VM of another runtime identity was not reported"); }; - let process = Process::open(mismatched.vmm().record().process()) - .unwrap() - .expect("the VM runs"); + let Opened::Running(process) = Process::open(mismatched.vmm().record().process()).unwrap() + else { + panic!("the VM runs"); + }; for timeout in [Duration::ZERO, Duration::MAX] { let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { panic!("a VM of another runtime identity was not reported"); @@ -467,9 +468,10 @@ fn outcome_reports_of_another_runtime_are_never_read() { let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { panic!("a VM of another runtime identity was not reported"); }; - let process = Process::open(mismatched.vmm().record().process()) - .unwrap() - .expect("the VM runs"); + let Opened::Running(process) = Process::open(mismatched.vmm().record().process()).unwrap() + else { + panic!("the VM runs"); + }; fs::write( instance.report_path(), r#"{"schema_version":1,"outcome":{"operation":"managed","category":"success", @@ -517,7 +519,9 @@ fn records_that_name_another_process_never_end_it() { let mut record: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); let reference: ProcessRef = serde_json::from_value(record["process"].clone()).unwrap(); - let process = Process::open(&reference).unwrap().expect("the VM runs"); + let Opened::Running(process) = Process::open(&reference).unwrap() else { + panic!("the VM runs"); + }; tamper(&mut record); fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap(); // Under another runtime identity too, the VM is found stopped, and its record is cleared, diff --git a/nvx-rs/tests/store.rs b/nvx-rs/tests/store.rs index 9602cce68..ea22e24ed 100644 --- a/nvx-rs/tests/store.rs +++ b/nvx-rs/tests/store.rs @@ -20,7 +20,7 @@ use std::time::{Duration, Instant}; use nvx::ErrorKind; use nvx::host::platform; -use nvx::host::process::{BootId, Process, ProcessRef}; +use nvx::host::process::{BootId, Opened, Process, ProcessRef}; use nvx::host::store::{ ArtifactIdentity, IdentityCheck, IdentityPart, JOURNAL_NAME, Launch, LaunchId, LaunchState, LockedStore, RUNTIME_NAME, Recovery, RuntimeIdentity, SandboxStore, @@ -179,7 +179,7 @@ fn kill(pid: u32, start_time: u64) -> io::Result { start_time, boot: BootId::current()?, }; - let Some(process) = Process::open(&reference)? else { + let Opened::Running(process) = Process::open(&reference)? else { return Ok(true); }; process.kill()?; @@ -530,9 +530,9 @@ fn a_committed_launch_names_its_vm_for_another_build() { IdentityCheck::Mismatched(vec![IdentityPart::Initramfs]) ); // The record's verified reference holds the VMM, whichever process started it. - let reattached = Process::open(found.process()) - .unwrap() - .expect("the recorded VMM runs"); + let Opened::Running(reattached) = Process::open(found.process()).unwrap() else { + panic!("the recorded VMM runs"); + }; assert!(!reattached.has_exited().unwrap()); drop(vmm); assert!( diff --git a/nvx-rs/tests/supervisor.rs b/nvx-rs/tests/supervisor.rs index 45b2058ab..f5a29252a 100644 --- a/nvx-rs/tests/supervisor.rs +++ b/nvx-rs/tests/supervisor.rs @@ -30,7 +30,7 @@ use std::time::{Duration, Instant}; use nvx::ErrorKind; use nvx::host::launch::MAX_TIMEOUT; -use nvx::host::process::{BootId, Process, ProcessRef, WaitMethod}; +use nvx::host::process::{BootId, Opened, Process, ProcessRef, WaitMethod}; #[cfg(windows)] use nvx::host::store::FORMAT; use nvx::host::store::{ @@ -449,7 +449,7 @@ impl Drop for VmmDirectory { fn drop(&mut self) { let _ = File::create(self.path().join(EXIT_MARKER)); if let Some(reference) = &self.reference - && let Ok(Some(process)) = Process::open(reference) + && let Ok(Opened::Running(process)) = Process::open(reference) && !process .wait(Instant::now() + TIMEOUT) .is_ok_and(|exited| exited.is_some()) @@ -572,16 +572,20 @@ impl Drop for Launcher { /// Opens the process that `reference` names, which must still run. fn open_running(reference: &ProcessRef) -> Process { - Process::open(reference) - .unwrap() - .unwrap_or_else(|| panic!("{reference:?} does not run")) + let Opened::Running(process) = Process::open(reference).unwrap() else { + panic!("{reference:?} does not run"); + }; + process } /// Checks that the process that `process` holds has exited, and that no process matches its /// reference. fn assert_ended(process: &Process) { assert!(process.has_exited().unwrap()); - assert!(Process::open(process.reference()).unwrap().is_none()); + assert!(matches!( + Process::open(process.reference()).unwrap(), + Opened::Exited(_) + )); } #[test] @@ -782,7 +786,7 @@ fn a_reused_process_id_is_never_signaled() { }, ] { assert!( - Process::open(&impostor).unwrap().is_none(), + matches!(Process::open(&impostor).unwrap(), Opened::Exited(_)), "{impostor:?} names a running process" ); } @@ -824,7 +828,10 @@ fn references_that_name_no_process_are_never_opened() { vmm.end(); assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); assert!(launched.kill_and_wait()); - assert!(Process::open(process.reference()).unwrap().is_none()); + assert!(matches!( + Process::open(process.reference()).unwrap(), + Opened::Exited(_) + )); assert!(process.has_exited().unwrap()); } diff --git a/nvx-rs/tests/template_capture.rs b/nvx-rs/tests/template_capture.rs index 0036b1c10..d03074966 100644 --- a/nvx-rs/tests/template_capture.rs +++ b/nvx-rs/tests/template_capture.rs @@ -23,7 +23,7 @@ use std::thread; use std::time::{Duration, Instant}; use nvx::host::platform; -use nvx::host::process::{BootId, Process, ProcessRef}; +use nvx::host::process::{BootId, Opened, Process, ProcessRef}; use nvx::host::sandbox::distro::{DISTRO_POLICY_REVISION, DistroBinding, PolicyVerdict}; use nvx::host::sandbox::{LayerRole, SandboxDistro, SandboxLayer, SandboxPlan, SandboxUpper}; #[cfg(target_os = "linux")] @@ -528,7 +528,7 @@ impl Orphan { impl Drop for Orphan { fn drop(&mut self) { - if let Ok(Some(process)) = Process::open(&self.0) { + if let Ok(Opened::Running(process)) = Process::open(&self.0) { let _ = process.kill(); let _ = process.wait(Instant::now() + TIMEOUT); } diff --git a/tests/runtime-adapter/tests/common/mod.rs b/tests/runtime-adapter/tests/common/mod.rs index dc5e0f914..fef951c26 100644 --- a/tests/runtime-adapter/tests/common/mod.rs +++ b/tests/runtime-adapter/tests/common/mod.rs @@ -30,7 +30,7 @@ use std::time::{Duration, Instant}; use nvx::Hypervisor; use nvx::host::artifacts::{ArtifactDir, Artifacts, Guest}; -use nvx::host::process::Process; +use nvx::host::process::{Opened, Process}; use nvx::host::store::{LOG_NAME, RuntimeRecord, SandboxStore}; use nvx::host::supervisor::{self, Reattached, StopOutcome}; use nvx::host::template::{CAPTURES_NAME, Store, TRANSACTION_NAME}; @@ -407,7 +407,10 @@ impl Sandbox { /// Returns the VMM of the sandbox's runtime record, if the record names one that runs. pub(crate) fn vmm(&self) -> Option { - Process::open(self.record()?.process()).unwrap() + match Process::open(self.record()?.process()).unwrap() { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + } } /// Ends the VM that the sandbox's store records, whatever its runtime identity: reattach ends diff --git a/tests/runtime-adapter/tests/vm_clone.rs b/tests/runtime-adapter/tests/vm_clone.rs index cbc20e665..46f447124 100644 --- a/tests/runtime-adapter/tests/vm_clone.rs +++ b/tests/runtime-adapter/tests/vm_clone.rs @@ -228,7 +228,7 @@ use nvx::exec::{Key, Limits, Timers, Window}; use nvx::host::artifacts::Artifacts; use nvx::host::filesystem::GUEST_EXPORT; use nvx::host::platform::Transport; -use nvx::host::process::{BootId, Process}; +use nvx::host::process::{BootId, Opened, Process}; use nvx::host::sandbox::SandboxNetwork; use nvx::host::store::{CallerFiles, LOG_NAME, SandboxStore}; use nvx::host::template::{ @@ -2379,9 +2379,10 @@ fn refuses_early(template: &Template, early: Message) { let restoring = planned .launch(&mut acquired, &runtime.artifacts.openvmm, &runtime.identity) .unwrap(); - let vmm = Process::open(restoring.starting().process().reference()) - .unwrap() - .expect("the clone's VMM runs"); + let Opened::Running(vmm) = Process::open(restoring.starting().process().reference()).unwrap() + else { + panic!("the clone's VMM runs"); + }; let mut control = Early { early: early.clone(), vmm, diff --git a/tests/runtime-adapter/tests/vm_steps.rs b/tests/runtime-adapter/tests/vm_steps.rs index bc4821d7d..9ff0fea3c 100644 --- a/tests/runtime-adapter/tests/vm_steps.rs +++ b/tests/runtime-adapter/tests/vm_steps.rs @@ -20,7 +20,7 @@ use std::path::Path; use std::time::Instant; use nvx::ErrorCode; -use nvx::host::process::Process; +use nvx::host::process::{Opened, Process}; use nvx::host::store::{LaunchState, SandboxStore}; use runtime_adapter::hook::{Action, HOOK_EXIT, STEP_HOOK, Step, StepHook}; use runtime_adapter::staged::Staged; @@ -205,9 +205,9 @@ fn the_hook_pauses_a_launch_at_each_of_its_steps() { .unwrap() .expect("the launch has its journal"); assert_eq!(journal.state(), LaunchState::Spawned, "at {step}"); - let vmm = Process::open(journal.process().unwrap()) - .unwrap() - .expect("the VMM runs"); + let Opened::Running(vmm) = Process::open(journal.process().unwrap()).unwrap() else { + panic!("the VMM runs"); + }; assert!(!vmm.has_exited().unwrap(), "at {step}"); assert_eq!(store.runtime().unwrap(), None, "at {step}"); } From 87528167b284851b5e4b7b4df057868b5e2c9904 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 03:53:05 -0800 Subject: [PATCH 08/30] guest: retain restored ingress as an init helper Port only SL7.5d onto the qualified capture parent. Fork the fixed announcer before switching roots, preserving PID 1 priority and fatal helper supervision. Reset inherited signals without introducing a restore caller or lifecycle machinery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dee059e7-7968-43d8-82d0-abd68b922eed --- nvx-rs/src/guest/init.rs | 60 +++++ nvx-rs/src/guest/init/tests.rs | 1 + nvx-rs/src/guest/init/tests/restore.rs | 313 +++++++++++++++++++++++++ nvx-rs/src/guest/sys/mod.rs | 2 + nvx-rs/src/guest/sys/spawn.rs | 2 +- scripts/test-layout-allowlist.json | 2 +- 6 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 nvx-rs/src/guest/init/tests/restore.rs diff --git a/nvx-rs/src/guest/init.rs b/nvx-rs/src/guest/init.rs index bd326adf1..15c760f1a 100644 --- a/nvx-rs/src/guest/init.rs +++ b/nvx-rs/src/guest/init.rs @@ -37,6 +37,7 @@ use std::ffi::{CStr, CString, OsStr}; use std::io::{self, Read}; +use std::net::Ipv4Addr; use std::os::fd::BorrowedFd; use std::os::unix::ffi::OsStrExt; use std::os::unix::process::ExitStatusExt; @@ -44,6 +45,7 @@ use std::path::{Path, PathBuf}; use std::process::{ExitCode, ExitStatus as ProcessStatus}; use std::time::{Duration, Instant}; +use crate::activation::Publication; use crate::protocol::cmdline; use crate::protocol::root::{DISTRO_MMIO_BASE, SCRATCH_MMIO_BASE}; @@ -992,6 +994,64 @@ impl Runtime { }) } + /// Starts ingress from the applied root's publication and gateway, before its root switch. + /// The fork keeps the initramfs root and PID 1's priority; the sole collector owns its exit. + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL7.7b starts the restored announcer before the root switch" + ) + )] + pub(crate) fn restore_announcer( + &mut self, + publication: Publication, + gateway: Option, + ) -> crate::Result<()> { + if publication == Publication::Unpublished { + return Ok(()); + } + if self.helpers.announcer.is_some() { + return Err(crate::Error::unsupported( + "an announcer is already registered", + )); + } + let gateway = gateway + .ok_or_else(|| crate::Error::unsupported("published listeners require a gateway"))?; + let program = super::ingress::PROGRAM.to_owned(); + #[cfg(test)] + let program = tests::restore::announcer_program(program); + let arguments = [ + c"nvx-ingress".to_owned(), + CString::new(gateway.to_string()).expect("an IPv4 address has no NUL"), + ]; + sys::check_released().map_err(|error| { + crate::Error::io("cannot fork the restored announcer with port permission") + .with_source(error) + })?; + let fork = sys::fork().map_err(|error| { + crate::Error::io("cannot fork the restored announcer") + .with_source(io::Error::from(error)) + })?; + if let sys::ForkResult::Parent(process) = fork { + self.helpers.announcer = Some(process); + return Ok(()); + } + if let Err(error) = sys::reset_signals() { + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-ERROR: cannot reset announcer signals: {error}\n").as_bytes(), + ); + sys::exit_immediately(FAILURE); + } + let error = sys::exec(&program, &arguments, None); + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-ERROR: cannot exec /sbin/nvx-ingress: {error}\n").as_bytes(), + ); + sys::exit_immediately(FAILURE); + } + /// Poll alongside control input so a blocked peer cannot hide a helper's end. pub(crate) fn wake(&self) -> Pollable<'_> { Pollable::Signals(self.system.signals.as_ref().expect("listening")) diff --git a/nvx-rs/src/guest/init/tests.rs b/nvx-rs/src/guest/init/tests.rs index 706bf18a7..66e525181 100644 --- a/nvx-rs/src/guest/init/tests.rs +++ b/nvx-rs/src/guest/init/tests.rs @@ -9,6 +9,7 @@ mod capture; mod exec; +pub(super) mod restore; pub(super) use exec::prepare_child; use std::collections::VecDeque; diff --git a/nvx-rs/src/guest/init/tests/restore.rs b/nvx-rs/src/guest/init/tests/restore.rs new file mode 100644 index 000000000..86cf3a741 --- /dev/null +++ b/nvx-rs/src/guest/init/tests/restore.rs @@ -0,0 +1,313 @@ +//! The restore-only announcer step and its existing helper supervision. + +use std::cell::RefCell; +use std::io::{Seek, SeekFrom, Write}; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::*; + +thread_local! { + static PROGRAM: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn announcer_program(program: CString) -> CString { + PROGRAM.with_borrow(|replacement| replacement.clone().unwrap_or(program)) +} + +fn with_program(path: &Path, run: impl FnOnce() -> T) -> T { + struct Restore(Option); + impl Drop for Restore { + fn drop(&mut self) { + PROGRAM.replace(self.0.take()); + } + } + let _restore = Restore(PROGRAM.replace(Some(sys::path_c_string(path).unwrap()))); + run() +} + +fn runtime() -> Runtime { + Runtime { + system: RealSystem { signals: None }, + helpers: Helpers { + time: pid(i32::MAX), + announcer: None, + }, + terminating: false, + } +} + +struct Child { + process: ProcessId, + descriptor: FileDescriptor, + collected: bool, +} + +impl Child { + fn new(process: ProcessId) -> Self { + Self { + process, + descriptor: sys::open_pidfd(process).unwrap(), + collected: false, + } + } +} + +impl Drop for Child { + fn drop(&mut self) { + if !self.collected { + let _ = sys::kill_through_pidfd(self.descriptor.borrowed(), false); + let _ = sys::wait(self.process); + } + } +} + +fn script(directory: &Path, body: &str) -> std::path::PathBuf { + let path = directory.join("announcer"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap(); + path +} + +fn until(mut ready: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(5); + while !ready() { + assert!(Instant::now() < deadline, "the announcer did not advance"); + std::thread::sleep(Duration::from_millis(1)); + } +} + +fn collect_failure(runtime: &mut Runtime, child: &mut Child) -> String { + assert!(sys::readable_or_hung_up(&[child.descriptor.borrowed()], 5000).unwrap()[0]); + let error = runtime + .service(|_, _| panic!("the restored announcer was treated as a workload")) + .unwrap_err(); + child.collected = true; + error +} + +fn blocked(process: ProcessId) -> u64 { + let status = std::fs::read_to_string(format!("/proc/{}/status", process.raw())).unwrap(); + let mask = status + .lines() + .find_map(|line| line.strip_prefix("SigBlk:")) + .unwrap(); + u64::from_str_radix(mask.trim(), 16).unwrap() +} + +#[test] +fn unpublished_restores_start_nothing_even_without_a_gateway() { + let mut runtime = runtime(); + for gateway in [None, Some(Ipv4Addr::new(10, 88, 0, 1))] { + runtime + .restore_announcer(Publication::Unpublished, gateway) + .unwrap(); + assert_eq!(runtime.helpers.announcer, None); + } +} + +#[test] +fn a_missing_gateway_or_an_existing_announcer_starts_no_replacement() { + let mut runtime = runtime(); + assert_eq!( + runtime + .restore_announcer(Publication::Published, None) + .unwrap_err() + .kind(), + crate::ErrorKind::Unsupported + ); + assert_eq!(runtime.helpers.announcer, None); + runtime.helpers.announcer = Some(pid(7)); + assert_eq!( + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap_err() + .kind(), + crate::ErrorKind::Unsupported + ); + assert_eq!(runtime.helpers.announcer, Some(pid(7))); +} + +#[test] +fn the_restored_child_gets_only_the_gateway_and_joins_fatal_helper_supervision() { + let name = "guest::init::tests::restore::the_restored_child_gets_only_the_gateway_and_joins_fatal_helper_supervision"; + if isolate_process_test("NVX_INIT_RESTORED_CHILD_TEST", name) { + return; + } + let directory = tempfile::tempdir().unwrap(); + in_child(|| { + let record = directory.path().join("arguments"); + let program = script( + directory.path(), + &format!( + "printf '%s\\n' \"$#\" \"$1\" \"$PPID\" > '{}'\nexec sleep 30", + record.display() + ), + ); + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + with_program(&program, || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + let expected = format!("1\n10.88.0.1\n{}\n", sys::current_process().raw()); + until(|| std::fs::read_to_string(&record).is_ok_and(|record| record == expected)); + assert_eq!(nice_of(child.process), nice_of(sys::current_process())); + assert_eq!( + std::fs::read(format!("/proc/{}/oom_score_adj", child.process.raw())).unwrap(), + std::fs::read("/proc/self/oom_score_adj").unwrap() + ); + sys::kill_through_pidfd(child.descriptor.borrowed(), false).unwrap(); + let error = collect_failure(&mut runtime, &mut child); + assert_eq!( + error, + format!( + "the announcer, process {}, was killed by signal 9", + child.process.raw() + ) + ); + let mut system = Fake::default(); + finish(&mut system, Err(error.clone())); + assert_eq!(system.calls, [failure(&error), Call::ShutDown(FAILURE)]); + }); +} + +#[test] +fn a_failed_exec_remains_a_registered_fatal_helper() { + let name = "guest::init::tests::restore::a_failed_exec_remains_a_registered_fatal_helper"; + if isolate_process_test("NVX_INIT_RESTORED_EXEC_FAILURE_TEST", name) { + return; + } + in_child(|| { + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + with_program(Path::new("/nonexistent/nvx-ingress"), || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + assert_eq!( + collect_failure(&mut runtime, &mut child), + format!( + "the announcer, process {}, exited with status 125", + child.process.raw() + ) + ); + }); +} + +#[test] +fn the_restored_announcer_unblocks_pid_1_signals_before_exec() { + let name = + "guest::init::tests::restore::the_restored_announcer_unblocks_pid_1_signals_before_exec"; + if isolate_process_test("NVX_INIT_RESTORED_SIGNALS_TEST", name) { + return; + } + in_child(|| { + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + assert_ne!(blocked(sys::current_process()), 0); + with_program(Path::new("/bin/false"), || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + assert!(sys::readable_or_hung_up(&[child.descriptor.borrowed()], 5000).unwrap()[0]); + assert_eq!(blocked(child.process), 0); + assert_eq!( + collect_failure(&mut runtime, &mut child), + format!( + "the announcer, process {}, exited with status 1", + child.process.raw() + ) + ); + }); +} + +#[test] +fn a_refused_fork_keeps_the_helper_registry_unchanged() { + let mut runtime = runtime(); + let error = runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap_err(); + assert_eq!(error.kind(), crate::ErrorKind::Io); + assert_eq!(runtime.helpers.announcer, None); +} + +#[test] +#[ignore = "requires a guest's root, CAP_SYS_NICE, CAP_SYS_RESOURCE, and CAP_SYS_CHROOT"] +fn the_restored_helper_keeps_the_pre_switch_root_and_pid_1_priority() { + let name = "guest::init::tests::restore::the_restored_helper_keeps_the_pre_switch_root_and_pid_1_priority"; + if std::env::var_os("NVX_INIT_RESTORED_PRIORITY_TEST").is_none() { + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", name, "--ignored", "--nocapture"]) + .env("NVX_INIT_RESTORED_PRIORITY_TEST", "1") + .status() + .unwrap(); + assert!(status.success()); + return; + } + assert!(holds_capability(CAP_SYS_NICE).unwrap()); + assert!(holds_capability(CAP_SYS_RESOURCE).unwrap()); + let directory = tempfile::tempdir().unwrap(); + in_child(|| { + let root = directory.path().join("new-root"); + std::fs::create_dir(&root).unwrap(); + let marker = directory.path().join("old-root-marker"); + std::fs::write(&marker, b"the helper kept the initramfs root\n").unwrap(); + let ready = directory.path().join("ready"); + let gate = directory.path().join("gate"); + let output = directory.path().join("observed"); + let mut gate_file = std::fs::File::create(&gate).unwrap(); + let mut output_file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .read(true) + .write(true) + .open(&output) + .unwrap(); + let program = script( + directory.path(), + &format!( + "printf ready > '{}'\nwhile test ! -s '{}'; do sleep 0.01; done\ncat '{}' > '{}'\nexec sleep 30", + ready.display(), + gate.display(), + marker.display(), + output.display() + ), + ); + let mut runtime = runtime(); + runtime.system.listen().unwrap(); + runtime.system.raise_priority(None).unwrap(); + with_program(&program, || { + runtime + .restore_announcer(Publication::Published, Some(Ipv4Addr::new(10, 88, 0, 1))) + .unwrap(); + }); + let mut child = Child::new(runtime.helpers.announcer.unwrap()); + until(|| ready.is_file()); + assert_eq!(nice_of(child.process), -10); + assert_eq!( + std::fs::read_to_string(format!("/proc/{}/oom_score_adj", child.process.raw())) + .unwrap() + .trim(), + "-1000" + ); + nix::unistd::chroot(&root).unwrap(); + std::env::set_current_dir("/").unwrap(); + gate_file.write_all(b"continue\n").unwrap(); + let mut observed = String::new(); + until(|| { + output_file.seek(SeekFrom::Start(0)).unwrap(); + observed.clear(); + output_file.read_to_string(&mut observed).unwrap(); + observed == "the helper kept the initramfs root\n" + }); + sys::kill_through_pidfd(child.descriptor.borrowed(), false).unwrap(); + assert!(collect_failure(&mut runtime, &mut child).contains("the announcer")); + }); +} diff --git a/nvx-rs/src/guest/sys/mod.rs b/nvx-rs/src/guest/sys/mod.rs index 328797ec7..6e0d5e80d 100644 --- a/nvx-rs/src/guest/sys/mod.rs +++ b/nvx-rs/src/guest/sys/mod.rs @@ -147,6 +147,8 @@ pub(crate) use sock_diag::{ INET_DIAG_BUFFER_LENGTH, InetDiagMessage, decode_inet_diag_dump, dump_ipv6_tcp_sockets, }; pub(crate) use socket::{UdpSocket, connect_datagram}; +#[cfg(target_arch = "x86_64")] +pub(crate) use spawn::reset_signals; pub(crate) use spawn::{ChildSpec, PreparedChild, REPORT_LENGTH, enter_root}; pub(crate) use terminal::{configure_raw, drain}; pub(crate) use timer::Timer; diff --git a/nvx-rs/src/guest/sys/spawn.rs b/nvx-rs/src/guest/sys/spawn.rs index 00116653c..7cf0590f0 100644 --- a/nvx-rs/src/guest/sys/spawn.rs +++ b/nvx-rs/src/guest/sys/spawn.rs @@ -985,7 +985,7 @@ struct KernelSigaction { /// /// The dispositions come first, so that a signal that arrived while blocked takes its default /// action once unblocked, and never runs a handler of the caller's. -fn reset_signals() -> Result<(), Errno> { +pub(crate) fn reset_signals() -> Result<(), Errno> { let default = KernelSigaction::default(); for signal in 1..=SIGNALS { if signal == nix::libc::SIGKILL || signal == nix::libc::SIGSTOP { diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 045dc828c..ca8ea1991 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -5,7 +5,7 @@ "nvx-rs/src/guest/cgroup.rs": 2, "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, - "nvx-rs/src/guest/init.rs": 6, + "nvx-rs/src/guest/init.rs": 8, "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, "nvx-rs/src/guest/sys/entry.rs": 3, From 688db9f55422de577b8a9c9d6ad7d8c1ce049db7 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 03:58:14 -0800 Subject: [PATCH 09/30] cli: run cold blockless VMs through the verified lifecycle Implement issue1109 SL3.4a2 on qualified SL2.10 adeb1d47, without replaying the donor's old stop-producer history. Keep restore and restore-network-policy on their existing path. Render CLI-owned guest tokens from the planned export, preserve cold foreground streams, terminal mode, exact exit status and outcome publication, and keep ordinary library spawns detached. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- THIRD_PARTY_NOTICES.md | 14 +- doc/usage.md | 15 +- guest/common/nvx-hostmount | 17 +- nvx-rs/Cargo.lock | 114 ++++ nvx-rs/Cargo.toml | 3 +- nvx-rs/public-api/linux.txt | 1 + nvx-rs/public-api/windows.txt | 1 + nvx-rs/src/host/artifacts/dir.rs | 46 +- nvx-rs/src/host/artifacts/tests.rs | 25 + nvx-rs/src/host/cli/run.rs | 639 ++++++++++++++++-- nvx-rs/src/host/cli/run/guest_tokens.rs | 86 +++ nvx-rs/src/host/cli/run/guest_tokens/tests.rs | 78 +++ nvx-rs/src/host/cli/run/tests.rs | 77 ++- nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs | 4 +- .../src/host/cli/sandbox/lifecycle/outcome.rs | 44 +- nvx-rs/src/host/cli/sandbox/mod.rs | 5 +- nvx-rs/src/host/cli/sandbox/options.rs | 7 +- nvx-rs/src/host/config/mod.rs | 13 + nvx-rs/src/host/config/tests.rs | 35 + nvx-rs/src/host/platform/linux/endpoint.rs | 9 +- nvx-rs/src/host/platform/linux/mod.rs | 2 + nvx-rs/src/host/platform/windows/endpoint.rs | 5 + nvx-rs/src/host/platform/windows/mod.rs | 4 +- nvx-rs/src/host/platform/windows/spawn.rs | 35 +- nvx-rs/src/host/platform/windows/tests.rs | 48 +- nvx-rs/src/host/spawn.rs | 31 +- nvx-rs/src/host/vm.rs | 56 +- nvx-rs/src/host/vm/tests.rs | 43 ++ nvx-rs/src/testing/fake_openvmm.rs | 201 +++++- nvx-rs/src/testing/fake_openvmm/tests.rs | 2 +- nvx-rs/tests/cli.rs | 28 +- nvx-rs/tests/cli/download.rs | 4 +- nvx-rs/tests/cli/dry_run.rs | 214 +++++- nvx-rs/tests/cli/harness.rs | 10 +- nvx-rs/tests/cli/one_shot.rs | 406 ++++++++++- nvx-rs/tests/cli/openvmm.rs | 350 +++++++++- nvx-rs/tests/cli/validation.rs | 27 +- 37 files changed, 2472 insertions(+), 227 deletions(-) create mode 100644 nvx-rs/src/host/cli/run/guest_tokens.rs create mode 100644 nvx-rs/src/host/cli/run/guest_tokens/tests.rs diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 322214b80..6091d4321 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,7 +14,7 @@ the distribution tooling. | Rust `libc` crate 0.2.189 | linked into the guest binaries and the Linux `bin/nvx-rs` through `nix` and `getrandom` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are at [`rust-lang/libc` tag 0.2.189](https://github.com/rust-lang/libc/tree/0.2.189). | | Rust `nix` crate 0.31.3 | system-call wrappers used by the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`nix-rust/nix`](https://crates.io/crates/nix/0.31.3). | | Rust `getrandom` crate 0.4.3 | random bytes for the guest binaries, such as `nvx-reseed --sample`, and for `bin/nvx-rs[.exe]`, such as control capabilities | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-random/getrandom`](https://crates.io/crates/getrandom/0.4.3). | -| Rust `bitflags` crate 2.13.2 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`bitflags/bitflags`](https://crates.io/crates/bitflags/2.13.2). | +| Rust `bitflags` crate 2.13.2 | transitive `nix` dependency in the guest binaries and `nix` or `crossterm` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`bitflags/bitflags`](https://crates.io/crates/bitflags/2.13.2). | | Rust `cfg-if` crate 1.0.5 | transitive `nix`, `getrandom`, and `sha2` dependency in the guest binaries and `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-lang/cfg-if`](https://crates.io/crates/cfg-if/1.0.5). | | Rust `memoffset` crate 0.9.1 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`Gilnaa/memoffset`](https://crates.io/crates/memoffset/0.9.1). | | Rust `serde` crate 1.0.229 | serialization framework of `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`serde-rs/serde`](https://crates.io/crates/serde/1.0.229). | @@ -37,6 +37,18 @@ the distribution tooling. | Rust `typenum` crate 1.20.1 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`paholg/typenum`](https://crates.io/crates/typenum/1.20.1). | | Rust `windows-sys` crate 0.61.2 | Windows API bindings of `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-sys/0.61.2). | | Rust `windows-link` crate 0.2.1 | transitive `windows-sys` dependency in `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-link/0.2.1). | +| Rust `crossterm` crate 0.29.0 | terminal-mode preservation in `bin/nvx-rs[.exe]` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`crossterm`](https://crates.io/crates/crossterm/0.29.0). | +| Rust `crossterm_winapi` crate 0.9.1 | transitive `crossterm` Windows console support in `bin/nvx-rs.exe` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`crossterm_winapi`](https://crates.io/crates/crossterm_winapi/0.9.1). | +| Rust `winapi` crate 0.3.9 | transitive `crossterm` and `crossterm_winapi` Windows API bindings in `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`winapi`](https://crates.io/crates/winapi/0.3.9). | +| Rust `parking_lot` crate 0.12.5 | transitive `crossterm` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`parking_lot`](https://crates.io/crates/parking_lot/0.12.5). | +| Rust `parking_lot_core` crate 0.9.12 | transitive `parking_lot` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`parking_lot_core`](https://crates.io/crates/parking_lot_core/0.9.12). | +| Rust `lock_api` crate 0.4.14 | transitive `parking_lot` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`lock_api`](https://crates.io/crates/lock_api/0.4.14). | +| Rust `scopeguard` crate 1.2.0 | transitive `lock_api` cleanup guards in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`scopeguard`](https://crates.io/crates/scopeguard/1.2.0). | +| Rust `smallvec` crate 1.16.5 | transitive `parking_lot_core` storage in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`smallvec`](https://crates.io/crates/smallvec/1.16.5). | +| Rust `document-features` crate 0.2.12 | build-time procedural macro used by `crossterm`, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`document-features`](https://crates.io/crates/document-features/0.2.12). | +| Rust `litrs` crate 1.0.1 | build-time `document-features` dependency, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`litrs`](https://crates.io/crates/litrs/1.0.1). | +| Rust `rustix` crate 1.1.5 | transitive `crossterm` terminal-mode support in the Linux `bin/nvx-rs` | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`rustix`](https://crates.io/crates/rustix/1.1.5). | +| Rust `linux-raw-sys` crate 0.12.1 | transitive `rustix` Linux API bindings in the Linux `bin/nvx-rs` | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`linux-raw-sys`](https://crates.io/crates/linux-raw-sys/0.12.1). | | musl libc | statically linked into `/sbin/nvx-console-pending`, `/sbin/nvx-mmio-write`, `/sbin/nvx-reseed`, `/sbin/nvx-exit`, `/sbin/nvx-device-io`, `/sbin/nvx-managed-agent`, `/sbin/nvx-ingress`, `/sbin/nvx-time`, `/sbin/nvx-time-probe`, and `/sbin/nvx-init` in every generated initramfs, and into `bin/nvx-rs` of the `linux-kvm` and `linux-mshv` release packages | MIT; Copyright © 2005-2020 Rich Felker, et al. The exact implementation is supplied by the pinned Rust release's `x86_64-unknown-linux-musl` target. Upstream source and the full copyright notice are at [musl libc](https://musl.libc.org/). | | LLVM `libunwind` | statically linked, with musl, into the ten guest binaries and the Linux `bin/nvx-rs`, which unwind and print backtraces through it | Apache-2.0 WITH LLVM-exception, whose exception lifts the attribution conditions for code embedded in a compiled program. The exact implementation is the `libunwind.a` that the pinned Rust release's `x86_64-unknown-linux-musl` target supplies, which the Rust project builds from its fork of [`llvm-project/libunwind`](https://github.com/llvm/llvm-project/tree/main/libunwind). | | Microsoft C runtime: the Visual C++ runtime and the Universal C Runtime | statically linked into `bin/nvx-rs.exe` of the `windows-whp` release package, which CI builds with `-C target-feature=+crt-static`, so that it runs without the Visual C++ Redistributable | Proprietary Microsoft object code, linked in by the Visual Studio build tools and the Windows SDK of CI's Windows runner under their Microsoft Software License Terms, which permit distributing the programs built with them; see [Redistribute Visual C++ files](https://learn.microsoft.com/cpp/windows/redistributing-visual-cpp-files). No source is distributed. | diff --git a/doc/usage.md b/doc/usage.md index 12eb06dd9..7b819b422 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -164,7 +164,7 @@ nvx-rs/target/release/nvx-rs run | `--memory-capacity-mib MIB` | none | Reserve an immutable, 128 MiB-aligned RAM capacity for a fresh microVM snapshot, in the range of `--memory-mib`. | | `--processors {1,2,4,8}` | `1` | Select the microVM processor count, written as one of the choices. | | `--cpu-profile ID` | `auto` | Select the guest's [CPU profile](#cpu-profiles): `auto` for the built-in profile of the host's CPU, which falls back with a warning to a development profile derived from this host on an Intel or AMD CPU that no built-in profile serves, a built-in profile ID, or `host` for that development profile. A restore uses the snapshot's profile, which `auto` and, for a host profile, `host` also name. | -| `--mount GUEST_TARGET,HOST_PATH[,ro\|rw]` | none | Expose a host directory, or a single regular host file without its directory, at the guest target: an absolute, canonical path other than `/`, without whitespace, control characters, double quotes, backslashes, `=`, or any UTF-8 byte that Linux treats as whitespace, such as the `0xa0` in `à`. The mode follows the last comma, so `HOST_PATH` may contain commas when the mode is given. Repeat to expose more directories or files, each with its own target and mode, as the children of an aggregate on the microVM's one virtio-fs device, which the guest binds at their targets; a shared file always uses the aggregate. Targets and host paths must not overlap, and the shares' kernel command-line tokens must fit its 1024-byte budget. An `rw` mapping accepts guest-created symbolic links, which the host never follows. Active snapshot restore requires the same mappings in the same order, with the same canonical paths, kinds, targets, modes, and ownership mode; a dormant-slot restore may attach one new directory mapping that the resumed guest mounts explicitly. | +| `--mount GUEST_TARGET,HOST_PATH[,ro\|rw]` | none | Expose a host directory, or a single regular host file without its directory, at the guest target: an absolute, canonical path other than `/`, without whitespace, control characters, double quotes, backslashes, `=`, or any UTF-8 byte that Linux treats as whitespace, such as the `0xa0` in `à`. Cold boots also protect the system paths listed below. The mode follows the last comma, so `HOST_PATH` may contain commas when the mode is given. Repeat to expose more directories or files, each with its own target and mode, as the children of an aggregate on the microVM's one virtio-fs device, which the guest binds at their targets; a shared file always uses the aggregate. Targets and host paths must not overlap, and the shares' kernel command-line tokens must fit its 1024-byte budget. An `rw` mapping accepts guest-created symbolic links, which the host never follows. Active snapshot restore requires the same mappings in the same order, with the same canonical paths, kinds, targets, modes, and ownership mode; a dormant-slot restore may attach one new directory mapping that the resumed guest mounts explicitly. | | `--mount-deny HOST_PATH` | none | Hide one existing file or directory inside a mounted host root; repeat to deny multiple paths. It may name the root itself when `--mount-allow` paths inside it remain. With several mappings, or a shared file, the path must be absolute; a shared file takes none. | | `--mount-allow HOST_PATH` | none | Expose one existing file or directory inside a `--mount-deny` path again; the hidden directories on the way list only the entries that lead to it. Repeat to allow multiple paths. With several mappings, or a shared file, the path must be absolute; a shared file takes none. See [Access policy](run.md#access-policy). | | `--mount-write HOST_PATH` | none | Make one existing file or directory one of the only writable parts of an `rw` mapping; the guest's writes anywhere else in it fail with `EROFS`. Repeat to declare multiple writable paths. With several mappings, or a shared file, the path must be absolute; a shared file takes none. Active snapshot restore requires the same denied, allowed, and writable paths. | @@ -179,8 +179,8 @@ nvx-rs/target/release/nvx-rs run | `--host-loopback {allow,deny}` | existing mapping | Control access between the guest and host loopback services. Explicit `allow` is bidirectional: the guest reaches host loopback through its gateway, and every TCP and UDP port on which the guest listens is published on host loopback for the host alone, or with `--network-ingress allow` admits the host as well; it needs a `--net` prefix of `/29` or shorter, which OpenVMM checks; see [Host-loopback access](run.md#host-loopback-access). Snapshot capture rejects `allow`, and so does a restore, unless `--restore-network-policy` installs a new policy. | | `--network-proxy IPV4:TCP-PORT` | none | Allow one explicit host TCP proxy endpoint. | | `--host-loopback-forward PROTOCOL:HOST_PORT:GUEST_PORT` | none | Publish one TCP or UDP localhost port to the guest; repeat to add forwards. Snapshot capture rejects forwards, and so does a restore, unless `--restore-network-policy` installs a new policy. | -| `--outcome-report PATH` | none | Write a bounded local JSON outcome report. | -| `--cmdline TEXT` | empty | Append kernel parameters; `nvx_*` and `tsc=` tokens are reserved. | +| `--outcome-report PATH` | none | Write a bounded local JSON outcome report to a new file in an existing plain directory. A cold boot rejects an existing destination before launch and never replaces a file or follows a destination symlink. A missing report or publication failure is diagnosed without replacing OpenVMM's exit status. | +| `--cmdline TEXT` | empty | Append kernel parameters; `nvx_*` and `tsc=` tokens are reserved. Cold boots reject double quotes and a standalone `--` token, which the VM control console cannot represent; snapshot restore retains its existing command-line handling. | | `--restore-snapshot PATH` | none | Restore the immutable machine contract and saved state from a snapshot directory. A snapshot captured with `--net` needs `--network-profile portable` and the network policy options of its capture, unless `--restore-network-policy` installs a new policy; see [Networked snapshots](run.md#networked-snapshots). | | `--restore-processors {1,2,4,8}` | none | Bring this contiguous processor prefix online before restore readiness. Requires an opt-in microVM snapshot and cannot exceed `--processors` capacity. | | `--restore-memory-mib MIB` | none | Select the 128 MiB-aligned RAM target for an expansion-capable snapshot restore, in the range of `--memory-mib`. | @@ -188,6 +188,15 @@ nvx-rs/target/release/nvx-rs run | `--restore-network-policy` | off | Install the policy of the network options, which may include `--network-ingress allow`, explicit `--host-loopback allow`, and forwards, in place of the complete deny-all policy, `--network-egress deny` and `--host-loopback deny` without egress rules, a proxy, or ingress, that the snapshot's NIC was captured with. The guest applies the new policy's DNS server and listener announcements before the host releases its input; see [Networked snapshots](run.md#networked-snapshots). Requires `--restore-snapshot`. | | `--dry-run` | off | Print the generated OpenVMM command without running it. | +Cold `--mount` targets may not cover, equal, or lie below `/bin`, `/boot`, +`/dev`, `/etc`, `/lib`, `/proc`, `/root`, `/run`, `/sbin`, `/sys`, `/usr`, +`/var`, or `/init`: these paths belong to the guest's blockless system. +For example, use `/config/single.txt` rather than `/etc/single.txt` for a +shared configuration file. Snapshot restore keeps its existing mapping rules. +Cold mount-policy options require a `--mount`, and network-policy options +require networking. A proxy is the guest's sole egress path, so it cannot be +combined with `--network-egress-allow` rules for direct connections. + `nvx-rs run` checks its options, prints the OpenVMM command after `>> `, and unless `--dry-run` is given, runs OpenVMM with the standard streams. A cold boot attaches the guest's console, the boot console `hvc1`, to them, so that diff --git a/guest/common/nvx-hostmount b/guest/common/nvx-hostmount index 837ec640e..944b5223b 100644 --- a/guest/common/nvx-hostmount +++ b/guest/common/nvx-hostmount @@ -4,7 +4,8 @@ # virtfs_mode= tokens describe. When virtfs_aggregate=1 marks it as an # aggregate of host directories and files, its root lists one child per # directory or file, which only root can enter, and each -# nvx_share=CHILD,TARGET,MODE[,file] token binds one child at TARGET with +# nvx_share=SOURCE,TARGET,MODE[,file] token binds a child or child-relative +# source from the planned export at TARGET with # MODE, without set-user-ID programs or device nodes; the file suffix marks a # child that is a host file, which is bound at a file. With --unmount, the # binds and then the share are unmounted. @@ -53,8 +54,20 @@ parse_share() { esac share_target=${share_rest%,*} share_mode=${share_rest##*,} + case "/$share_child/" in + *"//"* | *"/./"* | *"/../"*) fail "invalid live share $1" ;; + esac case "$share_child" in - '' | . | .. | *[!A-Za-z0-9._-]*) fail "invalid live share $1" ;; + */*) + share_root=${share_child%%/*} + case "$share_root" in + '' | *[!0-9]*) fail "invalid live share $1" ;; + esac + case "$share_child" in + *\\*) fail "invalid live share $1" ;; + esac + ;; + *[!A-Za-z0-9._-]*) fail "invalid live share $1" ;; esac case "$share_mode" in ro | rw) ;; *) fail "invalid live share mode in $1" ;; esac case "$share_target" in /?*) ;; *) fail "invalid live share target in $1" ;; esac diff --git a/nvx-rs/Cargo.lock b/nvx-rs/Cargo.lock index 311a844ef..8a1f16bff 100644 --- a/nvx-rs/Cargo.lock +++ b/nvx-rs/Cargo.lock @@ -70,6 +70,29 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -90,6 +113,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "errno" version = "0.3.14" @@ -146,6 +178,21 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "memchr" version = "2.8.3" @@ -187,6 +234,7 @@ dependencies = [ name = "nvx" version = "0.1.0-alpha.3" dependencies = [ + "crossterm", "getrandom", "nix", "proptest", @@ -203,6 +251,29 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -268,6 +339,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-syntax" version = "0.8.11" @@ -287,6 +367,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -341,6 +427,12 @@ dependencies = [ "digest", ] +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + [[package]] name = "syn" version = "3.0.6" @@ -389,6 +481,28 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index 804ce2173..fa151a5dc 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -43,7 +43,7 @@ bundled = ["host", "dep:sha2"] testing = ["dep:getrandom", "dep:serde", "dep:serde_json", "dep:nix", "dep:windows-sys"] # NVX's own `nvx-rs` binary, the command-line interface that runs NVX's microVMs and sandboxes, # compiles egress policies, looks up artifacts, and downloads releases. It adds no API. -cli = ["host"] +cli = ["host", "dep:crossterm"] # The serde forms of `Hypervisor` and `net::Ipv4Cidr`. The `host` feature enables it. serde = ["dep:serde"] # The programs that run in Linux guests: the `guest` module and the `nvx-*` guest binaries. Build @@ -51,6 +51,7 @@ serde = ["dep:serde"] guest = ["dep:nix", "dep:getrandom"] [dependencies] +crossterm = { version = "0.29", default-features = false, features = ["windows"], optional = true } getrandom = { version = "0.4", optional = true } serde = { version = "1.0.200", features = ["derive"], optional = true } serde_json = { version = "1.0.120", optional = true } diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 2d90a5759..16a0ac8ad 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -2086,6 +2086,7 @@ field nvx::host::config::BlockGeometry::physical_block_size struct nvx::host::config::Boot field nvx::host::config::Boot::capture field nvx::host::config::Boot::command_line +field nvx::host::config::Boot::memory_capacity_mib const nvx::host::config::COMMAND_LINE_BUDGET struct nvx::host::config::Capacity field nvx::host::config::Capacity::boot diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index c1e2bc8cf..60678b1de 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -672,6 +672,7 @@ field nvx::host::config::BlockGeometry::physical_block_size struct nvx::host::config::Boot field nvx::host::config::Boot::capture field nvx::host::config::Boot::command_line +field nvx::host::config::Boot::memory_capacity_mib const nvx::host::config::COMMAND_LINE_BUDGET struct nvx::host::config::Capacity field nvx::host::config::Capacity::boot diff --git a/nvx-rs/src/host/artifacts/dir.rs b/nvx-rs/src/host/artifacts/dir.rs index dddab5c11..08d80a541 100644 --- a/nvx-rs/src/host/artifacts/dir.rs +++ b/nvx-rs/src/host/artifacts/dir.rs @@ -235,8 +235,13 @@ impl ArtifactDir { /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a /// regular file, such as a FIFO, is refused without being waited on. pub fn runtime_identity(&self) -> Result { + self.runtime_identity_for(Guest::Alpine) + } + + /// Identifies the selected guest through its install metadata, without reading its image. + pub(crate) fn runtime_identity_for(&self, guest: Guest) -> Result { let [openvmm, kernel, initramfs] = match self.layout { - Layout::Release => self.listed_identities()?, + Layout::Release => self.listed_identities(guest)?, Layout::Repository => [ provenance_identity( &self.artifact(OPENVMM_PROVENANCE_NAME), @@ -248,11 +253,33 @@ impl ArtifactDir { "kernel", "kernel_sha256", )?, - provenance_identity( - &self.artifact(INITRAMFS_PROVENANCE_NAME), - "initramfs", - "initramfs_sha256", - )?, + if guest == Guest::Alpine { + provenance_identity( + &self.artifact(INITRAMFS_PROVENANCE_NAME), + "initramfs", + "initramfs_sha256", + )? + } else { + let path = self.package_manifest(guest); + let text = read_metadata( + &path, + "guest package manifest", + "build or install the selected guest", + )?; + let invalid = || { + Error::unavailable(format!( + "guest package manifest {} has no valid artifact SHA-256 digest", + path.display() + )) + }; + let manifest: serde_json::Value = serde_json::from_str(&text) + .map_err(|error| invalid().with_source(error))?; + manifest + .get("artifact_sha256") + .and_then(serde_json::Value::as_str) + .and_then(sha256_identity) + .ok_or_else(invalid)? + }, ], }; Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) @@ -261,7 +288,7 @@ impl ArtifactDir { /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 /// lowercase hexadecimal digits, two spaces, and its path in the release. - fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { + fn listed_identities(&self, guest: Guest) -> Result<[ArtifactIdentity; 3]> { let path = self.root.join(CHECKSUMS_NAME); let description = "release checksum list"; let text = read_metadata(&path, description, "the release is incomplete")?; @@ -295,10 +322,7 @@ impl ArtifactDir { Ok([ listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, - listed(format!( - "{RELEASE_GUEST_DIR}/{}", - Guest::Alpine.initramfs_name() - ))?, + listed(format!("{RELEASE_GUEST_DIR}/{}", guest.initramfs_name()))?, ]) } } diff --git a/nvx-rs/src/host/artifacts/tests.rs b/nvx-rs/src/host/artifacts/tests.rs index 3e4de6351..8d0b14be1 100644 --- a/nvx-rs/src/host/artifacts/tests.rs +++ b/nvx-rs/src/host/artifacts/tests.rs @@ -574,6 +574,31 @@ fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() ); } +#[test] +fn selected_guests_take_their_own_initramfs_digest_from_install_metadata() { + let directory = tempfile::tempdir().unwrap(); + let dir = ArtifactDir::repository(directory.path()).unwrap(); + let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); + record_provenance(directory.path(), &digests); + for guest in [Guest::Ubuntu, Guest::AzureLinux] { + let selected = "d".repeat(64); + let manifest = dir.package_manifest(guest); + fs::write(&manifest, format!("{{\"artifact_sha256\":\"{selected}\"}}")).unwrap(); + let identity = dir.runtime_identity_for(guest).unwrap(); + assert_eq!( + identity.initramfs, + ArtifactIdentity::sha256(&selected).unwrap() + ); + assert_eq!(identity.openvmm, identity_of(&digests).openvmm); + fs::write(&manifest, "{\"artifact_sha256\":\"invalid\"}").unwrap(); + assert_eq!( + dir.runtime_identity_for(guest).unwrap_err().kind(), + ErrorKind::Unavailable + ); + } + assert_eq!(dir.runtime_identity().unwrap(), identity_of(&digests)); +} + #[test] fn a_release_identifies_its_runtime_by_its_checksum_list() { let directory = tempfile::tempdir().unwrap(); diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index a7e8bc29c..914d2e587 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -7,10 +7,9 @@ //! `nvx.py run` with their names, defaults, types, and choices, and ends a usage error with //! status 2. //! -//! A cold boot attaches the guest's console, the boot console `hvc1`, to the terminal with -//! `--virtio-console console`, so that the terminal's input reaches the guest through it rather -//! than through the byte channel, `hvc0`, whose output, such as the kernel's early messages, goes -//! to standard error. A restore attaches the boot console as its snapshot had it: it reads the +//! A cold boot forwards the terminal through the VM's boot-console listener, `hvc1`, and the +//! VM's log to standard error. Its temporary private VM directory is removed after confirmed +//! exit. A restore attaches the boot console as its snapshot had it: it reads the //! snapshot's manifest, `manifest.bin` in the snapshot's directory, dry run or not, and passes //! `--virtio-console console` when the manifest's machine contract holds the attachment of the //! boot console, `console:microvm-virtio0`, with the reconnect policy @@ -31,41 +30,61 @@ //! new policy's DNS server and listener announcements before the host releases its input. //! //! Its shares differ from those of `sandbox`. `--mount-deny`, `--mount-allow`, and `--mount-write` -//! name host paths, which OpenVMM attributes to the share that holds them, and which must be -//! absolute with several shares or a shared file. One directory share uses `--mount`, several -//! shares or a file the children of an aggregate, whose `nvx_share=` tokens a cold boot adds to -//! the kernel command line, and a restore does not, since its guest keeps the binds that its boot -//! made. +//! name host paths, which must be absolute with several shares or a shared file. A cold boot +//! checks their ownership in the filesystem export plan, exports its numbered children, and adds +//! the plan's `nvx_share=` rows to the kernel command line. A restore keeps its existing lowering: +//! one directory uses `--mount`, several shares or a file use an aggregate, and no new share tokens +//! replace the binds that its saved guest already made. //! //! After OpenVMM exits with a status other than 0 from a cold boot, on a host whose CPU no built-in //! CPU profile serves, it prints the guidance of [`cpu_profile`] to standard error, which says //! what `--cpu-profile auto` does on that CPU and how to request a built-in profile for it. It //! never prints it after a dry run, a restore, a refused option, or Ctrl-C. +mod guest_tokens; + +use std::fs::{self, File}; +#[cfg(windows)] +use std::io::Write; +use std::io::{self, IsTerminal, Read}; use std::path::Path; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; use super::args::{Parser, Receiver, Spec, Syntax, choice, integer}; use super::artifacts::{Located, locate}; use super::cpu_profile::{self, HostCpu}; -use super::download; +use super::download::{self, Hold}; use super::network::{NativeRules, NetworkOptions}; use super::sandbox::{ - HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, error_with_cause, hypervisor, path_string, - path_text, run_openvmm, + HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, copy_openvmm_outcome, error_with_cause, hypervisor, + path_string, path_text, run_openvmm, sandbox_network, validate_outcome_destination, }; +use super::session::Session; use super::shell::format_command; use super::{Exit, Result, check_interrupt, output}; use crate::ErrorCode; -use crate::host::artifacts::{Guest, require_artifact}; +use crate::activation::{MachineIdMode, View}; +use crate::host::artifacts::{ArtifactDir, Guest, require_artifact}; +use crate::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; +use crate::host::filesystem::{ + self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, +}; +use crate::host::platform::{self, Transport}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime}; use crate::host::sandbox::mount::{ - AGGREGATE_MOUNT_TARGET, MountKind, MountOwner, POLICY_KINDS, PolicyKind, + AGGREGATE_MOUNT_TARGET, MountAccess, MountKind, MountOwner, POLICY_KINDS, PolicyKind, aggregate_child_argument, aggregate_child_name, aggregate_command_line_fragment, - aggregate_share_tokens, mount_argument, mount_kind, parse_mount_value, - require_mount_owner_supported, + aggregate_share_tokens, export_command_line_fragment, mount_argument, mount_kind, + parse_mount_value, require_mount_owner_supported, }; use crate::host::sandbox::options::validate_run_share_targets; use crate::host::sandbox::{SANDBOX_COMMAND_LINE_MAX_SIZE, has_kernel_separator}; use crate::host::template::read_attachments; +use crate::host::vm::{self, VmDir}; /// The usage and help of the parser. pub(super) const SYNTAX: Syntax = Syntax { @@ -413,8 +432,12 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { // nothing, though it reads the artifacts under the lock of downloads all the same. Their // arguments are built again once the artifacts are in place, as a share's kind depends on what // its host path holds, which a download may install. - let refused = terminal_console.as_ref().is_some_and(Result::is_err) - || extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err(); + let refused = if options.restore_snapshot.is_some() { + terminal_console.as_ref().is_some_and(Result::is_err) + || extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err() + } else { + cold_config(&options, &rules).is_err() + }; let located = locate(root)?; // A restore boots no kernel or initramfs: the snapshot holds the guest. let guest = options.restore_snapshot.is_none().then_some(options.guest); @@ -425,6 +448,9 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { }; let Located { dir, openvmm } = located; require_artifact(&openvmm, "OpenVMM release binary")?; + if options.restore_snapshot.is_none() { + return run_cold(dir, openvmm, &options, &rules, hold); + } let mut command = vec![ path_text(&path_string(&openvmm)?), "--single-process".to_owned(), @@ -449,21 +475,6 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { if terminal_console.transpose()? == Some(true) { push(&mut command, "--virtio-console", "console"); } - } else { - let kernel = dir.require_kernel()?; - let initrd = dir.require_initramfs(options.guest)?; - push( - &mut command, - "--memory", - format!("{}M", memory_mib(&options)), - ); - push(&mut command, "--kernel", path_string(&kernel)?); - push(&mut command, "--initrd", path_string(&initrd)?); - if let Some(capacity) = &options.memory_capacity_mib { - push(&mut command, "--memory-capacity", format!("{capacity}M")); - } - // The guest's console takes the terminal. - push(&mut command, "--virtio-console", "console"); } if let Some(profile) = &options.cpu_profile { push(&mut command, "--cpu-profile", profile.as_str()); @@ -478,19 +489,561 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { return Ok(()); } let status = run_openvmm(&command, &[], hold)?; - if status != 0 - && options.restore_snapshot.is_none() - && let Some(guidance) = cpu_profile::guidance( - options.cpu_profile.as_deref(), - HostCpu::current().as_ref(), - &format_command(&command[..1]), - hypervisor(options.hypervisor), - ) + Err(Exit::Status(status)) +} + +fn cold_config(options: &Options, rules: &NativeRules) -> Result { + let shares = options + .mounts + .iter() + .map(|value| parse_mount_value(value)) + .collect::>>()?; + validate_run_share_targets( + &shares + .iter() + .map(|(target, _, _)| target.as_str()) + .collect::>(), + )?; + if shares.is_empty() && (options.mount_owner.is_some() || !options.policy_paths.is_empty()) { + return Err(Exit::error( + ErrorCode::Unsupported, + if options.mount_owner.is_some() { + "--mount-owner requires --mount" + } else { + "mount policy options require --mount" + }, + )); + } + let hosts: Vec<_> = shares + .iter() + .map(|(_, host, _)| Path::new(host).to_owned()) + .collect(); + let aggregate = + shares.len() > 1 || hosts.iter().any(|host| mount_kind(host) == MountKind::File); + let mut policies: [Vec; 3] = Default::default(); + for kind in POLICY_KINDS { + for (_, given) in options.policy_paths.iter().filter(|(of, _)| *of == kind) { + let given = path_text(given); + let path = Path::new(&given); + if aggregate && !path.is_absolute() { + return Err(Exit::error( + ErrorCode::Unsupported, + format!( + "with several --mount shares, or a shared file, {} {given} must be an absolute host path", + kind.option() + ), + )); + } + let path = if path.is_absolute() { + path.to_owned() + } else { + hosts[0].join(path) + }; + policies[match kind { + PolicyKind::Denied => 0, + PolicyKind::Allowed => 1, + PolicyKind::Writable => 2, + }] + .push(path); + } + } + let mappings: Vec<_> = shares + .iter() + .zip(&hosts) + .map(|((target, _, access), host)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(target), + read_only: *access == MountAccess::ReadOnly, + }) + .collect(); + let filesystem = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: PathList { + name: "--mount-deny", + paths: &policies[0], + }, + allowed: PathList { + name: "--mount-allow", + paths: &policies[1], + }, + write: PathList { + name: "--mount-write", + paths: &policies[2], + }, + owner: if options.mount_owner == Some(MountOwner::Caller) { + Owner::Caller + } else { + Owner::Vmm + }, + reserved: ReservedTargets::BLOCKLESS_ROOT, + }) + .map_err(error_with_cause)?; + let network = sandbox_network(&options.network, rules)? + .map(|network| -> Result { + Ok(NetworkConfig { + guest_network: Some( + crate::net::Ipv4Cidr::parse(network.guest_network()) + .map_err(|error| Exit::error(ErrorCode::Unsupported, error.to_string()))?, + ), + policy: network.policy().clone(), + }) + }) + .transpose()?; + let config = VmConfig { + processors: options.processors, + memory_mib: memory_mib(options), + root: RootConfig::Blockless, + view: View { + hostname: b"nvx".to_vec(), + machine_id: MachineIdMode::Image, + }, + network, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: guest_tokens::render(&options.cmdline, filesystem.as_ref()) + .map_err(error_with_cause)?, + memory_capacity_mib: options.memory_capacity_mib, + capture: None, + }), + filesystem, + }; + if let Some(export) = &config.filesystem { + let line = config.kernel_command_line().map_err(error_with_cause)?; + if line.len() + export_command_line_fragment(export).len() + 1 + > SANDBOX_COMMAND_LINE_MAX_SIZE + { + return Err(Exit::error( + ErrorCode::Unsupported, + "the --mount shares and --cmdline exceed the 1024-byte budget of the kernel command \ + line; attach fewer shares or use shorter targets", + )); + } + } + config.check_command_line().map_err(error_with_cause)?; + Ok(config) +} + +fn remove_vm_directory(dir: &VmDir) -> Result { + for name in [ + "openvmm.log", + "outcome.json", + "control.sock", + "console.sock", + ] { + match fs::remove_file(dir.path().join(name)) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + } + fs::remove_dir(dir.path()).map_err(Exit::from_os_error) +} + +fn connect_running( + launched: &mut vm::Launched, + endpoint: &str, +) -> Result>> { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + check_interrupt()?; + match platform::connect_endpoint( + endpoint, + launched.process.reference().pid, + Duration::from_millis(250), + ) { + Ok(transport) => return Ok(Some(transport)), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + ) => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + if launched + .wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)? + .is_some() + { + return Ok(None); + } + if Instant::now() >= deadline { + return Err(Exit::error( + ErrorCode::TimedOut, + format!("VM console endpoint did not become available: {endpoint}"), + )); + } + thread::sleep(Duration::from_millis(10)); + } +} + +struct TerminalGuard(bool); + +impl TerminalGuard { + fn new() -> Result { + let terminal = io::stdin().is_terminal(); + if terminal { + crossterm::terminal::enable_raw_mode().map_err(Exit::from_os_error)?; + } + Ok(Self(terminal)) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if self.0 + && let Err(error) = crossterm::terminal::disable_raw_mode() + { + output::print_stderr(&format!("cannot restore terminal mode: {error}")); + } + } +} + +type InputCompletion = mpsc::Receiver>; + +fn console_input(console: Box) -> Result<(Box, InputCompletion)> { + #[cfg(any(target_os = "linux", windows))] + { + let duplicate = || { + console + .try_clone_handle() + .map_err(Exit::from_os_error)? + .ok_or_else(|| { + Exit::error( + ErrorCode::Unsupported, + "the VM console cannot be duplicated", + ) + }) + }; + let reader = duplicate()?; + let writer = duplicate()?; + drop(console); + let reader = platform::transport_from_handle(reader).map_err(Exit::from_os_error)?; + let mut writer = platform::transport_from_handle(writer).map_err(Exit::from_os_error)?; + let (sender, receiver) = mpsc::sync_channel(1); + thread::Builder::new() + .name("nvx-run-input".to_owned()) + .spawn(move || { + let result = (|| { + let mut stdin = io::stdin().lock(); + let mut buffer = [0; 8192]; + loop { + let count = stdin.read(&mut buffer)?; + if count == 0 { + return Ok(()); + } + match writer.write_all(&buffer[..count], None) { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + | io::ErrorKind::WriteZero + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + } + } + })(); + let _ = sender.send(result); + }) + .map_err(Exit::from_os_error)?; + Ok((reader, receiver)) + } + #[cfg(not(any(target_os = "linux", windows)))] + { + drop(console); + Err(Exit::error( + ErrorCode::Unsupported, + "foreground console I/O requires a supported NVX host", + )) + } +} + +fn write_console(data: &[u8]) -> io::Result<()> { + #[cfg(windows)] + { + use std::os::windows::io::AsHandle; + + if platform::interrupted() { + return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); + } + let mut stdout = io::stdout().lock(); + stdout.flush()?; + File::from(stdout.as_handle().try_clone_to_owned()?).write_all(data) + } + #[cfg(not(windows))] + output::write_stdout(data) +} + +fn foreground( + launched: &mut vm::Launched, + log: &mut File, + managed: bool, + control: &mut Option, +) -> Result { + let _terminal = TerminalGuard::new()?; + let endpoint = launched + .outputs + .console + .clone() + .expect("a cold run requests a console listener"); + let (mut console, input) = if let Some(console) = connect_running(launched, &endpoint)? { + let (console, input) = console_input(console)?; + (Some(console), Some(input)) + } else { + (None, None) + }; + let mut attach_closed = None; + if managed && console.is_some() { + let endpoint = launched.outputs.endpoint.clone(); + let capability = *launched.capability.as_bytes(); + match Session::connect_launched( + &endpoint, + launched.child_mut().map_err(Exit::from_os_error)?, + &capability, + 60.0, + ) { + Ok(session) => *control = Some(session), + Err(Exit::EndpointClosed) => { + attach_closed = Some(Instant::now() + Duration::from_secs(60)); + } + Err(error) => return Err(error), + } + } + let mut buffer = [0; 8192]; + loop { + check_interrupt()?; + let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; + let status = launched + .wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)?; + if let Some(input) = &input + && status.is_none() + && let Ok(result) = input.try_recv() + { + result.map_err(Exit::from_os_error)?; + } + if let Some(transport) = &mut console { + match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + Ok(0) => console = None, + Ok(count) => write_console(&buffer[..count]).map_err(Exit::from_os_error)?, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + ) => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + } + if let Some(status) = status { + loop { + let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + if count == 0 { + break; + } + output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; + } + if let Some(transport) = &mut console { + loop { + match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + Ok(0) => break, + Ok(count) => { + write_console(&buffer[..count]).map_err(Exit::from_os_error)? + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + ) => + { + break; + } + Err(error) => return Err(Exit::from_os_error(error)), + } + } + } + return Ok(status); + } + if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(Exit::EndpointClosed); + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn run_cold( + dir: ArtifactDir, + openvmm: std::path::PathBuf, + options: &Options, + rules: &NativeRules, + hold: Hold, +) -> Result { + let kernel = dir.require_kernel()?; + let initramfs = dir.require_initramfs(options.guest)?; + let config = cold_config(options, rules)?; + if !options.dry_run + && let Some(report) = &options.outcome_report { + validate_outcome_destination(report)?; + } + let mut identity = dir + .runtime_identity_for(options.guest) + .map_err(error_with_cause)?; + if openvmm != dir.openvmm() { + let mut file = File::open(&openvmm).map_err(Exit::from_os_error)?; + let mut digest = Sha256::new(); + let mut buffer = [0; 8192]; + loop { + let count = file.read(&mut buffer).map_err(Exit::from_os_error)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + identity.openvmm = ArtifactIdentity::sha256(&format!("{:x}", digest.finalize()))?; + } + let runtime = Runtime { + openvmm, + kernel, + initramfs, + identity, + hypervisor: hypervisor(options.hypervisor).parse()?, + cpu_profile: options.cpu_profile.clone(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let mut random = [0; 16]; + getrandom::fill(&mut random).map_err(|error| { + Exit::error( + ErrorCode::Io, + format!("cannot name the temporary VM: {error}"), + ) + })?; + let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + let path = std::env::temp_dir().join(format!("nvx-run-{name}")); + if path.try_exists().map_err(Exit::from_os_error)? { + return Err(Exit::error( + ErrorCode::Io, + "the temporary VM directory already exists", + )); + } + vm::create_private_dir(&path)?; + let vm_dir = VmDir::open(&path)?; + let mut cleanup = true; + let result = (|| { + let prepared = vm::create(&runtime, &config, None, &vm_dir) + .map_err(error_with_cause)? + .for_foreground(); + let command: Vec<_> = std::iter::once(&runtime.openvmm) + .map(|path| path_string(path)) + .chain(prepared.arguments().iter().map(|arg| { + arg.to_str().map(str::to_owned).ok_or_else(|| { + Exit::error(ErrorCode::Unsupported, "an OpenVMM argument is not Unicode") + }) + })) + .collect::>()?; + output::print_stdout(&format!(">> {}", format_command(&command))) + .map_err(Exit::from_os_error)?; + if options.dry_run { + return Ok(()); + } + let mut log = File::open(&prepared.outputs().log).map_err(Exit::from_os_error)?; + if platform::opened_file_id(&log).map_err(Exit::from_os_error)? + != platform::opened_file_id(prepared.log()).map_err(Exit::from_os_error)? + { + return Err(Exit::error( + ErrorCode::Io, + "the VM log was replaced before its reader opened", + )); + } + cleanup = false; + let mut launched = vm::spawn(prepared).map_err(error_with_cause)?; + drop(hold); + let mut control = None; + let managed = config.boot.as_ref().is_some_and(|boot| { + boot.command_line + .split_ascii_whitespace() + .any(|token| token == "nvx_mode=managed") + }); + let status = foreground(&mut launched, &mut log, managed, &mut control); + let now = Instant::now(); + let mut graceful_error = None; + let stopped = vm::stop( + &launched.process, + &launched.outputs, + |deadline| { + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() { + return false; + } + control + .as_mut() + .is_some_and(|session| match session.stop(timeout.as_secs_f64()) { + Ok(()) => true, + Err(error) => { + graceful_error = Some(error); + false + } + }) + }, + now + Duration::from_millis(250), + now + Duration::from_secs(10), + ) + .map_err(error_with_cause)?; + cleanup = true; + if let Some(error) = graceful_error { + output::print_stderr(&format!("VM graceful stop failed: {error:?}")); + } check_interrupt()?; - output::print_stderr(&guidance); + let status = status?; + if let Err(error) = stopped.report { + output::print_stderr(&format!( + "error[io]: {}", + error_with_cause(error) + .message() + .unwrap_or("cannot read VM outcome") + )); + } + if let Some(report) = &options.outcome_report + && let Err(error) = copy_openvmm_outcome(&launched.outputs.outcome, report) + { + error.report(); + } + if status != 0 + && let Some(guidance) = cpu_profile::guidance( + options.cpu_profile.as_deref(), + HostCpu::current().as_ref(), + &format_command(&command[..1]), + hypervisor(options.hypervisor), + ) + { + check_interrupt()?; + output::print_stderr(&guidance); + } + Err(Exit::Status(status)) + })(); + if cleanup && let Err(error) = remove_vm_directory(&vm_dir) { + output::print_stderr(&format!( + "cannot remove the temporary VM directory {}: {error:?}", + vm_dir.path().display() + )); + if result.is_ok() || matches!(result, Err(Exit::Status(0))) { + return Err(error); + } } - Err(Exit::Status(status)) + result } #[cfg(test)] diff --git a/nvx-rs/src/host/cli/run/guest_tokens.rs b/nvx-rs/src/host/cli/run/guest_tokens.rs new file mode 100644 index 000000000..9659e568c --- /dev/null +++ b/nvx-rs/src/host/cli/run/guest_tokens.rs @@ -0,0 +1,86 @@ +//! The blockless guest settings owned by the CLI, beside the library's root and view tokens. + +use crate::host::filesystem::ExportPlan; +use crate::host::sandbox::{has_kernel_separator, parse_workload_identity}; +use crate::{Error, Result}; + +/// Renders both blockless modes and the planned boot binds, keeping other caller tokens. +pub(super) fn render(command_line: &str, export: Option<&ExportPlan>) -> Result { + let mut mode = "one-shot"; + let (mut uid, mut gid, mut identity) = (None, None, None); + let mut caller = String::new(); + let mut tokens = Vec::new(); + for part in command_line.split_inclusive(|ch: char| ch.is_ascii_whitespace()) { + let token = part.trim_ascii_end(); + let (name, value) = token.split_once('=').unwrap_or((token, "")); + match name { + "nvx_mode" | "nvx_lifecycle" => mode = value, + "nvx_workload_uid" => uid = Some(value), + "nvx_workload_gid" => gid = Some(value), + "nvx_identity" => identity = Some(value.to_owned()), + _ => { + caller.push_str(part); + continue; + } + } + caller.push_str(&part[token.len()..]); + } + if !matches!(mode, "one-shot" | "managed") { + return Err(Error::unsupported( + "blockless mode must be one-shot or managed", + )); + } + if uid.is_some() || gid.is_some() { + let (Some(uid), Some(gid)) = (uid, gid) else { + return Err(Error::unsupported("a workload needs both its UID and GID")); + }; + if identity.is_some() { + return Err(Error::unsupported( + "a workload identity may be specified only once", + )); + } + identity = Some(format!("{uid}:{gid}")); + } + tokens.push(format!("nvx_mode={mode}")); + if let Some(identity) = identity { + let (uid, gid) = parse_workload_identity(&identity)?; + tokens.push(format!("nvx_identity={uid}:{gid}")); + } + if let Some(export) = export { + for (row, bind) in export.guest_table().into_iter().zip(&export.binds) { + if [&row.source, &row.target] + .into_iter() + .any(|text| has_kernel_separator(text) || text.contains([',', '\\', '='])) + { + return Err(Error::unsupported( + "a planned boot share's source and target must each fit one kernel token", + )); + } + let child = &export.children[bind.child]; + let file = if bind.source.is_empty() { + child.file + } else { + let path = child.root.join(&bind.source); + std::fs::metadata(&path) + .map_err(|error| { + Error::io(format!("cannot inspect boot share {}", path.display())) + .with_source(error) + })? + .is_file() + }; + let access = if row.read_only { "ro" } else { "rw" }; + let kind = if file { ",file" } else { "" }; + tokens.push(format!( + "nvx_share={},{},{access}{kind}", + row.source, row.target + )); + } + } + if !caller.is_empty() { + tokens.insert(0, caller); + } + Ok(tokens.join(" ")) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/cli/run/guest_tokens/tests.rs b/nvx-rs/src/host/cli/run/guest_tokens/tests.rs new file mode 100644 index 000000000..ed46ea5f1 --- /dev/null +++ b/nvx-rs/src/host/cli/run/guest_tokens/tests.rs @@ -0,0 +1,78 @@ +use super::*; +use crate::host::filesystem::{ExportRequest, Mapping, ReservedTargets, Target}; + +#[test] +fn blockless_modes_and_workloads_use_cli_owned_names() { + for mode in ["one-shot", "managed"] { + let line = render( + &format!( + "nvx_lifecycle={mode} nvx_workload_uid=1000 nvx_workload_gid=1001 \ + nvx_entrypoint=/bin/echo nvx_arg=a nvx_arg=--help nvx_workload_account=create" + ), + None, + ) + .unwrap(); + assert!(line.contains(&format!("nvx_mode={mode}"))); + assert!(line.contains("nvx_identity=1000:1001")); + assert!(line.contains("nvx_entrypoint=/bin/echo nvx_arg=a nvx_arg=--help")); + assert!(line.contains("nvx_workload_account=create")); + assert!(!line.contains("nvx_lifecycle=")); + assert!(!line.contains("nvx_workload_uid=")); + assert!(!line.contains("nvx_workload_gid=")); + } + assert_eq!( + render("quiet nvx_exec=/sbin/nvx-exit", None).unwrap(), + "quiet nvx_exec=/sbin/nvx-exit nvx_mode=one-shot" + ); + let caller = " quiet\tloglevel=3\nnvx_arg='x' "; + assert_eq!( + render(caller, None).unwrap(), + format!("{caller} nvx_mode=one-shot") + ); +} + +#[test] +fn partial_or_root_workload_identities_and_unknown_modes_are_refused() { + for line in [ + "nvx_workload_uid=1000", + "nvx_identity=0:1000", + "nvx_identity=1000:4294967295", + "nvx_mode=other", + ] { + assert!(render(line, None).is_err(), "{line}"); + } +} + +#[test] +fn planned_boot_shares_keep_table_sources_targets_access_and_file_kind() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("share"); + std::fs::create_dir(&directory).unwrap(); + let file = directory.join("single.txt"); + std::fs::write(&file, "data").unwrap(); + let mappings = [ + Mapping { + name: "--mount", + path: &directory, + target: Target::Guest("/workspace"), + read_only: true, + }, + Mapping { + name: "--mount", + path: &file, + target: Target::Guest("/config/single.txt"), + read_only: true, + }, + ]; + let plan = crate::host::filesystem::plan(&ExportRequest::new( + &mappings, + ReservedTargets::BLOCKLESS_ROOT, + )) + .unwrap() + .unwrap(); + let table = plan.guest_table(); + assert_eq!(table[1].source, "0/single.txt"); + let line = render("", Some(&plan)).unwrap(); + assert!(line.contains("nvx_share=0,/workspace,ro")); + assert!(line.contains("nvx_share=0/single.txt,/config/single.txt,ro,file")); +} diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 2aa8f99b5..1578d6a74 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -1,22 +1,19 @@ -//! The shares of `run`: their targets stay single kernel tokens, and OpenVMM, not option order, -//! attributes their policy paths. +//! Cold-run shares stay kernel tokens through the checked VM configuration; restore keeps its +//! existing policy-path lowering. use proptest::prelude::*; use super::*; -use crate::host::sandbox::mount::MountAccess; use crate::protocol::cmdline::{self, parameters, virtio_mmio_devices}; -/// Returns the kernel command line that OpenVMM composes from `cmdline`, the `--cmdline` that -/// `run` gives it for the cold boot of an aggregate of read-write shares, as its -/// `build_microvm_command_line` and `append_microvm_virtio_discovery` do: its console parameters, -/// `cmdline`, the virtio-mmio devices of its virtio-fs slot and of its boot virtio-console, and -/// the aggregate's bootstrap tokens. -fn effective_command_line(cmdline: &str) -> String { +/// Models the kernel's discovery of the filesystem, control console, and boot console around the +/// command line rendered by the actual cold VM configuration. +fn effective_command_line(config: &VmConfig) -> String { format!( - "earlycon=xe9 console=hvc1 reboot=t panic=-1 {cmdline} \ - virtio_mmio.device=0x1000@0xd0001000:17 virtio_mmio.device=0x1000@0xd0002000:18{}", - aggregate_command_line_fragment(&[MountAccess::ReadWrite]) + "earlycon=xe9 console=hvc1 reboot=t panic=-1 {} \ + virtio_mmio.device=0x1000@0xd0001000:17 virtio_mmio.device=0x1000@0xd0002000:18 \ + virtio_mmio.device=0x1000@0xd0003000:19", + config.kernel_command_line().unwrap() ) } @@ -49,7 +46,44 @@ fn single_share_targets_must_remain_one_kernel_token() { ..Options::default() }; let rules = options.network.egress_rules().unwrap(); - assert!(extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err()); + assert!(cold_config(&options, &rules).is_err()); + } +} + +#[test] +fn cold_share_command_lines_fit_the_kernel_budget_to_the_byte() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + std::fs::create_dir(&first).unwrap(); + std::fs::create_dir(&second).unwrap(); + let mut options = Options { + mounts: vec![ + format!("/a,{},ro", first.display()), + format!("/b,{},rw", second.display()), + ], + ..Options::default() + }; + let rules = options.network.egress_rules().unwrap(); + let base = cold_config(&options, &rules).unwrap(); + let fragment = export_command_line_fragment(base.filesystem.as_ref().unwrap()); + let room = SANDBOX_COMMAND_LINE_MAX_SIZE + - base.kernel_command_line().unwrap().len() + - fragment.len() + - 2; + for unicode in [false, true] { + options.cmdline = if unicode { + "\u{e9}".repeat(room / 2) + &"x".repeat(room % 2) + } else { + "x".repeat(room) + }; + let fitting = cold_config(&options, &rules).unwrap(); + assert_eq!( + fitting.kernel_command_line().unwrap().len() + fragment.len() + 1, + SANDBOX_COMMAND_LINE_MAX_SIZE + ); + options.cmdline.push('x'); + assert!(cold_config(&options, &rules).is_err()); } } @@ -63,24 +97,27 @@ proptest! { fn share_tokens_hide_no_virtio_mmio_device( components in prop::collection::vec(component(), 2..5), ) { + let directory = tempfile::tempdir().unwrap(); + let roots: Vec<_> = (0..components.len()).map(|index| directory.path().join(format!("host-{index}"))).collect(); + for root in &roots { std::fs::create_dir(root).unwrap(); } let options = Options { mounts: components .iter() .enumerate() - .map(|(index, component)| format!("/share-{index}/{component},host-{index},rw")) + .map(|(index, component)| format!("/share-{index}/{component},{},rw", roots[index].display())) .collect(), ..Options::default() }; let rules = options.network.egress_rules().unwrap(); - let result = extend_shares_and_network(&mut Vec::new(), &options, &rules); + let result = cold_config(&options, &rules); let separated = components.iter().any(|component| { component .bytes() .any(|byte| matches!(byte, b'\0' | b'"') || cmdline::is_space(byte)) }); prop_assert_eq!(result.is_err(), separated); - if let Ok(user) = result { - let line = effective_command_line(&user); + if let Ok(config) = result { + let line = effective_command_line(&config); let read: Vec = parameters(line.as_bytes()) .map(|parameter| { let mut text = parameter.name.to_vec(); @@ -92,12 +129,12 @@ proptest! { }) .collect(); prop_assert_eq!(read, line.split(' ').collect::>()); - prop_assert_eq!(virtio_mmio_devices(line.as_bytes()).count(), 2); + prop_assert_eq!(virtio_mmio_devices(line.as_bytes()).count(), 3); } } #[test] - fn policy_paths_attach_to_their_shares( + fn restore_policy_paths_attach_to_their_shares( events in prop::collection::vec((any::(), 0_u8..3, any::()), 0..40), ) { let directory = tempfile::tempdir().unwrap(); @@ -152,7 +189,7 @@ proptest! { ].map(str::to_owned); let (options, _) = parse(&arguments).unwrap(); let rules = options.network.egress_rules().unwrap(); - prop_assert!(extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err()); + prop_assert!(cold_config(&options, &rules).is_err()); } } } diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs index 10a08c8ad..8f582f44e 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs @@ -77,7 +77,9 @@ use crate::host::supervisor::{self, Reattached, Vmm}; use crate::protocol::{CAPABILITY_LEN, GuestFeatures}; pub(crate) use self::directory::{create_state_directory, prepare_state_directory}; -pub(crate) use self::outcome::{validate_outcome_destination, write_exec_outcome}; +pub(crate) use self::outcome::{ + copy_openvmm_outcome, validate_outcome_destination, write_exec_outcome, +}; pub(crate) use self::start::{exec_workload, start}; pub(crate) use self::stop::{deprovision, stop}; diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs index a50cee787..d473b2c9d 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs @@ -1,7 +1,7 @@ //! Publication and reading of managed-workload and OpenVMM outcome reports. -use std::fs::OpenOptions; -use std::io::Write; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use serde_json::{Value, json}; @@ -39,11 +39,15 @@ fn outcome_destination(path: &str) -> Result { let resolved = paths::resolve(parent) .map_err(Exit::from_os_error)? .join(name); - if std::fs::symlink_metadata(&resolved).is_ok() { - return Err(Exit::error( - ErrorCode::OutcomeReportExists, - format!("outcome report already exists: {}", resolved.display()), - )); + match std::fs::symlink_metadata(&resolved) { + Ok(_) => { + return Err(Exit::error( + ErrorCode::OutcomeReportExists, + format!("outcome report already exists: {}", resolved.display()), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(os_error(&error, &resolved)), } Ok(resolved) } @@ -57,6 +61,24 @@ pub(crate) fn validate_outcome_destination(path: &str) -> Result { /// Writes the JSON `text` to a new file at `path`: through a temporary file of mode 0600, synced, /// then linked to its name, which must not exist, with `\n` line ends on every host. fn write_new_json(path: &str, text: &str) -> Result { + let text = format!("{text}\n"); + write_new_file(path, |file| file.write_all(text.as_bytes())) +} + +/// Publishes an OpenVMM report's exact bytes without replacing an existing destination. +pub(crate) fn copy_openvmm_outcome(source: &Path, path: &str) -> Result { + let mut report = File::open(source).map_err(|error| os_error(&error, source))?; + let permissions = report + .metadata() + .map_err(|error| os_error(&error, source))? + .permissions(); + write_new_file(path, |file| { + io::copy(&mut report, file)?; + file.set_permissions(permissions) + }) +} + +fn write_new_file(path: &str, write: impl FnOnce(&mut File) -> io::Result<()>) -> Result { let resolved = outcome_destination(path)?; let temporary = temporary_path(&resolved)?; let mut options = OpenOptions::new(); @@ -74,8 +96,7 @@ fn write_new_json(path: &str, text: &str) -> Result { Err(error) => return Err(os_error(&error, &temporary)), }; let written = (|| -> Result { - let text = format!("{text}\n"); - file.write_all(text.as_bytes()) + write(&mut file) .and_then(|()| file.sync_all()) .map_err(Exit::from_os_error)?; match std::fs::hard_link(&temporary, &resolved) { @@ -84,10 +105,7 @@ fn write_new_json(path: &str, text: &str) -> Result { ErrorCode::OutcomeReportExists, format!("outcome report already exists: {}", resolved.display()), )), - Err(_) => Err(Exit::error( - ErrorCode::Io, - format!("failed to publish outcome report: {}", resolved.display()), - )), + Err(error) => Err(os_error(&error, &resolved)), } })(); drop(file); diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 8ab296e7f..60100036e 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -43,8 +43,9 @@ use crate::host::store::RuntimeIdentity; use crate::protocol::root::RootUpper; pub(crate) use self::lifecycle::ProvisionConfig; +pub(super) use self::lifecycle::{copy_openvmm_outcome, validate_outcome_destination}; pub(super) use self::one_shot::run_openvmm; -use self::options::sandbox_network; +pub(super) use self::options::sandbox_network; pub(super) use self::options::{ HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, hypervisor, parse, path_text, }; @@ -202,7 +203,7 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { // `provision` records its binding, which `start` checks without reading the layer again. let binding = validate_sandbox_systemd_policy(&launch)?; // The network's policy is checked before anything launches or is recorded. - let launch = launch.with_network(sandbox_network(&options, &rules)?); + let launch = launch.with_network(sandbox_network(&options.network, &rules)?); Some((launch, binding)) } else { None diff --git a/nvx-rs/src/host/cli/sandbox/options.rs b/nvx-rs/src/host/cli/sandbox/options.rs index 33f041d17..48fc36e55 100644 --- a/nvx-rs/src/host/cli/sandbox/options.rs +++ b/nvx-rs/src/host/cli/sandbox/options.rs @@ -363,7 +363,7 @@ pub(crate) fn path_string(path: &Path) -> Result { }) } -/// Returns the network that the network options of `options` describe, with `rules`, the egress +/// Returns the network that `network` describes, with `rules`, the egress /// rules that [`NetworkOptions::egress_rules`] resolved: the typed policy at the guest network of /// `--net`, which [`SandboxNetwork::new`] checks before any launch, or `None` without `--net`, which /// no other network option may then accompany. @@ -373,11 +373,10 @@ pub(crate) fn path_string(path: &Path) -> Result { /// egress rules only with an explicit `--network-egress`, and reads `--network-egress-allow`, /// `--network-egress-deny`, and `--host-loopback-forward` in OpenVMM's form. `--network-proxy` must /// name the guest's gateway, and makes the policy proxy-only, with no scheme in its URL. -pub(super) fn sandbox_network( - options: &Options, +pub(in crate::host::cli) fn sandbox_network( + network: &NetworkOptions, rules: &NativeRules, ) -> Result> { - let network = &options.network; let Some(net) = &network.net else { let network_options = network.network_egress.is_some() || network.network_ingress.is_some() diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 1e0e25b6a..b600a7d22 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -161,6 +161,9 @@ pub struct Boot { /// The root's tokens, `nvx_hostname`, `nvx_machine_id`, `nvx_maps` and `virtfs_*` are reserved; /// every other token, including `nvx_entrypoint`, `nvx_arg` and `nvx_share`, is caller-owned. pub command_line: String, + /// Memory capacity of a cold VM, in MiB, if it reserves more than its boot memory. + /// It must be at least [`VmConfig::memory_mib`] and fit a byte count. + pub memory_capacity_mib: Option, /// What a capture of the VM adds, if the VM may be captured. pub capture: Option, } @@ -615,6 +618,9 @@ impl VmConfig { "--processors".into(), self.processors.to_string().into(), ]; + if let Some(capacity) = self.boot.as_ref().and_then(|boot| boot.memory_capacity_mib) { + arguments.extend(["--memory-capacity".into(), format!("{capacity}M").into()]); + } if let RootConfig::Layered(root) = &self.root { let scratch = match &root.upper { UpperConfig::Scratch { path, .. } => Some(path), @@ -714,6 +720,13 @@ impl VmConfig { capture.check()?; } if let Some(boot) = &self.boot { + if let Some(capacity) = boot.memory_capacity_mib + && (capacity < self.memory_mib || capacity > u64::MAX >> 20) + { + return Err(Error::unsupported( + "a cold boot's memory capacity must be at least its memory and fit a byte count", + )); + } let guest = cmdline::init_tokens(boot.command_line.as_bytes()).map(|token| Parameter { name: token.name, value: token.value, diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index ac8f140c8..6256c3e8c 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -23,6 +23,29 @@ fn missing(name: &str) -> PathBuf { std::env::temp_dir().join("nvx-config-missing").join(name) } +#[test] +fn cold_memory_capacity_is_checked_and_lowered_without_a_capture() { + let mut config = cold(blockless()); + config.boot.as_mut().unwrap().memory_capacity_mib = Some(1024); + let arguments = config + .openvmm_arguments( + &runtime(), + "/control", + Path::new("/report"), + Some("/console"), + ) + .unwrap(); + assert_eq!(values(&arguments, "--memory"), [OsStr::new("512M")]); + assert_eq!( + values(&arguments, "--memory-capacity"), + [OsStr::new("1024M")] + ); + for capacity in [511, u64::MAX] { + config.boot.as_mut().unwrap().memory_capacity_mib = Some(capacity); + assert_eq!(config.check().unwrap_err().kind(), ErrorKind::Unsupported); + } +} + /// Returns a blockless configuration of one processor and 512 MiB. fn blockless() -> VmConfig { VmConfig { @@ -132,10 +155,12 @@ fn configurations_pass_with_or_without_a_nic_address_and_a_boot() { let boots = [ None, Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), Some(Boot { + memory_capacity_mib: None, command_line: "quiet loglevel=0".to_owned(), capture: None, }), @@ -550,6 +575,7 @@ fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { let mut config = VmConfig { filesystem: filesystem.clone(), boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -614,6 +640,7 @@ fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { fn the_check_of_a_configuration_checks_its_boots_capture() { let booting = |capture: Option| VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture, }), @@ -662,6 +689,7 @@ fn captured() -> VmConfig { policy: NetworkPolicy::new(Access::Deny), }), boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet".to_owned(), capture: Some(capture()), }), @@ -896,6 +924,7 @@ fn caller_tokens_follow_rendered_tokens_without_any_rewriting() { let caller = " \tquiet nvx_entrypoint=/bin/work nvx_arg=\"x y\" nvx_share=0,/work\r\n "; let mut config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -919,6 +948,7 @@ fn only_a_checked_cold_boot_has_a_kernel_command_line() { let config = VmConfig { processors: 0, boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -975,6 +1005,7 @@ fn caller_tokens_cannot_replace_rendered_tokens_under_kernel_name_matching() { for config in [blockless(), layered(erofs())] { let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: command_line.clone(), capture: None, }), @@ -1008,6 +1039,7 @@ fn caller_owned_guest_tokens_and_noncolliding_names_stay_unchanged() { ] { let mut config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -1046,6 +1078,7 @@ fn mapping_table_is_the_exact_export_projection_counted_by_the_boot_token() { let mut config = VmConfig { filesystem: Some(plan.clone()), boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -1104,6 +1137,7 @@ fn public_maps_codec_keeps_the_protocol_root_reexports_usable() { fn cold(config: VmConfig) -> VmConfig { VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet nvx_entrypoint=/bin/work nvx_arg=ab".to_owned(), capture: None, }), @@ -1159,6 +1193,7 @@ fn cold_boot_command_lines_refuse_native_control_console_syntax() { ] { let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: caller.to_owned(), capture: None, }), diff --git a/nvx-rs/src/host/platform/linux/endpoint.rs b/nvx-rs/src/host/platform/linux/endpoint.rs index dc29d19f6..50328a0bc 100644 --- a/nvx-rs/src/host/platform/linux/endpoint.rs +++ b/nvx-rs/src/host/platform/linux/endpoint.rs @@ -268,7 +268,14 @@ pub(crate) fn connect_endpoint( )); } check_original_peer(&stream)?; - Ok(Box::new(UnixTransport { stream })) + transport_from_handle(stream.into()) +} + +/// Adapts an owned duplicate of an already verified connection without changing its blocking mode. +pub(crate) fn transport_from_handle(handle: TransportHandle) -> io::Result> { + Ok(Box::new(UnixTransport { + stream: UnixStream::from(handle), + })) } /// Returns the process that listens on a control endpoint, or `None` if nothing listens. diff --git a/nvx-rs/src/host/platform/linux/mod.rs b/nvx-rs/src/host/platform/linux/mod.rs index 516d09b16..d50af4900 100644 --- a/nvx-rs/src/host/platform/linux/mod.rs +++ b/nvx-rs/src/host/platform/linux/mod.rs @@ -14,6 +14,8 @@ use crate::hypervisor::Hypervisor; #[cfg(feature = "cli")] pub(crate) use crate::host::sys::{interrupted, watch_interrupts, write_interruptibly}; +#[cfg(feature = "cli")] +pub(crate) use self::endpoint::transport_from_handle; pub(crate) use self::endpoint::{ ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, listen_ready, }; diff --git a/nvx-rs/src/host/platform/windows/endpoint.rs b/nvx-rs/src/host/platform/windows/endpoint.rs index 5ade3b93c..7358e0454 100644 --- a/nvx-rs/src/host/platform/windows/endpoint.rs +++ b/nvx-rs/src/host/platform/windows/endpoint.rs @@ -234,6 +234,11 @@ pub(crate) fn connect_endpoint( ), )); } + transport_from_handle(pipe) +} + +/// Gives an already verified pipe duplicate its own overlapped-operation event. +pub(crate) fn transport_from_handle(pipe: TransportHandle) -> io::Result> { // SAFETY: creates an unnamed manual-reset event with default security. let event = owned(unsafe { CreateEventW(ptr::null(), 1, 0, ptr::null()) }) .ok_or_else(io::Error::last_os_error)?; diff --git a/nvx-rs/src/host/platform/windows/mod.rs b/nvx-rs/src/host/platform/windows/mod.rs index eef623a5c..f8633541e 100644 --- a/nvx-rs/src/host/platform/windows/mod.rs +++ b/nvx-rs/src/host/platform/windows/mod.rs @@ -22,6 +22,8 @@ use windows_sys::Win32::System::Threading::OpenProcess; use crate::hypervisor::Hypervisor; +#[cfg(feature = "cli")] +pub(crate) use self::endpoint::transport_from_handle; pub(crate) use self::endpoint::{ ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, listen_ready, }; @@ -44,7 +46,7 @@ pub(crate) use self::process::{ }; #[cfg(feature = "cli")] pub(crate) use self::spawn::spawn_foreground; -pub(crate) use self::spawn::{LaunchedProcess, spawn_detached}; +pub(crate) use self::spawn::{LaunchedProcess, SpawnMode, spawn_with_stdio}; /// Whether this host can run NVX microVMs. pub const SUPPORTED: bool = true; diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index e89348406..ccb82122c 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -252,19 +252,27 @@ impl Drop for InheritableForSpawn { /// [`SpawnLock`]: crate::host::platform::inherit::SpawnLock /// [`spawn_command`]: crate::host::platform::inherit::spawn_command /// [`log_claim_held`]: crate::host::platform::windows::log_claim_held -pub(crate) fn spawn_detached( +pub(crate) fn spawn_with_stdio( program: &Path, arguments: &[OsString], working_dir: &Path, stdio: [OwnedHandle; 3], inherit: &[Inherited<'_>], - breakaway_from_job: bool, + mode: SpawnMode, job: Option, ) -> io::Result { - let mut flags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW; - if breakaway_from_job { - flags |= CREATE_BREAKAWAY_FROM_JOB; - } + let flags = match mode { + SpawnMode::Foreground => 0, + SpawnMode::Detached { breakaway_from_job } => { + CREATE_NEW_PROCESS_GROUP + | CREATE_NO_WINDOW + | if breakaway_from_job { + CREATE_BREAKAWAY_FROM_JOB + } else { + 0 + } + } + }; spawn_listed( program, arguments, @@ -276,12 +284,23 @@ pub(crate) fn spawn_detached( ) } +/// Whether the child stays in the caller's console and job. +pub(crate) enum SpawnMode { + /// Keeps the caller's console, process group, and job membership. + Foreground, + /// Starts without a console, in a new process group. + Detached { + /// Leaves the caller's job when that job permits breakaway. + breakaway_from_job: bool, + }, +} + /// Starts OpenVMM in the caller's console, process group, and working directory, as /// `subprocess.run` starts a child, with copies of the caller's standard handles as its own. /// /// Unlike `std::process::Command`, which lets the child inherit every inheritable handle of the /// caller, it passes the child no other handle of the caller than the handles `inherit`, which -/// keep their values in the child, through the handle list of [`spawn_detached`], with the same +/// keep their values in the child, through the handle list of [`spawn_with_stdio`], with the same /// window, under the same lock, for processes that the caller spawns concurrently without one. /// Unlike `std::process::Command` too, which escapes the arguments of a batch file for `cmd.exe`, /// it fails with [`io::ErrorKind::InvalidInput`] for a batch file, as [`refuse_batch_file`] says. @@ -331,7 +350,7 @@ fn standard_handle(which: STD_HANDLE) -> io::Result> { /// Starts `program` with `arguments` in `working_dir`, or in the caller's working directory, with /// the creation flags `flags`, the handles `stdio` as its standard handles, a missing one null, /// and the caller's handles `inherit`, and no other handle of the caller, in the job object -/// `job`, if any, as [`spawn_detached`] describes. Fails with [`io::ErrorKind::InvalidInput`] for +/// `job`, if any, as [`spawn_with_stdio`] describes. Fails with [`io::ErrorKind::InvalidInput`] for /// a working directory beyond Windows' limit or for a batch file, as [`refuse_batch_file`] says. fn spawn_listed( program: &Path, diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 7da84f781..e77dbe242 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -228,13 +228,15 @@ fn children_inherit_only_their_standard_handles() { let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let cmd = Path::new(&system).join("System32").join("cmd.exe"); - let child = spawn_detached( + let child = spawn_with_stdio( &cmd, &[OsString::from("/c"), OsString::from("ping -n 30 127.0.0.1")], &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -313,13 +315,15 @@ fn a_child_loses_only_the_inherited_handle_that_is_closed_in_it() { let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let ping = Path::new(&system).join("System32").join("PING.EXE"); - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[Inherited::new(&closed), Inherited::new(&kept)], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -371,13 +375,15 @@ fn children_inherit_the_allowed_handles_while_they_are_created() { Inherited::new(&kept), Inherited::new(&allowed), ]; - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &inherit, - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -457,13 +463,15 @@ fn batch_files_are_refused_before_cmd_could_read_their_arguments() { let (stdin, _stdin_writer) = io::pipe().unwrap(); let (_stdout_reader, stdout) = io::pipe().unwrap(); let stderr = stdout.try_clone().unwrap(); - refused(spawn_detached( + refused(spawn_with_stdio( &program, &arguments, directory.path(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, )); } @@ -492,13 +500,15 @@ fn concurrent_spawns_that_allow_one_handle_all_inherit_it_and_restore_it() { let (stdin, _stdin_writer) = io::pipe().unwrap(); let (_stdout_reader, stdout) = io::pipe().unwrap(); let stderr = stdout.try_clone().unwrap(); - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[Inherited::new(&shared)], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -524,13 +534,15 @@ fn a_spawn_that_panics_in_its_window_restores_the_handles_and_lets_other_spawns_ let (stdin, _stdin_writer) = io::pipe()?; let (_stdout_reader, stdout) = io::pipe()?; let stderr = stdout.try_clone()?; - spawn_detached( + spawn_with_stdio( &cmd, &["/c", "exit", "0"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], inherit, - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) } @@ -649,13 +661,15 @@ impl HeldSpawn { }))); let (stdin, _stdin_writer) = io::pipe()?; let stderr = file.try_clone()?; - spawn_detached( + spawn_with_stdio( &program, &arguments, &std::env::temp_dir(), [stdin.into(), file.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, job, ) }); @@ -1122,13 +1136,15 @@ fn a_thread_that_holds_the_spawn_lock_spawns_under_it_until_its_last_lock_is_dro let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let cmd = Path::new(&system).join("System32").join("cmd.exe"); - let vmm = spawn_detached( + let vmm = spawn_with_stdio( &cmd, &["/c", "exit", "0"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index fd71827ab..f9009867f 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -1,4 +1,4 @@ -//! The detached spawn of a VMM, with an explicit allowlist of the descriptors or handles of the +//! The spawn of a VMM, with an explicit allowlist of the descriptors or handles of the //! caller that it inherits, and the child that the spawn returns until its caller releases it. use std::ffi::OsString; @@ -107,7 +107,7 @@ pub fn spawn( capability: &[u8; CAPABILITY_LEN], log: File, ) -> io::Result { - spawn_inner(command, capability, log, false) + spawn_inner(command, capability, log, false, false) } /// Starts a library VM only with a trusted log-named job on Windows. @@ -115,8 +115,9 @@ pub(crate) fn spawn_vm( command: &VmmCommand<'_>, capability: &[u8; CAPABILITY_LEN], log: File, + foreground: bool, ) -> io::Result { - spawn_inner(command, capability, log, true) + spawn_inner(command, capability, log, true, foreground) } /// Shares the legacy spawn mechanics without its optional Windows log-job fallback for a VM. @@ -125,6 +126,7 @@ fn spawn_inner( capability: &[u8; CAPABILITY_LEN], log: File, require_log_job: bool, + foreground: bool, ) -> io::Result { #[cfg(not(windows))] let _ = require_log_job; @@ -141,13 +143,20 @@ fn spawn_inner( "the VM log has no trusted log-named job", )); } - platform::spawn_detached( + let mode = if foreground { + platform::SpawnMode::Foreground + } else { + platform::SpawnMode::Detached { + breakaway_from_job: command.breakaway_from_job, + } + }; + platform::spawn_with_stdio( &command.program, &command.arguments, &command.working_dir, [stdin.into(), log.into(), stderr.into()], &command.inherit, - command.breakaway_from_job, + mode, job, ) .map(|process| Launched { process }) @@ -164,7 +173,17 @@ fn spawn_inner( .stdin(Stdio::from(stdin)) .stdout(Stdio::from(log)) .stderr(Stdio::from(stderr)); - platform::detach(&mut child, &command.inherit)?; + #[cfg(feature = "cli")] + if foreground { + platform::inherit_only(&mut child, &command.inherit)?; + } else { + platform::detach(&mut child, &command.inherit)?; + } + #[cfg(not(feature = "cli"))] + { + let _ = foreground; + platform::detach(&mut child, &command.inherit)?; + } child.spawn().map(|child| Launched { child, reaped: false, diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 72e724aa6..6dd5772c0 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -16,6 +16,8 @@ use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io; use std::path::{Path, PathBuf}; +#[cfg(feature = "cli")] +use std::time::Duration; use std::time::Instant; use crate::host::config::{ConsoleAttachment, RootConfig, UpperConfig, VmConfig}; @@ -300,6 +302,8 @@ pub struct Prepared { working_dir: PathBuf, /// Whether the child leaves the caller's Windows job. windows_breakaway: bool, + /// Whether this CLI-owned child keeps the caller's session, group, and console. + foreground: bool, /// The control credential, never written to disk. capability: Capability, /// The launch's fixed file paths and chosen endpoints. @@ -313,6 +317,12 @@ pub struct Prepared { } impl Prepared { + #[cfg(feature = "cli")] + pub(crate) fn for_foreground(mut self) -> Self { + self.foreground = true; + self + } + /// Returns the credential to journal before a spawn or use when attaching a session. pub fn capability(&self) -> &Capability { &self.capability @@ -464,6 +474,7 @@ pub fn create( program: runtime.openvmm.clone(), working_dir: dir.path().to_owned(), windows_breakaway: runtime.windows_breakaway, + foreground: false, capability, outputs, log, @@ -500,6 +511,38 @@ pub struct Launched { pub outputs: VmOutputs, /// The control credential, never written to disk. pub capability: Capability, + #[cfg(feature = "cli")] + child: ForegroundChild, +} + +#[cfg(feature = "cli")] +#[derive(Debug)] +struct ForegroundChild(Option); + +#[cfg(feature = "cli")] +impl Drop for ForegroundChild { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.detach(); + } + } +} + +#[cfg(feature = "cli")] +impl Launched { + pub(crate) fn wait_status(&mut self, timeout: Duration) -> io::Result> { + Ok(self + .child_mut()? + .wait_timeout(timeout)? + .map(|status| status as i32)) + } + + pub(crate) fn child_mut(&mut self) -> io::Result<&mut spawn::Launched> { + self.child + .0 + .as_mut() + .ok_or_else(|| io::Error::other("the VM has no child owned by this caller")) + } } /// Starts a prepared cold launch and returns its verified, detached process. @@ -605,6 +648,7 @@ fn spawn_verified( program, working_dir, windows_breakaway, + foreground, capability, outputs, log, @@ -618,7 +662,7 @@ fn spawn_verified( breakaway_from_job: windows_breakaway, inherit: inherit.iter().map(Inherited::new).collect(), }; - let child = spawn::spawn_vm(&command, capability.as_bytes(), log) + let child = spawn::spawn_vm(&command, capability.as_bytes(), log, foreground) .map_err(|error| Error::io("cannot spawn the VM").with_source(error))?; let process = match verify(&child) { Ok(process) => process, @@ -633,11 +677,21 @@ fn spawn_verified( return Err(Error::io(message).with_source(error)); } }; + #[cfg(feature = "cli")] + let child = if foreground { + Some(child) + } else { + child.detach(); + None + }; + #[cfg(not(feature = "cli"))] child.detach(); Ok(Launched { process, outputs, capability, + #[cfg(feature = "cli")] + child: ForegroundChild(child), }) } diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index b637a7532..02180bc24 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -428,6 +428,7 @@ fn cold_config() -> VmConfig { filesystem: None, console: ConsoleAttachment::Listener, boot: Some(Boot { + memory_capacity_mib: None, command_line: "nvx_entrypoint=/bin/sh nvx_arg=-c nvx_arg=exit".to_owned(), capture: None, }), @@ -1078,10 +1079,51 @@ fn a_library_vm_outlives_the_caller_that_spawned_it_without_retaining_its_output process, outputs: VmDir::open(&vm).unwrap().outputs(false).unwrap(), capability: Capability([0; CAPABILITY_LEN]), + #[cfg(feature = "cli")] + child: ForegroundChild(None), }); assert!(!launched.0.process.has_exited().unwrap()); } +#[cfg(all(feature = "cli", any(target_os = "linux", windows)))] +#[test] +fn an_owned_child_status_is_kept_when_its_outcome_is_missing_or_malformed() { + for malformed in [false, true] { + let (_directory, dir) = vm_dir(); + let mut prepared = stand_in(&dir).for_foreground(); + #[cfg(target_os = "linux")] + { + prepared.program = PathBuf::from("/bin/sh"); + prepared.arguments = ["-c", "exit 37"].map(OsString::from).to_vec(); + } + #[cfg(windows)] + { + prepared.program = PathBuf::from(std::env::var_os("ComSpec").unwrap()); + prepared.arguments = ["/d", "/c", "exit 37"].map(OsString::from).to_vec(); + } + let mut launched = RunningVm(spawn(prepared).unwrap()); + assert_eq!( + launched.0.wait_status(Duration::from_secs(10)).unwrap(), + Some(37) + ); + if malformed { + fs::write(&launched.0.outputs.outcome, b"not-json").unwrap(); + } + let now = Instant::now(); + let stopped = stop( + &launched.0.process, + &launched.0.outputs, + |_| false, + now, + now, + ) + .unwrap(); + assert!(stopped.self_exited); + assert_eq!(stopped.report.is_err(), malformed); + assert_eq!(launched.0.wait_status(Duration::ZERO).unwrap(), Some(37)); + } +} + #[cfg(any(target_os = "linux", windows))] #[test] #[ignore = "invoked in a separate caller by the caller-survival test"] @@ -1223,6 +1265,7 @@ fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { let vm = VmDir::open(&path).unwrap(); let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet loglevel=0".to_owned(), capture: None, }), diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index c92651949..9ef3d8382 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -83,7 +83,9 @@ //! or with `STATUS` if the kernel command line has `fake_exit_status=STATUS`. //! //! The fake also runs the microVMs of `nvx-rs run`, whose command lines have -//! neither a `--microvm-lifecycle` nor a sandbox block device, on the terminal. Such a command +//! neither a `--microvm-lifecycle` nor a sandbox block device, on the terminal or a boot-console +//! listener. A cold library launch also supplies the authenticated control endpoint and +//! `--guest-shutdown-action exit`, without selecting a lifecycle through an OpenVMM flag. Such a command //! line takes `--processors` and at most one `--cpu-profile`, shares through one `--mount` or the //! children of an aggregate at `/run/nvx/shares`, whose relative policy paths name paths in the //! only share, as OpenVMM resolves them, and either boots a guest, with `--memory`, an optional @@ -353,6 +355,8 @@ struct Options { restore: Option, /// The `--virtio-console` of a run's restore, which the restore checks against its snapshot. restore_console: Option, + /// The listener attachment of a cold library launch's boot console. + boot_console: Option, /// Control endpoint at which the fake accepts host sessions; empty for a one-shot run. endpoint: String, /// Path to the synthetic OpenVMM outcome report, which a one-shot run may omit. @@ -377,6 +381,8 @@ struct Options { exit_status: Option, /// Artificial delay before the fake guest becomes ready. boot_delay: Duration, + /// Whether the boot console emits the binary-output regression fixture. + binary_console: bool, /// Artificial delay before the fake OpenVMM crashes, when requested. crash_after: Option, /// Whether stop requests are deliberately left unanswered. @@ -494,23 +500,48 @@ pub fn main() -> ExitCode { return ExitCode::from(2); } }; + let launch_capability = if options.boot_console.is_some() { + match read_capability() { + Ok(capability) => Some(capability), + Err(message) => { + eprintln!("{program}: {message}"); + return ExitCode::from(2); + } + } + } else { + None + }; + let mut console = match boot_console(&options) { + Ok(console) => console, + Err(error) => { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } + }; let ended = match options.lifecycle { - Lifecycle::OneShot => Some(run_one_shot(&options)), - Lifecycle::Run => Some(run_microvm(&options)), + Lifecycle::OneShot => Some(run_one_shot(&options, &mut console)), + Lifecycle::Run => Some(run_microvm(&options, &mut console)), Lifecycle::Capture => Some(run_capture(&options)), Lifecycle::Managed => None, }; if let Some(status) = ended { + if let Err(error) = console.flush() { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } let _ = io::stdout().flush(); let _ = io::stderr().flush(); return exit_code(status); } - let capability = match read_capability() { - Ok(capability) => capability, - Err(message) => { - eprintln!("{program}: {message}"); - return ExitCode::from(2); - } + let capability = match launch_capability { + Some(capability) => capability, + None => match read_capability() { + Ok(capability) => capability, + Err(message) => { + eprintln!("{program}: {message}"); + return ExitCode::from(2); + } + }, }; thread::sleep(options.boot_delay); if let Some(after) = options.crash_after { @@ -720,7 +751,7 @@ fn writers_of(_reader: &fs::File) -> Option { /// The entrypoint is `/bin/echo`, which prints its arguments, `/bin/sleep`, which sleeps for its /// argument in seconds, `/bin/true` or `/bin/false`, or any other program, which is not found; /// the `fake_exit_status=STATUS` token replaces the status. -fn run_one_shot(options: &Options) -> i32 { +fn run_one_shot(options: &Options, console: &mut dyn Write) -> i32 { let (program, arguments) = options .workload .split_first() @@ -729,7 +760,10 @@ fn run_one_shot(options: &Options) -> i32 { }); let status = match program { "/bin/echo" => { - println!("{}", arguments.join(" ")); + if let Err(error) = writeln!(console, "{}", arguments.join(" ")) { + eprintln!("{}: {error}", options.program); + return 1; + } 0 } "/bin/sleep" => { @@ -767,9 +801,9 @@ fn run_one_shot(options: &Options) -> i32 { /// of the `fake_exit_status=STATUS` token. A restore without its snapshot directory fails with 1, /// as OpenVMM fails a restore that it cannot read, and so does one whose console /// [`check_restored_console`] refuses; neither writes a report. -fn run_microvm(options: &Options) -> i32 { +fn run_microvm(options: &Options, console: &mut dyn Write) -> i32 { thread::sleep(options.boot_delay); - match &options.restore { + let message = match &options.restore { Some(snapshot) if !snapshot.is_dir() => { eprintln!( "{}: cannot restore {}: no such snapshot directory", @@ -785,9 +819,18 @@ fn run_microvm(options: &Options) -> i32 { eprintln!("{}: {message}", options.program); return 1; } - println!("{}: restored the microVM", options.program); + format!("{}: restored the microVM", options.program) } - None => println!("{}: booted the microVM", options.program), + None => format!("{}: booted the microVM", options.program), + }; + let written = if options.binary_console { + console.write_all(&[b'A', 0xff, 0x80, b'\n']) + } else { + writeln!(console, "{message}") + }; + if let Err(error) = written { + eprintln!("{}: {error}", options.program); + return 1; } let status = options.exit_status.unwrap_or(0); let (category, status_code) = if status == 0 { @@ -1203,7 +1246,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { "--host-loopback-forward", ]; // The options that only the microVMs of `nvx-rs run` and of captures take. - const RUN_VALUED: [&str; 7] = [ + const RUN_VALUED: [&str; 8] = [ "--processors", "--cpu-profile", "--memory-capacity", @@ -1211,6 +1254,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { "--restore-processors", "--restore-memory", "--restore-ready-path", + "--guest-shutdown-action", ]; // The options that only the VMs of templates' captures take. const CAPTURE_VALUED: [&str; 4] = [ @@ -1297,9 +1341,24 @@ fn parse(program: &str, arguments: &[String]) -> Result { if !flags.contains("--single-process") { return Err("--single-process is required".to_owned()); } - let (endpoint, report) = match lifecycle { - Lifecycle::Managed | Lifecycle::Capture => { - expect("--virtio-console", "none")?; + let boot_console = if run && values.contains_key("--guest-shutdown-action") { + if values.contains_key("--restore-snapshot") { + return Err("--guest-shutdown-action is only for a cold boot".to_owned()); + } + expect("--guest-shutdown-action", "exit")?; + let endpoint = single("--virtio-console")? + .strip_prefix("listen=") + .filter(|endpoint| !endpoint.is_empty()) + .ok_or("--virtio-console must name a listen= endpoint")?; + Some(endpoint.to_owned()) + } else { + None + }; + let (endpoint, report) = + if matches!(lifecycle, Lifecycle::Managed | Lifecycle::Capture) || boot_console.is_some() { + if boot_console.is_none() { + expect("--virtio-console", "none")?; + } if !flags.contains("--microvm-control-auth-stdin") { return Err("--microvm-control-auth-stdin is required".to_owned()); } @@ -1313,8 +1372,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { ); } (endpoint, Some(PathBuf::from(single("--microvm-report")?))) - } - Lifecycle::OneShot | Lifecycle::Run => { + } else { let kind = if run { "a microVM run" } else { @@ -1346,8 +1404,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { Some(_) => return Err("--microvm-report must be given at most once".to_owned()), }; (String::new(), report) - } - }; + }; let hypervisor = single("--hypervisor")?; if !["kvm", "mshv", "whp"].contains(&hypervisor) { return Err(format!("unsupported hypervisor {hypervisor}")); @@ -1508,7 +1565,12 @@ fn parse(program: &str, arguments: &[String]) -> Result { // CLI's tokens may name a one-shot workload and place the children of an aggregate. The // guest of a workload of either mode has the targets of its directory shares, as a // one-shot sandbox's has: a direct share's, and those that the tokens place. - let (children, mut share_targets) = sandbox_shares(&values)?; + let (children, mut share_targets) = if boot_console.is_some() { + let maps = mapping_count(&token_values(MAPS_TOKEN))?; + (mapped_children(&values, maps)?, Vec::new()) + } else { + sandbox_shares(&values)? + }; match lifecycle { Lifecycle::Run => share_targets.clear(), _ => share_targets.extend(bound_targets(&token_values("nvx_share="))?), @@ -1649,6 +1711,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { run, restore, restore_console, + boot_console, endpoint, args_dump: anchor.with_file_name(format!("fake-openvmm-{token}.json")), maps_dump: anchor.with_file_name(format!("fake-openvmm-{token}-maps.json")), @@ -1661,6 +1724,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { exit_on_start: knob("fake_exit_on_start").and_then(|value| value.parse().ok()), exit_status: knob("fake_exit_status").and_then(|value| value.parse().ok()), boot_delay: millis("fake_boot_delay_ms").unwrap_or_default(), + binary_console: knob("fake_binary_console") == Some("1"), crash_after: millis("fake_crash_after_ms"), ignore_stop: knob("fake_ignore_stop") == Some("1"), exit_delay: millis("fake_exit_delay_ms").unwrap_or_default(), @@ -2850,6 +2914,46 @@ fn record_session(options: &Options) { } } +fn boot_console(options: &Options) -> io::Result> { + let Some(endpoint) = &options.boot_console else { + return Ok(Box::new(io::stdout())); + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + let listener = UnixListener::bind(endpoint)?; + fs::set_permissions(endpoint, fs::Permissions::from_mode(0o600))?; + Ok(Box::new(listener.accept()?.0)) + } + #[cfg(windows)] + { + let pipe = pipe_listener(endpoint)?; + accept_pipe(&pipe)?; + Ok(Box::new(PipeConsole(pipe))) + } + #[cfg(not(any(unix, windows)))] + Err(io::Error::new( + io::ErrorKind::Unsupported, + "no boot-console listener on this host", + )) +} + +#[cfg(windows)] +struct PipeConsole(fs::File); + +#[cfg(windows)] +impl Write for PipeConsole { + fn write(&mut self, data: &[u8]) -> io::Result { + self.0.write(data) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.sync_all() + } +} + #[cfg(unix)] /// Serves the fake control endpoint as a Unix-domain socket until a stop request. fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { @@ -2873,24 +2977,20 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> } #[cfg(windows)] -/// Serves the fake control endpoint as a Windows named pipe until a stop request. -fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { +fn pipe_listener(endpoint: &str) -> io::Result { use std::fs::File; use std::os::windows::ffi::OsStrExt; - use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; + use std::os::windows::io::{FromRawHandle, OwnedHandle, RawHandle}; - use windows_sys::Win32::Foundation::{ - ERROR_NO_DATA, ERROR_PIPE_CONNECTED, GetLastError, HANDLE, INVALID_HANDLE_VALUE, - }; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_ACCESS_DUPLEX, }; use windows_sys::Win32::System::Pipes::{ - ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE, - PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, + CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, }; - let name: Vec = std::ffi::OsStr::new(&options.endpoint.replace('/', "\\")) + let name: Vec = std::ffi::OsStr::new(&endpoint.replace('/', "\\")) .encode_wide() .chain([0]) .collect(); @@ -2913,16 +3013,26 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> return Err(io::Error::last_os_error()); } // SAFETY: CreateNamedPipeW returned a fresh handle that nothing else owns. - let mut file = File::from(unsafe { OwnedHandle::from_raw_handle(handle as RawHandle) }); + Ok(File::from(unsafe { + OwnedHandle::from_raw_handle(handle as RawHandle) + })) +} + +#[cfg(windows)] +fn accept_pipe(file: &fs::File) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{ + ERROR_NO_DATA, ERROR_PIPE_CONNECTED, GetLastError, HANDLE, + }; + use windows_sys::Win32::System::Pipes::{ConnectNamedPipe, DisconnectNamedPipe}; + let pipe = file.as_raw_handle() as HANDLE; - let mut guest = Guest::new(options); - let mut epoch = 0; loop { // SAFETY: the handle is a pipe server instance; a null OVERLAPPED waits synchronously. if unsafe { ConnectNamedPipe(pipe, std::ptr::null_mut()) } == 0 { // SAFETY: GetLastError has no preconditions. match unsafe { GetLastError() } { - ERROR_PIPE_CONNECTED => {} + ERROR_PIPE_CONNECTED => return Ok(()), // The client left before the connection was accepted. ERROR_NO_DATA => { // SAFETY: the handle is a pipe server instance. @@ -2932,6 +3042,23 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> error => return Err(io::Error::from_raw_os_error(error as i32)), } } + return Ok(()); + } +} + +#[cfg(windows)] +/// Serves the fake control endpoint as a Windows named pipe until a stop request. +fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::Pipes::DisconnectNamedPipe; + + let mut file = pipe_listener(&options.endpoint)?; + let pipe = file.as_raw_handle() as HANDLE; + let mut guest = Guest::new(options); + let mut epoch = 0; + loop { + accept_pipe(&file)?; epoch += 1; record_session(options); let flow = serve_client(&mut file, options, capability, epoch, &mut guest); diff --git a/nvx-rs/src/testing/fake_openvmm/tests.rs b/nvx-rs/src/testing/fake_openvmm/tests.rs index 068ac0d36..39d2ec29f 100644 --- a/nvx-rs/src/testing/fake_openvmm/tests.rs +++ b/nvx-rs/src/testing/fake_openvmm/tests.rs @@ -648,7 +648,7 @@ fn restores_of_runs_resume_their_saved_guest_whatever_the_cli_tokens() { assert_eq!(options.lifecycle, Lifecycle::Run, "{snapshot}"); assert!(options.workload.is_empty(), "{snapshot}"); if snapshot.ends_with("missing") { - assert_eq!(run_microvm(&options), 1); + assert_eq!(run_microvm(&options, &mut io::sink()), 1); } } } diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index 01191d000..ef4a9e608 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -2538,7 +2538,7 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { let dry_run = text(&run(&root, &arguments(&["--dry-run"])).stdout); let (openvmm, _) = dry_run .strip_prefix(">> ") - .and_then(|command| command.split_once(" --single-process ")) + .and_then(|command| command.split_once(" --machine ")) .unwrap(); let default = if cfg!(windows) { "whp" } else { "kvm" }; // OpenVMM fails the cold boot, or refuses its command line with the status of a usage error; @@ -2559,13 +2559,6 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { unserved, default, ), - ( - &["--host-loopback-forward", "tcp:8080:80"][..], - 2, - "openvmm: --host-loopback-forward requires explicit --host-loopback allow\n", - unserved, - default, - ), ( &[ "--cpu-profile", @@ -2656,6 +2649,7 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { ), (&["--restore-snapshot", "missing"][..], 1), (&["--net", "10.0.0.2/24"][..], 1), + (&["--host-loopback-forward", "tcp:8080:80"][..], 1), ( &["--mount", "/data", "--cmdline", "fake_exit_status=1"][..], 1, @@ -2666,6 +2660,13 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { ] { let output = run(&root, &arguments(options)); assert_eq!(output.status.code(), Some(status), "{options:?}"); + if options.first() == Some(&"--host-loopback-forward") { + assert!(output.stdout.is_empty(), "{options:?}"); + assert!( + text(&output.stderr).starts_with("error[unsupported]:"), + "{options:?}" + ); + } assert!( !text(&output.stderr).contains("nvx: "), "{options:?}: {}", @@ -2702,9 +2703,8 @@ fn run_boots_each_guest_with_its_default_memory() { #[test] fn run_shares_relative_roots_with_absolute_policy_paths() { - // nvx-rs run passes the host path of each share as given, so the children of an aggregate may - // be relative to the working directory, while their policy paths must be absolute: OpenVMM - // attributes each one to the share whose root contains it once it has resolved both. + // The cold path resolves each relative root in its checked export plan, while an aggregate's + // policy paths must be absolute. let root = Root::new(); let path = |parts: &[&str]| { parts @@ -2728,9 +2728,11 @@ fn run_shares_relative_roots_with_absolute_policy_paths() { let output = run(&root, &arguments); assert!(output.status.success(), "{}", text(&output.stderr)); let stdout = text(&output.stdout); - let child = nvx::host::sandbox::mount::aggregate_child_name(0, "/data"); + let canonical = fs::canonicalize(root.path.join("share")).unwrap(); + let child = canonical.to_str().unwrap(); + let child = child.strip_prefix(r"\\?\").unwrap_or(child); assert!( - stdout.contains(&format!(" --mount-child {child},share,rw ")), + stdout.contains(&format!(" --mount-child 0,{child},rw ")), "{stdout}" ); assert!( diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index f9d758cea..9a041cedb 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -10,13 +10,15 @@ use std::time::{Duration, Instant}; use crate::harness::{LAYER, NVX_RS, OPENVMM, Root, SCRATCH, UUID}; /// The files of a test root that a release installs. -const INSTALLED: [&str; 6] = [ +const INSTALLED: [&str; 8] = [ OPENVMM, "build/vmlinux", "build/initramfs.cpio.gz", "build/openvmm.provenance.json", "build/vmlinux.provenance.json", "build/initramfs.provenance.json", + "build/initramfs-ubuntu.cpio.gz.packages.json", + "build/initramfs-azurelinux.cpio.gz.packages.json", ]; /// Returns a root that is a checkout of NVX, with its `SOURCE-MANIFEST.json` and a `.git` that is diff --git a/nvx-rs/tests/cli/dry_run.rs b/nvx-rs/tests/cli/dry_run.rs index f8d796528..ec2c6984d 100644 --- a/nvx-rs/tests/cli/dry_run.rs +++ b/nvx-rs/tests/cli/dry_run.rs @@ -41,6 +41,8 @@ struct DryRun { arguments: Vec, /// The options of the OpenVMM command line that it prints, with placeholders. options: Options, + /// The specific unsupported configuration this case expects instead of a command. + refusal: Option<&'static str>, } /// Returns the dry run `name` of `arguments`, which prints an OpenVMM command line of `options`, @@ -61,10 +63,16 @@ fn dry_run( .map(|argument| argument.to_string()) .collect(), options, + refusal: None, } } impl DryRun { + fn refusing(mut self, message: &'static str) -> Self { + self.refusal = Some(message); + self + } + /// Returns the case with the file `relative` written first, with `content`. fn with_file(mut self, relative: &'static str, content: Content) -> Self { self.files.push((relative, content)); @@ -412,8 +420,7 @@ fn run_dry_runs() -> Vec { let many_children: Vec = (0..20) .map(|index| mount_child(index, &format!("/share/{index:02}"), "{root/share}", "ro")) .collect(); - let long = "x".repeat(1100); - let fits = "x".repeat(841); + let fits = "x".repeat(crate::openvmm::two_share_command_line_room()); vec![ dry_run("run: Alpine default", run(&[]), cold_boot()), dry_run( @@ -721,7 +728,8 @@ fn run_dry_runs() -> Vec { cold_boot() .with("--mount-deny", ["{root/share/hidden}"]) .with("--mount-allow", ["x"]), - ), + ) + .refusing("mount policy options require --mount"), dry_run( "run: several shares with policy paths", run(&[ @@ -778,7 +786,8 @@ fn run_dry_runs() -> Vec { )], ) .kernel(&[share_token(0, "/etc/single.txt", "ro", true)]), - ), + ) + .refusing("the guest target /etc/single.txt, which would cover the guest's /etc"), dry_run( "run: one relative shared file", run(&["--mount", "/etc/single.txt,single.txt"]), @@ -789,6 +798,29 @@ fn run_dry_runs() -> Vec { [mount_child(0, "/etc/single.txt", "single.txt", "ro,file")], ) .kernel(&[share_token(0, "/etc/single.txt", "ro", true)]), + ) + .refusing("the guest target /etc/single.txt, which would cover the guest's /etc"), + dry_run( + "run: one shared file outside system paths", + run(&["--mount", "/config/single.txt,{root/single.txt},ro"]), + cold_boot() + .with("--mount-aggregate", ["/run/nvx/shares"]) + .with( + "--mount-child", + [mount_child(0, "/config/single.txt", "{root/single.txt}", "ro,file")], + ) + .kernel(&[share_token(0, "/config/single.txt", "ro", true)]), + ), + dry_run( + "run: one relative shared file outside system paths", + run(&["--mount", "/config/single.txt,single.txt"]), + cold_boot() + .with("--mount-aggregate", ["/run/nvx/shares"]) + .with( + "--mount-child", + [mount_child(0, "/config/single.txt", "single.txt", "ro,file")], + ) + .kernel(&[share_token(0, "/config/single.txt", "ro", true)]), ), dry_run( "run: a directory and a file", @@ -860,12 +892,14 @@ fn run_dry_runs() -> Vec { "run: host path ending in a mode", run(&["--mount", "/data,{root/share},rw,ro"]), cold_boot().with("--mount", ["/data,{root/share},rw,ro"]), - ), + ) + .with_file("share,rw/data.txt", Content::Text("data\n")), dry_run( "run: host path ending in a comma", run(&["--mount", "/data,{root/share},,rw"]), cold_boot().with("--mount", ["/data,{root/share},,rw"]), - ), + ) + .with_file("share,/data.txt", Content::Text("data\n")), dry_run( "run: vmm owner", run(&["--mount", "/data,{root/share}", "--mount-owner", "vmm"]), @@ -934,7 +968,7 @@ fn run_dry_runs() -> Vec { ) .with("--cmdline", ["quiet"]), ), - // `nvx-rs run` passes the network options to OpenVMM as they are. + // A proxy is exclusive of direct egress allow rules. dry_run( "run: network", run(&[ @@ -974,12 +1008,69 @@ fn run_dry_runs() -> Vec { .with("--host-loopback", ["allow"]) .with("--network-proxy", ["10.0.0.1:3128"]) .with("--host-loopback-forward", ["tcp:8080:80", "udp:5353:53"]), + ) + .refusing("a network proxy takes no --network-egress-allow rules"), + dry_run( + "run: direct network rules and forwards", + run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "allow", + "--network-ingress", + "allow", + "--network-egress-allow", + "10.0.0.0/8:tcp:443", + "--network-egress-allow", + "2001:db8::/32", + "--network-egress-deny", + "10.1.0.0/16", + "--host-loopback", + "allow", + "--host-loopback-forward", + "tcp:8080:80", + "--host-loopback-forward", + "udp:5353:53", + ]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["allow"]) + .with("--network-ingress", ["allow"]) + .with("--network-egress-allow", ["10.0.0.0/8:tcp:443", "2001:db8::/32"]) + .with("--network-egress-deny", ["10.1.0.0/16"]) + .with("--host-loopback", ["allow"]) + .with("--host-loopback-forward", ["tcp:8080:80", "udp:5353:53"]), + ), + dry_run( + "run: proxy-only network", + run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--host-loopback", + "deny", + "--network-proxy", + "10.0.0.1:3128", + ]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["deny"]) + .with("--host-loopback", ["deny"]) + .with("--network-proxy", ["10.0.0.1:3128"]), ), dry_run( "run: egress default", run(&["--network-egress", "deny"]), cold_boot().with("--network-egress", ["deny"]), - ), + ) + .refusing("network policy options require --net"), // A restore takes the addressing of its NIC from the snapshot, so it passes the profile // and the policy without `--net`. dry_run( @@ -1072,16 +1163,23 @@ fn run_dry_runs() -> Vec { "run: host loopback forward", run(&["--host-loopback-forward", "tcp:8080:80"]), cold_boot().with("--host-loopback-forward", ["tcp:8080:80"]), - ), + ) + .refusing("network policy options require --net"), dry_run( "run: egress policy file", run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", "--network-egress", "deny", "--network-egress-policy-file", "{root/policy.json}", ]), cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) .with("--network-egress", ["deny"]) .with("--network-egress-allow", COMPILED_ALLOW) .with("--network-egress-deny", COMPILED_DENY), @@ -1090,12 +1188,19 @@ fn run_dry_runs() -> Vec { dry_run( "run: empty egress policy file", run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", "--network-egress", "allow", "--network-egress-policy-file", "{root/./policy.json}", ]), - cold_boot().with("--network-egress", ["allow"]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["allow"]), ) .with_file("policy.json", Content::Text("{}")), dry_run( @@ -1107,6 +1212,27 @@ fn run_dry_runs() -> Vec { "run: quoted command line", run(&["--cmdline", "a=\"b c\" d"]), cold_boot().with("--cmdline", ["a=\"b c\" d"]), + ) + .refusing( + "boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console", + ), + dry_run( + "run: standalone command line delimiter", + run(&["--cmdline", "quiet -- debug"]), + cold_boot().with("--cmdline", ["quiet -- debug"]), + ) + .refusing( + "boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console", + ), + dry_run( + "run: restore retains quoted command line and delimiter", + run(&[ + "--restore-snapshot", + "{root/snapshot}", + "--cmdline", + "a=\"b c\" -- d", + ]), + restore().with("--cmdline", ["a=\"b c\" -- d"]), ), dry_run( "run: command line starting with a dash", @@ -1133,14 +1259,6 @@ fn run_dry_runs() -> Vec { run(&["--cmdline", "a", "--cmdline", "b"]), cold_boot().with("--cmdline", ["b"]), ), - // One directory takes no boot token, so no budget limits the command line. - dry_run( - "run: long command line of one directory share", - run(&["--mount", "/data,{root/share}", "--cmdline", &long]), - cold_boot() - .with("--mount", ["/data,{root/share},ro"]) - .with("--cmdline", [long.as_str()]), - ), dry_run( "run: restore without the budget", run(&many), @@ -1202,8 +1320,35 @@ fn dry_runs_print_the_openvmm_command_and_launch_nothing() { } let before = root.snapshot(); let arguments: Vec<&str> = case.arguments.iter().map(String::as_str).collect(); + let cold = case + .arguments + .first() + .is_some_and(|command| command == "run") + && !case + .arguments + .iter() + .any(|argument| argument == "--restore-snapshot"); let outcome = root.nvx_rs(&arguments); eprintln!("case: {}", case.name); + if let Some(message) = case.refusal { + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!( + outcome.stderr().starts_with("error[unsupported]:"), + "{}", + outcome.describe() + ); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(root.snapshot(), before, "{}", case.name); + return; + } + let expected = if cold { + case.options + .cold_run_expected(&root, "one-shot") + .unwrap_or_else(|error| panic!("{} unexpectedly refused: {error}", case.name)) + } else { + case.options.expanded(&root) + }; outcome.assert_success(); let (command, rest) = outcome.command(); assert!(rest.is_empty(), "{}: {}", case.name, outcome.describe()); @@ -1214,8 +1359,13 @@ fn dry_runs_print_the_openvmm_command_and_launch_nothing() { "{}", case.name ); - case.options - .assert_matches(&root, &Options::parse(arguments), case.name); + let actual = Options::parse(arguments); + let actual = if cold { + actual.normalized_cold_outputs(true) + } else { + actual + }; + expected.assert_matches(&root, &actual, case.name); // The run launched no OpenVMM, which records its command line once it has accepted it. assert_eq!(root.snapshot(), before, "{}", case.name); }); @@ -1238,8 +1388,7 @@ fn command_lines() -> impl Strategy { #[test] fn the_openvmm_command_of_a_run_splits_back_into_its_arguments() { - // `nvx-rs run` passes its kernel command line to OpenVMM as it is, so the `>> ` line, split as - // this host splits command lines, must give it back whatever characters it holds: with a + // Accepted caller text survives the `>> ` line: with a // shell-word splitter on POSIX, and with `CommandLineToArgvW` on Windows. let root = Root::new(); let openvmm = root.below(OPENVMM).to_str().unwrap().to_owned(); @@ -1257,16 +1406,27 @@ fn the_openvmm_command_of_a_run_splits_back_into_its_arguments() { ]; let output = root.command(&arguments).output().unwrap(); let outcome = Outcome::new(arguments, output); + if command_line.contains('"') + || command_line.split_ascii_whitespace().any(|token| token == "--") + { + prop_assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + prop_assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + prop_assert_eq!( + outcome.stderr(), + format!("error[unsupported]: boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console{}", crate::harness::LINE_END) + ); + return Ok(()); + } + let expected = cold_boot() + .with("--cmdline", [command_line.clone()]) + .cold_run_expected(&root, "one-shot") + .unwrap(); outcome.assert_success(); let (command, rest) = outcome.command(); prop_assert!(rest.is_empty(), "{}", outcome.describe()); prop_assert_eq!(&command[0], &openvmm); - let mut expected = cold_boot().expanded(&root); - if !command_line.is_empty() { - expected = expected.with("--cmdline", [command_line.clone()]); - } prop_assert_eq!( - Options::parse(&command[1..]), + Options::parse(&command[1..]).normalized_cold_outputs(true), expected, "{}", outcome.describe() diff --git a/nvx-rs/tests/cli/harness.rs b/nvx-rs/tests/cli/harness.rs index 1470a1cf5..acdd23e19 100644 --- a/nvx-rs/tests/cli/harness.rs +++ b/nvx-rs/tests/cli/harness.rs @@ -90,9 +90,17 @@ pub(crate) const PROVENANCE: [(&str, &str, &str); 3] = [ /// The other files of a root, by their paths below it, and their contents: the guest kernel and /// Alpine initramfs of a checkout, the layers of the sandboxes, of which only the distro layer /// has a role, their scratch image, and the directories and the file that they share. -const FILES: [(&str, &str); 12] = [ +const FILES: [(&str, &str); 14] = [ ("build/vmlinux", "kernel\n"), ("build/initramfs.cpio.gz", "initramfs\n"), + ( + "build/initramfs-ubuntu.cpio.gz.packages.json", + "{\"artifact_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}\n", + ), + ( + "build/initramfs-azurelinux.cpio.gz.packages.json", + "{\"artifact_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\"}\n", + ), ("distro.erofs", "distro\n"), ("runtime.erofs", "runtime\n"), ("custom.erofs", "custom\n"), diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index cef3c22a5..b14be2d20 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -49,6 +49,8 @@ struct Launch { stderr: &'static str, /// The outcome report that OpenVMM writes, if the run asks for one. report: Option, + /// A specific checked-configuration refusal, with no OpenVMM launch. + refusal: Option<&'static str>, } /// Returns the launch `name` of `arguments`, which starts OpenVMM with `options` and exits with @@ -75,10 +77,17 @@ fn launch( stdout, stderr: "", report: None, + refusal: None, } } impl Launch { + fn refusing(mut self, message: &'static str) -> Self { + self.refusal = Some(message); + self.status = 1; + self + } + /// Returns the case with the file `relative` written first, with `content`. fn with_file(mut self, (relative, content): (&'static str, Content)) -> Self { self.files.push((relative, content)); @@ -642,7 +651,16 @@ fn run_launches() -> Vec { "", ) .with_stderr("openvmm: --host-loopback-forward requires explicit --host-loopback allow\n") - .recorded_in(None), + .recorded_in(None) + .refusing("network policy options require --net"), + launch( + "run: OpenVMM exits with a usage status", + run(&["--cmdline", "fake_exit_on_start=2"]), + cold_boot().with("--cmdline", ["fake_exit_on_start=2"]), + 2, + "", + ) + .with_stderr("openvmm: failing the launch as requested\n"), // A relative NVX_OPENVMM is relative to the working directory, and the command names it // by its absolute path. launch( @@ -678,8 +696,30 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { .iter() .map(|(name, value)| (*name, value.as_str())) .collect(); + let cold = case + .arguments + .first() + .is_some_and(|command| command == "run") + && !case + .arguments + .iter() + .any(|argument| argument == "--restore-snapshot"); + let before = case.refusal.map(|_| root.snapshot()); let outcome = root.nvx_rs_with(&arguments, &environment); eprintln!("case: {}", case.name); + if let Some(message) = case.refusal { + assert_eq!(outcome.status, Some(case.status), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!( + outcome.stderr().starts_with("error[unsupported]:"), + "{}", + outcome.describe() + ); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(Some(root.snapshot()), before); + return; + } + let expected_cold = cold.then(|| case.options.cold_run_expected(&root, "one-shot")); assert_eq!(outcome.status, Some(case.status), "{}", outcome.describe()); let (command, rest) = outcome.command(); // The workload's output follows the command byte for byte, as does OpenVMM's. @@ -705,11 +745,28 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { case.name ); // The `>> ` line splits back into the arguments that OpenVMM received. - if let Some(record) = case.record { - assert_eq!(root.openvmm_arguments(record), arguments, "{}", case.name); - } let mut options = Options::parse(arguments); - let mut expected = case.options.clone(); + let record = case.record.map(|record| { + if cold { + let report = std::path::Path::new(options.value("--microvm-report")); + let token = report + .parent() + .unwrap() + .file_name() + .unwrap() + .to_str() + .unwrap(); + format!("build/fake-openvmm-{token}.json") + } else { + record.to_owned() + } + }); + if let Some(record) = record { + assert_eq!(root.openvmm_arguments(&record), arguments, "{}", case.name); + } + let mut expected = expected_cold + .map(|expected| expected.unwrap()) + .unwrap_or_else(|| case.options.clone()); // A sandbox's OpenVMM opens the images through the files that the run checked: the distro // layer alone for an upper in RAM, and the scratch image too for one on scratch. let blocks = expected.take("--microvm-sandbox-block"); @@ -728,6 +785,9 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { assert_opened_images(&root, token); } } + if cold { + options = options.normalized_cold_outputs(true); + } expected.assert_matches(&root, &options, case.name); if let Some(report) = &case.report { assert_outcome_report( @@ -740,6 +800,342 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { }); } +#[test] +fn both_cold_modes_keep_their_foreground_output_and_owned_exit_status() { + for (mode, caller, status, stdout) in [ + ( + "one-shot", + "fake_exit_status=7", + 7, + "openvmm: booted the microVM\n", + ), + ("managed", "fake_crash_after_ms=100", 3, ""), + ] { + let root = Root::new(); + let identity = if mode == "managed" { + " nvx_identity=1001:1001" + } else { + "" + }; + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + &format!("{caller} nvx_mode={mode}{identity}"), + ]); + assert_eq!(outcome.status, Some(status), "{}", outcome.describe()); + let (command, output) = outcome.command(); + assert_eq!(output, stdout.as_bytes(), "{}", outcome.describe()); + assert_eq!(command[0], root.below(OPENVMM).to_str().unwrap()); + let mut expected = cold_boot() + .with( + "--cmdline", + [format!( + "{caller}{}", + if identity.is_empty() { " " } else { " " } + )], + ) + .cold_run_expected(&root, mode) + .unwrap(); + if !identity.is_empty() { + let line = format!("{}{identity}", expected.value("--cmdline")); + expected = expected.with("--cmdline", [line]); + } + let actual = Options::parse(&command[1..]).normalized_cold_outputs(true); + expected.assert_matches(&root, &actual, mode); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_terminal_runs_forward_raw_output_and_restore_the_callers_terminal() { + use std::fs::File; + use std::io::{self, Read}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::fcntl::{FcntlArg, OFlag, fcntl}; + use nix::pty::openpty; + use nix::sys::termios::{LocalFlags, OutputFlags, tcgetattr}; + + for status in [0, 7] { + let root = Root::new(); + let terminal = openpty(None, None).unwrap(); + let original = tcgetattr(&terminal.slave).unwrap(); + let mut process = root + .command(&[ + "run", + "--cmdline", + &format!("fake_boot_delay_ms=250 fake_exit_status={status}"), + ]) + .stdin(Stdio::from(terminal.slave.try_clone().unwrap())) + .stdout(Stdio::from(terminal.slave.try_clone().unwrap())) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let mut observed_raw = false; + while process.try_wait().unwrap().is_none() { + let current = tcgetattr(&terminal.slave).unwrap(); + observed_raw |= !current + .local_flags + .intersects(LocalFlags::ICANON | LocalFlags::ECHO) + && !current.output_flags.contains(OutputFlags::OPOST); + if Instant::now() >= deadline { + process.kill().unwrap(); + process.wait().unwrap(); + panic!("terminal run did not exit within its deadline"); + } + std::thread::sleep(Duration::from_millis(5)); + } + let outcome = process.wait_with_output().unwrap(); + assert_eq!( + outcome.status.code(), + Some(status), + "{}", + String::from_utf8_lossy(&outcome.stderr) + ); + assert!(observed_raw, "cold run left its terminal cooked"); + let restored = tcgetattr(&terminal.slave).unwrap(); + assert_eq!(restored.input_flags, original.input_flags); + assert_eq!(restored.output_flags, original.output_flags); + assert_eq!(restored.local_flags, original.local_flags); + assert_eq!(restored.control_flags, original.control_flags); + assert_eq!(restored.control_chars, original.control_chars); + fcntl(&terminal.master, FcntlArg::F_SETFL(OFlag::O_NONBLOCK)).unwrap(); + let mut master = File::from(terminal.master); + let mut output = Vec::new(); + let mut buffer = [0; 8192]; + loop { + match master.read(&mut buffer) { + Ok(0) => break, + Ok(count) => output.extend_from_slice(&buffer[..count]), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => panic!("cannot read terminal output: {error}"), + } + } + assert!( + output.ends_with(b"openvmm: booted the microVM\n"), + "{output:?}" + ); + } +} + +#[test] +fn cold_runs_refuse_existing_and_missing_parent_reports_before_launch() { + let root = Root::new(); + root.write("reports/existing.json", "keep\n"); + for (path, message) in [ + ( + "{root/reports/existing.json}", + "error[outcome-report-exists]:", + ), + ( + "{root/missing/outcome.json}", + "outcome report parent is not a plain directory", + ), + ] { + let before = root.snapshot(); + let outcome = root.nvx_rs(&["run", "--outcome-report", path]); + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(root.snapshot(), before); + } +} + +#[test] +fn missing_cold_reports_do_not_replace_the_owned_child_status() { + for line in [ + "fake_exit_on_start=3", + "fake_crash_after_ms=100 nvx_mode=managed nvx_identity=1001:1001", + ] { + let root = Root::new(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + line, + "--outcome-report", + "{root/reports/outcome.json}", + ]); + assert_eq!(outcome.status, Some(3), "{}", outcome.describe()); + assert!( + outcome.stderr().contains("outcome.json"), + "{}", + outcome.describe() + ); + assert!(!root.below("reports/outcome.json").exists()); + } +} + +#[test] +fn cold_report_publication_never_clobbers_a_destination_created_during_boot() { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + + for symlink in [false, true] + .into_iter() + .filter(|symlink| !symlink || cfg!(unix)) + { + let root = Root::new(); + let destination = root.below("reports/outcome.json"); + root.write("reports/victim.txt", "keep\n"); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=250 fake_exit_status=7", + "--outcome-report", + destination.to_str().unwrap(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut command = String::new(); + stdout.read_line(&mut command).unwrap(); + assert!(command.starts_with(">> "), "{command}"); + if symlink { + #[cfg(unix)] + std::os::unix::fs::symlink(root.below("reports/victim.txt"), &destination).unwrap(); + } else { + root.write("reports/outcome.json", "keep\n"); + } + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(7), "{output:?}"); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("outcome report already exists"), + "the publication failure must be reported" + ); + assert_eq!(std::fs::read(&destination).unwrap(), b"keep\n"); + assert_eq!( + std::fs::read(root.below("reports/victim.txt")).unwrap(), + b"keep\n" + ); + if symlink { + assert!( + std::fs::symlink_metadata(&destination) + .unwrap() + .is_symlink() + ); + } + } +} + +#[test] +fn cold_console_backpressure_does_not_block_output_or_replace_the_child_status() { + use std::io::{self, Write}; + use std::process::Stdio; + + let root = Root::new(); + let arguments = [ + "run", + "--cmdline", + "fake_boot_delay_ms=2500 fake_exit_status=7", + ]; + let mut process = root + .command(&arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = process.stdin.take().unwrap(); + let writer = std::thread::spawn(move || stdin.write_all(&vec![b'x'; 16 * 1024 * 1024])); + let output = process.wait_with_output().unwrap(); + if let Err(error) = writer.join().unwrap() { + assert_eq!(error.kind(), io::ErrorKind::BrokenPipe, "{error}"); + } + let outcome = crate::harness::Outcome::new( + arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect(), + output, + ); + assert_eq!(outcome.status, Some(7), "{}", outcome.describe()); + assert_eq!( + outcome.command().1, + b"openvmm: booted the microVM\n", + "{}", + outcome.describe() + ); +} + +#[test] +fn cold_console_output_preserves_non_utf8_bytes() { + let root = Root::new(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + "fake_binary_console=1 fake_exit_status=7", + ]); + assert_eq!(outcome.status, Some(7), "{}", outcome.describe()); + assert_eq!( + outcome.command().1, + &[b'A', 0xff, 0x80, b'\n'], + "{}", + outcome.describe() + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_runs_keep_the_native_child_in_the_callers_session_and_process_group() { + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::unistd::{Pid, getpgid, getsid}; + + let root = Root::new(); + let process = root + .command(&["run", "--cmdline", "fake_boot_delay_ms=500"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let caller = Pid::from_raw(process.id() as i32); + let deadline = Instant::now() + Duration::from_secs(10); + let child = loop { + let children = std::fs::read_to_string(format!( + "/proc/{}/task/{}/children", + caller.as_raw(), + caller.as_raw() + )) + .unwrap(); + let native = children.split_ascii_whitespace().find_map(|pid| { + let arguments = match std::fs::read(format!("/proc/{pid}/cmdline")) { + Ok(arguments) => arguments, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => panic!("cannot inspect the owned child {pid}: {error}"), + }; + arguments + .split(|byte| *byte == 0) + .any(|argument| argument == b"--microvm-control-console") + .then(|| Pid::from_raw(pid.parse().unwrap())) + }); + if let Some(child) = native { + break child; + } + assert!( + Instant::now() < deadline, + "the owned native child did not appear" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + let caller_session = getsid(Some(caller)).unwrap(); + let caller_group = getpgid(Some(caller)).unwrap(); + let child_session = getsid(Some(child)).unwrap(); + let child_group = getpgid(Some(child)).unwrap(); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert_eq!(child_session, caller_session); + assert_eq!(child_group, caller_group); +} + /// A run leaves an outcome report that the caller can read whatever its umask, even one that /// clears the owner's own permissions, and whose group's and others' permissions the caller's /// umask narrows: OpenVMM starts with the caller's umask without the owner's bits. diff --git a/nvx-rs/tests/cli/openvmm.rs b/nvx-rs/tests/cli/openvmm.rs index b25d02c20..48360f4cf 100644 --- a/nvx-rs/tests/cli/openvmm.rs +++ b/nvx-rs/tests/cli/openvmm.rs @@ -6,11 +6,27 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; +use std::path::{Path, PathBuf}; use std::sync::LazyLock; use serde_json::{Value, json}; -use crate::harness::{Content, HYPERVISOR, Root, is_hex}; +use crate::harness::{Content, HYPERVISOR, OPENVMM, Root, is_hex}; + +/// Caller bytes left by the two-directory fixture, its native bootstrap, and a terminating NUL. +pub(crate) fn two_share_command_line_room() -> usize { + use nvx::host::filesystem::GUEST_EXPORT; + use nvx::host::sandbox::mount::{ + AGGREGATE_MOUNT_TARGET, MountAccess, aggregate_command_line_fragment, + }; + + let kernel = "nvx_hostname=6e7678 nvx_machine_id=image nvx_maps=2 nvx_mode=one-shot \ + nvx_share=0,/a,ro nvx_share=1,/b,rw"; + let fragment = + aggregate_command_line_fragment(&[MountAccess::ReadOnly, MountAccess::ReadWrite]) + .replace(AGGREGATE_MOUNT_TARGET, GUEST_EXPORT); + nvx::host::config::COMMAND_LINE_BUDGET - kernel.len() - fragment.len() - 2 +} /// The options of OpenVMM that take no value. const FLAGS: [&str; 3] = [ @@ -169,6 +185,338 @@ impl Options { } panic!("{context}: the OpenVMM options differ:\n{differences}"); } + + /// Projects an old cold-run fixture onto the checked public VM contract, independently of + /// the CLI parser and its private token renderer. Restore and sandbox fixtures stay old. + pub(crate) fn cold_run_expected(&self, root: &Root, mode: &str) -> nvx::Result { + use nvx::activation::{MachineIdMode, View}; + use nvx::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; + use nvx::host::filesystem::{ + self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, + }; + use nvx::host::network::{ + Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, + NetworkProxy, + }; + use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; + use nvx::host::sandbox::mount::{MountAccess, parse_mount_value}; + use nvx::host::vm::{self, VmDir}; + + use nvx::host::network::{ForwardProtocol, Protocol}; + + let old = self.expanded(root); + let mut caller = old.values("--cmdline").first().cloned().unwrap_or_default(); + let mut shares = Vec::new(); + let mut files = BTreeSet::new(); + for value in old.values("--mount") { + let (target, host, access) = parse_mount_value(value)?; + let host = PathBuf::from(host); + let host = if host.is_absolute() { + host + } else { + root.below(host.to_str().unwrap()) + }; + if host.is_file() { + files.insert(target.clone()); + } + shares.push((target, host, access)); + } + let tokens: Vec<_> = caller.split_ascii_whitespace().map(str::to_owned).collect(); + let mut generated = Vec::new(); + for value in old.values("--mount-child") { + let child = value.strip_suffix(",file").unwrap_or(value); + let (name, host, access) = parse_mount_value(child)?; + let token = tokens + .iter() + .find(|token| { + token + .strip_prefix("nvx_share=") + .is_some_and(|share| share.starts_with(&format!("{name},"))) + }) + .expect("the aggregate fixture names every child's target"); + let parts: Vec<_> = token + .strip_prefix("nvx_share=") + .unwrap() + .split(',') + .collect(); + let target = parts[1].to_owned(); + let host = PathBuf::from(host); + let host = if host.is_absolute() { + host + } else { + root.below(host.to_str().unwrap()) + }; + if value.ends_with(",file") { + files.insert(target.clone()); + } + shares.push((target, host, access)); + generated.push(token.clone()); + } + for token in generated.iter().rev() { + caller = if caller == *token { + String::new() + } else { + caller + .strip_suffix(&format!(" {token}")) + .expect("fixture boot shares follow caller text") + .to_owned() + }; + } + let aggregate = shares.len() > 1 || !files.is_empty(); + let mut policies: [Vec; 3] = Default::default(); + for (index, option) in ["--mount-deny", "--mount-allow", "--mount-write"] + .into_iter() + .enumerate() + { + for value in old.values(option) { + let path = PathBuf::from(value); + if shares.is_empty() || (aggregate && !path.is_absolute()) { + return Err(nvx::Error::unsupported( + "boot share policies need a mapped path and absolute aggregate paths", + )); + } + policies[index].push(if path.is_absolute() { + path + } else { + shares[0].1.join(path) + }); + } + } + let mappings: Vec<_> = shares + .iter() + .map(|(target, host, access)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(target), + read_only: *access == MountAccess::ReadOnly, + }) + .collect(); + let export = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: PathList { + name: "--mount-deny", + paths: &policies[0], + }, + allowed: PathList { + name: "--mount-allow", + paths: &policies[1], + }, + write: PathList { + name: "--mount-write", + paths: &policies[2], + }, + owner: if old + .values("--mount-owner") + .first() + .is_some_and(|value| value == "caller") + { + Owner::Caller + } else { + Owner::Vmm + }, + reserved: ReservedTargets::BLOCKLESS_ROOT, + })?; + let mut boot_tokens = vec![format!("nvx_mode={mode}")]; + if let Some(export) = &export { + boot_tokens.extend(export.guest_table().into_iter().map(|row| { + let access = if row.read_only { "ro" } else { "rw" }; + let kind = if files.contains(&row.target) { + ",file" + } else { + "" + }; + format!("nvx_share={},{},{access}{kind}", row.source, row.target) + })); + } + if !caller.is_empty() { + boot_tokens.insert(0, caller); + } + if old.values("--net").is_empty() + && [ + "--network-egress", + "--network-ingress", + "--network-egress-allow", + "--network-egress-deny", + "--host-loopback", + "--host-loopback-forward", + "--network-proxy", + ] + .into_iter() + .any(|option| !old.values(option).is_empty()) + { + return Err(nvx::Error::unsupported( + "network policy options require --net", + )); + } + let network = if let Some(net) = old.values("--net").first() { + let access = |option, default| match old.values(option).first().map(String::as_str) { + Some("allow") => Access::Allow, + Some("deny") => Access::Deny, + _ => default, + }; + let mut policy = NetworkPolicy::new(access("--network-egress", Access::Allow)); + for (option, destination) in [ + ("--network-egress-allow", &mut policy.allow), + ("--network-egress-deny", &mut policy.deny), + ] { + *destination = EgressRules { + name: option.to_owned(), + rules: old + .values(option) + .iter() + .map(|value| { + for (suffix, protocol) in [ + (":tcp", Protocol::Tcp), + (":udp", Protocol::Udp), + (":icmp", Protocol::Icmp), + ] { + if let Some((destination, ports)) = value.split_once(suffix) { + let rule = EgressRule::to(destination); + return if ports.is_empty() { + rule.on_protocol(protocol) + } else { + let ports = ports.strip_prefix(':').unwrap(); + if let Some((first, last)) = ports.split_once('-') { + rule.on_port_range( + protocol, + first.parse().unwrap(), + last.parse().unwrap(), + ) + } else { + rule.on_port(protocol, ports.parse().unwrap()) + } + }; + } + } + EgressRule::to(value) + }) + .collect(), + }; + } + policy.ingress = access("--network-ingress", Access::Deny); + policy.host_loopback = match old.values("--host-loopback").first().map(String::as_str) { + Some("allow") => HostLoopback::Allow, + Some("deny") => HostLoopback::Deny, + _ => HostLoopback::Outbound, + }; + policy.host_loopback_forwards = old + .values("--host-loopback-forward") + .iter() + .map(|value| { + let fields: Vec<_> = value.split(':').collect(); + LoopbackForward { + protocol: match fields[0] { + "tcp" => ForwardProtocol::Tcp, + "udp" => ForwardProtocol::Udp, + _ => panic!("a fixture names tcp or udp"), + }, + host_port: fields[1].parse().unwrap(), + guest_port: fields[2].parse().unwrap(), + } + }) + .collect(); + if let Some(proxy) = old.values("--network-proxy").first() { + let (gateway, port) = proxy.rsplit_once(':').unwrap(); + assert_eq!(gateway, nvx::net::guest_gateway(net).unwrap().to_string()); + policy.proxy = Some(NetworkProxy::new(port.parse().unwrap())); + } + Some(NetworkConfig { + guest_network: Some(nvx::net::Ipv4Cidr::parse(net).unwrap()), + policy, + }) + } else { + None + }; + let config = VmConfig { + processors: old.value("--processors").parse().unwrap(), + memory_mib: old.value("--memory").trim_end_matches('M').parse().unwrap(), + root: RootConfig::Blockless, + view: View { + hostname: b"nvx".to_vec(), + machine_id: MachineIdMode::Image, + }, + network, + filesystem: export, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: boot_tokens.join(" "), + memory_capacity_mib: old + .values("--memory-capacity") + .first() + .map(|value| value.trim_end_matches('M').parse().unwrap()), + capture: None, + }), + }; + config.check_command_line()?; + let identity = ArtifactIdentity::new("test-fixture").unwrap(); + let runtime = Runtime { + openvmm: root.below(OPENVMM), + kernel: PathBuf::from(old.value("--kernel")), + initramfs: PathBuf::from(old.value("--initrd")), + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), + hypervisor: old.value("--hypervisor").parse().unwrap(), + cpu_profile: old.values("--cpu-profile").first().cloned(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("expected-vm"); + vm::create_private_dir(&path)?; + let prepared = vm::create(&runtime, &config, None, &VmDir::open(&path)?)?; + let arguments: Vec<_> = prepared + .arguments() + .iter() + .map(|value| value.to_str().unwrap().to_owned()) + .collect(); + let expected = Self::parse(&arguments).normalized_cold_outputs(false); + drop(prepared); + Ok(expected) + } + + /// Checks fresh output names rather than making random endpoint values an assertion escape. + pub(crate) fn normalized_cold_outputs(&self, removed: bool) -> Self { + let mut options = self.clone(); + let report = PathBuf::from(options.value("--microvm-report")); + assert!(report.is_absolute()); + assert_eq!(report.file_name().unwrap(), "outcome.json"); + let parent = report.parent().unwrap(); + if removed { + let name = parent.file_name().unwrap().to_str().unwrap(); + let suffix = name + .strip_prefix("nvx-run-") + .expect("a cold CLI run owns a fresh VM directory"); + assert_eq!(suffix.len(), 32); + assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!( + !parent.exists(), + "the foreground run left its VM directory: {}", + parent.display() + ); + } + let console = options + .value("--virtio-console") + .strip_prefix("listen=") + .unwrap(); + let control = options + .value("--microvm-control-console") + .strip_prefix("listen=") + .unwrap(); + assert_ne!(console, control); + for (endpoint, name) in [(console, "console.sock"), (control, "control.sock")] { + if cfg!(windows) { + let suffix = endpoint.strip_prefix("//./pipe/openvmm-microvm-").unwrap(); + assert_eq!(suffix.len(), 32); + assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit())); + } else { + assert_eq!(Path::new(endpoint), parent.join(name)); + } + } + options = options + .with("--virtio-console", ["listen="]) + .with("--microvm-control-console", ["listen="]) + .with("--microvm-report", [""]); + options + } } /// Returns the options with which a one-shot run of the minimal sandbox, `sandbox run --layer diff --git a/nvx-rs/tests/cli/validation.rs b/nvx-rs/tests/cli/validation.rs index 441e54fc9..7cb9f24ff 100644 --- a/nvx-rs/tests/cli/validation.rs +++ b/nvx-rs/tests/cli/validation.rs @@ -729,6 +729,7 @@ fn sandbox_refusals() -> Vec { fn run_refusals() -> Vec { let targets = "with several shares, or a shared file"; let budget = &["--mount", "--cmdline", "1024"][..]; + let overflow = crate::openvmm::two_share_command_line_room() + 1; let two_shares = |cmdline: &str| { run_dry(&[ "--mount", @@ -740,7 +741,7 @@ fn run_refusals() -> Vec { ]) }; let mut many = vec!["run".to_owned()]; - many.extend(shares(20)); + many.extend(shares(42)); many.push("--dry-run".to_owned()); vec![ refusal( @@ -1230,13 +1231,12 @@ fn run_refusals() -> Vec { ]), &["--mount target", "\"/voil\u{e0}\"", targets], ), - // The target of one directory share reaches the kernel command line in OpenVMM's - // virtfs_dir= token. + // A single directory is now checked for the same planned nvx_share= row as an aggregate. refusal( "run: quoted target of one share", "unsupported", run_dry(&["--mount", "/a\"b,{root/share}"]), - &["--mount target", "\"/a\\\"b\"", "one kernel token"], + &["--mount target", "\"/a\\\"b\"", "quotes"], ), refusal( "run: target with a dot component", @@ -1331,14 +1331,27 @@ fn run_refusals() -> Vec { refusal( "run: budget exceeded by a byte", "unsupported", - two_shares(&"x".repeat(842)), + two_shares(&"x".repeat(overflow)), budget, ), // The budget is of bytes: each of these characters takes two. refusal( "run: budget in UTF-8 bytes", "unsupported", - two_shares(&"\u{e9}".repeat(421)), + two_shares(&("\u{e9}".repeat(overflow / 2) + &"x".repeat(overflow % 2))), + budget, + ), + // A single directory also has a planned boot bind now; keep the former unbounded + // positive case as a checked refusal rather than dropping its input. + refusal( + "run: long command line of one directory share", + "unsupported", + run_dry(&[ + "--mount", + "/data,{root/share}", + "--cmdline", + &"x".repeat(1100), + ]), budget, ), refusal( @@ -1352,7 +1365,7 @@ fn run_refusals() -> Vec { "run: caller owner on Windows", "unsupported", run_dry(&["--mount", "/data,{root/share}", "--mount-owner", "caller"]), - &["--mount-owner caller", "Linux"], + &["caller owner", "Linux"], ) .on(Hosts::Windows), refusal( From 72e3bd5cee0981f923eb6a5a91650d7517c91701 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:16:15 -0800 Subject: [PATCH 10/30] docs: document CLI foreground child ownership Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/vm.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 6dd5772c0..1bfceeb8f 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -317,6 +317,7 @@ pub struct Prepared { } impl Prepared { + /// Keeps this CLI launch in its caller's session, process group, and console. #[cfg(feature = "cli")] pub(crate) fn for_foreground(mut self) -> Self { self.foreground = true; @@ -511,10 +512,12 @@ pub struct Launched { pub outputs: VmOutputs, /// The control credential, never written to disk. pub capability: Capability, + /// Retains only a foreground caller's child for status and control attachment. #[cfg(feature = "cli")] child: ForegroundChild, } +/// Reaps a foreground child on release without adding `Drop` to the public launch value. #[cfg(feature = "cli")] #[derive(Debug)] struct ForegroundChild(Option); @@ -530,6 +533,7 @@ impl Drop for ForegroundChild { #[cfg(feature = "cli")] impl Launched { + /// Polls the owned child independently of its optional outcome report. pub(crate) fn wait_status(&mut self, timeout: Duration) -> io::Result> { Ok(self .child_mut()? @@ -537,6 +541,7 @@ impl Launched { .map(|status| status as i32)) } + /// Gives the CLI connector the same owned child whose exit it observes. pub(crate) fn child_mut(&mut self) -> io::Result<&mut spawn::Launched> { self.child .0 From b134e702dc97feb620dfa642d0dac1fa46c33246 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:39:15 -0800 Subject: [PATCH 11/30] integration: adapt CLI liveness to qualified process opening proofs Compose the existing CLI liveness contract with SL2.10b Opened results. Preserve held-running versus proved-exited distinction and propagate lookup failures. Update only its existing own-process test helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/liveness.rs | 17 +++++++++++------ nvx-rs/src/host/cli/store/tests/liveness.rs | 10 ++++++---- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/nvx-rs/src/host/cli/store/liveness.rs b/nvx-rs/src/host/cli/store/liveness.rs index 90af6ef29..4b584f836 100644 --- a/nvx-rs/src/host/cli/store/liveness.rs +++ b/nvx-rs/src/host/cli/store/liveness.rs @@ -6,7 +6,7 @@ use super::StateFile; use super::locked::LockedState; use super::record::LaunchJournal; use crate::host::platform; -use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; use crate::{Error, Result}; impl LockedState { @@ -51,11 +51,16 @@ fn find(pid: u32) -> io::Result> { let Some(start_time) = platform::process_start_time(pid)? else { return Ok(None); }; - Process::open(&ProcessRef { - pid, - start_time, - boot: BootId::current()?, - }) + Ok( + match Process::open(&ProcessRef { + pid, + start_time, + boot: BootId::current()?, + })? { + Opened::Running(process) => Some(process), + Opened::Exited(_) => None, + }, + ) } /// Adapted from the old store's owner check: the second endpoint lookup happens only after diff --git a/nvx-rs/src/host/cli/store/tests/liveness.rs b/nvx-rs/src/host/cli/store/tests/liveness.rs index 0ac403e01..e9bb3f683 100644 --- a/nvx-rs/src/host/cli/store/tests/liveness.rs +++ b/nvx-rs/src/host/cli/store/tests/liveness.rs @@ -7,20 +7,22 @@ use super::launch::prepared; use super::*; use crate::host::cli::store::liveness::hold_endpoint_server; use crate::host::config::ConsoleAttachment; -use crate::host::process::{BootId, Process, ProcessRef}; +use crate::host::process::{BootId, Opened, Process, ProcessRef}; use crate::host::spawn::{self, VmmCommand}; const HELPER: &str = "host::cli::store::tests::liveness::log_holder_helper"; fn own_process() -> Process { let pid = std::process::id(); - Process::open(&ProcessRef { + let Opened::Running(process) = Process::open(&ProcessRef { pid, start_time: platform::process_start_time(pid).unwrap().unwrap(), boot: BootId::current().unwrap(), }) - .unwrap() - .unwrap() + .unwrap() else { + panic!("this process runs"); + }; + process } struct Running(Option); From cc4ded03fc2c0d4860eebb86d98b913aac174fca Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:44:52 -0800 Subject: [PATCH 12/30] cli: recover interrupted launches under the sandbox lock Use verified endpoint and log claims for unrecorded launches and boot/PID/start identities for recorded processes. Require confirmed exit before removing capability and journal; retain indeterminate evidence and its refusal cause. Keep committed-runtime recovery separate and bounded claim rechecks intact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/launch.rs | 123 ++++++- nvx-rs/src/host/cli/store/locked.rs | 11 +- nvx-rs/src/host/cli/store/mod.rs | 3 + nvx-rs/src/host/cli/store/tests.rs | 1 + nvx-rs/src/host/cli/store/tests/launch.rs | 17 +- nvx-rs/src/host/cli/store/tests/recovery.rs | 362 ++++++++++++++++++++ 6 files changed, 505 insertions(+), 12 deletions(-) create mode 100644 nvx-rs/src/host/cli/store/tests/recovery.rs diff --git a/nvx-rs/src/host/cli/store/launch.rs b/nvx-rs/src/host/cli/store/launch.rs index d0a5c04e6..d3cdb0100 100644 --- a/nvx-rs/src/host/cli/store/launch.rs +++ b/nvx-rs/src/host/cli/store/launch.rs @@ -1,13 +1,17 @@ -//! Locked launch transitions, without spawning, recovery, or process termination. +//! Locked launch transitions and bounded recovery of an interrupted launch. + +use std::thread; +use std::time::{Duration, Instant}; use super::StateFile; use super::files; use super::locked::LockedState; use super::record::{LaunchJournal, RuntimeRecord}; -use crate::host::process::ProcessRef; +use crate::host::cli::deadline::RETRY_INTERVAL; +use crate::host::process::{BootId, KILL_TIMEOUT, Opened, Process, ProcessRef}; use crate::host::runtime::RuntimeIdentity; use crate::host::vm::{Prepared, SavedVm}; -use crate::{Error, Result}; +use crate::{Error, ErrorKind, Result}; /// A launch whose lock stays borrowed until commit or until its journal is left behind. #[derive(Debug)] @@ -17,6 +21,77 @@ pub(crate) struct Launch<'a> { } impl LockedState { + /// Resolves only an uncommitted journal, before the next command can mutate state. + pub(crate) fn recover(&mut self, deadline: Instant) -> Result<()> { + if self.runtime()?.is_some() { + return Ok(()); + } + let Some(journal) = self.journal()? else { + return Ok(()); + }; + if journal.vm_dir != self.dir() || journal.log != self.path(StateFile::Log) { + return Err(Error::io( + "the launch journal belongs to another sandbox directory", + )); + } + match journal.process() { + Some(reference) => self.end_recorded(&journal, reference, deadline)?, + None if !self.released_claim(&journal, deadline)? => { + let process = self.endpoint_owner(&journal)?; + end_process(&process, deadline)?; + } + None => {} + } + if self.capability()?.is_some() { + files::remove(self, StateFile::Capability)?; + } + // Keep the journal until cleanup has finished, including after a partial removal. + files::remove(self, StateFile::Journal) + } + + /// Ends only the recorded process, or accepts an independently proved release of its claim. + fn end_recorded( + &self, + journal: &LaunchJournal, + reference: &ProcessRef, + deadline: Instant, + ) -> Result<()> { + let boot = BootId::current().map_err(|error| { + Error::io("cannot identify the current host boot").with_source(error) + })?; + if boot != reference.boot { + return Ok(()); + } + let unheld = match Process::open(reference) { + Ok(Opened::Running(process)) => return end_process(&process, deadline), + Ok(Opened::Exited(_)) => Error::new( + ErrorKind::StillRunning, + "the recorded VM exited, but a process may still hold its launch log", + ), + Err(error) => Error::io("cannot verify the recorded VM process").with_source(error), + }; + if self.released_claim(journal, deadline)? { + Ok(()) + } else { + Err(unheld) + } + } + + /// Rechecks transient fork-inherited descriptors for at most one second. + fn released_claim(&self, journal: &LaunchJournal, deadline: Instant) -> Result { + let until = deadline.min(Instant::now() + Duration::from_secs(1)); + loop { + if !self.claim_held(journal)? { + return Ok(true); + } + let left = until.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Ok(false); + } + thread::sleep(left.min(RETRY_INTERVAL)); + } + } + /// Durably publishes the capability and complete prepared journal before the caller spawns. pub(crate) fn begin_launch( &mut self, @@ -38,6 +113,48 @@ impl LockedState { } } +/// Ends a held, verified process and requires confirmation of its exit before the deadline. +fn end_process(process: &Process, deadline: Instant) -> Result<()> { + let failed = |message: &str| { + Error::new( + ErrorKind::StillRunning, + format!( + "cannot confirm exit of VM process {}: {message}", + process.reference().pid + ), + ) + }; + if process + .wait(Instant::now()) + .map_err(|error| failed("the exit check failed").with_source(error))? + .is_some() + { + return Ok(()); + } + let until = deadline.min(Instant::now() + KILL_TIMEOUT); + if until <= Instant::now() { + return Err(failed("the recovery deadline passed")); + } + let kill_error = process.kill().err(); + let exited = process.wait(until).map_err(|error| { + let mut failure = failed("the exit wait failed"); + if let Some(kill_error) = &kill_error { + failure = failed(&format!( + "termination failed: {kill_error}; the exit wait failed" + )); + } + failure.with_source(error) + })?; + if exited.is_some() { + return Ok(()); + } + let failure = failed("the verified process has not exited"); + Err(match kill_error { + Some(error) => failure.with_source(error), + None => failure, + }) +} + impl Launch<'_> { /// Records only the caller's spawned process, durably, before changing the in-memory state. pub(crate) fn record_process(&mut self, process: &ProcessRef) -> Result<()> { diff --git a/nvx-rs/src/host/cli/store/locked.rs b/nvx-rs/src/host/cli/store/locked.rs index 68737e290..35bf46ffb 100644 --- a/nvx-rs/src/host/cli/store/locked.rs +++ b/nvx-rs/src/host/cli/store/locked.rs @@ -54,6 +54,9 @@ impl StateDir { /// access, who could hold its lock. A lock file that another transition removed or replaced /// after this opened it locks nothing, so the lock is taken again: at once the first time, /// and after a pause, until the deadline, when that happens again right after. + /// + /// Once held, the lock recovers an interrupted launch before returning. Recovery can end a + /// verified VM, but retains indeterminate records and reports the cause of its refusal. pub(crate) fn lock(&self, deadline: Deadline) -> Result { let path = self.path(StateFile::Lock); let mut retried_at_once = false; @@ -71,10 +74,14 @@ impl StateDir { } } if names_locked_file(&path, &file)? { - return Ok(LockedState { + let mut locked = LockedState { state: self.clone(), lock: file, - }); + }; + locked + .recover(deadline.instant()) + .map_err(crate::host::cli::sandbox::error_with_cause)?; + return Ok(locked); } if retried_at_once && !deadline.pause(RETRY_INTERVAL)? { return Err(busy()); diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index ac58708e0..43f2197df 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -33,6 +33,9 @@ //! Its journal also identifies the inherited log claim: held means a launch process may run, //! and released proves exit. An endpoint owner is held and rechecked before it is returned; //! Linux additionally verifies that this process holds that launch's log. +//! Taking the next lock recovers an interrupted, uncommitted launch before allowing mutation: +//! it ends only a verified process, removes its journal and capability only after proved exit, +//! and refuses indeterminate recovery without changing that evidence. #![cfg_attr( not(test), expect( diff --git a/nvx-rs/src/host/cli/store/tests.rs b/nvx-rs/src/host/cli/store/tests.rs index 5db8c5adb..4dabc42a6 100644 --- a/nvx-rs/src/host/cli/store/tests.rs +++ b/nvx-rs/src/host/cli/store/tests.rs @@ -10,6 +10,7 @@ use crate::host::store::{JOURNAL_NAME, RUNTIME_NAME, SandboxStore}; mod launch; mod liveness; +mod recovery; /// How long a test waits for a lock that it released to be free. /// diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs index 5fd38d31a..8712ea4ff 100644 --- a/nvx-rs/src/host/cli/store/tests/launch.rs +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -175,16 +175,19 @@ fn an_interruption_keeps_the_journal_and_capability_without_publishing_runtime() launch.record_process(&saved.process).unwrap(); } } + let before = fs::read(state.path(StateFile::Journal)).unwrap(); + assert!(locked.begin_launch(&prepared, &runtime).is_err()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); drop(locked); - let mut reopened = state.lock(Deadline::after(10.0)).unwrap(); - let journal = reopened.journal().unwrap().unwrap(); + let journal: record::LaunchJournal = + serde_json::from_slice(&fs::read(state.path(StateFile::Journal)).unwrap()).unwrap(); assert_eq!(journal.process(), spawned.then_some(&saved.process)); assert_eq!(journal.capability, saved.capability); - assert_eq!(reopened.capability().unwrap(), Some(saved.capability)); - assert!(reopened.runtime().unwrap().is_none()); - let before = fs::read(state.path(StateFile::Journal)).unwrap(); - assert!(reopened.begin_launch(&prepared, &runtime).is_err()); - assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), before); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + saved.capability + ); + assert!(!state.path(StateFile::Runtime).exists()); } } diff --git a/nvx-rs/src/host/cli/store/tests/recovery.rs b/nvx-rs/src/host/cli/store/tests/recovery.rs new file mode 100644 index 000000000..fae235449 --- /dev/null +++ b/nvx-rs/src/host/cli/store/tests/recovery.rs @@ -0,0 +1,362 @@ +use super::launch::prepared; +use super::*; +use crate::host::cli::store::files; +use crate::host::config::ConsoleAttachment; + +#[test] +fn the_next_lock_cleans_a_pre_spawn_journal_only_after_its_claim_is_released() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + drop(locked); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + let started = Instant::now(); + assert!(state.lock(Deadline::after(0.05)).is_err()); + assert!(started.elapsed() < Duration::from_secs(2)); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + drop(prepared); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + assert!(state.path(StateFile::Log).exists()); +} + +#[test] +fn a_transient_inherited_log_descriptor_is_rechecked_before_refusing_recovery() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let inherited = prepared.log().try_clone().unwrap(); + drop(prepared); + drop(locked); + let release = thread::spawn(move || { + thread::sleep(Duration::from_millis(80)); + drop(inherited); + }); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + release.join().unwrap(); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); +} + +#[test] +fn an_interrupted_cleanup_with_no_capability_can_finish_idempotently() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + files::remove(&locked, StateFile::Capability).unwrap(); + drop(prepared); + locked + .recover(Instant::now() + Duration::from_secs(10)) + .unwrap(); + assert!(locked.journal().unwrap().is_none()); + locked.recover(Instant::now()).unwrap(); +} + +#[test] +fn replaced_logs_and_foreign_journal_paths_keep_all_evidence() { + for foreign in [false, true] { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + drop(prepared); + if foreign { + let mut journal = locked.journal().unwrap().unwrap(); + journal.vm_dir = state.dir().join("another-vm"); + locked.write_record(StateFile::Journal, &journal).unwrap(); + } else { + fs::rename(state.path(StateFile::Log), state.dir().join("old-log")).unwrap(); + fs::write(state.path(StateFile::Log), b"another log").unwrap(); + } + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + assert!(locked.recover(Instant::now()).is_err()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + drop(locked); + let (_, message) = error(state.lock(Deadline::after(0.0)).unwrap_err()); + let cause = if foreign { + "another sandbox directory" + } else { + "is not the claimed log" + }; + assert!(message.contains(cause), "{message}"); + } +} + +#[cfg(any(target_os = "linux", windows))] +mod process { + #[cfg(target_os = "linux")] + use std::process::{Child, Command, Stdio}; + + use super::*; + use crate::host::process::{BootId, Process, ProcessRef}; + use crate::host::spawn::{self, VmmCommand}; + use crate::host::vm::{Prepared, SavedVm, VmDir}; + + const HELPER: &str = "host::cli::store::tests::recovery::process::launch_helper"; + + struct Running(Option); + + impl Running { + fn process(&self) -> Process { + self.0.as_ref().unwrap().process().unwrap() + } + } + + impl Drop for Running { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.kill_and_confirm_exit().unwrap(); + } + } + } + + #[cfg(target_os = "linux")] + struct Other(Child); + + #[cfg(target_os = "linux")] + impl Drop for Other { + fn drop(&mut self) { + if self.0.try_wait().unwrap().is_none() { + self.0.kill().unwrap(); + } + self.0.wait().unwrap(); + } + } + + fn running(state: &StateDir, prepared: &Prepared) -> Running { + fs::write( + state.dir().join("recovery-endpoint"), + &prepared.outputs().endpoint, + ) + .unwrap(); + let command = VmmCommand::new( + std::env::current_exe().unwrap(), + ["--exact", HELPER, "--ignored", "--nocapture"] + .map(Into::into) + .to_vec(), + state.dir(), + ); + let child = Running(Some( + spawn::spawn_vm( + &command, + prepared.capability().as_bytes(), + prepared.log().try_clone().unwrap(), + ) + .unwrap(), + )); + assert!(released(|| state.dir().join("recovery-ready").exists())); + child + } + + #[test] + fn an_unrecorded_vm_requires_both_endpoint_and_log_proofs_before_it_is_ended() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + let child = running(&state, &prepared); + let process = child.process(); + drop(prepared); + drop(locked); + assert!(!process.has_exited().unwrap()); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(process.wait(Instant::now()).unwrap().is_some()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + } + + #[cfg(target_os = "linux")] + #[test] + fn an_endpoint_owner_without_the_log_is_not_signaled_and_its_journal_stays() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + drop(locked.begin_launch(&prepared, &runtime).unwrap()); + fs::write( + state.dir().join("recovery-endpoint"), + &prepared.outputs().endpoint, + ) + .unwrap(); + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", HELPER, "--ignored", "--nocapture"]) + .current_dir(state.dir()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut other = Other(crate::host::tests::spawn_locked(&mut command).unwrap()); + assert!(released(|| state.dir().join("recovery-ready").exists())); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + assert!( + locked + .recover(Instant::now() + Duration::from_secs(2)) + .is_err() + ); + assert!(other.0.try_wait().unwrap().is_none()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + } + + #[test] + fn a_recorded_process_is_ended_only_after_a_matching_identity_and_confirmed_exit() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + launch.record_process(process.reference()).unwrap(); + drop(launch); + drop(prepared); + assert!(locked.recover(Instant::now()).is_err()); + assert!(!process.has_exited().unwrap()); + assert!(locked.journal().unwrap().is_some()); + assert!(locked.capability().unwrap().is_some()); + locked + .recover(Instant::now() + Duration::from_secs(10)) + .unwrap(); + assert!(process.wait(Instant::now()).unwrap().is_some()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + } + + #[test] + fn incorrect_recorded_ids_and_start_times_never_signal_a_live_process_or_drop_its_claim() { + for mismatch in 0..3 { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + let mut reference = process.reference().clone(); + match mismatch { + 0 => reference.pid = 0, + 1 => { + reference.pid = std::process::id(); + reference.start_time = platform::process_start_time(reference.pid) + .unwrap() + .unwrap() + + 1; + } + 2 => reference.start_time += 1, + _ => unreachable!(), + } + launch.record_process(&reference).unwrap(); + drop(launch); + drop(prepared); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + let capability = fs::read(state.path(StateFile::Capability)).unwrap(); + assert!( + locked + .recover(Instant::now() + Duration::from_secs(2)) + .is_err() + ); + assert!(!process.has_exited().unwrap()); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert_eq!( + fs::read(state.path(StateFile::Capability)).unwrap(), + capability + ); + } + } + + #[test] + fn an_earlier_boot_proves_exit_without_signaling_a_process_from_this_boot() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + let mut boot = *BootId::current().unwrap().as_bytes(); + boot[0] ^= 1; + launch + .record_process(&ProcessRef { + boot: BootId::from_bytes(boot), + ..process.reference().clone() + }) + .unwrap(); + drop(launch); + drop(prepared); + locked.recover(Instant::now()).unwrap(); + assert!(!process.has_exited().unwrap()); + assert!(locked.journal().unwrap().is_none()); + assert!(locked.capability().unwrap().is_none()); + } + + #[test] + fn a_committed_runtime_is_not_recovered_through_its_leftover_journal() { + let (_directory, state) = state(); + let (prepared, runtime) = prepared(&state, ConsoleAttachment::Disconnected); + let mut locked = state.lock(Deadline::after(10.0)).unwrap(); + let mut launch = locked.begin_launch(&prepared, &runtime).unwrap(); + let child = running(&state, &prepared); + let process = child.process(); + launch.record_process(process.reference()).unwrap(); + let journal = fs::read(state.path(StateFile::Journal)).unwrap(); + launch + .commit(&SavedVm { + vm_dir: VmDir::open(state.dir()).unwrap(), + endpoint: prepared.outputs().endpoint.clone(), + capability: *prepared.capability().as_bytes(), + process: process.reference().clone(), + runtime, + }) + .unwrap(); + files::replace(&locked, StateFile::Journal, &journal).unwrap(); + let before = fs::read(state.path(StateFile::Runtime)).unwrap(); + drop(prepared); + drop(locked); + let locked = state.lock(Deadline::after(10.0)).unwrap(); + assert!(!process.has_exited().unwrap()); + assert_eq!(fs::read(state.path(StateFile::Runtime)).unwrap(), before); + assert_eq!(fs::read(state.path(StateFile::Journal)).unwrap(), journal); + assert!(locked.capability().unwrap().is_some()); + } + + #[test] + #[ignore = "invoked as an owned subprocess by interrupted-launch recovery tests"] + fn launch_helper() { + let endpoint = fs::read_to_string("recovery-endpoint").unwrap(); + #[cfg(target_os = "linux")] + let _server = std::os::unix::net::UnixListener::bind(endpoint).unwrap(); + #[cfg(windows)] + let server = platform::tests::control_server(&endpoint); + fs::write("recovery-ready", b"").unwrap(); + #[cfg(target_os = "linux")] + thread::sleep(Duration::from_secs(30)); + #[cfg(windows)] + { + use std::io::Read; + + loop { + platform::tests::accept_control(&server); + match (&server).read(&mut [0]) { + Ok(0) => {} + Err(error) if error.kind() == std::io::ErrorKind::BrokenPipe => {} + result => panic!("unexpected endpoint probe: {result:?}"), + } + platform::tests::disconnect_control(&server); + } + } + } +} From 573f0badf25cb00b22de85eda4a1b15bc75f9d7e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:49:24 -0800 Subject: [PATCH 13/30] guest: activate repaired init sessions Integrate only the restored activation caller on qualified capture and announcer inputs. Preserve bounded signal-aware control I/O, retain one activation across resets, and keep postactivation captures refused. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dee059e7-7968-43d8-82d0-abd68b922eed --- nvx-rs/README.md | 10 +- nvx-rs/src/guest/init.rs | 30 ++- nvx-rs/src/guest/init/boot.rs | 190 +++++++++++++- nvx-rs/src/guest/init/boot/tests.rs | 213 ++++++++++++++++ nvx-rs/src/guest/init/tests/capture.rs | 45 ++++ nvx-rs/src/guest/managed_agent/dispatch.rs | 28 +- .../src/guest/managed_agent/dispatch/tests.rs | 135 ++++++++++ nvx-rs/src/guest/managed_agent/session.rs | 240 +++++++++++++++--- .../src/guest/managed_agent/session/tests.rs | 232 +++++++++++++++++ nvx-rs/src/protocol/app.rs | 10 +- nvx-rs/src/protocol/mod.rs | 11 +- scripts/test-layout-allowlist.json | 2 +- 12 files changed, 1086 insertions(+), 60 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index cd9c6f7ca..01bce1e2b 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -3193,7 +3193,15 @@ disk's block device. Each binary's command line, exit statuses, and byte output are compatibility contracts; the [`nvx::guest`](src/guest/mod.rs) documentation describes each -program. Guests run them as static `x86_64-unknown-linux-musl` executables. On +program. After a restored platform capture, `nvx-init` keeps one activation +across control-session replacements, with its deadline anchored to the repair +acknowledgement. It applies the restore's root and network, mounts the runtime, +starts and retains an announcer only for published listeners, switches once, +then completes and reports Ready. The host supplies the complete mapping table +before the first valid EXEC starts the restored workload; later executions and +byte-identical activation retries reuse the retained state. + +Guests run them as static `x86_64-unknown-linux-musl` executables. On Linux, with the musl target and a C compiler driver, `cc`, `nvx.py` builds them: ```bash diff --git a/nvx-rs/src/guest/init.rs b/nvx-rs/src/guest/init.rs index 15c760f1a..61d940e9d 100644 --- a/nvx-rs/src/guest/init.rs +++ b/nvx-rs/src/guest/init.rs @@ -58,6 +58,7 @@ use super::sys::{ }; mod boot; +pub(crate) use boot::{RestoreHandoff, Restored}; /// The process ID of the init process. const INIT_PROCESS: i32 = 1; @@ -183,7 +184,7 @@ pub(crate) struct Capture { /// Request ID, kill flag and the original pre-request deadline. pending: Option<(u64, bool, Instant)>, /// The repair whose RESET must be acknowledged before later activation. - repaired: Option, + pub(crate) restored: Option, /// A fatal capture disposition, whose status PID 1 must preserve. shutdown: Option, } @@ -213,14 +214,14 @@ impl Capture { helpers, fresh, pending: None, - repaired: None, + restored: None, shutdown: None, } } /// Keeps one pending request and anchors its deadline before cleanup. pub(crate) fn queue(&mut self, request_id: u64, kill: bool) -> bool { - if self.pending.is_some() || self.repaired.is_some() || self.shutdown.is_some() { + if self.pending.is_some() || self.restored.is_some() || self.shutdown.is_some() { return false; } self.pending = Some((request_id, kill, Instant::now() + CAPTURE_TIMEOUT)); @@ -239,12 +240,14 @@ impl Capture { /// Whether this session must admit no root-dependent operation. pub(crate) fn rootless(&self) -> bool { - self.fresh || self.repaired.is_some() + self.restored.as_ref().map_or(self.fresh, |restored| { + restored.activation.established().is_none() + }) } - /// Keeps the original repair for later activation, which this slice does not perform. + /// Starts one activation from the repair's original acknowledgement. pub(crate) fn restored(&mut self, repaired: Repaired) { - self.repaired = Some(repaired); + self.restored = Some(Restored::new(&repaired, Instant::now())); } /// Records the fatal status before the loop returns directly to PID 1. @@ -309,6 +312,14 @@ pub(crate) fn report_capture(failure: impl std::fmt::Display) { ); } +/// Reports an activation failure before PID 1 ends the VM. +pub(crate) fn report_activation(failure: impl std::fmt::Display) { + write_console( + kernel::CONSOLE_DEVICE, + format!("NVX-INIT-ACTIVATION: {failure}\n").as_bytes(), + ); +} + /// Whether execution requires an account in the shared root's databases. #[cfg_attr( not(test), @@ -996,13 +1007,6 @@ impl Runtime { /// Starts ingress from the applied root's publication and gateway, before its root switch. /// The fork keeps the initramfs root and PID 1's priority; the sole collector owns its exit. - #[cfg_attr( - not(test), - expect( - dead_code, - reason = "SL7.7b starts the restored announcer before the root switch" - ) - )] pub(crate) fn restore_announcer( &mut self, publication: Publication, diff --git a/nvx-rs/src/guest/init/boot.rs b/nvx-rs/src/guest/init/boot.rs index 2e66abc53..62c427890 100644 --- a/nvx-rs/src/guest/init/boot.rs +++ b/nvx-rs/src/guest/init/boot.rs @@ -1,13 +1,17 @@ -//! The fixed cold-boot sequence of the VM's shared layered root. +//! The fixed cold-boot and restored-space setup of the VM's shared layered root. use std::ffi::{CString, OsStr}; use std::net::IpAddr; use std::os::unix::ffi::OsStrExt; use std::path::{Path, PathBuf}; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::activation::{self, View}; use crate::gpt::Expected; +use crate::guest::activation::{ + self as guest_activation, Activation, ActivationAction, ActivationInput, ActivationState, + Applied, RootTemplate, +}; use crate::guest::mappings::{Access, Mapper, MappingTable, Reserved, SourceRoot, TargetRoot}; use crate::guest::root::{self, Assembly, Device, Root, ShareChild, ShareKind}; use crate::guest::sys::FileDescriptor; @@ -323,5 +327,187 @@ fn share_child(value: &[u8]) -> Result { }) } +/// One repaired activation and the space it owns across control epochs. +pub(crate) struct Restored { + pub(crate) activation: Activation, + payload: Option>, + _applied: Option, +} + +/// The account and open mapping handles handed to the shared dispatcher after activation. +pub(crate) struct RestoreHandoff { + pub(crate) workload: protocol::workload::WorkloadIdentity, + pub(crate) user: CString, + pub(crate) home: CString, + pub(crate) hostname: CString, + pub(crate) table: MappingTable, + pub(crate) mapper: Mapper, + pub(crate) export: Option, +} + +impl Restored { + /// Anchors the activation once, even when the next session attaches later. + pub(crate) fn new(repaired: &crate::guest::snapshot::Repaired, now: Instant) -> Self { + Self { + activation: Activation::new(repaired, Duration::from_secs(30), now), + payload: None, + _applied: None, + } + } + + /// Takes the first bootstrap reset or checks the same deadline on a replacement session. + pub(crate) fn reset(&mut self, now: Instant) -> Result<()> { + if self.activation.state() == ActivationState::Bootstrap { + self.activation + .input(now, ActivationInput::Reset) + .map_err(activation_error)?; + } else { + self.activation.tick(now).map_err(activation_error)?; + } + Ok(()) + } + + /// Compares the complete retry payload before delegating record replay to the state machine. + pub(crate) fn accept( + &mut self, + payload: &[u8], + now: Instant, + ) -> Result<(ActivationAction, protocol::workload::WorkloadIdentity)> { + if self + .payload + .as_deref() + .is_some_and(|accepted| accepted != payload) + { + return Err(Error::unsupported( + "the activate request changed after acceptance", + )); + } + let (_, workload) = protocol::decode_activate_payload(payload).map_err(protocol_error)?; + let action = self + .activation + .input(now, ActivationInput::Activate(&payload[9..])) + .map_err(activation_error)?; + if matches!(action, ActivationAction::Apply(_)) { + self.payload = Some(payload.to_vec()); + } + Ok((action, workload)) + } + + /// Applies and switches one restored space, retaining its root and handing over its mapper. + pub(crate) fn apply( + &mut self, + record: &activation::ActivationRecord, + workload: protocol::workload::WorkloadIdentity, + mut announce: impl FnMut(activation::Publication, Option) -> Result<()>, + ) -> Result { + let command_line = std::fs::read("/proc/cmdline").map_err(activation_error)?; + let template = RootTemplate { + runtime: "/run/nvx".into(), + distro: Device { + mmio: DISTRO_MMIO_BASE, + sectors: None, + }, + scratch: Device { + mmio: SCRATCH_MMIO_BASE, + sectors: None, + }, + timeout: Duration::from_secs(30), + filesystem_tag: "microvm".into(), + gateway: cmdline::gateway(&command_line).map_err(protocol_error)?, + }; + let (applied, handoff) = restore_in_order( + self.activation.deadline(), + || { + guest_activation::apply(record, &template, self.activation.deadline()) + .map_err(activation_error) + }, + |applied| { + applied + .root_mut() + .mount_runtime(Some(Path::new("/run/nvx/time"))) + .map_err(activation_error)?; + let mut target = + TargetRoot::open(applied.root().path()).map_err(activation_error)?; + target + .add_mount(&applied.root().path().join("run")) + .map_err(activation_error)?; + let (user, home) = account::resolve( + applied.root().path(), + (workload.uid, workload.gid), + workload.create_account, + )?; + Ok(RestoreHandoff { + workload, + user, + home, + hostname: CString::new(applied.hostname()).map_err(activation_error)?, + table: u32::try_from(record.configuration().mappings) + .ok() + .and_then(MappingTable::new) + .ok_or_else(|| { + Error::unsupported("the restored mapping count is invalid") + })?, + mapper: Mapper::new(target, Reserved::container_root()), + export: applied.take_export(), + }) + }, + |applied| { + if applied.publication() == activation::Publication::Published { + announce(applied.publication(), applied.gateway())?; + } + Ok(()) + }, + |applied| applied.root_mut().switch().map_err(activation_error), + |applied| applied.teardown().map_err(activation_error), + )?; + self._applied = Some(applied); + Ok(handoff) + } +} + +/// Closes prepared handles before tearing down a space whose setup failed. +fn restore_in_order( + deadline: Option, + apply: impl FnOnce() -> Result, + mount_runtime: impl FnOnce(&mut T) -> Result, + announce: impl FnOnce(&T) -> Result<()>, + switch: impl FnOnce(&mut T) -> Result<()>, + teardown: impl FnOnce(T) -> Result<()>, +) -> Result<(T, R)> { + let check = || { + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + Err(Error::io( + "the activation deadline expired before the next space step", + )) + } else { + Ok(()) + } + }; + check()?; + let mut applied = apply()?; + let result = check() + .and_then(|()| mount_runtime(&mut applied)) + .and_then(|retained| { + check()?; + announce(&applied)?; + check()?; + switch(&mut applied)?; + Ok(retained) + }); + match result { + Ok(retained) => Ok((applied, retained)), + Err(error) => match teardown(applied) { + Ok(()) => Err(error), + Err(cleanup) => { + Err(Error::io(format!("{error}; cleanup failed: {cleanup}")).with_source(error)) + } + }, + } +} + +fn activation_error(error: impl std::error::Error + Send + Sync + 'static) -> Error { + Error::io(format!("failed to activate the restored root: {error}")).with_source(error) +} + #[cfg(test)] mod tests; diff --git a/nvx-rs/src/guest/init/boot/tests.rs b/nvx-rs/src/guest/init/boot/tests.rs index 8ad287155..6ec9fef60 100644 --- a/nvx-rs/src/guest/init/boot/tests.rs +++ b/nvx-rs/src/guest/init/boot/tests.rs @@ -9,6 +9,219 @@ const CLI: &str = "nvx_identity=1000:1001 nvx_control_tty=hvc2"; const EXPORT: &str = "virtfs_dir=/run/nvx/hostfs/root virtfs_tag=hostfs virtfs_mode=rw virtfs_aggregate=1"; +#[test] +fn restored_sessions_keep_the_deadline_of_the_actual_repair_acknowledgement() { + let repaired = crate::guest::snapshot::repair::tests::repaired_fixture(); + let acknowledged = repaired.acknowledged_at().unwrap(); + let mut restored = Restored::new(&repaired, acknowledged + Duration::from_secs(12)); + let deadline = acknowledged + Duration::from_secs(30); + assert_eq!(restored.activation.deadline(), Some(deadline)); + restored + .reset(acknowledged + Duration::from_secs(13)) + .unwrap(); + restored + .reset(acknowledged + Duration::from_secs(20)) + .unwrap(); + assert_eq!(restored.activation.deadline(), Some(deadline)); + let (_, payload) = super::super::Capture::activation_fixture(); + assert!(matches!( + restored + .accept(&payload, acknowledged + Duration::from_secs(21)) + .unwrap() + .0, + ActivationAction::Apply(_) + )); + restored + .reset(acknowledged + Duration::from_secs(29)) + .unwrap(); + assert!(matches!( + restored + .accept(&payload, acknowledged + Duration::from_secs(29)) + .unwrap() + .0, + ActivationAction::Join + )); + assert_eq!(restored.activation.deadline(), Some(deadline)); + assert!( + restored + .reset(deadline) + .unwrap_err() + .to_string() + .contains("deadline") + ); + assert_eq!(restored.activation.state(), ActivationState::Failed); +} + +#[test] +fn restored_resource_steps_finish_before_completion_and_ready() { + use crate::activation::{Network, Publication, ReadyActivation}; + use std::cell::RefCell; + let (mut capture, mut payload) = super::super::Capture::activation_fixture(); + let (record, _) = protocol::decode_activate_payload(&payload).unwrap(); + let mut configuration = record.configuration().clone(); + configuration.network = Some(Network { + dns: Some("10.88.0.53".parse().unwrap()), + publication: Publication::Published, + proxy: Some("http://10.88.0.1:3128".into()), + }); + let record = + activation::ActivationRecord::new(record.restore().copied(), configuration).unwrap(); + payload.truncate(9); + payload.extend(record.encode()); + let restored = capture.restored.as_mut().unwrap(); + let (ActivationAction::Apply(accepted), _) = restored.accept(&payload, Instant::now()).unwrap() + else { + panic!("one accepted activation"); + }; + let calls = RefCell::new(Vec::new()); + let (retained, ()) = restore_in_order( + None, + || { + calls.borrow_mut().push("apply"); + Ok(accepted.configuration().clone()) + }, + |space| { + calls.borrow_mut().push("mount-runtime"); + assert_eq!( + space.network.as_ref().unwrap().dns, + Some("10.88.0.53".parse().unwrap()) + ); + assert_eq!( + space.upper, + activation::Upper::Tmpfs { + max_memory_percent: 63 + } + ); + Ok(()) + }, + |space| { + assert_eq!( + space.network.as_ref().unwrap().publication, + Publication::Published + ); + calls.borrow_mut().push("owned-announcer"); + Ok(()) + }, + |_| { + calls.borrow_mut().push("switch"); + Ok(()) + }, + |_| panic!("a successful switched root is retained"), + ) + .unwrap(); + assert_eq!(retained.network, accepted.configuration().network); + let ready = restored + .activation + .complete(Instant::now(), Ok(())) + .unwrap(); + calls.borrow_mut().push("complete"); + assert_eq!(ready, ReadyActivation::of(&accepted)); + calls.borrow_mut().push("ready"); + assert_eq!( + *calls.borrow(), + [ + "apply", + "mount-runtime", + "owned-announcer", + "switch", + "complete", + "ready" + ] + ); + assert!(matches!( + restored.accept(&payload, Instant::now()).unwrap().0, + ActivationAction::Ready(_) + )); +} + +#[test] +fn restored_setup_drops_prepared_handles_before_cleanup_and_preserves_both_errors() { + use std::cell::RefCell; + struct Handle<'a>(&'a RefCell>); + impl Drop for Handle<'_> { + fn drop(&mut self) { + self.0.borrow_mut().push("close-mapper"); + } + } + let calls = RefCell::new(Vec::new()); + let error = restore_in_order( + None, + || { + calls.borrow_mut().push("apply"); + Ok(()) + }, + |_| { + calls.borrow_mut().push("mount-runtime"); + Ok(Handle(&calls)) + }, + |_| { + calls.borrow_mut().push("announcer-failed"); + Err(Error::io("announcer failed")) + }, + |_| panic!("failed setup cannot switch"), + |_| { + calls.borrow_mut().push("teardown"); + Err(Error::io("teardown failed")) + }, + ) + .err() + .unwrap(); + assert_eq!( + *calls.borrow(), + [ + "apply", + "mount-runtime", + "announcer-failed", + "close-mapper", + "teardown" + ] + ); + assert!(error.to_string().contains("announcer failed")); + assert!(error.to_string().contains("teardown failed")); + assert!( + std::error::Error::source(&error) + .unwrap() + .to_string() + .contains("announcer failed") + ); +} + +#[test] +fn an_expired_activation_cannot_begin_or_advance_to_another_space_step() { + assert!( + restore_in_order::<(), ()>( + Some(Instant::now()), + || panic!("an expired activation cannot apply"), + |_| panic!("cannot mount"), + |_| panic!("cannot announce"), + |_| panic!("cannot switch"), + |_| panic!("nothing was applied"), + ) + .is_err() + ); + let deadline = Instant::now() + Duration::from_secs(1); + let applied = std::cell::Cell::new(false); + let cleaned = std::cell::Cell::new(false); + let error = restore_in_order::<(), ()>( + Some(deadline), + || { + applied.set(true); + std::thread::sleep(deadline.saturating_duration_since(Instant::now())); + Ok(()) + }, + |_| panic!("an application that used its deadline cannot mount runtime"), + |_| panic!("cannot announce"), + |_| panic!("cannot switch"), + |_| { + cleaned.set(true); + Ok(()) + }, + ) + .unwrap_err(); + assert_eq!(cleaned.get(), applied.get()); + assert!(error.to_string().contains("deadline")); +} + fn tokens(extra: &str) -> Tokens { Tokens::parse(format!("{ROOT} {VIEW} {CLI} {extra}").as_bytes()).unwrap() } diff --git a/nvx-rs/src/guest/init/tests/capture.rs b/nvx-rs/src/guest/init/tests/capture.rs index 1fddd18c4..f71d1b97e 100644 --- a/nvx-rs/src/guest/init/tests/capture.rs +++ b/nvx-rs/src/guest/init/tests/capture.rs @@ -14,6 +14,51 @@ impl Capture { fresh, ) } + + pub(crate) fn activation_fixture() -> (Self, Vec) { + use crate::activation::{ + ActivationRecord, Configuration, ExpectedResources, Image, MachineIdMode, OverlayMode, + RestoreSection, TransactionId, Upper, View, + }; + let repaired = crate::guest::snapshot::repair::tests::repaired_fixture(); + let record = ActivationRecord::new( + Some(RestoreSection { + transaction: TransactionId::from_bytes([0x22; TransactionId::LEN]), + }), + Configuration { + image: Image::Erofs { uuid: None }, + upper: Upper::Tmpfs { + max_memory_percent: 63, + }, + overlay: OverlayMode::UntrustedLowers, + export: None, + mappings: 0, + network: None, + view: View { + hostname: b"restored".to_vec(), + machine_id: MachineIdMode::Image, + }, + resources: ExpectedResources { + cpus: repaired.cpus(), + memory: repaired.memory(), + }, + }, + ) + .unwrap(); + let mut capture = Self::fixture(true); + capture.restored(repaired); + capture + .restored + .as_mut() + .unwrap() + .reset(Instant::now()) + .unwrap(); + let mut payload = 1000u32.to_le_bytes().to_vec(); + payload.extend_from_slice(&1001u32.to_le_bytes()); + payload.push(1); + payload.extend(record.encode()); + (capture, payload) + } } #[test] diff --git a/nvx-rs/src/guest/managed_agent/dispatch.rs b/nvx-rs/src/guest/managed_agent/dispatch.rs index 53a071d65..ad13b8437 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch.rs @@ -4,8 +4,9 @@ use crate::guest::init::Runtime; use crate::guest::mappings::{Mapper, MappingTable, SourceRoot}; use crate::guest::sys::{self, Errno, FileDescriptor}; use crate::protocol::{ - self, APP_CANCEL, APP_CAPTURE, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, APP_READY, APP_STOP, - APP_STOPPED, GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, + self, APP_ACTIVATE, APP_CANCEL, APP_CAPTURE, APP_EXEC, APP_FEATURES, APP_MAPS, APP_PING, + APP_READY, APP_STOP, APP_STOPPED, GuestFeatures, INVALID_REQUEST, MAPPINGS_INCOMPLETE, + UNSUPPORTED_OPERATION, }; use super::diagnostics; @@ -126,6 +127,7 @@ pub(crate) fn run( Err(error) }; } + let mut restored = None; loop { let payload = match session.next_event() { Ok(Event::Reset) => { @@ -146,7 +148,7 @@ pub(crate) fn run( // more specific stage. Its status is the error that ends the session, which // `control-session` reports too: `EPROTO` where C's `errno` was incidental, as after a // validation failure. - match handle_data(system, session, config, mappings, &payload) { + match handle_data(system, session, config, &mut restored, mappings, &payload) { Ok(Some(outcome)) => return Ok(outcome), Ok(None) => {} Err(_) if session.stopping() => return Ok(Outcome::Stopped), @@ -189,6 +191,7 @@ fn handle_data( system: &mut impl System, session: &mut Session, config: &AgentConfig, + restored: &mut Option, mappings: &mut HostMappings, payload: &[u8], ) -> Result, Errno> { @@ -199,7 +202,7 @@ fn handle_data( return Err(failure.into_error()); } }; - if session.rootless() && !matches!(request.kind, APP_CAPTURE | APP_STOP) { + if session.rootless() && !matches!(request.kind, APP_ACTIVATE | APP_CAPTURE | APP_STOP) { session.send_error( request.request_id, Errno::ENOTSUP as i32, @@ -207,7 +210,23 @@ fn handle_data( )?; return Ok(None); } + let config = restored.as_ref().unwrap_or(config); match request.kind { + APP_ACTIVATE if session.has_restored() => { + if let Some(handoff) = session.activate(request)? { + *mappings = HostMappings::layered(handoff.table, handoff.mapper, handoff.export)?; + *restored = Some(AgentConfig { + rootfs: c"-".to_owned(), + hostname: handoff.hostname, + uid: CString::new(handoff.workload.uid.to_string()).expect("decimal UID"), + gid: CString::new(handoff.workload.gid.to_string()).expect("decimal GID"), + user: handoff.user, + home: handoff.home, + direct: true, + entry: None, + }); + } + } APP_CAPTURE => { if session.queue_capture(request, false)? { return session.finish_capture(); @@ -245,6 +264,7 @@ fn handle_data( return Ok(None); } }; + session.start_activation()?; exec::run(system, session, config, request.request_id, &execution)?; if session.capture_pending() { return session.finish_capture(); diff --git a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs index 9efb42f47..a94523509 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs @@ -440,6 +440,141 @@ fn mappings_mount_in_order_and_unblock_workloads_through_dispatch() { assert_eq!(system.spawn_calls, 0); } +#[test] +fn init_refuses_activation_without_a_restore_and_keeps_serving() { + for fresh in [true, false] { + let config = AgentConfig::for_test(None); + let mut mappings = HostMappings::announced_for(&config, 0); + let mut system = TestSystem::new(Paths::default()); + let (host, guest) = UnixStream::pair().unwrap(); + let thread = thread::spawn(move || { + let mut session = Session::new(FileDescriptor::from_owned(guest.into())) + .with_capture(crate::guest::init::Capture::fixture(fresh)); + let outcome = run(&mut system, &mut session, &config, &mut mappings); + (outcome, system) + }); + let mut harness = Harness { + host, + thread, + epoch: 0, + host_sequence: 0, + guest_sequence: 0, + }; + assert_eq!(harness.read_outer().0.record_type, OUTER_GUEST_ATTACH); + for epoch in [1, 2] { + harness.reset(epoch, 7); + assert_eq!( + harness.request(APP_ACTIVATE, b"misrouted"), + response( + APP_ERROR, + 42, + Errno::ENOTSUP as i32, + b"unsupported-operation" + ) + ); + } + if !fresh { + assert_eq!( + harness.request(APP_PING, &[]), + response(APP_READY, 42, 0, b"") + ); + } + let system = harness.stop(); + assert!(system.diagnostics.is_empty()); + assert_eq!(system.spawn_calls, 0); + } +} + +#[test] +fn restored_ready_maps_and_first_exec_share_the_existing_start_transition() -> TestResult { + use crate::guest::activation::{ActivationAction, ActivationState}; + use crate::guest::init::Capture; + if super::super::tests::isolate_process_test( + "NVX_RESTORED_DISPATCH_TEST", + "guest::managed_agent::dispatch::tests::restored_ready_maps_and_first_exec_share_the_existing_start_transition", + ) { + return Ok(()); + } + let _process = super::super::tests::PROCESS_TEST_LOCK.lock()?; + let (temporary, mut system) = TestSystem::with_host_export(); + system.direct_starter = Some(super::super::seam::tests::start_as_privileged); + let target = temporary.path().canonicalize()?.join("guest"); + let first = maps_payload( + 0, + &[(b"0/src", target.join("src").as_os_str().as_bytes(), 0)], + None, + ); + let second = maps_payload( + 1, + &[(b"1", target.join("out").as_os_str().as_bytes(), 0)], + None, + ); + let config = AgentConfig::for_test(None); + let mut mappings = HostMappings::announced_for(&config, 2); + let (mut capture, payload) = Capture::activation_fixture(); + let activation = capture.restored.as_mut().unwrap(); + assert!(matches!( + activation.accept(&payload, std::time::Instant::now())?.0, + ActivationAction::Apply(_) + )); + let ready = activation + .activation + .complete(std::time::Instant::now(), Ok(()))? + .encode(); + let (host, guest) = UnixStream::pair()?; + let thread = thread::spawn(move || { + let mut session = + Session::new(FileDescriptor::from_owned(guest.into())).with_capture(capture); + let outcome = run(&mut system, &mut session, &config, &mut mappings); + assert_eq!( + session.activation_state_for_test(), + Some(ActivationState::Started) + ); + (outcome, system) + }); + let mut harness = Harness { + host, + thread, + epoch: 0, + host_sequence: 0, + guest_sequence: 0, + }; + assert_eq!(harness.read_outer().0.record_type, OUTER_GUEST_ATTACH); + harness.reset(1, 7); + assert_eq!( + harness.request(APP_ACTIVATE, &payload), + response(APP_READY, 42, 0, &ready) + ); + let execution = workload_payload(&["/bin/sh", "-c", "printf restored"], None)?; + for maps in [&first, &second] { + assert_eq!( + harness.request(APP_EXEC, &execution), + response(APP_ERROR, 42, Errno::EAGAIN as i32, b"mappings-incomplete"), + ); + assert_eq!( + harness.request(APP_MAPS, maps), + response(APP_READY, 42, 0, b"") + ); + } + assert_eq!( + stdout_from(harness.request_all(43, APP_EXEC, &execution), 43, 0), + b"restored" + ); + harness.reset(2, 4); + assert_eq!( + harness.request(APP_ACTIVATE, &payload), + response(APP_READY, 42, 0, &ready) + ); + assert_eq!( + stdout_from(harness.request_all(44, APP_EXEC, &execution), 44, 0), + b"restored" + ); + let system = harness.stop(); + assert!(system.diagnostics.is_empty()); + assert_eq!(system.kernel.moves().len(), 2); + Ok(()) +} + #[test] fn mapping_sequence_errors_are_reported_through_dispatch() { let (temporary, system) = TestSystem::with_host_export(); diff --git a/nvx-rs/src/guest/managed_agent/session.rs b/nvx-rs/src/guest/managed_agent/session.rs index 2256e29fe..c4d618ed0 100644 --- a/nvx-rs/src/guest/managed_agent/session.rs +++ b/nvx-rs/src/guest/managed_agent/session.rs @@ -1,8 +1,10 @@ use std::cell::{Cell, RefCell}; use std::io::{self, Read, Write}; use std::process::ExitStatus; +use std::time::{Duration, Instant}; -use crate::guest::init::{self, Capture, CaptureStep, Runtime}; +use crate::guest::activation::{ActivationAction, ActivationState}; +use crate::guest::init::{self, Capture, CaptureStep, RestoreHandoff, Restored, Runtime}; use crate::guest::nvxs::{GuestConnection, GuestConnectionError}; use crate::guest::snapshot::{Captured, Disposition}; use crate::guest::sys::{self, Errno, FileDescriptor, Pollable}; @@ -28,9 +30,23 @@ struct Control<'r> { leader: Cell, status: Cell>, outcome: RefCell>, + /// Bounds partial I/O until the restored activation's Ready has been written. + deadline: Cell>, } impl Control<'_> { + fn remaining(&self) -> Result, Errno> { + self.deadline + .get() + .map(|deadline| { + deadline + .checked_duration_since(Instant::now()) + .filter(|remaining| !remaining.is_zero()) + .ok_or(Errno::ETIMEDOUT) + }) + .transpose() + } + fn service_leader(&self) -> Result<(), Errno> { if self.service(|id, status| { // Keep the first leader status across partial I/O, not unrelated orphans. @@ -65,24 +81,32 @@ impl Read for Control<'_> { if bytes.is_empty() { return Read::read(&mut self.descriptor, bytes); } - let Some(runtime) = &self.runtime else { + if self.runtime.is_none() && self.deadline.get().is_none() { return Read::read(&mut self.descriptor, bytes); - }; + } loop { - self.service_leader()?; - let signals = sys::poll( - [Pollable::File(&self.descriptor), runtime.borrow().wake()], - None, - )?[1]; - if signals.error { - return Err(Errno::EIO.into()); + if self.runtime.is_some() { + self.service_leader()?; } - if !signals.readable { - // A control-only wake includes HUP, which PollEvents does not expose. - match self.descriptor.read(bytes) { - Err(Errno::EAGAIN) => {} - result => return result.map_err(io::Error::from), + let timeout = self.remaining()?; + if let Some(runtime) = &self.runtime { + let signals = sys::poll( + [Pollable::File(&self.descriptor), runtime.borrow().wake()], + timeout, + )?[1]; + if signals.error { + return Err(Errno::EIO.into()); } + if signals.readable { + continue; + } + } else { + sys::poll([Pollable::File(&self.descriptor)], timeout)?; + } + self.remaining()?; + match self.descriptor.read(bytes) { + Err(Errno::EAGAIN) => {} + result => return result.map_err(io::Error::from), } } } @@ -90,31 +114,42 @@ impl Read for Control<'_> { impl Write for Control<'_> { fn write(&mut self, bytes: &[u8]) -> io::Result { - let Some(runtime) = &self.runtime else { + if self.runtime.is_none() && self.deadline.get().is_none() { return Write::write(&mut self.descriptor, bytes); - }; + } if bytes.is_empty() { return Write::write(&mut self.descriptor, bytes); } loop { - self.service_leader()?; + self.remaining()?; + if self.runtime.is_some() { + self.service_leader()?; + } match self.descriptor.write(bytes) { - Err(Errno::EAGAIN) => loop { - let events = sys::poll( - [ - Pollable::Writable(&self.descriptor), - runtime.borrow().wake(), - ], - None, - )?; - if events[1].error { - return Err(Errno::EIO.into()); - } - self.service_leader()?; - if events[0].writable || !events[1].readable { - break; + Err(Errno::EAGAIN) => { + if let Some(runtime) = &self.runtime { + loop { + let events = sys::poll( + [ + Pollable::Writable(&self.descriptor), + runtime.borrow().wake(), + ], + self.remaining()?, + )?; + self.remaining()?; + if events[1].error { + return Err(Errno::EIO.into()); + } + self.service_leader()?; + if events[0].writable || !events[1].readable { + break; + } + } + } else { + sys::poll([Pollable::Writable(&self.descriptor)], self.remaining()?)?; + self.remaining()?; } - }, + } result => return result.map_err(io::Error::from), } } @@ -206,6 +241,7 @@ impl<'r> Session<'r> { leader: Cell::new(0), status: Cell::new(None), outcome: RefCell::new(Ok(false)), + deadline: Cell::new(None), }), capture: None, last_error: None, @@ -223,6 +259,12 @@ impl<'r> Session<'r> { self.capture.is_some() } + pub(crate) fn has_restored(&self) -> bool { + self.capture + .as_ref() + .is_some_and(|capture| capture.restored.is_some()) + } + /// Whether root-dependent requests must remain refused. pub(crate) fn rootless(&self) -> bool { self.capture.as_ref().is_some_and(Capture::rootless) @@ -314,8 +356,17 @@ impl<'r> Session<'r> { } Ok(Captured::Restored(repaired)) => { capture.restored(repaired); + let restored = capture.restored.as_mut().expect("the repair was retained"); + self.descriptor().set_nonblocking()?; + self.connection + .get_ref() + .deadline + .set(restored.activation.deadline()); match self.next_event() { - Ok(Event::Reset) => self.acknowledge()?, + Ok(Event::Reset) => { + restored.reset(Instant::now()).map_err(activation_failure)?; + self.acknowledge()?; + } Ok(Event::Data(_)) => return Err(Errno::EPROTO), Err(failure) => { return Err(super::dispatch::report_failure_for_capture(failure)); @@ -342,6 +393,117 @@ impl<'r> Session<'r> { result } + /// Runs init's restored space setup or returns its cached activation result. + pub(crate) fn activate( + &mut self, + request: protocol::AppFrame<'_>, + ) -> Result, Errno> { + self.activate_with(request, |restored, session, record, workload| { + let runtime = session + .connection + .get_ref() + .runtime + .as_ref() + .ok_or_else(|| crate::Error::unsupported("activation needs the init runtime"))?; + restored.apply(record, workload, |publication, gateway| { + runtime.borrow_mut().restore_announcer(publication, gateway) + }) + }) + } + + /// Keeps one application and its result while replacement sessions join before Ready. + fn activate_with( + &mut self, + request: protocol::AppFrame<'_>, + apply: impl FnOnce( + &mut Restored, + &mut Self, + &crate::activation::ActivationRecord, + protocol::workload::WorkloadIdentity, + ) -> crate::Result, + ) -> Result, Errno> { + let mut capture = self.capture.take().ok_or(Errno::ENOTSUP)?; + let result = (|| { + let restored = capture.restored.as_mut().ok_or(Errno::EPROTO)?; + let (action, workload) = restored + .accept(request.payload, Instant::now()) + .map_err(activation_failure)?; + let (retained, ready, replies) = match action { + ActivationAction::Apply(record) => { + let retained = match apply(restored, self, &record, workload) { + Ok(retained) => retained, + Err(error) => { + init::report_activation(&error); + return Err(activation_failure( + restored + .activation + .complete(Instant::now(), Err(error.into())) + .expect_err("a failed application cannot become ready"), + )); + } + }; + let mut replies = vec![request.request_id]; + while sys::readable_or_hung_up(&[self.descriptor().borrowed()], 0)?[0] { + match self + .next_event() + .map_err(super::dispatch::report_failure_for_capture)? + { + Event::Reset => { + restored.reset(Instant::now()).map_err(activation_failure)?; + self.acknowledge()?; + replies.clear(); + } + Event::Data(bytes) => { + let retry = self + .accept_request(&bytes) + .map_err(DataFailure::into_error)?; + if retry.kind != protocol::APP_ACTIVATE + || !matches!( + restored + .accept(retry.payload, Instant::now()) + .map_err(activation_failure)? + .0, + ActivationAction::Join + ) + { + return Err(Errno::EPROTO); + } + replies.push(retry.request_id); + } + } + } + let ready = restored + .activation + .complete(Instant::now(), Ok(())) + .map_err(activation_failure)?; + (Some(retained), ready, replies) + } + ActivationAction::Ready(ready) => (None, ready, vec![request.request_id]), + _ => return Err(Errno::EPROTO), + }; + for id in replies { + self.send_app(protocol::APP_READY, id, 0, &ready.encode())?; + } + self.connection.get_ref().deadline.set(None); + Ok(retained) + })(); + self.capture = Some(capture); + result + } + + /// Takes the first valid mapped EXEC as the start transition, never starting twice. + pub(crate) fn start_activation(&mut self) -> Result<(), Errno> { + if let Some(restored) = self + .capture + .as_mut() + .and_then(|capture| capture.restored.as_mut()) + && restored.activation.state() != ActivationState::Started + { + restored.activation.start().map_err(activation_failure)?; + } + Ok(()) + } + /// Borrows init's sole collector for the lifetime of this control session. pub(crate) fn with_runtime( descriptor: FileDescriptor, @@ -419,6 +581,11 @@ impl<'r> Session<'r> { Ok(GuestEvent::Reset(_)) => { if let Some(capture) = &mut self.capture { capture.reset(); + if let Some(restored) = &mut capture.restored { + restored + .reset(Instant::now()) + .map_err(|error| ReceiveFailure::Read(activation_failure(error)))?; + } } Ok(Event::Reset) } @@ -517,6 +684,11 @@ impl<'r> Session<'r> { } } +fn activation_failure(error: impl std::fmt::Display) -> Errno { + init::report_activation(error); + Errno::EPROTO +} + /// Returns the stage at which the C agent reported a record that the session refused, or `None` /// for one that it reported as a failure to read. /// diff --git a/nvx-rs/src/guest/managed_agent/session/tests.rs b/nvx-rs/src/guest/managed_agent/session/tests.rs index c91d12028..14e4722b4 100644 --- a/nvx-rs/src/guest/managed_agent/session/tests.rs +++ b/nvx-rs/src/guest/managed_agent/session/tests.rs @@ -12,6 +12,16 @@ use crate::protocol::{ const INSTANCE: [u8; 16] = [7; 16]; +impl Session<'_> { + pub(crate) fn activation_state_for_test(&self) -> Option { + self.capture + .as_ref()? + .restored + .as_ref() + .map(|state| state.activation.state()) + } +} + struct Peer { stream: UnixStream, epoch: u64, @@ -20,6 +30,15 @@ struct Peer { } impl Peer { + fn reset(&mut self, epoch: u64, sequence: u64) { + self.stream + .write_all(&encode_outer(OUTER_RESET, &INSTANCE, epoch, sequence, &[]).unwrap()) + .unwrap(); + self.epoch = epoch; + self.host_sequence = sequence + 1; + self.guest_sequence = 0; + } + fn read(&mut self) -> (OuterHeader, Vec) { let mut bytes = [0; OUTER_HEADER_LEN]; self.stream.read_exact(&mut bytes).unwrap(); @@ -95,6 +114,219 @@ fn connected(fresh: bool) -> (Session<'static>, Peer) { (session, peer) } +fn restored() -> (Session<'static>, Peer, Vec) { + let (mut session, peer) = connected(true); + let (capture, payload) = Capture::activation_fixture(); + let deadline = capture.restored.as_ref().unwrap().activation.deadline(); + session.capture = Some(capture); + session.descriptor().set_nonblocking().unwrap(); + session.connection.get_ref().deadline.set(deadline); + (session, peer, payload) +} + +fn activate_bytes(session: &mut Session<'_>, peer: &mut Peer, id: u64, payload: &[u8]) -> Vec { + peer.send(protocol::APP_ACTIVATE, id, payload); + let Event::Data(bytes) = session + .next_event() + .unwrap_or_else(|_| panic!("activate DATA")) + else { + panic!("the activate is DATA"); + }; + bytes +} + +#[test] +fn a_replacement_session_joins_one_activation_and_caches_ready_without_reapplying() { + let (mut session, mut peer, payload) = restored(); + let deadline = session.connection.get_ref().deadline.get(); + let (record, _) = protocol::decode_activate_payload(&payload).unwrap(); + let ready = crate::activation::ReadyActivation::of(&record).encode(); + let bytes = activate_bytes(&mut session, &mut peer, 61, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid activate")); + peer.credit(); + let retained = session + .activate_with(request, |restored, _, accepted, workload| { + assert_eq!(restored.activation.state(), ActivationState::Activating); + assert_eq!(restored.activation.deadline(), deadline); + assert_eq!(accepted, &record); + assert_eq!( + (workload.uid, workload.gid, workload.create_account), + (1000, 1001, true) + ); + peer.reset(2, 4); + peer.send(protocol::APP_ACTIVATE, 62, &payload); + Ok("one applied space") + }) + .unwrap(); + assert_eq!(retained, Some("one applied space")); + assert_eq!(peer.read().0.record_type, OUTER_ACK); + peer.credit(); + peer.app(APP_READY, 62, 0, &ready); + assert!(!session.rootless()); + assert_eq!(session.connection.get_ref().deadline.get(), None); + let bytes = activate_bytes(&mut session, &mut peer, 63, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid retry")); + peer.credit(); + assert!( + session + .activate_with::<()>(request, |_, _, _, _| panic!( + "must not apply or switch twice" + )) + .unwrap() + .is_none() + ); + peer.app(APP_READY, 63, 0, &ready); + session.start_activation().unwrap(); + session.start_activation().unwrap(); + assert_eq!( + session.activation_state_for_test(), + Some(ActivationState::Started) + ); +} + +#[test] +fn postactivation_capture_stays_refused_without_pending_cleanup() { + let (mut session, mut peer, payload) = restored(); + let state = session.capture.as_mut().unwrap().restored.as_mut().unwrap(); + assert!(matches!( + state.accept(&payload, Instant::now()).unwrap().0, + ActivationAction::Apply(_) + )); + state.activation.complete(Instant::now(), Ok(())).unwrap(); + session.start_activation().unwrap(); + for kill in [0, 1] { + assert!(!queue(&mut session, &mut peer, 71, &[kill], false)); + assert!(!session.capture_pending()); + assert!(session.finish_capture().unwrap().is_none()); + peer.app( + APP_ERROR, + 71, + Errno::ENOTSUP as i32, + b"unsupported-operation", + ); + } +} + +#[test] +fn changed_record_or_workload_bytes_during_application_fail_without_ready() { + for changed_identity in [false, true] { + let (mut session, mut peer, payload) = restored(); + let mut changed = payload.clone(); + if changed_identity { + changed[0] ^= 1; + } else { + let (record, _) = protocol::decode_activate_payload(&payload).unwrap(); + let mut configuration = record.configuration().clone(); + configuration.view.hostname = b"changed".to_vec(); + let record = + crate::activation::ActivationRecord::new(record.restore().copied(), configuration) + .unwrap(); + changed.truncate(9); + changed.extend(record.encode()); + } + let bytes = activate_bytes(&mut session, &mut peer, 64, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid activate")); + peer.credit(); + let result = session.activate_with(request, |_, _, _, _| { + peer.send(protocol::APP_ACTIVATE, 65, &changed); + Ok(()) + }); + assert_eq!(result.err(), Some(Errno::EPROTO)); + peer.credit(); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + assert!(session.rootless()); + } +} + +#[test] +fn failed_application_never_reports_ready_or_admits_root_dependent_work() { + let (mut session, mut peer, payload) = restored(); + let bytes = activate_bytes(&mut session, &mut peer, 66, &payload); + let request = session + .accept_request(&bytes) + .unwrap_or_else(|_| panic!("valid activate")); + peer.credit(); + assert_eq!( + session + .activate_with::<()>(request, |_, _, _, _| Err(crate::Error::io( + "runtime mounts failed" + ))) + .err(), + Some(Errno::EPROTO) + ); + assert!(session.rootless()); + assert_eq!( + session.activation_state_for_test(), + Some(ActivationState::Failed) + ); + peer.stream.set_nonblocking(true).unwrap(); + assert_eq!( + peer.stream.read(&mut [0]).unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); +} + +fn short_deadline(session: &mut Session<'_>) -> Instant { + let now = Instant::now(); + let repaired = crate::guest::snapshot::repair::tests::repaired_fixture(); + let state = session.capture.as_mut().unwrap().restored.as_mut().unwrap(); + state.activation = crate::guest::activation::Activation::with_resources( + crate::activation::ExpectedResources { + cpus: repaired.cpus(), + memory: repaired.memory(), + }, + Some(now - Duration::from_millis(100)), + Duration::from_millis(300), + now, + ); + state.reset(now).unwrap(); + let deadline = state.activation.deadline().unwrap(); + session.connection.get_ref().deadline.set(Some(deadline)); + deadline +} + +#[test] +fn reconnect_and_partial_input_wait_only_until_the_original_activation_deadline() { + let (mut session, mut peer, _) = restored(); + let deadline = short_deadline(&mut session); + peer.reset(2, 4); + assert!(matches!(session.next_event(), Ok(Event::Reset))); + session.acknowledge().unwrap(); + assert_eq!(peer.read().0.record_type, OUTER_ACK); + assert_eq!(session.connection.get_ref().deadline.get(), Some(deadline)); + let partial = encode_outer(OUTER_DATA, &INSTANCE, 2, 5, &[0; 24]).unwrap(); + peer.stream.write_all(&partial[..11]).unwrap(); + assert!(matches!( + session.next_event(), + Err(ReceiveFailure::Read(Errno::ETIMEDOUT)) + )); + assert!(Instant::now() >= deadline); +} + +#[test] +fn an_unread_output_stream_cannot_extend_the_activation_deadline() { + let (mut session, _peer, _) = restored(); + let deadline = short_deadline(&mut session); + let payload = vec![0; OUTER_MAX_PAYLOAD - protocol::APP_HEADER_LEN]; + let error = loop { + if let Err(error) = session.send_app(APP_READY, 67, 0, &payload) { + break error; + } + }; + assert_eq!(error, Errno::ETIMEDOUT); + assert!(Instant::now() >= deadline); +} + fn queue( session: &mut Session<'_>, peer: &mut Peer, diff --git a/nvx-rs/src/protocol/app.rs b/nvx-rs/src/protocol/app.rs index c6645486f..2feb2c3ff 100644 --- a/nvx-rs/src/protocol/app.rs +++ b/nvx-rs/src/protocol/app.rs @@ -62,7 +62,10 @@ pub const APP_MAPS: u8 = 6; pub(crate) const APP_CAPTURE: u8 = 7; /// Request that nvx-init activate a restored VM with its record and workload identity. #[cfg_attr( - not(test), + not(any( + test, + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect( dead_code, reason = "SL4.3 sends activation requests to nvx-init in SL7.7b" @@ -204,7 +207,10 @@ pub(crate) fn encode_activate_payload( /// Decodes the persistent workload identity and the activation record that follows it. #[cfg_attr( - not(test), + not(any( + test, + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + )), expect(dead_code, reason = "SL7.7b makes nvx-init decode activation requests") )] pub(crate) fn decode_activate_payload( diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 907ff555c..c66dfd348 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -61,16 +61,21 @@ pub mod view; all(feature = "guest", target_os = "linux", target_arch = "x86_64") ))] pub(crate) use app::APP_CAPTURE; +#[cfg(feature = "host")] +pub(crate) use app::encode_capture_payload; +#[cfg(any( + feature = "host", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use app::workload; #[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] -pub(crate) use app::decode_capture_payload; +pub(crate) use app::{APP_ACTIVATE, decode_activate_payload, decode_capture_payload}; pub use app::{ APP_CANCEL, APP_ERROR, APP_EXEC, APP_EXIT, APP_FEATURES, APP_HEADER_LEN, APP_MAGIC, APP_MAPS, APP_PING, APP_READY, APP_STDERR, APP_STDOUT, APP_STOP, APP_STOPPED, APP_VERSION, AppFrame, BUSY, CWD_FAILED, ExitCategory, INVALID_REQUEST, LAUNCH_FAILED, MAPPING_FAILED, MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, decode_app, encode_app, }; -#[cfg(feature = "host")] -pub(crate) use app::{encode_capture_payload, workload}; pub use boot::{MAPS_TOKEN, WORKLOAD_ACCOUNT_TOKEN, parse_host_mapping_count}; pub use exec::{ EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_EXTENDED, EXEC_INHERIT_DEFAULT_ENV, diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index ca8ea1991..5b7e6aa10 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -5,7 +5,7 @@ "nvx-rs/src/guest/cgroup.rs": 2, "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, - "nvx-rs/src/guest/init.rs": 8, + "nvx-rs/src/guest/init.rs": 7, "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, "nvx-rs/src/guest/sys/entry.rs": 3, From 93bd4d55ac4fc17e43aaea1bc45e61ab77a1f8cb Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:52:51 -0800 Subject: [PATCH 14/30] cli: preserve unrecorded run artifacts and planned-share runtime coverage Do not require install provenance or hash an override for a cold run that neither captures nor reattaches. Restore the public artifact identity implementation and remove its unused private extension and fixtures. Exercise metadata-free dry runs and overrides. Update the real comma-path guest scenario for planned aggregate binds, including all-read-only root remounts while retaining host write-denial checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/artifacts/dir.rs | 46 +++++-------------- nvx-rs/src/host/artifacts/tests.rs | 25 ---------- nvx-rs/src/host/cli/run.rs | 40 +++++----------- nvx-rs/tests/cli/dry_run.rs | 4 ++ nvx-rs/tests/cli/harness.rs | 10 +--- nvx-rs/tests/cli/one_shot.rs | 5 +- .../filesystem-comma-paths.sh.in | 19 +++----- scripts/nvx_tools/microvm_tests.py | 8 ++-- scripts/test_microvm_tests.py | 6 ++- 9 files changed, 49 insertions(+), 114 deletions(-) diff --git a/nvx-rs/src/host/artifacts/dir.rs b/nvx-rs/src/host/artifacts/dir.rs index 08d80a541..dddab5c11 100644 --- a/nvx-rs/src/host/artifacts/dir.rs +++ b/nvx-rs/src/host/artifacts/dir.rs @@ -235,13 +235,8 @@ impl ArtifactDir { /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a /// regular file, such as a FIFO, is refused without being waited on. pub fn runtime_identity(&self) -> Result { - self.runtime_identity_for(Guest::Alpine) - } - - /// Identifies the selected guest through its install metadata, without reading its image. - pub(crate) fn runtime_identity_for(&self, guest: Guest) -> Result { let [openvmm, kernel, initramfs] = match self.layout { - Layout::Release => self.listed_identities(guest)?, + Layout::Release => self.listed_identities()?, Layout::Repository => [ provenance_identity( &self.artifact(OPENVMM_PROVENANCE_NAME), @@ -253,33 +248,11 @@ impl ArtifactDir { "kernel", "kernel_sha256", )?, - if guest == Guest::Alpine { - provenance_identity( - &self.artifact(INITRAMFS_PROVENANCE_NAME), - "initramfs", - "initramfs_sha256", - )? - } else { - let path = self.package_manifest(guest); - let text = read_metadata( - &path, - "guest package manifest", - "build or install the selected guest", - )?; - let invalid = || { - Error::unavailable(format!( - "guest package manifest {} has no valid artifact SHA-256 digest", - path.display() - )) - }; - let manifest: serde_json::Value = serde_json::from_str(&text) - .map_err(|error| invalid().with_source(error))?; - manifest - .get("artifact_sha256") - .and_then(serde_json::Value::as_str) - .and_then(sha256_identity) - .ok_or_else(invalid)? - }, + provenance_identity( + &self.artifact(INITRAMFS_PROVENANCE_NAME), + "initramfs", + "initramfs_sha256", + )?, ], }; Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) @@ -288,7 +261,7 @@ impl ArtifactDir { /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 /// lowercase hexadecimal digits, two spaces, and its path in the release. - fn listed_identities(&self, guest: Guest) -> Result<[ArtifactIdentity; 3]> { + fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { let path = self.root.join(CHECKSUMS_NAME); let description = "release checksum list"; let text = read_metadata(&path, description, "the release is incomplete")?; @@ -322,7 +295,10 @@ impl ArtifactDir { Ok([ listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, - listed(format!("{RELEASE_GUEST_DIR}/{}", guest.initramfs_name()))?, + listed(format!( + "{RELEASE_GUEST_DIR}/{}", + Guest::Alpine.initramfs_name() + ))?, ]) } } diff --git a/nvx-rs/src/host/artifacts/tests.rs b/nvx-rs/src/host/artifacts/tests.rs index 8d0b14be1..3e4de6351 100644 --- a/nvx-rs/src/host/artifacts/tests.rs +++ b/nvx-rs/src/host/artifacts/tests.rs @@ -574,31 +574,6 @@ fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() ); } -#[test] -fn selected_guests_take_their_own_initramfs_digest_from_install_metadata() { - let directory = tempfile::tempdir().unwrap(); - let dir = ArtifactDir::repository(directory.path()).unwrap(); - let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); - record_provenance(directory.path(), &digests); - for guest in [Guest::Ubuntu, Guest::AzureLinux] { - let selected = "d".repeat(64); - let manifest = dir.package_manifest(guest); - fs::write(&manifest, format!("{{\"artifact_sha256\":\"{selected}\"}}")).unwrap(); - let identity = dir.runtime_identity_for(guest).unwrap(); - assert_eq!( - identity.initramfs, - ArtifactIdentity::sha256(&selected).unwrap() - ); - assert_eq!(identity.openvmm, identity_of(&digests).openvmm); - fs::write(&manifest, "{\"artifact_sha256\":\"invalid\"}").unwrap(); - assert_eq!( - dir.runtime_identity_for(guest).unwrap_err().kind(), - ErrorKind::Unavailable - ); - } - assert_eq!(dir.runtime_identity().unwrap(), identity_of(&digests)); -} - #[test] fn a_release_identifies_its_runtime_by_its_checksum_list() { let directory = tempfile::tempdir().unwrap(); diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 914d2e587..0dd64df23 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -52,8 +52,6 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; -use sha2::{Digest, Sha256}; - use super::args::{Parser, Receiver, Spec, Syntax, choice, integer}; use super::artifacts::{Located, locate}; use super::cpu_profile::{self, HostCpu}; @@ -74,7 +72,7 @@ use crate::host::filesystem::{ self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, }; use crate::host::platform::{self, Transport}; -use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; use crate::host::sandbox::mount::{ AGGREGATE_MOUNT_TARGET, MountAccess, MountKind, MountOwner, POLICY_KINDS, PolicyKind, aggregate_child_argument, aggregate_child_name, aggregate_command_line_fragment, @@ -898,40 +896,26 @@ fn run_cold( { validate_outcome_destination(report)?; } - let mut identity = dir - .runtime_identity_for(options.guest) - .map_err(error_with_cause)?; - if openvmm != dir.openvmm() { - let mut file = File::open(&openvmm).map_err(Exit::from_os_error)?; - let mut digest = Sha256::new(); - let mut buffer = [0; 8192]; - loop { - let count = file.read(&mut buffer).map_err(Exit::from_os_error)?; - if count == 0 { - break; - } - digest.update(&buffer[..count]); - } - identity.openvmm = ArtifactIdentity::sha256(&format!("{:x}", digest.finalize()))?; - } + let mut random = [0; 16]; + getrandom::fill(&mut random).map_err(|error| { + Exit::error( + ErrorCode::Io, + format!("cannot name the temporary VM: {error}"), + ) + })?; + let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + // Cold CLI runs neither capture nor reattach; this per-invocation identity is not persisted. + let identity = ArtifactIdentity::new(format!("unrecorded-run:{name}"))?; let runtime = Runtime { openvmm, kernel, initramfs, - identity, + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), hypervisor: hypervisor(options.hypervisor).parse()?, cpu_profile: options.cpu_profile.clone(), windows_breakaway: false, options: LaunchOptions::default(), }; - let mut random = [0; 16]; - getrandom::fill(&mut random).map_err(|error| { - Exit::error( - ErrorCode::Io, - format!("cannot name the temporary VM: {error}"), - ) - })?; - let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); let path = std::env::temp_dir().join(format!("nvx-run-{name}")); if path.try_exists().map_err(Exit::from_os_error)? { return Err(Exit::error( diff --git a/nvx-rs/tests/cli/dry_run.rs b/nvx-rs/tests/cli/dry_run.rs index ec2c6984d..104482699 100644 --- a/nvx-rs/tests/cli/dry_run.rs +++ b/nvx-rs/tests/cli/dry_run.rs @@ -423,6 +423,10 @@ fn run_dry_runs() -> Vec { let fits = "x".repeat(crate::openvmm::two_share_command_line_room()); vec![ dry_run("run: Alpine default", run(&[]), cold_boot()), + dry_run("run: no install metadata", run(&[]), cold_boot()) + .without("build/openvmm.provenance.json") + .without("build/vmlinux.provenance.json") + .without("build/initramfs.provenance.json"), dry_run( "run: Ubuntu default", run(&["--guest", "ubuntu"]), diff --git a/nvx-rs/tests/cli/harness.rs b/nvx-rs/tests/cli/harness.rs index acdd23e19..1470a1cf5 100644 --- a/nvx-rs/tests/cli/harness.rs +++ b/nvx-rs/tests/cli/harness.rs @@ -90,17 +90,9 @@ pub(crate) const PROVENANCE: [(&str, &str, &str); 3] = [ /// The other files of a root, by their paths below it, and their contents: the guest kernel and /// Alpine initramfs of a checkout, the layers of the sandboxes, of which only the distro layer /// has a role, their scratch image, and the directories and the file that they share. -const FILES: [(&str, &str); 14] = [ +const FILES: [(&str, &str); 12] = [ ("build/vmlinux", "kernel\n"), ("build/initramfs.cpio.gz", "initramfs\n"), - ( - "build/initramfs-ubuntu.cpio.gz.packages.json", - "{\"artifact_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}\n", - ), - ( - "build/initramfs-azurelinux.cpio.gz.packages.json", - "{\"artifact_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\"}\n", - ), ("distro.erofs", "distro\n"), ("runtime.erofs", "runtime\n"), ("custom.erofs", "custom\n"), diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index b14be2d20..7723f5ac6 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -670,7 +670,10 @@ fn run_launches() -> Vec { 0, booted, ) - .with_variable("NVX_OPENVMM", native(OPENVMM)), + .with_variable("NVX_OPENVMM", native(OPENVMM)) + .without("build/openvmm.provenance.json") + .without("build/vmlinux.provenance.json") + .without("build/initramfs.provenance.json"), ] } diff --git a/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in b/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in index 8468e6a6a..96aee1e1c 100644 --- a/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in +++ b/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in @@ -16,14 +16,10 @@ expect_read_only() { esac } -# A lone share is the root of the virtio-fs device, and several are the -# children of an aggregate, which the guest binds at their targets. -aggregate=@AGGREGATE@ -if [ "$aggregate" = 1 ]; then - grep -q 'virtfs_dir=/run/nvx/shares virtfs_tag=microvm virtfs_mode=rw virtfs_aggregate=1' /proc/cmdline || fail 10 -elif grep -q 'virtfs_aggregate=' /proc/cmdline; then - fail 11 -fi +# The checked export plan always uses an aggregate, even for one bind. +aggregate=/run/nvx/hostfs/root +export_mode=@EXPORT_MODE@ +grep -q "virtfs_dir=$aggregate virtfs_tag=microvm virtfs_mode=$export_mode virtfs_aggregate=1" /proc/cmdline || fail 10 # Each TARGET:MODE:SEED names a share's guest target, its mode, and the # contents of its seed file. @@ -51,11 +47,10 @@ for share in @SHARES@; do expect_read_only "$error" $((code + 6)) # OpenVMM enforces the mode on the host, so the share refuses writes # even after guest root makes its mount read-write. - if [ "$aggregate" = 1 ]; then - mount -o remount,bind,rw "$target" || fail $((code + 7)) - else - mount -o remount,rw "$target" || fail $((code + 7)) + if [ "$export_mode" = ro ]; then + mount -o remount,rw "$aggregate" || fail $((code + 7)) fi + mount -o remount,bind,rw "$target" || fail $((code + 7)) grep -q "^microvm $target virtiofs rw," /proc/mounts || fail $((code + 8)) if error=$(touch "$target/from-guest" 2>&1); then fail $((code + 9)) diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index 41fec1495..c4ace8282 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -9985,8 +9985,8 @@ def run_filesystem_comma_paths( ) -> None: """Share host directories whose paths contain commas through `nvx-rs run`: a read-write `alpha,beta` and a read-only `2026, final`, each with a - denied subdirectory, first each alone, as OpenVMM's `--mount`, and then - both, as the children of an aggregate. The guest must find each exact + denied subdirectory, first each alone and then both through the checked + export plan's aggregate and guest binds. The guest must find each exact directory, rather than the one beside it that the text before the last comma of its path names, with its mode, which OpenVMM enforces, and without its denied subdirectory.""" @@ -10031,7 +10031,9 @@ def run_filesystem_comma_paths( command, _render_script( "filesystem-comma-paths.sh.in", - AGGREGATE="1" if len(attached) > 1 else "0", + EXPORT_MODE=( + "rw" if any(shares[share][1] == "rw" for share in attached) else "ro" + ), SHARES=" ".join(":".join(shares[share]) for share in attached), ), FILESYSTEM_COMMA_PATHS_MARKER, diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index aeeb7f3a2..1ab994326 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -3909,7 +3909,11 @@ def test_scenario_shares_each_directory_alone_and_both_together(self): self.assertEqual((host.parent.name, host.name), (parent, name)) self.assertEqual(Path(deny), host / "secret") self.assertIn(f"{target}:{mode}:{seed}", script) - self.assertIn(f"\naggregate={int(len(shares) > 1)}\n", script) + export_mode = "rw" if any(share[3] == "rw" for share in shares) else "ro" + self.assertIn("\naggregate=/run/nvx/hostfs/root\n", script) + self.assertIn(f"\nexport_mode={export_mode}\n", script) + self.assertIn('mount -o remount,rw "$aggregate"', script) + self.assertIn('mount -o remount,bind,rw "$target"', script) self.assertNotIn("@", script) def test_scenario_rejects_a_write_to_the_read_only_directory(self): From 356eb21e4d5e3145fe2f6e3b6683bea9b6b1d1e2 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:58:57 -0800 Subject: [PATCH 15/30] style: format planned-share scenario export mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- scripts/nvx_tools/microvm_tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index c4ace8282..e4dc5e1e8 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -10032,7 +10032,9 @@ def run_filesystem_comma_paths( _render_script( "filesystem-comma-paths.sh.in", EXPORT_MODE=( - "rw" if any(shares[share][1] == "rw" for share in attached) else "ro" + "rw" + if any(shares[share][1] == "rw" for share in attached) + else "ro" ), SHARES=" ".join(":".join(shares[share]) for share in attached), ), From 8921c45162e38761f1dc5beb51042ee2e7218803 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 05:14:02 -0800 Subject: [PATCH 16/30] test: reconnect the owned Windows recovery control fixture Port the required single-instance named-pipe disconnect helper and CLI-gated test re-exports. Repeated endpoint-owner verification must reconnect after each finished client; no production API or assertion changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/platform/tests.rs | 2 ++ nvx-rs/src/host/platform/windows/endpoint/tests.rs | 13 +++++++++++++ nvx-rs/src/host/platform/windows/tests.rs | 2 ++ 3 files changed, 17 insertions(+) diff --git a/nvx-rs/src/host/platform/tests.rs b/nvx-rs/src/host/platform/tests.rs index be5b7967a..37392e1ba 100644 --- a/nvx-rs/src/host/platform/tests.rs +++ b/nvx-rs/src/host/platform/tests.rs @@ -2,6 +2,8 @@ pub(crate) use super::linux::tests::{ check_identity, owner_only_problem, start_time_from_stat, store_dir_problem, }; +#[cfg(all(windows, feature = "cli"))] +pub(crate) use super::windows::tests::disconnect_control; #[cfg(windows)] pub(crate) use super::windows::tests::set_access; #[cfg(windows)] diff --git a/nvx-rs/src/host/platform/windows/endpoint/tests.rs b/nvx-rs/src/host/platform/windows/endpoint/tests.rs index 42e283d69..4d882574a 100644 --- a/nvx-rs/src/host/platform/windows/endpoint/tests.rs +++ b/nvx-rs/src/host/platform/windows/endpoint/tests.rs @@ -36,6 +36,19 @@ pub(crate) fn accept_control(server: &File) -> i32 { connected } +#[cfg(feature = "cli")] +pub(crate) fn disconnect_control(server: &File) { + use std::os::windows::io::{AsHandle, AsRawHandle}; + + use windows_sys::Win32::System::Pipes::DisconnectNamedPipe; + + // SAFETY: the owned synchronous pipe finished its previous client. + assert_ne!( + unsafe { DisconnectNamedPipe(server.as_handle().as_raw_handle()) }, + 0 + ); +} + #[test] fn a_duplicated_transport_keeps_the_verified_connection_without_reconnecting() { let endpoint = format!( diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 7da84f781..a3aa27b6e 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -20,6 +20,8 @@ use windows_sys::Win32::System::Threading::{ PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, TerminateProcess, }; +#[cfg(feature = "cli")] +pub(crate) use super::endpoint::tests::disconnect_control; pub(crate) use super::endpoint::tests::{accept_control, control_server}; use super::identity::status_error; use super::log_claim::{local_directory, log_job_id, log_job_name, log_job_processes, object_name}; From 67bb715081715ac612bb8949bccd0fdd40969b19 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 05:32:29 -0800 Subject: [PATCH 17/30] test: align download cleanup with restored cold-run fixtures Remove the two no-longer-installed guest metadata entries from the fixture cleanup list, restoring this file exactly to the qualified base. Keep all six download test bodies and assertions unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/tests/cli/download.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index 9a041cedb..f9d758cea 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -10,15 +10,13 @@ use std::time::{Duration, Instant}; use crate::harness::{LAYER, NVX_RS, OPENVMM, Root, SCRATCH, UUID}; /// The files of a test root that a release installs. -const INSTALLED: [&str; 8] = [ +const INSTALLED: [&str; 6] = [ OPENVMM, "build/vmlinux", "build/initramfs.cpio.gz", "build/openvmm.provenance.json", "build/vmlinux.provenance.json", "build/initramfs.provenance.json", - "build/initramfs-ubuntu.cpio.gz.packages.json", - "build/initramfs-azurelinux.cpio.gz.packages.json", ]; /// Returns a root that is a checkout of NVX, with its `SOURCE-MANIFEST.json` and a `.git` that is From 206b3f15da77eb60fc2d7d3d3d82edd39d6d8138 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 06:03:02 -0800 Subject: [PATCH 18/30] test(nvx-rs): exercise restored init with native readiness Bind the established private restore-ready listener in a scoped in-crate live fixture. Keep production APIs and guards unchanged; exercise real activation, MAPS/EXEC, replay, helper publication and the original deadline under explicit host admission. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: dee059e7-7968-43d8-82d0-abd68b922eed --- nvx-rs/src/host/restore/tests.rs | 2 + nvx-rs/src/host/restore/tests/live_init.rs | 516 +++++++++++++++++++++ 2 files changed, 518 insertions(+) create mode 100644 nvx-rs/src/host/restore/tests/live_init.rs diff --git a/nvx-rs/src/host/restore/tests.rs b/nvx-rs/src/host/restore/tests.rs index b302590b0..c840fd558 100644 --- a/nvx-rs/src/host/restore/tests.rs +++ b/nvx-rs/src/host/restore/tests.rs @@ -1,6 +1,8 @@ #![cfg(any(target_os = "linux", windows))] #![cfg_attr(windows, allow(unsafe_code))] +mod live_init; + use std::io::{BufRead, BufReader, Write}; use std::process::{Child, Command, Stdio}; use std::time::Instant; diff --git a/nvx-rs/src/host/restore/tests/live_init.rs b/nvx-rs/src/host/restore/tests/live_init.rs new file mode 100644 index 000000000..c8e33508d --- /dev/null +++ b/nvx-rs/src/host/restore/tests/live_init.rs @@ -0,0 +1,516 @@ +use std::error::Error; +use std::ffi::OsString; +use std::fs::{self, OpenOptions}; +use std::io; +use std::net::{SocketAddr, TcpListener}; +use std::path::{Path, PathBuf}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::Hypervisor; +use crate::activation::{ + ActivationRecord, Configuration, ExpectedResources, Export, ExportKind, Image, MachineIdMode, + Network, OverlayMode, Publication, ReadyActivation, RestoreSection, TransactionId, Upper, View, +}; +use crate::host::instance::generate_capability; +use crate::host::nvxs::HostConnection; +use crate::host::session; +use crate::host::supervisor::{Launched, VmmCommand, spawn}; +use crate::host::template::TemplateManifest; +use crate::protocol::{ + APP_ERROR, APP_EXEC, APP_EXIT, APP_MAPS, APP_READY, APP_STDERR, APP_STDOUT, APP_STOP, + APP_STOPPED, HostMap, Workload, WorkloadEnvironment, decode_app, encode_app, + encode_exec_payload, encode_maps_payloads, +}; + +type AnyError = Box; +const ACTIVATE: u8 = 8; +const CAPTURE: u8 = 7; + +struct OwnedVm(Option); +impl Drop for OwnedVm { + fn drop(&mut self) { + if let Some(vm) = self.0.take() { + if vm + .process() + .and_then(|process| process.has_exited()) + .is_ok_and(|done| done) + { + vm.detach(); + } else if !vm.kill_and_wait() { + eprintln!("OWNED_RESTORE_TEARDOWN_FAILED"); + } + } + } +} + +fn require(ok: bool, message: impl Into) -> Result<(), AnyError> { + if ok { + Ok(()) + } else { + Err(message.into().into()) + } +} + +fn backend() -> Hypervisor { + if cfg!(windows) { + Hypervisor::Whp + } else { + Hypervisor::Mshv + } +} + +fn private_directory(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + fs::DirBuilder::new().mode(0o700).create(path) + } + #[cfg(windows)] + { + fs::create_dir(path) + } +} + +fn ready( + connection: &mut HostConnection, + id: u64, + expected: &[u8], + by: Instant, +) -> Result<(), AnyError> { + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + (frame.kind, frame.request_id, frame.status, frame.payload) == (APP_READY, id, 0, expected), + format!("unexpected Ready: {frame:?}"), + ) +} + +fn workload(script: &str) -> Result, AnyError> { + Ok(encode_exec_payload(&Workload { + argv: vec!["/bin/sh".into(), "-eu".into(), "-c".into(), script.into()], + timeout_ms: 10_000, + cwd: Some("/"), + environment: WorkloadEnvironment::Default, + })?) +} + +fn execute( + connection: &mut HostConnection, + id: u64, + script: &str, + by: Instant, +) -> Result { + connection.send(&encode_app(APP_EXEC, id, 0, &workload(script)?), Some(by))?; + let mut output = Vec::new(); + loop { + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.request_id == id, + format!("wrong execution response: {frame:?}"), + )?; + match frame.kind { + APP_STDOUT => output.extend_from_slice(frame.payload), + APP_STDERR => eprint!("{}", String::from_utf8_lossy(frame.payload)), + APP_EXIT => { + require( + frame.status == 0 && frame.payload == b"exit", + format!("execution failed: {frame:?}"), + )?; + return Ok(String::from_utf8(output)?); + } + _ => return Err(format!("unexpected execution frame: {frame:?}").into()), + } + } +} + +fn run() -> Result<(), AnyError> { + let grant = std::env::var("NVX_SL77B_LIVE_GRANT")?; + require( + !grant.is_empty(), + "live fixture requires explicit named host admission", + )?; + let mut args = vec![OsString::from("nvx-live-restored-init")]; + for key in [ + "NVX_SL77B_LIVE_NATIVE", + "NVX_SL77B_LIVE_SNAPSHOT", + "NVX_SL77B_LIVE_DISTRO", + "NVX_SL77B_LIVE_CONSOLE", + "NVX_SL77B_LIVE_ROOT", + "NVX_SL77B_LIVE_RESULT", + "NVX_SL77B_LIVE_CASE", + ] { + args.push( + std::env::var_os(key).ok_or_else(|| format!("missing live fixture input {key}"))?, + ); + } + require( + args.len() == 8, + "usage: restore VMM SNAPSHOT DISTRO ORIGINAL_CONSOLE ROOT RESULT CASE", + )?; + let vmm = PathBuf::from(&args[1]); + let snapshot = PathBuf::from(&args[2]); + let distro = PathBuf::from(&args[3]); + let original_console = PathBuf::from(&args[4]); + let root = PathBuf::from(&args[5]); + let result = PathBuf::from(&args[6]); + let case = args[7].to_str().ok_or("non-Unicode case")?; + require( + matches!(case, "published" | "unpublished" | "changed" | "deadline"), + "invalid fixture case", + )?; + for path in [&vmm, &snapshot, &distro, &original_console, &root, &result] { + require(path.is_absolute(), "all fixture paths must be absolute")?; + } + let manifest = TemplateManifest::read(&snapshot)?; + require( + manifest.hypervisor() == backend() && manifest.memory_len() == 128 << 20, + "wrong backend/memory snapshot", + )?; + require( + !root.exists() && !result.exists(), + "preserve existing fixture effects", + )?; + private_directory(&root)?; + let export = root.join("export"); + fs::create_dir(&export)?; + fs::write(export.join("proof"), b"CURRENT_573F_HOST_MAP\n")?; + let endpoint = if cfg!(windows) { + format!( + "//./pipe/openvmm-microvm-sl77b-live-{}-{case}", + std::process::id() + ) + } else { + snapshot + .parent() + .ok_or("snapshot has no private namespace parent")? + .join(format!("nvxs-{case}")) + .to_str() + .ok_or("non-Unicode endpoint")? + .to_owned() + }; + let address: SocketAddr = fs::read_to_string(&original_console)?.trim().parse()?; + require(address.ip().is_loopback(), "console must remain loopback")?; + let listener = TcpListener::bind(address)?; + listener.set_nonblocking(true)?; + let console = root.join("restored-console.log"); + let console_output = console.clone(); + let reader = thread::spawn(move || -> io::Result<()> { + let by = Instant::now() + Duration::from_secs(30); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock && Instant::now() < by => { + thread::sleep(Duration::from_millis(25)); + } + Err(error) => return Err(error), + } + }; + stream.set_nonblocking(false)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(console_output)?; + io::copy(&mut stream, &mut output)?; + Ok(()) + }); + let report = root.join("outcome.json"); + let (_temporary, directory) = super::vm_directory(); + let mut listener = super::ReadyListener::bind(&directory)?; + let acknowledgement = listener.endpoint().to_owned(); + let log_file = root.join("openvmm.log"); + let log = OpenOptions::new() + .write(true) + .create_new(true) + .open(&log_file)?; + let options: Vec = vec![ + "--machine".into(), + "microvm".into(), + "--microvm-sandbox-block".into(), + format!("distro:file:{},ro", distro.display()).into(), + "--restore-absent-scratch".into(), + "--single-process".into(), + "--hypervisor".into(), + if cfg!(windows) { "whp" } else { "mshv" }.into(), + "--cpu-profile".into(), + "auto".into(), + "--processors".into(), + "1".into(), + "--restore-snapshot".into(), + snapshot.as_os_str().to_owned(), + "--restore-memory".into(), + "128M".into(), + "--restore-ready-path".into(), + acknowledgement.into(), + "--restore-network-policy".into(), + "--network-profile".into(), + "portable".into(), + "--network-egress".into(), + "deny".into(), + "--network-ingress".into(), + "deny".into(), + "--host-loopback".into(), + "deny".into(), + "--mount-aggregate".into(), + "/run/nvx/hostfs/root".into(), + "--mount-child".into(), + format!("0,{},ro", export.display()).into(), + "--virtio-console".into(), + format!("connect=tcp:{address}").into(), + "--microvm-control-console".into(), + format!("listen={endpoint}").into(), + "--microvm-control-auth-stdin".into(), + "--microvm-report".into(), + report.as_os_str().to_owned(), + ]; + let capability = generate_capability()?; + let command = VmmCommand::new(vmm, options, &root); + let mut owned = OwnedVm(Some(spawn(&command, &capability, log)?)); + let running = owned.0.as_ref().ok_or("missing owned VM")?; + let process = running.process()?; + let pid = running.id(); + fs::write( + root.join("process.txt"), + format!( + "pid={pid}\nreference={:?}\ncase={case}\n", + process.reference() + ), + )?; + println!( + "OWNED_RESTORE_PID={pid} REFERENCE={:?} CASE={case}", + process.reference() + ); + let by = Instant::now() + Duration::from_secs(90); + let expected_event = b"OPENVMM_RESTORE_READY_V1\n"; + let mut event = Vec::new(); + while event.len() < expected_event.len() { + require(Instant::now() < by, "native readiness deadline expired")?; + let open = listener.receive(&process, &mut event, 64, Duration::from_millis(50))?; + require( + open || event.len() == expected_event.len(), + "native readiness closed before its event", + )?; + } + require( + event == expected_event, + "native restore-ready event differs", + )?; + super::parse_ready_event(&event)?; + fs::write(root.join("native-ready.receipt"), &event)?; + println!("NATIVE_READY_AUTHENTICATED_PID={pid}"); + let native_ready = Instant::now(); + if case == "deadline" { + thread::sleep(Duration::from_secs(8)); + } + let transport = session::connect(&endpoint, pid, process.reference().start_time, by)?; + let mut connection = HostConnection::attach(transport, &capability, by)?; + println!( + "RESTORED_HOST_SESSION_EPOCH={}", + connection.identity().epoch + ); + let attached = Instant::now(); + let expected_status; + if case == "deadline" { + require( + process.wait(Instant::now() + Duration::from_secs(40))?, + "activation deadline did not end VM", + )?; + require( + native_ready.elapsed() >= Duration::from_secs(27) + && native_ready.elapsed() < Duration::from_secs(38) + && attached.elapsed() < Duration::from_secs(27), + "activation deadline renewed at the deliberately delayed host attachment", + )?; + println!( + "ORIGINAL_ACK_DEADLINE_ELAPSED_MS={}", + native_ready.elapsed().as_millis() + ); + expected_status = 125; + } else { + let published = case != "unpublished"; + let transaction = TransactionId::from_bytes([0x77; 16]); + let record = ActivationRecord::new( + Some(RestoreSection { transaction }), + Configuration { + image: Image::Erofs { uuid: None }, + upper: Upper::Tmpfs { + max_memory_percent: 50, + }, + overlay: OverlayMode::UntrustedLowers, + export: Some(Export { + kind: ExportKind::Aggregate, + }), + mappings: 1, + network: Some(Network { + dns: Some("10.88.0.53".parse()?), + publication: if published { + Publication::Published + } else { + Publication::Unpublished + }, + proxy: None, + }), + view: View { + hostname: b"restored-573f".to_vec(), + machine_id: MachineIdMode::Generated, + }, + resources: ExpectedResources { + cpus: 1, + memory: 128 << 20, + }, + }, + )?; + let mut activation = Vec::new(); + activation.extend_from_slice(&1000u32.to_le_bytes()); + activation.extend_from_slice(&1001u32.to_le_bytes()); + activation.push(1); + activation.extend(record.encode()); + fs::write(root.join("activate.bin"), &activation)?; + let expected_ready = ReadyActivation { + transaction: Some(transaction), + } + .encode(); + connection.send(&encode_app(ACTIVATE, 100, 0, &activation), Some(by))?; + ready(&mut connection, 100, &expected_ready, by)?; + println!("ACTIVATION_READY_PUBLICATION={published} OWN_DNS=10.88.0.53"); + if case == "changed" { + activation[0] ^= 1; + connection.send(&encode_app(ACTIVATE, 101, 0, &activation), Some(by))?; + require( + process.wait(Instant::now() + Duration::from_secs(15))?, + "changed identity replay did not end VM", + )?; + expected_status = 125; + println!("CHANGED_IDENTITY_REPLAY_FATAL"); + } else { + connection.send(&encode_app(APP_EXEC, 102, 0, &workload("true")?), Some(by))?; + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.kind == APP_ERROR + && frame.status == 11 + && frame.payload == b"mappings-incomplete", + format!("EXEC bypassed MAPS gate: {frame:?}"), + )?; + let maps = [HostMap { + source: "0".into(), + target: "/mnt/restore-map".into(), + read_only: true, + }]; + for payload in encode_maps_payloads(&maps, 32768)? { + connection.send(&encode_app(APP_MAPS, 103, 0, &payload), Some(by))?; + ready(&mut connection, 103, &[], by)?; + } + let script = format!( + r#" +test "$(id -u)" = 1000 +test "$(id -g)" = 1001 +test "$(hostname)" = restored-573f +grep -q 'nameserver 10.88.0.53' /etc/resolv.conf +test "$(cat /mnt/restore-map/proof)" = CURRENT_573F_HOST_MAP +test ! -e /sbin/nvx-ingress +count=0 +for p in /proc/[0-9]*; do + test "$(cat "$p/comm" 2>/dev/null || true)" = nvx-ingress || continue + count=$((count + 1)) + grep -q '^PPid:[[:space:]]*1$' "$p/status" + test "$(cat "$p/oom_score_adj")" = -1000 + test "$(tr '\000' ' ' <"$p/cmdline")" = 'nvx-ingress 10.88.0.1 ' + test "$(awk '{{print $19}}' "$p/stat")" = -10 + printf 'REAL_RESTORED_ANNOUNCER pid=%s argv=' "${{p##*/}}" + tr '\000' ' ' <"$p/cmdline" + printf '\n' +done +test "$count" = {} +printf 'PRESERVED_573F_UPPER\n' >/tmp/restore-sentinel +printf 'MACHINE_ID=' +cat /etc/machine-id +printf '\n' +printf 'REAL_READY_MAPS_EXEC_OK\n' +"#, + u8::from(published) + ); + let first = execute(&mut connection, 104, &script, by)?; + print!("{first}"); + let machine = first + .lines() + .find(|line| line.starts_with("MACHINE_ID=")) + .ok_or("missing generated machine ID")? + .to_owned(); + for kill in [0, 1] { + connection.send( + &encode_app(CAPTURE, 105 + u64::from(kill), 0, &[kill]), + Some(by), + )?; + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.kind == APP_ERROR && frame.status == 95, + "activated restore accepted capture", + )?; + } + drop(connection); + let transport = session::connect(&endpoint, pid, process.reference().start_time, by)?; + connection = HostConnection::attach(transport, &capability, by)?; + connection.send(&encode_app(ACTIVATE, 107, 0, &activation), Some(by))?; + ready(&mut connection, 107, &expected_ready, by)?; + let replay = execute( + &mut connection, + 108, + r#" +test "$(cat /tmp/restore-sentinel)" = PRESERVED_573F_UPPER +test "$(cat /mnt/restore-map/proof)" = CURRENT_573F_HOST_MAP +printf 'MACHINE_ID=' +cat /etc/machine-id +printf '\n' +printf 'RECONNECT_REPLAY_PRESERVED_UPPER_AND_MAPS\n' +"#, + by, + )?; + require( + replay.lines().any(|line| line == machine), + "retry regenerated the machine ID", + )?; + print!("{replay}"); + connection.send(&encode_app(APP_STOP, 109, 0, &[]), Some(by))?; + let bytes = connection.receive(Some(by))?; + let frame = decode_app(&bytes)?; + require( + frame.kind == APP_STOPPED && frame.status == 0, + "STOP not acknowledged", + )?; + require( + process.wait(Instant::now() + Duration::from_secs(30))?, + "STOP did not tear down restore", + )?; + expected_status = 0; + } + } + drop(connection); + reader.join().map_err(|_| "console reader panicked")??; + let outcome = crate::host::report::read(&report).ok_or("missing native outcome")?; + require( + outcome.status_code == Some(expected_status) && outcome.teardown_complete == Some(true), + format!("wrong restore outcome: {outcome:?}"), + )?; + let logs = fs::read_to_string(&console)?; + let switches = logs.matches("NVX-INIT-ROOT: switched").count(); + require( + switches == usize::from(case != "deadline"), + format!("unexpected root switch count {switches}"), + )?; + let text = format!( + "RESTORE_OK case={case} pid={pid} status={expected_status} switches={switches} teardown=true\n" + ); + fs::write(result, &text)?; + print!("{text}"); + owned.0.take().ok_or("missing owned restore")?.detach(); + Ok(()) +} + +#[test] +#[ignore = "requires explicit named host admission and current-source live restore fixture inputs"] +fn live_restored_init_activation() -> Result<(), AnyError> { + run() +} From df508adf18a4b9f5a7228af7d0722773e24ef4ff Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 03:58:14 -0800 Subject: [PATCH 19/30] cli: run cold blockless VMs through the verified lifecycle Implement issue1109 SL3.4a2 on qualified SL2.10 adeb1d47, without replaying the donor's old stop-producer history. Keep restore and restore-network-policy on their existing path. Render CLI-owned guest tokens from the planned export, preserve cold foreground streams, terminal mode, exact exit status and outcome publication, and keep ordinary library spawns detached. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- THIRD_PARTY_NOTICES.md | 14 +- doc/usage.md | 15 +- guest/common/nvx-hostmount | 17 +- nvx-rs/Cargo.lock | 114 ++++ nvx-rs/Cargo.toml | 3 +- nvx-rs/public-api/linux.txt | 1 + nvx-rs/public-api/windows.txt | 1 + nvx-rs/src/host/artifacts/dir.rs | 46 +- nvx-rs/src/host/artifacts/tests.rs | 25 + nvx-rs/src/host/cli/run.rs | 639 ++++++++++++++++-- nvx-rs/src/host/cli/run/guest_tokens.rs | 86 +++ nvx-rs/src/host/cli/run/guest_tokens/tests.rs | 78 +++ nvx-rs/src/host/cli/run/tests.rs | 77 ++- nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs | 4 +- .../src/host/cli/sandbox/lifecycle/outcome.rs | 44 +- nvx-rs/src/host/cli/sandbox/mod.rs | 5 +- nvx-rs/src/host/cli/sandbox/options.rs | 7 +- nvx-rs/src/host/config/mod.rs | 13 + nvx-rs/src/host/config/tests.rs | 35 + nvx-rs/src/host/platform/linux/endpoint.rs | 9 +- nvx-rs/src/host/platform/linux/mod.rs | 2 + nvx-rs/src/host/platform/windows/endpoint.rs | 5 + nvx-rs/src/host/platform/windows/mod.rs | 4 +- nvx-rs/src/host/platform/windows/spawn.rs | 35 +- nvx-rs/src/host/platform/windows/tests.rs | 48 +- nvx-rs/src/host/spawn.rs | 31 +- nvx-rs/src/host/vm.rs | 56 +- nvx-rs/src/host/vm/tests.rs | 43 ++ nvx-rs/src/testing/fake_openvmm.rs | 201 +++++- nvx-rs/src/testing/fake_openvmm/tests.rs | 2 +- nvx-rs/tests/cli.rs | 28 +- nvx-rs/tests/cli/download.rs | 4 +- nvx-rs/tests/cli/dry_run.rs | 214 +++++- nvx-rs/tests/cli/harness.rs | 10 +- nvx-rs/tests/cli/one_shot.rs | 406 ++++++++++- nvx-rs/tests/cli/openvmm.rs | 350 +++++++++- nvx-rs/tests/cli/validation.rs | 27 +- 37 files changed, 2472 insertions(+), 227 deletions(-) create mode 100644 nvx-rs/src/host/cli/run/guest_tokens.rs create mode 100644 nvx-rs/src/host/cli/run/guest_tokens/tests.rs diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 322214b80..6091d4321 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -14,7 +14,7 @@ the distribution tooling. | Rust `libc` crate 0.2.189 | linked into the guest binaries and the Linux `bin/nvx-rs` through `nix` and `getrandom` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are at [`rust-lang/libc` tag 0.2.189](https://github.com/rust-lang/libc/tree/0.2.189). | | Rust `nix` crate 0.31.3 | system-call wrappers used by the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`nix-rust/nix`](https://crates.io/crates/nix/0.31.3). | | Rust `getrandom` crate 0.4.3 | random bytes for the guest binaries, such as `nvx-reseed --sample`, and for `bin/nvx-rs[.exe]`, such as control capabilities | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-random/getrandom`](https://crates.io/crates/getrandom/0.4.3). | -| Rust `bitflags` crate 2.13.2 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`bitflags/bitflags`](https://crates.io/crates/bitflags/2.13.2). | +| Rust `bitflags` crate 2.13.2 | transitive `nix` dependency in the guest binaries and `nix` or `crossterm` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`bitflags/bitflags`](https://crates.io/crates/bitflags/2.13.2). | | Rust `cfg-if` crate 1.0.5 | transitive `nix`, `getrandom`, and `sha2` dependency in the guest binaries and `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`rust-lang/cfg-if`](https://crates.io/crates/cfg-if/1.0.5). | | Rust `memoffset` crate 0.9.1 | transitive `nix` dependency in the guest binaries and the Linux `bin/nvx-rs` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`Gilnaa/memoffset`](https://crates.io/crates/memoffset/0.9.1). | | Rust `serde` crate 1.0.229 | serialization framework of `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`serde-rs/serde`](https://crates.io/crates/serde/1.0.229). | @@ -37,6 +37,18 @@ the distribution tooling. | Rust `typenum` crate 1.20.1 | transitive `sha2` dependency in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`paholg/typenum`](https://crates.io/crates/typenum/1.20.1). | | Rust `windows-sys` crate 0.61.2 | Windows API bindings of `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-sys/0.61.2). | | Rust `windows-link` crate 0.2.1 | transitive `windows-sys` dependency in `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and the source is published by [`microsoft/windows-rs`](https://crates.io/crates/windows-link/0.2.1). | +| Rust `crossterm` crate 0.29.0 | terminal-mode preservation in `bin/nvx-rs[.exe]` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`crossterm`](https://crates.io/crates/crossterm/0.29.0). | +| Rust `crossterm_winapi` crate 0.9.1 | transitive `crossterm` Windows console support in `bin/nvx-rs.exe` | MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`crossterm_winapi`](https://crates.io/crates/crossterm_winapi/0.9.1). | +| Rust `winapi` crate 0.3.9 | transitive `crossterm` and `crossterm_winapi` Windows API bindings in `bin/nvx-rs.exe` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`winapi`](https://crates.io/crates/winapi/0.3.9). | +| Rust `parking_lot` crate 0.12.5 | transitive `crossterm` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`parking_lot`](https://crates.io/crates/parking_lot/0.12.5). | +| Rust `parking_lot_core` crate 0.9.12 | transitive `parking_lot` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`parking_lot_core`](https://crates.io/crates/parking_lot_core/0.9.12). | +| Rust `lock_api` crate 0.4.14 | transitive `parking_lot` synchronization in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`lock_api`](https://crates.io/crates/lock_api/0.4.14). | +| Rust `scopeguard` crate 1.2.0 | transitive `lock_api` cleanup guards in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`scopeguard`](https://crates.io/crates/scopeguard/1.2.0). | +| Rust `smallvec` crate 1.16.5 | transitive `parking_lot_core` storage in `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`smallvec`](https://crates.io/crates/smallvec/1.16.5). | +| Rust `document-features` crate 0.2.12 | build-time procedural macro used by `crossterm`, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`document-features`](https://crates.io/crates/document-features/0.2.12). | +| Rust `litrs` crate 1.0.1 | build-time `document-features` dependency, not linked into `bin/nvx-rs[.exe]` | MIT OR Apache-2.0; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`litrs`](https://crates.io/crates/litrs/1.0.1). | +| Rust `rustix` crate 1.1.5 | transitive `crossterm` terminal-mode support in the Linux `bin/nvx-rs` | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`rustix`](https://crates.io/crates/rustix/1.1.5). | +| Rust `linux-raw-sys` crate 0.12.1 | transitive `rustix` Linux API bindings in the Linux `bin/nvx-rs` | Apache-2.0 WITH LLVM-exception OR Apache-2.0 OR MIT; the exact crate checksum is pinned in `nvx-rs/Cargo.lock`, and source and license files are published with [`linux-raw-sys`](https://crates.io/crates/linux-raw-sys/0.12.1). | | musl libc | statically linked into `/sbin/nvx-console-pending`, `/sbin/nvx-mmio-write`, `/sbin/nvx-reseed`, `/sbin/nvx-exit`, `/sbin/nvx-device-io`, `/sbin/nvx-managed-agent`, `/sbin/nvx-ingress`, `/sbin/nvx-time`, `/sbin/nvx-time-probe`, and `/sbin/nvx-init` in every generated initramfs, and into `bin/nvx-rs` of the `linux-kvm` and `linux-mshv` release packages | MIT; Copyright © 2005-2020 Rich Felker, et al. The exact implementation is supplied by the pinned Rust release's `x86_64-unknown-linux-musl` target. Upstream source and the full copyright notice are at [musl libc](https://musl.libc.org/). | | LLVM `libunwind` | statically linked, with musl, into the ten guest binaries and the Linux `bin/nvx-rs`, which unwind and print backtraces through it | Apache-2.0 WITH LLVM-exception, whose exception lifts the attribution conditions for code embedded in a compiled program. The exact implementation is the `libunwind.a` that the pinned Rust release's `x86_64-unknown-linux-musl` target supplies, which the Rust project builds from its fork of [`llvm-project/libunwind`](https://github.com/llvm/llvm-project/tree/main/libunwind). | | Microsoft C runtime: the Visual C++ runtime and the Universal C Runtime | statically linked into `bin/nvx-rs.exe` of the `windows-whp` release package, which CI builds with `-C target-feature=+crt-static`, so that it runs without the Visual C++ Redistributable | Proprietary Microsoft object code, linked in by the Visual Studio build tools and the Windows SDK of CI's Windows runner under their Microsoft Software License Terms, which permit distributing the programs built with them; see [Redistribute Visual C++ files](https://learn.microsoft.com/cpp/windows/redistributing-visual-cpp-files). No source is distributed. | diff --git a/doc/usage.md b/doc/usage.md index 12eb06dd9..7b819b422 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -164,7 +164,7 @@ nvx-rs/target/release/nvx-rs run | `--memory-capacity-mib MIB` | none | Reserve an immutable, 128 MiB-aligned RAM capacity for a fresh microVM snapshot, in the range of `--memory-mib`. | | `--processors {1,2,4,8}` | `1` | Select the microVM processor count, written as one of the choices. | | `--cpu-profile ID` | `auto` | Select the guest's [CPU profile](#cpu-profiles): `auto` for the built-in profile of the host's CPU, which falls back with a warning to a development profile derived from this host on an Intel or AMD CPU that no built-in profile serves, a built-in profile ID, or `host` for that development profile. A restore uses the snapshot's profile, which `auto` and, for a host profile, `host` also name. | -| `--mount GUEST_TARGET,HOST_PATH[,ro\|rw]` | none | Expose a host directory, or a single regular host file without its directory, at the guest target: an absolute, canonical path other than `/`, without whitespace, control characters, double quotes, backslashes, `=`, or any UTF-8 byte that Linux treats as whitespace, such as the `0xa0` in `à`. The mode follows the last comma, so `HOST_PATH` may contain commas when the mode is given. Repeat to expose more directories or files, each with its own target and mode, as the children of an aggregate on the microVM's one virtio-fs device, which the guest binds at their targets; a shared file always uses the aggregate. Targets and host paths must not overlap, and the shares' kernel command-line tokens must fit its 1024-byte budget. An `rw` mapping accepts guest-created symbolic links, which the host never follows. Active snapshot restore requires the same mappings in the same order, with the same canonical paths, kinds, targets, modes, and ownership mode; a dormant-slot restore may attach one new directory mapping that the resumed guest mounts explicitly. | +| `--mount GUEST_TARGET,HOST_PATH[,ro\|rw]` | none | Expose a host directory, or a single regular host file without its directory, at the guest target: an absolute, canonical path other than `/`, without whitespace, control characters, double quotes, backslashes, `=`, or any UTF-8 byte that Linux treats as whitespace, such as the `0xa0` in `à`. Cold boots also protect the system paths listed below. The mode follows the last comma, so `HOST_PATH` may contain commas when the mode is given. Repeat to expose more directories or files, each with its own target and mode, as the children of an aggregate on the microVM's one virtio-fs device, which the guest binds at their targets; a shared file always uses the aggregate. Targets and host paths must not overlap, and the shares' kernel command-line tokens must fit its 1024-byte budget. An `rw` mapping accepts guest-created symbolic links, which the host never follows. Active snapshot restore requires the same mappings in the same order, with the same canonical paths, kinds, targets, modes, and ownership mode; a dormant-slot restore may attach one new directory mapping that the resumed guest mounts explicitly. | | `--mount-deny HOST_PATH` | none | Hide one existing file or directory inside a mounted host root; repeat to deny multiple paths. It may name the root itself when `--mount-allow` paths inside it remain. With several mappings, or a shared file, the path must be absolute; a shared file takes none. | | `--mount-allow HOST_PATH` | none | Expose one existing file or directory inside a `--mount-deny` path again; the hidden directories on the way list only the entries that lead to it. Repeat to allow multiple paths. With several mappings, or a shared file, the path must be absolute; a shared file takes none. See [Access policy](run.md#access-policy). | | `--mount-write HOST_PATH` | none | Make one existing file or directory one of the only writable parts of an `rw` mapping; the guest's writes anywhere else in it fail with `EROFS`. Repeat to declare multiple writable paths. With several mappings, or a shared file, the path must be absolute; a shared file takes none. Active snapshot restore requires the same denied, allowed, and writable paths. | @@ -179,8 +179,8 @@ nvx-rs/target/release/nvx-rs run | `--host-loopback {allow,deny}` | existing mapping | Control access between the guest and host loopback services. Explicit `allow` is bidirectional: the guest reaches host loopback through its gateway, and every TCP and UDP port on which the guest listens is published on host loopback for the host alone, or with `--network-ingress allow` admits the host as well; it needs a `--net` prefix of `/29` or shorter, which OpenVMM checks; see [Host-loopback access](run.md#host-loopback-access). Snapshot capture rejects `allow`, and so does a restore, unless `--restore-network-policy` installs a new policy. | | `--network-proxy IPV4:TCP-PORT` | none | Allow one explicit host TCP proxy endpoint. | | `--host-loopback-forward PROTOCOL:HOST_PORT:GUEST_PORT` | none | Publish one TCP or UDP localhost port to the guest; repeat to add forwards. Snapshot capture rejects forwards, and so does a restore, unless `--restore-network-policy` installs a new policy. | -| `--outcome-report PATH` | none | Write a bounded local JSON outcome report. | -| `--cmdline TEXT` | empty | Append kernel parameters; `nvx_*` and `tsc=` tokens are reserved. | +| `--outcome-report PATH` | none | Write a bounded local JSON outcome report to a new file in an existing plain directory. A cold boot rejects an existing destination before launch and never replaces a file or follows a destination symlink. A missing report or publication failure is diagnosed without replacing OpenVMM's exit status. | +| `--cmdline TEXT` | empty | Append kernel parameters; `nvx_*` and `tsc=` tokens are reserved. Cold boots reject double quotes and a standalone `--` token, which the VM control console cannot represent; snapshot restore retains its existing command-line handling. | | `--restore-snapshot PATH` | none | Restore the immutable machine contract and saved state from a snapshot directory. A snapshot captured with `--net` needs `--network-profile portable` and the network policy options of its capture, unless `--restore-network-policy` installs a new policy; see [Networked snapshots](run.md#networked-snapshots). | | `--restore-processors {1,2,4,8}` | none | Bring this contiguous processor prefix online before restore readiness. Requires an opt-in microVM snapshot and cannot exceed `--processors` capacity. | | `--restore-memory-mib MIB` | none | Select the 128 MiB-aligned RAM target for an expansion-capable snapshot restore, in the range of `--memory-mib`. | @@ -188,6 +188,15 @@ nvx-rs/target/release/nvx-rs run | `--restore-network-policy` | off | Install the policy of the network options, which may include `--network-ingress allow`, explicit `--host-loopback allow`, and forwards, in place of the complete deny-all policy, `--network-egress deny` and `--host-loopback deny` without egress rules, a proxy, or ingress, that the snapshot's NIC was captured with. The guest applies the new policy's DNS server and listener announcements before the host releases its input; see [Networked snapshots](run.md#networked-snapshots). Requires `--restore-snapshot`. | | `--dry-run` | off | Print the generated OpenVMM command without running it. | +Cold `--mount` targets may not cover, equal, or lie below `/bin`, `/boot`, +`/dev`, `/etc`, `/lib`, `/proc`, `/root`, `/run`, `/sbin`, `/sys`, `/usr`, +`/var`, or `/init`: these paths belong to the guest's blockless system. +For example, use `/config/single.txt` rather than `/etc/single.txt` for a +shared configuration file. Snapshot restore keeps its existing mapping rules. +Cold mount-policy options require a `--mount`, and network-policy options +require networking. A proxy is the guest's sole egress path, so it cannot be +combined with `--network-egress-allow` rules for direct connections. + `nvx-rs run` checks its options, prints the OpenVMM command after `>> `, and unless `--dry-run` is given, runs OpenVMM with the standard streams. A cold boot attaches the guest's console, the boot console `hvc1`, to them, so that diff --git a/guest/common/nvx-hostmount b/guest/common/nvx-hostmount index 837ec640e..944b5223b 100644 --- a/guest/common/nvx-hostmount +++ b/guest/common/nvx-hostmount @@ -4,7 +4,8 @@ # virtfs_mode= tokens describe. When virtfs_aggregate=1 marks it as an # aggregate of host directories and files, its root lists one child per # directory or file, which only root can enter, and each -# nvx_share=CHILD,TARGET,MODE[,file] token binds one child at TARGET with +# nvx_share=SOURCE,TARGET,MODE[,file] token binds a child or child-relative +# source from the planned export at TARGET with # MODE, without set-user-ID programs or device nodes; the file suffix marks a # child that is a host file, which is bound at a file. With --unmount, the # binds and then the share are unmounted. @@ -53,8 +54,20 @@ parse_share() { esac share_target=${share_rest%,*} share_mode=${share_rest##*,} + case "/$share_child/" in + *"//"* | *"/./"* | *"/../"*) fail "invalid live share $1" ;; + esac case "$share_child" in - '' | . | .. | *[!A-Za-z0-9._-]*) fail "invalid live share $1" ;; + */*) + share_root=${share_child%%/*} + case "$share_root" in + '' | *[!0-9]*) fail "invalid live share $1" ;; + esac + case "$share_child" in + *\\*) fail "invalid live share $1" ;; + esac + ;; + *[!A-Za-z0-9._-]*) fail "invalid live share $1" ;; esac case "$share_mode" in ro | rw) ;; *) fail "invalid live share mode in $1" ;; esac case "$share_target" in /?*) ;; *) fail "invalid live share target in $1" ;; esac diff --git a/nvx-rs/Cargo.lock b/nvx-rs/Cargo.lock index 311a844ef..8a1f16bff 100644 --- a/nvx-rs/Cargo.lock +++ b/nvx-rs/Cargo.lock @@ -70,6 +70,29 @@ dependencies = [ "libc", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags", + "crossterm_winapi", + "document-features", + "parking_lot", + "rustix", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -90,6 +113,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "errno" version = "0.3.14" @@ -146,6 +178,21 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "memchr" version = "2.8.3" @@ -187,6 +234,7 @@ dependencies = [ name = "nvx" version = "0.1.0-alpha.3" dependencies = [ + "crossterm", "getrandom", "nix", "proptest", @@ -203,6 +251,29 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -268,6 +339,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-syntax" version = "0.8.11" @@ -287,6 +367,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -341,6 +427,12 @@ dependencies = [ "digest", ] +[[package]] +name = "smallvec" +version = "1.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "149309498a5f7334b12ddae0cad8aba81ce785fe91755068867e017cf98fee46" + [[package]] name = "syn" version = "3.0.6" @@ -389,6 +481,28 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index 804ce2173..fa151a5dc 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -43,7 +43,7 @@ bundled = ["host", "dep:sha2"] testing = ["dep:getrandom", "dep:serde", "dep:serde_json", "dep:nix", "dep:windows-sys"] # NVX's own `nvx-rs` binary, the command-line interface that runs NVX's microVMs and sandboxes, # compiles egress policies, looks up artifacts, and downloads releases. It adds no API. -cli = ["host"] +cli = ["host", "dep:crossterm"] # The serde forms of `Hypervisor` and `net::Ipv4Cidr`. The `host` feature enables it. serde = ["dep:serde"] # The programs that run in Linux guests: the `guest` module and the `nvx-*` guest binaries. Build @@ -51,6 +51,7 @@ serde = ["dep:serde"] guest = ["dep:nix", "dep:getrandom"] [dependencies] +crossterm = { version = "0.29", default-features = false, features = ["windows"], optional = true } getrandom = { version = "0.4", optional = true } serde = { version = "1.0.200", features = ["derive"], optional = true } serde_json = { version = "1.0.120", optional = true } diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index e52582295..5e14ac3a4 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -2086,6 +2086,7 @@ field nvx::host::config::BlockGeometry::physical_block_size struct nvx::host::config::Boot field nvx::host::config::Boot::capture field nvx::host::config::Boot::command_line +field nvx::host::config::Boot::memory_capacity_mib const nvx::host::config::COMMAND_LINE_BUDGET struct nvx::host::config::Capacity field nvx::host::config::Capacity::boot diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index fcb344468..b1a89e4bd 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -672,6 +672,7 @@ field nvx::host::config::BlockGeometry::physical_block_size struct nvx::host::config::Boot field nvx::host::config::Boot::capture field nvx::host::config::Boot::command_line +field nvx::host::config::Boot::memory_capacity_mib const nvx::host::config::COMMAND_LINE_BUDGET struct nvx::host::config::Capacity field nvx::host::config::Capacity::boot diff --git a/nvx-rs/src/host/artifacts/dir.rs b/nvx-rs/src/host/artifacts/dir.rs index dddab5c11..08d80a541 100644 --- a/nvx-rs/src/host/artifacts/dir.rs +++ b/nvx-rs/src/host/artifacts/dir.rs @@ -235,8 +235,13 @@ impl ArtifactDir { /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a /// regular file, such as a FIFO, is refused without being waited on. pub fn runtime_identity(&self) -> Result { + self.runtime_identity_for(Guest::Alpine) + } + + /// Identifies the selected guest through its install metadata, without reading its image. + pub(crate) fn runtime_identity_for(&self, guest: Guest) -> Result { let [openvmm, kernel, initramfs] = match self.layout { - Layout::Release => self.listed_identities()?, + Layout::Release => self.listed_identities(guest)?, Layout::Repository => [ provenance_identity( &self.artifact(OPENVMM_PROVENANCE_NAME), @@ -248,11 +253,33 @@ impl ArtifactDir { "kernel", "kernel_sha256", )?, - provenance_identity( - &self.artifact(INITRAMFS_PROVENANCE_NAME), - "initramfs", - "initramfs_sha256", - )?, + if guest == Guest::Alpine { + provenance_identity( + &self.artifact(INITRAMFS_PROVENANCE_NAME), + "initramfs", + "initramfs_sha256", + )? + } else { + let path = self.package_manifest(guest); + let text = read_metadata( + &path, + "guest package manifest", + "build or install the selected guest", + )?; + let invalid = || { + Error::unavailable(format!( + "guest package manifest {} has no valid artifact SHA-256 digest", + path.display() + )) + }; + let manifest: serde_json::Value = serde_json::from_str(&text) + .map_err(|error| invalid().with_source(error))?; + manifest + .get("artifact_sha256") + .and_then(serde_json::Value::as_str) + .and_then(sha256_identity) + .ok_or_else(invalid)? + }, ], }; Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) @@ -261,7 +288,7 @@ impl ArtifactDir { /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 /// lowercase hexadecimal digits, two spaces, and its path in the release. - fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { + fn listed_identities(&self, guest: Guest) -> Result<[ArtifactIdentity; 3]> { let path = self.root.join(CHECKSUMS_NAME); let description = "release checksum list"; let text = read_metadata(&path, description, "the release is incomplete")?; @@ -295,10 +322,7 @@ impl ArtifactDir { Ok([ listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, - listed(format!( - "{RELEASE_GUEST_DIR}/{}", - Guest::Alpine.initramfs_name() - ))?, + listed(format!("{RELEASE_GUEST_DIR}/{}", guest.initramfs_name()))?, ]) } } diff --git a/nvx-rs/src/host/artifacts/tests.rs b/nvx-rs/src/host/artifacts/tests.rs index 3e4de6351..8d0b14be1 100644 --- a/nvx-rs/src/host/artifacts/tests.rs +++ b/nvx-rs/src/host/artifacts/tests.rs @@ -574,6 +574,31 @@ fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() ); } +#[test] +fn selected_guests_take_their_own_initramfs_digest_from_install_metadata() { + let directory = tempfile::tempdir().unwrap(); + let dir = ArtifactDir::repository(directory.path()).unwrap(); + let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); + record_provenance(directory.path(), &digests); + for guest in [Guest::Ubuntu, Guest::AzureLinux] { + let selected = "d".repeat(64); + let manifest = dir.package_manifest(guest); + fs::write(&manifest, format!("{{\"artifact_sha256\":\"{selected}\"}}")).unwrap(); + let identity = dir.runtime_identity_for(guest).unwrap(); + assert_eq!( + identity.initramfs, + ArtifactIdentity::sha256(&selected).unwrap() + ); + assert_eq!(identity.openvmm, identity_of(&digests).openvmm); + fs::write(&manifest, "{\"artifact_sha256\":\"invalid\"}").unwrap(); + assert_eq!( + dir.runtime_identity_for(guest).unwrap_err().kind(), + ErrorKind::Unavailable + ); + } + assert_eq!(dir.runtime_identity().unwrap(), identity_of(&digests)); +} + #[test] fn a_release_identifies_its_runtime_by_its_checksum_list() { let directory = tempfile::tempdir().unwrap(); diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index a7e8bc29c..914d2e587 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -7,10 +7,9 @@ //! `nvx.py run` with their names, defaults, types, and choices, and ends a usage error with //! status 2. //! -//! A cold boot attaches the guest's console, the boot console `hvc1`, to the terminal with -//! `--virtio-console console`, so that the terminal's input reaches the guest through it rather -//! than through the byte channel, `hvc0`, whose output, such as the kernel's early messages, goes -//! to standard error. A restore attaches the boot console as its snapshot had it: it reads the +//! A cold boot forwards the terminal through the VM's boot-console listener, `hvc1`, and the +//! VM's log to standard error. Its temporary private VM directory is removed after confirmed +//! exit. A restore attaches the boot console as its snapshot had it: it reads the //! snapshot's manifest, `manifest.bin` in the snapshot's directory, dry run or not, and passes //! `--virtio-console console` when the manifest's machine contract holds the attachment of the //! boot console, `console:microvm-virtio0`, with the reconnect policy @@ -31,41 +30,61 @@ //! new policy's DNS server and listener announcements before the host releases its input. //! //! Its shares differ from those of `sandbox`. `--mount-deny`, `--mount-allow`, and `--mount-write` -//! name host paths, which OpenVMM attributes to the share that holds them, and which must be -//! absolute with several shares or a shared file. One directory share uses `--mount`, several -//! shares or a file the children of an aggregate, whose `nvx_share=` tokens a cold boot adds to -//! the kernel command line, and a restore does not, since its guest keeps the binds that its boot -//! made. +//! name host paths, which must be absolute with several shares or a shared file. A cold boot +//! checks their ownership in the filesystem export plan, exports its numbered children, and adds +//! the plan's `nvx_share=` rows to the kernel command line. A restore keeps its existing lowering: +//! one directory uses `--mount`, several shares or a file use an aggregate, and no new share tokens +//! replace the binds that its saved guest already made. //! //! After OpenVMM exits with a status other than 0 from a cold boot, on a host whose CPU no built-in //! CPU profile serves, it prints the guidance of [`cpu_profile`] to standard error, which says //! what `--cpu-profile auto` does on that CPU and how to request a built-in profile for it. It //! never prints it after a dry run, a restore, a refused option, or Ctrl-C. +mod guest_tokens; + +use std::fs::{self, File}; +#[cfg(windows)] +use std::io::Write; +use std::io::{self, IsTerminal, Read}; use std::path::Path; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use sha2::{Digest, Sha256}; use super::args::{Parser, Receiver, Spec, Syntax, choice, integer}; use super::artifacts::{Located, locate}; use super::cpu_profile::{self, HostCpu}; -use super::download; +use super::download::{self, Hold}; use super::network::{NativeRules, NetworkOptions}; use super::sandbox::{ - HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, error_with_cause, hypervisor, path_string, - path_text, run_openvmm, + HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, copy_openvmm_outcome, error_with_cause, hypervisor, + path_string, path_text, run_openvmm, sandbox_network, validate_outcome_destination, }; +use super::session::Session; use super::shell::format_command; use super::{Exit, Result, check_interrupt, output}; use crate::ErrorCode; -use crate::host::artifacts::{Guest, require_artifact}; +use crate::activation::{MachineIdMode, View}; +use crate::host::artifacts::{ArtifactDir, Guest, require_artifact}; +use crate::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; +use crate::host::filesystem::{ + self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, +}; +use crate::host::platform::{self, Transport}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime}; use crate::host::sandbox::mount::{ - AGGREGATE_MOUNT_TARGET, MountKind, MountOwner, POLICY_KINDS, PolicyKind, + AGGREGATE_MOUNT_TARGET, MountAccess, MountKind, MountOwner, POLICY_KINDS, PolicyKind, aggregate_child_argument, aggregate_child_name, aggregate_command_line_fragment, - aggregate_share_tokens, mount_argument, mount_kind, parse_mount_value, - require_mount_owner_supported, + aggregate_share_tokens, export_command_line_fragment, mount_argument, mount_kind, + parse_mount_value, require_mount_owner_supported, }; use crate::host::sandbox::options::validate_run_share_targets; use crate::host::sandbox::{SANDBOX_COMMAND_LINE_MAX_SIZE, has_kernel_separator}; use crate::host::template::read_attachments; +use crate::host::vm::{self, VmDir}; /// The usage and help of the parser. pub(super) const SYNTAX: Syntax = Syntax { @@ -413,8 +432,12 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { // nothing, though it reads the artifacts under the lock of downloads all the same. Their // arguments are built again once the artifacts are in place, as a share's kind depends on what // its host path holds, which a download may install. - let refused = terminal_console.as_ref().is_some_and(Result::is_err) - || extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err(); + let refused = if options.restore_snapshot.is_some() { + terminal_console.as_ref().is_some_and(Result::is_err) + || extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err() + } else { + cold_config(&options, &rules).is_err() + }; let located = locate(root)?; // A restore boots no kernel or initramfs: the snapshot holds the guest. let guest = options.restore_snapshot.is_none().then_some(options.guest); @@ -425,6 +448,9 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { }; let Located { dir, openvmm } = located; require_artifact(&openvmm, "OpenVMM release binary")?; + if options.restore_snapshot.is_none() { + return run_cold(dir, openvmm, &options, &rules, hold); + } let mut command = vec![ path_text(&path_string(&openvmm)?), "--single-process".to_owned(), @@ -449,21 +475,6 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { if terminal_console.transpose()? == Some(true) { push(&mut command, "--virtio-console", "console"); } - } else { - let kernel = dir.require_kernel()?; - let initrd = dir.require_initramfs(options.guest)?; - push( - &mut command, - "--memory", - format!("{}M", memory_mib(&options)), - ); - push(&mut command, "--kernel", path_string(&kernel)?); - push(&mut command, "--initrd", path_string(&initrd)?); - if let Some(capacity) = &options.memory_capacity_mib { - push(&mut command, "--memory-capacity", format!("{capacity}M")); - } - // The guest's console takes the terminal. - push(&mut command, "--virtio-console", "console"); } if let Some(profile) = &options.cpu_profile { push(&mut command, "--cpu-profile", profile.as_str()); @@ -478,19 +489,561 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { return Ok(()); } let status = run_openvmm(&command, &[], hold)?; - if status != 0 - && options.restore_snapshot.is_none() - && let Some(guidance) = cpu_profile::guidance( - options.cpu_profile.as_deref(), - HostCpu::current().as_ref(), - &format_command(&command[..1]), - hypervisor(options.hypervisor), - ) + Err(Exit::Status(status)) +} + +fn cold_config(options: &Options, rules: &NativeRules) -> Result { + let shares = options + .mounts + .iter() + .map(|value| parse_mount_value(value)) + .collect::>>()?; + validate_run_share_targets( + &shares + .iter() + .map(|(target, _, _)| target.as_str()) + .collect::>(), + )?; + if shares.is_empty() && (options.mount_owner.is_some() || !options.policy_paths.is_empty()) { + return Err(Exit::error( + ErrorCode::Unsupported, + if options.mount_owner.is_some() { + "--mount-owner requires --mount" + } else { + "mount policy options require --mount" + }, + )); + } + let hosts: Vec<_> = shares + .iter() + .map(|(_, host, _)| Path::new(host).to_owned()) + .collect(); + let aggregate = + shares.len() > 1 || hosts.iter().any(|host| mount_kind(host) == MountKind::File); + let mut policies: [Vec; 3] = Default::default(); + for kind in POLICY_KINDS { + for (_, given) in options.policy_paths.iter().filter(|(of, _)| *of == kind) { + let given = path_text(given); + let path = Path::new(&given); + if aggregate && !path.is_absolute() { + return Err(Exit::error( + ErrorCode::Unsupported, + format!( + "with several --mount shares, or a shared file, {} {given} must be an absolute host path", + kind.option() + ), + )); + } + let path = if path.is_absolute() { + path.to_owned() + } else { + hosts[0].join(path) + }; + policies[match kind { + PolicyKind::Denied => 0, + PolicyKind::Allowed => 1, + PolicyKind::Writable => 2, + }] + .push(path); + } + } + let mappings: Vec<_> = shares + .iter() + .zip(&hosts) + .map(|((target, _, access), host)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(target), + read_only: *access == MountAccess::ReadOnly, + }) + .collect(); + let filesystem = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: PathList { + name: "--mount-deny", + paths: &policies[0], + }, + allowed: PathList { + name: "--mount-allow", + paths: &policies[1], + }, + write: PathList { + name: "--mount-write", + paths: &policies[2], + }, + owner: if options.mount_owner == Some(MountOwner::Caller) { + Owner::Caller + } else { + Owner::Vmm + }, + reserved: ReservedTargets::BLOCKLESS_ROOT, + }) + .map_err(error_with_cause)?; + let network = sandbox_network(&options.network, rules)? + .map(|network| -> Result { + Ok(NetworkConfig { + guest_network: Some( + crate::net::Ipv4Cidr::parse(network.guest_network()) + .map_err(|error| Exit::error(ErrorCode::Unsupported, error.to_string()))?, + ), + policy: network.policy().clone(), + }) + }) + .transpose()?; + let config = VmConfig { + processors: options.processors, + memory_mib: memory_mib(options), + root: RootConfig::Blockless, + view: View { + hostname: b"nvx".to_vec(), + machine_id: MachineIdMode::Image, + }, + network, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: guest_tokens::render(&options.cmdline, filesystem.as_ref()) + .map_err(error_with_cause)?, + memory_capacity_mib: options.memory_capacity_mib, + capture: None, + }), + filesystem, + }; + if let Some(export) = &config.filesystem { + let line = config.kernel_command_line().map_err(error_with_cause)?; + if line.len() + export_command_line_fragment(export).len() + 1 + > SANDBOX_COMMAND_LINE_MAX_SIZE + { + return Err(Exit::error( + ErrorCode::Unsupported, + "the --mount shares and --cmdline exceed the 1024-byte budget of the kernel command \ + line; attach fewer shares or use shorter targets", + )); + } + } + config.check_command_line().map_err(error_with_cause)?; + Ok(config) +} + +fn remove_vm_directory(dir: &VmDir) -> Result { + for name in [ + "openvmm.log", + "outcome.json", + "control.sock", + "console.sock", + ] { + match fs::remove_file(dir.path().join(name)) { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + } + fs::remove_dir(dir.path()).map_err(Exit::from_os_error) +} + +fn connect_running( + launched: &mut vm::Launched, + endpoint: &str, +) -> Result>> { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + check_interrupt()?; + match platform::connect_endpoint( + endpoint, + launched.process.reference().pid, + Duration::from_millis(250), + ) { + Ok(transport) => return Ok(Some(transport)), + Err(error) + if matches!( + error.kind(), + io::ErrorKind::NotFound + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + ) => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + if launched + .wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)? + .is_some() + { + return Ok(None); + } + if Instant::now() >= deadline { + return Err(Exit::error( + ErrorCode::TimedOut, + format!("VM console endpoint did not become available: {endpoint}"), + )); + } + thread::sleep(Duration::from_millis(10)); + } +} + +struct TerminalGuard(bool); + +impl TerminalGuard { + fn new() -> Result { + let terminal = io::stdin().is_terminal(); + if terminal { + crossterm::terminal::enable_raw_mode().map_err(Exit::from_os_error)?; + } + Ok(Self(terminal)) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + if self.0 + && let Err(error) = crossterm::terminal::disable_raw_mode() + { + output::print_stderr(&format!("cannot restore terminal mode: {error}")); + } + } +} + +type InputCompletion = mpsc::Receiver>; + +fn console_input(console: Box) -> Result<(Box, InputCompletion)> { + #[cfg(any(target_os = "linux", windows))] + { + let duplicate = || { + console + .try_clone_handle() + .map_err(Exit::from_os_error)? + .ok_or_else(|| { + Exit::error( + ErrorCode::Unsupported, + "the VM console cannot be duplicated", + ) + }) + }; + let reader = duplicate()?; + let writer = duplicate()?; + drop(console); + let reader = platform::transport_from_handle(reader).map_err(Exit::from_os_error)?; + let mut writer = platform::transport_from_handle(writer).map_err(Exit::from_os_error)?; + let (sender, receiver) = mpsc::sync_channel(1); + thread::Builder::new() + .name("nvx-run-input".to_owned()) + .spawn(move || { + let result = (|| { + let mut stdin = io::stdin().lock(); + let mut buffer = [0; 8192]; + loop { + let count = stdin.read(&mut buffer)?; + if count == 0 { + return Ok(()); + } + match writer.write_all(&buffer[..count], None) { + Ok(()) => {} + Err(error) + if matches!( + error.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + | io::ErrorKind::WriteZero + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + } + } + })(); + let _ = sender.send(result); + }) + .map_err(Exit::from_os_error)?; + Ok((reader, receiver)) + } + #[cfg(not(any(target_os = "linux", windows)))] + { + drop(console); + Err(Exit::error( + ErrorCode::Unsupported, + "foreground console I/O requires a supported NVX host", + )) + } +} + +fn write_console(data: &[u8]) -> io::Result<()> { + #[cfg(windows)] + { + use std::os::windows::io::AsHandle; + + if platform::interrupted() { + return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); + } + let mut stdout = io::stdout().lock(); + stdout.flush()?; + File::from(stdout.as_handle().try_clone_to_owned()?).write_all(data) + } + #[cfg(not(windows))] + output::write_stdout(data) +} + +fn foreground( + launched: &mut vm::Launched, + log: &mut File, + managed: bool, + control: &mut Option, +) -> Result { + let _terminal = TerminalGuard::new()?; + let endpoint = launched + .outputs + .console + .clone() + .expect("a cold run requests a console listener"); + let (mut console, input) = if let Some(console) = connect_running(launched, &endpoint)? { + let (console, input) = console_input(console)?; + (Some(console), Some(input)) + } else { + (None, None) + }; + let mut attach_closed = None; + if managed && console.is_some() { + let endpoint = launched.outputs.endpoint.clone(); + let capability = *launched.capability.as_bytes(); + match Session::connect_launched( + &endpoint, + launched.child_mut().map_err(Exit::from_os_error)?, + &capability, + 60.0, + ) { + Ok(session) => *control = Some(session), + Err(Exit::EndpointClosed) => { + attach_closed = Some(Instant::now() + Duration::from_secs(60)); + } + Err(error) => return Err(error), + } + } + let mut buffer = [0; 8192]; + loop { + check_interrupt()?; + let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; + let status = launched + .wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)?; + if let Some(input) = &input + && status.is_none() + && let Ok(result) = input.try_recv() + { + result.map_err(Exit::from_os_error)?; + } + if let Some(transport) = &mut console { + match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + Ok(0) => console = None, + Ok(count) => write_console(&buffer[..count]).map_err(Exit::from_os_error)?, + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + ) => {} + Err(error) => return Err(Exit::from_os_error(error)), + } + } + if let Some(status) = status { + loop { + let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + if count == 0 { + break; + } + output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; + } + if let Some(transport) = &mut console { + loop { + match transport.read(&mut buffer, Some(Duration::from_millis(10))) { + Ok(0) => break, + Ok(count) => { + write_console(&buffer[..count]).map_err(Exit::from_os_error)? + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::TimedOut + | io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionReset + ) => + { + break; + } + Err(error) => return Err(Exit::from_os_error(error)), + } + } + } + return Ok(status); + } + if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { + return Err(Exit::EndpointClosed); + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn run_cold( + dir: ArtifactDir, + openvmm: std::path::PathBuf, + options: &Options, + rules: &NativeRules, + hold: Hold, +) -> Result { + let kernel = dir.require_kernel()?; + let initramfs = dir.require_initramfs(options.guest)?; + let config = cold_config(options, rules)?; + if !options.dry_run + && let Some(report) = &options.outcome_report { + validate_outcome_destination(report)?; + } + let mut identity = dir + .runtime_identity_for(options.guest) + .map_err(error_with_cause)?; + if openvmm != dir.openvmm() { + let mut file = File::open(&openvmm).map_err(Exit::from_os_error)?; + let mut digest = Sha256::new(); + let mut buffer = [0; 8192]; + loop { + let count = file.read(&mut buffer).map_err(Exit::from_os_error)?; + if count == 0 { + break; + } + digest.update(&buffer[..count]); + } + identity.openvmm = ArtifactIdentity::sha256(&format!("{:x}", digest.finalize()))?; + } + let runtime = Runtime { + openvmm, + kernel, + initramfs, + identity, + hypervisor: hypervisor(options.hypervisor).parse()?, + cpu_profile: options.cpu_profile.clone(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let mut random = [0; 16]; + getrandom::fill(&mut random).map_err(|error| { + Exit::error( + ErrorCode::Io, + format!("cannot name the temporary VM: {error}"), + ) + })?; + let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + let path = std::env::temp_dir().join(format!("nvx-run-{name}")); + if path.try_exists().map_err(Exit::from_os_error)? { + return Err(Exit::error( + ErrorCode::Io, + "the temporary VM directory already exists", + )); + } + vm::create_private_dir(&path)?; + let vm_dir = VmDir::open(&path)?; + let mut cleanup = true; + let result = (|| { + let prepared = vm::create(&runtime, &config, None, &vm_dir) + .map_err(error_with_cause)? + .for_foreground(); + let command: Vec<_> = std::iter::once(&runtime.openvmm) + .map(|path| path_string(path)) + .chain(prepared.arguments().iter().map(|arg| { + arg.to_str().map(str::to_owned).ok_or_else(|| { + Exit::error(ErrorCode::Unsupported, "an OpenVMM argument is not Unicode") + }) + })) + .collect::>()?; + output::print_stdout(&format!(">> {}", format_command(&command))) + .map_err(Exit::from_os_error)?; + if options.dry_run { + return Ok(()); + } + let mut log = File::open(&prepared.outputs().log).map_err(Exit::from_os_error)?; + if platform::opened_file_id(&log).map_err(Exit::from_os_error)? + != platform::opened_file_id(prepared.log()).map_err(Exit::from_os_error)? + { + return Err(Exit::error( + ErrorCode::Io, + "the VM log was replaced before its reader opened", + )); + } + cleanup = false; + let mut launched = vm::spawn(prepared).map_err(error_with_cause)?; + drop(hold); + let mut control = None; + let managed = config.boot.as_ref().is_some_and(|boot| { + boot.command_line + .split_ascii_whitespace() + .any(|token| token == "nvx_mode=managed") + }); + let status = foreground(&mut launched, &mut log, managed, &mut control); + let now = Instant::now(); + let mut graceful_error = None; + let stopped = vm::stop( + &launched.process, + &launched.outputs, + |deadline| { + let timeout = deadline.saturating_duration_since(Instant::now()); + if timeout.is_zero() { + return false; + } + control + .as_mut() + .is_some_and(|session| match session.stop(timeout.as_secs_f64()) { + Ok(()) => true, + Err(error) => { + graceful_error = Some(error); + false + } + }) + }, + now + Duration::from_millis(250), + now + Duration::from_secs(10), + ) + .map_err(error_with_cause)?; + cleanup = true; + if let Some(error) = graceful_error { + output::print_stderr(&format!("VM graceful stop failed: {error:?}")); + } check_interrupt()?; - output::print_stderr(&guidance); + let status = status?; + if let Err(error) = stopped.report { + output::print_stderr(&format!( + "error[io]: {}", + error_with_cause(error) + .message() + .unwrap_or("cannot read VM outcome") + )); + } + if let Some(report) = &options.outcome_report + && let Err(error) = copy_openvmm_outcome(&launched.outputs.outcome, report) + { + error.report(); + } + if status != 0 + && let Some(guidance) = cpu_profile::guidance( + options.cpu_profile.as_deref(), + HostCpu::current().as_ref(), + &format_command(&command[..1]), + hypervisor(options.hypervisor), + ) + { + check_interrupt()?; + output::print_stderr(&guidance); + } + Err(Exit::Status(status)) + })(); + if cleanup && let Err(error) = remove_vm_directory(&vm_dir) { + output::print_stderr(&format!( + "cannot remove the temporary VM directory {}: {error:?}", + vm_dir.path().display() + )); + if result.is_ok() || matches!(result, Err(Exit::Status(0))) { + return Err(error); + } } - Err(Exit::Status(status)) + result } #[cfg(test)] diff --git a/nvx-rs/src/host/cli/run/guest_tokens.rs b/nvx-rs/src/host/cli/run/guest_tokens.rs new file mode 100644 index 000000000..9659e568c --- /dev/null +++ b/nvx-rs/src/host/cli/run/guest_tokens.rs @@ -0,0 +1,86 @@ +//! The blockless guest settings owned by the CLI, beside the library's root and view tokens. + +use crate::host::filesystem::ExportPlan; +use crate::host::sandbox::{has_kernel_separator, parse_workload_identity}; +use crate::{Error, Result}; + +/// Renders both blockless modes and the planned boot binds, keeping other caller tokens. +pub(super) fn render(command_line: &str, export: Option<&ExportPlan>) -> Result { + let mut mode = "one-shot"; + let (mut uid, mut gid, mut identity) = (None, None, None); + let mut caller = String::new(); + let mut tokens = Vec::new(); + for part in command_line.split_inclusive(|ch: char| ch.is_ascii_whitespace()) { + let token = part.trim_ascii_end(); + let (name, value) = token.split_once('=').unwrap_or((token, "")); + match name { + "nvx_mode" | "nvx_lifecycle" => mode = value, + "nvx_workload_uid" => uid = Some(value), + "nvx_workload_gid" => gid = Some(value), + "nvx_identity" => identity = Some(value.to_owned()), + _ => { + caller.push_str(part); + continue; + } + } + caller.push_str(&part[token.len()..]); + } + if !matches!(mode, "one-shot" | "managed") { + return Err(Error::unsupported( + "blockless mode must be one-shot or managed", + )); + } + if uid.is_some() || gid.is_some() { + let (Some(uid), Some(gid)) = (uid, gid) else { + return Err(Error::unsupported("a workload needs both its UID and GID")); + }; + if identity.is_some() { + return Err(Error::unsupported( + "a workload identity may be specified only once", + )); + } + identity = Some(format!("{uid}:{gid}")); + } + tokens.push(format!("nvx_mode={mode}")); + if let Some(identity) = identity { + let (uid, gid) = parse_workload_identity(&identity)?; + tokens.push(format!("nvx_identity={uid}:{gid}")); + } + if let Some(export) = export { + for (row, bind) in export.guest_table().into_iter().zip(&export.binds) { + if [&row.source, &row.target] + .into_iter() + .any(|text| has_kernel_separator(text) || text.contains([',', '\\', '='])) + { + return Err(Error::unsupported( + "a planned boot share's source and target must each fit one kernel token", + )); + } + let child = &export.children[bind.child]; + let file = if bind.source.is_empty() { + child.file + } else { + let path = child.root.join(&bind.source); + std::fs::metadata(&path) + .map_err(|error| { + Error::io(format!("cannot inspect boot share {}", path.display())) + .with_source(error) + })? + .is_file() + }; + let access = if row.read_only { "ro" } else { "rw" }; + let kind = if file { ",file" } else { "" }; + tokens.push(format!( + "nvx_share={},{},{access}{kind}", + row.source, row.target + )); + } + } + if !caller.is_empty() { + tokens.insert(0, caller); + } + Ok(tokens.join(" ")) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/cli/run/guest_tokens/tests.rs b/nvx-rs/src/host/cli/run/guest_tokens/tests.rs new file mode 100644 index 000000000..ed46ea5f1 --- /dev/null +++ b/nvx-rs/src/host/cli/run/guest_tokens/tests.rs @@ -0,0 +1,78 @@ +use super::*; +use crate::host::filesystem::{ExportRequest, Mapping, ReservedTargets, Target}; + +#[test] +fn blockless_modes_and_workloads_use_cli_owned_names() { + for mode in ["one-shot", "managed"] { + let line = render( + &format!( + "nvx_lifecycle={mode} nvx_workload_uid=1000 nvx_workload_gid=1001 \ + nvx_entrypoint=/bin/echo nvx_arg=a nvx_arg=--help nvx_workload_account=create" + ), + None, + ) + .unwrap(); + assert!(line.contains(&format!("nvx_mode={mode}"))); + assert!(line.contains("nvx_identity=1000:1001")); + assert!(line.contains("nvx_entrypoint=/bin/echo nvx_arg=a nvx_arg=--help")); + assert!(line.contains("nvx_workload_account=create")); + assert!(!line.contains("nvx_lifecycle=")); + assert!(!line.contains("nvx_workload_uid=")); + assert!(!line.contains("nvx_workload_gid=")); + } + assert_eq!( + render("quiet nvx_exec=/sbin/nvx-exit", None).unwrap(), + "quiet nvx_exec=/sbin/nvx-exit nvx_mode=one-shot" + ); + let caller = " quiet\tloglevel=3\nnvx_arg='x' "; + assert_eq!( + render(caller, None).unwrap(), + format!("{caller} nvx_mode=one-shot") + ); +} + +#[test] +fn partial_or_root_workload_identities_and_unknown_modes_are_refused() { + for line in [ + "nvx_workload_uid=1000", + "nvx_identity=0:1000", + "nvx_identity=1000:4294967295", + "nvx_mode=other", + ] { + assert!(render(line, None).is_err(), "{line}"); + } +} + +#[test] +fn planned_boot_shares_keep_table_sources_targets_access_and_file_kind() { + let root = tempfile::tempdir().unwrap(); + let directory = root.path().join("share"); + std::fs::create_dir(&directory).unwrap(); + let file = directory.join("single.txt"); + std::fs::write(&file, "data").unwrap(); + let mappings = [ + Mapping { + name: "--mount", + path: &directory, + target: Target::Guest("/workspace"), + read_only: true, + }, + Mapping { + name: "--mount", + path: &file, + target: Target::Guest("/config/single.txt"), + read_only: true, + }, + ]; + let plan = crate::host::filesystem::plan(&ExportRequest::new( + &mappings, + ReservedTargets::BLOCKLESS_ROOT, + )) + .unwrap() + .unwrap(); + let table = plan.guest_table(); + assert_eq!(table[1].source, "0/single.txt"); + let line = render("", Some(&plan)).unwrap(); + assert!(line.contains("nvx_share=0,/workspace,ro")); + assert!(line.contains("nvx_share=0/single.txt,/config/single.txt,ro,file")); +} diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 2aa8f99b5..1578d6a74 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -1,22 +1,19 @@ -//! The shares of `run`: their targets stay single kernel tokens, and OpenVMM, not option order, -//! attributes their policy paths. +//! Cold-run shares stay kernel tokens through the checked VM configuration; restore keeps its +//! existing policy-path lowering. use proptest::prelude::*; use super::*; -use crate::host::sandbox::mount::MountAccess; use crate::protocol::cmdline::{self, parameters, virtio_mmio_devices}; -/// Returns the kernel command line that OpenVMM composes from `cmdline`, the `--cmdline` that -/// `run` gives it for the cold boot of an aggregate of read-write shares, as its -/// `build_microvm_command_line` and `append_microvm_virtio_discovery` do: its console parameters, -/// `cmdline`, the virtio-mmio devices of its virtio-fs slot and of its boot virtio-console, and -/// the aggregate's bootstrap tokens. -fn effective_command_line(cmdline: &str) -> String { +/// Models the kernel's discovery of the filesystem, control console, and boot console around the +/// command line rendered by the actual cold VM configuration. +fn effective_command_line(config: &VmConfig) -> String { format!( - "earlycon=xe9 console=hvc1 reboot=t panic=-1 {cmdline} \ - virtio_mmio.device=0x1000@0xd0001000:17 virtio_mmio.device=0x1000@0xd0002000:18{}", - aggregate_command_line_fragment(&[MountAccess::ReadWrite]) + "earlycon=xe9 console=hvc1 reboot=t panic=-1 {} \ + virtio_mmio.device=0x1000@0xd0001000:17 virtio_mmio.device=0x1000@0xd0002000:18 \ + virtio_mmio.device=0x1000@0xd0003000:19", + config.kernel_command_line().unwrap() ) } @@ -49,7 +46,44 @@ fn single_share_targets_must_remain_one_kernel_token() { ..Options::default() }; let rules = options.network.egress_rules().unwrap(); - assert!(extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err()); + assert!(cold_config(&options, &rules).is_err()); + } +} + +#[test] +fn cold_share_command_lines_fit_the_kernel_budget_to_the_byte() { + let directory = tempfile::tempdir().unwrap(); + let first = directory.path().join("first"); + let second = directory.path().join("second"); + std::fs::create_dir(&first).unwrap(); + std::fs::create_dir(&second).unwrap(); + let mut options = Options { + mounts: vec![ + format!("/a,{},ro", first.display()), + format!("/b,{},rw", second.display()), + ], + ..Options::default() + }; + let rules = options.network.egress_rules().unwrap(); + let base = cold_config(&options, &rules).unwrap(); + let fragment = export_command_line_fragment(base.filesystem.as_ref().unwrap()); + let room = SANDBOX_COMMAND_LINE_MAX_SIZE + - base.kernel_command_line().unwrap().len() + - fragment.len() + - 2; + for unicode in [false, true] { + options.cmdline = if unicode { + "\u{e9}".repeat(room / 2) + &"x".repeat(room % 2) + } else { + "x".repeat(room) + }; + let fitting = cold_config(&options, &rules).unwrap(); + assert_eq!( + fitting.kernel_command_line().unwrap().len() + fragment.len() + 1, + SANDBOX_COMMAND_LINE_MAX_SIZE + ); + options.cmdline.push('x'); + assert!(cold_config(&options, &rules).is_err()); } } @@ -63,24 +97,27 @@ proptest! { fn share_tokens_hide_no_virtio_mmio_device( components in prop::collection::vec(component(), 2..5), ) { + let directory = tempfile::tempdir().unwrap(); + let roots: Vec<_> = (0..components.len()).map(|index| directory.path().join(format!("host-{index}"))).collect(); + for root in &roots { std::fs::create_dir(root).unwrap(); } let options = Options { mounts: components .iter() .enumerate() - .map(|(index, component)| format!("/share-{index}/{component},host-{index},rw")) + .map(|(index, component)| format!("/share-{index}/{component},{},rw", roots[index].display())) .collect(), ..Options::default() }; let rules = options.network.egress_rules().unwrap(); - let result = extend_shares_and_network(&mut Vec::new(), &options, &rules); + let result = cold_config(&options, &rules); let separated = components.iter().any(|component| { component .bytes() .any(|byte| matches!(byte, b'\0' | b'"') || cmdline::is_space(byte)) }); prop_assert_eq!(result.is_err(), separated); - if let Ok(user) = result { - let line = effective_command_line(&user); + if let Ok(config) = result { + let line = effective_command_line(&config); let read: Vec = parameters(line.as_bytes()) .map(|parameter| { let mut text = parameter.name.to_vec(); @@ -92,12 +129,12 @@ proptest! { }) .collect(); prop_assert_eq!(read, line.split(' ').collect::>()); - prop_assert_eq!(virtio_mmio_devices(line.as_bytes()).count(), 2); + prop_assert_eq!(virtio_mmio_devices(line.as_bytes()).count(), 3); } } #[test] - fn policy_paths_attach_to_their_shares( + fn restore_policy_paths_attach_to_their_shares( events in prop::collection::vec((any::(), 0_u8..3, any::()), 0..40), ) { let directory = tempfile::tempdir().unwrap(); @@ -152,7 +189,7 @@ proptest! { ].map(str::to_owned); let (options, _) = parse(&arguments).unwrap(); let rules = options.network.egress_rules().unwrap(); - prop_assert!(extend_shares_and_network(&mut Vec::new(), &options, &rules).is_err()); + prop_assert!(cold_config(&options, &rules).is_err()); } } } diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs index 10a08c8ad..8f582f44e 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/mod.rs @@ -77,7 +77,9 @@ use crate::host::supervisor::{self, Reattached, Vmm}; use crate::protocol::{CAPABILITY_LEN, GuestFeatures}; pub(crate) use self::directory::{create_state_directory, prepare_state_directory}; -pub(crate) use self::outcome::{validate_outcome_destination, write_exec_outcome}; +pub(crate) use self::outcome::{ + copy_openvmm_outcome, validate_outcome_destination, write_exec_outcome, +}; pub(crate) use self::start::{exec_workload, start}; pub(crate) use self::stop::{deprovision, stop}; diff --git a/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs b/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs index a50cee787..d473b2c9d 100644 --- a/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs +++ b/nvx-rs/src/host/cli/sandbox/lifecycle/outcome.rs @@ -1,7 +1,7 @@ //! Publication and reading of managed-workload and OpenVMM outcome reports. -use std::fs::OpenOptions; -use std::io::Write; +use std::fs::{File, OpenOptions}; +use std::io::{self, Write}; use std::path::{Path, PathBuf}; use serde_json::{Value, json}; @@ -39,11 +39,15 @@ fn outcome_destination(path: &str) -> Result { let resolved = paths::resolve(parent) .map_err(Exit::from_os_error)? .join(name); - if std::fs::symlink_metadata(&resolved).is_ok() { - return Err(Exit::error( - ErrorCode::OutcomeReportExists, - format!("outcome report already exists: {}", resolved.display()), - )); + match std::fs::symlink_metadata(&resolved) { + Ok(_) => { + return Err(Exit::error( + ErrorCode::OutcomeReportExists, + format!("outcome report already exists: {}", resolved.display()), + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(os_error(&error, &resolved)), } Ok(resolved) } @@ -57,6 +61,24 @@ pub(crate) fn validate_outcome_destination(path: &str) -> Result { /// Writes the JSON `text` to a new file at `path`: through a temporary file of mode 0600, synced, /// then linked to its name, which must not exist, with `\n` line ends on every host. fn write_new_json(path: &str, text: &str) -> Result { + let text = format!("{text}\n"); + write_new_file(path, |file| file.write_all(text.as_bytes())) +} + +/// Publishes an OpenVMM report's exact bytes without replacing an existing destination. +pub(crate) fn copy_openvmm_outcome(source: &Path, path: &str) -> Result { + let mut report = File::open(source).map_err(|error| os_error(&error, source))?; + let permissions = report + .metadata() + .map_err(|error| os_error(&error, source))? + .permissions(); + write_new_file(path, |file| { + io::copy(&mut report, file)?; + file.set_permissions(permissions) + }) +} + +fn write_new_file(path: &str, write: impl FnOnce(&mut File) -> io::Result<()>) -> Result { let resolved = outcome_destination(path)?; let temporary = temporary_path(&resolved)?; let mut options = OpenOptions::new(); @@ -74,8 +96,7 @@ fn write_new_json(path: &str, text: &str) -> Result { Err(error) => return Err(os_error(&error, &temporary)), }; let written = (|| -> Result { - let text = format!("{text}\n"); - file.write_all(text.as_bytes()) + write(&mut file) .and_then(|()| file.sync_all()) .map_err(Exit::from_os_error)?; match std::fs::hard_link(&temporary, &resolved) { @@ -84,10 +105,7 @@ fn write_new_json(path: &str, text: &str) -> Result { ErrorCode::OutcomeReportExists, format!("outcome report already exists: {}", resolved.display()), )), - Err(_) => Err(Exit::error( - ErrorCode::Io, - format!("failed to publish outcome report: {}", resolved.display()), - )), + Err(error) => Err(os_error(&error, &resolved)), } })(); drop(file); diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 8ab296e7f..60100036e 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -43,8 +43,9 @@ use crate::host::store::RuntimeIdentity; use crate::protocol::root::RootUpper; pub(crate) use self::lifecycle::ProvisionConfig; +pub(super) use self::lifecycle::{copy_openvmm_outcome, validate_outcome_destination}; pub(super) use self::one_shot::run_openvmm; -use self::options::sandbox_network; +pub(super) use self::options::sandbox_network; pub(super) use self::options::{ HYPERVISORS, MAX_MEMORY_MIB, MOUNT_OWNERS, hypervisor, parse, path_text, }; @@ -202,7 +203,7 @@ pub(super) fn run(root: Option<&str>, options: Options) -> Result { // `provision` records its binding, which `start` checks without reading the layer again. let binding = validate_sandbox_systemd_policy(&launch)?; // The network's policy is checked before anything launches or is recorded. - let launch = launch.with_network(sandbox_network(&options, &rules)?); + let launch = launch.with_network(sandbox_network(&options.network, &rules)?); Some((launch, binding)) } else { None diff --git a/nvx-rs/src/host/cli/sandbox/options.rs b/nvx-rs/src/host/cli/sandbox/options.rs index 33f041d17..48fc36e55 100644 --- a/nvx-rs/src/host/cli/sandbox/options.rs +++ b/nvx-rs/src/host/cli/sandbox/options.rs @@ -363,7 +363,7 @@ pub(crate) fn path_string(path: &Path) -> Result { }) } -/// Returns the network that the network options of `options` describe, with `rules`, the egress +/// Returns the network that `network` describes, with `rules`, the egress /// rules that [`NetworkOptions::egress_rules`] resolved: the typed policy at the guest network of /// `--net`, which [`SandboxNetwork::new`] checks before any launch, or `None` without `--net`, which /// no other network option may then accompany. @@ -373,11 +373,10 @@ pub(crate) fn path_string(path: &Path) -> Result { /// egress rules only with an explicit `--network-egress`, and reads `--network-egress-allow`, /// `--network-egress-deny`, and `--host-loopback-forward` in OpenVMM's form. `--network-proxy` must /// name the guest's gateway, and makes the policy proxy-only, with no scheme in its URL. -pub(super) fn sandbox_network( - options: &Options, +pub(in crate::host::cli) fn sandbox_network( + network: &NetworkOptions, rules: &NativeRules, ) -> Result> { - let network = &options.network; let Some(net) = &network.net else { let network_options = network.network_egress.is_some() || network.network_ingress.is_some() diff --git a/nvx-rs/src/host/config/mod.rs b/nvx-rs/src/host/config/mod.rs index 1e0e25b6a..b600a7d22 100644 --- a/nvx-rs/src/host/config/mod.rs +++ b/nvx-rs/src/host/config/mod.rs @@ -161,6 +161,9 @@ pub struct Boot { /// The root's tokens, `nvx_hostname`, `nvx_machine_id`, `nvx_maps` and `virtfs_*` are reserved; /// every other token, including `nvx_entrypoint`, `nvx_arg` and `nvx_share`, is caller-owned. pub command_line: String, + /// Memory capacity of a cold VM, in MiB, if it reserves more than its boot memory. + /// It must be at least [`VmConfig::memory_mib`] and fit a byte count. + pub memory_capacity_mib: Option, /// What a capture of the VM adds, if the VM may be captured. pub capture: Option, } @@ -615,6 +618,9 @@ impl VmConfig { "--processors".into(), self.processors.to_string().into(), ]; + if let Some(capacity) = self.boot.as_ref().and_then(|boot| boot.memory_capacity_mib) { + arguments.extend(["--memory-capacity".into(), format!("{capacity}M").into()]); + } if let RootConfig::Layered(root) = &self.root { let scratch = match &root.upper { UpperConfig::Scratch { path, .. } => Some(path), @@ -714,6 +720,13 @@ impl VmConfig { capture.check()?; } if let Some(boot) = &self.boot { + if let Some(capacity) = boot.memory_capacity_mib + && (capacity < self.memory_mib || capacity > u64::MAX >> 20) + { + return Err(Error::unsupported( + "a cold boot's memory capacity must be at least its memory and fit a byte count", + )); + } let guest = cmdline::init_tokens(boot.command_line.as_bytes()).map(|token| Parameter { name: token.name, value: token.value, diff --git a/nvx-rs/src/host/config/tests.rs b/nvx-rs/src/host/config/tests.rs index ac8f140c8..6256c3e8c 100644 --- a/nvx-rs/src/host/config/tests.rs +++ b/nvx-rs/src/host/config/tests.rs @@ -23,6 +23,29 @@ fn missing(name: &str) -> PathBuf { std::env::temp_dir().join("nvx-config-missing").join(name) } +#[test] +fn cold_memory_capacity_is_checked_and_lowered_without_a_capture() { + let mut config = cold(blockless()); + config.boot.as_mut().unwrap().memory_capacity_mib = Some(1024); + let arguments = config + .openvmm_arguments( + &runtime(), + "/control", + Path::new("/report"), + Some("/console"), + ) + .unwrap(); + assert_eq!(values(&arguments, "--memory"), [OsStr::new("512M")]); + assert_eq!( + values(&arguments, "--memory-capacity"), + [OsStr::new("1024M")] + ); + for capacity in [511, u64::MAX] { + config.boot.as_mut().unwrap().memory_capacity_mib = Some(capacity); + assert_eq!(config.check().unwrap_err().kind(), ErrorKind::Unsupported); + } +} + /// Returns a blockless configuration of one processor and 512 MiB. fn blockless() -> VmConfig { VmConfig { @@ -132,10 +155,12 @@ fn configurations_pass_with_or_without_a_nic_address_and_a_boot() { let boots = [ None, Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), Some(Boot { + memory_capacity_mib: None, command_line: "quiet loglevel=0".to_owned(), capture: None, }), @@ -550,6 +575,7 @@ fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { let mut config = VmConfig { filesystem: filesystem.clone(), boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -614,6 +640,7 @@ fn cold_command_lines_keep_the_old_root_view_and_mapping_values() { fn the_check_of_a_configuration_checks_its_boots_capture() { let booting = |capture: Option| VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture, }), @@ -662,6 +689,7 @@ fn captured() -> VmConfig { policy: NetworkPolicy::new(Access::Deny), }), boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet".to_owned(), capture: Some(capture()), }), @@ -896,6 +924,7 @@ fn caller_tokens_follow_rendered_tokens_without_any_rewriting() { let caller = " \tquiet nvx_entrypoint=/bin/work nvx_arg=\"x y\" nvx_share=0,/work\r\n "; let mut config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -919,6 +948,7 @@ fn only_a_checked_cold_boot_has_a_kernel_command_line() { let config = VmConfig { processors: 0, boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -975,6 +1005,7 @@ fn caller_tokens_cannot_replace_rendered_tokens_under_kernel_name_matching() { for config in [blockless(), layered(erofs())] { let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: command_line.clone(), capture: None, }), @@ -1008,6 +1039,7 @@ fn caller_owned_guest_tokens_and_noncolliding_names_stay_unchanged() { ] { let mut config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -1046,6 +1078,7 @@ fn mapping_table_is_the_exact_export_projection_counted_by_the_boot_token() { let mut config = VmConfig { filesystem: Some(plan.clone()), boot: Some(Boot { + memory_capacity_mib: None, command_line: String::new(), capture: None, }), @@ -1104,6 +1137,7 @@ fn public_maps_codec_keeps_the_protocol_root_reexports_usable() { fn cold(config: VmConfig) -> VmConfig { VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet nvx_entrypoint=/bin/work nvx_arg=ab".to_owned(), capture: None, }), @@ -1159,6 +1193,7 @@ fn cold_boot_command_lines_refuse_native_control_console_syntax() { ] { let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: caller.to_owned(), capture: None, }), diff --git a/nvx-rs/src/host/platform/linux/endpoint.rs b/nvx-rs/src/host/platform/linux/endpoint.rs index dc29d19f6..50328a0bc 100644 --- a/nvx-rs/src/host/platform/linux/endpoint.rs +++ b/nvx-rs/src/host/platform/linux/endpoint.rs @@ -268,7 +268,14 @@ pub(crate) fn connect_endpoint( )); } check_original_peer(&stream)?; - Ok(Box::new(UnixTransport { stream })) + transport_from_handle(stream.into()) +} + +/// Adapts an owned duplicate of an already verified connection without changing its blocking mode. +pub(crate) fn transport_from_handle(handle: TransportHandle) -> io::Result> { + Ok(Box::new(UnixTransport { + stream: UnixStream::from(handle), + })) } /// Returns the process that listens on a control endpoint, or `None` if nothing listens. diff --git a/nvx-rs/src/host/platform/linux/mod.rs b/nvx-rs/src/host/platform/linux/mod.rs index 516d09b16..d50af4900 100644 --- a/nvx-rs/src/host/platform/linux/mod.rs +++ b/nvx-rs/src/host/platform/linux/mod.rs @@ -14,6 +14,8 @@ use crate::hypervisor::Hypervisor; #[cfg(feature = "cli")] pub(crate) use crate::host::sys::{interrupted, watch_interrupts, write_interruptibly}; +#[cfg(feature = "cli")] +pub(crate) use self::endpoint::transport_from_handle; pub(crate) use self::endpoint::{ ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, listen_ready, }; diff --git a/nvx-rs/src/host/platform/windows/endpoint.rs b/nvx-rs/src/host/platform/windows/endpoint.rs index 5ade3b93c..7358e0454 100644 --- a/nvx-rs/src/host/platform/windows/endpoint.rs +++ b/nvx-rs/src/host/platform/windows/endpoint.rs @@ -234,6 +234,11 @@ pub(crate) fn connect_endpoint( ), )); } + transport_from_handle(pipe) +} + +/// Gives an already verified pipe duplicate its own overlapped-operation event. +pub(crate) fn transport_from_handle(pipe: TransportHandle) -> io::Result> { // SAFETY: creates an unnamed manual-reset event with default security. let event = owned(unsafe { CreateEventW(ptr::null(), 1, 0, ptr::null()) }) .ok_or_else(io::Error::last_os_error)?; diff --git a/nvx-rs/src/host/platform/windows/mod.rs b/nvx-rs/src/host/platform/windows/mod.rs index eef623a5c..f8633541e 100644 --- a/nvx-rs/src/host/platform/windows/mod.rs +++ b/nvx-rs/src/host/platform/windows/mod.rs @@ -22,6 +22,8 @@ use windows_sys::Win32::System::Threading::OpenProcess; use crate::hypervisor::Hypervisor; +#[cfg(feature = "cli")] +pub(crate) use self::endpoint::transport_from_handle; pub(crate) use self::endpoint::{ ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, listen_ready, }; @@ -44,7 +46,7 @@ pub(crate) use self::process::{ }; #[cfg(feature = "cli")] pub(crate) use self::spawn::spawn_foreground; -pub(crate) use self::spawn::{LaunchedProcess, spawn_detached}; +pub(crate) use self::spawn::{LaunchedProcess, SpawnMode, spawn_with_stdio}; /// Whether this host can run NVX microVMs. pub const SUPPORTED: bool = true; diff --git a/nvx-rs/src/host/platform/windows/spawn.rs b/nvx-rs/src/host/platform/windows/spawn.rs index e89348406..ccb82122c 100644 --- a/nvx-rs/src/host/platform/windows/spawn.rs +++ b/nvx-rs/src/host/platform/windows/spawn.rs @@ -252,19 +252,27 @@ impl Drop for InheritableForSpawn { /// [`SpawnLock`]: crate::host::platform::inherit::SpawnLock /// [`spawn_command`]: crate::host::platform::inherit::spawn_command /// [`log_claim_held`]: crate::host::platform::windows::log_claim_held -pub(crate) fn spawn_detached( +pub(crate) fn spawn_with_stdio( program: &Path, arguments: &[OsString], working_dir: &Path, stdio: [OwnedHandle; 3], inherit: &[Inherited<'_>], - breakaway_from_job: bool, + mode: SpawnMode, job: Option, ) -> io::Result { - let mut flags = CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW; - if breakaway_from_job { - flags |= CREATE_BREAKAWAY_FROM_JOB; - } + let flags = match mode { + SpawnMode::Foreground => 0, + SpawnMode::Detached { breakaway_from_job } => { + CREATE_NEW_PROCESS_GROUP + | CREATE_NO_WINDOW + | if breakaway_from_job { + CREATE_BREAKAWAY_FROM_JOB + } else { + 0 + } + } + }; spawn_listed( program, arguments, @@ -276,12 +284,23 @@ pub(crate) fn spawn_detached( ) } +/// Whether the child stays in the caller's console and job. +pub(crate) enum SpawnMode { + /// Keeps the caller's console, process group, and job membership. + Foreground, + /// Starts without a console, in a new process group. + Detached { + /// Leaves the caller's job when that job permits breakaway. + breakaway_from_job: bool, + }, +} + /// Starts OpenVMM in the caller's console, process group, and working directory, as /// `subprocess.run` starts a child, with copies of the caller's standard handles as its own. /// /// Unlike `std::process::Command`, which lets the child inherit every inheritable handle of the /// caller, it passes the child no other handle of the caller than the handles `inherit`, which -/// keep their values in the child, through the handle list of [`spawn_detached`], with the same +/// keep their values in the child, through the handle list of [`spawn_with_stdio`], with the same /// window, under the same lock, for processes that the caller spawns concurrently without one. /// Unlike `std::process::Command` too, which escapes the arguments of a batch file for `cmd.exe`, /// it fails with [`io::ErrorKind::InvalidInput`] for a batch file, as [`refuse_batch_file`] says. @@ -331,7 +350,7 @@ fn standard_handle(which: STD_HANDLE) -> io::Result> { /// Starts `program` with `arguments` in `working_dir`, or in the caller's working directory, with /// the creation flags `flags`, the handles `stdio` as its standard handles, a missing one null, /// and the caller's handles `inherit`, and no other handle of the caller, in the job object -/// `job`, if any, as [`spawn_detached`] describes. Fails with [`io::ErrorKind::InvalidInput`] for +/// `job`, if any, as [`spawn_with_stdio`] describes. Fails with [`io::ErrorKind::InvalidInput`] for /// a working directory beyond Windows' limit or for a batch file, as [`refuse_batch_file`] says. fn spawn_listed( program: &Path, diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 7da84f781..e77dbe242 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -228,13 +228,15 @@ fn children_inherit_only_their_standard_handles() { let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let cmd = Path::new(&system).join("System32").join("cmd.exe"); - let child = spawn_detached( + let child = spawn_with_stdio( &cmd, &[OsString::from("/c"), OsString::from("ping -n 30 127.0.0.1")], &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -313,13 +315,15 @@ fn a_child_loses_only_the_inherited_handle_that_is_closed_in_it() { let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let ping = Path::new(&system).join("System32").join("PING.EXE"); - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[Inherited::new(&closed), Inherited::new(&kept)], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -371,13 +375,15 @@ fn children_inherit_the_allowed_handles_while_they_are_created() { Inherited::new(&kept), Inherited::new(&allowed), ]; - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &inherit, - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -457,13 +463,15 @@ fn batch_files_are_refused_before_cmd_could_read_their_arguments() { let (stdin, _stdin_writer) = io::pipe().unwrap(); let (_stdout_reader, stdout) = io::pipe().unwrap(); let stderr = stdout.try_clone().unwrap(); - refused(spawn_detached( + refused(spawn_with_stdio( &program, &arguments, directory.path(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, )); } @@ -492,13 +500,15 @@ fn concurrent_spawns_that_allow_one_handle_all_inherit_it_and_restore_it() { let (stdin, _stdin_writer) = io::pipe().unwrap(); let (_stdout_reader, stdout) = io::pipe().unwrap(); let stderr = stdout.try_clone().unwrap(); - let child = spawn_detached( + let child = spawn_with_stdio( &ping, &["-n", "30", "127.0.0.1"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[Inherited::new(&shared)], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); @@ -524,13 +534,15 @@ fn a_spawn_that_panics_in_its_window_restores_the_handles_and_lets_other_spawns_ let (stdin, _stdin_writer) = io::pipe()?; let (_stdout_reader, stdout) = io::pipe()?; let stderr = stdout.try_clone()?; - spawn_detached( + spawn_with_stdio( &cmd, &["/c", "exit", "0"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], inherit, - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) } @@ -649,13 +661,15 @@ impl HeldSpawn { }))); let (stdin, _stdin_writer) = io::pipe()?; let stderr = file.try_clone()?; - spawn_detached( + spawn_with_stdio( &program, &arguments, &std::env::temp_dir(), [stdin.into(), file.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, job, ) }); @@ -1122,13 +1136,15 @@ fn a_thread_that_holds_the_spawn_lock_spawns_under_it_until_its_last_lock_is_dro let stderr = stdout.try_clone().unwrap(); let system = std::env::var_os("SystemRoot").unwrap(); let cmd = Path::new(&system).join("System32").join("cmd.exe"); - let vmm = spawn_detached( + let vmm = spawn_with_stdio( &cmd, &["/c", "exit", "0"].map(OsString::from), &std::env::temp_dir(), [stdin.into(), stdout.into(), stderr.into()], &[], - false, + SpawnMode::Detached { + breakaway_from_job: false, + }, None, ) .unwrap(); diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index fd71827ab..f9009867f 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -1,4 +1,4 @@ -//! The detached spawn of a VMM, with an explicit allowlist of the descriptors or handles of the +//! The spawn of a VMM, with an explicit allowlist of the descriptors or handles of the //! caller that it inherits, and the child that the spawn returns until its caller releases it. use std::ffi::OsString; @@ -107,7 +107,7 @@ pub fn spawn( capability: &[u8; CAPABILITY_LEN], log: File, ) -> io::Result { - spawn_inner(command, capability, log, false) + spawn_inner(command, capability, log, false, false) } /// Starts a library VM only with a trusted log-named job on Windows. @@ -115,8 +115,9 @@ pub(crate) fn spawn_vm( command: &VmmCommand<'_>, capability: &[u8; CAPABILITY_LEN], log: File, + foreground: bool, ) -> io::Result { - spawn_inner(command, capability, log, true) + spawn_inner(command, capability, log, true, foreground) } /// Shares the legacy spawn mechanics without its optional Windows log-job fallback for a VM. @@ -125,6 +126,7 @@ fn spawn_inner( capability: &[u8; CAPABILITY_LEN], log: File, require_log_job: bool, + foreground: bool, ) -> io::Result { #[cfg(not(windows))] let _ = require_log_job; @@ -141,13 +143,20 @@ fn spawn_inner( "the VM log has no trusted log-named job", )); } - platform::spawn_detached( + let mode = if foreground { + platform::SpawnMode::Foreground + } else { + platform::SpawnMode::Detached { + breakaway_from_job: command.breakaway_from_job, + } + }; + platform::spawn_with_stdio( &command.program, &command.arguments, &command.working_dir, [stdin.into(), log.into(), stderr.into()], &command.inherit, - command.breakaway_from_job, + mode, job, ) .map(|process| Launched { process }) @@ -164,7 +173,17 @@ fn spawn_inner( .stdin(Stdio::from(stdin)) .stdout(Stdio::from(log)) .stderr(Stdio::from(stderr)); - platform::detach(&mut child, &command.inherit)?; + #[cfg(feature = "cli")] + if foreground { + platform::inherit_only(&mut child, &command.inherit)?; + } else { + platform::detach(&mut child, &command.inherit)?; + } + #[cfg(not(feature = "cli"))] + { + let _ = foreground; + platform::detach(&mut child, &command.inherit)?; + } child.spawn().map(|child| Launched { child, reaped: false, diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index 142ca7c0c..b90325116 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -16,6 +16,8 @@ use std::fmt; use std::fs::{self, File, OpenOptions}; use std::io; use std::path::{Path, PathBuf}; +#[cfg(feature = "cli")] +use std::time::Duration; use std::time::Instant; use crate::host::config::{ConsoleAttachment, RootConfig, UpperConfig, VmConfig}; @@ -307,6 +309,8 @@ pub struct Prepared { working_dir: PathBuf, /// Whether the child leaves the caller's Windows job. windows_breakaway: bool, + /// Whether this CLI-owned child keeps the caller's session, group, and console. + foreground: bool, /// The control credential, never written to disk. capability: Capability, /// The launch's fixed file paths and chosen endpoints. @@ -320,6 +324,12 @@ pub struct Prepared { } impl Prepared { + #[cfg(feature = "cli")] + pub(crate) fn for_foreground(mut self) -> Self { + self.foreground = true; + self + } + /// Returns the credential to journal before a spawn or use when attaching a session. pub fn capability(&self) -> &Capability { &self.capability @@ -471,6 +481,7 @@ pub fn create( program: runtime.openvmm.clone(), working_dir: dir.path().to_owned(), windows_breakaway: runtime.windows_breakaway, + foreground: false, capability, outputs, log, @@ -507,6 +518,38 @@ pub struct Launched { pub outputs: VmOutputs, /// The control credential, never written to disk. pub capability: Capability, + #[cfg(feature = "cli")] + child: ForegroundChild, +} + +#[cfg(feature = "cli")] +#[derive(Debug)] +struct ForegroundChild(Option); + +#[cfg(feature = "cli")] +impl Drop for ForegroundChild { + fn drop(&mut self) { + if let Some(child) = self.0.take() { + child.detach(); + } + } +} + +#[cfg(feature = "cli")] +impl Launched { + pub(crate) fn wait_status(&mut self, timeout: Duration) -> io::Result> { + Ok(self + .child_mut()? + .wait_timeout(timeout)? + .map(|status| status as i32)) + } + + pub(crate) fn child_mut(&mut self) -> io::Result<&mut spawn::Launched> { + self.child + .0 + .as_mut() + .ok_or_else(|| io::Error::other("the VM has no child owned by this caller")) + } } /// Starts a prepared cold launch and returns its verified, detached process. @@ -612,6 +655,7 @@ fn spawn_verified( program, working_dir, windows_breakaway, + foreground, capability, outputs, log, @@ -625,7 +669,7 @@ fn spawn_verified( breakaway_from_job: windows_breakaway, inherit: inherit.iter().map(Inherited::new).collect(), }; - let child = spawn::spawn_vm(&command, capability.as_bytes(), log) + let child = spawn::spawn_vm(&command, capability.as_bytes(), log, foreground) .map_err(|error| Error::io("cannot spawn the VM").with_source(error))?; let process = match verify(&child) { Ok(process) => process, @@ -640,11 +684,21 @@ fn spawn_verified( return Err(Error::io(message).with_source(error)); } }; + #[cfg(feature = "cli")] + let child = if foreground { + Some(child) + } else { + child.detach(); + None + }; + #[cfg(not(feature = "cli"))] child.detach(); Ok(Launched { process, outputs, capability, + #[cfg(feature = "cli")] + child: ForegroundChild(child), }) } diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index 97b4b5ca6..56b719cee 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -430,6 +430,7 @@ fn cold_config() -> VmConfig { filesystem: None, console: ConsoleAttachment::Listener, boot: Some(Boot { + memory_capacity_mib: None, command_line: "nvx_entrypoint=/bin/sh nvx_arg=-c nvx_arg=exit".to_owned(), capture: None, }), @@ -1085,10 +1086,51 @@ fn a_library_vm_outlives_the_caller_that_spawned_it_without_retaining_its_output process, outputs: VmDir::open(&vm).unwrap().outputs(false).unwrap(), capability: Capability([0; CAPABILITY_LEN]), + #[cfg(feature = "cli")] + child: ForegroundChild(None), }); assert!(!launched.0.process.has_exited().unwrap()); } +#[cfg(all(feature = "cli", any(target_os = "linux", windows)))] +#[test] +fn an_owned_child_status_is_kept_when_its_outcome_is_missing_or_malformed() { + for malformed in [false, true] { + let (_directory, dir) = vm_dir(); + let mut prepared = stand_in(&dir).for_foreground(); + #[cfg(target_os = "linux")] + { + prepared.program = PathBuf::from("/bin/sh"); + prepared.arguments = ["-c", "exit 37"].map(OsString::from).to_vec(); + } + #[cfg(windows)] + { + prepared.program = PathBuf::from(std::env::var_os("ComSpec").unwrap()); + prepared.arguments = ["/d", "/c", "exit 37"].map(OsString::from).to_vec(); + } + let mut launched = RunningVm(spawn(prepared).unwrap()); + assert_eq!( + launched.0.wait_status(Duration::from_secs(10)).unwrap(), + Some(37) + ); + if malformed { + fs::write(&launched.0.outputs.outcome, b"not-json").unwrap(); + } + let now = Instant::now(); + let stopped = stop( + &launched.0.process, + &launched.0.outputs, + |_| false, + now, + now, + ) + .unwrap(); + assert!(stopped.self_exited); + assert_eq!(stopped.report.is_err(), malformed); + assert_eq!(launched.0.wait_status(Duration::ZERO).unwrap(), Some(37)); + } +} + #[cfg(any(target_os = "linux", windows))] #[test] #[ignore = "invoked in a separate caller by the caller-survival test"] @@ -1230,6 +1272,7 @@ fn current_cold_spawn_boots_a_guest_accepts_shell_input_and_exits_cleanly() { let vm = VmDir::open(&path).unwrap(); let config = VmConfig { boot: Some(Boot { + memory_capacity_mib: None, command_line: "quiet loglevel=0".to_owned(), capture: None, }), diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index c92651949..9ef3d8382 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -83,7 +83,9 @@ //! or with `STATUS` if the kernel command line has `fake_exit_status=STATUS`. //! //! The fake also runs the microVMs of `nvx-rs run`, whose command lines have -//! neither a `--microvm-lifecycle` nor a sandbox block device, on the terminal. Such a command +//! neither a `--microvm-lifecycle` nor a sandbox block device, on the terminal or a boot-console +//! listener. A cold library launch also supplies the authenticated control endpoint and +//! `--guest-shutdown-action exit`, without selecting a lifecycle through an OpenVMM flag. Such a command //! line takes `--processors` and at most one `--cpu-profile`, shares through one `--mount` or the //! children of an aggregate at `/run/nvx/shares`, whose relative policy paths name paths in the //! only share, as OpenVMM resolves them, and either boots a guest, with `--memory`, an optional @@ -353,6 +355,8 @@ struct Options { restore: Option, /// The `--virtio-console` of a run's restore, which the restore checks against its snapshot. restore_console: Option, + /// The listener attachment of a cold library launch's boot console. + boot_console: Option, /// Control endpoint at which the fake accepts host sessions; empty for a one-shot run. endpoint: String, /// Path to the synthetic OpenVMM outcome report, which a one-shot run may omit. @@ -377,6 +381,8 @@ struct Options { exit_status: Option, /// Artificial delay before the fake guest becomes ready. boot_delay: Duration, + /// Whether the boot console emits the binary-output regression fixture. + binary_console: bool, /// Artificial delay before the fake OpenVMM crashes, when requested. crash_after: Option, /// Whether stop requests are deliberately left unanswered. @@ -494,23 +500,48 @@ pub fn main() -> ExitCode { return ExitCode::from(2); } }; + let launch_capability = if options.boot_console.is_some() { + match read_capability() { + Ok(capability) => Some(capability), + Err(message) => { + eprintln!("{program}: {message}"); + return ExitCode::from(2); + } + } + } else { + None + }; + let mut console = match boot_console(&options) { + Ok(console) => console, + Err(error) => { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } + }; let ended = match options.lifecycle { - Lifecycle::OneShot => Some(run_one_shot(&options)), - Lifecycle::Run => Some(run_microvm(&options)), + Lifecycle::OneShot => Some(run_one_shot(&options, &mut console)), + Lifecycle::Run => Some(run_microvm(&options, &mut console)), Lifecycle::Capture => Some(run_capture(&options)), Lifecycle::Managed => None, }; if let Some(status) = ended { + if let Err(error) = console.flush() { + eprintln!("{program}: {error}"); + return ExitCode::from(1); + } let _ = io::stdout().flush(); let _ = io::stderr().flush(); return exit_code(status); } - let capability = match read_capability() { - Ok(capability) => capability, - Err(message) => { - eprintln!("{program}: {message}"); - return ExitCode::from(2); - } + let capability = match launch_capability { + Some(capability) => capability, + None => match read_capability() { + Ok(capability) => capability, + Err(message) => { + eprintln!("{program}: {message}"); + return ExitCode::from(2); + } + }, }; thread::sleep(options.boot_delay); if let Some(after) = options.crash_after { @@ -720,7 +751,7 @@ fn writers_of(_reader: &fs::File) -> Option { /// The entrypoint is `/bin/echo`, which prints its arguments, `/bin/sleep`, which sleeps for its /// argument in seconds, `/bin/true` or `/bin/false`, or any other program, which is not found; /// the `fake_exit_status=STATUS` token replaces the status. -fn run_one_shot(options: &Options) -> i32 { +fn run_one_shot(options: &Options, console: &mut dyn Write) -> i32 { let (program, arguments) = options .workload .split_first() @@ -729,7 +760,10 @@ fn run_one_shot(options: &Options) -> i32 { }); let status = match program { "/bin/echo" => { - println!("{}", arguments.join(" ")); + if let Err(error) = writeln!(console, "{}", arguments.join(" ")) { + eprintln!("{}: {error}", options.program); + return 1; + } 0 } "/bin/sleep" => { @@ -767,9 +801,9 @@ fn run_one_shot(options: &Options) -> i32 { /// of the `fake_exit_status=STATUS` token. A restore without its snapshot directory fails with 1, /// as OpenVMM fails a restore that it cannot read, and so does one whose console /// [`check_restored_console`] refuses; neither writes a report. -fn run_microvm(options: &Options) -> i32 { +fn run_microvm(options: &Options, console: &mut dyn Write) -> i32 { thread::sleep(options.boot_delay); - match &options.restore { + let message = match &options.restore { Some(snapshot) if !snapshot.is_dir() => { eprintln!( "{}: cannot restore {}: no such snapshot directory", @@ -785,9 +819,18 @@ fn run_microvm(options: &Options) -> i32 { eprintln!("{}: {message}", options.program); return 1; } - println!("{}: restored the microVM", options.program); + format!("{}: restored the microVM", options.program) } - None => println!("{}: booted the microVM", options.program), + None => format!("{}: booted the microVM", options.program), + }; + let written = if options.binary_console { + console.write_all(&[b'A', 0xff, 0x80, b'\n']) + } else { + writeln!(console, "{message}") + }; + if let Err(error) = written { + eprintln!("{}: {error}", options.program); + return 1; } let status = options.exit_status.unwrap_or(0); let (category, status_code) = if status == 0 { @@ -1203,7 +1246,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { "--host-loopback-forward", ]; // The options that only the microVMs of `nvx-rs run` and of captures take. - const RUN_VALUED: [&str; 7] = [ + const RUN_VALUED: [&str; 8] = [ "--processors", "--cpu-profile", "--memory-capacity", @@ -1211,6 +1254,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { "--restore-processors", "--restore-memory", "--restore-ready-path", + "--guest-shutdown-action", ]; // The options that only the VMs of templates' captures take. const CAPTURE_VALUED: [&str; 4] = [ @@ -1297,9 +1341,24 @@ fn parse(program: &str, arguments: &[String]) -> Result { if !flags.contains("--single-process") { return Err("--single-process is required".to_owned()); } - let (endpoint, report) = match lifecycle { - Lifecycle::Managed | Lifecycle::Capture => { - expect("--virtio-console", "none")?; + let boot_console = if run && values.contains_key("--guest-shutdown-action") { + if values.contains_key("--restore-snapshot") { + return Err("--guest-shutdown-action is only for a cold boot".to_owned()); + } + expect("--guest-shutdown-action", "exit")?; + let endpoint = single("--virtio-console")? + .strip_prefix("listen=") + .filter(|endpoint| !endpoint.is_empty()) + .ok_or("--virtio-console must name a listen= endpoint")?; + Some(endpoint.to_owned()) + } else { + None + }; + let (endpoint, report) = + if matches!(lifecycle, Lifecycle::Managed | Lifecycle::Capture) || boot_console.is_some() { + if boot_console.is_none() { + expect("--virtio-console", "none")?; + } if !flags.contains("--microvm-control-auth-stdin") { return Err("--microvm-control-auth-stdin is required".to_owned()); } @@ -1313,8 +1372,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { ); } (endpoint, Some(PathBuf::from(single("--microvm-report")?))) - } - Lifecycle::OneShot | Lifecycle::Run => { + } else { let kind = if run { "a microVM run" } else { @@ -1346,8 +1404,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { Some(_) => return Err("--microvm-report must be given at most once".to_owned()), }; (String::new(), report) - } - }; + }; let hypervisor = single("--hypervisor")?; if !["kvm", "mshv", "whp"].contains(&hypervisor) { return Err(format!("unsupported hypervisor {hypervisor}")); @@ -1508,7 +1565,12 @@ fn parse(program: &str, arguments: &[String]) -> Result { // CLI's tokens may name a one-shot workload and place the children of an aggregate. The // guest of a workload of either mode has the targets of its directory shares, as a // one-shot sandbox's has: a direct share's, and those that the tokens place. - let (children, mut share_targets) = sandbox_shares(&values)?; + let (children, mut share_targets) = if boot_console.is_some() { + let maps = mapping_count(&token_values(MAPS_TOKEN))?; + (mapped_children(&values, maps)?, Vec::new()) + } else { + sandbox_shares(&values)? + }; match lifecycle { Lifecycle::Run => share_targets.clear(), _ => share_targets.extend(bound_targets(&token_values("nvx_share="))?), @@ -1649,6 +1711,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { run, restore, restore_console, + boot_console, endpoint, args_dump: anchor.with_file_name(format!("fake-openvmm-{token}.json")), maps_dump: anchor.with_file_name(format!("fake-openvmm-{token}-maps.json")), @@ -1661,6 +1724,7 @@ fn parse(program: &str, arguments: &[String]) -> Result { exit_on_start: knob("fake_exit_on_start").and_then(|value| value.parse().ok()), exit_status: knob("fake_exit_status").and_then(|value| value.parse().ok()), boot_delay: millis("fake_boot_delay_ms").unwrap_or_default(), + binary_console: knob("fake_binary_console") == Some("1"), crash_after: millis("fake_crash_after_ms"), ignore_stop: knob("fake_ignore_stop") == Some("1"), exit_delay: millis("fake_exit_delay_ms").unwrap_or_default(), @@ -2850,6 +2914,46 @@ fn record_session(options: &Options) { } } +fn boot_console(options: &Options) -> io::Result> { + let Some(endpoint) = &options.boot_console else { + return Ok(Box::new(io::stdout())); + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + use std::os::unix::net::UnixListener; + + let listener = UnixListener::bind(endpoint)?; + fs::set_permissions(endpoint, fs::Permissions::from_mode(0o600))?; + Ok(Box::new(listener.accept()?.0)) + } + #[cfg(windows)] + { + let pipe = pipe_listener(endpoint)?; + accept_pipe(&pipe)?; + Ok(Box::new(PipeConsole(pipe))) + } + #[cfg(not(any(unix, windows)))] + Err(io::Error::new( + io::ErrorKind::Unsupported, + "no boot-console listener on this host", + )) +} + +#[cfg(windows)] +struct PipeConsole(fs::File); + +#[cfg(windows)] +impl Write for PipeConsole { + fn write(&mut self, data: &[u8]) -> io::Result { + self.0.write(data) + } + + fn flush(&mut self) -> io::Result<()> { + self.0.sync_all() + } +} + #[cfg(unix)] /// Serves the fake control endpoint as a Unix-domain socket until a stop request. fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { @@ -2873,24 +2977,20 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> } #[cfg(windows)] -/// Serves the fake control endpoint as a Windows named pipe until a stop request. -fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { +fn pipe_listener(endpoint: &str) -> io::Result { use std::fs::File; use std::os::windows::ffi::OsStrExt; - use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle, RawHandle}; + use std::os::windows::io::{FromRawHandle, OwnedHandle, RawHandle}; - use windows_sys::Win32::Foundation::{ - ERROR_NO_DATA, ERROR_PIPE_CONNECTED, GetLastError, HANDLE, INVALID_HANDLE_VALUE, - }; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; use windows_sys::Win32::Storage::FileSystem::{ FILE_FLAG_FIRST_PIPE_INSTANCE, PIPE_ACCESS_DUPLEX, }; use windows_sys::Win32::System::Pipes::{ - ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, PIPE_READMODE_BYTE, - PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, + CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, }; - let name: Vec = std::ffi::OsStr::new(&options.endpoint.replace('/', "\\")) + let name: Vec = std::ffi::OsStr::new(&endpoint.replace('/', "\\")) .encode_wide() .chain([0]) .collect(); @@ -2913,16 +3013,26 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> return Err(io::Error::last_os_error()); } // SAFETY: CreateNamedPipeW returned a fresh handle that nothing else owns. - let mut file = File::from(unsafe { OwnedHandle::from_raw_handle(handle as RawHandle) }); + Ok(File::from(unsafe { + OwnedHandle::from_raw_handle(handle as RawHandle) + })) +} + +#[cfg(windows)] +fn accept_pipe(file: &fs::File) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::{ + ERROR_NO_DATA, ERROR_PIPE_CONNECTED, GetLastError, HANDLE, + }; + use windows_sys::Win32::System::Pipes::{ConnectNamedPipe, DisconnectNamedPipe}; + let pipe = file.as_raw_handle() as HANDLE; - let mut guest = Guest::new(options); - let mut epoch = 0; loop { // SAFETY: the handle is a pipe server instance; a null OVERLAPPED waits synchronously. if unsafe { ConnectNamedPipe(pipe, std::ptr::null_mut()) } == 0 { // SAFETY: GetLastError has no preconditions. match unsafe { GetLastError() } { - ERROR_PIPE_CONNECTED => {} + ERROR_PIPE_CONNECTED => return Ok(()), // The client left before the connection was accepted. ERROR_NO_DATA => { // SAFETY: the handle is a pipe server instance. @@ -2932,6 +3042,23 @@ fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> error => return Err(io::Error::from_raw_os_error(error as i32)), } } + return Ok(()); + } +} + +#[cfg(windows)] +/// Serves the fake control endpoint as a Windows named pipe until a stop request. +fn serve(options: &Options, capability: &[u8; CAPABILITY_LEN]) -> io::Result<()> { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::HANDLE; + use windows_sys::Win32::System::Pipes::DisconnectNamedPipe; + + let mut file = pipe_listener(&options.endpoint)?; + let pipe = file.as_raw_handle() as HANDLE; + let mut guest = Guest::new(options); + let mut epoch = 0; + loop { + accept_pipe(&file)?; epoch += 1; record_session(options); let flow = serve_client(&mut file, options, capability, epoch, &mut guest); diff --git a/nvx-rs/src/testing/fake_openvmm/tests.rs b/nvx-rs/src/testing/fake_openvmm/tests.rs index 068ac0d36..39d2ec29f 100644 --- a/nvx-rs/src/testing/fake_openvmm/tests.rs +++ b/nvx-rs/src/testing/fake_openvmm/tests.rs @@ -648,7 +648,7 @@ fn restores_of_runs_resume_their_saved_guest_whatever_the_cli_tokens() { assert_eq!(options.lifecycle, Lifecycle::Run, "{snapshot}"); assert!(options.workload.is_empty(), "{snapshot}"); if snapshot.ends_with("missing") { - assert_eq!(run_microvm(&options), 1); + assert_eq!(run_microvm(&options, &mut io::sink()), 1); } } } diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index 0c32142f2..157ae9784 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -2541,7 +2541,7 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { let dry_run = text(&run(&root, &arguments(&["--dry-run"])).stdout); let (openvmm, _) = dry_run .strip_prefix(">> ") - .and_then(|command| command.split_once(" --single-process ")) + .and_then(|command| command.split_once(" --machine ")) .unwrap(); let default = if cfg!(windows) { "whp" } else { "kvm" }; // OpenVMM fails the cold boot, or refuses its command line with the status of a usage error; @@ -2562,13 +2562,6 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { unserved, default, ), - ( - &["--host-loopback-forward", "tcp:8080:80"][..], - 2, - "openvmm: --host-loopback-forward requires explicit --host-loopback allow\n", - unserved, - default, - ), ( &[ "--cpu-profile", @@ -2659,6 +2652,7 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { ), (&["--restore-snapshot", "missing"][..], 1), (&["--net", "10.0.0.2/24"][..], 1), + (&["--host-loopback-forward", "tcp:8080:80"][..], 1), ( &["--mount", "/data", "--cmdline", "fake_exit_status=1"][..], 1, @@ -2669,6 +2663,13 @@ fn run_guides_failed_cold_boots_on_cpus_that_no_built_in_profile_serves() { ] { let output = run(&root, &arguments(options)); assert_eq!(output.status.code(), Some(status), "{options:?}"); + if options.first() == Some(&"--host-loopback-forward") { + assert!(output.stdout.is_empty(), "{options:?}"); + assert!( + text(&output.stderr).starts_with("error[unsupported]:"), + "{options:?}" + ); + } assert!( !text(&output.stderr).contains("nvx: "), "{options:?}: {}", @@ -2705,9 +2706,8 @@ fn run_boots_each_guest_with_its_default_memory() { #[test] fn run_shares_relative_roots_with_absolute_policy_paths() { - // nvx-rs run passes the host path of each share as given, so the children of an aggregate may - // be relative to the working directory, while their policy paths must be absolute: OpenVMM - // attributes each one to the share whose root contains it once it has resolved both. + // The cold path resolves each relative root in its checked export plan, while an aggregate's + // policy paths must be absolute. let root = Root::new(); let path = |parts: &[&str]| { parts @@ -2731,9 +2731,11 @@ fn run_shares_relative_roots_with_absolute_policy_paths() { let output = run(&root, &arguments); assert!(output.status.success(), "{}", text(&output.stderr)); let stdout = text(&output.stdout); - let child = nvx::host::sandbox::mount::aggregate_child_name(0, "/data"); + let canonical = fs::canonicalize(root.path.join("share")).unwrap(); + let child = canonical.to_str().unwrap(); + let child = child.strip_prefix(r"\\?\").unwrap_or(child); assert!( - stdout.contains(&format!(" --mount-child {child},share,rw ")), + stdout.contains(&format!(" --mount-child 0,{child},rw ")), "{stdout}" ); assert!( diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index f9d758cea..9a041cedb 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -10,13 +10,15 @@ use std::time::{Duration, Instant}; use crate::harness::{LAYER, NVX_RS, OPENVMM, Root, SCRATCH, UUID}; /// The files of a test root that a release installs. -const INSTALLED: [&str; 6] = [ +const INSTALLED: [&str; 8] = [ OPENVMM, "build/vmlinux", "build/initramfs.cpio.gz", "build/openvmm.provenance.json", "build/vmlinux.provenance.json", "build/initramfs.provenance.json", + "build/initramfs-ubuntu.cpio.gz.packages.json", + "build/initramfs-azurelinux.cpio.gz.packages.json", ]; /// Returns a root that is a checkout of NVX, with its `SOURCE-MANIFEST.json` and a `.git` that is diff --git a/nvx-rs/tests/cli/dry_run.rs b/nvx-rs/tests/cli/dry_run.rs index f8d796528..ec2c6984d 100644 --- a/nvx-rs/tests/cli/dry_run.rs +++ b/nvx-rs/tests/cli/dry_run.rs @@ -41,6 +41,8 @@ struct DryRun { arguments: Vec, /// The options of the OpenVMM command line that it prints, with placeholders. options: Options, + /// The specific unsupported configuration this case expects instead of a command. + refusal: Option<&'static str>, } /// Returns the dry run `name` of `arguments`, which prints an OpenVMM command line of `options`, @@ -61,10 +63,16 @@ fn dry_run( .map(|argument| argument.to_string()) .collect(), options, + refusal: None, } } impl DryRun { + fn refusing(mut self, message: &'static str) -> Self { + self.refusal = Some(message); + self + } + /// Returns the case with the file `relative` written first, with `content`. fn with_file(mut self, relative: &'static str, content: Content) -> Self { self.files.push((relative, content)); @@ -412,8 +420,7 @@ fn run_dry_runs() -> Vec { let many_children: Vec = (0..20) .map(|index| mount_child(index, &format!("/share/{index:02}"), "{root/share}", "ro")) .collect(); - let long = "x".repeat(1100); - let fits = "x".repeat(841); + let fits = "x".repeat(crate::openvmm::two_share_command_line_room()); vec![ dry_run("run: Alpine default", run(&[]), cold_boot()), dry_run( @@ -721,7 +728,8 @@ fn run_dry_runs() -> Vec { cold_boot() .with("--mount-deny", ["{root/share/hidden}"]) .with("--mount-allow", ["x"]), - ), + ) + .refusing("mount policy options require --mount"), dry_run( "run: several shares with policy paths", run(&[ @@ -778,7 +786,8 @@ fn run_dry_runs() -> Vec { )], ) .kernel(&[share_token(0, "/etc/single.txt", "ro", true)]), - ), + ) + .refusing("the guest target /etc/single.txt, which would cover the guest's /etc"), dry_run( "run: one relative shared file", run(&["--mount", "/etc/single.txt,single.txt"]), @@ -789,6 +798,29 @@ fn run_dry_runs() -> Vec { [mount_child(0, "/etc/single.txt", "single.txt", "ro,file")], ) .kernel(&[share_token(0, "/etc/single.txt", "ro", true)]), + ) + .refusing("the guest target /etc/single.txt, which would cover the guest's /etc"), + dry_run( + "run: one shared file outside system paths", + run(&["--mount", "/config/single.txt,{root/single.txt},ro"]), + cold_boot() + .with("--mount-aggregate", ["/run/nvx/shares"]) + .with( + "--mount-child", + [mount_child(0, "/config/single.txt", "{root/single.txt}", "ro,file")], + ) + .kernel(&[share_token(0, "/config/single.txt", "ro", true)]), + ), + dry_run( + "run: one relative shared file outside system paths", + run(&["--mount", "/config/single.txt,single.txt"]), + cold_boot() + .with("--mount-aggregate", ["/run/nvx/shares"]) + .with( + "--mount-child", + [mount_child(0, "/config/single.txt", "single.txt", "ro,file")], + ) + .kernel(&[share_token(0, "/config/single.txt", "ro", true)]), ), dry_run( "run: a directory and a file", @@ -860,12 +892,14 @@ fn run_dry_runs() -> Vec { "run: host path ending in a mode", run(&["--mount", "/data,{root/share},rw,ro"]), cold_boot().with("--mount", ["/data,{root/share},rw,ro"]), - ), + ) + .with_file("share,rw/data.txt", Content::Text("data\n")), dry_run( "run: host path ending in a comma", run(&["--mount", "/data,{root/share},,rw"]), cold_boot().with("--mount", ["/data,{root/share},,rw"]), - ), + ) + .with_file("share,/data.txt", Content::Text("data\n")), dry_run( "run: vmm owner", run(&["--mount", "/data,{root/share}", "--mount-owner", "vmm"]), @@ -934,7 +968,7 @@ fn run_dry_runs() -> Vec { ) .with("--cmdline", ["quiet"]), ), - // `nvx-rs run` passes the network options to OpenVMM as they are. + // A proxy is exclusive of direct egress allow rules. dry_run( "run: network", run(&[ @@ -974,12 +1008,69 @@ fn run_dry_runs() -> Vec { .with("--host-loopback", ["allow"]) .with("--network-proxy", ["10.0.0.1:3128"]) .with("--host-loopback-forward", ["tcp:8080:80", "udp:5353:53"]), + ) + .refusing("a network proxy takes no --network-egress-allow rules"), + dry_run( + "run: direct network rules and forwards", + run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "allow", + "--network-ingress", + "allow", + "--network-egress-allow", + "10.0.0.0/8:tcp:443", + "--network-egress-allow", + "2001:db8::/32", + "--network-egress-deny", + "10.1.0.0/16", + "--host-loopback", + "allow", + "--host-loopback-forward", + "tcp:8080:80", + "--host-loopback-forward", + "udp:5353:53", + ]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["allow"]) + .with("--network-ingress", ["allow"]) + .with("--network-egress-allow", ["10.0.0.0/8:tcp:443", "2001:db8::/32"]) + .with("--network-egress-deny", ["10.1.0.0/16"]) + .with("--host-loopback", ["allow"]) + .with("--host-loopback-forward", ["tcp:8080:80", "udp:5353:53"]), + ), + dry_run( + "run: proxy-only network", + run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", + "--network-egress", + "deny", + "--host-loopback", + "deny", + "--network-proxy", + "10.0.0.1:3128", + ]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["deny"]) + .with("--host-loopback", ["deny"]) + .with("--network-proxy", ["10.0.0.1:3128"]), ), dry_run( "run: egress default", run(&["--network-egress", "deny"]), cold_boot().with("--network-egress", ["deny"]), - ), + ) + .refusing("network policy options require --net"), // A restore takes the addressing of its NIC from the snapshot, so it passes the profile // and the policy without `--net`. dry_run( @@ -1072,16 +1163,23 @@ fn run_dry_runs() -> Vec { "run: host loopback forward", run(&["--host-loopback-forward", "tcp:8080:80"]), cold_boot().with("--host-loopback-forward", ["tcp:8080:80"]), - ), + ) + .refusing("network policy options require --net"), dry_run( "run: egress policy file", run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", "--network-egress", "deny", "--network-egress-policy-file", "{root/policy.json}", ]), cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) .with("--network-egress", ["deny"]) .with("--network-egress-allow", COMPILED_ALLOW) .with("--network-egress-deny", COMPILED_DENY), @@ -1090,12 +1188,19 @@ fn run_dry_runs() -> Vec { dry_run( "run: empty egress policy file", run(&[ + "--net", + "10.0.0.2/24", + "--network-profile", + "portable", "--network-egress", "allow", "--network-egress-policy-file", "{root/./policy.json}", ]), - cold_boot().with("--network-egress", ["allow"]), + cold_boot() + .with("--net", ["10.0.0.2/24"]) + .with("--network-profile", ["portable"]) + .with("--network-egress", ["allow"]), ) .with_file("policy.json", Content::Text("{}")), dry_run( @@ -1107,6 +1212,27 @@ fn run_dry_runs() -> Vec { "run: quoted command line", run(&["--cmdline", "a=\"b c\" d"]), cold_boot().with("--cmdline", ["a=\"b c\" d"]), + ) + .refusing( + "boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console", + ), + dry_run( + "run: standalone command line delimiter", + run(&["--cmdline", "quiet -- debug"]), + cold_boot().with("--cmdline", ["quiet -- debug"]), + ) + .refusing( + "boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console", + ), + dry_run( + "run: restore retains quoted command line and delimiter", + run(&[ + "--restore-snapshot", + "{root/snapshot}", + "--cmdline", + "a=\"b c\" -- d", + ]), + restore().with("--cmdline", ["a=\"b c\" -- d"]), ), dry_run( "run: command line starting with a dash", @@ -1133,14 +1259,6 @@ fn run_dry_runs() -> Vec { run(&["--cmdline", "a", "--cmdline", "b"]), cold_boot().with("--cmdline", ["b"]), ), - // One directory takes no boot token, so no budget limits the command line. - dry_run( - "run: long command line of one directory share", - run(&["--mount", "/data,{root/share}", "--cmdline", &long]), - cold_boot() - .with("--mount", ["/data,{root/share},ro"]) - .with("--cmdline", [long.as_str()]), - ), dry_run( "run: restore without the budget", run(&many), @@ -1202,8 +1320,35 @@ fn dry_runs_print_the_openvmm_command_and_launch_nothing() { } let before = root.snapshot(); let arguments: Vec<&str> = case.arguments.iter().map(String::as_str).collect(); + let cold = case + .arguments + .first() + .is_some_and(|command| command == "run") + && !case + .arguments + .iter() + .any(|argument| argument == "--restore-snapshot"); let outcome = root.nvx_rs(&arguments); eprintln!("case: {}", case.name); + if let Some(message) = case.refusal { + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!( + outcome.stderr().starts_with("error[unsupported]:"), + "{}", + outcome.describe() + ); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(root.snapshot(), before, "{}", case.name); + return; + } + let expected = if cold { + case.options + .cold_run_expected(&root, "one-shot") + .unwrap_or_else(|error| panic!("{} unexpectedly refused: {error}", case.name)) + } else { + case.options.expanded(&root) + }; outcome.assert_success(); let (command, rest) = outcome.command(); assert!(rest.is_empty(), "{}: {}", case.name, outcome.describe()); @@ -1214,8 +1359,13 @@ fn dry_runs_print_the_openvmm_command_and_launch_nothing() { "{}", case.name ); - case.options - .assert_matches(&root, &Options::parse(arguments), case.name); + let actual = Options::parse(arguments); + let actual = if cold { + actual.normalized_cold_outputs(true) + } else { + actual + }; + expected.assert_matches(&root, &actual, case.name); // The run launched no OpenVMM, which records its command line once it has accepted it. assert_eq!(root.snapshot(), before, "{}", case.name); }); @@ -1238,8 +1388,7 @@ fn command_lines() -> impl Strategy { #[test] fn the_openvmm_command_of_a_run_splits_back_into_its_arguments() { - // `nvx-rs run` passes its kernel command line to OpenVMM as it is, so the `>> ` line, split as - // this host splits command lines, must give it back whatever characters it holds: with a + // Accepted caller text survives the `>> ` line: with a // shell-word splitter on POSIX, and with `CommandLineToArgvW` on Windows. let root = Root::new(); let openvmm = root.below(OPENVMM).to_str().unwrap().to_owned(); @@ -1257,16 +1406,27 @@ fn the_openvmm_command_of_a_run_splits_back_into_its_arguments() { ]; let output = root.command(&arguments).output().unwrap(); let outcome = Outcome::new(arguments, output); + if command_line.contains('"') + || command_line.split_ascii_whitespace().any(|token| token == "--") + { + prop_assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + prop_assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + prop_assert_eq!( + outcome.stderr(), + format!("error[unsupported]: boot.command_line cannot contain double quotes or a standalone -- with the OpenVMM control console{}", crate::harness::LINE_END) + ); + return Ok(()); + } + let expected = cold_boot() + .with("--cmdline", [command_line.clone()]) + .cold_run_expected(&root, "one-shot") + .unwrap(); outcome.assert_success(); let (command, rest) = outcome.command(); prop_assert!(rest.is_empty(), "{}", outcome.describe()); prop_assert_eq!(&command[0], &openvmm); - let mut expected = cold_boot().expanded(&root); - if !command_line.is_empty() { - expected = expected.with("--cmdline", [command_line.clone()]); - } prop_assert_eq!( - Options::parse(&command[1..]), + Options::parse(&command[1..]).normalized_cold_outputs(true), expected, "{}", outcome.describe() diff --git a/nvx-rs/tests/cli/harness.rs b/nvx-rs/tests/cli/harness.rs index 1470a1cf5..acdd23e19 100644 --- a/nvx-rs/tests/cli/harness.rs +++ b/nvx-rs/tests/cli/harness.rs @@ -90,9 +90,17 @@ pub(crate) const PROVENANCE: [(&str, &str, &str); 3] = [ /// The other files of a root, by their paths below it, and their contents: the guest kernel and /// Alpine initramfs of a checkout, the layers of the sandboxes, of which only the distro layer /// has a role, their scratch image, and the directories and the file that they share. -const FILES: [(&str, &str); 12] = [ +const FILES: [(&str, &str); 14] = [ ("build/vmlinux", "kernel\n"), ("build/initramfs.cpio.gz", "initramfs\n"), + ( + "build/initramfs-ubuntu.cpio.gz.packages.json", + "{\"artifact_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}\n", + ), + ( + "build/initramfs-azurelinux.cpio.gz.packages.json", + "{\"artifact_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\"}\n", + ), ("distro.erofs", "distro\n"), ("runtime.erofs", "runtime\n"), ("custom.erofs", "custom\n"), diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index cef3c22a5..b14be2d20 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -49,6 +49,8 @@ struct Launch { stderr: &'static str, /// The outcome report that OpenVMM writes, if the run asks for one. report: Option, + /// A specific checked-configuration refusal, with no OpenVMM launch. + refusal: Option<&'static str>, } /// Returns the launch `name` of `arguments`, which starts OpenVMM with `options` and exits with @@ -75,10 +77,17 @@ fn launch( stdout, stderr: "", report: None, + refusal: None, } } impl Launch { + fn refusing(mut self, message: &'static str) -> Self { + self.refusal = Some(message); + self.status = 1; + self + } + /// Returns the case with the file `relative` written first, with `content`. fn with_file(mut self, (relative, content): (&'static str, Content)) -> Self { self.files.push((relative, content)); @@ -642,7 +651,16 @@ fn run_launches() -> Vec { "", ) .with_stderr("openvmm: --host-loopback-forward requires explicit --host-loopback allow\n") - .recorded_in(None), + .recorded_in(None) + .refusing("network policy options require --net"), + launch( + "run: OpenVMM exits with a usage status", + run(&["--cmdline", "fake_exit_on_start=2"]), + cold_boot().with("--cmdline", ["fake_exit_on_start=2"]), + 2, + "", + ) + .with_stderr("openvmm: failing the launch as requested\n"), // A relative NVX_OPENVMM is relative to the working directory, and the command names it // by its absolute path. launch( @@ -678,8 +696,30 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { .iter() .map(|(name, value)| (*name, value.as_str())) .collect(); + let cold = case + .arguments + .first() + .is_some_and(|command| command == "run") + && !case + .arguments + .iter() + .any(|argument| argument == "--restore-snapshot"); + let before = case.refusal.map(|_| root.snapshot()); let outcome = root.nvx_rs_with(&arguments, &environment); eprintln!("case: {}", case.name); + if let Some(message) = case.refusal { + assert_eq!(outcome.status, Some(case.status), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!( + outcome.stderr().starts_with("error[unsupported]:"), + "{}", + outcome.describe() + ); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(Some(root.snapshot()), before); + return; + } + let expected_cold = cold.then(|| case.options.cold_run_expected(&root, "one-shot")); assert_eq!(outcome.status, Some(case.status), "{}", outcome.describe()); let (command, rest) = outcome.command(); // The workload's output follows the command byte for byte, as does OpenVMM's. @@ -705,11 +745,28 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { case.name ); // The `>> ` line splits back into the arguments that OpenVMM received. - if let Some(record) = case.record { - assert_eq!(root.openvmm_arguments(record), arguments, "{}", case.name); - } let mut options = Options::parse(arguments); - let mut expected = case.options.clone(); + let record = case.record.map(|record| { + if cold { + let report = std::path::Path::new(options.value("--microvm-report")); + let token = report + .parent() + .unwrap() + .file_name() + .unwrap() + .to_str() + .unwrap(); + format!("build/fake-openvmm-{token}.json") + } else { + record.to_owned() + } + }); + if let Some(record) = record { + assert_eq!(root.openvmm_arguments(&record), arguments, "{}", case.name); + } + let mut expected = expected_cold + .map(|expected| expected.unwrap()) + .unwrap_or_else(|| case.options.clone()); // A sandbox's OpenVMM opens the images through the files that the run checked: the distro // layer alone for an upper in RAM, and the scratch image too for one on scratch. let blocks = expected.take("--microvm-sandbox-block"); @@ -728,6 +785,9 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { assert_opened_images(&root, token); } } + if cold { + options = options.normalized_cold_outputs(true); + } expected.assert_matches(&root, &options, case.name); if let Some(report) = &case.report { assert_outcome_report( @@ -740,6 +800,342 @@ fn one_shot_runs_exit_with_the_workload_or_openvmm_status() { }); } +#[test] +fn both_cold_modes_keep_their_foreground_output_and_owned_exit_status() { + for (mode, caller, status, stdout) in [ + ( + "one-shot", + "fake_exit_status=7", + 7, + "openvmm: booted the microVM\n", + ), + ("managed", "fake_crash_after_ms=100", 3, ""), + ] { + let root = Root::new(); + let identity = if mode == "managed" { + " nvx_identity=1001:1001" + } else { + "" + }; + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + &format!("{caller} nvx_mode={mode}{identity}"), + ]); + assert_eq!(outcome.status, Some(status), "{}", outcome.describe()); + let (command, output) = outcome.command(); + assert_eq!(output, stdout.as_bytes(), "{}", outcome.describe()); + assert_eq!(command[0], root.below(OPENVMM).to_str().unwrap()); + let mut expected = cold_boot() + .with( + "--cmdline", + [format!( + "{caller}{}", + if identity.is_empty() { " " } else { " " } + )], + ) + .cold_run_expected(&root, mode) + .unwrap(); + if !identity.is_empty() { + let line = format!("{}{identity}", expected.value("--cmdline")); + expected = expected.with("--cmdline", [line]); + } + let actual = Options::parse(&command[1..]).normalized_cold_outputs(true); + expected.assert_matches(&root, &actual, mode); + } +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_terminal_runs_forward_raw_output_and_restore_the_callers_terminal() { + use std::fs::File; + use std::io::{self, Read}; + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::fcntl::{FcntlArg, OFlag, fcntl}; + use nix::pty::openpty; + use nix::sys::termios::{LocalFlags, OutputFlags, tcgetattr}; + + for status in [0, 7] { + let root = Root::new(); + let terminal = openpty(None, None).unwrap(); + let original = tcgetattr(&terminal.slave).unwrap(); + let mut process = root + .command(&[ + "run", + "--cmdline", + &format!("fake_boot_delay_ms=250 fake_exit_status={status}"), + ]) + .stdin(Stdio::from(terminal.slave.try_clone().unwrap())) + .stdout(Stdio::from(terminal.slave.try_clone().unwrap())) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let mut observed_raw = false; + while process.try_wait().unwrap().is_none() { + let current = tcgetattr(&terminal.slave).unwrap(); + observed_raw |= !current + .local_flags + .intersects(LocalFlags::ICANON | LocalFlags::ECHO) + && !current.output_flags.contains(OutputFlags::OPOST); + if Instant::now() >= deadline { + process.kill().unwrap(); + process.wait().unwrap(); + panic!("terminal run did not exit within its deadline"); + } + std::thread::sleep(Duration::from_millis(5)); + } + let outcome = process.wait_with_output().unwrap(); + assert_eq!( + outcome.status.code(), + Some(status), + "{}", + String::from_utf8_lossy(&outcome.stderr) + ); + assert!(observed_raw, "cold run left its terminal cooked"); + let restored = tcgetattr(&terminal.slave).unwrap(); + assert_eq!(restored.input_flags, original.input_flags); + assert_eq!(restored.output_flags, original.output_flags); + assert_eq!(restored.local_flags, original.local_flags); + assert_eq!(restored.control_flags, original.control_flags); + assert_eq!(restored.control_chars, original.control_chars); + fcntl(&terminal.master, FcntlArg::F_SETFL(OFlag::O_NONBLOCK)).unwrap(); + let mut master = File::from(terminal.master); + let mut output = Vec::new(); + let mut buffer = [0; 8192]; + loop { + match master.read(&mut buffer) { + Ok(0) => break, + Ok(count) => output.extend_from_slice(&buffer[..count]), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => break, + Err(error) => panic!("cannot read terminal output: {error}"), + } + } + assert!( + output.ends_with(b"openvmm: booted the microVM\n"), + "{output:?}" + ); + } +} + +#[test] +fn cold_runs_refuse_existing_and_missing_parent_reports_before_launch() { + let root = Root::new(); + root.write("reports/existing.json", "keep\n"); + for (path, message) in [ + ( + "{root/reports/existing.json}", + "error[outcome-report-exists]:", + ), + ( + "{root/missing/outcome.json}", + "outcome report parent is not a plain directory", + ), + ] { + let before = root.snapshot(); + let outcome = root.nvx_rs(&["run", "--outcome-report", path]); + assert_eq!(outcome.status, Some(1), "{}", outcome.describe()); + assert!(outcome.stdout.is_empty(), "{}", outcome.describe()); + assert!(outcome.stderr().contains(message), "{}", outcome.describe()); + assert_eq!(root.snapshot(), before); + } +} + +#[test] +fn missing_cold_reports_do_not_replace_the_owned_child_status() { + for line in [ + "fake_exit_on_start=3", + "fake_crash_after_ms=100 nvx_mode=managed nvx_identity=1001:1001", + ] { + let root = Root::new(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + line, + "--outcome-report", + "{root/reports/outcome.json}", + ]); + assert_eq!(outcome.status, Some(3), "{}", outcome.describe()); + assert!( + outcome.stderr().contains("outcome.json"), + "{}", + outcome.describe() + ); + assert!(!root.below("reports/outcome.json").exists()); + } +} + +#[test] +fn cold_report_publication_never_clobbers_a_destination_created_during_boot() { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + + for symlink in [false, true] + .into_iter() + .filter(|symlink| !symlink || cfg!(unix)) + { + let root = Root::new(); + let destination = root.below("reports/outcome.json"); + root.write("reports/victim.txt", "keep\n"); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=250 fake_exit_status=7", + "--outcome-report", + destination.to_str().unwrap(), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut command = String::new(); + stdout.read_line(&mut command).unwrap(); + assert!(command.starts_with(">> "), "{command}"); + if symlink { + #[cfg(unix)] + std::os::unix::fs::symlink(root.below("reports/victim.txt"), &destination).unwrap(); + } else { + root.write("reports/outcome.json", "keep\n"); + } + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(7), "{output:?}"); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("outcome report already exists"), + "the publication failure must be reported" + ); + assert_eq!(std::fs::read(&destination).unwrap(), b"keep\n"); + assert_eq!( + std::fs::read(root.below("reports/victim.txt")).unwrap(), + b"keep\n" + ); + if symlink { + assert!( + std::fs::symlink_metadata(&destination) + .unwrap() + .is_symlink() + ); + } + } +} + +#[test] +fn cold_console_backpressure_does_not_block_output_or_replace_the_child_status() { + use std::io::{self, Write}; + use std::process::Stdio; + + let root = Root::new(); + let arguments = [ + "run", + "--cmdline", + "fake_boot_delay_ms=2500 fake_exit_status=7", + ]; + let mut process = root + .command(&arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdin = process.stdin.take().unwrap(); + let writer = std::thread::spawn(move || stdin.write_all(&vec![b'x'; 16 * 1024 * 1024])); + let output = process.wait_with_output().unwrap(); + if let Err(error) = writer.join().unwrap() { + assert_eq!(error.kind(), io::ErrorKind::BrokenPipe, "{error}"); + } + let outcome = crate::harness::Outcome::new( + arguments + .iter() + .map(|argument| (*argument).to_owned()) + .collect(), + output, + ); + assert_eq!(outcome.status, Some(7), "{}", outcome.describe()); + assert_eq!( + outcome.command().1, + b"openvmm: booted the microVM\n", + "{}", + outcome.describe() + ); +} + +#[test] +fn cold_console_output_preserves_non_utf8_bytes() { + let root = Root::new(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + "fake_binary_console=1 fake_exit_status=7", + ]); + assert_eq!(outcome.status, Some(7), "{}", outcome.describe()); + assert_eq!( + outcome.command().1, + &[b'A', 0xff, 0x80, b'\n'], + "{}", + outcome.describe() + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn cold_runs_keep_the_native_child_in_the_callers_session_and_process_group() { + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::unistd::{Pid, getpgid, getsid}; + + let root = Root::new(); + let process = root + .command(&["run", "--cmdline", "fake_boot_delay_ms=500"]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let caller = Pid::from_raw(process.id() as i32); + let deadline = Instant::now() + Duration::from_secs(10); + let child = loop { + let children = std::fs::read_to_string(format!( + "/proc/{}/task/{}/children", + caller.as_raw(), + caller.as_raw() + )) + .unwrap(); + let native = children.split_ascii_whitespace().find_map(|pid| { + let arguments = match std::fs::read(format!("/proc/{pid}/cmdline")) { + Ok(arguments) => arguments, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return None, + Err(error) => panic!("cannot inspect the owned child {pid}: {error}"), + }; + arguments + .split(|byte| *byte == 0) + .any(|argument| argument == b"--microvm-control-console") + .then(|| Pid::from_raw(pid.parse().unwrap())) + }); + if let Some(child) = native { + break child; + } + assert!( + Instant::now() < deadline, + "the owned native child did not appear" + ); + std::thread::sleep(Duration::from_millis(10)); + }; + let caller_session = getsid(Some(caller)).unwrap(); + let caller_group = getpgid(Some(caller)).unwrap(); + let child_session = getsid(Some(child)).unwrap(); + let child_group = getpgid(Some(child)).unwrap(); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert_eq!(child_session, caller_session); + assert_eq!(child_group, caller_group); +} + /// A run leaves an outcome report that the caller can read whatever its umask, even one that /// clears the owner's own permissions, and whose group's and others' permissions the caller's /// umask narrows: OpenVMM starts with the caller's umask without the owner's bits. diff --git a/nvx-rs/tests/cli/openvmm.rs b/nvx-rs/tests/cli/openvmm.rs index b25d02c20..48360f4cf 100644 --- a/nvx-rs/tests/cli/openvmm.rs +++ b/nvx-rs/tests/cli/openvmm.rs @@ -6,11 +6,27 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; +use std::path::{Path, PathBuf}; use std::sync::LazyLock; use serde_json::{Value, json}; -use crate::harness::{Content, HYPERVISOR, Root, is_hex}; +use crate::harness::{Content, HYPERVISOR, OPENVMM, Root, is_hex}; + +/// Caller bytes left by the two-directory fixture, its native bootstrap, and a terminating NUL. +pub(crate) fn two_share_command_line_room() -> usize { + use nvx::host::filesystem::GUEST_EXPORT; + use nvx::host::sandbox::mount::{ + AGGREGATE_MOUNT_TARGET, MountAccess, aggregate_command_line_fragment, + }; + + let kernel = "nvx_hostname=6e7678 nvx_machine_id=image nvx_maps=2 nvx_mode=one-shot \ + nvx_share=0,/a,ro nvx_share=1,/b,rw"; + let fragment = + aggregate_command_line_fragment(&[MountAccess::ReadOnly, MountAccess::ReadWrite]) + .replace(AGGREGATE_MOUNT_TARGET, GUEST_EXPORT); + nvx::host::config::COMMAND_LINE_BUDGET - kernel.len() - fragment.len() - 2 +} /// The options of OpenVMM that take no value. const FLAGS: [&str; 3] = [ @@ -169,6 +185,338 @@ impl Options { } panic!("{context}: the OpenVMM options differ:\n{differences}"); } + + /// Projects an old cold-run fixture onto the checked public VM contract, independently of + /// the CLI parser and its private token renderer. Restore and sandbox fixtures stay old. + pub(crate) fn cold_run_expected(&self, root: &Root, mode: &str) -> nvx::Result { + use nvx::activation::{MachineIdMode, View}; + use nvx::host::config::{Boot, ConsoleAttachment, NetworkConfig, RootConfig, VmConfig}; + use nvx::host::filesystem::{ + self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, + }; + use nvx::host::network::{ + Access, EgressRule, EgressRules, HostLoopback, LoopbackForward, NetworkPolicy, + NetworkProxy, + }; + use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; + use nvx::host::sandbox::mount::{MountAccess, parse_mount_value}; + use nvx::host::vm::{self, VmDir}; + + use nvx::host::network::{ForwardProtocol, Protocol}; + + let old = self.expanded(root); + let mut caller = old.values("--cmdline").first().cloned().unwrap_or_default(); + let mut shares = Vec::new(); + let mut files = BTreeSet::new(); + for value in old.values("--mount") { + let (target, host, access) = parse_mount_value(value)?; + let host = PathBuf::from(host); + let host = if host.is_absolute() { + host + } else { + root.below(host.to_str().unwrap()) + }; + if host.is_file() { + files.insert(target.clone()); + } + shares.push((target, host, access)); + } + let tokens: Vec<_> = caller.split_ascii_whitespace().map(str::to_owned).collect(); + let mut generated = Vec::new(); + for value in old.values("--mount-child") { + let child = value.strip_suffix(",file").unwrap_or(value); + let (name, host, access) = parse_mount_value(child)?; + let token = tokens + .iter() + .find(|token| { + token + .strip_prefix("nvx_share=") + .is_some_and(|share| share.starts_with(&format!("{name},"))) + }) + .expect("the aggregate fixture names every child's target"); + let parts: Vec<_> = token + .strip_prefix("nvx_share=") + .unwrap() + .split(',') + .collect(); + let target = parts[1].to_owned(); + let host = PathBuf::from(host); + let host = if host.is_absolute() { + host + } else { + root.below(host.to_str().unwrap()) + }; + if value.ends_with(",file") { + files.insert(target.clone()); + } + shares.push((target, host, access)); + generated.push(token.clone()); + } + for token in generated.iter().rev() { + caller = if caller == *token { + String::new() + } else { + caller + .strip_suffix(&format!(" {token}")) + .expect("fixture boot shares follow caller text") + .to_owned() + }; + } + let aggregate = shares.len() > 1 || !files.is_empty(); + let mut policies: [Vec; 3] = Default::default(); + for (index, option) in ["--mount-deny", "--mount-allow", "--mount-write"] + .into_iter() + .enumerate() + { + for value in old.values(option) { + let path = PathBuf::from(value); + if shares.is_empty() || (aggregate && !path.is_absolute()) { + return Err(nvx::Error::unsupported( + "boot share policies need a mapped path and absolute aggregate paths", + )); + } + policies[index].push(if path.is_absolute() { + path + } else { + shares[0].1.join(path) + }); + } + } + let mappings: Vec<_> = shares + .iter() + .map(|(target, host, access)| Mapping { + name: "--mount", + path: host, + target: Target::Guest(target), + read_only: *access == MountAccess::ReadOnly, + }) + .collect(); + let export = filesystem::plan(&ExportRequest { + mappings: &mappings, + denied: PathList { + name: "--mount-deny", + paths: &policies[0], + }, + allowed: PathList { + name: "--mount-allow", + paths: &policies[1], + }, + write: PathList { + name: "--mount-write", + paths: &policies[2], + }, + owner: if old + .values("--mount-owner") + .first() + .is_some_and(|value| value == "caller") + { + Owner::Caller + } else { + Owner::Vmm + }, + reserved: ReservedTargets::BLOCKLESS_ROOT, + })?; + let mut boot_tokens = vec![format!("nvx_mode={mode}")]; + if let Some(export) = &export { + boot_tokens.extend(export.guest_table().into_iter().map(|row| { + let access = if row.read_only { "ro" } else { "rw" }; + let kind = if files.contains(&row.target) { + ",file" + } else { + "" + }; + format!("nvx_share={},{},{access}{kind}", row.source, row.target) + })); + } + if !caller.is_empty() { + boot_tokens.insert(0, caller); + } + if old.values("--net").is_empty() + && [ + "--network-egress", + "--network-ingress", + "--network-egress-allow", + "--network-egress-deny", + "--host-loopback", + "--host-loopback-forward", + "--network-proxy", + ] + .into_iter() + .any(|option| !old.values(option).is_empty()) + { + return Err(nvx::Error::unsupported( + "network policy options require --net", + )); + } + let network = if let Some(net) = old.values("--net").first() { + let access = |option, default| match old.values(option).first().map(String::as_str) { + Some("allow") => Access::Allow, + Some("deny") => Access::Deny, + _ => default, + }; + let mut policy = NetworkPolicy::new(access("--network-egress", Access::Allow)); + for (option, destination) in [ + ("--network-egress-allow", &mut policy.allow), + ("--network-egress-deny", &mut policy.deny), + ] { + *destination = EgressRules { + name: option.to_owned(), + rules: old + .values(option) + .iter() + .map(|value| { + for (suffix, protocol) in [ + (":tcp", Protocol::Tcp), + (":udp", Protocol::Udp), + (":icmp", Protocol::Icmp), + ] { + if let Some((destination, ports)) = value.split_once(suffix) { + let rule = EgressRule::to(destination); + return if ports.is_empty() { + rule.on_protocol(protocol) + } else { + let ports = ports.strip_prefix(':').unwrap(); + if let Some((first, last)) = ports.split_once('-') { + rule.on_port_range( + protocol, + first.parse().unwrap(), + last.parse().unwrap(), + ) + } else { + rule.on_port(protocol, ports.parse().unwrap()) + } + }; + } + } + EgressRule::to(value) + }) + .collect(), + }; + } + policy.ingress = access("--network-ingress", Access::Deny); + policy.host_loopback = match old.values("--host-loopback").first().map(String::as_str) { + Some("allow") => HostLoopback::Allow, + Some("deny") => HostLoopback::Deny, + _ => HostLoopback::Outbound, + }; + policy.host_loopback_forwards = old + .values("--host-loopback-forward") + .iter() + .map(|value| { + let fields: Vec<_> = value.split(':').collect(); + LoopbackForward { + protocol: match fields[0] { + "tcp" => ForwardProtocol::Tcp, + "udp" => ForwardProtocol::Udp, + _ => panic!("a fixture names tcp or udp"), + }, + host_port: fields[1].parse().unwrap(), + guest_port: fields[2].parse().unwrap(), + } + }) + .collect(); + if let Some(proxy) = old.values("--network-proxy").first() { + let (gateway, port) = proxy.rsplit_once(':').unwrap(); + assert_eq!(gateway, nvx::net::guest_gateway(net).unwrap().to_string()); + policy.proxy = Some(NetworkProxy::new(port.parse().unwrap())); + } + Some(NetworkConfig { + guest_network: Some(nvx::net::Ipv4Cidr::parse(net).unwrap()), + policy, + }) + } else { + None + }; + let config = VmConfig { + processors: old.value("--processors").parse().unwrap(), + memory_mib: old.value("--memory").trim_end_matches('M').parse().unwrap(), + root: RootConfig::Blockless, + view: View { + hostname: b"nvx".to_vec(), + machine_id: MachineIdMode::Image, + }, + network, + filesystem: export, + console: ConsoleAttachment::Listener, + boot: Some(Boot { + command_line: boot_tokens.join(" "), + memory_capacity_mib: old + .values("--memory-capacity") + .first() + .map(|value| value.trim_end_matches('M').parse().unwrap()), + capture: None, + }), + }; + config.check_command_line()?; + let identity = ArtifactIdentity::new("test-fixture").unwrap(); + let runtime = Runtime { + openvmm: root.below(OPENVMM), + kernel: PathBuf::from(old.value("--kernel")), + initramfs: PathBuf::from(old.value("--initrd")), + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), + hypervisor: old.value("--hypervisor").parse().unwrap(), + cpu_profile: old.values("--cpu-profile").first().cloned(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("expected-vm"); + vm::create_private_dir(&path)?; + let prepared = vm::create(&runtime, &config, None, &VmDir::open(&path)?)?; + let arguments: Vec<_> = prepared + .arguments() + .iter() + .map(|value| value.to_str().unwrap().to_owned()) + .collect(); + let expected = Self::parse(&arguments).normalized_cold_outputs(false); + drop(prepared); + Ok(expected) + } + + /// Checks fresh output names rather than making random endpoint values an assertion escape. + pub(crate) fn normalized_cold_outputs(&self, removed: bool) -> Self { + let mut options = self.clone(); + let report = PathBuf::from(options.value("--microvm-report")); + assert!(report.is_absolute()); + assert_eq!(report.file_name().unwrap(), "outcome.json"); + let parent = report.parent().unwrap(); + if removed { + let name = parent.file_name().unwrap().to_str().unwrap(); + let suffix = name + .strip_prefix("nvx-run-") + .expect("a cold CLI run owns a fresh VM directory"); + assert_eq!(suffix.len(), 32); + assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit())); + assert!( + !parent.exists(), + "the foreground run left its VM directory: {}", + parent.display() + ); + } + let console = options + .value("--virtio-console") + .strip_prefix("listen=") + .unwrap(); + let control = options + .value("--microvm-control-console") + .strip_prefix("listen=") + .unwrap(); + assert_ne!(console, control); + for (endpoint, name) in [(console, "console.sock"), (control, "control.sock")] { + if cfg!(windows) { + let suffix = endpoint.strip_prefix("//./pipe/openvmm-microvm-").unwrap(); + assert_eq!(suffix.len(), 32); + assert!(suffix.bytes().all(|byte| byte.is_ascii_hexdigit())); + } else { + assert_eq!(Path::new(endpoint), parent.join(name)); + } + } + options = options + .with("--virtio-console", ["listen="]) + .with("--microvm-control-console", ["listen="]) + .with("--microvm-report", [""]); + options + } } /// Returns the options with which a one-shot run of the minimal sandbox, `sandbox run --layer diff --git a/nvx-rs/tests/cli/validation.rs b/nvx-rs/tests/cli/validation.rs index 441e54fc9..7cb9f24ff 100644 --- a/nvx-rs/tests/cli/validation.rs +++ b/nvx-rs/tests/cli/validation.rs @@ -729,6 +729,7 @@ fn sandbox_refusals() -> Vec { fn run_refusals() -> Vec { let targets = "with several shares, or a shared file"; let budget = &["--mount", "--cmdline", "1024"][..]; + let overflow = crate::openvmm::two_share_command_line_room() + 1; let two_shares = |cmdline: &str| { run_dry(&[ "--mount", @@ -740,7 +741,7 @@ fn run_refusals() -> Vec { ]) }; let mut many = vec!["run".to_owned()]; - many.extend(shares(20)); + many.extend(shares(42)); many.push("--dry-run".to_owned()); vec![ refusal( @@ -1230,13 +1231,12 @@ fn run_refusals() -> Vec { ]), &["--mount target", "\"/voil\u{e0}\"", targets], ), - // The target of one directory share reaches the kernel command line in OpenVMM's - // virtfs_dir= token. + // A single directory is now checked for the same planned nvx_share= row as an aggregate. refusal( "run: quoted target of one share", "unsupported", run_dry(&["--mount", "/a\"b,{root/share}"]), - &["--mount target", "\"/a\\\"b\"", "one kernel token"], + &["--mount target", "\"/a\\\"b\"", "quotes"], ), refusal( "run: target with a dot component", @@ -1331,14 +1331,27 @@ fn run_refusals() -> Vec { refusal( "run: budget exceeded by a byte", "unsupported", - two_shares(&"x".repeat(842)), + two_shares(&"x".repeat(overflow)), budget, ), // The budget is of bytes: each of these characters takes two. refusal( "run: budget in UTF-8 bytes", "unsupported", - two_shares(&"\u{e9}".repeat(421)), + two_shares(&("\u{e9}".repeat(overflow / 2) + &"x".repeat(overflow % 2))), + budget, + ), + // A single directory also has a planned boot bind now; keep the former unbounded + // positive case as a checked refusal rather than dropping its input. + refusal( + "run: long command line of one directory share", + "unsupported", + run_dry(&[ + "--mount", + "/data,{root/share}", + "--cmdline", + &"x".repeat(1100), + ]), budget, ), refusal( @@ -1352,7 +1365,7 @@ fn run_refusals() -> Vec { "run: caller owner on Windows", "unsupported", run_dry(&["--mount", "/data,{root/share}", "--mount-owner", "caller"]), - &["--mount-owner caller", "Linux"], + &["caller owner", "Linux"], ) .on(Hosts::Windows), refusal( From b08649de3ddd0ea558d331bfeb4c0f2e73fb3fdf Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:16:15 -0800 Subject: [PATCH 20/30] docs: document CLI foreground child ownership Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/vm.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nvx-rs/src/host/vm.rs b/nvx-rs/src/host/vm.rs index b90325116..9c0fbd585 100644 --- a/nvx-rs/src/host/vm.rs +++ b/nvx-rs/src/host/vm.rs @@ -324,6 +324,7 @@ pub struct Prepared { } impl Prepared { + /// Keeps this CLI launch in its caller's session, process group, and console. #[cfg(feature = "cli")] pub(crate) fn for_foreground(mut self) -> Self { self.foreground = true; @@ -518,10 +519,12 @@ pub struct Launched { pub outputs: VmOutputs, /// The control credential, never written to disk. pub capability: Capability, + /// Retains only a foreground caller's child for status and control attachment. #[cfg(feature = "cli")] child: ForegroundChild, } +/// Reaps a foreground child on release without adding `Drop` to the public launch value. #[cfg(feature = "cli")] #[derive(Debug)] struct ForegroundChild(Option); @@ -537,6 +540,7 @@ impl Drop for ForegroundChild { #[cfg(feature = "cli")] impl Launched { + /// Polls the owned child independently of its optional outcome report. pub(crate) fn wait_status(&mut self, timeout: Duration) -> io::Result> { Ok(self .child_mut()? @@ -544,6 +548,7 @@ impl Launched { .map(|status| status as i32)) } + /// Gives the CLI connector the same owned child whose exit it observes. pub(crate) fn child_mut(&mut self) -> io::Result<&mut spawn::Launched> { self.child .0 From 8268f52d7591fdfa8ce6eb4fca2de11d9ff35acb Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:52:51 -0800 Subject: [PATCH 21/30] cli: preserve unrecorded run artifacts and planned-share runtime coverage Do not require install provenance or hash an override for a cold run that neither captures nor reattaches. Restore the public artifact identity implementation and remove its unused private extension and fixtures. Exercise metadata-free dry runs and overrides. Update the real comma-path guest scenario for planned aggregate binds, including all-read-only root remounts while retaining host write-denial checks. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/artifacts/dir.rs | 46 +++++-------------- nvx-rs/src/host/artifacts/tests.rs | 25 ---------- nvx-rs/src/host/cli/run.rs | 40 +++++----------- nvx-rs/tests/cli/dry_run.rs | 4 ++ nvx-rs/tests/cli/harness.rs | 10 +--- nvx-rs/tests/cli/one_shot.rs | 5 +- .../filesystem-comma-paths.sh.in | 19 +++----- scripts/nvx_tools/microvm_tests.py | 8 ++-- scripts/test_microvm_tests.py | 6 ++- 9 files changed, 49 insertions(+), 114 deletions(-) diff --git a/nvx-rs/src/host/artifacts/dir.rs b/nvx-rs/src/host/artifacts/dir.rs index 08d80a541..dddab5c11 100644 --- a/nvx-rs/src/host/artifacts/dir.rs +++ b/nvx-rs/src/host/artifacts/dir.rs @@ -235,13 +235,8 @@ impl ArtifactDir { /// unreadable, or names no valid digest of an artifact. A file of metadata that is not a /// regular file, such as a FIFO, is refused without being waited on. pub fn runtime_identity(&self) -> Result { - self.runtime_identity_for(Guest::Alpine) - } - - /// Identifies the selected guest through its install metadata, without reading its image. - pub(crate) fn runtime_identity_for(&self, guest: Guest) -> Result { let [openvmm, kernel, initramfs] = match self.layout { - Layout::Release => self.listed_identities(guest)?, + Layout::Release => self.listed_identities()?, Layout::Repository => [ provenance_identity( &self.artifact(OPENVMM_PROVENANCE_NAME), @@ -253,33 +248,11 @@ impl ArtifactDir { "kernel", "kernel_sha256", )?, - if guest == Guest::Alpine { - provenance_identity( - &self.artifact(INITRAMFS_PROVENANCE_NAME), - "initramfs", - "initramfs_sha256", - )? - } else { - let path = self.package_manifest(guest); - let text = read_metadata( - &path, - "guest package manifest", - "build or install the selected guest", - )?; - let invalid = || { - Error::unavailable(format!( - "guest package manifest {} has no valid artifact SHA-256 digest", - path.display() - )) - }; - let manifest: serde_json::Value = serde_json::from_str(&text) - .map_err(|error| invalid().with_source(error))?; - manifest - .get("artifact_sha256") - .and_then(serde_json::Value::as_str) - .and_then(sha256_identity) - .ok_or_else(invalid)? - }, + provenance_identity( + &self.artifact(INITRAMFS_PROVENANCE_NAME), + "initramfs", + "initramfs_sha256", + )?, ], }; Ok(RuntimeIdentity::new(openvmm, kernel, initramfs)) @@ -288,7 +261,7 @@ impl ArtifactDir { /// Returns the identities of the OpenVMM executable, kernel, and Alpine initramfs of a /// release, as its `SHA256SUMS` lists their digests: one line for each file, its 64 /// lowercase hexadecimal digits, two spaces, and its path in the release. - fn listed_identities(&self, guest: Guest) -> Result<[ArtifactIdentity; 3]> { + fn listed_identities(&self) -> Result<[ArtifactIdentity; 3]> { let path = self.root.join(CHECKSUMS_NAME); let description = "release checksum list"; let text = read_metadata(&path, description, "the release is incomplete")?; @@ -322,7 +295,10 @@ impl ArtifactDir { Ok([ listed(format!("{RELEASE_BIN_DIR}/{}", openvmm_executable()))?, listed(format!("{RELEASE_GUEST_DIR}/{KERNEL_NAME}"))?, - listed(format!("{RELEASE_GUEST_DIR}/{}", guest.initramfs_name()))?, + listed(format!( + "{RELEASE_GUEST_DIR}/{}", + Guest::Alpine.initramfs_name() + ))?, ]) } } diff --git a/nvx-rs/src/host/artifacts/tests.rs b/nvx-rs/src/host/artifacts/tests.rs index 8d0b14be1..3e4de6351 100644 --- a/nvx-rs/src/host/artifacts/tests.rs +++ b/nvx-rs/src/host/artifacts/tests.rs @@ -574,31 +574,6 @@ fn a_checkout_identifies_its_runtime_by_the_build_provenance_of_its_artifacts() ); } -#[test] -fn selected_guests_take_their_own_initramfs_digest_from_install_metadata() { - let directory = tempfile::tempdir().unwrap(); - let dir = ArtifactDir::repository(directory.path()).unwrap(); - let digests = ["a", "b", "c"].map(|digit| digit.repeat(64)); - record_provenance(directory.path(), &digests); - for guest in [Guest::Ubuntu, Guest::AzureLinux] { - let selected = "d".repeat(64); - let manifest = dir.package_manifest(guest); - fs::write(&manifest, format!("{{\"artifact_sha256\":\"{selected}\"}}")).unwrap(); - let identity = dir.runtime_identity_for(guest).unwrap(); - assert_eq!( - identity.initramfs, - ArtifactIdentity::sha256(&selected).unwrap() - ); - assert_eq!(identity.openvmm, identity_of(&digests).openvmm); - fs::write(&manifest, "{\"artifact_sha256\":\"invalid\"}").unwrap(); - assert_eq!( - dir.runtime_identity_for(guest).unwrap_err().kind(), - ErrorKind::Unavailable - ); - } - assert_eq!(dir.runtime_identity().unwrap(), identity_of(&digests)); -} - #[test] fn a_release_identifies_its_runtime_by_its_checksum_list() { let directory = tempfile::tempdir().unwrap(); diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 914d2e587..0dd64df23 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -52,8 +52,6 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; -use sha2::{Digest, Sha256}; - use super::args::{Parser, Receiver, Spec, Syntax, choice, integer}; use super::artifacts::{Located, locate}; use super::cpu_profile::{self, HostCpu}; @@ -74,7 +72,7 @@ use crate::host::filesystem::{ self, ExportRequest, Mapping, Owner, PathList, ReservedTargets, Target, }; use crate::host::platform::{self, Transport}; -use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime}; +use crate::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; use crate::host::sandbox::mount::{ AGGREGATE_MOUNT_TARGET, MountAccess, MountKind, MountOwner, POLICY_KINDS, PolicyKind, aggregate_child_argument, aggregate_child_name, aggregate_command_line_fragment, @@ -898,40 +896,26 @@ fn run_cold( { validate_outcome_destination(report)?; } - let mut identity = dir - .runtime_identity_for(options.guest) - .map_err(error_with_cause)?; - if openvmm != dir.openvmm() { - let mut file = File::open(&openvmm).map_err(Exit::from_os_error)?; - let mut digest = Sha256::new(); - let mut buffer = [0; 8192]; - loop { - let count = file.read(&mut buffer).map_err(Exit::from_os_error)?; - if count == 0 { - break; - } - digest.update(&buffer[..count]); - } - identity.openvmm = ArtifactIdentity::sha256(&format!("{:x}", digest.finalize()))?; - } + let mut random = [0; 16]; + getrandom::fill(&mut random).map_err(|error| { + Exit::error( + ErrorCode::Io, + format!("cannot name the temporary VM: {error}"), + ) + })?; + let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); + // Cold CLI runs neither capture nor reattach; this per-invocation identity is not persisted. + let identity = ArtifactIdentity::new(format!("unrecorded-run:{name}"))?; let runtime = Runtime { openvmm, kernel, initramfs, - identity, + identity: RuntimeIdentity::new(identity.clone(), identity.clone(), identity), hypervisor: hypervisor(options.hypervisor).parse()?, cpu_profile: options.cpu_profile.clone(), windows_breakaway: false, options: LaunchOptions::default(), }; - let mut random = [0; 16]; - getrandom::fill(&mut random).map_err(|error| { - Exit::error( - ErrorCode::Io, - format!("cannot name the temporary VM: {error}"), - ) - })?; - let name: String = random.iter().map(|byte| format!("{byte:02x}")).collect(); let path = std::env::temp_dir().join(format!("nvx-run-{name}")); if path.try_exists().map_err(Exit::from_os_error)? { return Err(Exit::error( diff --git a/nvx-rs/tests/cli/dry_run.rs b/nvx-rs/tests/cli/dry_run.rs index ec2c6984d..104482699 100644 --- a/nvx-rs/tests/cli/dry_run.rs +++ b/nvx-rs/tests/cli/dry_run.rs @@ -423,6 +423,10 @@ fn run_dry_runs() -> Vec { let fits = "x".repeat(crate::openvmm::two_share_command_line_room()); vec![ dry_run("run: Alpine default", run(&[]), cold_boot()), + dry_run("run: no install metadata", run(&[]), cold_boot()) + .without("build/openvmm.provenance.json") + .without("build/vmlinux.provenance.json") + .without("build/initramfs.provenance.json"), dry_run( "run: Ubuntu default", run(&["--guest", "ubuntu"]), diff --git a/nvx-rs/tests/cli/harness.rs b/nvx-rs/tests/cli/harness.rs index acdd23e19..1470a1cf5 100644 --- a/nvx-rs/tests/cli/harness.rs +++ b/nvx-rs/tests/cli/harness.rs @@ -90,17 +90,9 @@ pub(crate) const PROVENANCE: [(&str, &str, &str); 3] = [ /// The other files of a root, by their paths below it, and their contents: the guest kernel and /// Alpine initramfs of a checkout, the layers of the sandboxes, of which only the distro layer /// has a role, their scratch image, and the directories and the file that they share. -const FILES: [(&str, &str); 14] = [ +const FILES: [(&str, &str); 12] = [ ("build/vmlinux", "kernel\n"), ("build/initramfs.cpio.gz", "initramfs\n"), - ( - "build/initramfs-ubuntu.cpio.gz.packages.json", - "{\"artifact_sha256\":\"dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd\"}\n", - ), - ( - "build/initramfs-azurelinux.cpio.gz.packages.json", - "{\"artifact_sha256\":\"eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee\"}\n", - ), ("distro.erofs", "distro\n"), ("runtime.erofs", "runtime\n"), ("custom.erofs", "custom\n"), diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index b14be2d20..7723f5ac6 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -670,7 +670,10 @@ fn run_launches() -> Vec { 0, booted, ) - .with_variable("NVX_OPENVMM", native(OPENVMM)), + .with_variable("NVX_OPENVMM", native(OPENVMM)) + .without("build/openvmm.provenance.json") + .without("build/vmlinux.provenance.json") + .without("build/initramfs.provenance.json"), ] } diff --git a/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in b/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in index 8468e6a6a..96aee1e1c 100644 --- a/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in +++ b/scripts/nvx_tools/microvm_test_scripts/filesystem-comma-paths.sh.in @@ -16,14 +16,10 @@ expect_read_only() { esac } -# A lone share is the root of the virtio-fs device, and several are the -# children of an aggregate, which the guest binds at their targets. -aggregate=@AGGREGATE@ -if [ "$aggregate" = 1 ]; then - grep -q 'virtfs_dir=/run/nvx/shares virtfs_tag=microvm virtfs_mode=rw virtfs_aggregate=1' /proc/cmdline || fail 10 -elif grep -q 'virtfs_aggregate=' /proc/cmdline; then - fail 11 -fi +# The checked export plan always uses an aggregate, even for one bind. +aggregate=/run/nvx/hostfs/root +export_mode=@EXPORT_MODE@ +grep -q "virtfs_dir=$aggregate virtfs_tag=microvm virtfs_mode=$export_mode virtfs_aggregate=1" /proc/cmdline || fail 10 # Each TARGET:MODE:SEED names a share's guest target, its mode, and the # contents of its seed file. @@ -51,11 +47,10 @@ for share in @SHARES@; do expect_read_only "$error" $((code + 6)) # OpenVMM enforces the mode on the host, so the share refuses writes # even after guest root makes its mount read-write. - if [ "$aggregate" = 1 ]; then - mount -o remount,bind,rw "$target" || fail $((code + 7)) - else - mount -o remount,rw "$target" || fail $((code + 7)) + if [ "$export_mode" = ro ]; then + mount -o remount,rw "$aggregate" || fail $((code + 7)) fi + mount -o remount,bind,rw "$target" || fail $((code + 7)) grep -q "^microvm $target virtiofs rw," /proc/mounts || fail $((code + 8)) if error=$(touch "$target/from-guest" 2>&1); then fail $((code + 9)) diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index 41fec1495..c4ace8282 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -9985,8 +9985,8 @@ def run_filesystem_comma_paths( ) -> None: """Share host directories whose paths contain commas through `nvx-rs run`: a read-write `alpha,beta` and a read-only `2026, final`, each with a - denied subdirectory, first each alone, as OpenVMM's `--mount`, and then - both, as the children of an aggregate. The guest must find each exact + denied subdirectory, first each alone and then both through the checked + export plan's aggregate and guest binds. The guest must find each exact directory, rather than the one beside it that the text before the last comma of its path names, with its mode, which OpenVMM enforces, and without its denied subdirectory.""" @@ -10031,7 +10031,9 @@ def run_filesystem_comma_paths( command, _render_script( "filesystem-comma-paths.sh.in", - AGGREGATE="1" if len(attached) > 1 else "0", + EXPORT_MODE=( + "rw" if any(shares[share][1] == "rw" for share in attached) else "ro" + ), SHARES=" ".join(":".join(shares[share]) for share in attached), ), FILESYSTEM_COMMA_PATHS_MARKER, diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index aeeb7f3a2..1ab994326 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -3909,7 +3909,11 @@ def test_scenario_shares_each_directory_alone_and_both_together(self): self.assertEqual((host.parent.name, host.name), (parent, name)) self.assertEqual(Path(deny), host / "secret") self.assertIn(f"{target}:{mode}:{seed}", script) - self.assertIn(f"\naggregate={int(len(shares) > 1)}\n", script) + export_mode = "rw" if any(share[3] == "rw" for share in shares) else "ro" + self.assertIn("\naggregate=/run/nvx/hostfs/root\n", script) + self.assertIn(f"\nexport_mode={export_mode}\n", script) + self.assertIn('mount -o remount,rw "$aggregate"', script) + self.assertIn('mount -o remount,bind,rw "$target"', script) self.assertNotIn("@", script) def test_scenario_rejects_a_write_to_the_read_only_directory(self): From 570b331b5a7f79e55c742d44557ddee7327fa895 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 04:58:57 -0800 Subject: [PATCH 22/30] style: format planned-share scenario export mode Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- scripts/nvx_tools/microvm_tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index c4ace8282..e4dc5e1e8 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -10032,7 +10032,9 @@ def run_filesystem_comma_paths( _render_script( "filesystem-comma-paths.sh.in", EXPORT_MODE=( - "rw" if any(shares[share][1] == "rw" for share in attached) else "ro" + "rw" + if any(shares[share][1] == "rw" for share in attached) + else "ro" ), SHARES=" ".join(":".join(shares[share]) for share in attached), ), From 46adcc13f797838eef599b34a260bdf275bfa393 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 05:32:29 -0800 Subject: [PATCH 23/30] test: align download cleanup with restored cold-run fixtures Remove the two no-longer-installed guest metadata entries from the fixture cleanup list, restoring this file exactly to the qualified base. Keep all six download test bodies and assertions unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/tests/cli/download.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nvx-rs/tests/cli/download.rs b/nvx-rs/tests/cli/download.rs index 9a041cedb..f9d758cea 100644 --- a/nvx-rs/tests/cli/download.rs +++ b/nvx-rs/tests/cli/download.rs @@ -10,15 +10,13 @@ use std::time::{Duration, Instant}; use crate::harness::{LAYER, NVX_RS, OPENVMM, Root, SCRATCH, UUID}; /// The files of a test root that a release installs. -const INSTALLED: [&str; 8] = [ +const INSTALLED: [&str; 6] = [ OPENVMM, "build/vmlinux", "build/initramfs.cpio.gz", "build/openvmm.provenance.json", "build/vmlinux.provenance.json", "build/initramfs.provenance.json", - "build/initramfs-ubuntu.cpio.gz.packages.json", - "build/initramfs-azurelinux.cpio.gz.packages.json", ]; /// Returns a root that is a checkout of NVX, with its `SOURCE-MANIFEST.json` and a `.git` that is From 9ff7907dcea203da9cd054e036e42d6abbfb5778 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 08:51:05 -0800 Subject: [PATCH 24/30] cli: address foreground connection, output, and report findings Recheck the owned child before surfacing endpoint authentication races, use the standard Unicode-aware stdout path, avoid idle sleeps while streams advance, and publish requested native reports after confirmed exit even on foreground errors. Add focused peer, private Windows console, streaming, output-error and interruption regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/run.rs | 91 +++++++++-------- nvx-rs/src/host/cli/run/tests.rs | 151 +++++++++++++++++++++++++++++ nvx-rs/src/testing/fake_openvmm.rs | 35 +++++++ nvx-rs/tests/cli/one_shot.rs | 129 ++++++++++++++++++++++++ 4 files changed, 368 insertions(+), 38 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 0dd64df23..8efd7c6e4 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -44,8 +44,6 @@ mod guest_tokens; use std::fs::{self, File}; -#[cfg(windows)] -use std::io::Write; use std::io::{self, IsTerminal, Read}; use std::path::Path; use std::sync::mpsc; @@ -642,32 +640,55 @@ fn connect_running( launched: &mut vm::Launched, endpoint: &str, ) -> Result>> { + let pid = launched.process.reference().pid; + connect_before_exit( + endpoint, + || platform::connect_endpoint(endpoint, pid, Duration::from_millis(250)), + |timeout| launched.wait_status(timeout), + ) +} + +/// Connects to the verified endpoint, or yields to the owned child's confirmed exit. +fn connect_before_exit( + endpoint: &str, + mut connect: impl FnMut() -> io::Result, + mut wait_status: impl FnMut(Duration) -> io::Result>, +) -> Result> { let deadline = Instant::now() + Duration::from_secs(60); loop { check_interrupt()?; - match platform::connect_endpoint( - endpoint, - launched.process.reference().pid, - Duration::from_millis(250), - ) { - Ok(transport) => return Ok(Some(transport)), - Err(error) - if matches!( - error.kind(), - io::ErrorKind::NotFound - | io::ErrorKind::ConnectionRefused - | io::ErrorKind::TimedOut - | io::ErrorKind::WouldBlock - ) => {} - Err(error) => return Err(Exit::from_os_error(error)), + if wait_status(Duration::ZERO) + .map_err(Exit::from_os_error)? + .is_some() + { + return Ok(None); } - if launched - .wait_status(Duration::ZERO) + let error = match connect() { + Ok(transport) => return Ok(Some(transport)), + Err(error) => error, + }; + let retry = matches!( + error.kind(), + io::ErrorKind::NotFound + | io::ErrorKind::ConnectionRefused + | io::ErrorKind::TimedOut + | io::ErrorKind::WouldBlock + ); + // Peer authentication may observe exit just before the child status becomes available. + let exit_wait = if retry { + Duration::ZERO + } else { + Duration::from_millis(250).min(deadline.saturating_duration_since(Instant::now())) + }; + if wait_status(exit_wait) .map_err(Exit::from_os_error)? .is_some() { return Ok(None); } + if !retry { + return Err(Exit::from_os_error(error)); + } if Instant::now() >= deadline { return Err(Exit::error( ErrorCode::TimedOut, @@ -765,18 +786,6 @@ fn console_input(console: Box) -> Result<(Box, Inp } fn write_console(data: &[u8]) -> io::Result<()> { - #[cfg(windows)] - { - use std::os::windows::io::AsHandle; - - if platform::interrupted() { - return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); - } - let mut stdout = io::stdout().lock(); - stdout.flush()?; - File::from(stdout.as_handle().try_clone_to_owned()?).write_all(data) - } - #[cfg(not(windows))] output::write_stdout(data) } @@ -819,6 +828,7 @@ fn foreground( loop { check_interrupt()?; let count = log.read(&mut buffer).map_err(Exit::from_os_error)?; + let mut progressed = count != 0; output::write_stderr(&buffer[..count]).map_err(Exit::from_os_error)?; let status = launched .wait_status(Duration::ZERO) @@ -832,7 +842,10 @@ fn foreground( if let Some(transport) = &mut console { match transport.read(&mut buffer, Some(Duration::from_millis(10))) { Ok(0) => console = None, - Ok(count) => write_console(&buffer[..count]).map_err(Exit::from_os_error)?, + Ok(count) => { + write_console(&buffer[..count]).map_err(Exit::from_os_error)?; + progressed = true; + } Err(error) if matches!( error.kind(), @@ -877,7 +890,9 @@ fn foreground( if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { return Err(Exit::EndpointClosed); } - thread::sleep(Duration::from_millis(10)); + if !progressed { + thread::sleep(Duration::from_millis(10)); + } } } @@ -990,6 +1005,11 @@ fn run_cold( if let Some(error) = graceful_error { output::print_stderr(&format!("VM graceful stop failed: {error:?}")); } + if let Some(report) = &options.outcome_report + && let Err(error) = copy_openvmm_outcome(&launched.outputs.outcome, report) + { + error.report(); + } check_interrupt()?; let status = status?; if let Err(error) = stopped.report { @@ -1000,11 +1020,6 @@ fn run_cold( .unwrap_or("cannot read VM outcome") )); } - if let Some(report) = &options.outcome_report - && let Err(error) = copy_openvmm_outcome(&launched.outputs.outcome, report) - { - error.report(); - } if status != 0 && let Some(guidance) = cpu_profile::guidance( options.cpu_profile.as_deref(), diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 1578d6a74..7892e519a 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -87,6 +87,157 @@ fn cold_share_command_lines_fit_the_kernel_budget_to_the_byte() { } } +#[test] +fn a_console_peer_exit_waits_for_the_owned_child_status() { + let result = connect_before_exit::<()>( + "unused", + || { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "peer exited", + )) + }, + |timeout| Ok((!timeout.is_zero()).then_some(7)), + ); + assert!(matches!(result, Ok(None)), "{result:?}"); +} + +#[test] +fn an_unverified_console_is_refused_while_the_owned_child_runs() { + let result = connect_before_exit::<()>( + "unused", + || { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "wrong peer", + )) + }, + |_| Ok(None), + ); + assert!( + matches!(&result, Err(Exit::Error { code: ErrorCode::Io, message }) if message == "wrong peer"), + "{result:?}" + ); +} + +#[cfg(windows)] +#[test] +fn windows_console_output_handles_non_utf8_code_pages() { + use std::os::windows::process::CommandExt; + use windows_sys::Win32::System::Threading::CREATE_NEW_CONSOLE; + + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "host::cli::run::tests::windows_console_output_helper", + "--ignored", + "--nocapture", + "--test-threads=1", + ]) + .env("NVX_TEST_PRIVATE_CONSOLE", "cp437") + .creation_flags(CREATE_NEW_CONSOLE) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); +} + +/// Runs only as the parent's isolated-console helper, never in the shared test process. +#[cfg(windows)] +#[test] +#[ignore] +fn windows_console_output_helper() { + use std::io::Write; + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + use windows_sys::Win32::Foundation::{ + GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::Storage::FileSystem::{FILE_SHARE_READ, FILE_SHARE_WRITE}; + use windows_sys::Win32::System::Console::{ + CONSOLE_TEXTMODE_BUFFER, COORD, CreateConsoleScreenBuffer, GetConsoleOutputCP, + GetConsoleProcessList, GetStdHandle, ReadConsoleOutputCharacterW, STD_OUTPUT_HANDLE, + SetConsoleOutputCP, SetStdHandle, + }; + + /// Restores only the helper's private console and its original redirected stdout. + struct RestoreConsole { + /// The original borrowed standard-output handle. + stdout: HANDLE, + /// The private console's original output code page. + code_page: u32, + } + impl Drop for RestoreConsole { + fn drop(&mut self) { + // SAFETY: both settings belong to this isolated helper, and stdout stays open. + unsafe { + assert_ne!(SetStdHandle(STD_OUTPUT_HANDLE, self.stdout), 0); + assert_ne!(SetConsoleOutputCP(self.code_page), 0); + } + } + } + + assert_eq!( + std::env::var("NVX_TEST_PRIVATE_CONSOLE").as_deref(), + Ok("cp437") + ); + let mut members = [0; 4]; + // SAFETY: the array is writable, and only this helper may belong to its new console. + let count = unsafe { GetConsoleProcessList(members.as_mut_ptr(), members.len() as u32) }; + assert_eq!(count, 1); + assert_eq!(members[0], std::process::id()); + // SAFETY: a private, inactive screen buffer has no inherited security attributes. + let raw = unsafe { + CreateConsoleScreenBuffer( + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null(), + CONSOLE_TEXTMODE_BUFFER, + std::ptr::null_mut(), + ) + }; + assert_ne!(raw, INVALID_HANDLE_VALUE); + // SAFETY: CreateConsoleScreenBuffer returned this newly owned handle. + let buffer = unsafe { OwnedHandle::from_raw_handle(raw.cast()) }; + io::stdout().flush().unwrap(); + // SAFETY: these getters return the helper's own current console settings. + let _restore = RestoreConsole { + stdout: unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }, + code_page: unsafe { GetConsoleOutputCP() }, + }; + // SAFETY: only this helper is attached, and the owned buffer remains open until restoration. + unsafe { + assert_ne!(SetConsoleOutputCP(437), 0); + assert_ne!( + SetStdHandle(STD_OUTPUT_HANDLE, buffer.as_raw_handle().cast()), + 0 + ); + } + for chunk in [ + b"caf\xc3".as_slice(), + b"\xa9 \xe2".as_slice(), + b"\x82\xac".as_slice(), + ] { + write_console(chunk).unwrap(); + } + let expected: Vec = "caf\u{e9} \u{20ac}".encode_utf16().collect(); + let mut actual = vec![0; expected.len()]; + let mut read = 0; + // SAFETY: the buffer is owned, and actual has room for the requested UTF-16 characters. + assert_ne!( + unsafe { + ReadConsoleOutputCharacterW( + buffer.as_raw_handle().cast(), + actual.as_mut_ptr(), + actual.len() as u32, + COORD { X: 0, Y: 0 }, + &mut read, + ) + }, + 0 + ); + assert_eq!(read as usize, expected.len()); + assert_eq!(actual, expected); +} + proptest! { /// A target that `run` accepts stays inside its share's `nvx_share=` token, which the kernel /// reads as one parameter. Linux splits a parameter at a byte that it reads as whitespace, diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index 9ef3d8382..90ac917a3 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -50,6 +50,8 @@ //! `fake_boot_delay_ms=MS` delays the control endpoint, `fake_crash_after_ms=MS` makes the VM //! die, `fake_ignore_stop=1` ignores stop requests, `fake_exit_delay_ms=MS` delays the exit after //! an acknowledged stop, `fake_ignore_cancel=1` ignores cancellation, +//! `fake_console_bytes=N` emits up to 16 MiB of `x` bytes on a run's console, and +//! `fake_report_before_console=1` writes a run's report before console output or its boot delay. //! `fake_guest_features=MASK` advertises the decimal feature mask `MASK` instead of the current //! guest's, `fake_legacy_guest=1` refuses the features request like a guest agent that //! predates it, and `fake_refuse_maps=1` fails every `MAPS` request like a guest that cannot @@ -383,6 +385,10 @@ struct Options { boot_delay: Duration, /// Whether the boot console emits the binary-output regression fixture. binary_console: bool, + /// Number of bytes in the sustained-console regression fixture. + console_bytes: usize, + /// Whether a run publishes its regression report before console output or boot delay. + report_before_console: bool, /// Artificial delay before the fake OpenVMM crashes, when requested. crash_after: Option, /// Whether stop requests are deliberately left unanswered. @@ -518,6 +524,17 @@ pub fn main() -> ExitCode { return ExitCode::from(1); } }; + if options.report_before_console + && matches!(options.lifecycle, Lifecycle::OneShot | Lifecycle::Run) + { + let status = options.exit_status.unwrap_or(0); + write_report( + &options, + "run", + if status == 0 { "success" } else { "guest-exit" }, + status, + ); + } let ended = match options.lifecycle { Lifecycle::OneShot => Some(run_one_shot(&options, &mut console)), Lifecycle::Run => Some(run_microvm(&options, &mut console)), @@ -825,6 +842,13 @@ fn run_microvm(options: &Options, console: &mut dyn Write) -> i32 { }; let written = if options.binary_console { console.write_all(&[b'A', 0xff, 0x80, b'\n']) + } else if options.console_bytes != 0 { + let buffer = [b'x'; 8192]; + (0..options.console_bytes) + .step_by(buffer.len()) + .try_for_each(|offset| { + console.write_all(&buffer[..buffer.len().min(options.console_bytes - offset)]) + }) } else { writeln!(console, "{message}") }; @@ -1725,6 +1749,17 @@ fn parse(program: &str, arguments: &[String]) -> Result { exit_status: knob("fake_exit_status").and_then(|value| value.parse().ok()), boot_delay: millis("fake_boot_delay_ms").unwrap_or_default(), binary_console: knob("fake_binary_console") == Some("1"), + console_bytes: match knob("fake_console_bytes") { + None => 0, + Some(value) => value + .parse::() + .ok() + .filter(|&count| count <= 16 * 1024 * 1024) + .ok_or_else(|| { + "fake_console_bytes must be an integer from 0 through 16777216".to_owned() + })?, + }, + report_before_console: knob("fake_report_before_console") == Some("1"), crash_after: millis("fake_crash_after_ms"), ignore_stop: knob("fake_ignore_stop") == Some("1"), exit_delay: millis("fake_exit_delay_ms").unwrap_or_default(), diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index 7723f5ac6..3de2633d0 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -1084,6 +1084,135 @@ fn cold_console_output_preserves_non_utf8_bytes() { ); } +#[test] +fn a_ready_console_stream_is_not_throttled_by_idle_sleeps() { + use std::time::{Duration, Instant}; + + let root = Root::new(); + let started = Instant::now(); + let outcome = root.nvx_rs(&[ + "run", + "--cmdline", + "fake_console_bytes=8388608 fake_exit_status=7", + ]); + assert_eq!(outcome.status, Some(7), "the bulk-console run failed"); + let output = outcome.command().1; + assert_eq!(output.len(), 8 * 1024 * 1024); + assert!(output.iter().all(|&byte| byte == b'x')); + assert!( + started.elapsed() < Duration::from_secs(8), + "the ready console stream was throttled: {:?}", + started.elapsed() + ); +} + +#[test] +fn a_foreground_output_error_keeps_the_requested_native_report() { + use std::io::{BufRead, BufReader}; + use std::process::Stdio; + + let root = Root::new(); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=100 fake_report_before_console=1", + ]) + .arg("--outcome-report") + .arg(root.below("reports/outcome.json")) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut stdout = BufReader::new(process.stdout.take().unwrap()); + let mut command = String::new(); + stdout.read_line(&mut command).unwrap(); + assert!(command.starts_with(">> "), "{command}"); + drop(stdout); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(1), "{output:?}"); + assert!( + String::from_utf8_lossy(&output.stderr).contains("error[io]:"), + "{output:?}" + ); + assert_outcome_report( + &root.json("reports/outcome.json"), + crate::harness::HYPERVISOR, + ("run", "success", 0), + NO_NETWORK, + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_interrupted_cold_run_keeps_its_already_written_native_report() { + use std::process::Stdio; + use std::time::{Duration, Instant}; + + use nix::sys::signal::{Signal, kill}; + use nix::unistd::Pid; + + let root = Root::new(); + let temporary = tempfile::Builder::new().prefix("nvx-c-").tempdir().unwrap(); + let mut process = root + .command(&[ + "run", + "--cmdline", + "fake_boot_delay_ms=60000 fake_report_before_console=1", + ]) + .arg("--outcome-report") + .arg(root.below("reports/outcome.json")) + .env("TMPDIR", temporary.path()) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let original = loop { + let report = std::fs::read_dir(temporary.path()) + .unwrap() + .map(|entry| entry.unwrap().path().join("outcome.json")) + .find(|path| path.is_file()); + if let Some(report) = report { + let bytes = std::fs::read(report).unwrap(); + if bytes.ends_with(b"\n") && serde_json::from_slice::(&bytes).is_ok() + { + break bytes; + } + } + if process.try_wait().unwrap().is_some() { + panic!( + "the cold run exited before writing its report: {:?}", + process.wait_with_output() + ); + } + if Instant::now() >= deadline { + kill( + Pid::from_raw(process.id().try_into().unwrap()), + Signal::SIGINT, + ) + .unwrap(); + let output = process.wait_with_output().unwrap(); + panic!("the fake did not write its early report: {output:?}"); + } + std::thread::sleep(Duration::from_millis(5)); + }; + kill( + Pid::from_raw(process.id().try_into().unwrap()), + Signal::SIGINT, + ) + .unwrap(); + let output = process.wait_with_output().unwrap(); + assert_eq!(output.status.code(), Some(130), "{output:?}"); + assert_eq!( + std::fs::read(root.below("reports/outcome.json")).unwrap(), + original + ); + assert_eq!(std::fs::read_dir(temporary.path()).unwrap().count(), 0); +} + #[cfg(target_os = "linux")] #[test] fn cold_runs_keep_the_native_child_in_the_callers_session_and_process_group() { From 773403c7a4b62a004a04d0f7bf6100674a2e3472 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 09:47:16 -0800 Subject: [PATCH 25/30] cli: stream lossy UTF-8 only to Windows consoles Render invalid guest UTF-8 as replacement characters only when stdout is an actual Windows console. Carry at most three split-sequence bytes between reads and finish them only at console EOF or final drain. Keep redirected pipes and files byte-exact and preserve real I/O failures. Extend the isolated CP437 console regression with invalid sequences, split-invalid input, incomplete endings, and raw pipe/file controls. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/run.rs | 86 ++++++++++++++++++++++++++++---- nvx-rs/src/host/cli/run/tests.rs | 51 +++++++++++++++++-- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/nvx-rs/src/host/cli/run.rs b/nvx-rs/src/host/cli/run.rs index 8efd7c6e4..30107442a 100644 --- a/nvx-rs/src/host/cli/run.rs +++ b/nvx-rs/src/host/cli/run.rs @@ -785,8 +785,62 @@ fn console_input(console: Box) -> Result<(Box, Inp } } -fn write_console(data: &[u8]) -> io::Result<()> { - output::write_stdout(data) +struct ConsoleOutput { + #[cfg(windows)] + console: bool, + #[cfg(windows)] + pending: [u8; 3], + #[cfg(windows)] + pending_len: usize, +} + +impl ConsoleOutput { + fn new() -> Self { + Self { + #[cfg(windows)] + console: io::stdout().is_terminal(), + #[cfg(windows)] + pending: [0; 3], + #[cfg(windows)] + pending_len: 0, + } + } + + fn write_all(&mut self, data: &[u8]) -> io::Result<()> { + #[cfg(windows)] + if self.console { + let combined; + let bytes = if self.pending_len == 0 { + data + } else { + combined = [&self.pending[..self.pending_len], data].concat(); + combined.as_slice() + }; + // Only a final incomplete sequence crosses reads; other invalid bytes render now. + let incomplete = + bytes.utf8_chunks().last().map_or(0, |chunk| { + match std::str::from_utf8(chunk.invalid()) { + Err(error) if error.error_len().is_none() => chunk.invalid().len(), + _ => 0, + } + }); + let ready = bytes.len() - incomplete; + output::write_stdout(String::from_utf8_lossy(&bytes[..ready]).as_bytes())?; + self.pending[..incomplete].copy_from_slice(&bytes[ready..]); + self.pending_len = incomplete; + return Ok(()); + } + output::write_stdout(data) + } + + fn finish(&mut self) -> io::Result<()> { + #[cfg(windows)] + if self.pending_len != 0 { + output::write_stdout("\u{fffd}".as_bytes())?; + self.pending_len = 0; + } + Ok(()) + } } fn foreground( @@ -824,6 +878,7 @@ fn foreground( Err(error) => return Err(error), } } + let mut console_output = ConsoleOutput::new(); let mut buffer = [0; 8192]; loop { check_interrupt()?; @@ -841,18 +896,26 @@ fn foreground( } if let Some(transport) = &mut console { match transport.read(&mut buffer, Some(Duration::from_millis(10))) { - Ok(0) => console = None, + Ok(0) => { + console = None; + console_output.finish().map_err(Exit::from_os_error)?; + } Ok(count) => { - write_console(&buffer[..count]).map_err(Exit::from_os_error)?; + console_output + .write_all(&buffer[..count]) + .map_err(Exit::from_os_error)?; progressed = true; } + Err(error) if error.kind() == io::ErrorKind::TimedOut => {} Err(error) if matches!( error.kind(), - io::ErrorKind::TimedOut - | io::ErrorKind::BrokenPipe - | io::ErrorKind::ConnectionReset - ) => {} + io::ErrorKind::BrokenPipe | io::ErrorKind::ConnectionReset + ) => + { + console = None; + console_output.finish().map_err(Exit::from_os_error)?; + } Err(error) => return Err(Exit::from_os_error(error)), } } @@ -868,9 +931,9 @@ fn foreground( loop { match transport.read(&mut buffer, Some(Duration::from_millis(10))) { Ok(0) => break, - Ok(count) => { - write_console(&buffer[..count]).map_err(Exit::from_os_error)? - } + Ok(count) => console_output + .write_all(&buffer[..count]) + .map_err(Exit::from_os_error)?, Err(error) if matches!( error.kind(), @@ -885,6 +948,7 @@ fn foreground( } } } + console_output.finish().map_err(Exit::from_os_error)?; return Ok(status); } if attach_closed.is_some_and(|deadline| Instant::now() >= deadline) { diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 7892e519a..763d297bb 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -120,6 +120,9 @@ fn an_unverified_console_is_refused_while_the_owned_child_runs() { ); } +#[cfg(windows)] +const REDIRECTED_CONSOLE_BYTES: &[u8] = b"nvx-raw:\xff\x80\xc3"; + #[cfg(windows)] #[test] fn windows_console_output_handles_non_utf8_code_pages() { @@ -139,6 +142,13 @@ fn windows_console_output_handles_non_utf8_code_pages() { .output() .unwrap(); assert!(output.status.success(), "{output:?}"); + assert!( + output + .stdout + .windows(REDIRECTED_CONSOLE_BYTES.len()) + .any(|bytes| bytes == REDIRECTED_CONSOLE_BYTES), + "{output:?}" + ); } /// Runs only as the parent's isolated-console helper, never in the shared test process. @@ -146,7 +156,7 @@ fn windows_console_output_handles_non_utf8_code_pages() { #[test] #[ignore] fn windows_console_output_helper() { - use std::io::Write; + use std::io::{Read, Seek, Write}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; use windows_sys::Win32::Foundation::{ GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, @@ -197,12 +207,32 @@ fn windows_console_output_helper() { assert_ne!(raw, INVALID_HANDLE_VALUE); // SAFETY: CreateConsoleScreenBuffer returned this newly owned handle. let buffer = unsafe { OwnedHandle::from_raw_handle(raw.cast()) }; + let mut file = tempfile::tempfile().unwrap(); io::stdout().flush().unwrap(); // SAFETY: these getters return the helper's own current console settings. let _restore = RestoreConsole { stdout: unsafe { GetStdHandle(STD_OUTPUT_HANDLE) }, code_page: unsafe { GetConsoleOutputCP() }, }; + assert!(!io::stdout().is_terminal()); + let mut redirected = ConsoleOutput::new(); + redirected.write_all(REDIRECTED_CONSOLE_BYTES).unwrap(); + redirected.finish().unwrap(); + // SAFETY: the file is owned and remains open until stdout has been restored. + unsafe { + assert_ne!( + SetStdHandle(STD_OUTPUT_HANDLE, file.as_raw_handle().cast()), + 0 + ); + } + assert!(!io::stdout().is_terminal()); + let mut redirected = ConsoleOutput::new(); + redirected.write_all(REDIRECTED_CONSOLE_BYTES).unwrap(); + redirected.finish().unwrap(); + file.rewind().unwrap(); + let mut redirected_bytes = Vec::new(); + file.read_to_end(&mut redirected_bytes).unwrap(); + assert_eq!(redirected_bytes, REDIRECTED_CONSOLE_BYTES); // SAFETY: only this helper is attached, and the owned buffer remains open until restoration. unsafe { assert_ne!(SetConsoleOutputCP(437), 0); @@ -211,14 +241,27 @@ fn windows_console_output_helper() { 0 ); } + let mut console = ConsoleOutput::new(); for chunk in [ b"caf\xc3".as_slice(), b"\xa9 \xe2".as_slice(), - b"\x82\xac".as_slice(), + b"\x82\xac|A\xff\x80|".as_slice(), + b"\xc3".as_slice(), + b"A".as_slice(), ] { - write_console(chunk).unwrap(); + console.write_all(chunk).unwrap(); + } + console.finish().unwrap(); + for incomplete in [b"\xc3".as_slice(), b"\xe2\x82", b"\xf0\x9f\x92"] { + let mut console = ConsoleOutput::new(); + console.write_all(b"|").unwrap(); + console.write_all(incomplete).unwrap(); + console.finish().unwrap(); } - let expected: Vec = "caf\u{e9} \u{20ac}".encode_utf16().collect(); + let expected: Vec = + "caf\u{e9} \u{20ac}|A\u{fffd}\u{fffd}|\u{fffd}A|\u{fffd}|\u{fffd}|\u{fffd}" + .encode_utf16() + .collect(); let mut actual = vec![0; expected.len()]; let mut read = 0; // SAFETY: the buffer is owned, and actual has room for the requested UTF-16 characters. From 9b813474c223f5e5e5454917564c910d37f63594 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 10:09:59 -0800 Subject: [PATCH 26/30] test: scope unsafe console calls to the isolated Windows helper Allow only the isolated Windows console test helper to exercise documented Win32 calls, matching existing Windows endpoint-test opt-outs. Keep the host-wide unsafe-code denial and production code unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/run/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/nvx-rs/src/host/cli/run/tests.rs b/nvx-rs/src/host/cli/run/tests.rs index 763d297bb..ede248caf 100644 --- a/nvx-rs/src/host/cli/run/tests.rs +++ b/nvx-rs/src/host/cli/run/tests.rs @@ -155,6 +155,7 @@ fn windows_console_output_handles_non_utf8_code_pages() { #[cfg(windows)] #[test] #[ignore] +#[allow(unsafe_code)] fn windows_console_output_helper() { use std::io::{Read, Seek, Write}; use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; From 6bc5c0a6cbea41060e1992834ca8884a1f94c0e2 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 10:18:45 -0800 Subject: [PATCH 27/30] test: initialize boot capacity in the integrated launch fixture Initialize the cold-boot memory-capacity option in the newly integrated CLI journal fixture. None preserves its prior boot-memory behavior and all existing assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: eb3cc7e0-f51e-45ed-b911-c7b214740e05 --- nvx-rs/src/host/cli/store/tests/launch.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs index aff660e14..e30426eee 100644 --- a/nvx-rs/src/host/cli/store/tests/launch.rs +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -47,6 +47,7 @@ fn prepared(state: &StateDir, console: ConsoleAttachment) -> (Prepared, RuntimeI console, boot: Some(Boot { command_line: "nvx_entrypoint=/bin/sh".to_owned(), + memory_capacity_mib: None, capture: None, }), }; From d56d3d343977178fd15bbd526a77a97603edbbac Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 11:08:16 -0800 Subject: [PATCH 28/30] guest: route new sandbox boots through nvx-init Install through the existing fixed guest-binary list and preserve the legacy-token and blockless routes. Pass the known helper PIDs and move the runtime adapter overlay to the new init path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 10c9dff8-5c75-4c96-9739-3896adfb0c71 --- guest/common/init | 30 ++++- scripts/test_nvx_tools.py | 162 +++++++++++++++++++++++ tests/runtime-adapter/README.md | 9 +- tests/runtime-adapter/src/initramfs.rs | 10 +- tests/runtime-adapter/src/worker.rs | 2 +- tests/runtime-adapter/tests/initramfs.rs | 7 +- 6 files changed, 203 insertions(+), 17 deletions(-) diff --git a/guest/common/init b/guest/common/init index 322333176..38e818f7e 100644 --- a/guest/common/init +++ b/guest/common/init @@ -35,6 +35,13 @@ case "$cmdline" in *" nvx_sandbox=1 "*) sandbox_mode=true ;; *) sandbox_mode=false ;; esac +new_init=false +if [ "$sandbox_mode" = true ]; then + case "$cmdline" in + *" nvx_lifecycle="*) ;; + *) new_init=true ;; + esac +fi fatal() { echo "$*" >&2 @@ -189,7 +196,7 @@ for token in $cmdline; do esac done set +f -if [ -n "$nvx_exec" ]; then +if [ -n "$nvx_exec" ] && [ "$new_init" = false ]; then case "$nvx_exec" in /*) ;; *) fatal "nvx_exec must be an absolute initramfs path" ;; @@ -206,6 +213,7 @@ if [ "$sandbox_mode" = false ]; then mount_virtfs fi # virt-net: if --net attached a NIC, configure the only non-loopback interface. +announcer_pid= nip=$(sed -n 's/.*virtnet_ip=\([^ ]*\).*/\1/p' /proc/cmdline) if [ -n "$nip" ]; then nmask=$(sed -n 's/.*virtnet_mask=\([^ ]*\).*/\1/p' /proc/cmdline) @@ -289,6 +297,7 @@ if [ -n "$nip" ]; then allow | host-loopback) if [ -n "$ngw" ] && [ -x /sbin/nvx-ingress ]; then /sbin/nvx-ingress "$ngw" str: return source[start : source.index("\n}\n", start) + 3] +class GuestInitRoutingTests(unittest.TestCase): + INIT = Path(__file__).parents[1] / "guest" / "common" / "init" + + def setUp(self): + shell = _posix_shell() + if shell is None: + self.skipTest("POSIX shell is unavailable") + self.shell = shell + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.pid = self.root / "daemon.pid" + self.announcer = self.root / "announcer.pid" + (self.root / "net" / "eth0").mkdir(parents=True) + (self.root / "os-release").write_text("ID=alpine\n", encoding="utf-8") + (self.root / "alpine-release").write_text("test\n", encoding="utf-8") + self.helpers: dict[str, Path] = {} + for name, script in { + "nvx-time": f"printf '4242\\n' >{shlex.quote(self.pid.as_posix())}\n", + "nvx-init": 'printf "new-init"; printf " <%s>" "$@"; echo\n', + "nvx-init-agent": 'printf "old-init"; printf " <%s>" "$@"; echo\n', + "nvx-ingress": ( + f'printf "%s\\n" "$$" >{shlex.quote(self.announcer.as_posix())}\n' + ), + "nvx-hostmount": "echo hostmount\n", + "shell": "echo blockless-shell\n", + }.items(): + path = self.root / name + path.write_text(f"#!/bin/sh\n{script}", encoding="utf-8") + path.chmod(0o755) + self.helpers[name] = path + + def _run(self, cmdline: str) -> subprocess.CompletedProcess[str]: + command_line = self.root / "cmdline" + command_line.write_text(cmdline, encoding="utf-8") + source = self.INIT.read_text(encoding="utf-8") + replacements = { + "/sbin/nvx-init-agent": self.helpers["nvx-init-agent"], + "/sbin/nvx-init": self.helpers["nvx-init"], + "/sbin/nvx-time": self.helpers["nvx-time"], + "/sbin/nvx-ingress": self.helpers["nvx-ingress"], + "/sbin/nvx-hostmount": self.helpers["nvx-hostmount"], + "/proc/cmdline": command_line, + "/proc/sys/kernel/modules_disabled": self.root / "modules_disabled", + "/sys/class/net": self.root / "net", + "/run/nvx/time/daemon.pid": self.pid, + "/etc/os-release": self.root / "os-release", + "/etc/alpine-release": self.root / "alpine-release", + "exec /bin/sh": self.helpers["shell"], + } + for original, replacement in replacements.items(): + target = shlex.quote(replacement.as_posix()) + if original == "exec /bin/sh": + target = f"exec {target}" + source = source.replace(original, target) + source = source.replace("/sbin/nvx-exit", "nvx_exit") + source = source.replace("/sbin/nvx-snapshot", "nvx_snapshot") + return subprocess.run( + [self.shell, "-s"], + input=( + "mount() { :; }\nifconfig() { :; }\nroute() { :; }\n" + 'nvx_exit() { exit "$1"; }\n' + "nvx_snapshot() { echo blockless-snapshot; }\n" + f"{source}" + ), + text=True, + capture_output=True, + check=False, + timeout=40, + ) + + def test_tokenless_layered_and_fresh_sandboxes_use_new_init(self): + for tokens in ( + "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs", + "nvx_snapshot_tier=platform", + "nvx_snapshot_tier=platform nvx_mode=managed", + ): + with self.subTest(tokens=tokens): + result = self._run(f"console=hvc0 nvx_sandbox=1 {tokens}") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "new-init <4242>\n") + + def test_old_lifecycle_tokens_keep_the_old_layered_init(self): + for lifecycle in ("managed", "one-shot", ""): + with self.subTest(lifecycle=lifecycle): + result = self._run( + "nvx_sandbox=1 nvx_layer=distro,0xd0003000,erofs " + f"nvx_overlay_upper=ramfs nvx_lifecycle={lifecycle}" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith("old-init"), result.stdout) + self.assertNotIn("new-init", result.stdout) + + def test_exec_override_cannot_bypass_the_new_sandbox_init(self): + result = self._run( + "nvx_sandbox=1 nvx_snapshot_tier=platform nvx_exec=/bin/true" + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "new-init <4242>\n") + for tokens in ("", "nvx_sandbox=1 nvx_lifecycle=managed"): + with self.subTest(tokens=tokens): + result = self._run(f"{tokens} nvx_exec=/bin/true") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "") + + def test_blockless_platform_and_snapshot_vms_keep_their_routes(self): + for tokens in ("", "nvx_snapshot_tier=platform", "shellsnap snap"): + with self.subTest(tokens=tokens): + result = self._run(f"console=hvc0 {tokens}") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("hostmount\n", result.stdout) + self.assertIn("blockless-shell\n", result.stdout) + self.assertNotIn("new-init", result.stdout) + self.assertNotIn("old-init", result.stdout) + if tokens == "shellsnap snap": + self.assertEqual(result.stdout.count("blockless-snapshot\n"), 2) + self.assertIn("SNAP-RESTORE-OK\n", result.stdout) + + def test_new_init_receives_the_announcers_process_id_when_present(self): + for ingress in ("allow", "host-loopback"): + with self.subTest(ingress=ingress): + result = self._run( + "nvx_sandbox=1 nvx_snapshot_tier=platform " + "virtnet_ip=172.16.0.2 virtnet_gw=172.16.0.1 " + f"virtnet_ingress={ingress}" + ) + self.assertEqual(result.returncode, 0, result.stderr) + pid = self.announcer.read_text(encoding="utf-8").strip() + self.assertGreater(int(pid), 1) + self.assertIn(f"new-init <4242> <{pid}>\n", result.stdout) + + def test_new_init_waits_for_the_time_daemons_deferred_publication(self): + temporary = self.pid.with_name("daemon.pid.tmp") + self.helpers["nvx-time"].write_text( + "#!/bin/sh\n" + f"(sleep 0.1; printf '4242\\n' >{shlex.quote(temporary.as_posix())}; " + f"mv {shlex.quote(temporary.as_posix())} " + f"{shlex.quote(self.pid.as_posix())}) &\n", + encoding="utf-8", + ) + result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "new-init <4242>\n") + + def test_missing_helpers_or_selected_init_fail_without_fallback(self): + self.helpers["nvx-time"].write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + self.pid.mkdir() + result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") + self.assertEqual(result.returncode, 125, result.stdout) + self.assertIn("time daemon process ID is unavailable", result.stderr) + self.assertNotIn("new-init", result.stdout) + + self.helpers["nvx-init"].unlink() + result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") + self.assertEqual(result.returncode, 125, result.stdout) + self.assertIn("selected guest cannot act as sandbox control", result.stderr) + self.assertNotIn("old-init", result.stdout) + result = self._run("nvx_sandbox=1 nvx_lifecycle=managed") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertTrue(result.stdout.startswith("old-init"), result.stdout) + + class GuestInitWorkloadIdentityTests(unittest.TestCase): """The shell /init's workload account and identity checks, as a one-shot launch that asks for an account with nvx_workload_account=create runs diff --git a/tests/runtime-adapter/README.md b/tests/runtime-adapter/README.md index d700bda5f..d5517f86e 100644 --- a/tests/runtime-adapter/README.md +++ b/tests/runtime-adapter/README.md @@ -1116,8 +1116,9 @@ NVX's initramfs hands a VM of the managed lifecycle to NVX's managed agent: its `/init` runs `exec /sbin/nvx-managed-agent /dev/hvc2 - nvx-managed UID GID NAME HOME` as PID 1. A VM with a layered root, whose kernel command line has -`nvx_sandbox=1`, goes to NVX's init agent instead, which assembles the root: -`/init` runs `exec /sbin/nvx-init-agent`, without arguments. +`nvx_sandbox=1` and no old OpenVMM lifecycle token, goes to NVX's init instead: +`/init` runs `exec /sbin/nvx-init TIME_DAEMON [ANNOUNCER]`, with the helpers' +process IDs. Old-token layered launches keep `/sbin/nvx-init-agent`. `initramfs::write` copies that initramfs, and appends to the copy a second archive, in the `newc` format, which Linux unpacks over the first: @@ -1128,8 +1129,8 @@ archive, in the `newc` format, which Linux unpacks over the first: - a symbolic link from `/sbin/nvx-managed-agent` to that script; - a script, `/sbin/runtime-adapter-layered`, that runs `runtime-adapter-guest init /dev/hvc2 layered`, which names the control tty - itself, since `/init` passes NVX's init agent none; -- a symbolic link from `/sbin/nvx-init-agent` to that script, so that NVX's + itself, and ignores the helper arguments; +- a symbolic link from `/sbin/nvx-init` to that script, so that NVX's init agent never assembles the root in the adapter's VMs; - for the capture of a template whose clones delay their repair, for tests, the delay's file, `/etc/runtime-adapter-repair-delay`, in the `/etc` directory of diff --git a/tests/runtime-adapter/src/initramfs.rs b/tests/runtime-adapter/src/initramfs.rs index 67384d158..f79127902 100644 --- a/tests/runtime-adapter/src/initramfs.rs +++ b/tests/runtime-adapter/src/initramfs.rs @@ -7,11 +7,11 @@ //! exec /sbin/nvx-managed-agent /dev/hvc2 - nvx-managed UID GID NAME HOME //! ``` //! -//! and a VM with a layered root, whose kernel command line has `nvx_sandbox=1`, to NVX's init -//! agent, which assembles the root, without arguments: +//! and a VM with a layered root, whose kernel command line has `nvx_sandbox=1` and no old +//! OpenVMM lifecycle token, to NVX's init, with its helpers' process IDs: //! //! ```text -//! exec /sbin/nvx-init-agent +//! exec /sbin/nvx-init TIME_DAEMON [ANNOUNCER] //! ``` //! //! [`write`](fn@write) copies that initramfs, and appends to the copy a second archive, in the @@ -23,7 +23,7 @@ //! - a symbolic link from [`MANAGED_AGENT`] to that script; //! - a script, at [`LAYERED_LAUNCHER`], that runs the guest agent as //! `runtime-adapter-guest init /dev/hvc2 layered`, PID 1 of a VM with a layered root, which -//! names the control tty, [`CONTROL_TTY`], itself, since `/init` passes NVX's init agent none; +//! names the control tty, [`CONTROL_TTY`], itself, and ignores the helper arguments; //! - and a symbolic link from [`INIT_AGENT`] to that script, so that NVX's init agent never //! assembles the root in the adapter's VMs; //! - for the capture of a template whose clones delay their repair, for tests, the delay's file, @@ -53,7 +53,7 @@ pub const MANAGED_AGENT: &str = "/sbin/nvx-managed-agent"; /// NVX's init agent. pub const LAYERED_LAUNCHER: &str = "/sbin/runtime-adapter-layered"; /// Path of NVX's init agent, which `/init` runs in a VM with a layered root. -pub const INIT_AGENT: &str = "/sbin/nvx-init-agent"; +pub const INIT_AGENT: &str = "/sbin/nvx-init"; /// The VM's control tty, which `/init` requires of both lifecycles that NVX's agents serve. pub const CONTROL_TTY: &str = "/dev/hvc2"; diff --git a/tests/runtime-adapter/src/worker.rs b/tests/runtime-adapter/src/worker.rs index c2eab36c9..dd80fe88c 100644 --- a/tests/runtime-adapter/src/worker.rs +++ b/tests/runtime-adapter/src/worker.rs @@ -4,7 +4,7 @@ //! //! # PID 1 and the worker //! -//! In a VM with a layered root, NVX's `/init` runs `/sbin/nvx-init-agent`, which the adapter's +//! In a VM with a tokenless layered root, NVX's `/init` runs `/sbin/nvx-init`, which the adapter's //! initramfs replaces with `runtime-adapter-guest init /dev/hvc2 layered`; see the host side's //! `initramfs` module. That PID 1, [`supervise`], stays minimal: //! diff --git a/tests/runtime-adapter/tests/initramfs.rs b/tests/runtime-adapter/tests/initramfs.rs index 22eea4537..8ad14aed9 100644 --- a/tests/runtime-adapter/tests/initramfs.rs +++ b/tests/runtime-adapter/tests/initramfs.rs @@ -103,7 +103,7 @@ fn expected(agent: &[u8]) -> Vec { 0o100_755, initramfs::layered_launcher_script().as_bytes(), ), - entry(INIT_AGENT, 5, 0o120_777, LAYERED_LAUNCHER.as_bytes()), + entry("/sbin/nvx-init", 5, 0o120_777, LAYERED_LAUNCHER.as_bytes()), ] } @@ -151,15 +151,14 @@ fn the_launchers_run_the_guest_agent_on_the_control_tty() { ); assert_eq!(MANAGED_AGENT, "/sbin/nvx-managed-agent"); - // It passes NVX's init agent nothing, so the layered launcher names the control tty, which - // `/init` requires of both lifecycles. + // The layered launcher ignores the helper PIDs and names its control tty itself. let layered = runtime_adapter::GUEST_LAYERED_MODE; assert_eq!( initramfs::layered_launcher_script(), format!("#!/bin/sh\nexec {GUEST_AGENT} {init} /dev/hvc2 {layered}\n") ); assert_eq!(CONTROL_TTY, "/dev/hvc2"); - assert_eq!(INIT_AGENT, "/sbin/nvx-init-agent"); + assert_eq!(INIT_AGENT, "/sbin/nvx-init"); } #[test] From 2e26738a642bfaf64e23c9e25fb668993f3ec309 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 11:55:47 -0800 Subject: [PATCH 29/30] Retain the published init test seam count after joining inputs The activation input already allows seven hooks; the feature input allows six. Preserve all published seams without adding a test hook. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ab84e49-48c9-4741-8fac-ff69b6734573 --- scripts/test-layout-allowlist.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 399c715f3..adf7c96c6 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -5,7 +5,7 @@ "nvx-rs/src/guest/cgroup.rs": 2, "nvx-rs/src/guest/entry.rs": 1, "nvx-rs/src/guest/exec.rs": 4, - "nvx-rs/src/guest/init.rs": 6, + "nvx-rs/src/guest/init.rs": 7, "nvx-rs/src/guest/init/boot.rs": 1, "nvx-rs/src/guest/reseed.rs": 1, "nvx-rs/src/guest/sys/entry.rs": 3, From e4843296e53588654722ea56f8e556e603f01e1e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 12:05:00 -0800 Subject: [PATCH 30/30] Reconcile joined test fixtures with current VM and process APIs Keep detached helper launches, default boot capacity, confirmed exit assertions and fallible outcome reads without changing runtime implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1ab84e49-48c9-4741-8fac-ff69b6734573 --- nvx-rs/src/host/cli/store/tests/launch.rs | 1 + nvx-rs/src/host/cli/store/tests/liveness.rs | 1 + nvx-rs/src/host/cli/store/tests/recovery.rs | 1 + nvx-rs/src/host/restore/tests/live_init.rs | 14 ++++++++++---- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/nvx-rs/src/host/cli/store/tests/launch.rs b/nvx-rs/src/host/cli/store/tests/launch.rs index 8712ea4ff..941c04284 100644 --- a/nvx-rs/src/host/cli/store/tests/launch.rs +++ b/nvx-rs/src/host/cli/store/tests/launch.rs @@ -49,6 +49,7 @@ pub(super) fn prepared( filesystem: None, console, boot: Some(Boot { + memory_capacity_mib: None, command_line: "nvx_entrypoint=/bin/sh".to_owned(), capture: None, }), diff --git a/nvx-rs/src/host/cli/store/tests/liveness.rs b/nvx-rs/src/host/cli/store/tests/liveness.rs index e9bb3f683..8f7c6a56f 100644 --- a/nvx-rs/src/host/cli/store/tests/liveness.rs +++ b/nvx-rs/src/host/cli/store/tests/liveness.rs @@ -118,6 +118,7 @@ fn the_spawned_process_inherits_the_preparations_claim() { &command, prepared.capability().as_bytes(), prepared.log().try_clone().unwrap(), + false, ) .unwrap(), )); diff --git a/nvx-rs/src/host/cli/store/tests/recovery.rs b/nvx-rs/src/host/cli/store/tests/recovery.rs index fae235449..82600dbe8 100644 --- a/nvx-rs/src/host/cli/store/tests/recovery.rs +++ b/nvx-rs/src/host/cli/store/tests/recovery.rs @@ -155,6 +155,7 @@ mod process { &command, prepared.capability().as_bytes(), prepared.log().try_clone().unwrap(), + false, ) .unwrap(), )); diff --git a/nvx-rs/src/host/restore/tests/live_init.rs b/nvx-rs/src/host/restore/tests/live_init.rs index c8e33508d..9b958ede6 100644 --- a/nvx-rs/src/host/restore/tests/live_init.rs +++ b/nvx-rs/src/host/restore/tests/live_init.rs @@ -313,7 +313,9 @@ fn run() -> Result<(), AnyError> { let expected_status; if case == "deadline" { require( - process.wait(Instant::now() + Duration::from_secs(40))?, + process + .wait(Instant::now() + Duration::from_secs(40))? + .is_some(), "activation deadline did not end VM", )?; require( @@ -378,7 +380,9 @@ fn run() -> Result<(), AnyError> { activation[0] ^= 1; connection.send(&encode_app(ACTIVATE, 101, 0, &activation), Some(by))?; require( - process.wait(Instant::now() + Duration::from_secs(15))?, + process + .wait(Instant::now() + Duration::from_secs(15))? + .is_some(), "changed identity replay did not end VM", )?; expected_status = 125; @@ -481,7 +485,9 @@ printf 'RECONNECT_REPLAY_PRESERVED_UPPER_AND_MAPS\n' "STOP not acknowledged", )?; require( - process.wait(Instant::now() + Duration::from_secs(30))?, + process + .wait(Instant::now() + Duration::from_secs(30))? + .is_some(), "STOP did not tear down restore", )?; expected_status = 0; @@ -489,7 +495,7 @@ printf 'RECONNECT_REPLAY_PRESERVED_UPPER_AND_MAPS\n' } drop(connection); reader.join().map_err(|_| "console reader panicked")??; - let outcome = crate::host::report::read(&report).ok_or("missing native outcome")?; + let outcome = crate::host::report::read(&report)?.ok_or("missing native outcome")?; require( outcome.status_code == Some(expected_status) && outcome.teardown_complete == Some(true), format!("wrong restore outcome: {outcome:?}"),