From a6ef2893913d50913d7f2eaa1badf12985b46d11 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 12 Nov 2026 00:09:29 -0800 Subject: [PATCH 1/2] tests: read the one-shot fake's start time as ProcessRef documents it Four tests of nvx-rs run hold the fake OpenVMM that the CLI launched through nvx::host::process, to see it exit and to end it if it does not: forwarding_failure_stops_the_verified_vm_during_connect_and_ handshake, terminal_ctrl_c_cancels_boot_and_handshake_and_restores_ the_terminal on Linux and on Windows, and terminal_quit_and_suspend_ keys_remain_guest_console_input. Their helper, gated_native, named the fake's process with nvx::host::platform::process_start_time, which SL6.14 (#1127) makes private to the crate. The tests drive the nvx-rs binary, so they stay integration tests, and the helper reads the start time itself, as ProcessRef's documentation defines it: on Linux, the starttime field of /proc//stat, and on Windows, the process's creation time as a 64-bit FILETIME, which GetProcessTimes reports. It opens the same reference with Process::open as before, so the four tests assert what they asserted. Evidence: on Windows with Rust 1.95.0, cargo test --all-features --test cli -- one_shot passes (17 passed, 2 ignored), with the two tests that use the helper there; on nvx-kvm-test (Linux 6.6) and nvx-mshv-test (Linux 6.18), tests/cli.rs passes whole (120 passed), with the three tests that use it on Linux. Part of #1127 (SL6.14). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- nvx-rs/tests/cli/one_shot.rs | 63 ++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/nvx-rs/tests/cli/one_shot.rs b/nvx-rs/tests/cli/one_shot.rs index fca1c2af5..4bf81a64f 100644 --- a/nvx-rs/tests/cli/one_shot.rs +++ b/nvx-rs/tests/cli/one_shot.rs @@ -574,9 +574,7 @@ fn gated_native( let pid = record["pid"].as_u64().unwrap().try_into().unwrap(); let reference = ProcessRef { pid, - start_time: nvx::host::platform::process_start_time(pid) - .unwrap() - .expect("the gated fake must still be running"), + start_time: start_time(pid), boot: BootId::current().unwrap(), }; match Process::open(&reference).unwrap() { @@ -585,6 +583,65 @@ fn gated_native( } } +/// Returns the start time of the gated fake, whose ID is `pid`, as a `ProcessRef` holds it: the +/// `starttime` field of `/proc//stat`. +#[cfg(target_os = "linux")] +fn start_time(pid: u32) -> u64 { + let stat = + std::fs::read(format!("/proc/{pid}/stat")).expect("the gated fake must still be running"); + // The command name may hold spaces and parentheses, so the fields after it, from field 3, + // start after the last closing parenthesis. + let name_end = stat.iter().rposition(|&byte| byte == b')').unwrap(); + std::str::from_utf8(&stat[name_end + 1..]) + .unwrap() + .split_whitespace() + .nth(22 - 3) + .unwrap() + .parse() + .unwrap() +} + +/// Returns the start time of the gated fake, whose ID is `pid`, as a `ProcessRef` holds it: the +/// creation time of the process as a 64-bit `FILETIME`. +#[cfg(windows)] +fn start_time(pid: u32) -> u64 { + use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}; + + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::System::Threading::{ + GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + + // SAFETY: OpenProcess has no memory-safety preconditions. + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + assert!( + !handle.is_null(), + "the gated fake must still be running: {}", + std::io::Error::last_os_error() + ); + // SAFETY: the handle is open, and nothing else owns it. + let process = unsafe { OwnedHandle::from_raw_handle(handle) }; + let mut created = FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }; + let mut exited = created; + let mut kernel = created; + let mut user = created; + // SAFETY: the handle has query access, and every output pointer is valid. + let queried = unsafe { + GetProcessTimes( + process.as_raw_handle(), + &mut created, + &mut exited, + &mut kernel, + &mut user, + ) + }; + assert_ne!(queried, 0, "{}", std::io::Error::last_os_error()); + (u64::from(created.dwHighDateTime) << 32) | u64::from(created.dwLowDateTime) +} + fn foreground_exits_promptly( process: &mut std::process::Child, native: &nvx::host::process::Process, From 659b005240848b85e39a6c05b1774341b3b19e24 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 12 Nov 2026 00:10:08 -0800 Subject: [PATCH 2/2] host: make the platform layer private to the crate nvx::host::platform, the host's operating-system layer, was a public module of nvx, with SUPPORTED, create_private_dir, file_identity, process_start_time, and the Transport trait and TransportHandle. Its public parts moved to host::vm, host::transport and host::runtime with the new interface, and its last outside users are gone, so SL6.14 (#1127) makes it crate-private where it is. The module becomes pub(crate), and its items pub(crate), as the unreachable_pub lint asks. Transport and TransportHandle stay pub where they are defined, since host::transport re-exports them as their public path. host::vm's create_private_dir, host::transport and host::runtime keep their public forms. The compiler then finds what only the public paths kept alive: - The platform's own create_private_dir, which nothing in the crate calls, goes on every host, with Linux's PRIVATE_DIR_MODE, which only it read; host::vm::create_private_dir is the VM directory's own function, and stays. SUPPORTED goes too: its last reader, the artifact discovery that SL6.6b deleted, is gone. - process_start_time serves only the CLI, which checks the processes that its records name, and the unit tests, which name the processes that they start. Its re-export on Linux and Windows, and on Windows the function and the open_existing_process that only it calls, are gated by cfg(any(test, feature = "cli")): four hooks, which the test-layout allowlist records as seams of linux/mod.rs, windows/mod.rs and windows/process.rs. On other hosts, where no test reads it, the cli feature alone gates it, and the import of std::fs, which only the CLI's code uses there now. Nothing that stays changes behavior. The integration tests that reached the platform layer become unit tests of their owners: - tests/security_checklist.rs's a_saved_vm_runs_until_its_process_exits_and_another_runtime_is_ refused_without_a_signal and reattaching_refuses_an_endpoint_that_ another_process_serves_and_keeps_the_vm_and_its_values, with the idle child and its ignored idle_child, move to host::vm's reattach tests, whose vm_dir, cold_runtime, saved and bind helpers do what the checklist's private_directory, runtime, saved and endpoint_server did. The child starts under the spawn lock, as the other children of the unit tests do. - tests/process.rs, whose tests of nvx::host::process name the children that they start by process_start_time, moves with git mv to host::process's tests as host::process::tests::orchestrator, with its seven tests unchanged. Its imports take crate paths, its module declaration holds its cfg, it names its fixtures by their unit-test paths, it starts its children under the spawn lock, as the other children of the unit tests start, and its Windows processes, whose security descriptors it restricts, allow the unsafe code that host's deny(unsafe_code) refuses. The case matrix names the moved checklist test and the moved process tests where they now live, and the checklist and the README say which unit tests check them. ProcessRef's documentation, which linked process_start_time, defines the start time itself, and host's module docs and the README name the platform layer as private: the README's module table and section cover report alone, and the Transport paragraphs move to host::transport's section, the trait's public path. The public-API snapshots drop host::platform's 11 items on each platform, and only those. Evidence, with Rust 1.95.0, on Windows: cargo clippy -D warnings passes for --all-targets --all-features, --all-targets, --lib, --lib --no-default-features, serde alone, --lib --bins with cli, and with testing alone, for x86_64-unknown-linux-gnu with those sets, and for x86_64-unknown-linux-musl with guest alone, --lib --bins and --all-targets, which also compile the external-launch example; cargo +1.89 check --all-targets --all-features and cargo check --all-targets --all-features for aarch64-apple-darwin pass, the latter with the one warning, in host::snapshot's tests, that the base has too; cargo doc -D warnings, for Windows and Linux, public_api.py, check_test_layout.py, check_markdown_links.py and the case matrix check pass; and cargo test --lib passes for host::process, with the seven moved tests, host::vm::tests::reattach, host::platform, host::transport and host::vm_directory, with the default features and with all of them, as do the security_checklist and host_modules tests and the CLI's one-shot tests. Part of #1127 (SL6.14). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/acceptance-case-matrix.md | 6 +- nvx-rs/README.md | 84 +++---- nvx-rs/public-api/linux.txt | 11 - nvx-rs/public-api/windows.txt | 11 - nvx-rs/src/host/mod.rs | 8 +- nvx-rs/src/host/platform/linux/identity.rs | 2 +- nvx-rs/src/host/platform/linux/mod.rs | 15 +- nvx-rs/src/host/platform/linux/private.rs | 18 +- nvx-rs/src/host/platform/linux/process.rs | 2 +- nvx-rs/src/host/platform/mod.rs | 1 - nvx-rs/src/host/platform/other.rs | 20 +- nvx-rs/src/host/platform/windows/identity.rs | 2 +- nvx-rs/src/host/platform/windows/mod.rs | 11 +- nvx-rs/src/host/platform/windows/private.rs | 18 -- nvx-rs/src/host/platform/windows/process.rs | 4 +- nvx-rs/src/host/process.rs | 11 +- nvx-rs/src/host/process/tests.rs | 2 + .../host/process/tests/orchestrator.rs} | 48 ++-- nvx-rs/src/host/vm/tests/reattach.rs | 132 +++++++++++ nvx-rs/tests/security_checklist.rs | 218 +----------------- scripts/test-layout-allowlist.json | 3 + 21 files changed, 254 insertions(+), 373 deletions(-) rename nvx-rs/{tests/process.rs => src/host/process/tests/orchestrator.rs} (94%) diff --git a/doc/acceptance-case-matrix.md b/doc/acceptance-case-matrix.md index c09b22c79..860e43fd6 100644 --- a/doc/acceptance-case-matrix.md +++ b/doc/acceptance-case-matrix.md @@ -143,7 +143,7 @@ The adapter's README said what each proved. | Scenario | Fate | Coverage, or why it retires | | --- | --- | --- | -| `a_host_program_of_another_runtime_finds_the_vm_mismatched_and_signals_nothing` | rewritten | `nvx-rs/tests/security_checklist.rs::a_saved_vm_runs_until_its_process_exits_and_another_runtime_is_refused_without_a_signal`; `nvx-rs/src/host/vm/tests/reattach.rs::verifying_saved_values_checks_runtime_and_process_without_opening_an_endpoint` | +| `a_host_program_of_another_runtime_finds_the_vm_mismatched_and_signals_nothing` | rewritten | `nvx-rs/src/host/vm/tests/reattach.rs::a_saved_vm_runs_until_its_process_exits_and_another_runtime_is_refused_without_a_signal`; `nvx-rs/src/host/vm/tests/reattach.rs::verifying_saved_values_checks_runtime_and_process_without_opening_an_endpoint` | | `a_launch_tears_down_a_vm_whose_guest_agent_is_of_another_build` | retired | The adapter's guest agent and the build that its Ready reports | | `a_restarted_host_program_reattaches_to_the_vm_and_stops_it` | rewritten | `nvx-rs/tests/vm_lifecycle.rs::a_cold_vm_is_reattached_from_saved_values_and_stopped`, which `test-microvm --scenario vm-lifecycle` runs; `nvx-rs/src/host/vm/tests/reattach.rs::reattaching_returns_the_held_owned_child_and_authenticated_connection`; `nvx-rs/src/host/vm/tests.rs::stop_forces_only_its_verified_process_after_grace` | @@ -235,7 +235,7 @@ The adapter's README said what each proved. | `a_forward_whose_port_another_socket_holds_fails_the_clone_as_the_host_s` | rewritten | `test-microvm --scenario platform-network-policy`; `nvx-rs/src/host/restore/tests.rs::restore_failures_attribute_the_existing_policy_free_evidence_classes` | | `a_generation_is_eligible_by_the_clock_of_its_capture` | rewritten | `nvx-rs/src/host/snapshot/eligibility/tests.rs::within_one_boot_the_downtime_is_the_host_monotonic_clocks`; `nvx-rs/src/host/snapshot/tests.rs::snapshot_accepts_reports_early_and_expired_time_as_unavailable` | | `a_generation_refuses_clones_of_fewer_processors_than_its_template_booted_with` | rewritten | `nvx-rs/src/host/snapshot/tests.rs::snapshot_accepts_refuses_a_blockless_root_and_each_processor_mismatch`; `test-microvm --scenario restore-processors` | -| `a_host_program_of_another_runtime_ends_the_clone_through_its_verified_identity` | rewritten | `nvx-rs/tests/security_checklist.rs::a_saved_vm_runs_until_its_process_exits_and_another_runtime_is_refused_without_a_signal`; `nvx-rs/src/host/vm/tests.rs::stop_forces_only_its_verified_process_after_grace`. Leases retire (#1121) | +| `a_host_program_of_another_runtime_ends_the_clone_through_its_verified_identity` | rewritten | `nvx-rs/src/host/vm/tests/reattach.rs::a_saved_vm_runs_until_its_process_exits_and_another_runtime_is_refused_without_a_signal`; `nvx-rs/src/host/vm/tests.rs::stop_forces_only_its_verified_process_after_grace`. Leases retire (#1121) | | `a_run_in_a_template_of_a_network_finds_its_policy_s_proxy` | rewritten | `test-microvm --scenario platform-network-policy`; `test-microvm --scenario untiered-network-policy`, whose runs restore a named snapshot with a NIC under a network policy of their own, though with no proxy. The template's `run` retires (#1121) | | `a_run_runs_one_execution_in_its_clone_then_tears_the_clone_down` | rewritten | `test-microvm --scenario snapshot-core`, whose `run --restore-snapshot` runs one workload in its restored VM and removes its sandbox. The template's `run` and leases retire (#1121) | | `a_run_s_execution_finds_its_shares_beneath_the_root` | rewritten | `test-microvm --scenario platform-aggregate-export` | @@ -500,7 +500,7 @@ this matrix. The README's own code is no doctest. | `nvx-rs/src/guest/time/mod.rs - guest::time (line 9)` | kept | SL6.11c removed its capture and restore finish, which became crate-private: `guest::snapshot::capture` runs them | | `nvx-rs/src/host/sandbox/prepared.rs - host::sandbox::prepared::PreparedLaunch (line 92)` | retired | `PreparedLaunch` leaves with the checked-file handoff (SL6.3, #1122) | | `nvx-rs/src/host/store/mod.rs - host::store (line 72)` | retired | The sandbox store leaves (SL6.1, #1121) | -| `nvx-rs/src/host/supervisor/mod.rs - host::supervisor (line 32)` | retired | The store-coupled supervisor leaves with the sandbox store (SL6.1, #1121), and `host::supervisor`, the transitional public path of the low-level spawn, with SL6.3 (#1122): `host::vm` spawns VMMs, and the tests of the spawn moved to `nvx-rs/src/host/spawn/tests.rs` and `nvx-rs/tests/process.rs` | +| `nvx-rs/src/host/supervisor/mod.rs - host::supervisor (line 32)` | retired | The store-coupled supervisor leaves with the sandbox store (SL6.1, #1121), and `host::supervisor`, the transitional public path of the low-level spawn, with SL6.3 (#1122): `host::vm` spawns VMMs, and the tests of the spawn moved to `nvx-rs/src/host/spawn/tests.rs` and `nvx-rs/src/host/process/tests/orchestrator.rs` | | `nvx-rs/src/host/template/clone.rs - host::template::clone::ClonePlan (line 247)` | retired | `ClonePlan` and `CloneRequest` leave with the old clone path (SL6.2b, #1121) | | `nvx-rs/src/host/template/plan.rs - host::template::plan::TemplatePlan (line 137)` | retired | `TemplatePlan` leaves with the template store (SL6.2a, #1121) | | `nvx-rs/src/host/template/store.rs - host::template::store::Store (line 473)` | retired | The template store leaves (SL6.2a, #1121) | diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 5cb11ebd5..d9695d4ae 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -122,7 +122,7 @@ changes only when the module's purpose does. | [`nvx::host::nvxs`](#nvxhostnvxs) | Compatibility paths for the host NVXS connection and its retained cause details | | [`nvx::host::transport`](#nvxhosttransport) | A verified VM endpoint connection and the blocking host NVXS driver, with the existing timed transport | | [`nvx::host::process`](#nvxhostprocess) | Verified references to OpenVMM processes, through which callers wait for them and end them | -| [`nvx::host::platform`, `report`](#nvxhostplatform-report) | Linux and Windows primitives, and OpenVMM's outcome report | +| [`nvx::host::report`](#nvxhostreport) | OpenVMM's outcome report | | [`nvx::host::sandbox`](#nvxhostsandbox) | The launch profile of `nvx-rs sandbox`: its live shares and option checks; its layer and bindings are private to the CLI | | [`nvx::guest`](#nvxguest) | The programs that run in Linux guests; see [Guest binaries](#guest-binaries) | | [`nvx::guest::mappings`](#nvxguestmappings) | `Mapping` and `MappingTable`: host mappings, and the account of a host's mapping table, for any guest init or agent | @@ -985,11 +985,29 @@ and another transport failure `Io`. Original I/O and protocol causes are retained directly; attachment refusal, reset and ended-connection details remain available as sources. -`Transport` and `TransportHandle` re-export the existing platform types, with -one definition beneath the OS adapters. Timed reads and writes, native-handle -duplication and `unread` are unchanged: Linux reports Unix `SIOCOUTQ` charges, -and Windows reports `None`. The old `session::connect`, private to the crate -like the session, remains a compatibility wrapper for the CLI. +`Transport` and `TransportHandle` re-export the types of the crate's private +platform layer, with one definition beneath the OS adapters. Timed reads and +writes, native-handle duplication and `unread` are unchanged: Linux reports +Unix `SIOCOUTQ` charges, and Windows reports `None`. The old +`session::connect`, private to the crate like the session, remains a +compatibility wrapper for the CLI. + +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 +its Unix socket, the kernel's charge for the buffers of each write until +OpenVMM read each whole, which a read of part of a buffer leaves as it was, and +which is zero once OpenVMM read everything. A named pipe tells its writer +nothing of the kind, so on Windows, and for a transport that does not tell, it +is `None`. + +On Linux and Windows, `Transport::try_clone_handle` optionally returns an +owned native handle to the same peer-verified connection, not a reconnect: +an `OwnedFd` for the Unix socket or an `OwnedHandle` for the overlapped pipe. +An adapter must drop the blocking transport before using or registering the +duplicate with an I/O runtime, or changing the shared blocking mode. The +default is `Ok(None)`, so transports without native handles need not implement +it, and NVX does not depend on an asynchronous I/O runtime. ### `nvx::host::process` @@ -1031,35 +1049,19 @@ holds what the process's children read once it reaped them, as the kernel accounts them to their parent, while Windows counts the process's own reads alone. -### `nvx::host::platform`, `report` +### `nvx::host::report` -Linux and Windows primitives, and OpenVMM's outcome report. The authenticated -control session, which carries NVX's own control protocol, `NVXC`, one frame -per DATA record of a `HostConnection`, is private to the crate: the CLI and its -restore driver use it, with the `cli` feature. +OpenVMM's outcome report. The Linux and Windows primitives are private to the +crate, in `host::platform`, and so is the authenticated control session, which +carries NVX's own control protocol, `NVXC`, one frame per DATA record of a +`HostConnection`: the CLI and its restore driver use it, with the `cli` +feature. `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 -its Unix socket, the kernel's charge for the buffers of each write until -OpenVMM read each whole, which a read of part of a buffer leaves as it was, and -which is zero once OpenVMM read everything. A named pipe tells its writer -nothing of the kind, so on Windows, and for a transport that does not tell, it -is `None`. - -On Linux and Windows, `Transport::try_clone_handle` optionally returns an -owned native handle to the same peer-verified connection, not a reconnect: -an `OwnedFd` for the Unix socket or an `OwnedHandle` for the overlapped pipe. -An adapter must drop the blocking transport before using or registering the -duplicate with an I/O runtime, or changing the shared blocking mode. The -default is `Ok(None)`, so transports without native handles need not implement -it, and NVX does not depend on an asynchronous I/O runtime. - ### `nvx::host::sandbox` The launch profile of `nvx-rs sandbox`, with native input validation: the @@ -1808,8 +1810,8 @@ components alone, as `Path::components` reads them, with backslashes on Windows, and resolved by `std::fs::canonicalize`, with the components that do not exist yet appended. Paths that are not valid Unicode are refused. The filesystem queries are those of `std::fs`, and the identities of files those -of `nvx::host::platform`: the device and inode on Unix, and the volume serial -number and the 64-bit file index on Windows. +of the crate's private platform layer: the device and inode on Unix, and the +volume serial number and the 64-bit file index on Windows. Every host path becomes absolute through one function, which joins a relative path to the current directory without consulting the filesystem, so it @@ -2781,11 +2783,13 @@ dependencies: break a cycle by moving the item that a lower child takes from a higher one down, and re-exporting it at its old path. So the children stack in layers, each above those that it depends on, which the test prints with `--nocapture`. -The process and spawn test spawns VMM stand-ins through the low-level spawn, -waits for them through their verified references, finds one again after the -process that spawned it crashed, and checks that a VMM inherits only its -standard streams and the descriptors or handles that it is allowed while -another thread spawns processes. +The process tests, unit tests of `nvx::host::process`, start processes of +their own as an orchestrator does, name them by the start time that the +crate's private platform layer reads, wait for them through their verified +references, and find one again after the process that started it exited; the +spawn's unit tests check that a VMM inherits only its standard streams and the +descriptors or handles that it is allowed while another thread spawns +processes. On Linux, with the `cli` feature, a loopback test drives the host's private `ControlSession` through a broker shim into the real managed agent's session, dispatch, and execution engine, and `nvx.py` also lints and @@ -2802,10 +2806,12 @@ The [security checklist](tests/security_checklist.rs) checks, through the public API alone and without a VM, each security requirement that the host's documentation states: the guest targets of an export and a read-only path inside a writable one, the exported objects that each start checks again, a -VM's private directory and its new private log, the exit proof of a saved VM, -the owner of its endpoint, and its recovery from the saved values, and a -snapshot's strict directory. An idle copy of the test binary stands in for a -VM's process. The checks that NVX makes of a restore need a captured snapshot, +VM's private directory and its new private log, and a snapshot's strict +directory. The exit proof of a saved VM, the owner of its endpoint, and its +recovery from the saved values need a process that the test names by its start +time, which only the crate's private platform layer reads, so the unit tests of +`nvx::host::vm` check them, with an idle copy of their test binary as the VM's +process. The checks that NVX makes of a restore need a captured snapshot, so the unit tests of `nvx::host::vm`, `nvx::host::snapshot`, and `nvx::host::restore` check them. The checks of a staged image and the claim of a scratch image, which the CLI keeps in directories that only its own account diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 9a2af9165..3b57f757f 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -1839,17 +1839,6 @@ field nvx::host::nvxs::HostConnectionError::Rejected::0 variant nvx::host::nvxs::HostConnectionError::Reset field nvx::host::nvxs::HostConnectionError::Reset::0 variant nvx::host::nvxs::HostConnectionError::TimedOut -mod nvx::host::platform -const nvx::host::platform::SUPPORTED -trait nvx::host::platform::Transport -method nvx::host::platform::Transport::read -method nvx::host::platform::Transport::try_clone_handle -method nvx::host::platform::Transport::unread -method nvx::host::platform::Transport::write_all -type nvx::host::platform::TransportHandle -fn nvx::host::platform::create_private_dir -fn nvx::host::platform::file_identity -fn nvx::host::platform::process_start_time mod nvx::host::process struct nvx::host::process::BootId method nvx::host::process::BootId::as_bytes diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 1a4029d05..25ae22fa6 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -567,17 +567,6 @@ field nvx::host::nvxs::HostConnectionError::Rejected::0 variant nvx::host::nvxs::HostConnectionError::Reset field nvx::host::nvxs::HostConnectionError::Reset::0 variant nvx::host::nvxs::HostConnectionError::TimedOut -mod nvx::host::platform -const nvx::host::platform::SUPPORTED -trait nvx::host::platform::Transport -method nvx::host::platform::Transport::read -method nvx::host::platform::Transport::try_clone_handle -method nvx::host::platform::Transport::unread -method nvx::host::platform::Transport::write_all -type nvx::host::platform::TransportHandle -fn nvx::host::platform::create_private_dir -fn nvx::host::platform::file_identity -fn nvx::host::platform::process_start_time mod nvx::host::process struct nvx::host::process::BootId method nvx::host::process::BootId::as_bytes diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 7c41e0d81..95d81b7ed 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -22,8 +22,8 @@ //! - [`transport`] verifies a VM's endpoint and runs the host's role of the NVXS session for it, //! for any application protocol, over a platform transport. The private `session` (feature //! `cli`) speaks NVX's own control protocol, `NVXC`, over it for NVX's programs. [`nvxs`] -//! retains compatibility paths. [`platform`] holds the primitives of Linux and Windows hosts. -//! Other hosts compile but report NVX as unavailable. +//! retains compatibility paths. The private `platform` module holds the primitives of Linux and +//! Windows hosts. Other hosts compile but report NVX as unavailable. //! - [`process`] names processes by verified references: their ID, start time, and host boot, //! through which a pidfd or a process handle holds them, so that waits sleep until a process //! exits and termination reaches only it, whether or not the caller started it. @@ -50,7 +50,7 @@ //! caller allows, and no other descriptor or handle of the caller's. //! //! The children of `host` depend on one another without cycles: each builds only on children -//! beneath it, as [`vm`] does on `spawn`, and `spawn` on [`platform`]. An item that +//! beneath it, as [`vm`] does on `spawn`, and `spawn` on `platform`. An item that //! a lower child needs from a higher one moves down, and the higher child re-exports it where it //! was. `tests/host_modules.rs` reads the crate's sources and fails on a cycle, which it lists //! with a reference for each of its dependencies. @@ -71,7 +71,7 @@ pub mod filesystem; pub mod network; pub mod nvxs; pub(crate) mod paths; -pub mod platform; +pub(crate) mod platform; pub mod process; pub mod report; pub mod restore; diff --git a/nvx-rs/src/host/platform/linux/identity.rs b/nvx-rs/src/host/platform/linux/identity.rs index 18cd920f4..2612d8d65 100644 --- a/nvx-rs/src/host/platform/linux/identity.rs +++ b/nvx-rs/src/host/platform/linux/identity.rs @@ -14,7 +14,7 @@ use std::path::Path; use crate::host::sys::Errno; /// Returns the device and inode of a file or directory. -pub fn file_identity(path: &Path) -> io::Result<(u64, u64)> { +pub(crate) fn file_identity(path: &Path) -> io::Result<(u64, u64)> { let metadata = fs::metadata(path)?; Ok((metadata.dev(), metadata.ino())) } diff --git a/nvx-rs/src/host/platform/linux/mod.rs b/nvx-rs/src/host/platform/linux/mod.rs index 63d87da00..db346b855 100644 --- a/nvx-rs/src/host/platform/linux/mod.rs +++ b/nvx-rs/src/host/platform/linux/mod.rs @@ -20,20 +20,22 @@ pub(crate) use self::endpoint::{ReadyListener, connect_endpoint, listen_ready}; pub(crate) use self::endpoint::{ connect_child_endpoint, endpoint_server_pid_until, interrupt_transfers, transport_from_handle, }; -pub use self::identity::file_identity; +pub(crate) use self::identity::file_identity; #[cfg(feature = "cli")] pub(crate) use self::identity::{ Directory, file_id, is_deleted, opened_file_id, opened_file_identity, }; #[cfg(feature = "cli")] pub(crate) use self::log_claim::{claim_log, log_claim_held, serves_launch}; -pub use self::private::create_private_dir; pub(crate) use self::private::{ check_snapshot_dir, check_store_dir, create_store_dir, make_sparse, private_file_problem, }; #[cfg(feature = "cli")] pub(crate) use self::private::{rename_no_replace, restore_private_file_mode}; -pub use self::process::process_start_time; +// Outside this module, only the CLI, which checks the processes that its records name, and the +// tests, which name the processes that they start, read a process's start time. +#[cfg(any(test, feature = "cli"))] +pub(crate) use self::process::process_start_time; pub(crate) use self::process::{ ProcessHandle, boot_id, host_id, monotonic_ns, open_process, own_bytes_read, pin_child, }; @@ -41,16 +43,9 @@ pub(crate) use self::spawn::detach; #[cfg(feature = "cli")] pub(crate) use self::spawn::inherit_only; -/// Whether this host can run NVX microVMs. -pub const SUPPORTED: bool = true; - /// File that holds the identity of the current boot, a UUID that the kernel chooses at random. const BOOT_ID_PATH: &str = "/proc/sys/kernel/random/boot_id"; -/// Mode of a directory that only the current user may access: read, write, and search for its -/// owner, and nothing for anyone else. -const PRIVATE_DIR_MODE: Mode = Mode::S_IRWXU; - /// Mode of the directory of a sandbox store, the exact mode that OpenVMM requires of the /// directory of its control socket. const STORE_DIR_MODE: Mode = Mode::S_IRWXU; diff --git a/nvx-rs/src/host/platform/linux/private.rs b/nvx-rs/src/host/platform/linux/private.rs index 2ff82605b..1c7cac3e9 100644 --- a/nvx-rs/src/host/platform/linux/private.rs +++ b/nvx-rs/src/host/platform/linux/private.rs @@ -11,27 +11,11 @@ use std::path::Path; use nix::sys::stat::Mode; use nix::unistd::Uid; -use super::{GROUP_AND_OTHER_BITS, PRIVATE_DIR_MODE, STORE_DIR_MODE}; +use super::{GROUP_AND_OTHER_BITS, STORE_DIR_MODE}; #[cfg(feature = "cli")] use crate::host::sys; use crate::host::sys::Errno; -/// Creates `path` if needed and restricts it to the current user. -pub fn create_private_dir(path: &Path) -> io::Result<()> { - match DirBuilder::new().mode(PRIVATE_DIR_MODE.bits()).create(path) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => return Err(error), - } - if !fs::symlink_metadata(path)?.is_dir() { - return Err(io::Error::other(format!( - "{} is not a plain directory", - path.display() - ))); - } - fs::set_permissions(path, Permissions::from_mode(PRIVATE_DIR_MODE.bits())) -} - /// Creates the directory `path` of a sandbox store with mode 0700, or checks that an existing one /// is private to the current user, as [`check_store_dir`] does, and returns why it is not, if it /// is not. diff --git a/nvx-rs/src/host/platform/linux/process.rs b/nvx-rs/src/host/platform/linux/process.rs index f5c344e21..2288d33bf 100644 --- a/nvx-rs/src/host/platform/linux/process.rs +++ b/nvx-rs/src/host/platform/linux/process.rs @@ -18,7 +18,7 @@ use crate::host::sys::{self, Errno, PidFd}; /// exits, as on Windows: a zombie whose main thread exited first still runs while its other threads /// exit, and they keep the process's descriptors, such as an inherited log and its lock, open. /// Zombies without other threads count as exited. -pub fn process_start_time(pid: u32) -> io::Result> { +pub(crate) fn process_start_time(pid: u32) -> io::Result> { let Some(stat) = read_stat(pid)? else { return Ok(None); }; diff --git a/nvx-rs/src/host/platform/mod.rs b/nvx-rs/src/host/platform/mod.rs index cbf9a585b..0901519af 100644 --- a/nvx-rs/src/host/platform/mod.rs +++ b/nvx-rs/src/host/platform/mod.rs @@ -42,7 +42,6 @@ use other as imp; use windows as imp; pub(crate) use imp::*; -pub use imp::{SUPPORTED, create_private_dir, file_identity, process_start_time}; /// Why this module refused a file for what the file is. #[derive(Debug)] diff --git a/nvx-rs/src/host/platform/other.rs b/nvx-rs/src/host/platform/other.rs index 62a2d310f..6cf304c7e 100644 --- a/nvx-rs/src/host/platform/other.rs +++ b/nvx-rs/src/host/platform/other.rs @@ -1,4 +1,6 @@ -use std::fs::{self, File}; +#[cfg(feature = "cli")] +use std::fs; +use std::fs::File; use std::io; use std::path::Path; use std::process::Command; @@ -11,9 +13,6 @@ use crate::hypervisor::Hypervisor; #[cfg(all(feature = "cli", unix))] pub(crate) use crate::host::sys::{interrupted, watch_interrupts, write_interruptibly}; -/// Whether this host can run NVX microVMs. -pub const SUPPORTED: bool = false; - /// Does nothing: this host has no console interrupts to watch. #[cfg(all(feature = "cli", not(unix)))] pub(crate) fn watch_interrupts() -> io::Result<()> { @@ -34,7 +33,8 @@ fn unsupported() -> io::Error { } /// Reports every process as gone: this host runs no OpenVMM processes. -pub fn process_start_time(_pid: u32) -> io::Result> { +#[cfg(feature = "cli")] +pub(crate) fn process_start_time(_pid: u32) -> io::Result> { Ok(None) } @@ -94,7 +94,7 @@ pub(crate) fn monotonic_ns() -> io::Result { } /// Fails: this host cannot export host paths. -pub fn file_identity(_path: &Path) -> io::Result<(u64, u64)> { +pub(crate) fn file_identity(_path: &Path) -> io::Result<(u64, u64)> { Err(unsupported()) } @@ -218,14 +218,6 @@ pub(crate) fn probe_hypervisor(_hypervisor: Hypervisor) -> Result<(), String> { Err(unsupported().to_string()) } -/// Creates `path` if needed. -pub fn create_private_dir(path: &Path) -> io::Result<()> { - match fs::create_dir(path) { - Err(error) if error.kind() != io::ErrorKind::AlreadyExists => Err(error), - _ => Ok(()), - } -} - /// The listener of the restore-ready endpoint of a restore, which this host never makes. #[derive(Debug)] pub(crate) struct ReadyListener; diff --git a/nvx-rs/src/host/platform/windows/identity.rs b/nvx-rs/src/host/platform/windows/identity.rs index 1ea897a3c..c629e7b6a 100644 --- a/nvx-rs/src/host/platform/windows/identity.rs +++ b/nvx-rs/src/host/platform/windows/identity.rs @@ -30,7 +30,7 @@ use super::{owned, raw}; /// system such as ReFS does not keep unique. /// /// The object is opened without access rights, which succeeds even while OpenVMM holds it open. -pub fn file_identity(path: &Path) -> io::Result<(u64, u64)> { +pub(crate) fn file_identity(path: &Path) -> io::Result<(u64, u64)> { let file = OpenOptions::new() .access_mode(0) .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) diff --git a/nvx-rs/src/host/platform/windows/mod.rs b/nvx-rs/src/host/platform/windows/mod.rs index a6d996558..6dff5228f 100644 --- a/nvx-rs/src/host/platform/windows/mod.rs +++ b/nvx-rs/src/host/platform/windows/mod.rs @@ -27,7 +27,7 @@ pub(crate) use self::endpoint::{ReadyListener, connect_endpoint}; pub(crate) use self::endpoint::{ endpoint_server_pid_until, interrupt_transfers, transport_from_handle, }; -pub use self::identity::file_identity; +pub(crate) use self::identity::file_identity; #[cfg(feature = "cli")] pub(crate) use self::identity::{ Directory, file_id, is_deleted, opened_file_id, opened_file_identity, @@ -37,21 +37,20 @@ pub(crate) use self::interrupt::{interrupted, watch_interrupts}; pub(crate) use self::log_claim::create_log_job; #[cfg(feature = "cli")] pub(crate) use self::log_claim::{claim_log, log_claim_held, serves_launch}; -pub use self::private::create_private_dir; pub(crate) use self::private::{ check_snapshot_dir, check_vm_dir, create_vm_dir, make_sparse, private_file_problem, }; #[cfg(feature = "cli")] pub(crate) use self::private::{check_store_dir, create_store_dir, rename_no_replace}; -pub use self::process::process_start_time; +// Only the CLI, which checks the processes that its records name, and the tests, which name the +// processes that they start, read a process's start time on Windows. +#[cfg(any(test, feature = "cli"))] +pub(crate) use self::process::process_start_time; pub(crate) use self::process::{ ProcessHandle, boot_id, host_id, monotonic_ns, open_process, own_bytes_read, }; pub(crate) use self::spawn::{LaunchedProcess, SpawnMode, spawn_with_stdio}; -/// Whether this host can run NVX microVMs. -pub const SUPPORTED: bool = true; - /// Encodes `value` as a NUL-terminated UTF-16 string for Win32. fn wide(value: impl AsRef) -> Vec { value.as_ref().encode_wide().chain([0]).collect() diff --git a/nvx-rs/src/host/platform/windows/private.rs b/nvx-rs/src/host/platform/windows/private.rs index 896e47222..bdd028d36 100644 --- a/nvx-rs/src/host/platform/windows/private.rs +++ b/nvx-rs/src/host/platform/windows/private.rs @@ -33,24 +33,6 @@ use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken} use super::identity::handle_information; use super::{last_error, owned, raw, refuse_nul, wide_path}; -/// Creates `path` if needed. The directory inherits the ACL of its parent, which is private to -/// the user under the default state root. -pub fn create_private_dir(path: &Path) -> io::Result<()> { - match fs::create_dir(path) { - Ok(()) => {} - Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} - Err(error) => return Err(error), - } - if fs::symlink_metadata(path)?.is_dir() { - Ok(()) - } else { - Err(io::Error::other(format!( - "{} is not a plain directory", - path.display() - ))) - } -} - /// The `AceType` of an entry that allows access, which `winnt.h` defines. const ACCESS_ALLOWED_ACE_TYPE: u8 = 0; /// The `AceType` of an entry that denies access, which `winnt.h` defines. diff --git a/nvx-rs/src/host/platform/windows/process.rs b/nvx-rs/src/host/platform/windows/process.rs index c979b086c..b98afffeb 100644 --- a/nvx-rs/src/host/platform/windows/process.rs +++ b/nvx-rs/src/host/platform/windows/process.rs @@ -115,6 +115,7 @@ pub(super) fn wait_step(left: Duration) -> (u32, Duration) { /// /// OpenVMM always runs as the calling user, so a process that denies access cannot be the /// sandbox's VM, even if it reuses the recorded process ID. +#[cfg(any(test, feature = "cli"))] fn open_existing_process(pid: u32, access: u32) -> io::Result> { if let Some(process) = open_handle(pid, access) { return Ok(Some(process)); @@ -127,7 +128,8 @@ fn open_existing_process(pid: u32, access: u32) -> io::Result io::Result> { +#[cfg(any(test, feature = "cli"))] +pub(crate) fn process_start_time(pid: u32) -> io::Result> { let Some(process) = open_existing_process(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE)? else { diff --git a/nvx-rs/src/host/process.rs b/nvx-rs/src/host/process.rs index ce2b91dd5..d5e03a54e 100644 --- a/nvx-rs/src/host/process.rs +++ b/nvx-rs/src/host/process.rs @@ -136,17 +136,16 @@ impl<'de> Deserialize<'de> for BootId { /// A reference to one process: its ID, its start time, and the boot of the host during which it /// started, which together never name another process. /// -/// The start time is that of [`platform::process_start_time`]: on Linux, the `starttime` field -/// of `/proc//stat`, in clock ticks since boot, and on Windows, the creation time of the -/// process as a 64-bit `FILETIME`. A reference is a claim, which a caller may record and read -/// back after it restarts: [`Process::open`] checks it before anything waits for the process or -/// signals it. +/// The start time is, on Linux, the `starttime` field of `/proc//stat`, in clock ticks since +/// boot, and on Windows, the creation time of the process as a 64-bit `FILETIME`. A reference is a +/// claim, which a caller may record and read back after it restarts: [`Process::open`] checks it +/// before anything waits for the process or signals it. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ProcessRef { /// Process ID. pub pid: u32, - /// Start time, in the units of [`platform::process_start_time`]. + /// Start time: clock ticks since boot on Linux, and a creation `FILETIME` on Windows. pub start_time: u64, /// The boot of the host during which the process started. pub boot: BootId, diff --git a/nvx-rs/src/host/process/tests.rs b/nvx-rs/src/host/process/tests.rs index 85f4b54d8..930889f94 100644 --- a/nvx-rs/src/host/process/tests.rs +++ b/nvx-rs/src/host/process/tests.rs @@ -4,6 +4,8 @@ use super::*; #[cfg(target_os = "linux")] mod linux; +#[cfg(any(target_os = "linux", windows))] +mod orchestrator; /// The boot of the references in these tests. const BOOT: BootId = BootId::from_bytes([0x5a; 16]); diff --git a/nvx-rs/tests/process.rs b/nvx-rs/src/host/process/tests/orchestrator.rs similarity index 94% rename from nvx-rs/tests/process.rs rename to nvx-rs/src/host/process/tests/orchestrator.rs index c073bd3e6..3926f1ffe 100644 --- a/nvx-rs/tests/process.rs +++ b/nvx-rs/src/host/process/tests/orchestrator.rs @@ -1,11 +1,13 @@ //! Verified process references and event-driven exit waits, through the public API of -//! `nvx::host::process`, as an orchestrator uses them. +//! `nvx::host::process`, as an orchestrator uses them, with each child named by the start time +//! that the crate's private platform layer reads. //! //! The processes are this test executable, run again as one of two fixtures, which inherit //! nothing of the test: a process fixture runs until a file named `exit` appears in its working //! directory, and a launcher fixture starts a process fixture, prints the process's reference as -//! an orchestrator would record it, and runs until its standard input closes. -#![cfg(all(feature = "host", any(target_os = "linux", windows)))] +//! an orchestrator would record it, and runs until its standard input closes. The test starts +//! each under a `SpawnLock`, so that on Windows it inherits no handle of a VMM that a concurrent +//! test spawns. use std::env; use std::ffi::OsString; @@ -17,8 +19,8 @@ use std::sync::{Mutex, MutexGuard, PoisonError, mpsc}; use std::thread; use std::time::{Duration, Instant}; -use nvx::host::platform; -use nvx::host::process::{BootId, Opened, Process, ProcessRef, WaitMethod}; +use crate::host::platform; +use crate::host::process::{BootId, Opened, Process, ProcessRef, WaitMethod}; /// File whose presence in its working directory marks a process fixture. Without it, the fixture /// returns at once, as it does when `cargo test -- --ignored` runs it. @@ -81,14 +83,15 @@ fn launcher_fixture() { io::copy(&mut input, &mut io::sink()).unwrap(); } -/// Returns the arguments that run only the fixture `name` of this test executable. +/// Returns the arguments that run only the fixture `name` of this module of this test executable. /// /// The fixture runs on libtest's main thread, whatever the host's processors or the inherited /// `RUST_TEST_THREADS`, so libtest always prints its name before the fixture's own output. fn fixture_arguments(name: &str) -> Vec { + let path = format!("host::process::tests::orchestrator::{name}"); [ "--exact", - name, + path.as_str(), "--ignored", "--nocapture", "--test-threads=1", @@ -100,14 +103,14 @@ fn fixture_arguments(name: &str) -> Vec { /// Starts a process fixture in `directory`, which it marks, without standard streams. fn spawn_process(directory: &Path) -> Child { File::create(directory.join(PROCESS_MARKER)).unwrap(); - Command::new(env::current_exe().unwrap()) + let mut command = Command::new(env::current_exe().unwrap()); + command .args(fixture_arguments("process_fixture")) .current_dir(directory) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - .unwrap() + .stderr(Stdio::null()); + crate::host::tests::spawn_locked(&mut command).unwrap() } /// Returns the reference of `child`, which has not been reaped, so that its ID still names it. @@ -191,13 +194,13 @@ struct Launcher { impl Launcher { /// Starts a launcher whose process runs in `directory`. fn start(directory: &Path) -> Self { - let mut child = Command::new(env::current_exe().unwrap()) + let mut command = Command::new(env::current_exe().unwrap()); + command .args(fixture_arguments("launcher_fixture")) .env(LAUNCHER_DIR, directory) .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .spawn() - .unwrap(); + .stdout(Stdio::piped()); + let mut child = crate::host::tests::spawn_locked(&mut command).unwrap(); let input = child.stdin.take(); Self { child, input } } @@ -463,13 +466,12 @@ fn a_process_that_this_user_may_not_terminate_is_waited_for_but_never_signaled() /// Processes whose security descriptor restricts what this user may do with them. #[cfg(windows)] +#[allow(unsafe_code)] mod restricted { use std::os::windows::io::AsRawHandle; use std::process::{Child, Command, Stdio}; use std::{io, mem, ptr}; - use nvx::host::platform; - use nvx::host::process::{BootId, ProcessRef}; use windows_sys::Win32::Foundation::{CloseHandle, ERROR_SUCCESS}; use windows_sys::Win32::Security::Authorization::{SE_KERNEL_OBJECT, SetSecurityInfo}; use windows_sys::Win32::Security::{ @@ -479,6 +481,9 @@ mod restricted { }; use windows_sys::Win32::System::Threading::OpenProcess; + use crate::host::platform; + use crate::host::process::{BootId, ProcessRef}; + /// A child process, which is ended when dropped, through the handle with full access that /// its creator holds. pub(crate) struct Running(pub(crate) Child); @@ -494,13 +499,12 @@ mod restricted { /// descriptor then grants everyone, this user included, only `access` to it. Returns it with /// its reference. pub(crate) fn spawn(access: u32) -> (Running, ProcessRef) { - let child = Command::new("ping") + let mut command = Command::new("ping"); + command .args(["-n", "60", "127.0.0.1"]) .stdin(Stdio::null()) - .stdout(Stdio::null()) - .spawn() - .unwrap(); - let child = Running(child); + .stdout(Stdio::null()); + let child = Running(crate::host::tests::spawn_locked(&mut command).unwrap()); let pid = child.0.id(); let reference = ProcessRef { pid, diff --git a/nvx-rs/src/host/vm/tests/reattach.rs b/nvx-rs/src/host/vm/tests/reattach.rs index 0611675b6..7335e7d3a 100644 --- a/nvx-rs/src/host/vm/tests/reattach.rs +++ b/nvx-rs/src/host/vm/tests/reattach.rs @@ -371,3 +371,135 @@ fn owned_endpoint_child() { peer.write_all(&record(RecordType::Data, 1, b"response")) .unwrap(); } + +/// An idle copy of the test binary, which stands in for a VM's process, and is killed and +/// reaped when dropped. +struct IdleChild(Child); + +impl IdleChild { + fn start() -> Self { + let mut command = Command::new(std::env::current_exe().unwrap()); + command + .args([ + "--exact", + "host::vm::tests::reattach::idle_child", + "--ignored", + "--test-threads=1", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()); + Self(crate::host::tests::spawn_locked(&mut command).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(), + } + } +} + +impl Drop for IdleChild { + fn drop(&mut self) { + if self.0.try_wait().unwrap().is_none() { + self.0.kill().unwrap(); + self.0.wait().unwrap(); + } + } +} + +#[test] +#[ignore = "the idle stand-in for a VM's process, which only the checks of saved values start"] +fn idle_child() { + io::stdin().read_to_end(&mut Vec::new()).unwrap(); +} + +#[test] +fn a_saved_vm_runs_until_its_process_exits_and_another_runtime_is_refused_without_a_signal() { + let (_directory, vm) = vm_dir(); + let mut child = IdleChild::start(); + let saved = saved(&vm, child.reference()); + let held = verify(&saved, &saved.runtime).unwrap(); + assert!(held.wait(Instant::now()).unwrap().is_none()); + let mut other = saved.runtime.clone(); + other.control_session += 1; + assert_eq!( + verify(&saved, &other).unwrap_err().kind(), + ErrorKind::RuntimeMismatch + ); + assert!(!held.has_exited().unwrap()); + child.0.kill().unwrap(); + child.0.wait().unwrap(); + assert!( + held.wait(Instant::now() + Duration::from_secs(10)) + .unwrap() + .is_some() + ); + let error = verify(&saved, &saved.runtime).unwrap_err(); + assert_eq!(error.kind(), ErrorKind::Exited); + assert_eq!( + error + .source() + .unwrap() + .downcast_ref::() + .unwrap() + .kind(), + io::ErrorKind::NotFound + ); +} + +#[test] +fn reattaching_refuses_an_endpoint_that_another_process_serves_and_keeps_the_vm_and_its_values() { + let (_directory, vm) = vm_dir(); + let child = IdleChild::start(); + let saved = saved(&vm, child.reference()); + let before = saved.clone(); + let _server = bind(&saved.endpoint); + let deadline = Instant::now() + Duration::from_secs(10); + let error = reattach(&saved, &cold_runtime(), deadline).err().unwrap(); + assert_eq!(error.kind(), ErrorKind::Protocol); + assert_eq!( + error + .source() + .unwrap() + .downcast_ref::() + .unwrap() + .kind(), + io::ErrorKind::PermissionDenied + ); + assert!( + !verify(&saved, &saved.runtime) + .unwrap() + .has_exited() + .unwrap() + ); + assert_eq!(saved.endpoint, before.endpoint); + assert_eq!(saved.capability, before.capability); + assert_eq!(saved.process, before.process); + assert_eq!(saved.runtime, before.runtime); + assert_eq!(saved.vm_dir.path(), before.vm_dir.path()); + + let mut invalid = saved.clone(); + invalid.process.pid = 0; + let error = + crate::host::transport::connect_verified(&invalid.endpoint, &invalid.process, deadline) + .err() + .unwrap(); + assert_eq!(error.kind(), ErrorKind::Io); + assert_eq!( + error + .source() + .unwrap() + .downcast_ref::() + .unwrap() + .kind(), + io::ErrorKind::InvalidInput + ); + assert!( + !verify(&saved, &saved.runtime) + .unwrap() + .has_exited() + .unwrap() + ); +} diff --git a/nvx-rs/tests/security_checklist.rs b/nvx-rs/tests/security_checklist.rs index 90b7e1f19..519134092 100644 --- a/nvx-rs/tests/security_checklist.rs +++ b/nvx-rs/tests/security_checklist.rs @@ -1,29 +1,29 @@ //! The checklist of the security requirements that the host's documentation states, through the //! public API alone: the guest paths of an export, the stability of exported objects, the privacy -//! of a VM's directory and outputs, and the proofs of a VM's liveness, of its endpoint's owner and -//! of its recovery from saved values, and the strictness of a snapshot's directory. The checks of -//! a staged image and of a scratch image's claim, which only NVX's CLI makes, are unit tests of +//! of a VM's directory and outputs, and the strictness of a snapshot's directory. The checks of a +//! staged image and of a scratch image's claim, which only NVX's CLI makes, are unit tests of //! `host::sandbox::distro` and `host::sandbox::scratch`. //! -//! Each check is short and runs no VM: a process of the test's own, an idle copy of the test -//! binary, stands in for a VM's process where one is needed. +//! Each check is short and runs no VM. The proofs of a saved VM's liveness, of its endpoint's +//! owner and of its recovery from saved values need a process of the test's own, named by its +//! start time, which only the crate's private platform layer reads, so the unit tests of +//! `nvx::host::vm` check them. #![cfg(all(feature = "host", any(target_os = "linux", windows)))] -use std::error::Error as _; use std::fs::{self, File}; -use std::io::{self, Read}; +#[cfg(windows)] +use std::io; use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; +#[cfg(windows)] +use std::process::{Command, Stdio}; use nvx::activation::{MachineIdMode, View}; use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; #[cfg(windows)] use nvx::host::filesystem::Owner; use nvx::host::filesystem::{self, ExportRequest, Mapping, ReservedTargets, Target}; -use nvx::host::process::{BootId, ProcessRef}; use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; -use nvx::host::{platform, snapshot, transport, vm}; +use nvx::host::{snapshot, vm}; use nvx::{ErrorKind, Hypervisor}; /// Plans the export of `mappings` into the blockless root. @@ -313,46 +313,6 @@ fn a_snapshot_opens_only_from_a_snapshot_directory_and_changes_nothing() { assert_eq!(fs::read(&caller).unwrap(), b"caller-owned launch state"); } -/// An idle copy of the test binary, which stands in for a VM's process, and is killed and -/// reaped when dropped. -struct IdleChild(Child); - -impl IdleChild { - fn start() -> Self { - Self( - Command::new(std::env::current_exe().unwrap()) - .args(["--exact", "idle_child", "--ignored", "--test-threads=1"]) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .spawn() - .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(), - } - } -} - -impl Drop for IdleChild { - fn drop(&mut self) { - if self.0.try_wait().unwrap().is_none() { - self.0.kill().unwrap(); - self.0.wait().unwrap(); - } - } -} - -#[test] -#[ignore = "the idle stand-in for a VM's process, which only the checklist starts"] -fn idle_child() { - io::stdin().read_to_end(&mut Vec::new()).unwrap(); -} - /// A runtime whose files no check reads. fn runtime() -> Runtime { Runtime { @@ -374,159 +334,3 @@ fn runtime() -> Runtime { options: LaunchOptions::default(), } } - -/// Returns what a caller saves of a VM whose process is `process`, with an endpoint that no -/// process serves. -fn saved(directory: &vm::VmDir, process: ProcessRef) -> vm::SavedVm { - #[cfg(target_os = "linux")] - let endpoint = directory - .path() - .join("control.sock") - .to_str() - .unwrap() - .to_owned(); - #[cfg(windows)] - let endpoint = { - let mut bytes = [0; 16]; - getrandom::fill(&mut bytes).unwrap(); - let suffix: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); - format!(r"\\.\pipe\openvmm-microvm-{suffix}") - }; - vm::SavedVm { - vm_dir: directory.clone(), - endpoint, - capability: [0xa5; nvx::protocol::CAPABILITY_LEN], - process, - runtime: runtime().identity, - } -} - -#[test] -fn a_saved_vm_runs_until_its_process_exits_and_another_runtime_is_refused_without_a_signal() { - let (_directory, dir) = private_directory(); - let mut child = IdleChild::start(); - let saved = saved(&dir, child.reference()); - let held = vm::verify(&saved, &saved.runtime).unwrap(); - assert!(held.wait(Instant::now()).unwrap().is_none()); - let mut other = saved.runtime.clone(); - other.control_session += 1; - assert_eq!( - vm::verify(&saved, &other).unwrap_err().kind(), - ErrorKind::RuntimeMismatch - ); - assert!(!held.has_exited().unwrap()); - child.0.kill().unwrap(); - child.0.wait().unwrap(); - assert!( - held.wait(Instant::now() + Duration::from_secs(10)) - .unwrap() - .is_some() - ); - let error = vm::verify(&saved, &saved.runtime).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Exited); - assert_eq!( - error - .source() - .unwrap() - .downcast_ref::() - .unwrap() - .kind(), - io::ErrorKind::NotFound - ); -} - -/// Serves `endpoint` from the test's own process, which is not the VM's. -#[cfg(target_os = "linux")] -fn endpoint_server(endpoint: &str) -> std::os::unix::net::UnixListener { - std::os::unix::net::UnixListener::bind(endpoint).unwrap() -} - -/// Serves `endpoint` from the test's own process, which is not the VM's. -#[cfg(windows)] -fn endpoint_server(endpoint: &str) -> File { - use std::os::windows::io::{FromRawHandle, OwnedHandle}; - 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::{ - CreateNamedPipeW, PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, - }; - let name: Vec = endpoint.encode_utf16().chain(Some(0)).collect(); - // SAFETY: the name is a NUL-terminated wide string that outlives the call, and the test owns - // the new pipe, which the returned file closes. - let handle = unsafe { - CreateNamedPipeW( - name.as_ptr(), - PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, - 1, - 4096, - 4096, - 0, - std::ptr::null(), - ) - }; - assert_ne!( - handle, - INVALID_HANDLE_VALUE, - "{}", - io::Error::last_os_error() - ); - // SAFETY: the successful call returned a new handle that nothing else owns. - File::from(unsafe { OwnedHandle::from_raw_handle(handle) }) -} - -#[test] -fn reattaching_refuses_an_endpoint_that_another_process_serves_and_keeps_the_vm_and_its_values() { - let (_directory, dir) = private_directory(); - let child = IdleChild::start(); - let saved = saved(&dir, child.reference()); - let before = saved.clone(); - let _server = endpoint_server(&saved.endpoint); - let deadline = Instant::now() + Duration::from_secs(10); - let error = vm::reattach(&saved, &runtime(), deadline).err().unwrap(); - assert_eq!(error.kind(), ErrorKind::Protocol); - assert_eq!( - error - .source() - .unwrap() - .downcast_ref::() - .unwrap() - .kind(), - io::ErrorKind::PermissionDenied - ); - assert!( - !vm::verify(&saved, &saved.runtime) - .unwrap() - .has_exited() - .unwrap() - ); - assert_eq!(saved.endpoint, before.endpoint); - assert_eq!(saved.capability, before.capability); - assert_eq!(saved.process, before.process); - assert_eq!(saved.runtime, before.runtime); - assert_eq!(saved.vm_dir.path(), before.vm_dir.path()); - - let mut invalid = saved.clone(); - invalid.process.pid = 0; - let error = transport::connect_verified(&invalid.endpoint, &invalid.process, deadline) - .err() - .unwrap(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!( - error - .source() - .unwrap() - .downcast_ref::() - .unwrap() - .kind(), - io::ErrorKind::InvalidInput - ); - assert!( - !vm::verify(&saved, &saved.runtime) - .unwrap() - .has_exited() - .unwrap() - ); -} diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index ad2e99f86..ddb768d07 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -12,6 +12,9 @@ "nvx-rs/src/guest/sys/spawn.rs": 7, "nvx-rs/src/guest/time/system.rs": 3, "nvx-rs/src/host/network/address.rs": 2, + "nvx-rs/src/host/platform/linux/mod.rs": 1, + "nvx-rs/src/host/platform/windows/mod.rs": 1, + "nvx-rs/src/host/platform/windows/process.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, "nvx-rs/src/host/session.rs": 5, "nvx-rs/src/host/vm.rs": 2