From ba716d48821adff7f0173a9316dd29e0a85f11b5 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Wed, 11 Nov 2026 20:01:33 -0800 Subject: [PATCH] host: delete the sandbox store, the instance layer and the supervision With the old clone path and the template store gone (SL6.2b, SL6.2a), and the code that stays off them (SL6.0a to SL6.0e), nothing uses the sandbox store, the instance layer that ran a VM in a store of its own, or the supervisor's store-coupled launch, wait, stop and reattach. host::instance and host::store leave whole, and host::supervisor loses launch, reattach, Vmm, Starting, Reattached, StopOutcome and Stopped, with its public SpawnLock and lock_spawns, which become crate-private in host::platform::inherit for host::vm::spawn's lock. The last store identity types, activation::Identity, StoreGeneration and TemplateReference, leave with the snapshot key's conversions to and from Identity. The public API snapshots lose 236 items on each platform. host::supervisor stays, with no code of its own, as the transitional public path of the low-level spawn items that code which stays uses: it re-exports spawn, spawn_vm, VmmCommand, Launched, Inherited and spawn_command. The external-launch example, which launches a caller's vm::VmmCommand with supervisor::spawn_vm (b2a70a3ab4f, 2026-11-10), checked_handoff's tests and PreparedLaunch's doc test came after the plan of #1121, which deletes the whole module. SL6.3 (#1122) deletes spawn_command and the checked-file handoff, and settles the example's launcher; the module says so. The crate-private code that only the store, the instance and the supervision used leaves with them, as the dead-code check of each feature set finds it on Linux and Windows: platform::control_endpoint and endpoint_server_pid on every host, and Directory's open_file, search, search_dir and open_dir, with Linux's open_dir_with. What only the CLI's own store uses now is gated by its cli feature on every host: endpoint_server_pid_until, is_deleted, file_id, opened_file_id, opened_file_identity, Directory, and the log claim's claim_log, log_claim_held and serves_launch, with Linux's log_claim module and restore_private_file_mode, and Windows' log_job_processes, check_store_dir and delete_opened, and its re-exports of opened_file_id and create_store_dir, which its own VM directories still use; the handle that a log job's name holds expects to be dead without cli, since only the CLI's claim checks query it. The tests of what leaves go with it: tests/store.rs, with the store's record fixtures, and tests/fake_openvmm.rs, the instance's cases; the cases of tests/supervisor.rs that launch, reattach to or stop a VMM through a sandbox store, with their orchestrator fixture; the store's, the instance's and the supervisor's unit tests; the store and supervisor doc tests; StoreGeneration's and the key identity's conversion tests; the Windows test of a store launch whose VMM exited; and the CLI store's test that it shares the sandbox store's lock and file names. The cases of tests/supervisor.rs that test what stays, host::process and the low-level spawn that host::supervisor still exports, stay unchanged, but for two that drop their checks of what leaves: one no longer checks that a Vmm is Send and Sync, and one no longer reattaches a sandbox to the process that it may not query. Two activation tests drop their checks of Identity, and the host-module test checks the edges vm -> spawn, supervisor -> spawn and spawn -> platform in place of those to the store. The tests of what the cli feature now gates are gated with it, two of them calling endpoint_server_pid_until where they called endpoint_server_pid; the Windows tests name their pipes with a random suffix of their own in place of a LaunchId or control_endpoint, and the snapshot fixture and the CLI's Windows lifecycle test create their private directories with vm::create_private_dir. The docs stop naming what left: the crate's example, now a cold boot and a stop through host::vm, and its module list; the host module's; the runtime, spawn, process-inheritance, prepared-launch and CLI-store rustdoc; Cargo.toml's host feature; the nvx README's layout, module table, runtime, VM, snapshot, supervisor, launch, CLI sandbox, state directory, example, error and test sections; and the store rules of doc/run.md and the crate's summary in doc/project-structure.md. The case matrix retires the store's and the supervisor's doc tests and rewrites the crate's. Part of #1121 (SL6.1). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/acceptance-case-matrix.md | 12 +- doc/project-structure.md | 8 +- doc/run.md | 6 +- nvx-rs/Cargo.toml | 4 +- nvx-rs/README.md | 310 ++-- nvx-rs/public-api/linux.txt | 236 --- nvx-rs/public-api/windows.txt | 236 --- nvx-rs/src/activation/mod.rs | 72 - nvx-rs/src/activation/tests.rs | 23 - nvx-rs/src/host/cli/store/mod.rs | 4 +- nvx-rs/src/host/cli/store/tests.rs | 31 - nvx-rs/src/host/instance/execution.rs | 181 --- nvx-rs/src/host/instance/files.rs | 133 -- nvx-rs/src/host/instance/locked.rs | 289 ---- nvx-rs/src/host/instance/mod.rs | 480 ------ nvx-rs/src/host/instance/tests.rs | 249 ---- nvx-rs/src/host/mod.rs | 20 +- nvx-rs/src/host/platform/inherit.rs | 31 +- nvx-rs/src/host/platform/linux/endpoint.rs | 15 +- nvx-rs/src/host/platform/linux/identity.rs | 98 +- nvx-rs/src/host/platform/linux/mod.rs | 12 +- nvx-rs/src/host/platform/linux/private.rs | 1 + nvx-rs/src/host/platform/linux/tests.rs | 7 +- nvx-rs/src/host/platform/other.rs | 37 +- nvx-rs/src/host/platform/tests.rs | 8 +- nvx-rs/src/host/platform/windows/endpoint.rs | 29 +- .../host/platform/windows/endpoint/tests.rs | 9 +- nvx-rs/src/host/platform/windows/identity.rs | 107 +- nvx-rs/src/host/platform/windows/log_claim.rs | 47 +- nvx-rs/src/host/platform/windows/mod.rs | 16 +- nvx-rs/src/host/platform/windows/private.rs | 1 + .../host/platform/windows/private/tests.rs | 1 + nvx-rs/src/host/platform/windows/tests.rs | 84 +- nvx-rs/src/host/runtime/mod.rs | 7 +- nvx-rs/src/host/sandbox/prepared.rs | 11 +- nvx-rs/src/host/snapshot/key.rs | 18 +- nvx-rs/src/host/snapshot/key/tests.rs | 15 - nvx-rs/src/host/snapshot/tests.rs | 4 +- nvx-rs/src/host/spawn.rs | 17 +- nvx-rs/src/host/store/caller.rs | 227 --- nvx-rs/src/host/store/caller/tests.rs | 221 --- nvx-rs/src/host/store/files.rs | 247 ---- nvx-rs/src/host/store/launch.rs | 134 -- nvx-rs/src/host/store/lease.rs | 475 ------ nvx-rs/src/host/store/lifecycle.rs | 799 ---------- nvx-rs/src/host/store/lifecycle/tests.rs | 1285 ----------------- nvx-rs/src/host/store/locked.rs | 395 ----- nvx-rs/src/host/store/mod.rs | 670 --------- nvx-rs/src/host/store/names.rs | 92 -- nvx-rs/src/host/store/record.rs | 362 ----- nvx-rs/src/host/store/regular.rs | 40 - nvx-rs/src/host/store/tests.rs | 1064 -------------- nvx-rs/src/host/store/vmm.rs | 93 -- nvx-rs/src/host/supervisor/launch.rs | 275 ---- nvx-rs/src/host/supervisor/mod.rs | 152 +- nvx-rs/src/host/supervisor/reattach.rs | 141 -- nvx-rs/src/host/supervisor/tests.rs | 766 ---------- nvx-rs/src/host/supervisor/vmm.rs | 369 ----- nvx-rs/src/host/tests.rs | 14 + nvx-rs/src/host/transport/tests.rs | 2 +- nvx-rs/src/host/vm/tests.rs | 2 +- nvx-rs/src/host/vm_directory/tests.rs | 2 + nvx-rs/src/lib.rs | 101 +- nvx-rs/tests/cli/lifecycle.rs | 3 +- nvx-rs/tests/fake_openvmm.rs | 543 ------- .../invalid/launch-instance-format-3.json | 8 - .../store/invalid/launch-without-log.json | 13 - .../store/invalid/runtime-cli-format-1.json | 6 - .../store/invalid/runtime-format-5.json | 19 - .../invalid/runtime-instance-format-3.json | 6 - .../store/invalid/runtime-truncated.json | 9 - .../store/invalid/runtime-unknown-field.json | 19 - .../tests/fixtures/store/launch-prepared.json | 19 - .../tests/fixtures/store/launch-spawned.json | 24 - nvx-rs/tests/fixtures/store/runtime.json | 18 - nvx-rs/tests/host_modules.rs | 14 +- nvx-rs/tests/managed_lifecycle.rs | 3 +- nvx-rs/tests/store.rs | 844 ----------- nvx-rs/tests/supervisor.rs | 715 +-------- 79 files changed, 431 insertions(+), 12629 deletions(-) delete mode 100644 nvx-rs/src/host/instance/execution.rs delete mode 100644 nvx-rs/src/host/instance/files.rs delete mode 100644 nvx-rs/src/host/instance/locked.rs delete mode 100644 nvx-rs/src/host/instance/mod.rs delete mode 100644 nvx-rs/src/host/instance/tests.rs delete mode 100644 nvx-rs/src/host/store/caller.rs delete mode 100644 nvx-rs/src/host/store/caller/tests.rs delete mode 100644 nvx-rs/src/host/store/files.rs delete mode 100644 nvx-rs/src/host/store/launch.rs delete mode 100644 nvx-rs/src/host/store/lease.rs delete mode 100644 nvx-rs/src/host/store/lifecycle.rs delete mode 100644 nvx-rs/src/host/store/lifecycle/tests.rs delete mode 100644 nvx-rs/src/host/store/locked.rs delete mode 100644 nvx-rs/src/host/store/mod.rs delete mode 100644 nvx-rs/src/host/store/names.rs delete mode 100644 nvx-rs/src/host/store/record.rs delete mode 100644 nvx-rs/src/host/store/regular.rs delete mode 100644 nvx-rs/src/host/store/tests.rs delete mode 100644 nvx-rs/src/host/store/vmm.rs delete mode 100644 nvx-rs/src/host/supervisor/launch.rs delete mode 100644 nvx-rs/src/host/supervisor/reattach.rs delete mode 100644 nvx-rs/src/host/supervisor/tests.rs delete mode 100644 nvx-rs/src/host/supervisor/vmm.rs delete mode 100644 nvx-rs/tests/fake_openvmm.rs delete mode 100644 nvx-rs/tests/fixtures/store/invalid/launch-instance-format-3.json delete mode 100644 nvx-rs/tests/fixtures/store/invalid/launch-without-log.json delete mode 100644 nvx-rs/tests/fixtures/store/invalid/runtime-cli-format-1.json delete mode 100644 nvx-rs/tests/fixtures/store/invalid/runtime-format-5.json delete mode 100644 nvx-rs/tests/fixtures/store/invalid/runtime-instance-format-3.json delete mode 100644 nvx-rs/tests/fixtures/store/invalid/runtime-truncated.json delete mode 100644 nvx-rs/tests/fixtures/store/invalid/runtime-unknown-field.json delete mode 100644 nvx-rs/tests/fixtures/store/launch-prepared.json delete mode 100644 nvx-rs/tests/fixtures/store/launch-spawned.json delete mode 100644 nvx-rs/tests/fixtures/store/runtime.json delete mode 100644 nvx-rs/tests/store.rs diff --git a/doc/acceptance-case-matrix.md b/doc/acceptance-case-matrix.md index 0b6b87153..77ce7a011 100644 --- a/doc/acceptance-case-matrix.md +++ b/doc/acceptance-case-matrix.md @@ -32,7 +32,7 @@ its own in turn. | [The adapter's other tests](#the-adapters-other-tests) | 169 | 0 | 9 | 160 | | [MicroVM scenarios](#microvm-scenarios) | 54 | 51 | 2 | 1 | | [CI](#ci) | 69 | 43 | 3 | 23 | -| [Docs](#docs) | 36 | 32 | 1 | 3 | +| [Docs](#docs) | 36 | 28 | 2 | 6 | ## The adapter's scenarios @@ -498,15 +498,15 @@ this matrix. The README's own code is no doctest. | `nvx-rs/src/guest/terminal.rs - guest::terminal (line 10)` | kept | | | `nvx-rs/src/guest/time/mod.rs - guest::time (line 15)` | kept | | | `nvx-rs/src/host/sandbox/prepared.rs - host::sandbox::prepared::PreparedLaunch (line 92)` | kept | Its launch type leaves with the old launch paths (#1087), whose PR retires it | -| `nvx-rs/src/host/store/mod.rs - host::store (line 72)` | kept | The sandbox store leaves (#1121), whose PR retires it | -| `nvx-rs/src/host/supervisor/mod.rs - host::supervisor (line 32)` | kept | The old supervisor leaves (#1087), whose PR retires it | +| `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); `host::supervisor` stays only as the transitional public path of the low-level spawn, until SL6.3 (#1122), with no doc test | | `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) | | `nvx-rs/src/host/transport.rs - host::transport (line 22)` | kept | | -| `nvx-rs/src/lib.rs - (line 58)` | kept | | +| `nvx-rs/src/lib.rs - (line 58)` | rewritten | Its `Instance` and sandbox store leave (SL6.1, #1121): `nvx-rs/src/lib.rs - (line 61)` compiles a cold boot and a stop through `host::vm` | | `nvx-rs/src/protocol/nvxs/mod.rs - protocol::nvxs (line 68)` | kept | | | `nvx-rs/src/host/snapshot/capture.rs - host::snapshot::capture::finish_capture (line 48)` | kept | | -| `nvx-rs/src/host/store/mod.rs - host::store (line 104)` | kept | The sandbox store leaves (#1121), whose PR retires it | -| `nvx-rs/README.md`, its Example: an instance through `LaunchSpec` and the sandbox store | rewritten | Nothing compiles it. `nvx-rs/examples/host-workflows.rs` compiles the documented host workflows, and the library's guide (SL6.10a) replaces the example | +| `nvx-rs/src/host/store/mod.rs - host::store (line 104)` | retired | The sandbox store leaves (SL6.1, #1121) | +| `nvx-rs/README.md`, its Example: an instance through `LaunchSpec` and the sandbox store | rewritten | Nothing compiles it. `nvx-rs/examples/host-workflows.rs` compiles the documented host workflows, and the library's guide (SL6.10a) replaces the example. SL6.1 (#1121) made it the cold boot that `nvx-rs/src/lib.rs - (line 61)` compiles | | `nvx-rs/examples/external-launch.rs` | kept | Compiled with the crate's targets, and run by hand on a host | diff --git a/doc/project-structure.md b/doc/project-structure.md index 714e4a3be..14457a8b5 100644 --- a/doc/project-structure.md +++ b/doc/project-structure.md @@ -102,12 +102,12 @@ nvx/ | | |-- contract.rs Control-contract versions and release compatibility | | |-- net/ CIDR parsing and subtraction, and hostname and guest-network forms | | |-- gpt/ Strict reading of GPT disks whose first partition configures an adapter and whose other partitions hold a root's read-only layers, shared by the host and the guest -| | |-- host/ Artifacts, launch, network and egress-policy lowering, sessions, VM instances, sandbox stores, the sandbox launch profile, and the `nvx-rs` CLI (feature `cli`) +| | |-- host/ Artifacts, launch, network and egress-policy lowering, sessions, VMs, the sandbox launch profile, and the `nvx-rs` CLI (feature `cli`) | | |-- testing/ Fake OpenVMM (feature `testing`) | | |-- guest/ Programs that run in Linux guests, the guest's NVXS connection, the libraries through which any guest init or agent opens the terminal on which it serves its session, starts a program, collects its children's statuses, settles its executions' cgroups, drives each execution, mounts host mappings beneath the descriptors of the roots that it names, assembles and tears down a layered root, and reads a GPT disk's partition table through its block device, and the typed calls through which it runs the time ABI's steps and the reseed (feature `guest`) | | `-- bin/ nvx-rs CLI, nvx-fake-openvmm test double, and the guest binaries | |-- examples/ The external-launch example, and the documented host and guest workflows, which only compile -| |-- tests/ Fake-OpenVMM instance lifecycle, sandbox store, CLI scenario, NVXS session, guest-binary, and hypervisor managed-lifecycle and public-API VM lifecycle tests, the security checklist, the store's record fixtures and sandbox configurations of every format, the cases of the export and root boot tokens, and the conformance vectors of the wire formats +| |-- tests/ Fake-OpenVMM behavior, process and VM spawn, CLI scenario, NVXS session, guest-binary, and hypervisor managed-lifecycle and public-API VM lifecycle tests, the security checklist, sandbox configurations of every format, the cases of the export and root boot tokens, and the conformance vectors of the wire formats | |-- public-api/ The crate's public API on Linux and on Windows, which CI checks | |-- build.rs Stages the bundled artifacts (feature `bundled`) | `-- artifacts.json Release package pinned for the bundled artifacts @@ -295,8 +295,8 @@ authenticated control protocol of the guest agent, artifact discovery and bundled-release staging, OpenVMM's command line, the lowering of network and filesystem policies, the compiler of the egress policy files that `nvx-rs run` and `nvx-rs sandbox` accept, process and platform primitives for Linux and -Windows hosts, the supervision of VM instances, the sandbox launch profile of -`nvx-rs sandbox`, the `nvx-rs` command-line interface that runs NVX's +Windows hosts, the creation, spawn, and stop of VMs, the sandbox launch profile +of `nvx-rs sandbox`, the `nvx-rs` command-line interface that runs NVX's microVMs and sandboxes, downloads releases, compiles egress policies, and looks up artifacts, and a fake OpenVMM for tests. Its `guest` feature builds the ten static executables that every guest image diff --git a/doc/run.md b/doc/run.md index 45aa02e29..b448ee753 100644 --- a/doc/run.md +++ b/doc/run.md @@ -1678,9 +1678,9 @@ lock, so it proceeds while a workload runs. A provisioned state directory keeps removes them with NVX's other files. A transition that leaves a state directory without `config.json`, such as one of a sandbox that was never provisioned, removes `sandbox.lock` as it releases the -lock. These are the rules of the sandbox store, which other orchestrators of -sandboxes share through `nvx-rs`. On NFS, where a removed file that is still -open, such as a lock file, stays as `.nfs*` until its last handle closes, and +lock. These are the rules of the CLI's own store of a sandbox. On NFS, where a +removed file that is still open, such as a lock file, stays as `.nfs*` until +its last handle closes, and on Windows file systems without POSIX deletion such as ReFS, where a removed lock file stays under its name, such files keep the emptied directory, so `deprovision` then releases the lock before it removes the directory. A diff --git a/nvx-rs/Cargo.toml b/nvx-rs/Cargo.toml index 693c1411e..0bdbe396e 100644 --- a/nvx-rs/Cargo.toml +++ b/nvx-rs/Cargo.toml @@ -25,8 +25,8 @@ name = "nvx" [features] default = ["host"] # The host side: artifact discovery, launch, network and filesystem lowering, control sessions, -# process and platform primitives, the supervision of VM instances, and the sandbox launch -# profile, whose aggregate child names are SHA-256 digests. +# process and platform primitives, VMs in private directories that their callers own, and the +# sandbox launch profile, whose aggregate child names are SHA-256 digests. host = [ "serde", "dep:getrandom", diff --git a/nvx-rs/README.md b/nvx-rs/README.md index f6deded0e..bb158b53b 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -47,11 +47,10 @@ nvx-rs/ │ │ sides, without I/O, threads, or features, for any framing and host │ ├── host/ feature `host` (default): the runtime that every launch shares, VM │ │ directories and configurations, artifacts, launch, network and egress policy -│ │ files, filesystem, NVXS connection, session, process, platform, VM instances, -│ │ sandbox stores, templates' manifests and compatibility keys, -│ │ outcome reports, file seals, and the sandbox launch profile of `nvx-rs sandbox`, -│ │ its distro and scratch bindings, and its prepared launches; with feature -│ │ `cli`, the `nvx-rs` CLI +│ │ files, filesystem, NVXS connection, session, process, platform, templates' +│ │ manifests and compatibility keys, outcome reports, file seals, and the +│ │ sandbox launch profile of `nvx-rs sandbox`, its distro and scratch bindings, +│ │ and its prepared launches; with feature `cli`, the `nvx-rs` CLI │ ├── testing/ feature `testing`: the fake OpenVMM │ ├── guest/ feature `guest`: the programs that run in Linux guests, the guest's NVXS │ │ connection, the libraries through which a guest init or agent opens the @@ -63,9 +62,9 @@ nvx-rs/ │ │ prepares a template's guest for a capture before its root's assembly, and │ │ the typed calls through which it runs the time ABI's steps and the reseed │ └── bin/ nvx-rs, nvx-fake-openvmm, and the guest binaries -├── tests/ fake-OpenVMM, CLI, NVXS session, process-supervision, checked-handoff, store, -│ host-module layering, and guest-binary tests, the CLI's -│ scenario tests and their harness, in cli/, the store's record fixtures, the +├── tests/ fake-OpenVMM, CLI, NVXS session, process and spawn, checked-handoff, +│ host-module layering, and guest-binary tests, the CLI's scenario tests and +│ their harness, in cli/, the │ hand-written sandbox configurations of every format, the cases of the export │ and root boot tokens, and the template manifests that OpenVMM wrote, in │ fixtures/, and the conformance vectors of the wire formats and of a template's @@ -81,9 +80,7 @@ on Linux and Windows. The [filesystem module](src/host/filesystem/mod.rs) keeps its request types and planner together, with private siblings for guest and host paths, access policies, -OpenVMM limits, and recorded-plan checks and rendering. The -[instance module](src/host/instance/mod.rs) separates private-file helpers, -lock-held lifecycle transitions, and workload execution. The CLI's +OpenVMM limits, and recorded-plan checks and rendering. The CLI's [managed sandbox lifecycle](src/host/cli/sandbox/lifecycle/mod.rs) separates state directories, outcome reports, start and exec, and stop and deprovision. The CLI's own [store](src/host/cli/store/mod.rs) keeps the layout of a sandbox's @@ -92,11 +89,7 @@ its files and its lock. The [Linux](src/host/platform/linux/mod.rs) and [Windows](src/host/platform/windows/mod.rs) platform modules each separate processes, spawns, private directories and files, file identities, the claim of a -launch's log, and OpenVMM's endpoints, and on Windows, the CLI's Ctrl-C. The -[store](src/host/store/mod.rs) keeps `SandboxStore` and its documentation, with -private siblings for the names of its files, `LockedStore`, and launches. The -[supervisor](src/host/supervisor/mod.rs) keeps its documentation, with private -siblings for launches, the VMs that it holds and their stops, and reattaches. +launch's log, and OpenVMM's endpoints, and on Windows, the CLI's Ctrl-C. Their `mod.rs` files retain the existing entry points through re-exports; their `tests.rs` files keep the unit tests at the same module paths. @@ -133,12 +126,10 @@ changes only when the module's purpose does. | [`nvx::host::network`](#nvxhostnetwork) | The one lowering of a network posture into OpenVMM options, for the CLI and external orchestrators | | [`nvx::host::network::policy`](#nvxhostnetworkpolicy) | Egress policy files and their compilation into OpenVMM's native rules | | [`nvx::host::filesystem`](#nvxhostfilesystem) | The planner of the host paths that every NVX caller exports to the guest through OpenVMM's virtio-fs device | -| [`nvx::host::store`](#nvxhoststore) | `SandboxStore`: the runtime records of one sandbox, which every host that supervises it shares under one lock | -| [`nvx::host::instance`](#nvxhostinstance) | `Instance`: a VM in a sandbox store of its own, which the store, the supervisor, and the control session drive | | [`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::supervisor`](#nvxhostsupervisor) | The supervisor of a sandbox's VMM, beneath the CLI, `Instance`, and external orchestrators | +| [`nvx::host::supervisor`](#nvxhostsupervisor) | The transitional public path of the low-level spawn of a VMM, which code that stays uses until SL6.3 (#1122) | | [`nvx::host::seal`](#nvxhostseal) | `FileSeal`: what the host reports about an open file from its metadata alone, without reading it | | [`nvx::host::session`, `platform`, `report`](#nvxhostsession-platform-report) | The authenticated control session, Linux and Windows primitives, and OpenVMM's outcome report | | [`nvx::host::sandbox`](#nvxhostsandbox) | The launch profile of `nvx-rs sandbox`: its plans, live shares, option checks, bindings, and prepared launches | @@ -442,7 +433,8 @@ attaches its own. their install or staging metadata gives, such as `sha256:` and a digest, which NVX compares and never computes, and adds the versions of the contracts that the crate speaks; `differences` names the `IdentityPart`s in which two identities -differ. The sandbox store records it with each VM, and re-exports its types. +differ. A caller saves it with each VM, in a `SavedVm`, which `vm::verify` +checks against the current one. `probe_hypervisor` checks that the host has the hypervisor: its device on Linux, and the Windows Hypervisor Platform on Windows. @@ -606,7 +598,7 @@ Read the resolved NIC or capture context, or build an activation record, before consuming `Prepared` into the command. The compiled [external-launch example](examples/external-launch.rs) consumes -this command, uses `supervisor::spawn_vm` without a sandbox store, checks the +this command, launches it with the transitional `supervisor::spawn_vm`, checks the cold guest's boot marker and shell response, then asks it to exit cleanly. It requires the VMM's actual exit status 0 and a successful outcome with complete teardown. Build with @@ -680,9 +672,8 @@ it, which each clone's restore replaces with the clone's own, so that a template with a NIC serves the clones of every network policy at its guest's address. A key and a class each have one canonical, versioned encoding, whose -domain-separated SHA-256 digest is its identity, a `KeyIdentity`, which converts -to and from the `nvx::activation::Identity` by which a clone's lease and durable records -name its template's key, or a `ClassIdentity`; the +domain-separated SHA-256 digest is its identity, a `KeyIdentity`, or a +`ClassIdentity`; the [conformance vectors](tests/vectors/README.md) of version 1, `tests/vectors/snapshot-key-v1.txt`, pin both encodings and identities. The key's encoding, version 1, leaves the network policy out, since templates @@ -991,8 +982,8 @@ with its arguments, in UTF-16 code units, as Windows quotes them, and every host, with the reserve that its caller keeps. The helpers live in `nvx::host::config` and remain re-exported here for existing callers. `LaunchSpec::check_process_command_line`, which a caller runs before it -provisions an instance, keeps 1024 code units for what may change before the -instance starts, whose own check keeps none. +provisions a VM, keeps 1024 code units for what may change before the VM +starts. `VmmLaunch::RESERVE`, 2048 code units, is what the planning of an adapter's launch keeps for the rest of the launch: the executable and the arguments that @@ -1047,80 +1038,6 @@ its ordered binds, which give the host-mapping table, and the identities that each start checks again; and `guest_path` and `resolve_guest_path` (also at `nvx::`). -### `nvx::host::store` - -`SandboxStore`: the runtime records of one sandbox in a private directory with -one lock, which every host that supervises the sandbox shares. - -The `RuntimeRecord` names the running VM: the `ProcessRef` of its VMM, its -control endpoint, and the `RuntimeIdentity` that it was launched with, the -identities of its OpenVMM, kernel, and initramfs from install or staging -metadata and its boot and control contract versions, which decide whether -another build of the host may reattach to it. - -The `LaunchJournal` of a launch holds a marker written before the spawn, the -VMM's `ProcessRef` right after it, and an inherited claim on the VMM's log, -which proves that a launch that never published its endpoint has exited, so that -recovery ends an interrupted launch and signals only a VMM that it holds through -a verified `Process`. - -Records are JSON in one versioned format, and records in any other format fail -rather than being migrated. - -The caller declares the files that it keeps in the directory, its configuration -record, whose presence means that the sandbox is provisioned, and any others, as -`CallerFiles`, and takes the lifecycle locks with them: -`SandboxStore::lock_for_provision` creates the directory, again if a removal -deletes it meanwhile, `SandboxStore::lock_existing` fails with -`ErrorKind::NotFound` once it is gone, and a holder that leaves the directory -without the configuration record removes its lock file as it releases the lock. - -`LockedStore::remove` removes a sandbox that no VMM can run in, for a caller -that declares its configuration record: the store's files, the caller's, and the -directory, unless it holds entries that are neither, which it refuses and keeps, -and `Lingering` waits for open files, such as the lock files of other -transitions, that keep the emptied directory; both remove the directory only -while the path still names it. - -The store also owns the file in which the VMM stages its outcome report, which a -VMM that is killed while it writes the report leaves, and removes it once no -process of its launch runs. - -On Windows, directory creation and no-replace renames use extended-length paths -when needed, so storage paths may exceed the legacy 248-character directory and -260-character file limits without enabling a machine-wide long-path setting. -The filesystem's component limits and Windows' extended-length path limit -(about 32,767 UTF-16 code units, including the resolved root and internal file -names) still apply. VM launches have a separate -[working-directory limit](#windows-working-directory-limit). - -### `nvx::host::instance` - -`Instance`: a VM in a sandbox store of its own, which `nvx::host::store`, -`nvx::host::supervisor`, and `nvx::host::session` drive, and in which the caller -keeps the files that it declares as `CallerFiles`. - -Under the store's lifecycle lock of an existing sandbox, `LockedInstance::find` -reattaches to the VM, ends a start that was interrupted, and reports a VM of -another runtime identity as `Mismatched` rather than driving it; -`LockedInstance::start` launches OpenVMM through the supervisor with a fresh -capability, under the caller's `RuntimeIdentity`, waits until the guest is ready -and has mounted the mapped paths, and commits the launch; `LockedInstance::stop` -stops the VM with a forced fallback; `LockedInstance::terminate` stops a -`Mismatched` VM by identity-verified forced termination, without a control -session or its outcome report; and `LockedInstance::remove` removes a stopped -instance, with the caller's files and its directory, through the store. - -`Instance::exec` runs workloads without the lock and pumps their output. - -`write_json` and `read_json` keep the configuration records that callers store -beside an instance. - -An instance's directory is also its VMM's working directory. `check_launch` and -`LockedInstance::start` apply the -[Windows working-directory limit](#windows-working-directory-limit) before -instance creation or launch records, respectively. - ### `nvx::host::nvxs` Compatibility paths for `HostConnection` and its retained cause details, now @@ -1197,16 +1114,15 @@ alone. ### `nvx::host::supervisor` -The supervisor of a sandbox's VMM, beneath the CLI, `Instance`, and external -orchestrators, which keep the configuration, the authorization, and the -application protocol. - -`launch` runs the store's launch steps under the sandbox's lock, as a launch -whose `LaunchId` the caller reserved beforehand, a fresh one for each launch: it -writes the journal's marker, which names the launch, spawns OpenVMM with the -claimed log, records its `ProcessRef` right after, and returns a `Starting` -launch that the caller commits once the VMM serves its control endpoint, or -abandons. A launch that fails never leaves an unrecorded VMM running. +The transitional public path of the low-level spawn of a VMM, which code that +stays uses until SL6.3 (#1122): `spawn` and `spawn_vm`, the `VmmCommand` that +they start and the `Launched` VMM that they return, `Inherited`, and +`spawn_command`. The module re-exports them and has no code of its own: it +supervises nothing. [`nvx::host::vm`](#nvxhostvm) creates, spawns, reattaches +to, and stops a VM in a private directory that its caller owns. SL6.3 deletes +`spawn_command` and the checked-file handoff of `PreparedLaunch`, and settles +the launcher of the `external-launch` example: the last users of this path, +besides the tests of the spawn in `tests/supervisor.rs`. `spawn_vm` starts a caller-owned command without a store, with the same strict Windows log-job requirement as `vm::spawn`: an untrusted job name or a log that @@ -1241,35 +1157,18 @@ and holds them until it exits: the read end of the VMM's capability pipe, its log, and the handles that it allows. The VMM runs in a job object of its own, named after its log, whose processes are the launch's claim while such a process holds the log, so a recovery still ends the launch once the VMM exits. -Each spawn of a VMM holds a `SpawnLock` throughout that window, so a caller that -launches VMMs spawns its other processes with `spawn_command`, which spawns a -`std::process::Command` under one, or under one that `lock_spawns` returns. On -other hosts, the lock holds nothing. - -`Vmm::stop` runs one graceful step of the caller's and waits for the VMM's exit -event until a deadline, and takes a bounded forced step through the VMM's -verified identity only on request. - -`Vmm::stop_unless_exited` stops the VM the same way only if its VMM still runs -as the stop begins, and otherwise stops nothing and keeps the runtime record of -its launch, so that `reattach` finds that the VM exited without its holder. - -`Vmm::terminate` takes that forced step alone, within the caller's bound, for a -VM whose guest the caller must not ask anything, such as one of another runtime -identity. - -`reattach` recovers an interrupted launch, and holds the recorded VMM again -after the orchestrator restarts within the same host boot: `Live`, `Mismatched` -with another runtime identity, `Exited`, or `Indeterminate`. +Each spawn of a VMM holds the crate's spawn lock throughout that window, so a +caller that launches VMMs spawns its other processes with `spawn_command`, +which spawns a `std::process::Command` under it. On other hosts, the lock holds +nothing. #### Windows working-directory limit Windows' process-creation API limits `VmmCommand::working_dir` to 258 UTF-16 code units in its absolute path, including any verbatim prefix and trailing -separator. It may name a shorter directory than the store. A raw `spawn` -refuses an overlong working directory with `InvalidInput`; high-level instance -and capture preflight return `Unsupported` with the limit in the top-level -message. Neither silently changes the requested directory. +separator. It may name a shorter directory than those of the VMM's other files. +A raw `spawn` refuses an overlong working directory with `InvalidInput`, and +never changes the requested directory. ### `nvx::host::seal` @@ -2554,9 +2453,9 @@ before them. [runtime identity](#runtime-identity). `deprovision` removes the state directory. -The state directory is the directory of the sandbox's -[store](src/host/store/mod.rs), the runtime records that every host of a -sandbox shares, and `config.json` is the CLI's own record beside them. It also +The state directory is the CLI's own [store](src/host/cli/store/mod.rs) of the +sandbox, which keeps the runtime records of its VM, and `config.json` is the +CLI's record of the sandbox beside them. It also records the [distro binding](#distro-bindings) and the [scratch binding](#checked-handoff) of the sandbox, and its network as the network options from which each `start` builds the policy again, as `provision` @@ -2585,21 +2484,22 @@ whole transition, and `exec` only while it finds the running VM, so that a long workload cannot delay a `stop`. An `exec` that finds another holding `exec.lock` releases the store's lock and tries again until `--wait` seconds, 60 by default, have passed, and then fails with `sandbox-busy`; `stop` takes no -exec lock. The CLI takes the store's -[lifecycle locks](src/host/store/mod.rs) with `config.json` as its +exec lock. The CLI takes the lifecycle locks of its own +[store](src/host/cli/store/mod.rs) with `config.json` as its configuration record: a transition that leaves the state directory without it, such as one of a sandbox that was never provisioned, removes the lock file as it releases the lock, and `deprovision` removes the sandbox through the store, which removes the store's files and `config.json`, refuses files that are not NVX's, and removes the lock file, while it still holds the lock, before the -directory. Every transition but `provision` finds the -sandbox's VM with `nvx::host::supervisor::reattach`, as an orchestrator does -after it restarts within the same boot of the host: it ends a start that was -interrupted, holds an OpenVMM that still runs through its verified process -identity, and clears the runtime record of one that exited without a `stop`. +directory. Taking the lock ends a start that was interrupted. Every transition +but `provision` then finds the sandbox's VM in the store's runtime record, as an +orchestrator does after it restarts within the same boot of the host: it holds +an OpenVMM that still runs through its verified process identity, and clears +the runtime record of one that exited without a `stop`. The first `exec` or `stop` to find such a record fails because the runtime state is stale, while `start` launches OpenVMM again and `deprovision` removes -the sandbox. `start` launches OpenVMM with `nvx::host::supervisor::launch`, and +the sandbox. `start` launches OpenVMM with `nvx::host::vm::create` and +`nvx::host::vm::spawn`, through the store's launch journal, and `stop` asks the guest to shut down through the control session, waits up to `--timeout` seconds for OpenVMM to exit, and reports one that does not, which keeps running: it never ends OpenVMM otherwise, except one of another @@ -2738,7 +2638,7 @@ guest agents of NVX advertise bits 0, 4, and 6 in sandbox mode. code page unless `PYTHONIOENCODING` or UTF-8 mode says otherwise. - `start` refuses a control endpoint that another process serves, which it treats as one that has not appeared yet, and, on Windows, starts OpenVMM - without a console window, as `nvx::host::supervisor` starts it. + without a console window, as `nvx::host::vm::spawn` starts it. - `start` stops waiting for the control endpoint once the OpenVMM that it launched exits, as `nvx::host::session::connect` does, and fails at once with `exited`, where `nvx.py` waited until `--timeout` passed before it @@ -2893,52 +2793,72 @@ nothing but `serde` when `serde` is enabled and `nix` and `getrandom` when ## Example +A cold boot through `nvx::host::vm`, in a private directory that the caller +owns, and the VM's stop. +[`examples/host-workflows.rs`](examples/host-workflows.rs) also captures a VM, +restores one, and finds one again after its caller restarts. + ```rust,no_run -use std::sync::atomic::AtomicBool; -use std::time::Duration; +use std::path::Path; +use std::time::{Duration, Instant}; -use nvx::host::instance::{Found, Instance}; -use nvx::host::launch::LaunchSpec; -use nvx::host::store::CallerFiles; -use nvx::protocol::{Workload, WorkloadEnvironment}; +use nvx::activation::{MachineIdMode, View}; +use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; +use nvx::host::runtime::{LaunchOptions, Runtime}; +use nvx::host::vm::{self, VmDir}; use nvx::{Artifacts, Hypervisor}; fn main() -> Result<(), Box> { + // Locates OpenVMM, the guest kernel, and the Alpine initramfs, with the identity that the + // install metadata of their release or checkout gives them. let artifacts = Artifacts::discover()?; - let runtime = artifacts.runtime_identity()?; - let spec = LaunchSpec::new(artifacts, Hypervisor::from_env_or_default()?); - // The example keeps no files of its own beside the instance's store. - let directory = std::env::temp_dir().join("nvx-example"); - let instance = Instance::create(directory, "example", CallerFiles::none())?; - // Lifecycle transitions hold the instance's lock; executions need none. - let mut locked = instance.lock(None)?; - let Found::Stopped = locked.find(|| Ok(runtime.clone()))? else { - return Err("the example's VM runs already".into()); - }; - locked.start(&spec, &runtime, Duration::from_secs(60))?; - let Found::Running(running) = locked.find(|| Ok(runtime.clone()))? else { - return Err("the example's VM stopped".into()); - }; - - let workload = Workload { - argv: vec!["/bin/echo".to_owned(), "hello".to_owned()], - timeout_ms: 0, - cwd: None, - environment: WorkloadEnvironment::Default, + let runtime = Runtime { + identity: artifacts.runtime_identity()?, + openvmm: artifacts.openvmm, + kernel: artifacts.kernel, + initramfs: artifacts.initrd, + hypervisor: Hypervisor::from_env_or_default()?, + cpu_profile: None, + windows_breakaway: false, + options: LaunchOptions::default(), }; - let timeout = Duration::from_secs(60); - let execution = instance.exec(&running, &workload, timeout, timeout)?; - let mut stdout = Vec::new(); - let outcome = execution.pump( - &mut |chunk: &[u8]| { - stdout.extend_from_slice(chunk); - Ok(()) + let config = VmConfig { + processors: 1, + memory_mib: 256, + root: RootConfig::Blockless, + view: View { + hostname: b"example".to_vec(), + machine_id: MachineIdMode::Image, }, - &mut |_: &[u8]| Ok(()), - &AtomicBool::new(false), + network: None, + filesystem: None, + console: ConsoleAttachment::Disconnected, + boot: Some(Boot { + command_line: "quiet".to_owned(), + memory_capacity_mib: None, + capture: None, + }), + }; + // The caller creates and owns the VM's private directory. + let directory = Path::new("example-vm"); + vm::create_private_dir(directory)?; + let dir = VmDir::open(directory)?; + // Creation checks the whole launch and creates its log, but starts nothing; the spawn starts + // OpenVMM detached, and returns its verified process. + let launched = vm::spawn(vm::create(&runtime, &config, None, &dir)?)?; + // The caller speaks its own protocol with its guest at `launched.outputs.endpoint`, with the + // secret `launched.capability`. To stop the VM, the callback would ask the guest in that + // protocol to end it, and return whether it sent the request; this one sends none, so the + // stop ends OpenVMM's verified process once the grace period has passed, unless it exited. + let grace = Instant::now() + Duration::from_secs(10); + let stopped = vm::stop( + &launched.process, + &launched.outputs, + |_| false, + grace, + grace + Duration::from_secs(10), )?; - println!("{outcome:?}: {}", String::from_utf8_lossy(&stdout)); - locked.stop(running, Duration::from_secs(30))?; + println!("forced: {}, report: {:?}", stopped.forced, stopped.report?); Ok(()) } ``` @@ -2962,9 +2882,6 @@ optional cause. | `RuntimeMismatch` | A VM that runs under another runtime identity than the artifacts', which NVX does not drive: only a stop, which terminates it, applies | | `Other` | Any other failure | -Messages call an instance `sandbox NAME`, after the name that `Instance::create` -or `Instance::open` gives it. - ### Error codes An `ErrorCode` is finer than an `ErrorKind`: callers tell errors apart by their @@ -3149,8 +3066,8 @@ tests, only the test hooks that [`test-layout-allowlist.json`](../scripts/test-layout-allowlist.json) allows, as [Test layout](../doc/contribute.md#test-layout) describes. -The tests need no hypervisor. The fake-OpenVMM test drives `Instance` end to end -against `nvx-fake-openvmm`. The CLI test runs the +The tests need no hypervisor. The fake-OpenVMM test runs `nvx-fake-openvmm` +directly, as `nvx-rs run` runs OpenVMM on the terminal. The CLI test runs the [scenario tests](#scenario-tests) of `nvx-rs`, and checks the guest-feature requirement and, on Linux, Ctrl-C. The [conformance vectors](tests/vectors/README.md) of the wire formats cover both @@ -3169,10 +3086,8 @@ egress policy compiler compare it with a reference model of the format ([Egress policy files](#egress-policy-files)). The NVXS test drives the host's and the guest's roles of the session and their connections through a broker that relays records as OpenVMM's does, with messages of an application -framing of its own that span records. The store test crashes orchestrator -processes at each step of a launch, recovers the VMM stand-ins that they leave -behind, and loads records that another build wrote, in `tests/fixtures/store/`. -The host-module test reads the crate's sources and fails if the direct children +framing of its own that span records. The host-module test reads the crate's +sources and fails if the direct children of `nvx::host` depend on one another in a cycle through the paths of their production code, outside comments and test-only code. It lists the shortest cycle through each dependency on one, with a reference for each of its @@ -3180,11 +3095,12 @@ 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-supervision test crashes orchestrator processes at each step of a -launch through the supervisor, reattaches to the VMM stand-ins that they leave -behind and stops them, 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 tests of `nvx::exec` drive a runner and a peer through a +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 tests of `nvx::exec` drive a runner and a +peer through a simulated workload, transport, and sink on a clock of their own, and, on Linux, the tests of `nvx::guest::exec::binding` drive real programs through them, two at once in one loop, one of them with a stalled sink; run as root, one of them diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 0bdbcb645..408037061 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -165,10 +165,6 @@ variant nvx::activation::Field::Restore variant nvx::activation::Field::Upper method nvx::activation::Field::from_tag method nvx::activation::Field::tag -struct nvx::activation::Identity -const nvx::activation::Identity::LEN -method nvx::activation::Identity::as_bytes -method nvx::activation::Identity::from_bytes enum nvx::activation::Image variant nvx::activation::Image::Erofs field nvx::activation::Image::Erofs::uuid @@ -206,12 +202,6 @@ method nvx::activation::ReadyActivation::of field nvx::activation::ReadyActivation::transaction struct nvx::activation::RestoreSection field nvx::activation::RestoreSection::transaction -struct nvx::activation::StoreGeneration -method nvx::activation::StoreGeneration::get -method nvx::activation::StoreGeneration::new -struct nvx::activation::TemplateReference -field nvx::activation::TemplateReference::generation -field nvx::activation::TemplateReference::key struct nvx::activation::TransactionId const nvx::activation::TransactionId::LEN method nvx::activation::TransactionId::as_bytes @@ -2203,66 +2193,6 @@ field nvx::host::filesystem::Target::Guest::0 fn nvx::host::filesystem::guest_path fn nvx::host::filesystem::plan fn nvx::host::filesystem::resolve_guest_path -mod nvx::host::instance -struct nvx::host::instance::Execution -method nvx::host::instance::Execution::pump -enum nvx::host::instance::Found -variant nvx::host::instance::Found::Mismatched -field nvx::host::instance::Found::Mismatched::0 -variant nvx::host::instance::Found::Running -field nvx::host::instance::Found::Running::0 -variant nvx::host::instance::Found::Stopped -struct nvx::host::instance::Instance -method nvx::host::instance::Instance::create -method nvx::host::instance::Instance::dir -method nvx::host::instance::Instance::exec -method nvx::host::instance::Instance::files -method nvx::host::instance::Instance::lock -method nvx::host::instance::Instance::log_path -method nvx::host::instance::Instance::name -method nvx::host::instance::Instance::open -method nvx::host::instance::Instance::report_path -method nvx::host::instance::Instance::store -struct nvx::host::instance::LockedInstance -method nvx::host::instance::LockedInstance::find -method nvx::host::instance::LockedInstance::instance -method nvx::host::instance::LockedInstance::remove -method nvx::host::instance::LockedInstance::start -method nvx::host::instance::LockedInstance::stop -method nvx::host::instance::LockedInstance::terminate -struct nvx::host::instance::Mismatched -method nvx::host::instance::Mismatched::differences -method nvx::host::instance::Mismatched::error -method nvx::host::instance::Mismatched::vmm -trait nvx::host::instance::OutputSink -method nvx::host::instance::OutputSink::write -struct nvx::host::instance::Running -method nvx::host::instance::Running::runtime -method nvx::host::instance::Running::vmm -struct nvx::host::instance::Started -field nvx::host::instance::Started::boot -field nvx::host::instance::Started::runtime -struct nvx::host::instance::Stopped -field nvx::host::instance::Stopped::forced -field nvx::host::instance::Stopped::report -enum nvx::host::instance::WorkloadOutcome -variant nvx::host::instance::WorkloadOutcome::Cancelled -variant nvx::host::instance::WorkloadOutcome::Exited -field nvx::host::instance::WorkloadOutcome::Exited::0 -variant nvx::host::instance::WorkloadOutcome::Failed -variant nvx::host::instance::WorkloadOutcome::LaunchFailed -variant nvx::host::instance::WorkloadOutcome::OutputLimitExceeded -variant nvx::host::instance::WorkloadOutcome::Signaled -field nvx::host::instance::WorkloadOutcome::Signaled::0 -variant nvx::host::instance::WorkloadOutcome::TimedOut -variant nvx::host::instance::WorkloadOutcome::WorkingDirectory -fn nvx::host::instance::check_launch -fn nvx::host::instance::generate_capability -fn nvx::host::instance::read_json -fn nvx::host::instance::remove_if_present -fn nvx::host::instance::remove_interrupted_writes -fn nvx::host::instance::write_json -fn nvx::host::instance::write_private mod nvx::host::launch const nvx::host::launch::COMMAND_LINE_BUDGET const nvx::host::launch::DEFAULT_GUEST_NETWORK @@ -3127,135 +3057,6 @@ field nvx::host::snapshot::TimeContract::lineage field nvx::host::snapshot::TimeContract::tsc_frequency_hz field nvx::host::snapshot::TimeContract::tsc_tolerance_ppm fn nvx::host::snapshot::finish_capture -mod nvx::host::store -struct nvx::host::store::ArtifactIdentity -method nvx::host::store::ArtifactIdentity::as_str -method nvx::host::store::ArtifactIdentity::new -method nvx::host::store::ArtifactIdentity::sha256 -const nvx::host::store::CAPABILITY_NAME -const nvx::host::store::CLONE_ACTIVATION_NAME -const nvx::host::store::CLONE_BINDING_NAME -const nvx::host::store::CLONE_FAILURE_NAME -const nvx::host::store::CLONE_PROGRESS_NAME -const nvx::host::store::CLONE_SCRATCH_NAME -const nvx::host::store::CONSOLE_NAME -struct nvx::host::store::CallerFiles -method nvx::host::store::CallerFiles::config -method nvx::host::store::CallerFiles::keeping_console -method nvx::host::store::CallerFiles::keeps_console -method nvx::host::store::CallerFiles::new -method nvx::host::store::CallerFiles::none -method nvx::host::store::CallerFiles::others -method nvx::host::store::CallerFiles::with -const nvx::host::store::FILE_NAMES -const nvx::host::store::FORMAT -enum nvx::host::store::IdentityCheck -variant nvx::host::store::IdentityCheck::Matches -variant nvx::host::store::IdentityCheck::Mismatched -field nvx::host::store::IdentityCheck::Mismatched::0 -enum nvx::host::store::IdentityPart -const nvx::host::store::IdentityPart::ALL -variant nvx::host::store::IdentityPart::BootContract -variant nvx::host::store::IdentityPart::ControlContract -variant nvx::host::store::IdentityPart::ControlSession -variant nvx::host::store::IdentityPart::Initramfs -variant nvx::host::store::IdentityPart::Kernel -variant nvx::host::store::IdentityPart::OpenVmm -method nvx::host::store::IdentityPart::name -const nvx::host::store::JOURNAL_NAME -const nvx::host::store::LOCK_NAME -const nvx::host::store::LOG_NAME -struct nvx::host::store::Launch -method nvx::host::store::Launch::abandon -method nvx::host::store::Launch::commit -method nvx::host::store::Launch::endpoint -method nvx::host::store::Launch::journal -method nvx::host::store::Launch::record_process -method nvx::host::store::Launch::take_log -struct nvx::host::store::LaunchId -method nvx::host::store::LaunchId::as_str -method nvx::host::store::LaunchId::generate -struct nvx::host::store::LaunchJournal -method nvx::host::store::LaunchJournal::endpoint -method nvx::host::store::LaunchJournal::launch -method nvx::host::store::LaunchJournal::log -method nvx::host::store::LaunchJournal::process -method nvx::host::store::LaunchJournal::runtime -method nvx::host::store::LaunchJournal::state -enum nvx::host::store::LaunchState -variant nvx::host::store::LaunchState::Prepared -variant nvx::host::store::LaunchState::Spawned -struct nvx::host::store::Lingering -method nvx::host::store::Lingering::dir -method nvx::host::store::Lingering::wait -struct nvx::host::store::LockedStore -method nvx::host::store::LockedStore::begin_launch -method nvx::host::store::LockedStore::clear_runtime -method nvx::host::store::LockedStore::files -method nvx::host::store::LockedStore::recover -method nvx::host::store::LockedStore::remove -struct nvx::host::store::LogClaim -field nvx::host::store::LogClaim::identity -const nvx::host::store::MAX_ENDPOINT_LEN -const nvx::host::store::MAX_IDENTITY_LEN -const nvx::host::store::OUTCOME_NAME -const nvx::host::store::RESTORE_READY_NAME -const nvx::host::store::RUNTIME_NAME -enum nvx::host::store::Recovery -variant nvx::host::store::Recovery::Clean -variant nvx::host::store::Recovery::Exited -variant nvx::host::store::Recovery::Indeterminate -field nvx::host::store::Recovery::Indeterminate::0 -variant nvx::host::store::Recovery::Terminated -field nvx::host::store::Recovery::Terminated::0 -enum nvx::host::store::RemovalError -variant nvx::host::store::RemovalError::Foreign -field nvx::host::store::RemovalError::Foreign::0 -variant nvx::host::store::RemovalError::Io -field nvx::host::store::RemovalError::Io::error -field nvx::host::store::RemovalError::Io::path -variant nvx::host::store::RemovalError::Launched -variant nvx::host::store::RemovalError::ProvisionedAgain -variant nvx::host::store::RemovalError::TimedOut -field nvx::host::store::RemovalError::TimedOut::0 -variant nvx::host::store::RemovalError::Undeclared -struct nvx::host::store::RuntimeIdentity -field nvx::host::store::RuntimeIdentity::boot_contract -method nvx::host::store::RuntimeIdentity::check -field nvx::host::store::RuntimeIdentity::control_contract -field nvx::host::store::RuntimeIdentity::control_session -method nvx::host::store::RuntimeIdentity::differences -field nvx::host::store::RuntimeIdentity::initramfs -field nvx::host::store::RuntimeIdentity::kernel -method nvx::host::store::RuntimeIdentity::new -field nvx::host::store::RuntimeIdentity::openvmm -struct nvx::host::store::RuntimeRecord -method nvx::host::store::RuntimeRecord::check_identity -method nvx::host::store::RuntimeRecord::endpoint -method nvx::host::store::RuntimeRecord::launch -method nvx::host::store::RuntimeRecord::process -method nvx::host::store::RuntimeRecord::runtime -const nvx::host::store::SOCKET_NAME -struct nvx::host::store::SandboxStore -method nvx::host::store::SandboxStore::binding_path -method nvx::host::store::SandboxStore::capability_path -method nvx::host::store::SandboxStore::clone_activation_path -method nvx::host::store::SandboxStore::clone_failure_path -method nvx::host::store::SandboxStore::clone_progress_path -method nvx::host::store::SandboxStore::console_path -method nvx::host::store::SandboxStore::create -method nvx::host::store::SandboxStore::dir -method nvx::host::store::SandboxStore::journal -method nvx::host::store::SandboxStore::lock -method nvx::host::store::SandboxStore::lock_existing -method nvx::host::store::SandboxStore::lock_for_provision -method nvx::host::store::SandboxStore::log_path -method nvx::host::store::SandboxStore::open -method nvx::host::store::SandboxStore::outcome_path -method nvx::host::store::SandboxStore::read_capability -method nvx::host::store::SandboxStore::restore_ready_path -method nvx::host::store::SandboxStore::runtime -method nvx::host::store::SandboxStore::socket_path mod nvx::host::supervisor struct nvx::host::supervisor::Inherited method nvx::host::supervisor::Inherited::new @@ -3267,40 +3068,6 @@ method nvx::host::supervisor::Launched::kill_and_confirm_exit method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process method nvx::host::supervisor::Launched::wait_timeout -enum nvx::host::supervisor::Reattached -variant nvx::host::supervisor::Reattached::Exited -field nvx::host::supervisor::Reattached::Exited::record -field nvx::host::supervisor::Reattached::Exited::recovery -variant nvx::host::supervisor::Reattached::Indeterminate -field nvx::host::supervisor::Reattached::Indeterminate::0 -variant nvx::host::supervisor::Reattached::Live -field nvx::host::supervisor::Reattached::Live::0 -variant nvx::host::supervisor::Reattached::Mismatched -field nvx::host::supervisor::Reattached::Mismatched::differences -field nvx::host::supervisor::Reattached::Mismatched::vmm -struct nvx::host::supervisor::SpawnLock -struct nvx::host::supervisor::Starting -method nvx::host::supervisor::Starting::abandon -method nvx::host::supervisor::Starting::commit -method nvx::host::supervisor::Starting::endpoint -method nvx::host::supervisor::Starting::journal -method nvx::host::supervisor::Starting::process -enum nvx::host::supervisor::StopOutcome -variant nvx::host::supervisor::StopOutcome::Forced -variant nvx::host::supervisor::StopOutcome::Graceful -variant nvx::host::supervisor::StopOutcome::TimedOut -struct nvx::host::supervisor::Stopped -field nvx::host::supervisor::Stopped::graceful_error -field nvx::host::supervisor::Stopped::outcome -struct nvx::host::supervisor::Vmm -method nvx::host::supervisor::Vmm::endpoint -method nvx::host::supervisor::Vmm::has_exited -method nvx::host::supervisor::Vmm::process -method nvx::host::supervisor::Vmm::record -method nvx::host::supervisor::Vmm::stop -method nvx::host::supervisor::Vmm::stop_unless_exited -method nvx::host::supervisor::Vmm::terminate -method nvx::host::supervisor::Vmm::wait struct nvx::host::supervisor::VmmCommand field nvx::host::supervisor::VmmCommand::arguments field nvx::host::supervisor::VmmCommand::breakaway_from_job @@ -3308,9 +3075,6 @@ field nvx::host::supervisor::VmmCommand::inherit method nvx::host::supervisor::VmmCommand::new field nvx::host::supervisor::VmmCommand::program field nvx::host::supervisor::VmmCommand::working_dir -fn nvx::host::supervisor::launch -fn nvx::host::supervisor::lock_spawns -fn nvx::host::supervisor::reattach fn nvx::host::supervisor::spawn fn nvx::host::supervisor::spawn_command fn nvx::host::supervisor::spawn_vm diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index 20edbeac3..ae29c4eee 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -165,10 +165,6 @@ variant nvx::activation::Field::Restore variant nvx::activation::Field::Upper method nvx::activation::Field::from_tag method nvx::activation::Field::tag -struct nvx::activation::Identity -const nvx::activation::Identity::LEN -method nvx::activation::Identity::as_bytes -method nvx::activation::Identity::from_bytes enum nvx::activation::Image variant nvx::activation::Image::Erofs field nvx::activation::Image::Erofs::uuid @@ -206,12 +202,6 @@ method nvx::activation::ReadyActivation::of field nvx::activation::ReadyActivation::transaction struct nvx::activation::RestoreSection field nvx::activation::RestoreSection::transaction -struct nvx::activation::StoreGeneration -method nvx::activation::StoreGeneration::get -method nvx::activation::StoreGeneration::new -struct nvx::activation::TemplateReference -field nvx::activation::TemplateReference::generation -field nvx::activation::TemplateReference::key struct nvx::activation::TransactionId const nvx::activation::TransactionId::LEN method nvx::activation::TransactionId::as_bytes @@ -806,66 +796,6 @@ field nvx::host::filesystem::Target::Guest::0 fn nvx::host::filesystem::guest_path fn nvx::host::filesystem::plan fn nvx::host::filesystem::resolve_guest_path -mod nvx::host::instance -struct nvx::host::instance::Execution -method nvx::host::instance::Execution::pump -enum nvx::host::instance::Found -variant nvx::host::instance::Found::Mismatched -field nvx::host::instance::Found::Mismatched::0 -variant nvx::host::instance::Found::Running -field nvx::host::instance::Found::Running::0 -variant nvx::host::instance::Found::Stopped -struct nvx::host::instance::Instance -method nvx::host::instance::Instance::create -method nvx::host::instance::Instance::dir -method nvx::host::instance::Instance::exec -method nvx::host::instance::Instance::files -method nvx::host::instance::Instance::lock -method nvx::host::instance::Instance::log_path -method nvx::host::instance::Instance::name -method nvx::host::instance::Instance::open -method nvx::host::instance::Instance::report_path -method nvx::host::instance::Instance::store -struct nvx::host::instance::LockedInstance -method nvx::host::instance::LockedInstance::find -method nvx::host::instance::LockedInstance::instance -method nvx::host::instance::LockedInstance::remove -method nvx::host::instance::LockedInstance::start -method nvx::host::instance::LockedInstance::stop -method nvx::host::instance::LockedInstance::terminate -struct nvx::host::instance::Mismatched -method nvx::host::instance::Mismatched::differences -method nvx::host::instance::Mismatched::error -method nvx::host::instance::Mismatched::vmm -trait nvx::host::instance::OutputSink -method nvx::host::instance::OutputSink::write -struct nvx::host::instance::Running -method nvx::host::instance::Running::runtime -method nvx::host::instance::Running::vmm -struct nvx::host::instance::Started -field nvx::host::instance::Started::boot -field nvx::host::instance::Started::runtime -struct nvx::host::instance::Stopped -field nvx::host::instance::Stopped::forced -field nvx::host::instance::Stopped::report -enum nvx::host::instance::WorkloadOutcome -variant nvx::host::instance::WorkloadOutcome::Cancelled -variant nvx::host::instance::WorkloadOutcome::Exited -field nvx::host::instance::WorkloadOutcome::Exited::0 -variant nvx::host::instance::WorkloadOutcome::Failed -variant nvx::host::instance::WorkloadOutcome::LaunchFailed -variant nvx::host::instance::WorkloadOutcome::OutputLimitExceeded -variant nvx::host::instance::WorkloadOutcome::Signaled -field nvx::host::instance::WorkloadOutcome::Signaled::0 -variant nvx::host::instance::WorkloadOutcome::TimedOut -variant nvx::host::instance::WorkloadOutcome::WorkingDirectory -fn nvx::host::instance::check_launch -fn nvx::host::instance::generate_capability -fn nvx::host::instance::read_json -fn nvx::host::instance::remove_if_present -fn nvx::host::instance::remove_interrupted_writes -fn nvx::host::instance::write_json -fn nvx::host::instance::write_private mod nvx::host::launch const nvx::host::launch::COMMAND_LINE_BUDGET const nvx::host::launch::DEFAULT_GUEST_NETWORK @@ -1730,135 +1660,6 @@ field nvx::host::snapshot::TimeContract::lineage field nvx::host::snapshot::TimeContract::tsc_frequency_hz field nvx::host::snapshot::TimeContract::tsc_tolerance_ppm fn nvx::host::snapshot::finish_capture -mod nvx::host::store -struct nvx::host::store::ArtifactIdentity -method nvx::host::store::ArtifactIdentity::as_str -method nvx::host::store::ArtifactIdentity::new -method nvx::host::store::ArtifactIdentity::sha256 -const nvx::host::store::CAPABILITY_NAME -const nvx::host::store::CLONE_ACTIVATION_NAME -const nvx::host::store::CLONE_BINDING_NAME -const nvx::host::store::CLONE_FAILURE_NAME -const nvx::host::store::CLONE_PROGRESS_NAME -const nvx::host::store::CLONE_SCRATCH_NAME -const nvx::host::store::CONSOLE_NAME -struct nvx::host::store::CallerFiles -method nvx::host::store::CallerFiles::config -method nvx::host::store::CallerFiles::keeping_console -method nvx::host::store::CallerFiles::keeps_console -method nvx::host::store::CallerFiles::new -method nvx::host::store::CallerFiles::none -method nvx::host::store::CallerFiles::others -method nvx::host::store::CallerFiles::with -const nvx::host::store::FILE_NAMES -const nvx::host::store::FORMAT -enum nvx::host::store::IdentityCheck -variant nvx::host::store::IdentityCheck::Matches -variant nvx::host::store::IdentityCheck::Mismatched -field nvx::host::store::IdentityCheck::Mismatched::0 -enum nvx::host::store::IdentityPart -const nvx::host::store::IdentityPart::ALL -variant nvx::host::store::IdentityPart::BootContract -variant nvx::host::store::IdentityPart::ControlContract -variant nvx::host::store::IdentityPart::ControlSession -variant nvx::host::store::IdentityPart::Initramfs -variant nvx::host::store::IdentityPart::Kernel -variant nvx::host::store::IdentityPart::OpenVmm -method nvx::host::store::IdentityPart::name -const nvx::host::store::JOURNAL_NAME -const nvx::host::store::LOCK_NAME -const nvx::host::store::LOG_NAME -struct nvx::host::store::Launch -method nvx::host::store::Launch::abandon -method nvx::host::store::Launch::commit -method nvx::host::store::Launch::endpoint -method nvx::host::store::Launch::journal -method nvx::host::store::Launch::record_process -method nvx::host::store::Launch::take_log -struct nvx::host::store::LaunchId -method nvx::host::store::LaunchId::as_str -method nvx::host::store::LaunchId::generate -struct nvx::host::store::LaunchJournal -method nvx::host::store::LaunchJournal::endpoint -method nvx::host::store::LaunchJournal::launch -method nvx::host::store::LaunchJournal::log -method nvx::host::store::LaunchJournal::process -method nvx::host::store::LaunchJournal::runtime -method nvx::host::store::LaunchJournal::state -enum nvx::host::store::LaunchState -variant nvx::host::store::LaunchState::Prepared -variant nvx::host::store::LaunchState::Spawned -struct nvx::host::store::Lingering -method nvx::host::store::Lingering::dir -method nvx::host::store::Lingering::wait -struct nvx::host::store::LockedStore -method nvx::host::store::LockedStore::begin_launch -method nvx::host::store::LockedStore::clear_runtime -method nvx::host::store::LockedStore::files -method nvx::host::store::LockedStore::recover -method nvx::host::store::LockedStore::remove -struct nvx::host::store::LogClaim -field nvx::host::store::LogClaim::identity -const nvx::host::store::MAX_ENDPOINT_LEN -const nvx::host::store::MAX_IDENTITY_LEN -const nvx::host::store::OUTCOME_NAME -const nvx::host::store::RESTORE_READY_NAME -const nvx::host::store::RUNTIME_NAME -enum nvx::host::store::Recovery -variant nvx::host::store::Recovery::Clean -variant nvx::host::store::Recovery::Exited -variant nvx::host::store::Recovery::Indeterminate -field nvx::host::store::Recovery::Indeterminate::0 -variant nvx::host::store::Recovery::Terminated -field nvx::host::store::Recovery::Terminated::0 -enum nvx::host::store::RemovalError -variant nvx::host::store::RemovalError::Foreign -field nvx::host::store::RemovalError::Foreign::0 -variant nvx::host::store::RemovalError::Io -field nvx::host::store::RemovalError::Io::error -field nvx::host::store::RemovalError::Io::path -variant nvx::host::store::RemovalError::Launched -variant nvx::host::store::RemovalError::ProvisionedAgain -variant nvx::host::store::RemovalError::TimedOut -field nvx::host::store::RemovalError::TimedOut::0 -variant nvx::host::store::RemovalError::Undeclared -struct nvx::host::store::RuntimeIdentity -field nvx::host::store::RuntimeIdentity::boot_contract -method nvx::host::store::RuntimeIdentity::check -field nvx::host::store::RuntimeIdentity::control_contract -field nvx::host::store::RuntimeIdentity::control_session -method nvx::host::store::RuntimeIdentity::differences -field nvx::host::store::RuntimeIdentity::initramfs -field nvx::host::store::RuntimeIdentity::kernel -method nvx::host::store::RuntimeIdentity::new -field nvx::host::store::RuntimeIdentity::openvmm -struct nvx::host::store::RuntimeRecord -method nvx::host::store::RuntimeRecord::check_identity -method nvx::host::store::RuntimeRecord::endpoint -method nvx::host::store::RuntimeRecord::launch -method nvx::host::store::RuntimeRecord::process -method nvx::host::store::RuntimeRecord::runtime -const nvx::host::store::SOCKET_NAME -struct nvx::host::store::SandboxStore -method nvx::host::store::SandboxStore::binding_path -method nvx::host::store::SandboxStore::capability_path -method nvx::host::store::SandboxStore::clone_activation_path -method nvx::host::store::SandboxStore::clone_failure_path -method nvx::host::store::SandboxStore::clone_progress_path -method nvx::host::store::SandboxStore::console_path -method nvx::host::store::SandboxStore::create -method nvx::host::store::SandboxStore::dir -method nvx::host::store::SandboxStore::journal -method nvx::host::store::SandboxStore::lock -method nvx::host::store::SandboxStore::lock_existing -method nvx::host::store::SandboxStore::lock_for_provision -method nvx::host::store::SandboxStore::log_path -method nvx::host::store::SandboxStore::open -method nvx::host::store::SandboxStore::outcome_path -method nvx::host::store::SandboxStore::read_capability -method nvx::host::store::SandboxStore::restore_ready_path -method nvx::host::store::SandboxStore::runtime -method nvx::host::store::SandboxStore::socket_path mod nvx::host::supervisor struct nvx::host::supervisor::Inherited method nvx::host::supervisor::Inherited::new @@ -1869,40 +1670,6 @@ method nvx::host::supervisor::Launched::kill_and_confirm_exit method nvx::host::supervisor::Launched::kill_and_wait method nvx::host::supervisor::Launched::process method nvx::host::supervisor::Launched::wait_timeout -enum nvx::host::supervisor::Reattached -variant nvx::host::supervisor::Reattached::Exited -field nvx::host::supervisor::Reattached::Exited::record -field nvx::host::supervisor::Reattached::Exited::recovery -variant nvx::host::supervisor::Reattached::Indeterminate -field nvx::host::supervisor::Reattached::Indeterminate::0 -variant nvx::host::supervisor::Reattached::Live -field nvx::host::supervisor::Reattached::Live::0 -variant nvx::host::supervisor::Reattached::Mismatched -field nvx::host::supervisor::Reattached::Mismatched::differences -field nvx::host::supervisor::Reattached::Mismatched::vmm -struct nvx::host::supervisor::SpawnLock -struct nvx::host::supervisor::Starting -method nvx::host::supervisor::Starting::abandon -method nvx::host::supervisor::Starting::commit -method nvx::host::supervisor::Starting::endpoint -method nvx::host::supervisor::Starting::journal -method nvx::host::supervisor::Starting::process -enum nvx::host::supervisor::StopOutcome -variant nvx::host::supervisor::StopOutcome::Forced -variant nvx::host::supervisor::StopOutcome::Graceful -variant nvx::host::supervisor::StopOutcome::TimedOut -struct nvx::host::supervisor::Stopped -field nvx::host::supervisor::Stopped::graceful_error -field nvx::host::supervisor::Stopped::outcome -struct nvx::host::supervisor::Vmm -method nvx::host::supervisor::Vmm::endpoint -method nvx::host::supervisor::Vmm::has_exited -method nvx::host::supervisor::Vmm::process -method nvx::host::supervisor::Vmm::record -method nvx::host::supervisor::Vmm::stop -method nvx::host::supervisor::Vmm::stop_unless_exited -method nvx::host::supervisor::Vmm::terminate -method nvx::host::supervisor::Vmm::wait struct nvx::host::supervisor::VmmCommand field nvx::host::supervisor::VmmCommand::arguments field nvx::host::supervisor::VmmCommand::breakaway_from_job @@ -1910,9 +1677,6 @@ field nvx::host::supervisor::VmmCommand::inherit method nvx::host::supervisor::VmmCommand::new field nvx::host::supervisor::VmmCommand::program field nvx::host::supervisor::VmmCommand::working_dir -fn nvx::host::supervisor::launch -fn nvx::host::supervisor::lock_spawns -fn nvx::host::supervisor::reattach fn nvx::host::supervisor::spawn fn nvx::host::supervisor::spawn_command fn nvx::host::supervisor::spawn_vm diff --git a/nvx-rs/src/activation/mod.rs b/nvx-rs/src/activation/mod.rs index 215233bea..2ff239990 100644 --- a/nvx-rs/src/activation/mod.rs +++ b/nvx-rs/src/activation/mod.rs @@ -167,7 +167,6 @@ use std::fmt; use std::net::IpAddr; -use std::num::NonZeroU64; use crate::protocol::MAX_HOST_MAPPINGS; use crate::protocol::root::ImageFormat; @@ -197,40 +196,6 @@ pub const MAX_HOSTNAME_BYTES: usize = 64; /// The longest proxy endpoint, in bytes. pub const MAX_PROXY_ENDPOINT_BYTES: usize = 255; -/// The identity of a stored object's canonical form: 32 bytes that the host computes, such as a -/// SHA-256 digest of a template's key. Activation records do not encode store identities. -/// -/// It displays as 64 lowercase hexadecimal digits. -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub struct Identity([u8; 32]); - -impl Identity { - /// The length of an identity, in bytes. - pub const LEN: usize = 32; - - /// Returns the identity whose bytes are `bytes`. - pub const fn from_bytes(bytes: [u8; Self::LEN]) -> Self { - Self(bytes) - } - - /// Returns the identity's bytes. - pub const fn as_bytes(&self) -> &[u8; Self::LEN] { - &self.0 - } -} - -impl fmt::Display for Identity { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write_hex(formatter, &self.0) - } -} - -impl fmt::Debug for Identity { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "Identity({self})") - } -} - /// The transaction ID of a clone's restore: 16 random bytes that the host picks for each restore /// and keeps across the restore's retries, which the clone's Ready reports. /// @@ -265,43 +230,6 @@ impl fmt::Debug for TransactionId { } } -/// The generation of a template in its store: the template's publication ID, a nonzero number -/// that the store assigns to each template that it publishes, in the order of publication, and -/// never reuses. -/// -/// Two generations of one store compare in the order of their publication. A generation displays -/// as its decimal number. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct StoreGeneration(NonZeroU64); - -impl StoreGeneration { - /// Returns the store generation `generation`. - pub const fn new(generation: NonZeroU64) -> Self { - Self(generation) - } - - /// Returns the store generation's number. - pub const fn get(self) -> NonZeroU64 { - self.0 - } -} - -impl fmt::Display for StoreGeneration { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}", self.0) - } -} - -/// The template that a clone restores: the generation of the template in its store, and the -/// identity of the template's key. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -pub struct TemplateReference { - /// The template's generation in its store. - pub generation: StoreGeneration, - /// The identity of the template's key. - pub key: Identity, -} - /// The restore section of a clone's record: only the restore's transaction ID. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct RestoreSection { diff --git a/nvx-rs/src/activation/tests.rs b/nvx-rs/src/activation/tests.rs index 172f71b36..5792fbd7b 100644 --- a/nvx-rs/src/activation/tests.rs +++ b/nvx-rs/src/activation/tests.rs @@ -1,7 +1,6 @@ //! Tests of the activation record's types and limits. use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::num::NonZeroU64; use super::*; @@ -104,7 +103,6 @@ fn the_limits_keep_their_values() { assert_eq!(MAX_MAPPINGS, crate::protocol::MAX_HOST_MAPPINGS); assert_eq!(MAX_HOSTNAME_BYTES, 64); assert_eq!(MAX_PROXY_ENDPOINT_BYTES, 255); - assert_eq!(Identity::LEN, 32); assert_eq!(TransactionId::LEN, 16); } @@ -348,12 +346,6 @@ fn errors_name_the_limits_that_they_break() { #[test] fn opaque_values_keep_their_bytes_and_display_them_in_hexadecimal() { - let bytes: [u8; 32] = std::array::from_fn(|index| index as u8 * 8 + 1); - let digits = "0109111921293139414951596169717981899199a1a9b1b9c1c9d1d9e1e9f1f9"; - let identity = Identity::from_bytes(bytes); - assert_eq!(identity.as_bytes(), &bytes); - assert_eq!(identity.to_string(), digits); - assert_eq!(format!("{identity:?}"), format!("Identity({digits})")); let bytes: [u8; 16] = std::array::from_fn(|index| 0xf0 - index as u8); let digits = "f0efeeedecebeae9e8e7e6e5e4e3e2e1"; let transaction = TransactionId::from_bytes(bytes); @@ -365,21 +357,6 @@ fn opaque_values_keep_their_bytes_and_display_them_in_hexadecimal() { ); } -#[test] -fn a_store_generation_is_a_nonzero_number_in_the_order_of_publication() { - let first = StoreGeneration::new(NonZeroU64::MIN); - let number = NonZeroU64::new(1_000_000_007).unwrap(); - let later = StoreGeneration::new(number); - assert_eq!(first.get(), NonZeroU64::MIN); - assert_eq!(later.get(), number); - assert!(first < later); - assert_eq!(later.to_string(), "1000000007"); - assert_eq!(format!("{later:?}"), "StoreGeneration(1000000007)"); - let last = StoreGeneration::new(NonZeroU64::MAX); - assert!(later < last); - assert_eq!(last.to_string(), "18446744073709551615"); -} - #[test] fn an_image_reports_its_format_and_expected_identity() { let guid: Uuid = DISK_GUID.parse().unwrap(); diff --git a/nvx-rs/src/host/cli/store/mod.rs b/nvx-rs/src/host/cli/store/mod.rs index 0f4f1da84..9cfd6eb6b 100644 --- a/nvx-rs/src/host/cli/store/mod.rs +++ b/nvx-rs/src/host/cli/store/mod.rs @@ -15,9 +15,7 @@ //! outcome.json OpenVMM's outcome report of the latest start //! ``` //! -//! The files have the names under which state directories have always kept them, and the lock -//! is the lock of the same file that `nvx`'s sandbox store locks, so that a transition that takes -//! one excludes a transition that takes the other. +//! The files have the names under which state directories have always kept them. //! //! The directory is private to the current user, since its records decide which process a later //! command signals. On Linux, it belongs to the user with mode 0700, which OpenVMM also requires diff --git a/nvx-rs/src/host/cli/store/tests.rs b/nvx-rs/src/host/cli/store/tests.rs index 4dabc42a6..510e4b67a 100644 --- a/nvx-rs/src/host/cli/store/tests.rs +++ b/nvx-rs/src/host/cli/store/tests.rs @@ -6,7 +6,6 @@ use std::time::{Duration, Instant}; use super::*; use crate::host::cli::deadline::Deadline; -use crate::host::store::{JOURNAL_NAME, RUNTIME_NAME, SandboxStore}; mod launch; mod liveness; @@ -370,33 +369,3 @@ fn lock_files_that_others_may_access_are_refused() { let exit = state.lock(Deadline::after(10.0)).unwrap_err(); assert_eq!(error(exit), (ErrorCode::Io, expected)); } - -#[test] -fn the_lock_and_the_files_are_those_of_the_sandbox_store_of_nvx() { - let (_directory, state) = state(); - let store = SandboxStore::open(state.dir()).unwrap(); - // Each lock excludes the other, so that the commands may move from one to the other. - let held = store.lock(None).unwrap(); - assert_eq!(error(state.lock(Deadline::after(0.1)).unwrap_err()), busy()); - drop(held); - let mut held = None; - assert!(released(|| { - held = state.lock(Deadline::after(0.0)).ok(); - held.is_some() - })); - let deadline = Some(Instant::now() + Duration::from_millis(100)); - let refused = store.lock(deadline).unwrap_err(); - assert_eq!(refused.kind(), crate::ErrorKind::TimedOut, "{refused}"); - drop(held); - // The commands find the store's other files where the store keeps them. - for (file, path) in [ - (StateFile::Journal, state.dir().join(JOURNAL_NAME)), - (StateFile::Runtime, state.dir().join(RUNTIME_NAME)), - (StateFile::Capability, store.capability_path()), - (StateFile::Socket, store.socket_path()), - (StateFile::Log, store.log_path()), - (StateFile::Outcome, store.outcome_path()), - ] { - assert_eq!(state.path(file), path, "{file:?}"); - } -} diff --git a/nvx-rs/src/host/instance/execution.rs b/nvx-rs/src/host/instance/execution.rs deleted file mode 100644 index 8624a5779..000000000 --- a/nvx-rs/src/host/instance/execution.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! A running workload, its output, cancellation, and outcome. - -use std::io; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, Instant}; - -use super::session_code; -use crate::error::{Error, ErrorCode, Result}; -use crate::host::session::{ControlSession, ExecEvent, SessionError}; -use crate::protocol::{ - CWD_FAILED, ExitCategory, LAUNCH_FAILED, MAPPINGS_INCOMPLETE, MAX_OUTPUT_BYTES, -}; - -/// Longest delay between a cancellation request and its delivery to the guest. -const CANCEL_POLL_INTERVAL: Duration = Duration::from_millis(20); - -/// Receives a workload's output. -pub trait OutputSink { - /// Writes one chunk. After an error, the chunks of this stream are discarded, and the - /// workload continues. - fn write(&mut self, chunk: &[u8]) -> io::Result<()>; -} - -impl io::Result<()>> OutputSink for F { - fn write(&mut self, chunk: &[u8]) -> io::Result<()> { - self(chunk) - } -} - -/// How a workload ended. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WorkloadOutcome { - /// The workload exited with this code. - Exited(i32), - /// A signal with this number ended the workload. - Signaled(i32), - /// The workload's timeout elapsed, and the guest agent killed it. - TimedOut, - /// The workload exceeded [`MAX_OUTPUT_BYTES`], and the guest agent killed it. - OutputLimitExceeded, - /// The guest agent could not supervise the workload. - Failed, - /// A cancellation request ended the workload. - Cancelled, - /// The guest agent could not launch the workload. - LaunchFailed, - /// The workload could not enter its working directory, so nothing of it ran. The guest agent - /// wrote a diagnostic that names the directory to its standard error. - WorkingDirectory, -} - -/// A workload that [`crate::host::instance::Instance::exec`] started. -pub struct Execution { - /// Exclusive control session that carries this workload's traffic. - pub(super) session: ControlSession, - /// Identifier correlating requests, output, cancellation, and the final outcome. - pub(super) request_id: u64, - /// Workload response deadline, absent for a workload without a timeout. - pub(super) deadline: Option, - /// Maximum time allowed for control traffic after cancellation or failure. - pub(super) control_timeout: Duration, -} - -impl Execution { - /// Relays the workload's output to `stdout` and `stderr` and returns its outcome. - /// - /// Events are read in short slices, so that setting `cancel` reaches the guest within 20 ms. - /// The guest then terminates the workload and reports the cancelled outcome as usual. Once - /// cancellation is sent, a missing response fails within the control timeout of - /// [`crate::host::instance::Instance::exec`], or the earlier workload-response deadline, even when the workload has - /// no timeout. The guest agent limits the combined output to [`MAX_OUTPUT_BYTES`], and a - /// guest that sends more fails the execution. If the process that pumps an execution dies, - /// the guest kills the workload as soon as it notices that the control session is gone. - pub fn pump( - mut self, - stdout: &mut dyn OutputSink, - stderr: &mut dyn OutputSink, - cancel: &AtomicBool, - ) -> Result { - let mut deadline = self.deadline; - let mut forwarded = 0usize; - let mut stdout_open = true; - let mut stderr_open = true; - let mut cancel_sent = false; - loop { - if !cancel_sent && cancel.load(Ordering::Acquire) { - let cancellation_deadline = Instant::now() + self.control_timeout; - self.session - .cancel_exec(self.request_id, cancellation_deadline) - .map_err(|error| { - Error::new( - session_code(&error), - format!("cannot deliver the cancellation request: {error}"), - ) - .with_source(error) - })?; - deadline = Some(deadline.map_or(cancellation_deadline, |deadline| { - deadline.min(cancellation_deadline) - })); - cancel_sent = true; - } - let slice = Instant::now() + CANCEL_POLL_INTERVAL; - let poll_deadline = if cancel_sent { - deadline - } else { - Some(deadline.map_or(slice, |deadline| deadline.min(slice))) - }; - let event = match self.session.next_exec_event(self.request_id, poll_deadline) { - Err(SessionError::TimedOut) - if poll_deadline != deadline - && deadline.is_none_or(|deadline| Instant::now() < deadline) => - { - continue; - } - event => event, - }; - let event = event.map_err(|error| match error { - SessionError::TimedOut => { - Error::timed_out("timed out waiting for the workload outcome") - } - ended @ (SessionError::Closed | SessionError::Reset) => Error::new( - session_code(&ended), - "the control session ended before the workload finished; the sandbox VM may \ - have stopped", - ), - other => Error::new( - session_code(&other), - format!("the control session failed: {other}"), - ) - .with_source(other), - })?; - let (chunk, sink, open): (Vec, &mut dyn OutputSink, &mut bool) = match event { - ExecEvent::Stdout(chunk) => (chunk, &mut *stdout, &mut stdout_open), - ExecEvent::Stderr(chunk) => (chunk, &mut *stderr, &mut stderr_open), - ExecEvent::Exit { category, status } => { - return Ok(match category { - ExitCategory::Exit => WorkloadOutcome::Exited(status), - ExitCategory::Signal => WorkloadOutcome::Signaled(if status > 128 { - status - 128 - } else { - status - }), - ExitCategory::Timeout => WorkloadOutcome::TimedOut, - ExitCategory::OutputLimit => WorkloadOutcome::OutputLimitExceeded, - ExitCategory::Failed => WorkloadOutcome::Failed, - ExitCategory::Cancelled => WorkloadOutcome::Cancelled, - }); - } - ExecEvent::Rejected { status, category } => { - return match category.as_str() { - LAUNCH_FAILED => Ok(WorkloadOutcome::LaunchFailed), - // The guest already wrote a diagnostic that names the directory to stderr. - CWD_FAILED => Ok(WorkloadOutcome::WorkingDirectory), - // Only a start that was interrupted leaves a guest without its mappings. - MAPPINGS_INCOMPLETE => Err(Error::new( - ErrorCode::ExecRefused, - "the guest has not mounted every mapped host path, because its start \ - did not complete; stop the sandbox and start it again", - )), - _ => Err(Error::new( - ErrorCode::ExecRefused, - format!( - "the guest agent rejected the workload: {category} (status \ - {status})" - ), - )), - }; - } - }; - forwarded = forwarded.saturating_add(chunk.len()); - if forwarded > MAX_OUTPUT_BYTES { - return Err(Error::protocol( - "the guest exceeded the output limit of the control protocol", - )); - } - if *open && sink.write(&chunk).is_err() { - *open = false; - } - } - } -} diff --git a/nvx-rs/src/host/instance/files.rs b/nvx-rs/src/host/instance/files.rs deleted file mode 100644 index 000ca7128..000000000 --- a/nvx-rs/src/host/instance/files.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! Private files of an instance's caller and the temporary files of their replacements. - -use std::fs::{self, OpenOptions}; -use std::io::{self, Write}; -use std::path::Path; - -use serde::Serialize; -use serde::de::DeserializeOwned; - -use crate::error::{Error, Result}; - -/// Builds an error mapper that preserves an I/O source under `context`. -pub(super) fn io_error(context: String) -> impl FnOnce(io::Error) -> Error { - move |error| Error::io(context).with_source(error) -} - -/// Returns create-and-write options that request owner-only permissions on Unix. -fn private_options() -> OpenOptions { - let mut options = OpenOptions::new(); - options.write(true).create(true); - #[cfg(unix)] - std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); - options -} - -/// Returns the name of the temporary file through which [`write_private`] replaces the file `name` -/// for the random `number`: `.NAME.NUMBER.tmp`, which stays beside it if the replacement is -/// interrupted. -pub(super) fn temporary_name(name: &str, number: u64) -> String { - format!(".{name}.{number}.tmp") -} - -/// Atomically replaces `path` with `contents`, readable only by the current user, as callers do -/// with the configuration records that they keep beside an instance's store. -/// -/// The contents go to a temporary file beside `path` first, which a replacement that is -/// interrupted leaves, and which [`remove_interrupted_writes`] removes. -pub fn write_private(path: &Path, contents: &[u8]) -> Result<()> { - let mut suffix = [0u8; 8]; - getrandom::fill(&mut suffix) - .map_err(|error| Error::other("cannot name a temporary file").with_source(error))?; - let name = path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_default(); - let temporary = path.with_file_name(temporary_name(&name, u64::from_le_bytes(suffix))); - let written = (|| -> io::Result<()> { - let mut file = private_options().create_new(true).open(&temporary)?; - file.write_all(contents)?; - file.sync_all()?; - drop(file); - fs::rename(&temporary, path) - })(); - if written.is_err() { - let _ = fs::remove_file(&temporary); - } - written.map_err(io_error(format!("cannot write {}", path.display()))) -} - -/// Atomically replaces `path` with `value` as pretty JSON and a newline, readable only by the -/// current user. -pub fn write_json(path: &Path, value: &T) -> Result<()> { - let mut text = serde_json::to_vec_pretty(value) - .map_err(|error| Error::other("cannot encode sandbox state").with_source(error))?; - text.push(b'\n'); - write_private(path, &text) -} - -/// Reads the JSON at `path`, or returns `None` if the file does not exist. -pub fn read_json(path: &Path) -> Result> { - let text = match fs::read(path) { - Ok(text) => text, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(io_error(format!("cannot read {}", path.display()))(error)), - }; - serde_json::from_slice(&text).map(Some).map_err(|error| { - Error::io(format!("sandbox state {} is malformed", path.display())).with_source(error) - }) -} - -/// Removes the file at `path` if it exists. -pub fn remove_if_present(path: &Path) -> Result<()> { - match fs::remove_file(path) { - Err(error) if error.kind() != io::ErrorKind::NotFound => { - Err(io_error(format!("cannot remove {}", path.display()))(error)) - } - _ => Ok(()), - } -} - -/// Removes the temporary files that interrupted replacements of `path` with [`write_private`] or -/// [`write_json`] left beside it, and keeps every other file. A directory that does not exist has -/// none. -/// -/// The store's removal refuses those files, which are neither the store's nor the files that its -/// caller declares, so a caller that writes its files with [`write_private`] removes them first, -/// under the instance's lock. -pub fn remove_interrupted_writes(path: &Path) -> Result<()> { - let Some(name) = path.file_name().map(|name| name.to_string_lossy()) else { - return Ok(()); - }; - let dir = match path.parent() { - Some(dir) if !dir.as_os_str().is_empty() => dir, - _ => Path::new("."), - }; - let unlisted = || io_error(format!("cannot list {}", dir.display())); - let entries = match fs::read_dir(dir) { - Ok(entries) => entries, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(error) => return Err(unlisted()(error)), - }; - for entry in entries { - let entry = entry.map_err(unlisted())?; - if entry - .file_name() - .to_str() - .is_some_and(|entry| is_temporary_of(entry, &name)) - { - remove_if_present(&entry.path())?; - } - } - Ok(()) -} - -/// Returns whether `entry` is the name of a temporary file through which [`write_private`] -/// replaces the file `name`. -fn is_temporary_of(entry: &str, name: &str) -> bool { - entry - .strip_suffix(".tmp") - .and_then(|rest| rest.rsplit_once('.')) - .and_then(|(_, number)| number.parse::().ok()) - .is_some_and(|number| temporary_name(name, number) == entry) -} diff --git a/nvx-rs/src/host/instance/locked.rs b/nvx-rs/src/host/instance/locked.rs deleted file mode 100644 index 2cbee7f48..000000000 --- a/nvx-rs/src/host/instance/locked.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! The lifecycle transitions that hold an instance's lock. - -use std::time::{Duration, Instant}; - -use super::{ - Found, Instance, Mismatched, Running, Started, Stopped, control_endpoint, generate_capability, - readiness_failure, session_code, -}; -use crate::error::{Error, ErrorCode, Result}; -use crate::host::launch::{LaunchSpec, timeout_problem}; -use crate::host::process::KILL_TIMEOUT; -use crate::host::session::{self, ControlSession, SessionError}; -use crate::host::store::{LaunchId, LockedStore, RemovalError, RuntimeIdentity}; -use crate::host::supervisor::{self, Reattached, StopOutcome, VmmCommand}; -use crate::host::{platform, report}; -use crate::protocol::GuestFeatures; - -/// An instance whose lock this holds, through which its lifecycle transitions change its -/// records. Dropping it releases the lock. -#[derive(Debug)] -pub struct LockedInstance { - /// The instance. - pub(super) instance: Instance, - /// Its locked store. - pub(super) locked: LockedStore, -} - -impl LockedInstance { - /// Returns the instance. - pub fn instance(&self) -> &Instance { - &self.instance - } - - /// Finds the instance's VM with [`supervisor::reattach`], under the runtime identity that - /// `runtime` returns: the one with which the caller would launch it now. - /// - /// `runtime` is asked only when the store records a VM or a start, so that an instance that - /// never started, or that stopped, needs no runtime identity. Reattach ends a start that was - /// interrupted, signaling only an OpenVMM process that it verified, and forgets a VM that - /// exited, as one that a record of an earlier host boot names has. Fails as `runtime` fails, - /// for records that the store does not read, such as those of an earlier format, whose - /// message names it, and when whether the VM runs cannot be determined, because an - /// interrupted start cannot be ended yet or the recorded process cannot be examined: the - /// records then remain for a later attempt. - /// - /// A VM whose capability cannot be read, as when its file is missing or damaged, still runs, - /// and is found [`Found::Running`]: [`Instance::exec`] then fails as reading it failed, and - /// [`stop`](Self::stop) ends the VM through its verified process identity. - pub fn find(&mut self, runtime: impl FnOnce() -> Result) -> Result { - if self.locked.journal()?.is_none() && self.locked.runtime()?.is_none() { - return Ok(Found::Stopped); - } - let runtime = runtime()?; - Ok(match supervisor::reattach(&mut self.locked, &runtime)? { - Reattached::Live(vmm) => Found::Running(Running { - vmm, - capability: self.locked.read_capability(), - }), - Reattached::Mismatched { vmm, differences } => Found::Mismatched(Mismatched { - name: self.instance.name.clone(), - vmm, - differences, - }), - Reattached::Exited { .. } => Found::Stopped, - Reattached::Indeterminate(reason) => { - return Err(Error::new( - ErrorCode::VmmIndeterminate, - format!( - "cannot determine whether the OpenVMM process of sandbox {} runs: \ - {reason}", - self.instance.name - ), - )); - } - }) - } - - /// Launches the VM that `spec` describes under the runtime identity `runtime`, and waits up - /// to `timeout` until its guest is ready. - /// - /// The caller has found the instance stopped with [`find`](Self::find). Start first checks the - /// export plan with [`ExportPlan::verify`](crate::host::filesystem::ExportPlan::verify): that the - /// host keeps its read-only paths read-only, and that the mapped host paths still name the - /// objects they named when they were planned. It launches OpenVMM with - /// [`supervisor::launch`], whose journal records the launch before OpenVMM is spawned and its - /// process identity right after, so that [`find`](Self::find) ends a start whose caller died. - /// The capability travels through OpenVMM's standard-input pipe, which is closed before - /// OpenVMM starts, and never appears in arguments, the environment, or the log. Once the guest - /// is ready, has the required features, and has mounted the mapped host paths, start commits - /// the launch: the runtime record names the VM from then on. - /// - /// Fails with [`crate::ErrorKind::Unavailable`], and the code [`ErrorCode::GuestFeaturesMissing`], - /// when the guest image lacks a feature in [`GuestFeatures::REQUIRED`], and with - /// [`crate::ErrorKind::Unsupported`] when `timeout` is zero or - /// longer than [`MAX_TIMEOUT`](crate::host::launch::MAX_TIMEOUT), when the export plan maps a - /// read-only path that the host cannot keep read-only, when the specification fails - /// [`LaunchSpec::check`] or exceeds a limit, when the instance's directory exceeds Windows' - /// working-directory limit, or when OpenVMM's command line would not fit - /// [`PROCESS_COMMAND_LINE_LIMIT`](crate::host::launch::PROCESS_COMMAND_LINE_LIMIT), which holds on - /// every host; such a launch records nothing. Unlike [`crate::host::instance::check_launch`], start keeps no reserve. - /// A launch that does not become ready is terminated through its verified process identity; - /// one that cannot be ended keeps its journal, and the next transition ends it. - pub fn start( - &mut self, - spec: &LaunchSpec, - runtime: &RuntimeIdentity, - timeout: Duration, - ) -> Result { - check_timeout("start timeout", timeout, true)?; - platform::check_working_dir(self.instance.dir())?; - if let Some(plan) = &spec.filesystem { - plan.verify()?; - } - let instance = &self.instance; - let report = instance.report_path(); - // Every control endpoint of the instance has the same length, so a launch that would not - // fit is refused before the store records it. - let endpoint = control_endpoint(&instance.store.socket_path())?; - spec.fit_process_command_line(&spec.openvmm_arguments(&endpoint, &report)?, 0)?; - let capability = generate_capability()?; - let launch = LaunchId::generate()?; - let started = Instant::now(); - let starting = supervisor::launch( - &mut self.locked, - &launch, - runtime, - &capability, - |endpoint| { - let arguments = spec.openvmm_arguments(endpoint, &report)?; - spec.fit_process_command_line(&arguments, 0)?; - let mut command = VmmCommand::new(&spec.openvmm, arguments, instance.dir()); - command.breakaway_from_job = spec.breakaway_from_job; - Ok(command) - }, - )?; - let (pid, start_time) = { - let reference = starting.process().reference(); - (reference.pid, reference.start_time) - }; - let deadline = started + timeout; - let ready = session::connect(starting.endpoint(), pid, start_time, deadline) - .and_then(|transport| ControlSession::attach(transport, &capability, deadline)) - .and_then(|mut session| { - session.ping(deadline)?; - let features = session.features(deadline)?; - Ok((session, features)) - }); - let (mut session, features) = match ready { - Ok(ready) => ready, - Err(error) => { - let reason = readiness_failure(&error, spec.identity.create_account); - return Err(instance.abort_start(starting, session_code(&error), &reason)); - } - }; - let missing = features.missing(GuestFeatures::REQUIRED); - if !missing.is_empty() { - drop(session); - return Err(instance.reject_guest(starting, &missing)); - } - // Workloads run only once the guest has mounted every mapped host path. - if let Some(plan) = &spec.filesystem - && let Err(error) = session.map_host_paths(&plan.guest_table(), deadline) - { - drop(session); - let reason = format!("the host paths were not mapped: {error}"); - return Err(instance.abort_start(starting, session_code(&error), &reason)); - } - drop(session); - let vmm = starting.commit()?; - Ok(Started { - runtime: vmm.record().clone(), - boot: started.elapsed(), - }) - } - - /// Asks the guest of the running VM `running` to shut down, and terminates OpenVMM through - /// its verified process identity if it has not exited within `timeout`, as [`crate::host::supervisor::Vmm::stop`] - /// does with a forced step, or at once when the guest cannot be asked, as without the VM's - /// capability. - /// - /// A running execution keeps the control session until it ends, so a stop that cannot reach - /// the guest in time terminates the VM, and the execution then fails. Once OpenVMM has - /// exited, the stop clears the runtime record and reads OpenVMM's outcome report. Fails with - /// [`crate::ErrorKind::Unsupported`] when `timeout` is zero or longer than - /// [`MAX_TIMEOUT`](crate::host::launch::MAX_TIMEOUT), and when OpenVMM cannot be terminated, or does - /// not exit after it was, which keeps its runtime record. - pub fn stop(&mut self, running: Running, timeout: Duration) -> Result { - check_timeout("stop timeout", timeout, true)?; - let deadline = Instant::now() + timeout; - let Running { vmm, capability } = running; - let stopped = vmm.stop( - &mut self.locked, - deadline, - Some(KILL_TIMEOUT), - |vmm, deadline| { - // Without its capability, no session can reach the guest, and the forced step - // ends the VM. - let capability = - capability.map_err(|error| SessionError::Protocol(error.to_string()))?; - let process = vmm.record().process(); - let transport = - session::connect(vmm.endpoint(), process.pid, process.start_time, deadline)?; - ControlSession::attach(transport, &capability, deadline)?.stop(deadline) - }, - )?; - let forced = match stopped.outcome { - StopOutcome::Graceful => false, - StopOutcome::Forced => true, - StopOutcome::TimedOut => { - return Err(Error::other(format!( - "OpenVMM process {} of sandbox {} did not exit after it was terminated", - vmm.record().process().pid, - self.instance.name - ))); - } - }; - Ok(Stopped { - forced, - // This legacy wrapper keeps its best-effort report policy. - report: report::read(&self.instance.report_path()).ok().flatten(), - }) - } - - /// Stops the VM `mismatched`, which runs under another runtime identity than the caller's, by - /// identity-verified forced termination: terminates OpenVMM through its verified process - /// identity, as [`crate::host::supervisor::Vmm::terminate`] does, and waits up to `timeout` for it to exit. - /// - /// The VM's guest might not speak this build's control protocol, so the stop opens no control - /// session to it, and does not ask it to shut down: only the OpenVMM process that - /// [`find`](Self::find) verified is signaled. Once OpenVMM has exited, the stop clears the - /// runtime record. It reads no outcome report, so [`Stopped::report`] is `None`: a terminated - /// OpenVMM writes none, and one that exited before it was terminated wrote it under the other - /// runtime's contracts. [`Stopped::forced`] is false only for an OpenVMM that exited before it - /// was terminated. - /// - /// Fails with [`crate::ErrorKind::Unsupported`] when `timeout` is zero or longer than - /// [`MAX_TIMEOUT`](crate::host::launch::MAX_TIMEOUT), and when OpenVMM's state cannot be - /// determined, OpenVMM cannot be terminated, or it does not exit within `timeout`: its runtime - /// record then remains, and a later stop retries. - pub fn terminate(&mut self, mismatched: Mismatched, timeout: Duration) -> Result { - check_timeout("stop timeout", timeout, true)?; - let Mismatched { vmm, .. } = mismatched; - let forced = match vmm.terminate(&mut self.locked, timeout)? { - StopOutcome::Graceful => false, - StopOutcome::Forced => true, - StopOutcome::TimedOut => { - return Err(Error::other(format!( - "OpenVMM process {} of sandbox {} did not exit after it was terminated", - vmm.record().process().pid, - self.instance.name - ))); - } - }; - Ok(Stopped { - forced, - report: None, - }) - } - - /// Removes the stopped instance: its store's records and files, the caller's files that it - /// declared, the configuration record last, and its directory, through the store's - /// [`LockedStore::remove`], and releases the lock. Open files, such as the lock files of other - /// transitions, that keep the emptied directory are then waited for until `deadline`, or for - /// as long as it takes without one, as [`Lingering::wait`](crate::host::store::Lingering::wait) - /// does. - /// - /// The caller has found the instance stopped with [`find`](Self::find): the removal refuses, - /// and removes nothing, while the store records a VM or a start, and for an instance whose - /// caller declared no configuration record. Entries in the directory that are neither the - /// store's nor the caller's are refused, and kept with the directory, once the rest is gone, - /// among them the temporary files that interrupted writes of the caller's files left, which - /// the caller removes first with [`crate::host::instance::remove_interrupted_writes`]. - pub fn remove(self, deadline: Option) -> std::result::Result<(), RemovalError> { - match self.locked.remove()? { - None => Ok(()), - Some(mut lingering) => lingering.wait(deadline), - } - } -} - -/// Fails with [`crate::ErrorKind::Unsupported`] when the duration that `name` describes is longer than -/// [`MAX_TIMEOUT`](crate::host::launch::MAX_TIMEOUT), or zero while it must be `positive`, so that no -/// deadline derived from it overflows. -pub(super) fn check_timeout(name: &str, value: Duration, positive: bool) -> Result<()> { - match timeout_problem(value, positive) { - Some(problem) => Err(Error::unsupported(format!("the {name} {problem}"))), - None => Ok(()), - } -} diff --git a/nvx-rs/src/host/instance/mod.rs b/nvx-rs/src/host/instance/mod.rs deleted file mode 100644 index 8e0865ff4..000000000 --- a/nvx-rs/src/host/instance/mod.rs +++ /dev/null @@ -1,480 +0,0 @@ -//! Supervision of one VM instance: its OpenVMM process, its control endpoint, and the records -//! that name them, through the [`store`](crate::host::store), the [`crate::host::supervisor`], and the control -//! [`session`]. -//! -//! An instance lives in a [`SandboxStore`] of its own: a directory private to the current user, -//! in which the store keeps the instance's lock, the journal of its launch, and the runtime -//! record of its VM, and in which the caller may keep files of its own under other names, which it -//! declares as [`CallerFiles`], such as the configuration record whose presence means that the -//! instance is provisioned: -//! -//! ```text -//! / -//! sandbox.lock the lock that serializes the instance's lifecycle transitions -//! launch.json the journal of a start in progress, or of one that was interrupted -//! runtime.json the runtime record of the running VM -//! control.capability launch capability (running only) -//! control.sock Linux control endpoint (running only) -//! openvmm.log OpenVMM output of the latest start -//! outcome.json OpenVMM outcome report of the latest run -//! ... the caller's files -//! ``` -//! -//! Lifecycle transitions run under the instance's lock, through the [`LockedInstance`] that -//! [`Instance::lock`] returns, so that every operation can run in a different process. The lock is -//! the store's [lifecycle lock](crate::host::store#lifecycle-locks) of an existing sandbox, which knows -//! the caller's files: -//! -//! - [`LockedInstance::find`] finds the instance's VM as an orchestrator does after it restarts -//! within the same host boot, with [`crate::host::supervisor::reattach`]: it ends a start whose caller died, -//! forgets a VM that exited, and compares the caller's runtime identity with the one that a -//! running VM was launched with. A VM of another runtime identity is [`Found::Mismatched`]: it -//! runs through artifacts or contracts that the caller does not have, so the instance does not -//! drive it, and only ends it. -//! - [`LockedInstance::start`] launches a detached OpenVMM process with the managed lifecycle -//! through [`crate::host::supervisor::launch`], passes it a fresh 32-byte capability through standard input, -//! and waits until the guest agent answers on the authenticated control console. The agent must -//! also advertise the control features in [`crate::protocol::GuestFeatures::REQUIRED`]; a guest image that lacks -//! one is terminated, because it would silently ignore the policy or request that needs the -//! feature. Start then hands the agent the table of mapped host paths, and fails if the agent -//! cannot mount them all. -//! - [`LockedInstance::stop`] asks the guest to shut down and falls back to terminating OpenVMM -//! through its verified process identity. -//! - [`LockedInstance::terminate`] stops a mismatched VM: it terminates OpenVMM through its -//! verified process identity, opens no control session, whose protocol the guest might not -//! speak, and reads no outcome report, which follows the other runtime's contracts. -//! - [`LockedInstance::remove`] removes a stopped instance, with the caller's files, and its -//! directory, through the store's [removal](crate::host::store#removal). -//! -//! [`Instance::exec`] runs a workload through the control console of a [`Running`] VM without the -//! lock, and [`Execution::pump`] streams its output live. The guest serializes executions: a -//! second execution waits until the first finishes. Messages call the instance `sandbox NAME`, -//! after the name that [`Instance::create`] or [`Instance::open`] gives it. - -mod execution; -mod files; -mod locked; - -use std::fmt; -use std::io; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use crate::error::{Error, ErrorCode, ErrorKind, Result}; -use crate::host::artifacts::Artifacts; -use crate::host::launch::LaunchSpec; -use crate::host::platform; -use crate::host::report::Report; -use crate::host::session::{self, ControlSession, SessionError}; -use crate::host::store::{ - CallerFiles, IdentityPart, OUTCOME_NAME, Recovery, RuntimeRecord, SOCKET_NAME, SandboxStore, -}; -use crate::host::supervisor::{Starting, Vmm}; -use crate::protocol::{CAPABILITY_LEN, Workload}; - -pub use self::execution::{Execution, OutputSink, WorkloadOutcome}; -use self::files::io_error; -pub use self::files::{ - read_json, remove_if_present, remove_interrupted_writes, write_json, write_private, -}; -pub use self::locked::LockedInstance; -use self::locked::check_timeout; - -/// Returns a fresh launch capability, which is never all zero. -pub fn generate_capability() -> Result<[u8; CAPABILITY_LEN]> { - crate::host::vm::generate_capability() -} - -/// Returns a control endpoint of an instance whose Linux control socket is `socket`: the socket -/// on Linux, and a fresh named pipe, whose name always has the same length, on Windows, as a -/// launch chooses it. -fn control_endpoint(socket: &Path) -> Result { - platform::control_endpoint(socket) - .map_err(|error| Error::io("cannot choose a control endpoint").with_source(error)) -} - -/// Fails with [`ErrorKind::Unsupported`] if the working directory `dir` exceeds Windows' limit, -/// or if OpenVMM's command line for `spec`, launched for an instance in `dir`, would not fit the -/// limit of a Windows process command line; see [`LaunchSpec::check_process_command_line`]. -/// -/// The directory need not exist yet, so that a caller can check a launch before it creates the -/// instance. Every control endpoint of an instance has the same length. -pub fn check_launch(dir: &Path, spec: &LaunchSpec) -> Result<()> { - let dir = std::path::absolute(dir).map_err(io_error(format!( - "cannot resolve the instance directory {}", - dir.display() - )))?; - platform::check_working_dir(&dir)?; - let endpoint = control_endpoint(&dir.join(SOCKET_NAME))?; - spec.check_process_command_line(&endpoint, &dir.join(OUTCOME_NAME)) -} - -/// Classifies a session failure. -fn session_kind(error: &SessionError) -> ErrorKind { - match error { - SessionError::TimedOut => ErrorKind::TimedOut, - SessionError::ProcessExited => ErrorKind::Exited, - SessionError::Refused(_) | SessionError::Io(_) => ErrorKind::Io, - SessionError::Closed - | SessionError::Reset - | SessionError::AuthenticationFailed - | SessionError::Protocol(_) => ErrorKind::Protocol, - } -} - -/// Returns the code of a session failure: [`ErrorCode::EndpointClosed`] for a session that the -/// control endpoint closed, as OpenVMM closes it when the VM shuts down, and otherwise the code of -/// its [kind](session_kind). -fn session_code(error: &SessionError) -> ErrorCode { - match error { - SessionError::Closed => ErrorCode::EndpointClosed, - other => session_kind(other).into(), - } -} - -/// Explains why a launch did not become ready. -fn readiness_failure(error: &SessionError, runs_as_host_user: bool) -> String { - let mut reason = error.to_string(); - // An image that predates account creation ends its boot, before the guest agent starts, when - // it finds no account for the host user that the workloads run as. The VM then vanishes, or - // its control endpoint closes first. - let vm_ended = matches!(error, SessionError::ProcessExited | SessionError::Closed); - if runs_as_host_user && vm_ended { - reason.push_str( - "; a guest image that predates workload account creation exits during boot when \ - workloads run under the host user's IDs, so use an NVX release or build that \ - includes it", - ); - } - reason -} - -/// A VM that [`LockedInstance::start`] launched and the guest agent of which is ready. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Started { - /// The runtime record of its launch: its OpenVMM process, its control endpoint, and the - /// runtime identity that it was launched with. - pub runtime: RuntimeRecord, - /// Time from launch to guest readiness. - pub boot: Duration, -} - -/// A VM that [`LockedInstance::stop`] stopped, or that [`LockedInstance::terminate`] ended. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Stopped { - /// Whether OpenVMM was terminated: because the guest did not shut down in time, or because - /// the VM ran under another runtime identity. - pub forced: bool, - /// Summary of OpenVMM's outcome report, if it wrote one. [`LockedInstance::terminate`] reads - /// none, since the other runtime's OpenVMM wrote it. - pub report: Option, -} - -/// The running VM of an instance, which [`LockedInstance::find`] found under the caller's runtime -/// identity, and the capability with which its control sessions attach. -pub struct Running { - /// The VM, held through its verified process identity. - vmm: Vmm, - /// The launch capability of the VM, or the error with which reading it failed: only the - /// VM's control sessions need it, and a stop ends the VM without one. - capability: Result<[u8; CAPABILITY_LEN]>, -} - -impl Running { - /// Returns the VM, held through its verified process identity. - pub fn vmm(&self) -> &Vmm { - &self.vmm - } - - /// Returns the runtime record of the VM's launch. - pub fn runtime(&self) -> &RuntimeRecord { - self.vmm.record() - } - - /// Returns the launch capability of the VM, or fails as reading it failed, with the same - /// code, description, and the description of its cause. - fn capability(&self) -> Result<&[u8; CAPABILITY_LEN]> { - self.capability.as_ref().map_err(|error| { - let copy = Error::new(error.code(), error.message()); - match std::error::Error::source(error) { - Some(cause) => copy.with_source(io::Error::other(cause.to_string())), - None => copy, - } - }) - } -} - -impl fmt::Debug for Running { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - // The capability authenticates control sessions, so it is never shown. - formatter - .debug_struct("Running") - .field("vmm", &self.vmm) - .finish_non_exhaustive() - } -} - -/// The running VM of an instance, which [`LockedInstance::find`] found under another runtime -/// identity than the caller's, and which [`LockedInstance::terminate`] ends. -#[derive(Debug)] -pub struct Mismatched { - /// The instance's name in messages. - name: String, - /// The VM, held through its verified process identity. - vmm: Vmm, - /// The parts of the runtime identity that differ. - differences: Vec, -} - -impl Mismatched { - /// Returns the VM, held through its verified process identity. - pub fn vmm(&self) -> &Vmm { - &self.vmm - } - - /// Returns the parts of the runtime identity that differ, as - /// [`crate::host::store::RuntimeIdentity::differences`] returns them. - pub fn differences(&self) -> &[IdentityPart] { - &self.differences - } - - /// Returns the error that reports the VM, which the instance does not drive, since only the - /// build and artifacts that started it speak its contracts: it names the parts that differ, - /// and that stopping the VM terminates it. Its code is [`ErrorCode::RuntimeMismatch`]. - pub fn error(&self) -> Error { - let parts: Vec<&str> = self.differences.iter().map(|part| part.name()).collect(); - Error::new( - ErrorCode::RuntimeMismatch, - format!( - "sandbox {} runs under another runtime identity, which differs in {}, so it is \ - not driven: stop it to terminate its OpenVMM process {} without a guest shutdown", - self.name, - parts.join(", "), - self.vmm.record().process().pid - ), - ) - } -} - -/// What [`LockedInstance::find`] found of an instance's VM. -#[derive(Debug)] -pub enum Found { - /// No VM of the instance runs: none was started, the last one stopped or exited, or a start - /// that was interrupted is over. - Stopped, - /// The VM runs under the caller's runtime identity. - Running(Running), - /// The VM runs under another runtime identity: the caller ends it with - /// [`LockedInstance::terminate`], or reports it, as [`Mismatched::error`] does. - Mismatched(Mismatched), -} - -/// One VM instance in a sandbox store of its own. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Instance { - /// The store of the instance's runtime records, logs, and control endpoint. - store: SandboxStore, - /// Sandbox name used in diagnostics. - name: String, - /// The files that the caller keeps in the instance's directory. - files: CallerFiles, -} - -impl Instance { - /// Creates the directory `dir` of a new instance, whose parent must exist, private to the - /// current user, as [`SandboxStore::create`] does, and supervises the instance in it, which - /// messages call `sandbox NAME`, and in which the caller keeps `files`. - pub fn create( - dir: impl Into, - name: impl Into, - files: CallerFiles, - ) -> Result { - Ok(Self { - store: SandboxStore::create(dir)?, - name: name.into(), - files, - }) - } - - /// Supervises the instance in the existing directory `dir`, which must be private to the - /// current user, as [`SandboxStore::open`] requires, which messages call `sandbox NAME`, and - /// in which the caller keeps `files`. Fails with [`ErrorKind::NotFound`] if `dir` does not - /// exist. - pub fn open( - dir: impl Into, - name: impl Into, - files: CallerFiles, - ) -> Result { - Ok(Self { - store: SandboxStore::open(dir)?, - name: name.into(), - files, - }) - } - - /// Returns the store of the instance's runtime records. - pub fn store(&self) -> &SandboxStore { - &self.store - } - - /// Returns the instance's directory, as an absolute path. - pub fn dir(&self) -> &Path { - self.store.dir() - } - - /// Returns the instance's name in messages. - pub fn name(&self) -> &str { - &self.name - } - - /// Returns the files that the caller keeps in the instance's directory. - pub fn files(&self) -> &CallerFiles { - &self.files - } - - /// Returns the path of the OpenVMM log of the latest start. - pub fn log_path(&self) -> PathBuf { - self.store.log_path() - } - - /// Returns the path of OpenVMM's outcome report of the latest run. - pub fn report_path(&self) -> PathBuf { - self.store.outcome_path() - } - - /// Takes the instance's lock, which serializes its lifecycle transitions, waiting for it - /// until `deadline`, or for as long as it takes without one, as - /// [`SandboxStore::lock_existing`] does with the caller's files. - /// - /// Fails with [`ErrorKind::NotFound`] once the instance's directory is gone, as a removal - /// that this waited for leaves it, and with [`ErrorKind::TimedOut`] when the deadline passes - /// first. A caller that keeps a configuration record checks it under the lock: the lock of a - /// directory without it is released without a lock file. - pub fn lock(&self, deadline: Option) -> Result { - Ok(LockedInstance { - locked: SandboxStore::lock_existing(self.dir(), &self.files, deadline)?, - instance: self.clone(), - }) - } - - /// Describes why this instance did not start and points the caller to its log. - fn start_failure(&self, code: impl Into, reason: &str) -> Error { - Error::new( - code, - format!( - "sandbox {} failed to start: {reason}; see {}", - self.name, - self.log_path().display() - ), - ) - } - - /// Ends a launch that did not become ready, as [`Starting::abandon`] does, and describes why - /// the instance did not start. - /// - /// A launch whose OpenVMM cannot be ended keeps its journal, and the next transition of the - /// instance ends it, when [`LockedInstance::find`] recovers it. - fn abort_start( - &self, - starting: Starting<'_>, - code: impl Into, - reason: &str, - ) -> Error { - let code = code.into(); - let unended = match starting.abandon() { - Ok(Recovery::Indeterminate(unended)) => unended, - Ok(_) => return self.start_failure(code, reason), - Err(error) => error.to_string(), - }; - self.start_failure( - code, - &format!( - "{reason}; the launch could not be ended, and remains for recovery: {unended}" - ), - ) - } - - /// Ends a launch whose guest image lacks control features that NVX needs. - /// - /// An older image would boot and answer, but it would silently ignore host path mappings and - /// cancellation requests, so the VM must not run at all. - fn reject_guest(&self, starting: Starting<'_>, missing: &[&str]) -> Error { - let reason = format!( - "the guest image lacks control features that the openvmm backend needs ({}); use an \ - NVX release or build that includes them, for example through {}", - missing.join(", "), - Artifacts::ENV_DIR - ); - self.abort_start(starting, ErrorCode::GuestFeaturesMissing, &reason) - } - - /// Maps a control-session failure onto an instance-facing error and message. - fn session_error(&self, error: SessionError, control_timeout: Duration) -> Error { - match error { - SessionError::ProcessExited => Error::new( - ErrorCode::SandboxExited, - format!("sandbox {} stopped unexpectedly", self.name), - ), - SessionError::TimedOut => Error::timed_out(format!( - "sandbox {} did not accept a control session within {} s; another exec may \ - still be running", - self.name, - control_timeout.as_secs() - )), - other => Error::new( - session_code(&other), - format!( - "the control session with sandbox {} failed: {other}", - self.name - ), - ) - .with_source(other), - } - } - - /// Starts `workload` in the instance's running VM, `running`, without the instance's lock. - /// - /// It waits up to `control_timeout` for the control session, which the guest grants one - /// execution at a time. [`Execution::pump`] then waits for the outcome up to the workload's - /// own timeout plus `response_grace`, and without a limit when the workload has no timeout. - /// Fails with [`ErrorKind::Exited`], and the code [`ErrorCode::SandboxExited`], when the VM is - /// gone, and with [`ErrorKind::Unsupported`] when `control_timeout` is zero or either - /// duration is longer than [`MAX_TIMEOUT`](crate::host::launch::MAX_TIMEOUT). Fails as reading the - /// capability failed when [`find`](LockedInstance::find) could not read it. - pub fn exec( - &self, - running: &Running, - workload: &Workload<'_>, - control_timeout: Duration, - response_grace: Duration, - ) -> Result { - check_timeout("control timeout", control_timeout, true)?; - check_timeout("response grace", response_grace, false)?; - let capability = running.capability()?; - let deadline = Instant::now() + control_timeout; - let process = running.vmm.record().process(); - let mut session = session::connect( - running.vmm.endpoint(), - process.pid, - process.start_time, - deadline, - ) - .and_then(|transport| ControlSession::attach(transport, capability, deadline)) - .map_err(|error| self.session_error(error, control_timeout))?; - let request_id = session - .start_exec(workload, deadline) - .map_err(|error| self.session_error(error, control_timeout))?; - let timeout_ms = workload.timeout_ms; - let response_deadline = (timeout_ms > 0) - .then(|| Instant::now() + Duration::from_millis(timeout_ms.into()) + response_grace); - Ok(Execution { - session, - request_id, - deadline: response_deadline, - control_timeout, - }) - } -} - -#[cfg(test)] -mod tests; diff --git a/nvx-rs/src/host/instance/tests.rs b/nvx-rs/src/host/instance/tests.rs deleted file mode 100644 index e8bfd6dd0..000000000 --- a/nvx-rs/src/host/instance/tests.rs +++ /dev/null @@ -1,249 +0,0 @@ -use std::fs; - -use super::files::temporary_name; -use super::*; - -#[test] -fn interrupted_writes_leave_temporary_files_that_only_their_removal_takes() { - let directory = tempfile::tempdir().unwrap(); - let names = || { - let mut names: Vec = fs::read_dir(directory.path()) - .unwrap() - .map(|entry| entry.unwrap().file_name().into_string().unwrap()) - .collect(); - names.sort(); - names - }; - let path = directory.path().join("config.json"); - write_json(&path, &serde_json::json!({ "written": true })).unwrap(); - assert_eq!(names(), ["config.json"]); - // Replacements of the file that were interrupted left their temporary files, which other - // files only resemble. - let left = [0, 12345, u64::MAX].map(|number| temporary_name("config.json", number)); - let others = [ - ".config.json.tmp", - ".config.json..tmp", - ".config.json.012.tmp", - ".config.json.+12.tmp", - ".config.json.1a.tmp", - ".config.json.18446744073709551616.tmp", - ".config.json.1.tmp.tmp", - ".xconfig.json.1.tmp", - ".other.json.1.tmp", - "config.json.1.tmp", - ".tmpAbC123", - ]; - for name in left.iter().map(String::as_str).chain(others) { - fs::write(directory.path().join(name), b"{").unwrap(); - } - remove_interrupted_writes(&path).unwrap(); - let mut kept: Vec<&str> = others.into_iter().chain(["config.json"]).collect(); - kept.sort_unstable(); - assert_eq!(names(), kept); - // A directory that does not exist has none. - remove_interrupted_writes(&directory.path().join("gone").join("config.json")).unwrap(); -} - -#[test] -fn readiness_failures_explain_images_without_workload_accounts() { - let hint = "predates workload account creation"; - for ended in [SessionError::ProcessExited, SessionError::Closed] { - assert!(readiness_failure(&ended, true).contains(hint)); - // Only a sandbox that needs the account suggests it. - assert!(!readiness_failure(&ended, false).contains(hint)); - } - for other in [ - SessionError::TimedOut, - SessionError::Protocol("invalid record".to_owned()), - ] { - assert_eq!(readiness_failure(&other, true), other.to_string()); - } -} - -#[test] -fn session_failures_have_the_codes_of_their_kinds_but_a_closed_endpoint() { - assert_eq!( - session_code(&SessionError::Closed), - ErrorCode::EndpointClosed - ); - assert_eq!( - ErrorCode::EndpointClosed.kind(), - session_kind(&SessionError::Closed) - ); - for (error, code) in [ - (SessionError::TimedOut, ErrorCode::TimedOut), - (SessionError::ProcessExited, ErrorCode::Exited), - (SessionError::Reset, ErrorCode::Protocol), - (SessionError::AuthenticationFailed, ErrorCode::Protocol), - ( - SessionError::Protocol("bad".to_owned()), - ErrorCode::Protocol, - ), - (SessionError::Io(io::Error::other("lost")), ErrorCode::Io), - ] { - assert_eq!(session_code(&error), code, "{error}"); - } -} - -/// The instance's store, which exists on Linux and Windows hosts. -#[cfg(any(target_os = "linux", windows))] -mod store { - use std::fs; - - use serde_json::json; - - use super::super::*; - use crate::host::launch::PROCESS_COMMAND_LINE_LIMIT; - use crate::host::store::{ - ArtifactIdentity, JOURNAL_NAME, LOCK_NAME, LOG_NAME, RUNTIME_NAME, RemovalError, - RuntimeIdentity, - }; - - /// The configuration record of these tests' caller. - const CONFIG: &str = "config.json"; - - fn instance() -> (tempfile::TempDir, Instance) { - let directory = tempfile::tempdir().unwrap(); - let instance = Instance::create( - directory.path().join("vm"), - "test", - CallerFiles::new(CONFIG).unwrap(), - ) - .unwrap(); - (directory, instance) - } - - /// Returns the runtime identity of the launches of these tests. - fn runtime() -> RuntimeIdentity { - let artifact = |digit: &str| ArtifactIdentity::sha256(&digit.repeat(64)).unwrap(); - RuntimeIdentity::new(artifact("1"), artifact("2"), artifact("3")) - } - - #[test] - fn instances_that_record_no_vm_need_no_runtime_identity() { - let (_directory, instance) = instance(); - let mut locked = instance.lock(None).unwrap(); - let found = locked - .find(|| panic!("an instance that records no VM needs no runtime identity")) - .unwrap(); - assert!(matches!(found, Found::Stopped), "{found:?}"); - drop(locked); - // The instance's caller recorded no configuration, so its lock file went too. - assert!(!instance.dir().join(LOCK_NAME).exists()); - let reopened = - Instance::open(instance.dir(), instance.name(), instance.files().clone()).unwrap(); - assert_eq!(reopened, instance); - } - - #[test] - fn stopped_instances_are_removed_with_the_callers_files() { - let (_directory, instance) = instance(); - for name in [CONFIG, LOG_NAME, RUNTIME_NAME] { - fs::write(instance.dir().join(name), b"{}").unwrap(); - } - // An instance whose store records a VM is not removed. - let locked = instance.lock(None).unwrap(); - assert!(matches!(locked.remove(None), Err(RemovalError::Launched))); - assert!(instance.dir().join(CONFIG).is_file()); - fs::remove_file(instance.dir().join(RUNTIME_NAME)).unwrap(); - // Nor is one whose caller declared no configuration record. - let undeclared = - Instance::open(instance.dir(), instance.name(), CallerFiles::none()).unwrap(); - let locked = undeclared.lock(None).unwrap(); - assert!(matches!(locked.remove(None), Err(RemovalError::Undeclared))); - assert!(instance.dir().join(CONFIG).is_file()); - instance.lock(None).unwrap().remove(None).unwrap(); - assert!(!instance.dir().exists()); - // A transition that comes later finds the instance gone. - let error = instance.lock(None).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); - let error = - Instance::open(instance.dir(), instance.name(), CallerFiles::none()).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); - } - - #[test] - fn records_of_the_earlier_format_fail_naming_it() { - // Before the store, instances kept runtime records and launch markers of format 3. - for (name, record) in [ - ( - RUNTIME_NAME, - json!({ "format": 3, "pid": 7, "startTime": 8, "endpoint": "e" }), - ), - ( - JOURNAL_NAME, - json!({ "format": 3, "endpoint": "e", "process": { "pid": 7, "startTime": 8 } }), - ), - ] { - let (_directory, instance) = instance(); - let path = instance.dir().join(name); - fs::write(&path, record.to_string()).unwrap(); - let mut locked = instance.lock(None).unwrap(); - let error = locked.find(|| Ok(runtime())).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert!( - error.message().contains("has the unsupported format 3"), - "{error}" - ); - assert!( - error - .message() - .contains("stop the sandbox with the build that started it"), - "{error}" - ); - // Nothing is migrated or removed. - assert!(path.is_file()); - } - } - - #[test] - fn launches_are_checked_before_the_instance_exists() { - let directory = tempfile::tempdir().unwrap(); - let mut spec = LaunchSpec::new( - Artifacts::new("openvmm", "vmlinux", "initramfs.cpio.gz"), - crate::Hypervisor::Kvm, - ); - let dir = directory.path().join("vm"); - check_launch(&dir, &spec).unwrap(); - spec.openvmm = directory - .path() - .join("o".repeat(PROCESS_COMMAND_LINE_LIMIT)); - let error = check_launch(&dir, &spec).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); - assert!(!dir.exists()); - } - - #[cfg(windows)] - #[test] - fn working_directory_limits_are_checked_before_instance_launches() { - use std::os::windows::ffi::OsStrExt; - - let directory = tempfile::tempdir().unwrap(); - let mut parent = directory.path().to_path_buf(); - while parent.as_os_str().encode_wide().count() < 300 { - parent.push("long-state-root-component"); - } - fs::create_dir_all(&parent).unwrap(); - let dir = parent.join("vm"); - let spec = LaunchSpec::new( - Artifacts::new("openvmm", "vmlinux", "initramfs.cpio.gz"), - crate::Hypervisor::Whp, - ); - let error = check_launch(&dir, &spec).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert!(error.message().contains("working directory")); - assert!(error.message().contains("258")); - assert!(!dir.exists()); - - let instance = Instance::create(&dir, "test", CallerFiles::new(CONFIG).unwrap()).unwrap(); - let mut locked = instance.lock(None).unwrap(); - let error = locked - .start(&spec, &runtime(), Duration::from_secs(1)) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert!(error.message().contains("working directory")); - assert!(error.message().contains("258")); - assert!(instance.store.journal().unwrap().is_none()); - assert!(!dir.join(LOG_NAME).exists()); - } -} diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 8ec3e1251..2baff95b2 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -17,18 +17,10 @@ //! - [`network`] lowers egress rules, and the egress policy files that `nvx-rs run` and //! `nvx-rs sandbox` accept, into OpenVMM's rules, and [`filesystem`] plans the export of host //! paths through OpenVMM's aggregate virtio-fs device. -//! - [`instance`] drives one VM instance in a directory of its own, the [`store`] of its VM, -//! through the [`supervisor`] and the [`session`]: it starts OpenVMM and waits until the guest -//! is ready, reattaches to the VM after a restart, runs workloads, and stops the VM. -//! [`report`] reads OpenVMM's outcome report. -//! - [`store`] keeps the runtime records of a sandbox in a private directory with one lock: the -//! runtime record of its VM, with the VM's runtime identity, and the journal of its launch, -//! through which an interrupted launch is recovered without signaling an unverified process. -//! - [`supervisor`] launches a sandbox's VMM through the store's journal, passing it only the -//! descriptors or handles that the caller allows, stops it through the caller's graceful step, -//! with a bounded forced step on request, and reattaches to it after the orchestrator restarts -//! within the same host boot. The private `spawn` module, whose items `supervisor` re-exports, -//! starts the VMM detached, with no other descriptor or handle of the caller's. +//! - [`report`] reads OpenVMM's outcome report. +//! - [`supervisor`] is the transitional public path, until SL6.3 (#1122), of the low-level spawn +//! of the private `spawn` module, which starts a VMM detached, passing it only the descriptors +//! or handles that the caller allows, and no other descriptor or handle of the caller's. //! - [`snapshot`] holds the compatibility key, [`SnapshotKey`](snapshot::SnapshotKey), derived //! from a [`CaptureClass`](snapshot::CaptureClass) and [`SnapshotManifest`](snapshot::SnapshotManifest), //! the strict manifest reader, and the capture's public time and eligibility information. @@ -62,7 +54,7 @@ //! `std::path` and `std::fs`, for the sandbox launch profile and the CLI. //! //! The children of `host` depend on one another without cycles: each builds only on children -//! beneath it, as the [`supervisor`] does on the [`store`], and the store on `spawn`. 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. @@ -80,7 +72,6 @@ pub mod artifacts; pub mod cli; pub mod config; pub mod filesystem; -pub mod instance; pub mod launch; pub mod network; pub mod nvxs; @@ -97,7 +88,6 @@ pub mod seal; pub mod session; pub mod snapshot; mod spawn; -pub mod store; pub mod supervisor; #[cfg(unix)] #[allow(unsafe_code)] diff --git a/nvx-rs/src/host/platform/inherit.rs b/nvx-rs/src/host/platform/inherit.rs index b41529a62..3b70684a2 100644 --- a/nvx-rs/src/host/platform/inherit.rs +++ b/nvx-rs/src/host/platform/inherit.rs @@ -48,11 +48,10 @@ thread_local! { /// holds them until it exits. It could read the VMM's capability before the VMM does, and it /// holds the VMM's log, and the handles that the VMM is allowed, such as the claim of a scratch /// image, after the VMM has exited. It holds no claim of the VMM's launch on the log, which the -/// VMM's job then decides, as [the store](crate::host::store#the-log-claim) describes, so a -/// recovery that finds the VMM gone still ends the launch. Each spawn of a VMM holds this lock -/// throughout that window, so a process that the caller spawns under it, as [`spawn_command`] -/// spawns one, inherits none of those handles, and one that the caller spawns otherwise still -/// can. +/// VMM's job then decides, so a recovery that finds the VMM gone still ends the launch. Each spawn +/// of a VMM holds this lock throughout that window, so a process that the caller spawns under it, +/// as [`spawn_command`] spawns one, inherits none of those handles, and one that the caller spawns +/// otherwise still can. /// /// On other hosts it holds nothing. On Linux, a process that another thread spawns holds a copy /// of a VMM's log only from its fork until it executes its program, which closes the copy, and @@ -64,12 +63,12 @@ thread_local! { /// while spawning. #[must_use = "the lock is released when it is dropped"] #[derive(Debug)] -pub struct SpawnLock(PhantomData>); +pub(crate) struct SpawnLock(PhantomData>); /// Waits until no spawn of a VMM of this process has handles inheritable for it, and returns a /// [`SpawnLock`], which keeps the spawns of VMMs of other threads from making any inheritable /// until it is dropped, as it describes. -pub fn lock_spawns() -> SpawnLock { +pub(crate) fn lock_spawns() -> SpawnLock { #[cfg(windows)] HELD.with_borrow_mut(|(locks, held)| { if *locks == 0 { @@ -94,14 +93,13 @@ impl Drop for SpawnLock { } } -/// Spawns `command`, as [`Command::spawn`] does, under a [`SpawnLock`], so that the process +/// Spawns `command`, as [`Command::spawn`] does, under the crate's spawn lock, so that the process /// inherits no handle of a VMM that this process spawns meanwhile. /// -/// A caller that launches VMMs on Windows should spawn its other processes this way, or under a -/// [`SpawnLock`] of its own: a process that it spawns otherwise while a VMM starts can hold the -/// VMM's handles until it exits, its capability's pipe, its log, and the handles that it allows, -/// as [`SpawnLock`] describes. The lock lasts only until the process is created, not until it -/// exits. +/// A caller that launches VMMs on Windows should spawn its other processes this way: a process +/// that it spawns otherwise while a VMM starts can hold the VMM's handles until it exits, its +/// capability's pipe, its log, and the handles that it allows. The lock lasts only until the +/// process is created, not until it exits. pub fn spawn_command(command: &mut Command) -> io::Result { let _lock = lock_spawns(); command.spawn() @@ -132,11 +130,10 @@ struct Borrowed<'a>(std::convert::Infallible, PhantomData<&'a ()>); /// opens it again as `/proc/self/fd/N`, which `proc_path` returns. /// - On Windows, the handle joins the explicit list of handles that the VMM inherits, and is /// inheritable only while the VMM is created, after which the spawn restores its earlier -/// inheritability. Spawns of VMMs take that window one at a time, under a [`SpawnLock`], so -/// several may allow the same handle. A process that another thread creates in that window +/// inheritability. Spawns of VMMs take that window one at a time, under the crate's spawn lock, +/// so several may allow the same handle. A process that another thread creates in that window /// without such a list, as `std::process::Command` does, can inherit it too, and holds it until -/// it exits, unless the caller spawns that process under a [`SpawnLock`], as [`spawn_command`] -/// does. +/// it exits, unless the caller spawns that process with [`spawn_command`]. /// /// Other hosts fail a spawn that passes any. #[derive(Debug, Clone, Copy)] diff --git a/nvx-rs/src/host/platform/linux/endpoint.rs b/nvx-rs/src/host/platform/linux/endpoint.rs index 699f58def..6d29c2bca 100644 --- a/nvx-rs/src/host/platform/linux/endpoint.rs +++ b/nvx-rs/src/host/platform/linux/endpoint.rs @@ -7,6 +7,7 @@ use std::os::fd::{AsFd, AsRawFd}; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::Path; +#[cfg(feature = "cli")] use std::thread; use std::time::{Duration, Instant}; @@ -16,14 +17,6 @@ use nix::sys::socket::{MsgFlags, getsockopt, recv, sockopt::PeerPidfd}; use crate::host::platform::{Transport, TransportHandle, time_left}; use crate::host::sys; -/// Returns the control endpoint OpenVMM should listen on for a sandbox. -pub(crate) fn control_endpoint(socket_path: &Path) -> io::Result { - socket_path - .to_str() - .map(str::to_owned) - .ok_or_else(|| io::Error::other("control socket path is not valid UTF-8")) -} - /// The listener at which a restore awaits the restore-ready event of its VMM: a Unix /// socket, which the VMM connects to once, and which queues its connection until the host /// accepts it, then the connection that the host accepted, from which it reads the event. @@ -312,12 +305,8 @@ pub(crate) fn interrupt_transfers(handle: &TransportHandle) -> io::Result<()> { } } -/// Returns the process that listens on a control endpoint, or `None` if nothing listens. -pub(crate) fn endpoint_server_pid(endpoint: &str) -> io::Result> { - endpoint_server_pid_until(endpoint, Instant::now() + Duration::from_secs(5)) -} - /// Looks up the endpoint owner within the caller's absolute deadline. +#[cfg(feature = "cli")] pub(crate) fn endpoint_server_pid_until( endpoint: &str, deadline: Instant, diff --git a/nvx-rs/src/host/platform/linux/identity.rs b/nvx-rs/src/host/platform/linux/identity.rs index c9c39b159..18cd920f4 100644 --- a/nvx-rs/src/host/platform/linux/identity.rs +++ b/nvx-rs/src/host/platform/linux/identity.rs @@ -1,13 +1,16 @@ //! The identities of files and directories, whether a file is deleted, and the open directories //! through which operations name their entries. -use std::fs::{self, File, OpenOptions}; +use std::fs; +#[cfg(feature = "cli")] +use std::fs::{File, OpenOptions}; use std::io; -use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; +use std::os::unix::fs::MetadataExt; +#[cfg(feature = "cli")] +use std::os::unix::fs::OpenOptionsExt; use std::path::Path; -use nix::sys::stat::Mode; - +#[cfg(feature = "cli")] use crate::host::sys::Errno; /// Returns the device and inode of a file or directory. @@ -18,11 +21,13 @@ pub fn file_identity(path: &Path) -> io::Result<(u64, u64)> { /// Returns whether the file at `path` is deleted: whether no file has that name, which a removal /// takes at once. +#[cfg(feature = "cli")] pub(crate) fn is_deleted(path: &Path) -> bool { fs::symlink_metadata(path).is_err_and(|error| error.kind() == io::ErrorKind::NotFound) } /// Returns the device and inode of the file that `file` holds open. +#[cfg(feature = "cli")] pub(crate) fn opened_file_identity(file: &File) -> io::Result<(u64, u64)> { let metadata = file.metadata()?; Ok((metadata.dev(), metadata.ino())) @@ -30,6 +35,7 @@ pub(crate) fn opened_file_identity(file: &File) -> io::Result<(u64, u64)> { /// Returns the identity by which the stores tell a file or directory apart from every other, /// following its links: its device and inode, as [`opened_file_id`] gives them. +#[cfg(feature = "cli")] pub(crate) fn file_id(path: &Path) -> io::Result<(u64, u128)> { let metadata = fs::metadata(path)?; Ok((metadata.dev(), u128::from(metadata.ino()))) @@ -37,6 +43,7 @@ pub(crate) fn file_id(path: &Path) -> io::Result<(u64, u128)> { /// Returns the identity by which the stores tell the file that `file` holds open apart from every /// other: its device and inode, as its [`FileSeal`](crate::host::seal::FileSeal) holds them. +#[cfg(feature = "cli")] pub(crate) fn opened_file_id(file: &File) -> io::Result<(u64, u128)> { let metadata = file.metadata()?; Ok((metadata.dev(), u128::from(metadata.ino()))) @@ -44,8 +51,10 @@ pub(crate) fn opened_file_id(file: &File) -> io::Result<(u64, u128)> { /// An open directory, through which operations name its entries, so that they reach the entries /// of the directory that it opened, whatever its path names meanwhile. +#[cfg(feature = "cli")] pub(crate) struct Directory(File); +#[cfg(feature = "cli")] impl Directory { /// Opens the directory `path`, following its links. pub(crate) fn open(path: &Path) -> io::Result { @@ -71,87 +80,6 @@ impl Directory { } } - /// Opens its entry `name` for reading, not following a link, and without waiting, as an open - /// of a FIFO without a writer would, or returns `None` if it has no such entry. The caller - /// checks what the entry is. - pub(crate) fn open_file(&self, name: &str) -> io::Result> { - use nix::fcntl::{OFlag, openat}; - - match openat( - &self.0, - name, - OFlag::O_RDONLY | OFlag::O_NOFOLLOW | OFlag::O_NONBLOCK | OFlag::O_CLOEXEC, - Mode::empty(), - ) { - Ok(file) => Ok(Some(File::from(file))), - Err(Errno::ENOENT) => Ok(None), - Err(errno) => Err(errno.into()), - } - } - - /// Opens the directory `path` with the right to search it alone, without following a link at - /// its own name, though links on the way to it are followed, or returns `None` if it does not - /// exist. Fails if it is a link, or not a directory. - /// - /// The handle names the directory's entries, and needs no right to read the directory, which - /// an owner-only directory may withhold; it cannot [sync](Self::sync) it. - pub(crate) fn search(path: &Path) -> io::Result> { - use nix::fcntl::OFlag; - - match OpenOptions::new() - .read(true) - .custom_flags((OFlag::O_PATH | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW).bits()) - .open(path) - { - Ok(dir) => Ok(Some(Self(dir))), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), - Err(error) - if [Errno::ELOOP, Errno::ENOTDIR] - .map(|errno| Some(errno as i32)) - .contains(&error.raw_os_error()) => - { - Err(io::Error::other(format!( - "{} is a link, or not a directory", - path.display() - ))) - } - Err(error) => Err(error), - } - } - - /// Opens its subdirectory `name` with the right to search it alone, not following a link, as - /// [`search`](Self::search) opens a directory, or returns `None` if it has no such entry. - /// Fails if the entry is a link, or not a directory. - pub(crate) fn search_dir(&self, name: &str) -> io::Result> { - self.open_dir_with(name, nix::fcntl::OFlag::O_PATH) - } - - /// Opens its subdirectory `name` for reading, not following a link, or returns `None` if it - /// has no such entry. Fails if the entry is a link, or not a directory. - pub(crate) fn open_dir(&self, name: &str) -> io::Result> { - self.open_dir_with(name, nix::fcntl::OFlag::O_RDONLY) - } - - /// Opens its subdirectory `name` with the access `access`, not following a link, as - /// [`open_dir`](Self::open_dir) and [`search_dir`](Self::search_dir) do. - fn open_dir_with(&self, name: &str, access: nix::fcntl::OFlag) -> io::Result> { - use nix::fcntl::{OFlag, openat}; - - match openat( - &self.0, - name, - access | OFlag::O_DIRECTORY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC, - Mode::empty(), - ) { - Ok(dir) => Ok(Some(Self(File::from(dir)))), - Err(Errno::ENOENT) => Ok(None), - Err(Errno::ELOOP | Errno::ENOTDIR) => Err(io::Error::other(format!( - "{name} is a link, or not a directory" - ))), - Err(errno) => Err(errno.into()), - } - } - /// Removes its file `name`, if it has one. pub(crate) fn remove_file(&self, name: &str) -> io::Result<()> { use nix::unistd::{UnlinkatFlags, unlinkat}; diff --git a/nvx-rs/src/host/platform/linux/mod.rs b/nvx-rs/src/host/platform/linux/mod.rs index 69fd736a1..63d87da00 100644 --- a/nvx-rs/src/host/platform/linux/mod.rs +++ b/nvx-rs/src/host/platform/linux/mod.rs @@ -1,5 +1,6 @@ mod endpoint; mod identity; +#[cfg(feature = "cli")] mod log_claim; mod private; mod process; @@ -14,25 +15,24 @@ use crate::hypervisor::Hypervisor; #[cfg(feature = "cli")] pub(crate) use crate::host::sys::{interrupted, watch_interrupts, write_interruptibly}; -pub(crate) use self::endpoint::{ - ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, listen_ready, -}; +pub(crate) use self::endpoint::{ReadyListener, connect_endpoint, listen_ready}; #[cfg(feature = "cli")] pub(crate) use self::endpoint::{ connect_child_endpoint, endpoint_server_pid_until, interrupt_transfers, transport_from_handle, }; pub 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; -#[cfg(feature = "cli")] -pub(crate) use self::private::rename_no_replace; pub(crate) use self::private::{ check_snapshot_dir, check_store_dir, create_store_dir, make_sparse, private_file_problem, - restore_private_file_mode, }; +#[cfg(feature = "cli")] +pub(crate) use self::private::{rename_no_replace, restore_private_file_mode}; pub 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, diff --git a/nvx-rs/src/host/platform/linux/private.rs b/nvx-rs/src/host/platform/linux/private.rs index 6aee426bc..453e09613 100644 --- a/nvx-rs/src/host/platform/linux/private.rs +++ b/nvx-rs/src/host/platform/linux/private.rs @@ -163,6 +163,7 @@ pub(crate) fn rename_no_replace(from: &Path, to: &Path) -> io::Result<()> { /// anything else, which includes a symbolic link or a missing file, unchanged. /// /// The file is opened only to locate it, which needs no permission on the file itself. +#[cfg(feature = "cli")] pub(crate) fn restore_private_file_mode(path: &Path, mode: Mode) -> io::Result<()> { use nix::fcntl::OFlag; diff --git a/nvx-rs/src/host/platform/linux/tests.rs b/nvx-rs/src/host/platform/linux/tests.rs index ed5a84da3..9819c9203 100644 --- a/nvx-rs/src/host/platform/linux/tests.rs +++ b/nvx-rs/src/host/platform/linux/tests.rs @@ -6,13 +6,16 @@ use std::process::{Command, Stdio}; use std::thread; use std::time::{Duration, Instant}; -use super::endpoint::{UnixTransport, endpoint_server_pid_until}; +use super::endpoint::UnixTransport; +#[cfg(feature = "cli")] +use super::endpoint::endpoint_server_pid_until; pub(crate) use super::private::store_dir_problem; use super::process::wait_step; pub(crate) use super::process::{check_identity, start_time_from_stat}; use super::*; use crate::host::platform::Transport; +#[cfg(feature = "cli")] #[test] fn a_full_control_backlog_never_replaces_the_callers_probe_deadline() { use std::os::unix::net::UnixListener; @@ -58,7 +61,7 @@ fn a_full_control_backlog_never_replaces_the_callers_probe_deadline() { std::io::ErrorKind::WouldBlock ); assert_eq!( - endpoint_server_pid(endpoint).unwrap(), + endpoint_server_pid_until(endpoint, Instant::now() + Duration::from_secs(5)).unwrap(), Some(std::process::id()) ); drop(listener); diff --git a/nvx-rs/src/host/platform/other.rs b/nvx-rs/src/host/platform/other.rs index 928518f46..62a2d310f 100644 --- a/nvx-rs/src/host/platform/other.rs +++ b/nvx-rs/src/host/platform/other.rs @@ -100,27 +100,33 @@ pub fn file_identity(_path: &Path) -> io::Result<(u64, u64)> { /// Returns whether the file at `path` is deleted: whether no file has that name, which a removal /// takes at once. +#[cfg(feature = "cli")] pub(crate) fn is_deleted(path: &Path) -> bool { fs::symlink_metadata(path).is_err_and(|error| error.kind() == io::ErrorKind::NotFound) } +#[cfg(feature = "cli")] pub(crate) fn opened_file_identity(_file: &File) -> io::Result<(u64, u64)> { Err(unsupported()) } /// Fails: this host keeps no store. +#[cfg(feature = "cli")] pub(crate) fn file_id(_path: &Path) -> io::Result<(u64, u128)> { Err(unsupported()) } /// Fails: this host keeps no store. +#[cfg(feature = "cli")] pub(crate) fn opened_file_id(_file: &File) -> io::Result<(u64, u128)> { Err(unsupported()) } /// An open directory, which this host never holds: it keeps no sandbox's store. +#[cfg(feature = "cli")] pub(crate) enum Directory {} +#[cfg(feature = "cli")] impl Directory { /// Fails: this host keeps no sandbox's store. pub(crate) fn open(_path: &Path) -> io::Result { @@ -132,26 +138,6 @@ impl Directory { match *self {} } - /// Never called: no directory is open. - pub(crate) fn open_file(&self, _name: &str) -> io::Result> { - match *self {} - } - - /// Never called: no directory is open. - pub(crate) fn open_dir(&self, _name: &str) -> io::Result> { - match *self {} - } - - /// Fails: this host keeps no store. - pub(crate) fn search(_path: &Path) -> io::Result> { - Err(unsupported()) - } - - /// Never called: no directory is open. - pub(crate) fn search_dir(&self, _name: &str) -> io::Result> { - match *self {} - } - /// Never called: no directory is open. pub(crate) fn remove_file(&self, _name: &str) -> io::Result<()> { match *self {} @@ -163,14 +149,17 @@ impl Directory { } } +#[cfg(feature = "cli")] pub(crate) fn claim_log(_file: &File) -> io::Result<()> { Err(unsupported()) } +#[cfg(feature = "cli")] pub(crate) fn log_claim_held(_path: &Path, _identity: (u64, u64)) -> io::Result { Err(unsupported()) } +#[cfg(feature = "cli")] pub(crate) fn serves_launch(_pid: u32, _start_time: u64, _log: (u64, u64)) -> io::Result { Err(unsupported()) } @@ -237,10 +226,6 @@ pub fn create_private_dir(path: &Path) -> io::Result<()> { } } -pub(crate) fn control_endpoint(_socket_path: &Path) -> io::Result { - Err(unsupported()) -} - /// The listener of the restore-ready endpoint of a restore, which this host never makes. #[derive(Debug)] pub(crate) struct ReadyListener; @@ -269,10 +254,6 @@ pub(crate) fn connect_endpoint( Err(unsupported()) } -pub(crate) fn endpoint_server_pid(_endpoint: &str) -> io::Result> { - Ok(None) -} - #[cfg(feature = "cli")] pub(crate) fn endpoint_server_pid_until( _endpoint: &str, diff --git a/nvx-rs/src/host/platform/tests.rs b/nvx-rs/src/host/platform/tests.rs index f39608e1d..1d03dfacf 100644 --- a/nvx-rs/src/host/platform/tests.rs +++ b/nvx-rs/src/host/platform/tests.rs @@ -7,9 +7,9 @@ pub(crate) use super::windows::tests::set_access; #[cfg(windows)] pub(crate) use super::windows::tests::{accept_control, control_server, wait_exit_handle}; #[cfg(windows)] -pub(crate) use super::windows::tests::{ - access, claim_released_within, inherits_file, untrusted_log_job, user_sid, with_spawn_window, -}; +pub(crate) use super::windows::tests::{access, inherits_file, untrusted_log_job, user_sid}; +#[cfg(all(windows, feature = "cli"))] +pub(crate) use super::windows::tests::{claim_released_within, with_spawn_window}; use super::*; #[test] @@ -175,7 +175,7 @@ fn the_hosts_identity_and_monotonic_time_are_read() { /// The stores tell files apart by the identities that their seals hold, with the 128-bit file /// IDs of `FILE_ID_INFO` on Windows, whether they examine a path, an open file, or an entry of /// an open directory. -#[cfg(any(target_os = "linux", windows))] +#[cfg(all(any(target_os = "linux", windows), feature = "cli"))] #[test] fn the_stores_tell_files_apart_by_the_identities_that_their_seals_hold() { use crate::host::seal::FileSeal; diff --git a/nvx-rs/src/host/platform/windows/endpoint.rs b/nvx-rs/src/host/platform/windows/endpoint.rs index 9b71cd78e..056a3cb67 100644 --- a/nvx-rs/src/host/platform/windows/endpoint.rs +++ b/nvx-rs/src/host/platform/windows/endpoint.rs @@ -3,17 +3,17 @@ use std::io; use std::os::windows::io::OwnedHandle; -use std::path::Path; use std::ptr; use std::thread; use std::time::{Duration, Instant}; use windows_sys::Win32::Foundation::{ - ERROR_BROKEN_PIPE, ERROR_FILE_NOT_FOUND, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_NO_DATA, - ERROR_OPERATION_ABORTED, ERROR_PIPE_BUSY, ERROR_PIPE_CONNECTED, ERROR_PIPE_NOT_CONNECTED, - ERROR_SEM_TIMEOUT, GENERIC_READ, GENERIC_WRITE, HANDLE, WAIT_EVENT, WAIT_OBJECT_0, - WAIT_TIMEOUT, + ERROR_BROKEN_PIPE, ERROR_IO_PENDING, ERROR_MORE_DATA, ERROR_NO_DATA, ERROR_OPERATION_ABORTED, + ERROR_PIPE_BUSY, ERROR_PIPE_CONNECTED, ERROR_PIPE_NOT_CONNECTED, GENERIC_READ, GENERIC_WRITE, + HANDLE, WAIT_EVENT, WAIT_OBJECT_0, WAIT_TIMEOUT, }; +#[cfg(feature = "cli")] +use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_SEM_TIMEOUT}; use windows_sys::Win32::Storage::FileSystem::{ CreateFileW, FILE_ALL_ACCESS, FILE_FLAG_FIRST_PIPE_INSTANCE, FILE_FLAG_OVERLAPPED, OPEN_EXISTING, PIPE_ACCESS_INBOUND, ReadFile, SECURITY_IDENTIFICATION, SECURITY_SQOS_PRESENT, @@ -33,14 +33,6 @@ use super::process::wait_step; use super::{last_error, owned, raw, wide}; use crate::host::platform::{Transport, TransportHandle, time_left}; -/// Returns a fresh named-pipe endpoint for OpenVMM to listen on. -pub(crate) fn control_endpoint(_socket_path: &Path) -> io::Result { - let mut bytes = [0u8; 16]; - getrandom::fill(&mut bytes).map_err(io::Error::other)?; - let suffix: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); - Ok(format!("//./pipe/openvmm-microvm-{suffix}")) -} - /// The bytes that the inbound buffer of a restore-ready pipe holds, more than OpenVMM's event. const READY_BUFFER_LEN: u32 = 4096; @@ -249,16 +241,13 @@ pub(crate) fn interrupt_transfers(handle: &TransportHandle) -> io::Result<()> { Ok(()) } -/// Returns the process that serves a named-pipe endpoint, or `None` if the pipe does not exist. +/// Returns the process that serves a named-pipe endpoint, or `None` if the pipe does not exist, by +/// the caller's absolute deadline. /// /// OpenVMM keeps one pipe instance for its whole lifetime, so a missing pipe means no OpenVMM /// serves the endpoint. The instance is busy while a client is attached and briefly between -/// clients, which is waited out for up to five seconds. -pub(crate) fn endpoint_server_pid(endpoint: &str) -> io::Result> { - endpoint_server_pid_until(endpoint, Instant::now() + Duration::from_secs(5)) -} - -/// Looks up the endpoint owner within the caller's absolute deadline. +/// clients, which is waited out until the deadline. +#[cfg(feature = "cli")] pub(crate) fn endpoint_server_pid_until( endpoint: &str, deadline: Instant, diff --git a/nvx-rs/src/host/platform/windows/endpoint/tests.rs b/nvx-rs/src/host/platform/windows/endpoint/tests.rs index 43b646b29..1c2af5d94 100644 --- a/nvx-rs/src/host/platform/windows/endpoint/tests.rs +++ b/nvx-rs/src/host/platform/windows/endpoint/tests.rs @@ -70,7 +70,7 @@ impl StaleServer { let endpoint = format!( r"\\.\pipe\nvx-stale-probe-{}-{}", std::process::id(), - crate::host::store::LaunchId::generate().unwrap().as_str() + crate::host::tests::unique_suffix() ); // SAFETY: this creates an unnamed, non-inheritable job for this fixture alone. let job = owned(unsafe { CreateJobObjectW(ptr::null(), ptr::null()) }) @@ -337,12 +337,13 @@ fn stale_probe_accept_helper() { (&server).write_all(&[9]).unwrap(); } +#[cfg(feature = "cli")] #[test] fn a_busy_control_pipe_never_replaces_the_callers_probe_deadline() { let endpoint = format!( r"\\.\pipe\nvx-probe-budget-{}-{}", std::process::id(), - crate::host::store::LaunchId::generate().unwrap().as_str() + crate::host::tests::unique_suffix() ); let server = control_server(&endpoint); let client = connect_endpoint(&endpoint, std::process::id(), Duration::from_secs(2)).unwrap(); @@ -372,7 +373,7 @@ fn a_busy_control_pipe_never_replaces_the_callers_probe_deadline() { ); let server = control_server(&endpoint); assert_eq!( - endpoint_server_pid(&endpoint).unwrap(), + endpoint_server_pid_until(&endpoint, Instant::now() + Duration::from_secs(5)).unwrap(), Some(std::process::id()) ); drop(server); @@ -383,7 +384,7 @@ fn a_duplicated_transport_keeps_the_verified_connection_without_reconnecting() { let endpoint = format!( r"\\.\pipe\nvx-transport-{}-{}", std::process::id(), - crate::host::store::LaunchId::generate().unwrap().as_str() + crate::host::tests::unique_suffix() ); let server = control_server(&endpoint); let transport = diff --git a/nvx-rs/src/host/platform/windows/identity.rs b/nvx-rs/src/host/platform/windows/identity.rs index 1ee230a2d..1ea897a3c 100644 --- a/nvx-rs/src/host/platform/windows/identity.rs +++ b/nvx-rs/src/host/platform/windows/identity.rs @@ -1,21 +1,29 @@ //! The identities of files and directories, whether a file is deleted, and the open directories //! through which operations name their entries. +#[cfg(feature = "cli")] use std::ffi::OsStr; use std::fs::{self, OpenOptions}; use std::io; +use std::mem; +#[cfg(feature = "cli")] use std::os::windows::ffi::OsStrExt; use std::os::windows::fs::OpenOptionsExt; -use std::os::windows::io::{AsRawHandle, OwnedHandle}; +use std::os::windows::io::AsRawHandle; +#[cfg(feature = "cli")] +use std::os::windows::io::OwnedHandle; use std::path::Path; -use std::{mem, ptr}; +#[cfg(feature = "cli")] +use std::ptr; -use windows_sys::Win32::Foundation::{ERROR_INVALID_PARAMETER, HANDLE}; +#[cfg(feature = "cli")] +use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER; +use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Storage::FileSystem::{ - BY_HANDLE_FILE_INFORMATION, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, GetFileInformationByHandle, + BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, GetFileInformationByHandle, }; +#[cfg(feature = "cli")] use super::{owned, raw}; /// Returns the volume serial number and 64-bit file index of a file or directory, which a file @@ -34,6 +42,7 @@ pub fn file_identity(path: &Path) -> io::Result<(u64, u64)> { /// following its links, as [`opened_file_id`] gives it. /// /// The object is opened without access rights, which succeeds even while OpenVMM holds it open. +#[cfg(feature = "cli")] pub(crate) fn file_id(path: &Path) -> io::Result<(u64, u128)> { let file = OpenOptions::new() .access_mode(0) @@ -49,6 +58,7 @@ pub(crate) fn file_id(path: &Path) -> io::Result<(u64, u128)> { /// /// Win32 reports a pending deletion as a denied access, so the file is opened with the native API, /// whose status tells the two apart, relative to its directory, which spares a native path. +#[cfg(feature = "cli")] pub(crate) fn is_deleted(path: &Path) -> bool { use windows_sys::Wdk::Storage::FileSystem::FILE_OPEN_REPARSE_POINT; use windows_sys::Win32::Foundation::{STATUS_DELETE_PENDING, STATUS_OBJECT_NAME_NOT_FOUND}; @@ -77,8 +87,10 @@ pub(crate) fn is_deleted(path: &Path) -> bool { /// /// They use the native API, whose status tells a file whose deletion is pending from one whose /// access control list refuses the open, which Win32 both reports as a denied access. +#[cfg(feature = "cli")] pub(crate) struct Directory(fs::File); +#[cfg(feature = "cli")] impl Directory { /// Opens the directory `path`, following its links, without access rights, which the opens of /// its entries do not need. @@ -110,89 +122,6 @@ impl Directory { } } - /// Opens its entry `name` for reading, not following a link or other reparse point, or - /// returns `None` if it has no such entry, or one whose deletion is pending, which a file - /// system without POSIX deletion keeps under its name until its last handle closes, and which - /// is removed. The caller checks what the entry is. - pub(crate) fn open_file(&self, name: &str) -> io::Result> { - use windows_sys::Wdk::Storage::FileSystem::{ - FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, - }; - use windows_sys::Win32::Foundation::{STATUS_DELETE_PENDING, STATUS_OBJECT_NAME_NOT_FOUND}; - use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; - - match self.open_entry( - OsStr::new(name), - FILE_GENERIC_READ, - FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, - ) { - Ok(entry) => Ok(Some(fs::File::from(entry))), - Err(STATUS_OBJECT_NAME_NOT_FOUND | STATUS_DELETE_PENDING) => Ok(None), - Err(status) => Err(status_error(status)), - } - } - - /// Opens the directory `path` without following a link or other reparse point at its own - /// name, though those on the way to it are followed, and without access rights, which the - /// opens of its entries do not need, or returns `None` if it does not exist. Fails if it is a - /// link, a junction or another reparse point, or not a directory. - pub(crate) fn search(path: &Path) -> io::Result> { - let opened = OpenOptions::new() - .access_mode(0) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) - .open(path); - let dir = match opened { - Ok(dir) => dir, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(error) => return Err(error), - }; - let attributes = handle_information(dir.as_raw_handle() as HANDLE)?.dwFileAttributes; - if attributes & FILE_ATTRIBUTE_DIRECTORY == 0 - || attributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 - { - return Err(io::Error::other(format!( - "{} is a link, or not a directory", - path.display() - ))); - } - Ok(Some(Self(dir))) - } - - /// Opens its subdirectory `name` as [`open_dir`](Self::open_dir) does: Windows needs no right - /// to list a directory to name its entries. - pub(crate) fn search_dir(&self, name: &str) -> io::Result> { - self.open_dir(name) - } - - /// Opens its subdirectory `name`, not following a link or other reparse point, or returns - /// `None` if it has no such entry, or one whose deletion is pending. Fails if the entry is a - /// link, a junction or another reparse point, or not a directory. - pub(crate) fn open_dir(&self, name: &str) -> io::Result> { - use windows_sys::Wdk::Storage::FileSystem::{ - FILE_DIRECTORY_FILE, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, - }; - use windows_sys::Win32::Foundation::{ - STATUS_DELETE_PENDING, STATUS_NOT_A_DIRECTORY, STATUS_OBJECT_NAME_NOT_FOUND, - }; - use windows_sys::Win32::Storage::FileSystem::{FILE_READ_ATTRIBUTES, SYNCHRONIZE}; - - let refused = || io::Error::other(format!("{name} is a link, or not a directory")); - let entry = match self.open_entry( - OsStr::new(name), - FILE_READ_ATTRIBUTES | SYNCHRONIZE, - FILE_DIRECTORY_FILE | FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT, - ) { - Ok(entry) => entry, - Err(STATUS_OBJECT_NAME_NOT_FOUND | STATUS_DELETE_PENDING) => return Ok(None), - Err(STATUS_NOT_A_DIRECTORY) => return Err(refused()), - Err(status) => return Err(status_error(status)), - }; - if handle_information(raw(&entry))?.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 { - return Err(refused()); - } - Ok(Some(Self(fs::File::from(entry)))) - } - /// Removes its file `name`, if it has one. A file whose deletion is pending already, which a /// file system without POSIX deletion keeps under its name until its last handle closes, /// counts as removed. @@ -280,6 +209,7 @@ impl Directory { /// Deletes the file that `file`, opened with the right to delete it, holds: with POSIX semantics /// where the file system supports them, so that its name goes at once, even while other handles /// hold it, and otherwise once the last handle to it closes. +#[cfg(feature = "cli")] fn delete_opened(file: &OwnedHandle) -> io::Result<()> { use windows_sys::Win32::Foundation::{ERROR_INVALID_FUNCTION, ERROR_NOT_SUPPORTED}; use windows_sys::Win32::Storage::FileSystem::{ @@ -338,6 +268,7 @@ pub(super) fn status_error(status: windows_sys::Win32::Foundation::NTSTATUS) -> /// Returns the volume serial number and 64-bit file index of the file that `file` holds open, /// which a file system such as ReFS does not keep unique: [`opened_file_id`] tells files apart. +#[cfg(feature = "cli")] pub(crate) fn opened_file_identity(file: &fs::File) -> io::Result<(u64, u64)> { handle_identity(file.as_raw_handle() as HANDLE) } diff --git a/nvx-rs/src/host/platform/windows/log_claim.rs b/nvx-rs/src/host/platform/windows/log_claim.rs index 23d4ed060..1f9fa708e 100644 --- a/nvx-rs/src/host/platform/windows/log_claim.rs +++ b/nvx-rs/src/host/platform/windows/log_claim.rs @@ -1,25 +1,37 @@ //! The claim of a launch's log: the job object, named after the log, in which its VMM starts, and //! the checks of whether a process of the launch still holds the log. -use std::fs::{self, OpenOptions}; +use std::fs; +#[cfg(feature = "cli")] +use std::fs::OpenOptions; use std::io; +#[cfg(feature = "cli")] use std::os::windows::fs::OpenOptionsExt; use std::os::windows::io::OwnedHandle; +#[cfg(feature = "cli")] use std::path::Path; use std::{mem, ptr}; +#[cfg(feature = "cli")] +use windows_sys::Win32::Foundation::ERROR_SHARING_VIOLATION; use windows_sys::Win32::Foundation::{ ERROR_ACCESS_DENIED, ERROR_ALREADY_EXISTS, ERROR_FILE_NOT_FOUND, ERROR_INVALID_HANDLE, - ERROR_SHARING_VIOLATION, ERROR_SUCCESS, HANDLE, SetLastError, + ERROR_SUCCESS, HANDLE, SetLastError, }; -use windows_sys::Win32::Storage::FileSystem::{FILE_FLAG_OPEN_REPARSE_POINT, READ_CONTROL}; +#[cfg(feature = "cli")] +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; +use windows_sys::Win32::Storage::FileSystem::READ_CONTROL; +use windows_sys::Win32::System::JobObjects::{CreateJobObjectW, OpenJobObjectW}; +#[cfg(feature = "cli")] use windows_sys::Win32::System::JobObjects::{ - CreateJobObjectW, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JobObjectBasicAccountingInformation, - OpenJobObjectW, QueryInformationJobObject, + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, JobObjectBasicAccountingInformation, + QueryInformationJobObject, }; use windows_sys::Win32::System::Threading::CreateEventW; -use super::identity::{opened_file_id, opened_file_identity, status_error}; +#[cfg(feature = "cli")] +use super::identity::opened_file_identity; +use super::identity::{opened_file_id, status_error}; use super::private::{CurrentUser, Required, privacy_problem, with_owner_only_attributes}; use super::{last_error, owned, raw, wide}; @@ -30,6 +42,7 @@ use super::{last_error, owned, raw, wide}; /// [`log_claim_held`] describes. /// /// [`spawn_detached`]: crate::host::platform::windows::spawn_detached +#[cfg(feature = "cli")] pub(crate) fn claim_log(_file: &fs::File) -> io::Result<()> { Ok(()) } @@ -138,7 +151,16 @@ enum LogJobName { /// of the user, as [`holds_name`] tells it. Foreign, /// The user's job, open to query its processes. - Users(OwnedHandle), + Users( + #[cfg_attr( + not(feature = "cli"), + expect( + dead_code, + reason = "only the CLI's claim checks query the job's processes" + ) + )] + OwnedHandle, + ), } /// The class of the information of `NtQueryObject` that names an object, which `ntifs.h` @@ -277,6 +299,7 @@ fn log_job_named(name: &[u16]) -> io::Result { /// Returns how many processes the job object of the log `id` holds, or `None` if no job of the /// user takes its name, as [`log_job_named`] judges it. +#[cfg(feature = "cli")] pub(super) fn log_job_processes(id: (u64, u128)) -> io::Result> { let LogJobName::Users(job) = log_job_named(&log_job_name(id))? else { return Ok(None); @@ -324,6 +347,7 @@ pub(super) fn log_job_processes(id: (u64, u128)) -> io::Result> { /// rename between the opens could have replaced the file. /// /// [`spawn_detached`]: crate::host::platform::windows::spawn_detached +#[cfg(feature = "cli")] pub(crate) fn log_claim_held(path: &Path, identity: (u64, u64)) -> io::Result { let replaced = || io::Error::other(format!("{} is not the claimed log", path.display())); let log = OpenOptions::new() @@ -355,11 +379,12 @@ pub(crate) fn log_claim_held(path: &Path, identity: (u64, u64)) -> io::Result io::Result { Ok(true) } diff --git a/nvx-rs/src/host/platform/windows/mod.rs b/nvx-rs/src/host/platform/windows/mod.rs index bf18bc295..a6d996558 100644 --- a/nvx-rs/src/host/platform/windows/mod.rs +++ b/nvx-rs/src/host/platform/windows/mod.rs @@ -22,27 +22,27 @@ use windows_sys::Win32::System::Threading::OpenProcess; use crate::hypervisor::Hypervisor; -pub(crate) use self::endpoint::{ - ReadyListener, connect_endpoint, control_endpoint, endpoint_server_pid, -}; +pub(crate) use self::endpoint::{ReadyListener, connect_endpoint}; #[cfg(feature = "cli")] pub(crate) use self::endpoint::{ endpoint_server_pid_until, interrupt_transfers, transport_from_handle, }; pub 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::interrupt::{interrupted, watch_interrupts}; -pub(crate) use self::log_claim::{claim_log, create_log_job, log_claim_held, serves_launch}; -pub use self::private::create_private_dir; +pub(crate) use self::log_claim::create_log_job; #[cfg(feature = "cli")] -pub(crate) use self::private::rename_no_replace; +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, check_vm_dir, create_store_dir, create_vm_dir, - make_sparse, private_file_problem, + 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; pub(crate) use self::process::{ ProcessHandle, boot_id, host_id, monotonic_ns, open_process, own_bytes_read, diff --git a/nvx-rs/src/host/platform/windows/private.rs b/nvx-rs/src/host/platform/windows/private.rs index 8fea4be75..896e47222 100644 --- a/nvx-rs/src/host/platform/windows/private.rs +++ b/nvx-rs/src/host/platform/windows/private.rs @@ -194,6 +194,7 @@ pub(super) fn with_owner_only_attributes( /// to SYSTEM or the Administrators group, and its access control list must allow no one else, as /// that of a directory under the user's profile does. Its list must also pass entries on to the /// files created in it, which would otherwise take their creator's default list. +#[cfg(feature = "cli")] pub(crate) fn check_store_dir(path: &Path) -> io::Result> { store_dir_problem(path, &CurrentUser::get()?) } diff --git a/nvx-rs/src/host/platform/windows/private/tests.rs b/nvx-rs/src/host/platform/windows/private/tests.rs index ef833c33e..171720f2c 100644 --- a/nvx-rs/src/host/platform/windows/private/tests.rs +++ b/nvx-rs/src/host/platform/windows/private/tests.rs @@ -28,6 +28,7 @@ fn private_directories_support_long_paths() { let directory = parent.join(format!("store-{index}-\u{1f600}")); assert_eq!(create_store_dir(&directory).unwrap(), None); assert_eq!(create_store_dir(&directory).unwrap(), None); + #[cfg(feature = "cli")] assert_eq!(check_store_dir(&directory).unwrap(), None); let file = OpenOptions::new() .write(true) diff --git a/nvx-rs/src/host/platform/windows/tests.rs b/nvx-rs/src/host/platform/windows/tests.rs index 5eefd2213..41becd1f2 100644 --- a/nvx-rs/src/host/platform/windows/tests.rs +++ b/nvx-rs/src/host/platform/windows/tests.rs @@ -3,6 +3,7 @@ use std::ffi::OsString; use std::fs::{self, OpenOptions}; use std::io::{self, Read, Write}; use std::mem; +#[cfg(feature = "cli")] use std::os::windows::fs::OpenOptionsExt; use std::os::windows::io::AsHandle; use std::path::Path; @@ -10,10 +11,11 @@ use std::rc::Rc; use std::thread; use std::time::{Duration, Instant}; +#[cfg(feature = "cli")] +use windows_sys::Win32::Foundation::ERROR_SHARING_VIOLATION; use windows_sys::Win32::Foundation::{ CompareObjectHandles, DUPLICATE_SAME_ACCESS, DuplicateHandle, ERROR_ACCESS_DENIED, - ERROR_INVALID_PARAMETER, ERROR_SHARING_VIOLATION, GetHandleInformation, HANDLE_FLAG_INHERIT, - SetHandleInformation, + ERROR_INVALID_PARAMETER, GetHandleInformation, HANDLE_FLAG_INHERIT, SetHandleInformation, }; use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; use windows_sys::Win32::System::JobObjects::CreateJobObjectW; @@ -26,7 +28,9 @@ use windows_sys::Win32::System::Threading::{ pub(crate) use super::endpoint::tests::disconnect_control; pub(crate) use super::endpoint::tests::{accept_control, control_server}; use super::identity::status_error; -use super::log_claim::{local_directory, log_job_id, log_job_name, log_job_processes, object_name}; +#[cfg(feature = "cli")] +use super::log_claim::log_job_processes; +use super::log_claim::{local_directory, log_job_id, log_job_name, object_name}; pub(crate) use super::private::tests::set_access; pub(crate) use super::private::tests::{access, user_sid}; use super::process::{end_process, wait_step}; @@ -131,7 +135,11 @@ fn a_restore_ready_client_that_closed_before_its_connection_was_accepted_is_read }; // A fresh endpoint and its listener, as a restore binds them. let listen = || { - let endpoint = control_endpoint(Path::new("unused")).unwrap(); + let endpoint = format!( + r"\\.\pipe\nvx-restore-ready-{}-{}", + std::process::id(), + crate::host::tests::unique_suffix() + ); let listener = ReadyListener::bind(&endpoint).unwrap(); (endpoint, listener) }; @@ -581,6 +589,7 @@ fn spawn_locked(command: &mut std::process::Command) -> io::Result>, } +#[cfg(feature = "cli")] impl HeldSpawn { /// Starts the spawn of `cmd /c exit 0`, outside any job, with its log in `directory`, and /// returns once it is in its window. @@ -682,6 +692,7 @@ impl HeldSpawn { /// Returns whether the claim on the log `log`, the file `identity`, is released within /// `timeout`. +#[cfg(feature = "cli")] pub(crate) fn claim_released_within(log: &Path, identity: (u64, u64), timeout: Duration) -> bool { let deadline = Instant::now() + timeout; loop { @@ -695,6 +706,7 @@ pub(crate) fn claim_released_within(log: &Path, identity: (u64, u64), timeout: D } } +#[cfg(feature = "cli")] #[test] fn a_process_spawned_under_the_spawn_lock_does_not_inherit_a_vmms_log() { let directory = tempfile::tempdir().unwrap(); @@ -724,6 +736,7 @@ fn a_process_spawned_under_the_spawn_lock_does_not_inherit_a_vmms_log() { ); } +#[cfg(feature = "cli")] #[test] fn a_process_spawned_without_the_spawn_lock_in_a_window_holds_the_vmms_log() { let directory = tempfile::tempdir().unwrap(); @@ -745,6 +758,7 @@ fn a_process_spawned_without_the_spawn_lock_in_a_window_holds_the_vmms_log() { assert!(released, "the log stayed held after the process exited"); } +#[cfg(feature = "cli")] #[test] fn a_process_spawned_in_the_window_of_a_vmm_in_its_logs_job_holds_the_log_but_not_its_claim() { let directory = tempfile::tempdir().unwrap(); @@ -826,6 +840,7 @@ fn job_with_descriptor(name: &[u16], sddl: &str) -> OwnedHandle { job.unwrap_or_else(|| panic!("cannot create a job: {}", io::Error::last_os_error())) } +#[cfg(feature = "cli")] #[test] fn a_job_that_is_not_the_users_under_the_name_of_a_logs_job_is_not_the_logs() { let directory = tempfile::tempdir().unwrap(); @@ -849,6 +864,7 @@ fn a_job_that_is_not_the_users_under_the_name_of_a_logs_job_is_not_the_logs() { assert!(!log_claim_held(&path, identity).unwrap()); } +#[cfg(feature = "cli")] #[test] fn an_object_of_another_type_under_the_name_of_a_logs_job_is_not_the_logs() { let directory = tempfile::tempdir().unwrap(); @@ -1005,6 +1021,7 @@ fn vm_spawns_refuse_untrusted_log_job_names_before_creating_a_child() { ); } +#[cfg(feature = "cli")] #[test] fn what_a_link_under_the_name_of_a_logs_job_leads_to_is_not_the_logs() { let directory = tempfile::tempdir().unwrap(); @@ -1060,6 +1077,7 @@ fn what_a_link_under_the_name_of_a_logs_job_leads_to_is_not_the_logs() { assert!(!log_claim_held(&path, identity).unwrap()); } +#[cfg(feature = "cli")] #[test] fn a_vmm_never_starts_outside_a_job_that_a_claim_check_takes_for_its_logs() { let directory = tempfile::tempdir().unwrap(); @@ -1087,6 +1105,7 @@ fn a_vmm_whose_log_is_no_file_of_a_disk_volume_starts_without_a_job() { assert!(create_log_job(&pipe).unwrap().is_none()); // A pipe has an identity that a claim check can match, but no file ID: a claim check, as // a spawn does, takes it for a log without a job. + #[cfg(feature = "cli")] assert!(opened_file_identity(&pipe).is_ok()); assert_eq!(log_job_id(&null).unwrap(), None); assert_eq!(log_job_id(&pipe).unwrap(), None); @@ -1122,6 +1141,7 @@ fn the_jobs_of_logs_whose_file_ids_share_their_low_64_bits_are_distinct() { /// Returns whether any process holds the file at `path` open: an open that shares nothing /// then fails with a sharing violation. +#[cfg(feature = "cli")] fn held_open(path: &Path) -> bool { match OpenOptions::new().read(true).share_mode(0).open(path) { Ok(_) => false, @@ -1130,62 +1150,6 @@ fn held_open(path: &Path) -> bool { } } -#[test] -fn a_launch_whose_vmm_exited_ends_while_a_process_spawned_in_its_window_holds_its_log() { - use crate::host::store::{ArtifactIdentity, LaunchId, Recovery, RuntimeIdentity, SandboxStore}; - use crate::host::supervisor::{self, VmmCommand}; - use crate::protocol::CAPABILITY_LEN; - - let directory = tempfile::tempdir().unwrap(); - let store = SandboxStore::create(directory.path().join("sandbox")).unwrap(); - let artifact = |digit: char| ArtifactIdentity::sha256(&digit.to_string().repeat(64)).unwrap(); - let runtime = RuntimeIdentity::new(artifact('a'), artifact('b'), artifact('c')); - let capability = [7; CAPABILITY_LEN]; - let mut locked = store.lock(None).unwrap(); - let mut launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime, &capability) - .unwrap(); - let system = std::env::var_os("SystemRoot").unwrap(); - let cmd = Path::new(&system).join("System32").join("cmd.exe"); - let command = VmmCommand::new( - cmd, - ["/c", "exit", "0"].map(OsString::from).to_vec(), - store.dir(), - ); - // While the VMM is created, another part of the caller spawns a process without the - // spawn lock, as `std::process::Command` spawns one, which inherits the VMM's log. - let other = std::rc::Rc::new(std::cell::RefCell::new(None)); - let spawned = std::rc::Rc::clone(&other); - IN_WINDOW.set(Some(Box::new(move || { - *spawned.borrow_mut() = Some(sleeper().spawn().map(Running)); - }))); - let launched = supervisor::spawn(&command, &capability, launch.take_log().unwrap()); - IN_WINDOW.set(None); - let launched = launched.unwrap(); - let mut other = other - .take() - .expect("no process was spawned in the window") - .unwrap(); - let vmm = launch.record_process(&launched).unwrap(); - launched.detach(); - assert!( - vmm.wait(Instant::now() + WINDOW_TIMEOUT).unwrap().is_some(), - "the stand-in did not exit" - ); - // The VMM has exited, so the launch ends, whatever the other process holds. - let ended = launch.abandon().unwrap(); - let holds = held_open(&store.log_path()); - let running = other.0.try_wait().unwrap().is_none(); - drop(other); - assert!(running, "the process spawned in the window exited early"); - assert!( - holds, - "the process spawned in the window did not hold the log" - ); - assert_eq!(ended, Recovery::Exited); - assert!(store.journal().unwrap().is_none()); -} - #[test] fn a_thread_that_holds_the_spawn_lock_spawns_under_it_until_its_last_lock_is_dropped() { let first = lock_spawns(); diff --git a/nvx-rs/src/host/runtime/mod.rs b/nvx-rs/src/host/runtime/mod.rs index 9310b0342..5485c69d6 100644 --- a/nvx-rs/src/host/runtime/mod.rs +++ b/nvx-rs/src/host/runtime/mod.rs @@ -3,8 +3,9 @@ //! [`probe_hypervisor`] checks, and the [`LaunchOptions`]. //! //! The runtime identity of a VM is the identities of its VMM, kernel, and initramfs, and the -//! versions of the contracts between its host, its VMM, and its guest. The sandbox -//! [`store`](crate::host::store) records it, and re-exports its types. +//! versions of the contracts between its host, its VMM, and its guest. A caller saves it with +//! each VM, in a [`SavedVm`](crate::host::vm::SavedVm), which [`vm::verify`](crate::host::vm::verify) +//! checks against the current one. use std::fmt; use std::path::PathBuf; @@ -227,7 +228,7 @@ impl fmt::Display for IdentityPart { /// build of the host's own code keeps the VM running and reattaches to it. A different runtime /// identity would drive the VM through artifacts or contracts that did not start it, so the VM /// must be stopped instead, through its verified process identity; see -/// [`RuntimeRecord::check_identity`](crate::host::store::RuntimeRecord::check_identity). +/// [`vm::verify`](crate::host::vm::verify). /// /// The caller takes the artifact identities from install or staging metadata, and /// [`RuntimeIdentity::new`] adds the contract versions that this crate speaks. diff --git a/nvx-rs/src/host/sandbox/prepared.rs b/nvx-rs/src/host/sandbox/prepared.rs index 7a3c9f0dc..8924e5e60 100644 --- a/nvx-rs/src/host/sandbox/prepared.rs +++ b/nvx-rs/src/host/sandbox/prepared.rs @@ -353,12 +353,11 @@ impl PreparedLaunch { /// VMM keeps its own opens of the images, and the scratch image's claim, which it inherited. /// /// `vmm` must be the VMM that inherited [`inherited`](Self::inherited), held as its launch - /// holds it: through [`Starting::process`](crate::host::supervisor::Starting::process), or - /// [`Launched::process`](crate::host::supervisor::Launched::process). A VMM that has exited - /// has closed its copies. A launch that failed needs no release: dropping the prepared launch - /// once the launch is torn down closes the checked files, and the VMM's copies went with it. A - /// prepared launch that is dropped without a release, as when its caller ends before the guest - /// is ready, leaves the VMM its copies until it exits. + /// holds it: through [`Launched::process`](crate::host::supervisor::Launched::process). A + /// VMM that has exited has closed its copies. A launch that failed needs no release: dropping + /// the prepared launch once the launch is torn down closes the checked files, and the VMM's + /// copies went with it. A prepared launch that is dropped without a release, as when its + /// caller ends before the guest is ready, leaves the VMM its copies until it exits. /// /// Fails on Windows with [`ErrorKind::Io`](crate::ErrorKind::Io) if the VMM's copy of a /// checked file cannot be closed: for a `vmm` that did not inherit it, which keeps all of its diff --git a/nvx-rs/src/host/snapshot/key.rs b/nvx-rs/src/host/snapshot/key.rs index c8fb3b436..0aeb8fbf0 100644 --- a/nvx-rs/src/host/snapshot/key.rs +++ b/nvx-rs/src/host/snapshot/key.rs @@ -210,27 +210,11 @@ identity!( /// capture-time anchors do not contribute to it. /// /// Its text form, which `Display` writes and `FromStr` parses, is 64 lowercase hexadecimal - /// digits; parsing also accepts uppercase ones. It converts to and from the - /// [`activation::Identity`] by which a clone's lease and durable records name the key of its - /// template, in their [`TemplateReference`](activation::TemplateReference), without loss. + /// digits; parsing also accepts uppercase ones. KeyIdentity, "a template key's identity" ); -impl From for activation::Identity { - /// Returns the identity whose bytes are the key identity's. - fn from(identity: KeyIdentity) -> Self { - Self::from_bytes(identity.0) - } -} - -impl From for KeyIdentity { - /// Returns the key identity whose bytes are the identity's. - fn from(identity: activation::Identity) -> Self { - Self(*identity.as_bytes()) - } -} - identity!( /// The identity of a [`CaptureClass`]: the SHA-256 digest of its canonical encoding, after the /// domain `nvx capture class` and a NUL. diff --git a/nvx-rs/src/host/snapshot/key/tests.rs b/nvx-rs/src/host/snapshot/key/tests.rs index ed735b3a9..8e416fd9e 100644 --- a/nvx-rs/src/host/snapshot/key/tests.rs +++ b/nvx-rs/src/host/snapshot/key/tests.rs @@ -906,21 +906,6 @@ fn keys_of_machines_that_no_manifest_describes_are_refused() { ); } -#[test] -fn a_key_identity_is_the_identity_that_names_a_clones_template() { - let identity = network_key().identity(); - let named = activation::Identity::from(identity); - assert_eq!(named.as_bytes(), identity.as_bytes()); - assert_eq!(named.to_string(), identity.to_string()); - assert_eq!(KeyIdentity::from(named), identity); - // A clone's lease and durable records name its template by generation and key identity. - let reference = activation::TemplateReference { - generation: activation::StoreGeneration::new(std::num::NonZeroU64::MIN), - key: identity.into(), - }; - assert_eq!(KeyIdentity::from(reference.key), identity); -} - #[test] fn identities_have_a_text_form() { let identity = network_key().identity(); diff --git a/nvx-rs/src/host/snapshot/tests.rs b/nvx-rs/src/host/snapshot/tests.rs index ba6520887..a1fada3ca 100644 --- a/nvx-rs/src/host/snapshot/tests.rs +++ b/nvx-rs/src/host/snapshot/tests.rs @@ -1015,9 +1015,9 @@ impl SnapshotFixture { let root = tempfile::tempdir().unwrap(); let dir = root.path().join("snapshot"); - assert_eq!(platform::create_store_dir(&dir).unwrap(), None); + crate::host::vm::create_private_dir(&dir).unwrap(); let openvmm = dir.join(OPENVMM_DIR); - assert_eq!(platform::create_store_dir(&openvmm).unwrap(), None); + crate::host::vm::create_private_dir(&openvmm).unwrap(); let spec = ManifestSpec::template(); let manifest = SnapshotManifest::parse(&spec.encode()).unwrap(); let key = SnapshotKey::new(&class(), &manifest).unwrap(); diff --git a/nvx-rs/src/host/spawn.rs b/nvx-rs/src/host/spawn.rs index 4056545bb..2cee04fa5 100644 --- a/nvx-rs/src/host/spawn.rs +++ b/nvx-rs/src/host/spawn.rs @@ -33,8 +33,8 @@ pub struct VmmCommand<'a> { /// The VMM's working directory. /// /// Windows permits at most 258 UTF-16 code units in its absolute path, including any verbatim - /// prefix and trailing separator. It may differ from the sandbox store, whose paths can be - /// longer. + /// prefix and trailing separator. It may differ from the directories of the files that the + /// VMM uses, whose paths can be longer. pub working_dir: PathBuf, /// Whether the VMM starts outside the caller's job object on Windows, which must permit /// breakaway, so that the VMM survives the job. Other hosts ignore it. @@ -80,11 +80,10 @@ impl VmmCommand<'_> { /// `log` is not a file of a disk volume, such as `NUL` or a pipe, or what takes that name is not /// the user's job. A process that the caller spawns while the VMM is created, which can inherit /// the log as [`Inherited`] describes, never joins the VMM's job, so it holds the log's claim only -/// if the VMM has none: see [the store](crate::host::store#the-log-claim). +/// if the VMM has none. /// -/// Record the VMM's identity right after the spawn, with -/// [`Launch::record_process`](crate::host::store::Launch::record_process), as -/// [`launch`](crate::host::supervisor::launch) does. Fails with [`io::ErrorKind::InvalidInput`] on +/// Record the VMM's identity right after the spawn, through [`Launched::process`], if the caller +/// recovers its launches. Fails with [`io::ErrorKind::InvalidInput`] on /// Linux for a descriptor of a standard stream, 0 to 2, or one that is not close-on-exec, and on /// Windows for a working directory whose absolute path exceeds 258 UTF-16 code units, including /// any verbatim prefix and trailing separator, or for a batch file, which Windows would run @@ -95,7 +94,8 @@ impl VmmCommand<'_> { /// [`io::ErrorKind::Unsupported`] for any descriptor or handle on hosts other than Linux and /// Windows. /// -/// On Windows, give each VMM a log file of its own, created anew, as a store's launch does. The +/// On Windows, give each VMM a log file of its own, created anew, as +/// [`vm::create`](crate::host::vm::create) does. The /// job of `log` is named after the file's identity, which the file keeps when it is truncated, as /// [`File::create`](std::fs::File::create) truncates an existing file, and its name stays taken /// while another spawn with the file holds the job, or while a process that inherited the job's @@ -214,8 +214,7 @@ fn spawn_inner( /// process until [`wait_timeout`](Self::wait_timeout) reaps it; hold it with /// [`process`](Self::process) before waiting to keep a verified handle after it exits. /// -/// Record it with [`Launch::record_process`](crate::host::store::Launch::record_process), or hold -/// it with [`process`](Self::process). Then release it with [`detach`](Self::detach), or end it +/// Hold it with [`process`](Self::process). Then release it with [`detach`](Self::detach), or end it /// with [`kill_and_confirm_exit`](Self::kill_and_confirm_exit); the [`Process`] stays valid /// either way. [`kill_and_wait`](Self::kill_and_wait) provides legacy, time-bounded cleanup. pub struct Launched { diff --git a/nvx-rs/src/host/store/caller.rs b/nvx-rs/src/host/store/caller.rs deleted file mode 100644 index d5f1e5cf1..000000000 --- a/nvx-rs/src/host/store/caller.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! The files that a store's caller keeps in the sandbox's directory beside the store's own, and the -//! names of the temporary files that the store owns there. - -use super::FILE_NAMES; -use crate::error::{Error, Result}; - -/// The files that the caller of a [`SandboxStore`](super::SandboxStore) keeps in the sandbox's -/// directory beside the store's own: the configuration record whose presence means that the -/// sandbox is provisioned, if the caller keeps one, and the caller's other files, such as the -/// socket of a boot console. -/// -/// The store's [lifecycle locks](super#lifecycle-locks) and its [removal](super#removal) take -/// the declaration: a holder that leaves the directory without the configuration record removes -/// its lock file as it releases the lock, and removal removes the caller's files with the store's, -/// the configuration record last. A caller that declares no configuration record, as -/// [`none`](Self::none) declares, keeps nothing that tells whether its sandbox is provisioned, -/// so the directory itself is the sandbox: its lock file stays when the lock is released, and the -/// store cannot be removed with the declaration. -/// -/// Each name is one plain file name, which [`new`](Self::new) and [`with`](Self::with) check, so -/// a declaration is valid before any store is created, opened, locked, or removed with it: -/// -/// - It is not empty, `.`, or `..`, and has no path separator, `/` or `\` on any host, so it -/// names no root, prefix, or path outside the directory. -/// - It has none of the characters that Windows refuses in a file name, a control character, -/// `"`, `*`, `:`, `<`, `>`, `?`, or `|`, no trailing dot or space, which Windows drops, and it is -/// not a name that Windows reserves for a device, such as `NUL` or `com1.json`, so it names the -/// same file on every host. -/// - It has only the portable file name characters, ASCII letters and digits, `.`, `_`, and `-`, -/// so that no file system takes it for another name: file systems ignore the case of other -/// letters by rules of their own, macOS's take a composed letter for the same letter followed -/// by a combining mark, and Windows also names files by short names, such as `RUNTIM~1.JSO` for -/// `runtime.json`. -/// - It is none of the store's [`FILE_NAMES`], the lock included, has neither form of a temporary -/// file that the store owns, and does not have the form of a removed file that a removal waits -/// for, `.nfs*`. -/// - It is declared once: the configuration record's name differs from the others. -/// -/// Names are compared without regard to ASCII case, as Windows and macOS file systems compare -/// them. -/// -/// A caller that captures the guest console in the store's console log, -/// [`CONSOLE_NAME`](super::CONSOLE_NAME), may also declare that it keeps the console of every -/// launch there, with [`keeping_console`](Self::keeping_console): a launch through a lifecycle -/// lock then leaves the log for the caller to append to, rather than removing it with the rest of -/// the previous launch's output. Removal removes it either way. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct CallerFiles { - /// The configuration record, whose presence means that the sandbox is provisioned. - config: Option, - /// The caller's other files. - others: Vec, - /// Whether the caller keeps the guest console of every launch in the store's console log. - keeps_console: bool, -} - -impl CallerFiles { - /// Declares no files: the caller keeps nothing in the sandbox's directory, which is itself the - /// sandbox. - pub fn none() -> Self { - Self::default() - } - - /// Declares `config`, the caller's configuration record, whose presence in the directory means - /// that the sandbox is provisioned. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a name that is not - /// one plain file name of the caller's, as the [type](Self) describes. - pub fn new(config: impl Into) -> Result { - let config = config.into(); - let mut files = Self::none(); - files.check(&config)?; - files.config = Some(config); - Ok(files) - } - - /// Adds `name`, another file of the caller's. - /// - /// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for a name that is not - /// one plain file name of the caller's, as the [type](Self) describes, or that is declared - /// already. - pub fn with(mut self, name: impl Into) -> Result { - let name = name.into(); - self.check(&name)?; - self.others.push(name); - Ok(self) - } - - /// Declares that the caller keeps the guest console of every launch in the store's console - /// log, which launches then leave in place, as the [type](Self) describes. - pub fn keeping_console(mut self) -> Self { - self.keeps_console = true; - self - } - - /// Returns whether the caller keeps the guest console of every launch, as - /// [`keeping_console`](Self::keeping_console) declares. - pub fn keeps_console(&self) -> bool { - self.keeps_console - } - - /// Returns the name of the caller's configuration record, if it keeps one. - pub fn config(&self) -> Option<&str> { - self.config.as_deref() - } - - /// Returns the names of the caller's other files. - pub fn others(&self) -> &[String] { - &self.others - } - - /// Returns every declared name, in the order in which removal removes them: the other files, - /// then the configuration record. - pub(super) fn names(&self) -> impl Iterator { - self.others - .iter() - .map(String::as_str) - .chain(self.config.as_deref()) - } - - /// Fails if `name` cannot be declared beside the names declared so far. - fn check(&self, name: &str) -> Result<()> { - match name_problem(name).or_else(|| self.collision(name)) { - Some(problem) => Err(Error::unsupported(format!( - "the caller's file name {name:?} {problem}" - ))), - None => Ok(()), - } - } - - /// Returns why `name` cannot be declared beside the store's files, its temporary files, and - /// the names declared so far, comparing names, which have only ASCII characters, without - /// regard to case. - fn collision(&self, name: &str) -> Option<&'static str> { - let folded = name.to_ascii_lowercase(); - if FILE_NAMES.contains(&folded.as_str()) { - Some("is the name of one of the store's files") - } else if is_store_temporary(&folded) || is_report_staging(&folded) { - Some("has the form of a temporary file that the store owns") - } else if folded.starts_with(".nfs") { - Some("has the form of a removed file that a removal waits for") - } else if self - .names() - .any(|declared| declared.eq_ignore_ascii_case(name)) - { - Some("is declared already") - } else { - None - } - } -} - -/// Returns why `name` is not one plain file name that names the same file on every host, if it is -/// not. -fn name_problem(name: &str) -> Option<&'static str> { - if name.is_empty() { - Some("is empty") - } else if name == "." || name == ".." { - Some("names a directory rather than a file in it") - } else if name.contains(['/', '\\']) { - Some("has a path separator") - } else if name - .chars() - .any(|c| c.is_control() || matches!(c, '"' | '*' | ':' | '<' | '>' | '?' | '|')) - { - Some("has a character that Windows refuses in a file name") - } else if name.ends_with(['.', ' ']) { - Some("ends with a dot or a space, which Windows drops") - } else if reserves_device(name) { - Some("is a name that Windows reserves for a device") - } else if !name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) - { - Some("has a character other than ASCII letters and digits, `.`, `_`, and `-`") - } else { - None - } -} - -/// Returns whether Windows takes `name` for a device: a name whose part before its first dot, -/// without trailing spaces, is `CON`, `PRN`, `AUX`, `NUL`, `CONIN$`, `CONOUT$`, or `COM` or `LPT` -/// and one digit, which may be a superscript one, two, or three, in any case. -fn reserves_device(name: &str) -> bool { - let stem = name - .split('.') - .next() - .unwrap_or_default() - .trim_end_matches(' ') - .to_ascii_uppercase(); - let numbered = |prefix: &str| { - stem.strip_prefix(prefix).is_some_and(|rest| { - let mut digits = rest.chars(); - matches!(digits.next(), Some('0'..='9' | '¹' | '²' | '³')) && digits.next().is_none() - }) - }; - matches!( - stem.as_str(), - "CON" | "PRN" | "AUX" | "NUL" | "CONIN$" | "CONOUT$" - ) || numbered("COM") - || numbered("LPT") -} - -/// Returns whether `name` is a temporary file that the store leaves beside one of its files when a -/// replacement of the file is interrupted: `.NAME.NUMBER.tmp`. -pub(super) fn is_store_temporary(name: &str) -> bool { - name.strip_prefix('.') - .and_then(|name| name.strip_suffix(".tmp")) - .and_then(|name| name.rsplit_once('.')) - .is_some_and(|(file, number)| { - FILE_NAMES.contains(&file) - && !number.is_empty() - && number.bytes().all(|byte| byte.is_ascii_digit()) - }) -} - -/// Returns whether `name` is the file in which the VMM stages its outcome report before it -/// publishes it as `outcome.json`, which a VMM that is killed while it writes the report leaves: -/// `.tmp` and six ASCII letters or digits, the name that the `tempfile` crate gives it. -pub(super) fn is_report_staging(name: &str) -> bool { - name.strip_prefix(".tmp").is_some_and(|suffix| { - suffix.len() == 6 && suffix.bytes().all(|byte| byte.is_ascii_alphanumeric()) - }) -} - -#[cfg(test)] -mod tests; diff --git a/nvx-rs/src/host/store/caller/tests.rs b/nvx-rs/src/host/store/caller/tests.rs deleted file mode 100644 index 3e158eb88..000000000 --- a/nvx-rs/src/host/store/caller/tests.rs +++ /dev/null @@ -1,221 +0,0 @@ -use super::*; -use crate::ErrorKind; - -/// Returns the message with which declaring `config`, and then `others`, fails. -fn refusal(config: &str, others: &[&str]) -> String { - let declared = CallerFiles::new(config).and_then(|files| { - others - .iter() - .try_fold(files, |files, &name| files.with(name)) - }); - let error = declared.unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); - error.message().to_owned() -} - -#[test] -fn declarations_keep_their_names() { - let files = CallerFiles::new("sandbox.json") - .unwrap() - .with("boot.sock") - .unwrap() - .with(".config.json.1.tmp") - .unwrap(); - assert_eq!(files.config(), Some("sandbox.json")); - assert_eq!(files.others(), ["boot.sock", ".config.json.1.tmp"]); - assert_eq!( - files.names().collect::>(), - ["boot.sock", ".config.json.1.tmp", "sandbox.json"] - ); - let none = CallerFiles::none().with("boot.sock").unwrap(); - assert_eq!(none.config(), None); - assert_eq!(none.names().collect::>(), ["boot.sock"]); - assert_eq!(CallerFiles::none(), CallerFiles::default()); -} - -#[test] -fn names_that_leave_the_directory_are_refused_on_every_host() { - for (name, problem) in [ - ("", "is empty"), - (".", "names a directory rather than a file in it"), - ("..", "names a directory rather than a file in it"), - ("/etc/passwd", "has a path separator"), - ("../config.json", "has a path separator"), - ("state/config.json", "has a path separator"), - ("config.json/", "has a path separator"), - ("C:\\config.json", "has a path separator"), - ("..\\config.json", "has a path separator"), - ("\\\\?\\C:\\config.json", "has a path separator"), - ("\\", "has a path separator"), - ("C:", "has a character that Windows refuses in a file name"), - ( - "C:config.json", - "has a character that Windows refuses in a file name", - ), - ( - "config.json:stream", - "has a character that Windows refuses in a file name", - ), - ( - "config\0.json", - "has a character that Windows refuses in a file name", - ), - ( - "config\n.json", - "has a character that Windows refuses in a file name", - ), - ( - "config?.json", - "has a character that Windows refuses in a file name", - ), - ( - "config.json.", - "ends with a dot or a space, which Windows drops", - ), - ( - "config.json ", - "ends with a dot or a space, which Windows drops", - ), - ("NUL", "is a name that Windows reserves for a device"), - ("nul.json", "is a name that Windows reserves for a device"), - ("Com1.json", "is a name that Windows reserves for a device"), - ("lpt\u{b9}", "is a name that Windows reserves for a device"), - ("CON .json", "is a name that Windows reserves for a device"), - ("conout$", "is a name that Windows reserves for a device"), - ] { - let expected = format!("the caller's file name {name:?} {problem}"); - assert_eq!(refusal(name, &[]), expected); - assert_eq!(refusal("config.json", &[name]), expected); - } - // Names that only resemble those are plain file names. - for name in [ - "...json", - "com10", - "console.json", - "nul-file", - "lpt", - "a_b.json", - ] { - CallerFiles::new(name).unwrap(); - } -} - -#[test] -fn names_that_a_file_system_could_take_for_others_are_refused() { - let problem = "has a character other than ASCII letters and digits, `.`, `_`, and `-`"; - for name in [ - // The uppercase forms of `ı` and `ſ` are `I` and `S`, which `to_lowercase` does not - // tell, so a file system that compares the uppercase forms of names could take these - // for the store's files. - "runt\u{131}me.json", - "control.\u{17f}ock", - // macOS's file systems take a composed letter for the same letter followed by a - // combining mark. - "caf\u{e9}.json", - "cafe\u{301}.json", - // Windows names `runtime.json` by this short name too on volumes with short names. - "RUNTIM~1.JSO", - "a b.json", - ] { - let expected = format!("the caller's file name {name:?} {problem}"); - assert_eq!(refusal(name, &[]), expected); - assert_eq!(refusal("config.json", &[name]), expected); - } -} - -#[test] -fn names_that_collide_are_refused_whatever_their_case() { - for (name, problem) in [ - ("sandbox.lock", "is the name of one of the store's files"), - ("Runtime.JSON", "is the name of one of the store's files"), - ("outcome.json", "is the name of one of the store's files"), - ( - ".runtime.json.12345.tmp", - "has the form of a temporary file that the store owns", - ), - ( - ".SANDBOX.LOCK.1.TMP", - "has the form of a temporary file that the store owns", - ), - ( - ".tmpAbC123", - "has the form of a temporary file that the store owns", - ), - ( - ".TMP000000", - "has the form of a temporary file that the store owns", - ), - ( - ".nfs000000001", - "has the form of a removed file that a removal waits for", - ), - ( - ".NFS1", - "has the form of a removed file that a removal waits for", - ), - ] { - let expected = format!("the caller's file name {name:?} {problem}"); - assert_eq!(refusal(name, &[]), expected); - assert_eq!(refusal("config.json", &[name]), expected); - } - let twice = |name: &str| format!("the caller's file name {name:?} is declared already"); - assert_eq!( - refusal("config.json", &["config.json"]), - twice("config.json") - ); - assert_eq!( - refusal("config.json", &["CONFIG.json"]), - twice("CONFIG.json") - ); - assert_eq!( - refusal("config.json", &["boot.sock", "Boot.Sock"]), - twice("Boot.Sock") - ); - let none = CallerFiles::none().with("boot.sock").unwrap(); - assert_eq!( - none.with("BOOT.sock").unwrap_err().message(), - twice("BOOT.sock") - ); -} - -#[test] -fn store_temporaries_name_one_of_the_stores_files_and_a_number() { - for name in [ - ".runtime.json.0.tmp", - ".sandbox.lock.18446744073709551615.tmp", - ] { - assert!(is_store_temporary(name), "{name}"); - } - for name in [ - ".config.json.1.tmp", - ".runtime.json..tmp", - ".runtime.json.1a.tmp", - "runtime.json.1.tmp", - ".runtime.json.1.tmp.tmp", - ".tmpAbC123", - ".Runtime.json.1.tmp", - ] { - assert!(!is_store_temporary(name), "{name}"); - } -} - -#[test] -fn report_staging_files_have_the_names_that_tempfile_gives_them() { - for name in [".tmpAbC123", ".tmp000000", ".tmpzzzzzz"] { - assert!(is_report_staging(name), "{name}"); - } - // Other names are not the VMM's, whatever they resemble. - for name in [ - ".tmpAbC12", - ".tmpAbC1234", - ".tmpAb-123", - ".tmpAb.123", - ".tmpAbC12\u{e9}", - ".TMPAbC123", - "tmpAbC123", - ".tmp", - ".runtime.json.1.tmp", - ] { - assert!(!is_report_staging(name), "{name}"); - } -} diff --git a/nvx-rs/src/host/store/files.rs b/nvx-rs/src/host/store/files.rs deleted file mode 100644 index 8937e4957..000000000 --- a/nvx-rs/src/host/store/files.rs +++ /dev/null @@ -1,247 +0,0 @@ -//! The private files of the store: their creation, atomic replacement, reading, and removal, and -//! the syncs of their directories. -//! -//! The creation of a private file and its atomic replacement are [`platform`]'s: this module -//! re-exports its `create_new` and `write_private` for its callers until #1121 deletes them. -//! -//! The sandbox's store opens, creates, replaces, and reads its lock, its records, its capability, -//! and its log only as [`StoreFile`]s, each of which holds a file's path with what messages call -//! it, so that no read or lock of one of them skips the checks that refuse a file that others may -//! open. - -use std::fs::{self, File, OpenOptions}; -use std::io::{self, Read}; -use std::path::{Path, PathBuf}; - -use crate::error::{Error, Result}; -#[cfg(unix)] -use crate::host::platform::PRIVATE_FILE_MODE; -use crate::host::platform::private_options; -pub(crate) use crate::host::platform::{create_new, write_private}; -use crate::host::{filesystem, platform}; - -/// Builds an error mapper that keeps an I/O source under `context`. -pub(super) fn io_error(context: String) -> impl FnOnce(io::Error) -> Error { - move |error| Error::io(context).with_source(error) -} - -/// Returns the error of a lock, a record, or a capability that is not a regular file. -fn not_regular() -> io::Error { - io::Error::other("is not a regular file") -} - -/// Opens the lock file at `path`, creating it readable only by the current user if it does not -/// exist. -/// -/// A lock file that is not a regular file is refused, and so is a link, which is not followed, or -/// on Windows any other reparse point: a lock taken through a link would hold the file that the -/// link names, which a launch may replace while the lock is held, so that the next transition -/// would lock the replacement. -/// -/// On Unix, a lock and the checks of the locked file need no read access, so the file is opened -/// only for writing. The open does not wait, as it would for a FIFO without a reader. The umask -/// may have cleared even the current user's permission to write it, whether this or another call -/// created it, so a lock file of the current user that the user can open for writing no more is -/// given its mode again. -pub(crate) fn open_lock(path: &Path) -> io::Result { - let open = || { - let mut options = private_options(); - options.read(cfg!(windows)).create(true).truncate(false); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - - // These flags replace those of `private_options`. - options.custom_flags( - (nix::fcntl::OFlag::O_NOFOLLOW | nix::fcntl::OFlag::O_NONBLOCK).bits(), - ); - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - - /// `FILE_FLAG_OPEN_REPARSE_POINT`, which opens a link as itself. - const OPEN_REPARSE_POINT: u32 = 0x0020_0000; - options.custom_flags(OPEN_REPARSE_POINT); - } - options.open(path) - }; - #[cfg(target_os = "linux")] - let opened = match open() { - Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { - platform::restore_private_file_mode( - path, - nix::sys::stat::Mode::from_bits_truncate(PRIVATE_FILE_MODE), - )?; - open() - } - opened => opened, - }; - #[cfg(not(target_os = "linux"))] - let opened = open(); - #[cfg(unix)] - { - use nix::errno::Errno; - - // `O_NOFOLLOW` refuses a link, and an open that does not wait refuses a FIFO without a - // reader, or a socket. - if let Err(error) = &opened - && [Errno::ELOOP, Errno::ENXIO] - .into_iter() - .any(|errno| error.raw_os_error() == Some(errno as i32)) - { - return Err(not_regular()); - } - } - let file = opened?; - let metadata = file.metadata()?; - if !metadata.file_type().is_file() || filesystem::is_reparse_point(&metadata) { - return Err(not_regular()); - } - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - if metadata.permissions().mode() & PRIVATE_FILE_MODE != PRIVATE_FILE_MODE { - file.set_permissions(fs::Permissions::from_mode(PRIVATE_FILE_MODE))?; - } - } - Ok(file) -} - -/// Removes the file at `path` if it exists. -pub(crate) fn remove_if_present(path: &Path) -> Result<()> { - match fs::remove_file(path) { - Err(error) if error.kind() != io::ErrorKind::NotFound => { - Err(io_error(format!("cannot remove {}", path.display()))(error)) - } - _ => Ok(()), - } -} - -/// One file of a sandbox's store: its path, and what messages call it. -/// -/// [`SandboxStore`](super::SandboxStore) builds one for each file that it creates and reads, its -/// lock, its launch journal, its runtime record, its capability, and its log, and opens, creates, -/// replaces, and reads those files only through it, so that each of them is created readable only -/// by the current user, replaced atomically, read only if it is a regular file that others may not -/// open, and locked only if others may not open it, and that every message about it calls it by -/// its description. -#[derive(Debug)] -pub(super) struct StoreFile { - /// The file's path. - path: PathBuf, - /// What messages call the file, such as `sandbox runtime record`. - description: &'static str, -} - -impl StoreFile { - /// Returns the file at `path`, which messages call `description`. - pub(super) fn new(path: PathBuf, description: &'static str) -> Self { - Self { path, description } - } - - /// Returns the file's path. - pub(super) fn path(&self) -> &Path { - &self.path - } - - /// Returns what messages call the file. - pub(super) fn description(&self) -> &'static str { - self.description - } - - /// Creates the file, which must not exist, readable only by the current user, as - /// [`create_new`] does. - pub(super) fn create_new(&self) -> io::Result { - create_new(&self.path) - } - - /// Opens the file as a lock, creating it readable only by the current user if it does not - /// exist, as [`open_lock`] does, and refuses it if it is not private to the current user, since - /// others could hold its lock. - /// - /// `failed` maps the failures to open the file and to check it, as the caller maps those of - /// the lock that it then takes. - pub(super) fn open_lock(&self, failed: impl Fn(io::Error) -> Error) -> Result { - let file = open_lock(&self.path).map_err(&failed)?; - if let Some(problem) = platform::private_file_problem(&file).map_err(failed)? { - return Err(self.not_private(&problem)); - } - Ok(file) - } - - /// Atomically replaces the file with `contents`, readable only by the current user, as - /// [`write_private`] does. - pub(super) fn replace(&self, contents: &[u8]) -> Result<()> { - write_private(&self.path, contents) - } - - /// Reads the file, or returns `None` if it does not exist. - /// - /// The open does not wait, as it would on Unix for a FIFO without a writer, and a file that is - /// not a regular file is refused before anything is read from it, as a FIFO whose writer keeps - /// it open would keep a read waiting. A file that is not private to the current user is - /// refused, since others could have written it: on Windows, a file's own access control list - /// decides who may open it, whatever the store's directory allows. - pub(super) fn read(&self) -> Result>> { - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - - // Reads of a regular file do not depend on the flag. - options.custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()); - } - let mut file = match options.open(&self.path) { - Ok(file) => file, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - // A socket refuses an open. - #[cfg(unix)] - Err(error) if error.raw_os_error() == Some(nix::errno::Errno::ENXIO as i32) => { - return Err(self.read_failed()(not_regular())); - } - Err(error) => return Err(self.read_failed()(error)), - }; - if !file - .metadata() - .map_err(self.read_failed())? - .file_type() - .is_file() - { - return Err(self.read_failed()(not_regular())); - } - if let Some(problem) = platform::private_file_problem(&file).map_err(self.read_failed())? { - return Err(self.not_private(&problem)); - } - let mut contents = Vec::new(); - file.read_to_end(&mut contents) - .map_err(self.read_failed())?; - Ok(Some(contents)) - } - - /// Removes the file if it exists, as [`remove_if_present`] does. - pub(super) fn remove_if_present(&self) -> Result<()> { - remove_if_present(&self.path) - } - - /// Builds an error mapper that keeps the I/O source of a failure to read the file. - pub(super) fn read_failed(&self) -> impl FnOnce(io::Error) -> Error { - io_error(format!( - "cannot read the {} {}", - self.description, - self.path.display() - )) - } - - /// Returns the error of the file, which is not private to the current user for the reason - /// that `problem` gives. - fn not_private(&self, problem: &str) -> Error { - Error::io(format!( - "the {} {} {problem}", - self.description, - self.path.display() - )) - } -} diff --git a/nvx-rs/src/host/store/launch.rs b/nvx-rs/src/host/store/launch.rs deleted file mode 100644 index 54efa96fb..000000000 --- a/nvx-rs/src/host/store/launch.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! A launch in progress, from the marker of its journal to its runtime record, and how a recovery -//! or an abandon ends one. - -use std::fs::File; - -use super::locked::LockedStore; -use super::record::{LaunchJournal, RuntimeRecord}; -use super::{record, vmm}; -use crate::error::{Error, Result}; -use crate::host::process::{Process, ProcessRef}; -use crate::host::spawn::Launched; - -/// Ends a launch by terminating its verified VMM `process`. -pub(super) fn end_process(process: &Process) -> Recovery { - let pid = process.reference().pid; - match vmm::terminate(process) { - Ok(true) => Recovery::Terminated(process.reference().clone()), - Ok(false) => Recovery::Indeterminate(format!( - "its VMM process {pid} did not exit after it was terminated" - )), - Err(error) => Recovery::Indeterminate(format!( - "its VMM process {pid} cannot be terminated: {error}" - )), - } -} - -/// How [`LockedStore::recover`] or [`Launch::abandon`] ended a launch. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Recovery { - /// No launch was interrupted. - Clean, - /// The launch never spawned its VMM, or its VMM has exited; its files are removed. - Exited, - /// Recovery terminated the launch's VMM, whose reference this holds, after verifying it; its - /// files are removed. - Terminated(ProcessRef), - /// Recovery cannot prove that nothing of the launch runs, for the reason that this gives, and - /// keeps its files for a later recovery. - Indeterminate(String), -} - -/// A launch in progress, which borrows its sandbox's locked store exclusively from its journal's -/// marker until its runtime record. -/// -/// Dropping it before [`commit`](Self::commit) or [`abandon`](Self::abandon) leaves its journal, -/// as a crash would, for [`LockedStore::recover`] to end. -#[derive(Debug)] -pub struct Launch<'a> { - /// The sandbox's locked store. - pub(super) locked: &'a mut LockedStore, - /// The launch's journal, as written. - pub(super) journal: LaunchJournal, - /// The claimed log, until the caller takes it for the VMM. - pub(super) log: Option, -} - -impl Launch<'_> { - /// Returns the launch's journal. - pub fn journal(&self) -> &LaunchJournal { - &self.journal - } - - /// Returns the control endpoint that the launch chose, on which the VMM must listen. - pub fn endpoint(&self) -> &str { - self.journal.endpoint() - } - - /// Takes the claimed log, which the caller passes to the VMM as its standard output and - /// error, as [`spawn`] does with its `log`, once. - /// - /// The launch keeps no copy of the log, so the claim lasts as long as the processes that hold - /// it. - /// - /// [`spawn`]: crate::host::supervisor::spawn - pub fn take_log(&mut self) -> Result { - self.log - .take() - .ok_or_else(|| Error::other("the log of the launch was taken already")) - } - - /// Records the reference to the VMM process `child`, which the caller spawned and has not - /// reaped, right after the spawn, and returns the process, held through a handle of its own - /// as [`Launched::process`] holds it. - /// - /// Fails if the launch has recorded its VMM already, or if the process's identity cannot be - /// read; [`abandon`](Self::abandon) then ends the launch. - pub fn record_process(&mut self, child: &Launched) -> Result { - if self.journal.process().is_some() { - return Err(Error::other( - "the launch has recorded its VMM process already", - )); - } - let process = child - .process() - .map_err(|error| Error::io("cannot identify the VMM process").with_source(error))?; - let journal = self.journal.spawned(process.reference().clone()); - record::write(&self.locked.journal_file(), &journal)?; - self.journal = journal; - Ok(process) - } - - /// Commits the launch of a VM that is ready: writes its runtime record, which then names the - /// VM, and removes the journal. - /// - /// The runtime record is the commit, so a journal that remains because its removal failed is - /// removed by [`LockedStore::recover`]. Fails if the launch has not recorded its VMM process, - /// or if the runtime record cannot be written; the journal then remains, as that of an - /// interrupted launch, for [`LockedStore::recover`] to end. - pub fn commit(self) -> Result { - let Some(process) = self.journal.process() else { - return Err(Error::other( - "a launch cannot commit before it records its VMM process", - )); - }; - let runtime = RuntimeRecord::committed(&self.journal, process.clone()); - record::write(&self.locked.runtime_file(), &runtime)?; - let _ = self.locked.journal_file().remove_if_present(); - Ok(runtime) - } - - /// Ends a launch that failed, as [`LockedStore::recover`] ends an interrupted one: it - /// terminates the VMM if its identity is verified, and removes the launch's files once - /// nothing of it runs. - /// - /// A caller that still holds the VMM's [`Launched`] process releases it as usual. If the - /// launch never recorded that process, the caller ends it with [`Launched::kill_and_wait`] - /// first: an unrecorded VMM that serves no endpoint yet holds the log's claim, so the launch - /// would end as [`Recovery::Indeterminate`]. - pub fn abandon(mut self) -> Result { - // A log that no VMM received holds no claim once it closes. - drop(self.log.take()); - self.locked.end_launch(&self.journal) - } -} diff --git a/nvx-rs/src/host/store/lease.rs b/nvx-rs/src/host/store/lease.rs deleted file mode 100644 index ba77f24ee..000000000 --- a/nvx-rs/src/host/store/lease.rs +++ /dev/null @@ -1,475 +0,0 @@ -//! The lease of a template's generation that the clone of a sandbox holds, as the sandbox's -//! [clone binding](CLONE_BINDING_NAME) records it, and the reading of the records of the template -//! store, in whose format the binding is. -//! -//! The sandbox's store reads the binding before it begins a launch, and as it removes the control -//! socket of a clone's VMM. - -use std::ffi::OsStr; -use std::fmt; -use std::fs::File; -use std::io; -use std::num::NonZeroU64; -use std::path::{Path, PathBuf}; -use std::str::FromStr; - -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; - -use super::regular::{open_regular, open_regular_in, read_at_most, was_removed}; -use super::{CLONE_BINDING_NAME, LaunchId, SandboxStore}; -use crate::activation::{Identity, StoreGeneration}; -use crate::error::{Error, ErrorCode, ErrorKind, Result}; -use crate::host::platform::{self, parse_hex_id, parse_hex32}; - -/// Version of the store's records: its identity record and its index. -pub(crate) const STORE_FORMAT: u32 = 1; - -/// Name of the directory of the generations' payloads. -pub(crate) const GENERATIONS_NAME: &str = "generations"; - -/// Name of the store's identity record. -pub(crate) const STORE_RECORD_NAME: &str = "store.json"; - -/// Longest identity record, in bytes. -pub(crate) const MAX_STORE_RECORD_LEN: u64 = 4 * 1024; - -/// Bytes of a [`StoreId`] and of a [`LeaseId`]. -pub(crate) const ID_LEN: usize = 16; - -/// Longest record of a lease, of a clone binding, or of a tombstone, in bytes: more than a record -/// that names the longest path of a sandbox takes. -pub(crate) const MAX_LEASE_RECORD_LEN: u64 = 256 * 1024; - -/// The binding of a clone in its sandbox, in messages. -pub(crate) const BINDING_DESCRIPTION: &str = "clone binding"; - -/// Returns `bytes` as lowercase hexadecimal digits. -pub(crate) fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -/// The identity of a store: 16 random bytes that its creation draws. -/// -/// Its text form, which `Display` writes and `FromStr` parses, is 32 lowercase hexadecimal -/// digits; parsing also accepts uppercase ones. -#[derive(Clone, Copy, PartialEq, Eq, Hash)] -pub(crate) struct StoreId([u8; ID_LEN]); - -impl fmt::Display for StoreId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&hex(&self.0)) - } -} - -impl fmt::Debug for StoreId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "StoreId({self})") - } -} - -impl FromStr for StoreId { - type Err = Error; - - /// Parses the text form of a store's identity, failing with - /// [`ErrorKind::Unsupported`] for anything else. - fn from_str(text: &str) -> Result { - parse_hex_id(text).map(Self).ok_or_else(|| { - Error::unsupported(format!( - "{text:?} is not a template store's identity, which is 32 hexadecimal digits" - )) - }) - } -} - -/// The identity of a lease: 16 random bytes that its acquisition draws. -/// -/// Its text form, which `Display` writes and `FromStr` parses, is 32 lowercase hexadecimal -/// digits; parsing also accepts uppercase ones. -#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) struct LeaseId([u8; ID_LEN]); - -impl fmt::Display for LeaseId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&hex(&self.0)) - } -} - -impl fmt::Debug for LeaseId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "LeaseId({self})") - } -} - -impl FromStr for LeaseId { - type Err = Error; - - /// Parses the text form of a lease's identity, failing with [`ErrorKind::Unsupported`] for - /// anything else. - fn from_str(text: &str) -> Result { - parse_hex_id(text).map(Self).ok_or_else(|| { - Error::unsupported(format!( - "{text:?} is not the identity of a lease, which is 32 hexadecimal digits" - )) - }) - } -} - -/// A lease of a generation of a template, which keeps the generation in its store for the clone -/// of one sandbox, whose VM restores it, and reserves the clone's launch. -/// -/// The store keeps the record of each lease that it granted, and the sandbox keeps the same -/// record as its clone binding, [`CLONE_BINDING_NAME`], beside its launch journal. A lease names -/// its sandbox by the path of its directory with its links resolved. A sandbox that holds a lease -/// must not move, and goes only through its store's [removal](super::LockedStore::remove), which -/// makes the binding's absence durable before the sandbox goes. The lease reserves one launch of -/// the clone, [`launch`](Self::launch), and allows no launch of the sandbox, as -/// [`LockedStore::begin_launch`](super::LockedStore::begin_launch) requires. -#[derive(Clone, PartialEq, Eq)] -pub(crate) struct Lease { - /// The lease's identity. - id: LeaseId, - /// The identity of the store that granted it. - store: StoreId, - /// The store generation of the leased generation. - generation: StoreGeneration, - /// The identity of the leased generation's key. - key: Identity, - /// The directory of the sandbox that holds the lease, as an absolute path with its links - /// resolved, which is valid Unicode. - sandbox: PathBuf, - /// The launch of the clone that the lease reserves. - launch: LaunchId, -} - -impl Lease { - /// Reads the clone binding of the sandbox of `sandbox`: the lease that it holds, if it holds - /// one. - /// - /// A read that holds no lock sees one whole binding, as the latest transition left it. Fails - /// with [`ErrorKind::Io`] for a binding that cannot be read, or is malformed, and with - /// [`ErrorCode::FormatUnsupported`] for one of another format. - pub(crate) fn of(sandbox: &SandboxStore) -> Result> { - read_binding(sandbox.dir()) - } - - /// Returns the lease's identity. - pub(crate) fn id(&self) -> LeaseId { - self.id - } - - /// Returns the identity of the store that granted the lease. - pub(crate) fn store(&self) -> StoreId { - self.store - } - - /// Returns the store generation of the leased generation. - pub(crate) fn generation(&self) -> StoreGeneration { - self.generation - } - - /// Returns the launch of the clone that the lease reserves. - pub(crate) fn launch(&self) -> &LaunchId { - &self.launch - } -} - -impl fmt::Debug for Lease { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter - .debug_struct("Lease") - .field("id", &self.id) - .field("store", &self.store) - .field("generation", &self.generation) - // The key writes itself as the `KeyIdentity` that `Lease::key` returns. - .field("key", &format_args!("KeyIdentity({})", self.key)) - .field("sandbox", &self.sandbox) - .field("launch", &self.launch) - .finish() - } -} - -/// Returns the name of the control socket of the VMM of the clone whose launch is `launch`, in the -/// payload of the leased generation: `.sock`. -pub(crate) fn clone_socket_name(launch: &LaunchId) -> String { - format!("{launch}.sock") -} - -/// Returns whether `path` names the control socket of the VMM of the clone that holds `lease`, in -/// the payload of the leased generation: `generations//.sock`, in a template -/// store, which a lease names by its identity alone. -pub(crate) fn is_clone_socket(lease: &Lease, path: &Path) -> bool { - let name = clone_socket_name(&lease.launch); - let generation = lease.generation.to_string(); - let payload = path.parent(); - path.file_name() == Some(OsStr::new(&name)) - && payload.and_then(Path::file_name) == Some(OsStr::new(&generation)) - && payload.and_then(Path::parent).and_then(Path::file_name) - == Some(OsStr::new(GENERATIONS_NAME)) -} - -/// Opens the payload of the generation `generation` of the template store `id` in `store`, which -/// holds the control sockets of the VMMs of the generation's clones on Linux: -/// `generations/`, for reading, so that the caller can sync it once it removed a -/// socket from it. -/// -/// It follows no link at the store's own name, at the directory of the generations, or at the -/// payload, and fails if one of them is a link, or not a directory, so that a link that takes the -/// place of one of them never leads a removal to the files of another directory. It reads the -/// store's identity through the handle of the store's directory, failing if it cannot read the -/// identity record, and returns `None` if that directory holds another store, or none, as it does -/// if the store, its directory of the generations, or the payload is gone: the payload of a store -/// that is gone holds no socket. It opens the store's directory and the directory of the -/// generations with the right to search them alone, which an owner-only directory may grant -/// without the right to read it. -pub(crate) fn open_payload( - store: &Path, - id: StoreId, - generation: StoreGeneration, -) -> Result> { - let payload = store.join(GENERATIONS_NAME).join(generation.to_string()); - let failed = || { - store_error(format!( - "cannot open the payload of the generation {}", - payload.display() - )) - }; - let Some(dir) = platform::Directory::search(store).map_err(failed())? else { - return Ok(None); - }; - if read_identity_in(&dir, store)? != Some(id) { - return Ok(None); - } - let Some(generations) = dir.search_dir(GENERATIONS_NAME).map_err(failed())? else { - return Ok(None); - }; - generations - .open_dir(&generation.to_string()) - .map_err(failed()) -} - -/// Reads the identity of the store whose directory `directory` holds open, in `dir`, from its -/// identity record, or returns `None` if the directory holds none, or, on Windows, one whose -/// deletion is pending: through the handle, so that it reads the identity of the directory that the -/// handle holds, whatever `dir` names meanwhile. -pub(crate) fn read_identity_in( - directory: &platform::Directory, - dir: &Path, -) -> Result> { - read_identity_with(&dir.join(STORE_RECORD_NAME), || { - open_regular_in(directory, STORE_RECORD_NAME) - }) -} - -/// Reads the identity of a store from its identity record at `path`, which `open` opens, or -/// returns `None` if `open` finds none. -fn read_identity_with( - path: &Path, - open: impl FnOnce() -> io::Result>, -) -> Result> { - let description = "identity record of the template store"; - let Some(record) = - read_record_with::(path, description, MAX_STORE_RECORD_LEN, open)? - else { - return Ok(None); - }; - record.store.parse().map(Some).map_err(|error: Error| { - Error::io(format!( - "the {description} {} is malformed: {}", - path.display(), - error.message() - )) - }) -} - -/// The identity record of a store, as JSON. -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct StoreRecord { - /// [`STORE_FORMAT`]. - pub(crate) format: u32, - /// The store's identity. - pub(crate) store: String, -} - -/// Reads the clone binding of the sandbox in `dir`, or returns `None` if it holds none, or if the -/// directory does not exist. -pub(crate) fn read_binding(dir: &Path) -> Result> { - read_lease(&dir.join(CLONE_BINDING_NAME), BINDING_DESCRIPTION) -} - -/// Reads the record of a lease at `path`, its `description`, or returns `None` if it does not -/// exist. -pub(crate) fn read_lease(path: &Path, description: &str) -> Result> { - read_lease_with(path, description, || open_existing(path)) -} - -/// Reads the record of a lease, its `description` at `path`, from the file that `open` opens, or -/// returns `None` if `open` finds none. -fn read_lease_with( - path: &Path, - description: &str, - open: impl FnOnce() -> io::Result>, -) -> Result> { - let Some(record) = - read_record_with::(path, description, MAX_LEASE_RECORD_LEN, open)? - else { - return Ok(None); - }; - record.into_lease().map(Some).map_err(|problem| { - Error::io(format!( - "the {description} {} is malformed: {problem}", - path.display() - )) - }) -} - -/// The record of a lease, as JSON: in its store, and as the clone binding of its sandbox. -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct LeaseRecord { - /// [`STORE_FORMAT`]. - format: u32, - /// The identity of the store that granted the lease. - store: String, - /// The identity of the lease. - lease: String, - /// The store generation of the leased generation. - generation: u64, - /// The identity of the leased generation's key. - key: String, - /// The directory of the sandbox that holds the lease, as an absolute path. - sandbox: String, - /// The launch of the clone that the lease reserves. - launch: String, -} - -impl LeaseRecord { - /// Checks the record, and returns its lease, or says why it is malformed. - fn into_lease(self) -> std::result::Result { - let field = - |what: &str, error: Error| format!("its {what} is invalid: {}", error.message()); - let sandbox = PathBuf::from(self.sandbox); - if !sandbox.is_absolute() { - return Err(format!( - "its sandbox, {}, is not an absolute path", - sandbox.display() - )); - } - Ok(Lease { - id: self - .lease - .parse() - .map_err(|error| field("identity", error))?, - store: self.store.parse().map_err(|error| field("store", error))?, - generation: NonZeroU64::new(self.generation) - .map(StoreGeneration::new) - .ok_or("it leases store generation 0, which no generation has")?, - key: parse_key(&self.key).map_err(|error| field("key", error))?, - sandbox, - launch: LaunchId::try_from(self.launch) - .map_err(|problem| format!("its launch is invalid: {problem}"))?, - }) - } -} - -/// Parses the text form of the identity of a template's key, 64 hexadecimal digits, as -/// [`KeyIdentity`](crate::host::snapshot::KeyIdentity) parses it, failing with -/// [`ErrorKind::Unsupported`] for anything else. -fn parse_key(text: &str) -> Result { - parse_hex32(text).map(Identity::from_bytes).ok_or_else(|| { - Error::unsupported(format!( - "{text:?} is not a template key's identity, which is 64 hexadecimal digits" - )) - }) -} - -/// Builds an error mapper that keeps an I/O source under `context`, as an error of -/// [`ErrorKind::NotFound`] if the source says that a path does not exist, and of -/// [`ErrorKind::Io`] otherwise. -pub(crate) fn store_error(context: String) -> impl FnOnce(io::Error) -> Error { - move |error| { - let kind = match error.kind() { - io::ErrorKind::NotFound => ErrorKind::NotFound, - _ => ErrorKind::Io, - }; - Error::new(kind, context).with_source(error) - } -} - -/// Opens the regular file at `path`, as [`open_regular`] does, or returns `None` if it does not -/// exist, or was removed as this opened it, as [`was_removed`] tells, which a transaction in -/// another process may do. -fn open_existing(path: &Path) -> io::Result> { - match open_regular(path) { - Ok(file) => Ok(Some(file)), - Err(error) if error.kind() == io::ErrorKind::NotFound || was_removed(path, &error) => { - Ok(None) - } - Err(error) => Err(error), - } -} - -/// Reads the private regular file that `open` opens, the store's `description` at `path`, of at -/// most `limit` bytes, or returns `None` if `open` finds none. -/// -/// `open` opens the file without following a link, or on Windows any other reparse point, at its -/// own name, and refuses it unless it is a regular file, before anything is read from it. A file -/// that is not private to the current user is refused, since others could have written it: on -/// Windows, a file's own access control list decides who may open it, whatever the store's -/// directory allows. -fn read_private_with( - path: &Path, - description: &str, - limit: u64, - open: impl FnOnce() -> io::Result>, -) -> Result>> { - let failed = || store_error(format!("cannot read the {description} {}", path.display())); - let Some(file) = open().map_err(failed())? else { - return Ok(None); - }; - if let Some(problem) = platform::private_file_problem(&file).map_err(failed())? { - return Err(Error::io(format!( - "the {description} {} {problem}", - path.display() - ))); - } - read_at_most(file, limit, &format!("the {description}")) - .map(Some) - .map_err(failed()) -} - -/// Reads the store's record that `open` opens, its `description` at `path`, of at most `limit` -/// bytes, in format [`STORE_FORMAT`], as [`read_private_with`] reads it, or returns `None` if -/// `open` finds none. -fn read_record_with( - path: &Path, - description: &str, - limit: u64, - open: impl FnOnce() -> io::Result>, -) -> Result> { - let Some(text) = read_private_with(path, description, limit, open)? else { - return Ok(None); - }; - let malformed = || format!("the {description} {} is malformed", path.display()); - let value: serde_json::Value = - serde_json::from_slice(&text).map_err(|error| Error::io(malformed()).with_source(error))?; - match value.get("format") { - Some(format) if format.as_u64() == Some(STORE_FORMAT.into()) => {} - Some(format) => { - return Err(Error::new( - ErrorCode::FormatUnsupported, - format!( - "the {description} {} has the unsupported format {format}; this build reads \ - format {STORE_FORMAT} only and migrates no other, so create a new template \ - store, and capture its templates again", - path.display() - ), - )); - } - None => return Err(Error::io(format!("{}: it has no format", malformed()))), - } - serde_json::from_value(value) - .map(Some) - .map_err(|error| Error::io(malformed()).with_source(error)) -} diff --git a/nvx-rs/src/host/store/lifecycle.rs b/nvx-rs/src/host/store/lifecycle.rs deleted file mode 100644 index 9bd928928..000000000 --- a/nvx-rs/src/host/store/lifecycle.rs +++ /dev/null @@ -1,799 +0,0 @@ -//! The lifecycle of a sandbox's store beside its caller's files: the locks of a provision and of an -//! existing sandbox, the release of a lock in a directory that holds no provisioned sandbox, and -//! the removal of the store and its directory. The [module documentation](super#lifecycle-locks) -//! describes their rules. - -use std::fmt; -use std::fs::{self, File}; -use std::io; -use std::path::{Path, PathBuf}; -use std::time::Instant; - -use super::caller::{is_report_staging, is_store_temporary}; -use super::{ - CLONE_BINDING_NAME, CLONE_FILE_NAMES, CallerFiles, FILE_NAMES, JOURNAL_NAME, LOCK_NAME, - LockedStore, RUNTIME_NAME, Races, SandboxStore, Taken, absolute, pause, transition_in_progress, -}; -use crate::error::{Error, ErrorCode, ErrorKind, Result}; -use crate::host::paths::not_unicode; -use crate::host::platform; - -#[cfg(test)] -mod tests; - -impl SandboxStore { - /// Takes the lock of the sandbox in `dir` for its provision, by a caller that keeps `files` - /// in the directory, waiting for it until `deadline`, or for as long as it takes without one. - /// - /// The directory, whose parent must exist, is created if needed, private to the current user, - /// as [`create`](Self::create) creates it, and created again if a concurrent removal deletes - /// it before the lock is taken, also while it is created and checked. On Windows, a file - /// system without POSIX deletion keeps a removed directory under its name until its last - /// handle closes, and refuses to open it, or to create a file in it, until then, so it is - /// created again once it goes. The caller checks under the lock whether its configuration - /// record exists already, and writes it. - /// - /// Fails with [`ErrorKind::TimedOut`] when the deadline passes first, also while Windows - /// refuses to open a removed lock file, as [`lock_existing`](Self::lock_existing) describes, - /// or a removed directory, or while concurrent transitions keep deleting the directory or - /// replacing the lock file, with [`ErrorKind::NotFound`] if the parent does not exist, and as - /// [`create`](Self::create) fails for a directory that is not private. A directory or lock - /// file that Windows refuses to open for good, unlike a removed one, fails the lock with the - /// denied access. - pub fn lock_for_provision( - dir: impl Into, - files: &CallerFiles, - deadline: Option, - ) -> Result { - let dir = absolute(dir.into())?; - Self::provision_lock( - &dir, - files, - deadline, - |dir| Self::create(dir), - Self::take_lifecycle_lock, - ) - } - - /// Takes the lock as [`lock_for_provision`](Self::lock_for_provision) does, with `create` - /// creating or checking the absolute directory `dir`, as [`create`](Self::create) does, and - /// `take` taking the lock of its store, as [`take_lifecycle_lock`](Self::take_lifecycle_lock) - /// does. - fn provision_lock( - dir: &Path, - files: &CallerFiles, - deadline: Option, - mut create: impl FnMut(&Path) -> Result, - mut take: impl FnMut(&Self, Option) -> Result, - ) -> Result { - let mut races = Races::default(); - // Whether the latest attempt was refused access while the directory was not pending - // deletion. - let mut refused = false; - loop { - let error = match create(dir) { - Ok(store) => match take(&store, deadline) { - Ok(Taken::Locked(lock)) => { - return Ok(LockedStore::new(store, lock, files.clone())); - } - // A removal deleted the lock file while this waited for its lock, and maybe the - // directory, or a transition replaced it as it released its lock: the - // directory is created and locked again. - Ok(Taken::Replaced) => { - refused = false; - if !races.lost(deadline) { - return Err(transition_in_progress(dir)); - } - continue; - } - Err(error) => error, - }, - Err(error) => error, - }; - if cfg!(windows) && denied(&error) { - // A removal deleted the directory, which a file system without POSIX deletion - // keeps under its name until its last handle closes, and refuses to open, or to - // create a file in, until then: it is created again once it goes. - if pending_deletion(dir) { - refused = false; - if !pause(deadline) { - return Err(transition_in_progress(dir)); - } - continue; - } - // The directory, or its lock file, refuses access for good, unless the removed - // directory went after the attempt failed, and is created again now. - if refused { - return Err(error); - } - refused = true; - continue; - } - // A removal deleted the directory after it was created, before it was checked or its - // lock file was opened: unless its parent is gone, it is created again. - if error.kind() != ErrorKind::NotFound || !dir.parent().is_some_and(Path::is_dir) { - return Err(error); - } - refused = false; - if !races.lost(deadline) { - return Err(transition_in_progress(dir)); - } - } - } - - /// Takes the lock of the existing sandbox in `dir`, by a caller that keeps `files` in the - /// directory, waiting for it until `deadline`, or for as long as it takes without one. - /// - /// Fails with [`ErrorKind::NotFound`] if the directory does not exist, also once a concurrent - /// removal deletes it while this waits: a lock file that is removed or replaced meanwhile - /// locks nothing, so the lock of the lock file at the path is taken again, as - /// [`lock`](Self::lock) does, which fails once the directory is gone. A directory that exists - /// is locked even if it holds no provisioned sandbox, as one that was never provisioned, or - /// that an interrupted removal emptied, or whose removal open files still keep: the caller - /// checks its configuration record under the lock, and the release then removes the lock file - /// again. - /// - /// On Windows, a file system without POSIX deletion keeps a removed lock file under its name - /// until its last handle closes, and refuses to open it until then, so the open is tried - /// again every 25 ms. Fails with [`ErrorKind::TimedOut`] when the deadline passes first, as - /// [`lock`](Self::lock) does. A lock file that Windows refuses to open for good, such as a - /// read-only one, still opens without access rights, unlike a removed one, and fails the lock - /// with the denied access. - pub fn lock_existing( - dir: impl Into, - files: &CallerFiles, - deadline: Option, - ) -> Result { - Self::existing_lock(Self::open(dir)?, files, deadline, Self::take_lifecycle_lock) - } - - /// Takes the lock as [`lock_existing`](Self::lock_existing) does, of the opened `store`, with - /// `take` taking it, as [`take_lifecycle_lock`](Self::take_lifecycle_lock) does. - fn existing_lock( - store: Self, - files: &CallerFiles, - deadline: Option, - mut take: impl FnMut(&Self, Option) -> Result, - ) -> Result { - let mut races = Races::default(); - loop { - if let Taken::Locked(lock) = take(&store, deadline)? { - return Ok(LockedStore::new(store, lock, files.clone())); - } - if !races.lost(deadline) { - return Err(transition_in_progress(store.dir())); - } - } - } - - /// Takes the lock as [`take_lock`](Self::take_lock) does, and opens a lock file that Windows - /// refuses to open again every 25 ms until `deadline`, as a removed one that other handles - /// still hold, which a file system without POSIX deletion keeps until they close. A lock file - /// that Windows refuses to open although it opens without access rights is not a removed one, - /// and fails the lock once a second open at once is refused too. - fn take_lifecycle_lock(&self, deadline: Option) -> Result { - // Whether the latest attempt was refused a lock file that opened without access rights. - let mut refused_for_good = false; - loop { - match self.take_lock(deadline) { - Err(error) if cfg!(windows) && denied(&error) => { - if !pending_deletion(&self.path(LOCK_NAME)) { - // The lock file refuses access for good, such as a read-only one, unless - // the removed one went, and another replaced it, after the open failed. - if refused_for_good { - return Err(error); - } - refused_for_good = true; - continue; - } - refused_for_good = false; - if !pause(deadline) { - return Err(transition_in_progress(self.dir())); - } - } - taken => return taken, - } - } - } -} - -/// Returns whether the store's `error` is an I/O error whose cause is a denied access. -fn denied(error: &Error) -> bool { - error.kind() == ErrorKind::Io - && std::error::Error::source(error) - .and_then(|source| source.downcast_ref::()) - .is_some_and(|source| source.kind() == io::ErrorKind::PermissionDenied) -} - -/// Returns whether the file or directory at `path`, which Windows refused to open, or to create a -/// file in, is a removed one that other handles still hold: Windows refuses every open of such a -/// file or directory, also one without access rights, which opens one whose access it refuses -/// otherwise. -fn pending_deletion(path: &Path) -> bool { - platform::file_identity(path) - .is_err_and(|error| error.kind() == io::ErrorKind::PermissionDenied) -} - -impl Drop for LockedStore { - /// Releases the lock. A holder that leaves the directory without its caller's configuration - /// record removes its own lock file first, unless it removed it already, so that a directory - /// that holds no provisioned sandbox keeps no lock file, and empties. - /// - /// The lock is released before its file closes: on Linux, a process that another thread is - /// spawning holds a copy of every descriptor from its fork until its exec, and `flock` keeps - /// a lock while any copy of its open file description remains, so closing the file alone could - /// keep the lock held until that process executes its program. - fn drop(&mut self) { - self.remove_unprovisioned_lock_file(); - let _ = self.lock.unlock(); - } -} - -impl LockedStore { - /// Removes the lock file, as [dropping](Drop) the lock does first, if the caller declared a - /// configuration record that the directory does not hold, unless this holder removed it - /// already, and if the path still names the locked file. - fn remove_unprovisioned_lock_file(&self) { - let Some(config) = self.files.config() else { - return; - }; - if self.lock_removed - || !matches!( - fs::symlink_metadata(self.path(config)), - Err(error) if error.kind() == io::ErrorKind::NotFound - ) - { - return; - } - let path = self.path(LOCK_NAME); - let held = platform::opened_file_id(&self.lock); - if matches!((held, platform::file_id(&path)), (Ok(held), Ok(current)) if held == current) { - let _ = fs::remove_file(&path); - } - } -} - -impl LockedStore { - /// Removes the sandbox's store, its caller's files, and its directory, which only a sandbox - /// that no VMM can run in allows, and releases the lock. - /// - /// The removal needs the caller's configuration record: a lock that was taken without one, as - /// with [`CallerFiles::none`] or by [`SandboxStore::lock`], refuses with - /// [`RemovalError::Undeclared`], and removes nothing, since a transition that waits through - /// the removal could not tell that the sandbox is gone then, and would keep the lock file that - /// it creates. - /// - /// The removal refuses, and removes nothing, while the store records a VM or a launch: the - /// caller reattaches to the VM and stops it, or recovers the launch, first, as - /// [`supervisor::reattach`](crate::host::supervisor::reattach) does. It also refuses, with - /// [`RemovalError::Io`], and removes nothing, once the directory that its path names holds - /// another lock file than this lock's, as after a link in the path changed. It makes both - /// checks, and removes what a clone kept of its launch, and the sandbox's - /// [clone binding](CLONE_BINDING_NAME), if it holds one, through one handle of the directory - /// that it opens, so that no change of a link in the path meanwhile redirects their removal - /// to another sandbox, whose VM may run. It makes their absence durable before anything else - /// goes, so that no crash after the removal of the directory, or of one of its ancestors, - /// brings back a binding. It then removes the store's other files but its lock, the caller's - /// files, its configuration record last, so that an - /// interrupted removal leaves a provisioned sandbox, which can be removed again, and the - /// temporary files that the store owns. It refuses the entries that are neither the store's - /// nor the caller's, which it names in the order in which the directory lists them, and keeps - /// them and the directory; the lock file goes as the lock is released, since the - /// configuration record is gone. Otherwise it removes its lock file while it still holds the - /// lock, so that a transition that waited for the lock finds the sandbox gone rather than - /// halfway removed, and then the directory, as long as the path still names it. - /// - /// Lock files of other transitions, a clone binding or a file that the clone kept of its launch - /// whose deletion is pending, and on NFS other removed files that handles still hold, can keep - /// the emptied directory, and are not refused: NFS renames a removed file that is still open to - /// `.nfs*`; a Windows file system without POSIX deletion keeps a removed lock file, or a - /// removed binding or clone's file that a reader holds, under its own name until its last - /// handle closes; and a transition that opens the lock file between the removals of the lock - /// file and of the directory creates a new one. Each of those transitions finds no sandbox, and - /// removes its lock file as it releases its lock, and the handles of other removed files close, - /// so the directory empties. The removal then holds the directory's identity, releases the - /// lock, and returns the [`Lingering`] removal, whose [`wait`](Lingering::wait) removes the - /// directory once it empties. It returns `None` once the directory is gone, whoever removed it. - pub fn remove(self) -> std::result::Result, RemovalError> { - self.remove_contents()?; - self.remove_directory() - } - - /// Empties the directory but for lingering files, its own lock file included, as - /// [`remove`](Self::remove) does first: it refuses without the caller's configuration record, - /// removes what a clone kept of its launch and the clone binding as [`unbind`](Self::unbind) - /// does, then the store's other files and the caller's, and the temporary files that the store - /// owns, and refuses any other entry. - fn remove_contents(&self) -> std::result::Result<(), RemovalError> { - self.remove_contents_with(&|| {}, &platform::Directory::sync) - } - - /// Empties the directory as [`remove_contents`](Self::remove_contents) does, with `checked` - /// and `sync` as [`unbind`](Self::unbind) takes them. - fn remove_contents_with( - &self, - checked: &dyn Fn(), - sync: &dyn Fn(&platform::Directory) -> io::Result<()>, - ) -> std::result::Result<(), RemovalError> { - if self.files.config().is_none() { - return Err(RemovalError::Undeclared); - } - self.unbind(checked, sync)?; - // What a clone kept of its launch, and the binding, go once, through the directory's - // handle: one whose deletion is pending refuses a second removal, and its path may name - // another sandbox meanwhile. - let owned: Vec = FILE_NAMES - .into_iter() - .filter(|&name| { - name != LOCK_NAME && name != CLONE_BINDING_NAME && !CLONE_FILE_NAMES.contains(&name) - }) - .chain(self.files.names()) - .map(|name| self.path(name)) - .collect(); - for path in owned { - remove_entry(path)?; - } - let listed = entries(self.dir()).map_err(|error| RemovalError::Io { - path: self.dir().to_owned(), - error, - })?; - let mut foreign = Vec::new(); - for name in listed { - // Lock files, a binding or a clone's file whose deletion is pending, and removed files - // that NFS keeps while handles hold them, go once their holders release them, which - // the removal waits for, if they keep the directory. - if lingers(self.dir(), &name) { - continue; - } - if is_store_temporary(&name) || is_report_staging(&name) { - remove_entry(self.path(&name))?; - } else { - foreign.push(name); - } - } - if foreign.is_empty() { - Ok(()) - } else { - Err(RemovalError::Foreign(foreign)) - } - } - - /// Removes what the clone of the sandbox kept of its launch, and then the sandbox's clone - /// binding, if it holds one, and makes their absence durable with `sync`, - /// before anything else of the sandbox goes, through one handle of the directory that the path - /// names. Through that handle, it checks first that the directory holds this lock's file, and - /// refuses otherwise, then that the store records no VM and no launch, and runs `checked` once - /// both checks pass, before it removes anything. No change of a link in the path meanwhile - /// redirects these steps to another sandbox, whose VM may run, and whose files then stay. - /// - /// The binding's absence, after this removal, is then durable while the directory, which - /// keeps the sandbox provisioned, remains. A binding, or a file of the clone, whose deletion - /// is pending already counts as removed. - fn unbind( - &self, - checked: &dyn Fn(), - sync: &dyn Fn(&platform::Directory) -> io::Result<()>, - ) -> std::result::Result<(), RemovalError> { - let dir = self.dir(); - let failed = |error: io::Error| RemovalError::Io { - path: dir.to_owned(), - error, - }; - let entry_failed = |name: &str| { - let path = self.path(name); - move |error: io::Error| RemovalError::Io { path, error } - }; - let directory = platform::Directory::open(dir).map_err(failed)?; - let held = platform::opened_file_id(&self.lock).map_err(entry_failed(LOCK_NAME))?; - if directory - .entry_id(LOCK_NAME) - .map_err(entry_failed(LOCK_NAME))? - != Some(held) - { - return Err(failed(io::Error::other( - "the directory that the path names does not hold the sandbox's lock", - ))); - } - for name in [JOURNAL_NAME, RUNTIME_NAME] { - if directory - .entry_id(name) - .map_err(entry_failed(name))? - .is_some() - { - return Err(RemovalError::Launched); - } - } - checked(); - // What a clone kept of its launch goes before its binding, so that the binding's durable - // absence makes theirs durable too. - for name in CLONE_FILE_NAMES { - directory.remove_file(name).map_err(entry_failed(name))?; - } - directory - .remove_file(CLONE_BINDING_NAME) - .map_err(entry_failed(CLONE_BINDING_NAME))?; - sync(&directory).map_err(failed) - } - - /// Removes the lock file while it still holds the lock, and then the directory, as - /// [`remove`](Self::remove) does once the directory holds nothing else of the sandbox. - fn remove_directory(self) -> std::result::Result, RemovalError> { - self.remove_directory_after(|| {}) - } - - /// Removes the lock file and the directory as [`remove_directory`](Self::remove_directory) - /// does, and runs `unlinked` between the two, where the transitions that the removal races - /// can act. - fn remove_directory_after( - mut self, - unlinked: impl FnOnce(), - ) -> std::result::Result, RemovalError> { - let dir = self.dir().to_owned(); - // While the lock is held, its file keeps the directory, which the path then names. - let (identity, held) = hold_directory(&dir).map_err(|error| RemovalError::Io { - path: dir.clone(), - error, - })?; - let lock = self.path(LOCK_NAME); - fs::remove_file(&lock).map_err(|error| RemovalError::Io { path: lock, error })?; - self.lock_removed = true; - unlinked(); - match remove_held_directory(&dir, identity) { - // The directory is gone: this removal removed it, or another one did once it emptied, - // after which the path may name another directory, or none. - Ok(_) => return Ok(None), - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None), - Err(error) if directory_not_empty(&error) => {} - Err(error) => return Err(RemovalError::Io { path: dir, error }), - } - let config = self.files.config().map(str::to_owned); - drop(self); - Ok(Some(Lingering { - dir, - identity, - _held: held, - config, - retried_at_once: false, - })) - } -} - -/// The removal of a sandbox's directory that open files, such as the lock files of other -/// transitions, keep, which [`LockedStore::remove`] returns once it has removed everything else -/// and released the lock. -/// -/// It holds the identity of the directory that it removes: on Unix, its open handle keeps a -/// directory that replaces it from reusing its inode number, and NTFS and ReFS do not reuse file -/// IDs. The path may name another directory once this one is gone, such as one that a provision -/// created again, which is not this removal's to remove. -#[derive(Debug)] -pub struct Lingering { - /// The directory. - dir: PathBuf, - /// The identity of the directory, which `identity_of` gives. - identity: Identity, - /// The open directory on Unix, which keeps its identity. - _held: Option, - /// The caller's configuration record, which a provision of the path writes. - config: Option, - /// Whether the latest attempt was the one that followed at once a failure without remaining - /// entries. - retried_at_once: bool, -} - -impl Lingering { - /// Returns the directory that the removal waits for. - pub fn dir(&self) -> &Path { - &self.dir - } - - /// Removes the directory once open files no longer keep it, trying again every 25 ms until - /// `deadline`, or for as long as it takes without one. A deadline that has passed still tries - /// once, so a caller that waits on its own, as for an interruption, calls this again, and a - /// removal that timed out can go on. - /// - /// Returns once the directory is gone, whoever removed it, or once the path names another - /// directory, which it checks before each attempt to remove it. Fails, and keeps the - /// directory, with [`RemovalError::ProvisionedAgain`] once the caller's configuration record - /// appears in it, as a provision that took the lock after the removal released it writes it, - /// which it looks up by its path, so that a file system that ignores case finds it whatever - /// the case of the name that the provision gave it, with [`RemovalError::Foreign`] for other - /// entries than lock files, a clone binding or a clone's file whose deletion is pending, and - /// the removed files that NFS keeps, sorted, and with [`RemovalError::TimedOut`] once the - /// deadline passes. - pub fn wait(&mut self, deadline: Option) -> std::result::Result<(), RemovalError> { - loop { - let error = match remove_held_directory(&self.dir, self.identity) { - Ok(_) => return Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(error) => error, - }; - if directory_status(&self.dir) != Some(self.identity) { - return Ok(()); - } - let remaining = match entries(&self.dir) { - Ok(remaining) => remaining, - Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()), - Err(error) => { - return Err(RemovalError::Io { - path: self.dir.clone(), - error, - }); - } - }; - let mut unexpected: Vec = remaining - .iter() - .filter(|name| !lingers(&self.dir, name)) - .cloned() - .collect(); - unexpected.sort(); - if !unexpected.is_empty() { - // A provision that took the lock after the removal released it, before the - // directory went, provisions the directory again. Its record is looked up by its - // path, which a file system that ignores case resolves to the name that the - // provision gave it, and one that does not keeps apart from other names. - if let Some(config) = &self.config { - let path = self.dir.join(config); - match fs::symlink_metadata(&path) { - Ok(_) => return Err(RemovalError::ProvisionedAgain), - Err(error) if error.kind() == io::ErrorKind::NotFound => {} - Err(error) => return Err(RemovalError::Io { path, error }), - } - } - return Err(RemovalError::Foreign(unexpected)); - } - if !directory_not_empty(&error) { - return Err(RemovalError::Io { - path: self.dir.clone(), - error, - }); - } - // Without remaining entries, the last open file went away after the removal failed, - // so the next attempt follows at once. Another such failure right after it pauses - // first, so that a file system that does not report emptiness right away cannot spin. - if remaining.is_empty() && !self.retried_at_once { - self.retried_at_once = true; - continue; - } - self.retried_at_once = false; - if !pause(deadline) { - return Err(RemovalError::TimedOut(error)); - } - } - } -} - -/// Why [`LockedStore::remove`] or [`Lingering::wait`] kept a sandbox's directory. -#[derive(Debug)] -#[non_exhaustive] -pub enum RemovalError { - /// The lock was taken without the caller's configuration record, which the removal needs, and - /// nothing was removed: see [`LockedStore::remove`]. - Undeclared, - /// The store records a VM or a launch, so a VMM of the sandbox may still run, and nothing was - /// removed. - Launched, - /// The directory holds entries that are neither the store's nor its caller's, which the - /// removal keeps, with the directory: their names, in the order in which the directory lists - /// them, or sorted once the removal waited for open files. - Foreign(Vec), - /// The sandbox was provisioned again while the removal waited for open files: the caller's - /// configuration record appeared in the directory, which the removal keeps. - ProvisionedAgain, - /// Open files still kept the directory when the deadline passed: the error with which the - /// latest attempt to remove it failed. - TimedOut(io::Error), - /// An operation on one of the sandbox's files, or on its directory, failed. - Io { - /// The file or directory. - path: PathBuf, - /// The error of the operation. - error: io::Error, - }, -} - -impl fmt::Display for RemovalError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Undeclared => formatter.write_str( - "the removal of a sandbox needs its caller's configuration record, which its lock \ - was not taken with", - ), - Self::Launched => { - formatter.write_str("the sandbox's store records a VM or a launch, which may run") - } - Self::Foreign(names) => write!( - formatter, - "the sandbox's directory holds entries that are neither the store's nor its \ - caller's: {}", - names.join(", ") - ), - Self::ProvisionedAgain => formatter - .write_str("the sandbox was provisioned again while its directory was removed"), - Self::TimedOut(_) => { - formatter.write_str("files that are still open keep the sandbox's directory") - } - Self::Io { path, .. } => write!(formatter, "cannot remove {}", path.display()), - } - } -} - -impl std::error::Error for RemovalError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::TimedOut(error) | Self::Io { error, .. } => Some(error), - _ => None, - } - } -} - -/// Keeps the message of a removal's error, and its I/O cause: a removal without the caller's -/// configuration record is of [`ErrorKind::Unsupported`], a timeout of [`ErrorKind::TimedOut`], an -/// I/O failure of [`ErrorKind::Io`], and every other refusal of [`ErrorKind::Other`], with the -/// code [`ErrorCode::SandboxRunning`] for a sandbox that may run, [`ErrorCode::ForeignFiles`] for -/// entries that are not NVX's, and [`ErrorCode::SandboxProvisioned`] for a sandbox that was -/// provisioned again. -impl From for Error { - fn from(error: RemovalError) -> Self { - let message = error.to_string(); - match error { - RemovalError::Undeclared => Error::unsupported(message), - RemovalError::Launched => Error::new(ErrorCode::SandboxRunning, message), - RemovalError::Foreign(_) => Error::new(ErrorCode::ForeignFiles, message), - RemovalError::ProvisionedAgain => Error::new(ErrorCode::SandboxProvisioned, message), - RemovalError::TimedOut(source) => Error::timed_out(message).with_source(source), - RemovalError::Io { error: source, .. } => Error::io(message).with_source(source), - } - } -} - -/// Removes the file at `path` if it exists. -fn remove_entry(path: PathBuf) -> std::result::Result<(), RemovalError> { - match fs::remove_file(&path) { - Err(error) if error.kind() != io::ErrorKind::NotFound => { - Err(RemovalError::Io { path, error }) - } - _ => Ok(()), - } -} - -/// Returns whether `name`, an entry of the directory `dir`, is a lock file, or a removed file that -/// a handle still keeps, which goes once its holder releases or closes it: NFS renames a removed -/// file that is still open to `.nfs*`, and Windows file systems without POSIX deletion keep a -/// removed lock file under its own name, as they keep the lock files that waiting transitions -/// create until they release them, and a removed clone binding, or a removed file that the clone -/// kept of its launch, until its readers close it, whose deletion is then pending. -fn lingers(dir: &Path, name: &str) -> bool { - name == LOCK_NAME - || name.starts_with(".nfs") - || ((name == CLONE_BINDING_NAME || CLONE_FILE_NAMES.contains(&name)) - && platform::is_deleted(&dir.join(name))) -} - -/// Returns whether `error` is how the removal of a directory reports one that is not empty. -fn directory_not_empty(error: &io::Error) -> bool { - #[cfg(windows)] - let not_empty = matches!(error.raw_os_error(), Some(145 | 80 | 183)); - #[cfg(unix)] - let not_empty = matches!( - error.raw_os_error(), - Some(code) if code == nix::errno::Errno::ENOTEMPTY as i32 - || code == nix::errno::Errno::EEXIST as i32 - ); - #[cfg(not(any(windows, unix)))] - let not_empty = error.kind() == io::ErrorKind::DirectoryNotEmpty; - not_empty -} - -/// Returns the names of the entries of `directory`, in the order in which it lists them. -fn entries(directory: &Path) -> io::Result> { - fs::read_dir(directory)? - .map(|entry| { - entry? - .file_name() - .into_string() - .map_err(|_| not_unicode("a file name")) - }) - .collect() -} - -/// The identity of a file or directory: its device and inode on Unix, and its volume serial number -/// and file ID on Windows. -type Identity = (u64, u128); - -/// Returns the identity of the directory `path`, which [`identity_of`] gives, and a handle that -/// keeps it: on Unix, the open directory keeps a directory that replaces it from reusing its inode -/// number, and NTFS and ReFS do not reuse file IDs. -fn hold_directory(path: &Path) -> io::Result<(Identity, Option)> { - #[cfg(unix)] - { - use std::os::unix::fs::{MetadataExt, OpenOptionsExt}; - - let file = fs::OpenOptions::new() - .read(true) - .custom_flags((nix::fcntl::OFlag::O_DIRECTORY | nix::fcntl::OFlag::O_NOFOLLOW).bits()) - .open(path)?; - // The open directory's status gives the identity that `identity_of` gives for its path. - let metadata = file.metadata()?; - Ok(((metadata.dev(), u128::from(metadata.ino())), Some(file))) - } - #[cfg(not(unix))] - { - Ok((identity_of(path)?, None)) - } -} - -/// Returns the identity of the file or directory that `path` names, without following a link -/// that it names: its device and inode on Unix, and on Windows the volume serial number and the -/// 128-bit file ID of `FILE_ID_INFO`, which tell the files of ReFS volumes apart too, as the -/// [`FileSeal`](crate::host::seal::FileSeal) of the object that `path` opens holds them. -/// -/// Windows refuses to open a directory that a removal deleted while another handle keeps it, as -/// it refuses some system files. Its entry still exists, as [`fs::symlink_metadata`] finds it in -/// its parent, but has no identity that can be read, so it gets the identity `(0, 0)`, which no -/// directory that a store holds has. -fn identity_of(path: &Path) -> io::Result { - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - - let metadata = fs::symlink_metadata(path)?; - Ok((metadata.dev(), u128::from(metadata.ino()))) - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - - use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION}; - use windows_sys::Win32::Storage::FileSystem::{ - FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_READ_ATTRIBUTES, - }; - - let opened = fs::OpenOptions::new() - .access_mode(FILE_READ_ATTRIBUTES) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) - .open(path); - match opened { - Ok(file) => { - let seal = crate::host::seal::FileSeal::of(&file)?; - Ok((seal.device(), seal.file_id())) - } - Err(error) - if [ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION] - .map(|code| Some(code as i32)) - .contains(&error.raw_os_error()) - && fs::symlink_metadata(path).is_ok() => - { - Ok((0, 0)) - } - Err(error) => Err(error), - } - } - #[cfg(not(any(unix, windows)))] - { - platform::file_identity(path).map(|(device, index)| (device, u128::from(index))) - } -} - -/// Returns the identity that [`identity_of`] gives for `path`, or `None` if it cannot be -/// examined. -fn directory_status(path: &Path) -> Option { - identity_of(path).ok() -} - -/// Removes the directory `dir` if the path still names the one that the caller holds, whose -/// identity, which [`identity_of`] gives, is `identity`, so that a directory that replaced it -/// stays. Returns whether the path named it, and fails as the check of the path or the removal -/// fails. -/// -/// The check and the removal are two steps: a directory that replaces the held one between them, -/// which only a concurrent removal of the held one and a creation of another at the path both -/// allow, still goes while it is empty, and its creator then finds it gone, as a provision does, -/// which creates it again. -fn remove_held_directory(dir: &Path, identity: Identity) -> io::Result { - if identity_of(dir)? != identity { - return Ok(false); - } - fs::remove_dir(dir)?; - Ok(true) -} diff --git a/nvx-rs/src/host/store/lifecycle/tests.rs b/nvx-rs/src/host/store/lifecycle/tests.rs deleted file mode 100644 index cb6a3572d..000000000 --- a/nvx-rs/src/host/store/lifecycle/tests.rs +++ /dev/null @@ -1,1285 +0,0 @@ -//! Tests of the lifecycle locks, their release, and the removal of a store, by threads of one -//! process, and by transitions in processes of their own, which [`transition_role`] runs. - -use std::cell::RefCell; -use std::env; -use std::io::{BufRead, BufReader, Read}; -use std::process::{Child, ChildStdout, Command, Stdio}; -use std::sync::mpsc; -use std::thread; -use std::time::Duration; - -use super::*; -use crate::host::platform::inherit::spawn_command; -use crate::host::store::{ - CAPABILITY_NAME, CLONE_ACTIVATION_NAME, CLONE_FAILURE_NAME, CLONE_PROGRESS_NAME, - CLONE_SCRATCH_NAME, CONSOLE_NAME, LOG_NAME, OUTCOME_NAME, RESTORE_READY_NAME, SOCKET_NAME, -}; -use crate::host::tests::released; - -/// The configuration record of these tests' caller. -const CONFIG: &str = "config.json"; -/// Another file of these tests' caller. -const BOOT: &str = "boot.sock"; -/// A name that NFS gives a removed lock file that a transition still holds open. -const RENAMED: &str = ".nfs00000000000000010000"; -/// The directory of the sandbox whose lock [`transition_role`] takes. -const ROLE_DIR: &str = "NVX_LIFECYCLE_TEST_DIR"; -/// The lock that [`transition_role`] takes: `provision` or `existing`. -const ROLE_LOCK: &str = "NVX_LIFECYCLE_TEST_LOCK"; -/// The line that [`transition_role`] prints before it waits for the lock. -const WAITING: &str = "nvx-lifecycle-test-waiting"; -/// The start of the line in which [`transition_role`] prints how its transition ended. -const OUTCOME: &str = "nvx-lifecycle-test-outcome "; - -/// Returns the files of these tests' caller. -fn files() -> CallerFiles { - CallerFiles::new(CONFIG).unwrap().with(BOOT).unwrap() -} - -/// Returns the deadline `seconds` from now. -fn within(seconds: u64) -> Option { - Some(Instant::now() + Duration::from_secs(seconds)) -} - -/// Returns the names of the entries of `dir`, sorted. -fn entries_of(dir: &Path) -> Vec { - let mut names = entries(dir).unwrap(); - names.sort(); - names -} - -/// Returns `names` as sorted strings. -fn sorted(names: &[&str]) -> Vec { - let mut names: Vec = names.iter().map(|&name| name.to_owned()).collect(); - names.sort(); - names -} - -/// Provisions a sandbox in a fresh temporary directory, and returns the directory of its store. -fn sandbox() -> (tempfile::TempDir, PathBuf) { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - let locked = SandboxStore::lock_for_provision(&dir, &files(), None).unwrap(); - assert!(!dir.join(CONFIG).exists()); - fs::write(dir.join(CONFIG), b"{}").unwrap(); - drop(locked); - (directory, dir) -} - -/// Takes the lock of the existing sandbox in `dir`, and starts its removal. -fn remove(dir: &Path) -> std::result::Result, RemovalError> { - SandboxStore::lock_existing(dir, &files(), None) - .unwrap() - .remove() -} - -/// Removes the sandbox in `dir` as [`LockedStore::remove`] does while the lock file of another -/// transition, which NFS renamed as the transition still held it, keeps the directory. -fn remove_lingering(dir: &Path) -> Lingering { - fs::write(dir.join(RENAMED), b"").unwrap(); - remove(dir) - .unwrap() - .expect("a lingering lock file keeps the directory") -} - -/// Takes the lock of the existing sandbox in `dir` until `deadline` and releases it, and returns -/// whether the sandbox was provisioned. -fn transition(dir: &Path, deadline: Option) -> Result { - let locked = SandboxStore::lock_existing(dir, &files(), deadline)?; - Ok(locked.dir().join(CONFIG).exists()) -} - -/// A transition in a process of its own, which [`transition_role`] runs. -struct Transition { - /// The process. - child: Child, - /// Its standard output. - output: BufReader, -} - -impl Transition { - /// Starts a transition that takes the lock `lock` of the sandbox in `dir`, and returns once - /// it is about to wait for the lock. - fn start(dir: &Path, lock: &str) -> Self { - let mut command = Command::new(env::current_exe().unwrap()); - command - .args([ - "--exact", - "host::store::lifecycle::tests::transition_role", - "--ignored", - "--nocapture", - "--test-threads=1", - ]) - .env(ROLE_DIR, dir) - .env(ROLE_LOCK, lock) - .stdin(Stdio::null()) - .stdout(Stdio::piped()); - let mut child = spawn_command(&mut command).unwrap(); - let mut output = BufReader::new(child.stdout.take().unwrap()); - let mut line = String::new(); - while line.trim_end() != WAITING { - line.clear(); - let read = output.read_line(&mut line).unwrap(); - assert_ne!( - read, 0, - "the transition ended before it waited for the lock" - ); - } - Self { child, output } - } - - /// Returns whether the transition still runs. - fn runs(&mut self) -> bool { - self.child.try_wait().unwrap().is_none() - } - - /// Waits for the transition to end, and returns how it ended, as [`transition_role`] prints - /// it. - fn outcome(mut self) -> String { - let mut rest = String::new(); - self.output.read_to_string(&mut rest).unwrap(); - assert!(self.child.wait().unwrap().success(), "{rest}"); - rest.lines() - .find_map(|line| line.strip_prefix(OUTCOME)) - .expect("the transition printed its outcome") - .to_owned() - } -} - -impl Drop for Transition { - fn drop(&mut self) { - let _ = self.child.kill(); - let _ = self.child.wait(); - } -} - -/// A transition in a process of its own, which [`Transition::start`] runs: it takes the lock that -/// [`ROLE_LOCK`] names of the sandbox in [`ROLE_DIR`] within 30 s, and prints how it ended: -/// `provisioned` if it found the configuration record, `unprovisioned` if it did not, which a -/// provision then writes, and `gone` if the sandbox no longer exists. -#[test] -#[ignore = "a transition that the lifecycle tests run in a process of its own"] -fn transition_role() { - let (Some(dir), Ok(lock)) = (env::var_os(ROLE_DIR), env::var(ROLE_LOCK)) else { - return; - }; - // The test harness has printed the test's name without ending the line. - println!("\n{WAITING}"); - let locked = match lock.as_str() { - "provision" => SandboxStore::lock_for_provision(&dir, &files(), within(30)), - "existing" => SandboxStore::lock_existing(&dir, &files(), within(30)), - _ => panic!("unknown lock {lock}"), - }; - let outcome = match locked { - Ok(locked) if locked.dir().join(CONFIG).exists() => "provisioned", - Ok(locked) => { - if lock == "provision" { - fs::write(locked.dir().join(CONFIG), b"{}").unwrap(); - } - "unprovisioned" - } - Err(error) if error.kind() == ErrorKind::NotFound => "gone", - Err(error) => panic!("{error}"), - }; - println!("{OUTCOME}{outcome}"); -} - -#[test] -fn lifecycle_locks_exclude_other_holders_until_released() { - let (_directory, dir) = sandbox(); - let held = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let started = Instant::now(); - let deadline = Some(Instant::now() + Duration::from_millis(100)); - let error = SandboxStore::lock_existing(&dir, &files(), deadline).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::TimedOut, "{error}"); - assert!(started.elapsed() >= Duration::from_millis(100)); - let error = SandboxStore::lock_for_provision(&dir, &files(), Some(Instant::now())).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::TimedOut, "{error}"); - // A waiter takes the lock once its holder releases it. - let (sender, receiver) = mpsc::channel(); - let waiter = thread::spawn({ - let dir = dir.clone(); - move || { - let locked = SandboxStore::lock_existing(&dir, &files(), within(30)).map(drop); - sender.send(()).unwrap(); - locked - } - }); - assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); - drop(held); - waiter.join().unwrap().unwrap(); - assert!(released(|| { - SandboxStore::lock_existing(&dir, &files(), Some(Instant::now())).is_ok() - })); - // A provisioned directory keeps its lock file. - assert_eq!(entries_of(&dir), sorted(&[CONFIG, LOCK_NAME])); -} - -#[test] -fn released_locks_leave_no_lock_file_in_unprovisioned_directories() { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - // A provision that records nothing leaves the directory that it created empty, and so does - // a transition of a sandbox that was never provisioned. - drop(SandboxStore::lock_for_provision(&dir, &files(), None).unwrap()); - assert!(entries_of(&dir).is_empty()); - drop(SandboxStore::lock_existing(&dir, &files(), None).unwrap()); - assert!(entries_of(&dir).is_empty()); - // A directory that does not exist holds no sandbox: only a provision creates it, below a - // parent that exists. - let missing = directory.path().join("missing"); - let error = SandboxStore::lock_existing(&missing, &files(), None).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); - assert!(!missing.exists()); - drop(SandboxStore::lock_for_provision(&missing, &files(), None).unwrap()); - assert!(missing.is_dir()); - let orphan = directory.path().join("absent").join("sandbox"); - let error = SandboxStore::lock_for_provision(&orphan, &files(), None).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); - assert!(!directory.path().join("absent").exists()); - // A caller that keeps no configuration record keeps the lock file, as the store's own lock - // does. - drop(SandboxStore::lock_existing(&dir, &CallerFiles::none(), None).unwrap()); - assert_eq!(entries_of(&dir), [LOCK_NAME]); - drop(SandboxStore::open(&dir).unwrap().lock(None).unwrap()); - assert_eq!(entries_of(&dir), [LOCK_NAME]); -} - -#[test] -fn a_holder_removes_only_its_own_lock_file_on_release() { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - // A holder that removed its lock file, as a removal does while it holds the lock, leaves the - // new lock file of a transition that took the lock meanwhile. - let mut held = SandboxStore::lock_for_provision(&dir, &files(), None).unwrap(); - fs::remove_file(dir.join(LOCK_NAME)).unwrap(); - held.lock_removed = true; - let other = SandboxStore::lock_existing(&dir, &files(), within(10)).unwrap(); - drop(held); - assert_eq!(entries_of(&dir), [LOCK_NAME]); - drop(other); - assert!(entries_of(&dir).is_empty()); - // So does a holder whose lock file another replaced. - let held = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - fs::remove_file(dir.join(LOCK_NAME)).unwrap(); - fs::write(dir.join(LOCK_NAME), b"").unwrap(); - drop(held); - assert_eq!(entries_of(&dir), [LOCK_NAME]); -} - -#[test] -fn directories_that_others_may_access_are_refused() { - let (_directory, dir) = sandbox(); - #[cfg(unix)] - let problem = { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(&dir, fs::Permissions::from_mode(0o755)).unwrap(); - "is accessible to other users (mode 755)" - }; - #[cfg(windows)] - let problem = { - platform::tests::set_access(&dir, "D:(A;OICI;FA;;;WD)").unwrap(); - "allows other users" - }; - for error in [ - SandboxStore::lock_existing(&dir, &files(), None).unwrap_err(), - SandboxStore::lock_for_provision(&dir, &files(), None).unwrap_err(), - ] { - assert_eq!( - error.message(), - format!("the sandbox store {} {problem}", dir.display()) - ); - } -} - -#[test] -fn stale_directories_hold_no_sandbox_and_keep_no_lock_file() { - let directory = tempfile::tempdir().unwrap(); - // A directory that was never provisioned, and those that an interrupted removal left: empty, - // or with a lock file and the temporary file of an interrupted replacement. - let never = SandboxStore::create(directory.path().join("never")).unwrap(); - let emptied = SandboxStore::create(directory.path().join("emptied")).unwrap(); - let left = SandboxStore::create(directory.path().join("left")).unwrap(); - fs::write(left.dir().join(LOCK_NAME), b"").unwrap(); - fs::write(left.dir().join(".runtime.json.1.tmp"), b"{").unwrap(); - for store in [&never, &emptied, &left] { - let locked = SandboxStore::lock_existing(store.dir(), &files(), None).unwrap(); - assert!(!locked.dir().join(CONFIG).exists()); - drop(locked); - assert!(!store.dir().join(LOCK_NAME).exists()); - } - // A removal of such a directory finds nothing of a sandbox, and removes it. - assert!(remove(left.dir()).unwrap().is_none()); - assert!(!left.dir().exists()); -} - -#[test] -fn a_removal_removes_the_store_the_callers_files_and_the_directory() { - let (_directory, dir) = sandbox(); - for name in [ - LOG_NAME, - OUTCOME_NAME, - CONSOLE_NAME, - CAPABILITY_NAME, - SOCKET_NAME, - CLONE_BINDING_NAME, - CLONE_SCRATCH_NAME, - CLONE_ACTIVATION_NAME, - CLONE_PROGRESS_NAME, - CLONE_FAILURE_NAME, - RESTORE_READY_NAME, - BOOT, - ".runtime.json.12345.tmp", - ".sandbox.lock.7.tmp", - ".tmpAbC123", - ] { - fs::write(dir.join(name), b"left").unwrap(); - } - assert!(remove(&dir).unwrap().is_none()); - assert!(!dir.exists()); - // A transition that comes later finds no sandbox, and creates nothing. - let error = SandboxStore::lock_existing(&dir, &files(), None).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); - assert!(!dir.exists()); -} - -#[test] -fn a_removal_refuses_foreign_entries_and_keeps_them() { - let (_directory, dir) = sandbox(); - fs::write(dir.join(LOG_NAME), b"log").unwrap(); - fs::write(dir.join(BOOT), b"").unwrap(); - // Entries that resemble the store's files and temporary files, or the caller's, are foreign, - // as are the caller's own temporary files, which it removes itself. - let foreign = [ - "notes.txt", - ".config.json.1.tmp", - ".runtime.json.1a.tmp", - "outcome.json.old", - ".tmpAbC1234", - "directory", - ]; - for name in &foreign[..5] { - fs::write(dir.join(name), b"kept").unwrap(); - } - fs::create_dir(dir.join("directory")).unwrap(); - // A removed file that NFS keeps while a handle holds it is not foreign: it goes as the - // handle closes. - fs::write(dir.join(RENAMED), b"").unwrap(); - let RemovalError::Foreign(mut names) = remove(&dir).unwrap_err() else { - panic!("foreign entries were not refused"); - }; - names.sort(); - assert_eq!(names, sorted(&foreign)); - // The store's files and the caller's went first, and the lock file as the lock was released, - // so the sandbox is gone and the foreign entries remain, in the directory. - let mut remaining = foreign.to_vec(); - remaining.push(RENAMED); - assert_eq!(entries_of(&dir), sorted(&remaining)); - assert_eq!(fs::read(dir.join("notes.txt")).unwrap(), b"kept"); - let error = Error::from(RemovalError::Foreign(vec!["a".to_owned(), "b".to_owned()])); - assert_eq!(error.kind(), ErrorKind::Other); - assert_eq!( - error.message(), - "the sandbox's directory holds entries that are neither the store's nor its caller's: a, b" - ); - // Once they are gone, the directory goes too. - for name in &foreign[..5] { - fs::remove_file(dir.join(name)).unwrap(); - } - fs::remove_dir(dir.join("directory")).unwrap(); - fs::remove_file(dir.join(RENAMED)).unwrap(); - assert!(remove(&dir).unwrap().is_none()); - assert!(!dir.exists()); -} - -#[test] -fn a_removal_is_refused_while_a_vm_or_a_launch_is_recorded() { - for name in [JOURNAL_NAME, RUNTIME_NAME] { - let (_directory, dir) = sandbox(); - fs::write(dir.join(name), b"{}").unwrap(); - fs::write(dir.join(LOG_NAME), b"log").unwrap(); - fs::write(dir.join(CLONE_BINDING_NAME), b"{}").unwrap(); - assert!( - matches!(remove(&dir), Err(RemovalError::Launched)), - "{name}" - ); - // Nothing was removed, not even the clone binding of the VM that may run, and the - // provisioned sandbox keeps its lock file. - assert_eq!( - entries_of(&dir), - sorted(&[CLONE_BINDING_NAME, CONFIG, LOCK_NAME, LOG_NAME, name]) - ); - } -} - -/// Writes the files that a clone keeps of its launch in the sandbox `dir`, its binding included, -/// and the file of the scratch device that an earlier version left for a clone in RAM. -fn write_clone_files(dir: &Path) { - for name in [ - CLONE_BINDING_NAME, - CLONE_SCRATCH_NAME, - CLONE_ACTIVATION_NAME, - CLONE_PROGRESS_NAME, - CLONE_FAILURE_NAME, - RESTORE_READY_NAME, - ] { - fs::write(dir.join(name), b"{}").unwrap(); - } -} - -#[test] -fn a_removal_makes_the_absence_of_the_clones_files_durable_before_it_removes_anything_else() { - let (_directory, dir) = sandbox(); - write_clone_files(&dir); - for name in [LOG_NAME, BOOT] { - fs::write(dir.join(name), b"{}").unwrap(); - } - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let synced = RefCell::new(Vec::new()); - locked - .remove_contents_with(&|| {}, &|directory| { - // The directory that the sync makes durable holds the sandbox's lock file. - let lock = directory.entry_id(LOCK_NAME).unwrap(); - synced.borrow_mut().push((lock, entries_of(&dir))); - Ok(()) - }) - .unwrap(); - // A crash after the sync, even once the directory or one of its ancestors is gone, cannot - // bring the binding or the clone's files back, and one before the configuration record goes - // leaves a provisioned sandbox, whose removal can be retried. - assert_eq!( - synced.into_inner(), - [( - Some(platform::file_id(&dir.join(LOCK_NAME)).unwrap()), - sorted(&[BOOT, CONFIG, LOCK_NAME, LOG_NAME]) - )] - ); - assert_eq!(entries_of(&dir), sorted(&[LOCK_NAME])); - assert!(locked.remove_directory().unwrap().is_none()); - assert!(!dir.exists()); -} - -#[test] -fn a_removal_that_cannot_make_the_absence_of_the_clones_files_durable_keeps_the_sandbox() { - let (_directory, dir) = sandbox(); - write_clone_files(&dir); - fs::write(dir.join(LOG_NAME), b"{}").unwrap(); - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let error = locked - .remove_contents_with(&|| {}, &|_| Err(io::Error::other("the sync failed"))) - .unwrap_err(); - assert!( - matches!(&error, RemovalError::Io { path, .. } if path == locked.dir()), - "{error}" - ); - assert_eq!(Error::from(error).kind(), ErrorKind::Io); - drop(locked); - // Only the clone's files went, so the sandbox is still provisioned, and its removal can be - // retried. - assert_eq!(entries_of(&dir), sorted(&[CONFIG, LOCK_NAME, LOG_NAME])); - assert!(remove(&dir).unwrap().is_none()); - assert!(!dir.exists()); -} - -/// Provisions a sandbox in `dir`, whose parent exists. -fn provision(dir: &Path) { - let locked = SandboxStore::lock_for_provision(dir, &files(), None).unwrap(); - fs::write(dir.join(CONFIG), b"{}").unwrap(); - drop(locked); -} - -/// Links `link` to the directory `target`: a symbolic link on Unix, and a junction on Windows, -/// which any user may create. -fn link_dir(target: &Path, link: &Path) { - #[cfg(unix)] - std::os::unix::fs::symlink(target, link).unwrap(); - #[cfg(windows)] - crate::host::tests::junction(link, target); -} - -/// Makes the link `link`, which [`link_dir`] made, name the directory `target` instead. -fn relink(target: &Path, link: &Path) { - #[cfg(unix)] - fs::remove_file(link).unwrap(); - // Removing a junction removes the link alone. - #[cfg(windows)] - fs::remove_dir(link).unwrap(); - link_dir(target, link); -} - -/// Provisions the sandboxes `one/sandbox` and `two/sandbox` in `root`, each holding a clone -/// binding and the files that a clone keeps of its launch, links `root/link` to `one`, and returns -/// `one`, `two`, and the link. -fn linked_sandboxes(root: &Path) -> (PathBuf, PathBuf, PathBuf) { - let (one, two, link) = (root.join("one"), root.join("two"), root.join("link")); - for parent in [&one, &two] { - fs::create_dir(parent).unwrap(); - let sandbox = parent.join("sandbox"); - provision(&sandbox); - write_clone_files(&sandbox); - } - link_dir(&one, &link); - (one, two, link) -} - -/// The entries of a provisioned sandbox that [`linked_sandboxes`] made, with `more`, sorted. -fn linked_entries(more: &[&str]) -> Vec { - let mut names = vec![ - CLONE_ACTIVATION_NAME, - CLONE_BINDING_NAME, - CLONE_FAILURE_NAME, - CLONE_PROGRESS_NAME, - CLONE_SCRATCH_NAME, - CONFIG, - LOCK_NAME, - RESTORE_READY_NAME, - ]; - names.extend_from_slice(more); - sorted(&names) -} - -#[test] -fn a_removal_unbinds_the_sandbox_whose_lock_it_holds_whatever_its_path_names_meanwhile() { - let directory = tempfile::tempdir().unwrap(); - let (one, two, link) = linked_sandboxes(directory.path()); - // The other sandbox records a launch, so its VM may run, and its binding and the files of its - // clone's launch must stay. - fs::write(two.join("sandbox").join(JOURNAL_NAME), b"{}").unwrap(); - let locked = SandboxStore::lock_existing(link.join("sandbox"), &files(), None).unwrap(); - // The link names the other sandbox's parent from the checks to the binding's removal, and - // this one's again from the sync on. - locked - .remove_contents_with(&|| relink(&two, &link), &|_| { - relink(&one, &link); - Ok(()) - }) - .unwrap(); - assert_eq!(entries_of(&one.join("sandbox")), [LOCK_NAME]); - assert_eq!( - entries_of(&two.join("sandbox")), - linked_entries(&[JOURNAL_NAME]) - ); - assert!(locked.remove_directory().unwrap().is_none()); - assert!(!one.join("sandbox").exists()); -} - -#[test] -fn a_removal_through_a_path_that_names_another_sandbox_meanwhile_removes_nothing() { - let directory = tempfile::tempdir().unwrap(); - let (one, two, link) = linked_sandboxes(directory.path()); - let locked = SandboxStore::lock_existing(link.join("sandbox"), &files(), None).unwrap(); - let dir = locked.dir().to_owned(); - // Once the lock is taken, the link comes to name the other sandbox's parent, whose lock file - // is not this lock's. - relink(&two, &link); - let error = locked.remove().unwrap_err(); - assert!( - matches!(&error, RemovalError::Io { path, .. } if *path == dir), - "{error}" - ); - assert_eq!(Error::from(error).kind(), ErrorKind::Io); - for parent in [&one, &two] { - assert_eq!(entries_of(&parent.join("sandbox")), linked_entries(&[])); - } -} - -#[test] -fn lingering_lock_files_keep_the_directory_until_they_go() { - let (_directory, dir) = sandbox(); - let mut lingering = remove_lingering(&dir); - assert_eq!(lingering.dir(), dir); - assert_eq!(entries_of(&dir), [RENAMED]); - // The removal released its lock, and the sandbox is gone. A transition takes the lock of a - // new lock file, which keeps the directory too, and finds no configuration record. - let waiting = SandboxStore::lock_existing(&dir, &files(), within(10)).unwrap(); - assert!(!dir.join(CONFIG).exists()); - let Err(RemovalError::TimedOut(error)) = lingering.wait(Some(Instant::now())) else { - panic!("the directory went while lock files kept it"); - }; - assert!(directory_not_empty(&error), "{error}"); - fs::remove_file(dir.join(RENAMED)).unwrap(); - assert!(matches!( - lingering.wait(Some(Instant::now())), - Err(RemovalError::TimedOut(_)) - )); - // The transition removes its lock file as it releases its lock, which empties the directory. - let removed = thread::spawn(move || lingering.wait(within(30))); - thread::sleep(Duration::from_millis(50)); - drop(waiting); - removed.join().unwrap().unwrap(); - assert!(!dir.exists()); -} - -#[test] -fn a_lingering_removal_reports_what_appears_in_the_directory() { - // A provision that takes the lock that the removal released provisions the directory again. - let (_directory, dir) = sandbox(); - let mut lingering = remove_lingering(&dir); - let provision = SandboxStore::lock_for_provision(&dir, &files(), within(10)).unwrap(); - fs::write(dir.join(CONFIG), b"{}").unwrap(); - drop(provision); - fs::remove_file(dir.join(RENAMED)).unwrap(); - assert!(matches!( - lingering.wait(within(10)), - Err(RemovalError::ProvisionedAgain) - )); - assert_eq!(entries_of(&dir), sorted(&[CONFIG, LOCK_NAME])); - // Other entries are foreign, sorted. - let mut lingering = remove_lingering(&dir); - for name in ["zebra.txt", "apple.txt"] { - fs::write(dir.join(name), b"").unwrap(); - } - let Err(RemovalError::Foreign(names)) = lingering.wait(within(10)) else { - panic!("foreign entries were not refused"); - }; - assert_eq!(names, ["apple.txt", "zebra.txt"]); - // The path may name another directory once the removal's is gone, which is not its to - // remove. - for name in [RENAMED, "zebra.txt", "apple.txt"] { - fs::remove_file(dir.join(name)).unwrap(); - } - fs::remove_dir(&dir).unwrap(); - fs::create_dir(&dir).unwrap(); - fs::write(dir.join(RENAMED), b"").unwrap(); - lingering.wait(Some(Instant::now())).unwrap(); - assert_eq!(entries_of(&dir), [RENAMED]); -} - -#[test] -fn a_lingering_removal_finds_the_configuration_record_by_its_path() { - // A provision by a caller that declares the configuration record in another case writes the - // removal's record on a file system that ignores case, as Windows' do, and another file on - // one that does not. - let (_directory, dir) = sandbox(); - let mut lingering = remove_lingering(&dir); - let upper = CONFIG.to_ascii_uppercase(); - let files = CallerFiles::new(upper.as_str()).unwrap(); - let provision = SandboxStore::lock_for_provision(&dir, &files, within(10)).unwrap(); - fs::write(dir.join(&upper), b"{}").unwrap(); - drop(provision); - fs::remove_file(dir.join(RENAMED)).unwrap(); - let ignores_case = dir.join(CONFIG).exists(); - match lingering.wait(within(10)) { - Err(RemovalError::ProvisionedAgain) => assert!(ignores_case), - Err(RemovalError::Foreign(names)) => { - assert!( - !ignores_case, - "a provision in another case was taken for a foreign file" - ); - assert_eq!(names, std::slice::from_ref(&upper)); - } - other => panic!("{other:?}"), - } - assert_eq!(entries_of(&dir), sorted(&[upper.as_str(), LOCK_NAME])); -} - -#[test] -fn a_removal_that_another_finishes_succeeds_and_keeps_what_replaced_the_directory() { - // Another removal, which takes the lock of a new lock file once this one has removed its own, - // finds the emptied directory, and removes it first. - let removed_by_another = |dir: &Path| { - let other = SandboxStore::lock_existing(dir, &files(), within(10)).unwrap(); - assert!(other.remove().unwrap().is_none()); - }; - let (_directory, dir) = sandbox(); - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - locked.remove_contents().unwrap(); - let lingering = locked - .remove_directory_after(|| removed_by_another(&dir)) - .unwrap(); - assert!(lingering.is_none()); - assert!(!dir.exists()); - // A directory that replaces it then stays, even an empty one. - let (_directory, dir) = sandbox(); - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - locked.remove_contents().unwrap(); - let lingering = locked - .remove_directory_after(|| { - removed_by_another(&dir); - fs::create_dir(&dir).unwrap(); - }) - .unwrap(); - assert!(lingering.is_none()); - assert!(entries_of(&dir).is_empty()); -} - -#[test] -fn a_lingering_removal_keeps_an_empty_directory_that_replaced_its_own() { - let (_directory, dir) = sandbox(); - let mut lingering = remove_lingering(&dir); - // Another removal finishes this one, and the path is created again, still empty. - fs::remove_file(dir.join(RENAMED)).unwrap(); - fs::remove_dir(&dir).unwrap(); - fs::create_dir(&dir).unwrap(); - lingering.wait(Some(Instant::now())).unwrap(); - assert!(dir.is_dir()); - assert!(entries_of(&dir).is_empty()); -} - -#[test] -fn only_the_directory_that_the_path_still_names_is_removed() { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - fs::create_dir(&dir).unwrap(); - let (identity, held) = hold_directory(&dir).unwrap(); - // Another removal removes the held directory, and another one replaces it, which stays. - fs::remove_dir(&dir).unwrap(); - fs::create_dir(&dir).unwrap(); - assert!(!remove_held_directory(&dir, identity).unwrap()); - assert!(dir.is_dir()); - drop(held); - let (identity, _held) = hold_directory(&dir).unwrap(); - assert!(remove_held_directory(&dir, identity).unwrap()); - assert!(!dir.exists()); - // A path that names nothing fails as its check does. - let error = remove_held_directory(&dir, identity).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::NotFound, "{error}"); -} - -#[cfg(any(target_os = "linux", windows))] -#[test] -fn directories_are_identified_as_their_seals_identify_them() { - use crate::host::seal::FileSeal; - use crate::host::seal::tests::open_metadata_only; - - // On Windows, by the 128-bit file ID of `FILE_ID_INFO`, which tells the directories of ReFS - // volumes apart where their 64-bit file index may not. - let directory = tempfile::tempdir().unwrap(); - let seal = FileSeal::of(&open_metadata_only(directory.path()).unwrap()).unwrap(); - let expected = (seal.device(), seal.file_id()); - assert_eq!(identity_of(directory.path()).unwrap(), expected); - assert_eq!(hold_directory(directory.path()).unwrap().0, expected); -} - -#[test] -fn a_removal_needs_the_configuration_record() { - let (_directory, dir) = sandbox(); - fs::write(dir.join(LOG_NAME), b"log").unwrap(); - // Without it, a transition that waits through the removal could not tell that the sandbox - // is gone, so neither a lifecycle lock without one nor the store's own lock removes it. - let undeclared = SandboxStore::lock_existing(&dir, &CallerFiles::none(), None).unwrap(); - assert!(matches!(undeclared.remove(), Err(RemovalError::Undeclared))); - let own = SandboxStore::open(&dir).unwrap().lock(None).unwrap(); - assert!(matches!(own.remove(), Err(RemovalError::Undeclared))); - assert_eq!(entries_of(&dir), sorted(&[CONFIG, LOCK_NAME, LOG_NAME])); - let error = Error::from(RemovalError::Undeclared); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); -} - -#[test] -fn a_provision_creates_again_a_directory_that_a_removal_deletes_while_it_is_created() { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - // A concurrent removal deletes the directory after it is created, before it is checked. - let mut attempts = 0; - let locked = SandboxStore::provision_lock( - &dir, - &files(), - within(10), - |dir| { - attempts += 1; - if attempts == 1 { - SandboxStore::create(dir).unwrap(); - fs::remove_dir(dir).unwrap(); - return SandboxStore::open(dir); - } - SandboxStore::create(dir) - }, - SandboxStore::take_lifecycle_lock, - ) - .unwrap(); - assert_eq!(attempts, 2); - assert!(!locked.dir().join(CONFIG).exists()); - assert_eq!(entries_of(&dir), [LOCK_NAME]); - drop(locked); - // A missing parent fails at once. - let orphan = directory.path().join("missing").join("sandbox"); - let error = SandboxStore::lock_for_provision(&orphan, &files(), within(10)).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); - // A directory that keeps disappearing fails once the deadline passes. - let gone = directory.path().join("gone"); - let mut attempts = 0; - let deadline = Some(Instant::now() + Duration::from_millis(200)); - let error = SandboxStore::provision_lock( - &dir, - &files(), - deadline, - |_| { - attempts += 1; - SandboxStore::open(&gone) - }, - SandboxStore::take_lifecycle_lock, - ) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::TimedOut, "{error}"); - assert!(attempts > 2, "{attempts}"); -} - -#[test] -fn locks_whose_lock_file_keeps_being_replaced_keep_their_deadline() { - // Transitions that keep removing their lock files as they release their locks replace the - // lock file each time that a waiter takes its lock. The waiter takes the lock again at once - // the first time, and after a pause when that repeats, until its deadline. - let (_directory, dir) = sandbox(); - for provision in [false, true] { - for (deadline, most) in [ - (Some(Instant::now()), 2), - (Some(Instant::now() + Duration::from_millis(200)), 20), - ] { - let mut taken = 0; - let replaced = |_: &SandboxStore, _: Option| { - taken += 1; - Ok(Taken::Replaced) - }; - let error = if provision { - SandboxStore::provision_lock( - &dir, - &files(), - deadline, - |dir| SandboxStore::create(dir), - replaced, - ) - } else { - let store = SandboxStore::open(&dir).unwrap(); - SandboxStore::existing_lock(store, &files(), deadline, replaced) - } - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::TimedOut, "{error}"); - assert!((2..=most).contains(&taken), "{provision} {most}: {taken}"); - } - } -} - -#[test] -fn a_provision_that_waits_through_a_removal_provisions_the_sandbox_again() { - let (_directory, dir) = sandbox(); - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let (ready, waiting) = mpsc::channel(); - let provision = thread::spawn({ - let dir = dir.clone(); - move || { - ready.send(()).unwrap(); - let locked = SandboxStore::lock_for_provision(&dir, &files(), within(30)).unwrap(); - let provisioned = dir.join(CONFIG).exists(); - fs::write(dir.join(CONFIG), b"{}").unwrap(); - drop(locked); - provisioned - } - }); - waiting.recv().unwrap(); - thread::sleep(Duration::from_millis(100)); - // The removal completes before the provision takes the lock, which creates the directory - // again, or, should the provision open the lock file between the removals of the lock file - // and of the directory, reports the provision. Where a removed file stays while a handle - // holds it, as on NFS, the handle with which the provision waits for the removed lock file - // keeps the directory instead, which then goes once the provision closes it, before the - // provision creates the directory again. - match locked.remove().unwrap() { - None => {} - Some(mut lingering) => { - assert!(matches!( - lingering.wait(within(30)), - Ok(()) | Err(RemovalError::ProvisionedAgain) - )); - } - } - assert!(!provision.join().unwrap()); - assert_eq!(entries_of(&dir), sorted(&[CONFIG, LOCK_NAME])); -} - -#[test] -fn transitions_that_wait_through_a_removal_find_the_sandbox_gone() { - let (_directory, dir) = sandbox(); - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let (ready, waiting) = mpsc::channel(); - let waiters: Vec<_> = [None, within(30)] - .into_iter() - .map(|deadline| { - let (dir, ready) = (dir.clone(), ready.clone()); - thread::spawn(move || { - ready.send(()).unwrap(); - transition(&dir, deadline) - }) - }) - .collect(); - for _ in &waiters { - waiting.recv().unwrap(); - } - thread::sleep(Duration::from_millis(100)); - let lingering = locked.remove().unwrap(); - let outcomes: Vec<_> = waiters - .into_iter() - .map(|waiter| waiter.join().unwrap()) - .collect(); - // The waiters wait for the lock of the removed lock file, and find the directory gone. A - // waiter that started late may open the lock file between the removals of the lock file and - // of the directory, which creates a new one, and find no sandbox: the directory then lingers - // until it releases that lock, unless it released it before the removal of the directory. - for outcome in outcomes { - match outcome { - Ok(provisioned) => assert!(!provisioned), - Err(error) => assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"), - } - } - if let Some(mut lingering) = lingering { - lingering.wait(within(30)).unwrap(); - } - assert!(!dir.exists()); -} - -#[test] -fn a_transition_that_waits_for_a_holder_that_provisions_nothing_finds_no_sandbox() { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - let held = SandboxStore::lock_for_provision(&dir, &files(), None).unwrap(); - let waiter = thread::spawn({ - let dir = dir.clone(); - move || transition(&dir, within(30)) - }); - thread::sleep(Duration::from_millis(100)); - // The provision fails, and its release removes the lock file that the waiter waits for: the - // waiter takes the lock of a new one in the directory, which holds no sandbox. - drop(held); - assert!(!waiter.join().unwrap().unwrap()); - assert!(entries_of(&dir).is_empty()); -} - -#[test] -fn transitions_in_other_processes_that_wait_through_a_removal_find_the_sandbox_gone() { - let (_directory, dir) = sandbox(); - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let mut transitions: Vec<_> = (0..2) - .map(|_| Transition::start(&dir, "existing")) - .collect(); - thread::sleep(Duration::from_millis(100)); - assert!(transitions.iter_mut().all(Transition::runs)); - let lingering = locked.remove().unwrap(); - let outcomes: Vec<_> = transitions.into_iter().map(Transition::outcome).collect(); - // As with threads, a transition that started late may find no sandbox rather than the - // directory gone, whether the directory lingers or not. - for outcome in &outcomes { - assert!( - ["gone", "unprovisioned"].contains(&outcome.as_str()), - "{outcome}" - ); - } - if let Some(mut lingering) = lingering { - lingering.wait(within(30)).unwrap(); - } - assert!(!dir.exists()); -} - -#[test] -fn provisions_in_other_processes_that_race_a_removal_provision_the_sandbox_again() { - // A provision that waits for the removal's lock creates the directory again once the removal - // completes. - let (_directory, dir) = sandbox(); - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let mut provision = Transition::start(&dir, "provision"); - thread::sleep(Duration::from_millis(100)); - assert!(provision.runs()); - // A lingering removal reports the provision, or removes the directory that the provision's - // handle of the removed lock file kept, as in the threads' case. - if let Some(mut lingering) = locked.remove().unwrap() { - assert!(matches!( - lingering.wait(within(30)), - Ok(()) | Err(RemovalError::ProvisionedAgain) - )); - } - assert_eq!(provision.outcome(), "unprovisioned"); - assert_eq!(entries_of(&dir), sorted(&[CONFIG, LOCK_NAME])); - // One that takes the lock that the removal released while lingering lock files keep the - // directory provisions it again, which the removal reports. - let mut lingering = remove_lingering(&dir); - assert_eq!( - Transition::start(&dir, "provision").outcome(), - "unprovisioned" - ); - fs::remove_file(dir.join(RENAMED)).unwrap(); - assert!(matches!( - lingering.wait(within(10)), - Err(RemovalError::ProvisionedAgain) - )); - assert_eq!(entries_of(&dir), sorted(&[CONFIG, LOCK_NAME])); -} - -#[test] -fn refused_removals_keep_the_directory_for_transitions_in_other_processes() { - // A removal that is refused while the store records a VM removes nothing, and the waiting - // transition finds the sandbox. - let (_directory, dir) = sandbox(); - fs::write(dir.join(RUNTIME_NAME), b"{}").unwrap(); - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let mut transition = Transition::start(&dir, "existing"); - thread::sleep(Duration::from_millis(100)); - assert!(transition.runs()); - assert!(matches!(locked.remove(), Err(RemovalError::Launched))); - assert_eq!(transition.outcome(), "provisioned"); - // One that is refused for foreign entries removed the sandbox's files: the waiting transition - // finds no sandbox, and the directory keeps the foreign entries alone. - fs::remove_file(dir.join(RUNTIME_NAME)).unwrap(); - fs::write(dir.join("notes.txt"), b"kept").unwrap(); - let locked = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let mut transition = Transition::start(&dir, "existing"); - thread::sleep(Duration::from_millis(100)); - assert!(transition.runs()); - let Err(RemovalError::Foreign(names)) = locked.remove() else { - panic!("foreign entries were not refused"); - }; - assert_eq!(names, ["notes.txt"]); - assert_eq!(transition.outcome(), "unprovisioned"); - assert_eq!(entries_of(&dir), ["notes.txt"]); -} - -/// Tests of lock files and directories that a Windows file system without POSIX deletion keeps -/// under their name until their last handle closes, which NTFS does for one deleted without POSIX -/// semantics. -#[cfg(windows)] -#[allow(unsafe_code)] -mod windows { - use std::os::windows::fs::OpenOptionsExt; - use std::os::windows::io::AsRawHandle; - - use windows_sys::Win32::Foundation::HANDLE; - use windows_sys::Win32::Storage::FileSystem::{ - DELETE, FILE_DISPOSITION_INFO, FILE_FLAG_BACKUP_SEMANTICS, FILE_SHARE_DELETE, - FILE_SHARE_READ, FILE_SHARE_WRITE, FileDispositionInfo, SetFileInformationByHandle, - }; - - use super::*; - - /// Deletes the file or empty directory `path` as a file system without POSIX deletion deletes - /// one that other handles hold: its name stays, and opening it, or creating a file in the - /// directory, fails, until the last handle closes, of which the returned one is the last - /// unless others hold it. - fn delete_pending(path: &Path) -> File { - let file = fs::OpenOptions::new() - .access_mode(DELETE) - .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) - .custom_flags(FILE_FLAG_BACKUP_SEMANTICS) - .open(path) - .unwrap(); - let disposition = FILE_DISPOSITION_INFO { DeleteFile: true }; - // SAFETY: the handle is open, and the information is a FILE_DISPOSITION_INFO of its size. - let set = unsafe { - SetFileInformationByHandle( - file.as_raw_handle() as HANDLE, - FileDispositionInfo, - (&raw const disposition).cast(), - size_of::() as u32, - ) - }; - assert_ne!(set, 0, "{}", io::Error::last_os_error()); - file - } - - #[test] - fn locks_wait_for_a_removed_lock_file_that_a_handle_keeps() { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - SandboxStore::create(&dir).unwrap(); - fs::write(dir.join(LOCK_NAME), b"").unwrap(); - let pending = delete_pending(&dir.join(LOCK_NAME)); - for error in [ - SandboxStore::lock_for_provision(&dir, &files(), Some(Instant::now())).unwrap_err(), - SandboxStore::lock_existing(&dir, &files(), Some(Instant::now())).unwrap_err(), - ] { - assert_eq!(error.kind(), ErrorKind::TimedOut, "{error}"); - } - let provision = thread::spawn({ - let dir = dir.clone(); - move || SandboxStore::lock_for_provision(&dir, &files(), within(30)).map(drop) - }); - thread::sleep(Duration::from_millis(100)); - drop(pending); - provision.join().unwrap().unwrap(); - assert!(entries_of(&dir).is_empty()); - } - - #[test] - fn provisions_wait_for_a_removed_directory_that_a_handle_keeps() { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - // A removal deleted the directory before the provision created it. - SandboxStore::create(&dir).unwrap(); - let pending = delete_pending(&dir); - let error = - SandboxStore::lock_for_provision(&dir, &files(), Some(Instant::now())).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::TimedOut, "{error}"); - let provision = thread::spawn({ - let dir = dir.clone(); - move || SandboxStore::lock_for_provision(&dir, &files(), within(30)).map(drop) - }); - thread::sleep(Duration::from_millis(100)); - assert!(!provision.is_finished()); - drop(pending); - provision.join().unwrap().unwrap(); - assert!(entries_of(&dir).is_empty()); - // A removal deletes the directory after the provision created it, before the provision - // opens its lock file in it. - let mut attempts = 0; - let locked = SandboxStore::provision_lock( - &dir, - &files(), - within(30), - |dir| { - attempts += 1; - let store = SandboxStore::create(dir)?; - if attempts == 1 { - let pending = delete_pending(dir); - thread::spawn(move || { - thread::sleep(Duration::from_millis(100)); - drop(pending); - }); - } - Ok(store) - }, - SandboxStore::take_lifecycle_lock, - ) - .unwrap(); - assert!(attempts > 1, "{attempts}"); - drop(locked); - assert!(entries_of(&dir).is_empty()); - } - - #[test] - fn a_removal_takes_a_binding_whose_deletion_is_pending_for_removed_and_waits_until_it_goes() { - let (_directory, dir) = sandbox(); - fs::write(dir.join(CLONE_BINDING_NAME), b"{}").unwrap(); - fs::write(dir.join(LOG_NAME), b"log").unwrap(); - // A release removed the binding while a reader held it. - let pending = delete_pending(&dir.join(CLONE_BINDING_NAME)); - let mut lingering = remove(&dir) - .unwrap() - .expect("the binding whose deletion is pending keeps the directory"); - // Everything else went, and the binding lingers, as a removed lock file does, rather than - // being refused as an entry that is not the store's. - assert_eq!(entries_of(&dir), [CLONE_BINDING_NAME]); - let error = lingering.wait(Some(Instant::now())).unwrap_err(); - assert!(matches!(error, RemovalError::TimedOut(_)), "{error}"); - drop(pending); - lingering.wait(within(10)).unwrap(); - assert!(!dir.exists()); - } - - #[test] - fn a_removal_takes_the_clone_files_whose_deletion_is_pending_for_removed() { - let (_directory, dir) = sandbox(); - fs::write(dir.join(CLONE_BINDING_NAME), b"{}").unwrap(); - for name in CLONE_FILE_NAMES { - fs::write(dir.join(name), b"").unwrap(); - } - // The removal of what a clone kept of its launch leaves the deletion of each file that a - // reader holds pending, which the removal's own removal of them then finds. - let pending: Vec = CLONE_FILE_NAMES - .into_iter() - .map(|name| delete_pending(&dir.join(name))) - .collect(); - let mut lingering = remove(&dir) - .unwrap() - .expect("the files whose deletion is pending keep the directory"); - // Everything else went, the binding included, and the clone's files linger, as a removed - // lock file does, rather than being refused as entries that are not the store's. - assert_eq!(entries_of(&dir), sorted(&CLONE_FILE_NAMES)); - let error = lingering.wait(Some(Instant::now())).unwrap_err(); - assert!(matches!(error, RemovalError::TimedOut(_)), "{error}"); - drop(pending); - lingering.wait(within(10)).unwrap(); - assert!(!dir.exists()); - } - - #[test] - fn locks_refuse_a_lock_file_that_refuses_access_for_good() { - // A read-only lock file, and a directory at its path, refuse an open for writing as a - // removed lock file does, but they open without access rights, so the locks fail at once, - // also without a deadline. - for read_only in [true, false] { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - SandboxStore::create(&dir).unwrap(); - let lock = dir.join(LOCK_NAME); - if read_only { - fs::write(&lock, b"").unwrap(); - set_read_only(&lock, true); - } else { - fs::create_dir(&lock).unwrap(); - } - let (sender, receiver) = mpsc::channel(); - thread::spawn({ - let dir = dir.clone(); - move || { - let provision = SandboxStore::lock_for_provision(&dir, &files(), None); - let existing = SandboxStore::lock_existing(&dir, &files(), None); - sender - .send([provision.map(drop), existing.map(drop)]) - .unwrap(); - } - }); - let taken = receiver - .recv_timeout(Duration::from_secs(10)) - .expect("a lock waited for a lock file that refuses access for good"); - for result in taken { - let error = result.unwrap_err(); - assert!(denied(&error), "{error}"); - } - if read_only { - set_read_only(&lock, false); - } - } - } - - /// Sets or clears the read-only attribute of the file `path`. - fn set_read_only(path: &Path, read_only: bool) { - let mut permissions = fs::metadata(path).unwrap().permissions(); - permissions.set_readonly(read_only); - fs::set_permissions(path, permissions).unwrap(); - } - - #[test] - fn a_transition_whose_lock_file_lingers_waits_until_it_goes() { - let (_directory, dir) = sandbox(); - let held = SandboxStore::lock_existing(&dir, &files(), None).unwrap(); - let (ready, waiting) = mpsc::channel(); - let waiter = thread::spawn({ - let dir = dir.clone(); - move || { - ready.send(()).unwrap(); - transition(&dir, within(30)) - } - }); - waiting.recv().unwrap(); - thread::sleep(Duration::from_millis(100)); - // The holder removes the sandbox, on a file system that keeps the lock file while a - // handle holds it. The waiter takes its lock, finds it removed, and cannot open the lock - // file at the path again until it goes. - fs::remove_file(dir.join(CONFIG)).unwrap(); - let pending = delete_pending(&dir.join(LOCK_NAME)); - drop(held); - thread::sleep(Duration::from_millis(100)); - assert!(!waiter.is_finished()); - drop(pending); - assert!(!waiter.join().unwrap().unwrap()); - assert!(entries_of(&dir).is_empty()); - } - - #[test] - fn transitions_in_other_processes_wait_for_a_lingering_lock_file() { - let (_directory, dir) = sandbox(); - fs::remove_file(dir.join(CONFIG)).unwrap(); - for (lock, left) in [ - ("existing", &[][..]), - ("provision", &[CONFIG, LOCK_NAME][..]), - ] { - fs::write(dir.join(LOCK_NAME), b"").unwrap(); - let pending = delete_pending(&dir.join(LOCK_NAME)); - let mut transition = Transition::start(&dir, lock); - thread::sleep(Duration::from_millis(100)); - assert!(transition.runs(), "{lock}"); - drop(pending); - assert_eq!(transition.outcome(), "unprovisioned", "{lock}"); - assert_eq!(entries_of(&dir), sorted(left), "{lock}"); - } - } -} diff --git a/nvx-rs/src/host/store/locked.rs b/nvx-rs/src/host/store/locked.rs deleted file mode 100644 index 9b52de4fe..000000000 --- a/nvx-rs/src/host/store/locked.rs +++ /dev/null @@ -1,395 +0,0 @@ -//! The store of a sandbox whose lock the caller holds, through which the sandbox's lifecycle -//! transitions change its records. - -use std::fs::File; -use std::io; -use std::ops::Deref; -use std::path::Path; -use std::thread; -use std::time::Instant; - -use super::caller::CallerFiles; -use super::files::{io_error, remove_if_present}; -use super::identity::RuntimeIdentity; -use super::launch::{Launch, Recovery, end_process}; -use super::lease::{Lease, clone_socket_name, is_clone_socket, open_payload}; -use super::record::{LaunchId, LaunchJournal, LogClaim, RuntimeRecord}; -use super::{CLAIM_RELEASE_WAIT, LOCK_RETRY_INTERVAL, SandboxStore, record, store_error, vmm}; -use crate::error::{Error, ErrorCode, Result}; -use crate::host::filesystem::FileIdentity; -use crate::host::platform; -use crate::host::process::{BootId, Opened, Process, ProcessRef}; -use crate::protocol::CAPABILITY_LEN; - -/// The store of a sandbox whose lock this holds, through which its lifecycle transitions change -/// its records. Dropping it releases the lock. -/// -/// Every transition borrows it exclusively, and a [`Launch`] keeps it borrowed until the launch -/// commits or is abandoned, so transitions never overlap, not even through one guard that -/// threads share. A lock that a [lifecycle lock] took also knows its caller's files: as it is -/// released, it removes its lock file if the caller's configuration record is missing, and it can -/// [remove](Self::remove) the store. -/// -/// [lifecycle lock]: crate::host::store#lifecycle-locks -#[derive(Debug)] -pub struct LockedStore { - /// The sandbox's store. - store: SandboxStore, - /// The locked file, whose lock lasts until the drop of this holder releases it. - pub(super) lock: File, - /// The caller's files in the directory, which a store's own lock does not know. - pub(super) files: CallerFiles, - /// Whether this holder removed its lock file, after which the path may name another's. - pub(super) lock_removed: bool, -} - -impl Deref for LockedStore { - type Target = SandboxStore; - - fn deref(&self) -> &SandboxStore { - &self.store - } -} - -impl LockedStore { - /// Returns the held lock of `store`, on its locked lock file `lock`, whose caller keeps - /// `files`. - pub(super) fn new(store: SandboxStore, lock: File, files: CallerFiles) -> Self { - Self { - store, - lock, - files, - lock_removed: false, - } - } - - /// Returns the caller's files that the lock knows: those that a [lifecycle lock] was taken - /// with, and none for [`SandboxStore::lock`]. - /// - /// [lifecycle lock]: crate::host::store#lifecycle-locks - pub fn files(&self) -> &CallerFiles { - &self.files - } - - /// Begins the launch `launch`, which the caller reserved with [`LaunchId::generate`], of the - /// runtime `runtime`, whose VMM receives `capability`. - /// - /// It stores the capability, replaces the log of the previous launch with a fresh one, which - /// it claims, removes the outcome report, console log, and control socket of the previous - /// launch, and any outcome report that its VMM staged without publishing it, chooses a control - /// endpoint, and writes the journal's marker, which names the launch. It keeps the console log - /// of a caller that keeps the console of every launch, as [`CallerFiles::keeping_console`] - /// declares. Nothing is spawned yet: the caller spawns the VMM with [`Launch::take_log`]. - /// - /// The control endpoint is a named pipe of a random name on Windows, and the sandbox's control - /// socket, [`socket_path`](SandboxStore::socket_path), on Linux. - /// - /// Fails if the sandbox's VM runs, or a launch was interrupted and is not recovered yet, and - /// with [`ErrorKind::Unsupported`] for an all-zero capability or an invalid runtime identity. - /// A sandbox whose clone holds a lease of a template's generation, which its [clone binding] - /// records, launches nothing: any launch fails with [`ErrorCode::SandboxLeased`]. - /// - /// [`ErrorKind::Unsupported`]: crate::ErrorKind::Unsupported - /// [clone binding]: crate::host::store::CLONE_BINDING_NAME - pub fn begin_launch( - &mut self, - launch: &LaunchId, - runtime: &RuntimeIdentity, - capability: &[u8; CAPABILITY_LEN], - ) -> Result> { - runtime.check()?; - if *capability == [0; CAPABILITY_LEN] { - return Err(Error::unsupported( - "a control capability cannot be all zero", - )); - } - if self.journal()?.is_some() { - return Err(Error::other(format!( - "a launch of the sandbox in {} was interrupted and must be recovered first", - self.dir.display() - ))); - } - if self.runtime()?.is_some() { - return Err(Error::other(format!( - "the sandbox in {} is already running", - self.dir.display() - ))); - } - if let Some(lease) = Lease::of(self)? { - return Err(Error::new( - ErrorCode::SandboxLeased, - format!( - "the sandbox in {} holds the lease {} of the generation {} of the template \ - store {}, which reserves the launch {}", - self.dir.display(), - lease.id(), - lease.generation(), - lease.store(), - lease.launch() - ), - )); - } - let socket = self.socket_path(); - for path in [&socket, &self.outcome_path()] { - remove_if_present(path)?; - } - if !self.files.keeps_console() { - remove_if_present(&self.console_path())?; - } - self.remove_report_staging()?; - let endpoint = platform::control_endpoint(&socket) - .map_err(|error| Error::io("cannot choose a control endpoint").with_source(error))?; - let (log, claim) = self.create_log()?; - let capability_file = self.capability_file(); - capability_file.replace(capability)?; - let journal = LaunchJournal::prepared(launch.clone(), endpoint, runtime.clone(), claim); - if let Err(error) = record::write(&self.journal_file(), &journal) { - let _ = capability_file.remove_if_present(); - return Err(error); - } - Ok(Launch { - locked: self, - journal, - log: Some(log), - }) - } - - /// Replaces the log of the previous launch with a fresh file, and claims it. - fn create_log(&self) -> Result<(File, LogClaim)> { - let file = self.log_file(); - let failed = |error| { - io_error(format!( - "cannot create the {} {}", - file.description(), - file.path().display() - ))(error) - }; - file.remove_if_present()?; - let log = file.create_new().map_err(failed)?; - platform::claim_log(&log).map_err(failed)?; - let (device, index) = platform::opened_file_identity(&log).map_err(failed)?; - let claim = LogClaim { - identity: FileIdentity { device, index }, - }; - Ok((log, claim)) - } - - /// Ends a launch that was interrupted, if its journal remains. - /// - /// Recovery signals only a VMM that it holds through a verified [`Process`]: the one that the - /// journal's [`ProcessRef`] names, or the one that serves the journal's control endpoint, as - /// the [module documentation] describes. Once nothing of the launch runs, it removes the - /// launch's capability, control socket, outcome report that its VMM staged without publishing - /// it, and journal, and keeps its log. A recovery that cannot prove that is - /// [`Recovery::Indeterminate`] and keeps them all for a later recovery. - /// - /// A journal that a committed runtime record of the same launch supersedes is removed. A - /// runtime record of another launch fails as inconsistent state. - /// - /// [module documentation]: crate::host::store#recovery - pub fn recover(&mut self) -> Result { - let Some(journal) = self.journal()? else { - return Ok(Recovery::Clean); - }; - match self.runtime()? { - None => self.end_launch(&journal), - Some(runtime) if runtime.launch() == journal.launch() => { - self.journal_file().remove_if_present()?; - Ok(Recovery::Clean) - } - Some(runtime) => Err(Error::io(format!( - "the launch journal of the sandbox in {} names launch {}, but its runtime record \ - names launch {}", - self.dir.display(), - journal.launch(), - runtime.launch() - ))), - } - } - - /// Ends the launch that `journal` records, and removes its files once nothing of it runs. - pub(super) fn end_launch(&self, journal: &LaunchJournal) -> Result { - let recovery = match journal.process() { - Some(process) => self.end_identified(journal, process, |reference| { - Ok(match Process::open(reference)? { - Opened::Running(process) => Some(process), - Opened::Exited(_) => None, - }) - }), - None => self.end_unidentified(journal), - }; - if !matches!(recovery, Recovery::Indeterminate(_)) { - self.capability_file().remove_if_present()?; - remove_if_present(&self.socket_path())?; - self.remove_clone_socket(journal.endpoint())?; - self.remove_report_staging()?; - // The journal goes last, so that an interrupted removal leaves it for a later - // recovery to finish. - self.journal_file().remove_if_present()?; - } - Ok(recovery) - } - - /// Returns whether a process still holds the claim of the launch of `journal` on its log, or - /// why that cannot be determined. - /// - /// A held claim is checked again at every [`LOCK_RETRY_INTERVAL`] until [`CLAIM_RELEASE_WAIT`] - /// passes, so that a process that another thread spawned while the log was open, which holds - /// the claim only until it executes its program, is not taken for a process of the launch. - fn claim_held(&self, journal: &LaunchJournal) -> std::result::Result { - let path = self.log_path(); - let identity = journal.log().identity; - let deadline = Instant::now() + CLAIM_RELEASE_WAIT; - loop { - match platform::log_claim_held(&path, (identity.device, identity.index)) { - Ok(true) => { - let left = deadline.saturating_duration_since(Instant::now()); - if left.is_zero() { - return Ok(true); - } - thread::sleep(left.min(LOCK_RETRY_INTERVAL)); - } - Ok(false) => return Ok(false), - Err(error) => { - return Err(format!( - "its claim on the log {} cannot be checked: {error}", - path.display() - )); - } - } - } - } - - /// Ends a launch through the recorded `reference` to its VMM, which `open`, [`Process::open`] - /// outside tests, holds only while the process that it names runs. - /// - /// A reference from an earlier boot names a process that has exited. Within the boot that it - /// names, a VMM that `open` cannot hold, because no process matches the reference or the - /// process that has its ID cannot be examined, has exited once the launch's claim on its log - /// is released: until then, a process of the launch may still run. - pub(super) fn end_identified( - &self, - journal: &LaunchJournal, - reference: &ProcessRef, - open: impl FnOnce(&ProcessRef) -> io::Result>, - ) -> Recovery { - let pid = reference.pid; - match BootId::current() { - Ok(boot) if boot != reference.boot => return Recovery::Exited, - Ok(_) => {} - Err(error) => { - return Recovery::Indeterminate(format!( - "the current boot cannot be identified: {error}" - )); - } - } - let unheld = match open(reference) { - Ok(Some(process)) => return end_process(&process), - Ok(None) => format!("no process matches its VMM process {pid}"), - Err(error) => format!("its VMM process {pid} cannot be examined: {error}"), - }; - match self.claim_held(journal) { - Ok(false) => Recovery::Exited, - Ok(true) => { - Recovery::Indeterminate(format!("a process still holds its log, and {unheld}")) - } - Err(reason) => Recovery::Indeterminate(format!("{unheld}, and {reason}")), - } - } - - /// Ends a launch that did not record its VMM's identity, through its claim on its log. - fn end_unidentified(&self, journal: &LaunchJournal) -> Recovery { - match self.claim_held(journal) { - Ok(false) => return Recovery::Exited, - Ok(true) => {} - Err(reason) => return Recovery::Indeterminate(reason), - } - // A VMM that the launch spawned may hold the claim. Once it serves its control endpoint, - // the endpoint names it. - let endpoint = journal.endpoint(); - let (pid, candidate) = match vmm::hold_endpoint_server( - || platform::endpoint_server_pid(endpoint), - vmm::find, - ) { - Ok(found) => found, - Err(reason) => return Recovery::Indeterminate(reason), - }; - let identity = journal.log().identity; - match vmm::serves_launch(&candidate, (identity.device, identity.index)) { - Ok(true) => end_process(&candidate), - Ok(false) => Recovery::Indeterminate(format!( - "process {pid} serves its control endpoint, but does not hold its log as its \ - standard output, so it is not signaled" - )), - Err(error) => Recovery::Indeterminate(format!( - "process {pid} serves its control endpoint, and whether it is its VMM cannot be \ - determined: {error}" - )), - } - } - - /// Removes the runtime record, the launch capability, and the control socket of a VM that has - /// exited, and any outcome report that its VMM staged without publishing it, the runtime - /// record last. The control socket of a clone's VMM, beside the snapshot that it restores, goes - /// too, and the clearing fails, keeping the runtime record, if a link takes the place of the - /// template store's directory, of its directory of the generations, or of the payload of the - /// socket's generation. The log, console log, and outcome report outlive the run. - pub fn clear_runtime(&mut self) -> Result<()> { - // Only a Linux endpoint is a file, which may be a clone's beside its snapshot. A record that - // cannot be read names none. - let record = if cfg!(unix) { - self.runtime().ok().flatten() - } else { - None - }; - self.clear_runtime_of(record.as_ref()) - } - - /// Clears the runtime record of a VM that has exited, as [`clear_runtime`](Self::clear_runtime) - /// does, given `record`, the runtime record that the caller read under this lock, if any, whose - /// control endpoint is the VM's. - pub(crate) fn clear_runtime_of(&mut self, record: Option<&RuntimeRecord>) -> Result<()> { - self.capability_file().remove_if_present()?; - remove_if_present(&self.socket_path())?; - if let Some(record) = record { - self.remove_clone_socket(record.endpoint())?; - } - self.remove_report_staging()?; - self.runtime_file().remove_if_present() - } - - /// Removes the control socket at `endpoint`, the control endpoint that a launch of the - /// sandbox recorded, if it is the socket of the sandbox's clone beside the snapshot that its - /// VMM restores, which the lease that the sandbox's clone binding records reserved, in the - /// payload of the leased generation of its template store. Any other endpoint stays: the - /// sandbox's own control socket, which the caller removes, and a named pipe. Neither reads the - /// binding. - /// - /// The socket goes through a handle of the generation's payload, which a handle of the - /// template store's directory opens: following no link at the store's directory, at its - /// directory of the generations, or at the payload, and once it checked through that handle - /// that the directory holds the lease's store, so that neither a link that takes the place of - /// one of them nor a directory that takes the place of the store ever leads the removal to the - /// files of another directory. Such a link fails the removal; a payload that is gone holds no - /// socket, and neither does a directory that holds another store, or none, in the place of the - /// lease's, whose files stay. The removal is not made durable here. - fn remove_clone_socket(&self, endpoint: &str) -> Result<()> { - let socket = Path::new(endpoint); - if !cfg!(unix) || socket == self.socket_path() { - return Ok(()); - } - let Some(lease) = Lease::of(self)?.filter(|lease| is_clone_socket(lease, socket)) else { - return Ok(()); - }; - // The socket is `/generations//.sock`, as the check found. - let Some(store) = socket.ancestors().nth(3) else { - return Ok(()); - }; - match open_payload(store, lease.store(), lease.generation())? { - Some(payload) => payload - .remove_file(&clone_socket_name(lease.launch())) - .map_err(store_error(format!("cannot remove {}", socket.display()))), - None => Ok(()), - } - } -} diff --git a/nvx-rs/src/host/store/mod.rs b/nvx-rs/src/host/store/mod.rs deleted file mode 100644 index f05a72060..000000000 --- a/nvx-rs/src/host/store/mod.rs +++ /dev/null @@ -1,670 +0,0 @@ -//! The store of a sandbox's runtime records, which every host that supervises the sandbox -//! shares: one private directory per sandbox, with one lock, the runtime record of its running -//! VM, and the journal of its launch, so that a VMM whose launch crashed midway is never lost. -//! -//! ```text -//! / one sandbox, private to the current user -//! sandbox.lock the lock that serializes its lifecycle transitions -//! launch.json the journal of a launch in progress, or of one that was interrupted -//! runtime.json the runtime record of its running VM -//! control.capability the launch capability of its VM -//! control.sock the control endpoint of its VM, on Linux -//! openvmm.log the VMM's output, claimed by the launch that created it -//! console.log the guest console of the latest launch, or of every launch, if the -//! caller captures it -//! outcome.json the VMM's outcome report of the latest launch -//! .tmpXXXXXX the outcome report while the VMM writes it, which it then publishes -//! clone.json the binding of a clone to the generation of a template that it leases -//! clone-scratch.raw the file of the scratch device that earlier versions created for a -//! clone whose upper is in RAM, which a removal deletes if one remains -//! clone-activation.bin the activation record of a clone, which its launch writes -//! clone-progress.log the progress log of a clone's launch and restore sequence -//! clone-failure.json the record of a clone's failure -//! restore.sock the endpoint at which the launch of a clone awaits the restore-ready -//! event of its VMM, on Linux -//! ... the caller's configuration record and other files, which it declares -//! ``` -//! -//! The directory is private to the current user, since its records decide which process -//! recovery signals: it belongs to the user, and no one else may access it. On Unix it has mode -//! 0700, which OpenVMM also requires of the directory of its control socket, and its files mode -//! 0600. On Windows, a directory that the store creates allows the user alone, through a -//! protected access control list that its files inherit; one that it did not create may also -//! allow SYSTEM and the Administrators group, as one under the user's profile does, and must pass -//! entries on to the files created in it. A file's own list decides who may open it on Windows, -//! whatever its directory allows, so the store also refuses a lock, a record, or a capability -//! whose list allows anyone else. [`SandboxStore::create`] and [`SandboxStore::open`] refuse an -//! existing directory that is not private, rather than change it, since others could have placed -//! records in it, or still hold its files open. The store names its files by their paths, so the -//! directories above it must be ones that only the user, or an administrator, may change. A lock, -//! a record, or a capability that is not a regular file, such as a FIFO, is refused at once, -//! rather than waited on while the sandbox is locked. So is a lock file that is a link, or on -//! Windows any other reparse point, since a lock taken through it would hold the file that it -//! names, which a launch may replace while the lock is held. -//! Records are replaced atomically, so readers that hold no lock, such as an execution, see whole -//! records. The store holds runtime records only: configuration belongs to its caller, which may -//! keep it in the same directory under a name of its own, and declares it, with its other files -//! there, as [`CallerFiles`]. -//! -//! # Records -//! -//! The [`RuntimeRecord`] and the [`LaunchJournal`] are JSON in format [`FORMAT`]. A record in any -//! other format fails with a message that names the format, and is neither read nor migrated: -//! switching from earlier formats takes one stop of every sandbox. Both records hold the -//! [`RuntimeIdentity`] of their launch, which decides whether a host may reattach to the VM; see -//! [`RuntimeRecord::check_identity`]. -//! -//! # Launches -//! -//! A launch holds the sandbox's lock throughout, under an identity that the caller reserved -//! before it began, with [`LaunchId::generate`], a fresh one for each launch: -//! -//! 1. [`LockedStore::begin_launch`] stores the capability, creates a fresh log and claims it, and -//! writes the journal's marker, which names the launch, before anything is spawned. -//! 2. The caller spawns the VMM with [`Launch::take_log`] as its standard output and error, and -//! [`Launch::record_process`] records the VMM's [`ProcessRef`] right after the spawn. -//! 3. Once the VM is ready, [`Launch::commit`] replaces the journal with the runtime record. A -//! launch that fails ends with [`Launch::abandon`]. -//! -//! [`supervisor::launch`](super::supervisor::launch) runs these steps. A caller that runs them -//! itself does so as follows: -//! -//! ```no_run -//! use std::time::{Duration, Instant}; -//! -//! use nvx::host::launch::LaunchSpec; -//! use nvx::host::store::{LaunchId, RuntimeIdentity, SandboxStore}; -//! use nvx::host::supervisor::{self, VmmCommand}; -//! use nvx::protocol::CAPABILITY_LEN; -//! -//! fn launch( -//! spec: &LaunchSpec, -//! runtime: &RuntimeIdentity, -//! capability: &[u8; CAPABILITY_LEN], -//! ) -> Result<(), Box> { -//! let store = SandboxStore::create("/var/lib/example/sandbox")?; -//! let mut locked = store.lock(Some(Instant::now() + Duration::from_secs(10)))?; -//! locked.recover()?; -//! let mut launch = locked.begin_launch(&LaunchId::generate()?, runtime, capability)?; -//! let arguments = spec.openvmm_arguments(launch.endpoint(), &store.outcome_path())?; -//! let command = VmmCommand::new(&spec.openvmm, arguments, store.dir()); -//! let log = launch.take_log()?; -//! let child = supervisor::spawn(&command, capability, log)?; -//! launch.record_process(&child)?; -//! // Wait until the VM is ready, then commit, or else abandon the launch. -//! launch.commit()?; -//! child.detach(); -//! Ok(()) -//! } -//! ``` -//! -//! The launch borrows the locked store exclusively until it commits or is abandoned, so no other -//! transition of the sandbox overlaps it, not even through the same guard: -//! -//! ```compile_fail -//! # use nvx::host::store::{LaunchId, RuntimeIdentity, SandboxStore}; -//! # use nvx::protocol::CAPABILITY_LEN; -//! # fn overlap( -//! # store: &SandboxStore, -//! # runtime: &RuntimeIdentity, -//! # capability: &[u8; CAPABILITY_LEN], -//! # ) -> nvx::Result<()> { -//! let mut locked = store.lock(None)?; -//! let launch = locked.begin_launch(&LaunchId::generate()?, runtime, capability)?; -//! locked.recover()?; // `locked` is still borrowed by `launch`. -//! launch.commit()?; -//! # Ok(()) -//! # } -//! ``` -//! -//! # The log claim -//! -//! The VMM inherits its launch's claim on the log, so the claim lasts as long as any process that -//! holds the log open from the launch. On Linux it is an exclusive `flock` lock of the log's open -//! file description, which the VMM shares through its standard output and error, and which only -//! the last close of that description releases. On Windows it is the processes of the job object -//! of the log, in which the VMM runs, while that job is open, as described below, and otherwise -//! the VMM's handles themselves: while any process holds the log open, an open of the log that -//! shares nothing fails. A released claim proves that the launch has exited, even one that never -//! recorded its VMM or published its endpoint. The claim relies on the VMM keeping its standard -//! output and error until it exits, as OpenVMM does, and on the directory being on a local file -//! system. -//! -//! On Linux, a process that another thread spawns while the log is open, such as another -//! sandbox's VMM, also holds the claim, from its fork until it executes its program, which closes -//! its copy. Recovery therefore checks a held claim again for up to a second before it concludes -//! that a process of the launch may still run. -//! -//! On Windows, a process that another thread creates while the VMM's spawn has the log's handles -//! inheritable, with inheritance but without a list of handles, as `std::process::Command` -//! creates one, inherits them, and holds the log until it exits. Each spawn of a VMM holds a -//! [`SpawnLock`](super::supervisor::SpawnLock) while the handles are inheritable, so a process -//! that the caller spawns under one, as [`spawn_command`](super::supervisor::spawn_command) does, -//! inherits none. One that the caller spawns otherwise is no process of the launch, though, and -//! does not hold the claim: [`spawn`](super::supervisor::spawn) starts the VMM in a job object of -//! its own, named after the log, which every process that the VMM starts joins and no other -//! process does, and keeps the job's handle inheritable for as long as the log's. Such a process -//! therefore holds the job open for as long as it holds the log, and while the job is open, the -//! claim is held only while the job holds a process. Recovery thus ends a launch whose VMM has -//! exited, whatever such a process holds, if the recovery runs in the Windows session of the -//! spawn: the job is named in that session's namespace, so a recovery from another session does -//! not find it, and checks the log's handles, as for a VMM that an earlier version started outside -//! a job, which such a process then holds until it exits. The job belongs to the user and allows -//! the user alone, and what takes its name but is not the user's job, such as another user's job, -//! an object of another type, or a symbolic link to another job of the user, leaves the claim to -//! the log's handles, so that another user cannot end a launch whose VMM still runs by creating an -//! empty job, or a link to one, under that name. If such an object takes the name at the spawn or -//! at a check, though, a process spawned in the window holds the claim again until it exits, as -//! for a VMM that an earlier version started outside a job, and recovery leaves the launch to a -//! later recovery until then. -//! -//! # Recovery -//! -//! A caller that dies during a launch leaves its journal, and [`LockedStore::recover`] ends the -//! launch, signaling only a VMM that it holds through a verified [`Process`]: -//! -//! - A crash before the spawn leaves the claim released, which proves that nothing of the launch -//! runs. -//! - After a crash between the spawn and the identity, the VMM holds the claim. Recovery finds -//! the VMM through the control endpoint that the journal names, holds it, confirms that the -//! endpoint still names it once held, and terminates it. -//! On Windows the endpoint is a pipe whose name each launch draws at random, which names the -//! VMM by itself. On Linux it is the same socket path for every launch of the sandbox, so the -//! VMM must also hold the claimed log as its standard output. Until the VMM serves its -//! endpoint, recovery is indeterminate and keeps the journal; once the VMM exits, the released -//! claim proves it. -//! - After a crash between the identity and the endpoint, the recorded [`ProcessRef`] decides: -//! the process ID, its start time, and the host's boot. [`Process::open`] holds a live VMM, -//! which is terminated through its handle, and never another process that reuses its ID, nor -//! any process when the reference comes from an earlier boot, whose processes have all exited. -//! Within the current boot, a VMM that it cannot hold, because no process matches the -//! reference or the process that has its ID, such as another user's, cannot be examined, has -//! exited once the claim is released; until then, a process of the launch may still run, and -//! recovery is indeterminate. -//! -//! The runtime record of a committed launch names its running VM, and -//! [`supervisor::reattach`](super::supervisor::reattach) holds that VM again after its -//! orchestrator restarts. -//! -//! # The report staging file -//! -//! The VMM writes its outcome report to a file of its own in the directory, `.tmp` and six ASCII -//! letters or digits, as the `tempfile` crate names it, and then publishes it as `outcome.json`. -//! A VMM that is killed while it writes the report leaves that file. The store owns files of -//! exactly that form, and removes them wherever it clears a launch's files once no process of -//! the launch runs: when [`LockedStore::recover`] or [`Launch::abandon`] ends a launch, unless -//! that is [`Recovery::Indeterminate`], in [`LockedStore::clear_runtime`], when -//! [`LockedStore::begin_launch`] begins the next launch, and in a [removal](#removal). It keeps -//! them while a process of the launch may still run. A file of any other name is not the -//! store's. -//! -//! # Lifecycle locks -//! -//! A caller that keeps a configuration record in the sandbox's directory, whose presence means -//! that the sandbox is provisioned, declares it, with any other files of its own there, as -//! [`CallerFiles`], and takes the lock of each lifecycle transition with them. Provisions, other -//! transitions, and removals of the sandbox then keep its directory consistent, from any process: -//! -//! - [`SandboxStore::lock_for_provision`] creates the directory if needed, and again if a -//! concurrent removal deletes it before the lock is taken, even while it is created. -//! - [`SandboxStore::lock_existing`] fails with [`ErrorKind::NotFound`] once the directory is gone, -//! also when a concurrent removal deletes it while the lock is awaited. The caller checks its -//! configuration record under the lock: a directory that was never provisioned, that an -//! interrupted removal emptied, or whose removal open files still keep, holds none. -//! - A holder that leaves the directory without the configuration record removes its own lock -//! file as it releases the lock, unless it removed it already, so that such a directory keeps -//! no lock file. -//! -//! Both locks wait for another holder until the caller's deadline, trying again every 25 ms, and -//! a deadline that has passed still tries once, so that a caller that waits on its own, as for an -//! interruption, can retry them itself. A lock file that another transition removes or replaces -//! after an attempt opened it is locked again at once, and after a pause, until the deadline, when -//! that happens again right after it, so that transitions that keep replacing the lock file keep -//! no caller past its deadline. -//! -//! # Removal -//! -//! [`LockedStore::remove`] removes a sandbox that no VMM can run in: it refuses while the store -//! records a VM or a launch, which the caller reattaches to, or recovers, first. It removes what a -//! clone kept of its launch, and the sandbox's -//! [clone binding](CLONE_BINDING_NAME) first, and makes their absence durable before anything -//! else goes. It checks for those records, and removes those files, through one handle of the -//! directory, once it has checked through that handle that the directory holds its lock, so that -//! no change of a link in the path meanwhile makes it remove another sandbox's files. It then -//! removes the store's other files and the caller's, the configuration record last, and the -//! temporary files that the store owns: those that an interrupted replacement of one of its files -//! leaves, `.NAME.NUMBER.tmp`, and the VMM's [report staging file](#the-report-staging-file). It -//! then refuses entries that are neither the store's nor the caller's, naming them, and keeps the -//! directory, or removes its lock file while it still holds the lock, and then the directory. A -//! removal needs the caller's configuration record, whose absence tells the transitions that wait -//! through it that the sandbox is gone: a lock taken without one refuses it. -//! -//! Files that are still open can keep the emptied directory for a while: NFS renames a removed -//! file that is still open, such as the lock file of a waiting transition, to `.nfs*`, Windows -//! file systems without POSIX deletion keep a removed lock file, or a removed clone binding that a -//! reader holds, under its name until its last handle closes, and a transition that opens the lock -//! file after the removal removed it creates a new one. Each such transition finds no sandbox, and -//! removes its lock file as it releases its lock. The removal leaves those files, then holds the -//! directory's identity, releases its lock, and returns a [`Lingering`] removal, which waits until -//! they go, and reports a provision that took the lock meanwhile as -//! [`RemovalError::ProvisionedAgain`]. A removal removes the directory only while the path still -//! names it, which it checks before each attempt. -//! -//! [`ProcessRef`]: crate::host::process::ProcessRef -//! [`Process`]: crate::host::process::Process -//! [`Process::open`]: crate::host::process::Process::open - -mod caller; -pub(crate) mod files; -/// The identities of runtimes, which [`crate::host::runtime`] defines, and how the one of a record -/// compares with the current one. -mod identity { - pub use crate::host::runtime::{ - ArtifactIdentity, IdentityPart, MAX_IDENTITY_LEN, RuntimeIdentity, - }; - - /// How the runtime identity of a record compares with the current one. - #[derive(Debug, Clone, PartialEq, Eq)] - pub enum IdentityCheck { - /// The identities are equal: the host may reattach to the VM and keep it running. - Matches, - /// The identities differ in the parts that this lists, as - /// [`RuntimeIdentity::differences`] returns them: the VM must be stopped, and only through - /// its verified process identity. - Mismatched(Vec), - } -} -mod launch; -pub(crate) mod lease; -mod lifecycle; -mod locked; -mod names; -mod record; -pub(crate) mod regular; -#[cfg(test)] -mod tests; -mod vmm; - -use std::fs::{File, TryLockError}; -use std::io; -use std::path::{Path, PathBuf}; -use std::thread; -use std::time::{Duration, Instant}; - -pub use self::caller::CallerFiles; -use self::caller::is_report_staging; -use self::files::{StoreFile, io_error, remove_if_present}; -pub use self::identity::{ - ArtifactIdentity, IdentityCheck, IdentityPart, MAX_IDENTITY_LEN, RuntimeIdentity, -}; -pub use self::launch::{Launch, Recovery}; -pub use self::lifecycle::{Lingering, RemovalError}; -pub use self::locked::LockedStore; -use self::names::CLONE_FILE_NAMES; -pub use self::names::{ - CAPABILITY_NAME, CLONE_ACTIVATION_NAME, CLONE_BINDING_NAME, CLONE_FAILURE_NAME, - CLONE_PROGRESS_NAME, CLONE_SCRATCH_NAME, CONSOLE_NAME, FILE_NAMES, FORMAT, JOURNAL_NAME, - LOCK_NAME, LOG_NAME, OUTCOME_NAME, RESTORE_READY_NAME, RUNTIME_NAME, SOCKET_NAME, -}; -use self::record::Record; -pub use self::record::{ - LaunchId, LaunchJournal, LaunchState, LogClaim, MAX_ENDPOINT_LEN, RuntimeRecord, -}; -use super::platform; -use crate::error::{Error, ErrorCode, ErrorKind, Result}; -use crate::protocol::CAPABILITY_LEN; - -/// Interval at which a lock that another holder has is tried again: the sandbox's lock, by a -/// caller that waits for it until a deadline, and a launch's claim on its log, by a recovery that -/// waits for the claim's release. A release is noticed at most this late. -const LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(25); - -/// How long recovery waits for a held claim on a launch's log to be released before it concludes -/// that a process of the launch may still run. -/// -/// On Linux, a process holds a copy of each descriptor of its parent from its fork until it -/// executes its program. A spawn by another thread of the process that holds the log, such as -/// the launch of another sandbox's VMM, therefore holds the claim for that moment, which lasted -/// about 5 ms on a busy host; a second leaves a wide margin for a more heavily loaded one. -/// -/// Recovery, and the abandon of a failed launch, wait only while they find the claim held: a -/// launch that succeeds and a stop never check it. A claim that stays held is still reported held -/// when the wait ends, so the wait can delay them by up to a second, but never makes them end a -/// launch that may still run. -const CLAIM_RELEASE_WAIT: Duration = Duration::from_secs(1); - -/// Returns `dir` as an absolute path, which a relative one names from the current directory. -fn absolute(dir: PathBuf) -> Result { - std::path::absolute(&dir).map_err(io_error(format!( - "cannot resolve the sandbox store {}", - dir.display() - ))) -} - -/// Builds an error mapper that keeps an I/O source under `context`, as an error of -/// [`ErrorKind::NotFound`] if the source says that a path does not exist, and of -/// [`ErrorKind::Io`] otherwise. -fn store_error(context: String) -> impl FnOnce(io::Error) -> Error { - move |error| { - let kind = match error.kind() { - io::ErrorKind::NotFound => ErrorKind::NotFound, - _ => ErrorKind::Io, - }; - Error::new(kind, context).with_source(error) - } -} - -/// Returns the error of a wait for another lifecycle transition of the sandbox in `dir` that is -/// still in progress past its deadline, [`ErrorCode::SandboxBusy`]. -fn transition_in_progress(dir: &Path) -> Error { - Error::new( - ErrorCode::SandboxBusy, - format!( - "another lifecycle transition of the sandbox in {} is still in progress", - dir.display() - ), - ) -} - -/// Waits before the next try of a step that `deadline` bounds: returns `false` at once if the -/// deadline has passed, and otherwise sleeps for [`LOCK_RETRY_INTERVAL`] or the time left, -/// whichever is shorter, and returns `true`. -fn pause(deadline: Option) -> bool { - let left = deadline.map(|deadline| deadline.saturating_duration_since(Instant::now())); - if left.is_some_and(|left| left.is_zero()) { - return false; - } - thread::sleep(left.map_or(LOCK_RETRY_INTERVAL, |left| left.min(LOCK_RETRY_INTERVAL))); - true -} - -/// The races that the attempts to take a lock lose to other transitions, which remove or replace -/// the lock file, or the directory, after an attempt opens it. The attempt after a lost race -/// follows at once, and one after another race lost right after it pauses first, until the -/// deadline, so that transitions that keep replacing the lock file can neither keep a caller past -/// its deadline nor make it spin. -#[derive(Default)] -struct Races { - /// Whether the latest attempt followed at once one that lost a race. - retried_at_once: bool, -} - -impl Races { - /// Records that an attempt lost a race, and returns whether the lock may be tried again - /// before `deadline`, which it pauses for first if the attempt followed one that lost too. - fn lost(&mut self, deadline: Option) -> bool { - if self.retried_at_once && !pause(deadline) { - return false; - } - self.retried_at_once = !self.retried_at_once; - true - } -} - -/// How an attempt to take a store's lock ended, if it did not fail. -enum Taken { - /// The lock of the lock file that the path names. - Locked(File), - /// The lock of a lock file that was removed or replaced after it was opened, which locks - /// nothing. - Replaced, -} - -/// The store of one sandbox: its private directory. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SandboxStore { - /// The sandbox's private directory. - dir: PathBuf, -} - -impl SandboxStore { - /// Creates the directory `dir` if needed, whose parent must exist, private to the current - /// user, and opens the store in it. - /// - /// An existing directory must already be private, as [`open`](Self::open) requires. One that - /// others could access is refused rather than restricted: they could have placed records in - /// it, which would decide what recovery signals, or still hold its files open. - /// - /// A relative `dir` is resolved against the current directory, so that the store's paths, and - /// the control endpoint that a launch records, name the same files from any working directory. - /// Fails with [`ErrorKind::NotFound`] if the parent does not exist. - pub fn create(dir: impl Into) -> Result { - let dir = absolute(dir.into())?; - let problem = platform::create_store_dir(&dir).map_err(store_error(format!( - "cannot create the sandbox store {}", - dir.display() - )))?; - Self::private(dir, problem) - } - - /// Opens the store in the existing directory `dir`, which a relative path names from the - /// current directory, and which must be private to the current user, as - /// [`create`](Self::create) makes it. Fails with [`ErrorKind::NotFound`] if `dir` does not - /// exist. - pub fn open(dir: impl Into) -> Result { - let dir = absolute(dir.into())?; - let problem = platform::check_store_dir(&dir).map_err(store_error(format!( - "cannot open the sandbox store {}", - dir.display() - )))?; - Self::private(dir, problem) - } - - /// Returns the store in `dir`, unless `problem` says why `dir` is not private to the current - /// user. - fn private(dir: PathBuf, problem: Option) -> Result { - match problem { - None => Ok(Self { dir }), - Some(problem) => Err(Error::io(format!( - "the sandbox store {} {problem}", - dir.display() - ))), - } - } - - /// Returns the sandbox's directory, as an absolute path. - pub fn dir(&self) -> &Path { - &self.dir - } - - /// Returns the path of the store's file `name`, one of [`FILE_NAMES`]. - fn path(&self, name: &str) -> PathBuf { - self.dir.join(name) - } - - /// Returns the store's file `name`, one of [`FILE_NAMES`], which messages call `description`. - fn file(&self, name: &str, description: &'static str) -> StoreFile { - StoreFile::new(self.path(name), description) - } - - /// Returns the sandbox's lock file, [`LOCK_NAME`]. - fn lock_file(&self) -> StoreFile { - self.file(LOCK_NAME, "sandbox lock") - } - - /// Returns the launch journal, [`JOURNAL_NAME`]. - fn journal_file(&self) -> StoreFile { - self.file(JOURNAL_NAME, LaunchJournal::DESCRIPTION) - } - - /// Returns the runtime record, [`RUNTIME_NAME`]. - fn runtime_file(&self) -> StoreFile { - self.file(RUNTIME_NAME, RuntimeRecord::DESCRIPTION) - } - - /// Returns the file that holds the launch capability, [`CAPABILITY_NAME`]. - fn capability_file(&self) -> StoreFile { - self.file(CAPABILITY_NAME, "sandbox control capability") - } - - /// Returns the VMM's log, [`LOG_NAME`]. - fn log_file(&self) -> StoreFile { - self.file(LOG_NAME, "log") - } - - /// Returns the path of the VMM's log. - pub fn log_path(&self) -> PathBuf { - self.path(LOG_NAME) - } - - /// Returns the path of the guest console's log, which a launch whose caller captures the - /// console points the VMM at. - pub fn console_path(&self) -> PathBuf { - self.path(CONSOLE_NAME) - } - - /// Returns the path of the VMM's outcome report. - pub fn outcome_path(&self) -> PathBuf { - self.path(OUTCOME_NAME) - } - - /// Returns the path of the file that holds the launch capability. - pub fn capability_path(&self) -> PathBuf { - self.path(CAPABILITY_NAME) - } - - /// Returns the path of the Linux control socket. - pub fn socket_path(&self) -> PathBuf { - self.path(SOCKET_NAME) - } - - /// Returns the path of the binding of the sandbox's clone to the generation of a template that - /// it leases, [`CLONE_BINDING_NAME`]. - pub fn binding_path(&self) -> PathBuf { - self.path(CLONE_BINDING_NAME) - } - - /// Returns the path of the activation record of a clone, [`CLONE_ACTIVATION_NAME`]. - pub fn clone_activation_path(&self) -> PathBuf { - self.path(CLONE_ACTIVATION_NAME) - } - - /// Returns the path of the Linux socket at which the launch of a clone awaits the - /// restore-ready event of its VMM, [`RESTORE_READY_NAME`]. - pub fn restore_ready_path(&self) -> PathBuf { - self.path(RESTORE_READY_NAME) - } - - /// Returns the path of the progress log of a clone's launch, [`CLONE_PROGRESS_NAME`]. - pub fn clone_progress_path(&self) -> PathBuf { - self.path(CLONE_PROGRESS_NAME) - } - - /// Returns the path of the record of a clone's failure, [`CLONE_FAILURE_NAME`]. - pub fn clone_failure_path(&self) -> PathBuf { - self.path(CLONE_FAILURE_NAME) - } - - /// Reads the runtime record, which names the sandbox's VM from the commit of its launch until - /// its runtime is cleared. - pub fn runtime(&self) -> Result> { - record::read(&self.runtime_file()) - } - - /// Reads the journal of a launch in progress, or of one that was interrupted. - pub fn journal(&self) -> Result> { - record::read(&self.journal_file()) - } - - /// Reads the launch capability of the sandbox's VM. - pub fn read_capability(&self) -> Result<[u8; CAPABILITY_LEN]> { - let file = self.capability_file(); - let Some(bytes) = file.read()? else { - return Err(file.read_failed()(io::Error::from(io::ErrorKind::NotFound))); - }; - match <[u8; CAPABILITY_LEN]>::try_from(bytes.as_slice()) { - Ok(capability) if capability != [0; CAPABILITY_LEN] => Ok(capability), - _ => Err(Error::io(format!( - "the {} {} is invalid", - file.description(), - file.path().display() - ))), - } - } - - /// Takes the sandbox's lock, waiting for it until `deadline`, or for as long as it takes - /// without one. - /// - /// Fails with [`ErrorKind::TimedOut`] when the deadline passes first, and with - /// [`ErrorKind::NotFound`] if the directory does not exist. A lock file that is removed or - /// replaced while this waits locks nothing, so the lock is then taken again, at once the first - /// time, and after a pause, until the deadline, when that happens again right after it. A lock - /// file that is not private to the current user is refused, as a record is, since others could - /// hold its lock, and one that is a link, or not a regular file, is refused at once. - /// - /// This lock knows nothing of its caller's files. A caller that keeps a configuration record - /// in the directory, or removes the store, takes the [lifecycle locks](self#lifecycle-locks) - /// instead. - pub fn lock(&self, deadline: Option) -> Result { - let mut races = Races::default(); - loop { - if let Taken::Locked(file) = self.take_lock(deadline)? { - return Ok(LockedStore::new(self.clone(), file, CallerFiles::none())); - } - if !races.lost(deadline) { - return Err(transition_in_progress(&self.dir)); - } - } - } - - /// Opens the lock file, which it creates if needed, takes its lock, waiting for it until - /// `deadline`, or for as long as it takes without one, and checks that the path still names - /// the locked file. - /// - /// Fails as [`lock`](Self::lock) does. On Windows, a file system without POSIX deletion keeps - /// a removed file under its name until its last handle closes, and refuses to open it until - /// then: the open then fails with an I/O error whose cause is the denied access, while a - /// removed lock file that the lock's own handle keeps is taken for a replaced one. - fn take_lock(&self, deadline: Option) -> Result { - let lock = self.lock_file(); - let path = lock.path(); - let failed = |error| store_error(format!("cannot lock {}", path.display()))(error); - let file = lock.open_lock(failed)?; - match deadline { - None => file.lock().map_err(failed)?, - Some(deadline) => loop { - match file.try_lock() { - Ok(()) => break, - Err(TryLockError::WouldBlock) => { - let left = deadline.saturating_duration_since(Instant::now()); - if left.is_zero() { - return Err(transition_in_progress(&self.dir)); - } - thread::sleep(left.min(LOCK_RETRY_INTERVAL)); - } - Err(TryLockError::Error(error)) => return Err(failed(error)), - } - }, - } - let locked = platform::opened_file_id(&file).map_err(failed)?; - match platform::file_id(path) { - Ok(current) if current == locked => Ok(Taken::Locked(file)), - Ok(_) => Ok(Taken::Replaced), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Taken::Replaced), - Err(error) if cfg!(windows) && error.kind() == io::ErrorKind::PermissionDenied => { - Ok(Taken::Replaced) - } - Err(error) => Err(failed(error)), - } - } - - /// Removes the files in which a VMM of the sandbox staged an outcome report that it never - /// published, which only a VMM that was killed while it wrote the report leaves. The caller - /// knows that no process of the sandbox's launches runs. Fails with [`ErrorKind::NotFound`] - /// if the directory is gone, as [`open`](Self::open) does. - fn remove_report_staging(&self) -> Result<()> { - let failed = |error| { - store_error(format!( - "cannot list the sandbox store {}", - self.dir.display() - ))(error) - }; - for entry in std::fs::read_dir(&self.dir).map_err(failed)? { - let name = entry.map_err(failed)?.file_name(); - if name.to_str().is_some_and(is_report_staging) { - remove_if_present(&self.dir.join(name))?; - } - } - Ok(()) - } -} diff --git a/nvx-rs/src/host/store/names.rs b/nvx-rs/src/host/store/names.rs deleted file mode 100644 index 67e4dc103..000000000 --- a/nvx-rs/src/host/store/names.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! The version of the store's records, and the names of the files that the store keeps in a -//! sandbox's directory. - -/// Version of the store's records. -/// -/// It follows the versions of the runtime state that the CLI kept on its own, 1, and that -/// [`instance`] kept, up to 3, before both moved to the store, so that a record of theirs fails as -/// unsupported rather than being read as one of the store's. -/// -/// [`instance`]: crate::host::instance -pub const FORMAT: u32 = 4; -/// Name of the sandbox's lock. -pub const LOCK_NAME: &str = "sandbox.lock"; -/// Name of the launch journal. -pub const JOURNAL_NAME: &str = "launch.json"; -/// Name of the runtime record. -pub const RUNTIME_NAME: &str = "runtime.json"; -/// Name of the file that holds the launch capability. -pub const CAPABILITY_NAME: &str = "control.capability"; -/// Name of the Linux control socket, at which the VMM of every launch of the sandbox listens. -pub const SOCKET_NAME: &str = "control.sock"; -/// Name of the VMM's log. -pub const LOG_NAME: &str = "openvmm.log"; -/// Name of the guest console's log. -pub const CONSOLE_NAME: &str = "console.log"; -/// Name of the VMM's outcome report. -pub const OUTCOME_NAME: &str = "outcome.json"; -/// Name of the binding of the sandbox's clone to the generation of a template that it leases. A -/// [removal] of the sandbox removes it first, and makes its absence durable before the sandbox -/// goes. -/// -/// [removal]: crate::host::store#removal -pub const CLONE_BINDING_NAME: &str = "clone.json"; -/// Name of the file of the `scratch` block device that earlier versions of this crate created for -/// a clone whose upper is in RAM, a sparse file of the template's scratch length. Such a clone now -/// restores with its scratch absent, and nothing creates the file, but a sandbox's directory may -/// still hold one that an earlier version left, which a [removal] of the sandbox removes. -/// -/// [removal]: crate::host::store#removal -pub const CLONE_SCRATCH_NAME: &str = "clone-scratch.raw"; -/// Name of the activation record of a clone, in its canonical encoding, which a [removal] of the -/// sandbox removes. -/// -/// [removal]: crate::host::store#removal -pub const CLONE_ACTIVATION_NAME: &str = "clone-activation.bin"; -/// Name of the Linux socket of the restore-ready event of a clone's VMM, which a [removal] of the -/// sandbox removes. -/// -/// [removal]: crate::host::store#removal -pub const RESTORE_READY_NAME: &str = "restore.sock"; -/// Name of the progress log of a clone's launch, which a [removal] of the sandbox removes. -/// -/// [removal]: crate::host::store#removal -pub const CLONE_PROGRESS_NAME: &str = "clone-progress.log"; -/// Name of the record of a clone's failure, which a [removal] of the sandbox removes. -/// -/// [removal]: crate::host::store#removal -pub const CLONE_FAILURE_NAME: &str = "clone-failure.json"; -/// The files that the clone of a sandbox keeps of its launch, and the file of the scratch device -/// that an earlier version created for a clone in RAM, which a [removal] of the sandbox removes -/// before the clone binding. -/// -/// [removal]: crate::host::store#removal -pub(super) const CLONE_FILE_NAMES: [&str; 5] = [ - CLONE_SCRATCH_NAME, - CLONE_ACTIVATION_NAME, - RESTORE_READY_NAME, - CLONE_PROGRESS_NAME, - CLONE_FAILURE_NAME, -]; -/// Every file that the store keeps in a sandbox's directory. Replacing a record leaves a -/// temporary file named `.NAME.NUMBER.tmp` beside it only if the replacement was interrupted, and -/// a VMM that is killed while it writes its outcome report leaves the file in which it stages it; -/// see [the report staging file]. -/// -/// [the report staging file]: crate::host::store#the-report-staging-file -pub const FILE_NAMES: [&str; 14] = [ - LOCK_NAME, - JOURNAL_NAME, - RUNTIME_NAME, - CAPABILITY_NAME, - SOCKET_NAME, - LOG_NAME, - CONSOLE_NAME, - OUTCOME_NAME, - CLONE_BINDING_NAME, - CLONE_SCRATCH_NAME, - CLONE_ACTIVATION_NAME, - RESTORE_READY_NAME, - CLONE_PROGRESS_NAME, - CLONE_FAILURE_NAME, -]; diff --git a/nvx-rs/src/host/store/record.rs b/nvx-rs/src/host/store/record.rs deleted file mode 100644 index a33e9d806..000000000 --- a/nvx-rs/src/host/store/record.rs +++ /dev/null @@ -1,362 +0,0 @@ -//! The records of the store: the runtime record of a running VM and the journal of a launch, with -//! the identities that they hold, and their reading and writing in format [`FORMAT`]. - -use std::fmt; - -use serde::de::DeserializeOwned; -use serde::{Deserialize, Serialize}; - -use super::FORMAT; -use super::files::StoreFile; -use super::identity::{IdentityCheck, RuntimeIdentity}; -use crate::error::{Error, ErrorCode, Result}; -use crate::host::filesystem::FileIdentity; -use crate::host::process::ProcessRef; - -/// Longest control endpoint, in bytes. -pub const MAX_ENDPOINT_LEN: usize = 4096; - -/// Random bytes in a [`LaunchId`]: 128 bits, so that no two launches draw the same identity. -const LAUNCH_ID_BYTES: usize = 16; - -/// Hexadecimal digits of a [`LaunchId`]: two for each of its bytes. -const LAUNCH_ID_LEN: usize = 2 * LAUNCH_ID_BYTES; - -/// Returns `bytes` as lowercase hexadecimal digits. -fn hex(bytes: &[u8]) -> String { - bytes.iter().map(|byte| format!("{byte:02x}")).collect() -} - -/// Returns whether `text` is exactly [`LAUNCH_ID_LEN`] lowercase hexadecimal digits. -fn is_hex_id(text: &str) -> bool { - text.len() == LAUNCH_ID_LEN - && text - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -/// Identity of one launch: 32 lowercase hexadecimal digits, the 16 random bytes that -/// [`generate`](Self::generate) draws for every launch, and which its journal and runtime record -/// share. -#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] -#[serde(try_from = "String", into = "String")] -pub struct LaunchId(String); - -impl LaunchId { - /// Draws a fresh launch identity, which a caller reserves for one launch before it begins it - /// with [`LockedStore::begin_launch`](super::LockedStore::begin_launch), or - /// [`supervisor::launch`](crate::host::supervisor::launch), and never uses for another. - pub fn generate() -> Result { - let mut bytes = [0u8; LAUNCH_ID_BYTES]; - getrandom::fill(&mut bytes) - .map_err(|error| Error::other("cannot draw a launch identity").with_source(error))?; - Ok(Self(hex(&bytes))) - } - - /// Returns the identity's text. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -impl fmt::Display for LaunchId { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.0) - } -} - -impl TryFrom for LaunchId { - type Error = String; - - fn try_from(text: String) -> std::result::Result { - if is_hex_id(&text) { - Ok(Self(text)) - } else { - Err(format!( - "launch identity {text:?} is not {LAUNCH_ID_LEN} lowercase hexadecimal digits" - )) - } - } -} - -impl From for String { - fn from(launch: LaunchId) -> Self { - launch.0 - } -} - -/// Explains why the identity of a VMM process is invalid, if it is. -fn process_problem(process: &ProcessRef) -> Option { - (process.pid == 0).then(|| "its process ID is 0".to_owned()) -} - -/// The claim of a launch on the log of its VMM: the identity of the log file, which the launch -/// creates afresh, and which the VMM holds as its standard output and error. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct LogClaim { - /// The device and inode of the log, or its volume serial number and file index. - pub identity: FileIdentity, -} - -/// Explains why a control endpoint is invalid, if it is. -fn endpoint_problem(endpoint: &str) -> Option { - if endpoint.is_empty() || endpoint.len() > MAX_ENDPOINT_LEN || endpoint.contains('\0') { - Some(format!( - "its control endpoint must hold 1 to {MAX_ENDPOINT_LEN} bytes and no NUL" - )) - } else { - None - } -} - -/// Explains why a runtime identity is invalid, if it is. -fn runtime_problem(runtime: &RuntimeIdentity) -> Option { - runtime - .check() - .err() - .map(|error| error.message().to_owned()) -} - -/// The runtime record of a sandbox whose VM runs: the verified process reference of its VMM, its -/// control endpoint, and the runtime identity that it was launched with. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct RuntimeRecord { - /// [`FORMAT`]. - format: u32, - /// The launch that started the VM. - launch: LaunchId, - /// The VMM process. - process: ProcessRef, - /// The control endpoint: a Unix socket path on Linux, and a named pipe on Windows. - endpoint: String, - /// The runtime that the VM was launched with. - runtime: RuntimeIdentity, -} - -impl RuntimeRecord { - /// Records the VM that the launch of `journal` started, once it has recorded its process. - pub(super) fn committed(journal: &LaunchJournal, process: ProcessRef) -> Self { - Self { - format: FORMAT, - launch: journal.launch.clone(), - process, - endpoint: journal.endpoint.clone(), - runtime: journal.runtime.clone(), - } - } - - /// Returns the launch that started the VM. - pub fn launch(&self) -> &LaunchId { - &self.launch - } - - /// Returns the reference to the VMM process, which [`Process::open`] verifies before anything - /// waits for the process or signals it. - /// - /// [`Process::open`]: crate::host::process::Process::open - pub fn process(&self) -> &ProcessRef { - &self.process - } - - /// Returns the control endpoint: a Unix socket path on Linux, and a named pipe on Windows. - pub fn endpoint(&self) -> &str { - &self.endpoint - } - - /// Returns the runtime identity that the VM was launched with. - pub fn runtime(&self) -> &RuntimeIdentity { - &self.runtime - } - - /// Compares the runtime identity that the VM was launched with with `current`, the identity - /// of the runtime that the host would now launch. - /// - /// A match lets the host reattach to the VM and keep it running, whatever else changed in - /// the host, such as the build of its own code. A mismatch means that the VM must be stopped, - /// and only through its verified process identity. - pub fn check_identity(&self, current: &RuntimeIdentity) -> IdentityCheck { - let differences = self.runtime.differences(current); - if differences.is_empty() { - IdentityCheck::Matches - } else { - IdentityCheck::Mismatched(differences) - } - } -} - -/// The step that a launch has reached, as its journal records it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum LaunchState { - /// The launch claimed its log and may have spawned its VMM, whose identity it has not - /// recorded. - Prepared, - /// The launch spawned its VMM and recorded its process identity, and has not committed its - /// runtime record yet. - Spawned, -} - -/// The journal of a launch in progress, or of one that its caller did not finish. -/// -/// A marker written before the VMM is spawned holds the launch's identity, control endpoint, -/// runtime identity, and claim on its log. The VMM's process identity follows right after the -/// spawn. Committing the launch replaces the journal with the runtime record. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub struct LaunchJournal { - /// [`FORMAT`]. - format: u32, - /// The launch. - launch: LaunchId, - /// The control endpoint that the launch chose. - endpoint: String, - /// The runtime that the launch starts. - runtime: RuntimeIdentity, - /// The launch's claim on its log. - log: LogClaim, - /// The VMM process, once spawned. - #[serde(default, skip_serializing_if = "Option::is_none")] - process: Option, -} - -impl LaunchJournal { - /// Returns the marker of a launch that is about to spawn its VMM. - pub(super) fn prepared( - launch: LaunchId, - endpoint: String, - runtime: RuntimeIdentity, - log: LogClaim, - ) -> Self { - Self { - format: FORMAT, - launch, - endpoint, - runtime, - log, - process: None, - } - } - - /// Records the VMM process that the launch spawned. - pub(super) fn spawned(&self, process: ProcessRef) -> Self { - Self { - process: Some(process), - ..self.clone() - } - } - - /// Returns the launch. - pub fn launch(&self) -> &LaunchId { - &self.launch - } - - /// Returns the control endpoint that the launch chose. - pub fn endpoint(&self) -> &str { - &self.endpoint - } - - /// Returns the runtime identity that the launch starts. - pub fn runtime(&self) -> &RuntimeIdentity { - &self.runtime - } - - /// Returns the launch's claim on its log. - pub fn log(&self) -> &LogClaim { - &self.log - } - - /// Returns the reference to the VMM process, once the launch has recorded it. - pub fn process(&self) -> Option<&ProcessRef> { - self.process.as_ref() - } - - /// Returns the step that the launch has reached. - pub fn state(&self) -> LaunchState { - match self.process { - None => LaunchState::Prepared, - Some(_) => LaunchState::Spawned, - } - } -} - -/// A record of the store, in format [`FORMAT`]. -pub(super) trait Record: Serialize + DeserializeOwned { - /// What messages call the record, and the [`StoreFile`] that holds it. - const DESCRIPTION: &'static str; - - /// Explains why the record, which parsed, is invalid, if it is. - fn problem(&self) -> Option; -} - -impl Record for RuntimeRecord { - const DESCRIPTION: &'static str = "sandbox runtime record"; - - fn problem(&self) -> Option { - process_problem(&self.process) - .or_else(|| endpoint_problem(&self.endpoint)) - .or_else(|| runtime_problem(&self.runtime)) - } -} - -impl Record for LaunchJournal { - const DESCRIPTION: &'static str = "sandbox launch journal"; - - fn problem(&self) -> Option { - self.process - .as_ref() - .and_then(process_problem) - .or_else(|| endpoint_problem(&self.endpoint)) - .or_else(|| runtime_problem(&self.runtime)) - } -} - -/// Reads the record that `file` holds, or returns `None` if the file does not exist. -/// -/// A record of another format fails before its fields are read, with a message that names its -/// format, and the code [`ErrorCode::FormatUnsupported`]: the store reads format [`FORMAT`] only, -/// and migrates no other. A record file that is not private to the current user fails as well, -/// since others could have written it, and so does one that is not a regular file, without waiting -/// on it, as [`StoreFile::read`] reads it. Every failure to read or check the record is an -/// [`ErrorKind::Io`](crate::ErrorKind::Io) error. -pub(super) fn read(file: &StoreFile) -> Result> { - let (path, description) = (file.path(), file.description()); - let Some(text) = file.read()? else { - return Ok(None); - }; - let malformed = || format!("the {description} {} is malformed", path.display()); - let value: serde_json::Value = - serde_json::from_slice(&text).map_err(|error| Error::io(malformed()).with_source(error))?; - match value.get("format") { - Some(format) if format.as_u64() == Some(FORMAT.into()) => {} - Some(format) => { - return Err(Error::new( - ErrorCode::FormatUnsupported, - format!( - "the {description} {} has the unsupported format {format}; this build reads \ - format {FORMAT} only and migrates no other, so stop the sandbox with the \ - build that started it", - path.display() - ), - )); - } - None => { - return Err(Error::io(format!("{}: it has no format", malformed()))); - } - } - let record: T = - serde_json::from_value(value).map_err(|error| Error::io(malformed()).with_source(error))?; - match record.problem() { - Some(problem) => Err(Error::io(format!("{}: {problem}", malformed()))), - None => Ok(Some(record)), - } -} - -/// Atomically replaces the record that `file` holds with `record`, as pretty JSON and a newline. -pub(super) fn write(file: &StoreFile, record: &T) -> Result<()> { - let mut text = serde_json::to_vec_pretty(record).map_err(|error| { - Error::other(format!("cannot encode the {}", file.description())).with_source(error) - })?; - text.push(b'\n'); - file.replace(&text) -} diff --git a/nvx-rs/src/host/store/regular.rs b/nvx-rs/src/host/store/regular.rs deleted file mode 100644 index 10b9d7cbb..000000000 --- a/nvx-rs/src/host/store/regular.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! The files that a sandbox's store reads as its clone binding: regular files that are not links, -//! which it reads within a bound. -//! -//! The reads themselves are [`platform`]'s: this module re-exports its `open_regular` and -//! `read_at_most` for its callers until #1121 deletes them. - -use std::fs::File; -use std::io; -use std::path::Path; - -use crate::host::platform; -pub(crate) use crate::host::platform::{open_regular, read_at_most}; - -/// Returns whether `error`, with which an open or a removal of the file at `path` failed, means -/// that the file was removed, by a transaction of another process, say: on Windows, a file system -/// without POSIX deletion keeps a removed file under its name while other handles hold it, such as -/// a scanner's, and refuses every open of it until they close, which Win32 reports as a denied -/// access. So a denied access met a removed file if the file is deleted now, or its deletion is -/// pending, as [`platform::is_deleted`] tells, which a file whose access control list refuses the -/// access is not. No open reaches a removed file again, so it is gone for its readers, and for any -/// other removal of it. -pub(crate) fn was_removed(path: &Path, error: &io::Error) -> bool { - cfg!(windows) && error.kind() == io::ErrorKind::PermissionDenied && platform::is_deleted(path) -} - -/// Opens the file `name` of `directory` for reading, as [`open_regular`] opens the file at a -/// path, if it is a regular file, or returns `None` if the directory has no such entry, or, on -/// Windows, one whose deletion is pending, which is removed. The open names the entry through the -/// handle, so that it reaches the entry of the directory that the handle holds, whatever the -/// directory's path names meanwhile. -pub(crate) fn open_regular_in( - directory: &platform::Directory, - name: &str, -) -> io::Result> { - match directory.open_file(name) { - Ok(Some(file)) => platform::regular(file).map(Some), - Ok(None) => Ok(None), - Err(error) => Err(platform::refused_open(error)), - } -} diff --git a/nvx-rs/src/host/store/tests.rs b/nvx-rs/src/host/store/tests.rs deleted file mode 100644 index 546b92765..000000000 --- a/nvx-rs/src/host/store/tests.rs +++ /dev/null @@ -1,1064 +0,0 @@ -//! Tests of the store's records, lock, and launch bookkeeping. The launches of real processes and -//! the recovery of interrupted ones are tested in `tests/store.rs`. - -use std::fs; -use std::sync::mpsc; -use std::thread; - -use super::*; -use crate::ErrorKind; -use crate::contract; -use crate::host::filesystem::FileIdentity; -use crate::host::process::{BootId, Process, ProcessRef}; -use crate::host::tests::released; - -/// A launch capability. -const CAPABILITY: [u8; CAPABILITY_LEN] = [7; CAPABILITY_LEN]; - -/// Returns a reference to process 42, started at 4242 during the boot whose bytes are all `boot`. -fn reference(boot: u8) -> ProcessRef { - ProcessRef { - pid: 42, - start_time: 4242, - boot: BootId::from_bytes([boot; 16]), - } -} - -/// Returns the identity `sha256:` and 64 copies of `digit`. -fn artifact(digit: char) -> ArtifactIdentity { - ArtifactIdentity::sha256(&digit.to_string().repeat(64)).unwrap() -} - -/// Returns a runtime identity under this crate's contracts. -fn runtime() -> RuntimeIdentity { - RuntimeIdentity::new(artifact('a'), artifact('b'), artifact('c')) -} - -/// Returns a store in a fresh temporary directory. -fn store() -> (tempfile::TempDir, SandboxStore) { - let directory = tempfile::tempdir().unwrap(); - let store = SandboxStore::create(directory.path().join("sandbox")).unwrap(); - (directory, store) -} - -/// Writes `value` as the file `name` of `store`. -fn write(store: &SandboxStore, name: &str, value: &serde_json::Value) { - fs::write(store.path(name), serde_json::to_vec(value).unwrap()).unwrap(); -} - -#[test] -fn records_keep_their_json_form() { - let journal = LaunchJournal::prepared( - LaunchId::try_from("0123456789abcdef0123456789abcdef".to_owned()).unwrap(), - "/state/sandbox/control.sock".to_owned(), - runtime(), - LogClaim { - identity: FileIdentity { - device: 1, - index: 2, - }, - }, - ); - let runtime_json = serde_json::json!({ - "openvmm": format!("sha256:{}", "a".repeat(64)), - "kernel": format!("sha256:{}", "b".repeat(64)), - "initramfs": format!("sha256:{}", "c".repeat(64)), - "bootContract": contract::BOOT_CONTRACT_VERSION, - "controlContract": contract::CONTROL_CONTRACT_REVISION, - "controlSession": contract::CONTROL_SESSION_PROTOCOL_VERSION, - }); - let prepared = serde_json::json!({ - "format": 4, - "launch": "0123456789abcdef0123456789abcdef", - "endpoint": "/state/sandbox/control.sock", - "runtime": runtime_json, - "log": { "identity": { "device": 1, "index": 2 } }, - }); - assert_eq!(serde_json::to_value(&journal).unwrap(), prepared); - assert_eq!(journal.state(), LaunchState::Prepared); - - let process = reference(0xab); - let spawned = journal.spawned(process.clone()); - assert_eq!(spawned.state(), LaunchState::Spawned); - let process_json = serde_json::json!({ - "pid": 42, - "startTime": 4242, - "boot": "ab".repeat(16), - }); - let mut expected = prepared.clone(); - expected["process"] = process_json.clone(); - assert_eq!(serde_json::to_value(&spawned).unwrap(), expected); - - let record = RuntimeRecord::committed(&spawned, process); - assert_eq!( - serde_json::to_value(&record).unwrap(), - serde_json::json!({ - "format": 4, - "launch": "0123456789abcdef0123456789abcdef", - "process": process_json, - "endpoint": "/state/sandbox/control.sock", - "runtime": runtime_json, - }) - ); - assert_eq!(record.check_identity(&runtime()), IdentityCheck::Matches); - let mut upgraded = runtime(); - upgraded.openvmm = artifact('9'); - assert_eq!( - record.check_identity(&upgraded), - IdentityCheck::Mismatched(vec![IdentityPart::OpenVmm]) - ); -} - -#[test] -fn records_of_other_formats_fail_clearly() { - let (_directory, store) = store(); - let unsupported = |value: serde_json::Value, format: &str| { - write(&store, RUNTIME_NAME, &value); - let error = store.runtime().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io, "{error}"); - assert!( - error - .message() - .contains(&format!("has the unsupported format {format};")), - "{error}" - ); - assert!(error.message().contains("migrates no other"), "{error}"); - }; - // The runtime records of the CLI's state directories and of `instance`. - unsupported( - serde_json::json!({ - "format": 1, "pid": 7, "start_time": 8, "control_endpoint": "/state/control.sock", - }), - "1", - ); - unsupported( - serde_json::json!({ "format": 3, "pid": 7, "startTime": 8, "endpoint": "e" }), - "3", - ); - unsupported(serde_json::json!({ "format": 5 }), "5"); - unsupported(serde_json::json!({ "format": "4" }), "\"4\""); - - let malformed = |text: &[u8], detail: &str| { - fs::write(store.path(JOURNAL_NAME), text).unwrap(); - let error = store.journal().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io, "{error}"); - assert!(error.message().contains("is malformed"), "{error}"); - assert!(error.message().contains(detail), "{error}"); - }; - malformed(b"{ truncated", ""); - malformed(b"[4]", "it has no format"); - malformed(b"{}", "it has no format"); - malformed(br#"{ "format": 4 }"#, ""); -} - -#[test] -fn records_with_invalid_fields_are_malformed() { - let (_directory, store) = store(); - let valid = serde_json::json!({ - "format": 4, - "launch": "0123456789abcdef0123456789abcdef", - "process": { "pid": 42, "startTime": 4242, "boot": "ab".repeat(16) }, - "endpoint": "/state/sandbox/control.sock", - "runtime": serde_json::to_value(runtime()).unwrap(), - }); - write(&store, RUNTIME_NAME, &valid); - let record = store.runtime().unwrap().unwrap(); - assert_eq!(record.process(), &reference(0xab)); - assert_eq!(record.endpoint(), "/state/sandbox/control.sock"); - assert_eq!(record.runtime(), &runtime()); - - let invalid = |pointer: &str, value: serde_json::Value, detail: &str| { - let mut record = valid.clone(); - *record.pointer_mut(pointer).unwrap() = value; - write(&store, RUNTIME_NAME, &record); - let error = store.runtime().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io, "{pointer}: {error}"); - assert!( - error.message().contains("is malformed"), - "{pointer}: {error}" - ); - assert!(error.message().contains(detail), "{pointer}: {error}"); - }; - invalid("/process/pid", 0.into(), "its process ID is 0"); - invalid("/process/boot", "ab".into(), ""); - invalid("/endpoint", "".into(), "its control endpoint"); - invalid("/runtime/controlContract", "a b".into(), "control contract"); - invalid("/launch", "0123".into(), ""); - invalid("/runtime/kernel", "".into(), ""); - invalid("/runtime/bootContract", (-1).into(), ""); - // Unknown fields are refused rather than ignored. - let mut extra = valid.clone(); - extra["capability"] = "secret".into(); - write(&store, RUNTIME_NAME, &extra); - assert_eq!(store.runtime().unwrap_err().kind(), ErrorKind::Io); -} - -#[test] -fn the_lock_excludes_other_holders() { - let (_directory, store) = store(); - let held = store.lock(None).unwrap(); - let started = Instant::now(); - let error = store - .lock(Some(Instant::now() + Duration::from_millis(100))) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::TimedOut, "{error}"); - assert!(started.elapsed() >= Duration::from_millis(100)); - // A deadline that has passed still tries once. - assert!(store.lock(Some(Instant::now())).is_err()); - drop(held); - assert!(released(|| match store.lock(Some(Instant::now())) { - Ok(_) => true, - Err(error) if error.kind() == ErrorKind::TimedOut => false, - Err(error) => panic!("{error}"), - })); - - // A waiter without a deadline takes the lock once its holder releases it. - let held = store.lock(None).unwrap(); - let (sender, receiver) = mpsc::channel(); - let waiter = thread::spawn({ - let store = store.clone(); - move || { - let locked = store.lock(None).map(drop); - sender.send(()).unwrap(); - locked - } - }); - assert!(receiver.recv_timeout(Duration::from_millis(100)).is_err()); - drop(held); - waiter.join().unwrap().unwrap(); -} - -#[test] -fn a_lock_file_replaced_while_waiting_locks_nothing() { - let (_directory, store) = store(); - let held = store.lock(None).unwrap(); - let waiter = thread::spawn({ - let store = store.clone(); - move || { - let locked = store.lock(None).unwrap(); - // The waiter holds the lock of the file that the path names now. - let current = platform::file_id(&locked.path(LOCK_NAME)).unwrap(); - let held = platform::opened_file_id(&locked.lock).unwrap(); - assert_eq!(current, held); - } - }); - thread::sleep(Duration::from_millis(100)); - // The lock file goes while the waiter waits for it. - fs::remove_file(store.path(LOCK_NAME)).unwrap(); - drop(held); - waiter.join().unwrap(); -} - -#[cfg(unix)] -#[test] -fn a_lock_file_that_is_not_a_regular_file_fails_at_once() { - use std::os::unix::fs::OpenOptionsExt; - - use nix::sys::stat::Mode; - - let (_directory, store) = store(); - let path = store.path(LOCK_NAME); - nix::unistd::mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); - let refused = |error: Error| { - assert_eq!(error.message(), format!("cannot lock {}", path.display())); - let cause = std::error::Error::source(&error).map(ToString::to_string); - assert_eq!(cause.as_deref(), Some("is not a regular file"), "{error}"); - }; - // An open of a FIFO for writing that waits would wait for a reader, so the lock is taken on a - // thread that the test can leave behind. - let (sender, receiver) = mpsc::channel(); - thread::spawn({ - let store = store.clone(); - move || sender.send(store.lock(None).map(drop)).unwrap() - }); - let locked = receiver - .recv_timeout(Duration::from_secs(10)) - .expect("the lock waits for a reader of the FIFO"); - refused(locked.unwrap_err()); - // A FIFO that has a reader opens, and is refused all the same. - let _reader = fs::OpenOptions::new() - .read(true) - .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) - .open(&path) - .unwrap(); - refused(store.lock(Some(Instant::now())).unwrap_err()); -} - -#[test] -fn a_lock_file_that_is_a_link_fails_at_once() { - let (_directory, store) = store(); - let path = store.path(LOCK_NAME); - // A lock taken through a link to a file that a launch replaces, such as the capability, would - // hold the replaced file, and the next transition would lock its replacement. - let target = store.capability_path(); - fs::write(&target, CAPABILITY).unwrap(); - #[cfg(unix)] - let linked = std::os::unix::fs::symlink(&target, &path); - #[cfg(windows)] - let linked = std::os::windows::fs::symlink_file(&target, &path); - if linked.is_err() { - // Windows lets only Developer Mode or a privilege create a symbolic link. - eprintln!("skipped: this host does not let the test create a symbolic link"); - return; - } - let error = store.lock(None).unwrap_err(); - assert_eq!(error.message(), format!("cannot lock {}", path.display())); - let cause = std::error::Error::source(&error).map(ToString::to_string); - assert_eq!(cause.as_deref(), Some("is not a regular file"), "{error}"); - // The link and the file that it names stay as they are. - assert!( - fs::symlink_metadata(&path) - .unwrap() - .file_type() - .is_symlink() - ); - assert_eq!(fs::read(&target).unwrap(), CAPABILITY); -} - -#[cfg(unix)] -#[test] -fn records_and_capabilities_that_are_not_regular_files_fail_at_once() { - use std::os::unix::fs::OpenOptionsExt; - - use nix::sys::stat::Mode; - - let (_directory, store) = store(); - for (name, description) in [ - (RUNTIME_NAME, "sandbox runtime record"), - (JOURNAL_NAME, "sandbox launch journal"), - (CAPABILITY_NAME, "sandbox control capability"), - ] { - let path = store.path(name); - nix::unistd::mkfifo(&path, Mode::S_IRUSR | Mode::S_IWUSR).unwrap(); - // An open of a FIFO for reading that waits would wait for a writer, and a read of one would - // wait until its writer closes it, so the file is read on a thread that the test can leave - // behind. - let refused = || { - let (sender, receiver) = mpsc::channel(); - thread::spawn({ - let store = store.clone(); - move || { - let read = match name { - RUNTIME_NAME => store.runtime().map(drop), - JOURNAL_NAME => store.journal().map(drop), - _ => store.read_capability().map(drop), - }; - sender.send(read).unwrap(); - } - }); - let error = receiver - .recv_timeout(Duration::from_secs(10)) - .unwrap_or_else(|_| panic!("the read of the {description} waits on the FIFO")) - .unwrap_err(); - assert_eq!( - error.message(), - format!("cannot read the {description} {}", path.display()) - ); - let cause = std::error::Error::source(&error).map(ToString::to_string); - assert_eq!(cause.as_deref(), Some("is not a regular file"), "{error}"); - }; - refused(); - // A FIFO that has a writer opens at once, and is refused all the same, before a read waits - // for the writer to close it. - let reader = fs::OpenOptions::new() - .read(true) - .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) - .open(&path) - .unwrap(); - let writer = fs::OpenOptions::new() - .write(true) - .custom_flags(nix::fcntl::OFlag::O_NONBLOCK.bits()) - .open(&path) - .unwrap(); - drop(reader); - refused(); - drop(writer); - fs::remove_file(&path).unwrap(); - } -} - -// Other hosts open no handle of a directory, through which the store names its entries. -#[cfg(any(target_os = "linux", windows))] -#[test] -fn entries_that_are_links_or_not_regular_files_are_refused_as_the_platform_refuses_them() { - let directory = tempfile::tempdir().unwrap(); - let opened = platform::Directory::open(directory.path()).unwrap(); - let target = directory.path().join("record"); - fs::write(&target, b"data").unwrap(); - let file = regular::open_regular_in(&opened, "record") - .unwrap() - .unwrap(); - assert_eq!(regular::read_at_most(file, 4, "a record").unwrap(), b"data"); - fs::create_dir(directory.path().join("directory")).unwrap(); - let mut refused = vec!["directory"]; - let link = directory.path().join("link"); - #[cfg(unix)] - let linked = std::os::unix::fs::symlink(&target, &link); - #[cfg(windows)] - let linked = std::os::windows::fs::symlink_file(&target, &link); - // Windows lets only Developer Mode or a privilege create a symbolic link. - if linked.is_ok() { - refused.push("link"); - } - for name in refused { - let error = regular::open_regular_in(&opened, name).unwrap_err(); - assert_eq!( - error.to_string(), - "it is a link, or not a regular file", - "{name}" - ); - } -} - -#[test] -fn failures_to_read_the_records_and_the_capability_name_them() { - let (_directory, store) = store(); - let read = |name: &str| match name { - RUNTIME_NAME => store.runtime().map(drop), - JOURNAL_NAME => store.journal().map(drop), - _ => store.read_capability().map(drop), - }; - let files = [ - (RUNTIME_NAME, "sandbox runtime record"), - (JOURNAL_NAME, "sandbox launch journal"), - (CAPABILITY_NAME, "sandbox control capability"), - ]; - // A missing record is none, but a missing capability fails, as its launch needs it. - assert!(store.runtime().unwrap().is_none()); - assert!(store.journal().unwrap().is_none()); - let path = store.path(CAPABILITY_NAME); - let error = store.read_capability().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!( - error.message(), - format!( - "cannot read the sandbox control capability {}", - path.display() - ) - ); - let cause = std::error::Error::source(&error) - .and_then(|source| source.downcast_ref::()) - .map(io::Error::kind); - assert_eq!(cause, Some(io::ErrorKind::NotFound), "{error}"); - // A capability of another length, or an all-zero one, is invalid. - for invalid in [&CAPABILITY[1..], &[0; CAPABILITY_LEN][..]] { - fs::write(&path, invalid).unwrap(); - let error = store.read_capability().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!( - error.message(), - format!( - "the sandbox control capability {} is invalid", - path.display() - ) - ); - } - fs::remove_file(&path).unwrap(); - // A record that does not parse is malformed. - for (name, description) in &files[..2] { - let path = store.path(name); - fs::write(&path, b"{").unwrap(); - let error = read(name).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!( - error.message(), - format!("the {description} {} is malformed", path.display()) - ); - fs::remove_file(&path).unwrap(); - } - // A directory in the place of a record or of the capability cannot be read as one: Unix opens - // it, and refuses it as no regular file, and Windows refuses to open it. - for (name, description) in files { - let path = store.path(name); - fs::create_dir(&path).unwrap(); - let error = read(name).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!( - error.message(), - format!("cannot read the {description} {}", path.display()) - ); - fs::remove_dir(&path).unwrap(); - } -} - -#[cfg(target_os = "linux")] -#[test] -fn the_store_is_private_to_the_current_user() { - use std::os::unix::fs::{PermissionsExt, symlink}; - - let (directory, store) = store(); - let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o7777; - let set_mode = |path: &Path, mode: u32| { - fs::set_permissions(path, fs::Permissions::from_mode(mode)).unwrap(); - }; - // A directory that others could access is refused, and left as it is, rather than restricted: - // another user could hold its files open, as this journal that anyone may write to, which a - // change of the directory's mode would not take back. - let shared = directory.path().join("shared"); - fs::create_dir(&shared).unwrap(); - let journal = shared.join(JOURNAL_NAME); - fs::write(&journal, b"{}").unwrap(); - set_mode(&journal, 0o666); - let held = fs::OpenOptions::new().write(true).open(&journal).unwrap(); - for shared_mode in [0o755, 0o777] { - set_mode(&shared, shared_mode); - for error in [ - SandboxStore::create(&shared).unwrap_err(), - SandboxStore::open(&shared).unwrap_err(), - ] { - let expected = format!("is accessible to other users (mode {shared_mode:03o})"); - assert!(error.message().ends_with(&expected), "{error}"); - } - assert_eq!(mode(&shared), shared_mode); - } - drop(held); - // A link to a store is no store. - let link = directory.path().join("link"); - symlink(store.dir(), &link).unwrap(); - for error in [ - SandboxStore::create(&link).unwrap_err(), - SandboxStore::open(&link).unwrap_err(), - ] { - assert!( - error.message().ends_with("is not a plain directory"), - "{error}" - ); - } - // The store's own directory and files are the user's alone. - assert_eq!(mode(store.dir()), 0o700); - assert_eq!(SandboxStore::open(store.dir()).unwrap(), store); - assert_eq!(SandboxStore::create(store.dir()).unwrap(), store); - let mut locked = store.lock(None).unwrap(); - let launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - drop(launch); - for name in [LOCK_NAME, JOURNAL_NAME, CAPABILITY_NAME, LOG_NAME] { - assert_eq!(mode(&store.path(name)), 0o600, "{name}"); - } - // A new store takes the setgid bit of its parent, and drops it for the mode 0700 that OpenVMM - // requires of the directory of its control socket; an existing directory with another mode is - // refused. - let setgid = directory.path().join("setgid"); - fs::create_dir(&setgid).unwrap(); - set_mode(&setgid, 0o2700); - assert_eq!(mode(&setgid), 0o2700); - let plain = setgid.join("plain"); - fs::create_dir(&plain).unwrap(); - assert_eq!(mode(&plain) & 0o2000, 0o2000); - let nested = SandboxStore::create(setgid.join("sandbox")).unwrap(); - assert_eq!(mode(nested.dir()), 0o700); - for error in [ - SandboxStore::create(&setgid).unwrap_err(), - SandboxStore::open(&setgid).unwrap_err(), - ] { - assert!( - error.message().ends_with( - "has mode 2700, not the mode 700 that the directory of a control socket must have" - ), - "{error}" - ); - } -} - -#[cfg(target_os = "linux")] -#[test] -fn stores_belong_to_the_current_user_with_mode_700() { - use nix::sys::stat::Mode; - use nix::unistd::Uid; - - // The check takes the owner and the current user as user IDs, and the permission bits of the - // directory's mode, without the bits of its type, as the store reads them from its metadata. - let store_dir_problem = |uid, mode, euid| { - platform::tests::store_dir_problem( - Uid::from_raw(uid), - Mode::from_bits_truncate(mode), - Uid::from_raw(euid), - ) - }; - // Another user's directory is refused whatever its mode, since its owner may change it. - assert_eq!( - store_dir_problem(1001, 0o40700, 1000), - Some("belongs to user 1001, not to the current user 1000".to_owned()) - ); - assert_eq!(store_dir_problem(1000, 0o40700, 1000), None); - for mode in [0o40750, 0o40705, 0o40770] { - let problem = store_dir_problem(1000, mode, 1000).unwrap(); - assert!( - problem.starts_with("is accessible to other users"), - "{problem}" - ); - } - for (mode, shown) in [(0o42700, "2700"), (0o41700, "1700"), (0o40500, "500")] { - let problem = store_dir_problem(1000, mode, 1000).unwrap(); - assert!( - problem.starts_with(&format!("has mode {shown}, ")), - "{problem}" - ); - } -} - -#[cfg(windows)] -#[test] -fn the_store_is_private_to_the_current_user() { - let directory = tempfile::tempdir().unwrap(); - platform::tests::set_access(directory.path(), "D:(A;OICI;FA;;;WD)").unwrap(); - // A directory that every user may change holds no store, nor does one created in it. - let error = SandboxStore::open(directory.path()).unwrap_err(); - assert!(error.message().ends_with("allows other users"), "{error}"); - let shared = directory.path().join("shared"); - fs::create_dir(&shared).unwrap(); - let error = SandboxStore::create(&shared).unwrap_err(); - assert!(error.message().ends_with("allows other users"), "{error}"); - // Nor does one that allows its owner alone, but passes nothing on to the files created in it, - // which would take their creator's default list. - platform::tests::set_access(&shared, "D:P(A;;FA;;;OW)").unwrap(); - for error in [ - SandboxStore::create(&shared).unwrap_err(), - SandboxStore::open(&shared).unwrap_err(), - ] { - assert!( - error - .message() - .ends_with("passes no access control entries on to the files created in it"), - "{error}" - ); - } - // A store created there allows the current user alone, and so do its files. - let store = SandboxStore::create(directory.path().join("sandbox")).unwrap(); - assert_eq!(SandboxStore::open(store.dir()).unwrap(), store); - assert_eq!(SandboxStore::create(store.dir()).unwrap(), store); - let mut locked = store.lock(None).unwrap(); - let launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - drop(launch); - let problem = |name: &str| { - let file = fs::File::open(store.path(name)).unwrap(); - platform::private_file_problem(&file).unwrap() - }; - for name in [LOCK_NAME, JOURNAL_NAME, CAPABILITY_NAME, LOG_NAME] { - assert_eq!(problem(name), None, "{name}"); - } - // A record or a capability whose own list allows others could have been written by them, - // whatever the directory allows, and is refused. - for name in [JOURNAL_NAME, CAPABILITY_NAME] { - platform::tests::set_access(&store.path(name), "D:P(A;;FA;;;WD)").unwrap(); - assert_eq!( - problem(name).as_deref(), - Some("allows other users"), - "{name}" - ); - } - let refused = |description: &str, name: &str| { - format!( - "the {description} {} allows other users", - store.path(name).display() - ) - }; - let error = store.journal().unwrap_err(); - assert_eq!( - error.message(), - refused("sandbox launch journal", JOURNAL_NAME) - ); - let error = locked.recover().unwrap_err(); - assert_eq!( - error.message(), - refused("sandbox launch journal", JOURNAL_NAME) - ); - let error = store.read_capability().unwrap_err(); - assert_eq!( - error.message(), - refused("sandbox control capability", CAPABILITY_NAME) - ); - // So is a lock file whose own list allows others, who could hold its lock. - drop(locked); - platform::tests::set_access(&store.path(LOCK_NAME), "D:P(A;;FA;;;WD)").unwrap(); - let error = store.lock(None).unwrap_err(); - assert_eq!(error.message(), refused("sandbox lock", LOCK_NAME)); -} - -#[test] -fn a_caller_that_keeps_the_console_keeps_it_across_launches() { - assert!(!CallerFiles::new("config.json").unwrap().keeps_console()); - let files = CallerFiles::new("config.json").unwrap().keeping_console(); - assert!(files.keeps_console()); - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("sandbox"); - let mut locked = SandboxStore::lock_for_provision(&dir, &files, None).unwrap(); - fs::write(dir.join("config.json"), b"{}").unwrap(); - let (console, outcome) = (locked.console_path(), locked.outcome_path()); - fs::write(&console, b"earlier launches\n").unwrap(); - fs::write(&outcome, b"{}").unwrap(); - let launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - // The launch leaves the console of the earlier launches for the caller to append to, and - // removes the previous launch's outcome report, as for any caller. - assert_eq!(fs::read(&console).unwrap(), b"earlier launches\n"); - assert!(!outcome.exists()); - match launch.abandon().unwrap() { - Recovery::Exited => {} - // A process that a concurrent test spawned may still hold the log that the launch closed, - // until it executes its program, and the next recovery then ends the launch. - Recovery::Indeterminate(_) => { - assert!(released(|| locked.recover().unwrap() == Recovery::Exited)); - } - other => panic!("{other:?}"), - } - // The removal removes the console log with the store's other files. - assert!(locked.remove().unwrap().is_none()); - assert!(!dir.exists()); -} - -#[test] -fn stores_open_only_existing_directories() { - let (directory, store) = store(); - assert_eq!(SandboxStore::open(store.dir()).unwrap(), store); - let missing = SandboxStore::open(directory.path().join("missing")).unwrap_err(); - assert_eq!(missing.kind(), ErrorKind::NotFound); - let file = directory.path().join("file"); - fs::write(&file, b"").unwrap(); - assert!( - SandboxStore::open(&file) - .unwrap_err() - .message() - .contains("is not a plain directory") - ); - // A relative directory names the same store from any later working directory. - let current = absolute(PathBuf::from(".")).unwrap(); - assert!(current.is_absolute()); - assert_eq!( - platform::file_identity(¤t).unwrap(), - platform::file_identity(&std::env::current_dir().unwrap()).unwrap() - ); -} - -#[test] -fn a_launch_writes_its_marker_before_its_vmm_runs() { - let (_directory, store) = store(); - for name in [OUTCOME_NAME, CONSOLE_NAME, LOG_NAME] { - fs::write(store.path(name), b"previous launch").unwrap(); - } - let previous_log = platform::file_identity(&store.log_path()).unwrap(); - // Held open, the previous log keeps its inode, which the fresh log cannot then reuse. - let previous = fs::File::open(store.log_path()).unwrap(); - let mut locked = store.lock(None).unwrap(); - let mut launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - let journal = store.journal().unwrap().unwrap(); - assert_eq!(&journal, launch.journal()); - assert_eq!(journal.state(), LaunchState::Prepared); - assert_eq!(journal.runtime(), &runtime()); - assert_eq!(launch.endpoint(), journal.endpoint()); - if cfg!(windows) { - assert!(journal.endpoint().starts_with("//./pipe/openvmm-microvm-")); - } else { - assert_eq!(Path::new(journal.endpoint()), store.socket_path()); - } - assert_eq!(store.read_capability().unwrap(), CAPABILITY); - // The previous launch's outcome and console are gone, and its log is replaced. - assert!(!store.outcome_path().exists()); - assert!(!store.console_path().exists()); - assert_eq!(fs::read(store.log_path()).unwrap(), b""); - let identity = platform::file_identity(&store.log_path()).unwrap(); - assert_ne!(identity, previous_log); - let claim = journal.log(); - assert_eq!((claim.identity.device, claim.identity.index), identity); - - // The claim lasts until every holder of the log closes it. - let log = launch.take_log().unwrap(); - assert!(launch.take_log().is_err()); - assert!(platform::log_claim_held(&store.log_path(), identity).unwrap()); - let copy = log.try_clone().unwrap(); - drop(log); - assert!(platform::log_claim_held(&store.log_path(), identity).unwrap()); - drop(copy); - let free = || !platform::log_claim_held(&store.log_path(), identity).unwrap(); - assert!(released(free)); - // A log that is not the claimed file proves nothing. - assert!(platform::log_claim_held(&store.log_path(), previous_log).is_err()); - - // A second launch waits until the first is recovered. - drop(launch); - let error = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap_err(); - assert!( - error.message().contains("must be recovered first"), - "{error}" - ); - assert_eq!(locked.recover().unwrap(), Recovery::Exited); - assert!(store.journal().unwrap().is_none()); - assert!(store.read_capability().is_err()); - // The log outlives the launch. - assert!(store.log_path().exists()); - assert_eq!(locked.recover().unwrap(), Recovery::Clean); - drop(previous); -} - -#[test] -fn launches_check_their_inputs() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let error = locked - .begin_launch( - &LaunchId::generate().unwrap(), - &runtime(), - &[0; CAPABILITY_LEN], - ) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - let mut invalid = runtime(); - invalid.control_contract = String::new(); - assert_eq!( - locked - .begin_launch(&LaunchId::generate().unwrap(), &invalid, &CAPABILITY) - .unwrap_err() - .kind(), - ErrorKind::Unsupported - ); - // Nothing was written. - assert!(store.journal().unwrap().is_none()); - assert!(!store.capability_path().exists()); - assert!(!store.log_path().exists()); -} - -#[test] -fn an_abandoned_launch_that_spawned_nothing_has_exited() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - // A launch commits only once it records its VMM. - let error = launch.commit().unwrap_err(); - assert!(error.message().contains("before it records"), "{error}"); - let error = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap_err(); - assert!( - error.message().contains("must be recovered first"), - "{error}" - ); - // A process that a concurrent test spawned may still hold the log that the launch closed, - // until it executes its program, and a later recovery then ends the launch. - assert!(released(|| locked.recover().unwrap() == Recovery::Exited)); - let launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - match launch.abandon().unwrap() { - Recovery::Exited => {} - Recovery::Indeterminate(_) => { - assert!(released(|| locked.recover().unwrap() == Recovery::Exited)); - } - other => panic!("{other:?}"), - } - assert!(store.journal().unwrap().is_none()); - assert!(!store.capability_path().exists()); -} - -#[test] -fn a_recorded_vmm_that_cannot_be_held_has_exited_once_its_log_is_released() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let mut launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - let log = launch.take_log().unwrap(); - let reference = ProcessRef { - boot: BootId::current().unwrap(), - ..reference(0) - }; - let journal = launch.journal().spawned(reference.clone()); - drop(launch); - // No process matches the reference, or the process that has its ID, such as another user's, - // cannot be examined. - /// Holds the process that a reference names, as `Process::open` does. - type Open = fn(&ProcessRef) -> io::Result>; - let opens: [Open; 2] = [ - |_| Ok(None), - |_| Err(io::Error::new(io::ErrorKind::PermissionDenied, "hidden")), - ]; - // While a process of the launch holds its log, the launch may still run. - for open in opens { - let Recovery::Indeterminate(reason) = locked.end_identified(&journal, &reference, open) - else { - panic!("recovery ended a launch whose log is still held"); - }; - assert!(reason.contains("still holds its log"), "{reason}"); - } - // Once nothing holds the log, the launch has exited. A process that a concurrent test spawned - // may still hold it, until it executes its program. - drop(log); - for open in opens { - let exited = || locked.end_identified(&journal, &reference, open) == Recovery::Exited; - assert!(released(exited)); - } -} - -#[test] -fn a_journal_that_its_runtime_record_supersedes_is_removed() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - let journal = launch.journal().spawned(reference(1)); - drop(launch); - // The launch wrote its runtime record and crashed before it removed its journal. - let committed = RuntimeRecord::committed(&journal, journal.process().unwrap().clone()); - record::write(&store.runtime_file(), &committed).unwrap(); - record::write(&store.journal_file(), &journal).unwrap(); - assert_eq!(locked.recover().unwrap(), Recovery::Clean); - assert!(store.journal().unwrap().is_none()); - assert_eq!(store.runtime().unwrap(), Some(committed)); - // A runtime record of another launch makes the state inconsistent. - let other = LaunchJournal::prepared( - LaunchId::generate().unwrap(), - journal.endpoint().to_owned(), - runtime(), - *journal.log(), - ); - record::write(&store.journal_file(), &other).unwrap(); - let error = locked.recover().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert!(error.message().contains("names launch"), "{error}"); - // A runtime record keeps a launch from beginning until it is cleared. - fs::remove_file(store.path(JOURNAL_NAME)).unwrap(); - let error = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap_err(); - assert!(error.message().contains("already running"), "{error}"); - locked.clear_runtime().unwrap(); - assert!(store.runtime().unwrap().is_none()); - assert!(!store.capability_path().exists()); - drop( - locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(), - ); -} - -#[test] -fn report_staging_files_go_once_nothing_of_their_launch_runs() { - let (_directory, store) = store(); - // The file in which a VMM that was killed staged its outcome report, and other names, which - // are not the store's. - let staging = store.path(".tmpAbC123"); - let others = [".tmpAbC1234", ".tmpAb-123", ".tmp"]; - for name in others { - fs::write(store.path(name), b"{").unwrap(); - } - fs::write(&staging, b"{").unwrap(); - let mut locked = store.lock(None).unwrap(); - // A launch begins without the staging file of an earlier one. - let mut launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - assert!(!staging.exists()); - // While a process of the launch may still hold its log, as its VMM does, the file stays. - let log = launch.take_log().unwrap(); - drop(launch); - fs::write(&staging, b"{").unwrap(); - let recovery = locked.recover().unwrap(); - assert!( - matches!(recovery, Recovery::Indeterminate(_)), - "{recovery:?}" - ); - assert!(staging.exists()); - // Once nothing holds the log, recovery ends the launch and removes the file. - drop(log); - assert!(released(|| locked.recover().unwrap() == Recovery::Exited)); - assert!(!staging.exists()); - // An abandoned launch removes it, and so does clearing the runtime of a VM that exited. - let launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - fs::write(&staging, b"{").unwrap(); - match launch.abandon().unwrap() { - Recovery::Exited => {} - // A process that a concurrent test spawned may still hold the log that the launch closed, - // until it executes its program, and the next recovery then ends the launch. - Recovery::Indeterminate(_) => { - assert!(staging.exists()); - assert!(released(|| locked.recover().unwrap() == Recovery::Exited)); - } - other => panic!("{other:?}"), - } - assert!(!staging.exists()); - fs::write(&staging, b"{").unwrap(); - locked.clear_runtime().unwrap(); - assert!(!staging.exists()); - for name in others { - assert!(store.path(name).exists(), "{name}"); - } -} - -#[test] -fn a_store_whose_directory_went_reports_it_missing_as_it_clears_a_runtime() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - // Something other than a removal deletes the directory while the lock is held. - fs::remove_dir_all(store.dir()).unwrap(); - let error = locked.clear_runtime().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::NotFound, "{error}"); -} - -#[test] -fn launch_identities_are_fresh_hexadecimal() { - let first = LaunchId::generate().unwrap(); - assert_eq!(first.as_str().len(), 32); - assert_ne!(first, LaunchId::generate().unwrap()); - assert!(LaunchId::try_from(first.to_string()).is_ok()); - for invalid in [ - String::new(), - "0123".to_owned(), - "A".repeat(32), - "g".repeat(32), - ] { - assert!(LaunchId::try_from(invalid.clone()).is_err(), "{invalid}"); - } -} - -#[test] -fn an_endpoint_server_counts_only_while_the_endpoint_names_it_once_held() { - /// Holds the endpoint server whose IDs `lookups` returns in turn, as a value that names its - /// ID, or `None` if `hold_exits`. - fn hold( - lookups: Vec>>, - hold_exits: bool, - ) -> std::result::Result<(u32, u32), String> { - let mut lookups = lookups.into_iter(); - vmm::hold_endpoint_server( - || { - lookups - .next() - .expect("the endpoint is looked up at most twice") - }, - |pid| Ok((!hold_exits).then_some(pid)), - ) - } - let failed = || Err(io::Error::other("lookup failed")); - - assert_eq!(hold(vec![Ok(Some(7)), Ok(Some(7))], false), Ok((7, 7))); - // The server exited after the first lookup, and another process reused its ID before it was - // held: once held, the endpoint names no process, or another one. - for again in [Ok(None), Ok(Some(8))] { - let reason = hold(vec![Ok(Some(7)), again], false).unwrap_err(); - assert!(reason.contains("stopped serving"), "{reason}"); - } - let reason = hold(vec![Ok(Some(7)), failed()], false).unwrap_err(); - assert!(reason.contains("cannot be examined again"), "{reason}"); - // The server exited before it was held, or no process serves the endpoint. - let reason = hold(vec![Ok(Some(7))], true).unwrap_err(); - assert!( - reason.contains("exited before it could be verified"), - "{reason}" - ); - let reason = hold(vec![Ok(None)], false).unwrap_err(); - assert!(reason.contains("no process serves"), "{reason}"); - let reason = hold(vec![failed()], false).unwrap_err(); - assert!(reason.contains("cannot be examined"), "{reason}"); -} diff --git a/nvx-rs/src/host/store/vmm.rs b/nvx-rs/src/host/store/vmm.rs deleted file mode 100644 index f4a66918c..000000000 --- a/nvx-rs/src/host/store/vmm.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! The VMM processes of the store's launches, which recovery holds through verified process -//! references before it signals one. - -use std::io; -use std::time::Instant; - -use crate::host::platform; -use crate::host::process::{BootId, KILL_TIMEOUT, Opened, Process, ProcessRef}; - -/// Finds the live process `pid` of the current boot and holds it, or returns `None` if it has -/// exited. -/// -/// A process that reuses the ID meanwhile does not have the start time that this read, so it is -/// not held. -pub(super) fn find(pid: u32) -> io::Result> { - let Some(start_time) = platform::process_start_time(pid)? else { - return Ok(None); - }; - let opened = Process::open(&ProcessRef { - pid, - start_time, - boot: BootId::current()?, - })?; - Ok(match opened { - Opened::Running(process) => Some(process), - Opened::Exited(_) => None, - }) -} - -/// Returns whether `process`, which serves the control endpoint of a launch whose log is the file -/// `log`, is that launch's VMM. -pub(super) fn serves_launch(process: &Process, log: (u64, u64)) -> io::Result { - let reference = process.reference(); - platform::serves_launch(reference.pid, reference.start_time, log) -} - -/// Terminates `process` through its handle, and returns whether it exited within -/// [`KILL_TIMEOUT`], the wait that the termination of other OpenVMM processes allows. -pub(super) fn terminate(process: &Process) -> io::Result { - process.kill()?; - process.wait_bool(Instant::now() + KILL_TIMEOUT) -} - -/// Holds the process that serves a launch's control endpoint, whose ID `server_pid` returns, with -/// `hold`, which [`find`] is outside tests, and returns its ID and the held process, or why it -/// cannot be held. -/// -/// The endpoint is examined again once the process is held. A held process keeps its ID on -/// Windows, so an endpoint that still names the ID names the held process, and not another that -/// reused the ID after the first lookup, before it was held. On Linux, [`serves_launch`] then -/// examines the held process itself. -pub(super) fn hold_endpoint_server

( - mut server_pid: impl FnMut() -> io::Result>, - hold: impl FnOnce(u32) -> io::Result>, -) -> Result<(u32, P), String> { - let pid = match server_pid() { - Ok(Some(pid)) => pid, - Ok(None) => { - return Err( - "a process holds its log, and no process serves its control endpoint yet" - .to_owned(), - ); - } - Err(error) => { - return Err(format!( - "a process holds its log, and its control endpoint cannot be examined: {error}" - )); - } - }; - let held = match hold(pid) { - Ok(Some(held)) => held, - Ok(None) => { - return Err(format!( - "process {pid}, which served its control endpoint, exited before it could be \ - verified" - )); - } - Err(error) => { - return Err(format!( - "process {pid}, which serves its control endpoint, cannot be held: {error}" - )); - } - }; - match server_pid() { - Ok(Some(again)) if again == pid => Ok((pid, held)), - Ok(_) => Err(format!( - "process {pid} stopped serving its control endpoint while it was held" - )), - Err(error) => Err(format!( - "process {pid} was held, and its control endpoint cannot be examined again: {error}" - )), - } -} diff --git a/nvx-rs/src/host/supervisor/launch.rs b/nvx-rs/src/host/supervisor/launch.rs deleted file mode 100644 index 9377e28f5..000000000 --- a/nvx-rs/src/host/supervisor/launch.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! The launches of a sandbox's VMM through the store's launch journal, and the starting launches -//! that their callers commit or abandon. - -use super::vmm::Vmm; -use crate::error::{Error, Result}; -use crate::host::process::Process; -use crate::host::spawn::{Launched, VmmCommand, spawn}; -use crate::host::store::{ - Launch, LaunchId, LaunchJournal, LockedStore, Recovery, RuntimeIdentity, SandboxStore, -}; -use crate::protocol::CAPABILITY_LEN; - -/// Launches a VMM in the sandbox of `locked`, through the store's launch journal, as the launch -/// `reserved`, which the caller reserved with [`LaunchId::generate`], under the runtime identity -/// `runtime`, with `capability` on its standard input. -/// -/// It begins the launch with [`LockedStore::begin_launch`], asks `command` for the VMM's command, -/// given the control endpoint that the launch chose, [`spawn`]s the VMM with the launch's claimed -/// log, and records its identity right after with [`Launch::record_process`]. The caller then -/// checks that the VMM serves its endpoint, through its own transport, and commits or abandons the -/// returned [`Starting`]. -/// -/// A step that fails ends the launch as [`Launch::abandon`] ends it, after it has ended a VMM whose -/// identity it could not record through its handle, and returns the step's error. A launch that -/// cannot be ended then, because its VMM did not exit, keeps its journal for -/// [`LockedStore::recover`], and the error says so. Fails as [`LockedStore::begin_launch`] does if -/// the sandbox's VM runs, an interrupted launch has not been recovered, or the sandbox holds a -/// lease. -pub fn launch<'s, 'h>( - locked: &'s mut LockedStore, - reserved: &LaunchId, - runtime: &RuntimeIdentity, - capability: &[u8; CAPABILITY_LEN], - command: impl FnOnce(&str) -> Result>, -) -> Result> { - launch_reporting( - locked, - reserved, - runtime, - capability, - command, - |_, _| Ok(()), - ) - .map_err(|failure| failure.error) -} - -/// How a launch of [`launch_reporting`] failed. -#[derive(Debug)] -pub(crate) struct LaunchFailure { - /// The error of the step that failed, which says why the launch could not be ended if it - /// could not. - pub(crate) error: Error, -} - -/// Launches a VMM as [`launch`] does, and tells how a launch that fails ended, as a -/// [`LaunchFailure`]. A launch that began, and fails, calls `failing` with the step's error, and -/// whether it spawned the VMM, before it ends the launch, so that the caller may record the -/// failure before anything of the launch ends. If `failing` fails, the launch ends a VMM that it -/// spawned through the VMM's handle, as always, but keeps its journal, as a crash would leave it, -/// for [`LockedStore::recover`]: the journal then still tells that the launch began, though the -/// caller could not record its failure. -pub(crate) fn launch_reporting<'s, 'h>( - locked: &'s mut LockedStore, - reserved: &LaunchId, - runtime: &RuntimeIdentity, - capability: &[u8; CAPABILITY_LEN], - command: impl FnOnce(&str) -> Result>, - failing: impl FnOnce(&Error, bool) -> Result<()>, -) -> std::result::Result, LaunchFailure> { - let store = SandboxStore::clone(locked); - // A launch that does not begin leaves nothing to end. - let mut launch = locked - .begin_launch(reserved, runtime, capability) - .map_err(|error| LaunchFailure { error })?; - let command = match command(launch.endpoint()) { - Ok(command) => command, - Err(error) => { - let recorded = failing(&error, false); - return Err(abandon_after(launch, error, recorded)); - } - }; - let log = match launch.take_log() { - Ok(log) => log, - Err(error) => { - let recorded = failing(&error, false); - return Err(abandon_after(launch, error, recorded)); - } - }; - let child = match spawn(&command, capability, log) { - Ok(child) => child, - Err(error) => { - let error = Error::io(format!("cannot launch {}", command.program.display())) - .with_source(error); - let recorded = failing(&error, false); - return Err(abandon_after(launch, error, recorded)); - } - }; - let process = match launch.record_process(&child) { - Ok(process) => process, - Err(error) => { - let recorded = failing(&error, true); - // An unrecorded VMM holds the claim of its launch, so only its handle can end it, - // before the launch ends. - child.kill_and_wait(); - return Err(abandon_after(launch, error, recorded)); - } - }; - Ok(Starting { - inner: Some(StartingInner { - launch, - child, - process, - }), - store, - }) -} - -/// Ends `launch`, which failed with `error`, once its caller `recorded` the failure, and returns -/// how it failed, whose error says why the launch was not ended if it was not: its journal then -/// remains for [`LockedStore::recover`]. A launch whose caller could not record its failure keeps -/// its journal, as a crash would leave it, and ends no more of it than its caller did. -fn abandon_after(launch: Launch<'_>, error: Error, recorded: Result<()>) -> LaunchFailure { - let reason = match recorded { - Err(unrecorded) => { - drop(launch); - unrecorded.to_string() - } - Ok(()) => match launch.abandon() { - Ok(Recovery::Indeterminate(reason)) => reason, - Ok(_) => return LaunchFailure { error }, - Err(failure) => failure.to_string(), - }, - }; - let (kind, message, source) = error.into_parts(); - let error = Error::new( - kind, - format!("{message}; the launch could not be ended, and remains for recovery: {reason}"), - ); - LaunchFailure { - error: match source { - Some(source) => error.with_source(source), - None => error, - }, - } -} - -/// A launch whose VMM runs, and whose journal records the VMM's identity, until the caller -/// [commits](Self::commit) it, once the VMM serves its control endpoint, or -/// [abandons](Self::abandon) it. -/// -/// It borrows the sandbox's locked store exclusively, as the store's [`Launch`] does, so no other -/// lifecycle transition of the sandbox overlaps it. Dropping it without committing it abandons the -/// launch, as [`abandon`](Self::abandon) does, ignoring failures; a crash of the caller leaves the -/// launch's journal instead, for [`LockedStore::recover`]. -#[derive(Debug)] -pub struct Starting<'s> { - /// The launch and its VMM, until the launch is committed or abandoned. - inner: Option>, - /// The sandbox's store. - store: SandboxStore, -} - -/// The launch of a [`Starting`], and its VMM. -#[derive(Debug)] -struct StartingInner<'s> { - /// The store's launch, whose journal records the VMM. - launch: Launch<'s>, - /// The VMM's child, which the launch has not released. - child: Launched, - /// The VMM, held through its verified handle. - process: Process, -} - -impl StartingInner<'_> { - /// Ends the launch as [`Launch::abandon`] ends it, and releases its VMM's child. - fn abandon(self) -> Result { - let Self { - launch, - child, - process: _, - } = self; - let recovery = launch.abandon(); - match &recovery { - Ok(Recovery::Clean | Recovery::Exited | Recovery::Terminated(_)) => child.detach(), - // The VMM may still run: its handle ends it, and the journal that remains then shows - // that it exited. - Ok(Recovery::Indeterminate(_)) | Err(_) => { - child.kill_and_wait(); - } - } - recovery - } -} - -impl<'s> Starting<'s> { - /// Returns the launch and its VMM. - fn inner(&self) -> &StartingInner<'s> { - self.inner - .as_ref() - .expect("a launch is in progress until it is committed or abandoned") - } - - /// Returns the control endpoint that the launch chose, on which the VMM listens. - pub fn endpoint(&self) -> &str { - self.inner().launch.endpoint() - } - - /// Returns the launch's journal, which records the VMM's identity. - pub fn journal(&self) -> &LaunchJournal { - self.inner().launch.journal() - } - - /// Returns the VMM, held through its verified handle, through which the caller may wait for - /// it, for example while it checks that the VMM serves its endpoint. - pub fn process(&self) -> &Process { - &self.inner().process - } - - /// Commits the launch of a VMM that serves its control endpoint: writes its runtime record, - /// as [`Launch::commit`] does, releases the VMM's child, which keeps running, and returns the - /// VM. - /// - /// Fails if the runtime record cannot be written. The VMM is then ended through its handle, - /// and the journal that remains shows [`LockedStore::recover`] that it exited. - pub fn commit(self) -> Result { - self.commit_reporting(|_| {}) - } - - /// Commits the launch as [`commit`](Self::commit) does, and calls `failing` with the error of - /// a commit that fails, before it ends the VMM, so that the caller may record the failure - /// first. The journal remains all the same, whether or not the caller could. - pub(crate) fn commit_reporting(mut self, failing: impl FnOnce(&Error)) -> Result { - let StartingInner { - launch, - child, - process, - } = self.inner.take().expect("a launch commits once"); - match launch.commit() { - Ok(record) => { - child.detach(); - Ok(Vmm { - store: self.store.clone(), - record, - process, - }) - } - Err(error) => { - failing(&error); - // No runtime record names the VM, so its VMM must not run on. - child.kill_and_wait(); - Err(error) - } - } - } - - /// Ends a launch that failed, as [`Launch::abandon`] does: terminates its verified VMM, and - /// removes the launch's files once nothing of it runs. - /// - /// A launch that it cannot end, which [`Recovery::Indeterminate`] reports, keeps its journal - /// for [`LockedStore::recover`], after its VMM is ended through its handle. - pub fn abandon(mut self) -> Result { - self.inner - .take() - .expect("a launch is abandoned once") - .abandon() - } -} - -impl Drop for Starting<'_> { - fn drop(&mut self) { - if let Some(inner) = self.inner.take() { - let _ = inner.abandon(); - } - } -} diff --git a/nvx-rs/src/host/supervisor/mod.rs b/nvx-rs/src/host/supervisor/mod.rs index 9e1278c34..0ee278b5c 100644 --- a/nvx-rs/src/host/supervisor/mod.rs +++ b/nvx-rs/src/host/supervisor/mod.rs @@ -1,77 +1,21 @@ -//! The supervisor of a sandbox's VMM, beneath the CLI, [`instance`](super::instance), and -//! external orchestrators: it launches OpenVMM through the [`store`](super::store)'s launch -//! journal, passing it only the descriptors or handles that the caller allows, waits for its exit -//! by event, stops it, and reattaches to it after the orchestrator restarts within the same host -//! boot. -//! -//! Callers keep the configuration, the launch planning, the authorization, and the application -//! protocol. The supervisor owns no control session: the caller checks that a launch is ready, and -//! asks a guest to shut down, through its own transport. -//! -//! # Launches -//! -//! [`launch`] runs the store's launch steps under the sandbox's lock, as a launch whose identity -//! the caller reserved beforehand, with [`LaunchId::generate`]: -//! -//! 1. [`LockedStore::begin_launch`] writes the journal's marker, which names the launch, and the -//! caller's `command` builds the VMM's [`VmmCommand`] with the control endpoint that the -//! launch chose. -//! 2. [`spawn`] starts the VMM with the launch's claimed log as its standard output and error, and -//! [`Launch::record_process`] records its identity right after. -//! 3. The caller checks, through its own transport, that the VMM serves its control endpoint, and -//! anything else that it requires of a launch, then [`Starting::commit`]s it, which writes the -//! runtime record, or [`Starting::abandon`]s it. -//! -//! A launch that fails at any step ends as [`Launch::abandon`] ends it, and never leaves an -//! unrecorded VMM running: a VMM whose identity is not recorded is ended through its handle first. -//! -//! A caller launching an owned [`crate::host::vm::VmmCommand`] without a store uses [`spawn_vm`], -//! which refuses an untrusted Windows log-job name rather than taking [`spawn`]'s fallback -//! without a job. -//! -//! ```no_run -//! use std::time::{Duration, Instant}; -//! -//! use nvx::host::launch::LaunchSpec; -//! use nvx::host::store::{LaunchId, RuntimeIdentity, SandboxStore}; -//! use nvx::host::supervisor::{self, Reattached, StopOutcome, Vmm, VmmCommand}; -//! use nvx::protocol::CAPABILITY_LEN; -//! -//! /// Finds the sandbox's VM again after a restart of the orchestrator, or launches it. -//! fn run( -//! store: &SandboxStore, -//! spec: &LaunchSpec, -//! runtime: &RuntimeIdentity, -//! capability: &[u8; CAPABILITY_LEN], -//! ) -> nvx::Result { -//! let mut locked = store.lock(Some(Instant::now() + Duration::from_secs(10)))?; -//! match supervisor::reattach(&mut locked, runtime)? { -//! Reattached::Live(vmm) => return Ok(vmm), -//! Reattached::Exited { .. } => {} -//! // Other artifacts or contracts started the VM: end it through its verified handle, -//! // without a graceful step that it might not understand. -//! Reattached::Mismatched { vmm, .. } => { -//! if vmm.terminate(&mut locked, Duration::from_secs(10))? == StopOutcome::TimedOut { -//! return Err(nvx::Error::timed_out("the VM of another runtime still runs")); -//! } -//! } -//! Reattached::Indeterminate(reason) => return Err(nvx::Error::other(reason)), -//! } -//! let launch = LaunchId::generate()?; -//! let starting = supervisor::launch(&mut locked, &launch, runtime, capability, |endpoint| { -//! let arguments = spec.openvmm_arguments(endpoint, &store.outcome_path())?; -//! Ok(VmmCommand::new(&spec.openvmm, arguments, store.dir())) -//! })?; -//! // Connect to `starting.endpoint()` through the caller's own transport, then commit once -//! // the VMM serves it, or else abandon the launch. -//! starting.commit() -//! } -//! ``` +//! The transitional public path of the low-level spawn of a VMM, which code that stays still uses +//! until SL6.3 (#1122): [`spawn`] and [`spawn_vm`], which start a VMM detached, the [`VmmCommand`] +//! that they start and the [`Launched`] VMM that they return, [`Inherited`], which allows the VMM +//! a descriptor (Linux) or handle (Windows) of the caller's, and [`spawn_command`], with which a +//! caller that spawns VMMs spawns its other processes. +//! +//! The module has no code of its own, and supervises nothing: it re-exports these items from the +//! crate's private spawn and platform modules. [`host::vm`](crate::host::vm) creates, spawns, +//! reattaches to, and stops a VM in a private directory that its caller owns. SL6.3 deletes +//! `spawn_command` and the checked-file handoff of +//! [`PreparedLaunch`](crate::host::sandbox::PreparedLaunch), and settles the launcher of the +//! `external-launch` example: the last users of this path, besides the tests of the spawn in +//! `tests/supervisor.rs`. //! //! # Inheritance //! -//! The VMM receives its capability on its standard input, its claimed log as its standard output -//! and error, and the descriptors (Linux) or handles (Windows) that the caller names in +//! The VMM receives its capability on its standard input, its log as its standard output and +//! error, and the descriptors (Linux) or handles (Windows) that the caller names in //! [`VmmCommand::inherit`], under the numbers that they have in the caller, and nothing else of //! the caller's. On Linux, every other descriptor stays close-on-exec, and only the VMM's own //! process clears the flag of the allowed ones, so a process that another thread spawns meanwhile @@ -82,67 +26,9 @@ //! that another thread of the caller creates in that window without a list of handles, as //! `std::process::Command` creates one, inherits them all, and holds them until it exits: it could //! read the VMM's capability before the VMM does, and it holds the VMM's log, and the handles that -//! the VMM is allowed. It holds no claim of the VMM's launch, though: [`spawn`] starts the VMM in a -//! job object of its own, named after its log, whose processes decide the claim while such a -//! process holds the log, so a recovery still ends the launch once the VMM exits; see -//! [the store](crate::host::store#the-log-claim). Each spawn of a VMM holds a [`SpawnLock`] -//! throughout that window, so a caller that launches VMMs spawns its other processes with -//! [`spawn_command`], or under a lock of its own from [`lock_spawns`], and keeps them out of it. On -//! Linux, the lock holds nothing: a process that another thread spawns holds the VMM's log only -//! from its fork until its exec, and recovery checks a held claim again for long enough to see the -//! claim released. -//! -//! # Exit waits and stops -//! -//! A [`Vmm`] holds its VMM through a [`Process`]: a pidfd on Linux and a process handle on -//! Windows, whose exit event wakes waiters, whether or not this caller started the VMM. Linux -//! hosts without pidfds poll the VMM's identity instead. -//! -//! [`Vmm::stop`] runs one graceful step of the caller's, with a bounded deadline, and takes a -//! bounded forced step against the VMM's verified identity only when the caller requests it. -//! [`Vmm::terminate`] takes that forced step alone, with no graceful step: it ends a VM whose -//! guest the caller cannot ask to shut down, such as one that another runtime identity started. -//! -//! A stop decides by its own check whether the VMM still runs as it begins. [`Vmm::stop`] ends a -//! VM whose VMM had exited by then as one that it stopped, and clears the runtime record of its -//! launch. [`Vmm::stop_unless_exited`] stops nothing then, and keeps the record, so that -//! [`reattach`] finds that the VM exited without its holder, as a caller that tells how the VM -//! ended needs: a holder that checked whether the VMM had exited, then stopped the VM, would -//! otherwise attribute to its stop a VMM that exited in between. -//! -//! # Reattach -//! -//! After an orchestrator restarts within the same host boot, [`reattach`] ends an interrupted -//! launch with [`LockedStore::recover`], compares the runtime identity of the recorded VM with the -//! caller's own, with [`RuntimeRecord::check_identity`], and holds the VMM with -//! [`Process::open`]. A record from an earlier boot names a VMM that has exited. A VM of another -//! runtime identity is ended with [`Vmm::terminate`], never through the caller's own protocol, -//! which its guest might not speak. -//! -//! The supervisor signals only a process that it holds through a verified identity: the child -//! that it has not released, a [`Process`] that matched its recorded reference, or, during the -//! recovery of a launch that did not record its VMM, the process that recovery verified to be that -//! VMM. It never signals a process that reuses a recorded ID. -//! -//! [`launch`]: fn@crate::host::supervisor::launch -//! [`LaunchId::generate`]: crate::host::store::LaunchId::generate -//! [`LockedStore::begin_launch`]: crate::host::store::LockedStore::begin_launch -//! [`Launch::record_process`]: crate::host::store::Launch::record_process -//! [`Launch::abandon`]: crate::host::store::Launch::abandon -//! [`Process`]: crate::host::process::Process -//! [`reattach`]: fn@crate::host::supervisor::reattach -//! [`LockedStore::recover`]: crate::host::store::LockedStore::recover -//! [`RuntimeRecord::check_identity`]: crate::host::store::RuntimeRecord::check_identity -//! [`Process::open`]: crate::host::process::Process::open - -mod launch; -mod reattach; -#[cfg(test)] -mod tests; -mod vmm; +//! the VMM is allowed. Each spawn of a VMM holds the crate's spawn lock throughout that window, so +//! a caller that launches VMMs spawns its other processes with [`spawn_command`], which takes the +//! same lock, and keeps them out of it. On Linux, the lock holds nothing. -pub use self::launch::{Starting, launch}; -pub use self::reattach::{Reattached, reattach}; -pub use self::vmm::{StopOutcome, Stopped, Vmm}; -pub use super::platform::inherit::{Inherited, SpawnLock, lock_spawns, spawn_command}; +pub use super::platform::inherit::{Inherited, spawn_command}; pub use super::spawn::{Launched, VmmCommand, spawn, spawn_vm}; diff --git a/nvx-rs/src/host/supervisor/reattach.rs b/nvx-rs/src/host/supervisor/reattach.rs deleted file mode 100644 index c04037bf9..000000000 --- a/nvx-rs/src/host/supervisor/reattach.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! The reattaches to the VM of a sandbox after its orchestrator restarts within the same host -//! boot. - -use std::io; - -use super::vmm::Vmm; -use crate::error::Result; -use crate::host::process::{Opened, Process, ProcessRef}; -use crate::host::store::{ - IdentityCheck, IdentityPart, LockedStore, Recovery, RuntimeIdentity, RuntimeRecord, - SandboxStore, -}; - -/// What [`reattach`] found of the VM of a sandbox. -#[derive(Debug)] -pub enum Reattached { - /// The recorded VMM runs, and was launched with the caller's runtime identity: the caller may - /// keep driving the VM, which it now holds. - Live(Vmm), - /// The recorded VMM runs, but was launched with another runtime identity, which would drive - /// the VM through artifacts or contracts that did not start it: the caller must end it with - /// [`Vmm::terminate`], which signals only the VMM that the [`Vmm`] holds by its verified - /// identity, and asks its guest nothing. - Mismatched { - /// The VM, which the caller must end. - vmm: Vmm, - /// The parts of the runtime identity that differ, as - /// [`RuntimeIdentity::differences`] returns them. - differences: Vec, - }, - /// No VMM of the sandbox runs: none was recorded, or the recorded one exited, as one that a - /// record of an earlier boot names always has. The runtime record of a VMM that exited is - /// cleared. - Exited { - /// The runtime record of the VMM that exited, which reattach cleared, if one was recorded. - record: Option, - /// How [`LockedStore::recover`] ended an interrupted launch: [`Recovery::Clean`] if none - /// was interrupted. - recovery: Recovery, - }, - /// Whether a VMM of the sandbox runs cannot be determined, for the reason that this gives: - /// an interrupted launch cannot be ended yet, or the recorded VMM cannot be examined. The - /// runtime record, and the journal of an interrupted launch, remain for a later reattach, - /// which may succeed. - /// - /// Reattach signals no process that it has not verified, but it is not free of effects: - /// [`LockedStore::recover`] may have terminated a verified VMM of the interrupted launch that - /// has not exited yet, and removes a journal that the runtime record of its committed launch - /// supersedes. - Indeterminate(String), -} - -/// Reattaches to the VM of the sandbox of `locked` after the orchestrator restarts within the -/// same host boot, under the runtime identity `runtime`, which the caller would launch now. -/// -/// It ends an interrupted launch with [`LockedStore::recover`], which signals only a VMM that it -/// verified, reads the runtime record, holds the recorded VMM with [`Process::open`], which -/// verifies its process ID, its start time, and the host boot, and compares the record's runtime -/// identity with `runtime`, as [`RuntimeRecord::check_identity`] does; see [`Reattached`]. A -/// record from an earlier boot names a VMM that has exited. Beyond that recovery, reattach -/// signals nothing: a VM that it reports as live or mismatched keeps running. -/// -/// Reattach does not replay the VM's start or its mappings, restore streams that the restart -/// lost, or resolve images: the caller's own protocol resumes through the recorded endpoint, if it -/// can. Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for an invalid -/// `runtime`, and as [`LockedStore::recover`] does for inconsistent records. -pub fn reattach(locked: &mut LockedStore, runtime: &RuntimeIdentity) -> Result { - reattach_reporting(locked, runtime, |_| Ok(())) -} - -/// Reattaches as [`reattach`] does, and calls `exited` with the runtime record of a VMM that it -/// found exited, before it clears the record, so that the caller may record what ended the VM -/// while the sandbox still names it, as a clone's reattach does. Fails, and keeps the record, if -/// `exited` fails. -pub(crate) fn reattach_reporting( - locked: &mut LockedStore, - runtime: &RuntimeIdentity, - exited: impl FnOnce(&RuntimeRecord) -> Result<()>, -) -> Result { - reattach_with( - locked, - runtime, - |reference| { - Ok(match Process::open(reference)? { - Opened::Running(process) => Some(process), - Opened::Exited(_) => None, - }) - }, - exited, - ) -} - -/// Reattaches as [`reattach_reporting`] does, holding the recorded VMM with `open`, which is -/// [`Process::open`] outside tests. -pub(super) fn reattach_with( - locked: &mut LockedStore, - runtime: &RuntimeIdentity, - open: impl FnOnce(&ProcessRef) -> io::Result>, - exited: impl FnOnce(&RuntimeRecord) -> Result<()>, -) -> Result { - runtime.check()?; - let recovery = locked.recover()?; - if let Recovery::Indeterminate(reason) = recovery { - return Ok(Reattached::Indeterminate(format!( - "an interrupted launch of the sandbox cannot be ended yet: {reason}" - ))); - } - let Some(record) = locked.runtime()? else { - return Ok(Reattached::Exited { - record: None, - recovery, - }); - }; - let pid = record.process().pid; - let process = match open(record.process()) { - Ok(Some(process)) => process, - Ok(None) => { - exited(&record)?; - locked.clear_runtime_of(Some(&record))?; - return Ok(Reattached::Exited { - record: Some(record), - recovery, - }); - } - Err(error) => { - return Ok(Reattached::Indeterminate(format!( - "the recorded VMM process {pid} cannot be examined: {error}" - ))); - } - }; - let check = record.check_identity(runtime); - let vmm = Vmm { - store: SandboxStore::clone(locked), - record, - process, - }; - Ok(match check { - IdentityCheck::Matches => Reattached::Live(vmm), - IdentityCheck::Mismatched(differences) => Reattached::Mismatched { vmm, differences }, - }) -} diff --git a/nvx-rs/src/host/supervisor/tests.rs b/nvx-rs/src/host/supervisor/tests.rs deleted file mode 100644 index 7080032b8..000000000 --- a/nvx-rs/src/host/supervisor/tests.rs +++ /dev/null @@ -1,766 +0,0 @@ -//! Tests of the supervisor's stop and reattach decisions, and of launches that fail before their -//! VMM runs. Real VMMs are launched, stopped, and reattached to in `tests/supervisor.rs`. - -use std::cell::{Cell, RefCell}; -use std::fs; -use std::io; -use std::time::{Duration, Instant}; - -use serde_json::json; - -use super::launch::launch_reporting; -use super::reattach::reattach_with; -use super::vmm::{Stoppable, stop_process, terminate_process}; -use super::*; -use crate::ErrorKind; -use crate::error::{Error, Result}; -use crate::host::launch::MAX_TIMEOUT; -use crate::host::platform; -use crate::host::process::{BootId, Process, ProcessRef}; -use crate::host::store::{ - ArtifactIdentity, FORMAT, IdentityPart, JOURNAL_NAME, LaunchId, RUNTIME_NAME, Recovery, - RuntimeIdentity, SandboxStore, -}; -use crate::protocol::CAPABILITY_LEN; - -/// A launch capability. -const CAPABILITY: [u8; CAPABILITY_LEN] = [7; CAPABILITY_LEN]; -/// The launch of the runtime records that [`record`] writes. -const RECORDED_LAUNCH: &str = "5d41402abc4b2a76b9719d911017c592"; - -/// Returns the identity `sha256:` and 64 copies of `digit`. -fn artifact(digit: char) -> ArtifactIdentity { - ArtifactIdentity::sha256(&digit.to_string().repeat(64)).unwrap() -} - -/// Returns the runtime identity of these tests' VMs. -fn runtime() -> RuntimeIdentity { - RuntimeIdentity::new(artifact('a'), artifact('b'), artifact('c')) -} - -/// Returns a store in a fresh temporary directory. -fn store() -> (tempfile::TempDir, SandboxStore) { - let directory = tempfile::tempdir().unwrap(); - let store = SandboxStore::create(directory.path().join("sandbox")).unwrap(); - (directory, store) -} - -/// Returns the reference of this test process, which stands for a running VMM that reattach -/// holds but never signals. -fn this_process() -> ProcessRef { - let pid = std::process::id(); - ProcessRef { - pid, - start_time: platform::process_start_time(pid).unwrap().unwrap(), - boot: BootId::current().unwrap(), - } -} - -/// Writes the runtime record of a VM whose VMM is `process`, launched with `runtime`, as another -/// orchestrator would have committed it. -fn record(store: &SandboxStore, process: &ProcessRef, runtime: &RuntimeIdentity) { - record_launch(store, RECORDED_LAUNCH, process, runtime); -} - -/// Writes the runtime record of the launch `launch` of a VM whose VMM is `process`, launched with -/// `runtime`, as another orchestrator would have committed it. -fn record_launch( - store: &SandboxStore, - launch: &str, - process: &ProcessRef, - runtime: &RuntimeIdentity, -) { - let record = json!({ - "format": FORMAT, - "launch": launch, - "process": process, - "endpoint": "/var/lib/sandboxes/example/control.sock", - "runtime": runtime, - }); - fs::write( - store.dir().join(RUNTIME_NAME), - serde_json::to_vec(&record).unwrap(), - ) - .unwrap(); -} - -/// A VMM whose state drives a stop's decisions without a process: it exits once the graceful -/// step runs, if it heeds that step, or once it is killed, if killing ends it. -#[derive(Default)] -struct Scripted { - /// Whether the VMM has exited. - exited: Cell, - /// Whether the graceful step ends the VMM, even one that fails. - heeds_graceful: bool, - /// Whether killing the VMM ends it. - dies_when_killed: bool, - /// Whether checks of the VMM's state fail. - unreadable: bool, - /// Whether killing the VMM fails. - unkillable: bool, - /// The operations of the stop, in order. - operations: RefCell>, -} - -impl Scripted { - /// Records the operation `name`. - fn note(&self, name: &'static str) { - self.operations.borrow_mut().push(name); - } - - /// Returns whether the VMM has exited, or fails if its state is unreadable. - fn state(&self) -> io::Result { - if self.unreadable { - Err(io::Error::other("unreadable")) - } else { - Ok(self.exited.get()) - } - } - - /// Stops the VMM with `forced`, and with a graceful step that fails with `graceful_error` if - /// it is given, unless it had exited, as [`Vmm::stop_unless_exited`] does. - fn stop_unless_exited( - &self, - forced: Option, - graceful_error: Option<&'static str>, - ) -> Result>> { - let deadline = Instant::now() + Duration::from_secs(60); - stop_process(self, 7, deadline, forced, || { - self.note("graceful"); - if self.heeds_graceful { - self.exited.set(true); - } - graceful_error.map_or(Ok(()), Err) - }) - } - - /// Stops the VMM, which runs as the stop begins, as - /// [`stop_unless_exited`](Self::stop_unless_exited) does. - fn stop( - &self, - forced: Option, - graceful_error: Option<&'static str>, - ) -> Result> { - let stopped = self.stop_unless_exited(forced, graceful_error)?; - Ok(stopped.expect("the VMM had exited before the stop began")) - } - - /// Returns the operations of the stop. - fn operations(&self) -> Vec<&'static str> { - self.operations.borrow().clone() - } - - /// Terminates the VMM, as [`Vmm::terminate`] does. - fn terminate(&self) -> Result { - terminate_process(self, 7, Duration::from_secs(10)) - } -} - -impl Stoppable for Scripted { - fn has_exited(&self) -> io::Result { - self.note("check"); - self.state() - } - - fn wait(&self, _deadline: Instant) -> io::Result { - self.note("wait"); - self.state() - } - - fn kill(&self) -> io::Result<()> { - self.note("kill"); - if self.unkillable { - return Err(io::Error::from(io::ErrorKind::Unsupported)); - } - if self.dies_when_killed { - self.exited.set(true); - } - Ok(()) - } -} - -#[test] -fn a_vmm_that_heeds_the_graceful_step_stops_gracefully() { - let vmm = Scripted { - heeds_graceful: true, - dies_when_killed: true, - ..Scripted::default() - }; - let stopped = vmm.stop(Some(Duration::from_secs(10)), None).unwrap(); - assert_eq!(stopped.outcome, StopOutcome::Graceful); - assert!(stopped.graceful_error.is_none()); - // The forced step that the caller requested is not taken. - assert_eq!(vmm.operations(), ["check", "graceful", "wait"]); -} - -#[test] -fn without_the_forced_step_a_stop_times_out_and_never_signals() { - let vmm = Scripted { - dies_when_killed: true, - ..Scripted::default() - }; - let stopped = vmm.stop(None, None).unwrap(); - assert_eq!(stopped.outcome, StopOutcome::TimedOut); - assert_eq!(vmm.operations(), ["check", "graceful", "wait"]); - assert!(!vmm.exited.get()); -} - -#[test] -fn a_requested_forced_step_that_ends_the_vmm_reports_forced() { - let vmm = Scripted { - dies_when_killed: true, - ..Scripted::default() - }; - let stopped = vmm.stop(Some(Duration::from_secs(10)), None).unwrap(); - assert_eq!(stopped.outcome, StopOutcome::Forced); - assert_eq!( - vmm.operations(), - ["check", "graceful", "wait", "kill", "wait"] - ); - - // A VMM that outlives the forced step's bound still runs. - let survivor = Scripted::default(); - let stopped = survivor.stop(Some(Duration::from_secs(10)), None).unwrap(); - assert_eq!(stopped.outcome, StopOutcome::TimedOut); - assert_eq!( - survivor.operations(), - ["check", "graceful", "wait", "kill", "wait"] - ); -} - -#[test] -fn a_failed_graceful_step_is_kept_and_not_waited_out() { - // The step may not have reached the guest, so only one check follows it. - let vmm = Scripted::default(); - let stopped = vmm.stop(None, Some("unreachable")).unwrap(); - assert_eq!(stopped.outcome, StopOutcome::TimedOut); - assert_eq!(stopped.graceful_error, Some("unreachable")); - assert_eq!(vmm.operations(), ["check", "graceful", "check"]); - - let forced = Scripted { - dies_when_killed: true, - ..Scripted::default() - }; - let stopped = forced - .stop(Some(Duration::from_secs(10)), Some("unreachable")) - .unwrap(); - assert_eq!(stopped.outcome, StopOutcome::Forced); - assert_eq!(stopped.graceful_error, Some("unreachable")); - assert_eq!( - forced.operations(), - ["check", "graceful", "check", "kill", "wait"] - ); - - // A VMM that exited anyway, as one that closed the caller's transport as it shut down, - // stopped without the forced step. - let exited = Scripted { - heeds_graceful: true, - dies_when_killed: true, - ..Scripted::default() - }; - let stopped = exited - .stop(Some(Duration::from_secs(10)), Some("closed")) - .unwrap(); - assert_eq!(stopped.outcome, StopOutcome::Graceful); - assert_eq!(stopped.graceful_error, Some("closed")); - assert_eq!(exited.operations(), ["check", "graceful", "check"]); -} - -#[test] -fn a_vmm_that_exited_already_skips_the_graceful_step() { - let vmm = Scripted::default(); - vmm.exited.set(true); - let stopped = vmm - .stop_unless_exited(Some(Duration::from_secs(10)), None) - .unwrap(); - assert!(stopped.is_none(), "{stopped:?}"); - assert_eq!(vmm.operations(), ["check"]); -} - -#[test] -fn undetermined_states_and_failed_terminations_are_errors() { - let unreadable = Scripted { - unreadable: true, - ..Scripted::default() - }; - let error = unreadable.stop(None, None).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!( - error.message(), - "cannot determine whether VMM process 7 exited" - ); - - // A host that cannot hold the VMM cannot take the forced step. - let unkillable = Scripted { - unkillable: true, - ..Scripted::default() - }; - let error = unkillable - .stop(Some(Duration::from_secs(10)), None) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!(error.message(), "cannot terminate VMM process 7"); -} - -#[test] -fn a_termination_takes_the_forced_step_alone() { - // No graceful step runs, even for a VMM that would heed one. - let vmm = Scripted { - heeds_graceful: true, - dies_when_killed: true, - ..Scripted::default() - }; - assert_eq!(vmm.terminate().unwrap(), StopOutcome::Forced); - assert_eq!(vmm.operations(), ["check", "kill", "wait"]); - - // A VMM that outlives the bound still runs. - let survivor = Scripted::default(); - assert_eq!(survivor.terminate().unwrap(), StopOutcome::TimedOut); - assert_eq!(survivor.operations(), ["check", "kill", "wait"]); - - // A VMM that exited already is not signaled. - let exited = Scripted::default(); - exited.exited.set(true); - assert_eq!(exited.terminate().unwrap(), StopOutcome::Graceful); - assert_eq!(exited.operations(), ["check"]); -} - -#[test] -fn terminations_whose_effect_cannot_be_determined_are_errors() { - // A VMM whose state cannot be read is never signaled. - let unreadable = Scripted { - unreadable: true, - dies_when_killed: true, - ..Scripted::default() - }; - let error = unreadable.terminate().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!( - error.message(), - "cannot determine whether VMM process 7 exited" - ); - assert_eq!(unreadable.operations(), ["check"]); - - let unkillable = Scripted { - unkillable: true, - ..Scripted::default() - }; - let error = unkillable.terminate().unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!(error.message(), "cannot terminate VMM process 7"); - assert_eq!(unkillable.operations(), ["check", "kill"]); -} - -#[test] -fn a_sandbox_without_a_record_has_no_vm() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let Reattached::Exited { record, recovery } = reattach(&mut locked, &runtime()).unwrap() else { - panic!("a VM was found in an empty store"); - }; - assert!(record.is_none()); - assert_eq!(recovery, Recovery::Clean); -} - -#[test] -fn a_live_vmm_of_the_same_runtime_is_held() { - let (_directory, store) = store(); - let process = this_process(); - record(&store, &process, &runtime()); - let mut locked = store.lock(None).unwrap(); - let Reattached::Live(vmm) = reattach(&mut locked, &runtime()).unwrap() else { - panic!("the recorded VMM was not reattached"); - }; - assert_eq!(vmm.record().process(), &process); - assert_eq!(vmm.process().reference(), &process); - assert_eq!(vmm.endpoint(), "/var/lib/sandboxes/example/control.sock"); - assert!(!vmm.has_exited().unwrap()); - // The record stays. - assert_eq!(store.runtime().unwrap().as_ref(), Some(vmm.record())); -} - -#[test] -fn a_live_vmm_of_another_runtime_is_mismatched() { - let (_directory, store) = store(); - let process = this_process(); - record(&store, &process, &runtime()); - let mut current = runtime(); - current.initramfs = artifact('d'); - let mut locked = store.lock(None).unwrap(); - let Reattached::Mismatched { vmm, differences } = reattach(&mut locked, ¤t).unwrap() - else { - panic!("a VMM of another runtime was not reported as mismatched"); - }; - assert_eq!(differences, [IdentityPart::Initramfs]); - assert_eq!(vmm.process().reference(), &process); - assert!(store.runtime().unwrap().is_some()); -} - -#[test] -fn records_of_exited_vmms_are_cleared() { - let (_directory, store) = store(); - let live = this_process(); - let mut earlier_boot = *live.boot.as_bytes(); - earlier_boot[0] ^= 1; - for exited in [ - // A record of an earlier boot names a VMM that has exited. - ProcessRef { - boot: BootId::from_bytes(earlier_boot), - ..live.clone() - }, - // The recorded ID now names another process: this one. - ProcessRef { - start_time: live.start_time + 1, - ..live.clone() - }, - ] { - record(&store, &exited, &runtime()); - let mut locked = store.lock(None).unwrap(); - let Reattached::Exited { record, recovery } = reattach(&mut locked, &runtime()).unwrap() - else { - panic!("{exited:?} names a live VMM"); - }; - assert_eq!(record.unwrap().process(), &exited); - assert_eq!(recovery, Recovery::Clean); - assert!(store.runtime().unwrap().is_none()); - } -} - -#[test] -fn a_vmm_that_cannot_be_examined_is_indeterminate_and_keeps_its_record() { - let (_directory, store) = store(); - record(&store, &this_process(), &runtime()); - let mut locked = store.lock(None).unwrap(); - let hidden = |_: &ProcessRef| -> io::Result> { - Err(io::Error::new(io::ErrorKind::PermissionDenied, "hidden")) - }; - let Reattached::Indeterminate(reason) = - reattach_with(&mut locked, &runtime(), hidden, |_| Ok(())).unwrap() - else { - panic!("a VMM that cannot be examined was classified"); - }; - assert!(reason.ends_with("cannot be examined: hidden"), "{reason}"); - assert!(store.runtime().unwrap().is_some()); -} - -#[test] -fn an_interrupted_launch_that_cannot_be_ended_is_indeterminate() { - let (_directory, store) = store(); - // The journal of a launch whose log is gone, so that its claim cannot be checked. - let journal = json!({ - "format": FORMAT, - "launch": "5d41402abc4b2a76b9719d911017c592", - "endpoint": "/var/lib/sandboxes/example/control.sock", - "runtime": runtime(), - "log": {"identity": {"device": 2049, "index": 393_219}}, - }); - fs::write( - store.dir().join(JOURNAL_NAME), - serde_json::to_vec(&journal).unwrap(), - ) - .unwrap(); - let mut locked = store.lock(None).unwrap(); - let Reattached::Indeterminate(reason) = reattach(&mut locked, &runtime()).unwrap() else { - panic!("an interrupted launch was ended without proof"); - }; - assert!( - reason.starts_with("an interrupted launch of the sandbox cannot be ended yet: "), - "{reason}" - ); - assert!(store.journal().unwrap().is_some()); -} - -#[test] -fn reattach_refuses_an_invalid_runtime() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let mut invalid = runtime(); - invalid.control_contract = "with space".to_owned(); - let error = reattach(&mut locked, &invalid).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); -} - -#[test] -fn stops_check_their_store_and_forced_bound_before_anything_else() { - let (_directory, store) = store(); - let (_other_directory, other) = self::store(); - record(&store, &this_process(), &runtime()); - let mut locked = store.lock(None).unwrap(); - let Reattached::Live(vmm) = reattach(&mut locked, &runtime()).unwrap() else { - panic!("the recorded VMM was not reattached"); - }; - let never = |_: &Vmm, _: Instant| -> std::result::Result<(), ()> { - panic!("the graceful step of an invalid stop ran") - }; - // This test process stands for the VMM, and none of these stops reaches it. - let error = vmm - .stop(&mut other.lock(None).unwrap(), Instant::now(), None, never) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Other); - let error = vmm - .stop_unless_exited(&mut other.lock(None).unwrap(), Instant::now(), None, never) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Other); - for bound in [Duration::ZERO, MAX_TIMEOUT + Duration::from_secs(1)] { - let error = vmm - .stop(&mut locked, Instant::now(), Some(bound), never) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{bound:?}"); - let error = vmm - .stop_unless_exited(&mut locked, Instant::now(), Some(bound), never) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{bound:?}"); - } - assert!(store.runtime().unwrap().is_some()); -} - -#[test] -fn a_launch_whose_command_fails_ends_before_anything_runs() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let error = launch( - &mut locked, - &LaunchId::generate().unwrap(), - &runtime(), - &CAPABILITY, - |endpoint| { - assert!(!endpoint.is_empty()); - Err(Error::unsupported("no command")) - }, - ) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported); - assert_eq!(error.message(), "no command"); - // The launch ended: its journal and capability are gone, and its fresh log stays. - assert!(store.journal().unwrap().is_none()); - assert!(store.read_capability().is_err()); - assert!(store.log_path().exists()); - assert_eq!(locked.recover().unwrap(), Recovery::Clean); -} - -#[test] -fn a_launch_whose_caller_cannot_record_its_failure_keeps_its_journal_for_recovery() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let failure = launch_reporting( - &mut locked, - &LaunchId::generate().unwrap(), - &runtime(), - &CAPABILITY, - |_| Err(Error::unsupported("no command")), - |error, spawned| { - assert_eq!((error.message(), spawned), ("no command", false)); - Err(Error::io("the test cannot record the failure")) - }, - ) - .unwrap_err(); - let reason = "the test cannot record the failure"; - assert_eq!(failure.error.kind(), ErrorKind::Unsupported); - assert_eq!( - failure.error.message(), - format!("no command; the launch could not be ended, and remains for recovery: {reason}") - ); - // The journal remains, as a crash leaves it, until a recovery ends the launch, of which - // nothing runs. - assert!(store.journal().unwrap().is_some()); - assert_eq!(locked.recover().unwrap(), Recovery::Exited); - assert!(store.journal().unwrap().is_none()); -} - -#[test] -fn a_reattach_whose_caller_cannot_record_an_exit_keeps_the_record_until_one_can() { - let (_directory, store) = store(); - record(&store, &this_process(), &runtime()); - let mut locked = store.lock(None).unwrap(); - // The recorded VMM exited, but the caller cannot record what ended it. - let exited = |_: &ProcessRef| -> io::Result> { Ok(None) }; - let error = reattach_with(&mut locked, &runtime(), exited, |_| { - Err(Error::io("the test cannot record the exit")) - }) - .unwrap_err(); - assert_eq!(error.message(), "the test cannot record the exit"); - // The record remains for a later reattach. - assert!(locked.runtime().unwrap().is_some()); - let Reattached::Exited { record, recovery } = - reattach_with(&mut locked, &runtime(), exited, |_| Ok(())).unwrap() - else { - panic!("a VMM that exited was not reported as exited"); - }; - assert_eq!(record.unwrap().launch().as_str(), RECORDED_LAUNCH); - assert_eq!(recovery, Recovery::Clean); - assert!(locked.runtime().unwrap().is_none()); -} - -#[test] -fn a_launch_whose_vmm_cannot_start_ends() { - let (directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let missing = directory.path().join("missing-openvmm"); - let error = launch( - &mut locked, - &LaunchId::generate().unwrap(), - &runtime(), - &CAPABILITY, - |_| Ok(VmmCommand::new(&missing, Vec::new(), directory.path())), - ) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io); - assert_eq!( - error.message(), - format!("cannot launch {}", missing.display()) - ); - assert!(store.journal().unwrap().is_none()); - // The next launch begins at once. - let next = launch( - &mut locked, - &LaunchId::generate().unwrap(), - &runtime(), - &CAPABILITY, - |_| Err(Error::other("the next launch began")), - ); - assert_eq!(next.unwrap_err().message(), "the next launch began"); -} - -#[test] -fn a_forced_teardown_that_fails_keeps_the_record_until_one_ends_the_vmm() { - let (_directory, store) = store(); - record(&store, &this_process(), &runtime()); - let mut locked = store.lock(None).unwrap(); - let Reattached::Live(vmm) = reattach(&mut locked, &runtime()).unwrap() else { - panic!("the recorded VMM was not reattached"); - }; - // This test process stands for the VMM, which these teardowns reach only through stand-ins. - let bound = Duration::from_secs(10); - // A teardown that cannot terminate the VMM fails, and keeps its record. - let unkillable = Scripted { - unkillable: true, - ..Scripted::default() - }; - let error = vmm - .terminate_with(&mut locked, bound, &unkillable) - .unwrap_err(); - assert_eq!( - error.message(), - format!("cannot terminate VMM process {}", std::process::id()) - ); - assert_eq!(locked.runtime().unwrap().as_ref(), Some(vmm.record())); - // One that does not end the VMM within its bound keeps it too. - let survivor = Scripted::default(); - assert_eq!( - vmm.terminate_with(&mut locked, bound, &survivor).unwrap(), - StopOutcome::TimedOut - ); - assert_eq!(locked.runtime().unwrap().as_ref(), Some(vmm.record())); - // One that ends it clears the record. - let dying = Scripted { - dies_when_killed: true, - ..Scripted::default() - }; - assert_eq!( - vmm.terminate_with(&mut locked, bound, &dying).unwrap(), - StopOutcome::Forced - ); - assert!(locked.runtime().unwrap().is_none()); -} - -#[test] -fn only_a_stop_unless_exited_keeps_the_record_of_a_vmm_that_had_exited() { - let (_directory, store) = store(); - record(&store, &this_process(), &runtime()); - let mut locked = store.lock(None).unwrap(); - let Reattached::Live(vmm) = reattach(&mut locked, &runtime()).unwrap() else { - panic!("the recorded VMM was not reattached"); - }; - // This test process stands for the VMM, which these stops reach only through a stand-in, - // which exited once its holder found it running, before the stops began. - let deadline = Instant::now() + Duration::from_secs(60); - let forced = Some(Duration::from_secs(10)); - let never = |_: &Vmm, _: Instant| -> std::result::Result<(), ()> { - panic!("a stop ran the graceful step of a VMM that had exited") - }; - let exited = Scripted::default(); - exited.exited.set(true); - // The stop takes no step, and keeps the record, which tells a reattach that the VM exited - // unheld. - let stopped = vmm - .stop_unless_exited_with(&mut locked, deadline, forced, never, &exited) - .unwrap(); - assert!(stopped.is_none(), "{stopped:?}"); - assert_eq!(exited.operations(), ["check"]); - assert_eq!(locked.runtime().unwrap().as_ref(), Some(vmm.record())); - // A stop that ends such a VM as one that it stopped clears the record. - let stopped = vmm - .stop_with(&mut locked, deadline, forced, never, &exited) - .unwrap(); - assert_eq!(stopped.outcome, StopOutcome::Graceful); - assert!(stopped.graceful_error.is_none()); - assert_eq!(exited.operations(), ["check", "check"]); - assert!(locked.runtime().unwrap().is_none()); -} - -#[test] -fn a_stop_unless_exited_stops_a_running_vmm_as_a_stop_does() { - let (_directory, store) = store(); - record(&store, &this_process(), &runtime()); - let mut locked = store.lock(None).unwrap(); - let Reattached::Live(vmm) = reattach(&mut locked, &runtime()).unwrap() else { - panic!("the recorded VMM was not reattached"); - }; - // This test process stands for the VMM, which these stops reach only through stand-ins. - let deadline = Instant::now() + Duration::from_secs(60); - // One that outlives the stop keeps its record. - let survivor = Scripted::default(); - let stopped = vmm - .stop_unless_exited_with( - &mut locked, - deadline, - None, - |_, _| Ok::<_, ()>(()), - &survivor, - ) - .unwrap() - .expect("the stop of a VMM that runs"); - assert_eq!(stopped.outcome, StopOutcome::TimedOut); - assert_eq!(survivor.operations(), ["check", "wait"]); - assert_eq!(locked.runtime().unwrap().as_ref(), Some(vmm.record())); - // One that heeds the graceful step exits through the stop, which clears its record. - let heeding = Scripted::default(); - let shut_down = |_: &Vmm, _: Instant| { - heeding.exited.set(true); - Ok::<_, ()>(()) - }; - let stopped = vmm - .stop_unless_exited_with(&mut locked, deadline, None, shut_down, &heeding) - .unwrap() - .expect("the stop of a VMM that runs"); - assert_eq!(stopped.outcome, StopOutcome::Graceful); - assert_eq!(heeding.operations(), ["check", "wait"]); - assert!(locked.runtime().unwrap().is_none()); -} - -#[test] -fn a_stop_keeps_the_record_of_another_vmm_under_the_same_launch_identity() { - let (_directory, store) = store(); - let process = this_process(); - record(&store, &process, &runtime()); - let mut locked = store.lock(None).unwrap(); - let Reattached::Live(vmm) = reattach(&mut locked, &runtime()).unwrap() else { - panic!("the recorded VMM was not reattached"); - }; - // Another VMM's record replaced it, under the launch identity that its caller used again. - let other = ProcessRef { - start_time: process.start_time + 1, - ..process.clone() - }; - record(&store, &other, &runtime()); - let exited = Scripted::default(); - exited.exited.set(true); - assert_eq!( - vmm.terminate_with(&mut locked, Duration::from_secs(10), &exited) - .unwrap(), - StopOutcome::Graceful - ); - let kept = store.runtime().unwrap().unwrap(); - assert_eq!(kept.launch().as_str(), RECORDED_LAUNCH); - assert_eq!(kept.process(), &other); -} diff --git a/nvx-rs/src/host/supervisor/vmm.rs b/nvx-rs/src/host/supervisor/vmm.rs deleted file mode 100644 index 1de352cf7..000000000 --- a/nvx-rs/src/host/supervisor/vmm.rs +++ /dev/null @@ -1,369 +0,0 @@ -//! The VMs that the supervisor holds, and the stops and terminations of their VMMs. - -use std::io; -use std::time::{Duration, Instant}; - -use crate::error::{Error, Result}; -use crate::host::launch::timeout_problem; -use crate::host::process::Process; -use crate::host::store::{LockedStore, RuntimeRecord, SandboxStore}; - -/// A VM that the supervisor holds: the runtime record of its launch, and its VMM, held through a -/// verified handle, as [`Starting::commit`] and [`reattach`] return it. -/// -/// The VMM's exit event wakes its waiters, whether or not this caller started it. -/// -/// [`Starting::commit`]: crate::host::supervisor::Starting::commit -/// [`reattach`]: fn@crate::host::supervisor::reattach -#[derive(Debug)] -pub struct Vmm { - /// The sandbox's store. - pub(super) store: SandboxStore, - /// The runtime record of the VM's launch. - pub(super) record: RuntimeRecord, - /// The VMM, held through its verified handle. - pub(super) process: Process, -} - -impl Vmm { - /// Returns the runtime record of the VM's launch: the reference to its VMM, its control - /// endpoint, and the runtime identity that it was launched with. - pub fn record(&self) -> &RuntimeRecord { - &self.record - } - - /// Returns the VM's control endpoint: a Unix socket path on Linux, and a named pipe on - /// Windows. - pub fn endpoint(&self) -> &str { - self.record.endpoint() - } - - /// Returns the VMM, held through its verified handle. - pub fn process(&self) -> &Process { - &self.process - } - - /// Waits until the VMM exits or `deadline` passes, and returns whether it exited, as - /// [`Process::wait`] does. - pub fn wait(&self, deadline: Instant) -> io::Result { - self.process.wait_bool(deadline) - } - - /// Returns whether the VMM has exited, without waiting. - pub fn has_exited(&self) -> io::Result { - self.process.has_exited() - } - - /// Stops the VM: asks it to shut down through the caller's graceful step, waits for its VMM - /// to exit until `deadline`, and takes the forced step if the caller requests it with - /// `forced` and the VMM still runs. - /// - /// - The graceful step, `graceful`, runs once, with this VM and `deadline`, unless the VMM - /// has exited already. It asks the guest to shut down through the caller's own transport, - /// such as its control session, which the supervisor does not own. Once it succeeds, the - /// stop waits until the VMM exits or `deadline` passes. A graceful step that fails may not - /// have reached the guest, so the stop then only checks whether the VMM has exited, and - /// keeps the step's error in [`Stopped::graceful_error`]. - /// - The forced step terminates the VMM through its verified handle, and waits up to `forced` - /// for it to exit. Without `forced`, the stop never signals the VMM. - /// - /// The result says whether the VMM exited without the forced step, the forced step ended it, - /// or it still runs; see [`StopOutcome`]. Once the VMM has exited, the stop clears the runtime - /// record of its launch, as [`LockedStore::clear_runtime`] does, unless the record is another - /// VM's by then. A VMM that still runs keeps its record. One that had exited before the stop - /// began, which runs no step, ends as one that the stop ended, as [`StopOutcome::Graceful`], - /// and its record is cleared: [`stop_unless_exited`](Self::stop_unless_exited) tells such a - /// VMM apart, and keeps its record. - /// - /// `locked` must be the locked store of the VM's sandbox. Fails with - /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) if `forced` is zero or longer - /// than [`MAX_TIMEOUT`], and fails when the VMM's state cannot be determined, or when the - /// forced step cannot terminate it, as on Linux hosts without pidfds, which cannot hold it. - /// - /// [`MAX_TIMEOUT`]: crate::host::launch::MAX_TIMEOUT - pub fn stop( - &self, - locked: &mut LockedStore, - deadline: Instant, - forced: Option, - graceful: impl FnOnce(&Self, Instant) -> std::result::Result<(), E>, - ) -> Result> { - self.stop_with(locked, deadline, forced, graceful, &self.process) - } - - /// Stops the VM as [`stop`](Self::stop) does, unless its VMM had exited before the stop began, - /// and returns `None` then, with the runtime record of its launch kept. - /// - /// The stop decides by its own check of the VMM, before its graceful step, as - /// [`stop`](Self::stop) does. A VMM that had exited by then ended without its holder, however - /// recently the holder found it running: the stop runs no step, signals nothing, and clears - /// nothing, so that the record tells [`reattach`] that the VM exited unheld, as one that exits - /// while no caller holds it does. A caller that tells how the VM ended then reattaches. - /// [`stop`](Self::stop) ends such a VM as one that it stopped, and clears its record, so a - /// holder that checks whether the VMM has exited, then stops the VM with it, attributes to its - /// stop a VMM that exits in between. - /// - /// A VMM that runs as the stop begins is stopped as [`stop`](Self::stop) stops it, with the - /// same result, and its exit is the stop's from then on. Fails as [`stop`](Self::stop) fails. - /// - /// [`reattach`]: fn@crate::host::supervisor::reattach - pub fn stop_unless_exited( - &self, - locked: &mut LockedStore, - deadline: Instant, - forced: Option, - graceful: impl FnOnce(&Self, Instant) -> std::result::Result<(), E>, - ) -> Result>> { - self.stop_unless_exited_with(locked, deadline, forced, graceful, &self.process) - } - - /// Ends the VM by identity-verified forced termination: terminates its VMM through its - /// verified handle, and waits up to `bound` for it to exit. It is the forced step of - /// [`stop`](Self::stop), taken alone, with no graceful step before it. - /// - /// It ends a VM whose guest the caller must not ask to shut down, such as one that another - /// runtime identity started, which [`reattach`] reports as [`Reattached::Mismatched`]: its - /// guest might not speak the caller's protocol, so the termination sends it nothing, and runs - /// nothing of the caller's. It signals only the VMM that this holds, whose process ID, start - /// time, and host boot were verified, never a process that reuses its ID. - /// - /// The result says whether the VMM exited: [`StopOutcome::Forced`] once the termination ended - /// it within `bound`, [`StopOutcome::Graceful`] if it had exited before, and was not - /// signaled, and [`StopOutcome::TimedOut`] if it still runs. Once the VMM has exited, the - /// termination clears the runtime record of its launch, as [`stop`](Self::stop) does. A VMM - /// that still runs, or whose exit cannot be determined, keeps its record, so that a later - /// termination may retry. - /// - /// `locked` must be the locked store of the VM's sandbox. Fails with - /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) if `bound` is zero or longer - /// than [`MAX_TIMEOUT`], and fails when the VMM's state cannot be determined, or when it cannot - /// be terminated, as on Linux hosts without pidfds, which cannot hold it. - /// - /// [`reattach`]: fn@crate::host::supervisor::reattach - /// [`Reattached::Mismatched`]: crate::host::supervisor::Reattached::Mismatched - /// [`MAX_TIMEOUT`]: crate::host::launch::MAX_TIMEOUT - pub fn terminate(&self, locked: &mut LockedStore, bound: Duration) -> Result { - self.terminate_with(locked, bound, &self.process) - } - - /// Stops the VM as [`stop`](Self::stop) does, through `process`: its VMM, or, in tests, a - /// stand-in for it. - pub(super) fn stop_with( - &self, - locked: &mut LockedStore, - deadline: Instant, - forced: Option, - graceful: impl FnOnce(&Self, Instant) -> std::result::Result<(), E>, - process: &impl Stoppable, - ) -> Result> { - if let Some(stopped) = - self.stop_unless_exited_with(locked, deadline, forced, graceful, process)? - { - return Ok(stopped); - } - self.clear_if_exited(locked, StopOutcome::Graceful)?; - Ok(Stopped { - outcome: StopOutcome::Graceful, - graceful_error: None, - }) - } - - /// Stops the VM as [`stop_unless_exited`](Self::stop_unless_exited) does, through `process`: - /// its VMM, or, in tests, a stand-in for it. - pub(super) fn stop_unless_exited_with( - &self, - locked: &mut LockedStore, - deadline: Instant, - forced: Option, - graceful: impl FnOnce(&Self, Instant) -> std::result::Result<(), E>, - process: &impl Stoppable, - ) -> Result>> { - self.check_store(locked)?; - if let Some(bound) = forced { - check_bound(bound, "the forced step")?; - } - let stopped = stop_process(process, self.record.process().pid, deadline, forced, || { - graceful(self, deadline) - })?; - if let Some(stopped) = &stopped { - self.clear_if_exited(locked, stopped.outcome)?; - } - Ok(stopped) - } - - /// Ends the VM as [`terminate`](Self::terminate) does, through `process`: its VMM, or, in - /// tests, a stand-in for it. - pub(super) fn terminate_with( - &self, - locked: &mut LockedStore, - bound: Duration, - process: &impl Stoppable, - ) -> Result { - self.check_store(locked)?; - check_bound(bound, "the termination")?; - let outcome = terminate_process(process, self.record.process().pid, bound)?; - self.clear_if_exited(locked, outcome)?; - Ok(outcome) - } - - /// Fails unless `locked` is the locked store of the VM's sandbox. - fn check_store(&self, locked: &LockedStore) -> Result<()> { - if locked.dir() == self.store.dir() { - return Ok(()); - } - Err(Error::other(format!( - "the VM of the sandbox in {} cannot be stopped through the store in {}", - self.store.dir().display(), - locked.dir().display() - ))) - } - - /// Clears the runtime record of the VM's launch from `locked` once its VMM has exited, which - /// `outcome` tells, unless the record is another VM's by then: another launch's, or that of - /// another VMM under a launch identity that its caller used again. - fn clear_if_exited(&self, locked: &mut LockedStore, outcome: StopOutcome) -> Result<()> { - if outcome != StopOutcome::TimedOut - && locked - .runtime()? - .is_some_and(|record| record == self.record) - { - locked.clear_runtime_of(Some(&self.record))?; - } - Ok(()) - } -} - -/// Fails with [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) if `bound`, the bound of -/// `what`, is zero or longer than [`MAX_TIMEOUT`]. -/// -/// [`MAX_TIMEOUT`]: crate::host::launch::MAX_TIMEOUT -fn check_bound(bound: Duration, what: &str) -> Result<()> { - match timeout_problem(bound, true) { - Some(problem) => Err(Error::unsupported(format!("the bound of {what} {problem}"))), - None => Ok(()), - } -} - -/// How [`Vmm::stop`], [`Vmm::stop_unless_exited`], or [`Vmm::terminate`] ended. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum StopOutcome { - /// The VMM exited without the forced step: by the deadline of the graceful step, or before - /// the stop or the termination began, which [`Vmm::stop_unless_exited`] reports as no stop. - Graceful, - /// The forced step terminated the VMM through its verified handle, and it exited within the - /// step's bound: the graceful step of a stop did not end it, or a termination took the step - /// alone. - Forced, - /// The VMM still runs: it did not exit by the deadline of the graceful step, or, after a - /// graceful step that failed, had not exited, and the caller requested no forced step; or it - /// did not exit within the bound of the forced step, which a termination takes alone. - TimedOut, -} - -/// What [`Vmm::stop`] or [`Vmm::stop_unless_exited`] did: how the stop ended, and the failure of -/// its graceful step, if it failed. -#[derive(Debug)] -pub struct Stopped { - /// How the stop ended. - pub outcome: StopOutcome, - /// The error of the graceful step, if the step ran and failed. - pub graceful_error: Option, -} - -/// The operations on a VMM that a stop or a termination takes, which tests replace. -pub(super) trait Stoppable { - /// Returns whether the VMM has exited, without waiting. - fn has_exited(&self) -> io::Result; - - /// Waits until the VMM exits or `deadline` passes, and returns whether it exited. - fn wait(&self, deadline: Instant) -> io::Result; - - /// Ends the VMM at once through its verified handle, without waiting. - fn kill(&self) -> io::Result<()>; -} - -impl Stoppable for Process { - fn has_exited(&self) -> io::Result { - Process::has_exited(self) - } - - fn wait(&self, deadline: Instant) -> io::Result { - Process::wait_bool(self, deadline) - } - - fn kill(&self) -> io::Result<()> { - Process::kill(self) - } -} - -/// Stops the VMM `process`, whose ID is `pid`, with the graceful step `graceful`, the deadline -/// `deadline`, and the forced step that `forced` requests, as [`Vmm::stop_unless_exited`] -/// describes, without clearing its runtime record: returns `None`, and takes no step, if the VMM -/// had exited before. -pub(super) fn stop_process( - process: &impl Stoppable, - pid: u32, - deadline: Instant, - forced: Option, - graceful: impl FnOnce() -> std::result::Result<(), E>, -) -> Result>> { - if process - .has_exited() - .map_err(|error| undetermined(pid, error))? - { - return Ok(None); - } - let graceful_error = graceful().err(); - let exited = match graceful_error { - None => process.wait(deadline), - Some(_) => process.has_exited(), - } - .map_err(|error| undetermined(pid, error))?; - let outcome = match forced { - _ if exited => StopOutcome::Graceful, - None => StopOutcome::TimedOut, - Some(bound) => force(process, pid, bound)?, - }; - Ok(Some(Stopped { - outcome, - graceful_error, - })) -} - -/// Terminates the VMM `process`, whose ID is `pid`, unless it has exited, and waits up to `bound` -/// for it to exit, as [`Vmm::terminate`] describes, without clearing its runtime record. -pub(super) fn terminate_process( - process: &impl Stoppable, - pid: u32, - bound: Duration, -) -> Result { - if process - .has_exited() - .map_err(|error| undetermined(pid, error))? - { - return Ok(StopOutcome::Graceful); - } - force(process, pid, bound) -} - -/// Takes the forced step of a stop or a termination: terminates the VMM `process`, whose ID is -/// `pid`, through its verified handle, and waits up to `bound` for it to exit. -fn force(process: &impl Stoppable, pid: u32, bound: Duration) -> Result { - process.kill().map_err(|error| { - Error::io(format!("cannot terminate VMM process {pid}")).with_source(error) - })?; - let exited = process - .wait(Instant::now() + bound) - .map_err(|error| undetermined(pid, error))?; - Ok(if exited { - StopOutcome::Forced - } else { - StopOutcome::TimedOut - }) -} - -/// Returns the error of a stop or a termination that cannot determine whether VMM process `pid` -/// exited, for the reason `error`. -fn undetermined(pid: u32, error: io::Error) -> Error { - Error::io(format!("cannot determine whether VMM process {pid} exited")).with_source(error) -} diff --git a/nvx-rs/src/host/tests.rs b/nvx-rs/src/host/tests.rs index c183ef74d..3474383cb 100644 --- a/nvx-rs/src/host/tests.rs +++ b/nvx-rs/src/host/tests.rs @@ -2,7 +2,9 @@ #[cfg(windows)] use std::path::Path; +#[cfg(any(windows, feature = "cli"))] use std::thread; +#[cfg(any(windows, feature = "cli"))] use std::time::{Duration, Instant}; /// How long a test waits for a lock or claim that it released to be free. @@ -10,14 +12,17 @@ use std::time::{Duration, Instant}; /// A process that a concurrent test spawns holds a copy of each descriptor of the test process /// from its fork until it executes its program, and with it the lock or claim of a file that a /// test had open, for that moment. +#[cfg(any(windows, feature = "cli"))] const RELEASE_WAIT: Duration = Duration::from_secs(10); /// Interval at which a test checks a lock or claim that it released again, the interval at which /// the host retries a lock. +#[cfg(any(windows, feature = "cli"))] const RELEASE_RETRY_INTERVAL: Duration = Duration::from_millis(25); /// Returns whether `free` holds before [`RELEASE_WAIT`] passes, checking it again at every /// [`RELEASE_RETRY_INTERVAL`] until then. +#[cfg(any(windows, feature = "cli"))] pub(crate) fn released(mut free: impl FnMut() -> bool) -> bool { let deadline = Instant::now() + RELEASE_WAIT; while !free() { @@ -29,6 +34,15 @@ pub(crate) fn released(mut free: impl FnMut() -> bool) -> bool { true } +/// Returns 32 random lowercase hexadecimal digits, with which a test names a pipe that no +/// concurrent test or run of the tests names. +#[cfg(windows)] +pub(crate) fn unique_suffix() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).unwrap(); + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + /// Spawns `command` under a `SpawnLock`, which it releases once the process exists, so that on /// Windows the process inherits no handle of a VMM that a concurrent test spawns, such as the /// VMM's log, which it would hold until it exits. diff --git a/nvx-rs/src/host/transport/tests.rs b/nvx-rs/src/host/transport/tests.rs index eb449723e..5c0716802 100644 --- a/nvx-rs/src/host/transport/tests.rs +++ b/nvx-rs/src/host/transport/tests.rs @@ -36,7 +36,7 @@ fn endpoint(directory: &tempfile::TempDir) -> String { format!( r"\\.\pipe\nvx-verified-{}-{}", std::process::id(), - crate::host::store::LaunchId::generate().unwrap().as_str() + crate::host::tests::unique_suffix() ) } } diff --git a/nvx-rs/src/host/vm/tests.rs b/nvx-rs/src/host/vm/tests.rs index f70176271..242b80238 100644 --- a/nvx-rs/src/host/vm/tests.rs +++ b/nvx-rs/src/host/vm/tests.rs @@ -2132,7 +2132,7 @@ fn library_spawns_refuse_taken_and_untrusted_log_job_names() { } } -#[cfg(windows)] +#[cfg(all(windows, feature = "cli"))] #[test] fn an_unrelated_process_started_in_the_library_spawn_window_gets_no_vm_claim() { struct ChildGuard(std::process::Child); diff --git a/nvx-rs/src/host/vm_directory/tests.rs b/nvx-rs/src/host/vm_directory/tests.rs index 15d7e1da4..ba8808721 100644 --- a/nvx-rs/src/host/vm_directory/tests.rs +++ b/nvx-rs/src/host/vm_directory/tests.rs @@ -199,6 +199,7 @@ fn existing_vm_directories_that_grant_others_or_inherit_are_refused_and_left_unc let full = format!("(A;OICI;FA;;;{sid})"); for other in ["SY", "BA", "OW"] { platform::tests::set_access(&path, &format!("D:P{full}(A;OICI;FA;;;{other})")).unwrap(); + #[cfg(feature = "cli")] assert_eq!(platform::check_store_dir(&path).unwrap(), None, "{other}"); refused(&path, "allows others than the current user"); } @@ -224,6 +225,7 @@ fn existing_vm_directories_that_grant_others_or_inherit_are_refused_and_left_unc platform::tests::set_access(&path, &format!("D:P{full}")).unwrap(); let inheriting = path.join("inheriting"); fs::create_dir(&inheriting).unwrap(); + #[cfg(feature = "cli")] assert_eq!(platform::check_store_dir(&inheriting).unwrap(), None); let owned = platform::tests::access(&inheriting) .unwrap() diff --git a/nvx-rs/src/lib.rs b/nvx-rs/src/lib.rs index 5b635d012..4e078bed5 100644 --- a/nvx-rs/src/lib.rs +++ b/nvx-rs/src/lib.rs @@ -33,9 +33,8 @@ //! - [`Hypervisor`]: the hypervisors that run the VMs. //! - [`host`] (feature `host`): artifact discovery, the OpenVMM command line, the lowering of //! network and filesystem policies, the host's NVXS connection, control sessions, process and -//! platform primitives, the supervision of VM instances: starting, reattaching, executing -//! workloads, and stopping, the store of a sandbox's runtime records and launch journal, and the -//! manifests and compatibility keys of templates. +//! platform primitives, VMs that a caller creates, starts, reattaches to, and stops in private +//! directories that it owns, and the manifests and compatibility keys of templates. //! - `guest` (feature `guest`, Linux): the programs that run in NVX guests, such as //! `nvx-console-pending`, the guest's NVXS connection, the libraries through which any guest //! init or agent opens the terminal on which it serves its session (`guest::terminal`), starts @@ -55,56 +54,72 @@ //! //! # Example //! +//! A cold boot through [`host::vm`], in a private directory that the caller owns, and the VM's +//! stop. The `host-workflows` example also captures a VM, restores one, and finds one again after +//! its caller restarts. +//! //! ```no_run //! # #[cfg(feature = "host")] //! # fn main() -> Result<(), Box> { -//! use std::sync::atomic::AtomicBool; -//! use std::time::Duration; +//! use std::path::Path; +//! use std::time::{Duration, Instant}; //! -//! use nvx::host::instance::{Found, Instance}; -//! use nvx::host::launch::LaunchSpec; -//! use nvx::host::store::CallerFiles; -//! use nvx::protocol::{Workload, WorkloadEnvironment}; +//! use nvx::activation::{MachineIdMode, View}; +//! use nvx::host::config::{Boot, ConsoleAttachment, RootConfig, VmConfig}; +//! use nvx::host::runtime::{LaunchOptions, Runtime}; +//! use nvx::host::vm::{self, VmDir}; //! use nvx::{Artifacts, Hypervisor}; //! -//! // Locates OpenVMM, the guest kernel, and the Alpine initramfs, which the install metadata of -//! // their release or checkout identifies. +//! // Locates OpenVMM, the guest kernel, and the Alpine initramfs, with the identity that the +//! // install metadata of their release or checkout gives them. //! let artifacts = Artifacts::discover()?; -//! let runtime = artifacts.runtime_identity()?; -//! let spec = LaunchSpec::new(artifacts, Hypervisor::from_env_or_default()?); -//! // The example keeps no files of its own beside the instance's store. -//! let directory = std::env::temp_dir().join("nvx-example"); -//! let instance = Instance::create(directory, "example", CallerFiles::none())?; -//! // Lifecycle transitions hold the instance's lock; executions need none. -//! let mut locked = instance.lock(None)?; -//! let Found::Stopped = locked.find(|| Ok(runtime.clone()))? else { -//! return Err("the example's VM runs already".into()); -//! }; -//! locked.start(&spec, &runtime, Duration::from_secs(60))?; -//! let Found::Running(running) = locked.find(|| Ok(runtime.clone()))? else { -//! return Err("the example's VM stopped".into()); -//! }; -//! -//! let workload = Workload { -//! argv: vec!["/bin/echo".to_owned(), "hello".to_owned()], -//! timeout_ms: 0, -//! cwd: None, -//! environment: WorkloadEnvironment::Default, +//! let runtime = Runtime { +//! identity: artifacts.runtime_identity()?, +//! openvmm: artifacts.openvmm, +//! kernel: artifacts.kernel, +//! initramfs: artifacts.initrd, +//! hypervisor: Hypervisor::from_env_or_default()?, +//! cpu_profile: None, +//! windows_breakaway: false, +//! options: LaunchOptions::default(), //! }; -//! let timeout = Duration::from_secs(60); -//! let execution = instance.exec(&running, &workload, timeout, timeout)?; -//! let mut stdout = Vec::new(); -//! let outcome = execution.pump( -//! &mut |chunk: &[u8]| { -//! stdout.extend_from_slice(chunk); -//! Ok(()) +//! let config = VmConfig { +//! processors: 1, +//! memory_mib: 256, +//! root: RootConfig::Blockless, +//! view: View { +//! hostname: b"example".to_vec(), +//! machine_id: MachineIdMode::Image, //! }, -//! &mut |_: &[u8]| Ok(()), -//! &AtomicBool::new(false), +//! network: None, +//! filesystem: None, +//! console: ConsoleAttachment::Disconnected, +//! boot: Some(Boot { +//! command_line: "quiet".to_owned(), +//! memory_capacity_mib: None, +//! capture: None, +//! }), +//! }; +//! // The caller creates and owns the VM's private directory. +//! let directory = Path::new("example-vm"); +//! vm::create_private_dir(directory)?; +//! let dir = VmDir::open(directory)?; +//! // Creation checks the whole launch and creates its log, but starts nothing; the spawn starts +//! // OpenVMM detached, and returns its verified process. +//! let launched = vm::spawn(vm::create(&runtime, &config, None, &dir)?)?; +//! // The caller speaks its own protocol with its guest at `launched.outputs.endpoint`, with the +//! // secret `launched.capability`. To stop the VM, the callback would ask the guest in that +//! // protocol to end it, and return whether it sent the request; this one sends none, so the +//! // stop ends OpenVMM's verified process once the grace period has passed, unless it exited. +//! let grace = Instant::now() + Duration::from_secs(10); +//! let stopped = vm::stop( +//! &launched.process, +//! &launched.outputs, +//! |_| false, +//! grace, +//! grace + Duration::from_secs(10), //! )?; -//! println!("{outcome:?}: {}", String::from_utf8_lossy(&stdout)); -//! -//! locked.stop(running, Duration::from_secs(30))?; +//! println!("forced: {}, report: {:?}", stopped.forced, stopped.report?); //! # Ok(()) //! # } //! # #[cfg(not(feature = "host"))] diff --git a/nvx-rs/tests/cli/lifecycle.rs b/nvx-rs/tests/cli/lifecycle.rs index fc0ec19b0..fe2c7db8b 100644 --- a/nvx-rs/tests/cli/lifecycle.rs +++ b/nvx-rs/tests/cli/lifecycle.rs @@ -1806,7 +1806,6 @@ pub(crate) mod windows { use std::thread; use std::time::Duration; - use nvx::host::store::SandboxStore; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Storage::FileSystem::{ DELETE, FILE_DISPOSITION_INFO, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, @@ -1821,7 +1820,7 @@ pub(crate) mod windows { /// lock file. fn unprovisioned(root: &Root, state: &str) -> PathBuf { let dir = root.below(state); - SandboxStore::create(&dir).unwrap(); + nvx::host::vm::create_private_dir(&dir).unwrap(); dir.join("sandbox.lock") } diff --git a/nvx-rs/tests/fake_openvmm.rs b/nvx-rs/tests/fake_openvmm.rs deleted file mode 100644 index 3444797cd..000000000 --- a/nvx-rs/tests/fake_openvmm.rs +++ /dev/null @@ -1,543 +0,0 @@ -//! Drives VM instances end to end against the `nvx-fake-openvmm` test double, which serves the -//! real control protocol without running a VM. -//! -//! An orchestrator that starts an instance in a process of its own, which then exits, lets a test -//! restart the orchestrator within the same host boot. -#![cfg(all( - feature = "host", - feature = "testing", - any(target_os = "linux", windows) -))] - -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::thread; -use std::time::{Duration, Instant}; - -use nvx::host::instance::{Found, Instance, Running, Started, Stopped, WorkloadOutcome}; -use nvx::host::launch::{LaunchSpec, PROCESS_COMMAND_LINE_LIMIT}; -use nvx::host::process::{Opened, Process, ProcessRef}; -use nvx::host::store::{ArtifactIdentity, CallerFiles, IdentityPart, RuntimeIdentity}; -use nvx::host::supervisor::spawn_command; -use nvx::protocol::{Workload, WorkloadEnvironment}; -use nvx::{Artifacts, ErrorKind, Hypervisor}; - -const TIMEOUT: Duration = Duration::from_secs(30); -/// The instance directory in which [`orchestrator_role`] starts a VM. -const ORCHESTRATOR_INSTANCE: &str = "NVX_FAKE_OPENVMM_TEST_INSTANCE"; - -/// Returns the runtime identity of these tests' launches, with the OpenVMM identity of the digit -/// `openvmm`. -fn runtime_with(openvmm: char) -> RuntimeIdentity { - let artifact = |digit: char| ArtifactIdentity::sha256(&digit.to_string().repeat(64)).unwrap(); - RuntimeIdentity::new(artifact(openvmm), artifact('2'), artifact('3')) -} - -/// Returns the runtime identity of these tests' launches. -fn runtime() -> RuntimeIdentity { - runtime_with('1') -} - -/// Creates the fake guest artifacts in `directory`, and returns the specification of a VM that -/// runs them. -fn spec(directory: &Path) -> LaunchSpec { - fs::write(directory.join("vmlinux"), b"kernel").unwrap(); - fs::write(directory.join("initramfs.cpio.gz"), b"initrd").unwrap(); - let artifacts = Artifacts::new( - env!("CARGO_BIN_EXE_nvx-fake-openvmm"), - directory.join("vmlinux"), - directory.join("initramfs.cpio.gz"), - ); - let hypervisor = Hypervisor::platform_default().expect("tests run on Linux or Windows"); - LaunchSpec::new(artifacts, hypervisor) -} - -/// Creates the fake guest artifacts and an instance directory below `directory`. -fn setup(directory: &Path) -> (LaunchSpec, Instance) { - let instance = Instance::create(directory.join("vm"), "test", CallerFiles::none()).unwrap(); - (spec(directory), instance) -} - -/// Finds the instance's VM under these tests' runtime identity. -fn find(instance: &Instance) -> Found { - instance.lock(None).unwrap().find(|| Ok(runtime())).unwrap() -} - -/// Returns the instance's running VM. -fn running(instance: &Instance) -> Running { - match find(instance) { - Found::Running(running) => running, - other => panic!("the instance does not run: {other:?}"), - } -} - -/// Starts the instance's VM, which does not run, with `timeout`. -fn start_within(instance: &Instance, spec: &LaunchSpec, timeout: Duration) -> nvx::Result { - let mut locked = instance.lock(None).unwrap(); - assert!(matches!(locked.find(|| Ok(runtime()))?, Found::Stopped)); - locked.start(spec, &runtime(), timeout) -} - -/// Starts the instance's VM, which does not run. -fn start(instance: &Instance, spec: &LaunchSpec) -> nvx::Result { - start_within(instance, spec, TIMEOUT) -} - -/// Stops the instance's running VM with `timeout`. -fn stop(instance: &Instance, timeout: Duration) -> nvx::Result { - let mut locked = instance.lock(None).unwrap(); - let Found::Running(running) = locked.find(|| Ok(runtime()))? else { - panic!("the instance does not run"); - }; - locked.stop(running, timeout) -} - -fn workload(argv: &[&str]) -> Workload<'static> { - Workload { - argv: argv.iter().map(|argument| (*argument).to_owned()).collect(), - timeout_ms: 0, - cwd: None, - environment: WorkloadEnvironment::Default, - } -} - -struct Output { - outcome: WorkloadOutcome, - stdout: Vec, - stderr: Vec, -} - -/// Runs `workload` in the instance's running VM, which it finds as every exec does. -fn run(instance: &Instance, workload: &Workload<'_>, cancel: &AtomicBool) -> Output { - let running = running(instance); - let execution = instance.exec(&running, workload, TIMEOUT, TIMEOUT).unwrap(); - let (mut stdout, mut stderr) = (Vec::new(), Vec::new()); - let outcome = execution - .pump( - &mut |chunk: &[u8]| { - stdout.extend_from_slice(chunk); - Ok(()) - }, - &mut |chunk: &[u8]| { - stderr.extend_from_slice(chunk); - Ok(()) - }, - cancel, - ) - .unwrap(); - Output { - outcome, - stdout, - stderr, - } -} - -/// Returns whether the store of `instance` records neither a launch nor a VM. -fn records_nothing(instance: &Instance) -> bool { - let store = instance.store(); - store.journal().unwrap().is_none() - && store.runtime().unwrap().is_none() - && store.read_capability().is_err() -} - -#[test] -fn instances_start_run_workloads_and_stop() { - let directory = tempfile::tempdir().unwrap(); - let (spec, instance) = setup(directory.path()); - assert!(matches!(find(&instance), Found::Stopped)); - let started = start(&instance, &spec).unwrap(); - assert_eq!(started.runtime.runtime(), &runtime()); - assert_eq!(running(&instance).runtime(), &started.runtime); - // Only a launch in progress keeps a journal. - assert!(instance.store().journal().unwrap().is_none()); - - let idle = AtomicBool::new(false); - let output = run( - &instance, - &workload(&["/bin/sh", "-c", "echo hello; echoerr warning; exit 3"]), - &idle, - ); - assert_eq!(output.outcome, WorkloadOutcome::Exited(3)); - assert_eq!(output.stdout, b"hello\n"); - assert_eq!(output.stderr, b"warning\n"); - let signaled = run(&instance, &workload(&["/bin/sh", "-c", "signal 15"]), &idle); - assert_eq!(signaled.outcome, WorkloadOutcome::Signaled(15)); - let mut timed = workload(&["/bin/sh", "-c", "sleep 10000"]); - timed.timeout_ms = 50; - assert_eq!( - run(&instance, &timed, &idle).outcome, - WorkloadOutcome::TimedOut - ); - let mut missing = workload(&["/bin/pwd"]); - missing.cwd = Some("/missing"); - let refused = run(&instance, &missing, &idle); - assert_eq!(refused.outcome, WorkloadOutcome::WorkingDirectory); - assert!( - String::from_utf8_lossy(&refused.stderr).contains("/missing"), - "{:?}", - refused.stderr - ); - - let stopped = stop(&instance, TIMEOUT).unwrap(); - assert!(!stopped.forced); - let report = stopped.report.expect("the VM reports its outcome"); - assert_eq!(report.category.as_deref(), Some("success")); - assert_eq!(report.teardown_complete, Some(true)); - assert!(matches!(find(&instance), Found::Stopped)); - assert!(records_nothing(&instance)); - // The log and the report outlive the run. - assert!(instance.log_path().is_file()); - assert!(instance.report_path().is_file()); -} - -#[test] -fn timeouts_are_checked_before_they_are_used() { - let directory = tempfile::tempdir().unwrap(); - let (spec, instance) = setup(directory.path()); - for timeout in [Duration::ZERO, Duration::MAX] { - let error = start_within(&instance, &spec, timeout).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); - // Nothing was launched. - assert!(records_nothing(&instance)); - } - start(&instance, &spec).unwrap(); - let echo = workload(&["/bin/echo", "hello"]); - let vm = running(&instance); - for (control_timeout, response_grace) in [ - (Duration::ZERO, TIMEOUT), - (Duration::MAX, TIMEOUT), - (TIMEOUT, Duration::MAX), - ] { - let Err(error) = instance.exec(&vm, &echo, control_timeout, response_grace) else { - panic!("exec accepted {control_timeout:?} and {response_grace:?}"); - }; - assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); - } - drop(vm); - for timeout in [Duration::ZERO, Duration::MAX] { - let error = stop(&instance, timeout).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); - } - // The refused requests left the VM running. - let idle = AtomicBool::new(false); - let output = run(&instance, &echo, &idle); - assert_eq!(output.outcome, WorkloadOutcome::Exited(0)); - assert_eq!(output.stdout, b"hello\n"); - stop(&instance, TIMEOUT).unwrap(); -} - -#[test] -fn launches_beyond_the_process_command_line_limit_are_refused() { - let directory = tempfile::tempdir().unwrap(); - let (mut spec, instance) = setup(directory.path()); - // The log of an earlier start outlives a launch that is refused. - start(&instance, &spec).unwrap(); - stop(&instance, TIMEOUT).unwrap(); - let log = fs::read(instance.log_path()).unwrap(); - // The program's path counts toward the command line, which every host holds to the limit. - spec.openvmm = directory - .path() - .join("o".repeat(PROCESS_COMMAND_LINE_LIMIT)); - let error = start(&instance, &spec).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); - // Nothing was launched or recorded. - assert!(records_nothing(&instance)); - assert_eq!(fs::read(instance.log_path()).unwrap(), log); -} - -#[test] -fn cancellation_ends_a_running_workload() { - let directory = tempfile::tempdir().unwrap(); - let (spec, instance) = setup(directory.path()); - start(&instance, &spec).unwrap(); - let cancel = Arc::new(AtomicBool::new(false)); - let canceller = thread::spawn({ - let cancel = Arc::clone(&cancel); - move || { - thread::sleep(Duration::from_millis(200)); - cancel.store(true, Ordering::Release); - } - }); - let output = run( - &instance, - &workload(&["/bin/sh", "-c", "echo started; sleep 60000"]), - &cancel, - ); - canceller.join().unwrap(); - assert_eq!(output.outcome, WorkloadOutcome::Cancelled); - assert_eq!(output.stdout, b"started\n"); - stop(&instance, TIMEOUT).unwrap(); -} - -#[test] -fn unresponsive_guests_are_terminated() { - let directory = tempfile::tempdir().unwrap(); - let (mut spec, instance) = setup(directory.path()); - spec.kernel_parameters = "fake_ignore_stop=1".to_owned(); - start(&instance, &spec).unwrap(); - let stopped = stop(&instance, Duration::from_millis(500)).unwrap(); - assert!(stopped.forced); - assert!(matches!(find(&instance), Found::Stopped)); - assert!(records_nothing(&instance)); -} - -#[test] -fn a_vm_whose_capability_is_lost_is_still_stopped() { - // Only control sessions need the capability: without it, exec fails as reading it fails, and - // stop ends the VM through its verified process identity. - let directory = tempfile::tempdir().unwrap(); - let (spec, instance) = setup(directory.path()); - let capability = instance.store().capability_path(); - for damage in ["missing", "truncated"] { - start(&instance, &spec).unwrap(); - match damage { - "missing" => fs::remove_file(&capability).unwrap(), - _ => fs::write(&capability, b"short").unwrap(), - } - let running = running(&instance); - let Err(error) = instance.exec(&running, &workload(&["/bin/true"]), TIMEOUT, TIMEOUT) - else { - panic!("{damage}: exec attached without the capability"); - }; - assert_eq!(error.kind(), ErrorKind::Io, "{damage}: {error}"); - assert!( - error.message().contains("sandbox control capability"), - "{damage}: {error}" - ); - drop(running); - let stopped = stop(&instance, TIMEOUT).unwrap(); - assert!(stopped.forced, "{damage}"); - assert!(matches!(find(&instance), Found::Stopped), "{damage}"); - assert!(records_nothing(&instance), "{damage}"); - assert!(!capability.exists(), "{damage}"); - } -} - -#[test] -fn guests_without_required_features_are_refused() { - let directory = tempfile::tempdir().unwrap(); - let (mut spec, instance) = setup(directory.path()); - // Cancellation alone. - spec.kernel_parameters = "fake_guest_features=1".to_owned(); - let error = start(&instance, &spec).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unavailable); - assert!( - error.message().contains("lacks control features"), - "{error}" - ); - assert!(error.message().contains("sandbox test failed to start")); - // The rejected VM is gone and leaves no runtime state behind. - assert!(matches!(find(&instance), Found::Stopped)); - assert!(records_nothing(&instance)); -} - -#[test] -fn launches_that_exit_fail_to_start() { - let directory = tempfile::tempdir().unwrap(); - let (mut spec, instance) = setup(directory.path()); - spec.kernel_parameters = "fake_exit_on_start=4".to_owned(); - let error = start(&instance, &spec).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Exited, "{error}"); - assert!( - error - .message() - .contains(&instance.log_path().display().to_string()), - "{error}" - ); - assert!(matches!(find(&instance), Found::Stopped)); - assert!(records_nothing(&instance)); -} - -/// The orchestrator that [`a_restarted_orchestrator_reattaches_to_its_vm`] runs in a process of -/// its own: it starts the instance in the directory that [`ORCHESTRATOR_INSTANCE`] names, with the -/// fake guest artifacts of its working directory, and exits, leaving the VM running. -#[test] -#[ignore = "an orchestrator that the tests run in a process of its own"] -fn orchestrator_role() { - let Some(dir) = env::var_os(ORCHESTRATOR_INSTANCE) else { - return; - }; - let spec = spec(&env::current_dir().unwrap()); - let instance = Instance::create(PathBuf::from(dir), "test", CallerFiles::none()).unwrap(); - start(&instance, &spec).unwrap(); -} - -#[test] -fn a_restarted_orchestrator_reattaches_to_its_vm() { - let directory = tempfile::tempdir().unwrap(); - let dir = directory.path().join("vm"); - let mut command = Command::new(env::current_exe().unwrap()); - command - .args([ - "--exact", - "orchestrator_role", - "--ignored", - "--nocapture", - "--test-threads=1", - ]) - .current_dir(directory.path()) - .env(ORCHESTRATOR_INSTANCE, &dir) - .stdin(Stdio::null()); - let status = spawn_command(&mut command).unwrap().wait().unwrap(); - assert!(status.success(), "the orchestrator failed: {status}"); - - // The orchestrator restarts within the same host boot: it finds the VM that its earlier run - // started, through the records of the store, and drives it. - let instance = Instance::open(&dir, "test", CallerFiles::none()).unwrap(); - let idle = AtomicBool::new(false); - let output = run(&instance, &workload(&["/bin/echo", "reattached"]), &idle); - assert_eq!(output.outcome, WorkloadOutcome::Exited(0)); - assert_eq!(output.stdout, b"reattached\n"); - - // Under another runtime identity, as after an upgrade of OpenVMM, the VM is mismatched: it is - // reported, and not driven. - { - let mut locked = instance.lock(None).unwrap(); - let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { - panic!("a VM of another runtime identity was not reported"); - }; - assert_eq!(mismatched.differences(), [IdentityPart::OpenVmm]); - let error = mismatched.error(); - assert_eq!( - error.message(), - format!( - "sandbox test runs under another runtime identity, which differs in OpenVMM, so \ - it is not driven: stop it to terminate its OpenVMM process {} without a guest \ - shutdown", - mismatched.vmm().record().process().pid - ) - ); - } - assert_eq!( - run(&instance, &workload(&["/bin/echo", "still"]), &idle).stdout, - b"still\n" - ); - - // Its stop terminates OpenVMM through its verified process identity, and opens no control - // session, whose protocol the guest might not speak. - let sessions = sessions(directory.path()); - assert!(sessions > 0, "the fake OpenVMM recorded no session"); - let mut locked = instance.lock(None).unwrap(); - let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { - panic!("a VM of another runtime identity was not reported"); - }; - let Opened::Running(process) = Process::open(mismatched.vmm().record().process()).unwrap() - else { - panic!("the VM runs"); - }; - for timeout in [Duration::ZERO, Duration::MAX] { - let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { - panic!("a VM of another runtime identity was not reported"); - }; - let error = locked.terminate(mismatched, timeout).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{error}"); - } - assert!(!process.has_exited().unwrap()); - let stopped = locked.terminate(mismatched, TIMEOUT).unwrap(); - assert!(stopped.forced); - // A terminated OpenVMM reports no outcome. - assert!(stopped.report.is_none()); - assert!(process.has_exited().unwrap()); - assert_eq!(self::sessions(directory.path()), sessions); - drop(locked); - assert!(matches!(find(&instance), Found::Stopped)); - assert!(records_nothing(&instance)); -} - -/// Returns how many connections to its control endpoint the fake OpenVMM of the instance `vm`, -/// whose guest artifacts are in `directory`, has accepted. -fn sessions(directory: &Path) -> usize { - fs::read_to_string(directory.join("fake-openvmm-vm-sessions.log")) - .map_or(0, |log| log.lines().count()) -} - -#[test] -fn outcome_reports_of_another_runtime_are_never_read() { - let directory = tempfile::tempdir().unwrap(); - let (spec, instance) = setup(directory.path()); - // A report beside a VM of another runtime identity is that runtime's, under its contracts, so - // its stop reads none: neither when it terminates OpenVMM nor when OpenVMM exited, and - // published its report, after the VM was found. - for exits_first in [false, true] { - start(&instance, &spec).unwrap(); - let mut locked = instance.lock(None).unwrap(); - let Found::Mismatched(mismatched) = locked.find(|| Ok(runtime_with('9'))).unwrap() else { - panic!("a VM of another runtime identity was not reported"); - }; - let Opened::Running(process) = Process::open(mismatched.vmm().record().process()).unwrap() - else { - panic!("the VM runs"); - }; - fs::write( - instance.report_path(), - r#"{"schema_version":1,"outcome":{"operation":"managed","category":"success", - "status_code":0},"teardown":{"workers":true}}"#, - ) - .unwrap(); - if exits_first { - process.kill().unwrap(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); - } - let stopped = locked.terminate(mismatched, TIMEOUT).unwrap(); - assert_eq!( - stopped, - Stopped { - forced: !exits_first, - report: None, - } - ); - assert!(process.has_exited().unwrap()); - drop(locked); - assert!(records_nothing(&instance)); - } -} - -#[test] -fn records_that_name_another_process_never_end_it() { - let directory = tempfile::tempdir().unwrap(); - let (spec, instance) = setup(directory.path()); - let path = instance.dir().join("runtime.json"); - // The record names the VMM's ID with another start time, as after the VMM exited and another - // process reused the ID, or names another boot, every process of which has exited. - let tampers: [fn(&mut serde_json::Value); 2] = [ - |record| { - let start_time = record["process"]["startTime"].as_u64().unwrap(); - record["process"]["startTime"] = (start_time + 1).into(); - }, - |record| { - let boot = record["process"]["boot"].as_str().unwrap(); - let flipped = if boot.starts_with('0') { "1" } else { "0" }; - record["process"]["boot"] = format!("{flipped}{}", &boot[1..]).into(); - }, - ]; - for tamper in tampers { - start(&instance, &spec).unwrap(); - let mut record: serde_json::Value = - serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - let reference: ProcessRef = serde_json::from_value(record["process"].clone()).unwrap(); - let Opened::Running(process) = Process::open(&reference).unwrap() else { - panic!("the VM runs"); - }; - tamper(&mut record); - fs::write(&path, serde_json::to_vec(&record).unwrap()).unwrap(); - // Under another runtime identity too, the VM is found stopped, and its record is cleared, - // but the process that its ID names is not signaled. - let mut locked = instance.lock(None).unwrap(); - let found = locked.find(|| Ok(runtime_with('9'))).unwrap(); - assert!(matches!(found, Found::Stopped), "{found:?}"); - drop(locked); - assert!(records_nothing(&instance)); - assert!( - process - .wait(Instant::now() + Duration::from_millis(200)) - .unwrap() - .is_none() - ); - process.kill().unwrap(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); - } -} diff --git a/nvx-rs/tests/fixtures/store/invalid/launch-instance-format-3.json b/nvx-rs/tests/fixtures/store/invalid/launch-instance-format-3.json deleted file mode 100644 index 8e4bc11e5..000000000 --- a/nvx-rs/tests/fixtures/store/invalid/launch-instance-format-3.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "format": 3, - "endpoint": "/var/lib/sandboxes/example/control.sock", - "process": { - "pid": 4242, - "startTime": 1234567 - } -} diff --git a/nvx-rs/tests/fixtures/store/invalid/launch-without-log.json b/nvx-rs/tests/fixtures/store/invalid/launch-without-log.json deleted file mode 100644 index 64c452c15..000000000 --- a/nvx-rs/tests/fixtures/store/invalid/launch-without-log.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "format": 4, - "launch": "7f3a9c2e5b1d4f6a8c0e2b4d6f8a0c2e", - "endpoint": "/var/lib/sandboxes/example/control.sock", - "runtime": { - "openvmm": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "kernel": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "initramfs": "sha256:3333333333333333333333333333333333333333333333333333333333333333", - "bootContract": 5, - "controlContract": "nvx-microvm-v2-control-v2", - "controlSession": 1 - } -} diff --git a/nvx-rs/tests/fixtures/store/invalid/runtime-cli-format-1.json b/nvx-rs/tests/fixtures/store/invalid/runtime-cli-format-1.json deleted file mode 100644 index 5eae116ca..000000000 --- a/nvx-rs/tests/fixtures/store/invalid/runtime-cli-format-1.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "control_endpoint": "/var/lib/sandboxes/example/control.sock", - "format": 1, - "pid": 4242, - "start_time": 1234567 -} diff --git a/nvx-rs/tests/fixtures/store/invalid/runtime-format-5.json b/nvx-rs/tests/fixtures/store/invalid/runtime-format-5.json deleted file mode 100644 index 079ab7acc..000000000 --- a/nvx-rs/tests/fixtures/store/invalid/runtime-format-5.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "format": 5, - "launch": "5d41402abc4b2a76b9719d911017c592", - "process": { - "pid": 4242, - "startTime": 1234567, - "boot": "8f0e2c46a1b94d1f9e3b7a6c5d4e3f21" - }, - "endpoint": "/var/lib/sandboxes/example/control.sock", - "runtime": { - "openvmm": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "kernel": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "initramfs": "sha256:3333333333333333333333333333333333333333333333333333333333333333", - "bootContract": 3, - "controlContract": "a-later-revision", - "controlSession": 2 - }, - "console": "/var/lib/sandboxes/example/console.log" -} diff --git a/nvx-rs/tests/fixtures/store/invalid/runtime-instance-format-3.json b/nvx-rs/tests/fixtures/store/invalid/runtime-instance-format-3.json deleted file mode 100644 index f59e96aa9..000000000 --- a/nvx-rs/tests/fixtures/store/invalid/runtime-instance-format-3.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "format": 3, - "pid": 4242, - "startTime": 1234567, - "endpoint": "/var/lib/sandboxes/example/control.sock" -} diff --git a/nvx-rs/tests/fixtures/store/invalid/runtime-truncated.json b/nvx-rs/tests/fixtures/store/invalid/runtime-truncated.json deleted file mode 100644 index 33cf62ed2..000000000 --- a/nvx-rs/tests/fixtures/store/invalid/runtime-truncated.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "format": 4, - "launch": "5d41402abc4b2a76b9719d911017c592", - "process": { - "pid": 4242, - "startTime": 1234567, - "boot": "8f0e2c46a1b94d1f9e3b7a6c5d4e3f21" - }, - "endpoint": "/var/lib/sandboxes/exa diff --git a/nvx-rs/tests/fixtures/store/invalid/runtime-unknown-field.json b/nvx-rs/tests/fixtures/store/invalid/runtime-unknown-field.json deleted file mode 100644 index 4142f55d7..000000000 --- a/nvx-rs/tests/fixtures/store/invalid/runtime-unknown-field.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "format": 4, - "launch": "5d41402abc4b2a76b9719d911017c592", - "process": { - "pid": 4242, - "startTime": 1234567, - "boot": "8f0e2c46a1b94d1f9e3b7a6c5d4e3f21" - }, - "endpoint": "/var/lib/sandboxes/example/control.sock", - "runtime": { - "openvmm": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "kernel": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "initramfs": "sha256:3333333333333333333333333333333333333333333333333333333333333333", - "bootContract": 5, - "controlContract": "nvx-microvm-v2-control-v2", - "controlSession": 1 - }, - "memoryMib": 512 -} diff --git a/nvx-rs/tests/fixtures/store/launch-prepared.json b/nvx-rs/tests/fixtures/store/launch-prepared.json deleted file mode 100644 index eec204363..000000000 --- a/nvx-rs/tests/fixtures/store/launch-prepared.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "format": 4, - "launch": "7f3a9c2e5b1d4f6a8c0e2b4d6f8a0c2e", - "endpoint": "/var/lib/sandboxes/example/control.sock", - "runtime": { - "openvmm": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "kernel": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "initramfs": "sha256:3333333333333333333333333333333333333333333333333333333333333333", - "bootContract": 5, - "controlContract": "nvx-microvm-v2-control-v2", - "controlSession": 1 - }, - "log": { - "identity": { - "device": 2049, - "index": 393219 - } - } -} diff --git a/nvx-rs/tests/fixtures/store/launch-spawned.json b/nvx-rs/tests/fixtures/store/launch-spawned.json deleted file mode 100644 index 67d3bc4dc..000000000 --- a/nvx-rs/tests/fixtures/store/launch-spawned.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "format": 4, - "launch": "7f3a9c2e5b1d4f6a8c0e2b4d6f8a0c2e", - "endpoint": "/var/lib/sandboxes/example/control.sock", - "runtime": { - "openvmm": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "kernel": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "initramfs": "sha256:3333333333333333333333333333333333333333333333333333333333333333", - "bootContract": 5, - "controlContract": "nvx-microvm-v2-control-v2", - "controlSession": 1 - }, - "log": { - "identity": { - "device": 2049, - "index": 393219 - } - }, - "process": { - "pid": 4242, - "startTime": 1234567, - "boot": "8f0e2c46a1b94d1f9e3b7a6c5d4e3f21" - } -} diff --git a/nvx-rs/tests/fixtures/store/runtime.json b/nvx-rs/tests/fixtures/store/runtime.json deleted file mode 100644 index f04a44b51..000000000 --- a/nvx-rs/tests/fixtures/store/runtime.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "format": 4, - "launch": "5d41402abc4b2a76b9719d911017c592", - "process": { - "pid": 4242, - "startTime": 1234567, - "boot": "8f0e2c46a1b94d1f9e3b7a6c5d4e3f21" - }, - "endpoint": "/var/lib/sandboxes/example/control.sock", - "runtime": { - "openvmm": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "kernel": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "initramfs": "sha256:3333333333333333333333333333333333333333333333333333333333333333", - "bootContract": 5, - "controlContract": "nvx-microvm-v2-control-v2", - "controlSession": 1 - } -} diff --git a/nvx-rs/tests/host_modules.rs b/nvx-rs/tests/host_modules.rs index ff7fd18d1..02ab8dccc 100644 --- a/nvx-rs/tests/host_modules.rs +++ b/nvx-rs/tests/host_modules.rs @@ -88,7 +88,7 @@ use std::path::{Path, PathBuf}; /// A file that the test reads. struct Source { - /// The file's path in the crate, with `/` separators, such as `src/host/store/mod.rs`. + /// The file's path in the crate, with `/` separators, such as `src/host/platform/mod.rs`. path: String, /// The file's text. text: String, @@ -259,7 +259,7 @@ struct Graph { /// A reference that makes a dependency. #[derive(Debug)] struct Example { - /// The file and line of the reference, such as `src/host/store/mod.rs:242`. + /// The file and line of the reference, such as `src/host/platform/mod.rs:242`. location: String, /// The path that the reference writes, such as `super::spawn::Launched`. path: String, @@ -2667,8 +2667,8 @@ fn parse(module: &[String], text: &str, context: &Context) -> Facts { facts } -/// Returns the module of the file at `path` in the crate, such as `["host", "store"]` for -/// `src/host/store/mod.rs`, or `None` for a path that is not a source file. +/// Returns the module of the file at `path` in the crate, such as `["host", "platform"]` for +/// `src/host/platform/mod.rs`, or `None` for a path that is not a source file. fn module_of(path: &str) -> Option> { let relative = path.strip_prefix("src/")?.strip_suffix(".rs")?; let mut module: Vec = relative.split('/').map(str::to_owned).collect(); @@ -3302,10 +3302,10 @@ fn the_children_of_host_depend_on_one_another_without_cycles() { fn the_graph_holds_well_known_dependencies_of_the_sources() { let graph = analyze(&read_sources()).unwrap_or_else(|error| panic!("{error}")); for (from, to) in [ - ("instance", "store"), - ("supervisor", "store"), + ("vm", "spawn"), + ("supervisor", "spawn"), ("cli", "sandbox"), - ("store", "spawn"), + ("spawn", "platform"), ] { assert!( graph.edges.contains_key(&(from.to_owned(), to.to_owned())), diff --git a/nvx-rs/tests/managed_lifecycle.rs b/nvx-rs/tests/managed_lifecycle.rs index 4dc582fa3..7b1debb8c 100644 --- a/nvx-rs/tests/managed_lifecycle.rs +++ b/nvx-rs/tests/managed_lifecycle.rs @@ -1,8 +1,7 @@ //! The managed lifecycle of a guest whose init hands the boot to the managed agent, on a real //! hypervisor: authenticated sessions and reconnects, request-scoped working directories and //! environments, the workload identity, timeouts, descendants that outlive their workload, -//! refused working directories, and stop, driven through `nvx::host::session` as -//! `nvx::host::instance` drives it. +//! refused working directories, and stop, driven through `nvx::host::session`. //! //! `scripts/nvx.py test-microvm --scenario managed-lifecycle` runs it for the selected guest and //! backend, then checks the outcome report that OpenVMM wrote and the guest console. It sets: diff --git a/nvx-rs/tests/store.rs b/nvx-rs/tests/store.rs deleted file mode 100644 index ea22e24ed..000000000 --- a/nvx-rs/tests/store.rs +++ /dev/null @@ -1,844 +0,0 @@ -//! The sandbox store across processes, on Linux and Windows: records that another build wrote, -//! and launches that crash at each step, which recovery ends without signaling an unverified -//! process. These are the host parts of the crash-recovery and runtime-identity scenarios. -//! -//! The crashes are real: a child process of this test binary, the orchestrator, launches a VMM -//! stand-in in a store and exits without unwinding at the chosen step, as a crash would. The VMM -//! stand-in is this test binary too, which reads its capability and sleeps, or, with the -//! `testing` feature, the fake OpenVMM, which also serves the control endpoint. -#![cfg(all(feature = "host", any(target_os = "linux", windows)))] - -use std::env; -use std::ffi::OsString; -use std::fs; -use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::{Mutex, MutexGuard, PoisonError}; -use std::thread; -use std::time::{Duration, Instant}; - -use nvx::ErrorKind; -use nvx::host::platform; -use nvx::host::process::{BootId, Opened, Process, ProcessRef}; -use nvx::host::store::{ - ArtifactIdentity, IdentityCheck, IdentityPart, JOURNAL_NAME, Launch, LaunchId, LaunchState, - LockedStore, RUNTIME_NAME, Recovery, RuntimeIdentity, SandboxStore, -}; -use nvx::host::supervisor::{self, Launched, VmmCommand}; -use nvx::protocol::CAPABILITY_LEN; - -/// The capability of every launch of these tests. -const CAPABILITY: [u8; CAPABILITY_LEN] = [7; CAPABILITY_LEN]; -/// The argument that tells [`vmm_role`] that a test spawned it. -const VMM_MARKER: &str = "nvx-store-test-vmm"; -/// The start of the line in which [`orchestrator_role`] prints the process ID of its VMM. -const VMM_LINE: &str = "nvx-store-test-vmm-pid "; -/// The store in which [`orchestrator_role`] launches. -const ORCHESTRATOR_STORE: &str = "NVX_STORE_TEST_STORE"; -/// The step after which [`orchestrator_role`] crashes: `before-spawn`, `after-spawn`, or -/// `after-identity`. -const ORCHESTRATOR_CRASH: &str = "NVX_STORE_TEST_CRASH"; -/// The boot delay, in milliseconds, of the fake OpenVMM that [`orchestrator_role`] launches -/// instead of a VMM stand-in, if set. -const ORCHESTRATOR_FAKE: &str = "NVX_STORE_TEST_FAKE_BOOT_DELAY_MS"; -/// The store that [`restrictive_umask_role`] creates. -#[cfg(target_os = "linux")] -const UMASK_STORE: &str = "NVX_STORE_TEST_UMASK_STORE"; -/// The umask, in octal, of [`restrictive_umask_role`]. -#[cfg(target_os = "linux")] -const UMASK_VALUE: &str = "NVX_STORE_TEST_UMASK"; -/// The longest wait for a recovery to become determinate. -const RECOVERY_TIMEOUT: Duration = Duration::from_secs(30); - -/// Serializes the tests that spawn processes: a process that one test spawns while another test's -/// log is inheritable for its own spawn could hold that log's claim for a while. -static SPAWNS: Mutex<()> = Mutex::new(()); - -/// Takes [`SPAWNS`]. -fn serialized() -> MutexGuard<'static, ()> { - SPAWNS.lock().unwrap_or_else(PoisonError::into_inner) -} - -/// Returns the identity `sha256:` and 64 copies of `digit`. -fn artifact(digit: char) -> ArtifactIdentity { - ArtifactIdentity::sha256(&digit.to_string().repeat(64)).unwrap() -} - -/// Returns the runtime identity of these tests' launches, and of the fixtures. -fn runtime() -> RuntimeIdentity { - RuntimeIdentity::new(artifact('1'), artifact('2'), artifact('3')) -} - -/// Returns a store in a fresh temporary directory. -fn store() -> (tempfile::TempDir, SandboxStore) { - let directory = tempfile::tempdir().unwrap(); - let store = SandboxStore::create(directory.path().join("sandbox")).unwrap(); - (directory, store) -} - -/// Returns the path of the fixture `name`. -fn fixture(name: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests") - .join("fixtures") - .join("store") - .join(name) -} - -/// Installs the fixture `name` in `store` as its file `as_name`. -fn install(store: &SandboxStore, name: &str, as_name: &str) { - fs::copy(fixture(name), store.dir().join(as_name)).unwrap(); -} - -/// Returns the program and arguments that run the ignored test `name` of this binary alone. -fn role(name: &str, extra: &[&str]) -> (PathBuf, Vec) { - let arguments = [ - "--exact", - name, - "--ignored", - "--nocapture", - "--test-threads=1", - ] - .iter() - .chain(extra) - .map(OsString::from) - .collect(); - (env::current_exe().unwrap(), arguments) -} - -/// Spawns a VMM stand-in that holds the log of `launch`. -fn spawn_vmm(launch: &mut Launch<'_>, store: &SandboxStore) -> Launched { - let (program, arguments) = role("vmm_role", &[VMM_MARKER]); - let log = launch.take_log().unwrap(); - supervisor::spawn( - &VmmCommand::new(program, arguments, store.dir()), - &CAPABILITY, - log, - ) - .unwrap() -} - -/// Spawns the fake OpenVMM, which serves the endpoint of `launch` after `boot_delay_ms`, with the -/// log of `launch`. -#[cfg(feature = "testing")] -fn spawn_fake(launch: &mut Launch<'_>, store: &SandboxStore, boot_delay_ms: u64) -> Launched { - use nvx::host::launch::LaunchSpec; - use nvx::{Artifacts, Hypervisor}; - - let directory = store.dir().parent().unwrap(); - fs::write(directory.join("vmlinux"), b"kernel").unwrap(); - fs::write(directory.join("initramfs.cpio.gz"), b"initrd").unwrap(); - let artifacts = Artifacts::new( - env!("CARGO_BIN_EXE_nvx-fake-openvmm"), - directory.join("vmlinux"), - directory.join("initramfs.cpio.gz"), - ); - let mut spec = LaunchSpec::new(artifacts, Hypervisor::platform_default().unwrap()); - spec.kernel_parameters = format!("fake_boot_delay_ms={boot_delay_ms}"); - let arguments = spec - .openvmm_arguments(launch.endpoint(), &store.outcome_path()) - .unwrap(); - let log = launch.take_log().unwrap(); - supervisor::spawn( - &VmmCommand::new(&spec.openvmm, arguments, store.dir()), - &CAPABILITY, - log, - ) - .unwrap() -} - -/// A VMM stand-in that a test spawned, which is killed when the test ends. -struct Vmm(Option); - -impl Vmm { - /// Returns the stand-in's process ID. - fn id(&self) -> u32 { - self.0.as_ref().unwrap().id() - } -} - -impl Drop for Vmm { - fn drop(&mut self) { - if let Some(launched) = self.0.take() { - launched.kill_and_wait(); - } - } -} - -/// Returns whether the process `pid` still runs with the start time `start_time`. -fn runs(pid: u32, start_time: u64) -> bool { - platform::process_start_time(pid).unwrap() == Some(start_time) -} - -/// Ends the process `pid` that started at `start_time` in this boot, through its verified -/// handle, if it still runs, and returns whether it exited within [`RECOVERY_TIMEOUT`]. -fn kill(pid: u32, start_time: u64) -> io::Result { - let reference = ProcessRef { - pid, - start_time, - boot: BootId::current()?, - }; - let Opened::Running(process) = Process::open(&reference)? else { - return Ok(true); - }; - process.kill()?; - process - .wait(Instant::now() + RECOVERY_TIMEOUT) - .map(|exited| exited.is_some()) -} - -/// A VMM that a crashed orchestrator left behind, which is killed when the test ends if it still -/// runs. -struct Orphan { - /// The VMM's process ID. - pid: u32, - /// The VMM's start time. - start_time: u64, -} - -impl Orphan { - /// Returns whether the VMM still runs. - fn runs(&self) -> bool { - runs(self.pid, self.start_time) - } -} - -impl Drop for Orphan { - fn drop(&mut self) { - let _ = kill(self.pid, self.start_time); - } -} - -/// Runs an orchestrator that launches in `store` and crashes after the step `crash`, launching -/// the fake OpenVMM with `fake_boot_delay_ms`, if given, or else a VMM stand-in. Returns the VMM -/// that it left behind, if it spawned one. -fn crash_launch( - store: &SandboxStore, - crash: &str, - fake_boot_delay_ms: Option, -) -> Option { - let (program, arguments) = role("orchestrator_role", &[]); - let mut command = Command::new(program); - // The orchestrator names its store relative to its working directory. - command - .args(arguments) - .current_dir(store.dir().parent().unwrap()) - .env(ORCHESTRATOR_STORE, store.dir().file_name().unwrap()) - .env(ORCHESTRATOR_CRASH, crash) - .stdin(Stdio::null()); - if let Some(delay) = fake_boot_delay_ms { - command.env(ORCHESTRATOR_FAKE, delay.to_string()); - } - let output = command.output().unwrap(); - assert!( - output.status.success(), - "the orchestrator failed: {}{}", - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr) - ); - let pid: u32 = String::from_utf8(output.stdout) - .unwrap() - .lines() - .find_map(|line| line.strip_prefix(VMM_LINE))? - .trim() - .parse() - .unwrap(); - let start_time = platform::process_start_time(pid) - .unwrap() - .expect("the VMM that the orchestrator left behind runs"); - Some(Orphan { pid, start_time }) -} - -/// Recovers until recovery is determinate, as a caller retries an indeterminate recovery, and -/// returns how it ended the launch. -/// -/// On Windows, a scanner that briefly opens the log can keep its claim held for a moment. -fn recover_until_determinate(locked: &mut LockedStore) -> Recovery { - let deadline = Instant::now() + RECOVERY_TIMEOUT; - loop { - match locked.recover().unwrap() { - Recovery::Indeterminate(reason) if Instant::now() < deadline => { - eprintln!("recovery is indeterminate: {reason}"); - thread::sleep(Duration::from_millis(100)); - } - recovery => return recovery, - } - } -} - -/// The orchestrator that [`crash_launch`] runs in a process of its own: it begins a launch in -/// the store that [`ORCHESTRATOR_STORE`] names, and exits without unwinding after the step that -/// [`ORCHESTRATOR_CRASH`] names, as a crash would. It prints [`VMM_LINE`] and the VMM's process -/// ID once it has spawned the VMM. -#[test] -#[ignore = "an orchestrator that the store tests run in a process of its own"] -fn orchestrator_role() { - let Some(directory) = env::var_os(ORCHESTRATOR_STORE) else { - return; - }; - let crash = env::var(ORCHESTRATOR_CRASH).unwrap(); - let crash_now = || -> ! { - io::stdout().flush().unwrap(); - std::process::exit(0) - }; - let store = SandboxStore::open(directory).unwrap(); - // The store resolves its relative path, so that the endpoint that the VMM listens on, in - // another working directory, and that recovery examines, is the same. - assert!(store.dir().is_absolute()); - let mut locked = store.lock(None).unwrap(); - let mut launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - if crash == "before-spawn" { - crash_now(); - } - let child = match env::var(ORCHESTRATOR_FAKE) { - #[cfg(feature = "testing")] - Ok(delay) => spawn_fake(&mut launch, &store, delay.parse().unwrap()), - _ => spawn_vmm(&mut launch, &store), - }; - // The test harness has printed the test's name without ending the line. - println!("\n{VMM_LINE}{}", child.id()); - if crash == "after-spawn" { - crash_now(); - } - launch.record_process(&child).unwrap(); - if crash == "after-identity" { - crash_now(); - } - panic!("unknown crash step {crash}"); -} - -/// The VMM stand-in: it reads the capability that a launch gives it and sleeps, holding the log -/// that it inherited as its standard output and error. -#[test] -#[ignore = "a VMM stand-in that the store tests spawn"] -fn vmm_role() { - if !env::args().any(|argument| argument == VMM_MARKER) { - return; - } - let mut capability = [0; CAPABILITY_LEN]; - io::stdin().read_exact(&mut capability).unwrap(); - assert_eq!(capability, CAPABILITY); - // Bounded, so that a stand-in that a failed test leaves behind exits on its own. - thread::sleep(Duration::from_secs(60)); -} - -/// A user of the store whose umask clears even the current user's permission to read or write -/// the files and directories that it creates. The store sets their modes again, so that its -/// records, its capability, and its lock still open, as their next launch and OpenVMM need. The -/// umask belongs to the whole process, so this runs in a process of its own. -#[cfg(target_os = "linux")] -#[test] -#[ignore = "a store user that the store tests run in a process of its own"] -fn restrictive_umask_role() { - use std::os::unix::fs::PermissionsExt; - - use nix::sys::stat::{Mode, umask}; - - let (Some(directory), Some(mask)) = (env::var_os(UMASK_STORE), env::var_os(UMASK_VALUE)) else { - return; - }; - let mask = u32::from_str_radix(mask.to_str().unwrap(), 8).unwrap(); - umask(Mode::from_bits_truncate(mask)); - let store = SandboxStore::create(directory).unwrap(); - let mode = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o7777; - assert_eq!(mode(store.dir()), 0o700); - let lock = store.dir().join(nvx::host::store::LOCK_NAME); - // The second launch opens the lock file again and replaces the first launch's files. - for _ in 0..2 { - let mut locked = store.lock(None).unwrap(); - let launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - assert_eq!(store.journal().unwrap().as_ref(), Some(launch.journal())); - assert_eq!(store.read_capability().unwrap(), CAPABILITY); - for path in [ - lock.clone(), - store.dir().join(JOURNAL_NAME), - store.capability_path(), - store.log_path(), - ] { - assert_eq!(mode(&path), 0o600, "{}", path.display()); - } - assert_eq!(launch.abandon().unwrap(), Recovery::Exited); - } - // A caller that crashed before it set the mode of the lock file that it created left it so. - fs::set_permissions(&lock, fs::Permissions::from_mode(0o000)).unwrap(); - drop(store.lock(None).unwrap()); - assert_eq!(mode(&lock), 0o600); -} - -#[cfg(target_os = "linux")] -#[test] -fn a_restrictive_umask_leaves_the_store_usable() { - for mask in ["400", "200", "777"] { - let directory = tempfile::tempdir().unwrap(); - let (program, arguments) = role("restrictive_umask_role", &[]); - let status = Command::new(program) - .args(arguments) - .env(UMASK_STORE, directory.path().join("sandbox")) - .env(UMASK_VALUE, mask) - .status() - .unwrap(); - assert!(status.success(), "umask {mask}"); - } -} - -#[test] -fn a_crash_before_the_spawn_leaves_a_released_claim() { - let _spawns = serialized(); - let (_directory, store) = store(); - assert!(crash_launch(&store, "before-spawn", None).is_none()); - let journal = store.journal().unwrap().unwrap(); - assert_eq!(journal.state(), LaunchState::Prepared); - // The crash released the orchestrator's lock, and the claim of its unused log. - let mut locked = store.lock(Some(Instant::now() + RECOVERY_TIMEOUT)).unwrap(); - assert_eq!(recover_until_determinate(&mut locked), Recovery::Exited); - assert!(store.journal().unwrap().is_none()); - assert!(store.read_capability().is_err()); - assert!(store.log_path().exists()); -} - -#[test] -fn the_log_claim_proves_that_an_unrecorded_vmm_exited() { - let _spawns = serialized(); - let (_directory, store) = store(); - let vmm = crash_launch(&store, "after-spawn", None).unwrap(); - assert_eq!( - store.journal().unwrap().unwrap().state(), - LaunchState::Prepared - ); - let mut locked = store.lock(Some(Instant::now() + RECOVERY_TIMEOUT)).unwrap(); - // The VMM holds the claim, and serves no endpoint that would name it, so recovery can neither - // prove that it exited nor verify it, and keeps the journal without signaling it. - let Recovery::Indeterminate(reason) = locked.recover().unwrap() else { - panic!("recovery ended a launch whose VMM runs unverified"); - }; - assert!( - reason.contains("no process serves its control endpoint yet"), - "{reason}" - ); - assert_eq!( - store.journal().unwrap().unwrap().state(), - LaunchState::Prepared - ); - assert!(store.read_capability().is_ok()); - assert!(vmm.runs()); - // Once the VMM exits, its released claim proves it. - assert!(kill(vmm.pid, vmm.start_time).unwrap()); - assert_eq!(recover_until_determinate(&mut locked), Recovery::Exited); - assert!(store.journal().unwrap().is_none()); -} - -#[test] -fn a_crash_before_the_endpoint_ends_the_recorded_vmm() { - let _spawns = serialized(); - let (_directory, store) = store(); - let vmm = crash_launch(&store, "after-identity", None).unwrap(); - let journal = store.journal().unwrap().unwrap(); - assert_eq!(journal.state(), LaunchState::Spawned); - let process = journal.process().unwrap().clone(); - assert_eq!((process.pid, process.start_time), (vmm.pid, vmm.start_time)); - // The VMM is not the recovering process's child. - let mut locked = store.lock(Some(Instant::now() + RECOVERY_TIMEOUT)).unwrap(); - assert_eq!(locked.recover().unwrap(), Recovery::Terminated(process)); - assert!(!vmm.runs()); - assert!(store.journal().unwrap().is_none()); - assert!(store.read_capability().is_err()); -} - -#[test] -fn recovery_never_signals_a_process_that_the_journal_does_not_name() { - let _spawns = serialized(); - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let mut launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - let vmm = Vmm(Some(spawn_vmm(&mut launch, &store))); - let recorded = launch - .record_process(vmm.0.as_ref().unwrap()) - .unwrap() - .reference() - .clone(); - assert_eq!(recorded.pid, vmm.id()); - // The launch is interrupted. - drop(launch); - let journal: serde_json::Value = - serde_json::from_slice(&fs::read(store.dir().join(JOURNAL_NAME)).unwrap()).unwrap(); - let tamper = |field: &str, value: serde_json::Value| { - let mut tampered = journal.clone(); - tampered["process"][field] = value; - fs::write( - store.dir().join(JOURNAL_NAME), - serde_json::to_vec(&tampered).unwrap(), - ) - .unwrap(); - }; - // The recorded process ID with another start time, as when the ID is reused, names no live - // process. The VMM still holds the launch's log, which shows that the launch may still run, - // so recovery signals nothing and keeps the journal. - tamper("startTime", serde_json::json!(recorded.start_time + 1)); - let Recovery::Indeterminate(reason) = locked.recover().unwrap() else { - panic!("recovery ended a launch whose log is still held"); - }; - assert!(reason.contains("still holds its log"), "{reason}"); - assert!(store.journal().unwrap().is_some()); - assert!(runs(recorded.pid, recorded.start_time)); - // A reference from another boot, as after the host restarts, names a process that has - // exited, and nothing is signaled either. - tamper("boot", serde_json::json!("f".repeat(32))); - assert_eq!(locked.recover().unwrap(), Recovery::Exited); - assert!(store.journal().unwrap().is_none()); - assert!(runs(recorded.pid, recorded.start_time)); -} - -#[test] -fn a_committed_launch_names_its_vm_for_another_build() { - let _spawns = serialized(); - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let mut launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - let endpoint = launch.endpoint().to_owned(); - let vmm = Vmm(Some(spawn_vmm(&mut launch, &store))); - let process = launch.record_process(vmm.0.as_ref().unwrap()).unwrap(); - // A launch records one VMM. - assert!(launch.record_process(vmm.0.as_ref().unwrap()).is_err()); - let committed = launch.commit().unwrap(); - assert_eq!(committed.process(), process.reference()); - assert_eq!(committed.endpoint(), endpoint); - assert_eq!(committed.runtime(), &runtime()); - assert!(store.journal().unwrap().is_none()); - assert_eq!(locked.recover().unwrap(), Recovery::Clean); - drop(locked); - - // Another build of the orchestrator finds the record. Under the same runtime identity it - // reattaches; under another it must stop the VM. - let other = SandboxStore::open(store.dir()).unwrap(); - let found = other.runtime().unwrap().unwrap(); - assert_eq!(found, committed); - assert_eq!(other.read_capability().unwrap(), CAPABILITY); - assert_eq!(found.check_identity(&runtime()), IdentityCheck::Matches); - let mut upgraded = runtime(); - upgraded.initramfs = artifact('4'); - assert_eq!( - found.check_identity(&upgraded), - IdentityCheck::Mismatched(vec![IdentityPart::Initramfs]) - ); - // The record's verified reference holds the VMM, whichever process started it. - let Opened::Running(reattached) = Process::open(found.process()).unwrap() else { - panic!("the recorded VMM runs"); - }; - assert!(!reattached.has_exited().unwrap()); - drop(vmm); - assert!( - reattached - .wait(Instant::now() + RECOVERY_TIMEOUT) - .unwrap() - .is_some() - ); - other.lock(None).unwrap().clear_runtime().unwrap(); - assert!(other.runtime().unwrap().is_none()); -} - -#[test] -fn an_abandoned_launch_ends_its_recorded_vmm() { - let _spawns = serialized(); - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - let mut launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - let mut vmm = Vmm(Some(spawn_vmm(&mut launch, &store))); - let held = launch.record_process(vmm.0.as_ref().unwrap()).unwrap(); - let process = held.reference().clone(); - assert_eq!( - launch.abandon().unwrap(), - Recovery::Terminated(process.clone()) - ); - assert!(held.has_exited().unwrap()); - assert!(!runs(process.pid, process.start_time)); - assert!(store.journal().unwrap().is_none()); - // The caller still releases the child that it holds. - vmm.0.take().unwrap().detach(); -} - -#[cfg(feature = "testing")] -#[test] -fn an_unrecorded_vmm_is_verified_through_its_endpoint_and_log() { - let _spawns = serialized(); - let (_directory, store) = store(); - let vmm = crash_launch(&store, "after-spawn", Some(0)).unwrap(); - let mut locked = store.lock(Some(Instant::now() + RECOVERY_TIMEOUT)).unwrap(); - // Recovery waits until the fake OpenVMM serves its endpoint, which names it. - let Recovery::Terminated(process) = recover_until_determinate(&mut locked) else { - panic!("recovery did not terminate the VMM"); - }; - assert_eq!((process.pid, process.start_time), (vmm.pid, vmm.start_time)); - assert!(!vmm.runs()); - assert!(store.journal().unwrap().is_none()); -} - -/// On Linux, the endpoint is the same socket path for every launch of a sandbox, so a process that -/// serves it must also hold the launch's log. On Windows, each launch draws its endpoint's name at -/// random, so no other process serves it. -#[cfg(all(feature = "testing", target_os = "linux"))] -#[test] -fn an_endpoint_served_without_the_claimed_log_is_not_signaled() { - let _spawns = serialized(); - // A VM of another sandbox serves an endpoint. - let (_other_directory, other) = store(); - let mut other_locked = other.lock(None).unwrap(); - let mut other_launch = other_locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - let served = other_launch.endpoint().to_owned(); - let fake = Vmm(Some(spawn_fake(&mut other_launch, &other, 0))); - let fake_process = other_launch - .record_process(fake.0.as_ref().unwrap()) - .unwrap() - .reference() - .clone(); - - // An unrecorded VMM holds the claim of this sandbox's launch, whose journal names the other - // sandbox's endpoint. - let (_directory, store) = store(); - let vmm = crash_launch(&store, "after-spawn", None).unwrap(); - let path = store.dir().join(JOURNAL_NAME); - let mut journal: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - journal["endpoint"] = served.into(); - fs::write(&path, serde_json::to_vec(&journal).unwrap()).unwrap(); - - let mut locked = store.lock(None).unwrap(); - let deadline = Instant::now() + RECOVERY_TIMEOUT; - let reason = loop { - let Recovery::Indeterminate(reason) = locked.recover().unwrap() else { - panic!("recovery ended a launch through a process that it did not verify"); - }; - // The fake OpenVMM may not serve its endpoint yet. - if !reason.contains("no process serves") || Instant::now() >= deadline { - break reason; - } - thread::sleep(Duration::from_millis(100)); - }; - assert!(reason.contains("does not hold its log"), "{reason}"); - assert!(runs(fake_process.pid, fake_process.start_time)); - assert!(vmm.runs()); - assert!(kill(vmm.pid, vmm.start_time).unwrap()); - assert_eq!(recover_until_determinate(&mut locked), Recovery::Exited); - drop(fake); -} - -#[test] -fn another_build_with_the_same_runtime_identity_reattaches() { - let (_directory, store) = store(); - install(&store, "runtime.json", RUNTIME_NAME); - let record = store.runtime().unwrap().unwrap(); - assert_eq!(record.launch().as_str(), "5d41402abc4b2a76b9719d911017c592"); - assert_eq!(record.process().pid, 4242); - assert_eq!(record.process().start_time, 1_234_567); - assert_eq!( - record.process().boot.to_string(), - "8f0e2c46a1b94d1f9e3b7a6c5d4e3f21" - ); - assert_eq!(record.endpoint(), "/var/lib/sandboxes/example/control.sock"); - // This build speaks the contracts that the fixture's build recorded; a build that changes a - // contract version must stop the VMs of earlier builds, and updates the fixture. - assert_eq!(record.check_identity(&runtime()), IdentityCheck::Matches); - // The format is pinned: this build writes what it reads. - let written: serde_json::Value = - serde_json::from_slice(&fs::read(fixture("runtime.json")).unwrap()).unwrap(); - assert_eq!(serde_json::to_value(&record).unwrap(), written); -} - -#[test] -fn another_runtime_identity_is_mismatched() { - let (_directory, store) = store(); - install(&store, "runtime.json", RUNTIME_NAME); - let record = store.runtime().unwrap().unwrap(); - let mismatched = |change: fn(&mut RuntimeIdentity), part: IdentityPart| { - let mut current = runtime(); - change(&mut current); - assert_eq!( - record.check_identity(¤t), - IdentityCheck::Mismatched(vec![part]) - ); - }; - mismatched( - |runtime| runtime.openvmm = artifact('9'), - IdentityPart::OpenVmm, - ); - mismatched( - |runtime| runtime.kernel = artifact('9'), - IdentityPart::Kernel, - ); - mismatched( - |runtime| runtime.initramfs = artifact('9'), - IdentityPart::Initramfs, - ); - mismatched( - |runtime| runtime.boot_contract += 1, - IdentityPart::BootContract, - ); - // Builds before boot contract 3 recorded the microVM ABI version, 2, as the boot contract, - // and booted guests that read a layered root's image token without a format. - mismatched( - |runtime| runtime.boot_contract = 2, - IdentityPart::BootContract, - ); - // Builds of boot contract 3 booted guests on microVM ABI 2, whose devices offered - // access-platform and whose consoles offered their size. - mismatched( - |runtime| runtime.boot_contract = 3, - IdentityPart::BootContract, - ); - // Builds of boot contract 4 booted guests on the microVM of an earlier OpenVMM, whose - // restore packets and time samples the guest decoded at other versions. - mismatched( - |runtime| runtime.boot_contract = 4, - IdentityPart::BootContract, - ); - mismatched( - |runtime| runtime.control_contract.push_str("-next"), - IdentityPart::ControlContract, - ); - mismatched( - |runtime| runtime.control_session += 1, - IdentityPart::ControlSession, - ); -} - -#[test] -fn journals_of_each_launch_step_load() { - let (_directory, store) = store(); - install(&store, "launch-prepared.json", JOURNAL_NAME); - let prepared = store.journal().unwrap().unwrap(); - assert_eq!(prepared.state(), LaunchState::Prepared); - assert_eq!(prepared.process(), None); - assert_eq!(prepared.log().identity.index, 393_219); - assert_eq!(prepared.runtime(), &runtime()); - install(&store, "launch-spawned.json", JOURNAL_NAME); - let spawned = store.journal().unwrap().unwrap(); - assert_eq!(spawned.state(), LaunchState::Spawned); - assert_eq!(spawned.launch(), prepared.launch()); - assert_eq!(spawned.process().unwrap().pid, 4242); - // The journal names a process of another host's boot, which recovery never signals. - let mut locked = store.lock(None).unwrap(); - assert_eq!(locked.recover().unwrap(), Recovery::Exited); - assert!(store.journal().unwrap().is_none()); -} - -#[test] -fn unsupported_and_corrupt_records_fail_clearly() { - let (_directory, store) = store(); - let mut locked = store.lock(None).unwrap(); - for (name, as_name, expected) in [ - ( - "runtime-cli-format-1.json", - RUNTIME_NAME, - "has the unsupported format 1;", - ), - ( - "runtime-instance-format-3.json", - RUNTIME_NAME, - "has the unsupported format 3;", - ), - ( - "launch-instance-format-3.json", - JOURNAL_NAME, - "has the unsupported format 3;", - ), - ( - "runtime-format-5.json", - RUNTIME_NAME, - "has the unsupported format 5;", - ), - ("runtime-truncated.json", RUNTIME_NAME, "is malformed"), - ("runtime-unknown-field.json", RUNTIME_NAME, "is malformed"), - ("launch-without-log.json", JOURNAL_NAME, "is malformed"), - ] { - let fixture = fixture(&format!("invalid/{name}")); - let path = store.dir().join(as_name); - fs::copy(&fixture, &path).unwrap(); - let read = if as_name == RUNTIME_NAME { - store.runtime().map(drop) - } else { - store.journal().map(drop) - }; - let error = read.unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io, "{name}: {error}"); - assert!(error.message().contains(expected), "{name}: {error}"); - // Nothing reads past the record or rewrites it: a launch and a recovery fail the same - // way, and the record stays as it was. - let launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .map(drop); - assert!(launch.unwrap_err().message().contains(expected), "{name}"); - if as_name == JOURNAL_NAME { - assert!( - locked.recover().unwrap_err().message().contains(expected), - "{name}" - ); - } - assert_eq!(fs::read(&path).unwrap(), fs::read(&fixture).unwrap()); - fs::remove_file(&path).unwrap(); - } -} - -/// A dropped lock is free at once, also while a process that another thread spawns still holds a -/// copy of its descriptor, as every process does from its fork until its exec when its spawn runs -/// a `pre_exec` hook: `flock` keeps a lock while any copy of its open file description remains, -/// so the lock is released before its file closes. -#[cfg(target_os = "linux")] -#[test] -fn a_dropped_lock_is_free_while_a_spawning_process_holds_its_descriptor() { - use std::os::fd::AsRawFd; - use std::os::unix::process::CommandExt; - - use nix::libc; - - let _spawns = serialized(); - let (_directory, store) = store(); - let locked = store.lock(None).unwrap(); - let (mut forked, signal) = io::pipe().unwrap(); - let spawner = thread::spawn(move || { - let descriptor = signal.as_raw_fd(); - let mut command = Command::new("true"); - // SAFETY: the hook makes only async-signal-safe system calls and allocates nothing, as - // code that runs between fork and exec must. - unsafe { - command.pre_exec(move || { - // The process holds a copy of every descriptor of the test now: say so, and keep - // them for a while, through any interruption. - let interrupted = - || io::Error::last_os_error().kind() == io::ErrorKind::Interrupted; - loop { - match libc::write(descriptor, b"f".as_ptr().cast(), 1) { - 1 => break, - -1 if interrupted() => {} - _ => return Err(io::Error::last_os_error()), - } - } - while libc::poll(std::ptr::null_mut(), 0, 500) < 0 { - if !interrupted() { - return Err(io::Error::last_os_error()); - } - } - Ok(()) - }); - } - let status = command.status(); - drop(signal); - status - }); - let mut byte = [0]; - forked.read_exact(&mut byte).unwrap(); - drop(locked); - let relocked = store.lock(Some(Instant::now())).map(drop); - assert!(spawner.join().unwrap().unwrap().success()); - relocked.unwrap(); -} diff --git a/nvx-rs/tests/supervisor.rs b/nvx-rs/tests/supervisor.rs index f5a29252a..7cc91fbfa 100644 --- a/nvx-rs/tests/supervisor.rs +++ b/nvx-rs/tests/supervisor.rs @@ -1,21 +1,18 @@ -//! Verified process references, event-driven exit waits, and the supervision of VMM processes, -//! through the public API of `nvx::host::process`, `nvx::host::supervisor`, and -//! `nvx::host::store`, as an orchestrator uses them. +//! Verified process references, event-driven exit waits, and the spawn of VMM processes, +//! through the public API of `nvx::host::process` and the low-level spawn that +//! `nvx::host::supervisor` exports, as an orchestrator uses them. //! -//! The processes are this test executable, run again as one of three fixtures. A VMM fixture +//! The processes are this test executable, run again as one of two fixtures. A VMM fixture //! reads the capability that `nvx::host::supervisor::spawn` passes on its standard input, as //! OpenVMM does, reports its descriptors on Linux, writes a line through each descriptor or handle //! that its arguments name as inherited, and runs until a file named `exit` appears in its working //! directory. A launcher fixture spawns a VMM fixture, prints the VMM's reference as an -//! orchestrator would record it, and runs until its standard input closes. An orchestrator fixture -//! launches a VMM fixture through the supervisor in a sandbox store, and exits without unwinding -//! after the launch step that the test names, as a crash would, or, once it has committed the -//! launch, runs until its standard input closes. A fixture that the test kills, or whose job -//! object the test closes, stands for an orchestrator that crashed and restarts within the same -//! boot: the test then finds the VMM again, as the restarted orchestrator would. +//! orchestrator would record it, and runs until its standard input closes. A launcher that the +//! test kills, or whose job object the test closes, stands for an orchestrator that crashed and +//! restarts within the same boot: the test then finds the VMM again, as the restarted +//! orchestrator would. #![cfg(all(feature = "host", any(target_os = "linux", windows)))] -use std::convert::Infallible; use std::env; use std::ffi::OsString; use std::fs::{self, File}; @@ -28,18 +25,9 @@ use std::sync::{Mutex, MutexGuard, PoisonError, mpsc}; use std::thread; use std::time::{Duration, Instant}; -use nvx::ErrorKind; -use nvx::host::launch::MAX_TIMEOUT; use nvx::host::process::{BootId, Opened, Process, ProcessRef, WaitMethod}; -#[cfg(windows)] -use nvx::host::store::FORMAT; -use nvx::host::store::{ - ArtifactIdentity, IdentityPart, LaunchId, LaunchState, LockedStore, RUNTIME_NAME, Recovery, - RuntimeIdentity, SandboxStore, -}; -use nvx::host::supervisor::{self, Inherited, Launched, Reattached, StopOutcome, Vmm, VmmCommand}; +use nvx::host::supervisor::{self, Inherited, Launched, VmmCommand}; use nvx::protocol::CAPABILITY_LEN; -use serde_json::json; /// File whose presence in its working directory marks a VMM fixture. Without it, the fixture /// returns at once, as it does when `cargo test -- --ignored` runs it. @@ -54,24 +42,13 @@ const DESCRIPTORS_REPORT: &str = "descriptors"; /// fixture inherited, and through which it writes a line. Libtest takes the argument for a test /// name that matches no test. const INHERITED_PREFIX: &str = "inherited="; -/// Environment variable that marks a launcher fixture, and names the working directory of the VMM -/// of a launcher or orchestrator fixture. +/// Environment variable that marks a launcher fixture, and names the working directory of its +/// VMM. const LAUNCHER_DIR: &str = "NVX_SUPERVISOR_TEST_VMM_DIR"; -/// Environment variable that asks a launcher or orchestrator fixture to start its VMM outside its -/// job object. +/// Environment variable that asks a launcher fixture to start its VMM outside its job object. const LAUNCHER_BREAKAWAY: &str = "NVX_SUPERVISOR_TEST_BREAKAWAY"; -/// Environment variable that marks an orchestrator fixture, and names its sandbox store. -const ORCHESTRATOR_STORE: &str = "NVX_SUPERVISOR_TEST_STORE"; -/// Environment variable that names the launch step after which an orchestrator fixture crashes: -/// `before-spawn`, `after-spawn`, or `after-identity`; or `committed`, after which it runs until -/// its standard input closes. -const ORCHESTRATOR_STEP: &str = "NVX_SUPERVISOR_TEST_STEP"; -/// Environment variable that asks an orchestrator fixture to launch the fake OpenVMM, which -/// serves its control endpoint, instead of a VMM fixture. -#[cfg(feature = "testing")] -const ORCHESTRATOR_FAKE: &str = "NVX_SUPERVISOR_TEST_FAKE"; -/// Marker before the reference that a launcher or orchestrator fixture prints. Libtest's own -/// output, the name of the fixture, precedes it on its line. +/// Marker before the reference that a launcher fixture prints. Libtest's own output, the name of +/// the fixture, precedes it on its line. const REFERENCE_PREFIX: &str = "vmm-reference "; /// The capability of every VMM of these tests. const CAPABILITY: [u8; CAPABILITY_LEN] = [0x5a; CAPABILITY_LEN]; @@ -183,90 +160,6 @@ fn launcher_fixture() { io::copy(&mut input, &mut io::sink()).unwrap(); } -/// The orchestrator fixture: when the test writes a line to its standard input, launches a VMM -/// fixture, or the fake OpenVMM, through the supervisor in the store that [`ORCHESTRATOR_STORE`] -/// names, and stops after the step that [`ORCHESTRATOR_STEP`] names. It prints the reference of -/// the VMM once it has spawned it. -#[test] -#[ignore = "fixture that the supervision tests run as an orchestrator"] -fn orchestrator_fixture() { - let Some(store) = env::var_os(ORCHESTRATOR_STORE) else { - return; - }; - let step = env::var(ORCHESTRATOR_STEP).unwrap(); - let directory = PathBuf::from(env::var_os(LAUNCHER_DIR).unwrap()); - let breakaway = env::var_os(LAUNCHER_BREAKAWAY).is_some(); - let mut input = io::stdin().lock(); - input.read_line(&mut String::new()).unwrap(); - let store = SandboxStore::open(store).unwrap(); - let mut locked = store.lock(None).unwrap(); - let command = |endpoint: &str| -> nvx::Result> { - #[cfg(feature = "testing")] - if env::var_os(ORCHESTRATOR_FAKE).is_some() { - return Ok(fake_openvmm(&directory, endpoint, &store)); - } - let _ = endpoint; - Ok(vmm_command(&directory, breakaway)) - }; - match step.as_str() { - "before-spawn" => { - let _ = supervisor::launch( - &mut locked, - &LaunchId::generate().unwrap(), - &runtime(), - &CAPABILITY, - |_| crash(), - ); - unreachable!("the orchestrator crashed before its spawn"); - } - "after-spawn" => { - // The steps of `supervisor::launch`, up to the spawn. - let mut launch = locked - .begin_launch(&LaunchId::generate().unwrap(), &runtime(), &CAPABILITY) - .unwrap(); - let command = command(launch.endpoint()).unwrap(); - let log = launch.take_log().unwrap(); - let launched = supervisor::spawn(&command, &CAPABILITY, log).unwrap(); - print_reference(launched.process().unwrap().reference()); - crash(); - } - "after-identity" => { - let starting = supervisor::launch( - &mut locked, - &LaunchId::generate().unwrap(), - &runtime(), - &CAPABILITY, - command, - ) - .unwrap(); - print_reference(starting.process().reference()); - crash(); - } - "committed" => { - let starting = supervisor::launch( - &mut locked, - &LaunchId::generate().unwrap(), - &runtime(), - &CAPABILITY, - command, - ) - .unwrap(); - let vmm = starting.commit().unwrap(); - drop(locked); - print_reference(vmm.record().process()); - io::copy(&mut input, &mut io::sink()).unwrap(); - } - step => panic!("unknown launch step {step}"), - } -} - -/// Ends this process at once, once its output is flushed, without unwinding or running -/// destructors, as a crash would. -fn crash() -> ! { - io::stdout().flush().unwrap(); - std::process::exit(0) -} - /// Prints `reference` as JSON, after [`REFERENCE_PREFIX`]. fn print_reference(reference: &ProcessRef) { println!( @@ -291,23 +184,6 @@ fn fixture_arguments(name: &str) -> Vec { .to_vec() } -/// Returns the identity `sha256:` and 64 copies of `digit`. -fn artifact(digit: char) -> ArtifactIdentity { - ArtifactIdentity::sha256(&digit.to_string().repeat(64)).unwrap() -} - -/// Returns the runtime identity of the launches of these tests. -fn runtime() -> RuntimeIdentity { - RuntimeIdentity::new(artifact('1'), artifact('2'), artifact('3')) -} - -/// Returns a sandbox store in a fresh temporary directory, which it returns too. -fn sandbox() -> (tempfile::TempDir, SandboxStore) { - let directory = tempfile::tempdir().unwrap(); - let store = SandboxStore::create(directory.path().join("sandbox")).unwrap(); - (directory, store) -} - /// Returns the command of a VMM fixture in `directory`, which it marks, outside the caller's job /// object if `breakaway`. fn vmm_command(directory: &Path, breakaway: bool) -> VmmCommand<'static> { @@ -321,28 +197,6 @@ fn vmm_command(directory: &Path, breakaway: bool) -> VmmCommand<'static> { command } -/// Returns the command of the fake OpenVMM in `directory`, which serves `endpoint` at once, and -/// writes its outcome report to that of `store`. -#[cfg(feature = "testing")] -fn fake_openvmm(directory: &Path, endpoint: &str, store: &SandboxStore) -> VmmCommand<'static> { - use nvx::host::launch::LaunchSpec; - use nvx::{Artifacts, Hypervisor}; - - fs::write(directory.join("vmlinux"), b"kernel").unwrap(); - fs::write(directory.join("initramfs.cpio.gz"), b"initrd").unwrap(); - let artifacts = Artifacts::new( - env!("CARGO_BIN_EXE_nvx-fake-openvmm"), - directory.join("vmlinux"), - directory.join("initramfs.cpio.gz"), - ); - let mut spec = LaunchSpec::new(artifacts, Hypervisor::platform_default().unwrap()); - spec.kernel_parameters = "fake_boot_delay_ms=0".to_owned(); - let arguments = spec - .openvmm_arguments(endpoint, &store.outcome_path()) - .unwrap(); - VmmCommand::new(&spec.openvmm, arguments, directory) -} - /// Spawns a VMM fixture in `directory` as OpenVMM is spawned, outside the caller's job object if /// `breakaway`. fn spawn_vmm(directory: &Path, breakaway: bool) -> Launched { @@ -350,69 +204,8 @@ fn spawn_vmm(directory: &Path, breakaway: bool) -> Launched { supervisor::spawn(&vmm_command(directory, breakaway), &CAPABILITY, log).unwrap() } -/// Launches a VMM fixture in `vmm` through the supervisor in `locked`, under [`runtime`], and -/// commits the launch, as an orchestrator does once the VMM serves its control endpoint. -fn launch_vmm(locked: &mut LockedStore, vmm: &mut VmmDirectory) -> Vmm { - let starting = supervisor::launch( - locked, - &LaunchId::generate().unwrap(), - &runtime(), - &CAPABILITY, - |endpoint| { - assert!(!endpoint.is_empty()); - Ok(vmm_command(vmm.path(), false)) - }, - ) - .unwrap(); - vmm.record(starting.process().reference()); - // The launch recorded its VMM before it returned. - assert_eq!(starting.journal().state(), LaunchState::Spawned); - assert_eq!( - starting.journal().process(), - Some(starting.process().reference()) - ); - starting.commit().unwrap() -} - -/// Reattaches until the result is determinate, as a caller retries an indeterminate reattach, and -/// returns it. -/// -/// On Windows, a scanner that briefly opens the log of a launch can keep its claim held for a -/// moment. -fn reattach_until_determinate(locked: &mut LockedStore) -> Reattached { - let deadline = Instant::now() + TIMEOUT; - loop { - match supervisor::reattach(locked, &runtime()).unwrap() { - Reattached::Indeterminate(reason) if Instant::now() < deadline => { - eprintln!("reattach is indeterminate: {reason}"); - thread::sleep(SHORT_WAIT); - } - reattached => return reattached, - } - } -} - -/// Writes the runtime record of a VM whose VMM is `process` to `store`, as another orchestrator -/// would have committed it. -#[cfg(windows)] -fn write_record(store: &SandboxStore, process: &ProcessRef) { - let record = json!({ - "format": FORMAT, - "launch": "5d41402abc4b2a76b9719d911017c592", - "process": process, - "endpoint": r"\\.\pipe\openvmm-microvm-5d41402abc4b2a76b9719d911017c592", - "runtime": runtime(), - }); - fs::write( - store.dir().join(RUNTIME_NAME), - serde_json::to_vec(&record).unwrap(), - ) - .unwrap(); -} - /// The working directory of a VMM fixture. Dropping it ends the fixture, or a VMM that ignores -/// the exit marker, as the fake OpenVMM does, and waits for it to exit, so that a failed test -/// leaves no process behind. +/// the exit marker, and waits for it to exit, so that a failed test leaves no process behind. struct VmmDirectory { /// The reference of the VMM, once the test knows it. reference: Option, @@ -460,7 +253,7 @@ impl Drop for VmmDirectory { } } -/// A launcher or orchestrator fixture, which the test drives through its standard streams. +/// A launcher fixture, which the test drives through its standard streams. struct Launcher { /// The fixture's process. child: Child, @@ -482,24 +275,6 @@ impl Launcher { Self::spawn(command) } - /// Returns the command of an orchestrator that launches a VMM in `directory` through `store`, - /// and stops after `step`. - fn orchestrator(store: &SandboxStore, directory: &Path, step: &str) -> Command { - let mut command = Command::new(env::current_exe().unwrap()); - command - .args(fixture_arguments("orchestrator_fixture")) - .env(ORCHESTRATOR_STORE, store.dir()) - .env(ORCHESTRATOR_STEP, step) - .env(LAUNCHER_DIR, directory); - command - } - - /// Starts an orchestrator that launches a VMM in `directory` through `store`, and stops after - /// `step`. - fn orchestrate(store: &SandboxStore, directory: &Path, step: &str) -> Self { - Self::spawn(Self::orchestrator(store, directory, step)) - } - /// Starts the fixture that `command` runs, with its standard input and output piped. fn spawn(mut command: Command) -> Self { command.stdin(Stdio::piped()).stdout(Stdio::piped()); @@ -594,47 +369,6 @@ fn processes_can_be_shared_between_threads() { fn shared() {} shared::(); shared::(); - shared::(); -} - -#[test] -#[cfg(windows)] -fn long_stores_launch_with_a_short_working_directory() { - use std::os::windows::ffi::OsStrExt; - - let _spawns = serialized(); - let root = tempfile::tempdir().unwrap(); - let mut parent = root.path().to_path_buf(); - while parent.as_os_str().encode_wide().count() < 320 { - parent.push("long-state-root-component"); - } - fs::create_dir_all(&parent).unwrap(); - let store = SandboxStore::create(parent.join("sandbox")).unwrap(); - let log = File::create(parent.join("refused.log")).unwrap(); - let error = supervisor::spawn(&vmm_command(&parent, false), &CAPABILITY, log).unwrap_err(); - assert_eq!(error.kind(), io::ErrorKind::InvalidInput); - assert!(error.to_string().contains("working directory")); - assert!(error.to_string().contains("258")); - - let mut vmm = VmmDirectory::new(); - let mut locked = store.lock(None).unwrap(); - let running = launch_vmm(&mut locked, &mut vmm); - assert!( - running - .process() - .wait(Instant::now() + SHORT_WAIT) - .unwrap() - .is_none() - ); - let stopped = running - .stop(&mut locked, Instant::now() + TIMEOUT, None, |_, _| { - vmm.end(); - Ok::<(), Infallible>(()) - }) - .unwrap(); - assert_eq!(stopped.outcome, StopOutcome::Graceful); - assert!(stopped.graceful_error.is_none()); - assert!(store.runtime().unwrap().is_none()); } #[test] @@ -835,300 +569,6 @@ fn references_that_name_no_process_are_never_opened() { assert!(process.has_exited().unwrap()); } -#[test] -fn a_launch_that_crashes_before_its_spawn_is_ended_on_reattach() { - let _spawns = serialized(); - let (_directory, store) = sandbox(); - let vmm = VmmDirectory::new(); - let mut orchestrator = Launcher::orchestrate(&store, vmm.path(), "before-spawn"); - assert_eq!(orchestrator.try_launch(), None); - assert!(orchestrator.wait().success()); - assert_eq!( - store.journal().unwrap().unwrap().state(), - LaunchState::Prepared - ); - assert!(!vmm.path().join(VMM_MARKER).exists()); - // The crash released the orchestrator's lock, and the claim of the log that it never passed - // on. - let mut locked = store.lock(Some(Instant::now() + TIMEOUT)).unwrap(); - match reattach_until_determinate(&mut locked) { - Reattached::Exited { - record: None, - recovery: Recovery::Exited, - } => {} - other => panic!("unexpected reattach: {other:?}"), - } - assert!(store.journal().unwrap().is_none()); - assert!(store.read_capability().is_err()); -} - -#[test] -fn an_unrecorded_vmm_that_serves_no_endpoint_is_never_signaled() { - let _spawns = serialized(); - let (_directory, store) = sandbox(); - let mut vmm = VmmDirectory::new(); - let mut orchestrator = Launcher::orchestrate(&store, vmm.path(), "after-spawn"); - let reference = orchestrator.launch_reference(); - vmm.record(&reference); - assert!(orchestrator.wait().success()); - assert_eq!( - store.journal().unwrap().unwrap().state(), - LaunchState::Prepared - ); - // The VMM, which is no child of this process, holds the claim of its launch, and serves no - // control endpoint that would name it, so reattach can neither prove that it exited nor - // verify it: it signals nothing, and keeps the journal. - let process = open_running(&reference); - let mut locked = store.lock(Some(Instant::now() + TIMEOUT)).unwrap(); - let Reattached::Indeterminate(reason) = supervisor::reattach(&mut locked, &runtime()).unwrap() - else { - panic!("reattach ended a launch whose VMM runs unverified"); - }; - assert!( - reason.contains("no process serves its control endpoint yet"), - "{reason}" - ); - assert!(!process.has_exited().unwrap()); - assert!(store.journal().unwrap().is_some()); - // Once the VMM exits, the claim that it released proves it. - vmm.end(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); - match reattach_until_determinate(&mut locked) { - Reattached::Exited { - record: None, - recovery: Recovery::Exited, - } => {} - other => panic!("unexpected reattach: {other:?}"), - } - assert!(store.journal().unwrap().is_none()); -} - -#[cfg(feature = "testing")] -#[test] -fn an_unrecorded_vmm_that_serves_its_endpoint_is_ended_on_reattach() { - let _spawns = serialized(); - let (_directory, store) = sandbox(); - let mut vmm = VmmDirectory::new(); - let mut command = Launcher::orchestrator(&store, vmm.path(), "after-spawn"); - command.env(ORCHESTRATOR_FAKE, "1"); - let mut orchestrator = Launcher::spawn(command); - let reference = orchestrator.launch_reference(); - vmm.record(&reference); - assert!(orchestrator.wait().success()); - let process = open_running(&reference); - // Once the fake OpenVMM serves its endpoint, which names it, reattach verifies that it holds - // the claimed log, and ends it. - let mut locked = store.lock(Some(Instant::now() + TIMEOUT)).unwrap(); - match reattach_until_determinate(&mut locked) { - Reattached::Exited { - record: None, - recovery: Recovery::Terminated(terminated), - } => assert_eq!(terminated, reference), - other => panic!("unexpected reattach: {other:?}"), - } - assert_ended(&process); - assert!(store.journal().unwrap().is_none()); -} - -#[test] -fn a_launch_that_crashes_before_it_commits_ends_its_recorded_vmm_on_reattach() { - let _spawns = serialized(); - let (_directory, store) = sandbox(); - let mut vmm = VmmDirectory::new(); - let mut orchestrator = Launcher::orchestrate(&store, vmm.path(), "after-identity"); - let reference = orchestrator.launch_reference(); - vmm.record(&reference); - assert!(orchestrator.wait().success()); - let journal = store.journal().unwrap().unwrap(); - assert_eq!(journal.state(), LaunchState::Spawned); - assert_eq!(journal.process(), Some(&reference)); - let process = open_running(&reference); - // The VMM is no child of this process, and its verified reference ends it. - let mut locked = store.lock(Some(Instant::now() + TIMEOUT)).unwrap(); - match supervisor::reattach(&mut locked, &runtime()).unwrap() { - Reattached::Exited { - record: None, - recovery: Recovery::Terminated(terminated), - } => assert_eq!(terminated, reference), - other => panic!("unexpected reattach: {other:?}"), - } - assert_ended(&process); - assert!(store.journal().unwrap().is_none()); - assert!(store.read_capability().is_err()); -} - -#[test] -fn a_committed_vmm_outlives_its_orchestrator_and_stops_gracefully() { - let _spawns = serialized(); - let (_directory, store) = sandbox(); - let mut vmm = VmmDirectory::new(); - let mut orchestrator = Launcher::orchestrate(&store, vmm.path(), "committed"); - let reference = orchestrator.launch_reference(); - vmm.record(&reference); - orchestrator.finish(); - - // The restarted orchestrator holds the VMM, which is no child of it, again. - let mut locked = store.lock(Some(Instant::now() + TIMEOUT)).unwrap(); - let Reattached::Live(running) = supervisor::reattach(&mut locked, &runtime()).unwrap() else { - panic!("the committed VMM was not reattached"); - }; - assert_eq!(running.record().process(), &reference); - assert_eq!(running.record().runtime(), &runtime()); - assert_eq!(running.process().wait_method(), WaitMethod::Event); - assert!(!running.wait(Instant::now() + SHORT_WAIT).unwrap()); - assert_eq!(store.read_capability().unwrap(), CAPABILITY); - - // The graceful step asks the VMM to exit through the caller's own channel, and the stop waits - // for the VMM's exit event. - let stopped = running - .stop( - &mut locked, - Instant::now() + TIMEOUT, - None, - |stopping, _deadline| { - assert_eq!(stopping.endpoint(), running.endpoint()); - vmm.end(); - Ok::<(), Infallible>(()) - }, - ) - .unwrap(); - assert_eq!(stopped.outcome, StopOutcome::Graceful); - assert!(stopped.graceful_error.is_none()); - assert_ended(running.process()); - // The stop cleared the runtime record, and kept the log. - assert!(store.runtime().unwrap().is_none()); - assert!(store.read_capability().is_err()); - assert!(store.log_path().exists()); - match supervisor::reattach(&mut locked, &runtime()).unwrap() { - Reattached::Exited { - record: None, - recovery: Recovery::Clean, - } => {} - other => panic!("unexpected reattach: {other:?}"), - } -} - -#[test] -fn a_vmm_of_another_runtime_is_ended_only_by_its_termination() { - let _spawns = serialized(); - let (_directory, store) = sandbox(); - let (_other_directory, other) = sandbox(); - let mut vmm = VmmDirectory::new(); - let mut locked = store.lock(None).unwrap(); - // The orchestrator that launched the VMM restarts. - drop(launch_vmm(&mut locked, &mut vmm)); - - // Another build of the orchestrator would launch another initramfs. - let mut upgraded = runtime(); - upgraded.initramfs = artifact('4'); - let Reattached::Mismatched { - vmm: running, - differences, - } = supervisor::reattach(&mut locked, &upgraded).unwrap() - else { - panic!("a VMM of another runtime was not reported as mismatched"); - }; - assert_eq!(differences, [IdentityPart::Initramfs]); - // The VMM heeds no graceful step of this build. Without the forced step, the stop gives up at - // its deadline, and leaves the VMM running, with its record. - let ignored = |_: &Vmm, _: Instant| Ok::<(), Infallible>(()); - let stopped = running - .stop(&mut locked, Instant::now() + SHORT_WAIT, None, ignored) - .unwrap(); - assert_eq!(stopped.outcome, StopOutcome::TimedOut); - // A termination through another sandbox's store, or with a bound that is zero or too long, - // signals nothing. - let error = running - .terminate(&mut other.lock(None).unwrap(), TIMEOUT) - .unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Other, "{error}"); - for bound in [Duration::ZERO, MAX_TIMEOUT + Duration::from_secs(1)] { - let error = running.terminate(&mut locked, bound).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Unsupported, "{bound:?}"); - } - assert!(!running.has_exited().unwrap()); - assert!(store.runtime().unwrap().is_some()); - - // The termination ends it through its verified handle, with no graceful step, and clears its - // runtime record. The log outlives it. - assert_eq!( - running.terminate(&mut locked, TIMEOUT).unwrap(), - StopOutcome::Forced - ); - assert_ended(running.process()); - assert!(store.runtime().unwrap().is_none()); - assert!(store.read_capability().is_err()); - assert!(store.log_path().exists()); - // A VMM that exited is not signaled again. - assert_eq!( - running.terminate(&mut locked, TIMEOUT).unwrap(), - StopOutcome::Graceful - ); - match supervisor::reattach(&mut locked, &upgraded).unwrap() { - Reattached::Exited { - record: None, - recovery: Recovery::Clean, - } => {} - other => panic!("unexpected reattach: {other:?}"), - } -} - -#[test] -fn records_of_reused_ids_and_earlier_boots_name_vmms_that_exited() { - let _spawns = serialized(); - let (_directory, store) = sandbox(); - let mut vmm = VmmDirectory::new(); - let mut locked = store.lock(None).unwrap(); - let launched = launch_vmm(&mut locked, &mut vmm); - let reference = launched.record().process().clone(); - let path = store.dir().join(RUNTIME_NAME); - let recorded: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap(); - // The recorded ID with another start time names another process, which reused the ID after - // the VMM exited, and a record of another boot names a VMM that exited with that boot. Under - // another runtime identity too, neither is mismatched, which would end it: only a VMM that - // reattach verified is. - let mut other_boot = *reference.boot.as_bytes(); - other_boot[0] ^= 1; - let mut upgraded = runtime(); - upgraded.kernel = artifact('5'); - for (field, value) in [ - ("startTime", json!(reference.start_time + 1)), - ("boot", json!(BootId::from_bytes(other_boot))), - ] { - let mut tampered = recorded.clone(); - tampered["process"][field] = value; - fs::write(&path, serde_json::to_vec(&tampered).unwrap()).unwrap(); - let Reattached::Exited { - record: Some(record), - recovery: Recovery::Clean, - } = supervisor::reattach(&mut locked, &upgraded).unwrap() - else { - panic!("a record with another {field} named a live VMM"); - }; - assert_ne!(record.process(), &reference); - assert!(store.runtime().unwrap().is_none()); - // The process that has the ID was not signaled. - assert!(!launched.has_exited().unwrap()); - } - // The true record still names the VMM. - fs::write(&path, serde_json::to_vec(&recorded).unwrap()).unwrap(); - let Reattached::Live(running) = supervisor::reattach(&mut locked, &runtime()).unwrap() else { - panic!("the recorded VMM was not reattached"); - }; - let stopped = running - .stop( - &mut locked, - Instant::now() + TIMEOUT, - Some(TIMEOUT), - |_, _| { - vmm.end(); - Ok::<(), Infallible>(()) - }, - ) - .unwrap(); - assert_eq!(stopped.outcome, StopOutcome::Graceful); -} - /// Sets its flag when dropped, also when its thread panics. struct SetOnDrop<'a>(&'a AtomicBool); @@ -1462,64 +902,6 @@ fn sleeper() -> Command { command } -#[cfg(windows)] -#[test] -fn a_vmm_that_breaks_away_survives_its_orchestrators_job() { - let _spawns = serialized(); - let (_directory, store) = sandbox(); - let mut vmm = VmmDirectory::new(); - let job = job::Job::new(true); - let mut command = Launcher::orchestrator(&store, vmm.path(), "committed"); - command.env(LAUNCHER_BREAKAWAY, "1"); - let mut orchestrator = Launcher::spawn(command); - job.assign(&orchestrator.child); - let reference = orchestrator.launch_reference(); - vmm.record(&reference); - // The orchestrator crashes with its job, and its VMM, which broke away, survives it. - drop(job); - orchestrator.wait(); - let mut locked = store.lock(Some(Instant::now() + TIMEOUT)).unwrap(); - let Reattached::Live(running) = supervisor::reattach(&mut locked, &runtime()).unwrap() else { - panic!("the VMM that broke away was not reattached"); - }; - assert_eq!(running.record().process(), &reference); - let stopped = running - .stop(&mut locked, Instant::now() + TIMEOUT, None, |_, _| { - vmm.end(); - Ok::<(), Infallible>(()) - }) - .unwrap(); - assert_eq!(stopped.outcome, StopOutcome::Graceful); - assert!(store.runtime().unwrap().is_none()); -} - -#[cfg(windows)] -#[test] -fn a_vmm_in_its_orchestrators_job_ends_with_it() { - let _spawns = serialized(); - let (_directory, store) = sandbox(); - let mut vmm = VmmDirectory::new(); - let job = job::Job::new(false); - let mut orchestrator = Launcher::orchestrate(&store, vmm.path(), "committed"); - job.assign(&orchestrator.child); - let reference = orchestrator.launch_reference(); - vmm.record(&reference); - let process = open_running(&reference); - // The VMM did not break away, so closing the job ends it with its orchestrator. - drop(job); - orchestrator.wait(); - assert!(process.wait(Instant::now() + TIMEOUT).unwrap().is_some()); - let mut locked = store.lock(Some(Instant::now() + TIMEOUT)).unwrap(); - match supervisor::reattach(&mut locked, &runtime()).unwrap() { - Reattached::Exited { - record: Some(record), - recovery: Recovery::Clean, - } => assert_eq!(record.process(), &reference), - other => panic!("unexpected reattach: {other:?}"), - } - assert!(store.runtime().unwrap().is_none()); -} - #[cfg(windows)] #[test] fn a_job_that_ends_its_vmm_wakes_the_waiters() { @@ -1558,22 +940,6 @@ fn a_process_that_this_user_may_not_query_has_an_unknown_state() { child.0.try_wait().unwrap().is_none(), "the process was ended" ); - - // A sandbox whose runtime record names it cannot tell whether its VMM runs: reattach keeps - // the record, and signals nothing. - let (_directory, store) = sandbox(); - write_record(&store, &reference); - let mut locked = store.lock(None).unwrap(); - let Reattached::Indeterminate(reason) = supervisor::reattach(&mut locked, &runtime()).unwrap() - else { - panic!("a VMM whose identity cannot be checked was classified"); - }; - assert!(reason.contains("cannot be examined"), "{reason}"); - assert!(store.runtime().unwrap().is_some()); - assert!( - child.0.try_wait().unwrap().is_none(), - "the process was ended" - ); } #[cfg(windows)] @@ -1607,55 +973,6 @@ fn a_process_that_this_user_may_not_terminate_is_waited_for_but_never_signaled() assert_ended(&process); } -#[cfg(windows)] -#[test] -fn a_vmm_of_another_runtime_that_cannot_be_terminated_keeps_its_record() { - use windows_sys::Win32::System::Threading::{ - PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE, - }; - - let _spawns = serialized(); - let (mut child, reference) = - restricted::spawn(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE); - if restricted::may_open(&child.0, PROCESS_TERMINATE) { - eprintln!("skipping: this user's privileges bypass the security descriptors of processes"); - return; - } - // Another build of the orchestrator, which would launch another kernel, finds the VM. - let (_directory, store) = sandbox(); - write_record(&store, &reference); - let mut upgraded = runtime(); - upgraded.kernel = artifact('5'); - let mut locked = store.lock(None).unwrap(); - let Reattached::Mismatched { vmm, differences } = - supervisor::reattach(&mut locked, &upgraded).unwrap() - else { - panic!("a VMM of another runtime was not reported as mismatched"); - }; - assert_eq!(differences, [IdentityPart::Kernel]); - // The termination fails, and reports it: the VMM still runs, and keeps its record. - let error = vmm.terminate(&mut locked, TIMEOUT).unwrap_err(); - assert_eq!(error.kind(), ErrorKind::Io, "{error}"); - assert_eq!( - error.message(), - format!("cannot terminate VMM process {}", reference.pid) - ); - assert!(store.runtime().unwrap().is_some()); - assert!( - child.0.try_wait().unwrap().is_none(), - "the process was ended" - ); - - // Once the VMM has exited, the next termination finds it so, and clears the record. - child.0.kill().unwrap(); - assert!(vmm.wait(Instant::now() + TIMEOUT).unwrap()); - assert_eq!( - vmm.terminate(&mut locked, TIMEOUT).unwrap(), - StopOutcome::Graceful - ); - assert!(store.runtime().unwrap().is_none()); -} - /// Processes whose security descriptor restricts what this user may do with them. #[cfg(windows)] mod restricted {