From d83488a45e141d14c0c4b163db9ef91d26c23488 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 13:06:47 -0800 Subject: [PATCH 1/9] Add journaled named snapshot capture command Implement only SL3.7b on the published capture inputs. Preserve interruption recovery and require timely verified exit before immutable publication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1dcd7c0-6291-46ec-805e-2b4379ea3e44 --- doc/usage.md | 29 + nvx-rs/src/host/cli/mod.rs | 23 +- nvx-rs/src/host/cli/snapshots.rs | 6 +- nvx-rs/src/host/cli/snapshots/capture.rs | 420 +++++++++++++ .../src/host/cli/snapshots/capture/tests.rs | 574 ++++++++++++++++++ nvx-rs/src/host/session.rs | 7 +- scripts/test-layout-allowlist.json | 1 - 7 files changed, 1047 insertions(+), 13 deletions(-) create mode 100644 nvx-rs/src/host/cli/snapshots/capture.rs create mode 100644 nvx-rs/src/host/cli/snapshots/capture/tests.rs diff --git a/doc/usage.md b/doc/usage.md index f0b6e793a..17ac54ad0 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -343,6 +343,35 @@ boot of `nvx-rs run` does. See [Run](run.md) for artifact preparation, the security boundary, and current snapshot/configuration limitations. +### `snapshot capture` + +```sh +nvx-rs/target/release/nvx-rs snapshot capture baseline \ + --store-dir /private/snapshots --hypervisor kvm +``` + +Use `mshv` on Linux/MSHV or `whp` on Windows. The store's parent must exist; +the store and its capture VM are private to the current user. Capture uses the +identified OpenVMM, kernel and Alpine initramfs already installed in the artifact +root, without downloading substitutes or accepting `NVX_OPENVMM` overrides. + +The VM boots with `nvx_sandbox=1` and no workload or layered root. Its sparse +placeholder image is at most `--image-capacity-mib` (1024 by default), and its +dormant scratch is `--scratch-mib` (64 by default). `--processors` (1) and +`--memory-mib` (256) select boot resources; `--processor-capacity` and +`--memory-capacity-mib` may reserve larger capacities. RAM capacity must be a +multiple of 128 MiB. `--cpu-profile` selects the OpenVMM profile. + +Only a successful capture whose own VMM exits within `--timeout` (60 whole +seconds by default) can publish. The immutable named directory contains exactly +`key.bin`, `snapshot.json` and `openvmm/`; an existing name is never replaced. +Capture's own VM directory is then removed. A timed-out VM is stopped but its +capture is never finished. The next capture command using this store ends and +removes an interrupted attempt through ordinary launch recovery, without +resuming or publishing it. Indeterminate recovery retains its evidence and +reports an error. Captures in one store serialize; snapshot locks remain +separate from their immutable directories. Restore commands are not added here. + ### `download` ```text diff --git a/nvx-rs/src/host/cli/mod.rs b/nvx-rs/src/host/cli/mod.rs index 4af6a3051..0ded2ec87 100644 --- a/nvx-rs/src/host/cli/mod.rs +++ b/nvx-rs/src/host/cli/mod.rs @@ -6,6 +6,7 @@ //! nvx-rs [-h] [--root PATH] egress compile PATH //! nvx-rs [-h] [--root PATH] run [OPTIONS] //! nvx-rs [-h] [--root PATH] sandbox [run|provision|start|exec|stop|deprovision] [OPTIONS] +//! nvx-rs [-h] [--root PATH] snapshot capture NAME [OPTIONS] //! ``` //! //! - `artifacts` prints the paths of the artifacts of the artifact root as JSON. @@ -18,6 +19,7 @@ //! exit statuses of the `nvx.py run` that it replaced. //! - `sandbox` runs or manages a sandbox, with the options, outputs, exit statuses, outcome //! reports, and state directories of the `nvx.py sandbox` that it replaced. +//! - `snapshot capture` captures a blockless VM into an immutable named snapshot. //! //! The artifact root is `--root`, or else the release or repository checkout that holds the //! executable, and the `NVX_OPENVMM` environment variable names another OpenVMM executable. @@ -97,25 +99,33 @@ pub(crate) fn hex(bytes: &[u8]) -> String { } /// The commands, in the order in which usage messages list them. -const COMMANDS: [&str; 5] = ["artifacts", "download", "egress", "run", "sandbox"]; +const COMMANDS: [&str; 6] = [ + "artifacts", + "download", + "egress", + "run", + "sandbox", + "snapshot", +]; /// The options of the top-level parser. const OPTIONS: [Spec; 1] = [Spec::one("--root")]; /// The usage and help of the top-level parser. const SYNTAX: Syntax = Syntax { command: "", - arguments: "[-h] [--root PATH] {artifacts,download,egress,run,sandbox} ...", + arguments: "[-h] [--root PATH] {artifacts,download,egress,run,sandbox,snapshot} ...", help: "\ Run NVX microVMs and sandboxes, download and look up NVX artifacts, and compile egress policies. positional arguments: - {artifacts,download,egress,run,sandbox} + {artifacts,download,egress,run,sandbox,snapshot} artifacts print the paths of the NVX artifacts as JSON download download and install the NVX release of the checkout egress compile egress policy files run run an OpenVMM microVM sandbox run or manage workloads over EROFS layers and private ext4 scratch + snapshot capture an immutable named snapshot options: -h, --help show this help message and exit @@ -334,6 +344,8 @@ enum Command { Run(Box), /// `sandbox ...`. Sandbox(Box), + /// `snapshot capture ...`. + Snapshot(snapshots::capture::Options), } /// The top-level options. @@ -389,6 +401,10 @@ fn run(arguments: &[OsString]) -> Result { let (options, unrecognized) = sandbox::parse(rest)?; (Command::Sandbox(Box::new(options)), unrecognized) } + "snapshot" => { + let (options, unrecognized) = snapshots::capture::parse(rest)?; + (Command::Snapshot(options), unrecognized) + } other => { return Err(usage( ErrorCode::ArgumentInvalid, @@ -413,6 +429,7 @@ fn run(arguments: &[OsString]) -> Result { Command::EgressCompile(path) => egress::run(&path), Command::Run(options) => run::run(root, *options), Command::Sandbox(options) => sandbox::run(root, *options), + Command::Snapshot(options) => snapshots::capture::run(root, options), } } diff --git a/nvx-rs/src/host/cli/snapshots.rs b/nvx-rs/src/host/cli/snapshots.rs index 044c8fd6c..1125b20fe 100644 --- a/nvx-rs/src/host/cli/snapshots.rs +++ b/nvx-rs/src/host/cli/snapshots.rs @@ -3,10 +3,8 @@ //! Names map to hexadecimal directory names under `snapshots/`. Each published directory //! contains only the strict snapshot's three entries; its lock stays under `locks/`. //! Completed private directories under `staging/` publish with one non-replacing rename. -#![cfg_attr( - not(test), - expect(dead_code, reason = "SL3.7b and SL3.8a use the named snapshot store") -)] + +pub(super) mod capture; use std::path::{Path, PathBuf}; diff --git a/nvx-rs/src/host/cli/snapshots/capture.rs b/nvx-rs/src/host/cli/snapshots/capture.rs new file mode 100644 index 000000000..80df74db6 --- /dev/null +++ b/nvx-rs/src/host/cli/snapshots/capture.rs @@ -0,0 +1,420 @@ +//! A blockless capture, kept in the ordinary launch journal until it ends. + +use std::fs; +use std::path::Path; +use std::time::Instant; + +use super::{Name, Store}; +use crate::ErrorCode; +use crate::activation::{MachineIdMode, View}; +use crate::host::cli::args::{Parser, Receiver, Spec, Syntax, choice, integer}; +use crate::host::cli::deadline::{Deadline, RETRY_INTERVAL}; +use crate::host::cli::sandbox::{ + Artifacts, HYPERVISORS, MAX_MEMORY_MIB, error_with_cause, hypervisor, +}; +use crate::host::cli::store::{LockedState, StateDir}; +use crate::host::cli::{Exit, Result, check_interrupt, io_error, output}; +use crate::host::config::{ + BlockGeometry, Boot, Capacity, CaptureConfig, ConsoleAttachment, DiskCapture, Invariants, + RootConfig, VmConfig, +}; +use crate::host::process::{Exited, KILL_TIMEOUT, Process}; +use crate::host::runtime::{LaunchOptions, Runtime}; +use crate::host::session::{ControlSession, SessionError}; +use crate::host::snapshot::{DESCRIPTION_FILE, KEY_FILE, OPENVMM_DIR, finish_capture}; +use crate::host::transport; +use crate::host::vm::{self, Prepared, VmDir, VmOutputs}; + +/// The snapshot subcommand parser. +const SYNTAX: Syntax = Syntax { + command: "snapshot", + arguments: "[-h] {capture} ...", + help: "positional arguments:\n {capture} capture a named snapshot\n\noptions:\n -h, --help show this help message and exit", +}; + +/// The capture command parser. +const CAPTURE_SYNTAX: Syntax = Syntax { + command: "snapshot capture", + arguments: "[-h] --store-dir PATH [OPTIONS] NAME", + help: "\ +Capture a blockless VM before any workload or layered root exists. +The store's next capture command ends an interrupted attempt; it never resumes it. + +positional arguments: + NAME a new snapshot name + +options: + -h, --help show this help message and exit + --store-dir PATH private named snapshot store; its parent must exist + --hypervisor {auto,whp,kvm,mshv} + --cpu-profile ID OpenVMM CPU profile (default: auto) + --processors COUNT boot processors: 1, 2, 4 or 8 (default: 1) + --processor-capacity COUNT + processor capacity (default: boot processors) + --memory-mib MIB boot RAM (default: 256) + --memory-capacity-mib MIB + RAM capacity, a multiple of 128 (default: boot RAM) + --image-capacity-mib MIB + longest restore image (default: 1024) + --scratch-mib MIB dormant scratch length (default: 64) + --timeout SECONDS positive whole seconds for launch, capture and exit, + and each lock wait (default: 60)", +}; + +/// Capture's small, fixed option set. +const OPTIONS: [Spec; 10] = [ + Spec::one("--store-dir"), + Spec::one("--hypervisor"), + Spec::one("--cpu-profile"), + Spec::one("--processors"), + Spec::one("--processor-capacity"), + Spec::one("--memory-mib"), + Spec::one("--memory-capacity-mib"), + Spec::one("--image-capacity-mib"), + Spec::one("--scratch-mib"), + Spec::one("--timeout"), +]; + +/// Inputs of one capture, with no workload, mounts or caller command-line tokens. +pub(in crate::host::cli) struct Options { + /// The private store. + store_dir: String, + /// The required name, filled by the parser. + name: Option, + /// The selected backend. + hypervisor: &'static str, + /// The requested CPU profile. + cpu_profile: Option, + /// Boot processors and optional capacity. + processors: u32, + /// Maximum processors. + processor_capacity: Option, + /// Boot memory. + memory_mib: u64, + /// Maximum memory. + memory_capacity_mib: Option, + /// Maximum restore image size. + image_mib: u64, + /// Dormant scratch size. + scratch_mib: u64, + /// Bounded operation time. + timeout: u32, +} + +impl Default for Options { + fn default() -> Self { + Self { + store_dir: String::new(), + name: None, + hypervisor: "auto", + cpu_profile: None, + processors: 1, + processor_capacity: None, + memory_mib: 256, + memory_capacity_mib: None, + image_mib: 1024, + scratch_mib: 64, + timeout: 60, + } + } +} + +impl Receiver for Options { + fn take(&mut self, name: &'static str, value: Option<&str>) -> std::result::Result<(), String> { + let value = value.unwrap_or_default(); + match name { + "NAME" => { + self.name = + Some(Name::try_from(value.to_owned()).map_err(|error| error.to_string())?) + } + "--store-dir" => self.store_dir = value.to_owned(), + "--hypervisor" => self.hypervisor = choice(value, &HYPERVISORS)?, + "--cpu-profile" => self.cpu_profile = Some(value.to_owned()), + "--processors" | "--processor-capacity" => { + let count = choice(value, &["1", "2", "4", "8"])? + .parse() + .expect("processor choice"); + if name == "--processors" { + self.processors = count; + } else { + self.processor_capacity = Some(count); + } + } + "--memory-mib" => self.memory_mib = integer(value, 1..=MAX_MEMORY_MIB)?, + "--memory-capacity-mib" => { + self.memory_capacity_mib = Some(integer(value, 1..=MAX_MEMORY_MIB)?) + } + "--image-capacity-mib" => self.image_mib = integer(value, 1..=MAX_MEMORY_MIB)?, + "--scratch-mib" => self.scratch_mib = integer(value, 1..=MAX_MEMORY_MIB)?, + "--timeout" => self.timeout = integer(value, 1..=u32::MAX)?, + _ => unreachable!("the capture parser has a fixed option set"), + } + Ok(()) + } +} + +/// Parses only the capture operation; restore belongs to later slices. +pub(in crate::host::cli) fn parse(arguments: &[String]) -> Result<(Options, Vec)> { + let (index, mut extras) = Parser::new(&[], &[Spec::one("snapshot_operation")]) + .split_command(arguments, &mut |_, _| Ok(())) + .map_err(|stop| Exit::from_stop(stop, &SYNTAX))?; + choice(&arguments[index], &["capture"]) + .map_err(|message| Exit::usage(&SYNTAX, ErrorCode::ArgumentInvalid, message))?; + let mut options = Options::default(); + extras.extend( + Parser::new(&OPTIONS, &[Spec::one("NAME")]) + .parse(&arguments[index + 1..], &mut options) + .map_err(|stop| Exit::from_stop(stop, &CAPTURE_SYNTAX))?, + ); + if options.store_dir.is_empty() { + return Err(Exit::usage( + &CAPTURE_SYNTAX, + ErrorCode::ArgumentMissing, + "--store-dir is required".to_owned(), + )); + } + Ok((options, extras)) +} + +/// Uses the identified artifacts, never an unlabelled OpenVMM override or a downloaded substitute. +pub(in crate::host::cli) fn run(root: Option<&str>, options: Options) -> Result { + let artifacts = Artifacts { root }; + let _hold = artifacts.hold()?; + let [openvmm, kernel, initramfs] = artifacts.require()?; + let runtime = Runtime { + openvmm: openvmm.into(), + kernel: kernel.into(), + initramfs: initramfs.into(), + identity: artifacts.launch_identity()?, + hypervisor: hypervisor(options.hypervisor).parse()?, + cpu_profile: options.cpu_profile.clone(), + windows_breakaway: false, + options: LaunchOptions::default(), + }; + capture(&options, &runtime, |prepared| { + Ok(vm::spawn(prepared)?.process) + })?; + output::print_stdout("Snapshot captured").map_err(Exit::from_os_error) +} + +/// Runs a single launch. The spawn callback consumes exactly the journaled preparation. +fn capture( + options: &Options, + runtime: &Runtime, + spawn: impl FnOnce(Prepared) -> crate::Result, +) -> Result { + let store = Store::create(Path::new(&options.store_dir))?; + let deadline = Deadline::after(f64::from(options.timeout)); + // This persistent outer lock protects removal of the inner VM directory and its lock file. + let _capture_lock = store.root.lock(deadline)?; + remove_capture(&store, deadline)?; + let name = options + .name + .as_ref() + .expect("the parser requires a snapshot name"); + let named = store.lock(name, deadline)?; + match fs::symlink_metadata(&named.dir) { + Ok(_) => { + return Err(Exit::error( + ErrorCode::Io, + "the named snapshot already exists", + )); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error(ErrorCode::Io, &error, &named.dir)), + } + let state = StateDir::create(&store.root.dir().join("capture"))?; + let mut locked = state.lock(deadline)?; + let dir = VmDir::open(state.dir())?; + let mut geometry = disk_geometry(&dir)?; + geometry.length = options.image_mib << 20; + let config = VmConfig { + processors: options.processors, + memory_mib: options.memory_mib, + root: RootConfig::Blockless, + view: View { + hostname: b"nvx-capture".to_vec(), + machine_id: MachineIdMode::Image, + }, + network: None, + filesystem: None, + console: ConsoleAttachment::Disconnected, + boot: Some(Boot { + memory_capacity_mib: None, + command_line: "nvx_sandbox=1".to_owned(), + capture: Some(CaptureConfig { + disks: DiskCapture::Fresh { + image: geometry, + scratch: BlockGeometry { + length: options.scratch_mib << 20, + ..geometry + }, + }, + processors: Capacity { + boot: u64::from(options.processors), + capacity: u64::from(options.processor_capacity.unwrap_or(options.processors)), + }, + memory: Capacity { + boot: options.memory_mib, + capacity: options.memory_capacity_mib.unwrap_or(options.memory_mib), + }, + invariants: Invariants::new(), + }), + }), + }; + let prepared = vm::create(runtime, &config, None, &dir)?; + let context = prepared + .capture_context() + .cloned() + .expect("boot.capture was checked"); + let capability = *prepared.capability().as_bytes(); + let outputs = prepared.outputs().clone(); + let mut launch = locked.begin_launch(&prepared, &runtime.identity)?; + check_interrupt()?; + let process = spawn(prepared).map_err(error_with_cause)?; + launch.record_process(process.reference())?; + drop(launch); + let exited = match request_and_wait(&process, &outputs, &capability, deadline) { + Ok(exited) => exited, + Err(error) + if !matches!(error, Exit::Interrupted) + && (error.code() == Some(ErrorCode::TimedOut) || deadline.passed()) => + { + let now = Instant::now(); + let stopped = vm::stop(&process, &outputs, |_| false, now, now + KILL_TIMEOUT) + .map_err(|cleanup| { + Exit::error( + cleanup.code(), + format!( + "capture timed out; stopping its VM failed: {}", + error_with_cause(cleanup).message().unwrap_or_default() + ), + ) + })?; + stopped.report.map_err(error_with_cause)?; + return Err(error); + } + Err(error) => return Err(error), + }; + check_interrupt()?; + finish_capture(&dir, &context, &exited).map_err(error_with_cause)?; + check_interrupt()?; + let staged = named.stage()?; + let publication: Result = (|| { + for entry in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + fs::rename(dir.path().join(entry), staged.dir().join(entry)) + .map_err(|error| io_error(ErrorCode::Io, &error, &dir.path().join(entry)))?; + } + named.publish(staged.clone())?; + Ok(()) + })(); + if let Err(error) = publication { + return Err(match fs::remove_dir_all(staged.dir()) { + Ok(()) => error, + Err(cleanup) if cleanup.kind() == std::io::ErrorKind::NotFound => error, + Err(cleanup) => Exit::error( + ErrorCode::Io, + format!( + "{}; cannot remove capture staging {}: {cleanup}", + error.message().unwrap_or_default(), + staged.dir().display() + ), + ), + }); + } + drop(locked); + drop(process); + remove_capture(&store, Deadline::after(KILL_TIMEOUT.as_secs_f64())).map_err(|error| { + Exit::error( + error.code().unwrap_or(ErrorCode::Io), + format!( + "snapshot was published; capture VM cleanup failed: {}", + error.message().unwrap_or_default() + ), + ) + }) +} + +/// Reads the geometry through the existing block-device helper on this VM's filesystem. +fn disk_geometry(dir: &VmDir) -> Result { + let path = dir.path().join("geometry.raw"); + let file = crate::host::store::files::create_new(&path)?; + file.set_len(512) + .map_err(|error| io_error(ErrorCode::Io, &error, &path))?; + let geometry = crate::host::sandbox::block_geometry(&file, &path, "capture probe")?; + drop(file); + fs::remove_file(&path).map_err(|error| io_error(ErrorCode::Io, &error, &path))?; + Ok(geometry) +} + +/// Normal launch recovery must prove exit before the reserved capture directory can be removed. +fn remove_capture(store: &Store, deadline: Deadline) -> Result { + let path = store.root.dir().join("capture"); + let state = match StateDir::open(&path) { + Ok(state) => state, + Err(error) if error.code() == Some(ErrorCode::NotFound) => return Ok(()), + Err(error) => return Err(error), + }; + let locked: LockedState = state.lock(deadline)?; + if locked.runtime()?.is_some() { + return Err(Exit::error( + ErrorCode::Io, + "a capture directory cannot hold a committed VM", + )); + } + drop(locked); + fs::remove_dir_all(&path).map_err(|error| io_error(ErrorCode::Io, &error, &path)) +} + +/// Uses authentication and the actual held process, never the capture acknowledgement as exit. +fn request_and_wait( + process: &Process, + outputs: &VmOutputs, + capability: &[u8; crate::protocol::CAPABILITY_LEN], + deadline: Deadline, +) -> Result { + let until = deadline.instant(); + let transport = transport::connect_verified(&outputs.endpoint, process.reference(), until); + check_interrupt()?; + let attached = ControlSession::attach(transport.map_err(error_with_cause)?, capability, until); + check_interrupt()?; + let requested = attached.map_err(control_error)?.capture(false, until); + check_interrupt()?; + requested.map_err(control_error)?; + loop { + check_interrupt()?; + let now = Instant::now(); + if deadline.passed() { + return Err(Exit::error( + ErrorCode::TimedOut, + "capture VM did not exit before its deadline", + )); + } + let exited = process + .wait(until.min(now + RETRY_INTERVAL)) + .map_err(Exit::from_os_error)?; + if let Some(exited) = exited { + if deadline.passed() { + return Err(Exit::error( + ErrorCode::TimedOut, + "capture VM exit was not confirmed before its deadline", + )); + } + return Ok(exited); + } + } +} + +/// Keeps control failures explicit, including the distinction between timeout and I/O. +fn control_error(error: SessionError) -> Exit { + let code = match error { + SessionError::TimedOut => ErrorCode::TimedOut, + SessionError::Io(_) | SessionError::Refused(_) => ErrorCode::Io, + SessionError::Closed | SessionError::ProcessExited => ErrorCode::Exited, + _ => ErrorCode::Protocol, + }; + Exit::error(code, format!("capture control failed: {error}")) +} + +#[cfg(test)] +mod tests; diff --git a/nvx-rs/src/host/cli/snapshots/capture/tests.rs b/nvx-rs/src/host/cli/snapshots/capture/tests.rs new file mode 100644 index 000000000..77b03368a --- /dev/null +++ b/nvx-rs/src/host/cli/snapshots/capture/tests.rs @@ -0,0 +1,574 @@ +#![cfg(any(target_os = "linux", windows))] + +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::thread; +use std::time::Duration; + +use serde_json::{Value, json}; + +use super::*; +use crate::host::platform; +use crate::host::process::{Opened, ProcessRef}; +use crate::host::snapshot::tests::{ManifestSpec, SnapshotFixture, class}; +use crate::host::snapshot::{MANIFEST_NAME, Snapshot}; +use crate::host::spawn::{self, VmmCommand}; +use crate::protocol::{ + APP_CAPTURE, APP_READY, CAPABILITY_LEN, OUTER_DATA, OUTER_HEADER_LEN, OUTER_HOST_ATTACH, + OUTER_READY, decode_app, decode_capture_payload, decode_outer_header, encode_app, encode_outer, +}; + +const HELPER: &str = "host::cli::snapshots::capture::tests::capture_helper"; +const COMMAND_HELPER: &str = "host::cli::snapshots::capture::tests::command_helper"; + +type Inventory = Vec<(PathBuf, (u64, u128), u64, std::time::SystemTime)>; + +struct Fixture { + root: tempfile::TempDir, +} + +impl Fixture { + fn new() -> Self { + Self { + root: tempfile::tempdir().unwrap(), + } + } + + fn options(&self, name: &str) -> Options { + options(self.root.path(), name) + } + + fn capture(&self, name: &str, behavior: &str, timeout: u32) -> Result { + let mut options = self.options(name); + options.timeout = timeout; + capture(&options, &runtime(self.root.path()), |prepared| { + Ok(fake_spawn(self.root.path(), prepared, behavior)) + }) + } + + fn store(&self) -> Store { + Store::create(&self.root.path().join("store")).unwrap() + } + + fn vm_dir(&self) -> PathBuf { + self.root.path().join("store").join("capture") + } + + fn references(&self) -> Vec { + match fs::read(self.root.path().join("processes.json")) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => panic!("cannot read owned process receipts: {error}"), + } + } + + fn existing(&self) { + let store = self.store(); + let held = store + .lock(&name("existing"), Deadline::after(10.0)) + .unwrap(); + let staged = held.stage().unwrap(); + let fixture = SnapshotFixture::new(); + for entry in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + fs::rename(fixture.dir.join(entry), staged.dir().join(entry)).unwrap(); + } + held.publish(staged).unwrap(); + } + + fn inventory(&self) -> Inventory { + let dir = self + .root + .path() + .join("store") + .join("snapshots") + .join("6578697374696e67"); + [ + KEY_FILE, + DESCRIPTION_FILE, + "openvmm/manifest.bin", + "openvmm/memory.bin", + "openvmm/state.bin", + ] + .into_iter() + .map(|entry| { + let path = dir.join(entry); + let metadata = fs::metadata(&path).unwrap(); + let id = platform::file_id(&path).unwrap(); + (path, id, metadata.len(), metadata.modified().unwrap()) + }) + .collect() + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + for reference in self.references() { + if let Opened::Running(process) = Process::open(&reference).unwrap() { + if process.wait(Instant::now()).unwrap().is_none() { + process.kill().unwrap(); + } + assert!( + process + .wait(Instant::now() + KILL_TIMEOUT) + .unwrap() + .is_some() + ); + } + } + } +} + +fn name(text: &str) -> Name { + Name::try_from(text.to_owned()).unwrap() +} + +fn options(root: &Path, snapshot: &str) -> Options { + let arguments = [ + "capture".to_owned(), + snapshot.to_owned(), + "--store-dir".to_owned(), + root.join("store").to_str().unwrap().to_owned(), + "--processors".to_owned(), + "2".to_owned(), + "--memory-mib".to_owned(), + "512".to_owned(), + "--memory-capacity-mib".to_owned(), + "2048".to_owned(), + ]; + let (options, extras) = parse(&arguments).unwrap(); + assert!(extras.is_empty()); + options +} + +fn runtime(root: &Path) -> Runtime { + Runtime { + openvmm: std::env::current_exe().unwrap(), + kernel: root.join("fake-kernel"), + initramfs: root.join("fake-initramfs"), + identity: class().runtime, + hypervisor: if cfg!(windows) { + crate::Hypervisor::Whp + } else { + crate::Hypervisor::Kvm + }, + cpu_profile: None, + windows_breakaway: false, + options: LaunchOptions::default(), + } +} + +fn fake_spawn(root: &Path, prepared: Prepared, behavior: &str) -> Process { + let dir = prepared.outputs().log.parent().unwrap(); + let journal: Value = + serde_json::from_slice(&fs::read(dir.join("launch.json")).unwrap()).unwrap(); + assert!( + journal["process"].is_null(), + "spawn must follow the durable prepared barrier" + ); + assert_eq!(journal["log"], prepared.outputs().log.to_str().unwrap()); + assert_eq!( + journal["outcome"], + prepared.outputs().outcome.to_str().unwrap() + ); + assert_eq!(journal["endpoint"], prepared.outputs().endpoint); + assert_eq!( + fs::read(dir.join("control.capability")).unwrap(), + prepared.capability().as_bytes() + ); + assert_eq!( + journal["capability"], + json!(prepared.capability().as_bytes()) + ); + assert!(journal["console"].is_null()); + assert!(!dir.join("runtime.json").exists()); + let command_line = prepared + .arguments() + .windows(2) + .find(|pair| pair[0] == "--cmdline") + .unwrap()[1] + .to_str() + .unwrap(); + assert_eq!( + command_line + .split_whitespace() + .filter(|token| *token == "nvx_sandbox=1") + .count(), + 1 + ); + let context = prepared.capture_context().unwrap(); + let DiskCapture::Fresh { image, scratch } = context.capture.disks; + let mut manifest = ManifestSpec::template(); + let contract = manifest.contract(); + contract.source_hypervisor = runtime(root).hypervisor.to_string(); + contract.blocks[0].physical_block_size = image.physical_block_size; + contract.blocks[1].physical_block_size = scratch.physical_block_size; + let recipe = json!({ + "root": root, "endpoint": prepared.outputs().endpoint, "behavior": behavior, + "manifest": manifest.encode(), "memory": manifest.memory_size, "state": manifest.state_size, + }); + fs::write( + dir.join("helper.json"), + serde_json::to_vec(&recipe).unwrap(), + ) + .unwrap(); + let command = VmmCommand::new( + std::env::current_exe().unwrap(), + ["--exact", HELPER, "--ignored", "--nocapture"] + .map(Into::into) + .to_vec(), + dir, + ); + let child = spawn::spawn_vm( + &command, + prepared.capability().as_bytes(), + prepared.log().try_clone().unwrap(), + ) + .unwrap(); + let process = child.process().unwrap(); + let path = root.join("processes.json"); + let mut references: Vec = if path.exists() { + serde_json::from_slice(&fs::read(&path).unwrap()).unwrap() + } else { + Vec::new() + }; + references.push(process.reference().clone()); + fs::write(path, serde_json::to_vec(&references).unwrap()).unwrap(); + child.detach(); + drop(prepared); + process +} + +fn wait_file(path: &Path) { + let until = Instant::now() + Duration::from_secs(20); + while !path.exists() { + assert!(Instant::now() < until, "missing receipt {}", path.display()); + thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn capture_parser_refuses_restore_bad_names_and_invalid_options() { + for arguments in [ + vec!["restore", "name"], + vec!["capture", "../escape", "--store-dir", "store"], + vec!["capture", "name"], + vec!["capture", "name", "--store-dir", "store", "--timeout", "0"], + vec![ + "capture", + "name", + "--store-dir", + "store", + "--processors", + "3", + ], + ] { + assert!( + parse( + &arguments + .iter() + .map(|value| (*value).to_owned()) + .collect::>() + ) + .is_err() + ); + } + assert!(matches!( + parse(&["capture".to_owned(), "--help".to_owned()]), + Err(Exit::Help(_)) + )); + CAPTURE_SYNTAX.assert_lists(&OPTIONS); +} + +#[test] +fn capture_publishes_only_three_strict_entries_after_its_own_exit_and_removes_its_vm() { + let fixture = Fixture::new(); + fixture.capture("first", "publish", 20).unwrap(); + let store = fixture.store(); + let held = store.lock(&name("first"), Deadline::after(10.0)).unwrap(); + let snapshot = held.open().unwrap(); + assert_eq!( + snapshot.key().runtime(), + &runtime(fixture.root.path()).identity + ); + let entries: Vec<_> = fs::read_dir(&held.dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(entries.len(), 3); + for entry in [KEY_FILE, DESCRIPTION_FILE, OPENVMM_DIR] { + assert!(entries.iter().any(|name| name == entry)); + } + assert!(Snapshot::open(&held.dir).is_ok()); + assert!(!fixture.vm_dir().exists()); + let receipt: Value = + serde_json::from_slice(&fs::read(fixture.root.path().join("ack.json")).unwrap()).unwrap(); + assert_eq!(receipt["capture"], true); + assert_eq!(receipt["finalizedWhileAlive"], false); + assert_eq!(receipt["pid"], fixture.references()[0].pid); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Exited(_) + )); +} + +#[test] +fn duplicate_names_and_a_failed_new_capture_never_change_an_existing_snapshot() { + let fixture = Fixture::new(); + fixture.existing(); + let before = fixture.inventory(); + let options = fixture.options("existing"); + assert!( + capture(&options, &runtime(fixture.root.path()), |_| panic!( + "duplicate must not spawn" + )) + .is_err() + ); + assert_eq!(fixture.inventory(), before); + assert!(!fixture.vm_dir().exists()); + assert_eq!( + fixture.capture("failed", "fail", 20).unwrap_err().code(), + Some(ErrorCode::Exited) + ); + let store = fixture.store(); + assert_eq!(fs::read_dir(store.entries.dir()).unwrap().count(), 1); + assert_eq!(fixture.inventory(), before); + assert!( + store + .lock(&name("existing"), Deadline::after(10.0)) + .unwrap() + .open() + .is_ok() + ); + assert!(!fixture.vm_dir().join(KEY_FILE).exists()); + assert!( + capture(&options, &runtime(fixture.root.path()), |_| panic!( + "duplicate must not spawn" + )) + .is_err() + ); + assert!(!fixture.vm_dir().exists()); + assert_eq!(fixture.inventory(), before); +} + +#[test] +fn a_capture_ack_and_successful_output_without_timely_exit_are_stopped_but_never_finished() { + let fixture = Fixture::new(); + let error = fixture.capture("late", "hang", 1).unwrap_err(); + assert_eq!(error.code(), Some(ErrorCode::TimedOut), "{error:?}"); + assert!(fixture.root.path().join("ack.json").exists()); + assert!(fixture.vm_dir().join(OPENVMM_DIR).exists()); + assert!(fixture.vm_dir().join("outcome.json").exists()); + assert!(!fixture.vm_dir().join(KEY_FILE).exists()); + assert!(!fixture.vm_dir().join(DESCRIPTION_FILE).exists()); + assert!(fixture.vm_dir().join("launch.json").exists()); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Exited(_) + )); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); + fixture.existing(); + let options = fixture.options("existing"); + assert!( + capture(&options, &runtime(fixture.root.path()), |_| panic!( + "duplicate must not spawn" + )) + .is_err() + ); + assert!( + !fixture.vm_dir().exists(), + "later exit must only permit recovery, not finishing" + ); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 1 + ); +} + +struct OwnedCommand(Child); + +impl Drop for OwnedCommand { + fn drop(&mut self) { + if self.0.try_wait().unwrap().is_none() { + self.0.kill().unwrap(); + } + self.0.wait().unwrap(); + } +} + +fn interrupted(fixture: &Fixture) { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args(["--exact", COMMAND_HELPER, "--ignored", "--nocapture"]) + .env("NVX_CAPTURE_COMMAND_ROOT", fixture.root.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let mut child = OwnedCommand(crate::host::tests::spawn_locked(&mut command).unwrap()); + wait_file(&fixture.root.path().join("ack.json")); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + assert!(fixture.vm_dir().join("launch.json").exists()); + assert!(!fixture.vm_dir().join(KEY_FILE).exists()); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Running(_) + )); +} + +#[test] +fn the_next_command_ends_an_interrupted_capture_and_removes_its_directory_without_publishing() { + let fixture = Fixture::new(); + fixture.existing(); + let before = fixture.inventory(); + interrupted(&fixture); + let options = fixture.options("existing"); + assert!( + capture(&options, &runtime(fixture.root.path()), |_| panic!( + "duplicate must not spawn" + )) + .is_err() + ); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Exited(_) + )); + assert!(!fixture.vm_dir().exists()); + assert_eq!(fixture.inventory(), before); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 1 + ); +} + +#[test] +fn indeterminate_recovery_retains_the_live_vm_its_capability_and_journal() { + let fixture = Fixture::new(); + interrupted(&fixture); + let journal_path = fixture.vm_dir().join("launch.json"); + let mut journal: Value = serde_json::from_slice(&fs::read(&journal_path).unwrap()).unwrap(); + journal["process"]["pid"] = json!(0); + fs::write(&journal_path, serde_json::to_vec(&journal).unwrap()).unwrap(); + let journal = fs::read(&journal_path).unwrap(); + let capability = fs::read(fixture.vm_dir().join("control.capability")).unwrap(); + let options = fixture.options("next"); + assert!( + capture(&options, &runtime(fixture.root.path()), |_| panic!( + "recovery must refuse" + )) + .is_err() + ); + assert_eq!(fs::read(journal_path).unwrap(), journal); + assert_eq!( + fs::read(fixture.vm_dir().join("control.capability")).unwrap(), + capability + ); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Running(_) + )); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); +} + +#[test] +#[ignore = "owned subprocess of interrupted-capture command tests"] +fn command_helper() { + let root = PathBuf::from(std::env::var_os("NVX_CAPTURE_COMMAND_ROOT").unwrap()); + let result = capture(&options(&root, "abandoned"), &runtime(&root), |prepared| { + Ok(fake_spawn(&root, prepared, "hang")) + }); + panic!("the test must interrupt this capture: {result:?}"); +} + +fn read_record(stream: &mut (impl Read + Write)) -> (u8, Vec) { + let mut header = [0; OUTER_HEADER_LEN]; + stream.read_exact(&mut header).unwrap(); + let header = decode_outer_header(&header).unwrap(); + assert!(header.length <= crate::protocol::OUTER_MAX_PAYLOAD); + let mut payload = vec![0; header.length]; + stream.read_exact(&mut payload).unwrap(); + (header.record_type, payload) +} + +fn respond(stream: &mut (impl Read + Write), capability: &[u8]) { + let (kind, payload) = read_record(stream); + assert_eq!(kind, OUTER_HOST_ATTACH); + assert_eq!(payload, capability); + stream + .write_all(&encode_outer(OUTER_READY, &[7; 16], 1, 0, &[]).unwrap()) + .unwrap(); + let (kind, payload) = read_record(stream); + assert_eq!(kind, OUTER_DATA); + let request = decode_app(&payload).unwrap(); + assert_eq!(request.kind, APP_CAPTURE); + assert!(!decode_capture_payload(request.payload).unwrap()); + let reply = encode_app(APP_READY, request.request_id, 0, &[]).unwrap(); + stream + .write_all(&encode_outer(OUTER_DATA, &[7; 16], 1, 1, &reply).unwrap()) + .unwrap(); +} + +#[test] +#[ignore = "authenticated fake VMM subprocess; not real-VM capture proof"] +fn capture_helper() { + let mut capability = Vec::new(); + std::io::stdin().read_to_end(&mut capability).unwrap(); + assert_eq!(capability.len(), CAPABILITY_LEN); + assert_eq!(fs::read("control.capability").unwrap(), capability); + let recipe: Value = serde_json::from_slice(&fs::read("helper.json").unwrap()).unwrap(); + let endpoint = recipe["endpoint"].as_str().unwrap(); + #[cfg(target_os = "linux")] + { + let server = std::os::unix::net::UnixListener::bind(endpoint).unwrap(); + let (mut stream, _) = server.accept().unwrap(); + respond(&mut stream, &capability); + } + #[cfg(windows)] + { + let mut server = platform::tests::control_server(endpoint); + platform::tests::accept_control(&server); + respond(&mut server, &capability); + } + let journal: Value = serde_json::from_slice(&fs::read("launch.json").unwrap()).unwrap(); + assert_eq!(journal["process"]["pid"], std::process::id()); + assert_eq!(journal["endpoint"], endpoint); + let root = PathBuf::from(recipe["root"].as_str().unwrap()); + fs::write(root.join("ack.json"), serde_json::to_vec(&json!({ + "capture": true, "pid": std::process::id(), + "finalizedWhileAlive": Path::new(KEY_FILE).exists() || Path::new(DESCRIPTION_FILE).exists(), + })).unwrap()).unwrap(); + vm::create_private_dir(Path::new(OPENVMM_DIR)).unwrap(); + let manifest: Vec = serde_json::from_value(recipe["manifest"].clone()).unwrap(); + crate::host::store::files::write_private( + &Path::new(OPENVMM_DIR).join(MANIFEST_NAME), + &manifest, + ) + .unwrap(); + for (entry, field) in [("memory.bin", "memory"), ("state.bin", "state")] { + let file = + crate::host::store::files::create_new(&Path::new(OPENVMM_DIR).join(entry)).unwrap(); + platform::make_sparse(&file).unwrap(); + file.set_len(recipe[field].as_u64().unwrap()).unwrap(); + } + let failed = recipe["behavior"] == "fail"; + crate::host::store::files::write_private(Path::new("outcome.json"), &serde_json::to_vec(&json!({ + "outcome": { "operation": "capture", "category": if failed { "failure" } else { "success" }, + "status_code": if failed { 1 } else { 0 } }, + })).unwrap()).unwrap(); + if recipe["behavior"] == "hang" { + thread::sleep(Duration::from_secs(120)); + } else { + thread::sleep(Duration::from_millis(100)); + assert!( + !Path::new(KEY_FILE).exists(), + "ACK and completed output are not exit proof" + ); + assert!(!Path::new(DESCRIPTION_FILE).exists()); + } +} diff --git a/nvx-rs/src/host/session.rs b/nvx-rs/src/host/session.rs index b5d388a90..8fb8c1f77 100644 --- a/nvx-rs/src/host/session.rs +++ b/nvx-rs/src/host/session.rs @@ -168,11 +168,8 @@ impl ControlSession { /// Asks nvx-init to capture the VM, killing its executions first when requested. #[cfg_attr( - not(test), - expect( - dead_code, - reason = "SL3.7b requests capture once nvx-init handles it in SL7.7a" - ) + not(any(test, feature = "cli")), + expect(dead_code, reason = "NVX's capture command requires the cli feature") )] pub(crate) fn capture( &mut self, diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 347e17ac4..2b0a9cc65 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -12,7 +12,6 @@ "nvx-rs/src/guest/sys/spawn.rs": 7, "nvx-rs/src/guest/time/system.rs": 3, "nvx-rs/src/host/cli/sandbox/state.rs": 1, - "nvx-rs/src/host/cli/snapshots.rs": 1, "nvx-rs/src/host/cli/store/mod.rs": 1, "nvx-rs/src/host/network/address.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, From 1d5cf41c98fc3a4b842c23d930ac2f9a63f80095 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 13:13:55 -0800 Subject: [PATCH 2/9] Keep capture parser and file errors properly typed Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1dcd7c0-6291-46ec-805e-2b4379ea3e44 --- nvx-rs/src/host/cli/snapshots/capture.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nvx-rs/src/host/cli/snapshots/capture.rs b/nvx-rs/src/host/cli/snapshots/capture.rs index 80df74db6..187e8cec8 100644 --- a/nvx-rs/src/host/cli/snapshots/capture.rs +++ b/nvx-rs/src/host/cli/snapshots/capture.rs @@ -156,7 +156,7 @@ impl Receiver for Options { /// Parses only the capture operation; restore belongs to later slices. pub(in crate::host::cli) fn parse(arguments: &[String]) -> Result<(Options, Vec)> { let (index, mut extras) = Parser::new(&[], &[Spec::one("snapshot_operation")]) - .split_command(arguments, &mut |_, _| Ok(())) + .split_command(arguments, &mut |_: &'static str, _: Option<&str>| Ok(())) .map_err(|stop| Exit::from_stop(stop, &SYNTAX))?; choice(&arguments[index], &["capture"]) .map_err(|message| Exit::usage(&SYNTAX, ErrorCode::ArgumentInvalid, message))?; @@ -338,7 +338,8 @@ fn capture( /// Reads the geometry through the existing block-device helper on this VM's filesystem. fn disk_geometry(dir: &VmDir) -> Result { let path = dir.path().join("geometry.raw"); - let file = crate::host::store::files::create_new(&path)?; + let file = crate::host::store::files::create_new(&path) + .map_err(|error| io_error(ErrorCode::Io, &error, &path))?; file.set_len(512) .map_err(|error| io_error(ErrorCode::Io, &error, &path))?; let geometry = crate::host::sandbox::block_geometry(&file, &path, "capture probe")?; From 951ed0fc7df66a1714c0f6abab6c5f1080215572 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 13:16:56 -0800 Subject: [PATCH 3/9] Recover abandoned captures before requiring launch artifacts Reuse the CLI artifact helper with CLI-scoped field access; leave public APIs unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1dcd7c0-6291-46ec-805e-2b4379ea3e44 --- nvx-rs/src/host/cli/sandbox/mod.rs | 2 +- nvx-rs/src/host/cli/snapshots/capture.rs | 6 ++++++ nvx-rs/src/host/cli/snapshots/capture/tests.rs | 9 ++------- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/nvx-rs/src/host/cli/sandbox/mod.rs b/nvx-rs/src/host/cli/sandbox/mod.rs index 60100036e..33a6b2876 100644 --- a/nvx-rs/src/host/cli/sandbox/mod.rs +++ b/nvx-rs/src/host/cli/sandbox/mod.rs @@ -55,7 +55,7 @@ use self::state::RecordedDistro; /// The artifacts of a sandbox, found when an operation first needs them. pub(crate) struct Artifacts<'a> { /// `--root`, if given. - root: Option<&'a str>, + pub(super) root: Option<&'a str>, } impl Artifacts<'_> { diff --git a/nvx-rs/src/host/cli/snapshots/capture.rs b/nvx-rs/src/host/cli/snapshots/capture.rs index 187e8cec8..670bcb872 100644 --- a/nvx-rs/src/host/cli/snapshots/capture.rs +++ b/nvx-rs/src/host/cli/snapshots/capture.rs @@ -178,6 +178,12 @@ pub(in crate::host::cli) fn parse(arguments: &[String]) -> Result<(Options, Vec< /// Uses the identified artifacts, never an unlabelled OpenVMM override or a downloaded substitute. pub(in crate::host::cli) fn run(root: Option<&str>, options: Options) -> Result { + { + let store = Store::create(Path::new(&options.store_dir))?; + let deadline = Deadline::after(f64::from(options.timeout)); + let _lock = store.root.lock(deadline)?; + remove_capture(&store, deadline)?; + } let artifacts = Artifacts { root }; let _hold = artifacts.hold()?; let [openvmm, kernel, initramfs] = artifacts.require()?; diff --git a/nvx-rs/src/host/cli/snapshots/capture/tests.rs b/nvx-rs/src/host/cli/snapshots/capture/tests.rs index 77b03368a..40e2bda20 100644 --- a/nvx-rs/src/host/cli/snapshots/capture/tests.rs +++ b/nvx-rs/src/host/cli/snapshots/capture/tests.rs @@ -425,13 +425,8 @@ fn the_next_command_ends_an_interrupted_capture_and_removes_its_directory_withou fixture.existing(); let before = fixture.inventory(); interrupted(&fixture); - let options = fixture.options("existing"); - assert!( - capture(&options, &runtime(fixture.root.path()), |_| panic!( - "duplicate must not spawn" - )) - .is_err() - ); + let missing_artifacts = fixture.root.path().join("missing-artifacts"); + assert!(run(missing_artifacts.to_str(), fixture.options("existing")).is_err()); assert!(matches!( Process::open(&fixture.references()[0]).unwrap(), Opened::Exited(_) From 26b8219eebdd6abb41df06d4ba98cd4b9d3a6371 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 13:24:16 -0800 Subject: [PATCH 4/9] Keep capture fakes in the host-only feature boundary Check the capture flag directly on the authenticated wire without requiring guest-only decoder exports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1dcd7c0-6291-46ec-805e-2b4379ea3e44 --- nvx-rs/src/host/cli/snapshots/capture/tests.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nvx-rs/src/host/cli/snapshots/capture/tests.rs b/nvx-rs/src/host/cli/snapshots/capture/tests.rs index 40e2bda20..4542a95d3 100644 --- a/nvx-rs/src/host/cli/snapshots/capture/tests.rs +++ b/nvx-rs/src/host/cli/snapshots/capture/tests.rs @@ -16,7 +16,7 @@ use crate::host::snapshot::{MANIFEST_NAME, Snapshot}; use crate::host::spawn::{self, VmmCommand}; use crate::protocol::{ APP_CAPTURE, APP_READY, CAPABILITY_LEN, OUTER_DATA, OUTER_HEADER_LEN, OUTER_HOST_ATTACH, - OUTER_READY, decode_app, decode_capture_payload, decode_outer_header, encode_app, encode_outer, + OUTER_READY, decode_app, decode_outer_header, encode_app, encode_outer, }; const HELPER: &str = "host::cli::snapshots::capture::tests::capture_helper"; @@ -502,8 +502,8 @@ fn respond(stream: &mut (impl Read + Write), capability: &[u8]) { assert_eq!(kind, OUTER_DATA); let request = decode_app(&payload).unwrap(); assert_eq!(request.kind, APP_CAPTURE); - assert!(!decode_capture_payload(request.payload).unwrap()); - let reply = encode_app(APP_READY, request.request_id, 0, &[]).unwrap(); + assert_eq!(request.payload, [0]); + let reply = encode_app(APP_READY, request.request_id, 0, &[]); stream .write_all(&encode_outer(OUTER_DATA, &[7; 16], 1, 1, &reply).unwrap()) .unwrap(); From 33bf0e358f68a351e7454c142bfee4c32835fedf Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 13:46:23 -0800 Subject: [PATCH 5/9] Document capture and preserve exact CLI usage assertions Extend the existing top-level usage and invalid-choice expectations with snapshot; retain every prior error/status assertion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1dcd7c0-6291-46ec-805e-2b4379ea3e44 --- nvx-rs/README.md | 10 ++++++++++ nvx-rs/tests/cli.rs | 7 ++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/nvx-rs/README.md b/nvx-rs/README.md index e885c5c9b..bf3756829 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -2519,6 +2519,7 @@ nvx-rs [-h] [--root PATH] download [--repository OWNER/REPOSITORY] [--hypervisor nvx-rs [-h] [--root PATH] egress compile PATH nvx-rs [-h] [--root PATH] run [OPTIONS] nvx-rs [-h] [--root PATH] sandbox [run|provision|start|exec|stop|deprovision] [OPTIONS] +nvx-rs [-h] [--root PATH] snapshot capture NAME [OPTIONS] ``` ```bash @@ -2569,6 +2570,15 @@ which take many options, name them `[OPTIONS]`, as in `usage: nvx-rs run [OPTIONS]`, and their help lists every option. A usage error names the parser after the usage, as in `nvx-rs run: error[CODE]: MESSAGE`. +`snapshot capture NAME --store-dir PATH` captures a blockless VM before any +layered root or workload exists. It journals the launch before spawning, +requests capture through authenticated NVXC, and requires that VM's timely +exit before finishing and publishing exactly the strict snapshot's three +entries. The next capture using that store ends an interrupted attempt through +ordinary launch recovery, never resuming it. See +[`snapshot capture`](../doc/usage.md#snapshot-capture) for its resource options +and recovery behavior. Restore commands are separate follow-ups. + ### Artifact root Every command uses the artifacts of one root: a repository checkout, with the diff --git a/nvx-rs/tests/cli.rs b/nvx-rs/tests/cli.rs index 157ae9784..3938ca914 100644 --- a/nvx-rs/tests/cli.rs +++ b/nvx-rs/tests/cli.rs @@ -2382,7 +2382,8 @@ fn a_relative_openvmm_override_is_relative_to_the_working_directory() { #[test] fn top_level_usage_errors_exit_with_status_2() { - let usage = "usage: nvx-rs [-h] [--root PATH] {artifacts,download,egress,run,sandbox} ...\n"; + let usage = + "usage: nvx-rs [-h] [--root PATH] {artifacts,download,egress,run,sandbox,snapshot} ...\n"; for (arguments, message) in [ ( &[][..], @@ -2395,7 +2396,7 @@ fn top_level_usage_errors_exit_with_status_2() { ( &["build"][..], "nvx-rs: error[argument-invalid]: argument command: invalid choice: \"build\" (choose \ - from \"artifacts\", \"download\", \"egress\", \"run\", \"sandbox\")\n", + from \"artifacts\", \"download\", \"egress\", \"run\", \"sandbox\", \"snapshot\")\n", ), ( &["artifacts", "--bogus"][..], @@ -2405,7 +2406,7 @@ fn top_level_usage_errors_exit_with_status_2() { ( &["--prog", "nvx.py", "sandbox"][..], "nvx-rs: error[argument-invalid]: argument command: invalid choice: \"nvx.py\" \ - (choose from \"artifacts\", \"download\", \"egress\", \"run\", \"sandbox\")\n", + (choose from \"artifacts\", \"download\", \"egress\", \"run\", \"sandbox\", \"snapshot\")\n", ), ( &["--prog=nvx.py", "artifacts"][..], From cb77b075a1243fe2b4758a7562375e6ae1162bcd Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 14:30:52 -0800 Subject: [PATCH 6/9] Stop failed captures and retain unowned capture-slot data Keep launch-record and control errors on the verified-process stop path, and reject unjournaled or unknown slot contents before mutation. Add focused rejection and ownership regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1dcd7c0-6291-46ec-805e-2b4379ea3e44 --- doc/usage.md | 13 ++-- nvx-rs/src/host/cli/snapshots/capture.rs | 61 ++++++++++++--- .../src/host/cli/snapshots/capture/tests.rs | 74 +++++++++++++++++-- 3 files changed, 123 insertions(+), 25 deletions(-) diff --git a/doc/usage.md b/doc/usage.md index 17ac54ad0..15d10e0ef 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -366,11 +366,14 @@ Only a successful capture whose own VMM exits within `--timeout` (60 whole seconds by default) can publish. The immutable named directory contains exactly `key.bin`, `snapshot.json` and `openvmm/`; an existing name is never replaced. Capture's own VM directory is then removed. A timed-out VM is stopped but its -capture is never finished. The next capture command using this store ends and -removes an interrupted attempt through ordinary launch recovery, without -resuming or publishing it. Indeterminate recovery retains its evidence and -reports an error. Captures in one store serialize; snapshot locks remain -separate from their immutable directories. Restore commands are not added here. +capture is never finished. Other control or launch-record failures also stop +the command's verified VM without finishing it. The next capture command using +this store ends and removes an interrupted attempt through ordinary launch +recovery, without resuming or publishing it. Indeterminate recovery retains its +evidence and reports an error. Unknown files or nonempty unjournaled capture-slot +contents are kept for inspection, not deleted; an empty launch lock alone is +harmless. Captures in one store serialize; snapshot locks remain separate from +their immutable directories. Restore commands are not added here. ### `download` diff --git a/nvx-rs/src/host/cli/snapshots/capture.rs b/nvx-rs/src/host/cli/snapshots/capture.rs index 670bcb872..b6a231871 100644 --- a/nvx-rs/src/host/cli/snapshots/capture.rs +++ b/nvx-rs/src/host/cli/snapshots/capture.rs @@ -12,7 +12,7 @@ use crate::host::cli::deadline::{Deadline, RETRY_INTERVAL}; use crate::host::cli::sandbox::{ Artifacts, HYPERVISORS, MAX_MEMORY_MIB, error_with_cause, hypervisor, }; -use crate::host::cli::store::{LockedState, StateDir}; +use crate::host::cli::store::{LockedState, StateDir, StateFile}; use crate::host::cli::{Exit, Result, check_interrupt, io_error, output}; use crate::host::config::{ BlockGeometry, Boot, Capacity, CaptureConfig, ConsoleAttachment, DiskCapture, Invariants, @@ -278,29 +278,32 @@ fn capture( let mut launch = locked.begin_launch(&prepared, &runtime.identity)?; check_interrupt()?; let process = spawn(prepared).map_err(error_with_cause)?; - launch.record_process(process.reference())?; - drop(launch); - let exited = match request_and_wait(&process, &outputs, &capability, deadline) { + let result = (|| { + launch + .record_process(process.reference()) + .map_err(error_with_cause)?; + drop(launch); + request_and_wait(&process, &outputs, &capability, deadline) + })(); + let exited = match result { Ok(exited) => exited, - Err(error) - if !matches!(error, Exit::Interrupted) - && (error.code() == Some(ErrorCode::TimedOut) || deadline.passed()) => - { + Err(Exit::Interrupted) => return Err(Exit::Interrupted), + Err(error) => { let now = Instant::now(); - let stopped = vm::stop(&process, &outputs, |_| false, now, now + KILL_TIMEOUT) + vm::stop(&process, &outputs, |_| false, now, now + KILL_TIMEOUT) + .and_then(|stopped| stopped.report.map(|_| ())) .map_err(|cleanup| { Exit::error( cleanup.code(), format!( - "capture timed out; stopping its VM failed: {}", + "{}; ending its capture VM failed: {}", + error.message().unwrap_or_default(), error_with_cause(cleanup).message().unwrap_or_default() ), ) })?; - stopped.report.map_err(error_with_cause)?; return Err(error); } - Err(error) => return Err(error), }; check_interrupt()?; finish_capture(&dir, &context, &exited).map_err(error_with_cause)?; @@ -362,6 +365,40 @@ fn remove_capture(store: &Store, deadline: Deadline) -> Result { Err(error) if error.code() == Some(ErrorCode::NotFound) => return Ok(()), Err(error) => return Err(error), }; + let journaled = state + .path(StateFile::Journal) + .try_exists() + .map_err(Exit::from_os_error)?; + for entry in fs::read_dir(&path).map_err(Exit::from_os_error)? { + let entry = entry.map_err(Exit::from_os_error)?; + let name = entry.file_name(); + let known = StateFile::ALL.iter().any(|file| name == file.name()) + || [ + "geometry.raw", + "capture-image.raw", + "capture-scratch.raw", + "console.sock", + KEY_FILE, + DESCRIPTION_FILE, + OPENVMM_DIR, + ] + .iter() + .any(|file| name == *file) + || name + .to_str() + .is_some_and(|name| name.starts_with(".openvmm")); + let empty_lock = name == StateFile::Lock.name() + && entry.metadata().map_err(Exit::from_os_error)?.len() == 0; + if !known || name == StateFile::Config.name() || (!journaled && !empty_lock) { + return Err(Exit::error( + ErrorCode::Io, + format!( + "refusing to remove unowned capture content {}", + entry.path().display() + ), + )); + } + } let locked: LockedState = state.lock(deadline)?; if locked.runtime()?.is_some() { return Err(Exit::error( diff --git a/nvx-rs/src/host/cli/snapshots/capture/tests.rs b/nvx-rs/src/host/cli/snapshots/capture/tests.rs index 4542a95d3..b41980cf0 100644 --- a/nvx-rs/src/host/cli/snapshots/capture/tests.rs +++ b/nvx-rs/src/host/cli/snapshots/capture/tests.rs @@ -15,8 +15,8 @@ use crate::host::snapshot::tests::{ManifestSpec, SnapshotFixture, class}; use crate::host::snapshot::{MANIFEST_NAME, Snapshot}; use crate::host::spawn::{self, VmmCommand}; use crate::protocol::{ - APP_CAPTURE, APP_READY, CAPABILITY_LEN, OUTER_DATA, OUTER_HEADER_LEN, OUTER_HOST_ATTACH, - OUTER_READY, decode_app, decode_outer_header, encode_app, encode_outer, + APP_CAPTURE, APP_ERROR, APP_READY, CAPABILITY_LEN, OUTER_DATA, OUTER_HEADER_LEN, + OUTER_HOST_ATTACH, OUTER_READY, decode_app, decode_outer_header, encode_app, encode_outer, }; const HELPER: &str = "host::cli::snapshots::capture::tests::capture_helper"; @@ -208,7 +208,7 @@ fn fake_spawn(root: &Path, prepared: Prepared, behavior: &str) -> Process { "manifest": manifest.encode(), "memory": manifest.memory_size, "state": manifest.state_size, }); fs::write( - dir.join("helper.json"), + root.join("helper.json"), serde_json::to_vec(&recipe).unwrap(), ) .unwrap(); @@ -388,6 +388,48 @@ fn a_capture_ack_and_successful_output_without_timely_exit_are_stopped_but_never ); } +#[test] +fn a_capture_rejection_stops_its_verified_vm_without_finishing_or_publishing() { + let fixture = Fixture::new(); + let error = fixture.capture("refused", "refuse", 20).unwrap_err(); + assert_eq!(error.code(), Some(ErrorCode::Protocol), "{error:?}"); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Exited(_) + )); + assert!(fixture.vm_dir().join("launch.json").exists()); + assert!(!fixture.vm_dir().join(KEY_FILE).exists()); + assert!(!fixture.vm_dir().join(DESCRIPTION_FILE).exists()); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); +} + +#[test] +fn unjournaled_user_content_is_preserved_before_any_capture_slot_mutation() { + for entry in ["personal.txt", KEY_FILE, OPENVMM_DIR, ".openvmm-personal"] { + let fixture = Fixture::new(); + let store = fixture.store(); + StateDir::create(&fixture.vm_dir()).unwrap(); + let path = fixture.vm_dir().join(entry); + let data = if entry == OPENVMM_DIR { + StateDir::create(&path).unwrap(); + path.join("personal.txt") + } else { + path + }; + fs::write(&data, b"preserve user content").unwrap(); + let missing_artifacts = fixture.root.path().join("missing-artifacts"); + let error = run(missing_artifacts.to_str(), fixture.options("first")).unwrap_err(); + assert!(error.message().unwrap().contains("unowned capture content")); + assert_eq!(fs::read(data).unwrap(), b"preserve user content"); + assert!(!fixture.vm_dir().join("sandbox.lock").exists()); + assert!(!fixture.vm_dir().join("launch.json").exists()); + assert_eq!(fs::read_dir(store.entries.dir()).unwrap().count(), 0); + } +} + struct OwnedCommand(Child); impl Drop for OwnedCommand { @@ -491,7 +533,7 @@ fn read_record(stream: &mut (impl Read + Write)) -> (u8, Vec) { (header.record_type, payload) } -fn respond(stream: &mut (impl Read + Write), capability: &[u8]) { +fn respond(stream: &mut (impl Read + Write), capability: &[u8], refused: bool) { let (kind, payload) = read_record(stream); assert_eq!(kind, OUTER_HOST_ATTACH); assert_eq!(payload, capability); @@ -503,7 +545,11 @@ fn respond(stream: &mut (impl Read + Write), capability: &[u8]) { let request = decode_app(&payload).unwrap(); assert_eq!(request.kind, APP_CAPTURE); assert_eq!(request.payload, [0]); - let reply = encode_app(APP_READY, request.request_id, 0, &[]); + let reply = if refused { + encode_app(APP_ERROR, request.request_id, 22, b"capture-failed") + } else { + encode_app(APP_READY, request.request_id, 0, &[]) + }; stream .write_all(&encode_outer(OUTER_DATA, &[7; 16], 1, 1, &reply).unwrap()) .unwrap(); @@ -516,19 +562,27 @@ fn capture_helper() { std::io::stdin().read_to_end(&mut capability).unwrap(); assert_eq!(capability.len(), CAPABILITY_LEN); assert_eq!(fs::read("control.capability").unwrap(), capability); - let recipe: Value = serde_json::from_slice(&fs::read("helper.json").unwrap()).unwrap(); + let directory = std::env::current_dir().unwrap(); + let recipe_path = directory + .parent() + .unwrap() + .parent() + .unwrap() + .join("helper.json"); + let recipe: Value = serde_json::from_slice(&fs::read(recipe_path).unwrap()).unwrap(); let endpoint = recipe["endpoint"].as_str().unwrap(); + let refused = recipe["behavior"] == "refuse"; #[cfg(target_os = "linux")] { let server = std::os::unix::net::UnixListener::bind(endpoint).unwrap(); let (mut stream, _) = server.accept().unwrap(); - respond(&mut stream, &capability); + respond(&mut stream, &capability, refused); } #[cfg(windows)] { let mut server = platform::tests::control_server(endpoint); platform::tests::accept_control(&server); - respond(&mut server, &capability); + respond(&mut server, &capability, refused); } let journal: Value = serde_json::from_slice(&fs::read("launch.json").unwrap()).unwrap(); assert_eq!(journal["process"]["pid"], std::process::id()); @@ -545,6 +599,10 @@ fn capture_helper() { &manifest, ) .unwrap(); + if refused { + thread::sleep(Duration::from_secs(120)); + return; + } for (entry, field) in [("memory.bin", "memory"), ("state.bin", "state")] { let file = crate::host::store::files::create_new(&Path::new(OPENVMM_DIR).join(entry)).unwrap(); From 6180464efa3a2f60d135ae3862d113edcbd7edd8 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 14:42:14 -0800 Subject: [PATCH 7/9] Expose the recovery error in the interrupted-capture assertion Keep the existing actual-exit assertion intact and include the next-command error and typed process state in failures. No production change or later interruption barrier. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1dcd7c0-6291-46ec-805e-2b4379ea3e44 --- nvx-rs/src/host/cli/snapshots/capture/tests.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/nvx-rs/src/host/cli/snapshots/capture/tests.rs b/nvx-rs/src/host/cli/snapshots/capture/tests.rs index b41980cf0..0abbc14c5 100644 --- a/nvx-rs/src/host/cli/snapshots/capture/tests.rs +++ b/nvx-rs/src/host/cli/snapshots/capture/tests.rs @@ -468,11 +468,12 @@ fn the_next_command_ends_an_interrupted_capture_and_removes_its_directory_withou let before = fixture.inventory(); interrupted(&fixture); let missing_artifacts = fixture.root.path().join("missing-artifacts"); - assert!(run(missing_artifacts.to_str(), fixture.options("existing")).is_err()); - assert!(matches!( - Process::open(&fixture.references()[0]).unwrap(), - Opened::Exited(_) - )); + let error = run(missing_artifacts.to_str(), fixture.options("existing")).unwrap_err(); + let opened = Process::open(&fixture.references()[0]).unwrap(); + assert!( + matches!(&opened, Opened::Exited(_)), + "next-command recovery returned {error:?}; process is {opened:?}" + ); assert!(!fixture.vm_dir().exists()); assert_eq!(fixture.inventory(), before); assert_eq!( From 4076f9ca311d2c3259c919b4b7a2c9766ccf5750 Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:03:40 -0800 Subject: [PATCH 8/9] Pin interrupted atomic-output recovery and refusal regressions Keep the existing interruption barrier and exit assertions. Add deterministic known-temp recovery, near-miss and unjournaled preservation cases, and require the actual process-verification error for indeterminate recovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1dcd7c0-6291-46ec-805e-2b4379ea3e44 --- .../src/host/cli/snapshots/capture/tests.rs | 99 ++++++++++++++++++- 1 file changed, 94 insertions(+), 5 deletions(-) diff --git a/nvx-rs/src/host/cli/snapshots/capture/tests.rs b/nvx-rs/src/host/cli/snapshots/capture/tests.rs index 0abbc14c5..dc9b04c87 100644 --- a/nvx-rs/src/host/cli/snapshots/capture/tests.rs +++ b/nvx-rs/src/host/cli/snapshots/capture/tests.rs @@ -408,7 +408,14 @@ fn a_capture_rejection_stops_its_verified_vm_without_finishing_or_publishing() { #[test] fn unjournaled_user_content_is_preserved_before_any_capture_slot_mutation() { - for entry in ["personal.txt", KEY_FILE, OPENVMM_DIR, ".openvmm-personal"] { + for entry in [ + "personal.txt", + KEY_FILE, + OPENVMM_DIR, + ".openvmm-personal", + ".tmpAb3xY9", + ".outcome.json.7.tmp", + ] { let fixture = Fixture::new(); let store = fixture.store(); StateDir::create(&fixture.vm_dir()).unwrap(); @@ -482,6 +489,82 @@ fn the_next_command_ends_an_interrupted_capture_and_removes_its_directory_withou ); } +#[test] +fn interrupted_atomic_outputs_are_recovered_only_after_their_vm_exits() { + for entry in [ + ".launch.json.0.tmp", + ".control.capability.18446744073709551615.tmp", + ".outcome.json.7.tmp", + ".key.bin.0.tmp", + ".snapshot.json.1.tmp", + ".tmpAb3xY9", + ] { + let fixture = Fixture::new(); + interrupted(&fixture); + fs::write(fixture.vm_dir().join(entry), b"interrupted atomic output").unwrap(); + let missing_artifacts = fixture.root.path().join("missing-artifacts"); + let error = run(missing_artifacts.to_str(), fixture.options("next")).unwrap_err(); + let opened = Process::open(&fixture.references()[0]).unwrap(); + assert!( + matches!(&opened, Opened::Exited(_)), + "{entry}: recovery returned {error:?}; process is {opened:?}" + ); + assert!(!fixture.vm_dir().exists(), "{entry}: {error:?}"); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); + } +} + +#[test] +fn journaled_unknown_and_near_miss_names_are_preserved_without_recovery_or_publication() { + for entry in [ + ".tmp12345", + ".tmp1234567", + ".tmpabcd\u{e9}", + ".outcome.json..tmp", + ".outcome.json.-1.tmp", + ".outcome.json.+1.tmp", + ".outcome.json.01.tmp", + ".outcome.json.18446744073709551616.tmp", + ".config.json.1.tmp", + ".runtime.json.1.tmp", + ".personal.txt.1.tmp", + "config.json", + ] { + let fixture = Fixture::new(); + interrupted(&fixture); + let journal = fs::read(fixture.vm_dir().join("launch.json")).unwrap(); + let capability = fs::read(fixture.vm_dir().join("control.capability")).unwrap(); + let path = fixture.vm_dir().join(entry); + fs::write(&path, b"preserve user content").unwrap(); + let missing_artifacts = fixture.root.path().join("missing-artifacts"); + let error = run(missing_artifacts.to_str(), fixture.options("next")).unwrap_err(); + assert!( + error.message().unwrap().contains("unowned capture content"), + "{entry}: {error:?}" + ); + assert_eq!(fs::read(path).unwrap(), b"preserve user content"); + assert_eq!( + fs::read(fixture.vm_dir().join("launch.json")).unwrap(), + journal + ); + assert_eq!( + fs::read(fixture.vm_dir().join("control.capability")).unwrap(), + capability + ); + assert!(matches!( + Process::open(&fixture.references()[0]).unwrap(), + Opened::Running(_) + )); + assert_eq!( + fs::read_dir(fixture.store().entries.dir()).unwrap().count(), + 0 + ); + } +} + #[test] fn indeterminate_recovery_retains_the_live_vm_its_capability_and_journal() { let fixture = Fixture::new(); @@ -492,12 +575,18 @@ fn indeterminate_recovery_retains_the_live_vm_its_capability_and_journal() { fs::write(&journal_path, serde_json::to_vec(&journal).unwrap()).unwrap(); let journal = fs::read(&journal_path).unwrap(); let capability = fs::read(fixture.vm_dir().join("control.capability")).unwrap(); + fs::write(fixture.vm_dir().join(".tmpAb3xY9"), b"owned report staging").unwrap(); let options = fixture.options("next"); + let error = capture(&options, &runtime(fixture.root.path()), |_| { + panic!("recovery must refuse") + }) + .unwrap_err(); assert!( - capture(&options, &runtime(fixture.root.path()), |_| panic!( - "recovery must refuse" - )) - .is_err() + error + .message() + .unwrap() + .contains("cannot verify the recorded VM process"), + "{error:?}" ); assert_eq!(fs::read(journal_path).unwrap(), journal); assert_eq!( From 9761cdf4fcc24386d361d0c2f7966ca424a2104e Mon Sep 17 00:00:00 2001 From: Enrique Saurez Date: Tue, 10 Nov 2026 15:04:43 -0800 Subject: [PATCH 9/9] Recover exact atomic staging names in owned capture slots Recognize only the existing host writer's canonical numeric names and the pinned native report's six-character staging names when a journal is present. Ordinary recovery must still validate ownership and prove exit before deletion. Preserve no-journal, unknown, config, near-miss and indeterminate refusal. The user-approved bounded exception is 470 production lines in the same five files; actual production is 463, with 27 gross new lines in this fix and no deletion offsets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e1dcd7c0-6291-46ec-805e-2b4379ea3e44 --- doc/usage.md | 5 ++-- nvx-rs/src/host/cli/snapshots/capture.rs | 30 +++++++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/doc/usage.md b/doc/usage.md index 15d10e0ef..c0a78ea5c 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -372,8 +372,9 @@ this store ends and removes an interrupted attempt through ordinary launch recovery, without resuming or publishing it. Indeterminate recovery retains its evidence and reports an error. Unknown files or nonempty unjournaled capture-slot contents are kept for inspection, not deleted; an empty launch lock alone is -harmless. Captures in one store serialize; snapshot locks remain separate from -their immutable directories. Restore commands are not added here. +harmless. Known host and OpenVMM atomic staging files are recovered only with +a valid launch journal. Captures in one store serialize; snapshot locks remain +separate from their immutable directories. Restore commands are not added here. ### `download` diff --git a/nvx-rs/src/host/cli/snapshots/capture.rs b/nvx-rs/src/host/cli/snapshots/capture.rs index b6a231871..eb2235443 100644 --- a/nvx-rs/src/host/cli/snapshots/capture.rs +++ b/nvx-rs/src/host/cli/snapshots/capture.rs @@ -384,9 +384,9 @@ fn remove_capture(store: &Store, deadline: Deadline) -> Result { ] .iter() .any(|file| name == *file) - || name - .to_str() - .is_some_and(|name| name.starts_with(".openvmm")); + || name.to_str().is_some_and(|name| { + name.starts_with(".openvmm") || (journaled && capture_temporary(name)) + }); let empty_lock = name == StateFile::Lock.name() && entry.metadata().map_err(Exit::from_os_error)?.len() == 0; if !known || name == StateFile::Config.name() || (!journaled && !empty_lock) { @@ -410,6 +410,30 @@ fn remove_capture(store: &Store, deadline: Deadline) -> Result { fs::remove_dir_all(&path).map_err(|error| io_error(ErrorCode::Io, &error, &path)) } +fn capture_temporary(name: &str) -> bool { + if let Some(random) = name.strip_prefix(".tmp") { + return random.len() == 6 && random.bytes().all(|byte| byte.is_ascii_alphanumeric()); + } + let Some((file, suffix)) = name + .strip_prefix('.') + .and_then(|name| name.strip_suffix(".tmp")) + .and_then(|name| name.rsplit_once('.')) + else { + return false; + }; + [ + StateFile::Journal.name(), + StateFile::Capability.name(), + StateFile::Outcome.name(), + KEY_FILE, + DESCRIPTION_FILE, + ] + .contains(&file) + && suffix + .parse::() + .is_ok_and(|value| value.to_string() == suffix) +} + /// Uses authentication and the actual held process, never the capture acknowledgement as exit. fn request_and_wait( process: &Process,