diff --git a/doc/acceptance-case-matrix.md b/doc/acceptance-case-matrix.md index 77ce7a011..9ac21e0fe 100644 --- a/doc/acceptance-case-matrix.md +++ b/doc/acceptance-case-matrix.md @@ -324,7 +324,7 @@ suite in its shards, and the public sandbox scenarios in its guest and sandbox j | `l3-l4-egress-protocols` | kept | | | `lifecycle` | kept | | | `managed-exec-config` | kept | A [cold cell](#cold-boots): the EROFS layer over a scratch upper, through `provision` and `start` | -| `managed-lifecycle` | kept | | +| `managed-lifecycle` | kept | It runs the ignored unit test of the host's private control session in `nvx-rs/src/host/session/tests/managed_lifecycle.rs`, with the `cli` feature, which SL6.12 (#1127) moved there from the crate's integration tests | | `network-snapshot` | kept | | | `platform-aggregate-export` | kept | | | `platform-filesystem` | kept | | @@ -504,9 +504,9 @@ this matrix. The README's own code is no doctest. | `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)` | 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/lib.rs - (line 58)` | rewritten | Its `Instance` and sandbox store leave (SL6.1, #1121): `nvx-rs/src/lib.rs - (line 62)` 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)` | 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/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 62)` 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/adversarial-testing.md b/doc/adversarial-testing.md index 4ab831faa..52ebadb86 100644 --- a/doc/adversarial-testing.md +++ b/doc/adversarial-testing.md @@ -157,7 +157,8 @@ metadata and summary record the absolute target artifact root. Besides the guest and OpenVMM artifacts, a target needs nvx-rs, the Rust command-line interface that runs NVX's sandboxes and microVMs ([Usage](usage.md#nvx-rs)). Of the catalogued scenarios, `managed-lifecycle` -runs the nvx-rs test `managed_lifecycle` with Cargo, and +runs the nvx-rs unit test of its host control session, +`host::session::tests::managed_lifecycle`, with Cargo, and `l3-l4-egress-policy` compiles its egress policy with nvx-rs and runs `nvx-rs run`. They find nvx-rs at `NVX_RS`, then at `nvx-rs/target/release/nvx-rs[.exe]`, and otherwise they build it there with @@ -177,7 +178,7 @@ of the nvx-rs build: ```bash cargo build --release --locked --features cli --manifest-path nvx-rs/Cargo.toml --target-dir nvx-rs/target -cargo test --no-run --locked --manifest-path nvx-rs/Cargo.toml --test managed_lifecycle +cargo test --no-run --locked --manifest-path nvx-rs/Cargo.toml --features cli --lib python3 scripts/nvx.py record-nvx-rs-provenance ``` diff --git a/doc/ci.md b/doc/ci.md index e044ba784..ebe75e301 100644 --- a/doc/ci.md +++ b/doc/ci.md @@ -697,8 +697,9 @@ both through the [`package-release`](../.github/actions/package-release/action.yml) action, the provenance into `build/`, so that each package ships the `nvx-rs` of its operating system. The `managed-lifecycle` and `vm-lifecycle` scenarios also -build and run the ignored `nvx-rs` tests `tests/managed_lifecycle.rs` and -`tests/vm_lifecycle.rs` with Cargo on the runner. +build and run the ignored `nvx-rs` tests +`src/host/session/tests/managed_lifecycle.rs`, a unit test of the library with +the `cli` feature, and `tests/vm_lifecycle.rs` with Cargo on the runner. Once the required artifacts are ready, benchmarks run in parallel with the NVX test layer, each on its backend's diff --git a/doc/contribute.md b/doc/contribute.md index 45ea0e118..12e41c47b 100644 --- a/doc/contribute.md +++ b/doc/contribute.md @@ -136,7 +136,8 @@ registers them. `sandbox` and the control protocol belong to `nvx-rs` scenarios drive them as commands, compile egress policies with `nvx-rs egress compile`, and leave the control session to `nvx-rs`: the `managed-lifecycle` scenario builds OpenVMM's command line and runs the ignored -`nvx-rs` test `tests/managed_lifecycle.rs`, which drives the guest, and the +`nvx-rs` unit test `src/host/session/tests/managed_lifecycle.rs`, which drives +the guest, and the `vm-lifecycle` scenario runs the ignored test `tests/vm_lifecycle.rs`, which drives a VM through the public API of `nvx`. Add no Python client of the control protocol, lifecycle, or egress logic. diff --git a/doc/project-structure.md b/doc/project-structure.md index 14457a8b5..fc726a13e 100644 --- a/doc/project-structure.md +++ b/doc/project-structure.md @@ -107,7 +107,7 @@ nvx/ | | |-- 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 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 +| |-- tests/ Fake-OpenVMM behavior, process and VM spawn, CLI scenario, NVXS session, guest-binary, and hypervisor 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 diff --git a/doc/usage.md b/doc/usage.md index 74c92040b..38fe88dcc 100644 --- a/doc/usage.md +++ b/doc/usage.md @@ -1303,8 +1303,9 @@ the hooked rate of host UTC, then restores a snapshot with another rate, and with one beyond Linux's 500 ppm limit, which the guest must hold at the limit and report. The `managed-lifecycle` scenario boots a guest whose init hands -the boot to the managed agent, and runs the ignored `nvx-rs` test -`tests/managed_lifecycle.rs` with Cargo, which starts OpenVMM with the +the boot to the managed agent, and runs the ignored `nvx-rs` unit test of the +host's control session, in `src/host/session/tests/managed_lifecycle.rs`, with +Cargo, which starts OpenVMM with the scenario's command line and drives the guest's control session; the scenario then checks OpenVMM's outcome report and the guest console. The `vm-lifecycle` scenario runs the ignored `nvx-rs` test diff --git a/nvx-rs/README.md b/nvx-rs/README.md index bb158b53b..9a4e6edbf 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -100,7 +100,7 @@ changes only when the module's purpose does. | Module | Purpose | | --- | --- | -| [`nvx::protocol`](#nvxprotocol) | The codec of the control console's `NVXS` records, `NVXC` frames, and payloads, which the host, the guest agent, and the fake OpenVMM share | +| [`nvx::protocol`](#nvxprotocol) | The codec of the control console's `NVXS` records and the payloads that the host, the guest agent, and the fake OpenVMM share | | [`nvx::protocol::cmdline`](#nvxprotocolcmdline) | The kernel command line as Linux and the guest's init read it, with the virtio-mmio devices and the guest's DNS server that it names | | [`nvx::protocol::export`](#nvxprotocolexport) | The boot tokens of the host export, the virtio-fs device from which a guest maps host paths, and the guest's parser of them | | [`nvx::protocol::nvxs`](#nvxprotocolnvxs) | The NVXS session, independent of any application protocol: its records and the state machines of its host and guest roles | @@ -131,7 +131,7 @@ changes only when the module's purpose does. | [`nvx::host::process`](#nvxhostprocess) | Verified references to OpenVMM processes, through which callers wait for them and end them | | [`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::platform`, `report`](#nvxhostplatform-report) | 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 | | [`nvx::guest`](#nvxguest) | The programs that run in Linux guests; see [Guest binaries](#guest-binaries) | | [`nvx::guest::mappings`](#nvxguestmappings) | `Mapping` and `MappingTable`: host mappings, and the account of a host's mapping table, for any guest init or agent | @@ -163,10 +163,12 @@ fields. ### `nvx::protocol` -The codec of the control console: `NVXS` outer records, `NVXC` application +The codec of the control console: `NVXS` outer records, the `MAPS` payloads of +the host mapping table, and the `nvx_maps=` and `nvx_workload_account=create` +tokens. NVX's own control protocol, `NVXC`, is private to the crate, gated by +the features of its users, `cli`, `guest` and `testing`: its application frames, the `EXEC` payload that the host encodes and the guest agent decodes, -the `MAPS` payloads of the host mapping table, `GuestFeatures`, and the -`nvx_maps=` and `nvx_workload_account=create` tokens. +and the control features that a guest image advertises. The [conformance vectors](tests/vectors/README.md) of version 1, `tests/vectors/nvxc-v1.txt`, and OpenVMM's vectors and cases of the `NVXS` @@ -1054,7 +1056,8 @@ and refuses unverifiable peers rather than falling back to a numeric PID. An indeterminate process check remains an error, never exit proof. `HostConnection`: the host's role of the NVXS session over a platform transport, -on which `nvx::host::session` and other host applications run their protocols. +on which NVX's own control session and other host applications run their +protocols. It attaches with the launch capability, waiting while the broker asks it to, sends application bytes in as many DATA records as they take, and returns each DATA payload from the guest, with deadlines; a receive that times out keeps a @@ -1069,8 +1072,8 @@ remain available as sources. `Transport` and `TransportHandle` re-export the existing platform types, with one definition beneath the OS adapters. Timed reads and writes, native-handle duplication and `unread` are unchanged: Linux reports Unix `SIOCOUTQ` charges, -and Windows reports `None`. The old `host::session::connect` remains a -compatibility wrapper. +and Windows reports `None`. The old `session::connect`, private to the crate +like the session, remains a compatibility wrapper for the CLI. ### `nvx::host::process` @@ -1178,11 +1181,12 @@ file ID, the kind, the length, the modification time, and on Linux the change time, with a versioned serde form. A seal detects accidental change, not tampering; see [Distro bindings](#distro-bindings). -### `nvx::host::session`, `platform`, `report` +### `nvx::host::platform`, `report` -The authenticated control session, which carries NVX's control protocol, `NVXC`, -one frame per DATA record of a `HostConnection`; Linux and Windows primitives; -and OpenVMM's outcome report. +Linux and Windows primitives, and OpenVMM's outcome report. The authenticated +control session, which carries NVX's own control protocol, `NVXC`, one frame +per DATA record of a `HostConnection`, is private to the crate: the CLI and its +restore driver use it, with the `cli` feature. `report::read` returns `Ok(None)` for a missing report, and an `Io` error for an unreadable or malformed report, retaining the underlying error. It keeps today's @@ -2640,9 +2644,9 @@ guest agents of NVX advertise bits 0, 4, and 6 in sandbox mode. treats as one that has not appeared yet, and, on Windows, starts OpenVMM 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 - reported the exit. + launched exits, as `nvx::host::transport::connect_verified` does, and fails + at once with `exited`, where `nvx.py` waited until `--timeout` passed before + it reported the exit. - `exec` refuses more than 1 MiB of workload output, which the guest agent never forwards. - `provision`, and `start` again, refuse a share whose resolved host path @@ -3105,10 +3109,11 @@ 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 also settles a real cgroup. -On Linux, a loopback test drives -the host `ControlSession` through a broker shim into the real managed agent's -session, dispatch, and execution engine, and `nvx.py` also lints and tests the -guest binaries, and runs their integration tests against a build of them: +On Linux, with the `cli` feature, a loopback test drives +the host's private `ControlSession` through a broker shim into the real managed +agent's session, dispatch, and execution engine, and `nvx.py` also lints and +tests the guest binaries, and runs their integration tests against a build of +them: ```bash python3 ../scripts/nvx.py check-guest-binaries @@ -3202,14 +3207,15 @@ the submodule out, and so does an OpenVMM pin promotion; see profiles from which [`run`](#run) guides a failed cold boot, with the profiles that the submodule pins, and runs where that one does. -`tests/managed_lifecycle.rs` is an ignored test of 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, refused working directories, -and stop, which `nvx::host::session` drives. `nvx.py test-microvm --scenario -managed-lifecycle` builds OpenVMM's command line for its guest and backend, -runs the test with Cargo, and checks the outcome report and the guest console -that it leaves: +`src/host/session/tests/managed_lifecycle.rs` is an ignored unit test of the +host's private control session, which the `cli` feature builds: 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, refused working +directories, and stop, which the session drives. `nvx.py test-microvm +--scenario managed-lifecycle` builds OpenVMM's command line for its guest and +backend, runs the test with Cargo, and checks the outcome report and the guest +console that it leaves: ```bash python3 ../scripts/nvx.py test-microvm --backend kvm --scenario managed-lifecycle diff --git a/nvx-rs/examples/external-launch.rs b/nvx-rs/examples/external-launch.rs index c168e53ff..ee8bd1ef5 100644 --- a/nvx-rs/examples/external-launch.rs +++ b/nvx-rs/examples/external-launch.rs @@ -23,7 +23,7 @@ use nvx::host::network::{Access, NetworkPolicy}; use nvx::host::platform::Transport; use nvx::host::runtime::{ArtifactIdentity, LaunchOptions, Runtime, RuntimeIdentity}; use nvx::host::supervisor::{self, Inherited}; -use nvx::host::{artifacts, report, session, snapshot, vm}; +use nvx::host::{artifacts, report, snapshot, transport, vm}; use nvx::protocol::root::RootImage; /// One byte past OpenVMM's 25-byte restore-ready event, so that a longer event fails to parse. @@ -180,12 +180,7 @@ fn main() -> Result<(), Box> { let console = outputs.console.as_deref().ok_or_else(|| { io::Error::new(io::ErrorKind::InvalidInput, "no prepared boot console") })?; - let mut console = session::connect( - console, - process.reference().pid, - process.reference().start_time, - deadline, - )?; + let mut console = transport::connect_verified(console, process.reference(), deadline)?; read_until(console.as_mut(), b"NVX-GUEST-BOOT-OK:", deadline)?; console.write_all( b"printf 'NVX-EXTERNAL-LAUNCH-OK\\n'; /sbin/nvx-exit 0\n", diff --git a/nvx-rs/examples/host-workflows.rs b/nvx-rs/examples/host-workflows.rs index 8aedcf940..abe44aefc 100644 --- a/nvx-rs/examples/host-workflows.rs +++ b/nvx-rs/examples/host-workflows.rs @@ -26,9 +26,10 @@ use nvx::host::restore::{ ReadyListener, RestoreAction, RestoreEvidence, RestoreInput, RestoreMachine, RestorePhase, }; use nvx::host::runtime::Runtime; -use nvx::host::session::{self, ControlSession, SessionError}; use nvx::host::snapshot::{self, Snapshot}; +use nvx::host::transport::{self, HostConnection, HostConnectionError}; use nvx::host::vm::{self, Launched, Prepared, Reattached, SavedVm, Stopped, VmDir, VmOutputs}; +use nvx::protocol::nvxs::SessionError; /// One byte past OpenVMM's 25-byte restore-ready event, so that a longer event fails to parse. const READY_LIMIT: usize = 26; @@ -36,7 +37,7 @@ const READY_LIMIT: usize = 26; /// How long a failed capture or restore waits for the exit of the VM that it ends. const STOP_TIMEOUT: Duration = Duration::from_secs(10); -/// How long a lost session's VM has to show its exit. +/// How long a lost connection's VM has to show its exit. const EXIT_PAUSE: Duration = Duration::from_millis(50); fn main() {} @@ -105,13 +106,13 @@ pub fn cold_boot( /// directory, moves it to `snapshot_dir`, another, which must be empty, and opens it there. /// /// `request` asks the guest for a snapshot, in the caller's own protocol with its guest, on a -/// control session that only the VM's own process may serve, and returns once the guest answered. -/// The guest then captures the VM, as the guest example shows, and the VM exits once the capture -/// commits. The caller finishes the capture with the proof of that exit, then moves the -/// snapshot's three entries, and nothing else, out of the VM directory. It keeps them private -/// and immutable while it restores from them. When the request fails, or the VM doesn't exit by -/// `deadline`, the caller finishes no snapshot: it ends the VM, or keeps it in an [`Unstopped`] -/// error. +/// connection to the VM's control endpoint that only the VM's own process may serve, and returns +/// once the guest answered. The guest then captures the VM, as the guest example shows, and the +/// VM exits once the capture commits. The caller finishes the capture with the proof of that +/// exit, then moves the snapshot's three entries, and nothing else, out of the VM directory. It +/// keeps them private and immutable while it restores from them. When the request fails, or the +/// VM doesn't exit by `deadline`, the caller finishes no snapshot: it ends the VM, or keeps it in +/// an [`Unstopped`] error. pub fn capture( runtime: &Runtime, config: &VmConfig, @@ -121,7 +122,7 @@ pub fn capture( deadline: Instant, ) -> Result> where - R: FnOnce(&mut ControlSession, Instant) -> Result<(), SessionError>, + R: FnOnce(&mut HostConnection, Instant) -> nvx::Result<()>, { vm::create_private_dir(snapshot_dir)?; if fs::read_dir(snapshot_dir)?.next().is_some() { @@ -136,15 +137,13 @@ where .cloned() .ok_or("the configuration's boot has no capture")?; let launched = start(prepared)?; - let process = launched.process.reference(); - let requested = session::connect( + let requested = transport::connect_verified( &launched.outputs.endpoint, - process.pid, - process.start_time, + launched.process.reference(), deadline, ) - .and_then(|stream| ControlSession::attach(stream, launched.capability.as_bytes(), deadline)) - .and_then(|mut session| request(&mut session, deadline)); + .and_then(|stream| HostConnection::attach(stream, launched.capability.as_bytes(), deadline)) + .and_then(|mut connection| request(&mut connection, deadline)); if let Err(error) = requested { return Err(end(launched, error.into())); } @@ -168,7 +167,7 @@ where } /// Restores `snapshot` with `config`, which has no `boot`, in `directory`, a new private -/// directory, and returns the running VM and its control session once its guest is Ready. +/// directory, and returns the running VM and its connection to the guest once the guest is Ready. /// /// Creation hard-links the snapshot's OpenVMM files into the VM directory, a view that the /// caller removes once the VM exited. On Linux, the caller holds its own lock of the snapshot, @@ -188,9 +187,9 @@ pub fn restore( transaction: TransactionId, activate: A, deadline: Instant, -) -> Result<(Launched, ControlSession), Box> +) -> Result<(Launched, HostConnection), Box> where - A: FnMut(&mut ControlSession, &[u8], Instant) -> Result, + A: FnMut(&mut HostConnection, &[u8], Instant) -> nvx::Result, { vm::create_private_dir(directory)?; let dir = VmDir::open(directory)?; @@ -198,25 +197,25 @@ where let record = prepared.activation_record(transaction)?; let mut launched = start(prepared)?; match drive(&mut launched, &record, activate, deadline) { - Ok(session) => Ok((launched, session)), + Ok(connection) => Ok((launched, connection)), Err(error) => Err(end(launched, error)), } } -/// Drives a launched restore to its guest's Ready, and returns the session in which it came. +/// Drives a launched restore to its guest's Ready, and returns the connection in which it came. /// /// The restore's machine says what to do next and checks what the caller observed: the -/// restore-ready event, which only the VM's own process may send, a control session, which only -/// it may serve, and the guest's Ready, which must echo the record's transaction. A lost session -/// is replaced, with the same record, until the deadline. +/// restore-ready event, which only the VM's own process may send, a connection to the VM's +/// control endpoint, which only it may serve, and the guest's Ready, which must echo the record's +/// transaction. A lost connection is replaced, with the same record, until the deadline. fn drive( launched: &mut Launched, record: &ActivationRecord, mut activate: A, deadline: Instant, -) -> Result> +) -> Result> where - A: FnMut(&mut ControlSession, &[u8], Instant) -> Result, + A: FnMut(&mut HostConnection, &[u8], Instant) -> nvx::Result, { let mut machine = RestoreMachine::new(record); machine.input(RestoreInput::Spawned(launched.process.reference().clone())); @@ -224,7 +223,7 @@ where while let Some(action) = machine.next_action() { let input = match action { RestoreAction::Done => { - return current.ok_or_else(|| "a restore that is Ready has its session".into()); + return current.ok_or_else(|| "a restore that is Ready has its connection".into()); } RestoreAction::Fail(error) => return Err(error.into()), _ if Instant::now() >= deadline => RestoreInput::Expired, @@ -251,26 +250,26 @@ where } } RestoreAction::Connect => { - // A session that was lost closes before another replaces it. + // A connection that was lost closes before another replaces it. current = None; let process = launched.process.reference(); let endpoint = &launched.outputs.endpoint; let capability = launched.capability.as_bytes(); - match session::connect(endpoint, process.pid, process.start_time, deadline) - .and_then(|stream| ControlSession::attach(stream, capability, deadline)) + match transport::connect_verified(endpoint, process, deadline) + .and_then(|stream| HostConnection::attach(stream, capability, deadline)) { - Ok(session) => { - current = Some(session); + Ok(connection) => { + current = Some(connection); RestoreInput::Connected } Err(error) => failed(launched, RestorePhase::Connect, error), } } RestoreAction::SendActivate(bytes) => { - let session = current + let connection = current .as_mut() - .ok_or("a restore activates in its session")?; - match activate(session, &bytes, deadline) { + .ok_or("a restore activates in its connection")?; + match activate(connection, &bytes, deadline) { Ok(ready) => RestoreInput::GuestReady(ready), Err(error) => failed(launched, RestorePhase::Activate, error), } @@ -301,47 +300,62 @@ fn ready_event( Ok(Some(event)) } -/// What a control session's failure in `phase` tells the restore: the VM's exit, a lost session, -/// which another may replace, an expired deadline, or a final failure. +/// What a failure of the connection to the guest in `phase` tells the restore: the VM's exit, a +/// lost connection, which another may replace, an expired deadline, or a final failure. /// -/// A VM that exits ends its sessions before its exit shows, so a lost session's VM has a moment -/// to show its exit first. -fn failed(launched: &Launched, phase: RestorePhase, error: SessionError) -> RestoreInput { - let lost = match &error { - SessionError::Closed | SessionError::Reset => true, - SessionError::Io(error) => matches!( - error.kind(), - io::ErrorKind::BrokenPipe - | io::ErrorKind::ConnectionAborted - | io::ErrorKind::ConnectionReset - | io::ErrorKind::UnexpectedEof - | io::ErrorKind::WriteZero - ), - _ => false, - }; +/// A VM that exits ends its connections before its exit shows, so a lost connection's VM has a +/// moment to show its exit first. +fn failed(launched: &Launched, phase: RestorePhase, error: nvx::Error) -> RestoreInput { + // The broker closed or reset the session, or the stream ended inside a record. + let closed = error.source().is_some_and(|cause| { + matches!( + cause.downcast_ref(), + Some(HostConnectionError::Closed | HostConnectionError::Reset(_)) + ) || matches!(cause.downcast_ref(), Some(SessionError::Truncated { .. })) + }); + let lost = closed + || io_cause(&error).is_some_and(|cause| { + matches!( + cause.kind(), + io::ErrorKind::BrokenPipe + | io::ErrorKind::ConnectionAborted + | io::ErrorKind::ConnectionReset + | io::ErrorKind::UnexpectedEof + | io::ErrorKind::WriteZero + ) + }); let gone = lost && launched .process .wait(Instant::now() + EXIT_PAUSE) .is_ok_and(|exit| exit.is_some()); - let evidence = match error { - SessionError::ProcessExited => return exited(&launched.outputs, phase), + let evidence = match error.kind() { + ErrorKind::Exited => return exited(&launched.outputs, phase), _ if gone => return exited(&launched.outputs, phase), _ if lost => return RestoreInput::SessionLost, - SessionError::TimedOut => return RestoreInput::Expired, - SessionError::Refused(_) => RestoreEvidence::PeerMismatch, - SessionError::Protocol(_) | SessionError::AuthenticationFailed => { - RestoreEvidence::Malformed + ErrorKind::TimedOut => return RestoreInput::Expired, + // `transport::connect_verified` refuses an endpoint that another process serves. + ErrorKind::Protocol + if io_cause(&error) + .is_some_and(|cause| cause.kind() == io::ErrorKind::PermissionDenied) => + { + RestoreEvidence::PeerMismatch } + ErrorKind::Protocol => RestoreEvidence::Malformed, _ => RestoreEvidence::Io, }; RestoreInput::OperationFailed { phase, evidence, - source: Some(Box::new(error)), + source: Some(error.into()), } } +/// The I/O error that caused `error`, if one did. +fn io_cause(error: &nvx::Error) -> Option<&io::Error> { + error.source()?.downcast_ref() +} + /// The VM's exit, with the outcome report that tells what the failure is attributed to. fn exited(outputs: &VmOutputs, phase: RestorePhase) -> RestoreInput { match report::read(&outputs.outcome) { diff --git a/nvx-rs/public-api/linux.txt b/nvx-rs/public-api/linux.txt index 408037061..2967ea292 100644 --- a/nvx-rs/public-api/linux.txt +++ b/nvx-rs/public-api/linux.txt @@ -2894,42 +2894,6 @@ variant nvx::host::seal::SealScheme::Windows struct nvx::host::seal::SealTime field nvx::host::seal::SealTime::nanoseconds field nvx::host::seal::SealTime::seconds -mod nvx::host::session -struct nvx::host::session::ControlSession -method nvx::host::session::ControlSession::attach -method nvx::host::session::ControlSession::cancel_exec -method nvx::host::session::ControlSession::features -method nvx::host::session::ControlSession::map_host_paths -method nvx::host::session::ControlSession::next_exec_event -method nvx::host::session::ControlSession::ping -method nvx::host::session::ControlSession::read_response -method nvx::host::session::ControlSession::send_request -method nvx::host::session::ControlSession::start_exec -method nvx::host::session::ControlSession::stop -enum nvx::host::session::ExecEvent -variant nvx::host::session::ExecEvent::Exit -field nvx::host::session::ExecEvent::Exit::category -field nvx::host::session::ExecEvent::Exit::status -variant nvx::host::session::ExecEvent::Rejected -field nvx::host::session::ExecEvent::Rejected::category -field nvx::host::session::ExecEvent::Rejected::status -variant nvx::host::session::ExecEvent::Stderr -field nvx::host::session::ExecEvent::Stderr::0 -variant nvx::host::session::ExecEvent::Stdout -field nvx::host::session::ExecEvent::Stdout::0 -enum nvx::host::session::SessionError -variant nvx::host::session::SessionError::AuthenticationFailed -variant nvx::host::session::SessionError::Closed -variant nvx::host::session::SessionError::Io -field nvx::host::session::SessionError::Io::0 -variant nvx::host::session::SessionError::ProcessExited -variant nvx::host::session::SessionError::Protocol -field nvx::host::session::SessionError::Protocol::0 -variant nvx::host::session::SessionError::Refused -field nvx::host::session::SessionError::Refused::0 -variant nvx::host::session::SessionError::Reset -variant nvx::host::session::SessionError::TimedOut -fn nvx::host::session::connect mod nvx::host::snapshot struct nvx::host::snapshot::BlockGeometry field nvx::host::snapshot::BlockGeometry::length @@ -3186,72 +3150,7 @@ fn nvx::net::guest_gateway fn nvx::net::is_valid_guest_network fn nvx::net::is_valid_hostname mod nvx::protocol -const nvx::protocol::APP_CANCEL -const nvx::protocol::APP_ERROR -const nvx::protocol::APP_EXEC -const nvx::protocol::APP_EXIT -const nvx::protocol::APP_FEATURES -const nvx::protocol::APP_HEADER_LEN -const nvx::protocol::APP_MAGIC -const nvx::protocol::APP_MAPS -const nvx::protocol::APP_PING -const nvx::protocol::APP_READY -const nvx::protocol::APP_STDERR -const nvx::protocol::APP_STDOUT -const nvx::protocol::APP_STOP -const nvx::protocol::APP_STOPPED -const nvx::protocol::APP_VERSION -struct nvx::protocol::AppFrame -field nvx::protocol::AppFrame::kind -field nvx::protocol::AppFrame::payload -field nvx::protocol::AppFrame::request_id -field nvx::protocol::AppFrame::status -const nvx::protocol::BUSY const nvx::protocol::CAPABILITY_LEN -const nvx::protocol::CWD_FAILED -const nvx::protocol::EXEC_CWD_PRESENT -const nvx::protocol::EXEC_ENVIRONMENT_PRESENT -const nvx::protocol::EXEC_EXTENDED -const nvx::protocol::EXEC_INHERIT_DEFAULT_ENV -struct nvx::protocol::ExecPayload -field nvx::protocol::ExecPayload::argv -field nvx::protocol::ExecPayload::cwd -field nvx::protocol::ExecPayload::environment -field nvx::protocol::ExecPayload::inherit_default_env -field nvx::protocol::ExecPayload::timeout_ms -enum nvx::protocol::ExitCategory -variant nvx::protocol::ExitCategory::Cancelled -variant nvx::protocol::ExitCategory::Exit -variant nvx::protocol::ExitCategory::Failed -variant nvx::protocol::ExitCategory::OutputLimit -variant nvx::protocol::ExitCategory::Signal -variant nvx::protocol::ExitCategory::Timeout -method nvx::protocol::ExitCategory::as_str -method nvx::protocol::ExitCategory::parse -struct nvx::protocol::GuestExecPayload -field nvx::protocol::GuestExecPayload::argv -field nvx::protocol::GuestExecPayload::cwd -field nvx::protocol::GuestExecPayload::environment -field nvx::protocol::GuestExecPayload::inherit_default_env -field nvx::protocol::GuestExecPayload::timeout_ms -struct nvx::protocol::GuestFeatures -const nvx::protocol::GuestFeatures::CANCEL -const nvx::protocol::GuestFeatures::DIRECT_AGENT -const nvx::protocol::GuestFeatures::EXEC_CGROUP -const nvx::protocol::GuestFeatures::EXEC_CWD -const nvx::protocol::GuestFeatures::EXEC_ENVIRONMENT -const nvx::protocol::GuestFeatures::HOST_MAPPING_TABLE -const nvx::protocol::GuestFeatures::LAYERED_SANDBOX -const nvx::protocol::GuestFeatures::NONE -const nvx::protocol::GuestFeatures::REQUIRED -const nvx::protocol::GuestFeatures::SANDBOX_AGENT -const nvx::protocol::GuestFeatures::WORKLOAD_ACCOUNT -method nvx::protocol::GuestFeatures::bits -method nvx::protocol::GuestFeatures::contains -method nvx::protocol::GuestFeatures::decode -method nvx::protocol::GuestFeatures::encode -method nvx::protocol::GuestFeatures::from_bits -method nvx::protocol::GuestFeatures::missing struct nvx::protocol::GuestHostMap field nvx::protocol::GuestHostMap::read_only field nvx::protocol::GuestHostMap::source @@ -3263,23 +3162,12 @@ struct nvx::protocol::HostMap field nvx::protocol::HostMap::read_only field nvx::protocol::HostMap::source field nvx::protocol::HostMap::target -const nvx::protocol::INVALID_REQUEST -const nvx::protocol::LAUNCH_FAILED -const nvx::protocol::MAPPINGS_INCOMPLETE -const nvx::protocol::MAPPING_FAILED const nvx::protocol::MAPS_HEADER_LEN const nvx::protocol::MAPS_TOKEN const nvx::protocol::MAP_ENTRY_HEADER_LEN const nvx::protocol::MAP_READ_ONLY -const nvx::protocol::MAX_ARGUMENTS -const nvx::protocol::MAX_ARGUMENT_BYTES -const nvx::protocol::MAX_CWD_BYTES -const nvx::protocol::MAX_ENVIRONMENT -const nvx::protocol::MAX_GUEST_CWD_BYTES const nvx::protocol::MAX_HOST_MAPPINGS const nvx::protocol::MAX_MAP_PATH_BYTES -const nvx::protocol::MAX_OUTPUT_BYTES -const nvx::protocol::MAX_TIMEOUT_MS struct nvx::protocol::MapsPayload field nvx::protocol::MapsPayload::first field nvx::protocol::MapsPayload::maps @@ -3308,19 +3196,7 @@ field nvx::protocol::OuterHeader::sequence struct nvx::protocol::ProtocolError method nvx::protocol::ProtocolError::into_message method nvx::protocol::ProtocolError::message -const nvx::protocol::UNSUPPORTED_OPERATION const nvx::protocol::WORKLOAD_ACCOUNT_TOKEN -struct nvx::protocol::Workload -field nvx::protocol::Workload::argv -field nvx::protocol::Workload::cwd -field nvx::protocol::Workload::environment -field nvx::protocol::Workload::timeout_ms -enum nvx::protocol::WorkloadEnvironment -variant nvx::protocol::WorkloadEnvironment::Default -variant nvx::protocol::WorkloadEnvironment::Layered -field nvx::protocol::WorkloadEnvironment::Layered::0 -variant nvx::protocol::WorkloadEnvironment::Replaced -field nvx::protocol::WorkloadEnvironment::Replaced::0 mod nvx::protocol::cmdline const nvx::protocol::cmdline::DNS_TOKEN const nvx::protocol::cmdline::GATEWAY_TOKEN @@ -3347,17 +3223,12 @@ fn nvx::protocol::cmdline::parameters fn nvx::protocol::cmdline::virtio_mmio_devices fn nvx::protocol::cmdline::write_resolver fn nvx::protocol::decode_ack_payload -fn nvx::protocol::decode_app fn nvx::protocol::decode_credit_payload -fn nvx::protocol::decode_exec_payload -fn nvx::protocol::decode_guest_exec_payload fn nvx::protocol::decode_guest_maps_payload fn nvx::protocol::decode_maps_payload fn nvx::protocol::decode_outer_header fn nvx::protocol::encode_ack_payload -fn nvx::protocol::encode_app fn nvx::protocol::encode_credit_payload -fn nvx::protocol::encode_exec_payload fn nvx::protocol::encode_maps_payloads fn nvx::protocol::encode_outer mod nvx::protocol::export diff --git a/nvx-rs/public-api/windows.txt b/nvx-rs/public-api/windows.txt index ae29c4eee..de5f1ecc5 100644 --- a/nvx-rs/public-api/windows.txt +++ b/nvx-rs/public-api/windows.txt @@ -1497,42 +1497,6 @@ variant nvx::host::seal::SealScheme::Windows struct nvx::host::seal::SealTime field nvx::host::seal::SealTime::nanoseconds field nvx::host::seal::SealTime::seconds -mod nvx::host::session -struct nvx::host::session::ControlSession -method nvx::host::session::ControlSession::attach -method nvx::host::session::ControlSession::cancel_exec -method nvx::host::session::ControlSession::features -method nvx::host::session::ControlSession::map_host_paths -method nvx::host::session::ControlSession::next_exec_event -method nvx::host::session::ControlSession::ping -method nvx::host::session::ControlSession::read_response -method nvx::host::session::ControlSession::send_request -method nvx::host::session::ControlSession::start_exec -method nvx::host::session::ControlSession::stop -enum nvx::host::session::ExecEvent -variant nvx::host::session::ExecEvent::Exit -field nvx::host::session::ExecEvent::Exit::category -field nvx::host::session::ExecEvent::Exit::status -variant nvx::host::session::ExecEvent::Rejected -field nvx::host::session::ExecEvent::Rejected::category -field nvx::host::session::ExecEvent::Rejected::status -variant nvx::host::session::ExecEvent::Stderr -field nvx::host::session::ExecEvent::Stderr::0 -variant nvx::host::session::ExecEvent::Stdout -field nvx::host::session::ExecEvent::Stdout::0 -enum nvx::host::session::SessionError -variant nvx::host::session::SessionError::AuthenticationFailed -variant nvx::host::session::SessionError::Closed -variant nvx::host::session::SessionError::Io -field nvx::host::session::SessionError::Io::0 -variant nvx::host::session::SessionError::ProcessExited -variant nvx::host::session::SessionError::Protocol -field nvx::host::session::SessionError::Protocol::0 -variant nvx::host::session::SessionError::Refused -field nvx::host::session::SessionError::Refused::0 -variant nvx::host::session::SessionError::Reset -variant nvx::host::session::SessionError::TimedOut -fn nvx::host::session::connect mod nvx::host::snapshot struct nvx::host::snapshot::BlockGeometry field nvx::host::snapshot::BlockGeometry::length @@ -1788,72 +1752,7 @@ fn nvx::net::guest_gateway fn nvx::net::is_valid_guest_network fn nvx::net::is_valid_hostname mod nvx::protocol -const nvx::protocol::APP_CANCEL -const nvx::protocol::APP_ERROR -const nvx::protocol::APP_EXEC -const nvx::protocol::APP_EXIT -const nvx::protocol::APP_FEATURES -const nvx::protocol::APP_HEADER_LEN -const nvx::protocol::APP_MAGIC -const nvx::protocol::APP_MAPS -const nvx::protocol::APP_PING -const nvx::protocol::APP_READY -const nvx::protocol::APP_STDERR -const nvx::protocol::APP_STDOUT -const nvx::protocol::APP_STOP -const nvx::protocol::APP_STOPPED -const nvx::protocol::APP_VERSION -struct nvx::protocol::AppFrame -field nvx::protocol::AppFrame::kind -field nvx::protocol::AppFrame::payload -field nvx::protocol::AppFrame::request_id -field nvx::protocol::AppFrame::status -const nvx::protocol::BUSY const nvx::protocol::CAPABILITY_LEN -const nvx::protocol::CWD_FAILED -const nvx::protocol::EXEC_CWD_PRESENT -const nvx::protocol::EXEC_ENVIRONMENT_PRESENT -const nvx::protocol::EXEC_EXTENDED -const nvx::protocol::EXEC_INHERIT_DEFAULT_ENV -struct nvx::protocol::ExecPayload -field nvx::protocol::ExecPayload::argv -field nvx::protocol::ExecPayload::cwd -field nvx::protocol::ExecPayload::environment -field nvx::protocol::ExecPayload::inherit_default_env -field nvx::protocol::ExecPayload::timeout_ms -enum nvx::protocol::ExitCategory -variant nvx::protocol::ExitCategory::Cancelled -variant nvx::protocol::ExitCategory::Exit -variant nvx::protocol::ExitCategory::Failed -variant nvx::protocol::ExitCategory::OutputLimit -variant nvx::protocol::ExitCategory::Signal -variant nvx::protocol::ExitCategory::Timeout -method nvx::protocol::ExitCategory::as_str -method nvx::protocol::ExitCategory::parse -struct nvx::protocol::GuestExecPayload -field nvx::protocol::GuestExecPayload::argv -field nvx::protocol::GuestExecPayload::cwd -field nvx::protocol::GuestExecPayload::environment -field nvx::protocol::GuestExecPayload::inherit_default_env -field nvx::protocol::GuestExecPayload::timeout_ms -struct nvx::protocol::GuestFeatures -const nvx::protocol::GuestFeatures::CANCEL -const nvx::protocol::GuestFeatures::DIRECT_AGENT -const nvx::protocol::GuestFeatures::EXEC_CGROUP -const nvx::protocol::GuestFeatures::EXEC_CWD -const nvx::protocol::GuestFeatures::EXEC_ENVIRONMENT -const nvx::protocol::GuestFeatures::HOST_MAPPING_TABLE -const nvx::protocol::GuestFeatures::LAYERED_SANDBOX -const nvx::protocol::GuestFeatures::NONE -const nvx::protocol::GuestFeatures::REQUIRED -const nvx::protocol::GuestFeatures::SANDBOX_AGENT -const nvx::protocol::GuestFeatures::WORKLOAD_ACCOUNT -method nvx::protocol::GuestFeatures::bits -method nvx::protocol::GuestFeatures::contains -method nvx::protocol::GuestFeatures::decode -method nvx::protocol::GuestFeatures::encode -method nvx::protocol::GuestFeatures::from_bits -method nvx::protocol::GuestFeatures::missing struct nvx::protocol::GuestHostMap field nvx::protocol::GuestHostMap::read_only field nvx::protocol::GuestHostMap::source @@ -1865,23 +1764,12 @@ struct nvx::protocol::HostMap field nvx::protocol::HostMap::read_only field nvx::protocol::HostMap::source field nvx::protocol::HostMap::target -const nvx::protocol::INVALID_REQUEST -const nvx::protocol::LAUNCH_FAILED -const nvx::protocol::MAPPINGS_INCOMPLETE -const nvx::protocol::MAPPING_FAILED const nvx::protocol::MAPS_HEADER_LEN const nvx::protocol::MAPS_TOKEN const nvx::protocol::MAP_ENTRY_HEADER_LEN const nvx::protocol::MAP_READ_ONLY -const nvx::protocol::MAX_ARGUMENTS -const nvx::protocol::MAX_ARGUMENT_BYTES -const nvx::protocol::MAX_CWD_BYTES -const nvx::protocol::MAX_ENVIRONMENT -const nvx::protocol::MAX_GUEST_CWD_BYTES const nvx::protocol::MAX_HOST_MAPPINGS const nvx::protocol::MAX_MAP_PATH_BYTES -const nvx::protocol::MAX_OUTPUT_BYTES -const nvx::protocol::MAX_TIMEOUT_MS struct nvx::protocol::MapsPayload field nvx::protocol::MapsPayload::first field nvx::protocol::MapsPayload::maps @@ -1910,19 +1798,7 @@ field nvx::protocol::OuterHeader::sequence struct nvx::protocol::ProtocolError method nvx::protocol::ProtocolError::into_message method nvx::protocol::ProtocolError::message -const nvx::protocol::UNSUPPORTED_OPERATION const nvx::protocol::WORKLOAD_ACCOUNT_TOKEN -struct nvx::protocol::Workload -field nvx::protocol::Workload::argv -field nvx::protocol::Workload::cwd -field nvx::protocol::Workload::environment -field nvx::protocol::Workload::timeout_ms -enum nvx::protocol::WorkloadEnvironment -variant nvx::protocol::WorkloadEnvironment::Default -variant nvx::protocol::WorkloadEnvironment::Layered -field nvx::protocol::WorkloadEnvironment::Layered::0 -variant nvx::protocol::WorkloadEnvironment::Replaced -field nvx::protocol::WorkloadEnvironment::Replaced::0 mod nvx::protocol::cmdline const nvx::protocol::cmdline::DNS_TOKEN const nvx::protocol::cmdline::GATEWAY_TOKEN @@ -1949,17 +1825,12 @@ fn nvx::protocol::cmdline::parameters fn nvx::protocol::cmdline::virtio_mmio_devices fn nvx::protocol::cmdline::write_resolver fn nvx::protocol::decode_ack_payload -fn nvx::protocol::decode_app fn nvx::protocol::decode_credit_payload -fn nvx::protocol::decode_exec_payload -fn nvx::protocol::decode_guest_exec_payload fn nvx::protocol::decode_guest_maps_payload fn nvx::protocol::decode_maps_payload fn nvx::protocol::decode_outer_header fn nvx::protocol::encode_ack_payload -fn nvx::protocol::encode_app fn nvx::protocol::encode_credit_payload -fn nvx::protocol::encode_exec_payload fn nvx::protocol::encode_maps_payloads fn nvx::protocol::encode_outer mod nvx::protocol::export diff --git a/nvx-rs/src/guest/managed_agent/dispatch.rs b/nvx-rs/src/guest/managed_agent/dispatch.rs index 0693028a4..d047af7c6 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch.rs @@ -315,5 +315,5 @@ fn handle_data( Ok(None) } -#[cfg(test)] +#[cfg(all(test, feature = "cli"))] mod tests; diff --git a/nvx-rs/src/guest/managed_agent/seam/tests.rs b/nvx-rs/src/guest/managed_agent/seam/tests.rs index 0319e3eb3..574203b6d 100644 --- a/nvx-rs/src/guest/managed_agent/seam/tests.rs +++ b/nvx-rs/src/guest/managed_agent/seam/tests.rs @@ -55,6 +55,7 @@ impl TestSystem { } /// Returns a direct agent with no cgroup hierarchy in its temporary directory. + #[cfg(feature = "cli")] pub(crate) fn with_direct() -> (tempfile::TempDir, Self) { let temporary = tempfile::tempdir().unwrap(); let root = temporary.path().join("cgroup"); @@ -200,6 +201,7 @@ impl System for TestSystem { /// Starts a native direct workload as the privileged agent does, without privileges: the child's /// credential calls are replaced and succeed, and it enters the working directory with the test's /// own credentials. +#[cfg(feature = "cli")] pub(crate) fn start_as_privileged( request: init::ExecRequest, ) -> Result { @@ -207,6 +209,7 @@ pub(crate) fn start_as_privileged( } /// Starts a native direct workload whose simulated identity stage fails with `EPERM`. +#[cfg(feature = "cli")] pub(crate) fn start_with_failing_credentials( request: init::ExecRequest, ) -> Result { diff --git a/nvx-rs/src/guest/managed_agent/tests.rs b/nvx-rs/src/guest/managed_agent/tests.rs index c4da4b053..7f1bae486 100644 --- a/nvx-rs/src/guest/managed_agent/tests.rs +++ b/nvx-rs/src/guest/managed_agent/tests.rs @@ -3,11 +3,12 @@ use super::*; use crate::guest::entry::AccountMode; pub(crate) use crate::guest::sys::tests::isolate_process_test; -#[cfg(feature = "host")] +#[cfg(feature = "cli")] /// In-process host/broker/agent integration harness. mod loopback; /// Serializes tests that temporarily alter process-global state. +#[cfg(feature = "cli")] pub(crate) static PROCESS_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); impl AgentConfig { diff --git a/nvx-rs/src/guest/sys/spawn/tests.rs b/nvx-rs/src/guest/sys/spawn/tests.rs index 443e24d11..b22de6243 100644 --- a/nvx-rs/src/guest/sys/spawn/tests.rs +++ b/nvx-rs/src/guest/sys/spawn/tests.rs @@ -5,15 +5,19 @@ use super::*; use crate::guest::sys::{self, ChildStatus}; /// Capability to bypass file read, write, and execute permission checks. +#[cfg(feature = "cli")] pub(crate) const CAP_DAC_OVERRIDE: u32 = 1; /// Capability to bypass file read permission and directory read and search permission checks. +#[cfg(feature = "cli")] pub(crate) const CAP_DAC_READ_SEARCH: u32 = 2; /// Capability to change group IDs and supplementary groups. +#[cfg(feature = "cli")] pub(crate) const CAP_SETGID: u32 = 6; /// Capability to change user IDs. +#[cfg(feature = "cli")] pub(crate) const CAP_SETUID: u32 = 7; /// Capability to change the root directory. diff --git a/nvx-rs/src/guest/sys/tests.rs b/nvx-rs/src/guest/sys/tests.rs index c877aa249..4569f4bcb 100644 --- a/nvx-rs/src/guest/sys/tests.rs +++ b/nvx-rs/src/guest/sys/tests.rs @@ -23,9 +23,11 @@ pub(crate) use super::rooted::tests::{ }; pub(crate) use super::signal::tests::{default_sigpipe, has_child, reap_children_automatically}; pub(crate) use super::sock_diag::tests::{encode_inet_diag_done, encode_inet_diag_socket}; +#[cfg(feature = "cli")] pub(crate) use super::spawn::tests::{ - CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_SETGID, CAP_SETUID, CAP_SYS_ADMIN, holds_capability, + CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_SETGID, CAP_SETUID, }; +pub(crate) use super::spawn::tests::{CAP_SYS_ADMIN, holds_capability}; #[cfg(target_arch = "x86_64")] pub(crate) use super::spawn::tests::{CAP_SYS_NICE, CAP_SYS_RESOURCE}; diff --git a/nvx-rs/src/host/cli/session/tests.rs b/nvx-rs/src/host/cli/session/tests.rs index 34882c923..9c616c3e5 100644 --- a/nvx-rs/src/host/cli/session/tests.rs +++ b/nvx-rs/src/host/cli/session/tests.rs @@ -9,8 +9,10 @@ use std::time::Instant; use super::*; use crate::host::transport::HostConnection; -use crate::protocol::{ +use crate::protocol::exec::{ EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_EXTENDED, EXEC_INHERIT_DEFAULT_ENV, +}; +use crate::protocol::{ OUTER_DATA, OUTER_HEADER_LEN, OUTER_HOST_ATTACH, OUTER_READY, OUTER_RESET, decode_app, decode_outer_header, encode_app, encode_outer, }; @@ -1062,6 +1064,10 @@ fn output_beyond_the_guest_limit_breaks_the_protocol() { worker.join().unwrap(); } +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn features_of_a_guest_that_predates_them_are_none() { let (mut session, worker) = session_with(|guest| { @@ -1087,6 +1093,10 @@ fn features_of_a_guest_that_predates_them_are_none() { worker.join().unwrap(); } +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn protocol_violations_keep_the_messages_of_python() { let (mut session, worker) = session_with(|guest| { diff --git a/nvx-rs/src/host/mod.rs b/nvx-rs/src/host/mod.rs index 2baff95b2..baca8e201 100644 --- a/nvx-rs/src/host/mod.rs +++ b/nvx-rs/src/host/mod.rs @@ -24,10 +24,11 @@ //! - [`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. -//! - [`session`] speaks NVX's control protocol, `NVXC`, over [`transport`], which verifies a VM's -//! endpoint and runs the host's role of the NVXS session for it and for any other application -//! protocol, over a platform transport. [`nvxs`] retains compatibility paths. [`platform`] holds -//! the primitives of Linux and Windows hosts. Other hosts compile but report NVX as unavailable. +//! - [`transport`] verifies a VM's endpoint and runs the host's role of the NVXS session for it, +//! for any application protocol, over a platform transport. The private `session` (feature +//! `cli`) speaks NVX's own control protocol, `NVXC`, over it for NVX's programs. [`nvxs`] +//! retains compatibility paths. [`platform`] holds the primitives of Linux and Windows hosts. +//! Other hosts compile but report NVX as unavailable. //! - [`process`] names processes by verified references: their ID, start time, and host boot, //! through which a pidfd or a process handle holds them, so that waits sleep until a process //! exits and termination reaches only it, whether or not the caller started it. @@ -45,8 +46,8 @@ //! the VM directory and binds its restore-ready listener before any spawn. //! - [`restore`] orders caller-driven activation to Ready without I/O or a clock, and listens for //! OpenVMM's restore-ready event from the held VM process. The private `restore_driver` (feature -//! `cli`) drives it over the [`session`] for NVX's blocking callers, through Ready, the mapping -//! table and the workload's start. +//! `cli`) drives it over the private `session` for NVX's blocking callers, through Ready, the +//! mapping table and the workload's start. //! - `cli` (feature `cli`) is the `nvx-rs` command-line interface, which runs microVMs and //! sandboxes, looks up and downloads artifacts, and compiles egress policies. //! @@ -85,7 +86,8 @@ mod restore_driver; pub mod runtime; pub mod sandbox; pub mod seal; -pub mod session; +#[cfg(feature = "cli")] +pub(crate) mod session; pub mod snapshot; mod spawn; pub mod supervisor; diff --git a/nvx-rs/src/host/restore/tests.rs b/nvx-rs/src/host/restore/tests.rs index 918e04ae3..4c5a2bbb9 100644 --- a/nvx-rs/src/host/restore/tests.rs +++ b/nvx-rs/src/host/restore/tests.rs @@ -1,6 +1,7 @@ #![cfg(any(target_os = "linux", windows))] #![cfg_attr(windows, allow(unsafe_code))] +#[cfg(feature = "cli")] mod live_init; use std::io::{BufRead, BufReader, Write}; diff --git a/nvx-rs/src/host/session.rs b/nvx-rs/src/host/session.rs index 74b23fe57..ecbb429f0 100644 --- a/nvx-rs/src/host/session.rs +++ b/nvx-rs/src/host/session.rs @@ -18,7 +18,7 @@ use crate::{Error, ErrorKind}; /// Failure of a control session. #[derive(Debug)] -pub enum SessionError { +pub(crate) enum SessionError { /// The deadline passed. TimedOut, /// The OpenVMM process exited. @@ -74,7 +74,7 @@ impl From for SessionError { /// Connects to the control endpoint of OpenVMM process `pid`, retrying until `deadline` while /// the endpoint is not yet available and the process is still alive. -pub fn connect( +pub(crate) fn connect( endpoint: &str, pid: u32, start_time: u64, @@ -114,7 +114,7 @@ fn connect_failure(error: Error) -> SessionError { /// Event observed while an execution runs. #[derive(Debug, PartialEq, Eq)] -pub enum ExecEvent { +pub(crate) enum ExecEvent { /// A chunk of the workload's standard output. Stdout(Vec), /// A chunk of the workload's standard error. @@ -143,7 +143,7 @@ pub enum ExecEvent { /// loses nothing and callers can poll with short deadlines. A failure that leaves the stream /// unusable, such as a reset or a record that the protocol does not allow, ends the session, and /// later calls fail. -pub struct ControlSession { +pub(crate) struct ControlSession { /// The host's NVXS connection to OpenVMM's control endpoint. pub(crate) connection: HostConnection, } @@ -151,7 +151,7 @@ pub struct ControlSession { impl ControlSession { /// Authenticates with the capability OpenVMM received at launch, and waits until `deadline` /// while OpenVMM's broker waits for the guest to acknowledge the session. - pub fn attach( + pub(crate) fn attach( transport: Box, capability: &[u8; CAPABILITY_LEN], deadline: Instant, @@ -162,15 +162,18 @@ impl ControlSession { } /// Waits until the guest agent acknowledges readiness. - pub fn ping(&mut self, deadline: Instant) -> Result<(), SessionError> { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL4.4c's host paths ping the guest through the session" + ) + )] + pub(crate) fn ping(&mut self, deadline: Instant) -> Result<(), SessionError> { self.acknowledged(APP_PING, &[], APP_READY, "readiness", deadline) } /// Asks nvx-init to capture the VM, killing its executions first when requested. - #[cfg_attr( - not(any(test, feature = "cli")), - expect(dead_code, reason = "NVX's capture command requires the cli feature") - )] pub(crate) fn capture( &mut self, kill_executions: bool, @@ -187,7 +190,6 @@ impl ControlSession { /// Sends nvx-init's activate message, whose `payload` carries a restore's activation record /// and workload identity, and returns the activation that the guest's Ready reports. - #[cfg(feature = "cli")] pub(crate) fn activate( &mut self, payload: &[u8], @@ -211,7 +213,14 @@ impl ControlSession { /// /// An agent that predates the request refuses it as an unsupported operation, which means /// that the image provides none of them. - pub fn features(&mut self, deadline: Instant) -> Result { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "no host path sends FEATURES here, and SL4.4d deletes it" + ) + )] + pub(crate) fn features(&mut self, deadline: Instant) -> Result { let request_id = request_id()?; self.send_app(APP_FEATURES, request_id, &[], Some(deadline))?; let (kind, status, payload) = self.read_reply(request_id, Some(deadline))?; @@ -226,7 +235,7 @@ impl ControlSession { /// Hands the guest agent the host mapping table, in as many requests as it takes, and waits /// until it has mounted every entry. - pub fn map_host_paths( + pub(crate) fn map_host_paths( &mut self, maps: &[HostMap], deadline: Instant, @@ -261,7 +270,14 @@ impl ControlSession { } /// Starts a workload and returns the request ID that identifies its events. - pub fn start_exec( + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL4.4c's sandbox exec and run start workloads through it" + ) + )] + pub(crate) fn start_exec( &mut self, workload: &Workload<'_>, deadline: Instant, @@ -273,7 +289,7 @@ impl ControlSession { } /// Reads the next event of the workload started with `request_id`. - pub fn next_exec_event( + pub(crate) fn next_exec_event( &mut self, request_id: u64, deadline: Option, @@ -301,19 +317,37 @@ impl ControlSession { /// The guest reports the workload's outcome as a regular exec event, with the cancelled /// category if the request arrived before the workload exited. A request for a workload that /// already finished is ignored. - pub fn cancel_exec(&mut self, request_id: u64, deadline: Instant) -> Result<(), SessionError> { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL4.4c's sandbox exec and run cancel through it on Ctrl-C" + ) + )] + pub(crate) fn cancel_exec( + &mut self, + request_id: u64, + deadline: Instant, + ) -> Result<(), SessionError> { self.send_app(APP_CANCEL, request_id, &[], Some(deadline)) } /// Asks the guest agent to shut the VM down. - pub fn stop(&mut self, deadline: Instant) -> Result<(), SessionError> { + #[cfg_attr( + not(test), + expect( + dead_code, + reason = "SL4.4c's sandbox exec and run stop the guest through it" + ) + )] + pub(crate) fn stop(&mut self, deadline: Instant) -> Result<(), SessionError> { self.acknowledged(APP_STOP, &[], APP_STOPPED, "stop", deadline) } /// Sends an application request of `kind` with `request_id` and `payload` in the next /// sequenced outer record, for a caller that checks the responses itself, as the `nvx-rs` /// CLI does with the messages of `nvx.py`. - pub fn send_request( + pub(crate) fn send_request( &mut self, kind: u8, request_id: u64, @@ -325,7 +359,7 @@ impl ControlSession { /// Reads the next application frame, checked against the session's identity and sequence, /// and returns its kind, request ID, status, and payload. - pub fn read_response( + pub(crate) fn read_response( &mut self, deadline: Option, ) -> Result<(u8, u64, i32, Vec), SessionError> { diff --git a/nvx-rs/src/host/session/tests.rs b/nvx-rs/src/host/session/tests.rs index e1753b39a..74f63c441 100644 --- a/nvx-rs/src/host/session/tests.rs +++ b/nvx-rs/src/host/session/tests.rs @@ -9,6 +9,9 @@ use crate::protocol::{ OUTER_WAIT, WorkloadEnvironment, decode_app, decode_outer_header, encode_app, encode_outer, }; +#[cfg(any(target_os = "linux", windows))] +mod managed_lifecycle; + const INSTANCE: [u8; 16] = [7; 16]; #[test] @@ -351,6 +354,10 @@ fn exec_streams_events_in_order() { ); } +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn exec_sends_the_working_directory_and_reports_its_refusal() { let (mut session, wire) = ready(); @@ -659,6 +666,7 @@ fn activate_refuses_a_reply_other_than_a_valid_ready() { } } +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] #[test] fn features_report_what_the_guest_provides() { let mut session = attached(|kind, id| { diff --git a/nvx-rs/tests/managed_lifecycle.rs b/nvx-rs/src/host/session/tests/managed_lifecycle.rs similarity index 96% rename from nvx-rs/tests/managed_lifecycle.rs rename to nvx-rs/src/host/session/tests/managed_lifecycle.rs index 7b1debb8c..be185de6c 100644 --- a/nvx-rs/tests/managed_lifecycle.rs +++ b/nvx-rs/src/host/session/tests/managed_lifecycle.rs @@ -1,7 +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`. +//! refused working directories, and stop, driven through the host's control 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: @@ -23,7 +23,6 @@ //! //! These checks were `run_managed_lifecycle` of `scripts/nvx_tools/microvm_tests.py`, which drove //! the control session from Python until microsoft/nvx#359 left the protocol to nvx-rs alone. -#![cfg(all(feature = "host", any(target_os = "linux", windows)))] use std::env; use std::fs::{self, File, OpenOptions}; @@ -35,13 +34,23 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use nvx::host::platform::process_start_time; -use nvx::host::session::{self, ControlSession, ExecEvent}; -use nvx::protocol::{ - self, APP_ERROR, APP_MAPS, APP_READY, CAPABILITY_LEN, CWD_FAILED, ExitCategory, HostMap, - Workload, WorkloadEnvironment, +use crate::host::platform::process_start_time; +use crate::host::session::{self, ControlSession, ExecEvent}; +use crate::protocol::{ + self, APP_ERROR, APP_MAPS, APP_READY, CAPABILITY_LEN, ExitCategory, HostMap, Workload, + WorkloadEnvironment, }; +/// The categories with which the guest refuses a working directory that the workload cannot +/// enter, a malformed request, and a workload before it applied every host mapping: NVXC's wire +/// values, which the guest's own constants hold, but which the `cli` feature alone, with which +/// this test runs, does not build. +const CWD_FAILED: &str = "cwd-failed"; +/// See [`CWD_FAILED`]. +const INVALID_REQUEST: &str = "invalid-request"; +/// See [`CWD_FAILED`]. +const MAPPINGS_INCOMPLETE: &str = "mappings-incomplete"; + /// An entry whose value has spaces, an equals sign, and a character beyond ASCII. const COMPLEX: &str = "COMPLEX=space = \u{2603}"; /// The file that a workload creates if it runs despite a refused working directory. @@ -391,12 +400,7 @@ fn layered_mapping_session( let expected = if accepted { (APP_READY, 900, 0, Vec::new()) } else { - ( - APP_ERROR, - 900, - 22, - protocol::INVALID_REQUEST.as_bytes().to_vec(), - ) + (APP_ERROR, 900, 22, INVALID_REQUEST.as_bytes().to_vec()) }; assert_eq!(session.read_response(Some(deadline)).unwrap(), expected); }; @@ -411,10 +415,7 @@ fn layered_mapping_session( None, WorkloadEnvironment::Default, ); - assert_eq!( - outcome.refusal, - Some((11, protocol::MAPPINGS_INCOMPLETE.to_owned())) - ); + assert_eq!(outcome.refusal, Some((11, MAPPINGS_INCOMPLETE.to_owned()))); assert!(outcome.exit.is_none()); }; incomplete(&mut session); diff --git a/nvx-rs/src/host/transport/tests.rs b/nvx-rs/src/host/transport/tests.rs index 5c0716802..b49065f54 100644 --- a/nvx-rs/src/host/transport/tests.rs +++ b/nvx-rs/src/host/transport/tests.rs @@ -1,6 +1,7 @@ #![cfg(any(target_os = "linux", windows))] use std::error::Error as _; +#[cfg(feature = "cli")] use std::io::{Read, Write}; use std::process::{Child, Command, Stdio}; @@ -52,6 +53,7 @@ fn bind(endpoint: &str) -> Server { } } +#[cfg(feature = "cli")] fn exchange(server: Server) { #[cfg(target_os = "linux")] let mut peer = server.accept().unwrap().0; @@ -96,6 +98,7 @@ impl Drop for Sleeper { } } +#[cfg(feature = "cli")] #[test] fn verified_and_legacy_connections_wait_for_the_owned_endpoint_and_keep_native_io() { let process = reference(std::process::id()); @@ -138,6 +141,7 @@ fn verified_and_legacy_connections_wait_for_the_owned_endpoint_and_keep_native_i } } +#[cfg(feature = "cli")] #[test] fn another_process_serving_the_endpoint_is_refused() { let vm = Sleeper::new(); diff --git a/nvx-rs/src/lib.rs b/nvx-rs/src/lib.rs index 4e078bed5..c4d2ce295 100644 --- a/nvx-rs/src/lib.rs +++ b/nvx-rs/src/lib.rs @@ -5,10 +5,11 @@ //! starts a managed agent, which runs workloads that the host sends it over an authenticated //! control console. This crate holds everything it takes to drive one: //! -//! - [`protocol`]: the codec of the control console, the `NVXS` session that OpenVMM brokers and -//! the `NVXC` frames of the guest agent, the guest targets that a sandbox's live shares may not -//! take, and the exit statuses with which a clone's guest fails fast. It has no dependencies, -//! so the host, the guest, and test doubles share it. +//! - [`protocol`]: the codec of the control console and the `NVXS` session that OpenVMM brokers, +//! which carries any application protocol, the guest targets that a sandbox's live shares may +//! not take, and the exit statuses with which a clone's guest fails fast. NVX's own control +//! protocol, `NVXC`, is private to the crate. It has no dependencies, so the host, the guest, +//! and test doubles share it. //! - [`activation`]: the activation record of a layered root, the bounded, versioned, non-secret //! record of the settings that its activation applies, which a clone restored from a template //! reads in place of its captured configuration, and what Ready reports about the activation. @@ -32,8 +33,8 @@ //! the host and the guest share it. //! - [`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, VMs that a caller creates, starts, reattaches to, and stops in private +//! network and filesystem policies, the host's NVXS connection, process and 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 diff --git a/nvx-rs/src/protocol/app.rs b/nvx-rs/src/protocol/app.rs index 872522658..da56b199e 100644 --- a/nvx-rs/src/protocol/app.rs +++ b/nvx-rs/src/protocol/app.rs @@ -5,12 +5,29 @@ //! request ID, a status, and the payload length) followed by the payload. Responses repeat the ID //! of their request. +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] use super::{ProtocolError, array, violation}; +#[cfg(any( + feature = "cli", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] use crate::activation::ActivationRecord; +#[cfg(any( + feature = "cli", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] use self::workload::WorkloadIdentity; /// Shared identity data; host-side selection remains in the host module. +#[cfg(any( + feature = "host", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] pub(crate) mod workload { /// The identity that workloads run as: a non-root user and group ID. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -30,97 +47,201 @@ pub(crate) mod workload { } /// Magic of every application frame. -pub const APP_MAGIC: [u8; 4] = *b"NVXC"; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_MAGIC: [u8; 4] = *b"NVXC"; /// Version of the application frame format. -pub const APP_VERSION: u8 = 1; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_VERSION: u8 = 1; /// Length of an application frame header. -pub const APP_HEADER_LEN: usize = 24; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_HEADER_LEN: usize = 24; /// Request that the guest agent acknowledge readiness with [`APP_READY`]. -pub const APP_PING: u8 = 1; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_PING: u8 = 1; /// Request that the guest agent run a workload; the payload is an `EXEC` payload. -pub const APP_EXEC: u8 = 2; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_EXEC: u8 = 2; /// Request that the guest agent shut the VM down, which it acknowledges with [`APP_STOPPED`]. -pub const APP_STOP: u8 = 3; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_STOP: u8 = 3; /// Request that the guest agent terminate the workload of the request with the frame's ID. -pub const APP_CANCEL: u8 = 4; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_CANCEL: u8 = 4; /// Request for the guest's control features, which the guest agent answers with [`APP_READY`] /// and a [`GuestFeatures`](super::GuestFeatures) payload. -pub const APP_FEATURES: u8 = 5; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_FEATURES: u8 = 5; /// Request that the guest agent mount entries of the host mapping table; the payload is a `MAPS` /// payload. -pub const APP_MAPS: u8 = 6; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_MAPS: u8 = 6; /// Request that nvx-init capture the VM after optionally killing its executions. -#[cfg_attr( - not(any( - test, - feature = "host", - all(feature = "guest", target_os = "linux", target_arch = "x86_64") - )), - expect(dead_code, reason = "SL7.7a makes nvx-init handle capture requests") -)] +#[cfg(any( + feature = "cli", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] pub(crate) const APP_CAPTURE: u8 = 7; /// Request that nvx-init activate a restored VM with its record and workload identity. -#[cfg_attr( - not(any( - test, - feature = "cli", - all(feature = "guest", target_os = "linux", target_arch = "x86_64") - )), - expect( - dead_code, - reason = "only the CLI's restore driver sends it, and only nvx-init reads it" - ) -)] +#[cfg(any( + feature = "cli", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] pub(crate) const APP_ACTIVATE: u8 = 8; /// Response that acknowledges a request. -pub const APP_READY: u8 = 0x81; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_READY: u8 = 0x81; /// Response that carries a chunk of a workload's standard output. -pub const APP_STDOUT: u8 = 0x82; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_STDOUT: u8 = 0x82; /// Response that carries a chunk of a workload's standard error. -pub const APP_STDERR: u8 = 0x83; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_STDERR: u8 = 0x83; /// Response that ends a workload: the status is its exit status, and the payload its /// [`ExitCategory`]. -pub const APP_EXIT: u8 = 0x84; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_EXIT: u8 = 0x84; /// Response that acknowledges [`APP_STOP`]. -pub const APP_STOPPED: u8 = 0x85; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_STOPPED: u8 = 0x85; /// Response that refuses a request: the status is an error number, and the payload an error /// category such as [`LAUNCH_FAILED`]. -pub const APP_ERROR: u8 = 0xff; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const APP_ERROR: u8 = 0xff; /// Error category with which a guest agent refuses a request kind that it does not know. -pub const UNSUPPORTED_OPERATION: &[u8] = b"unsupported-operation"; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const UNSUPPORTED_OPERATION: &[u8] = b"unsupported-operation"; /// Error category with which a guest agent refuses a malformed request. -pub const INVALID_REQUEST: &str = "invalid-request"; +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const INVALID_REQUEST: &str = "invalid-request"; /// Error category with which a guest agent refuses a request while a workload runs. -pub const BUSY: &str = "busy"; +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const BUSY: &str = "busy"; /// Error category with which a guest agent reports a workload that it could not launch. -pub const LAUNCH_FAILED: &str = "launch-failed"; +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const LAUNCH_FAILED: &str = "launch-failed"; /// Error category with which a guest agent reports a working directory that the workload cannot /// enter. The status is the guest's error number, and nothing of the workload ran. -pub const CWD_FAILED: &str = "cwd-failed"; +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const CWD_FAILED: &str = "cwd-failed"; /// Error category with which a guest agent refuses a workload until it has applied every host /// mapping that the kernel command line announced. -pub const MAPPINGS_INCOMPLETE: &str = "mappings-incomplete"; +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const MAPPINGS_INCOMPLETE: &str = "mappings-incomplete"; /// Error category with which a guest agent reports a host mapping that it could not apply. The /// status is the guest's error number. -pub const MAPPING_FAILED: &str = "mapping-failed"; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) const MAPPING_FAILED: &str = "mapping-failed"; /// Decoded application frame. +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct AppFrame<'a> { +pub(crate) struct AppFrame<'a> { /// One of the `APP_*` frame kinds. - pub kind: u8, + pub(crate) kind: u8, /// ID of the request, which its responses repeat. - pub request_id: u64, + pub(crate) request_id: u64, /// Status of a response; zero in requests. - pub status: i32, + pub(crate) status: i32, /// Payload that follows the header. - pub payload: &'a [u8], + pub(crate) payload: &'a [u8], } /// Encodes one application frame. -pub fn encode_app(kind: u8, request_id: u64, status: i32, payload: &[u8]) -> Vec { +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) fn encode_app(kind: u8, request_id: u64, status: i32, payload: &[u8]) -> Vec { let length = u32::try_from(payload.len()).unwrap_or(u32::MAX); let mut frame = Vec::with_capacity(APP_HEADER_LEN + payload.len()); frame.extend_from_slice(&APP_MAGIC); @@ -135,7 +256,12 @@ pub fn encode_app(kind: u8, request_id: u64, status: i32, payload: &[u8]) -> Vec } /// Decodes and validates an application frame. -pub fn decode_app(frame: &[u8]) -> Result, ProtocolError> { +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) fn decode_app(frame: &[u8]) -> Result, ProtocolError> { if frame.len() < APP_HEADER_LEN { return Err(violation( "control endpoint returned a truncated application frame", @@ -158,22 +284,13 @@ pub fn decode_app(frame: &[u8]) -> Result, ProtocolError> { } /// Encodes the capture request's flag as one byte, 0 or 1. -#[cfg_attr( - not(any(test, feature = "host")), - expect(dead_code, reason = "SL7.7a makes nvx-init handle capture requests") -)] +#[cfg(feature = "cli")] pub(crate) fn encode_capture_payload(kill_executions: bool) -> [u8; 1] { [u8::from(kill_executions)] } /// Decodes the capture request's sole kill-executions flag. -#[cfg_attr( - not(any( - test, - all(feature = "guest", target_os = "linux", target_arch = "x86_64") - )), - expect(dead_code, reason = "SL7.7a makes nvx-init handle capture requests") -)] +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] pub(crate) fn decode_capture_payload(payload: &[u8]) -> Result { match payload { [0] => Ok(false), @@ -188,13 +305,7 @@ pub(crate) fn decode_capture_payload(payload: &[u8]) -> Result Result<(ActivationRecord, WorkloadIdentity), ProtocolError> { @@ -243,6 +348,10 @@ pub(crate) fn decode_activate_payload( } /// Matches the guest's non-root credential range, excluding the unchanged-ID sentinel. +#[cfg(any( + feature = "cli", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] fn check_workload_identity(workload: &WorkloadIdentity) -> Result<(), ProtocolError> { if matches!(workload.uid, 0 | u32::MAX) || matches!(workload.gid, 0 | u32::MAX) { return Err(violation( @@ -253,8 +362,9 @@ fn check_workload_identity(workload: &WorkloadIdentity) -> Result<(), ProtocolEr } /// Exit category reported by the guest agent, the payload of an [`APP_EXIT`] response. +#[cfg(any(feature = "cli", feature = "testing"))] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExitCategory { +pub(crate) enum ExitCategory { /// The workload exited; the status is its exit code. Exit, /// The workload's timeout elapsed and the guest agent killed it. @@ -269,9 +379,11 @@ pub enum ExitCategory { Cancelled, } +#[cfg(any(feature = "cli", feature = "testing"))] impl ExitCategory { /// Parses the payload of an [`APP_EXIT`] response. - pub fn parse(payload: &[u8]) -> Result { + #[cfg(feature = "cli")] + pub(crate) fn parse(payload: &[u8]) -> Result { match payload { b"exit" => Ok(Self::Exit), b"timeout" => Ok(Self::Timeout), @@ -286,7 +398,8 @@ impl ExitCategory { } /// Returns the payload of an [`APP_EXIT`] response that reports this category. - pub const fn as_str(self) -> &'static str { + #[cfg(feature = "testing")] + pub(crate) const fn as_str(self) -> &'static str { match self { Self::Exit => "exit", Self::Timeout => "timeout", diff --git a/nvx-rs/src/protocol/exec.rs b/nvx-rs/src/protocol/exec.rs index 20937d0de..2795f2cf9 100644 --- a/nvx-rs/src/protocol/exec.rs +++ b/nvx-rs/src/protocol/exec.rs @@ -10,38 +10,41 @@ use std::collections::BTreeSet; -use super::{APP_HEADER_LEN, OUTER_MAX_PAYLOAD, ProtocolError, u16_at, u32_at, violation}; +#[cfg(feature = "cli")] +use super::{APP_HEADER_LEN, OUTER_MAX_PAYLOAD}; +use super::{ProtocolError, violation}; +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +use super::{u16_at, u32_at}; /// Largest number of workload arguments, including the program. -pub const MAX_ARGUMENTS: usize = 64; +pub(crate) const MAX_ARGUMENTS: usize = 64; /// Largest encoded size of one workload argument, working directory, or environment entry. -pub const MAX_ARGUMENT_BYTES: usize = 4096; -/// Largest working directory that hosts send: Linux's `PATH_MAX` without the terminating NUL, one -/// byte below the agent's bound. -pub const MAX_CWD_BYTES: usize = 4095; +pub(crate) const MAX_ARGUMENT_BYTES: usize = 4096; /// Largest working directory that the C guest decoder accepts. Unlike host paths, the guest's /// wire bound includes all 4096 non-NUL bytes because its terminating NUL is allocated separately. -pub const MAX_GUEST_CWD_BYTES: usize = MAX_ARGUMENT_BYTES; +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] +pub(crate) const MAX_GUEST_CWD_BYTES: usize = MAX_ARGUMENT_BYTES; /// Largest number of workload environment entries. -pub const MAX_ENVIRONMENT: usize = 256; -/// Largest workload timeout the guest agent accepts: any value of the exec request's 32-bit -/// timeout field, which is also the largest `process.timeout` that MXC's schema allows. -pub const MAX_TIMEOUT_MS: u32 = u32::MAX; +pub(crate) const MAX_ENVIRONMENT: usize = 256; /// Largest combined stdout and stderr volume the guest agent forwards for one execution. -pub const MAX_OUTPUT_BYTES: usize = 1024 * 1024; +pub(crate) const MAX_OUTPUT_BYTES: usize = 1024 * 1024; /// Extension field of a payload that carries flags, a working directory, and an environment. -pub const EXEC_EXTENDED: u16 = 1; +pub(crate) const EXEC_EXTENDED: u16 = 1; /// Flag of an extended payload that carries a working directory. -pub const EXEC_CWD_PRESENT: u16 = 1 << 0; +pub(crate) const EXEC_CWD_PRESENT: u16 = 1 << 0; /// Flag of an extended payload that carries an explicit environment. -pub const EXEC_ENVIRONMENT_PRESENT: u16 = 1 << 1; +pub(crate) const EXEC_ENVIRONMENT_PRESENT: u16 = 1 << 1; /// Flag of an extended payload whose environment entries layer over the default environment. -pub const EXEC_INHERIT_DEFAULT_ENV: u16 = 1 << 2; +pub(crate) const EXEC_INHERIT_DEFAULT_ENV: u16 = 1 << 2; /// The environment that the guest agent gives a workload. +#[cfg(feature = "cli")] #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WorkloadEnvironment<'a> { +pub(crate) enum WorkloadEnvironment<'a> { /// The guest's default environment. Default, /// Exactly these `KEY=VALUE` entries; an empty slice is an empty environment. @@ -52,17 +55,18 @@ pub enum WorkloadEnvironment<'a> { } /// A workload that an `EXEC` request asks the guest agent to run. +#[cfg(feature = "cli")] #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Workload<'a> { +pub(crate) struct Workload<'a> { /// The program, an absolute guest path, followed by its arguments. - pub argv: Vec, + pub(crate) argv: Vec, /// Timeout in milliseconds; zero disables it. - pub timeout_ms: u32, + pub(crate) timeout_ms: u32, /// Absolute guest directory that the guest agent enters before it starts the program; /// `None` selects `/`. - pub cwd: Option<&'a str>, + pub(crate) cwd: Option<&'a str>, /// The program's environment. - pub environment: WorkloadEnvironment<'a>, + pub(crate) environment: WorkloadEnvironment<'a>, } /// Encodes the payload of an `EXEC` request. @@ -70,10 +74,11 @@ pub struct Workload<'a> { /// The guest agent requires 1 to [`MAX_ARGUMENTS`] non-empty arguments without NUL bytes, each /// at most [`MAX_ARGUMENT_BYTES`] long, an absolute program path, an absolute working directory /// of at most [`MAX_ARGUMENT_BYTES`] without NUL bytes, and at most [`MAX_ENVIRONMENT`] -/// `KEY=VALUE` environment entries with unique names. It accepts any timeout up to -/// [`MAX_TIMEOUT_MS`], and zero disables it. A workload without a working directory or an -/// explicit environment keeps the legacy payload. -pub fn encode_exec_payload(workload: &Workload<'_>) -> Result, ProtocolError> { +/// `KEY=VALUE` environment entries with unique names. It accepts any value of the 32-bit +/// timeout, and zero disables it. A workload without a working directory or an explicit +/// environment keeps the legacy payload. +#[cfg(feature = "cli")] +pub(crate) fn encode_exec_payload(workload: &Workload<'_>) -> Result, ProtocolError> { let argv = &workload.argv; if argv.is_empty() || argv.len() > MAX_ARGUMENTS { return Err(violation(format!( @@ -164,19 +169,20 @@ pub fn encode_exec_payload(workload: &Workload<'_>) -> Result, ProtocolE } /// An `EXEC` payload as the guest agent reads it. +#[cfg(feature = "testing")] #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ExecPayload { +pub(crate) struct ExecPayload { /// Timeout in milliseconds; zero disables it. - pub timeout_ms: u32, + pub(crate) timeout_ms: u32, /// The program, followed by its arguments. - pub argv: Vec, + pub(crate) argv: Vec, /// Absolute guest directory to start the program in; `None` selects `/`. - pub cwd: Option, + pub(crate) cwd: Option, /// The explicit `KEY=VALUE` environment entries, or `None` for the default environment. - pub environment: Option>, + pub(crate) environment: Option>, /// Whether the program starts from the default environment: true without explicit entries, /// and true when the entries layer over the default environment rather than replace it. - pub inherit_default_env: bool, + pub(crate) inherit_default_env: bool, } /// An `EXEC` payload as the guest agent reads it, without imposing UTF-8 on guest byte strings. @@ -184,21 +190,26 @@ pub struct ExecPayload { /// Linux arguments, paths, and environment entries are byte strings. Keeping this form separate /// from [`ExecPayload`] preserves the host and fake-OpenVMM APIs, which intentionally use UTF-8, /// while accepting every non-NUL byte sequence that the C guest accepted. +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] #[derive(Debug, Clone, PartialEq, Eq)] -pub struct GuestExecPayload { +pub(crate) struct GuestExecPayload { /// Timeout in milliseconds; zero disables it. - pub timeout_ms: u32, + pub(crate) timeout_ms: u32, /// The absolute program, followed by its arguments. - pub argv: Vec>, + pub(crate) argv: Vec>, /// Absolute guest directory to start the program in; `None` selects `/`. - pub cwd: Option>, + pub(crate) cwd: Option>, /// Explicit `KEY=VALUE` entries, or `None` for the default environment. - pub environment: Option>>, + pub(crate) environment: Option>>, /// Whether explicit entries layer over the default environment. - pub inherit_default_env: bool, + pub(crate) inherit_default_env: bool, } /// The fixed fields of an `EXEC` payload, which both decoders read and check alike. +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] struct ExecHeader { /// Timeout in milliseconds; zero disables it. timeout_ms: u32, @@ -221,6 +232,10 @@ struct ExecHeader { /// unknown extension or flag, a working-directory length without the working-directory flag or /// the reverse, environment entries without the environment flag, and more than /// [`MAX_ENVIRONMENT`] entries. +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] fn decode_exec_header(payload: &[u8], max_cwd: usize) -> Result { let truncated = || violation("the exec request is truncated"); if payload.len() < 8 { @@ -273,6 +288,10 @@ fn decode_exec_header(payload: &[u8], max_cwd: usize) -> Result { /// Timeout in milliseconds; zero disables it. timeout_ms: u32, @@ -294,6 +313,10 @@ struct DecodedExec { /// entry, or one longer than [`MAX_ARGUMENT_BYTES`], one that contains NUL characters or that `S` /// refuses, a relative program or working directory, an entry without a name, a name that /// repeats, and trailing bytes. +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] fn decode_exec( payload: &[u8], max_cwd: usize, @@ -368,7 +391,8 @@ fn decode_exec( /// longer than [`MAX_ARGUMENT_BYTES`], text that is not UTF-8 or contains NUL characters, a /// relative program or working directory, an entry without a name, a name that repeats, and /// trailing bytes. -pub fn decode_exec_payload(payload: &[u8]) -> Result { +#[cfg(feature = "testing")] +pub(crate) fn decode_exec_payload(payload: &[u8]) -> Result { let DecodedExec { timeout_ms, flags, @@ -395,7 +419,8 @@ pub fn decode_exec_payload(payload: &[u8]) -> Result /// [`EXEC_INHERIT_DEFAULT_ENV`] alone, so unlike the host's, it is false without explicit entries. /// The host-facing decoder remains text-oriented, so this guest decoder does not broaden host /// behavior. -pub fn decode_guest_exec_payload(payload: &[u8]) -> Result { +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] +pub(crate) fn decode_guest_exec_payload(payload: &[u8]) -> Result { let DecodedExec { timeout_ms, flags, @@ -416,6 +441,10 @@ pub fn decode_guest_exec_payload(payload: &[u8]) -> Result + Sized { /// Converts the bytes of a string of the payload, which `what` names, as soon as they are /// read. @@ -429,6 +458,7 @@ trait ExecString: AsRef<[u8]> + Sized { } /// The host's decoder refuses bytes that are not UTF-8, and names a variable that repeats. +#[cfg(feature = "testing")] impl ExecString for String { fn convert(bytes: Vec, what: &str) -> Result { String::from_utf8(bytes).map_err(|_| violation(format!("exec {what} must be valid UTF-8"))) @@ -448,6 +478,7 @@ impl ExecString for String { } /// The guest's decoder keeps Linux byte strings as they are. +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] impl ExecString for Vec { fn convert(bytes: Vec, _what: &str) -> Result { Ok(bytes) @@ -464,6 +495,10 @@ impl ExecString for Vec { /// Reads the string at `offset` that its `u32` length precedes, of 1 to [`MAX_ARGUMENT_BYTES`] /// bytes, as `S`, and advances `offset` past it. +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] fn prefixed_string( payload: &[u8], offset: &mut usize, @@ -473,6 +508,10 @@ fn prefixed_string( } /// Reads one little-endian length-prefixed byte string and advances `offset`. +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] fn prefixed_bytes( payload: &[u8], offset: &mut usize, @@ -496,6 +535,10 @@ fn prefixed_bytes( } /// Returns one bounded non-NUL byte range without imposing UTF-8. +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] fn bytes_at<'a>( payload: &'a [u8], offset: usize, diff --git a/nvx-rs/src/protocol/features.rs b/nvx-rs/src/protocol/features.rs index 9789c9c5a..a303a5da4 100644 --- a/nvx-rs/src/protocol/features.rs +++ b/nvx-rs/src/protocol/features.rs @@ -2,43 +2,47 @@ use std::ops::BitOr; +#[cfg(feature = "cli")] use super::{ProtocolError, array, violation}; -/// Control features of a guest image, advertised in the response to -/// [`APP_FEATURES`](super::APP_FEATURES). +/// Control features of a guest image, advertised in the response to `APP_FEATURES`. /// /// The Rust guest agent uses these shared definitions and also speaks for the features of its -/// image's init script. A host drives a sandbox only when its guest provides every feature in -/// [`GuestFeatures::REQUIRED`]; an older image would silently ignore the policy or the request -/// that depends on the missing feature. +/// image's init script. A host drives a sandbox only when its guest provides every feature that +/// the host depends on, such as [`LAYERED_SANDBOX`](Self::LAYERED_SANDBOX) for `nvx-rs sandbox`; +/// an older image would silently ignore the policy or the request that depends on the missing +/// feature. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct GuestFeatures(u32); +pub(crate) struct GuestFeatures(u32); impl GuestFeatures { /// What a guest agent that predates the `APP_FEATURES` request provides. - pub const NONE: Self = Self(0); + #[cfg(feature = "cli")] + pub(crate) const NONE: Self = Self(0); /// Terminates a workload on `APP_CANCEL` and reports the `cancelled` exit category. - pub const CANCEL: Self = Self(1 << 0); + pub(crate) const CANCEL: Self = Self(1 << 0); // Bit 1 announced the bind mounts that `nvx_map=` kernel command-line tokens listed. Guests // no longer set it, so a host that still sends those tokens refuses them. /// Creates the account that `nvx_workload_account=create` selects. Only the blockless root /// creates accounts; the layered root's executions require an existing one. - pub const WORKLOAD_ACCOUNT: Self = Self(1 << 2); + pub(crate) const WORKLOAD_ACCOUNT: Self = Self(1 << 2); /// Runs each workload in a cgroup of its own and kills whatever it leaves behind. - pub const EXEC_CGROUP: Self = Self(1 << 3); + #[cfg(feature = "cli")] + pub(crate) const EXEC_CGROUP: Self = Self(1 << 3); /// Applies the working directory and the explicit environment of an extended `EXEC` request /// to that execution alone, and layers the environment over the default one on request. - pub const EXEC_ENVIRONMENT: Self = Self(1 << 4); + pub(crate) const EXEC_ENVIRONMENT: Self = Self(1 << 4); /// Starts each workload in the working directory that its `EXEC` request names, or in `/`, /// entered with the workload's identity, and refuses the launch with the `cwd-failed` /// category when the workload cannot enter it. - pub const EXEC_CWD: Self = Self(1 << 5); + pub(crate) const EXEC_CWD: Self = Self(1 << 5); /// Bind-mounts the host mapping table of `APP_MAPS` requests, as many entries as the /// `nvx_maps=` kernel command-line token announces, keeps the shared export root-only, and /// refuses workloads until every entry is mounted. - pub const HOST_MAPPING_TABLE: Self = Self(1 << 6); + pub(crate) const HOST_MAPPING_TABLE: Self = Self(1 << 6); /// Features announced by the direct-mode managed agent. - pub const DIRECT_AGENT: Self = Self( + #[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] + pub(crate) const DIRECT_AGENT: Self = Self( Self::CANCEL.0 | Self::WORKLOAD_ACCOUNT.0 | Self::EXEC_ENVIRONMENT.0 @@ -46,10 +50,12 @@ impl GuestFeatures { | Self::HOST_MAPPING_TABLE.0, ); /// Features announced by the managed agent that launches a sandbox container. - pub const SANDBOX_AGENT: Self = + #[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + ))] + pub(crate) const SANDBOX_AGENT: Self = Self(Self::CANCEL.0 | Self::EXEC_ENVIRONMENT.0 | Self::HOST_MAPPING_TABLE.0); - /// The features that the host depends on. - pub const REQUIRED: Self = Self::DIRECT_AGENT; /// The features that `nvx-rs sandbox` depends on in the layered sandbox root, whose agent /// announces [`SANDBOX_AGENT`](Self::SANDBOX_AGENT): /// [`EXEC_ENVIRONMENT`](Self::EXEC_ENVIRONMENT), because `exec` sends a working directory and @@ -62,9 +68,12 @@ impl GuestFeatures { /// when OpenVMM resets the session that it ran for. It holds no /// [`WORKLOAD_ACCOUNT`](Self::WORKLOAD_ACCOUNT), because no sandbox launch requests account /// creation, which the layered root neither performs nor announces (microsoft/nvx#566). - pub const LAYERED_SANDBOX: Self = Self(Self::EXEC_ENVIRONMENT.0 | Self::HOST_MAPPING_TABLE.0); + #[cfg(feature = "cli")] + pub(crate) const LAYERED_SANDBOX: Self = + Self(Self::EXEC_ENVIRONMENT.0 | Self::HOST_MAPPING_TABLE.0); /// Human-readable names of every required feature, used in compatibility errors. + #[cfg(feature = "cli")] pub(crate) const NAMES: [(Self, &'static str); 6] = [ (Self::CANCEL, "cancellation"), (Self::WORKLOAD_ACCOUNT, "workload accounts"), @@ -75,24 +84,22 @@ impl GuestFeatures { ]; /// Returns the features whose bits are set in `bits`. - pub const fn from_bits(bits: u32) -> Self { + #[cfg(feature = "testing")] + pub(crate) const fn from_bits(bits: u32) -> Self { Self(bits) } /// Returns the bits of these features. - pub const fn bits(self) -> u32 { + #[cfg(feature = "testing")] + pub(crate) const fn bits(self) -> u32 { self.0 } - /// Returns whether this set holds every feature of `other`. - pub const fn contains(self, other: Self) -> bool { - self.0 & other.0 == other.0 - } - /// Decodes the payload of a features response. /// /// Later revisions may append fields, so trailing bytes are ignored. - pub fn decode(payload: &[u8]) -> Result { + #[cfg(feature = "cli")] + pub(crate) fn decode(payload: &[u8]) -> Result { let bits = payload .get(..4) .ok_or_else(|| violation("the guest agent returned a truncated features response"))?; @@ -100,12 +107,17 @@ impl GuestFeatures { } /// Encodes the payload of a features response, as a guest agent sends it. - pub const fn encode(self) -> [u8; 4] { + #[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") + ))] + pub(crate) const fn encode(self) -> [u8; 4] { self.0.to_le_bytes() } /// Names the features of `required` that this set lacks. - pub fn missing(self, required: Self) -> Vec<&'static str> { + #[cfg(feature = "cli")] + pub(crate) fn missing(self, required: Self) -> Vec<&'static str> { Self::NAMES .iter() .filter(|(feature, _)| required.0 & feature.0 != 0 && self.0 & feature.0 == 0) diff --git a/nvx-rs/src/protocol/maps.rs b/nvx-rs/src/protocol/maps.rs index f72d4353f..4997087d7 100644 --- a/nvx-rs/src/protocol/maps.rs +++ b/nvx-rs/src/protocol/maps.rs @@ -1,5 +1,5 @@ -//! `MAPS` payloads: the host mapping table that [`APP_MAPS`](super::APP_MAPS) requests hand the -//! guest agent. +//! `MAPS` payloads: the host mapping table that the `APP_MAPS` requests of NVX's own control +//! protocol, `NVXC`, hand the guest agent. //! //! A payload holds the index of its first entry (`u32`) and its number of entries (`u32`), //! followed by the entries. Each entry holds its flags (`u16`), the lengths of its source and diff --git a/nvx-rs/src/protocol/mod.rs b/nvx-rs/src/protocol/mod.rs index 4b82adb3a..a89f0ffe2 100644 --- a/nvx-rs/src/protocol/mod.rs +++ b/nvx-rs/src/protocol/mod.rs @@ -1,18 +1,14 @@ //! Codec of the OpenVMM control-console protocol. //! //! OpenVMM brokers an authenticated stream of outer records (magic `NVXS`) between one host -//! client and the Rust guest agent. Data records carry application frames (magic `NVXC`) that -//! the host and guest share from this module. All integers are little-endian. +//! client and the guest's agent. Data records carry the bytes of an application protocol. NVX's +//! own, `NVXC`, with its frames, its `EXEC` payloads and the control features that a guest image +//! advertises, is private to this crate: only NVX's programs speak it, the CLI, nvx-init, the +//! managed agent and the fake OpenVMM. All integers are little-endian. //! //! - Outer records: [`encode_outer`] and [`decode_outer_header`], and the `OUTER_*` record types. -//! - Application frames: [`encode_app`] and [`decode_app`], the `APP_*` request and response -//! kinds, the error categories with which the guest agent refuses a request, and the -//! [`ExitCategory`] of a workload. -//! - `EXEC` payloads: the [`Workload`] that the host encodes with [`encode_exec_payload`], and -//! the [`ExecPayload`] that the guest agent decodes with [`decode_exec_payload`]. //! - `MAPS` payloads: the [`HostMap`] table that the host encodes with [`encode_maps_payloads`], //! and the [`MapsPayload`] that the guest agent decodes with [`decode_maps_payload`]. -//! - [`GuestFeatures`]: the control features that a guest image advertises. //! - The kernel command-line tokens through which the host configures the guest agent at boot, //! such as [`MAPS_TOKEN`]. //! - [`cmdline`]: the kernel command line as Linux reads it, for the readers that need the @@ -42,8 +38,18 @@ use std::fmt; mod app; mod boot; pub mod cmdline; -mod exec; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) mod exec; pub mod export; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] mod features; pub mod maps; pub mod nvxs; @@ -62,35 +68,64 @@ pub mod view; ))] pub(crate) use app::APP_ACTIVATE; #[cfg(any( - feature = "host", + feature = "cli", all(feature = "guest", target_os = "linux", target_arch = "x86_64") ))] pub(crate) use app::APP_CAPTURE; +#[cfg(any( + feature = "cli", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use app::APP_HEADER_LEN; +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] +pub(crate) use app::AppFrame; +#[cfg(any(feature = "cli", feature = "testing"))] +pub(crate) use app::ExitCategory; #[cfg(feature = "cli")] pub(crate) use app::encode_activate_payload; -#[cfg(feature = "host")] +#[cfg(feature = "cli")] pub(crate) use app::encode_capture_payload; #[cfg(any( feature = "host", all(feature = "guest", target_os = "linux", target_arch = "x86_64") ))] pub(crate) use app::workload; -pub use app::{ - APP_CANCEL, APP_ERROR, APP_EXEC, APP_EXIT, APP_FEATURES, APP_HEADER_LEN, APP_MAGIC, APP_MAPS, - APP_PING, APP_READY, APP_STDERR, APP_STDOUT, APP_STOP, APP_STOPPED, APP_VERSION, AppFrame, - BUSY, CWD_FAILED, ExitCategory, INVALID_REQUEST, LAUNCH_FAILED, MAPPING_FAILED, - MAPPINGS_INCOMPLETE, UNSUPPORTED_OPERATION, decode_app, encode_app, +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use app::{ + APP_CANCEL, APP_ERROR, APP_EXEC, APP_EXIT, APP_FEATURES, APP_MAPS, APP_PING, APP_READY, + APP_STDERR, APP_STDOUT, APP_STOP, APP_STOPPED, MAPPING_FAILED, UNSUPPORTED_OPERATION, + decode_app, encode_app, }; +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use app::{BUSY, CWD_FAILED, INVALID_REQUEST, LAUNCH_FAILED, MAPPINGS_INCOMPLETE}; #[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] pub(crate) use app::{decode_activate_payload, decode_capture_payload}; pub use boot::{MAPS_TOKEN, WORKLOAD_ACCOUNT_TOKEN, parse_host_mapping_count}; -pub use exec::{ - EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_EXTENDED, EXEC_INHERIT_DEFAULT_ENV, - ExecPayload, GuestExecPayload, MAX_ARGUMENT_BYTES, MAX_ARGUMENTS, MAX_CWD_BYTES, - MAX_ENVIRONMENT, MAX_GUEST_CWD_BYTES, MAX_OUTPUT_BYTES, MAX_TIMEOUT_MS, Workload, - WorkloadEnvironment, decode_exec_payload, decode_guest_exec_payload, encode_exec_payload, -}; -pub use features::GuestFeatures; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use exec::MAX_OUTPUT_BYTES; +#[cfg(feature = "testing")] +pub(crate) use exec::decode_exec_payload; +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] +pub(crate) use exec::{GuestExecPayload, decode_guest_exec_payload}; +#[cfg(feature = "cli")] +pub(crate) use exec::{Workload, WorkloadEnvironment, encode_exec_payload}; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +pub(crate) use features::GuestFeatures; pub use maps::{ GuestHostMap, GuestMapsPayload, HostMap, MAP_ENTRY_HEADER_LEN, MAP_READ_ONLY, MAPS_HEADER_LEN, MAX_HOST_MAPPINGS, MAX_MAP_PATH_BYTES, MapsPayload, decode_guest_maps_payload, diff --git a/nvx-rs/src/protocol/tests.rs b/nvx-rs/src/protocol/tests.rs index 72ab4809b..215eba5f5 100644 --- a/nvx-rs/src/protocol/tests.rs +++ b/nvx-rs/src/protocol/tests.rs @@ -5,14 +5,120 @@ //! encoders, and the decoders that the guest agent and the fake OpenVMM share with it, must all //! match them, so a change to the codec that every side would agree on still fails here. +#[cfg(all( + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] use proptest::prelude::*; use super::nvxs::{RecordParser, RecordType, encode_record}; use super::*; +#[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] use crate::activation::ActivationRecord; -use crate::tests::{ACTIVATION_V2, NVXC_V1, Values, of_kind, vector, vectors}; +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] +use crate::tests::ACTIVATION_V2; +#[cfg(all( + feature = "cli", + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] +use crate::tests::Values; +#[cfg(all( + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] +use crate::tests::vectors; +use crate::tests::{NVXC_V1, of_kind, vector}; + +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +use super::app::{APP_HEADER_LEN, APP_VERSION, AppFrame}; +#[cfg(all( + feature = "cli", + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] +use super::exec::ExecPayload; +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] +use super::exec::MAX_GUEST_CWD_BYTES; +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +use super::exec::{ + EXEC_CWD_PRESENT, EXEC_ENVIRONMENT_PRESENT, EXEC_EXTENDED, EXEC_INHERIT_DEFAULT_ENV, + MAX_ARGUMENTS, +}; +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +use super::exec::{MAX_ARGUMENT_BYTES, MAX_ENVIRONMENT}; + +/// Largest working directory that hosts send: Linux's `PATH_MAX` without the terminating NUL, one +/// byte below the agent's bound. Only these tests read it. +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +const MAX_CWD_BYTES: usize = 4095; +/// Largest workload timeout the guest agent accepts: any value of the exec request's 32-bit +/// timeout field, which is also the largest `process.timeout` that MXC's schema allows. Only these +/// tests read it. +#[cfg(feature = "cli")] +const MAX_TIMEOUT_MS: u32 = u32::MAX; + +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] +impl GuestFeatures { + /// The features that the host once required, which the direct-mode managed agent announces. + /// Only tests read them. + #[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" + ))] + pub(crate) const REQUIRED: Self = Self::DIRECT_AGENT; + + /// Returns whether this set holds every feature of `other`. + #[cfg(any( + all(feature = "cli", feature = "testing"), + all( + any(feature = "cli", feature = "testing"), + feature = "guest", + target_os = "linux", + target_arch = "x86_64" + ) + ))] + pub(crate) fn contains(self, other: Self) -> bool { + (self | other) == self + } +} /// A workload that runs `argv` without a timeout in `/` with the default environment. +#[cfg(feature = "cli")] fn program<'a>(argv: &[&str]) -> Workload<'a> { Workload { argv: argv.iter().map(|argument| (*argument).to_owned()).collect(), @@ -22,6 +128,7 @@ fn program<'a>(argv: &[&str]) -> Workload<'a> { } } +#[cfg(feature = "cli")] fn strings(values: &[&str]) -> Vec { values.iter().map(|value| (*value).to_owned()).collect() } @@ -35,6 +142,11 @@ fn record_type(name: &str) -> RecordType { } /// Returns the frame kind that a vector names. +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] fn frame_kind(name: &str) -> u8 { match name { "PING" => APP_PING, @@ -135,6 +247,11 @@ fn records_match_their_vectors() { } /// Each `FRAME` vector decodes to its values, which encode to its bytes. +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn frames_match_their_vectors() { let names = ["PING", "STDOUT", "EXIT", "ERROR_CWD_FAILED"]; @@ -158,6 +275,12 @@ fn frames_match_their_vectors() { } } +#[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn capture_requests_round_trip_both_kill_flags() { for (payload, kill_executions) in [([0], false), ([1], true)] { @@ -172,6 +295,7 @@ fn capture_requests_round_trip_both_kill_flags() { } } +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] #[test] fn capture_requests_refuse_unknown_or_mis_sized_flags() { for payload in [&[][..], &[2], &[1, 0]] { @@ -179,6 +303,12 @@ fn capture_requests_refuse_unknown_or_mis_sized_flags() { } } +#[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn activate_requests_round_trip_records_and_workload_identity() { let names = [ @@ -210,6 +340,7 @@ fn activate_requests_round_trip_records_and_workload_identity() { } } +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] #[test] fn activate_requests_refuse_truncated_or_malformed_payloads() { let record = vector(ACTIVATION_V2, "RECORD", "GPT_DIRECTORY").bytes; @@ -233,6 +364,12 @@ fn activate_requests_refuse_truncated_or_malformed_payloads() { assert!(app::decode_activate_payload(&trailing).is_err()); } +#[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn activate_workload_ids_match_guest_credential_bounds() { let record = ActivationRecord::decode(&vector(ACTIVATION_V2, "RECORD", "CLONE").bytes).unwrap(); @@ -268,6 +405,13 @@ fn activate_workload_ids_match_guest_credential_bounds() { /// the host encodes to its bytes. Without an explicit environment, the host's /// `inherit_default_env` is true, and the guest's, which follows the layering flag alone, false /// (microsoft/nvx#581). +#[cfg(all( + feature = "cli", + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn exec_payloads_match_their_vectors() { let names = [ @@ -382,6 +526,7 @@ fn maps_payloads_match_their_vectors() { } /// Each `FEATURES` vector decodes to its bits, which encode to its bytes. +#[cfg(all(feature = "cli", feature = "testing"))] #[test] fn features_match_their_vectors() { for vector in of_kind(NVXC_V1, "FEATURES", &["ALL"]) { @@ -394,6 +539,12 @@ fn features_match_their_vectors() { } } +#[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn the_host_requires_every_feature() { assert_eq!( @@ -408,6 +559,7 @@ fn the_host_requires_every_feature() { assert!(!GuestFeatures::REQUIRED.contains(GuestFeatures::EXEC_CGROUP)); } +#[cfg(all(feature = "cli", feature = "testing"))] #[test] fn exec_payload_carries_every_32_bit_timeout() { for timeout_ms in [0, 3_600_001, 86_400_000, MAX_TIMEOUT_MS] { @@ -425,6 +577,7 @@ fn exec_payload_carries_every_32_bit_timeout() { } } +#[cfg(all(feature = "cli", feature = "testing"))] #[test] fn exec_payload_carries_the_working_directory_natively() { for cwd in ["", "tmp", &format!("/{}", "a".repeat(4096)), "/t\0mp"] { @@ -443,6 +596,7 @@ fn exec_payload_carries_the_working_directory_natively() { assert_eq!(decode_exec_payload(&payload).unwrap().cwd, Some(longest)); } +#[cfg(feature = "cli")] #[test] fn exec_payload_distinguishes_explicit_environments() { let environment = strings(&["FOO=a b", "EMPTY="]); @@ -462,6 +616,7 @@ fn exec_payload_distinguishes_explicit_environments() { assert_eq!(&default[6..8], &[0, 0]); } +#[cfg(feature = "cli")] #[test] fn exec_payload_enforces_agent_limits() { let encode = |argv: Vec, timeout_ms| { @@ -514,6 +669,7 @@ fn exec_payload_enforces_agent_limits() { } /// Encodes an extended exec request with an explicit environment, without the host's checks. +#[cfg(all(feature = "cli", feature = "testing"))] fn extended(argv: &[&str], cwd: Option<&str>, environment: &[&str]) -> Vec { let length = |text: &str| u32::try_from(text.len()).unwrap().to_le_bytes(); let mut flags = EXEC_ENVIRONMENT_PRESENT; @@ -538,6 +694,7 @@ fn extended(argv: &[&str], cwd: Option<&str>, environment: &[&str]) -> Vec { payload } +#[cfg(all(feature = "cli", feature = "testing"))] #[test] fn exec_requests_are_refused_where_the_guest_agent_refuses_them() { let exec = decode_exec_payload(&extended( @@ -599,6 +756,11 @@ fn exec_requests_are_refused_where_the_guest_agent_refuses_them() { assert_eq!(most.argv.len(), MAX_ARGUMENTS); } +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn decoders_reject_malformed_input() { let mut header = [0u8; OUTER_HEADER_LEN]; @@ -615,6 +777,7 @@ fn decoders_reject_malformed_input() { assert!(decode_app(&frame[..10]).is_err()); } +#[cfg(all(feature = "cli", feature = "testing"))] #[test] fn exit_categories_match_the_guest_agent() { for category in [ @@ -654,6 +817,13 @@ fn exit_categories_match_the_guest_agent() { assert!(ExitCategory::parse(b"canceled").is_err()); } +#[cfg(all( + feature = "cli", + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn guest_features_report_what_a_guest_lacks() { let required = GuestFeatures::REQUIRED; @@ -688,6 +858,7 @@ fn guest_features_report_what_a_guest_lacks() { ); } +#[cfg(all(feature = "cli", feature = "testing"))] #[test] fn guest_features_use_distinct_bits() { let mut seen = 1u32 << 1; @@ -706,6 +877,11 @@ fn map(source: &str, target: &str, read_only: bool) -> HostMap { } } +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn maps_payloads_are_split_to_fit_the_limit() { let maps: Vec = (0..10) @@ -777,6 +953,11 @@ fn maps_payloads_are_split_to_fit_the_limit() { ); } +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn maps_payloads_enforce_agent_limits() { let limit = OUTER_MAX_PAYLOAD - APP_HEADER_LEN; @@ -864,6 +1045,11 @@ fn maps_decoders_refuse_the_invalid_vectors() { // Hosts and guests share the wire constants, so changing one passes every round trip between // them yet breaks compatibility with released peers. These tests pin each value literally. +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn record_definitions_keep_their_wire_values() { assert_eq!(OUTER_HEADER_LEN, 44); @@ -889,6 +1075,7 @@ fn record_definitions_keep_their_wire_values() { assert_eq!(APP_ERROR, 0xff); } +#[cfg(all(feature = "cli", feature = "testing"))] #[test] fn feature_definitions_keep_their_wire_values() { assert_eq!(APP_FEATURES, 5); @@ -902,6 +1089,10 @@ fn feature_definitions_keep_their_wire_values() { assert_eq!(GuestFeatures::HOST_MAPPING_TABLE.bits(), 0x40); } +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn host_mapping_table_keeps_its_wire_values() { assert_eq!(MAX_HOST_MAPPINGS, 4096); @@ -914,6 +1105,10 @@ fn host_mapping_table_keeps_its_wire_values() { assert_eq!(MAPPING_FAILED, "mapping-failed"); } +#[cfg(any( + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn exec_extension_keeps_its_wire_values() { assert_eq!(EXEC_EXTENDED, 1); @@ -931,6 +1126,11 @@ fn exec_extension_keeps_its_wire_values() { assert_eq!(UNSUPPORTED_OPERATION, b"unsupported-operation"); } +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn exec_limits_support_linux_paths() { // A working directory may be as long as one argument, and Linux cannot @@ -1038,6 +1238,12 @@ fn host_mapping_count_splits_the_command_line_as_the_guest_init_does() { } } +#[cfg(all( + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn both_agent_modes_announce_the_host_mapping_table() { assert_eq!(GuestFeatures::DIRECT_AGENT.bits(), 0x75); @@ -1055,6 +1261,7 @@ fn both_agent_modes_announce_the_host_mapping_table() { ); } +#[cfg(all(feature = "cli", feature = "testing"))] #[test] fn layered_sandboxes_need_per_execution_environments_and_mapping_tables() { // The CLI's exec sends working directories and environments, and its start sends the @@ -1074,6 +1281,12 @@ fn layered_sandboxes_need_per_execution_environments_and_mapping_tables() { ); } +#[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn host_exec_encoding_round_trips_through_the_guest_decoder() { let environment = strings(&["FOO=a b", "EMPTY="]); @@ -1098,6 +1311,12 @@ fn host_exec_encoding_round_trips_through_the_guest_decoder() { assert!(decoded.inherit_default_env); } +#[cfg(all( + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn guest_exec_decoder_accepts_linux_bytes_and_the_c_cwd_boundary() { let mut payload = Vec::new(); @@ -1123,6 +1342,7 @@ fn guest_exec_decoder_accepts_linux_bytes_and_the_c_cwd_boundary() { /// Encodes an extended exec request field by field, so that its flags, environment count, and /// working-directory length may contradict what follows them. +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] fn raw_extended( flags: u16, environment_count: u16, @@ -1149,6 +1369,7 @@ fn raw_extended( payload } +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] #[test] fn guest_exec_decoder_accepts_what_the_c_agent_accepted() { fn with_environment(flags: u16, entries: &[&[u8]]) -> GuestExecPayload { @@ -1186,6 +1407,7 @@ fn guest_exec_decoder_accepts_what_the_c_agent_accepted() { } } +#[cfg(all(feature = "guest", target_os = "linux", target_arch = "x86_64"))] #[test] fn guest_exec_decoder_refuses_what_the_c_agent_refused() { // The agent decodes with its own byte-oriented decoder, so each refusal that the host @@ -1271,6 +1493,12 @@ fn guest_exec_decoder_refuses_what_the_c_agent_refused() { /// same message, except where they differ by design: the host's requires each string to be UTF-8 /// as soon as it reads it, and they word the refusals of an entry's name differently, the host's /// naming the variable that repeats. +#[cfg(all( + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn exec_decoders_refuse_strings_with_the_same_messages() { const PROGRAM: &[u8] = b"/bin/true"; @@ -1426,6 +1654,11 @@ fn exec_decoders_refuse_strings_with_the_same_messages() { } } +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn host_maps_encoding_round_trips_through_the_guest_decoder() { let maps = [ @@ -1458,6 +1691,11 @@ fn host_maps_encoding_round_trips_through_the_guest_decoder() { ); } +#[cfg(any( + feature = "cli", + feature = "testing", + all(feature = "guest", target_os = "linux", target_arch = "x86_64") +))] #[test] fn guest_maps_decoder_accepts_non_utf8_linux_paths() { let mut payload = encode_maps_payloads( @@ -1527,6 +1765,12 @@ fn maps_decoders_refuse_entries_with_the_same_messages() { /// Returns bytes near the vectors of `kinds`: the bytes of one of them, with up to four bytes /// overwritten, perhaps truncated, and with up to eight bytes appended. +#[cfg(all( + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] fn near_vectors(kinds: &'static [&'static str]) -> impl Strategy> { let encodings: Vec<_> = vectors(NVXC_V1) .into_iter() @@ -1554,6 +1798,12 @@ fn near_vectors(kinds: &'static [&'static str]) -> impl Strategy }) } +#[cfg(all( + feature = "testing", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] proptest! { /// The host's and the guest's `EXEC` and `MAPS` decoders return, never panic, on arbitrary /// bytes and on bytes near their vectors, which reach past the checks of the headers. diff --git a/nvx-rs/src/testing/fake_openvmm.rs b/nvx-rs/src/testing/fake_openvmm.rs index 8004c62e6..a6da98ec8 100644 --- a/nvx-rs/src/testing/fake_openvmm.rs +++ b/nvx-rs/src/testing/fake_openvmm.rs @@ -75,7 +75,7 @@ //! and holds them until it exits; it reads them whole, and records what each held, with its role //! and path, in `fake-openvmm--blocks.json` next to the kernel. The //! `fake_block_delay_ms=MS` token delays the opens. Its guest advertises the features of the -//! sandbox agent, [`GuestFeatures::SANDBOX_AGENT`]. With `--microvm-lifecycle managed`, it serves +//! sandbox agent, `GuestFeatures::SANDBOX_AGENT`. With `--microvm-lifecycle managed`, it serves //! the control console as above, and maps the sandbox's shares as above too: from the children of //! the aggregate export at `/run/nvx/hostfs/root`, as many as `nvx_maps=` announces, which `MAPS` //! requests deliver; it refuses a `--mount` share and `nvx_share=` tokens. With diff --git a/nvx-rs/src/testing/fake_openvmm/tests.rs b/nvx-rs/src/testing/fake_openvmm/tests.rs index 39d2ec29f..ea78459ad 100644 --- a/nvx-rs/src/testing/fake_openvmm/tests.rs +++ b/nvx-rs/src/testing/fake_openvmm/tests.rs @@ -20,6 +20,12 @@ fn shells_raise_shlvl_like_busybox() { assert_eq!(fresh[0], ("SHLVL".to_owned(), "1".to_owned())); } +#[cfg(all( + feature = "cli", + feature = "guest", + target_os = "linux", + target_arch = "x86_64" +))] #[test] fn the_current_guest_advertises_every_required_feature() { assert_eq!(GUEST_FEATURES, GuestFeatures::REQUIRED); diff --git a/nvx-rs/tests/vm_lifecycle.rs b/nvx-rs/tests/vm_lifecycle.rs index 109c7c54a..2865a123c 100644 --- a/nvx-rs/tests/vm_lifecycle.rs +++ b/nvx-rs/tests/vm_lifecycle.rs @@ -29,13 +29,21 @@ use nvx::host::runtime::{ use nvx::host::transport::HostConnection; use nvx::host::vm::{self, SavedVm, VmDir, VmOutputs}; use nvx::protocol::root::RootImage; -use nvx::protocol::{self, APP_PING, APP_READY, APP_STOP, APP_STOPPED}; use nvx::uuid::Uuid; /// The caller's kernel command-line tokens, which `nvx-rs sandbox start` gives nvx-init too: it /// manages the VM, and runs workloads as the fixed identity `65534:65534`. const COMMAND_LINE: &str = "nvx_sandbox=1 nvx_mode=managed nvx_identity=65534:65534"; +/// The request of NVXC, nvx-init's protocol, that asks the guest whether it is ready. +const PING: u8 = 1; +/// The request of NVXC that asks the guest to end the VM. +const STOP: u8 = 3; +/// NVXC's acknowledgement of [`PING`]. +const READY: u8 = 0x81; +/// NVXC's acknowledgement of [`STOP`]. +const STOPPED: u8 = 0x85; + /// The inputs of the test, which the scenario gives it as a JSON object in `NVX_VM_LIFECYCLE`. #[derive(serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -92,8 +100,28 @@ impl Drop for Started { } } -/// Sends the request `kind` of the caller's own protocol with its guest, which for nvx-init is -/// NVXC, and checks that the guest acknowledges it with `reply` by `deadline`. +/// The NVXC frame of `kind` for the request `request`, with status 0 and no payload: the magic, +/// the version, 1, the kind, two zero flag bytes, the request's ID, the status, and the payload's +/// length, each integer little-endian, as `tests/vectors/nvxc-v1.txt` pins them. +fn frame(kind: u8, request: u64) -> Vec { + [ + &b"NVXC"[..], + &[1, kind, 0, 0], + &request.to_le_bytes(), + &0i32.to_le_bytes(), + &0u32.to_le_bytes(), + ] + .concat() +} + +/// Sends the request `kind` of the caller's own protocol with its guest, and checks that the guest +/// acknowledges it with `reply` by `deadline`. +/// +/// `nvx` carries the bytes of whatever protocol a caller speaks with its guest over the +/// `HostConnection` that `vm::reattach` returns, and leaves the protocol to the caller. This +/// caller's guest is nvx-init, which speaks NVX's own protocol, NVXC; NVXC is private to `nvx`, +/// so the caller encodes the few frames that it sends and expects itself, as any caller of a +/// guest of its own would. fn acknowledged( connection: &mut HostConnection, kind: u8, @@ -102,15 +130,14 @@ fn acknowledged( deadline: Instant, ) { connection - .send(&protocol::encode_app(kind, request, 0, &[]), Some(deadline)) + .send(&frame(kind, request), Some(deadline)) .expect("the guest takes the request"); let response = connection .receive(Some(deadline)) .expect("the guest answers the request"); - let frame = protocol::decode_app(&response).expect("the answer is an NVXC frame"); assert_eq!( - (frame.kind, frame.request_id, frame.status, frame.payload), - (reply, request, 0, &[][..]), + response, + frame(reply, request), "the guest acknowledges request {kind}" ); } @@ -203,9 +230,9 @@ fn a_cold_vm_is_reattached_from_saved_values_and_stopped() { assert_eq!(reattached.process.reference(), &saved.process); acknowledged( &mut reattached.connection, - APP_PING, + PING, 1, - APP_READY, + READY, Instant::now() + timeout, ); drop(reattached); @@ -213,9 +240,9 @@ fn a_cold_vm_is_reattached_from_saved_values_and_stopped() { .expect("the saved values reattach the VM again, with a new connection"); acknowledged( &mut reattached.connection, - APP_PING, + PING, 2, - APP_READY, + READY, Instant::now() + timeout, ); let grace = Instant::now() + timeout; @@ -223,13 +250,7 @@ fn a_cold_vm_is_reattached_from_saved_values_and_stopped() { &reattached.process, &started.outputs, |deadline| { - acknowledged( - &mut reattached.connection, - APP_STOP, - 3, - APP_STOPPED, - deadline, - ); + acknowledged(&mut reattached.connection, STOP, 3, STOPPED, deadline); true }, grace, diff --git a/scripts/nvx_tools/microvm_tests.py b/scripts/nvx_tools/microvm_tests.py index b07015b4b..e49da312e 100644 --- a/scripts/nvx_tools/microvm_tests.py +++ b/scripts/nvx_tools/microvm_tests.py @@ -1359,8 +1359,13 @@ def run_workload_identity( # The ignored nvx-rs test that drives the managed lifecycle of a guest whose -# init hands the boot to the managed agent: nvx-rs/tests/managed_lifecycle.rs. -MANAGED_LIFECYCLE_TEST = "managed_lifecycle" +# init hands the boot to the managed agent: a unit test of the host's control +# session, which is private to nvx and which the cli feature builds, in +# nvx-rs/src/host/session/tests/managed_lifecycle.rs. +MANAGED_LIFECYCLE_TEST = ( + "host::session::tests::managed_lifecycle::" + "a_managed_guest_runs_request_scoped_workloads_until_it_stops" +) def ignored_nvx_rs_test_command(cargo: str, test: str) -> list[str]: @@ -1381,8 +1386,23 @@ def ignored_nvx_rs_test_command(cargo: str, test: str) -> list[str]: def managed_lifecycle_test_command(cargo: str) -> list[str]: - """Return the Cargo command that runs MANAGED_LIFECYCLE_TEST.""" - return ignored_nvx_rs_test_command(cargo, MANAGED_LIFECYCLE_TEST) + """Return the Cargo command that runs MANAGED_LIFECYCLE_TEST, an ignored + unit test of nvx-rs's library, with the cli feature.""" + return [ + cargo, + "test", + "--manifest-path", + os.fspath(nvx_rs.DIRECTORY / "Cargo.toml"), + "--locked", + "--features", + "cli", + "--lib", + "--", + "--ignored", + "--nocapture", + "--exact", + MANAGED_LIFECYCLE_TEST, + ] def run_managed_lifecycle( diff --git a/scripts/nvx_tools/sandbox_lifecycle_tests.py b/scripts/nvx_tools/sandbox_lifecycle_tests.py index 5ee5b6db7..ffc35e348 100644 --- a/scripts/nvx_tools/sandbox_lifecycle_tests.py +++ b/scripts/nvx_tools/sandbox_lifecycle_tests.py @@ -315,9 +315,10 @@ def check_managed_report( """Checks the report of a managed sandbox that stopped cleanly. These are the report checks of the direct-mode managed lifecycle - (nvx-rs/tests/managed_lifecycle.rs) that hold for the layered root too: the - report names the backend, reports `network_policy`, or that no network - policy was requested when that is None, and every teardown step completed. + (nvx-rs/src/host/session/tests/managed_lifecycle.rs) that hold for the + layered root too: the report names the backend, reports `network_policy`, + or that no network policy was requested when that is None, and every + teardown step completed. """ outcome = report.get("outcome") if outcome != MANAGED_SUCCESS: diff --git a/scripts/test-layout-allowlist.json b/scripts/test-layout-allowlist.json index 76a11780e..ad2e99f86 100644 --- a/scripts/test-layout-allowlist.json +++ b/scripts/test-layout-allowlist.json @@ -13,8 +13,7 @@ "nvx-rs/src/guest/time/system.rs": 3, "nvx-rs/src/host/network/address.rs": 2, "nvx-rs/src/host/platform/windows/spawn.rs": 1, - "nvx-rs/src/host/session.rs": 1, - "nvx-rs/src/host/vm.rs": 2, - "nvx-rs/src/protocol/app.rs": 6 + "nvx-rs/src/host/session.rs": 5, + "nvx-rs/src/host/vm.rs": 2 } } diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index ee395bba2..48a8ce11d 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -5691,7 +5691,8 @@ def start_test( return runs def test_managed_lifecycle_runs_the_nvx_rs_test_and_checks_what_it_leaves(self): - # nvx-rs/tests/managed_lifecycle.rs drives the control session; the + # The unit test of nvx-rs's host control session in + # src/host/session/tests/managed_lifecycle.rs drives the session; the # scenario hands it OpenVMM's command, then checks the outcome report # and the guest console. with tempfile.TemporaryDirectory() as temporary: @@ -5722,11 +5723,15 @@ def test_managed_lifecycle_runs_the_nvx_rs_test_and_checks_what_it_leaves(self): "--manifest-path", os.fspath(BuildConstants.REPO_ROOT / "nvx-rs" / "Cargo.toml"), "--locked", - "--test", - "managed_lifecycle", + "--features", + "cli", + "--lib", "--", "--ignored", "--nocapture", + "--exact", + "host::session::tests::managed_lifecycle::" + "a_managed_guest_runs_request_scoped_workloads_until_it_stops", ], ) # The repository root selects the pinned Rust release. @@ -5791,8 +5796,9 @@ def test_managed_lifecycle_requires_cargo(self): patch.object(microvm_tests.subprocess, "Popen") as popen, self.assertRaisesRegex( common.ScriptError, - "runs the nvx-rs test managed_lifecycle with Cargo, which is not " - "on PATH", + "runs the nvx-rs test host::session::tests::managed_lifecycle::" + "a_managed_guest_runs_request_scoped_workloads_until_it_stops with " + "Cargo, which is not on PATH", ), ): microvm_tests.run_managed_lifecycle( @@ -5854,8 +5860,9 @@ def test_managed_lifecycle_fails_with_the_test_and_with_the_report(self): ( {"returncode": 101}, RuntimeError, - "the managed lifecycle test managed_lifecycle failed with exit " - "status 101", + "the managed lifecycle test host::session::tests::managed_lifecycle::" + "a_managed_guest_runs_request_scoped_workloads_until_it_stops failed " + "with exit status 101", ), # The guest's own explanation comes before the test's failure. (